diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1393302..d96f78c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ }, "metadata": { "description": "Claude Code plugin suite: brewcode for infinite task execution, brewdoc for documentation tools, brewtools for text utilities, brewui for UI/visual/creative tools", - "version": "5.0.0" + "version": "5.1.0" }, "plugins": [ { "name": "brewcode", "description": "Brewcode - full-featured development platform for Claude Code: infinite focus tasks, prompt optimization, skill/agent creation, quorum reviews, rules management", - "version": "5.0.0", + "version": "5.1.0", "category": "productivity", "keywords": [ "brewcode", @@ -46,7 +46,7 @@ { "name": "brewdoc", "description": "Brewdoc - Claude Code documentation tools: my-claude installation docs, memory sync, md-to-pdf conversion", - "version": "5.0.0", + "version": "5.1.0", "category": "productivity", "keywords": [ "brewdoc", @@ -72,7 +72,7 @@ { "name": "brewtools", "description": "Brewtools - universal utilities for Claude Code: text optimization, humanization, secrets scanning", - "version": "5.0.0", + "version": "5.1.0", "category": "productivity", "keywords": [ "brewtools", @@ -100,7 +100,7 @@ { "name": "brewui", "description": "Placeholder for future UI/visual/creative tools (currently empty, installable)", - "version": "5.0.0", + "version": "5.1.0", "category": "productivity", "keywords": [ "ui", diff --git a/.codex/plugins/brewcode/skills/convention/scripts/convention.sh b/.codex/plugins/brewcode/skills/convention/scripts/convention.sh index b513664..7f35edc 100755 --- a/.codex/plugins/brewcode/skills/convention/scripts/convention.sh +++ b/.codex/plugins/brewcode/skills/convention/scripts/convention.sh @@ -3,6 +3,12 @@ # Usage: convention.sh set -eu +# Self-location: scripts/ -> convention/ -> skills/ -> PLUGIN_ROOT. Correct in the dev checkout +# AND in the installed cache, so the version is read from the manifest and never hardcoded. +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +PLUGIN_JSON="$SCRIPT_DIR/../../../.codex-plugin/plugin.json" +GENERATED_BY="brewcode:convention" + usage() { echo "Usage: convention.sh " echo "" @@ -143,20 +149,46 @@ EOF fi } +plugin_version() { + v="" + if [ -f "$PLUGIN_JSON" ]; then + if $HAS_JQ; then + v=$(jq -r '.version // empty' "$PLUGIN_JSON" 2>/dev/null || true) + else + v=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_JSON" 2>/dev/null | head -1 || true) + fi + fi + printf '%s' "${v:-unknown}" +} + +# Creates the output dir AND hands back the artifact-metadata scalars P4 stamps into each of the +# three generated docs. The old `created` key was an ISO-8601 timestamp nothing ever persisted. setup_convention() { mkdir -p .codex/convention - printf '{"created":"%s","path":".codex/convention/"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '{"path":".codex/convention/","version":"%s","generated_by":"%s","last_updated":"%s"}\n' \ + "$(plugin_version)" "$GENERATED_BY" "$(date +%F)" +} + +# A convention doc counts as present only when it also carries the standard metadata: a doc with +# no stamp cannot be aged against the running plugin, which is the whole point of `rules` mode. +check_doc() { + [ -f "$1" ] || return 1 + head -1 "$1" | grep -q '^---$' || { err "X $1 has no YAML frontmatter"; return 1; } + for k in doc_type version generated_by last_updated; do + grep -q "^${k}:" "$1" || { err "X $1 missing frontmatter key: $k"; return 1; } + done + return 0 } validate_convention() { errors=0 f1=false f2=false f3=false - [ -f .codex/convention/reference-patterns.md ] && f1=true || errors=$((errors + 1)) - [ -f .codex/convention/testing-conventions.md ] && f2=true || errors=$((errors + 1)) - [ -f .codex/convention/project-architecture.md ] && f3=true || errors=$((errors + 1)) + check_doc .codex/convention/reference-patterns.md && f1=true || errors=$((errors + 1)) + check_doc .codex/convention/testing-conventions.md && f2=true || errors=$((errors + 1)) + check_doc .codex/convention/project-architecture.md && f3=true || errors=$((errors + 1)) valid=true; [ "$errors" -gt 0 ] && valid=false - if [ "$valid" = "true" ]; then err "All convention files present" - else err "Missing $errors convention file(s)"; fi + if [ "$valid" = "true" ]; then err "All convention files present and stamped" + else err "$errors convention file(s) missing or unstamped"; fi if $HAS_JQ; then printf '{"valid":%s,"files":{"reference-patterns.md":%s,"testing-conventions.md":%s,"project-architecture.md":%s}}' \ diff --git a/.codex/plugins/brewcode/skills/rules/scripts/rules.sh b/.codex/plugins/brewcode/skills/rules/scripts/rules.sh index 11ff135..30254a8 100755 --- a/.codex/plugins/brewcode/skills/rules/scripts/rules.sh +++ b/.codex/plugins/brewcode/skills/rules/scripts/rules.sh @@ -7,20 +7,40 @@ # read - Read knowledge file (first 100 lines) # check - Check existing rules files (main + specialized) # create - Create missing main rules from templates -# create-specialized - Create specialized rules (e.g., test-avoid.md) +# create-specialized [paths] - Create specialized rules (e.g., test-avoid.md) # list - List all rule files (*-avoid.md, *-best-practice.md) -# validate - Validate table structure +# validate - Validate frontmatter + table structure set -euo pipefail MODE="${1:-check}" ARG="${2:-}" +ARG2="${3:-}" # Self-location: derive plugin root from script path SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Path: scripts/rules.sh -> skills/rules/scripts -> skills/rules -> skills -> PLUGIN_ROOT PLUGIN_ROOT="$(dirname "$(dirname "$(dirname "$SCRIPT_DIR")")")" PLUGIN_TEMPLATES="$PLUGIN_ROOT/templates" +# Manifest by self-location: correct in the dev checkout AND in the installed cache. +PLUGIN_JSON="$PLUGIN_ROOT/.codex-plugin/plugin.json" + +# Artifact-metadata standard. The version is read from the manifest, never hardcoded. +plugin_version() { + local v="" + if [ -f "$PLUGIN_JSON" ]; then + if command -v jq >/dev/null 2>&1; then + v=$(jq -r '.version // empty' "$PLUGIN_JSON" 2>/dev/null || true) + else + v=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_JSON" 2>/dev/null | head -1 || true) + fi + fi + printf '%s' "${v:-unknown}" +} + +PLUGIN_VERSION="$(plugin_version)" +GENERATED_BY="brewcode:rules" +LAST_UPDATED="$(date +%F)" # Validate plugin structure validate_plugin() { @@ -70,6 +90,20 @@ check_rules() { fi } +# Render a template: substitute the scope scalars + the four standard metadata keys. +# `|` is the sed delimiter, so no substituted value may contain one -- all of them are +# globs, titles and versions produced here, never user prose. +render_template() { + local tpl="$1" out="$2" title="$3" paths="$4" desc="$5" + sed -e "s|{TITLE}|$title|g" \ + -e "s|{PATHS}|$paths|g" \ + -e "s|{DESCRIPTION}|$desc|g" \ + -e "s|{PLUGIN_VERSION}|$PLUGIN_VERSION|g" \ + -e "s|{GENERATED_BY}|$GENERATED_BY|g" \ + -e "s|{LAST_UPDATED}|$LAST_UPDATED|g" \ + "$tpl" > "$out" +} + # Create missing rules from templates create_rules() { echo "=== Create Rules ===" @@ -78,35 +112,65 @@ create_rules() { mkdir -p .codex/rules if [ ! -f .codex/rules/avoid.md ]; then - cp "$PLUGIN_TEMPLATES/rules/avoid.md.template" .codex/rules/avoid.md + render_template "$PLUGIN_TEMPLATES/rules/avoid.md.template" .codex/rules/avoid.md \ + "Avoid" '["**/*"]' 'avoid - project-wide anti-patterns and the thing to do instead; one table row per rule' echo "V Created: .codex/rules/avoid.md" else echo ">> Preserved: .codex/rules/avoid.md (exists)" fi if [ ! -f .codex/rules/best-practice.md ]; then - cp "$PLUGIN_TEMPLATES/rules/best-practice.md.template" .codex/rules/best-practice.md + render_template "$PLUGIN_TEMPLATES/rules/best-practice.md.template" .codex/rules/best-practice.md \ + "Best Practices" '["**/*"]' 'best-practice - project-wide practices worth repeating; one table row per rule' echo "V Created: .codex/rules/best-practice.md" else echo ">> Preserved: .codex/rules/best-practice.md (exists)" fi } -# Validate table structure (main + specialized) +# Validate ONE rule file: frontmatter, the four standard metadata keys, table header. +# $2 = "specialized" -> also reject repo-wide paths. +validate_file() { + local f="$1" kind="${2:-main}" + local name errs=0 k + name=$(basename "$f") + + head -1 "$f" | grep -q '^---$' || { echo "X $name no YAML frontmatter (line 1 must be ---)"; errs=$((errs+1)); } + + for k in paths description doc_type version generated_by last_updated; do + grep -q "^${k}:" "$f" || { echo "X $name missing frontmatter key: $k"; errs=$((errs+1)); } + done + + grep -q '^doc_type: llm$' "$f" || { echo "X $name doc_type must be exactly 'llm'"; errs=$((errs+1)); } + grep -Eq '^version: "[0-9]+\.[0-9]+\.[0-9]+"$' "$f" || { echo "X $name version must be a quoted X.Y.Z"; errs=$((errs+1)); } + grep -Eq '^last_updated: "[0-9]{4}-[0-9]{2}-[0-9]{2}"$' "$f" || { echo "X $name last_updated must be a quoted YYYY-MM-DD"; errs=$((errs+1)); } + + if [ "$kind" = "specialized" ] && grep -q '"\*\*/\*"' "$f"; then + echo "X $name is specialized but claims repo-wide paths -> it loads into every request" + errs=$((errs+1)) + fi + + grep -q "^| #" "$f" || { echo "X $name invalid structure (missing table header)"; errs=$((errs+1)); } + + [ "$errs" -eq 0 ] && echo "V $name valid (frontmatter + metadata + table)" + ERRORS=$((ERRORS + errs)) +} + +# Validate frontmatter + table structure (main + specialized) validate_rules() { echo "=== Validate Rules Structure ===" ERRORS=0 # Validate main files if [ -f .codex/rules/avoid.md ]; then - grep -q "^| #" .codex/rules/avoid.md && echo "V avoid.md valid structure" || { echo "X avoid.md invalid structure (missing table header)"; ERRORS=$((ERRORS+1)); } + validate_file .codex/rules/avoid.md main else echo "X avoid.md not found" ERRORS=$((ERRORS+1)) fi if [ -f .codex/rules/best-practice.md ]; then - grep -q "^| #" .codex/rules/best-practice.md && echo "V best-practice.md valid structure" || { echo "X best-practice.md invalid structure (missing table header)"; ERRORS=$((ERRORS+1)); } + validate_file .codex/rules/best-practice.md main else echo "X best-practice.md not found" ERRORS=$((ERRORS+1)) @@ -119,12 +183,7 @@ validate_rules() { [ "$(basename "$f")" = "avoid.md" ] && continue [ "$(basename "$f")" = "best-practice.md" ] && continue - if grep -q "^| #" "$f"; then - echo "V $(basename "$f") valid structure" - else - echo "X $(basename "$f") invalid structure (missing table header)" - ERRORS=$((ERRORS+1)) - fi + validate_file "$f" specialized done exit $ERRORS @@ -172,13 +231,33 @@ capitalize() { printf '%s%s' "$(printf '%s' "${s%"${s#?}"}" | tr '[:lower:]' '[:upper:]')" "${s#?}" } +# A specialized rule file applies to ONE slice of the repo, so it must never ship the +# repo-wide `["**/*"]` -- that is what made every specialized rule load into every request. +# Known prefixes get a curated glob set; anything else gets a prefix-derived guess that the +# caller is told to confirm. An explicit `paths` argument always wins. +default_paths_for_prefix() { + case "$1" in + test|tests|unit) printf '["**/test/**", "**/tests/**", "**/*_test.*", "**/*.test.*", "**/*Test.*"]' ;; + e2e|it|integration) printf '["**/e2e/**", "**/it/**", "**/*E2E*", "**/*e2e*"]' ;; + doc|docs) printf '["**/*.md", "**/*.mdx", "docs/**"]' ;; + ci|cd|cicd) printf '[".github/**", "**/*.yml", "**/*.yaml"]' ;; + sql|db|database) printf '["**/*.sql", "**/migration*/**", "**/migrations/**"]' ;; + api) printf '["**/api/**", "**/openapi/**", "**/*.openapi.*"]' ;; + ui|front|frontend|web) printf '["**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte", "**/*.css"]' ;; + infra|docker|k8s|deploy) printf '["**/Dockerfile*", "**/docker-compose*.yml", "**/docker-compose*.yaml", "**/*.tf", "k8s/**"]' ;; + *) printf '["**/%s/**", "**/*%s*"]' "$1" "$1" ;; + esac +} + # Create specialized rules from template with prefix create_specialized() { local prefix="$1" + local paths="${2:-}" if [ -z "$prefix" ]; then echo "X Missing prefix argument" - echo "Usage: rules.sh create-specialized " + echo "Usage: rules.sh create-specialized [paths]" echo "Example: rules.sh create-specialized test" + echo "Example: rules.sh create-specialized payment '[\"src/payment/**\"]'" exit 1 fi @@ -191,16 +270,30 @@ create_specialized() { local cap cap=$(capitalize "$prefix") + if [ -z "$paths" ]; then + paths=$(default_paths_for_prefix "$prefix") + echo "! paths not supplied -> derived $paths" + echo " Confirm it with the user and narrow it by hand if it does not match this repo's layout." + fi + case "$paths" in + *'"**/*"'*) + echo "X Refusing repo-wide paths for a specialized rule: $paths" + echo " A ${prefix}-* rule that matches everything loads into every request. Pass a narrower glob." + exit 1 + ;; + esac + if [ ! -f "$avoid_file" ]; then - # Create from template with prefix substitution - sed "s/# Avoid/# ${cap} Avoid/" "$PLUGIN_TEMPLATES/rules/avoid.md.template" > "$avoid_file" + render_template "$PLUGIN_TEMPLATES/rules/avoid.md.template" "$avoid_file" \ + "${cap} Avoid" "$paths" "${prefix}-avoid - ${prefix} anti-patterns and the thing to do instead; one table row per rule" echo "V Created: $avoid_file" else echo ">> Preserved: $avoid_file (exists)" fi if [ ! -f "$bp_file" ]; then - sed "s/# Best Practices/# ${cap} Best Practices/" "$PLUGIN_TEMPLATES/rules/best-practice.md.template" > "$bp_file" + render_template "$PLUGIN_TEMPLATES/rules/best-practice.md.template" "$bp_file" \ + "${cap} Best Practices" "$paths" "${prefix}-best-practice - ${prefix} practices worth repeating; one table row per rule" echo "V Created: $bp_file" else echo ">> Preserved: $bp_file (exists)" @@ -219,7 +312,7 @@ case "$MODE" in create_rules ;; create-specialized) - create_specialized "$ARG" + create_specialized "$ARG" "$ARG2" ;; list) list_rules @@ -234,9 +327,10 @@ case "$MODE" in echo " read - Read knowledge file (first 100 lines)" echo " check - Check existing rules files (main + specialized)" echo " create - Create missing main rules from templates" - echo " create-specialized - Create specialized rules (e.g., test-avoid.md)" + echo " create-specialized [paths] - Create specialized rules (e.g., test-avoid.md);" + echo " paths is a YAML flow list, e.g. '[\"src/payment/**\"]'" echo " list - List all rule files" - echo " validate - Validate table structure" + echo " validate - Validate frontmatter (standard metadata keys) + table structure" exit 1 ;; esac diff --git a/.codex/plugins/brewcode/skills/superreview-setup/README.md b/.codex/plugins/brewcode/skills/superreview-setup/README.md index 096feb2..4f2a970 100644 --- a/.codex/plugins/brewcode/skills/superreview-setup/README.md +++ b/.codex/plugins/brewcode/skills/superreview-setup/README.md @@ -77,8 +77,10 @@ prose is the interface. with two entry points: `emit` (full generation) and `emit-agent` (the agent alone — no superreview skill needed, this is what `$brewcode:teams-setup` calls). It is never hand-written, never authored by `brewcode:agent-creator` (which may only ADAPT the seeded blocks), and never a domain expert. A usable existing file is **REUSED byte-untouched** — the writer -prints one status line, `INTENT_GUARD: CREATED ` or `INTENT_GUARD: REUSE ` — so local edits survive every -regeneration; an empty or frontmatter-less file counts as absent and is recreated. Its evidence tiers are baked in at +prints one status line, `INTENT_GUARD: CREATED|REUSE|MIGRATED ` — so local edits survive every +regeneration; an empty or frontmatter-less file counts as absent and is recreated. `MIGRATED` is the pre-5.0 case: +a file carrying the retired `intent-guard template vN` stamp gets its metadata restamped in place (the four +frontmatter keys + the tail anchor) with the tailored body preserved byte-for-byte. Its evidence tiers are baked in at emit time: `T1` tracker, `T2` specs, `T3` plans, `T4` policy files, `T5` the live session transcript. ## How review + standards-review are merged @@ -100,14 +102,24 @@ matrix and report scaffolding baked into it; scope + expert selection make the e Run inside the repo you want to wire up: ``` -$brewcode:superreview-setup [status|install|upgrade] "" [scope] +$brewcode:superreview-setup [status|install|upgrade|enable|disable|uninstall|purge] "" [scope] ``` | Verb | Effect | |------|--------| -| `status` | read-only: is the skill emitted, is `intent-guard.md` present, does `validate` pass | -| `install` | full generation (Phase 0 -> 4). Also the no-verb default | +| `status` | read-only: is the skill emitted, is it enabled or parked, is `intent-guard.toml` present, does `validate` pass | +| `install` | full generation (Phase 0 -> 4). Also the default when a fine-tune prompt is given with no verb | | `upgrade` | refresh a live install from the template baseline without erasing tailoring | +| `enable` | rename `SKILL.md.disabled` back to `SKILL.md` — `/superreview` is offered again | +| `disable` | rename `SKILL.md` to `SKILL.md.disabled` — `/superreview` stops being discovered. `references/`, `.template-baseline/` and every tailoring stay on disk; reversible, nothing regenerated | +| `uninstall` | delete `.codex/skills/superreview/`. **Keeps** the review reports and `intent-guard.toml` | +| `purge` | uninstall + delete `.codex/reports/*_superreview/`. Still keeps `intent-guard.toml` | + +No arguments at all: `status` when the skill is already emitted, `install` when it is not. + +`intent-guard.toml` survives all seven verbs — it is shared with `$brewcode:teams-setup`, and that skill +may be the one that put it there. `enable`/`disable` take effect in the NEXT session, since Codex +discovers skills at session start. - `` — what to emphasize in the emitted skill's focus ordering (e.g. "weight reuse highest", "always treat auth as P0"). Woven into the emitted Focus table + emphasis line. Scope discipline stays in rank 1 @@ -150,7 +162,7 @@ After generation, run the emitted skill in that project. Depth comes from how yo | File | Role | |------|------| | `SKILL.md` | The generator orchestrator | -| `scripts/generate.sh` | `scan` / `emit` / `emit-agent` / `upgrade` / `validate` | +| `scripts/generate.sh` | `scan` / `emit` / `emit-agent` / `upgrade` / `enable` / `disable` / `uninstall` / `purge` / `validate` | | `references/SKILL.md.template` | The emitted SKILL.md (placeholder slots) | | `references/agent-prompt.md` | Emitted runtime expert-selection procedure + domain-owner prompt contract | | `references/scope.md.template` | Emitted scope-discipline reference (baseline, ownership, taxonomy, delivery, closeout, gate) | @@ -167,7 +179,7 @@ the expected path, not a failure: it writes nothing and prints no `INTENT_GUARD: | Command | Effect | |---------|--------| -| `generate.sh upgrade` | The supported refresh. Writes NO live file. Stages a fresh emit under `.upgrade-staging/` and reports, per asset, the **new template vs the pristine `.template-baseline/` copy `emit` saved** — so `DIFFERS ( template line(s))` counts real template changes and never your tailoring. A deleted asset is restored RAW and labelled `MISSING -> restored (NEEDS PHASE 3)`. The generator ports each delta into the live file with targeted `Edit` calls, then promotes `.upgrade-staging/.template` to the new baseline | +| `generate.sh upgrade` | The supported refresh. Writes NO live file. Stages a fresh emit under `.upgrade-staging/` and reports, per asset, the **new template vs the pristine `.template-baseline/` copy `emit` saved** — so `DIFFERS ( template line(s))` counts real template changes and never your tailoring. The per-stack reference is re-derived from the installed tree (`UPGRADE_STACK=`), never re-defaulted, so a TypeScript/Go/Java-Kotlin install gets its own reference restamped. A deleted asset is restored RAW — scalars included, deliberately unresolved — and labelled `MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)`. The generator ports each delta into the live file with targeted `Edit` calls, then promotes `.upgrade-staging/.template` to the new baseline | | `SUPERREVIEW_FORCE=1 generate.sh emit` | Conscious destructive override: overwrites the live installation and **loses** every tailored + self-synced edit. Only on an explicit request for a clean regeneration | `.template-baseline/` and `.upgrade-staging/` each carry a `.gitignore` of `*`, so neither shows up in your @@ -179,7 +191,7 @@ and falls back to a live-vs-template diff, which must be reviewed by hand. Run `upgrade` when: a project agent is added/renamed, a rule/convention file changes, the stack changes, a new source group is added, the tracker or branch convention changes, a spec/plan/policy location moves, or a new always-shared surface appears. It re-wires the emitted skill to the current project shape — and leaves an existing -`intent-guard.md` alone. +`intent-guard.toml` alone. ## Notes diff --git a/.codex/plugins/brewcode/skills/superreview-setup/SKILL.md b/.codex/plugins/brewcode/skills/superreview-setup/SKILL.md index 4ceaa7b..4fa3e2a 100644 --- a/.codex/plugins/brewcode/skills/superreview-setup/SKILL.md +++ b/.codex/plugins/brewcode/skills/superreview-setup/SKILL.md @@ -70,25 +70,76 @@ plus optional `[scope]` hint. The fine-tune prompt is woven into the emitted ski ### Verb routing — resolve FIRST, before anything else -`` may start with one of three verbs. Anything else is the fine-tune prompt and takes the -free-form path. Strip the verb before using the rest as the fine-tune prompt. +`` may start with one of the seven canonical verbs, in this order: +`status | install | upgrade | enable | disable | uninstall | purge`. Anything else is the fine-tune +prompt and takes the free-form path. Strip the verb before using the rest as the fine-tune prompt. + +Removed aliases that must never be accepted or printed: `init`, `on`, `off`, `setup`, `remove`, +`reset`, `create`, `update`, `cleanup`. Recognize them in free text, echo the canonical verb back. | Verb | What runs | Writes? | |------|-----------|---------| -| `status` | read-only: does `.codex/skills/superreview/` exist, is `.codex/agents/intent-guard.toml` present, is `.template-baseline/` there? Then `generate.sh validate` and report. **STOP** — no phases run | no | +| `status` | read-only: is `.codex/skills/superreview/` there, is it ENABLED or parked, is `.codex/agents/intent-guard.toml` present, is `.template-baseline/` there? Then `generate.sh validate` and report. **STOP** — no phases run | no | | `install` | the full generate flow, Phase 0 -> Phase 4 below | yes | | `upgrade` | Phase 2b only (`generate.sh upgrade`), then Phase 3 for any `MISSING -> restored` asset, then Phase 4 `validate`. **STOP** | live files only via targeted Edit | -| *(no verb)* | same as `install`; the whole `` is the fine-tune prompt | yes | +| `enable` | `generate.sh enable` — un-parks the installed skill. **STOP** | one rename | +| `disable` | `generate.sh disable` — parks the installed skill without deleting anything. **STOP** | one rename | +| `uninstall` | `generate.sh uninstall` — deletes the generated skill dir, KEEPS the reports and `intent-guard.toml`. Confirm once. **STOP** | deletes | +| `purge` | `generate.sh purge` — uninstall + deletes `.codex/reports/*_superreview/`. Still keeps `intent-guard.toml`. Confirm once, naming the report count. **STOP** | deletes | +| *(no args at all)* | `status` when `.codex/skills/superreview/` exists, otherwise `install` | status: no | +| *(no verb, but a prompt)* | same as `install`; the whole `` is the fine-tune prompt | yes | **EXECUTE** using shell (`status` only): ```bash -test -d .codex/skills/superreview && echo "installed" || echo "not_installed" +if test -f .codex/skills/superreview/SKILL.md; then echo "installed: enabled" +elif test -f .codex/skills/superreview/SKILL.md.disabled; then echo "installed: DISABLED (parked as SKILL.md.disabled — run 'enable' to restore)" +elif test -d .codex/skills/superreview; then echo "installed: BROKEN (dir present, no SKILL.md and no SKILL.md.disabled)" +else echo "not_installed"; fi test -f .codex/agents/intent-guard.toml && echo "intent-guard: present" || echo "intent-guard: MISSING" test -d .codex/skills/superreview/.template-baseline && echo "baseline: present" || echo "baseline: absent (pre-baseline install)" +echo "reports: $({ find .codex/reports -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | wc -l | tr -d ' ') dir(s) — deleted by 'purge', kept by 'uninstall'" bash "/scripts/generate.sh" validate && echo "✅ validate" || echo "❌ validate FAILED" ``` > `status` never writes and never asks. `not_installed` -> report it and offer `install`; nothing else. +> `installed: DISABLED` is a state, not a fault — report it and offer `enable`. `validate` fails on a +> disabled install (it looks for `SKILL.md`); say so rather than presenting it as a broken installation. + +--- + +### Modes: enable | disable | uninstall | purge + +| Mode | Generated skill dir | `references/` + `.template-baseline/` | Phase 3 tailoring | `.codex/reports/*_superreview/` | `intent-guard.toml` | +|------|--------------------|---------------------------------------|-------------------|----------------------------------|-------------------| +| `enable` | `SKILL.md.disabled` -> `SKILL.md` | kept | kept | kept | kept | +| `disable` | `SKILL.md` -> `SKILL.md.disabled` | kept | kept | kept | kept | +| `uninstall` | **deleted** | deleted with it | lost | **kept** | kept | +| `purge` | **deleted** | deleted with it | lost | **deleted** | kept | + +**How the toggle works.** Codex discovers a project skill only through `/SKILL.md`. +`disable` renames that ONE file to `SKILL.md.disabled`, so `/superreview` stops being offered while +`references/`, `.template-baseline/` and every Phase 3 tailoring stay byte-identical on disk. `enable` +renames it back. Nothing is regenerated in either direction, so no `version` is bumped and no +self-synced edit is at risk. Use `disable` to park a review setup that is temporarily noisy; use +`uninstall` when it should really go. Both take effect in the NEXT session — skills are discovered at +session start. + +**`intent-guard` is never touched by any of the four.** `generate.sh` (`emit`/`emit-agent`) is its +only writer, and it is shared with `$brewcode:teams-setup`, which may have put it there. Deleting or +parking it would silently break an unrelated team install. All four modes print it as `KEPT`. + +**Confirm before deleting.** `uninstall` and `purge` each `request_user_input` exactly once, listing the +real paths (`find .codex/skills/superreview -type f | sort`) and, for `purge`, the number of review +reports being destroyed, with `uninstall` offered as the keep-the-reports alternative. A declined +confirmation ends the run cleanly — delete nothing. + +**EXECUTE** using shell (the chosen verb, after confirmation where required): +```bash +bash "/scripts/generate.sh" MODE_HERE && echo "✅ MODE_HERE" || echo "❌ MODE_HERE FAILED" +``` + +Then report the script's `MOVED:` / `REMOVED:` / `KEPT:` lines verbatim. Not installed at all -> +say so and **STOP**; never "disable" or "purge" something that was never emitted. ### Delegation (applies to every sub-agent task this generator spawns AND to the fan-out it emits) @@ -211,6 +262,7 @@ unconditionally by the emitted skill at BOTH depths, so the emitted skill is bro |------|--------| | **Single writer** | `scripts/generate.sh` is the ONLY writer of `.codex/agents/intent-guard.toml`, via ONE shared implementation exposed as two subcommands: `emit` (full generation, Phase 2) and `emit-agent` (the agent alone, no superreview skill involved — this is what `$brewcode:teams-setup` calls instead of authoring its own copy). **Never hand-write the file.** `brewcode:agent-creator` may only ADAPT the seeded BLOCKs of an already-written file; it may never author it | | **Reuse wins** | a USABLE file already exists -> the writer prints `INTENT_GUARD: REUSE ` and leaves it BYTE-UNTOUCHED. An existing intent-guard is the project's own tuned version (or a sibling generator's) and outranks this template. Do not "refresh" it, do not diff-merge it, do not fill BLOCKs in it. "Usable" = non-empty AND carrying `name: intent-guard` frontmatter AND free of unresolved `{UPPER_SNAKE}` tokens; an empty, truncated or placeholder-laden file is treated as ABSENT and recreated | +| **Migrate, never re-emit** | a file carrying the RETIRED `` stamp is ours but pre-standard: the writer prints `INTENT_GUARD: MIGRATED ` and restamps METADATA ONLY — the four frontmatter keys and the tail anchor. Every tailored line survives byte-for-byte, so this is the `upgrade restamps it` path, not a regeneration. A file with NO stamp of either generation is the project's own hand-written agent and is only ever REUSED | | **No request_user_input** | creation is not gated. Do not ask whether to create it; it is part of the emitted artifact, like `references/scope.md` | | **Roster scan** | note in Phase 1 whether the file is present (`generate.sh scan` reports it) so the Phase 5 summary can say CREATED vs REUSED | | **Not an expert** | never count it toward the domain-expert requirement, never put it in `DOMAIN_AGENTS_TABLE` / `FILE_GROUP_MAP` / `SIMPLIFY_AGENTS`, never make it `VALIDATOR_AGENT` or a scope-pass owner. `generate.sh validate` excludes it from the expert count for exactly this reason | @@ -248,20 +300,26 @@ bash "/scripts/generate.sh" emit && echo "✅ emit" || echo " > `/references/SKILL.md.template` exists and the target `.codex/` is writable. This writes `/.codex/skills/superreview/SKILL.md` (scalars substituted), copies `agent-prompt.md`, -`report-template.md` and `scope.md` (scalar-substituted), copies the chosen `${STACK_REF}` into the emitted +`report-template.md`, `scope.md` and the chosen `${STACK_REF}` (all scalar-substituted) into the emitted `references/`, saves the pristine templates to `.codex/skills/superreview/.template-baseline/` (what `upgrade` later diffs against), and **creates-or-reuses `/.codex/agents/intent-guard.toml`** (template header -stripped, provenance stamp kept). Key off the ONE machine-readable status line the writer prints — the +stripped, provenance stamp kept). Every emitted artifact is stamped with the four standard metadata fields — +`doc_type: llm`, `version`, `generated_by: brewcode:superreview-setup`, `last_updated` — in its frontmatter; +you export NOTHING for them. `version` is read out of the plugin's own `.codex-plugin/plugin.json` by script +self-location and `last_updated` is `date +%F`. Both stay `{PLUGIN_VERSION}` / `{LAST_UPDATED}` in the raw +`.template-baseline/` copies, so a plain version bump makes `upgrade` report IDENTICAL, never a diff. +Key off the ONE machine-readable status line the writer prints — the `already installed` refusal path prints NO status line, because nothing was written: | Status line | Meaning | |-------------|---------| | `INTENT_GUARD: CREATED .codex/agents/intent-guard.toml` | written from the template with SEEDED-DEFAULT BLOCKs — you MUST adapt all three in Phase 3 | | `INTENT_GUARD: REUSE .codex/agents/intent-guard.toml` | the file is the project's own — touch NOTHING in it, skip its Phase 3 table | +| `INTENT_GUARD: MIGRATED .codex/agents/intent-guard.toml` | a pre-standard file of ours was restamped in place (metadata only, tailored body preserved) — treat it exactly like REUSE: skip its Phase 3 table, edit nothing | > The same writer is available standalone as `generate.sh emit-agent` (agent only, no superreview skill required, > same env overrides `PROJECT_NAME` / `TRACKER_LABEL` / `SPEC_LOCATION` / `PLAN_LOCATION` / `POLICY_LOCATION`, -> same two status lines). `$brewcode:teams-setup` uses it; this generator does not need it, `emit` covers it. +> same three status lines). `$brewcode:teams-setup` uses it; this generator does not need it, `emit` covers it. ### Phase 2b — Already installed? `upgrade`, never re-emit @@ -274,7 +332,8 @@ REFUSES on a live installation. When it does: bash "/scripts/generate.sh" upgrade && echo "✅ upgrade" || echo "❌ upgrade FAILED" ``` -It writes NO live file. It stages a fresh emit at `.codex/skills/superreview/.upgrade-staging/` (with the raw new +It rewrites no live file's CONTENT — the one thing it does write into a live file is the metadata restamp below. +It stages a fresh emit at `.codex/skills/superreview/.upgrade-staging/` (with the raw new templates under `.upgrade-staging/.template/`) and compares the NEW TEMPLATE against the pristine copies `emit` saved in `.codex/skills/superreview/.template-baseline/` — **never the live file against a template**, because a live file legitimately carries Phase 3 tailoring and Phase 4b self-sync edits that no template ever knew about. @@ -282,11 +341,37 @@ One line per asset: | Line | Meaning | What you do | |------|---------|-------------| -| `IDENTICAL (template unchanged since install)` | no template delta | nothing | +| `IDENTICAL (template unchanged since install)` | no template delta | nothing — but the file is still restamped, see below | | `DIFFERS ( template line(s))` | the TEMPLATE really changed | run the printed `diff `, then port ONLY those changes into the LIVE file with targeted **Edit** calls, keeping every tailored + self-synced line | -| `MISSING -> restored (NEEDS PHASE 3)` | a deleted asset was restored from the RAW template | **go to Phase 3 for that file** and fill its BLOCK placeholders — it is un-tailored, and Phase 4 `validate` fails on it otherwise | +| `MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)` | a deleted asset was restored from the RAW template | **go to Phase 3 for that file** and fill BOTH kinds of placeholder — the SCALARS too (`{PROJECT_NAME}`, `{STACK_LABEL}`, `{ARBITER_AGENT}`, …), because `upgrade` runs with a bare environment and deliberately does NOT re-guess them. `validate` lists every one by name | | `NO BASELINE - full diff, tailoring included` | install predates the baseline | the count is NOT a template delta; review the staged copy by hand and port only genuine template changes | +**The stack is re-derived, never re-defaulted.** The first line `upgrade` prints is +`UPGRADE_STACK=.md (derived from the installed tree)`. The per-stack reference was a Phase 1 DECISION +(`STACK_REF`), and `upgrade` runs with a bare environment, so it reads that decision back out of the installed tree — +whichever of `python.md` / `typescript-react.md` / `go.md` / `java-kotlin.md` is present in +`references/` or in `.template-baseline/references/` — instead of falling back to a default. Everything below +iterates that name: a wrong one would leave the project's real reference behind at the old version forever while +restamping a file the project does not have, so `$brewcode:setup-status` would report `stale` after every +successful upgrade. More than one present = a multi-stack install, and all of them are restamped. None +determinable prints `UPGRADE_STACK=none — ❌ NO per-stack reference found` and skips the stack doc only; the other +four artifacts are still restamped. `STACK_REF=.md` in the environment overrides the derivation. + +**The restamp — one `RESTAMP:` line per live file, and it is unconditional.** After the delta report, `upgrade` +refreshes `version` / `generated_by` / `last_updated` in the frontmatter of every live emitted file, in place: + +``` +RESTAMP: .codex/skills/superreview/SKILL.md version "A.B.C" -> "X.Y.Z", generated_by/last_updated refreshed (body untouched) +``` + +It is deliberately NOT gated on the verdict above. A plain version bump moves no template line, so every asset +reports `IDENTICAL` — and the emitted `SKILL.md` frontmatter `version:` is exactly what `$brewcode:setup-status` +reads to decide `stale`. An `upgrade` that skipped it reported success and left the stamp where it was, so the +next `status` printed `stale` again, forever. Nothing else in the file is touched: `doc_type` is preserved when +present (it is user-owned), the body is compared byte-for-byte afterwards, and any mismatch aborts the run before +anything is written — Phase 3 tailoring and Phase 4b self-sync edits survive intact. A second `upgrade` on the +same version is a no-op apart from `last_updated`. + Then, once the delta is applied (and any restored file has been through Phase 3), promote the new templates to the baseline and clean up with the command the script printed: `rm -rf && mv /.template && rm -rf ` — after which go to Phase 4. Both @@ -325,7 +410,7 @@ placeholder in the EMITTED files with content you build from Phase 1 analysis. | `{SHARED_SURFACES_TABLE}` | the concrete always-shared surfaces of THIS repo (public API/contract dirs, migrations, schema/registry files, CI workflows, dependency manifests, design tokens) | **In `/.codex/agents/intent-guard.toml` — ONLY when the writer printed `INTENT_GUARD: CREATED`. On -`INTENT_GUARD: REUSE`, SKIP this table entirely and edit nothing in that file.** +`INTENT_GUARD: REUSE` or `INTENT_GUARD: MIGRATED`, SKIP this table entirely and edit nothing in that file.** > **The three BLOCK placeholders are already gone by now** — emit replaced each with a runnable GENERIC DEFAULT > block that ends in its own marker line. Key every Edit on the marker, not on the old `{TOKEN}`: your @@ -365,6 +450,11 @@ bash "/scripts/generate.sh" validate && echo "✅ validate" || > The template checks above run ONLY against an agent file carrying the template stamp. A REUSED hand-written > intent-guard is byte-untouchable by contract, so validate says so and does not judge it by template rules. +> **Shell expansions are NOT placeholders.** The scan strips every `${UPPER_SNAKE}` before looking for tokens, so +> Phase 3 evidence commands may freely use `${BASE}`, `${HOME}`, `` or any other variable — +> only a BARE `{TOKEN}` is reported, and it is reported by name with no surrounding characters. Do not work around +> a false positive by adding the variable's name to the runtime allow-list. + > **`⚠️ UNTAILORED` is a WARNING, not a failure** (exit code unaffected): the agent still carries seeded generic > BLOCK defaults, i.e. the Phase 3 adaptation was skipped or incomplete. Go back to Phase 3, replace each named > block AND its marker, and re-run — never ship an UNTAILORED agent silently. @@ -426,7 +516,7 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe | Fan-out | ONE parallel message. `QUICK`: `intent-guard` alone. `EXTENDED`: `intent-guard` + domain experts + scope pass A (diff side, shapes 1-6) + scope pass B (baseline side, delivery D1-D5 + closeout C1-C4); shared JSON finding contract; search-first before flagging reuse/duplication | | Validation | `EXTENDED` only. A NON-OWNING validator reverse-validates EVERY verdictless candidate (adversarial, per-finding gate, batched <=40), merges + de-dups + prioritizes P0-P3; unvalidatable -> `UNVALIDATED` and the run is `INCOMPLETE`. At `QUICK` the pool is entirely self-verdicted, so the coordinator merges + ranks in-session and the run is NOT `INCOMPLETE` | | Scope gate | `EXTENDED` only. `request_user_input` on unsanctioned expansion / unproven absence; rewrites priorities only, never adds findings, never lifts the UNKNOWN cap. Intent rows never enter it | -| **Self-sync** | `EXTENDED` only, coordinator only, after the report: Phase 4b corrects the emitted SKILL.md + `references/scope.md` IN PLACE from data already in context — routing table vs the live roster, a gate that reported `not run` because the command does not exist, an `UNKNOWN`/mismatched scope baseline, a shared surface a scope finding named. Line delta `<= 0`, facts only; DECISIONS, missing experts and `intent-guard.md` are PROPOSALS printed in the summary, never writes | +| **Self-sync** | `EXTENDED` only, coordinator only, after the report: Phase 4b corrects the emitted SKILL.md + `references/scope.md` IN PLACE from data already in context — routing table vs the live roster, a gate that reported `not run` because the command does not exist, an `UNKNOWN`/mismatched scope baseline, a shared surface a scope finding named. Line delta `<= 0`, facts only; DECISIONS, missing experts and `intent-guard.toml` are PROPOSALS printed in the summary, never writes | | Report | ONE merged report at `.codex/reports/{TIMESTAMP}_superreview/REPORT.md`, sorted P0->P3, every row carrying its verdict, with a Scope Discipline / Blast Radius section; READ-ONLY; recommends `/simplify` + a Manager-mode fix session; never edits code | --- @@ -437,7 +527,8 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe |---------|---------|-------------| | Emit target | `/.codex/skills/superreview/` | Where the generated skill is written | | Emit templates | `/references/` | Source templates for the generation | -| Generation script | `/scripts/generate.sh` | `scan` \| `emit` \| `emit-agent` \| `upgrade` \| `validate`. `emit-agent` writes ONLY `.codex/agents/intent-guard.toml` (shared writer, no superreview skill required) — that is the entry point `$brewcode:teams-setup` calls | +| Generation script | `/scripts/generate.sh` | `scan` \| `emit` \| `emit-agent` \| `upgrade` \| `enable` \| `disable` \| `uninstall` \| `purge` \| `validate`. `emit-agent` writes ONLY `.codex/agents/intent-guard.toml` (shared writer, no superreview skill required) — that is the entry point `$brewcode:teams-setup` calls | +| Disabled marker | `/.codex/skills/superreview/SKILL.md.disabled` | What `disable` renames `SKILL.md` to. Its presence IS the disabled state — there is no config file. `enable` renames it back; `uninstall`/`purge` delete the whole dir either way | | Re-generation | `upgrade` (Phase 2b) | `emit` refuses on a live installation because the emitted skill self-syncs; `upgrade` stages the new templates and never writes a live file. `SUPERREVIEW_FORCE=1` overwrites and destroys self-synced edits | | Template baseline | `/.codex/skills/superreview/.template-baseline/` | Pristine copies of the templates `emit` generated from (git-ignored via its own `.gitignore`). `upgrade` diffs the NEW template against them, so the reported delta is the TEMPLATE's change and never the Phase 3 tailoring the live files carry. Absent (pre-baseline install) -> `upgrade` reports `NO BASELINE` and falls back to a live-vs-template diff | | Stack reference | one of `python.md \| java-kotlin.md \| typescript-react.md \| go.md` | Emitted per the dominant detected stack | @@ -465,15 +556,21 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe | Multi-stack repo | Pick dominant stack for `STACK_REF`; note secondaries in the agent/group tables | | `.codex/agents/intent-guard.toml` already exists | REUSE it — the writer prints `INTENT_GUARD: REUSE ` and does not write the file. Never overwrite, never diff it into shape, never ask. Skip the Phase 3 BLOCK adaptation for it | | `.codex/agents/intent-guard.toml` exists but is EMPTY / has no `name: intent-guard` frontmatter | Not a reusable file — the writer says so and RECREATES it from the template. Then the Phase 3 adaptation applies as for any CREATED file | +| `.codex/agents/intent-guard.toml` carries the retired `intent-guard template vN` stamp | Pre-standard file of ours. The writer prints `INTENT_GUARD: MIGRATED `: the four metadata keys and the tail anchor are restamped, the tailored body is untouched. Do NOT run Phase 3 on it and do NOT re-emit it | +| `enable`/`disable`/`uninstall`/`purge` but nothing installed | The script exits 1 with `❌ not installed` (or `⚠️ nothing to uninstall`). Report it and **STOP** — never emit a fresh install as a "fix" for a removal verb | +| `enable` on a live install, `disable` on a parked one | The script prints `✅ already {enabled\|disabled}` and exits 0. Report it and **STOP**; do not rename | +| `validate` fails right after `disable` | Expected: `validate` looks for `SKILL.md`, which is now `SKILL.md.disabled`. Say "disabled, not broken" and offer `enable`. Never re-`emit` to "repair" it — that would destroy the Phase 4b self-synced edits the parked file still holds | +| `.codex/skills/superreview/` present with neither `SKILL.md` nor `SKILL.md.disabled` | Genuinely broken (a half-deleted install). Report the dir contents, offer `uninstall` then a fresh `install`. Do not guess which file to recreate | | `validate` prints `⚠️ UNTAILORED` | The Phase 3 BLOCK adaptation was skipped or partial (seeded markers survive). Warning, not a failure: go back to Phase 3, replace each seeded block + marker, re-run validate | | No tracker AND no spec/plan/policy dirs | Emit anyway with the defaults; the agent falls back to T5 (the session transcript) and reports its tier in every finding. Do NOT invent paths and do NOT skip the agent | | Target has no writable `.codex/agents/` | `emit` does `mkdir -p .codex/agents` first; a failure there is the same STOP as an unwritable `.codex/` | | Asked to add a `--fast`/`--deep` flag | Refuse — depth is inferred from the prompt by design. A flag would freeze the axis the emitted skill must read semantically | -| Unresolved `{PLACEHOLDER}` after Phase 3 | `validate` fails listing them (including any left in the emitted `intent-guard.md`); fix via Edit, re-run validate | +| Unresolved `{PLACEHOLDER}` after Phase 3 | `validate` fails listing them (including any left in the emitted `intent-guard.toml`); fix via Edit, re-run validate | | `emit` refuses — superreview already installed | Expected, not an error: the live skill carries Phase 4b self-sync corrections, and the refusal prints NO `INTENT_GUARD:` line. Go to Phase 2b and run `upgrade`. Only `SUPERREVIEW_FORCE=1` overwrites, and only on an explicit request for a clean regeneration | | `upgrade` says `DIFFERS` on a file the user hand-edited | `DIFFERS` counts TEMPLATE lines (new template vs `.template-baseline/`), never the user's tailoring. Port that template change onto the live file with Edit; never replace the file with the staged copy. Conflicting section -> ask before replacing it | | `upgrade` says `NO BASELINE` | The install predates `.template-baseline/`, so the printed count is a live-vs-template diff that INCLUDES Phase 3 tailoring — do not treat it as a template delta. Review the staged copy by hand, port only what the template really changed, then promote `.upgrade-staging/.template` to the baseline (command printed by the script) | -| `upgrade` says `MISSING -> restored (NEEDS PHASE 3)` | The restored file is a RAW template with unresolved BLOCK placeholders. Run Phase 3 on it BEFORE Phase 4 — going straight to `validate` fails on those placeholders | +| `upgrade` says `MISSING -> restored RAW` | The restored file is a RAW template: BOTH its BLOCK placeholders AND its scalars (`{PROJECT_NAME}`, `{STACK_LABEL}`, `{SOURCE_GLOB}`, the agent names) are unresolved, on purpose — `upgrade` has no environment to resolve them from and re-defaulting them would bake `this project` / `general-purpose` into a live file that `validate` then passes. Run Phase 3 on it BEFORE Phase 4; `validate` names every token | +| `upgrade` prints `UPGRADE_STACK=none — ❌ NO per-stack reference found` | The install carries none of `python.md` / `typescript-react.md` / `go.md` / `java-kotlin.md` (emitted without one, or it was deleted). The other four artifacts are still restamped; nothing is guessed. Re-run as `STACK_REF=.md generate.sh upgrade` to restore the right one — it then reports `MISSING -> restored RAW` | | Target `.codex/` not writable | STOP — ask the user to run from the repo root | --- @@ -486,13 +583,14 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe - `references/intent-guard.md.template` — the anti-drift agent (asked vs delivered), emitted to `.codex/agents/intent-guard.toml` create-or-reuse. - `references/report-template.md` — emitted merged-report layout. - `references/{python,java-kotlin,typescript-react,go}.md` — per-stack reference docs (one is emitted). -- `scripts/generate.sh` — `scan` / `emit` / `emit-agent` / `upgrade` / `validate` (validate also enforces the +- `scripts/generate.sh` — `scan` / `emit` / `emit-agent` / `upgrade` / `enable` / `disable` / `uninstall` / + `purge` / `validate` (validate also enforces the domain-expert requirement; `emit-agent` is the shared intent-guard writer used standalone by `$brewcode:teams-setup`; `upgrade` refreshes a live installation without destroying its self-synced edits, diffing the NEW template against the pristine `.template-baseline/` copies `emit` saved). + diff --git a/.codex/plugins/brewcode/skills/superreview-setup/references/java-kotlin.md b/.codex/plugins/brewcode/skills/superreview-setup/references/java-kotlin.md index ece93e7..f2e9ad5 100644 --- a/.codex/plugins/brewcode/skills/superreview-setup/references/java-kotlin.md +++ b/.codex/plugins/brewcode/skills/superreview-setup/references/java-kotlin.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Java/Kotlin Standards Reference Standards for Java/Kotlin enterprise projects. The project's own rules in `.codex/rules/*` + `.codex/convention/*` diff --git a/.codex/plugins/brewcode/skills/superreview-setup/references/python.md b/.codex/plugins/brewcode/skills/superreview-setup/references/python.md index 152ffe2..1639d9b 100644 --- a/.codex/plugins/brewcode/skills/superreview-setup/references/python.md +++ b/.codex/plugins/brewcode/skills/superreview-setup/references/python.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Python Standards Reference GENERIC modern-Python guidance (type hints, docstrings, imports, exceptions, comprehensions, testing). The project's diff --git a/.codex/plugins/brewcode/skills/superreview-setup/references/report-template.md b/.codex/plugins/brewcode/skills/superreview-setup/references/report-template.md index fd3d31f..7ea009f 100644 --- a/.codex/plugins/brewcode/skills/superreview-setup/references/report-template.md +++ b/.codex/plugins/brewcode/skills/superreview-setup/references/report-template.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Merged Report Layout (superreview Phase 4 — {PROJECT_NAME}) Output: `.codex/reports/{TIMESTAMP}_superreview/REPORT.md`. ONE consolidated, validated, P0->P3-sorted report. diff --git a/.codex/plugins/brewcode/skills/superreview-setup/references/scope.md.template b/.codex/plugins/brewcode/skills/superreview-setup/references/scope.md.template index 2b8c4a4..5e62316 100644 --- a/.codex/plugins/brewcode/skills/superreview-setup/references/scope.md.template +++ b/.codex/plugins/brewcode/skills/superreview-setup/references/scope.md.template @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Scope Discipline Reference (superreview — {PROJECT_NAME}) SINGLE home of: sanctioned-scope resolution, sanction sources + precedence, the ownership map, the scope-creep diff --git a/.codex/plugins/brewcode/skills/superreview-setup/references/typescript-react.md b/.codex/plugins/brewcode/skills/superreview-setup/references/typescript-react.md index 1e0ffdd..726b707 100644 --- a/.codex/plugins/brewcode/skills/superreview-setup/references/typescript-react.md +++ b/.codex/plugins/brewcode/skills/superreview-setup/references/typescript-react.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # TypeScript / Node / React Standards Reference Standards for TypeScript, Node.js and React projects. The project's own rules in `.codex/rules/*` + diff --git a/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh b/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh index 5e43899..6999024 100755 --- a/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh +++ b/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh @@ -11,23 +11,35 @@ # Also saves PRISTINE copies of the templates it emitted from under .template-baseline/ — that # baseline is what makes `upgrade` able to tell a TEMPLATE change apart from Phase 3 tailoring. # REFUSES to overwrite a live installation (the emitted skill SELF-SYNCS — Phase 4b — so its -# SKILL.md and references/scope.md carry edits no template knows about). SUPERREVIEW_FORCE=1 -# overrides and DESTROYS those edits. +# SKILL.md and references/scope.md carry edits no template knows about). "Live" = ANY emitted +# artifact on disk (see `_live_artifacts`), not SKILL.md alone: a PARTIAL install must not be +# re-substituted with DEFAULT scalars. SUPERREVIEW_FORCE=1 overrides and DESTROYS those edits. # upgrade - Refresh a LIVE installation without touching hand-edits (Phase 2b): stages a fresh emit next # to it and reports, per file, the NEW TEMPLATE vs the .template-baseline/ copy — IDENTICAL | -# DIFFERS (real template delta) | MISSING -> restored (NEEDS PHASE 3) | NO BASELINE (pre-baseline -# install: falls back to live-vs-template, tailoring included). Live files are never written; -# the AI applies the template delta with targeted Edit calls. +# DIFFERS (real template delta) | MISSING -> restored RAW (NEEDS PHASE 3) | NO BASELINE (pre-baseline +# install: falls back to live-vs-template, tailoring included). Live file CONTENT is never +# written; the AI applies the template delta with targeted Edit calls. The one live write is an +# UNCONDITIONAL metadata restamp (version/generated_by/last_updated, one `RESTAMP:` line per +# file, body compared byte-for-byte) — without it a version bump, which reports IDENTICAL on +# every asset, could never clear the `stale` verdict setup-status reads off the emitted +# SKILL.md frontmatter. The per-stack reference is RE-DERIVED from the installed tree +# (see `_installed_stack_refs`), never re-defaulted — see `UPGRADE_STACK=` on stdout. +# Runs on ANY live install, SKILL.md included in the restorable set — so it is also the remedy +# for a partially damaged install, which `emit` refuses to touch. A DISABLED install (parked +# SKILL.md.disabled) is refused with `enable` as its remedy, never silently resurrected. # emit-agent - Create-or-reuse /.codex/agents/intent-guard.toml ONLY. No superreview skill is # written, read or required. Used by $brewcode:teams-setup, which must not author its own copy. # Prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED ` | -# `INTENT_GUARD: REUSE `. Diagnostics go to stderr and never break that contract. +# `INTENT_GUARD: REUSE ` | `INTENT_GUARD: MIGRATED ` (a pre-standard agent of +# ours, restamped in place — tailored body preserved). Diagnostics go to stderr and never +# break that contract. # validate - Fail if any unresolved setup-time {PLACEHOLDER} remains (Phase 4) # # Env overrides (honored by BOTH emit and emit-agent; SUPERREVIEW_FORCE=1 lets emit overwrite a live install): # PROJECT_NAME, TRACKER_LABEL, SPEC_LOCATION, PLAN_LOCATION, POLICY_LOCATION # (emit also honors STACK_LABEL, STACK_REF, SOURCE_GLOB, PATHSPEC_GLOBS, ARBITER_AGENT, -# VALIDATOR_AGENT, SCOPE_AGENT_A, SCOPE_AGENT_B) +# VALIDATOR_AGENT, SCOPE_AGENT_A, SCOPE_AGENT_B; upgrade honors STACK_REF as an override of the +# stack it derives from the installed tree, and ignores the rest — see upgrade_skill) set -euo pipefail @@ -37,6 +49,9 @@ MODE="${1:-emit}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(dirname "$SCRIPT_DIR")" REFS="$SKILL_DIR/references" +# Plugin manifest by SELF-LOCATION: skills/superreview-setup -> skills -> . +# Correct in the dev checkout AND in the installed cache. The version is NEVER hardcoded. +PLUGIN_JSON="$SKILL_DIR/../../.codex-plugin/plugin.json" # Target is the current working directory (the repo being reviewed) TARGET=".codex/skills/superreview" @@ -46,10 +61,17 @@ STAGING="$TARGET/.upgrade-staging" # Pristine copies of the templates the live install was emitted from. `upgrade` diffs the NEW template against # these, so Phase 3 tailoring in the live files can never be mistaken for a template change. BASELINE="$TARGET/.template-baseline" +# Where `disable` parks SKILL.md. Read by `enable`/`disable` and by `upgrade`, which must tell a DISABLED +# install apart from one whose SKILL.md was deleted. +DISABLED_MARK="$TARGET/SKILL.md.disabled" # The one agent file this script owns, and the provenance stamp that proves a file came out of this pipeline. IG_PATH=".codex/agents/intent-guard.toml" -IG_STAMP_PREFIX="`). Its presence +# without IG_STAMP_PREFIX is what proves a file came out of THIS pipeline before the artifact-metadata standard — +# i.e. ours, migratable, and never to be confused with a hand-written agent that carries no stamp at all. +IG_LEGACY_STAMP_RE='`), append the current anchor block. + awk ' + //) drop = 0; next } + { print } + ' "$_bd/agent.md" > "$_bd/agent.next" + printf '\n' >> "$_bd/agent.next" + cat "$_bd/tail" >> "$_bd/agent.next" + cat -s "$_bd/agent.next" > "$_bd/agent.md" + + # POST-CONDITIONS. Both edits are pattern-driven; a silent miss would ship a half-migrated agent that + # still reads as legacy to `setup-status`. + for _k in doc_type version generated_by last_updated; do + grep -q "^${_k}:" "$_bd/agent.md" || { echo "❌ migration aborted: $_k missing after restamp" >&2; return 1; } + done + grep -qF "$IG_STAMP_PREFIX" "$_bd/agent.md" || { echo "❌ migration aborted: current tail anchor not written" >&2; return 1; } + grep -qE "$IG_LEGACY_STAMP_RE" "$_bd/agent.md" && { echo "❌ migration aborted: retired stamp survived" >&2; return 1; } + grep -q '^name:[[:space:]]*intent-guard[[:space:]]*$' "$_bd/agent.md" || { echo "❌ migration aborted: frontmatter name lost" >&2; return 1; } + + mv "$_bd/agent.md" "$IG_PATH" + rm -rf "$_bd"; _bd="" + echo "INTENT_GUARD: MIGRATED $IG_PATH" } write_intent_guard() { @@ -242,14 +453,22 @@ write_intent_guard() { resolve_scalars mkdir -p .codex/agents - if _ig_usable; then - echo "INTENT_GUARD: REUSE $IG_PATH" - return 0 - fi - # Diagnostic only — STDERR, so stdout keeps carrying exactly one `INTENT_GUARD:` status line. - if [ -e "$IG_PATH" ]; then - echo "⚠️ $IG_PATH exists but is empty, has no 'name: intent-guard' frontmatter, or still carries unresolved {PLACEHOLDER} tokens — recreating from template" >&2 - fi + case "$(_ig_kind)" in + CURRENT|FOREIGN) + echo "INTENT_GUARD: REUSE $IG_PATH" + return 0 + ;; + LEGACY) + # Ours, pre-standard. Restamp instead of recreating: the body is the project's own tailoring. + echo "ℹ️ $IG_PATH carries the retired 'intent-guard template vN' stamp — restamping metadata in place, body preserved" >&2 + _ig_migrate + return 0 + ;; + BROKEN) + # Diagnostic only — STDERR, so stdout keeps carrying exactly one `INTENT_GUARD:` status line. + echo "⚠️ $IG_PATH exists but is empty, has no 'name: intent-guard' frontmatter, or still carries unresolved {PLACEHOLDER} tokens — recreating from template" >&2 + ;; + esac # The agent must be RUNNABLE straight out of emit — the emitted skill spawns it at BOTH depths, so a # half-filled agent file breaks a QUICK run entirely. The three BLOCKs therefore get stack-generic @@ -334,10 +553,16 @@ emit_skill() { # The emitted skill SELF-SYNCS (its Phase 4b corrects its own routing table, gates, baseline and shared # surfaces). A blind re-emit would silently erase every one of those corrections, so a live installation is # never overwritten: `upgrade` refreshes it, and SUPERREVIEW_FORCE=1 is the conscious destructive override. - if [ -f "$TARGET/SKILL.md" ] && [ "${SUPERREVIEW_FORCE:-0}" != "1" ]; then - echo "❌ superreview is already installed at $TARGET/SKILL.md" - echo " It SELF-SYNCS (Phase 4b) — overwriting it destroys those in-place corrections." - echo " Use 'generate.sh upgrade' (live files preserved), or SUPERREVIEW_FORCE=1 to overwrite and LOSE them." + # The guard keys on the WHOLE artifact set, not on SKILL.md alone: a PARTIALLY damaged install (SKILL.md + # deleted, every tailored reference still in place) used to slip past it, and emit then re-substituted those + # references with DEFAULT scalars — `this project`, the generic scope, `python.md` over a TypeScript install. + _installed="$(_live_artifact_list)" + if [ -n "$_installed" ] && [ "${SUPERREVIEW_FORCE:-0}" != "1" ]; then + echo "❌ superreview is already installed at $TARGET/ — live artifact(s): $_installed" + echo " It SELF-SYNCS (Phase 4b) — overwriting it destroys those in-place corrections, and re-emitting" + echo " re-substitutes EVERY file with DEFAULT scalars (this project / the project stack / python.md)." + echo " Use 'generate.sh upgrade' (live files preserved; a MISSING one is restored RAW), or SUPERREVIEW_FORCE=1" + echo " to overwrite and LOSE them." exit 1 fi @@ -358,7 +583,7 @@ emit_skill() { echo "✅ $TARGET_REFS/scope.md" if [ -f "$REFS/$STACK_REF" ]; then - cp "$REFS/$STACK_REF" "$TARGET_REFS/$STACK_REF" + _subst "$REFS/$STACK_REF" "$TARGET_REFS/$STACK_REF" echo "✅ $TARGET_REFS/$STACK_REF" else echo "⚠️ stack reference not found: $REFS/$STACK_REF (emitted without per-stack doc)" @@ -380,6 +605,62 @@ emit_skill() { echo " — then run: generate.sh validate" } +# ── shared: in-place metadata restamp ────────────────────────────────────────── +# First frontmatter block of a file -> stdout. One reader for every check below, so "the frontmatter" means +# the same lines everywhere — `references/scope.md` carries a second `---` in its body and must not confuse it. +_fm_block() { awk 'NR == 1 && $0 == "---" { f = 1; next } f && $0 == "---" { exit } f { print }' "$1"; } +# Everything AFTER that block -> stdout. Used as the did-not-touch-the-body proof. +_fm_body() { awk 'NR == 1 && $0 == "---" { f = 1; next } f == 1 && $0 == "---" { f = 2; next } f == 2 { print }' "$1"; } + +# Refresh ONLY `version` / `generated_by` / `last_updated` in a LIVE file's own frontmatter, in place. +# $1 = live file, $2 = its freshly substituted staging counterpart — the single source for the spelling of the +# three values, exactly as `_ig_migrate` takes them from the substituted template. `doc_type` is PRESERVED when +# present (§1 of the artifact-metadata spec: it is user-owned) and seeded as `llm` only when the file has none. +# The body is copied through untouched and then compared byte-for-byte; a mismatch aborts rather than shipping a +# file whose Phase 3 tailoring or Phase 4b self-sync edits were silently mangled. +_restamp_meta() { + _live="$1"; _src="$2" + if [ "$(head -1 "$_live")" != "---" ]; then + echo "⚠️ RESTAMP: $_live has no frontmatter block — left untouched" >&2 + return 0 + fi + _bd="$(mktemp -d)" + _fm_block "$_src" | grep -E '^(version|generated_by|last_updated):[[:space:]]' > "$_bd/meta" || true + if [ "$(grep -c . "$_bd/meta" || true)" -ne 3 ]; then + echo "❌ restamp aborted: $_src frontmatter carries no version/generated_by/last_updated trio" >&2 + return 1 + fi + # Materialise the live frontmatter once: a `grep -q` / `head -1` on a live pipe can SIGPIPE the awk + # upstream, and under `pipefail` that reads as a failure (repo rule avoid#7). + _fm_block "$_live" > "$_bd/live.fm" + _was=$(sed -n 's/^version:[[:space:]]*//p' "$_bd/live.fm" | sed -n 1p || true) + _needdt=0 + grep -q '^doc_type:' "$_bd/live.fm" || _needdt=1 + + awk -v metaf="$_bd/meta" -v needdt="$_needdt" ' + NR == 1 && $0 == "---" { fm = 1; print; next } + fm == 1 && $0 == "---" { + if (needdt == 1) print "doc_type: llm" + while ((getline l < metaf) > 0) print l + close(metaf); fm = 2; print; next + } + fm == 1 && /^(version|generated_by|last_updated):[[:space:]]/ { next } + { print } + ' "$_live" > "$_bd/next" + + # POST-CONDITIONS. The body must be identical, and the result must satisfy the same frontmatter gate + # `validate` applies — one dialect, checked here so a bad restamp never reaches the user's tree. + _fm_body "$_live" > "$_bd/body.old"; _fm_body "$_bd/next" > "$_bd/body.new" + cmp -s "$_bd/body.old" "$_bd/body.new" \ + || { echo "❌ restamp aborted: $_live body changed — nothing written" >&2; return 1; } + _check_meta_frontmatter "$_bd/next" \ + || { echo "❌ restamp aborted: $_live would fail the metadata gate — nothing written" >&2; return 1; } + + mv "$_bd/next" "$_live" + rm -rf "$_bd"; _bd="" + echo "RESTAMP: $_live version ${_was:-(none)} -> \"$PLUGIN_VERSION\", generated_by/last_updated refreshed (body untouched)" +} + # ── upgrade: refresh a LIVE installation, hand-edits preserved ────────────────── # The emitted skill is EXPECTED to have self-modified (its Phase 4b SELF-SYNC) and to carry Phase 3 tailoring, so # no live file is ever written over AND no live file is ever the diff baseline: comparing a tailored install to a @@ -391,10 +672,52 @@ upgrade_skill() { echo "=== superreview: upgrade ===" validate_templates - if [ ! -f "$TARGET/SKILL.md" ]; then - echo "❌ nothing to upgrade: $TARGET/SKILL.md does not exist — run 'generate.sh emit' first" + # A LIVE INSTALL is the requirement, not SKILL.md specifically. Gating on SKILL.md alone left the one state + # this mode exists for — SKILL.md deleted, every tailored reference intact — with `emit` as its only advertised + # remedy, and `emit` re-defaults every one of those references. The restore loop below already handles a + # missing artifact correctly (RAW, out of `.template/`, NEEDS PHASE 3), and SKILL.md is in that set. + _installed="$(_live_artifact_list)" + if [ -z "$_installed" ]; then + echo "❌ nothing to upgrade: no superreview artifact under $TARGET/ — run 'generate.sh emit' first" exit 1 fi + if [ ! -f "$TARGET/SKILL.md" ]; then + if [ -f "$DISABLED_MARK" ]; then + # Parked, not damaged. Restoring a RAW SKILL.md here would resurrect the skill behind the user's back and + # leave two copies of it, so the remedy is the reversible one that already exists. + echo "❌ superreview is DISABLED: $DISABLED_MARK is parked in place of $TARGET/SKILL.md" + echo " Run 'generate.sh enable' first, then upgrade." + exit 1 + fi + echo "ℹ️ $TARGET/SKILL.md is MISSING from an otherwise live install — it is restored RAW below (NEEDS PHASE 3);" + echo " every other artifact keeps its tailoring untouched." + fi + + # STACK — re-derived from the INSTALLED tree BEFORE any scalar resolves. `resolve_scalars` would otherwise fall + # back to `python.md`, and every loop below iterates `references/$STACK_REF`: on a TypeScript/Go/Java-Kotlin + # install the project's REAL reference would never be staged, never be restamped, and stay behind at the old + # version forever — so `setup-status` keeps printing `stale` after a successful upgrade. An explicit STACK_REF in + # the environment still wins (documented override); nothing else re-defaults. + if [ -n "${STACK_REF:-}" ]; then + STACK_REFS="$STACK_REF" + echo "UPGRADE_STACK=$STACK_REFS (STACK_REF override)" + else + STACK_REFS="$(_installed_stack_refs | tr '\n' ' ' | sed 's/[[:space:]]*$//')" + if [ -n "$STACK_REFS" ]; then + # >1 = multi-stack install: all of them are live artifacts, all of them get restamped. The scalar keeps the + # first, which is only ever substituted into TEXT (`references/{STACK_REF}` prose). + STACK_REF="${STACK_REFS%% *}" + echo "UPGRADE_STACK=$STACK_REFS (derived from the installed tree)" + else + # NOT determinable: emitted without a per-stack doc, or the reference was deleted by hand. Guessing is the + # bug this block exists to remove, so nothing is guessed and nothing per-stack is staged — but the run does + # NOT abort: the other four artifacts still need their restamp or `status` reads `stale` forever. + STACK_REF="none" + echo "UPGRADE_STACK=none — ❌ NO per-stack reference found in $TARGET_REFS/ or $BASELINE/references/" + echo " (candidates: $(_stack_catalog | tr '\n' ' ' | sed 's/[[:space:]]*$//')). No stack doc is staged or restamped; the other four" + echo " artifacts are. Re-run as: STACK_REF=.md generate.sh upgrade — it is then restored RAW." + fi + fi resolve_scalars rm -rf "$STAGING" @@ -406,20 +729,30 @@ upgrade_skill() { _subst "$REFS/report-template.md" "$STAGING/references/report-template.md" _subst "$REFS/scope.md.template" "$STAGING/references/scope.md" # `|| true`: a missing per-stack ref must not abort the run under `set -e`. - { [ -f "$REFS/$STACK_REF" ] && cp "$REFS/$STACK_REF" "$STAGING/references/$STACK_REF"; } || true + for _s in $STACK_REFS; do + { [ -f "$REFS/$_s" ] && _subst "$REFS/$_s" "$STAGING/references/$_s"; } || true + done # Raw NEW templates, in the same shape as the baseline — this pair is what the delta is computed from. copy_raw_templates "$STAGING/.template" echo "UPGRADE_STAGING=$STAGING" echo "UPGRADE_BASELINE=$BASELINE" + # The live artifact set: four stack-independent files plus every per-stack reference this install carries. + _rels="SKILL.md references/agent-prompt.md references/report-template.md references/scope.md" + for _s in $STACK_REFS; do _rels="$_rels references/$_s"; done + _restored=0 - for _rel in "SKILL.md" "references/agent-prompt.md" "references/report-template.md" \ - "references/scope.md" "references/$STACK_REF"; do + for _rel in $_rels; do [ -f "$STAGING/$_rel" ] || continue if [ ! -f "$TARGET/$_rel" ]; then - cp "$STAGING/$_rel" "$TARGET/$_rel" + # RAW, from `.template/` — never the substituted staging copy. `upgrade` runs with a bare environment, so + # every scalar in that copy would be the DEFAULT ("this project", "the project stack", `general-purpose`, + # `Explore`), i.e. the install-time decision silently re-guessed and baked into a live file that `validate` + # then passes. Restoring RAW makes each one an unresolved {TOKEN} that `validate` lists by name, which is + # exactly the NEEDS PHASE 3 contract. The metadata trio is refreshed by the restamp loop below. + cp "$STAGING/.template/$_rel" "$TARGET/$_rel" _restored=$((_restored+1)) - echo "UPGRADE: $_rel MISSING -> restored (NEEDS PHASE 3)" + echo "UPGRADE: $_rel MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)" elif [ -f "$BASELINE/$_rel" ]; then if cmp -s "$BASELINE/$_rel" "$STAGING/.template/$_rel"; then echo "UPGRADE: $_rel IDENTICAL (template unchanged since install — live file untouched)" @@ -435,7 +768,19 @@ upgrade_skill() { fi done - # Same create-or-reuse writer as emit: a usable intent-guard.md is REUSED byte-untouched. + # Restamp the LIVE files. Unconditional, and deliberately NOT gated on the IDENTICAL/DIFFERS verdict above: + # a plain version bump changes no template line, so every asset reports IDENTICAL — yet the emitted + # `SKILL.md` frontmatter `version:` is exactly what setup-status reads to decide `stale`. Without this an + # `upgrade` reported success and left the stamp untouched, so the next `status` printed `stale` forever. + # Same `$_rels` set as the delta report above — including the project's REAL per-stack reference, whatever it is. + for _rel in $_rels; do + [ -f "$TARGET/$_rel" ] || continue + [ -f "$STAGING/$_rel" ] || continue + _restamp_meta "$TARGET/$_rel" "$STAGING/$_rel" || exit 1 + done + + # Same writer as emit: a current or hand-written intent-guard.toml is REUSED byte-untouched, and a + # pre-standard one of ours is MIGRATED here — this is the `upgrade restamps it` path setup-status promises. write_intent_guard echo "" @@ -446,6 +791,29 @@ upgrade_skill() { echo " rm -rf \"$BASELINE\" && mv \"$STAGING/.template\" \"$BASELINE\" && rm -rf \"$STAGING\" && generate.sh validate" } +# Artifact-metadata frontmatter gate: the four keys, in D2 order, quoted exactly as +# `brewcode/skills/rules/scripts/rules.sh:140-146` already requires — one dialect, not a second one. +# Prints one line per defect, returns 1 when any fired. +_check_meta_frontmatter() { + _f="$1"; _bad=0 + _fm=$(awk 'NR == 1 && $0 == "---" { f = 1; next } f && $0 == "---" { exit } f { print }' "$_f") + for _k in doc_type version generated_by last_updated; do + printf '%s\n' "$_fm" | grep -q "^${_k}:" || { echo "❌ $_f frontmatter missing metadata key: $_k"; _bad=1; } + done + printf '%s\n' "$_fm" | grep -q '^doc_type: llm$' \ + || { echo "❌ $_f doc_type must be exactly 'llm', UNQUOTED"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^version: "[0-9]+\.[0-9]+\.[0-9]+"$' \ + || { echo "❌ $_f version must be a QUOTED X.Y.Z"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^generated_by: "[^"]+"$' \ + || { echo "❌ $_f generated_by must be a QUOTED :"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^last_updated: "[0-9]{4}-[0-9]{2}-[0-9]{2}"$' \ + || { echo "❌ $_f last_updated must be a QUOTED YYYY-MM-DD"; _bad=1; } + _order=$(printf '%s\n' "$_fm" | grep -oE '^(doc_type|version|generated_by|last_updated)' | tr '\n' ' ' || true) + [ "$_order" = "doc_type version generated_by last_updated " ] \ + || { echo "❌ $_f metadata keys out of order [$_order] — must be doc_type, version, generated_by, last_updated"; _bad=1; } + return "$_bad" +} + # ── validate: no setup-time {PLACEHOLDER} may remain ──────────────────────────── validate_emit() { echo "=== superreview: validate ===" @@ -456,13 +824,18 @@ validate_emit() { fi # Runtime tokens the emitted skill legitimately keeps (resolved at REVIEW time, not GENERATION time). + # This list is NOT the shell-variable escape hatch — `_scan_tokens` handles `${VAR}` now. `MAIN`, `ROOT`, `TOK` + # and `REPORT_DIR` occur in SKILL.md.template ONLY as `${…}` expansions and never as bare tokens; they are kept + # here as harmless no-ops rather than removed, but do NOT add a name here to silence a shell variable — that is + # the workaround that hid the collision until an adapted artifact used a variable nobody had allowlisted. _runtime='MODE|DEPTH|BRANCH|SCOPE|FILES|COUNT|TIMESTAMP|FOCUS|FILE_LIST|AGENT_LIST|CANDIDATES|MERGED|PATHSPEC|MAIN|SHA|FOLDER|GROUP|AGENT|N|OC|SC|K|U|D|ROOT|TOK|RANGE|REPORT_DIR|SCOPE_BASELINE|OWNERSHIP|GATE_RESULTS|PR_ISSUE_JSON|INTENT_VERDICT|USER_REQUEST' _errors=0 - for f in "$TARGET/SKILL.md" "$TARGET_REFS/agent-prompt.md" "$TARGET_REFS/report-template.md" \ - "$TARGET_REFS/scope.md"; do + # Every emitted reference, not a fixed list: the per-stack ref is substituted too, so an unresolved + # metadata token in it must fail the gate like any other. + for f in "$TARGET/SKILL.md" "$TARGET_REFS"/*.md; do [ -f "$f" ] || continue - _unresolved=$(grep -oE '\{[A-Z_]+\}' "$f" | sort -u | grep -vE "^\{(${_runtime})\}$" || true) + _unresolved=$(_scan_tokens "$f" | sort -u | grep -vE "^\{(${_runtime})\}$" || true) if [ -n "$_unresolved" ]; then echo "❌ unresolved setup-time placeholders in $f:" echo "$_unresolved" @@ -519,21 +892,29 @@ EOF _errors=$((_errors+1)) fi done - # intent-guard is EXECUTED at both depths: an empty or frontmatter-less file is as broken as a missing one. - if ! _ig_usable; then - if [ -e "$IG_PATH" ]; then + # intent-guard is EXECUTED at both depths: an empty or frontmatter-less file is as broken as a missing one, + # and a LEGACY one is a live agent whose restamp never ran — both are failures with a one-command fix. + _ig_state="$(_ig_kind)" + case "$_ig_state" in + BROKEN) echo "❌ unusable emitted asset: $IG_PATH (empty, no 'name: intent-guard' frontmatter, or unresolved {PLACEHOLDER} tokens) — re-run 'generate.sh emit-agent'" - else + _errors=$((_errors+1)) + ;; + LEGACY) + echo "❌ pre-standard emitted asset: $IG_PATH still carries the retired 'intent-guard template vN' stamp and none of the four metadata keys — run 'generate.sh emit-agent' (or 'upgrade') to restamp it; the tailored body is preserved" + _errors=$((_errors+1)) + ;; + ABSENT) echo "❌ missing emitted asset: $IG_PATH" - fi - _errors=$((_errors+1)) - fi + _errors=$((_errors+1)) + ;; + esac # (c2) TEMPLATE-DERIVED agents only. A file carrying the template stamp came out of this pipeline, so every # {PLACEHOLDER} in it must be resolved (scalars by emit, the three BLOCKs by AI Edit in SKILL.md Phase 3). # A REUSED hand-written intent-guard is byte-untouchable by contract — it is not judged by template rules. - if _ig_usable && grep -qF "$IG_STAMP_PREFIX" "$IG_PATH"; then - _ig_unresolved=$(grep -oE '\{[A-Z_]+\}' "$IG_PATH" | sort -u || true) + if [ "$_ig_state" = "CURRENT" ]; then + _ig_unresolved=$(_scan_tokens "$IG_PATH" | sort -u || true) if [ -n "$_ig_unresolved" ]; then echo "❌ unresolved placeholders in $IG_PATH (no token is runtime here):" echo "$_ig_unresolved" @@ -543,6 +924,7 @@ EOF echo "❌ $IG_PATH still carries the TEMPLATE HEADER comment — emit must strip it" _errors=$((_errors+1)) fi + _check_meta_frontmatter "$IG_PATH" || _errors=$((_errors+1)) # (c3) TAILORING. Seeded BLOCK defaults are a runnable floor, not the target: a run that skipped the # Phase 3 adaptation ships boilerplate and would otherwise pass every gate silently. WARN, not fail — # `emit-agent` is a legitimate standalone path whose adaptation happens in the caller's own flow. @@ -552,8 +934,8 @@ EOF grep -nF "$IG_SEED_MARK" "$IG_PATH" || true echo " INTENT_GUARD: UNTAILORED $IG_PATH ($_ig_seeded seeded block(s)) — run SKILL.md Phase 3 and replace each block + its marker" fi - elif _ig_usable; then - echo "ℹ️ $IG_PATH carries no template stamp — treated as the project's own hand-written agent, not checked against the template" + elif [ "$_ig_state" = "FOREIGN" ]; then + echo "ℹ️ $IG_PATH carries no template stamp of any generation — treated as the project's own hand-written agent, not checked against the template" fi # (d) DOMAIN EXPERTS — a review routed only to generic agents is a degraded review. @@ -610,19 +992,105 @@ EOF exit "$_errors" } +# --- enable / disable ------------------------------------------------------ +# Codex discovers a project skill only through /SKILL.md. Parking that ONE file as +# SKILL.md.disabled makes /superreview vanish while references/, .template-baseline/ and every +# Phase 3 tailoring stay exactly where they are, so the toggle is reversible and lossless. +# intent-guard is NEVER parked: it is shared with $brewcode:teams-setup and belongs to whichever +# install put it there. ($DISABLED_MARK is defined next to $TARGET, above.) + +toggle_skill() { + _want="$1" # enable | disable + if [ "$_want" = "disable" ]; then _from="$TARGET/SKILL.md"; _to="$DISABLED_MARK" + else _from="$DISABLED_MARK"; _to="$TARGET/SKILL.md"; fi + + echo "=== superreview: $_want ===" + if [ ! -d "$TARGET" ]; then + echo "❌ not installed: $TARGET does not exist — run 'generate.sh emit' first" + exit 1 + fi + if [ -f "$_to" ] && [ ! -f "$_from" ]; then + echo "✅ already ${_want}d — $_to is in place, nothing to move" + exit 0 + fi + if [ ! -f "$_from" ]; then + echo "❌ broken installation: neither $TARGET/SKILL.md nor $DISABLED_MARK exists" + exit 1 + fi + mv "$_from" "$_to" + echo "MOVED: $_from -> $_to" + echo "KEPT: $TARGET_REFS/ $BASELINE/ $IG_PATH" + echo "✅ $_want (takes effect in the NEXT session — skills are discovered at session start)" +} + +# --- uninstall / purge ----------------------------------------------------- +# uninstall removes the MACHINERY (the generated skill dir); purge additionally removes the DATA +# (the review reports it produced). Same machinery/data split as $brewtools:task-board-setup. +# intent-guard survives BOTH: shared with $brewcode:teams-setup, and deleting it would break a +# team install that has nothing to do with superreview. +REPORT_GLOB=".codex/reports" + +remove_skill() { + _purge="$1" # 0 = uninstall, 1 = purge + _label=$([ "$_purge" = "1" ] && echo purge || echo uninstall) + echo "=== superreview: $_label ===" + + _found=0 + if [ -d "$TARGET" ]; then + rm -rf "$TARGET" + echo "REMOVED: $TARGET/ (SKILL.md, references/, .template-baseline/, any staging)" + _found=1 + else + echo "SKIP: $TARGET/ absent" + fi + + if [ "$_purge" = "1" ]; then + _reports=$({ find "$REPORT_GLOB" -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | sort) + if [ -n "$_reports" ]; then + printf '%s\n' "$_reports" | while IFS= read -r _d; do + [ -n "$_d" ] || continue + rm -rf "$_d" + echo "REMOVED: $_d/" + done + _found=1 + else + echo "SKIP: no .codex/reports/*_superreview/ to remove" + fi + else + _rc=$({ find "$REPORT_GLOB" -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | wc -l | tr -d ' ') + echo "KEPT: $_rc review report dir(s) under $REPORT_GLOB/ — 'purge' deletes those too" + fi + + if [ -f "$IG_PATH" ]; then + echo "KEPT: $IG_PATH — shared with $brewcode:teams-setup, never deleted by either skill" + fi + + [ "$_found" = "1" ] || { echo "⚠️ nothing to $_label — superreview was not installed here"; exit 0; } + echo "✅ $_label" +} + case "$MODE" in scan) scan_target ;; emit) emit_skill ;; emit-agent) emit_agent_only ;; upgrade) upgrade_skill ;; + enable) toggle_skill enable ;; + disable) toggle_skill disable ;; + uninstall) remove_skill 0 ;; + purge) remove_skill 1 ;; validate) validate_emit ;; *) - echo "Usage: generate.sh " + echo "Usage: generate.sh " echo " emit refuses to overwrite a live installation (SUPERREVIEW_FORCE=1 overrides, DESTROYS self-sync edits)" echo " emit-agent create-or-reuse /.codex/agents/intent-guard.toml ONLY (no superreview skill needed);" - echo " prints 'INTENT_GUARD: CREATED ' or 'INTENT_GUARD: REUSE '" + echo " prints 'INTENT_GUARD: CREATED|REUSE|MIGRATED ' (MIGRATED = pre-standard agent restamped in place)" echo " upgrade refresh a live installation; reports NEW template vs .template-baseline/ (the real template" echo " delta, tailoring excluded), restores missing assets RAW (NEEDS PHASE 3), never overwrites" + echo " enable rename .codex/skills/superreview/SKILL.md.disabled back to SKILL.md" + echo " disable rename .codex/skills/superreview/SKILL.md to SKILL.md.disabled — /superreview stops being" + echo " discovered; references/, .template-baseline/ and all tailoring are untouched, reversible" + echo " uninstall delete .codex/skills/superreview/; KEEPS the review reports and intent-guard.toml" + echo " purge uninstall + delete .codex/reports/*_superreview/; still keeps intent-guard.toml" exit 1 ;; esac diff --git a/.codex/plugins/brewcode/skills/teams-setup/README.md b/.codex/plugins/brewcode/skills/teams-setup/README.md index 7e4fe90..e9a336e 100644 --- a/.codex/plugins/brewcode/skills/teams-setup/README.md +++ b/.codex/plugins/brewcode/skills/teams-setup/README.md @@ -17,14 +17,18 @@ Analyzes the project, proposes agent variants (minimal/balanced/maximum), create | Status | `$brewcode:teams-setup status ` | Read-only report: agent health, success rates, issues, insights | | Install | `$brewcode:teams-setup install [prompt]` | Analyze project, propose team, create agents + tracking framework | | Upgrade | `$brewcode:teams-setup upgrade ` | Analyze performance, tune or replace underperformers | +| Enable | `$brewcode:teams-setup enable ` | Restore a disabled team: every parked `.md.disabled` is renamed back to `.md` | +| Disable | `$brewcode:teams-setup disable ` | Park the team without deleting it: each `.md` becomes `.md.disabled`, so Codex stops discovering it. `team.md`, `trace.jsonl` and the archive are untouched | | Uninstall | `$brewcode:teams-setup uninstall ` | Archive old tracking data, remove inactive agents | | Purge | `$brewcode:teams-setup purge ` | Total removal: every domain agent + `.codex/teams//` incl. the archive. Confirmed once, not recoverable | No arguments: `status` of the first existing team, or `install` of a team named `default` when none exists. -`enable` / `disable` are rejected with an error — a team either exists or it does not. The same parser guard makes `purge` a mode instead of a team name: in earlier versions any unrecognised first word became a team name, so `$brewcode:teams-setup purge` installed a team called `purge`. +The verb always comes first and the optional `` after it. That parser guard is why `purge` is a mode instead of a team name: in earlier versions any unrecognised first word became a team name, so `$brewcode:teams-setup purge` installed a team called `purge`. -`purge` keeps exactly one thing: `.codex/agents/intent-guard.toml`, shared with `$brewcode:superreview-setup`. +`disable` is a rename, not a deletion — the roster rows stay in `team.md` with `Status: disabled`, and `verify-team.sh` reports `DISABLED` per parked member and still exits PASS. `enable` puts it all back. Both take effect for the NEXT session: agent discovery is read at session start. + +`purge` keeps exactly one thing: `.codex/agents/intent-guard.toml`, shared with `$brewcode:superreview-setup`. It removes both `.toml` and `.toml.disabled`, so purging a disabled team leaves nothing behind. ## Examples @@ -41,6 +45,12 @@ $brewcode:teams-setup status backend # Tune agents based on tracking data $brewcode:teams-setup upgrade backend +# Park the team without losing it -- agents leave the roster, history stays +$brewcode:teams-setup disable backend + +# Put it back +$brewcode:teams-setup enable backend + # Clean up after a long project phase $brewcode:teams-setup uninstall backend @@ -73,7 +83,7 @@ After `$brewcode:teams-setup install my-team`: agents/ agent-one.md # Domain agents (5-20 depending on variant) agent-two.md - intent-guard.md # Fixed review-only member, every team, not counted + intent-guard.toml # Fixed review-only member, every team, not counted teams/ my-team/ team.md # Roster: agent list, domains, missions, status @@ -176,9 +186,10 @@ Every team gets `intent-guard` in addition to its domain agents. It is an **anti **Single writer (idempotent):** `teams` never authors this file. It runs `superreview-setup/scripts/generate.sh emit-agent`, which creates it from the shared template or reuses an -existing one and prints `INTENT_GUARD: CREATED|REUSE `. On `REUSE` -- typically because +existing one and prints `INTENT_GUARD: CREATED|REUSE|MIGRATED `. On `REUSE` -- typically because `$brewcode:superreview-setup` ran first -- the file is left exactly as is and only the `team.md` roster row is -added. On `CREATE`, one `agent-creator` pass tailors the three seeded generic blocks (project +added. `MIGRATED` means a pre-5.0 file of ours was restamped in place (metadata only, tailored body +preserved); treat it like `REUSE` -- no adaptation pass. On `CREATE`, one `agent-creator` pass tailors the three seeded generic blocks (project invariants, drift examples, evidence commands) and touches nothing else -- frontmatter and header stay as emitted. Both skills therefore converge on one shared file produced by one pipeline, never two variants. diff --git a/.codex/plugins/brewcode/skills/teams-setup/SKILL.md b/.codex/plugins/brewcode/skills/teams-setup/SKILL.md index e55701c..3b433f0 100644 --- a/.codex/plugins/brewcode/skills/teams-setup/SKILL.md +++ b/.codex/plugins/brewcode/skills/teams-setup/SKILL.md @@ -29,11 +29,28 @@ Manage dynamic teams of domain-specific agents with tracking framework. bash "/scripts/detect-mode.sh" "" && echo "OK" || echo "FAILED" ``` -Output: `MODE:`, `TEAM_NAME:`, `PROMPT:` (optional). Store all three. +Output: `MODE:`, `TEAM_NAME:`, `PROMPT:` (optional), plus the artifact-metadata scalars +`PLUGIN_VERSION:`, `GENERATED_BY:`, `LAST_UPDATED:`. Store all of them. -`MODE` is one of `status | install | upgrade | uninstall | purge`. The script prints `ERROR:...` and -exits 1 for `enable` / `disable` — teams-setup has no enable/disable state. On any `ERROR:` line: -report it verbatim and **STOP**. Never guess a mode, and never treat a canonical verb as a team name. +> **Artifact metadata — every file this skill writes.** `team.md` and every generated domain agent carry +> `version` = `PLUGIN_VERSION:`, `generated_by` = `GENERATED_BY:` (`brewcode:teams-setup`), +> `last_updated` = `LAST_UPDATED:`, and `doc_type: llm` on the agents. Take the values from the output +> above — never hardcode a version, never call `date` a second time with a different format, and never +> stamp a "template version": the plugin version replaces it. +> `.codex/agents/intent-guard.toml` is the ONE exception: `generate.sh emit-agent` stamps it with +> `generated_by: brewcode:superreview-setup`, and teams never touches those keys. + +`MODE` is one of the canonical seven, in this order: `status | install | upgrade | enable | disable | +uninstall | purge`. On any `ERROR:` line: report it verbatim and **STOP**. Never guess a mode, and +never treat a canonical verb as a team name — `install enable` creates a team NAMED `enable`, so the +verb always comes first and the optional `[name]` positional after it. + +> **How a team is enabled or disabled.** Codex discovers a project agent only through +> `.codex/agents/.toml`. `disable` renames each member to `.toml.disabled`; `enable` renames +> it back. The file body, `team.md`, `trace.jsonl`, `trace-archive.jsonl` and the cursor are untouched +> either way, so the toggle is fully reversible and loses no configuration and no history. It is NOT +> an uninstall: nothing is deleted. `intent-guard` is never parked — it is shared with +> `$brewcode:superreview-setup`, exactly as in UNINSTALL and PURGE. --- @@ -176,6 +193,26 @@ If "Mixed" -- ask model per agent in C3. Store as `DEFAULT_MODEL` (default: high 1. Read `/references/agent-template.md` 2. For each agent, spawn `Codex delegation brief (task_role="brewcode:agent-creator")` — ONE agent file per spawn, never "create the whole team" in one task. Prompt carries GOAL (this roster is being built for {TEAM_NAME}; siblings own the other domains), ROLE (owns `.codex/agents/{name}.toml` only), SCOPE (that file; out of bounds: other agents, team.md, project source), CONTEXT (mission + domain + project analysis from C1 are settled; reasoning_tier={DEFAULT_MODEL or per-agent} chosen in C2; the 3-4 sibling agent-creators in this batch own {COLLEAGUE_NAMES} — stay off their domains and do not duplicate their triggers), CONSUMER (C4 writes `.codex/teams/{TEAM_NAME}/team.md` from your path + description line, C5 quorum-reviews the file, and colleagues re-delegate to it by domain via the sub-agent task Acceptance Protocol), DONE (file written, `description` <= 100 chars (optimal ~80), single line, role + 2-3 triggers, no `` blocks; report path + description line). + + Every spawn prompt MUST also carry the template path and the four metadata lines, resolved — the + subagent cannot see Phase 1's output, so **replace `{PLUGIN_VERSION}` and `{LAST_UPDATED}` below with + the literal values from the Phase 1 `PLUGIN_VERSION:` / `LAST_UPDATED:` lines before you send the + prompt.** A token that reaches the subagent ships verbatim into the agent file, and `setup-status` + then reports that agent `partial` forever. Those two spellings are the only sanctioned ones — never an + angle form, never a double brace: + + ``` + CONTEXT (cont.): structure from /references/agent-template.md — read it first. + DONE (cont.): the frontmatter ends with exactly these four keys, in this order, AFTER the agent's + own keys (name, description, model, tools — leave those byte-untouched, `tools` above all): + doc_type: llm + version: "{PLUGIN_VERSION}" + generated_by: "brewcode:teams-setup" + last_updated: "{LAST_UPDATED}" + ``` + + `verify-team.sh` re-reads every generated agent's frontmatter and FAILS on a wrong order, a missing + key or wrong quoting, so a prompt that shipped a token does not pass C4. 3. Batch 3-4 agents in parallel per message 4. After each batch, optimize: ``` @@ -207,22 +244,27 @@ bash "/../superreview-setup/scripts/generate.sh" emit-agent && ``` It creates-or-reuses ONLY `.codex/agents/intent-guard.toml` (superreview does not need to have run) and -prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED ` or -`INTENT_GUARD: REUSE `. Diagnostics (e.g. "recreating from template") go to stderr and never -add a second status line. +prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED `, +`INTENT_GUARD: REUSE ` or `INTENT_GUARD: MIGRATED ` (a pre-standard file of ours, restamped +in place — metadata only, tailored body preserved). Diagnostics (e.g. "recreating from template") go to +stderr and never add a second status line. > **STOP if FAILED** -- report the script output; do not fall back to hand-authoring the file. **Step 2 — sanity-check the emitted file** (a pre-existing file may be empty, truncated or -placeholder-laden; `-f` alone proves nothing): +placeholder-laden; `-f` alone proves nothing). This runs on the REUSE path too, where `$f` is somebody's +already-adapted agent whose evidence block legitimately holds shell expansions — so strip `${VAR}` FIRST +and match bare tokens on what is left. Without the strip a `${BASE}` scores as an unresolved placeholder, +and this step's remedy is `rm -f`: it would delete a tailored file. ```bash f=.codex/agents/intent-guard.toml -[ -s "$f" ] && grep -q '^name: intent-guard' "$f" && ! grep -q '{[A-Z_]\{2,\}}' "$f" && echo "SANE" || echo "CORRUPT" +[ -s "$f" ] && grep -q '^name: intent-guard' "$f" \ + && ! sed 's/\${[A-Z_][A-Z_]*}//g' "$f" | grep -q '{[A-Z_]\{2,\}}' && echo "SANE" || echo "CORRUPT" ``` - `CORRUPT` -> `rm -f .codex/agents/intent-guard.toml`, re-run Step 1 once (a fresh emit is now a `CREATED`), re-check. Still `CORRUPT` -> **STOP** and report; do not patch it by hand. -**Step 3 — adapt the seeded BLOCKs.** Only on `INTENT_GUARD: CREATED`. On `REUSE` skip this step -entirely: the existing file is already project-adapted and must not be rewritten or "refreshed". +**Step 3 — adapt the seeded BLOCKs.** Only on `INTENT_GUARD: CREATED`. On `REUSE` or `MIGRATED` skip this +step entirely: the existing file is already project-adapted and must not be rewritten or "refreshed". `emit-agent` seeds three BLOCKs with GENERIC marked defaults. Spawn ONE `Codex delegation brief (task_role="brewcode:agent-creator")`, alone (not batched with the domain agents), to replace @@ -266,11 +308,22 @@ Codex delegation brief (task_role="brewcode:agent-creator", message=" ") ``` -**Step 4 — verify:** +**Step 4 — verify.** FOUR counts, one grep per line, in this order. Each pattern matches the ARTIFACT, +never prose ABOUT it: the emitted agent legitimately keeps a tail comment that NAMES the stripped +`TEMPLATE HEADER`, so an unanchored `grep -c 'TEMPLATE HEADER'` reports `1` on every healthy file and +turns this gate into an unpassable loop. Match the header's opening line, not the phrase. Same reason the +placeholder count strips `${VAR}` first: `{PROJECT_NAME}` is a token, `` in an adapted +evidence command is not, and only a strip-then-match tells them apart — a `$`-guard inside the pattern +mis-handles adjacent tokens. `|| true` on every line: zero matches is the happy path for three of the four +counts (repo rule avoid#7), and a count must still PRINT under `set -o pipefail`, especially when it is the +one going red. + ```bash f=.codex/agents/intent-guard.toml -grep -c '{[A-Z_]\{2,\}}' "$f"; grep -c 'TEMPLATE HEADER' "$f"; grep -c '^name: intent-guard' "$f" -grep -c 'SEEDED-DEFAULT' "$f" +sed 's/\${[A-Z_][A-Z_]*}//g' "$f" | grep -c '{[A-Z_]\{2,\}}' || true # 0 — unresolved placeholder +grep -c '^ + Be terse. Lead with results. Use ASCII unless the requested artifact requires other text. Think short: keep internal reasoning minimal and do not narrate exploration. Search before opening large files. Prefer focused edits and parallel read-only checks. diff --git a/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-session.mjs b/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-session.mjs index b49898d..9f12213 100644 --- a/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-session.mjs +++ b/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-session.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup /** * think-short — SessionStart hook (self-contained, no plugin-root deps). * diff --git a/.codex/scripts/generate-compat.mjs b/.codex/scripts/generate-compat.mjs index 8dc9169..e2f1eb1 100644 --- a/.codex/scripts/generate-compat.mjs +++ b/.codex/scripts/generate-compat.mjs @@ -118,13 +118,62 @@ function transformText(value, { agent = false } = {}) { return text; } +// Agent files this marketplace's setup skills write into a target repo's agents dir. Add a +// name here whenever a skill starts installing another agent, or its prose keeps shipping the +// Claude extension to Codex users. +const SHIPPED_AGENT_FILES = ['intent-guard', 'task-tracker']; + +// A Codex agent file is `.toml`; a Claude one is `.md`. The two contiguous +// `.codex/agents/.md` rules in nativeWorkflowText only fire when the directory and +// the extension sit in one literal token, which is exactly what the sources most often +// do NOT do: +// 1. shell scripts hoist the directory into a variable -- `AGENTS_DIR=".claude/agents"` +// then `"$AGENTS_DIR/${agent}.md"`. The path rewrite lands on the assignment, the +// extension never does, so the mirrored script hunts for `.md` files under +// `.codex/agents/` and matches nothing (teams-setup toggle-team.sh enable/disable). +// 2. prose names the parked or literal form on its own -- ``.claude/agents/.md`. +// `disable` renames each member to `.md.disabled`` -- and the second half +// keeps the Claude extension while the first half is corrected. +// Both are scoped so a skill, reference or doc `.md` can never be reached: (1) only +// resolves variables literally assigned the agents dir, (2) only rewrites placeholder +// basenames (``, `{name}`, `${agent}`) on lines that already mention the agents +// dir, which leaves real filenames such as `references/intent-guard.md.template` alone. +function codexAgentExtension(text) { + const agentDirVars = new Set(); + for (const match of text.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=["']?\.codex\/agents\/?["']?\s*$/gm)) { + agentDirVars.add(match[1]); + } + for (const name of agentDirVars) { + text = text.replace(new RegExp(`(\\$\\{?${name}\\}?/[^\\s"'\`]+?)\\.md\\b`, 'g'), '$1.toml'); + } + text = text.replace(/^.*\.codex\/agents\/.*$/gm, line => line + .replace(/((?:<[A-Za-z0-9_-]+>|\{[A-Za-z0-9_-]+\}|\$\{[A-Za-z0-9_]+\}))\.md\b/g, '$1.toml') + .replace(/\bagent (`?)\.md\1/g, 'agent $1.toml$1')); + // Parking prose with no path on the line at all: "A roster member has neither `.md` nor + // `.md.disabled`". A stem-less `.md.disabled` is ALWAYS an agent -- a parked skill is always + // written with its stem, `SKILL.md.disabled` -- so that token is the anchor, and the bare + // `.md` beside it is rewritten only on a line already carrying it. That keeps genuine + // markdown talk (md-to-pdf's `.md` inputs, text-optimize's `.md` targets) untouched. + text = text.replace(/^.*`\.md\.disabled`.*$/gm, line => + line.replace(/`\.md(\.disabled)?`/g, (_, suffix) => '`.toml' + (suffix || '') + '`')); + // Agents this marketplace itself installs. Their prose names them bare, far from any path + // (`Still keeps \`intent-guard.md\``, ``excluding \`task-tracker.md\```), so neither the + // contiguous nor the line-scoped rule reaches them, yet each ships as `.toml`. The + // lookahead protects a plugin-internal source file of the same stem -- notably + // `references/intent-guard.md.template`, which is a template and stays `.md.template`. + for (const agent of SHIPPED_AGENT_FILES) { + text = text.replace(new RegExp(`\\b${agent}\\.md\\b(?!\\.template)`, 'g'), `${agent}.toml`); + } + return text; +} + function nativeWorkflowText(value, options = {}) { - return transformText(value, options) + return codexAgentExtension(transformText(value, options) .replaceAll('$code:', '$brewcode:') .replaceAll('$doc:', '$brewdoc:') .replaceAll('$tools:', '$brewtools:') .replace(/\.codex\/agents\/([^\s'"`]+)\.md\b/g, '.codex/agents/$1.toml') - .replace(/~\/\.codex\/agents\/([^\s'"`]+)\.md\b/g, '~/.codex/agents/$1.toml') + .replace(/~\/\.codex\/agents\/([^\s'"`]+)\.md\b/g, '~/.codex/agents/$1.toml')) .replace(/\b(?:BC|BD|BT)_PLUGIN_ROOT\b/g, '') .replace(/\b(?:BC|BD|BT)_ROOT\b/g, '') .replaceAll('CLAUDE_MD', 'AGENTS_FILE') @@ -318,7 +367,21 @@ This skill configures ambient prompt guidance only. It does not create, claim, o ## Intent and scope -Resolve \`status\`, \`on\`, \`off\`, \`level\`, \`edit\`, or \`reset\`, then choose project state at \`.codex/brewtools/manager/state.json\` or personal prompt overrides under \`~/.codex/manager/\`. Obtain confirmation before global writes. +Resolve exactly one canonical mode -- \`status\`, \`install\`, \`upgrade\`, \`enable\`, \`disable\`, \`uninstall\`, \`purge\` -- plus the extras \`level\` and \`edit\`, then choose project state at \`.codex/brewtools/manager/state.json\` or personal prompt overrides under \`~/.codex/manager/\`. Obtain confirmation before global writes. With no mode given, resolve \`status\` when state already exists and \`install\` otherwise. \`on\`, \`off\`, \`setup\`, \`remove\`, \`reset\`, \`create\`, \`update\` and \`cleanup\` are not modes: read them as the canonical verb, echo the canonical name back, and never print a retired alias as a command. + +## Modes + +| Mode | Effect | +|------|--------| +| \`status\` | Show hook registration, state source, level, override paths, and the no-security-wall limitation. Writes nothing, asks nothing. | +| \`install\` | Register the \`SessionStart\` and \`UserPromptSubmit\` handlers for this project and arm ambient prompt state. Idempotent: a second run leaves exactly one entry per event. | +| \`upgrade\` | Re-register the handlers from the current plugin version and restamp the version recorded in state, keeping the armed flag, the level and every override verbatim. It asks nothing, and it is the only thing that clears a stale version report. | +| \`enable\` | Arm ambient prompt state only. With nothing registered there is no handler to arm, so report not-installed and route the user to \`install\`. | +| \`disable\` | Disarm ambient prompt state only. Never touches registration: the handlers stay registered and no-op while disarmed. | +| \`uninstall\` | Deregister the handlers. State and prompt overrides are KEPT, so a later \`install\` returns to the same level and the same customized text. | +| \`purge\` | \`uninstall\` plus deletion of \`.codex/brewtools/manager/\` and, in personal scope, the personal prompt override. The only destructive mode: state exactly what will be deleted before running it. | +| \`level\` | Set balanced or strict prompt wording. State only; it does not change sandbox or authorization. | +| \`edit\` | Update or remove prompt overrides after showing the diff. Changes injected text only, never registration or arm state. | ## Behavior @@ -326,10 +389,8 @@ Resolve \`status\`, \`on\`, \`off\`, \`level\`, \`edit\`, or \`reset\`, then cho - \`++a\`: architecture-first guidance. - \`++rr\`: anti-regression review guidance. - \`++r\`: two-pass review guidance. -- \`on\` / \`off\`: enable or disable ambient prompt state only. -- \`level\`: set balanced or strict prompt wording; it does not change sandbox or authorization. -- \`edit\` / \`reset\`: update or remove prompt overrides after showing the diff. -- \`status\`: show hook registration, state source, level, override paths, and the no-security-wall limitation. + +The codewords are hook-driven: they fire on every prompt regardless of the mode state above. \`status\` explains them and \`edit\` customizes their text; no mode turns them off. The plugin uses \`SessionStart\` and \`UserPromptSubmit\` hooks. Preserve unrelated hook entries and review changed definitions with \`/hooks\`.`, 'brewtools/plugin-update': `# Codex plugin maintenance @@ -386,6 +447,22 @@ Inspect connection targets and the requested operation before connecting. Defaul Create exactly one Codex-owned file board; never create or mirror it under another assistant namespace. +## Modes + +Resolve exactly one canonical mode from \`status\`, \`install\`, \`upgrade\`, \`enable\`, \`disable\`, \`uninstall\`, \`purge\` -- a standalone token only, never a word that merely appears inside a sentence. With no mode given, a deployed board (\`.codex/features/board.md\` exists) resolves to \`status\` and an empty target resolves to \`install\`. \`init\`, \`on\`, \`off\`, \`setup\`, \`remove\`, \`reset\`, \`create\`, \`update\` and \`cleanup\` are not modes: read them as the canonical verb, echo the canonical name back, and never print a retired alias as a command. + +| Mode | Effect | +|------|--------| +| \`status\` | Read-only inventory of the target board. Writes nothing, delegates nothing, asks nothing. A parked \`.disabled\` twin is reported as parked, never as missing. | +| \`install\` | Run the phases below and deploy the board into the resolved target. | +| \`upgrade\` | Retrofit onto an already deployed board instead of the fresh-init phases. Recover the existing findings from the deployed artifacts rather than re-deriving them, ask for anything unrecoverable, write new files outright, and gate every edit of an existing file behind its own diff and confirmation. Never renumber and never delete. The metadata restamp is ungated and always runs -- it is the only thing that clears a stale version report. | +| \`enable\` | Restore parked machinery by renaming each \`.disabled\` twin back to the filename discovery keys on. Writes no content. | +| \`disable\` | Park the machinery by renaming the task-tracker agent, the \`task-board\` and \`task-spec\` skills and the task rule to \`.disabled\`. Bodies are untouched and every task is kept. | +| \`uninstall\` | Remove the generated agent, skills and rule plus any \`.disabled\` twin of them. \`.codex/features/**\` is KEPT: the generated pieces are machinery, the board is the user's data. | +| \`purge\` | \`uninstall\` plus deletion of \`.codex/features/**\`. Confirm first, stating the task counts that will be destroyed, and offer \`uninstall\` as the alternative that keeps them. | + +\`status\`, \`enable\`, \`disable\`, \`uninstall\` and \`purge\` replace the phases below; run the \`status\` inventory afterwards as the proof. Optimization of \`AGENTS.md\` is never reverted by any mode -- say so in the report and point at version history. + ## P0: resolve target and directive 1. Resolve the target repository, language, release marker style, exclusions, and whether optional AGENTS.md optimization is requested. @@ -420,17 +497,24 @@ Compress the requested text while preserving every load-bearing constraint, iden ## Resolve intent and target -1. Resolve \`install\` or \`remove\`, then project or personal scope. Show the exact target before mutation. +1. Resolve exactly one canonical mode from \`status\`, \`install\`, \`upgrade\`, \`enable\`, \`disable\`, \`uninstall\`, \`purge\`, then project or personal scope. Show the exact target before mutation. With no mode given, resolve \`status\` when the assets are already present and \`install\` otherwise. \`on\`, \`off\`, \`setup\`, \`remove\`, \`reset\`, \`create\`, \`update\` and \`cleanup\` are not modes: read them as the canonical verb and echo the canonical name back. -## Install or remove +## Modes -2. For install, copy the two native scripts and prompt described by \`assets/INSTALL.md\`, merge \`SessionStart\` and \`UserPromptSubmit\` entries by exact command string, and preserve unrelated hooks. -3. For removal, delete only matching command entries and the three copied assets; remove empty directories only when owned by this workflow. +| Mode | Effect | +|------|--------| +| \`status\` | Report scope, registered entries, copied asset paths and their recorded version. Writes nothing. | +| \`install\` | Copy the two native scripts and the prompt described by \`assets/INSTALL.md\`, merge \`SessionStart\` and \`UserPromptSubmit\` entries by exact command string, and preserve unrelated hooks. | +| \`upgrade\` | Re-copy the same assets from the current plugin version and re-register any entry that went missing, restamping the recorded version. Keeps the parked-or-active state as it was. | +| \`enable\` | Restore parked assets by renaming each \`.disabled\` twin back to the filename the handler resolves. | +| \`disable\` | Park the copied assets by renaming them \`.disabled\`, leaving the bodies byte-identical, so the registered handlers no-op. | +| \`uninstall\` | Delete only the matching command entries and the three copied assets, plus any \`.disabled\` twin of them; remove empty directories only when owned by this workflow. | +| \`purge\` | \`uninstall\` plus removal of the workflow's own directory and any personal-scope override. State what will be deleted first. | ## Verify and report -4. Validate JSON, run both hook scripts with valid and malformed fixtures, and confirm repeated install/remove is idempotent. -5. Report the changed paths and require review through \`/hooks\`. +2. Validate JSON, run both hook scripts with valid and malformed fixtures, and confirm a repeated \`install\`, \`upgrade\` or \`uninstall\` is idempotent. +3. Report the changed paths and require review through \`/hooks\`. Handlers use one command string, timeout values in seconds, and no matcher for \`UserPromptSubmit\`. This Codex variant does not install a sub-agent prompt-rewrite hook.` }; @@ -634,7 +718,12 @@ The future implementation prompt must begin with Step 0: re-assume [ROLE: MANAGE } const counterPath = path.join(targetDir, 'assets', 'think-short-prompt-counter.mjs'); fs.writeFileSync(counterPath, fs.readFileSync(counterPath, 'utf8').replace('const INTERVAL = 10;', 'const INTERVAL = 5;'), 'utf8'); - writeFile(path.join(targetDir, 'assets', 'think-short-prompt.md'), ` + // The prompt body is rewritten by hand here, so carry the source's release stamp across + // or the mirror silently ships an unstamped copy of a stamped asset. + const promptMeta = fs.readFileSync(path.join(sourceDir, 'assets', 'think-short-prompt.md'), 'utf8') + .match(/brewcode-meta: version=[0-9]+\.[0-9]+\.[0-9]+ generated_by=\S+/); + const promptMarker = promptMeta ? `` : ''; + writeFile(path.join(targetDir, 'assets', 'think-short-prompt.md'), `${promptMarker} Be terse. Lead with results. Use ASCII unless the requested artifact requires other text. Think short: keep internal reasoning minimal and do not narrate exploration. Search before opening large files. Prefer focused edits and parallel read-only checks. diff --git a/.codex/scripts/validate-compat.mjs b/.codex/scripts/validate-compat.mjs index ad09ec1..85fa94a 100644 --- a/.codex/scripts/validate-compat.mjs +++ b/.codex/scripts/validate-compat.mjs @@ -13,6 +13,9 @@ const EXPECTED_SKILLS = { brewdoc: ['md-to-pdf'], brewtools: ['manager-setup', 'task-board-setup', 'text-human', 'text-optimize', 'think-short-setup'] }; +// Canonical setup-skill mode set, in the mandated order. A skill declares the subset it +// supports in its source `argument-hint`; the Codex variant must document each one. +const CANONICAL_MODES = ['status', 'install', 'upgrade', 'enable', 'disable', 'uninstall', 'purge']; const MANUAL_NATIVE_SKILLS = new Set([ 'brewcode/convention', 'brewcode/rules', 'brewtools/manager-setup', 'brewtools/task-board-setup', 'brewtools/think-short-setup' @@ -111,9 +114,24 @@ for (const [plugin, [skillCount, agentCount]] of Object.entries(EXPECTED)) { const openai = path.join(skillsRoot, entry.name, 'agents', 'openai.yaml'); if (!fs.existsSync(openai)) fail(`${plugin}/${entry.name}: missing agents/openai.yaml`); const sourceSkill = path.join(pluginRoot, 'skills', entry.name, 'SKILL.md'); + const sourceText = fs.readFileSync(sourceSkill, 'utf8'); const manual = MANUAL_NATIVE_SKILLS.has(`${plugin}/${entry.name}`); if (manual && source.length < 900) fail(`${plugin}/${entry.name}: native workflow is too short to preserve source phases`); - if (!manual && source.length < fs.readFileSync(sourceSkill, 'utf8').length * 0.75) fail(`${plugin}/${entry.name}: transformed workflow lost substantive source content`); + if (!manual && source.length < sourceText.length * 0.75) fail(`${plugin}/${entry.name}: transformed workflow lost substantive source content`); + + // Mode parity. A MANUAL_NATIVE variant is hand-authored and does NOT track source + // SKILL.md edits, so a mode added or renamed upstream reaches nobody here and + // regeneration will never notice. The source argument-hint is the contract: every + // canonical mode it declares must be documented in the Codex variant. Checked for + // every skill, not only the manual ones -- a transformed variant satisfies it for + // free, which is exactly why the manual ones are the only place it can rot. + const sourceHint = sourceText.match(/^argument-hint:\s*(.*)$/m)?.[1] ?? ''; + for (const mode of CANONICAL_MODES) { + if (!new RegExp(`[[|]${mode}[\\]|]`).test(sourceHint)) continue; + if (!new RegExp('`' + mode + '`').test(source)) { + fail(`${plugin}/${entry.name}: Codex variant omits canonical mode \`${mode}\` declared by the source argument-hint`); + } + } const sourceRoot = path.join(pluginRoot, 'skills', entry.name); const targetRoot = path.join(skillsRoot, entry.name); @@ -235,4 +253,10 @@ if (errors.length) { process.stderr.write(`${errors.map(error => `- ${error}`).join('\n')}\n`); process.exit(1); } -process.stdout.write(`Codex compatibility validation passed: 3 plugins, 12 skills, 4 agents, ${retainedResources} mapped source resources.\n`); +// Derived from EXPECTED, never hardcoded: a literal count silently goes stale the moment a +// skill or agent is added or dropped, and the banner then lies about a run that did pass. +const totals = Object.values(EXPECTED).reduce( + (acc, [skills, agents]) => ({ skills: acc.skills + skills, agents: acc.agents + agents }), + { skills: 0, agents: 0 } +); +process.stdout.write(`Codex compatibility validation passed: ${Object.keys(EXPECTED).length} plugins, ${totals.skills} skills, ${totals.agents} agents, ${retainedResources} mapped source resources.\n`); diff --git a/.gitignore b/.gitignore index f0d1694..bc7b826 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ brewtools/skills/think-short/tests/e2e/results/ !.agents/plugins/marketplace.json # brewcode:semble -.claude/semble/.reminder-ts +.claude/semble/.prefetch-ts diff --git a/.sembleignore b/.sembleignore new file mode 100644 index 0000000..4271ca5 --- /dev/null +++ b/.sembleignore @@ -0,0 +1,205 @@ +# brewcode-meta: version=5.0.0 generated_by=brewcode:semble-setup +# brewcode:semble — managed file. Regenerate with +# semble-guidance.sh install --part ignore --force +# Edit it freely: any change makes it `user_modified`, and the installer then +# leaves it alone (a backup is taken before --force overwrites). +# +# WHY THIS FILE EXISTS +# semble 0.5.4 builds its ignore set in index/file_walker.py:_load_ignore_for_dir +# from exactly two files per directory — ./.gitignore and ./.sembleignore. It +# never reads the user's global excludes file (core.excludesFile / ~/.gitignore*) +# and never asks git. So a directory that is invisible to `git status` only +# because of a GLOBAL ignore rule is still fully indexed and still comes back as +# search evidence. `.claude/` is the common case. +# +# ORDER MATTERS, AND IT WORKS IN OUR FAVOUR +# _load_ignore_for_dir concatenates ./.gitignore lines FIRST and ./.sembleignore +# lines SECOND into one GitIgnoreSpec, and _is_ignored keeps the LAST pattern +# that matched. A rule here therefore overrides a conflicting rule in the +# sibling .gitignore — including a `!` un-ignore. That is the only lever for the +# bypass described next. +# +# THE NEGATION BYPASS (file_walker.py:_is_ignored, the `found` flag) +# A `!` un-ignore pattern whose text ends in a file extension — `!keep.png`, +# `!web/docs/package-lock.json`, `!*.json` — sets `found = True`, and `_walk` +# then yields the file **even though its suffix belongs to no content type**. +# So a .gitignore negation can drag binaries and lockfiles into the corpus that +# `--content` alone can never reach, and no change to the content set removes +# them. Measured on this workspace: one negated `package-lock.json` was 552 +# chunks (5.9% of the whole index) and two negated `.png` files added 143 chunks +# of decoded binary garbage. The two blocks below exist to re-ignore exactly +# that class of file. +# +# Only paths that are never project source belong here. Leaving noise indexed is +# cheaper than hiding something you wanted to find. + +# --- Claude Code working directories --------------------------------------- +# Scratch, vendored upstream copies, generated reports and machine state. +# NOT excluded, because they are project-authored: .claude/skills/, +# .claude/agents/, .claude/rules/, .claude/commands/, .claude/hooks/, +# .claude/scripts/, .claude/tasks/. +.claude/tmp/ +.claude/reports/ +.claude/backups/ +.claude/logs/ +.claude/semble/ +.claude/projects/ +.claude/history/ +.claude/file-history/ +.claude/shell-snapshots/ +.claude/statsig/ +.claude/todos/ +.claude/ide/ + +# --- Build output and caches semble does not skip by default ---------------- +# Its built-in list already covers .git .hg .svn __pycache__ node_modules +# .venv venv .tox .mypy_cache .pytest_cache .ruff_cache .cache .semble .next +# dist build .eggs — these are the ones it misses. +target/ +coverage/ +htmlcov/ +.gradle/ +.astro/ +.turbo/ +.parcel-cache/ +.nuxt/ +.svelte-kit/ +.output/ +.docusaurus/ +.terraform/ +.dart_tool/ +_site/ + +# --- Vendored dependency trees ---------------------------------------------- +# Conventional names for "someone else's source, copied in". Every one of these +# is upstream code the question is never about. +vendor/ +third_party/ +bower_components/ +.yarn/ +Godeps/ + +# --- Generated bundles ------------------------------------------------------ +*.min.js +*.min.css +*.bundle.js +*.map + +# --- Binary and non-text assets --------------------------------------------- +# GENERIC AND ZERO-RISK. None of these suffixes maps to a language, so with a +# plain .gitignore these lines are a no-op. They earn their place only against +# the negation bypass above: when a `!logo.png` slips one through, semble reads +# it with errors="replace" and indexes the mojibake. There is no repo in which +# decoded binary is the answer to a question. +*.png +*.jpg +*.jpeg +*.gif +*.bmp +*.tiff +*.webp +*.avif +*.ico +*.icns +*.svgz +*.pdf +*.woff +*.woff2 +*.ttf +*.otf +*.eot +*.mp3 +*.mp4 +*.wav +*.mov +*.webm +*.zip +*.gz +*.tgz +*.bz2 +*.xz +*.7z +*.rar +*.jar +*.war +*.class +*.so +*.dylib +*.dll +*.exe +*.bin +*.o +*.a +*.pyc +*.pyo +*.wasm +*.db +*.sqlite +*.sqlite3 +*.parquet +*.avro +*.pack +*.idx + +# --- Dependency lockfiles --------------------------------------------------- +# Machine-written dependency resolution. `pnpm-lock.yaml` is a .yaml and so is +# in the config bucket outright; the rest reach the corpus only through the +# negation bypass. No lockfile has ever been the answer to a "how does this +# work" question, and one of them was 5.9% of this workspace's index. +package-lock.json +npm-shrinkwrap.json +yarn.lock +pnpm-lock.yaml +bun.lockb +composer.lock +Gemfile.lock +Cargo.lock +poetry.lock +uv.lock +Pipfile.lock +pdm.lock +go.sum +gradle.lockfile +packages.lock.json + +# --- Per-repo exclusions ---------------------------------------------------- +# NOTHING BELOW THIS LINE SHIPS PRE-FILLED. The rules above hold in any repo; +# the two biggest sources of wasted result slots do not, because they are +# layout-specific and no static pattern can recognise them: +# +# 1. DUPLICATE TREES — the same file committed at two or three paths (a +# mirror for another agent runtime, a vendored copy of your own plugin, a +# generated port). Semble has no dedup: N copies means N chances to fill a +# result slot with the same text. On this workspace three mirrors of one +# plugin tree were 2202 chunks and took 15 of 80 result slots across 16 +# queries. +# 2. LONG CHANGELOGS — RELEASE-NOTES.md / CHANGELOG.md. Genuinely useful for +# "when did X land", genuinely ruinous for "how does X work": a 24k-line +# changelog here was 503 chunks and took 9 of 80 slots. Exclude it only if +# you do not ask semble history questions. +# +# `semble-guidance.sh install --part ignore` MEASURES this repo and appends what +# it found below, in a delimited "measured candidates" block: +# duplicate trees (byte-identical copies of files that live somewhere else) and +# paths carrying a disproportionate share of the corpus, with exact chunk counts +# when an index exists and byte share before that. Every proposal is written +# COMMENTED OUT and excludes nothing until you uncomment it - a wrong exclusion +# fails silently, so the scan proposes and you decide. Re-running only ever adds +# paths it has never proposed; your edits inside that block survive. +# +# To see the same measurement without installing: +# +# scripts/semble-project.sh candidates +# +# Then add them here, one per line, with a comment saying why. Root-anchor a +# path that must not match deeper copies of the same name (`/skills/` hits only +# the top-level directory; `skills/` would hit every `*/skills/` in the repo). + +# --- brewcode:semble measured candidates --- +# Measured in THIS repo by `semble-project.sh candidates` (exact chunk counts, 3203 files scanned). +# PROPOSALS ONLY - every line below is commented out and excludes nothing. +# Uncomment what you agree with; delete what you do not. A re-run only ever +# adds paths it has never proposed, so your edits here survive. +# /.codex/ duplicate-tree 13.6% 102 of 107 files are byte-identical copies of files under brewtools +# /RELEASE-NOTES.md heavy-file 5.6% one file is 6% of the corpus chunks +# --- end brewcode:semble measured candidates --- diff --git a/README.md b/README.md index 39638c8..ae72c6f 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Every spawn prompt carries six fields: ## Skills Reference -> **The `-setup` suffix** marks a skill you run once to install a mechanism -- afterwards you use what it produced (a generated skill, a hook, an MCP server), not the skill itself. Recurring tools you invoke every day keep bare names. Every `-setup` skill shares one mode vocabulary: `status | install | upgrade | enable | disable | uninstall | purge`, and no argument means `status` when installed, `install` when not. The one exception is `/brewcode:semble-setup`, which always defaults to `status` so a bare invocation never triggers a machine-level package install. A setup with no on/off state rejects `enable`/`disable` with an error rather than falling back to something else. +> **The `-setup` suffix** marks a skill you run once to install a mechanism -- afterwards you use what it produced (a generated skill, a hook, an MCP server), not the skill itself. Recurring tools you invoke every day keep bare names. Every `-setup` skill shares one mode vocabulary: `status | install | upgrade | enable | disable | uninstall | purge`, and no argument means `status` when installed, `install` when not. The one exception is `/brewcode:semble-setup`, which always defaults to `status` so a bare invocation never triggers a machine-level package install. All ten setups implement all seven verbs, via one of two mechanisms: a live config flag re-checked each invocation (semble, agent-deadline, agent-router, manager, docsync), or entry-file parking, where the filename discovery keys on is renamed `.disabled` with the body byte-identical (teams, superreview, task-board, think-short, memory-sync). > **All 26 skills are user-invoked only.** Every one carries `user-invocable: true` **and** `disable-model-invocation: true` in its frontmatter: the model never sees their descriptions and never auto-activates one. You type `/plugin:skill`, or nothing runs. This is a deliberate trade about context cost -- 26 model-visible descriptions would be a permanent tax on every request -- and these skills do not want auto-activation anyway: ten of them write real files into your repo after asking you real questions, and the rest are tools you point at a scope you choose. @@ -196,7 +196,7 @@ Every spawn prompt carries six fields: |-------|---------| | `/brewcode:setup-status` | Read-only cross-plugin dashboard: which setup skills are installed, stale, disabled, partial or missing here, plus the exact command to run for each. `disabled` outranks `partial`/`stale`, so a mechanism you turned off on purpose is never reported as broken. Runs nothing itself -- setups are interactive generators that spawn many subagents, and stacking several in one session degrades all of them | | `/brewcode:superreview-setup` | Generate a project-tailored deep-review skill: `QUICK` (default, `intent-guard` + mechanical gates) or `EXTENDED` (adds domain-expert fan-out, scope discipline, adversarial validation) depth, read from your prompt | -| `/brewcode:teams-setup` | Create and manage dynamic teams of domain-specific agents -- every team also gets a fixed review-only `intent-guard` member (not counted in team size). Modes `status`/`install`/`upgrade`/`uninstall`/`purge`, each taking an optional team `[name]`; `enable`/`disable` are rejected, a team has no armed state | +| `/brewcode:teams-setup` | Create and manage dynamic teams of domain-specific agents -- every team also gets a fixed review-only `intent-guard` member (not counted in team size). Modes `status`/`install`/`upgrade`/`enable`/`disable`/`uninstall`/`purge`, each taking an optional team `[name]`; `enable`/`disable` park/unpark each roster member's agent file | | `/brewcode:convention` | Extract etalon classes, patterns, architecture into convention docs | | `/brewcode:rules` | Prompt-driven rules management: status, create, improve, review | | `/brewcode:skills` | Prompt-driven skill management: status, create, improve, sync, review | diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index ba8d4e0..861bfeb 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -2,6 +2,176 @@ --- +## v5.1.0 (2026-08-09) + +> Docs: [semble-setup](https://doc-claude.brewcode.app/brewcode/skills/semble-setup/) | [setup-status](https://doc-claude.brewcode.app/brewcode/skills/setup-status/) | [superreview-setup](https://doc-claude.brewcode.app/brewcode/skills/superreview-setup/) | [teams-setup](https://doc-claude.brewcode.app/brewcode/skills/teams-setup/) | [e2e](https://doc-claude.brewcode.app/brewcode/skills/e2e/) | [rules](https://doc-claude.brewcode.app/brewcode/skills/rules/) | [convention](https://doc-claude.brewcode.app/brewcode/skills/convention/) | [skills](https://doc-claude.brewcode.app/brewcode/skills/skills/) | [brewcode hooks](https://doc-claude.brewcode.app/brewcode/hooks/) | [agent-creator](https://doc-claude.brewcode.app/brewcode/agents/agent-creator/) | [bash-expert](https://doc-claude.brewcode.app/brewcode/agents/bash-expert/) | [hook-creator](https://doc-claude.brewcode.app/brewcode/agents/hook-creator/) | [skill-creator](https://doc-claude.brewcode.app/brewcode/agents/skill-creator/) | [bc-rules-organizer](https://doc-claude.brewcode.app/brewcode/agents/bc-rules-organizer/) | [task-board-setup](https://doc-claude.brewcode.app/brewtools/skills/task-board-setup/) | [manager-setup](https://doc-claude.brewcode.app/brewtools/skills/manager-setup/) | [think-short-setup](https://doc-claude.brewcode.app/brewtools/skills/think-short-setup/) | [agent-deadline-setup](https://doc-claude.brewcode.app/brewtools/skills/agent-deadline-setup/) | [agent-router-setup](https://doc-claude.brewcode.app/brewtools/skills/agent-router-setup/) | [deploy](https://doc-claude.brewcode.app/brewtools/skills/deploy/) | [ssh](https://doc-claude.brewcode.app/brewtools/skills/ssh/) | [text-human](https://doc-claude.brewcode.app/brewtools/skills/text-human/) | [deploy-admin](https://doc-claude.brewcode.app/brewtools/agents/deploy-admin/) | [ssh-admin](https://doc-claude.brewcode.app/brewtools/agents/ssh-admin/) | [text-optimizer](https://doc-claude.brewcode.app/brewtools/agents/text-optimizer/) | [brewtools prompt injection](https://doc-claude.brewcode.app/brewtools/prompt-injection/) | [docsync-setup](https://doc-claude.brewcode.app/brewdoc/skills/docsync-setup/) | [memory-sync-setup](https://doc-claude.brewcode.app/brewdoc/skills/memory-sync-setup/) | [md-to-pdf](https://doc-claude.brewcode.app/brewdoc/skills/md-to-pdf/) | [my-claude](https://doc-claude.brewcode.app/brewdoc/skills/my-claude/) | [full-setup](https://doc-claude.brewcode.app/full-setup/) + +> **Two themes.** First, `semble-setup` stops nagging and starts fetching: the two advisory hooks that never once produced a search are deleted, and a prefetch hook that injects real file paths takes their place. Second, every generated and shipped artifact in the suite carries the same four metadata keys — and, more to the point, every `upgrade` can now actually *clear* the stale verdict it is prescribed for. Before this release five setups reported `stale` forever after a successful `upgrade`, and a semble install still shaped like v1 reported `ready` with `nextStep: none`, so nobody mid-migration was ever told to migrate. + +### Action required for existing installs + +| If you have | Do this | Why | +|-------------|---------|-----| +| `semble-setup` installed in a project | `/brewcode:semble-setup install` once, per project | Migrates you off the two retired hooks and reconciles the settings entries. **There is no manual step** — `install` deletes `semble-reminder.mjs`/`semble-explore.mjs`, purges their settings rows and wires the three live hooks | +| a `semble-setup` install and disk to reclaim | `rm -rf ~/Library/Caches/semble` by hand | Pre-5.0.1 prefetch runs left a stray cache root there, tens of MB depending on repo size. No mode deletes it; the supported cache lives elsewhere | +| `manager-setup`, `task-board-setup`, `superreview-setup`, `memory-sync-setup` or `e2e` installed | run that skill's `upgrade` once | Each one's `upgrade` used to leave the version stamp untouched, so `setup-status` reported `stale` after a successful upgrade, forever. All five now restamp unconditionally | +| `think-short-setup` installed alongside `semble-setup` | `/brewtools:think-short-setup upgrade` | The task hook's family list still named the two hooks retired in 5.0.0 and had never heard of `semble-prefetch.mjs`/`semble-stats.mjs`, so it did not recognise them as family and did not yield to them | +| `docsync-setup` installed | `/brewdoc:docsync-setup upgrade` | Writes the three provenance keys into `config.json` — the only place `setup-status` can read a docsync version from. `enabled`, `threshold_days` and `exclude` are preserved verbatim, so a disabled install stays disabled | +| `agent-deadline-setup` or `agent-router-setup` installed | run that skill's `upgrade` once, per scope | Their configs (`.claude/agent-deadline.json`, `.claude/brewtools/agent-router.json`) gain the three provenance keys in this release. A pre-5.1 config has none, so `status` and `setup-status` report it as unknown/`stale`; only a writing mode (`upgrade`) adds them. Behaviour keys are read back and preserved | +| a `superreview-setup` install | `/brewcode:superreview-setup upgrade` | Repairs retroactively: the stack reference is now derived from the installed tree instead of falling back to `python.md` on every project | +| a team from `teams-setup` | nothing to do | `enable`/`disable` are new capabilities, not migrations. Running `upgrade` back-fills a pre-5.0 `team.md` header if you want the version columns | + +### all plugins + +#### Added + +- **Unified artifact metadata.** Four canonical field names, one order, everywhere: `doc_type` (`llm` | `user` | `skip`, unquoted, `.md` frontmatter only, never in JSON), `version "X.Y.Z"`, `generated_by ":"`, `last_updated "YYYY-MM-DD"` — the last three always quoted, in that order, after the file's own keys. JSON artifacts carry the same three keys as top-level snake_case, in every writing mode, without `doc_type` +- **Five metadata carriers, one vocabulary.** JSON top-level keys; `.md` YAML frontmatter; a `// brewcode-meta:` / `# brewcode-meta:` one-liner on line 2 of byte-copied `.mjs`/`.sh`; a header table (`| Version |`, `| Generated by |`, `| Last update |`) in `team.md`; `` on line 1 of byte-copied `.md` +- **Versions always come from `.claude-plugin/plugin.json`** — never hardcoded, never written as the literal `unknown`. Two mechanisms, mutually exclusive per file: baked at release (30 assets in `bump-version.sh`'s `STAMPED_FILES`) or substituted at install (exactly three single-brace tokens `{PLUGIN_VERSION}`, `{GENERATED_BY}`, `{LAST_UPDATED}`). A file uses one or the other, never both +- **Six version writers in two shapes.** Four refuse to write at all when the version will not resolve (`teams-setup`/`e2e` `detect-mode.sh`, `superreview-setup/generate.sh`, `semble-common.sh`) rather than stamp a fake. Two use a sentinel: `memory-sync-setup/generate.sh` treats `unknown` as an internal marker and hard-fails on it before writing; `brewtools/hooks/lib/manager-state.mjs` omits the `version` key instead of failing, because it is the off-switch for the manager HARD wall and an abort would lock the user behind an enabled wall +- **Version stamps for teams and agents.** `team.md` carries `| Version |` in its header table, `detect-mode.sh` prints the resolved `PLUGIN_VERSION`, and `setup-status` reads it back. The 8 shipped plugin agents — `brewcode/agents/{agent-creator,bash-expert,bc-rules-organizer,hook-creator,skill-creator}.md` and `brewtools/agents/{deploy-admin,ssh-admin,text-optimizer}.md` — are baked at release as 8 of the 9 `fmd` entries in `STAMPED_FILES` (the ninth is `setup-status/references/artifact-metadata.md`), while the generated `intent-guard` takes the other mechanism and is substituted at install from the `{PLUGIN_VERSION}`/`{GENERATED_BY}`/`{LAST_UPDATED}` tokens in `references/intent-guard.md.template` +- **`references/artifact-metadata.md` under `setup-status` is the normative document** (715 lines, 9 sections): the four fields, the five carriers, the three mechanisms, the writer/reader split, and an explicit exemption list so audits stop re-flagging the artifacts that are unstamped on purpose (`agent-router-setup/assets/judge-prompt.md`, the task-board `SPEC_TEMPLATE.md`/`DESIGN_TEMPLATE.md`, the user-authored manager prompts, and the `.codex/` mirror pinned at `4.0.6+codex.`) + +#### Changed + +- **Version resolution is split by role.** A writer resolves from `.claude-plugin/plugin.json` **by self-location** — a cache path is forbidden — and aborts rather than stamping `unknown`; a reader takes the cache-dir basename first, then that root's manifest. Thirteen retired field spellings and eight retired placeholder spellings are tabulated so they stop reappearing +- **Skill Bash blocks resolve the plugin root from `CLAUDE_SKILL_DIR`, not `CLAUDE_PLUGIN_ROOT`.** The old `ROOT="${CLAUDE_PLUGIN_ROOT:-...}"` prelude could never work: the token is a prompt-level text substitution, is never exported into a skill's Bash tool, and a brace-modifier form is not matched by the substitution regex at all — so the expression reached the shell verbatim, the fallback always won, and the script named the *installed* plugin. A `--plugin-dir` dev run resolved to the wrong root every time. Replaced by `$SD/../../.claude-plugin/plugin.json` with a cache glob as last resort and an explicit empty-root abort + +#### Fixed + +- **A whole defect class: a remedy that could not clear its own verdict.** Every earlier release verified that an artifact is stamped at install; none verified that the stamp can ever change. Five of the ten `-setup` skills reported `stale` and prescribed `upgrade`, and `upgrade` could not fix it. `memory-sync-setup` was a closed loop whose only documented exit destroyed the user's own edits. All ten now close the loop: install at X, bump, `stale`, `upgrade`, `current`, with the artifact body byte-identical and a second `upgrade` idempotent. The new normative rule is that **a remedy must be able to clear the verdict it follows**, and `setup-status` names the findings that have no clearing mode instead of pairing them with a command that cannot help + +### brewcode + +#### Removed + +- **semble-setup: the two advisory hooks are deleted, not deprecated.** `semble-reminder.mjs` (349 lines, PreToolUse on `Bash`/`Grep` plus a UserPromptSubmit row) and `semble-explore.mjs` (150 lines, SubagentStart on `Explore`) both told the model it *should* search semantically. Delivery was verified independently — the text reached the model every time. Conversion was **0 of 18** on the main channel and **0 of 11** on the subagent channel. Not "low"; zero. Advice that is delivered and ignored is a token cost with no output, so both were removed rather than tuned + +#### Added + +- **semble-setup: `assets/semble-prefetch.mjs` (UserPromptSubmit) replaces them by doing the search itself.** It gates on the prompt, distils a query from it, runs `uvx --from 'semble[mcp]==0.5.4' semble search ... -k 3 --max-snippet-lines 0` and injects the top three `file_path:start_line` candidates with their provenance and a directive — **paths only, no snippets**. The snippet-carrying arm was measured and rejected: it converted 2 of 6 and produced *zero* tool calls in 2 of 6 sessions, i.e. the model answered from the snippet. Paths-only converted 5 of 6 and used fewer tool calls than control in 5 of 6 questions +- **semble-setup: what prefetch buys, measured, and what it does not.** All 18 answers were correct in all three arms. Prefetch buys turns and citation precision; it does **not** buy correctness. The gate (lexical INTENT/DOMAIN/REPOREF signals with SELF/LITERAL/ENUM/TASKREF suppressors, RU+EN) fires on 36% of 61 real user prompts at precision 55% / recall 71% / F1 0.62. The query distiller lifts hit@3 to 11 of 16 from 9 of 16 and MRR to 0.674 from 0.398 (paired: 8 wins, 3 losses, 5 ties) +- **semble-setup: prefetch is bounded by construction** — 30 s throttle, 3 s search timeout, 10 min cooldown after a miss (60 s after a timeout), `SIGKILL` on overrun, and a cold-index short-circuit that spawns no child at all (`why=cold-index`, keyed on `chunks.json`/`metadata.json`/`bm25_index`/`semantic_index`). The repo hash is always recomputed from `realpath(cwd)` and never trusted from `state.json` +- **semble-setup: `assets/semble-stats.mjs` (PostToolUse + PostToolUseFailure) — a telemetry observer.** It always returns `{}` and changes nothing; it appends to `.claude/semble/telemetry.jsonl`, counting semble calls against search-shaped `Bash`/`Grep`/`Glob` use (grep/egrep/fgrep/ugrep/rg/ag/ack/find/bfs) and `Read` opens. The want-table is now **three hook files wired as four settings entries**, all at `timeout: 5` (seconds) +- **semble-setup: `semble-status.sh --section telemetry`** (with `--sid ID` / `--last N`, deliberately excluded from `all`) reports gate/prefetch/nudge/call/search/open counters, prefetch conversion at session and path level, ms median and max, and the share of search-shaped tool use that went through semble +- **semble-setup: a managed `.sembleignore`.** New `assets/sembleignore.template` (196 lines) plus `semble-guidance.sh install --part ignore`. It excludes Claude Code scratch dirs (`.claude/tmp/`, `reports/`, `backups/`, `logs/`, `semble/`, `projects/`, `history/`) while deliberately keeping `.claude/{skills,agents,rules,commands,hooks,scripts,tasks}` indexed, plus build caches semble misses, vendored trees, generated bundles, ~50 binary suffixes and 15 lockfiles +- **semble-setup: `semble-project.sh candidates` measures the repo instead of guessing.** Duplicate trees (>= 5 files, >= 90% duplicated, >= 1% weight), heavy dirs (>= 15%) and heavy files (>= 3%), from exact `index/chunks.json` counts when an index exists and byte share otherwise. `--part ignore` appends the result **commented out** in a delimited block that only ever grows, so the user decides what to exclude. On this workspace (3203 files scanned): `/.codex/` at 13.6% with 102 of 107 files byte-identical to `brewtools`, `RELEASE-NOTES.md` at 5.6% — three mirrors of one plugin tree were 2202 chunks taking 15 of 80 result slots across 16 queries, and a 24k-line changelog was 503 chunks taking 9 of 80 +- **setup-status: a stamp reader (Phase 2a).** A `STAMPS` heredoc of `plugin|path|expected-owner` drives 17 carrier lines across all ten setups, with two hard assertions (`TOTAL == 17`, per-plugin 3 brewcode / 11 brewtools / 3 brewdoc) so a silently dropped row fails the dashboard instead of reporting `CURRENT`. Verdicts: `PLACEHLD`, `LEGACY-FMT`, `LEGACY-NONE`, `BEHIND`, `AHEAD`, `CURRENT`, `OWNER-WRONG` (another skill's name is on the file), `OWNER-NONE`. A `.disabled` filename is retried automatically; only `version` and `generated_by` are read +- **setup-status: Phase 1b probes all ten setups**, not five, with a mechanism column and a `no-key` third token. It documents the opposite `enabled`-key defaults explicitly — `agent-deadline` is opt-in (absent key = inert) while `docsync`/`agent-router` are opt-out (absent key = enabled; `manager` has no `enabled` key at all — its off-switch is `.hard` in `.claude/brewtools/manager/state.json`) — which is exactly the asymmetry a dashboard gets backwards +- **teams-setup: `scripts/toggle-team.sh [--dry-run]`** backs the canonical `enable`/`disable`. It parses the `## Agents` table, parks members as `.md.disabled`, and skips `intent-guard` (`SKIP:intent-guard (shared with superreview-setup)`) so the review-only member stays live. Prints `WOULD:`/`MOVED:`/`NOOP:`/`MISSING:` plus counters, and exits 1 when a member has neither spelling on disk +- **superreview-setup: the canonical set is complete** — `enable | disable | uninstall | purge` added to `generate.sh` and the verb-routing table. `enable`/`disable` park `SKILL.md` <-> `SKILL.md.disabled`, keeping `references/`, `.template-baseline/` and `intent-guard.md` on disk; `purge` additionally deletes `.claude/reports/*_superreview/`. `intent-guard` survives all seven verbs +- **rules: `create-specialized [paths]` takes an explicit `paths` glob.** `default_paths_for_prefix()` supplies curated globs for test/e2e/doc/ci/sql/api/ui/infra prefixes and a prefix-derived guess otherwise, printed with a confirm-me warning; `SKILL.md` requires an `AskUserQuestion` about the repo slice first. `["**/*"]` is hard-refused — a "specialized" rule matching everything auto-loads into every request, which is the opposite of specialized +- **rules: `validate` checks frontmatter**, not just the table header — `paths`, `description`, unquoted `doc_type: llm`, quoted `version` X.Y.Z, quoted `last_updated` YYYY-MM-DD, plus the repo-wide-paths rejection +- **convention: `check_doc()` fails a doc that exists but has no frontmatter** or is missing a metadata key, naming the key on stderr. `setup` now returns `{path, version, generated_by, last_updated}` + +#### Changed + +- **semble-setup: the pin moves `0.5.2` -> `0.5.4`.** No re-index is forced: `cache_version` is still `1`, and an index built by `0.5.2` was read by `0.5.4` and back with every file under `/index/` byte-identical and `metadata.json.time` unchanged. `src/semble/index/` and `src/semble/mcp.py` are byte-identical between the two sdists, so the corpus, the ignore handling, the cache key and both MCP tool shapes are unchanged +- **semble-setup: the pin-resolvability probe is `semble --version`, selected by the pin.** `--version`/`-V` reached semble's CLI dispatch set in `0.5.4`, costs the same (0.26 s warm, 2.5 s cold) and prints the resolved `X.Y.Z`, so the probe proves *which* build was served rather than merely that something resolved. `sc_semble_probe_arg` keeps the always-safe `--help` for any `SEMBLE_PIN_VERSION` below `0.5.4`, where `--version` is unrecognised argv and starts the blocking stdio server +- **semble-setup: never write a `!` negation line into `.sembleignore`.** semble 0.5.4's `file_walker.py` has a negation bypass: a `!` line whose pattern ends in a file extension sets the walker's `found` flag and the extension filter is skipped entirely. That is how one negated `package-lock.json` (552 chunks, 5.9% of the index) and two negated `.png` files (143 chunks of decoded binary) reached an index. The lever the template uses instead is ordering — `.sembleignore` is concatenated *after* `.gitignore` and the last match wins, so a plain re-ignore line beats a `.gitignore` negation without tripping the bypass. The shipped template contains no `!` lines and its per-repo section ships empty +- **semble-setup: the settings merge reconciles instead of appending.** `SG_WANT_TABLE` (`event, matcher, script, timeout`) is the single source of truth; stale entries are purged on the `(event, matcher, path)` triple, an emptied event is deleted rather than left as a `"PreToolUse": []` husk, and `semble-guidance.sh` tracks all five basenames it has ever owned against the three live ones +- **semble-setup: the advertised corpus matches reality.** The report and the injected CLAUDE.md block printed a stale `corpus: code config`; the actual `SEMBLE_CONTENT_ARGS` has been `code docs config` and did not change here — what changed is that the docs stopped misreporting it, which is why nobody knew markdown was searchable. `uncovered:` now names `.json/.json5/.csv/.tsv/.psv` (no content type reaches them) and `.mdx/.txt` (absent from `_EXTENSION_TO_LANGUAGE`), and `references/language-coverage.md` records the decision to keep `config`: 53 of 9307 chunks (0.57%) across 40 files, it made two benchmark questions answerable, and it is not what pulled the lockfile in +- **semble-setup: `assets/semble-first.md.template` carries a measured tool-selection table** — semble wins behaviour and vocabulary-mismatch questions 8 of 9, loses exhaustive enumeration 2 of 5, `rg` wins exact identifiers — and states that `.json`'s absence is load-bearing and that semble does not deduplicate an identical file committed at several paths +- **semble-setup: `sc_timeout_watch` measures its deadline on wall clock** (`$SECONDS`) instead of summed sleeps, so it can no longer fire early; it now fires within `[secs, secs+1)` with a <= 250 ms poll and a 100 ms TERM->KILL grace +- **semble-setup: the awaiting-reload message stopped being wrong.** `semble-session.mjs` used to tell you to run `resume` first; semantic search is usable immediately, so it prints the exact `mcp__semble_code__search` call and warns that the first call rebuilds the index +- **semble-setup: managed-file install/remove refactored** onto generic `install_managed`/`remove_managed` with `meta` and `metaline` strip modes, adding a metadata-only re-sync that needs no `--force` and no backup, collapsing a net-zero-byte change to `unchanged`, and simulating `--part ignore` dry-runs against a temp dir so they never announce a phantom change +- **setup-status: the `installed (version unknown)` state is retired.** Its replacements are specific: `stale (legacy, unstamped)`, `stale (legacy stamp)`, `stale (drift)`, `stale (behind X.Y.Z)`, plus a distinct `version unknown (plugin asset missing)` for the case where the comparison source itself is absent +- **setup-status: Phase 3 classify rewritten from 7 rules to 11, with `disabled` evaluated ahead of `missing`** — a deliberately parked mechanism was being reported as absent +- **setup-status: the report leads with a count** (`N of 10 setups are behind the installed plugin`), gains a Version column with explicit formats (`X.Y.Z`, `X.Y.Z -> A.B.C`, `legacy -> A.B.C`, `unstamped -> A.B.C`, `--`), a mandatory closing run-list, and a *Remedy check* clause on every roster row naming the code that proves that row's `upgrade` restamps. Phase 0's version probes carry `|| true` so `set -euo pipefail` cannot abort the whole dashboard +- **superreview-setup: `upgrade` restamps unconditionally.** A `_restamp_meta()` loop runs over every live artifact after the delta report instead of being gated on IDENTICAL/DIFFERS. It refreshes only version/generated_by/last_updated, preserves an existing `doc_type`, seeds `doc_type: llm` when absent, and byte-compares the body. `GENERATED_AT` is retired for `{PLUGIN_VERSION}`/`{GENERATED_BY}`/`{LAST_UPDATED}`, none env-overridable, with a hard failure on a non-`X.Y.Z` version +- **superreview-setup: the intent-guard state machine migrates instead of re-emitting.** `_ig_usable` becomes `_ig_kind()` returning ABSENT/BROKEN/CURRENT/LEGACY/FOREIGN, and a new `_ig_migrate()` restamps a LEGACY agent in place — four frontmatter keys plus the tail anchor from the substituted template, body preserved, four post-conditions that abort on failure — printing `INTENT_GUARD: MIGRATED `. `validate` now iterates every `references/*.md` rather than a fixed four-file list and treats LEGACY as an error +- **teams-setup: `verify-team.sh` learns two states it could not see.** A metadata layer (`check_agent_meta()`: conforming / malformed / pre-standard, where pre-standard is a WARN not a FAIL) plus per-agent `DISABLED`/`MISSING` verdicts, a `DISABLED_AGENTS:N` line and `VERIFY: PASS (team DISABLED ...)`. `detect-mode.sh` accepts all seven canonical verbs and resolves the version by self-location, hard-failing rather than stamping `unknown` +- **e2e: staleness is `config.version != PLUGIN_VERSION`,** not `lastSetup > 30 days`. `config.json` drops `lastSetup` for the three provenance keys; a missing stamp reports `stale (legacy, unstamped)` and never `unknown`. Every written artifact — config, `e2e-rules.md`, the `e2e-*` agents, `e2e-conventions.md` — carries the four keys, and a new unconditional re-stamp step touches metadata only, leaving bodies byte-identical +- **skills: the metadata contract renames `updated:` to `last_updated:`** and bans `updated`/`updatedAt`/`lastUpdated`, pointing at `setup-status/references/artifact-metadata.md` section 8 + +#### Fixed + +- **semble-setup: a v1-shaped install reported `ready` with `nextStep: none`.** The verdict now downgrades `ready` -> `partial` when a retired hook is still on disk, when a stale settings entry survives, or when `wired !== want`, and the human line prints `hooks n/ wired`. A second, independent downgrade covers stale artifact stamps (`artifacts at X, plugin at Y` -> run `upgrade`). Until this release the dashboard actively told people mid-migration that they had nothing to do +- **semble-setup: `upgrade` always reported `changed`** on a zero delta; a second project on the same machine never got a `state.json` at all, because the MCP server is user-scoped and the script short-circuited on it; and `status` showed no version. `upgrade` now has an unconditional project half, and `semble-mcp.sh add` writes the per-project checkpoint itself when the user-scope registration is already correct +- **semble-setup: a pipx or venv install was mislabelled `uvx-ephemeral`** — `sc_semble_tool_version` falls back to `semble --version` (5 s bound) when `uv tool list` reports nothing but a `semble` is on PATH +- **semble-setup: `sc_plugin_version()` hard-fails instead of stamping a placeholder,** and `sc_state_patch` migrates `lastVerifiedAt` -> `last_verified_at` (date only) and drops `updatedAt` +- **semble-setup: stale upstream citations refreshed against the `0.5.4` source** — `clear index` is `_clear_indexes` at `cli.py:147-163`, the savings CLI is `cli.py:166,252,280`, and `semble clear orphans` (new in `0.5.4`, `cli.py:176-199`) is documented as narrower but still not per-repo +- **setup-status read every healthy semble project as `stale`.** `.sembleignore` was in the byte-comparison set even though the installer appends a candidates block to it after copying. The only prescribed remedy was `--force`, which destroyed the user's own exclusions. It is out of the comparison set; its presence is still checked and its stamp is still read +- **setup-status: runtime state is barred from the `STAMPS` table,** and `session-start.mjs`'s TTL marker was renamed `checkedAt` -> `fetchedAtMs` so ephemeral runtime state can no longer trip the legacy-format detector. Old caches simply miss and refetch +- **superreview-setup `upgrade` re-stamped the wrong stack reference.** `STACK_REF` fell back to its default on every project, so `python.md` was restamped whatever the project was and the real stack reference stayed on the old version forever. The stack is now derived from the installed tree, so an existing install is repaired retroactively +- **superreview-setup restored a deleted artifact from the *substituted* copy,** silently baking placeholder defaults (`this project`, `general-purpose`) into a live file — and `validate` passed it. Restoration now copies the raw template and says so (`MISSING -> restored RAW`) +- **superreview-setup: a `${HOME}` in a Phase 3 evidence command destroyed the tailoring.** `{[A-Z_]+}` matches the `{HOME}` inside `${HOME}`, so `_scan_tokens()` classified a perfectly good tailored agent as BROKEN and **recreated it**. Fixed as strip-then-match (`sed 's/\${[A-Z_][A-Z_]*}//g'` first). The same false positive is fixed in `teams-setup/SKILL.md` Step 2, where the `CORRUPT` verdict prescribes `rm -f` and a re-emit — there a false positive deleted a hand-tailored agent +- **teams-setup Step 4 could never pass.** `grep -c 'TEMPLATE HEADER'` also matched the prose that legitimately names the marker, so every healthy install hit the STOP gate and re-ran Step 3. Both counts are now anchored (`^` trailer.** Removed, replaced by real frontmatter; `text-optimizer` gained the same frontmatter +- **deploy: the post-release step ran `bash ` with the literal angle brackets.** It now assigns `POST_SCRIPT="..."` and runs `bash "$POST_SCRIPT"` + +### brewdoc + +#### Added + +- **docsync-setup: `enable` / `disable`** flip one `enabled` key in `.claude/docsync/config.json`. All three hooks re-read it per invocation and return empty immediately, so the pause takes effect with no session restart while `settings.json`, the hook files, `state.json` and every `last_updated` stay exactly where they are +- **docsync-setup: `config.json` leads with three provenance keys,** which is what `/brewcode:setup-status` reads a docsync version from; install aborts if the version cannot be resolved. The hooks carry a `brewcode-meta` stamp on line 2 so an installed copy can be `cmp`'d byte-for-byte against the plugin's +- **memory-sync-setup: `enable` / `disable` / `purge` / `restamp`.** `enable`/`disable` rename `SKILL.md` <-> `SKILL.md.disabled` — the roster reappears next session and the three references plus every SELF-SYNC hand-edit stay byte-identical. `purge` deletes the whole `.claude/skills/memory-sync/` plus any `.memory-sync-emit.*` staging left by a crashed emit; `uninstall` is now manifest-scoped and lists user-added files under `KEPT:` +- **memory-sync-setup: `status` reports what it actually found** — `PLUGIN_VERSION`, `INSTALLED=yes|parked|no`, `STAMP_FORMAT=frontmatter|legacy|none`, the five metadata values, a `PARKED - ` verdict prefix and a `STALE-LEGACY (n drifts)` verdict for pre-5.0 tail stamps +- **my-claude: every generated `.md` under `.claude/brewdoc/my-claude/` opens with provenance frontmatter,** version resolved from the manifest; regeneration refreshes the three quoted values and preserves a hand-set `doc_type` + +#### Changed + +- **docsync-setup: the Stop gate re-applies scope and reports undated docs.** `exclude` globs and `doc_type: skip` are evaluated again at gate time, so marking a doc `skip` mid-session silences it; and the gate lists `no last_updated: ...` separately from `stale (>Nd): ...` instead of skipping dateless files +- **docsync-setup: frontmatter convention settled** — `last_updated` and `sync_procedure` quoted, `doc_type` bare; documents in either spelling keep parsing. `upgrade` refreshes only the three provenance keys, preserving `enabled`, `threshold_days` and `exclude` verbatim, so a disabled install stays disabled, and first-run detection gains `INSTALLED (DISABLED)` as a third state that `install` refuses to overwrite +- **memory-sync-setup: provenance moved from a tail HTML comment to YAML frontmatter** (five keys, written by an awk stamper), and the stamped version is the brewdoc plugin version resolved from the manifest instead of a hardcoded `VERSION="1.0.0"`. `validate` fails on a stale stamp and names `restamp` as the remedy — explicitly not `emit`, not `MEMORY_SYNC_FORCE=1` +- **memory-sync-setup: the generated `/memory-sync` skill ships `disable-model-invocation: true`,** matching the rest of the suite +- **md-to-pdf: `.claude/md-to-pdf.config.json` writes carry the three provenance keys** on both the engine-choice and styles paths, through an explicit writer with JSON validation + +#### Fixed + +- **memory-sync-setup: an install one version behind had no route to a fresh stamp.** `upgrade` refreshed the tables, `validate` then hard-failed on the stale version, and the only documented escape was `MEMORY_SYNC_FORCE=1 emit` — which destroys exactly the SELF-SYNC hand-edits `upgrade` exists to preserve. `restamp` closes the loop: it diffs the body before and after and refuses to write unless the only change is the metadata keys +- **memory-sync-setup: `upgrade` never re-copied the three emitted references,** so `setup-status`' `cmp` reported DIFFERS forever with no mode that could clear it. `refresh_refs` re-copies where provably lossless (`REF RECOPIED:`), restores a missing one, and reports `REF DIFFERS:` rather than overwriting otherwise. A pre-5.0 tail stamp is parsed and migrated in the same call +- **docsync-setup: a doc that is only read and carries no `last_updated` produced no signal at all** — the watch hook is silent by design and the gate skipped dateless files. It is now listed by the gate. Separately, `doc_type` was a raw string compare, so an unrecognised or differently cased value was not normalised; all three hooks share `docTypeOf()` (trim, lowercase, absent/unknown -> `user`, only `skip` removes a file) +- **docsync-setup: `frontmatter` mode omitted `sync_procedure`,** so it produced documents that `sync` could not follow; and `enable`/`disable` demanded the metadata trio be byte-identical instead of adding it when absent +- **md-to-pdf: the styles path rewrote the config wholesale and silently dropped the saved `engine` and `pygments_theme`,** resetting the engine choice on every style change. The new merge carries them over + +### docs and Codex mirror + +#### Changed + +- **`semble-setup.mdx`, `setup-status.mdx` and `full-setup.mdx` reconciled** against the retired hook pair, the three live hooks wired as four entries, the new verdict downgrades and the metadata standard +- **`references/output-contract.md` corrected** — pin `0.5.4` in three places, the `hooks n/4 wired` legend rewritten to the four current entries, `corpus:` and `uncovered:` fixed. `references/hooks-roadmap.md` marks its on-disk-state section OBSOLETE, naming `SG_WANT_TABLE` plus `assets/INSTALL.md` as the source of truth; `references/engine-landscape.md` closes its stale-pin defect and records the bidirectional `0.5.2` <-> `0.5.4` cache compatibility +- **`.gitignore`:** the semble marker line follows the hook rename, `.claude/semble/.reminder-ts` -> `.prefetch-ts` + +#### Fixed + +- **Codex mode parity was worse than measured.** `manager-setup` documented three retired aliases (`on`, `off`, `reset`), `task-board-setup` documented none of the seven canonical modes, and `think-short-setup` documented only `install`/`remove`. All three are at parity, and `validate-compat.mjs` gains a mode-parity gate: a canonical mode declared in a source `argument-hint` and missing from the Codex variant now fails validation +- **The `.codex` generator shipped an unstamped mirror of a stamped asset.** `think-short-prompt.md` is hand-rewritten for Codex and was emitted with a bare `` marker while its source carries a `brewcode-meta` stamp, so the mirror had no version at all. The generator now carries the source's stamp into the marker + +--- + ## v5.0.0 (2026-08-08) > Docs: [setup-status](https://doc-claude.brewcode.app/brewcode/skills/setup-status/) | [superreview-setup](https://doc-claude.brewcode.app/brewcode/skills/superreview-setup/) | [teams-setup](https://doc-claude.brewcode.app/brewcode/skills/teams-setup/) | [semble-setup](https://doc-claude.brewcode.app/brewcode/skills/semble-setup/) | [e2e](https://doc-claude.brewcode.app/brewcode/skills/e2e/) | [skills](https://doc-claude.brewcode.app/brewcode/skills/skills/) | [convention](https://doc-claude.brewcode.app/brewcode/skills/convention/) | [rules](https://doc-claude.brewcode.app/brewcode/skills/rules/) | [skill-creator](https://doc-claude.brewcode.app/brewcode/agents/skill-creator/) | [hook-creator](https://doc-claude.brewcode.app/brewcode/agents/hook-creator/) | [task-board-setup](https://doc-claude.brewcode.app/brewtools/skills/task-board-setup/) | [manager-setup](https://doc-claude.brewcode.app/brewtools/skills/manager-setup/) | [think-short-setup](https://doc-claude.brewcode.app/brewtools/skills/think-short-setup/) | [agent-deadline-setup](https://doc-claude.brewcode.app/brewtools/skills/agent-deadline-setup/) | [agent-router-setup](https://doc-claude.brewcode.app/brewtools/skills/agent-router-setup/) | [provider-switch](https://doc-claude.brewcode.app/brewtools/skills/provider-switch/) | [deploy](https://doc-claude.brewcode.app/brewtools/skills/deploy/) | [secrets-scan](https://doc-claude.brewcode.app/brewtools/skills/secrets-scan/) | [text-human](https://doc-claude.brewcode.app/brewtools/skills/text-human/) | [deploy-admin](https://doc-claude.brewcode.app/brewtools/agents/deploy-admin/) | [docsync-setup](https://doc-claude.brewcode.app/brewdoc/skills/docsync-setup/) | [memory-sync-setup](https://doc-claude.brewcode.app/brewdoc/skills/memory-sync-setup/) | [publish](https://doc-claude.brewcode.app/brewdoc/skills/publish/) | [my-claude](https://doc-claude.brewcode.app/brewdoc/skills/my-claude/) | [full-setup](https://doc-claude.brewcode.app/full-setup/) | [faq](https://doc-claude.brewcode.app/faq/) diff --git a/brewcode/.claude-plugin/plugin.json b/brewcode/.claude-plugin/plugin.json index ecafa3a..c81dddd 100644 --- a/brewcode/.claude-plugin/plugin.json +++ b/brewcode/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brewcode", - "version": "5.0.0", + "version": "5.1.0", "description": "Brewcode - full-featured development platform for Claude Code: infinite focus tasks, prompt optimization, skill/agent creation, quorum reviews, rules management", "author": { "name": "Maksim Kochetkov", diff --git a/brewcode/.codex/skills/convention/scripts/convention.sh b/brewcode/.codex/skills/convention/scripts/convention.sh index b513664..7f35edc 100755 --- a/brewcode/.codex/skills/convention/scripts/convention.sh +++ b/brewcode/.codex/skills/convention/scripts/convention.sh @@ -3,6 +3,12 @@ # Usage: convention.sh set -eu +# Self-location: scripts/ -> convention/ -> skills/ -> PLUGIN_ROOT. Correct in the dev checkout +# AND in the installed cache, so the version is read from the manifest and never hardcoded. +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +PLUGIN_JSON="$SCRIPT_DIR/../../../.codex-plugin/plugin.json" +GENERATED_BY="brewcode:convention" + usage() { echo "Usage: convention.sh " echo "" @@ -143,20 +149,46 @@ EOF fi } +plugin_version() { + v="" + if [ -f "$PLUGIN_JSON" ]; then + if $HAS_JQ; then + v=$(jq -r '.version // empty' "$PLUGIN_JSON" 2>/dev/null || true) + else + v=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_JSON" 2>/dev/null | head -1 || true) + fi + fi + printf '%s' "${v:-unknown}" +} + +# Creates the output dir AND hands back the artifact-metadata scalars P4 stamps into each of the +# three generated docs. The old `created` key was an ISO-8601 timestamp nothing ever persisted. setup_convention() { mkdir -p .codex/convention - printf '{"created":"%s","path":".codex/convention/"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '{"path":".codex/convention/","version":"%s","generated_by":"%s","last_updated":"%s"}\n' \ + "$(plugin_version)" "$GENERATED_BY" "$(date +%F)" +} + +# A convention doc counts as present only when it also carries the standard metadata: a doc with +# no stamp cannot be aged against the running plugin, which is the whole point of `rules` mode. +check_doc() { + [ -f "$1" ] || return 1 + head -1 "$1" | grep -q '^---$' || { err "X $1 has no YAML frontmatter"; return 1; } + for k in doc_type version generated_by last_updated; do + grep -q "^${k}:" "$1" || { err "X $1 missing frontmatter key: $k"; return 1; } + done + return 0 } validate_convention() { errors=0 f1=false f2=false f3=false - [ -f .codex/convention/reference-patterns.md ] && f1=true || errors=$((errors + 1)) - [ -f .codex/convention/testing-conventions.md ] && f2=true || errors=$((errors + 1)) - [ -f .codex/convention/project-architecture.md ] && f3=true || errors=$((errors + 1)) + check_doc .codex/convention/reference-patterns.md && f1=true || errors=$((errors + 1)) + check_doc .codex/convention/testing-conventions.md && f2=true || errors=$((errors + 1)) + check_doc .codex/convention/project-architecture.md && f3=true || errors=$((errors + 1)) valid=true; [ "$errors" -gt 0 ] && valid=false - if [ "$valid" = "true" ]; then err "All convention files present" - else err "Missing $errors convention file(s)"; fi + if [ "$valid" = "true" ]; then err "All convention files present and stamped" + else err "$errors convention file(s) missing or unstamped"; fi if $HAS_JQ; then printf '{"valid":%s,"files":{"reference-patterns.md":%s,"testing-conventions.md":%s,"project-architecture.md":%s}}' \ diff --git a/brewcode/.codex/skills/rules/scripts/rules.sh b/brewcode/.codex/skills/rules/scripts/rules.sh index 11ff135..30254a8 100755 --- a/brewcode/.codex/skills/rules/scripts/rules.sh +++ b/brewcode/.codex/skills/rules/scripts/rules.sh @@ -7,20 +7,40 @@ # read - Read knowledge file (first 100 lines) # check - Check existing rules files (main + specialized) # create - Create missing main rules from templates -# create-specialized - Create specialized rules (e.g., test-avoid.md) +# create-specialized [paths] - Create specialized rules (e.g., test-avoid.md) # list - List all rule files (*-avoid.md, *-best-practice.md) -# validate - Validate table structure +# validate - Validate frontmatter + table structure set -euo pipefail MODE="${1:-check}" ARG="${2:-}" +ARG2="${3:-}" # Self-location: derive plugin root from script path SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Path: scripts/rules.sh -> skills/rules/scripts -> skills/rules -> skills -> PLUGIN_ROOT PLUGIN_ROOT="$(dirname "$(dirname "$(dirname "$SCRIPT_DIR")")")" PLUGIN_TEMPLATES="$PLUGIN_ROOT/templates" +# Manifest by self-location: correct in the dev checkout AND in the installed cache. +PLUGIN_JSON="$PLUGIN_ROOT/.codex-plugin/plugin.json" + +# Artifact-metadata standard. The version is read from the manifest, never hardcoded. +plugin_version() { + local v="" + if [ -f "$PLUGIN_JSON" ]; then + if command -v jq >/dev/null 2>&1; then + v=$(jq -r '.version // empty' "$PLUGIN_JSON" 2>/dev/null || true) + else + v=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_JSON" 2>/dev/null | head -1 || true) + fi + fi + printf '%s' "${v:-unknown}" +} + +PLUGIN_VERSION="$(plugin_version)" +GENERATED_BY="brewcode:rules" +LAST_UPDATED="$(date +%F)" # Validate plugin structure validate_plugin() { @@ -70,6 +90,20 @@ check_rules() { fi } +# Render a template: substitute the scope scalars + the four standard metadata keys. +# `|` is the sed delimiter, so no substituted value may contain one -- all of them are +# globs, titles and versions produced here, never user prose. +render_template() { + local tpl="$1" out="$2" title="$3" paths="$4" desc="$5" + sed -e "s|{TITLE}|$title|g" \ + -e "s|{PATHS}|$paths|g" \ + -e "s|{DESCRIPTION}|$desc|g" \ + -e "s|{PLUGIN_VERSION}|$PLUGIN_VERSION|g" \ + -e "s|{GENERATED_BY}|$GENERATED_BY|g" \ + -e "s|{LAST_UPDATED}|$LAST_UPDATED|g" \ + "$tpl" > "$out" +} + # Create missing rules from templates create_rules() { echo "=== Create Rules ===" @@ -78,35 +112,65 @@ create_rules() { mkdir -p .codex/rules if [ ! -f .codex/rules/avoid.md ]; then - cp "$PLUGIN_TEMPLATES/rules/avoid.md.template" .codex/rules/avoid.md + render_template "$PLUGIN_TEMPLATES/rules/avoid.md.template" .codex/rules/avoid.md \ + "Avoid" '["**/*"]' 'avoid - project-wide anti-patterns and the thing to do instead; one table row per rule' echo "V Created: .codex/rules/avoid.md" else echo ">> Preserved: .codex/rules/avoid.md (exists)" fi if [ ! -f .codex/rules/best-practice.md ]; then - cp "$PLUGIN_TEMPLATES/rules/best-practice.md.template" .codex/rules/best-practice.md + render_template "$PLUGIN_TEMPLATES/rules/best-practice.md.template" .codex/rules/best-practice.md \ + "Best Practices" '["**/*"]' 'best-practice - project-wide practices worth repeating; one table row per rule' echo "V Created: .codex/rules/best-practice.md" else echo ">> Preserved: .codex/rules/best-practice.md (exists)" fi } -# Validate table structure (main + specialized) +# Validate ONE rule file: frontmatter, the four standard metadata keys, table header. +# $2 = "specialized" -> also reject repo-wide paths. +validate_file() { + local f="$1" kind="${2:-main}" + local name errs=0 k + name=$(basename "$f") + + head -1 "$f" | grep -q '^---$' || { echo "X $name no YAML frontmatter (line 1 must be ---)"; errs=$((errs+1)); } + + for k in paths description doc_type version generated_by last_updated; do + grep -q "^${k}:" "$f" || { echo "X $name missing frontmatter key: $k"; errs=$((errs+1)); } + done + + grep -q '^doc_type: llm$' "$f" || { echo "X $name doc_type must be exactly 'llm'"; errs=$((errs+1)); } + grep -Eq '^version: "[0-9]+\.[0-9]+\.[0-9]+"$' "$f" || { echo "X $name version must be a quoted X.Y.Z"; errs=$((errs+1)); } + grep -Eq '^last_updated: "[0-9]{4}-[0-9]{2}-[0-9]{2}"$' "$f" || { echo "X $name last_updated must be a quoted YYYY-MM-DD"; errs=$((errs+1)); } + + if [ "$kind" = "specialized" ] && grep -q '"\*\*/\*"' "$f"; then + echo "X $name is specialized but claims repo-wide paths -> it loads into every request" + errs=$((errs+1)) + fi + + grep -q "^| #" "$f" || { echo "X $name invalid structure (missing table header)"; errs=$((errs+1)); } + + [ "$errs" -eq 0 ] && echo "V $name valid (frontmatter + metadata + table)" + ERRORS=$((ERRORS + errs)) +} + +# Validate frontmatter + table structure (main + specialized) validate_rules() { echo "=== Validate Rules Structure ===" ERRORS=0 # Validate main files if [ -f .codex/rules/avoid.md ]; then - grep -q "^| #" .codex/rules/avoid.md && echo "V avoid.md valid structure" || { echo "X avoid.md invalid structure (missing table header)"; ERRORS=$((ERRORS+1)); } + validate_file .codex/rules/avoid.md main else echo "X avoid.md not found" ERRORS=$((ERRORS+1)) fi if [ -f .codex/rules/best-practice.md ]; then - grep -q "^| #" .codex/rules/best-practice.md && echo "V best-practice.md valid structure" || { echo "X best-practice.md invalid structure (missing table header)"; ERRORS=$((ERRORS+1)); } + validate_file .codex/rules/best-practice.md main else echo "X best-practice.md not found" ERRORS=$((ERRORS+1)) @@ -119,12 +183,7 @@ validate_rules() { [ "$(basename "$f")" = "avoid.md" ] && continue [ "$(basename "$f")" = "best-practice.md" ] && continue - if grep -q "^| #" "$f"; then - echo "V $(basename "$f") valid structure" - else - echo "X $(basename "$f") invalid structure (missing table header)" - ERRORS=$((ERRORS+1)) - fi + validate_file "$f" specialized done exit $ERRORS @@ -172,13 +231,33 @@ capitalize() { printf '%s%s' "$(printf '%s' "${s%"${s#?}"}" | tr '[:lower:]' '[:upper:]')" "${s#?}" } +# A specialized rule file applies to ONE slice of the repo, so it must never ship the +# repo-wide `["**/*"]` -- that is what made every specialized rule load into every request. +# Known prefixes get a curated glob set; anything else gets a prefix-derived guess that the +# caller is told to confirm. An explicit `paths` argument always wins. +default_paths_for_prefix() { + case "$1" in + test|tests|unit) printf '["**/test/**", "**/tests/**", "**/*_test.*", "**/*.test.*", "**/*Test.*"]' ;; + e2e|it|integration) printf '["**/e2e/**", "**/it/**", "**/*E2E*", "**/*e2e*"]' ;; + doc|docs) printf '["**/*.md", "**/*.mdx", "docs/**"]' ;; + ci|cd|cicd) printf '[".github/**", "**/*.yml", "**/*.yaml"]' ;; + sql|db|database) printf '["**/*.sql", "**/migration*/**", "**/migrations/**"]' ;; + api) printf '["**/api/**", "**/openapi/**", "**/*.openapi.*"]' ;; + ui|front|frontend|web) printf '["**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte", "**/*.css"]' ;; + infra|docker|k8s|deploy) printf '["**/Dockerfile*", "**/docker-compose*.yml", "**/docker-compose*.yaml", "**/*.tf", "k8s/**"]' ;; + *) printf '["**/%s/**", "**/*%s*"]' "$1" "$1" ;; + esac +} + # Create specialized rules from template with prefix create_specialized() { local prefix="$1" + local paths="${2:-}" if [ -z "$prefix" ]; then echo "X Missing prefix argument" - echo "Usage: rules.sh create-specialized " + echo "Usage: rules.sh create-specialized [paths]" echo "Example: rules.sh create-specialized test" + echo "Example: rules.sh create-specialized payment '[\"src/payment/**\"]'" exit 1 fi @@ -191,16 +270,30 @@ create_specialized() { local cap cap=$(capitalize "$prefix") + if [ -z "$paths" ]; then + paths=$(default_paths_for_prefix "$prefix") + echo "! paths not supplied -> derived $paths" + echo " Confirm it with the user and narrow it by hand if it does not match this repo's layout." + fi + case "$paths" in + *'"**/*"'*) + echo "X Refusing repo-wide paths for a specialized rule: $paths" + echo " A ${prefix}-* rule that matches everything loads into every request. Pass a narrower glob." + exit 1 + ;; + esac + if [ ! -f "$avoid_file" ]; then - # Create from template with prefix substitution - sed "s/# Avoid/# ${cap} Avoid/" "$PLUGIN_TEMPLATES/rules/avoid.md.template" > "$avoid_file" + render_template "$PLUGIN_TEMPLATES/rules/avoid.md.template" "$avoid_file" \ + "${cap} Avoid" "$paths" "${prefix}-avoid - ${prefix} anti-patterns and the thing to do instead; one table row per rule" echo "V Created: $avoid_file" else echo ">> Preserved: $avoid_file (exists)" fi if [ ! -f "$bp_file" ]; then - sed "s/# Best Practices/# ${cap} Best Practices/" "$PLUGIN_TEMPLATES/rules/best-practice.md.template" > "$bp_file" + render_template "$PLUGIN_TEMPLATES/rules/best-practice.md.template" "$bp_file" \ + "${cap} Best Practices" "$paths" "${prefix}-best-practice - ${prefix} practices worth repeating; one table row per rule" echo "V Created: $bp_file" else echo ">> Preserved: $bp_file (exists)" @@ -219,7 +312,7 @@ case "$MODE" in create_rules ;; create-specialized) - create_specialized "$ARG" + create_specialized "$ARG" "$ARG2" ;; list) list_rules @@ -234,9 +327,10 @@ case "$MODE" in echo " read - Read knowledge file (first 100 lines)" echo " check - Check existing rules files (main + specialized)" echo " create - Create missing main rules from templates" - echo " create-specialized - Create specialized rules (e.g., test-avoid.md)" + echo " create-specialized [paths] - Create specialized rules (e.g., test-avoid.md);" + echo " paths is a YAML flow list, e.g. '[\"src/payment/**\"]'" echo " list - List all rule files" - echo " validate - Validate table structure" + echo " validate - Validate frontmatter (standard metadata keys) + table structure" exit 1 ;; esac diff --git a/brewcode/.codex/skills/superreview-setup/README.md b/brewcode/.codex/skills/superreview-setup/README.md index 096feb2..4f2a970 100644 --- a/brewcode/.codex/skills/superreview-setup/README.md +++ b/brewcode/.codex/skills/superreview-setup/README.md @@ -77,8 +77,10 @@ prose is the interface. with two entry points: `emit` (full generation) and `emit-agent` (the agent alone — no superreview skill needed, this is what `$brewcode:teams-setup` calls). It is never hand-written, never authored by `brewcode:agent-creator` (which may only ADAPT the seeded blocks), and never a domain expert. A usable existing file is **REUSED byte-untouched** — the writer -prints one status line, `INTENT_GUARD: CREATED ` or `INTENT_GUARD: REUSE ` — so local edits survive every -regeneration; an empty or frontmatter-less file counts as absent and is recreated. Its evidence tiers are baked in at +prints one status line, `INTENT_GUARD: CREATED|REUSE|MIGRATED ` — so local edits survive every +regeneration; an empty or frontmatter-less file counts as absent and is recreated. `MIGRATED` is the pre-5.0 case: +a file carrying the retired `intent-guard template vN` stamp gets its metadata restamped in place (the four +frontmatter keys + the tail anchor) with the tailored body preserved byte-for-byte. Its evidence tiers are baked in at emit time: `T1` tracker, `T2` specs, `T3` plans, `T4` policy files, `T5` the live session transcript. ## How review + standards-review are merged @@ -100,14 +102,24 @@ matrix and report scaffolding baked into it; scope + expert selection make the e Run inside the repo you want to wire up: ``` -$brewcode:superreview-setup [status|install|upgrade] "" [scope] +$brewcode:superreview-setup [status|install|upgrade|enable|disable|uninstall|purge] "" [scope] ``` | Verb | Effect | |------|--------| -| `status` | read-only: is the skill emitted, is `intent-guard.md` present, does `validate` pass | -| `install` | full generation (Phase 0 -> 4). Also the no-verb default | +| `status` | read-only: is the skill emitted, is it enabled or parked, is `intent-guard.toml` present, does `validate` pass | +| `install` | full generation (Phase 0 -> 4). Also the default when a fine-tune prompt is given with no verb | | `upgrade` | refresh a live install from the template baseline without erasing tailoring | +| `enable` | rename `SKILL.md.disabled` back to `SKILL.md` — `/superreview` is offered again | +| `disable` | rename `SKILL.md` to `SKILL.md.disabled` — `/superreview` stops being discovered. `references/`, `.template-baseline/` and every tailoring stay on disk; reversible, nothing regenerated | +| `uninstall` | delete `.codex/skills/superreview/`. **Keeps** the review reports and `intent-guard.toml` | +| `purge` | uninstall + delete `.codex/reports/*_superreview/`. Still keeps `intent-guard.toml` | + +No arguments at all: `status` when the skill is already emitted, `install` when it is not. + +`intent-guard.toml` survives all seven verbs — it is shared with `$brewcode:teams-setup`, and that skill +may be the one that put it there. `enable`/`disable` take effect in the NEXT session, since Codex +discovers skills at session start. - `` — what to emphasize in the emitted skill's focus ordering (e.g. "weight reuse highest", "always treat auth as P0"). Woven into the emitted Focus table + emphasis line. Scope discipline stays in rank 1 @@ -150,7 +162,7 @@ After generation, run the emitted skill in that project. Depth comes from how yo | File | Role | |------|------| | `SKILL.md` | The generator orchestrator | -| `scripts/generate.sh` | `scan` / `emit` / `emit-agent` / `upgrade` / `validate` | +| `scripts/generate.sh` | `scan` / `emit` / `emit-agent` / `upgrade` / `enable` / `disable` / `uninstall` / `purge` / `validate` | | `references/SKILL.md.template` | The emitted SKILL.md (placeholder slots) | | `references/agent-prompt.md` | Emitted runtime expert-selection procedure + domain-owner prompt contract | | `references/scope.md.template` | Emitted scope-discipline reference (baseline, ownership, taxonomy, delivery, closeout, gate) | @@ -167,7 +179,7 @@ the expected path, not a failure: it writes nothing and prints no `INTENT_GUARD: | Command | Effect | |---------|--------| -| `generate.sh upgrade` | The supported refresh. Writes NO live file. Stages a fresh emit under `.upgrade-staging/` and reports, per asset, the **new template vs the pristine `.template-baseline/` copy `emit` saved** — so `DIFFERS ( template line(s))` counts real template changes and never your tailoring. A deleted asset is restored RAW and labelled `MISSING -> restored (NEEDS PHASE 3)`. The generator ports each delta into the live file with targeted `Edit` calls, then promotes `.upgrade-staging/.template` to the new baseline | +| `generate.sh upgrade` | The supported refresh. Writes NO live file. Stages a fresh emit under `.upgrade-staging/` and reports, per asset, the **new template vs the pristine `.template-baseline/` copy `emit` saved** — so `DIFFERS ( template line(s))` counts real template changes and never your tailoring. The per-stack reference is re-derived from the installed tree (`UPGRADE_STACK=`), never re-defaulted, so a TypeScript/Go/Java-Kotlin install gets its own reference restamped. A deleted asset is restored RAW — scalars included, deliberately unresolved — and labelled `MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)`. The generator ports each delta into the live file with targeted `Edit` calls, then promotes `.upgrade-staging/.template` to the new baseline | | `SUPERREVIEW_FORCE=1 generate.sh emit` | Conscious destructive override: overwrites the live installation and **loses** every tailored + self-synced edit. Only on an explicit request for a clean regeneration | `.template-baseline/` and `.upgrade-staging/` each carry a `.gitignore` of `*`, so neither shows up in your @@ -179,7 +191,7 @@ and falls back to a live-vs-template diff, which must be reviewed by hand. Run `upgrade` when: a project agent is added/renamed, a rule/convention file changes, the stack changes, a new source group is added, the tracker or branch convention changes, a spec/plan/policy location moves, or a new always-shared surface appears. It re-wires the emitted skill to the current project shape — and leaves an existing -`intent-guard.md` alone. +`intent-guard.toml` alone. ## Notes diff --git a/brewcode/.codex/skills/superreview-setup/SKILL.md b/brewcode/.codex/skills/superreview-setup/SKILL.md index 4ceaa7b..4fa3e2a 100644 --- a/brewcode/.codex/skills/superreview-setup/SKILL.md +++ b/brewcode/.codex/skills/superreview-setup/SKILL.md @@ -70,25 +70,76 @@ plus optional `[scope]` hint. The fine-tune prompt is woven into the emitted ski ### Verb routing — resolve FIRST, before anything else -`` may start with one of three verbs. Anything else is the fine-tune prompt and takes the -free-form path. Strip the verb before using the rest as the fine-tune prompt. +`` may start with one of the seven canonical verbs, in this order: +`status | install | upgrade | enable | disable | uninstall | purge`. Anything else is the fine-tune +prompt and takes the free-form path. Strip the verb before using the rest as the fine-tune prompt. + +Removed aliases that must never be accepted or printed: `init`, `on`, `off`, `setup`, `remove`, +`reset`, `create`, `update`, `cleanup`. Recognize them in free text, echo the canonical verb back. | Verb | What runs | Writes? | |------|-----------|---------| -| `status` | read-only: does `.codex/skills/superreview/` exist, is `.codex/agents/intent-guard.toml` present, is `.template-baseline/` there? Then `generate.sh validate` and report. **STOP** — no phases run | no | +| `status` | read-only: is `.codex/skills/superreview/` there, is it ENABLED or parked, is `.codex/agents/intent-guard.toml` present, is `.template-baseline/` there? Then `generate.sh validate` and report. **STOP** — no phases run | no | | `install` | the full generate flow, Phase 0 -> Phase 4 below | yes | | `upgrade` | Phase 2b only (`generate.sh upgrade`), then Phase 3 for any `MISSING -> restored` asset, then Phase 4 `validate`. **STOP** | live files only via targeted Edit | -| *(no verb)* | same as `install`; the whole `` is the fine-tune prompt | yes | +| `enable` | `generate.sh enable` — un-parks the installed skill. **STOP** | one rename | +| `disable` | `generate.sh disable` — parks the installed skill without deleting anything. **STOP** | one rename | +| `uninstall` | `generate.sh uninstall` — deletes the generated skill dir, KEEPS the reports and `intent-guard.toml`. Confirm once. **STOP** | deletes | +| `purge` | `generate.sh purge` — uninstall + deletes `.codex/reports/*_superreview/`. Still keeps `intent-guard.toml`. Confirm once, naming the report count. **STOP** | deletes | +| *(no args at all)* | `status` when `.codex/skills/superreview/` exists, otherwise `install` | status: no | +| *(no verb, but a prompt)* | same as `install`; the whole `` is the fine-tune prompt | yes | **EXECUTE** using shell (`status` only): ```bash -test -d .codex/skills/superreview && echo "installed" || echo "not_installed" +if test -f .codex/skills/superreview/SKILL.md; then echo "installed: enabled" +elif test -f .codex/skills/superreview/SKILL.md.disabled; then echo "installed: DISABLED (parked as SKILL.md.disabled — run 'enable' to restore)" +elif test -d .codex/skills/superreview; then echo "installed: BROKEN (dir present, no SKILL.md and no SKILL.md.disabled)" +else echo "not_installed"; fi test -f .codex/agents/intent-guard.toml && echo "intent-guard: present" || echo "intent-guard: MISSING" test -d .codex/skills/superreview/.template-baseline && echo "baseline: present" || echo "baseline: absent (pre-baseline install)" +echo "reports: $({ find .codex/reports -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | wc -l | tr -d ' ') dir(s) — deleted by 'purge', kept by 'uninstall'" bash "/scripts/generate.sh" validate && echo "✅ validate" || echo "❌ validate FAILED" ``` > `status` never writes and never asks. `not_installed` -> report it and offer `install`; nothing else. +> `installed: DISABLED` is a state, not a fault — report it and offer `enable`. `validate` fails on a +> disabled install (it looks for `SKILL.md`); say so rather than presenting it as a broken installation. + +--- + +### Modes: enable | disable | uninstall | purge + +| Mode | Generated skill dir | `references/` + `.template-baseline/` | Phase 3 tailoring | `.codex/reports/*_superreview/` | `intent-guard.toml` | +|------|--------------------|---------------------------------------|-------------------|----------------------------------|-------------------| +| `enable` | `SKILL.md.disabled` -> `SKILL.md` | kept | kept | kept | kept | +| `disable` | `SKILL.md` -> `SKILL.md.disabled` | kept | kept | kept | kept | +| `uninstall` | **deleted** | deleted with it | lost | **kept** | kept | +| `purge` | **deleted** | deleted with it | lost | **deleted** | kept | + +**How the toggle works.** Codex discovers a project skill only through `/SKILL.md`. +`disable` renames that ONE file to `SKILL.md.disabled`, so `/superreview` stops being offered while +`references/`, `.template-baseline/` and every Phase 3 tailoring stay byte-identical on disk. `enable` +renames it back. Nothing is regenerated in either direction, so no `version` is bumped and no +self-synced edit is at risk. Use `disable` to park a review setup that is temporarily noisy; use +`uninstall` when it should really go. Both take effect in the NEXT session — skills are discovered at +session start. + +**`intent-guard` is never touched by any of the four.** `generate.sh` (`emit`/`emit-agent`) is its +only writer, and it is shared with `$brewcode:teams-setup`, which may have put it there. Deleting or +parking it would silently break an unrelated team install. All four modes print it as `KEPT`. + +**Confirm before deleting.** `uninstall` and `purge` each `request_user_input` exactly once, listing the +real paths (`find .codex/skills/superreview -type f | sort`) and, for `purge`, the number of review +reports being destroyed, with `uninstall` offered as the keep-the-reports alternative. A declined +confirmation ends the run cleanly — delete nothing. + +**EXECUTE** using shell (the chosen verb, after confirmation where required): +```bash +bash "/scripts/generate.sh" MODE_HERE && echo "✅ MODE_HERE" || echo "❌ MODE_HERE FAILED" +``` + +Then report the script's `MOVED:` / `REMOVED:` / `KEPT:` lines verbatim. Not installed at all -> +say so and **STOP**; never "disable" or "purge" something that was never emitted. ### Delegation (applies to every sub-agent task this generator spawns AND to the fan-out it emits) @@ -211,6 +262,7 @@ unconditionally by the emitted skill at BOTH depths, so the emitted skill is bro |------|--------| | **Single writer** | `scripts/generate.sh` is the ONLY writer of `.codex/agents/intent-guard.toml`, via ONE shared implementation exposed as two subcommands: `emit` (full generation, Phase 2) and `emit-agent` (the agent alone, no superreview skill involved — this is what `$brewcode:teams-setup` calls instead of authoring its own copy). **Never hand-write the file.** `brewcode:agent-creator` may only ADAPT the seeded BLOCKs of an already-written file; it may never author it | | **Reuse wins** | a USABLE file already exists -> the writer prints `INTENT_GUARD: REUSE ` and leaves it BYTE-UNTOUCHED. An existing intent-guard is the project's own tuned version (or a sibling generator's) and outranks this template. Do not "refresh" it, do not diff-merge it, do not fill BLOCKs in it. "Usable" = non-empty AND carrying `name: intent-guard` frontmatter AND free of unresolved `{UPPER_SNAKE}` tokens; an empty, truncated or placeholder-laden file is treated as ABSENT and recreated | +| **Migrate, never re-emit** | a file carrying the RETIRED `` stamp is ours but pre-standard: the writer prints `INTENT_GUARD: MIGRATED ` and restamps METADATA ONLY — the four frontmatter keys and the tail anchor. Every tailored line survives byte-for-byte, so this is the `upgrade restamps it` path, not a regeneration. A file with NO stamp of either generation is the project's own hand-written agent and is only ever REUSED | | **No request_user_input** | creation is not gated. Do not ask whether to create it; it is part of the emitted artifact, like `references/scope.md` | | **Roster scan** | note in Phase 1 whether the file is present (`generate.sh scan` reports it) so the Phase 5 summary can say CREATED vs REUSED | | **Not an expert** | never count it toward the domain-expert requirement, never put it in `DOMAIN_AGENTS_TABLE` / `FILE_GROUP_MAP` / `SIMPLIFY_AGENTS`, never make it `VALIDATOR_AGENT` or a scope-pass owner. `generate.sh validate` excludes it from the expert count for exactly this reason | @@ -248,20 +300,26 @@ bash "/scripts/generate.sh" emit && echo "✅ emit" || echo " > `/references/SKILL.md.template` exists and the target `.codex/` is writable. This writes `/.codex/skills/superreview/SKILL.md` (scalars substituted), copies `agent-prompt.md`, -`report-template.md` and `scope.md` (scalar-substituted), copies the chosen `${STACK_REF}` into the emitted +`report-template.md`, `scope.md` and the chosen `${STACK_REF}` (all scalar-substituted) into the emitted `references/`, saves the pristine templates to `.codex/skills/superreview/.template-baseline/` (what `upgrade` later diffs against), and **creates-or-reuses `/.codex/agents/intent-guard.toml`** (template header -stripped, provenance stamp kept). Key off the ONE machine-readable status line the writer prints — the +stripped, provenance stamp kept). Every emitted artifact is stamped with the four standard metadata fields — +`doc_type: llm`, `version`, `generated_by: brewcode:superreview-setup`, `last_updated` — in its frontmatter; +you export NOTHING for them. `version` is read out of the plugin's own `.codex-plugin/plugin.json` by script +self-location and `last_updated` is `date +%F`. Both stay `{PLUGIN_VERSION}` / `{LAST_UPDATED}` in the raw +`.template-baseline/` copies, so a plain version bump makes `upgrade` report IDENTICAL, never a diff. +Key off the ONE machine-readable status line the writer prints — the `already installed` refusal path prints NO status line, because nothing was written: | Status line | Meaning | |-------------|---------| | `INTENT_GUARD: CREATED .codex/agents/intent-guard.toml` | written from the template with SEEDED-DEFAULT BLOCKs — you MUST adapt all three in Phase 3 | | `INTENT_GUARD: REUSE .codex/agents/intent-guard.toml` | the file is the project's own — touch NOTHING in it, skip its Phase 3 table | +| `INTENT_GUARD: MIGRATED .codex/agents/intent-guard.toml` | a pre-standard file of ours was restamped in place (metadata only, tailored body preserved) — treat it exactly like REUSE: skip its Phase 3 table, edit nothing | > The same writer is available standalone as `generate.sh emit-agent` (agent only, no superreview skill required, > same env overrides `PROJECT_NAME` / `TRACKER_LABEL` / `SPEC_LOCATION` / `PLAN_LOCATION` / `POLICY_LOCATION`, -> same two status lines). `$brewcode:teams-setup` uses it; this generator does not need it, `emit` covers it. +> same three status lines). `$brewcode:teams-setup` uses it; this generator does not need it, `emit` covers it. ### Phase 2b — Already installed? `upgrade`, never re-emit @@ -274,7 +332,8 @@ REFUSES on a live installation. When it does: bash "/scripts/generate.sh" upgrade && echo "✅ upgrade" || echo "❌ upgrade FAILED" ``` -It writes NO live file. It stages a fresh emit at `.codex/skills/superreview/.upgrade-staging/` (with the raw new +It rewrites no live file's CONTENT — the one thing it does write into a live file is the metadata restamp below. +It stages a fresh emit at `.codex/skills/superreview/.upgrade-staging/` (with the raw new templates under `.upgrade-staging/.template/`) and compares the NEW TEMPLATE against the pristine copies `emit` saved in `.codex/skills/superreview/.template-baseline/` — **never the live file against a template**, because a live file legitimately carries Phase 3 tailoring and Phase 4b self-sync edits that no template ever knew about. @@ -282,11 +341,37 @@ One line per asset: | Line | Meaning | What you do | |------|---------|-------------| -| `IDENTICAL (template unchanged since install)` | no template delta | nothing | +| `IDENTICAL (template unchanged since install)` | no template delta | nothing — but the file is still restamped, see below | | `DIFFERS ( template line(s))` | the TEMPLATE really changed | run the printed `diff `, then port ONLY those changes into the LIVE file with targeted **Edit** calls, keeping every tailored + self-synced line | -| `MISSING -> restored (NEEDS PHASE 3)` | a deleted asset was restored from the RAW template | **go to Phase 3 for that file** and fill its BLOCK placeholders — it is un-tailored, and Phase 4 `validate` fails on it otherwise | +| `MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)` | a deleted asset was restored from the RAW template | **go to Phase 3 for that file** and fill BOTH kinds of placeholder — the SCALARS too (`{PROJECT_NAME}`, `{STACK_LABEL}`, `{ARBITER_AGENT}`, …), because `upgrade` runs with a bare environment and deliberately does NOT re-guess them. `validate` lists every one by name | | `NO BASELINE - full diff, tailoring included` | install predates the baseline | the count is NOT a template delta; review the staged copy by hand and port only genuine template changes | +**The stack is re-derived, never re-defaulted.** The first line `upgrade` prints is +`UPGRADE_STACK=.md (derived from the installed tree)`. The per-stack reference was a Phase 1 DECISION +(`STACK_REF`), and `upgrade` runs with a bare environment, so it reads that decision back out of the installed tree — +whichever of `python.md` / `typescript-react.md` / `go.md` / `java-kotlin.md` is present in +`references/` or in `.template-baseline/references/` — instead of falling back to a default. Everything below +iterates that name: a wrong one would leave the project's real reference behind at the old version forever while +restamping a file the project does not have, so `$brewcode:setup-status` would report `stale` after every +successful upgrade. More than one present = a multi-stack install, and all of them are restamped. None +determinable prints `UPGRADE_STACK=none — ❌ NO per-stack reference found` and skips the stack doc only; the other +four artifacts are still restamped. `STACK_REF=.md` in the environment overrides the derivation. + +**The restamp — one `RESTAMP:` line per live file, and it is unconditional.** After the delta report, `upgrade` +refreshes `version` / `generated_by` / `last_updated` in the frontmatter of every live emitted file, in place: + +``` +RESTAMP: .codex/skills/superreview/SKILL.md version "A.B.C" -> "X.Y.Z", generated_by/last_updated refreshed (body untouched) +``` + +It is deliberately NOT gated on the verdict above. A plain version bump moves no template line, so every asset +reports `IDENTICAL` — and the emitted `SKILL.md` frontmatter `version:` is exactly what `$brewcode:setup-status` +reads to decide `stale`. An `upgrade` that skipped it reported success and left the stamp where it was, so the +next `status` printed `stale` again, forever. Nothing else in the file is touched: `doc_type` is preserved when +present (it is user-owned), the body is compared byte-for-byte afterwards, and any mismatch aborts the run before +anything is written — Phase 3 tailoring and Phase 4b self-sync edits survive intact. A second `upgrade` on the +same version is a no-op apart from `last_updated`. + Then, once the delta is applied (and any restored file has been through Phase 3), promote the new templates to the baseline and clean up with the command the script printed: `rm -rf && mv /.template && rm -rf ` — after which go to Phase 4. Both @@ -325,7 +410,7 @@ placeholder in the EMITTED files with content you build from Phase 1 analysis. | `{SHARED_SURFACES_TABLE}` | the concrete always-shared surfaces of THIS repo (public API/contract dirs, migrations, schema/registry files, CI workflows, dependency manifests, design tokens) | **In `/.codex/agents/intent-guard.toml` — ONLY when the writer printed `INTENT_GUARD: CREATED`. On -`INTENT_GUARD: REUSE`, SKIP this table entirely and edit nothing in that file.** +`INTENT_GUARD: REUSE` or `INTENT_GUARD: MIGRATED`, SKIP this table entirely and edit nothing in that file.** > **The three BLOCK placeholders are already gone by now** — emit replaced each with a runnable GENERIC DEFAULT > block that ends in its own marker line. Key every Edit on the marker, not on the old `{TOKEN}`: your @@ -365,6 +450,11 @@ bash "/scripts/generate.sh" validate && echo "✅ validate" || > The template checks above run ONLY against an agent file carrying the template stamp. A REUSED hand-written > intent-guard is byte-untouchable by contract, so validate says so and does not judge it by template rules. +> **Shell expansions are NOT placeholders.** The scan strips every `${UPPER_SNAKE}` before looking for tokens, so +> Phase 3 evidence commands may freely use `${BASE}`, `${HOME}`, `` or any other variable — +> only a BARE `{TOKEN}` is reported, and it is reported by name with no surrounding characters. Do not work around +> a false positive by adding the variable's name to the runtime allow-list. + > **`⚠️ UNTAILORED` is a WARNING, not a failure** (exit code unaffected): the agent still carries seeded generic > BLOCK defaults, i.e. the Phase 3 adaptation was skipped or incomplete. Go back to Phase 3, replace each named > block AND its marker, and re-run — never ship an UNTAILORED agent silently. @@ -426,7 +516,7 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe | Fan-out | ONE parallel message. `QUICK`: `intent-guard` alone. `EXTENDED`: `intent-guard` + domain experts + scope pass A (diff side, shapes 1-6) + scope pass B (baseline side, delivery D1-D5 + closeout C1-C4); shared JSON finding contract; search-first before flagging reuse/duplication | | Validation | `EXTENDED` only. A NON-OWNING validator reverse-validates EVERY verdictless candidate (adversarial, per-finding gate, batched <=40), merges + de-dups + prioritizes P0-P3; unvalidatable -> `UNVALIDATED` and the run is `INCOMPLETE`. At `QUICK` the pool is entirely self-verdicted, so the coordinator merges + ranks in-session and the run is NOT `INCOMPLETE` | | Scope gate | `EXTENDED` only. `request_user_input` on unsanctioned expansion / unproven absence; rewrites priorities only, never adds findings, never lifts the UNKNOWN cap. Intent rows never enter it | -| **Self-sync** | `EXTENDED` only, coordinator only, after the report: Phase 4b corrects the emitted SKILL.md + `references/scope.md` IN PLACE from data already in context — routing table vs the live roster, a gate that reported `not run` because the command does not exist, an `UNKNOWN`/mismatched scope baseline, a shared surface a scope finding named. Line delta `<= 0`, facts only; DECISIONS, missing experts and `intent-guard.md` are PROPOSALS printed in the summary, never writes | +| **Self-sync** | `EXTENDED` only, coordinator only, after the report: Phase 4b corrects the emitted SKILL.md + `references/scope.md` IN PLACE from data already in context — routing table vs the live roster, a gate that reported `not run` because the command does not exist, an `UNKNOWN`/mismatched scope baseline, a shared surface a scope finding named. Line delta `<= 0`, facts only; DECISIONS, missing experts and `intent-guard.toml` are PROPOSALS printed in the summary, never writes | | Report | ONE merged report at `.codex/reports/{TIMESTAMP}_superreview/REPORT.md`, sorted P0->P3, every row carrying its verdict, with a Scope Discipline / Blast Radius section; READ-ONLY; recommends `/simplify` + a Manager-mode fix session; never edits code | --- @@ -437,7 +527,8 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe |---------|---------|-------------| | Emit target | `/.codex/skills/superreview/` | Where the generated skill is written | | Emit templates | `/references/` | Source templates for the generation | -| Generation script | `/scripts/generate.sh` | `scan` \| `emit` \| `emit-agent` \| `upgrade` \| `validate`. `emit-agent` writes ONLY `.codex/agents/intent-guard.toml` (shared writer, no superreview skill required) — that is the entry point `$brewcode:teams-setup` calls | +| Generation script | `/scripts/generate.sh` | `scan` \| `emit` \| `emit-agent` \| `upgrade` \| `enable` \| `disable` \| `uninstall` \| `purge` \| `validate`. `emit-agent` writes ONLY `.codex/agents/intent-guard.toml` (shared writer, no superreview skill required) — that is the entry point `$brewcode:teams-setup` calls | +| Disabled marker | `/.codex/skills/superreview/SKILL.md.disabled` | What `disable` renames `SKILL.md` to. Its presence IS the disabled state — there is no config file. `enable` renames it back; `uninstall`/`purge` delete the whole dir either way | | Re-generation | `upgrade` (Phase 2b) | `emit` refuses on a live installation because the emitted skill self-syncs; `upgrade` stages the new templates and never writes a live file. `SUPERREVIEW_FORCE=1` overwrites and destroys self-synced edits | | Template baseline | `/.codex/skills/superreview/.template-baseline/` | Pristine copies of the templates `emit` generated from (git-ignored via its own `.gitignore`). `upgrade` diffs the NEW template against them, so the reported delta is the TEMPLATE's change and never the Phase 3 tailoring the live files carry. Absent (pre-baseline install) -> `upgrade` reports `NO BASELINE` and falls back to a live-vs-template diff | | Stack reference | one of `python.md \| java-kotlin.md \| typescript-react.md \| go.md` | Emitted per the dominant detected stack | @@ -465,15 +556,21 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe | Multi-stack repo | Pick dominant stack for `STACK_REF`; note secondaries in the agent/group tables | | `.codex/agents/intent-guard.toml` already exists | REUSE it — the writer prints `INTENT_GUARD: REUSE ` and does not write the file. Never overwrite, never diff it into shape, never ask. Skip the Phase 3 BLOCK adaptation for it | | `.codex/agents/intent-guard.toml` exists but is EMPTY / has no `name: intent-guard` frontmatter | Not a reusable file — the writer says so and RECREATES it from the template. Then the Phase 3 adaptation applies as for any CREATED file | +| `.codex/agents/intent-guard.toml` carries the retired `intent-guard template vN` stamp | Pre-standard file of ours. The writer prints `INTENT_GUARD: MIGRATED `: the four metadata keys and the tail anchor are restamped, the tailored body is untouched. Do NOT run Phase 3 on it and do NOT re-emit it | +| `enable`/`disable`/`uninstall`/`purge` but nothing installed | The script exits 1 with `❌ not installed` (or `⚠️ nothing to uninstall`). Report it and **STOP** — never emit a fresh install as a "fix" for a removal verb | +| `enable` on a live install, `disable` on a parked one | The script prints `✅ already {enabled\|disabled}` and exits 0. Report it and **STOP**; do not rename | +| `validate` fails right after `disable` | Expected: `validate` looks for `SKILL.md`, which is now `SKILL.md.disabled`. Say "disabled, not broken" and offer `enable`. Never re-`emit` to "repair" it — that would destroy the Phase 4b self-synced edits the parked file still holds | +| `.codex/skills/superreview/` present with neither `SKILL.md` nor `SKILL.md.disabled` | Genuinely broken (a half-deleted install). Report the dir contents, offer `uninstall` then a fresh `install`. Do not guess which file to recreate | | `validate` prints `⚠️ UNTAILORED` | The Phase 3 BLOCK adaptation was skipped or partial (seeded markers survive). Warning, not a failure: go back to Phase 3, replace each seeded block + marker, re-run validate | | No tracker AND no spec/plan/policy dirs | Emit anyway with the defaults; the agent falls back to T5 (the session transcript) and reports its tier in every finding. Do NOT invent paths and do NOT skip the agent | | Target has no writable `.codex/agents/` | `emit` does `mkdir -p .codex/agents` first; a failure there is the same STOP as an unwritable `.codex/` | | Asked to add a `--fast`/`--deep` flag | Refuse — depth is inferred from the prompt by design. A flag would freeze the axis the emitted skill must read semantically | -| Unresolved `{PLACEHOLDER}` after Phase 3 | `validate` fails listing them (including any left in the emitted `intent-guard.md`); fix via Edit, re-run validate | +| Unresolved `{PLACEHOLDER}` after Phase 3 | `validate` fails listing them (including any left in the emitted `intent-guard.toml`); fix via Edit, re-run validate | | `emit` refuses — superreview already installed | Expected, not an error: the live skill carries Phase 4b self-sync corrections, and the refusal prints NO `INTENT_GUARD:` line. Go to Phase 2b and run `upgrade`. Only `SUPERREVIEW_FORCE=1` overwrites, and only on an explicit request for a clean regeneration | | `upgrade` says `DIFFERS` on a file the user hand-edited | `DIFFERS` counts TEMPLATE lines (new template vs `.template-baseline/`), never the user's tailoring. Port that template change onto the live file with Edit; never replace the file with the staged copy. Conflicting section -> ask before replacing it | | `upgrade` says `NO BASELINE` | The install predates `.template-baseline/`, so the printed count is a live-vs-template diff that INCLUDES Phase 3 tailoring — do not treat it as a template delta. Review the staged copy by hand, port only what the template really changed, then promote `.upgrade-staging/.template` to the baseline (command printed by the script) | -| `upgrade` says `MISSING -> restored (NEEDS PHASE 3)` | The restored file is a RAW template with unresolved BLOCK placeholders. Run Phase 3 on it BEFORE Phase 4 — going straight to `validate` fails on those placeholders | +| `upgrade` says `MISSING -> restored RAW` | The restored file is a RAW template: BOTH its BLOCK placeholders AND its scalars (`{PROJECT_NAME}`, `{STACK_LABEL}`, `{SOURCE_GLOB}`, the agent names) are unresolved, on purpose — `upgrade` has no environment to resolve them from and re-defaulting them would bake `this project` / `general-purpose` into a live file that `validate` then passes. Run Phase 3 on it BEFORE Phase 4; `validate` names every token | +| `upgrade` prints `UPGRADE_STACK=none — ❌ NO per-stack reference found` | The install carries none of `python.md` / `typescript-react.md` / `go.md` / `java-kotlin.md` (emitted without one, or it was deleted). The other four artifacts are still restamped; nothing is guessed. Re-run as `STACK_REF=.md generate.sh upgrade` to restore the right one — it then reports `MISSING -> restored RAW` | | Target `.codex/` not writable | STOP — ask the user to run from the repo root | --- @@ -486,13 +583,14 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe - `references/intent-guard.md.template` — the anti-drift agent (asked vs delivered), emitted to `.codex/agents/intent-guard.toml` create-or-reuse. - `references/report-template.md` — emitted merged-report layout. - `references/{python,java-kotlin,typescript-react,go}.md` — per-stack reference docs (one is emitted). -- `scripts/generate.sh` — `scan` / `emit` / `emit-agent` / `upgrade` / `validate` (validate also enforces the +- `scripts/generate.sh` — `scan` / `emit` / `emit-agent` / `upgrade` / `enable` / `disable` / `uninstall` / + `purge` / `validate` (validate also enforces the domain-expert requirement; `emit-agent` is the shared intent-guard writer used standalone by `$brewcode:teams-setup`; `upgrade` refreshes a live installation without destroying its self-synced edits, diffing the NEW template against the pristine `.template-baseline/` copies `emit` saved). + diff --git a/brewcode/.codex/skills/superreview-setup/references/java-kotlin.md b/brewcode/.codex/skills/superreview-setup/references/java-kotlin.md index ece93e7..f2e9ad5 100644 --- a/brewcode/.codex/skills/superreview-setup/references/java-kotlin.md +++ b/brewcode/.codex/skills/superreview-setup/references/java-kotlin.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Java/Kotlin Standards Reference Standards for Java/Kotlin enterprise projects. The project's own rules in `.codex/rules/*` + `.codex/convention/*` diff --git a/brewcode/.codex/skills/superreview-setup/references/python.md b/brewcode/.codex/skills/superreview-setup/references/python.md index 152ffe2..1639d9b 100644 --- a/brewcode/.codex/skills/superreview-setup/references/python.md +++ b/brewcode/.codex/skills/superreview-setup/references/python.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Python Standards Reference GENERIC modern-Python guidance (type hints, docstrings, imports, exceptions, comprehensions, testing). The project's diff --git a/brewcode/.codex/skills/superreview-setup/references/report-template.md b/brewcode/.codex/skills/superreview-setup/references/report-template.md index fd3d31f..7ea009f 100644 --- a/brewcode/.codex/skills/superreview-setup/references/report-template.md +++ b/brewcode/.codex/skills/superreview-setup/references/report-template.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Merged Report Layout (superreview Phase 4 — {PROJECT_NAME}) Output: `.codex/reports/{TIMESTAMP}_superreview/REPORT.md`. ONE consolidated, validated, P0->P3-sorted report. diff --git a/brewcode/.codex/skills/superreview-setup/references/scope.md.template b/brewcode/.codex/skills/superreview-setup/references/scope.md.template index 2b8c4a4..5e62316 100644 --- a/brewcode/.codex/skills/superreview-setup/references/scope.md.template +++ b/brewcode/.codex/skills/superreview-setup/references/scope.md.template @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Scope Discipline Reference (superreview — {PROJECT_NAME}) SINGLE home of: sanctioned-scope resolution, sanction sources + precedence, the ownership map, the scope-creep diff --git a/brewcode/.codex/skills/superreview-setup/references/typescript-react.md b/brewcode/.codex/skills/superreview-setup/references/typescript-react.md index 1e0ffdd..726b707 100644 --- a/brewcode/.codex/skills/superreview-setup/references/typescript-react.md +++ b/brewcode/.codex/skills/superreview-setup/references/typescript-react.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # TypeScript / Node / React Standards Reference Standards for TypeScript, Node.js and React projects. The project's own rules in `.codex/rules/*` + diff --git a/brewcode/.codex/skills/superreview-setup/scripts/generate.sh b/brewcode/.codex/skills/superreview-setup/scripts/generate.sh index 5e43899..6999024 100755 --- a/brewcode/.codex/skills/superreview-setup/scripts/generate.sh +++ b/brewcode/.codex/skills/superreview-setup/scripts/generate.sh @@ -11,23 +11,35 @@ # Also saves PRISTINE copies of the templates it emitted from under .template-baseline/ — that # baseline is what makes `upgrade` able to tell a TEMPLATE change apart from Phase 3 tailoring. # REFUSES to overwrite a live installation (the emitted skill SELF-SYNCS — Phase 4b — so its -# SKILL.md and references/scope.md carry edits no template knows about). SUPERREVIEW_FORCE=1 -# overrides and DESTROYS those edits. +# SKILL.md and references/scope.md carry edits no template knows about). "Live" = ANY emitted +# artifact on disk (see `_live_artifacts`), not SKILL.md alone: a PARTIAL install must not be +# re-substituted with DEFAULT scalars. SUPERREVIEW_FORCE=1 overrides and DESTROYS those edits. # upgrade - Refresh a LIVE installation without touching hand-edits (Phase 2b): stages a fresh emit next # to it and reports, per file, the NEW TEMPLATE vs the .template-baseline/ copy — IDENTICAL | -# DIFFERS (real template delta) | MISSING -> restored (NEEDS PHASE 3) | NO BASELINE (pre-baseline -# install: falls back to live-vs-template, tailoring included). Live files are never written; -# the AI applies the template delta with targeted Edit calls. +# DIFFERS (real template delta) | MISSING -> restored RAW (NEEDS PHASE 3) | NO BASELINE (pre-baseline +# install: falls back to live-vs-template, tailoring included). Live file CONTENT is never +# written; the AI applies the template delta with targeted Edit calls. The one live write is an +# UNCONDITIONAL metadata restamp (version/generated_by/last_updated, one `RESTAMP:` line per +# file, body compared byte-for-byte) — without it a version bump, which reports IDENTICAL on +# every asset, could never clear the `stale` verdict setup-status reads off the emitted +# SKILL.md frontmatter. The per-stack reference is RE-DERIVED from the installed tree +# (see `_installed_stack_refs`), never re-defaulted — see `UPGRADE_STACK=` on stdout. +# Runs on ANY live install, SKILL.md included in the restorable set — so it is also the remedy +# for a partially damaged install, which `emit` refuses to touch. A DISABLED install (parked +# SKILL.md.disabled) is refused with `enable` as its remedy, never silently resurrected. # emit-agent - Create-or-reuse /.codex/agents/intent-guard.toml ONLY. No superreview skill is # written, read or required. Used by $brewcode:teams-setup, which must not author its own copy. # Prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED ` | -# `INTENT_GUARD: REUSE `. Diagnostics go to stderr and never break that contract. +# `INTENT_GUARD: REUSE ` | `INTENT_GUARD: MIGRATED ` (a pre-standard agent of +# ours, restamped in place — tailored body preserved). Diagnostics go to stderr and never +# break that contract. # validate - Fail if any unresolved setup-time {PLACEHOLDER} remains (Phase 4) # # Env overrides (honored by BOTH emit and emit-agent; SUPERREVIEW_FORCE=1 lets emit overwrite a live install): # PROJECT_NAME, TRACKER_LABEL, SPEC_LOCATION, PLAN_LOCATION, POLICY_LOCATION # (emit also honors STACK_LABEL, STACK_REF, SOURCE_GLOB, PATHSPEC_GLOBS, ARBITER_AGENT, -# VALIDATOR_AGENT, SCOPE_AGENT_A, SCOPE_AGENT_B) +# VALIDATOR_AGENT, SCOPE_AGENT_A, SCOPE_AGENT_B; upgrade honors STACK_REF as an override of the +# stack it derives from the installed tree, and ignores the rest — see upgrade_skill) set -euo pipefail @@ -37,6 +49,9 @@ MODE="${1:-emit}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(dirname "$SCRIPT_DIR")" REFS="$SKILL_DIR/references" +# Plugin manifest by SELF-LOCATION: skills/superreview-setup -> skills -> . +# Correct in the dev checkout AND in the installed cache. The version is NEVER hardcoded. +PLUGIN_JSON="$SKILL_DIR/../../.codex-plugin/plugin.json" # Target is the current working directory (the repo being reviewed) TARGET=".codex/skills/superreview" @@ -46,10 +61,17 @@ STAGING="$TARGET/.upgrade-staging" # Pristine copies of the templates the live install was emitted from. `upgrade` diffs the NEW template against # these, so Phase 3 tailoring in the live files can never be mistaken for a template change. BASELINE="$TARGET/.template-baseline" +# Where `disable` parks SKILL.md. Read by `enable`/`disable` and by `upgrade`, which must tell a DISABLED +# install apart from one whose SKILL.md was deleted. +DISABLED_MARK="$TARGET/SKILL.md.disabled" # The one agent file this script owns, and the provenance stamp that proves a file came out of this pipeline. IG_PATH=".codex/agents/intent-guard.toml" -IG_STAMP_PREFIX="`). Its presence +# without IG_STAMP_PREFIX is what proves a file came out of THIS pipeline before the artifact-metadata standard — +# i.e. ours, migratable, and never to be confused with a hand-written agent that carries no stamp at all. +IG_LEGACY_STAMP_RE='`), append the current anchor block. + awk ' + //) drop = 0; next } + { print } + ' "$_bd/agent.md" > "$_bd/agent.next" + printf '\n' >> "$_bd/agent.next" + cat "$_bd/tail" >> "$_bd/agent.next" + cat -s "$_bd/agent.next" > "$_bd/agent.md" + + # POST-CONDITIONS. Both edits are pattern-driven; a silent miss would ship a half-migrated agent that + # still reads as legacy to `setup-status`. + for _k in doc_type version generated_by last_updated; do + grep -q "^${_k}:" "$_bd/agent.md" || { echo "❌ migration aborted: $_k missing after restamp" >&2; return 1; } + done + grep -qF "$IG_STAMP_PREFIX" "$_bd/agent.md" || { echo "❌ migration aborted: current tail anchor not written" >&2; return 1; } + grep -qE "$IG_LEGACY_STAMP_RE" "$_bd/agent.md" && { echo "❌ migration aborted: retired stamp survived" >&2; return 1; } + grep -q '^name:[[:space:]]*intent-guard[[:space:]]*$' "$_bd/agent.md" || { echo "❌ migration aborted: frontmatter name lost" >&2; return 1; } + + mv "$_bd/agent.md" "$IG_PATH" + rm -rf "$_bd"; _bd="" + echo "INTENT_GUARD: MIGRATED $IG_PATH" } write_intent_guard() { @@ -242,14 +453,22 @@ write_intent_guard() { resolve_scalars mkdir -p .codex/agents - if _ig_usable; then - echo "INTENT_GUARD: REUSE $IG_PATH" - return 0 - fi - # Diagnostic only — STDERR, so stdout keeps carrying exactly one `INTENT_GUARD:` status line. - if [ -e "$IG_PATH" ]; then - echo "⚠️ $IG_PATH exists but is empty, has no 'name: intent-guard' frontmatter, or still carries unresolved {PLACEHOLDER} tokens — recreating from template" >&2 - fi + case "$(_ig_kind)" in + CURRENT|FOREIGN) + echo "INTENT_GUARD: REUSE $IG_PATH" + return 0 + ;; + LEGACY) + # Ours, pre-standard. Restamp instead of recreating: the body is the project's own tailoring. + echo "ℹ️ $IG_PATH carries the retired 'intent-guard template vN' stamp — restamping metadata in place, body preserved" >&2 + _ig_migrate + return 0 + ;; + BROKEN) + # Diagnostic only — STDERR, so stdout keeps carrying exactly one `INTENT_GUARD:` status line. + echo "⚠️ $IG_PATH exists but is empty, has no 'name: intent-guard' frontmatter, or still carries unresolved {PLACEHOLDER} tokens — recreating from template" >&2 + ;; + esac # The agent must be RUNNABLE straight out of emit — the emitted skill spawns it at BOTH depths, so a # half-filled agent file breaks a QUICK run entirely. The three BLOCKs therefore get stack-generic @@ -334,10 +553,16 @@ emit_skill() { # The emitted skill SELF-SYNCS (its Phase 4b corrects its own routing table, gates, baseline and shared # surfaces). A blind re-emit would silently erase every one of those corrections, so a live installation is # never overwritten: `upgrade` refreshes it, and SUPERREVIEW_FORCE=1 is the conscious destructive override. - if [ -f "$TARGET/SKILL.md" ] && [ "${SUPERREVIEW_FORCE:-0}" != "1" ]; then - echo "❌ superreview is already installed at $TARGET/SKILL.md" - echo " It SELF-SYNCS (Phase 4b) — overwriting it destroys those in-place corrections." - echo " Use 'generate.sh upgrade' (live files preserved), or SUPERREVIEW_FORCE=1 to overwrite and LOSE them." + # The guard keys on the WHOLE artifact set, not on SKILL.md alone: a PARTIALLY damaged install (SKILL.md + # deleted, every tailored reference still in place) used to slip past it, and emit then re-substituted those + # references with DEFAULT scalars — `this project`, the generic scope, `python.md` over a TypeScript install. + _installed="$(_live_artifact_list)" + if [ -n "$_installed" ] && [ "${SUPERREVIEW_FORCE:-0}" != "1" ]; then + echo "❌ superreview is already installed at $TARGET/ — live artifact(s): $_installed" + echo " It SELF-SYNCS (Phase 4b) — overwriting it destroys those in-place corrections, and re-emitting" + echo " re-substitutes EVERY file with DEFAULT scalars (this project / the project stack / python.md)." + echo " Use 'generate.sh upgrade' (live files preserved; a MISSING one is restored RAW), or SUPERREVIEW_FORCE=1" + echo " to overwrite and LOSE them." exit 1 fi @@ -358,7 +583,7 @@ emit_skill() { echo "✅ $TARGET_REFS/scope.md" if [ -f "$REFS/$STACK_REF" ]; then - cp "$REFS/$STACK_REF" "$TARGET_REFS/$STACK_REF" + _subst "$REFS/$STACK_REF" "$TARGET_REFS/$STACK_REF" echo "✅ $TARGET_REFS/$STACK_REF" else echo "⚠️ stack reference not found: $REFS/$STACK_REF (emitted without per-stack doc)" @@ -380,6 +605,62 @@ emit_skill() { echo " — then run: generate.sh validate" } +# ── shared: in-place metadata restamp ────────────────────────────────────────── +# First frontmatter block of a file -> stdout. One reader for every check below, so "the frontmatter" means +# the same lines everywhere — `references/scope.md` carries a second `---` in its body and must not confuse it. +_fm_block() { awk 'NR == 1 && $0 == "---" { f = 1; next } f && $0 == "---" { exit } f { print }' "$1"; } +# Everything AFTER that block -> stdout. Used as the did-not-touch-the-body proof. +_fm_body() { awk 'NR == 1 && $0 == "---" { f = 1; next } f == 1 && $0 == "---" { f = 2; next } f == 2 { print }' "$1"; } + +# Refresh ONLY `version` / `generated_by` / `last_updated` in a LIVE file's own frontmatter, in place. +# $1 = live file, $2 = its freshly substituted staging counterpart — the single source for the spelling of the +# three values, exactly as `_ig_migrate` takes them from the substituted template. `doc_type` is PRESERVED when +# present (§1 of the artifact-metadata spec: it is user-owned) and seeded as `llm` only when the file has none. +# The body is copied through untouched and then compared byte-for-byte; a mismatch aborts rather than shipping a +# file whose Phase 3 tailoring or Phase 4b self-sync edits were silently mangled. +_restamp_meta() { + _live="$1"; _src="$2" + if [ "$(head -1 "$_live")" != "---" ]; then + echo "⚠️ RESTAMP: $_live has no frontmatter block — left untouched" >&2 + return 0 + fi + _bd="$(mktemp -d)" + _fm_block "$_src" | grep -E '^(version|generated_by|last_updated):[[:space:]]' > "$_bd/meta" || true + if [ "$(grep -c . "$_bd/meta" || true)" -ne 3 ]; then + echo "❌ restamp aborted: $_src frontmatter carries no version/generated_by/last_updated trio" >&2 + return 1 + fi + # Materialise the live frontmatter once: a `grep -q` / `head -1` on a live pipe can SIGPIPE the awk + # upstream, and under `pipefail` that reads as a failure (repo rule avoid#7). + _fm_block "$_live" > "$_bd/live.fm" + _was=$(sed -n 's/^version:[[:space:]]*//p' "$_bd/live.fm" | sed -n 1p || true) + _needdt=0 + grep -q '^doc_type:' "$_bd/live.fm" || _needdt=1 + + awk -v metaf="$_bd/meta" -v needdt="$_needdt" ' + NR == 1 && $0 == "---" { fm = 1; print; next } + fm == 1 && $0 == "---" { + if (needdt == 1) print "doc_type: llm" + while ((getline l < metaf) > 0) print l + close(metaf); fm = 2; print; next + } + fm == 1 && /^(version|generated_by|last_updated):[[:space:]]/ { next } + { print } + ' "$_live" > "$_bd/next" + + # POST-CONDITIONS. The body must be identical, and the result must satisfy the same frontmatter gate + # `validate` applies — one dialect, checked here so a bad restamp never reaches the user's tree. + _fm_body "$_live" > "$_bd/body.old"; _fm_body "$_bd/next" > "$_bd/body.new" + cmp -s "$_bd/body.old" "$_bd/body.new" \ + || { echo "❌ restamp aborted: $_live body changed — nothing written" >&2; return 1; } + _check_meta_frontmatter "$_bd/next" \ + || { echo "❌ restamp aborted: $_live would fail the metadata gate — nothing written" >&2; return 1; } + + mv "$_bd/next" "$_live" + rm -rf "$_bd"; _bd="" + echo "RESTAMP: $_live version ${_was:-(none)} -> \"$PLUGIN_VERSION\", generated_by/last_updated refreshed (body untouched)" +} + # ── upgrade: refresh a LIVE installation, hand-edits preserved ────────────────── # The emitted skill is EXPECTED to have self-modified (its Phase 4b SELF-SYNC) and to carry Phase 3 tailoring, so # no live file is ever written over AND no live file is ever the diff baseline: comparing a tailored install to a @@ -391,10 +672,52 @@ upgrade_skill() { echo "=== superreview: upgrade ===" validate_templates - if [ ! -f "$TARGET/SKILL.md" ]; then - echo "❌ nothing to upgrade: $TARGET/SKILL.md does not exist — run 'generate.sh emit' first" + # A LIVE INSTALL is the requirement, not SKILL.md specifically. Gating on SKILL.md alone left the one state + # this mode exists for — SKILL.md deleted, every tailored reference intact — with `emit` as its only advertised + # remedy, and `emit` re-defaults every one of those references. The restore loop below already handles a + # missing artifact correctly (RAW, out of `.template/`, NEEDS PHASE 3), and SKILL.md is in that set. + _installed="$(_live_artifact_list)" + if [ -z "$_installed" ]; then + echo "❌ nothing to upgrade: no superreview artifact under $TARGET/ — run 'generate.sh emit' first" exit 1 fi + if [ ! -f "$TARGET/SKILL.md" ]; then + if [ -f "$DISABLED_MARK" ]; then + # Parked, not damaged. Restoring a RAW SKILL.md here would resurrect the skill behind the user's back and + # leave two copies of it, so the remedy is the reversible one that already exists. + echo "❌ superreview is DISABLED: $DISABLED_MARK is parked in place of $TARGET/SKILL.md" + echo " Run 'generate.sh enable' first, then upgrade." + exit 1 + fi + echo "ℹ️ $TARGET/SKILL.md is MISSING from an otherwise live install — it is restored RAW below (NEEDS PHASE 3);" + echo " every other artifact keeps its tailoring untouched." + fi + + # STACK — re-derived from the INSTALLED tree BEFORE any scalar resolves. `resolve_scalars` would otherwise fall + # back to `python.md`, and every loop below iterates `references/$STACK_REF`: on a TypeScript/Go/Java-Kotlin + # install the project's REAL reference would never be staged, never be restamped, and stay behind at the old + # version forever — so `setup-status` keeps printing `stale` after a successful upgrade. An explicit STACK_REF in + # the environment still wins (documented override); nothing else re-defaults. + if [ -n "${STACK_REF:-}" ]; then + STACK_REFS="$STACK_REF" + echo "UPGRADE_STACK=$STACK_REFS (STACK_REF override)" + else + STACK_REFS="$(_installed_stack_refs | tr '\n' ' ' | sed 's/[[:space:]]*$//')" + if [ -n "$STACK_REFS" ]; then + # >1 = multi-stack install: all of them are live artifacts, all of them get restamped. The scalar keeps the + # first, which is only ever substituted into TEXT (`references/{STACK_REF}` prose). + STACK_REF="${STACK_REFS%% *}" + echo "UPGRADE_STACK=$STACK_REFS (derived from the installed tree)" + else + # NOT determinable: emitted without a per-stack doc, or the reference was deleted by hand. Guessing is the + # bug this block exists to remove, so nothing is guessed and nothing per-stack is staged — but the run does + # NOT abort: the other four artifacts still need their restamp or `status` reads `stale` forever. + STACK_REF="none" + echo "UPGRADE_STACK=none — ❌ NO per-stack reference found in $TARGET_REFS/ or $BASELINE/references/" + echo " (candidates: $(_stack_catalog | tr '\n' ' ' | sed 's/[[:space:]]*$//')). No stack doc is staged or restamped; the other four" + echo " artifacts are. Re-run as: STACK_REF=.md generate.sh upgrade — it is then restored RAW." + fi + fi resolve_scalars rm -rf "$STAGING" @@ -406,20 +729,30 @@ upgrade_skill() { _subst "$REFS/report-template.md" "$STAGING/references/report-template.md" _subst "$REFS/scope.md.template" "$STAGING/references/scope.md" # `|| true`: a missing per-stack ref must not abort the run under `set -e`. - { [ -f "$REFS/$STACK_REF" ] && cp "$REFS/$STACK_REF" "$STAGING/references/$STACK_REF"; } || true + for _s in $STACK_REFS; do + { [ -f "$REFS/$_s" ] && _subst "$REFS/$_s" "$STAGING/references/$_s"; } || true + done # Raw NEW templates, in the same shape as the baseline — this pair is what the delta is computed from. copy_raw_templates "$STAGING/.template" echo "UPGRADE_STAGING=$STAGING" echo "UPGRADE_BASELINE=$BASELINE" + # The live artifact set: four stack-independent files plus every per-stack reference this install carries. + _rels="SKILL.md references/agent-prompt.md references/report-template.md references/scope.md" + for _s in $STACK_REFS; do _rels="$_rels references/$_s"; done + _restored=0 - for _rel in "SKILL.md" "references/agent-prompt.md" "references/report-template.md" \ - "references/scope.md" "references/$STACK_REF"; do + for _rel in $_rels; do [ -f "$STAGING/$_rel" ] || continue if [ ! -f "$TARGET/$_rel" ]; then - cp "$STAGING/$_rel" "$TARGET/$_rel" + # RAW, from `.template/` — never the substituted staging copy. `upgrade` runs with a bare environment, so + # every scalar in that copy would be the DEFAULT ("this project", "the project stack", `general-purpose`, + # `Explore`), i.e. the install-time decision silently re-guessed and baked into a live file that `validate` + # then passes. Restoring RAW makes each one an unresolved {TOKEN} that `validate` lists by name, which is + # exactly the NEEDS PHASE 3 contract. The metadata trio is refreshed by the restamp loop below. + cp "$STAGING/.template/$_rel" "$TARGET/$_rel" _restored=$((_restored+1)) - echo "UPGRADE: $_rel MISSING -> restored (NEEDS PHASE 3)" + echo "UPGRADE: $_rel MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)" elif [ -f "$BASELINE/$_rel" ]; then if cmp -s "$BASELINE/$_rel" "$STAGING/.template/$_rel"; then echo "UPGRADE: $_rel IDENTICAL (template unchanged since install — live file untouched)" @@ -435,7 +768,19 @@ upgrade_skill() { fi done - # Same create-or-reuse writer as emit: a usable intent-guard.md is REUSED byte-untouched. + # Restamp the LIVE files. Unconditional, and deliberately NOT gated on the IDENTICAL/DIFFERS verdict above: + # a plain version bump changes no template line, so every asset reports IDENTICAL — yet the emitted + # `SKILL.md` frontmatter `version:` is exactly what setup-status reads to decide `stale`. Without this an + # `upgrade` reported success and left the stamp untouched, so the next `status` printed `stale` forever. + # Same `$_rels` set as the delta report above — including the project's REAL per-stack reference, whatever it is. + for _rel in $_rels; do + [ -f "$TARGET/$_rel" ] || continue + [ -f "$STAGING/$_rel" ] || continue + _restamp_meta "$TARGET/$_rel" "$STAGING/$_rel" || exit 1 + done + + # Same writer as emit: a current or hand-written intent-guard.toml is REUSED byte-untouched, and a + # pre-standard one of ours is MIGRATED here — this is the `upgrade restamps it` path setup-status promises. write_intent_guard echo "" @@ -446,6 +791,29 @@ upgrade_skill() { echo " rm -rf \"$BASELINE\" && mv \"$STAGING/.template\" \"$BASELINE\" && rm -rf \"$STAGING\" && generate.sh validate" } +# Artifact-metadata frontmatter gate: the four keys, in D2 order, quoted exactly as +# `brewcode/skills/rules/scripts/rules.sh:140-146` already requires — one dialect, not a second one. +# Prints one line per defect, returns 1 when any fired. +_check_meta_frontmatter() { + _f="$1"; _bad=0 + _fm=$(awk 'NR == 1 && $0 == "---" { f = 1; next } f && $0 == "---" { exit } f { print }' "$_f") + for _k in doc_type version generated_by last_updated; do + printf '%s\n' "$_fm" | grep -q "^${_k}:" || { echo "❌ $_f frontmatter missing metadata key: $_k"; _bad=1; } + done + printf '%s\n' "$_fm" | grep -q '^doc_type: llm$' \ + || { echo "❌ $_f doc_type must be exactly 'llm', UNQUOTED"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^version: "[0-9]+\.[0-9]+\.[0-9]+"$' \ + || { echo "❌ $_f version must be a QUOTED X.Y.Z"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^generated_by: "[^"]+"$' \ + || { echo "❌ $_f generated_by must be a QUOTED :"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^last_updated: "[0-9]{4}-[0-9]{2}-[0-9]{2}"$' \ + || { echo "❌ $_f last_updated must be a QUOTED YYYY-MM-DD"; _bad=1; } + _order=$(printf '%s\n' "$_fm" | grep -oE '^(doc_type|version|generated_by|last_updated)' | tr '\n' ' ' || true) + [ "$_order" = "doc_type version generated_by last_updated " ] \ + || { echo "❌ $_f metadata keys out of order [$_order] — must be doc_type, version, generated_by, last_updated"; _bad=1; } + return "$_bad" +} + # ── validate: no setup-time {PLACEHOLDER} may remain ──────────────────────────── validate_emit() { echo "=== superreview: validate ===" @@ -456,13 +824,18 @@ validate_emit() { fi # Runtime tokens the emitted skill legitimately keeps (resolved at REVIEW time, not GENERATION time). + # This list is NOT the shell-variable escape hatch — `_scan_tokens` handles `${VAR}` now. `MAIN`, `ROOT`, `TOK` + # and `REPORT_DIR` occur in SKILL.md.template ONLY as `${…}` expansions and never as bare tokens; they are kept + # here as harmless no-ops rather than removed, but do NOT add a name here to silence a shell variable — that is + # the workaround that hid the collision until an adapted artifact used a variable nobody had allowlisted. _runtime='MODE|DEPTH|BRANCH|SCOPE|FILES|COUNT|TIMESTAMP|FOCUS|FILE_LIST|AGENT_LIST|CANDIDATES|MERGED|PATHSPEC|MAIN|SHA|FOLDER|GROUP|AGENT|N|OC|SC|K|U|D|ROOT|TOK|RANGE|REPORT_DIR|SCOPE_BASELINE|OWNERSHIP|GATE_RESULTS|PR_ISSUE_JSON|INTENT_VERDICT|USER_REQUEST' _errors=0 - for f in "$TARGET/SKILL.md" "$TARGET_REFS/agent-prompt.md" "$TARGET_REFS/report-template.md" \ - "$TARGET_REFS/scope.md"; do + # Every emitted reference, not a fixed list: the per-stack ref is substituted too, so an unresolved + # metadata token in it must fail the gate like any other. + for f in "$TARGET/SKILL.md" "$TARGET_REFS"/*.md; do [ -f "$f" ] || continue - _unresolved=$(grep -oE '\{[A-Z_]+\}' "$f" | sort -u | grep -vE "^\{(${_runtime})\}$" || true) + _unresolved=$(_scan_tokens "$f" | sort -u | grep -vE "^\{(${_runtime})\}$" || true) if [ -n "$_unresolved" ]; then echo "❌ unresolved setup-time placeholders in $f:" echo "$_unresolved" @@ -519,21 +892,29 @@ EOF _errors=$((_errors+1)) fi done - # intent-guard is EXECUTED at both depths: an empty or frontmatter-less file is as broken as a missing one. - if ! _ig_usable; then - if [ -e "$IG_PATH" ]; then + # intent-guard is EXECUTED at both depths: an empty or frontmatter-less file is as broken as a missing one, + # and a LEGACY one is a live agent whose restamp never ran — both are failures with a one-command fix. + _ig_state="$(_ig_kind)" + case "$_ig_state" in + BROKEN) echo "❌ unusable emitted asset: $IG_PATH (empty, no 'name: intent-guard' frontmatter, or unresolved {PLACEHOLDER} tokens) — re-run 'generate.sh emit-agent'" - else + _errors=$((_errors+1)) + ;; + LEGACY) + echo "❌ pre-standard emitted asset: $IG_PATH still carries the retired 'intent-guard template vN' stamp and none of the four metadata keys — run 'generate.sh emit-agent' (or 'upgrade') to restamp it; the tailored body is preserved" + _errors=$((_errors+1)) + ;; + ABSENT) echo "❌ missing emitted asset: $IG_PATH" - fi - _errors=$((_errors+1)) - fi + _errors=$((_errors+1)) + ;; + esac # (c2) TEMPLATE-DERIVED agents only. A file carrying the template stamp came out of this pipeline, so every # {PLACEHOLDER} in it must be resolved (scalars by emit, the three BLOCKs by AI Edit in SKILL.md Phase 3). # A REUSED hand-written intent-guard is byte-untouchable by contract — it is not judged by template rules. - if _ig_usable && grep -qF "$IG_STAMP_PREFIX" "$IG_PATH"; then - _ig_unresolved=$(grep -oE '\{[A-Z_]+\}' "$IG_PATH" | sort -u || true) + if [ "$_ig_state" = "CURRENT" ]; then + _ig_unresolved=$(_scan_tokens "$IG_PATH" | sort -u || true) if [ -n "$_ig_unresolved" ]; then echo "❌ unresolved placeholders in $IG_PATH (no token is runtime here):" echo "$_ig_unresolved" @@ -543,6 +924,7 @@ EOF echo "❌ $IG_PATH still carries the TEMPLATE HEADER comment — emit must strip it" _errors=$((_errors+1)) fi + _check_meta_frontmatter "$IG_PATH" || _errors=$((_errors+1)) # (c3) TAILORING. Seeded BLOCK defaults are a runnable floor, not the target: a run that skipped the # Phase 3 adaptation ships boilerplate and would otherwise pass every gate silently. WARN, not fail — # `emit-agent` is a legitimate standalone path whose adaptation happens in the caller's own flow. @@ -552,8 +934,8 @@ EOF grep -nF "$IG_SEED_MARK" "$IG_PATH" || true echo " INTENT_GUARD: UNTAILORED $IG_PATH ($_ig_seeded seeded block(s)) — run SKILL.md Phase 3 and replace each block + its marker" fi - elif _ig_usable; then - echo "ℹ️ $IG_PATH carries no template stamp — treated as the project's own hand-written agent, not checked against the template" + elif [ "$_ig_state" = "FOREIGN" ]; then + echo "ℹ️ $IG_PATH carries no template stamp of any generation — treated as the project's own hand-written agent, not checked against the template" fi # (d) DOMAIN EXPERTS — a review routed only to generic agents is a degraded review. @@ -610,19 +992,105 @@ EOF exit "$_errors" } +# --- enable / disable ------------------------------------------------------ +# Codex discovers a project skill only through /SKILL.md. Parking that ONE file as +# SKILL.md.disabled makes /superreview vanish while references/, .template-baseline/ and every +# Phase 3 tailoring stay exactly where they are, so the toggle is reversible and lossless. +# intent-guard is NEVER parked: it is shared with $brewcode:teams-setup and belongs to whichever +# install put it there. ($DISABLED_MARK is defined next to $TARGET, above.) + +toggle_skill() { + _want="$1" # enable | disable + if [ "$_want" = "disable" ]; then _from="$TARGET/SKILL.md"; _to="$DISABLED_MARK" + else _from="$DISABLED_MARK"; _to="$TARGET/SKILL.md"; fi + + echo "=== superreview: $_want ===" + if [ ! -d "$TARGET" ]; then + echo "❌ not installed: $TARGET does not exist — run 'generate.sh emit' first" + exit 1 + fi + if [ -f "$_to" ] && [ ! -f "$_from" ]; then + echo "✅ already ${_want}d — $_to is in place, nothing to move" + exit 0 + fi + if [ ! -f "$_from" ]; then + echo "❌ broken installation: neither $TARGET/SKILL.md nor $DISABLED_MARK exists" + exit 1 + fi + mv "$_from" "$_to" + echo "MOVED: $_from -> $_to" + echo "KEPT: $TARGET_REFS/ $BASELINE/ $IG_PATH" + echo "✅ $_want (takes effect in the NEXT session — skills are discovered at session start)" +} + +# --- uninstall / purge ----------------------------------------------------- +# uninstall removes the MACHINERY (the generated skill dir); purge additionally removes the DATA +# (the review reports it produced). Same machinery/data split as $brewtools:task-board-setup. +# intent-guard survives BOTH: shared with $brewcode:teams-setup, and deleting it would break a +# team install that has nothing to do with superreview. +REPORT_GLOB=".codex/reports" + +remove_skill() { + _purge="$1" # 0 = uninstall, 1 = purge + _label=$([ "$_purge" = "1" ] && echo purge || echo uninstall) + echo "=== superreview: $_label ===" + + _found=0 + if [ -d "$TARGET" ]; then + rm -rf "$TARGET" + echo "REMOVED: $TARGET/ (SKILL.md, references/, .template-baseline/, any staging)" + _found=1 + else + echo "SKIP: $TARGET/ absent" + fi + + if [ "$_purge" = "1" ]; then + _reports=$({ find "$REPORT_GLOB" -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | sort) + if [ -n "$_reports" ]; then + printf '%s\n' "$_reports" | while IFS= read -r _d; do + [ -n "$_d" ] || continue + rm -rf "$_d" + echo "REMOVED: $_d/" + done + _found=1 + else + echo "SKIP: no .codex/reports/*_superreview/ to remove" + fi + else + _rc=$({ find "$REPORT_GLOB" -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | wc -l | tr -d ' ') + echo "KEPT: $_rc review report dir(s) under $REPORT_GLOB/ — 'purge' deletes those too" + fi + + if [ -f "$IG_PATH" ]; then + echo "KEPT: $IG_PATH — shared with $brewcode:teams-setup, never deleted by either skill" + fi + + [ "$_found" = "1" ] || { echo "⚠️ nothing to $_label — superreview was not installed here"; exit 0; } + echo "✅ $_label" +} + case "$MODE" in scan) scan_target ;; emit) emit_skill ;; emit-agent) emit_agent_only ;; upgrade) upgrade_skill ;; + enable) toggle_skill enable ;; + disable) toggle_skill disable ;; + uninstall) remove_skill 0 ;; + purge) remove_skill 1 ;; validate) validate_emit ;; *) - echo "Usage: generate.sh " + echo "Usage: generate.sh " echo " emit refuses to overwrite a live installation (SUPERREVIEW_FORCE=1 overrides, DESTROYS self-sync edits)" echo " emit-agent create-or-reuse /.codex/agents/intent-guard.toml ONLY (no superreview skill needed);" - echo " prints 'INTENT_GUARD: CREATED ' or 'INTENT_GUARD: REUSE '" + echo " prints 'INTENT_GUARD: CREATED|REUSE|MIGRATED ' (MIGRATED = pre-standard agent restamped in place)" echo " upgrade refresh a live installation; reports NEW template vs .template-baseline/ (the real template" echo " delta, tailoring excluded), restores missing assets RAW (NEEDS PHASE 3), never overwrites" + echo " enable rename .codex/skills/superreview/SKILL.md.disabled back to SKILL.md" + echo " disable rename .codex/skills/superreview/SKILL.md to SKILL.md.disabled — /superreview stops being" + echo " discovered; references/, .template-baseline/ and all tailoring are untouched, reversible" + echo " uninstall delete .codex/skills/superreview/; KEEPS the review reports and intent-guard.toml" + echo " purge uninstall + delete .codex/reports/*_superreview/; still keeps intent-guard.toml" exit 1 ;; esac diff --git a/brewcode/.codex/skills/teams-setup/README.md b/brewcode/.codex/skills/teams-setup/README.md index 7e4fe90..e9a336e 100644 --- a/brewcode/.codex/skills/teams-setup/README.md +++ b/brewcode/.codex/skills/teams-setup/README.md @@ -17,14 +17,18 @@ Analyzes the project, proposes agent variants (minimal/balanced/maximum), create | Status | `$brewcode:teams-setup status ` | Read-only report: agent health, success rates, issues, insights | | Install | `$brewcode:teams-setup install [prompt]` | Analyze project, propose team, create agents + tracking framework | | Upgrade | `$brewcode:teams-setup upgrade ` | Analyze performance, tune or replace underperformers | +| Enable | `$brewcode:teams-setup enable ` | Restore a disabled team: every parked `.md.disabled` is renamed back to `.md` | +| Disable | `$brewcode:teams-setup disable ` | Park the team without deleting it: each `.md` becomes `.md.disabled`, so Codex stops discovering it. `team.md`, `trace.jsonl` and the archive are untouched | | Uninstall | `$brewcode:teams-setup uninstall ` | Archive old tracking data, remove inactive agents | | Purge | `$brewcode:teams-setup purge ` | Total removal: every domain agent + `.codex/teams//` incl. the archive. Confirmed once, not recoverable | No arguments: `status` of the first existing team, or `install` of a team named `default` when none exists. -`enable` / `disable` are rejected with an error — a team either exists or it does not. The same parser guard makes `purge` a mode instead of a team name: in earlier versions any unrecognised first word became a team name, so `$brewcode:teams-setup purge` installed a team called `purge`. +The verb always comes first and the optional `` after it. That parser guard is why `purge` is a mode instead of a team name: in earlier versions any unrecognised first word became a team name, so `$brewcode:teams-setup purge` installed a team called `purge`. -`purge` keeps exactly one thing: `.codex/agents/intent-guard.toml`, shared with `$brewcode:superreview-setup`. +`disable` is a rename, not a deletion — the roster rows stay in `team.md` with `Status: disabled`, and `verify-team.sh` reports `DISABLED` per parked member and still exits PASS. `enable` puts it all back. Both take effect for the NEXT session: agent discovery is read at session start. + +`purge` keeps exactly one thing: `.codex/agents/intent-guard.toml`, shared with `$brewcode:superreview-setup`. It removes both `.toml` and `.toml.disabled`, so purging a disabled team leaves nothing behind. ## Examples @@ -41,6 +45,12 @@ $brewcode:teams-setup status backend # Tune agents based on tracking data $brewcode:teams-setup upgrade backend +# Park the team without losing it -- agents leave the roster, history stays +$brewcode:teams-setup disable backend + +# Put it back +$brewcode:teams-setup enable backend + # Clean up after a long project phase $brewcode:teams-setup uninstall backend @@ -73,7 +83,7 @@ After `$brewcode:teams-setup install my-team`: agents/ agent-one.md # Domain agents (5-20 depending on variant) agent-two.md - intent-guard.md # Fixed review-only member, every team, not counted + intent-guard.toml # Fixed review-only member, every team, not counted teams/ my-team/ team.md # Roster: agent list, domains, missions, status @@ -176,9 +186,10 @@ Every team gets `intent-guard` in addition to its domain agents. It is an **anti **Single writer (idempotent):** `teams` never authors this file. It runs `superreview-setup/scripts/generate.sh emit-agent`, which creates it from the shared template or reuses an -existing one and prints `INTENT_GUARD: CREATED|REUSE `. On `REUSE` -- typically because +existing one and prints `INTENT_GUARD: CREATED|REUSE|MIGRATED `. On `REUSE` -- typically because `$brewcode:superreview-setup` ran first -- the file is left exactly as is and only the `team.md` roster row is -added. On `CREATE`, one `agent-creator` pass tailors the three seeded generic blocks (project +added. `MIGRATED` means a pre-5.0 file of ours was restamped in place (metadata only, tailored body +preserved); treat it like `REUSE` -- no adaptation pass. On `CREATE`, one `agent-creator` pass tailors the three seeded generic blocks (project invariants, drift examples, evidence commands) and touches nothing else -- frontmatter and header stay as emitted. Both skills therefore converge on one shared file produced by one pipeline, never two variants. diff --git a/brewcode/.codex/skills/teams-setup/SKILL.md b/brewcode/.codex/skills/teams-setup/SKILL.md index e55701c..3b433f0 100644 --- a/brewcode/.codex/skills/teams-setup/SKILL.md +++ b/brewcode/.codex/skills/teams-setup/SKILL.md @@ -29,11 +29,28 @@ Manage dynamic teams of domain-specific agents with tracking framework. bash "/scripts/detect-mode.sh" "" && echo "OK" || echo "FAILED" ``` -Output: `MODE:`, `TEAM_NAME:`, `PROMPT:` (optional). Store all three. +Output: `MODE:`, `TEAM_NAME:`, `PROMPT:` (optional), plus the artifact-metadata scalars +`PLUGIN_VERSION:`, `GENERATED_BY:`, `LAST_UPDATED:`. Store all of them. -`MODE` is one of `status | install | upgrade | uninstall | purge`. The script prints `ERROR:...` and -exits 1 for `enable` / `disable` — teams-setup has no enable/disable state. On any `ERROR:` line: -report it verbatim and **STOP**. Never guess a mode, and never treat a canonical verb as a team name. +> **Artifact metadata — every file this skill writes.** `team.md` and every generated domain agent carry +> `version` = `PLUGIN_VERSION:`, `generated_by` = `GENERATED_BY:` (`brewcode:teams-setup`), +> `last_updated` = `LAST_UPDATED:`, and `doc_type: llm` on the agents. Take the values from the output +> above — never hardcode a version, never call `date` a second time with a different format, and never +> stamp a "template version": the plugin version replaces it. +> `.codex/agents/intent-guard.toml` is the ONE exception: `generate.sh emit-agent` stamps it with +> `generated_by: brewcode:superreview-setup`, and teams never touches those keys. + +`MODE` is one of the canonical seven, in this order: `status | install | upgrade | enable | disable | +uninstall | purge`. On any `ERROR:` line: report it verbatim and **STOP**. Never guess a mode, and +never treat a canonical verb as a team name — `install enable` creates a team NAMED `enable`, so the +verb always comes first and the optional `[name]` positional after it. + +> **How a team is enabled or disabled.** Codex discovers a project agent only through +> `.codex/agents/.toml`. `disable` renames each member to `.toml.disabled`; `enable` renames +> it back. The file body, `team.md`, `trace.jsonl`, `trace-archive.jsonl` and the cursor are untouched +> either way, so the toggle is fully reversible and loses no configuration and no history. It is NOT +> an uninstall: nothing is deleted. `intent-guard` is never parked — it is shared with +> `$brewcode:superreview-setup`, exactly as in UNINSTALL and PURGE. --- @@ -176,6 +193,26 @@ If "Mixed" -- ask model per agent in C3. Store as `DEFAULT_MODEL` (default: high 1. Read `/references/agent-template.md` 2. For each agent, spawn `Codex delegation brief (task_role="brewcode:agent-creator")` — ONE agent file per spawn, never "create the whole team" in one task. Prompt carries GOAL (this roster is being built for {TEAM_NAME}; siblings own the other domains), ROLE (owns `.codex/agents/{name}.toml` only), SCOPE (that file; out of bounds: other agents, team.md, project source), CONTEXT (mission + domain + project analysis from C1 are settled; reasoning_tier={DEFAULT_MODEL or per-agent} chosen in C2; the 3-4 sibling agent-creators in this batch own {COLLEAGUE_NAMES} — stay off their domains and do not duplicate their triggers), CONSUMER (C4 writes `.codex/teams/{TEAM_NAME}/team.md` from your path + description line, C5 quorum-reviews the file, and colleagues re-delegate to it by domain via the sub-agent task Acceptance Protocol), DONE (file written, `description` <= 100 chars (optimal ~80), single line, role + 2-3 triggers, no `` blocks; report path + description line). + + Every spawn prompt MUST also carry the template path and the four metadata lines, resolved — the + subagent cannot see Phase 1's output, so **replace `{PLUGIN_VERSION}` and `{LAST_UPDATED}` below with + the literal values from the Phase 1 `PLUGIN_VERSION:` / `LAST_UPDATED:` lines before you send the + prompt.** A token that reaches the subagent ships verbatim into the agent file, and `setup-status` + then reports that agent `partial` forever. Those two spellings are the only sanctioned ones — never an + angle form, never a double brace: + + ``` + CONTEXT (cont.): structure from /references/agent-template.md — read it first. + DONE (cont.): the frontmatter ends with exactly these four keys, in this order, AFTER the agent's + own keys (name, description, model, tools — leave those byte-untouched, `tools` above all): + doc_type: llm + version: "{PLUGIN_VERSION}" + generated_by: "brewcode:teams-setup" + last_updated: "{LAST_UPDATED}" + ``` + + `verify-team.sh` re-reads every generated agent's frontmatter and FAILS on a wrong order, a missing + key or wrong quoting, so a prompt that shipped a token does not pass C4. 3. Batch 3-4 agents in parallel per message 4. After each batch, optimize: ``` @@ -207,22 +244,27 @@ bash "/../superreview-setup/scripts/generate.sh" emit-agent && ``` It creates-or-reuses ONLY `.codex/agents/intent-guard.toml` (superreview does not need to have run) and -prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED ` or -`INTENT_GUARD: REUSE `. Diagnostics (e.g. "recreating from template") go to stderr and never -add a second status line. +prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED `, +`INTENT_GUARD: REUSE ` or `INTENT_GUARD: MIGRATED ` (a pre-standard file of ours, restamped +in place — metadata only, tailored body preserved). Diagnostics (e.g. "recreating from template") go to +stderr and never add a second status line. > **STOP if FAILED** -- report the script output; do not fall back to hand-authoring the file. **Step 2 — sanity-check the emitted file** (a pre-existing file may be empty, truncated or -placeholder-laden; `-f` alone proves nothing): +placeholder-laden; `-f` alone proves nothing). This runs on the REUSE path too, where `$f` is somebody's +already-adapted agent whose evidence block legitimately holds shell expansions — so strip `${VAR}` FIRST +and match bare tokens on what is left. Without the strip a `${BASE}` scores as an unresolved placeholder, +and this step's remedy is `rm -f`: it would delete a tailored file. ```bash f=.codex/agents/intent-guard.toml -[ -s "$f" ] && grep -q '^name: intent-guard' "$f" && ! grep -q '{[A-Z_]\{2,\}}' "$f" && echo "SANE" || echo "CORRUPT" +[ -s "$f" ] && grep -q '^name: intent-guard' "$f" \ + && ! sed 's/\${[A-Z_][A-Z_]*}//g' "$f" | grep -q '{[A-Z_]\{2,\}}' && echo "SANE" || echo "CORRUPT" ``` - `CORRUPT` -> `rm -f .codex/agents/intent-guard.toml`, re-run Step 1 once (a fresh emit is now a `CREATED`), re-check. Still `CORRUPT` -> **STOP** and report; do not patch it by hand. -**Step 3 — adapt the seeded BLOCKs.** Only on `INTENT_GUARD: CREATED`. On `REUSE` skip this step -entirely: the existing file is already project-adapted and must not be rewritten or "refreshed". +**Step 3 — adapt the seeded BLOCKs.** Only on `INTENT_GUARD: CREATED`. On `REUSE` or `MIGRATED` skip this +step entirely: the existing file is already project-adapted and must not be rewritten or "refreshed". `emit-agent` seeds three BLOCKs with GENERIC marked defaults. Spawn ONE `Codex delegation brief (task_role="brewcode:agent-creator")`, alone (not batched with the domain agents), to replace @@ -266,11 +308,22 @@ Codex delegation brief (task_role="brewcode:agent-creator", message=" ") ``` -**Step 4 — verify:** +**Step 4 — verify.** FOUR counts, one grep per line, in this order. Each pattern matches the ARTIFACT, +never prose ABOUT it: the emitted agent legitimately keeps a tail comment that NAMES the stripped +`TEMPLATE HEADER`, so an unanchored `grep -c 'TEMPLATE HEADER'` reports `1` on every healthy file and +turns this gate into an unpassable loop. Match the header's opening line, not the phrase. Same reason the +placeholder count strips `${VAR}` first: `{PROJECT_NAME}` is a token, `` in an adapted +evidence command is not, and only a strip-then-match tells them apart — a `$`-guard inside the pattern +mis-handles adjacent tokens. `|| true` on every line: zero matches is the happy path for three of the four +counts (repo rule avoid#7), and a count must still PRINT under `set -o pipefail`, especially when it is the +one going red. + ```bash f=.codex/agents/intent-guard.toml -grep -c '{[A-Z_]\{2,\}}' "$f"; grep -c 'TEMPLATE HEADER' "$f"; grep -c '^name: intent-guard' "$f" -grep -c 'SEEDED-DEFAULT' "$f" +sed 's/\${[A-Z_][A-Z_]*}//g' "$f" | grep -c '{[A-Z_]\{2,\}}' || true # 0 — unresolved placeholder +grep -c '^` on line 1 of a byte-copied `.md`. Versions +always come from `.claude-plugin/plugin.json`, never hardcoded, never `unknown`. `/brewcode:setup-status` reads these back across all ten `-setup` skills and flags any +artifact running on an older version than the installed plugin. + ## Documentation Full docs: [doc-claude.brewcode.app/brewcode/overview](https://doc-claude.brewcode.app/brewcode/overview/) diff --git a/brewcode/agents/agent-creator.md b/brewcode/agents/agent-creator.md index 6d6002c..616e867 100644 --- a/brewcode/agents/agent-creator.md +++ b/brewcode/agents/agent-creator.md @@ -5,6 +5,10 @@ model: inherit maxTurns: 80 color: cyan tools: Read, Write, Edit, Glob, Grep, Bash, Agent, WebFetch, WebSearch, AskUserQuestion +doc_type: llm +version: "5.1.0" +generated_by: "brewcode" +last_updated: "2026-08-09" --- [DICT: AG=agent, BC=brewcode, CC=Claude Code, CD=CLAUDE.md, EX=example, FM=frontmatter, MDL=model, PLG=plugin, SA=subagent, SK=skill, SP=system prompt, TL=tool(s), TRG=trigger, VH=version history] diff --git a/brewcode/agents/bash-expert.md b/brewcode/agents/bash-expert.md index 8aa5583..f400b4c 100644 --- a/brewcode/agents/bash-expert.md +++ b/brewcode/agents/bash-expert.md @@ -5,6 +5,10 @@ model: inherit maxTurns: 60 color: green tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch +doc_type: llm +version: "5.1.0" +generated_by: "brewcode" +last_updated: "2026-08-09" --- # Bash Expert diff --git a/brewcode/agents/bc-rules-organizer.md b/brewcode/agents/bc-rules-organizer.md index 1b3a059..4a0abdd 100644 --- a/brewcode/agents/bc-rules-organizer.md +++ b/brewcode/agents/bc-rules-organizer.md @@ -4,6 +4,10 @@ description: Internal. Spawned only by /brewcode:rules. No direct/auto use. model: haiku maxTurns: 60 tools: Read, Write, Edit, Glob, Grep, Bash, Agent +doc_type: llm +version: "5.1.0" +generated_by: "brewcode" +last_updated: "2026-08-09" --- # Rules Organizer diff --git a/brewcode/agents/hook-creator.md b/brewcode/agents/hook-creator.md index ef548dc..40358f6 100644 --- a/brewcode/agents/hook-creator.md +++ b/brewcode/agents/hook-creator.md @@ -5,6 +5,10 @@ model: inherit maxTurns: 80 color: yellow tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch +doc_type: llm +version: "5.1.0" +generated_by: "brewcode" +last_updated: "2026-08-09" --- [DICT: AC=additionalContext, CC=Claude Code, HE=hook event, MD=MessageDisplay, PTU=PreToolUse, PCD=PostCompact, POT=PostToolUse, PR=PermissionRequest, SA=subagent, SS=SessionStart, UI=updatedInput] diff --git a/brewcode/agents/skill-creator.md b/brewcode/agents/skill-creator.md index 25744d1..79db495 100644 --- a/brewcode/agents/skill-creator.md +++ b/brewcode/agents/skill-creator.md @@ -5,6 +5,10 @@ model: inherit maxTurns: 80 color: green tools: Read, Write, Edit, Glob, Grep, Bash, Agent, AskUserQuestion +doc_type: llm +version: "5.1.0" +generated_by: "brewcode" +last_updated: "2026-08-09" --- [DICT: ACT=activation, AT=allowed-tools, BPR=${CLAUDE_PLUGIN_ROOT}, CC=Claude Code, CSD=${CLAUDE_SKILL_DIR}, CTX=context, DESC=description, DMI=disable-model-invocation, FM=frontmatter, FORK=context:fork, GP=general-purpose, PLG=plugin, REF=reference, SA=subagent, SK=skill, UI-F=user-invocable] diff --git a/brewcode/docs/commands.md b/brewcode/docs/commands.md index a40b949..3612abc 100644 --- a/brewcode/docs/commands.md +++ b/brewcode/docs/commands.md @@ -6,7 +6,7 @@ description: Detailed description of all brewcode plugin commands # BC Plugin Commands -> **ver:** 5.0.0 | **Author:** Maksim Kochetkov | **License:** MIT +> **ver:** 5.1.0 | **Author:** Maksim Kochetkov | **License:** MIT ## Naming @@ -20,7 +20,7 @@ status | install | upgrade | enable | disable | uninstall | purge No arguments = `status` when installed, `install` when not -- except `/brewcode:semble-setup`, which always defaults to `status` so a bare invocation can never trigger a machine-level package install. -Each skill implements the modes that mean something for it and rejects the rest with an `ERROR:` line and exit 1, instead of guessing. `/brewcode:teams-setup` covers `status | install | upgrade | uninstall | purge` and rejects `enable` / `disable`. Skill-specific extras come after the canonical set, never in place of it (`semble-setup`: `reindex | optimize | resume`). +No setup rejects any of the seven canonical verbs -- all ten setup skills implement all seven, either via a live config flag (semble, agent-deadline, agent-router, manager, docsync) or entry-file parking (teams, superreview, task-board, think-short, memory-sync). Skill-specific extras come after the canonical set, never in place of it (`semble-setup`: `reindex | optimize | resume`). ## Quick Reference @@ -85,21 +85,22 @@ Recurring tools with no installed state (`agents`, `rules`, `convention`, `e2e`, ### Classification -Each row gets exactly one state, evaluated in order: `n/a` -> `missing` -> `disabled` -> `partial` -> `stale` -> `installed`. +Each row gets exactly one state, evaluated in order: `n/a` -> `disabled` -> `missing` -> `partial` -> `stale` -> `installed`. **Anchor MISS is decisive.** The anchor is the artifact only that setup writes; without it the row is `missing`, whatever else the project contains. Secondaries must be EXCLUSIVE too -- `teams-setup` claims `.claude/teams/*/trace.jsonl` and `trace-ops.sh`, and `superreview-setup` no longer claims the shared `intent-guard.md`, because a shared file made every project with a hand-written agent report a broken `partial` install. -**`disabled` outranks `partial` and `stale`.** Five setups leave a probeable off-switch: semble (`.claude/semble/state.json` `.enabled`), think-short (`think-short-prompt.md.disabled`), manager (`.claude/brewtools/manager/state.json` `.hard`), agent-deadline (`.claude/agent-deadline.json` `.enabled`), agent-router (`.claude/brewtools/agent-router.json` `.enabled`). A disabled row offers `enable` and never enters the run-list -- a switched-off mechanism is a choice, not a defect. +**`disabled` outranks `missing`, `partial` and `stale`.** All ten setups leave a probeable off-switch, in one of two mechanisms: live config flag -- semble, agent-deadline, agent-router, manager, docsync; entry-file parking -- teams, superreview, task-board, think-short, memory-sync. A disabled row offers `enable` and never enters the run-list -- a switched-off mechanism is a choice, not a defect. ### Staleness signals | Signal | Used by | How | |--------|---------|-----| | Checksum | semble, think-short, agent-deadline, agent-router, manager, docsync | Those setups `cp` hook files verbatim -> `cmp` against the plugin asset is exact | -| Provenance stamp | memory-sync | Emitted skill's trailing ` + Agent frontmatter (name, description, model, tools) is added by agent-creator on top, followed by + the four standard metadata keys -- LAST, after the agent's own keys, exactly these names and quoting: + + doc_type: llm + version: "{PLUGIN_VERSION}" + generated_by: "brewcode:e2e" + last_updated: "{LAST_UPDATED}" + + {PLUGIN_VERSION} and {LAST_UPDATED} are the `PLUGIN_VERSION:` / `LAST_UPDATED:` lines Phase 0's + detect-mode.sh already printed. Never hardcode either; {ISO_DATE} is retired. --> # {AGENT_NAME} **Mission:** {one sentence} **Domain:** {area of responsibility} **Character:** {brief characteristic -- CAN change during update} -**Last Updated:** {ISO_DATE} ## Immutable Traits (do NOT change during update) - **Name:** {AGENT_NAME} diff --git a/brewcode/skills/e2e/references/mode-install.md b/brewcode/skills/e2e/references/mode-install.md index e4d257b..feffe80 100644 --- a/brewcode/skills/e2e/references/mode-install.md +++ b/brewcode/skills/e2e/references/mode-install.md @@ -139,6 +139,18 @@ mkdir -p .claude/e2e && test -s .claude/e2e/e2e-rules.md && echo "OK" || echo "F ``` > **STOP if FAILED** — every agent halts on a missing rules file by its own Rules Loading Protocol. +The rules file gets the standard frontmatter, ABOVE its `# E2E Testing Rules` heading — fill the three +values from the Phase 0 `PLUGIN_VERSION:` / `GENERATED_BY:` / `LAST_UPDATED:` lines: + +```yaml +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "brewcode:e2e" +last_updated: "{LAST_UPDATED}" +--- +``` + Then create `.claude/e2e/config.json`: ```json @@ -149,11 +161,38 @@ Then create `.claude/e2e/config.json`: "scenarioDir": ".claude/e2e/scenarios", "agents": ["e2e-architect", "e2e-scenario-analyst", "e2e-automation-tester", "e2e-manual-tester", "e2e-reviewer"], "rulesPath": ".claude/e2e/e2e-rules.md", - "lastSetup": "{ISO_DATE}" + "version": "{PLUGIN_VERSION}", + "generated_by": "brewcode:e2e", + "last_updated": "{LAST_UPDATED}" } ``` -Optionally generate `.claude/rules/e2e-conventions.md` (~20-30 lines) with key rules. +> `version` / `generated_by` / `last_updated` replace the old `lastSetup` key, whose format nothing ever +> bound. `{PLUGIN_VERSION}` and `{LAST_UPDATED}` are the `PLUGIN_VERSION:` and `LAST_UPDATED:` lines +> Phase 0's `detect-mode.sh` already printed — the ONE command that produces them, so the date is always +> `date +%F` (`YYYY-MM-DD`) and the version always comes from `.claude-plugin/plugin.json`. Never +> hardcode either. Migrating an older install: drop `lastSetup`, write the three keys. +> +> `{PLUGIN_VERSION}` is always a real `X.Y.Z` here: `detect-mode.sh` hard-fails when it cannot read +> the manifest, so Phase 0 never hands this mode a placeholder. If you are ever holding something +> that is not `X.Y.Z` — `unknown`, an empty string, an unsubstituted `{PLUGIN_VERSION}` — do NOT +> write the file. Stop and report it. + +Optionally generate `.claude/rules/e2e-conventions.md` (~20-30 lines) with key rules. It is a rule file +Claude Code auto-loads, so it carries `paths:` and `description:` plus the same four standard keys: + +```yaml +--- +paths: + - "{config.testSourceDir}/**" +description: e2e-conventions — condensed E2E rules export; full set in .claude/e2e/e2e-rules.md +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "brewcode:e2e" +last_updated: "{LAST_UPDATED}" +--- +``` + AskUser: "Export key E2E rules to .claude/rules/?" Options: "Yes" / "No" ## S7: Final Summary diff --git a/brewcode/skills/e2e/references/mode-rules.md b/brewcode/skills/e2e/references/mode-rules.md index 8aaa470..c53df7f 100644 --- a/brewcode/skills/e2e/references/mode-rules.md +++ b/brewcode/skills/e2e/references/mode-rules.md @@ -13,14 +13,23 @@ Read `.claude/e2e/config.json`. agents actually load; it is the one this mode updates 2. Read base rules: `${CLAUDE_SKILL_DIR}/references/e2e-rules.md` (upstream reference, for diffing) 3. Read the condensed export (if exists): `.claude/rules/e2e-conventions.md` -4. Check freshness: compare lastSetup date from config with current date +4. Check freshness: compare the `version` stamped in `config.json` with the `PLUGIN_VERSION:` line + Phase 0's `detect-mode.sh` printed. Different (or absent) -> say so in the table below; L5 + re-stamps every artifact either way, so this run always clears the staleness `status` reported 5. Present current state: -| Source | Rules Count | Last Updated | -|--------|-------------|-------------| -| Live (`{config.rulesPath}`) | {N} | {date} | -| Base (plugin) | {N} | {date} | -| Conventions export | {N or "none"} | {date or "N/A"} | +| Source | Rules Count | Version | Last updated | +|--------|-------------|---------|--------------| +| Live (`{config.rulesPath}`) | {N} | its frontmatter `version` | its frontmatter `last_updated` | +| Base (plugin `references/e2e-rules.md`) | {N} | `PLUGIN_VERSION:` | -- (ships with the plugin) | +| Conventions export (`.claude/rules/e2e-conventions.md`) | {N or "none"} | its frontmatter `version` | its frontmatter `last_updated` | + +> Every cell above has exactly one source. The plugin baseline carries no per-file stamp and cannot: +> it ships INSIDE the plugin, so its version IS `PLUGIN_VERSION` by definition and a stamp would only +> be a second copy that can go stale. Its `Last updated` is therefore `--`, not a guess. +> A row whose file has no frontmatter at all is a pre-standard artifact -> print +> `stale (legacy, unstamped)`, never `unknown`: a word that sorts against real semver turns a failed +> read into a confident verdict. L5 re-stamps it. > `{config.rulesPath}` missing -> "Run `/brewcode:e2e install` first." STOP. Never fall back to the > plugin copy: the agents cannot read it. @@ -121,6 +130,24 @@ Options: > Never write back into the plugin's `references/e2e-rules.md`. It is the upstream baseline, it is > read-only for this skill, and a plugin update overwrites it. -Update `config.json` lastSetup date. +Re-stamp the artifact metadata on EVERY existing artifact below, from the Phase 0 `PLUGIN_VERSION:` / +`GENERATED_BY:` / `LAST_UPDATED:` lines — one command, one date spelling (`date +%F`), no hardcoding: -Summary: rules added/modified/removed, sources breakdown. +| File | Keys | +|------|------| +| `.claude/e2e/config.json` | `version`, `generated_by`, `last_updated` (top level, snake_case). Drop a leftover `lastSetup` | +| `{config.rulesPath}` | frontmatter `doc_type: llm`, `version`, `generated_by`, `last_updated` | +| `.claude/rules/e2e-conventions.md` (if it exists) | same four, after its own `paths:` / `description:` | +| `.claude/agents/e2e-*.md` (each) | same four, after the agent's own frontmatter keys | + +> **The re-stamp is UNCONDITIONAL — it is not gated on a rules change.** `rules` is the remedy +> `status` prescribes for `config.version != PLUGIN_VERSION`, so a run that only re-stamps is a +> legitimate and expected run. Gating it on "everything this run wrote" would make the loop +> permanent: `status` says stale -> `rules` finds nothing to change -> `rules` reports success -> +> `status` says stale again, forever. Cancelling at L4 skips the rules diff, not this step. + +> Re-stamping is METADATA-ONLY. Touch just the four keys in the first frontmatter block (and the +> three JSON keys); leave every other key, the agents' Immutable Traits and all prose byte-identical. +> An artifact whose body you did not change must differ by exactly those keys. + +Summary: rules added/modified/removed, sources breakdown, artifacts re-stamped (count + version). diff --git a/brewcode/skills/e2e/references/mode-status.md b/brewcode/skills/e2e/references/mode-status.md index fbfe4cd..701a19a 100644 --- a/brewcode/skills/e2e/references/mode-status.md +++ b/brewcode/skills/e2e/references/mode-status.md @@ -20,7 +20,17 @@ Scan for E2E rules: Read `.claude/e2e/config.json`: - If not found → report "Not configured. Run `/brewcode:e2e install`." -- If found → extract: stack, testFramework, testSourceDir, scenarioDir, lastSetup +- If found → extract: stack, testFramework, testSourceDir, scenarioDir, `version`, `generated_by`, + `last_updated` +- Compare the stamped `version` against the running plugin: the `PLUGIN_VERSION:` line Phase 0's + `detect-mode.sh` printed. Equal → current; different → the setup was generated by another release +- No `version` key at all → a pre-standard install (it carried `lastSetup`); report it as + `stale (legacy, unstamped)` + +> **Never print `unknown` as a version.** It is not a reading, it is the absence of one, and it sorts +> against real semver — a reader that accepts it turns a failed lookup into a confident verdict. A +> missing stamp is `stale (legacy, unstamped)`; the running version can never be missing, because +> Phase 0's `detect-mode.sh` hard-fails instead of emitting a placeholder. ## T4: Artifact Scan @@ -52,16 +62,25 @@ Scan configured paths: | Test files | {N} | {path} | ## Freshness -| Item | Last Updated | -|------|-------------| -| Config | {date} | -| Last scenario | {date} | -| Last test | {date} | +| Item | Version | Last updated | +|------|---------|--------------| +| Config (`.claude/e2e/config.json`) | {config.version} | {config.last_updated} | +| Rules (`{config.rulesPath}` frontmatter) | {version} | {last_updated} | +| Plugin (running) | {PLUGIN_VERSION} | -- | +| Last scenario | -- | {file mtime} | +| Last test | -- | {file mtime} | ## Recommendations - {if agents < 5}: "Missing agents. Run `/brewcode:e2e install`." - {if scenarios with status=approved but no test}: "Approved scenarios without tests. Run `/brewcode:e2e create`." -- {if config.lastSetup > 30 days}: "Setup is stale. Consider `/brewcode:e2e rules` to refresh." +- {if config.version != PLUGIN_VERSION}: "Setup generated by brewcode {config.version}, running {PLUGIN_VERSION}. Run `/brewcode:e2e rules` to refresh." +- {if config.version missing}: "Setup predates the artifact-metadata standard (no `version` key) — reported `stale (legacy, unstamped)`. Run `/brewcode:e2e rules` to refresh and stamp it." ``` +> **Staleness is a VERSION comparison, not a date threshold.** The old rule fired on +> `lastSetup > 30 days` — which flagged a setup that was perfectly current every single month whenever +> the plugin had not changed, and stayed silent on the case that actually matters: a plugin release the +> day after install. `version != PLUGIN_VERSION` is exact, actionable and names the two versions in the +> message. Dates stay in the table as information; they never trigger a recommendation. + No AskUserQuestion — purely informational output. diff --git a/brewcode/skills/e2e/scripts/detect-mode.sh b/brewcode/skills/e2e/scripts/detect-mode.sh index f15f6a7..0667346 100755 --- a/brewcode/skills/e2e/scripts/detect-mode.sh +++ b/brewcode/skills/e2e/scripts/detect-mode.sh @@ -3,6 +3,33 @@ set -eu ARGS="${1:-}" +# Artifact-metadata standard. The plugin version that produces every artifact this run writes, +# read from the manifest by SELF-LOCATION: scripts/ -> e2e/ -> skills/ -> . +# Correct in the dev checkout AND in the installed cache. NEVER hardcoded, never a template version. +# `|| true` on both branches: under `set -e` a failing command substitution aborts the script. +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PLUGIN_JSON="$SCRIPT_DIR/../../../.claude-plugin/plugin.json" +PLUGIN_VERSION="" +if [ -f "$PLUGIN_JSON" ]; then + if command -v jq >/dev/null 2>&1; then + PLUGIN_VERSION=$(jq -r '.version // empty' "$PLUGIN_JSON" 2>/dev/null || true) + else + PLUGIN_VERSION=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_JSON" 2>/dev/null | head -1 || true) + fi +fi +# HARD FAIL, never a placeholder value. A word like `unknown` carries no `{}<>`, so setup-status's +# PLACEHLD test cannot catch it: `sort -V` would compare it against the real version and print a +# confident `AHEAD unknown > X.Y.Z`, and an artifact stamped that way can never clear its own +# staleness. Emitting the raw `{PLUGIN_VERSION}` token instead would only defer the failure until +# after the artifact is on disk. The manifest ships with the plugin, so an unreadable one is a broken +# install - stop before install/rules writes config.json, the rules file or a single agent. Every +# mode goes through this one script and the skill treats any `ERROR:` line as STOP, status included: +# a status run that cannot name the running version has nothing to compare a stamp against. +case "$PLUGIN_VERSION" in + [0-9]*.[0-9]*.[0-9]*) : ;; + *) printf 'ERROR:cannot resolve plugin version (X.Y.Z) from %s - refusing to stamp artifacts with a fake version\n' "$PLUGIN_JSON"; exit 1 ;; +esac + # Parse first word and remainder FIRST="" REST="" @@ -54,5 +81,10 @@ fi printf 'MODE:%s\n' "$MODE" [ -n "$PROMPT" ] && printf 'PROMPT:%s\n' "$PROMPT" +# The three metadata scalars every artifact this run writes must carry. Emitted here so the skill +# never has to guess a version or invent a date spelling. +printf 'PLUGIN_VERSION:%s\n' "$PLUGIN_VERSION" +printf 'GENERATED_BY:brewcode:e2e\n' +printf 'LAST_UPDATED:%s\n' "$(date +%F)" exit 0 diff --git a/brewcode/skills/rules/SKILL.md b/brewcode/skills/rules/SKILL.md index bcb0371..8c8ff8b 100644 --- a/brewcode/skills/rules/SKILL.md +++ b/brewcode/skills/rules/SKILL.md @@ -149,10 +149,41 @@ report needs the per-file added/merged/skipped counts; SCOPE + DONE per the temp - Plugin templates: ${CLAUDE_PLUGIN_ROOT}/templates/rules/ - Validate: bash "${CLAUDE_SKILL_DIR}/scripts/rules.sh" validate - Create missing: bash "${CLAUDE_SKILL_DIR}/scripts/rules.sh" create + - Create specialized: bash "${CLAUDE_SKILL_DIR}/scripts/rules.sh" create-specialized '' - Targets: avoid.md, best-practice.md, {prefix}-avoid.md, {prefix}-best-practice.md - DEDUP 3-Check: within-file (>70% skip, 40-70% merge); cross-file antonym (avoid<->best-practice keep avoid only); CLAUDE.md duplicate (skip; "CLAUDE.md" forbidden as Source). Fallback if agent unavailable: error "bc-rules-organizer not available — install brewcode plugin". +### Scope of a specialized rule file (ASK before creating one) + +A `{prefix}-avoid.md` / `{prefix}-best-practice.md` applies to ONE slice of the repo. Before +running `create-specialized`, AskUserQuestion for that slice and pass it as the `paths` argument +(a YAML flow list, e.g. `'["src/payment/**", "**/payment/**"]'`). Omitting the argument makes the +script derive a glob from the prefix and print it with a confirm-me warning; passing `["**/*"]` is +refused outright — a specialized rule that matches everything is auto-loaded into every request, +which is exactly the drift `/brewdoc:memory-sync`'s HARD pass A had to keep cleaning up. + +### Artifact metadata — every rule file this skill writes + +The templates under `templates/rules/` carry the three placeholder tokens raw, and `rules.sh` +substitutes them at creation time, so a created file already carries, after its `paths:` and +`description:`: + +```yaml +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +``` + +`{PLUGIN_VERSION}` resolves from `.claude-plugin/plugin.json` (script self-location), +`{GENERATED_BY}` to `brewcode:rules`, `{LAST_UPDATED}` to `date +%F`. Never hardcode any of them. +`doc_type` is the one UNQUOTED value — `validate` gates on `^doc_type: llm$` and hard-fails +`doc_type: "llm"`; the other three must be quoted. When the organizer EDITS an existing rule file, it refreshes +`last_updated` (and `version`, if the file was written by an older release) with those same two +sources and leaves every other key alone. `rules.sh validate` fails the run when a key is missing, +misspelled or misformatted, so run it after every write. + diff --git a/brewcode/skills/rules/scripts/rules.sh b/brewcode/skills/rules/scripts/rules.sh index 4f68b9e..ae18376 100755 --- a/brewcode/skills/rules/scripts/rules.sh +++ b/brewcode/skills/rules/scripts/rules.sh @@ -7,20 +7,40 @@ # read - Read knowledge file (first 100 lines) # check - Check existing rules files (main + specialized) # create - Create missing main rules from templates -# create-specialized - Create specialized rules (e.g., test-avoid.md) +# create-specialized [paths] - Create specialized rules (e.g., test-avoid.md) # list - List all rule files (*-avoid.md, *-best-practice.md) -# validate - Validate table structure +# validate - Validate frontmatter + table structure set -euo pipefail MODE="${1:-check}" ARG="${2:-}" +ARG2="${3:-}" # Self-location: derive plugin root from script path SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Path: scripts/rules.sh -> skills/rules/scripts -> skills/rules -> skills -> PLUGIN_ROOT PLUGIN_ROOT="$(dirname "$(dirname "$(dirname "$SCRIPT_DIR")")")" PLUGIN_TEMPLATES="$PLUGIN_ROOT/templates" +# Manifest by self-location: correct in the dev checkout AND in the installed cache. +PLUGIN_JSON="$PLUGIN_ROOT/.claude-plugin/plugin.json" + +# Artifact-metadata standard. The version is read from the manifest, never hardcoded. +plugin_version() { + local v="" + if [ -f "$PLUGIN_JSON" ]; then + if command -v jq >/dev/null 2>&1; then + v=$(jq -r '.version // empty' "$PLUGIN_JSON" 2>/dev/null || true) + else + v=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_JSON" 2>/dev/null | head -1 || true) + fi + fi + printf '%s' "${v:-unknown}" +} + +PLUGIN_VERSION="$(plugin_version)" +GENERATED_BY="brewcode:rules" +LAST_UPDATED="$(date +%F)" # Validate plugin structure validate_plugin() { @@ -70,6 +90,20 @@ check_rules() { fi } +# Render a template: substitute the scope scalars + the four standard metadata keys. +# `|` is the sed delimiter, so no substituted value may contain one -- all of them are +# globs, titles and versions produced here, never user prose. +render_template() { + local tpl="$1" out="$2" title="$3" paths="$4" desc="$5" + sed -e "s|{TITLE}|$title|g" \ + -e "s|{PATHS}|$paths|g" \ + -e "s|{DESCRIPTION}|$desc|g" \ + -e "s|{PLUGIN_VERSION}|$PLUGIN_VERSION|g" \ + -e "s|{GENERATED_BY}|$GENERATED_BY|g" \ + -e "s|{LAST_UPDATED}|$LAST_UPDATED|g" \ + "$tpl" > "$out" +} + # Create missing rules from templates create_rules() { echo "=== Create Rules ===" @@ -78,35 +112,65 @@ create_rules() { mkdir -p .claude/rules if [ ! -f .claude/rules/avoid.md ]; then - cp "$PLUGIN_TEMPLATES/rules/avoid.md.template" .claude/rules/avoid.md + render_template "$PLUGIN_TEMPLATES/rules/avoid.md.template" .claude/rules/avoid.md \ + "Avoid" '["**/*"]' 'avoid - project-wide anti-patterns and the thing to do instead; one table row per rule' echo "V Created: .claude/rules/avoid.md" else echo ">> Preserved: .claude/rules/avoid.md (exists)" fi if [ ! -f .claude/rules/best-practice.md ]; then - cp "$PLUGIN_TEMPLATES/rules/best-practice.md.template" .claude/rules/best-practice.md + render_template "$PLUGIN_TEMPLATES/rules/best-practice.md.template" .claude/rules/best-practice.md \ + "Best Practices" '["**/*"]' 'best-practice - project-wide practices worth repeating; one table row per rule' echo "V Created: .claude/rules/best-practice.md" else echo ">> Preserved: .claude/rules/best-practice.md (exists)" fi } -# Validate table structure (main + specialized) +# Validate ONE rule file: frontmatter, the four standard metadata keys, table header. +# $2 = "specialized" -> also reject repo-wide paths. +validate_file() { + local f="$1" kind="${2:-main}" + local name errs=0 k + name=$(basename "$f") + + head -1 "$f" | grep -q '^---$' || { echo "X $name no YAML frontmatter (line 1 must be ---)"; errs=$((errs+1)); } + + for k in paths description doc_type version generated_by last_updated; do + grep -q "^${k}:" "$f" || { echo "X $name missing frontmatter key: $k"; errs=$((errs+1)); } + done + + grep -q '^doc_type: llm$' "$f" || { echo "X $name doc_type must be exactly 'llm'"; errs=$((errs+1)); } + grep -Eq '^version: "[0-9]+\.[0-9]+\.[0-9]+"$' "$f" || { echo "X $name version must be a quoted X.Y.Z"; errs=$((errs+1)); } + grep -Eq '^last_updated: "[0-9]{4}-[0-9]{2}-[0-9]{2}"$' "$f" || { echo "X $name last_updated must be a quoted YYYY-MM-DD"; errs=$((errs+1)); } + + if [ "$kind" = "specialized" ] && grep -q '"\*\*/\*"' "$f"; then + echo "X $name is specialized but claims repo-wide paths -> it loads into every request" + errs=$((errs+1)) + fi + + grep -q "^| #" "$f" || { echo "X $name invalid structure (missing table header)"; errs=$((errs+1)); } + + [ "$errs" -eq 0 ] && echo "V $name valid (frontmatter + metadata + table)" + ERRORS=$((ERRORS + errs)) +} + +# Validate frontmatter + table structure (main + specialized) validate_rules() { echo "=== Validate Rules Structure ===" ERRORS=0 # Validate main files if [ -f .claude/rules/avoid.md ]; then - grep -q "^| #" .claude/rules/avoid.md && echo "V avoid.md valid structure" || { echo "X avoid.md invalid structure (missing table header)"; ERRORS=$((ERRORS+1)); } + validate_file .claude/rules/avoid.md main else echo "X avoid.md not found" ERRORS=$((ERRORS+1)) fi if [ -f .claude/rules/best-practice.md ]; then - grep -q "^| #" .claude/rules/best-practice.md && echo "V best-practice.md valid structure" || { echo "X best-practice.md invalid structure (missing table header)"; ERRORS=$((ERRORS+1)); } + validate_file .claude/rules/best-practice.md main else echo "X best-practice.md not found" ERRORS=$((ERRORS+1)) @@ -119,12 +183,7 @@ validate_rules() { [ "$(basename "$f")" = "avoid.md" ] && continue [ "$(basename "$f")" = "best-practice.md" ] && continue - if grep -q "^| #" "$f"; then - echo "V $(basename "$f") valid structure" - else - echo "X $(basename "$f") invalid structure (missing table header)" - ERRORS=$((ERRORS+1)) - fi + validate_file "$f" specialized done exit $ERRORS @@ -172,13 +231,33 @@ capitalize() { printf '%s%s' "$(printf '%s' "${s%"${s#?}"}" | tr '[:lower:]' '[:upper:]')" "${s#?}" } +# A specialized rule file applies to ONE slice of the repo, so it must never ship the +# repo-wide `["**/*"]` -- that is what made every specialized rule load into every request. +# Known prefixes get a curated glob set; anything else gets a prefix-derived guess that the +# caller is told to confirm. An explicit `paths` argument always wins. +default_paths_for_prefix() { + case "$1" in + test|tests|unit) printf '["**/test/**", "**/tests/**", "**/*_test.*", "**/*.test.*", "**/*Test.*"]' ;; + e2e|it|integration) printf '["**/e2e/**", "**/it/**", "**/*E2E*", "**/*e2e*"]' ;; + doc|docs) printf '["**/*.md", "**/*.mdx", "docs/**"]' ;; + ci|cd|cicd) printf '[".github/**", "**/*.yml", "**/*.yaml"]' ;; + sql|db|database) printf '["**/*.sql", "**/migration*/**", "**/migrations/**"]' ;; + api) printf '["**/api/**", "**/openapi/**", "**/*.openapi.*"]' ;; + ui|front|frontend|web) printf '["**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte", "**/*.css"]' ;; + infra|docker|k8s|deploy) printf '["**/Dockerfile*", "**/docker-compose*.yml", "**/docker-compose*.yaml", "**/*.tf", "k8s/**"]' ;; + *) printf '["**/%s/**", "**/*%s*"]' "$1" "$1" ;; + esac +} + # Create specialized rules from template with prefix create_specialized() { local prefix="$1" + local paths="${2:-}" if [ -z "$prefix" ]; then echo "X Missing prefix argument" - echo "Usage: rules.sh create-specialized " + echo "Usage: rules.sh create-specialized [paths]" echo "Example: rules.sh create-specialized test" + echo "Example: rules.sh create-specialized payment '[\"src/payment/**\"]'" exit 1 fi @@ -191,16 +270,30 @@ create_specialized() { local cap cap=$(capitalize "$prefix") + if [ -z "$paths" ]; then + paths=$(default_paths_for_prefix "$prefix") + echo "! paths not supplied -> derived $paths" + echo " Confirm it with the user and narrow it by hand if it does not match this repo's layout." + fi + case "$paths" in + *'"**/*"'*) + echo "X Refusing repo-wide paths for a specialized rule: $paths" + echo " A ${prefix}-* rule that matches everything loads into every request. Pass a narrower glob." + exit 1 + ;; + esac + if [ ! -f "$avoid_file" ]; then - # Create from template with prefix substitution - sed "s/# Avoid/# ${cap} Avoid/" "$PLUGIN_TEMPLATES/rules/avoid.md.template" > "$avoid_file" + render_template "$PLUGIN_TEMPLATES/rules/avoid.md.template" "$avoid_file" \ + "${cap} Avoid" "$paths" "${prefix}-avoid - ${prefix} anti-patterns and the thing to do instead; one table row per rule" echo "V Created: $avoid_file" else echo ">> Preserved: $avoid_file (exists)" fi if [ ! -f "$bp_file" ]; then - sed "s/# Best Practices/# ${cap} Best Practices/" "$PLUGIN_TEMPLATES/rules/best-practice.md.template" > "$bp_file" + render_template "$PLUGIN_TEMPLATES/rules/best-practice.md.template" "$bp_file" \ + "${cap} Best Practices" "$paths" "${prefix}-best-practice - ${prefix} practices worth repeating; one table row per rule" echo "V Created: $bp_file" else echo ">> Preserved: $bp_file (exists)" @@ -219,7 +312,7 @@ case "$MODE" in create_rules ;; create-specialized) - create_specialized "$ARG" + create_specialized "$ARG" "$ARG2" ;; list) list_rules @@ -234,9 +327,10 @@ case "$MODE" in echo " read - Read knowledge file (first 100 lines)" echo " check - Check existing rules files (main + specialized)" echo " create - Create missing main rules from templates" - echo " create-specialized - Create specialized rules (e.g., test-avoid.md)" + echo " create-specialized [paths] - Create specialized rules (e.g., test-avoid.md);" + echo " paths is a YAML flow list, e.g. '[\"src/payment/**\"]'" echo " list - List all rule files" - echo " validate - Validate table structure" + echo " validate - Validate frontmatter (standard metadata keys) + table structure" exit 1 ;; esac diff --git a/brewcode/skills/semble-setup/README.md b/brewcode/skills/semble-setup/README.md index 6135628..2be77a1 100644 --- a/brewcode/skills/semble-setup/README.md +++ b/brewcode/skills/semble-setup/README.md @@ -1,6 +1,6 @@ # Semble -Lifecycle skill for **`semble_code`** — a semantic code-search MCP server ([semble](https://pypi.org/project/semble/), pinned to `0.5.2`) wired into any project: install, audit, configure, repair, upgrade, enable, disable, reindex, uninstall. +Lifecycle skill for **`semble_code`** — a semantic code-search MCP server ([semble](https://pypi.org/project/semble/), pinned to `0.5.4`) wired into any project: install, audit, configure, repair, upgrade, enable, disable, reindex, uninstall. One command covers the whole lifecycle. It always reports the current state **before** it changes anything, and every mutation is delegated to a script — the skill itself only routes. @@ -40,7 +40,7 @@ The seven canonical modes every `-setup` skill shares, in order: |------|--------|---------| | `status` | full report: prereqs, MCP, cache, guidance, agents, coverage, state | no | | `install` | install `uv` (and, if you accept, `coreutils`), register `semble_code` at user scope, **wire the rule, the CLAUDE.md block, the three hooks, the permissions and the agent frontmatter**, checkpoint for reload | yes | -| `upgrade` | compare the recorded pin with `0.5.2`, re-register if different | yes | +| `upgrade` | compare the recorded pin with `0.5.4`, re-register if different | yes | | `enable` | back on: verify, warm, `phase=ready` | yes | | `disable` | `enabled=false` — hooks go silent, nothing is deleted | yes | | `uninstall` | four flavours: `integration` / `mcp` / `cli` / `purge` | yes | @@ -63,19 +63,20 @@ Plus three extras specific to a search index: | Surface | Location | |---------|----------| | MCP server | `~/.claude.json` `.mcpServers.semble_code`, **user scope** (`-s user` is mandatory — the CLI default is `local`), with `"alwaysLoad": true` so the two tools are never deferred behind `ToolSearch`. Only `claude mcp add-json` can write that key, so that is the form the skill uses | -| Command | `uvx --from 'semble[mcp]==0.5.2' semble --content ` — the content set lives in `scripts/lib/semble-common.sh` as `SEMBLE_CONTENT_ARGS` and is deliberately not repeated here. Every consumer must pass exactly that set: semble keys its cache directory by project path alone but rejects a cached index whose stored content set differs, so two consumers with different sets evict each other on every call | +| Command | `uvx --from 'semble[mcp]==0.5.4' semble --content ` — the content set lives in `scripts/lib/semble-common.sh` as `SEMBLE_CONTENT_ARGS` and is deliberately not repeated here. Every consumer must pass exactly that set: semble keys its cache directory by project path alone but rejects a cached index whose stored content set differs, so two consumers with different sets evict each other on every call | | Cache root | macOS `~/Library/Caches/semble-code` · Linux `${XDG_CACHE_HOME:-~/.cache}/semble-code` | | Reserved docs root | same path with a `semble-docs` leaf — created empty, never registered | | State | `/.claude/semble/state.json` | | Rule | `/.claude/rules/semble-first.md` | +| Ignore file | `/.sembleignore` — read per-directory by `semble/index/file_walker.py:_load_ignore_for_dir` (gitignore syntax via `pathspec`; `core.excludesFile` is NOT honoured). Keeps generated/vendored trees (`.claude/tmp/`, `.claude/reports/`, build output, minified bundles) out of the corpus; deliberately never excludes `.claude/{skills,agents,rules,commands,hooks}/`. Same managed-file policy as the rule: user edits are reported, not clobbered, and `--force` backs up first | | CLAUDE.md | a marked `` block | -| Hooks | `/.claude/hooks/semble-session.mjs` (SessionStart) + `semble-reminder.mjs` (PreToolUse, advisory only) + `semble-explore.mjs` (SubagentStart, matcher `Explore` — tells the spawned Explore subagent it can call `mcp__semble_code__search` without a `ToolSearch` first) | +| Hooks | `/.claude/hooks/semble-session.mjs` (SessionStart — state and reload messaging) + `semble-prefetch.mjs` (UserPromptSubmit — runs one semble search on the prompt and injects the top-3 candidate **paths**, never snippets) + `semble-stats.mjs` (PostToolUse + PostToolUseFailure — pure observer, JSONL telemetry). The two advisory hooks of earlier versions (`semble-reminder.mjs`, `semble-explore.mjs`) are **retired in 5.0.0**: they converted at 0/18 and 0/11 with delivery proven, so `install`/`upgrade` deletes the files and un-wires their rows | | Permissions | `/.claude/settings.json` -> exactly `mcp__semble_code__search` and `mcp__semble_code__find_related`, never a wildcard | | Agents | `/.claude/agents/**/*.md` get the two tool names; agents with no `tools:` key inherit and are left untouched. Global agents are never touched by `install` | ### The state file and its phases -`.claude/semble/state.json` is what the three hooks read, so its `phase` is a claim about reality and is guarded by a transition machine: +`.claude/semble/state.json` is what `semble-session.mjs` and `semble-prefetch.mjs` read, so its `phase` is a claim about reality and is guarded by a transition machine: ``` absent -> prereq_ready -> awaiting_reload -> verifying -> ready @@ -85,7 +86,7 @@ absent -> prereq_ready -> awaiting_reload -> verifying -> ready `ready` is reachable **only** from `verifying` — there is no `awaiting_reload -> ready` shortcut. `resume` therefore enters `verifying` before it verifies anything and writes `ready` only after, so a run that dies half-way leaves an honest record rather than a phase that claims a verification nobody performed. Re-registration (`install` / `upgrade`) parks the phase back at `awaiting_reload` from any state where a setup exists, and `disable` reaches `disabled` from any of them too; only `absent -> disabled` stays illegal. -Fields the installer owns — `resumePrompt`, `cacheRoot`, `repoHash`, `approvedVersion`, `projectRoot`, `schema` — are recomputed on **every** write, not just when the file is created, so a state file written by an older version stops advertising stale values. Fields you and the run own — `enabled`, `phase`, `completed`, `notes` — are never reset as a side effect. +Fields the installer owns — `resumePrompt`, `cacheRoot`, `repoHash`, `approvedVersion`, `projectRoot`, `schema`, plus the artifact metadata `version` / `generated_by` / `last_updated` — are recomputed on **every** write, not just when the file is created, so a state file written by an older version stops advertising stale values. Fields you and the run own — `enabled`, `phase`, `completed`, `notes` — are never reset as a side effect. Installation is **uvx-ephemeral** by default: no `semble` on `PATH`. That is deliberate — any unrecognized argv makes `semble` start a *blocking* stdio server, so a stray bare invocation would hang. `uv tool install` is opt-in. @@ -102,9 +103,12 @@ Installation is **uvx-ephemeral** by default: no `semble` on `PATH`. That is del | Fact | Consequence | |------|-------------| -| **No watcher, no daemon.** semble 0.5.2 has no background thread or service (its README says otherwise; the code does not) | Nothing is ever started or stopped. Staleness is re-checked inside each tool call, behind a `3x last-build-duration` cooldown | +| **No watcher, no daemon.** semble 0.5.4 has no background thread or service | Nothing is ever started or stopped. Staleness is re-checked inside each tool call, behind a `3x last-build-duration` cooldown | | The embedding model is pre-loaded at server start and calls block until it is ready | The **first query on a cold cache downloads hundreds of MB and is slow** — allow up to 600 s. Offline + cold HuggingFace cache = every call errors | | The corpus is exactly `SEMBLE_CONTENT_ARGS` | `.json`/`.json5`/`.csv`/`.tsv`/`.psv` are excluded from **every** content type — unreachable even with `--content all` — and `.mdx`/`.txt` belong to no bucket at all. Use `rg` for those. The per-suffix table is `references/language-coverage.md` | +| One exception, and it is not configurable away | A `!` un-ignore pattern ending in a file extension skips the extension filter, so a negated `.json`/`.png` lands in the index at any `--content` setting — 5.9% of this workspace's index was one such lockfile, plus 143 chunks of decoded PNG. `.sembleignore` re-ignores win, because its lines are concatenated after `.gitignore`'s and the last match decides | +| `--content config` is 0.57% of the corpus and stays | Those 53 chunks are all six `.github/workflows/*.yml` and four `docker-compose*.yml` — the whole CI/CD surface, and the only reachable answer to the deployment questions. It was never what pulled the lockfile in | +| semble wins behaviour, `rg` wins enumeration — measured, 16 questions | Behaviour and vocabulary-mismatch: semble 8 of 9. Exhaustive enumeration: semble lost 2 of 5, and `hooks.json` questions are unanswerable in principle because `.json` is unreachable | | Adding docs to this corpus is not a fix | The per-repo cache dir is `sha256(repo path)` and does not encode the content type, so a docs index and a code index collide on one directory and invalidate each other on every call. Hence the separately reserved docs root | | A newly registered MCP server is invisible to the running session | `install` stops at a reload checkpoint; `/brewcode:semble-setup resume` finishes the job in the new session | | `semble clear index` wipes **every** index under the cache root | There is no per-repo rebuild CLI, so `reindex` deletes exactly one resolved `/<64-hex>` directory, guarded and confirmed | @@ -154,7 +158,7 @@ Every script takes `--json` and uses the same exit codes: `0` ok · `1` hard fai | First search hangs for minutes | the embedding model is downloading | wait (up to 600 s); it happens once per model | | Every call errors offline | model pre-load cannot reach HuggingFace | run once online, or set `SEMBLE_NO_NETWORK=1` to skip warm steps | | `search` rejects the call | `repo` is missing — it is required | pass the absolute project root | -| A `.html` / `.json` file is never found | not in this corpus by design | use `rg` | +| A `.json` / `.csv` / `.mdx` / `.txt` file is never found | not in this corpus by design (`.html`/`.htm` **is** indexed, in the docs bucket) | use `rg` | | Status says `partial` | half-wired — `hooks N/4 wired` counts only entries that are present **and** field-conforming; a hook whose `timeout`, `args` or `command` drifted is counted in `driftedCount`, not in `wiredCount` | `/brewcode:semble-setup install` re-runs idempotently and repairs each drifted field in place | | `hooks 4/4 wired` but a hook never fires | a duplicate entry for the same event/matcher/script — reported as `duplicateCount` with a `drift[]` row, never as `wired` | re-run `install`; the merge collapses duplicates | | `malformed` | `~/.claude.json` or `.mcp.json` is not valid JSON | the skill refuses to write; fix that file by hand, then re-run | diff --git a/brewcode/skills/semble-setup/SKILL.md b/brewcode/skills/semble-setup/SKILL.md index 2b587fb..ca7a182 100644 --- a/brewcode/skills/semble-setup/SKILL.md +++ b/brewcode/skills/semble-setup/SKILL.md @@ -10,7 +10,7 @@ model: opus # Semble -> Lifecycle router for **`semble_code`** — a semantic code-search MCP server (semble `0.5.2`, pinned) registered at **user scope** with `alwaysLoad: true`, indexing the corpus named by `SEMBLE_CONTENT_ARGS` into a **code-only cache root**. This skill decides the **mode**, prints the state **before** touching anything, and delegates every mutation to the scripts under `scripts/`. No mutation logic lives in this file. +> Lifecycle router for **`semble_code`** — a semantic code-search MCP server (semble `0.5.4`, pinned) registered at **user scope** with `alwaysLoad: true`, indexing the corpus named by `SEMBLE_CONTENT_ARGS` into a **code-only cache root**. This skill decides the **mode**, prints the state **before** touching anything, and delegates every mutation to the scripts under `scripts/`. No mutation logic lives in this file. Two tools become available once it is wired: @@ -25,12 +25,14 @@ Both take a **required `repo`** parameter — the absolute project root, or an e | Fact | Consequence | |------|-------------| -| **There is no watcher and no daemon.** semble 0.5.2 has no background thread, no service, nothing to start or stop. (Its own README claims otherwise; the code does not.) | Never report a daemon as running, starting or stopped. Staleness is re-checked *inside each tool call*, behind a `3x last-build-duration` cooldown. | +| **There is no watcher and no daemon.** semble 0.5.4 has no background thread, no service, nothing to start or stop. | Never report a daemon as running, starting or stopped. Staleness is re-checked *inside each tool call*, behind a `3x last-build-duration` cooldown. | | The embedding model is pre-loaded when the MCP server starts, and tool calls block until it is ready | The **first query on a cold cache downloads the embedding model (hundreds of MB) and is slow** — allow up to 600 s and say so before starting. Offline with a cold HuggingFace cache = every call errors. | | The corpus is whatever `SEMBLE_CONTENT_ARGS` says — read the constant, never retype it | `.json`/`.json5`/`.csv`/`.tsv`/`.psv` are **excluded from every content type** — unreachable even with `--content all` — and `.mdx`/`.txt` are in no bucket at all. Use `rg` for those. Full table: `references/language-coverage.md`. | +| …with **one hole**: a `.gitignore`/`.sembleignore` `!` negation whose text ends in a file extension bypasses the extension filter entirely (`_is_ignored`'s `found` flag) | An un-ignored `.json` or `.png` **is** indexed, at any `--content` setting, and reads as decoded binary. Measured here: one negated lockfile was 5.9% of the index. The cure is a re-ignore in `.sembleignore`, which wins because its lines are appended last. Never propose a content-set change to fix it. | +| Retrieval quality is measured, and the split is not a preference | 16 questions at `k=5`: semble takes behaviour and vocabulary-mismatch questions **8 of 9**; `rg` takes exhaustive enumeration (semble lost 2 of 5) and exact identifiers. A question containing "every"/"all"/"how many" is an `rg` question — say so instead of running a search. | | Adding docs to this corpus is not a fix | The per-repo cache dir is `sha256(repo path)` and does **not** include the content type, so a docs index and a code index would collide on one directory and invalidate each other on every call. That is why the docs cache root is reserved separately and never registered here. | | A newly registered MCP server is unavailable until a **NEW session** | `install` therefore stops at a reload checkpoint written to `.claude/semble/state.json` and resumes at verification via `/brewcode:semble-setup resume`. | -| `semble` has no `--version`, no `status`, no `serve`; any unrecognized argv starts a **blocking** stdio server | Never run bare `semble`. Version comes from `uv tool list`; resolvability from `uvx --from 'semble[mcp]==0.5.2' semble --help`. | +| `semble` has no `status` and no `serve`; any argv outside `_CLI_DISPATCH_ARGS` starts a **blocking** stdio server. `--version`/`-V` joined that set in **0.5.4** (`cli.py:25`, `:215`) — on `0.5.3` and older it is unrecognized argv and hangs | Never run bare `semble`. Resolvability comes from `uvx --from 'semble[mcp]==0.5.4' semble --version` -> prints `0.5.4`, exit 0 (measured 0.26 s warm, 2.5 s cold). The argv is chosen **from the pin** by `sc_semble_probe_arg`, which degrades to the always-safe `--help` for any pin below 0.5.4 — a hang is not recoverable by a fallback. Version of a `uv tool install`ed copy still comes from `uv tool list` first, for the same reason. | | `semble install` writes an **unpinned** server named `semble` into `~/.claude.json` | This skill never runs it. An existing `semble` server is *detected* and reported as a conflict, never auto-removed. | | `semble clear index` wipes **every** index under the cache root | Per-repo rebuild has no CLI. `reindex` deletes exactly one resolved `/<64-hex>` dir, guarded and confirmed. | | Windows is unsupported by this skill | On a non-macOS/Linux platform: print `⚠️ Windows is unsupported by this skill` and refuse every mutation. | @@ -43,7 +45,7 @@ Both take a **required `repo`** parameter — the absolute project root, or an e | Const | Value | |-------|-------| | MCP server name | `semble_code` | -| Pin | `semble[mcp]==0.5.2` — **always single-quoted** (`zsh` globs `[ ]`) | +| Pin | `semble[mcp]==0.5.4` — **always single-quoted** (`zsh` globs `[ ]`) | | Scope | `user` (the `claude mcp` CLI default is `local` — `-s user` is mandatory) | | Corpus | `SEMBLE_CONTENT_ARGS` in `scripts/lib/semble-common.sh` — the single source of truth for the `--content` argv. Every consumer must pass it verbatim: a differing set evicts the shared cache dir on every alternation. Never copy the token list into a doc, a prompt or a new invocation | | `alwaysLoad` | `true`, written by `add-json` only (`claude mcp add` has no flag for it). Without it the two tools stay deferred behind `ToolSearch` and are effectively uncallable | @@ -68,16 +70,22 @@ References — read the one you need, not all of them: `$CLAUDE_PLUGIN_ROOT` is **empty** in skill bash blocks. Resolve `${CLAUDE_SKILL_DIR}` with a plugin-cache fallback, and repeat these two lines at the top of **every** later block (a new Bash call inherits nothing). +`${CLAUDE_SKILL_DIR}` is a **text substitution on the skill prompt**, not an environment variable: `getPromptForCommand` runs `W.replace(/\$\{CLAUDE_SKILL_DIR\}/g, )` (verified in the CC 2.1.226 binary), and that regex matches the **bare literal only**. Write it bare and test for emptiness on the next line. A brace-modifier spelling such as `${CLAUDE_SKILL_DIR:-}` is never matched, reaches the shell verbatim, and — since the name is genuinely unset in the Bash tool environment — makes the fallback win on *every* run. + The fallback matches **two** leaf names — `semble-setup` (current) and `semble` (the pre-rename layout still sitting in installed caches, e.g. `brewcode/4.10.1/skills/semble`) — and it uses `find`, not a shell glob: an unmatched glob is a hard error in `zsh` and an empty string in `bash`, and both spellings resolved to a silent miss. `sort -V | tail -1` keeps the newest version, and within one version prefers `semble-setup` over `semble`. **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" +case "$SD" in "$HOME"/.claude/plugins/cache/*) echo "⚠️ SD came from the plugin CACHE, not \$CLAUDE_SKILL_DIR — assets may be older than the marketplace HEAD" ;; esac test -d "$SD/scripts" && test -f "$SD/scripts/semble-status.sh" && echo "SD=$SD" && echo "✅" || echo "❌ FAILED — skill dir unresolved: SD='$SD'" ``` > **STOP if ❌** — the plugin cache is incomplete. Run `/brewtools:plugin-update` (or `claude plugin update brewcode@claude-brewcode`) and retry. +> +> **The ⚠️ line is load-bearing, not cosmetic.** It fires whenever the substitution did not happen — the skill body was copied into an agent prompt, or the block was re-run outside skill mode — and the cache then answers instead of the checkout. A run from an up-to-date working tree would silently install the cache's older hook assets over the newer ones. Whenever the ⚠️ fires, print `SD` in the report and say which version directory it names. Note `--plugin-dir ` alone does **not** redirect this resolution: the substituted value is the directory the skill was *loaded* from. --- @@ -90,12 +98,13 @@ Two side effects belong to the tools it shells out to, not to this skill — say | Side effect | Detail | |-------------|--------| | `claude mcp get semble_code` (MCP detection) | the real `claude` CLI may touch its own `~/.claude.json` / statsig files. The skill itself writes neither. | -| `uvx --from 'semble[mcp]==0.5.2' semble --help` (pin resolvability) | an **uncached network fetch on the first run** — slow on a cold uv cache, and it fails offline. Nothing is installed by it. | +| `uvx --from 'semble[mcp]==0.5.4' semble --version` (pin resolvability) | an **uncached network fetch on the first run** — slow on a cold uv cache, and it fails offline. Nothing is installed by it. | **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-status.sh" --section all --json; RC=$? echo "RC=$RC" [ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" @@ -131,7 +140,7 @@ If the resolved mode is `status`, or if everything is already `ready` and the in |------|-------| | `status` | `semble-status.sh --section all --json` (Step 1 output; nothing further) | | `install` | Step 3 chain: `semble-install.sh all --json` (probe: `check -> uv -> coreutils -> semble`; exit 4 = confirm) -> confirm -> `semble-install.sh all --yes --json`, or on exit 0 the report-driven `semble-install.sh coreutils --yes --json` offer -> `semble-cache.sh reserve-docs` -> `semble-mcp.sh detect`/`add`/`repair` -> `semble-guidance.sh install` + `semble-agents.sh apply` (Step 3.3b) -> **reload checkpoint** | -| `upgrade` | `semble-install.sh check --json` + `semble-mcp.sh detect --json` -> `semble-mcp.sh repair --yes --json` -> **reload checkpoint** | +| `upgrade` | `semble-install.sh check --json` + `semble-mcp.sh detect --json` -> `semble-mcp.sh repair --yes --json` -> `semble-guidance.sh install --part all` + `semble-agents.sh apply` (the Step 3.3b block, verbatim) -> **reload checkpoint** | | `enable` | `semble-project.sh enable --yes --json` | | `disable` | `semble-project.sh disable --yes --json` | | `uninstall` | `AskUserQuestion` flavour -> `semble-remove.sh --yes --json` | @@ -177,7 +186,8 @@ Keys to read from the probe JSON — decide on these, not on `RC` alone: **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-install.sh" all --json; RC=$? echo "RC=$RC # 0 = uv already present | 3 = precondition | 4 = confirmation required, nothing installed" { [ "$RC" -eq 0 ] || [ "$RC" -eq 3 ] || [ "$RC" -eq 4 ]; } && echo "✅" || echo "❌ FAILED" @@ -192,7 +202,7 @@ echo "RC=$RC # 0 = uv already present | 3 = precondition | 4 = confirmation re ```text brew install uv brew install coreutils -uvx --from 'semble[mcp]==0.5.2' semble --help +uvx --from 'semble[mcp]==0.5.4' semble --version ``` Then one `AskUserQuestion`: *"Run these Homebrew installs now?"* — options `Install` (runs exactly the commands printed above) / `Cancel` (nothing runs; `install` stops and the manual fallback `curl -LsSf https://astral.sh/uv/install.sh | sh` is printed, not run). Say plainly that `brew install` writes to the machine, outside this project, and that `coreutils` is the optional half: it only upgrades `sc_timeout` from its bash watchdog to `gtimeout`. On `Cancel`: emit the report with `Actions -> skipped: brew install uv (declined)` and end the invocation. @@ -204,7 +214,8 @@ Then one `AskUserQuestion`: *"Run these Homebrew installs now?"* — options `In **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-install.sh" all --yes --json; RC=$? echo "RC=$RC" { [ "$RC" -eq 0 ] || [ "$RC" -eq 3 ]; } && echo "✅" || echo "❌ FAILED" @@ -231,7 +242,8 @@ Rows 1, 3 and 4 **never ask and never block**. This step cannot fail the `instal **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-install.sh" coreutils --yes --json; RC=$? echo "RC=$RC" [ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" @@ -246,7 +258,8 @@ Creates an empty `semble-docs` root with a `RESERVED-FOR-DOCS.txt` marker so a f **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-cache.sh" reserve-docs --json && echo "✅" || echo "❌ FAILED" ``` @@ -259,7 +272,7 @@ bash "$SD/scripts/semble-cache.sh" reserve-docs --json && echo "✅" || echo " | State | Action | |-------|--------| | `absent` | `semble-mcp.sh add --scope user --yes` | -| `correct` | no MCP mutation — go straight to verification | +| `correct` | no MCP mutation — `add` still writes the project checkpoint itself (see below); no extra step | | `stale_args` | show the before/after diff, confirm once, `semble-mcp.sh repair --yes` | | `wrong_scope` | one `AskUserQuestion` (migrate to `user` / keep), then `repair --yes` | | `duplicate` | one `AskUserQuestion` (which scope to keep), then `repair --yes` | @@ -271,7 +284,8 @@ Precedence when several apply: `malformed` > `duplicate` > `wrong_scope` > `stal **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-mcp.sh" add --scope user --yes --json; RC=$? echo "RC=$RC" [ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" @@ -280,18 +294,21 @@ echo "RC=$RC" > **STOP if ❌** — the registration failed. `add` already wrote the `awaiting_reload` checkpoint *before* touching `~/.claude.json`, and it retries once with `add-json` internally; a second failure leaves the config untouched. Report the raw output and stop. > Replace `add --scope user` with `repair` for `stale_args` / `wrong_scope` / `duplicate`. +> **`correct` needs no follow-up step — `add` writes the checkpoint itself.** The MCP is user-scoped, so on the second project of a machine `add` short-circuits on an already-approved registration; the state file is born only inside an MCP mutation, so that project would end up with no `state.json` at all. `add` therefore writes it on that path too, and its `note` reports the resulting phase. Absent / `prereq_ready` / `error` become `awaiting_reload` — this session still cannot see the server; `verifying`, `ready` and `disabled` take an identity transition, which refreshes the three installer-owned fields (`cacheRoot`, `repoHash`, `resumePrompt`) without walking a verified project backwards. Nothing here is left to the model remembering a block. + ### 3.3b Wire the guidance, permissions and agents — `install` does this itself Everything that does **not** need a live MCP server is wired now, not left hostage to the user coming back for `resume`. This is the same block as Step 4.2 and every step in it is idempotent, so `resume` re-runs it harmlessly and repairs any drift it finds. -Why it belongs here: a project that stops at 3.4 with a registered server and **no hooks** looks installed and behaves as if semble were never set up. The three hooks read `state.phase`, so at `awaiting_reload` they correctly print the resume nudge rather than advertising tools that are not loaded yet. +Why it belongs here: a project that stops at 3.4 with a registered server and **no hooks** looks installed and behaves as if semble were never set up. The three nudge hooks read `state.phase` and **do** fire at `awaiting_reload` — they print the resume-aware wording ("Verification has not finished (phase=awaiting_reload) — the first call rebuilds the index and may take minutes") instead of the plain `ready` text. They are never silenced by the checkpoint; only `enabled:false`, `phase disabled`, `phase error`, `phase prereq_ready` and a `completed` list without `mcp` silence them. What is **not** done here and cannot be: the smoke query (Step 4.1) — the server does not exist for this session — and therefore `phase ready`. **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" RC=0 bash "$SD/scripts/semble-guidance.sh" install --part all --json || RC=1 bash "$SD/scripts/semble-agents.sh" apply --scope project --yes --json; ARC=$? @@ -303,6 +320,23 @@ echo "agents apply RC=$ARC # 3 = reported conflict, not a failure" > **STOP if ❌** — report which step failed and what it left behind. The MCP registration from 3.3 stands either way; do not roll it back. `agents apply` exiting `3` is a reported outcome, not a failure. > Record `guidance` and `agents` in **Actions**; they are marked complete on the state file in Step 4.3, after `resume` has confirmed them. +**`skipped: rule: user_modified` is never the end of it.** The rule states which suffixes semble indexes; a rule left at an older `--content` set is not a preserved user edit, it is a *wrong fact* the model will act on. `install` prints the full `diff -u` of the rule against the template to stderr — read it: + +| The diff shows | Do | +|----------------|----| +| any change to the corpus / `--content` / "Not in this corpus" section | **Re-run with `--force`** (it backs the file up first) and record `rule: overwritten (backup )` in **Actions** | +| only local additions elsewhere (extra frontmatter, project prose) | leave it; record `rule: user_modified, kept` and name the one section that is now behind | + +```bash +bash "$SD/scripts/semble-guidance.sh" install --part rule --force --json +``` + +> `--force` is the *only* way the rule is ever overwritten, and the backup is `.bak.` next to it. It restores the template **byte for byte** — frontmatter included, so a locally chosen `doc_type` or an extra key of your own is replaced along with the prose. It is in the backup; re-apply it by hand if you meant to keep it. +> +> The rule is copied verbatim and is never stamped at install time. `doc_type`, `version` and `generated_by` are baked into the plugin's own template by `.claude/scripts/bump-version.sh` at release; there is no `last_updated`, and nothing is substituted here. That is what lets `setup-status` `cmp` the installed rule against the plugin asset and read them as identical (`brewcode/skills/setup-status/references/artifact-metadata.md`, mechanism `a`). +> +> The managed/user_modified verdict still ignores the four metadata keys on both sides, so a rule installed by an older version of this skill — which did stamp `last_updated` — is not mistaken for a user edit. Such a rule is **re-synced to the plugin bytes without `--force` and without a backup** and reported as `rule: re-synced (metadata only)` — record it in **Actions** as a re-sync, not an overwrite. + ### 3.4 Reload checkpoint — `install` STOPS here The server does not exist for this session. Do not attempt a smoke query, do not claim success, do not continue. Print the **Next Step** exactly as `references/output-contract.md` requires: @@ -322,14 +356,15 @@ Re-run Step 1 first. If `.mcp.state` is not `correct`, go back to Step 3 instead ### 4.0 Enter `verifying` — before anything is verified -`ready` is reachable **only** from `verifying`; there is no `awaiting_reload -> ready` edge and there must not be one, because `phase` is what the three hooks read to decide whether to advertise the MCP tools. Writing `verifying` first is what makes the claim honest: a resume that dies half-way leaves the file saying *verification started and never finished*, which is the truth, instead of `awaiting_reload` (as if nothing happened) or `ready` (a verification that never ran). +`ready` is reachable **only** from `verifying`; there is no `awaiting_reload -> ready` edge and there must not be one, because `phase` is what `semble-session.mjs` and `semble-prefetch.mjs` read to decide whether to advertise the MCP tools. Writing `verifying` first is what makes the claim honest: a resume that dies half-way leaves the file saying *verification started and never finished*, which is the truth, instead of `awaiting_reload` (as if nothing happened) or `ready` (a verification that never ran). `phase verifying` also self-heals a **missing** state file — it is created at `prereq_ready` and walked `prereq_ready -> awaiting_reload -> verifying` through the legal chain. That is the case where the MCP was already registered at user scope by an earlier project, so `add` reported `unchanged` and no checkpoint was ever written. **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-state.sh" phase verifying --json; RC=$? echo "RC=$RC" [ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" @@ -341,10 +376,13 @@ echo "RC=$RC" The first run downloads the embedding model. Tell the user that **before** starting, then run this block with the Bash tool timeout raised to **600000 ms**. +`smoke` shells out to `uvx --from 'semble[mcp]==0.5.4' semble search` — the **CLI**, not the MCP server — so it builds and queries the very same cache directory without needing the server to be loaded in this session. That is why `resume` is fully scriptable: `claude -p "/brewcode:semble-setup resume"` in a fresh process completes end to end. The reload checkpoint exists so that *Claude* gets the two MCP tools, not because anything here is unverifiable before a restart. + **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-project.sh" smoke --json; RC=$? echo "RC=$RC" [ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" @@ -354,12 +392,13 @@ echo "RC=$RC" ### 4.2 Guidance, permissions and agents -`install --part all` writes the `semble-first` rule (never blind-overwriting a user-edited file), refreshes the `` block in `CLAUDE.md`, copies the three hooks into `.claude/hooks/` (SessionStart, PreToolUse advisory, SubagentStart/`Explore`), and merges the settings + the two exact permission entries. Every step is idempotent. Then the project agents are audited and patched — **project scope only**; global agents are never touched by `install`/`resume`. +`install --part all` writes the `semble-first` rule (never blind-overwriting a user-edited file — the `--force` rule of Step 3.3b applies here too), writes `/.sembleignore` under that same managed-file policy (`--part ignore`; it keeps generated trees such as `.claude/tmp/` and `.claude/reports/` out of the index — on this workspace that was 32% of all indexed files), refreshes the `` block in `CLAUDE.md`, copies the three hook files into `.claude/hooks/` (`semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`), **deletes the two retired ones** (`semble-reminder.mjs`, `semble-explore.mjs`) and merges the **four** settings entries they wire — SessionStart, UserPromptSubmit, PostToolUse and PostToolUseFailure on the stats matcher, each with `"timeout": 5` (**seconds**) — plus the two exact permission entries. The merge is a reconcile, not an append: a v1-shaped `settings.json` has its `PreToolUse`/`Bash`, `PreToolUse`/`Grep` and `SubagentStart`/`Explore` rows purged and the emptied events removed, and the retired `.claude/semble/.reminder-ts` ignore line — and the marker file itself — are dropped. **`--part ignore` also MEASURES the repo** (`semble-project.sh candidates`: byte-identical duplicate trees, and directories or single files carrying a disproportionate share of the corpus, with exact chunk counts once an index exists) and writes what it found into a delimited block at the end of `.sembleignore`, **commented out**. Nothing is excluded until the user uncomments a line: a wrong exclusion removes code from the index silently, which is the worse error, so the scan proposes and the user decides. Re-running only ever adds paths it has never proposed, and the block is stripped before the managed-file compare, so an annotated file still reads `managed`. Measured: `.codex/` at 13.3% + `RELEASE-NOTES.md` at 5.5% here, `data/` at 26.5% on a second repo where nothing in the generic template matched anything. Every step is idempotent. Then the project agents are audited and patched — **project scope only**; global agents are never touched by `install`/`resume`. **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" RC=0 bash "$SD/scripts/semble-guidance.sh" install --part all --json || RC=1 bash "$SD/scripts/semble-agents.sh" audit --scope project --json || RC=1 @@ -382,7 +421,8 @@ Both `semble-state.sh` writes also refresh the installer-owned fields — `resum **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" SMOKE_OK=1 # set to 0 when Step 4.1 reported "status":"skipped" — never guess STEPS="prereq mcp permissions guidance agents" [ "$SMOKE_OK" = "1" ] && STEPS="$STEPS warm smoke" @@ -411,7 +451,8 @@ Each is one delegation. Run Step 1 first, state the plan, then the block. **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" ACTION=disable # or: enable bash "$SD/scripts/semble-project.sh" "$ACTION" --yes --json && echo "✅" || echo "❌ FAILED" ``` @@ -425,7 +466,8 @@ There is no per-repo rebuild CLI. Run it **without** `--yes` first: it exits `4` **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-project.sh" reindex --json; RC=$? echo "RC=$RC # 4 = confirmation required, nothing deleted" [ "$RC" -eq 4 ] || [ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" @@ -442,7 +484,8 @@ Fan out the four audits, then report findings and offer concrete actions. It mut **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" RC=0 bash "$SD/scripts/semble-project.sh" audit --json || RC=1 bash "$SD/scripts/semble-cache.sh" info --json || RC=1 @@ -455,12 +498,13 @@ bash "$SD/scripts/semble-agents.sh" audit --scope project --json || RC=1 ### `upgrade` -Compare the recorded pin against the approved `0.5.2`. **Identical -> no-op**: report `unchanged` and stop. Different -> print the exact `from -> to` transition and the exact commands, confirm once, then re-register through `repair` (which uses `add-json`) and go to the Step 3.4 reload checkpoint. Never `@latest`, never an unpinned `--from semble[mcp]`. +Two halves, and **the second one always runs**. The MCP half compares the recorded pin against the approved `0.5.4`: **identical -> no-op** for that half; different -> print the exact `from -> to` transition and the exact commands, confirm once, then re-register through `repair` (which uses `add-json`) and go to the Step 3.4 reload checkpoint. Never `@latest`, never an unpinned `--from semble[mcp]`. **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-install.sh" check --json; bash "$SD/scripts/semble-mcp.sh" detect --json; RC=$? echo "RC=$RC" [ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" @@ -468,6 +512,29 @@ echo "RC=$RC" > **STOP if ❌** — do not re-register on an unreadable detection. Apply with `semble-mcp.sh repair --yes --json` only after the user confirms the printed transition. +The project half is **unconditional and runs even when the pin is unchanged** — it is the only thing that moves this install's version stamp. Re-run the Step 3.3b block verbatim: `semble-guidance.sh install --part all` re-copies the rule, `.sembleignore` and the three live hooks from the plugin's assets (a byte-copy: identical files report `unchanged`, a file whose only delta is the release stamp takes the metadata-only re-sync branch, a hand-edited one is skipped and diffed to stderr), **deletes the two v5.0.0-retired hooks** `semble-reminder.mjs` / `semble-explore.mjs` if the install predates the migration, and re-merges the settings entries and permissions. + +**EXECUTE** using Bash tool: + +```bash +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" +RC=0 +bash "$SD/scripts/semble-guidance.sh" install --part all --json || RC=1 +bash "$SD/scripts/semble-agents.sh" apply --scope project --yes --json; ARC=$? +echo "agents apply RC=$ARC # 3 = reported conflict, not a failure" +{ [ "$ARC" -eq 0 ] || [ "$ARC" -eq 3 ]; } || RC=1 +[ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED" +``` + +> **STOP if ❌** — report which half failed and what it left behind; the MCP registration stands either way. +> +> **Without this block `upgrade` could never clear a `stale` verdict.** `setup-status` reads this install's version out of the frontmatter of `.claude/rules/semble-first.md`, and `semble-guidance.sh install` is the only writer of that file — an `upgrade` that skipped it reported success and left the stamp exactly where it was, so the next `status` printed `stale` again forever. +> +> `skipped: rule: user_modified` is the ONE case where the stamp does not move: the hand-edit is preserved on purpose. Read the printed `diff -u` and follow the Step 3.3b table — re-running with `--force` (a backup is taken) is what re-stamps it. +> +> **Stray cache root, installs that ran a pre-5.0.1 prefetch hook.** That hook spawned `semble search` without `SEMBLE_CACHE_LOCATION`, so the child used semble's own default root — `~/Library/Caches/semble` on macOS, `$XDG_CACHE_HOME/semble` elsewhere — and built a SECOND copy of every index the MCP server had already built under `semble-code` (measured: 62 MB here). The hook now passes the registered root, so nothing writes there any more, but the old copy is not deleted by any mode: it sits outside the project and this skill does not remove machine-level directories on its own. Report it and hand the user the command — `du -sh ~/Library/Caches/semble` to size it, `rm -rf ~/Library/Caches/semble` to drop it. **The registered root is `semble-code` and `semble-docs` is reserved beside it; neither is ever the one to delete** — that single missing suffix is the whole bug. + ### `uninstall` — four flavours, always an explicit choice | Flavour | Rule / hooks / CLAUDE.md | MCP | Agent frontmatter | Cache | uv tool | @@ -482,7 +549,8 @@ echo "RC=$RC" **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" FLAVOUR=integration # or: mcp | cli — chosen by the user, never guessed bash "$SD/scripts/semble-remove.sh" "$FLAVOUR" --yes --json && echo "✅" || echo "❌ FAILED" ``` @@ -496,7 +564,8 @@ Requires `--yes` **and** the literal `--confirm-text "purge semble code cache"`. **EXECUTE** using Bash tool: ```bash -SD="${CLAUDE_SKILL_DIR:-$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)}" +SD="${CLAUDE_SKILL_DIR}" +[ -n "$SD" ] || SD="$(find "$HOME/.claude/plugins/cache/claude-brewcode/brewcode" -maxdepth 3 -type d -path '*/skills/*' \( -name semble-setup -o -name semble \) 2>/dev/null | sort -V | tail -1)" bash "$SD/scripts/semble-remove.sh" purge --yes --confirm-text "purge semble code cache" --json && echo "✅" || echo "❌ FAILED" ``` diff --git a/brewcode/skills/semble-setup/assets/INSTALL.md b/brewcode/skills/semble-setup/assets/INSTALL.md index 2dcc89c..836ad6f 100644 --- a/brewcode/skills/semble-setup/assets/INSTALL.md +++ b/brewcode/skills/semble-setup/assets/INSTALL.md @@ -9,26 +9,36 @@ wires nothing on its own. | File | Target | Event | Channel | |------|--------|-------|---------| | `semble-first.md.template` | `/.claude/rules/semble-first.md` | — | project rule, always loaded | +| `sembleignore.template` | `/.sembleignore` | — | read by the indexer, keeps generated junk out of the corpus | | — (marker block) | `/CLAUDE.md` | — | 6 lines between HTML markers | | `semble-session.mjs` | `/.claude/hooks/` | SessionStart | `systemMessage` + `additionalContext` | -| `semble-reminder.mjs` | `/.claude/hooks/` | PreToolUse (`Bash`, `Grep`) | `additionalContext` ONLY — advisory | -| `semble-explore.mjs` | `/.claude/hooks/` | SubagentStart (`Explore`) | `additionalContext` into the SPAWNED subagent | +| `semble-prefetch.mjs` | `/.claude/hooks/` | UserPromptSubmit | `additionalContext` — top-3 candidate PATHS from a real search | +| `semble-stats.mjs` | `/.claude/hooks/` | PostToolUse + PostToolUseFailure | **nothing** — appends JSONL telemetry, replies `{}` | + +> Retired in 5.0.0: `semble-reminder.mjs` (PreToolUse `Bash`/`Grep`) and +> `semble-explore.mjs` (SubagentStart `Explore`). Both were pure advice and both +> converted at zero; `semble-prefetch.mjs` replaces them. `install` and `upgrade` +> DELETE the two files and un-wire their settings rows — see §4. > Pure ESM, Node built-ins only, no plugin-root and no npm deps. Each reads -> stdin, never throws, prints exactly one JSON object and exits 0. No hook -> spawns a process, calls `pgrep`, or implies a daemon: **semble has no watcher -> and no daemon** — the index is rebuilt inside a tool call and cached. +> stdin, never throws, prints exactly one JSON object and exits 0. Only +> `semble-prefetch.mjs` spawns a process, and only a single `uvx … semble search` +> under a hard 3 s `SIGKILL` cap. No hook calls `pgrep` or implies a daemon: +> **semble has no watcher and no daemon** — the index is rebuilt inside a tool +> call and cached. ## The one-command path ``` -scripts/semble-guidance.sh install [--part rule|claudemd|hooks|permissions|all] [--force] [--json] +scripts/semble-guidance.sh install [--part rule|ignore|claudemd|hooks|permissions|all] [--force] [--json] scripts/semble-guidance.sh status [--json] scripts/semble-guidance.sh remove [--part ...|all] [--force] [--json] ``` -`install --part all` does, in order: rule -> CLAUDE.md block -> copy the three -`.mjs` -> `.gitignore` line -> settings hooks + permissions merge. Every step is +`install --part all` does, in order: rule -> `.sembleignore` -> CLAUDE.md block +-> copy the three `.mjs` (deleting any retired one) -> `.gitignore` line +-> settings hooks + permissions +merge. Every step is idempotent and re-runnable. `--json` prints one object `{schema,mode,part,changed,unchanged,skipped,failed}` and nothing else; a non-empty `failed` makes the script exit 1. @@ -46,7 +56,7 @@ It teaches the three facts that make every generated tool call work: |------|----------------| | `repo` is a **required** parameter on `search` AND `find_related` | it is the absolute project root (or an `https://` git URL) and is never inferred — omit it and the call fails | | results carry `start_line` / `end_line`, **there is no `line` field** | open the hit at `start_line` | -| `.html`/`.htm` and `.json`/`.json5`/`.csv`/`.tsv`/`.psv` are outside the corpus | `rg` is the only way to reach them | +| `.json`/`.json5`/`.csv`/`.tsv`/`.psv` and `.mdx`/`.txt` are outside the corpus — `.html`/`.htm` **are** indexed, in the docs bucket | `rg` is the only way to reach the ones that are not | **Install policy — never a blind `cp`:** @@ -63,6 +73,92 @@ up and then deleted. --- +## 1a. The ignore file + +`/.sembleignore`, verbatim from `sembleignore.template`, managed by the +exact same policy table as the rule file above (`--part ignore`). + +**Mechanism, verified in source** — `semble/index/file_walker.py`, +`_load_ignore_for_dir()`: for each directory the walker reads exactly +`./.gitignore` and `./.sembleignore` and compiles them with `pathspec`'s +`GitIgnoreSpec`. It never shells out to git, so `core.excludesFile` and +`~/.gitignore_global` are NOT honoured — a repo-local file is the only lever. +Patterns are per-directory, gitignore syntax, `!` un-ignore included. + +Two properties of that function decide what the template can do: + +- **`.sembleignore` wins ties.** Its lines are appended *after* the sibling + `.gitignore`'s into one spec, and `_is_ignored` keeps the last pattern that + matched. A rule here overrides a conflicting `.gitignore` rule, negations + included. +- **A `!` negation ending in a file extension skips the extension filter** + (`_is_ignored`'s `found` flag). `!web/docs/package-lock.json` therefore + indexed a lockfile whose suffix belongs to no content type at all — 552 + chunks, 5.9% of this workspace's index — and `!*.png` negations added 143 + chunks of decoded binary. No `--content` change removes them; only a + re-ignore does. Full derivation: `references/language-coverage.md`. + +Why it ships: without it the index absorbs whatever the tooling wrote into the +repo. On this workspace `.claude/tmp/` (vendored upstream markdown) was 214 of +871 indexed files and semble returned it as evidence about this project. +Installing the template took the index from **871 -> 590 files (-32.3%)** and +**13219 -> 9038 chunks**, and dropped noise hits across 10 real queries from +**18/120 to 0/120**, displacing exactly one legitimate result out of a top-12. + +Round 2 (binary + lockfile blocks, plus this repo's per-repo lines) on the same +workspace, isolated cache, 16 questions at `k=5`: + +| | Before | After | +|---|---|---| +| Files / chunks / cache | 593 / 9307 / 23 MB | **370 / 5781 / 14 MB** | +| Junk result slots | **24 of 80 (30.0%)** | **0 of 80** | +| Queries with junk in the top 5 | 13 of 16 | 0 of 16 | + +Attribution of the 3526 removed chunks: `.codex/` mirrors 2202, lockfile 552, +changelog 503, binaries 143, root `skills/` duplicate 126. The two generic +blocks alone account for 695 chunks (7.5%) and need no per-repo knowledge. + +No previously-correct hit was lost. Three queries changed a top-5 member: two +were ties at identical scores swapping equivalent files, and on the third the +*answer* moved up into the top 3 while the implementation file it replaced +stayed at rank 5 at `k=10`. One question that used to fail (P6, "how is the +plugin root path resolved, current and legacy") now lands at rank 3, on a chunk +that had been outranked by a changelog entry and two mirror copies. + +The template is **conservative on principle** — excluding something the user +wanted indexed is worse than leaving noise, so every shipped rule has to hold in +any repo, sight unseen: + +| Block | Why it is generic | +|-------|-------------------| +| `.claude/{tmp,reports,backups,logs,semble,projects,history,file-history,shell-snapshots,statsig,todos,ide}/` | Machine state and scratch written by the harness itself, identical in every repo. Deliberately NOT `.claude/{skills,agents,rules,commands,hooks,scripts,tasks}/` — those are authored source | +| Build output: `target/ coverage/ htmlcov/ .gradle/ .astro/ .turbo/ .parcel-cache/ .nuxt/ .svelte-kit/ .output/ .docusaurus/ .terraform/ .dart_tool/ _site/` | Tool-owned output directories with fixed names. Does not repeat semble's own `_DEFAULT_IGNORED_DIRS` (`.git`, `node_modules`, `venv`, `dist`, `build`, …), already skipped | +| Vendored trees: `vendor/ third_party/ bower_components/ .yarn/ Godeps/` | Conventional names for "someone else's source, copied in" | +| Minified bundles: `*.min.js *.min.css *.bundle.js *.map` | Generated, unreadable, never authored | +| **Binary suffixes** (`*.png *.pdf *.woff2 *.zip *.so *.sqlite`, ~45 in all) | Zero-risk by construction: none of these suffixes maps to a language, so against a plain `.gitignore` the block is a no-op. It exists solely to cancel a negation bypass, and decoded binary is never the answer to anything | +| **Lockfiles** (`package-lock.json yarn.lock pnpm-lock.yaml Cargo.lock go.sum …`) | Machine-written dependency resolution. `pnpm-lock.yaml` is a `.yaml` and reaches the config bucket outright; the rest arrive only through the bypass | + +**What is deliberately NOT shipped**, because no static pattern can recognise it +without knowing the repo: + +| Not shipped | Reasoning | +|-------------|-----------| +| Duplicate/mirror trees | The generic form of "this tree is a copy of that tree" is content-hash dedup, not a filename. `.codex/` in particular is another agent runtime's directory — exactly parallel to `.claude/`, whose authored subdirectories we deliberately keep — so blanket-ignoring it by name would contradict the rule right above it. Here it happened to be a generated triplicate (211 files, 2202 chunks, 15 of 80 result slots); in the next repo it is hand-written config | +| `RELEASE-NOTES.md` / `CHANGELOG.md` | Genuinely the right answer to "when did X land" and genuinely ruinous for "how does X work" (503 chunks, 9 of 80 slots here). Which of those a user asks is not knowable from the file | + +Both are covered instead by a commented **per-repo section** at the end of the +template: the reasoning, the measured cost, and a copy-pasteable snippet that +prints the 20 files eating the most chunks in *this* repo's cache. The user adds +the lines; nothing ships pre-filled. + +> **Follow-up, not implemented here:** the per-repo section is manual because the +> generator (`semble-guidance.sh`) writes the template verbatim and has no +> detection pass. Duplicate-tree detection (hash every indexed file, report paths +> sharing a digest) belongs in `install --part ignore` and would turn that +> commented block into a proposed diff the user confirms. + +--- + ## 2. CLAUDE.md marker block Appended to `/CLAUDE.md` (created as `# CLAUDE.md` when absent): @@ -74,7 +170,7 @@ Appended to `/CLAUDE.md` (created as `# CLAUDE.md` when absent): > Semantic search first: ONE `mcp__semble_code__search` with `repo` = absolute project root, > `top_k=5`, `max_snippet_lines=10` — then open the hit at `start_line`. > `rg`/Grep stays for exact identifiers, regexes, paths and exhaustive enumeration. -> Not indexed: `.html`, `.json`/`.csv`. Details: `.claude/rules/semble-first.md`. +> Not indexed: `.json`/`.csv`, `.mdx`/`.txt`. Details: `.claude/rules/semble-first.md`. ``` @@ -103,79 +199,272 @@ Reads exactly one file, `/.claude/semble/state.json`. | `phase === "ready"` | `semble: ready \| cache ` + the one-search-then-read directive with `repo=` | | any other phase | `semble: ` | -### `semble-reminder.mjs` — PreToolUse, matchers `Bash` and `Grep` +### `semble-prefetch.mjs` — UserPromptSubmit, no matcher -**ADVISORY ONLY.** It emits at most `hookSpecificOutput.additionalContext` and -never `permissionDecision`, never a deny, never `updatedInput`. It cannot block, -rewrite or slow a search; a legitimate exact/exhaustive `rg`/`grep` is never -affected. The message ends with "this is a reminder, not a block." +**It replaces the two advisory hooks that shipped before 5.0.0.** They emitted +`additionalContext` telling the model to prefer semble, and converted at +**zero**: 0/18 on the main channel (95% upper bound 15.4%), 0/11 on the +Explore/subagent channel (upper bound 23.8%). Delivery was proven independently +three ways — a transcript `hook_additional_context` attachment record, a canary +session that quoted the injected sentence back verbatim, and 11/11 subagent +initial contexts containing it. The model receives the advice and ignores it. +This hook runs the search itself and hands over the **result** instead: measured +5/6 sessions opened an injected path, 5/6 cited one, at fewer tool calls than +control in 5/6 questions and ~15% lower mean cost. -It returns `{}` when ANY of these holds: +> **It buys turns and citation precision, not correctness.** All 18 answers were +> correct in all three arms (control, snippet-framing, path-framing). Nothing in +> this skill may claim prefetch makes answers more accurate. -1. state file missing / unparseable, `enabled === false`, or `phase !== "ready"` - (this is also the "MCP not available" case — nothing is ever suggested before - the server is verified); -2. `tool_name` is neither `Bash` nor `Grep`; -3. Bash and `tool_input.command` is empty or has no search binary at a command - boundary (`SEARCH_RE` below); -4. the command already mentions `semble`; -5. the throttle marker `/.claude/semble/.reminder-ts` is younger than 600 s - (written on every emit; a failed write is ignored); -6. `isExactIntent()` is true. +**Advisory only** in the same sense as its predecessors: it emits at most +`hookSpecificOutput.additionalContext`, never `permissionDecision`, never a +deny, never `updatedInput`. It cannot block or rewrite anything. -```js -const SEARCH_RE = /(?:^|[|;&(]|&&|\|\|)\s*(?:command\s+)?(grep|egrep|fgrep|ugrep|rg|ag|ack|find|bfs)\b/; +#### Fail-open is the top-priority property + +It runs on **every prompt the user types**. A crash or a hang here is the worst +failure mode in the skill, so every path ends in `{}` on stdout and exit 0: + +| Broken input | Behaviour | +|---|---| +| stdin empty / not JSON / not an object | `{}` | +| state file missing, empty, corrupt, a directory | `{}` (skip `no-state` / `corrupt`) | +| `enabled === false`, `phase` `disabled` / `error` / `prereq_ready` | `{}` | +| MCP never registered (`completed` lacks `"mcp"`) | `{}` (skip `no-mcp`) | +| marker file unreadable / malformed JSON | read as `{}` — a corrupt throttle fails **open** | +| marker file unwritable | warn on stderr, continue | +| `uvx` absent (ENOENT) | `{}` + cooldown | +| search exits non-zero, times out, or prints unparseable output | `{}` + cooldown | +| telemetry file unwritable | swallowed; behaviour identical | +| anything else thrown | caught in `main`, `{}` | + +Timing is bounded twice. The child is spawned with +`timeout: 3000, killSignal:'SIGKILL'` — under the registered hook timeout of 5 +seconds — and any failure writes a `cool` marker that parks the mechanism for +**600 s**. That is what makes a **cold or absent index** safe: semble builds +lazily inside the call and a first build takes minutes, so without the cooldown +every prompt would burn the full 3 s cap. With it the cost is one 3 s stall per +ten minutes until the MCP server has warmed the same cache directory. A separate +`t` marker throttles successful firings to one per **30 s** (anti-storm, not a +rate limiter — the gate already suppresses ~64% of prompts). + +Both live in ONE file, `/.claude/semble/.prefetch-ts`, holding +`{"t":,"cool":}`, so the install needs exactly one +`.gitignore` line. + +#### Decision order + +`stateGate` -> cooldown -> throttle -> `gateV3` -> `distill` -> `search` -> +render. Each step that stops emits exactly one telemetry record with the reason +token, so every silent prompt is attributable to the clause that silenced it. + +#### Gate v3 — `INTENT and (DOMAIN or REPOREF)`, minus four suppressors + +| Clause | Meaning | +|---|---| +| `INTENT` | an interrogative or an investigative verb, EN + RU | +| `DOMAIN` | a code noun, a backticked span, `snake_case`/`camelCase`, a `.ext` filename, `fn()`, or a path | +| `REPOREF` | the prompt anchors to THIS repo (`в проекте`, `our codebase`, `у нас`) without naming a code noun | +| S1 `self-reference` | about this conversation or what the assistant just did — the answer is in context, not on disk | +| S2 `exact-literal` | an exact path, filename or quoted literal — rg territory (rg won 2/2, 20x faster) | +| S3 `enumeration` | exhaustive listing/counting — rg won 5/5, semble 2/5 | +| S4 `task-reference` | a numbered task or section from the tracker, not code | + +Plus the cheap pre-filters `empty`, `slash-command`, `codeword-only`, +`meta-reply`, `too-short` (<30 chars), `too-long` (>2000), `bare-url`, +`bare-path`. + +`REPOREF` is the whole of v3. v1 required a code-domain noun, which a +vocabulary-mismatch question by construction never has, and that one clause was +what capped recall. + +Two details are load-bearing and were measured that way — changing either +invalidates the numbers below: + +- **The four suppressors read the WHOLE trimmed prompt**, codewords included, + while `INTENT`/`DOMAIN`/`REPOREF` read the codeword-stripped body. A `++m` + prefix must never flip a verdict. +- The `SELF` regex exempts `как ты думаешь` (an opinion, not a question about the + transcript) and includes the profanity forms that actually occur in the corpus. + +Measured on 61 real user prompts: + +| fires | precision | recall | F1 | confusion | +|---|---|---|---|---| +| 22 / 61 (36%) | 55% | 71% | 0.62 | tp 12, fp 10, fn 5, tn 34 | + +**55% is the honest ceiling of lexical rules** — roughly half of the firings are +pure overhead. That is affordable only because a firing costs one ~0.6 s search +and ~90 tokens. Do not "improve" the gate without re-running the 61-prompt +corpus. + +#### Distiller + +The prompt is reduced to at most 9 keywords: code-shaped tokens first +(backticked spans, `.ext` filenames, `kebab`/`snake`, `camelCase`), then content +words minus a bilingual stop-list. Measured against handing semble the raw +prompt: **hit@3 11/16 vs 9/16, MRR 0.674 vs 0.398, paired 8 wins / 3 losses / +5 ties.** The assumption that rewriting the query would hurt is refuted. + +#### The search, and why its flags are frozen + +``` +uvx --from 'semble[mcp]==0.5.4' semble search \ + --content code docs config -k 3 --max-snippet-lines 0 ``` -#### The heuristic is APPROXIMATE — and biased to silence +`PIN_SPEC` and `CONTENT_ARGS` MUST stay byte-identical to the MCP registration +written by `semble-mcp.sh` (`SEMBLE_PIN_SPEC`, `SEMBLE_CONTENT_ARGS` in +`scripts/lib/semble-common.sh`). semble keys its cache directory by project path +ALONE but rejects a cached index whose content-type set differs, so a mismatched +set here would make the hook and the server evict each other's index on every +alternation. `tests/suite-hooks.mjs` asserts the two agree. -`isExactIntent(command, pattern, bin)` cannot actually tell a semantic question -from a literal search. It is a cheap syntactic filter, and every ambiguity -resolves to **silent**. It returns true when ANY of: +`null` from `search()` means "could not be trusted" and starts the cooldown; +`[]` means the search ran and found nothing, which is **not** a failure and must +not park the mechanism. -| # | Rule | Rationale | -|---|------|-----------| -| a | the command matches `/(^\|\s)-{1,2}(F\|fixed-strings\|w\|word-regexp\|l\|files-with-matches\|L\|files-without-match\|c\|count\|o\|only-matching)(=\|\s\|$)/` | literal / enumeration / verification flags | -| b | the pattern contains any of `\ ^ $ * + ? ( ) [ ] { } \|` | a real regex, not a description | -| c | the pattern contains `/` or matches `/\.[A-Za-z0-9]{1,6}$/` | a path or a filename | -| d | the binary is `find`/`bfs` and the command has `-name`/`-path`/`-iname`/`-type` | filename search, semble cannot help | -| e | the command pipes into `wc`, `sort`, `uniq`, `head`, `tail`, `cut`, `awk` | exhaustive enumeration | -| f | the pattern is shorter than 3 characters | too short to be an intent | -| g | pattern extraction failed | unknown shape -> stay silent | +#### The framing — paths, never snippets -Pattern extraction: the first argument after the search binary that does not -start with `-`, with one matching layer of `'...'` / `"..."` stripped. Only the -FIRST search command of a pipeline is examined. For the native `Grep` tool the -pattern IS the command for heuristic purposes, and `output_mode` of -`files_with_matches` / `count` is treated as enumeration (rule a by another name). +``` +Retrieval note (automatic, from a semantic index of THIS repository, built by semble over the working tree). +These candidate locations were ranked for the question above before you started: + 1. path/to/file.ext:120 + 2. ... + 3. ... -Consequence, accepted deliberately: the reminder will sometimes stay silent when -semble would have helped. That is the correct trade — a false nag on an exact -search costs the model attention on every single grep, a missed nudge costs -nothing but one extra tool call. - -The `Grep` matcher is registered even though native `Grep`/`Glob` are no-ops on -the macOS Claude Code build (search there goes through `Bash`). Other builds -still have the tool; the entry is inert where it is not. - -### `semble-explore.mjs` — SubagentStart, matcher `Explore` - -The built-in `Explore` subagent type has the semble MCP tools available but not -pre-listed in its own tool set, so it has to `ToolSearch` its way to -`mcp__semble_code__search` before it can call it — and usually reaches for `rg` -instead. `SubagentStart`'s `additionalContext` lands in the SPAWNED subagent's -own transcript, not the parent's, so the reminder arrives before its first move. - -Reads exactly one file, `/.claude/semble/state.json`. Returns `{}` unless -ALL of these hold: `agent_type === "Explore"`, the state file parses, -`enabled !== false`, and `phase === "ready"`. Otherwise: - -```json -{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"semble: call mcp__semble_code__search directly first (repo=\"\", top_k=5) for intent/behavior questions — it is already available, no ToolSearch needed. rg/Grep stay for exact/exhaustive matches."}} +Open the candidates that look right BEFORE running any search of your own; they are already ranked. +If none of them answers the question, say so and search normally. +Name the file you actually used in your answer. ``` -Advisory only, like the reminder: no `permissionDecision`, no throttle, no -process. The matcher is exactly `Explore` — no other subagent type is touched. +Three parts, all load-bearing: named **provenance** (what produced this and from +what), bare **paths** with no snippet, and an explicit **directive** that +includes permission to reject the candidates. + +The snippet exclusion is empirical, not stylistic. With 5 hits carrying +path + lines + snippet and no directive, conversion was **2/6**, and in **2/6 +sessions the model answered with ZERO tool calls** straight off the snippets — +the snippet SUBSTITUTES for verification. Three bare paths plus a directive +provoke the read instead. **Never add `content` to this output.** + +#### Latency + +semble search 727 ms median / 734 ms p90 on a warm index; the hook measured +736 ms when it fires and **2 ms when the gate suppresses** — the common case +costs nothing. + +### `semble-stats.mjs` — PostToolUse + PostToolUseFailure, matcher list + +The measurement hook. The other two can only prove they *ran*; this one is the +only place where a completed `mcp__semble_code__*` call is observable and the +only place where an opened FILE is observable, so it is what turns "prefetch +fired" into "prefetch converted". + +**Pure observer.** It returns the neutral `{}` on every event, always exits 0, +and emits no `additionalContext`, no `permissionDecision`, no `systemMessage`. +Wiring it cannot change what any tool call does. Its only side effect is one +appended JSONL line. + +Registered on **both** post-tool events with the **same** matcher: + +``` +mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read +``` + +Why two rows and not one: on Claude Code 2.1.226 a tool call that errors fires +`PostToolUseFailure` **instead of** `PostToolUse`, never both. A single +`PostToolUse` row would silently drop every failed call — including `grep` with +no matches (exit 1), which is a large and perfectly ordinary slice of the +denominator. + +Why `|` and not `,`: the matcher is parsed as an exact name list when it matches +`/^[a-zA-Z0-9_|, -]+$/` at some call sites and the stricter `/^[a-zA-Z0-9_|]+$/` +at others, and as an unanchored regex otherwise. A `|`-only list is an exact list +under both readings; a comma-or-space list is not. The two `mcp__…` names are +safe in a list — Claude Code only warns about an `mcp__x` matcher with a single +`__` segment, and both of these have two. + +What the payload really carries on this build (verified against the shipped +binary, not the docs prose): + +| Field | Present | Used for | +|-------|---------|----------| +| `tool_name`, `tool_input`, `session_id`, `cwd`, `hook_event_name`, `tool_use_id` | always | routing, `sid`, log path | +| `tool_response` | `PostToolUse` only | `ok` (an explicit `isError`/`is_error` flips it false) | +| `error`, `is_interrupt` | `PostToolUseFailure` only | — (`ok:false` is already implied by the event) | +| `duration_ms` | optional, both events | `ms` — **omitted from the record when absent, never invented** | +| `agent_id` | subagent calls only | `agent: "sub"` | +| `agent_type` | subagent calls, and main thread of a `--agent` session | `agent: "sub"` | + +`agent` is `sub` when **either** `agent_id` or `agent_type` is present, `main` +otherwise. `agent_id` alone would be the strictly correct test (a `--agent` +session's main thread carries `agent_type` but no `agent_id`), but +`semble-prefetch.mjs` uses the both-keys test and the conversion metric joins a +`prefetch` to a later `open` on this field — the two writers agreeing matters +more than either being right about `--agent` sessions. **Change both or neither.** + +`Explore` is deliberately absent from the matcher: it is an agent *type*, not a +tool name — the tool is `Agent` (with `Task` as an alias). Counting `Agent` would +double-count, because the subagent's own `Bash`/`Grep` calls already arrive here +tagged `agent:"sub"`. + +Search-shaped filter for the denominator: `Grep` and `Glob` are search tools by +definition and always count. A `Bash` call counts only when `SEARCH_RE` matches +it, so numerator and denominator use one definition of "search-shaped". + +`Read` is in the matcher for **one** reason: prefetch conversion. It is never +counted as a search — semble does not displace opening a known file. Every +`Read` emits `ev:"open"` carrying the path in **both** repo-relative (`f`) and +absolute (`abs`) form, because `semble-prefetch.mjs` logs semble's repo-relative +`file_path` while Claude Code reads with an absolute path, and the reader has no +`cwd` at read time. Without this record the conversion number would only ever +exist while somebody was replaying transcripts by hand. + +--- + +## 3a. Telemetry contract + +File: `/.claude/semble/telemetry.jsonl`. One JSON object per line, appended +with a **single `appendFileSync(path, JSON.stringify(rec) + "\n")`** — never +read-modify-write, so concurrent hooks cannot interleave a partial record. + +Every record carries `ts` (`new Date().toISOString()`), `ev`, `src`, and `sid` +(the hook input's `session_id`, or `""`). + +| `ev` | `src` | Extra fields | +|------|-------|--------------| +| `prefetch` | `prefetch` | `fired` (bool) — **exactly one record per prompt that reached the hook**. When `false`: `why` = the clause that stopped it, plus `phase`/`enabled` on a state skip and `q`/`ms` on `search-failed`/`no-hits`. When `true`: `why` (always `behaviour-or-vocab`), `q` (≤120 chars, the DISTILLED query), `n` (hit count, 1..3), `ms` (whole-hook latency), `paths` (the injected repo-relative paths, in rank order) | +| `open` | `stats` | `f` (repo-relative path, ≤200 chars), `abs` (absolute path, ≤200 chars), `agent` — one per `Read` | +| `call` | `stats` | `tool` (full MCP name), `ok` (bool), `ms` (int, **omitted** when the payload had no `duration_ms`), `agent` | +| `search` | `stats` | `tool`, `q` (≤120 chars), `agent` | + +Retired in 5.0.0: `ev:"gate"` and `ev:"nudge"` with `src` `reminder`/`explore`. +The reader still tolerates them in an old log and reports them under a +`[retired hooks]` label; nothing writes them any more. + +**The conversion join — computable from the log alone, no re-run.** For each +`prefetch` record with `fired:true`, an injected path CONVERTED when some later +`open` record in the SAME `sid` (strictly `open.ts > prefetch.ts`) has an `f` or +`abs` ending in that path. `semble-status.sh --section telemetry` reports it as +`prefetchConversion`: `pathsInjected`/`pathsOpened`/`pathPct` (per candidate) and +`injectedSessions`/`openedSessions`/`sessionPct` (per session). The +post-release headline number is **sessionPct: the fraction of sessions where an +injected candidate was subsequently opened** — the same quantity that measured +5/6 in the pre-release trial. + +Writer rules, binding on every hook that logs: + +- Every write is wrapped in `try/catch` and swallowed. A telemetry failure must + never break a tool call or change the hook's output by one byte. +- Size guard, best-effort: over **2 MB**, keep the last **~1000** lines before + appending. The guard is itself inside a `try/catch` — a failed trim appends + anyway rather than losing the sample. +- Exit 0 always. + +Reader rules (`semble-status.sh --section telemetry`): an absent file reports +"no telemetry yet"; a truncated final line and an unknown `ev` from a future +version are **counted and skipped**, never fatal. --- @@ -190,12 +479,14 @@ process. The matcher is exactly `Explore` — no other subagent type is touched. "SessionStart": [ { "hooks": [ { "type": "command", "command": "node", "args": ["/semble-session.mjs"], "timeout": 5 } ] } ], - "PreToolUse": [ - { "matcher": "Bash", "hooks": [ { "type": "command", "command": "node", "args": ["/semble-reminder.mjs"], "timeout": 5 } ] }, - { "matcher": "Grep", "hooks": [ { "type": "command", "command": "node", "args": ["/semble-reminder.mjs"], "timeout": 5 } ] } + "UserPromptSubmit": [ + { "hooks": [ { "type": "command", "command": "node", "args": ["/semble-prefetch.mjs"], "timeout": 5 } ] } ], - "SubagentStart": [ - { "matcher": "Explore", "hooks": [ { "type": "command", "command": "node", "args": ["/semble-explore.mjs"], "timeout": 5 } ] } + "PostToolUse": [ + { "matcher": "mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read", "hooks": [ { "type": "command", "command": "node", "args": ["/semble-stats.mjs"], "timeout": 5 } ] } + ], + "PostToolUseFailure": [ + { "matcher": "mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read", "hooks": [ { "type": "command", "command": "node", "args": ["/semble-stats.mjs"], "timeout": 5 } ] } ] }, "permissions": { @@ -204,13 +495,21 @@ process. The matcher is exactly `Explore` — no other subagent type is touched. } ``` -`timeout` is in SECONDS and is **not optional**: an entry without it -inherits Claude Code's 600 s default, so a hung `node` on the `Bash` matcher would -stall the tool call for 10 minutes. 5 s is ~80x the measured -runtime of these hooks. +`timeout` is in SECONDS and is **not optional**: an entry without it inherits +Claude Code's 600 s default, so a hung `node` on `UserPromptSubmit` would stall +every prompt for 10 minutes. 5 s is ~7x the measured p90 of the one hook that +does real work (`semble-prefetch.mjs`, which caps its own child at 3 s) and +~500x the runtime of the other two. The marker for all semble entries is `args` containing a path whose basename is -`semble-session.mjs`, `semble-reminder.mjs` or `semble-explore.mjs` — which is exactly why the +one of the **five names this skill has ever owned** — +`semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs` and the retired +`semble-reminder.mjs`, `semble-explore.mjs`. Ownership (`marks`) and desire +(`live`) are deliberately SEPARATE lists: `wanted` is built from `live` only, so +a retired hook sitting at the CURRENT hooks dir is stale by construction and the +step-2 purge removes it. Building `wanted` from `marks` — the pre-5.0.0 bug — +made every retired row survive forever. A retired basename must never leave +`marks`, or an old install becomes unowned and unremovable. This is also exactly why the `{hooks:[{type,command:"node",args:[abs],timeout}]}` form is mandatory. An entry written as `command: "node /abs/x.mjs"` has no `args` and would be invisible to both the stale-path purge and the uninstall. @@ -228,9 +527,9 @@ both the stale-path purge and the uninstall. entry dies only once its `hooks[]` is empty. A hook with no semble arg is foreign and is **never** touched. 3. **Reconcile each want row against the file — the merge repairs, it does not - only append.** The key is `event + matcher + full path` (the reminder - legitimately appears twice under `PreToolUse`, so deduping on the path alone - would silently drop the `Grep` registration). For that key: + only append.** The key is `event + matcher + full path` (`semble-stats.mjs` + legitimately appears under two events, so deduping on the path alone would + silently drop the `PostToolUseFailure` registration). For that key: - **absent** → append the full desired entry; - **present once** → compare MY hook **field by field** against the desired hook `{type, command, args, timeout}` and rewrite it in place when *any* @@ -249,12 +548,18 @@ both the stale-path purge and the uninstall. skipped forever. 4. Merge `permissions.allow` with the two tool names, deduped. 5. **Assert BEFORE the write, then re-read and assert again**: exactly 1 - `SessionStart` entry, exactly 1 `PreToolUse`/`Bash`, exactly 1 - `PreToolUse`/`Grep`, exactly 1 `SubagentStart`/`Explore`, each of those - carrying exactly one semble hook deep-equal to the desired hook, and each - tool name present exactly once in `permissions.allow`. Anything else exits 1. - The pre-write check is the load-bearing one: a post-write-only assert reports - the failure *after* it has already saved the bad file. + `SessionStart` entry, exactly 1 `UserPromptSubmit` entry, exactly 1 + `PostToolUse`/``, exactly 1 `PostToolUseFailure`/``, each carrying exactly one semble hook deep-equal to the desired + hook, and each tool name present exactly once in `permissions.allow`. + Anything else exits 1. The pre-write check is the load-bearing one: a + post-write-only assert reports the failure *after* it has already saved the + bad file. +6. **Delete events the purge emptied.** `PreToolUse` and `SubagentStart` exist in + a v1-shaped settings file only to carry the retired hooks; once step 2 has + emptied them and they are not in the want table, the empty arrays are removed + rather than left as `[]` litter. An event that still holds a foreign hook is + left alone. **EXECUTE** merge (project, Bash tool). `SETTINGS`/`HOOKS_DIR` are the only inputs; this is the canonical block — use it, not a hand `Edit`, because it is @@ -265,11 +570,17 @@ before and after writing: SETTINGS="$PWD/.claude/settings.json" HOOKS_DIR="$PWD/.claude/hooks" node -e ' const fs=require("fs"), path=require("path"); const f=process.env.SETTINGS, dir=process.env.HOOKS_DIR; -const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"]; +// EVERY basename this skill has ever owned - ownership, for isMine/purge/uninstall. +const marks=["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs", + "semble-reminder.mjs","semble-explore.mjs"]; // last two retired in 5.0.0 +// What is wanted NOW. `wanted` is built from THIS list, not from marks - that is +// what makes a retired hook at the current dir stale and purges it. +const live=["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs"]; +const STATS="mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read"; const want=[["SessionStart",null,"semble-session.mjs",5], - ["PreToolUse","Bash","semble-reminder.mjs",5], - ["PreToolUse","Grep","semble-reminder.mjs",5], - ["SubagentStart","Explore","semble-explore.mjs",5]]; + ["UserPromptSubmit",null,"semble-prefetch.mjs",5], + ["PostToolUse",STATS,"semble-stats.mjs",5], + ["PostToolUseFailure",STATS,"semble-stats.mjs",5]]; const tools=["mcp__semble_code__search","mcp__semble_code__find_related"]; let s={}; if(fs.existsSync(f)){ @@ -283,7 +594,7 @@ if(fs.existsSync(f)){ const argsOf=e=>((e&&e.hooks)||[]).flatMap(h=>(h&&h.args)||[]).filter(a=>typeof a==="string"); const matcherOf=e=>(e&&typeof e.matcher==="string")?e.matcher:null; const isMine=a=>marks.some(m=>a===m||a.endsWith("/"+m)||a.endsWith("\\"+m)); -const wanted=new Set(marks.map(m=>path.join(dir,m))); +const wanted=new Set(live.map(m=>path.join(dir,m))); // live, NOT marks const desiredHook=(full,timeout)=>({type:"command",command:"node",args:[full],timeout}); const hasArg=(h,full)=>((h&&h.args)||[]).filter(a=>typeof a==="string").includes(full); const deq=(a,b)=>{ // key-order-insensitive deep equal @@ -349,6 +660,9 @@ for(const [ev,matcher,script,timeout] of want){ // 3. reconcile THIS even s.hooks[ev].push(entry); } } +const wantEvents=new Set(want.map(w=>w[0])); // 6. drop events the purge emptied +for(const ev of Object.keys(s.hooks)) + if(Array.isArray(s.hooks[ev])&&s.hooks[ev].length===0&&!wantEvents.has(ev)) delete s.hooks[ev]; s.permissions=(s.permissions&&typeof s.permissions==="object"&&!Array.isArray(s.permissions))?s.permissions:{}; const allow=Array.isArray(s.permissions.allow)?s.permissions.allow.slice():[]; // 4. permissions for(const t of tools) if(!allow.includes(t)) allow.push(t); @@ -375,35 +689,47 @@ against the file field by field and reports the four rows as `wired` / `drifted` ```json "hooks": { - "wiredCount": 3, "driftedCount": 1, "missingCount": 0, "duplicateCount": 0, - "entries": [ { "event": "PreToolUse", "matcher": "Bash", - "script": "semble-reminder.mjs", "count": 1, "state": "drifted" } ], - "drift": [ { "event": "PreToolUse", "matcher": "Bash", - "script": "semble-reminder.mjs", "field": "timeout", - "expected": 5, "actual": 5000 } ] + "wiredCount": 3, "wantCount": 4, "driftedCount": 1, "missingCount": 0, "duplicateCount": 0, + "entries": [ { "event": "UserPromptSubmit", "matcher": null, + "script": "semble-prefetch.mjs", "count": 1, "state": "drifted" } ], + "drift": [ { "event": "UserPromptSubmit", "matcher": null, + "script": "semble-prefetch.mjs", "field": "timeout", + "expected": 5, "actual": 5000 } ], + "retired": ["semble-reminder.mjs"] } ``` -`hooks.session.wired` / `hooks.reminder.wired` / `hooks.explore.wired` and -`hooks.wiredCount` mean **present AND conforming** — a row with `"timeout": 5000` -is `drifted`, counted in `driftedCount`, and is **not** counted as wired. Reporting -it as 4/4 is what let two installs sit broken for days. +`hooks.session.wired` / `hooks.prefetch.wired` / `hooks.stats.wired` and +`hooks.wiredCount` mean **present AND conforming** — a row +with `"timeout": 5000` is `drifted`, counted in `driftedCount`, and is **not** +counted as wired. Reporting it as fully wired is what let two installs sit broken +for days. `wantCount` is the want table's length, so `wiredCount/wantCount` is the +only ratio worth printing — never a hard-coded denominator. + +`stats.wired` needs both post-tool events. A half-wired pair is not wired. + +`hooks.retired` lists retired basenames still present in `/.claude/hooks/`. +Non-empty means the migration has not run in this repo yet; `install` and +`upgrade` delete the files and the purge un-wires their rows. `drift[]` is the machine-readable "what differs": one object per differing field, naming `event`, `matcher`, `script`, `field`, `expected` and `actual`. A missing row contributes no `drift[]` entries — its `entries[]` state is `missing`. The fix for any non-`wired` row is the same: re-run the merge above, which rewrites it in place. -**EXECUTE** copy the three hook files first (project, Bash tool; `SRC` = the -directory holding THIS runbook, i.e. the skill's `assets/`): +**EXECUTE** copy the three hook files first, and DELETE any retired one left by +an older install — copying without deleting leaves a wired-then-unwired `.mjs` +on disk and `status` reports it under `hooks.retired` forever (project, Bash +tool; `SRC` = the directory holding THIS runbook, i.e. the skill's `assets/`): ``` SRC="$(dirname "$RUNBOOK")" DST="$PWD/.claude/hooks" mkdir -p "$DST" && \ -cp "$SRC/semble-session.mjs" "$SRC/semble-reminder.mjs" "$SRC/semble-explore.mjs" "$DST/" && \ -node --check "$DST/semble-session.mjs" && node --check "$DST/semble-reminder.mjs" && \ -node --check "$DST/semble-explore.mjs" && \ +cp "$SRC/semble-session.mjs" "$SRC/semble-prefetch.mjs" "$SRC/semble-stats.mjs" "$DST/" && \ +rm -f "$DST/semble-reminder.mjs" "$DST/semble-explore.mjs" && \ +node --check "$DST/semble-session.mjs" && node --check "$DST/semble-prefetch.mjs" && \ +node --check "$DST/semble-stats.mjs" && \ echo "✅ copied + verified in $DST" || echo "❌ FAILED" ``` @@ -411,10 +737,11 @@ echo "✅ copied + verified in $DST" || echo "❌ FAILED" ### Scope -These hooks are project-scoped by design: all three read -`/.claude/semble/state.json`, so a **global** install into `~/.claude/` is -inert in every project that has no semble state — silent, but it still pays a -Node start-up per `Bash` call everywhere. Install per project. If a global +These hooks are project-scoped by design: `semble-session.mjs` and +`semble-prefetch.mjs` read `/.claude/semble/state.json`, so a **global** +install into `~/.claude/` is inert in every project that has no semble state — +silent, but it still pays a Node start-up per prompt and per tool call +everywhere. Install per project. If a global install is nevertheless wanted, the same block runs with `SETTINGS="$HOME/.claude/settings.json" HOOKS_DIR="$HOME/.claude/hooks"` and **must go through the Bash tool only**: `~/.claude/*` is harness-protected, so @@ -424,14 +751,18 @@ override it. ### Throttle marker and `.gitignore` -The reminder writes `/.claude/semble/.reminder-ts` (mtime only, empty -file) next to the state file. Install appends +`semble-prefetch.mjs` writes `/.claude/semble/.prefetch-ts` next to the +state file: ONE file holding `{"t":,"cool":}` — the 30 s +throttle and the 600 s failure cooldown share it precisely so the install needs +exactly ONE ignore line. Install appends ``` # brewcode:semble -.claude/semble/.reminder-ts +.claude/semble/.prefetch-ts ``` +and, migrating a v1 repo, drops the retired `.claude/semble/.reminder-ts` line. + to `/.gitignore`. The outcome is **verified by re-reading the file**, never inferred from the exit status of the write — a silent "unchanged" over a tracked marker is exactly how the marker ended up committed. @@ -443,7 +774,8 @@ marker is exactly how the marker ended up committed. | absent | present | create it with the two lines, re-read, confirm → `changed` | | absent | absent | `skipped`, with the reason spelled out — not a git repo, so there is nothing to ignore and creating a `.gitignore` would be litter | -Uninstall removes exactly those two lines and re-reads to confirm they are gone; +Uninstall removes exactly those two lines — plus the retired `.reminder-ts` line +if a v1 install left one — and re-reads to confirm they are gone; a `.gitignore` created by install is left in place (it may have grown other entries since). @@ -453,8 +785,10 @@ entries since). Do NOT unwire the hooks to mute them. Flip the project state instead: `enabled:false` (or `phase:"disabled"`) in `/.claude/semble/state.json` -makes all three hooks go quiet immediately — they read the state on every call, so no -restart is needed. That is what `/brewcode:semble-setup disable` and `enable` do via +makes `semble-session.mjs` and `semble-prefetch.mjs` go quiet immediately +(`semble-stats.mjs` keeps measuring — it is state-independent by design, so a +disabled period is still visible in the log). They read the state on every call, +so no restart is needed. That is what `/brewcode:semble-setup disable` and `enable` do via `semble-project.sh`; the rule, the CLAUDE.md block, the hook files and the settings entries all stay in place. @@ -463,12 +797,14 @@ settings entries all stay in place. ## 6. UNINSTALL `scripts/semble-guidance.sh remove --part all` — or the equivalent by hand. It -strips settings by the three basenames — **per hook, inside `entry.hooks[]`**, so a +strips settings by all **five** owned basenames, retired ones included — **per +hook, inside `entry.hooks[]`**, so a foreign hook hand-merged into a semble entry survives and the entry is dropped only once its `hooks[]` is empty — deletes an event array that empties, the `hooks` object if it empties, only the two permission strings (and `allow` / -`permissions` if they empty), then deletes the three `.mjs` files, the managed rule -file and the CLAUDE.md marker range. Foreign hooks and every other +`permissions` if they empty), then deletes all five `.mjs` files (the three live +ones and any retired leftover), the managed rule file and the CLAUDE.md marker +range. Foreign hooks and every other settings key are never touched. > Removing the files without removing the registration is the one failure that @@ -482,7 +818,10 @@ export HOOKS_DIR="$PWD/.claude/hooks" SETTINGS="$PWD/.claude/settings.json" node -e ' const fs=require("fs"); const f=process.env.SETTINGS; -const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"]; +// Uninstall matches on OWNERSHIP, so the retired names stay - a v1 repo that +// never ran the migrating install still has those rows to clean. +const marks=["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs", + "semble-reminder.mjs","semble-explore.mjs"]; const tools=["mcp__semble_code__search","mcp__semble_code__find_related"]; if(!fs.existsSync(f)){ console.log("no settings to clean: "+f); process.exit(0); } const raw=fs.readFileSync(f,"utf8"); @@ -519,8 +858,10 @@ const left=Object.values(back.hooks||{}).flat().filter(e=>argsOf(e).some(isMine) const perm=((back.permissions&&back.permissions.allow)||[]).filter(x=>tools.includes(x)).length; if(left!==0||perm!==0){ console.error("ABORT: verification failed - "+left+" hook / "+perm+" permission entries still in "+f); process.exit(1); } console.log("OK cleaned "+f); -' && rm -f "$HOOKS_DIR/semble-session.mjs" "$HOOKS_DIR/semble-reminder.mjs" "$HOOKS_DIR/semble-explore.mjs" \ - && test ! -e "$HOOKS_DIR/semble-session.mjs" && test ! -e "$HOOKS_DIR/semble-reminder.mjs" \ +' && rm -f "$HOOKS_DIR/semble-session.mjs" "$HOOKS_DIR/semble-prefetch.mjs" \ + "$HOOKS_DIR/semble-stats.mjs" "$HOOKS_DIR/semble-reminder.mjs" "$HOOKS_DIR/semble-explore.mjs" \ + && test ! -e "$HOOKS_DIR/semble-session.mjs" && test ! -e "$HOOKS_DIR/semble-prefetch.mjs" \ + && test ! -e "$HOOKS_DIR/semble-stats.mjs" && test ! -e "$HOOKS_DIR/semble-reminder.mjs" \ && test ! -e "$HOOKS_DIR/semble-explore.mjs" \ && echo "✅ uninstalled from $HOOKS_DIR" || echo "❌ FAILED" ``` @@ -538,28 +879,36 @@ Synthetic payloads, no session needed. `` = the installed hooks dir, # 1. unconfigured project -> must print {} (both hooks) echo '{"session_id":"S","cwd":"/tmp","hook_event_name":"SessionStart"}' \ | node /semble-session.mjs; echo " exit=$?" +echo '{"session_id":"S","cwd":"/tmp","hook_event_name":"UserPromptSubmit","prompt":"where is the session state written and how is it read back"}' \ + | node /semble-prefetch.mjs; echo " exit=$?" # 2. ready project -> "semble: ready | cache <8 hex>" + additionalContext echo '{"session_id":"S","cwd":"","hook_event_name":"SessionStart"}' \ | node /semble-session.mjs; echo " exit=$?" -# 3. intent-shaped search -> one additionalContext ending in "reminder, not a block." -echo '{"cwd":"","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rg \"how does auth work\""}}' \ - | node /semble-reminder.mjs; echo " exit=$?" +# 3. gate FIRES -> additionalContext listing up to 3 "path:line" candidates, +# starting "Retrieval note (automatic, ...". Never a snippet. +echo '{"session_id":"S","cwd":"","hook_event_name":"UserPromptSubmit","prompt":"where does the installer decide that a managed file was modified by the user"}' \ + | node /semble-prefetch.mjs; echo " exit=$?" -# 4. immediately again -> {} (600 s throttle), and rm /.claude/semble/.reminder-ts to re-arm +# 4. immediately again -> {} (30 s throttle). rm /.claude/semble/.prefetch-ts +# to re-arm; that same file holds the 600 s failure cooldown. -# 5. exact search -> {} (must NEVER be anything else) -echo '{"cwd":"","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rg -l foo"}}' \ - | node /semble-reminder.mjs; echo " exit=$?" +# 5. gate SUPPRESSED -> {} (exact literal is rg territory) +echo '{"session_id":"S","cwd":"","hook_event_name":"UserPromptSubmit","prompt":"where is scripts/semble-guidance.sh referenced from"}' \ + | node /semble-prefetch.mjs; echo " exit=$?" -# 6. Explore subagent on a ready project -> one additionalContext naming search -echo '{"cwd":"","hook_event_name":"SubagentStart","agent_type":"Explore"}' \ - | node /semble-explore.mjs; echo " exit=$?" +# 6. FAIL-OPEN, the property that matters most. Every one of these must print +# exactly {} and exit 0 - test by breaking things, not by asserting success. +echo 'not json' | node /semble-prefetch.mjs; echo " exit=$?" # garbage stdin +echo '[]' | node /semble-prefetch.mjs; echo " exit=$?" # wrong root type +echo '' | node /semble-prefetch.mjs; echo " exit=$?" # empty stdin +printf '%s' '{"cwd":"","hook_event_name":"UserPromptSubmit","prompt":"how does the cache key get derived from the project path"}' \ + | PATH=/nonexistent node /semble-prefetch.mjs; echo " exit=$?" # no uvx -> cooldown -# 7. any other subagent type -> {} (must NEVER be anything else) -echo '{"cwd":"","hook_event_name":"SubagentStart","agent_type":"general-purpose"}' \ - | node /semble-explore.mjs; echo " exit=$?" +# 7. stats hook is a pure observer -> {} on every event +echo '{"cwd":"","hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"/README.md"}}' \ + | node /semble-stats.mjs; echo " exit=$?" ``` Full regression: `node tests/suite-hooks.mjs` from the skill dir — it runs the diff --git a/brewcode/skills/semble-setup/assets/semble-explore.mjs b/brewcode/skills/semble-setup/assets/semble-explore.mjs deleted file mode 100644 index 95c2b9a..0000000 --- a/brewcode/skills/semble-setup/assets/semble-explore.mjs +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env node -/** - * brewcode:semble-setup — SubagentStart hook (self-contained, installed into a project). - * Registered with matcher "Explore" only. - * - * The built-in Explore subagent type has semble's MCP tools available but not - * pre-listed in its own tool set, so it has to ToolSearch its way to - * mcp__semble_code__search before it can call it. This hook's additionalContext - * lands in the SPAWNED subagent's own transcript (not the parent's — verified - * against SubagentStart semantics), so it can call semble directly first. - * - * Never spawns a process, never probes for a daemon (semble has none), always - * prints exactly one JSON object, always exits 0. - * - * Pure ESM, Node built-ins only. readStdin/output are inlined on purpose: this - * file travels alone into a user's .claude/hooks/ and must have no imports. - */ -import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -// --- inlined helpers ------------------------------------------------------- -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return JSON.parse(Buffer.concat(chunks).toString('utf8')); -} - -function output(response) { - let text = '{}'; - try { - text = JSON.stringify(response === undefined ? {} : response); - } catch { - text = '{}'; - } - process.stdout.write(text + '\n'); -} - -function warn(message) { - try { - process.stderr.write('[semble-explore] ' + message + '\n'); - } catch { - /* stderr is best-effort */ - } -} -// --- telemetry (best-effort, never throws, never changes hook output) ------ -const TELEMETRY_SRC = 'explore'; -const TELEMETRY_MAX_BYTES = 2_000_000; -const TELEMETRY_KEEP_LINES = 1000; - -/** - * Appends one JSONL record to .claude/semble/telemetry.jsonl. Single - * appendFileSync, never read-modify-write. Every failure is swallowed: a hook - * that cannot measure itself must still behave exactly as if it had. - */ -function telemetry(cwd, sid, ev, extra) { - try { - const file = join(cwd, '.claude', 'semble', 'telemetry.jsonl'); - try { - if (statSync(file).size > TELEMETRY_MAX_BYTES) { - const kept = readFileSync(file, 'utf8').split('\n').filter((l) => l).slice(-TELEMETRY_KEEP_LINES); - writeFileSync(file, kept.join('\n') + '\n'); - } - } catch { - /* no file yet, or the trim failed - append anyway */ - } - const rec = { - ts: new Date().toISOString(), - ev, - src: TELEMETRY_SRC, - sid: typeof sid === 'string' ? sid : '', - ...(extra || {}), - }; - appendFileSync(file, JSON.stringify(rec) + '\n'); - } catch { - /* telemetry must never break a hook */ - } -} -// --------------------------------------------------------------------------- - -/** {kind:'missing'|'corrupt'|'ok', state} — same reader as the other semble hooks. */ -function readState(cwd) { - const file = join(cwd, '.claude', 'semble', 'state.json'); - let st; - try { - st = statSync(file); - } catch { - return { kind: 'missing' }; - } - if (!st.isFile()) return { kind: 'corrupt' }; - let raw; - try { - raw = readFileSync(file, 'utf8'); - } catch { - return { kind: 'corrupt' }; - } - if (!raw.trim()) return { kind: 'missing' }; - try { - const state = JSON.parse(raw); - if (state === null || typeof state !== 'object' || Array.isArray(state)) return { kind: 'corrupt' }; - return { kind: 'ok', state }; - } catch { - return { kind: 'corrupt' }; - } -} - -function message(cwd) { - return ( - 'semble: call mcp__semble_code__search directly first (repo="' + cwd + - '", top_k=5) for intent/behavior questions — it is already available, no ' + - 'ToolSearch needed. rg/Grep stay for exact/exhaustive matches.' - ); -} - -function decide(input, cwd) { - const agentType = typeof input.agent_type === 'string' ? input.agent_type : ''; - if (agentType !== 'Explore') return {}; - - const read = readState(cwd); - if (read.kind !== 'ok') return {}; - const state = read.state; - if (state.enabled === false) return {}; - if (state.phase !== 'ready') return {}; - - return { - hookSpecificOutput: { - hookEventName: 'SubagentStart', - additionalContext: message(cwd), - }, - }; -} - -async function main() { - let cwd = process.cwd(); - try { - let input = {}; - try { - input = await readStdin(); - } catch { - input = {}; // malformed/empty stdin: stay silent - } - if (!input || typeof input !== 'object' || Array.isArray(input)) input = {}; - if (typeof input.cwd === 'string' && input.cwd) cwd = input.cwd; - output(decide(input, cwd)); - } catch (e) { - warn('hook error: ' + (e && e.message)); - output({}); - } -} - -main(); diff --git a/brewcode/skills/semble-setup/assets/semble-first.md.template b/brewcode/skills/semble-setup/assets/semble-first.md.template index b06213b..1924e29 100644 --- a/brewcode/skills/semble-setup/assets/semble-first.md.template +++ b/brewcode/skills/semble-setup/assets/semble-first.md.template @@ -2,6 +2,9 @@ paths: - "**/*" description: semble-first — one semantic search, then read the exact line; rg stays for exact matching +doc_type: llm +version: "5.1.0" +generated_by: "brewcode:semble-setup" --- # semble-first @@ -51,6 +54,23 @@ open the file at `start_line`. 4. Need siblings of a confirmed location? `find_related` with that `file_path`/`start_line`. 5. Never issue two equivalent searches across semble, `rg`, and Grep. +## Which tool wins, measured + +Head-to-head on 16 questions written from the question's own vocabulary, not the +answer's identifier. The split is not a matter of taste: + +| Question shape | Winner | Score | +|----------------|--------|-------| +| Behaviour / intent — "how does X work", "what decides Y" | **semble** | 8 of 9 | +| Vocabulary mismatch — the question's words are not in the code | **semble** | (same 9) | +| Exhaustive enumeration — "every place that…", "all N of them" | **rg** | semble lost 2 of 5 | +| Exact identifier, literal string, exact path | **rg** | — | + +So: ask semble what something does; ask `rg` where every occurrence is. A top-k +result set is a ranked sample, never a complete list — a question containing +"every", "all", "how many" or "list the" is an `rg -l`/`-c` question, and semble +cannot answer it even in principle. + ## Keep using rg / Grep for exact identifiers, literal error strings, regexes, path and filename patterns, @@ -70,6 +90,19 @@ NOT indexed at any setting, because semble maps no content type to them: Use `rg` for those. +**`.json` being absent is load-bearing, not a footnote.** Hook registrations, +plugin and marketplace manifests, `package.json`, `tsconfig.json`, `settings.json` +and every OpenAPI spec live in files semble has never read. A question whose +answer is a JSON key — "which hook events are registered", "what is the declared +version", "is this permission allowed" — will come back with prose *about* the +JSON and never the JSON itself, and the top hits will look plausible. Go straight +to `rg` for those; do not spend a semble call first. + +Duplicate trees are not deduplicated either: if the repo commits the same file +at two or three paths, all of them compete for the same five slots. If your +results look like the same text three times, that is what happened — check +`.sembleignore`. + Semble has no background watcher. The index is (re)built inside a tool call and cached; the first call on a cold cache is slow, later calls are fast. diff --git a/brewcode/skills/semble-setup/assets/semble-prefetch.mjs b/brewcode/skills/semble-setup/assets/semble-prefetch.mjs new file mode 100644 index 0000000..c319fe5 --- /dev/null +++ b/brewcode/skills/semble-setup/assets/semble-prefetch.mjs @@ -0,0 +1,584 @@ +#!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup +/** + * brewcode:semble-setup — UserPromptSubmit hook (self-contained, installed into + * a project). It replaces the two advisory hooks that shipped before it. + * + * WHY IT EXISTS. The advisory nudge (`semble-reminder.mjs` on PreToolUse, + * `semble-explore.mjs` on SubagentStart) was measured at ZERO conversion: 0/18 + * on the main channel, 0/11 on the subagent channel, with delivery proven + * independently (a transcript attachment record, a canary that quoted the + * injected sentence back verbatim, 11/11 subagent initial contexts containing + * it). The model receives the advice and ignores it. Prefetch — running the + * search itself and handing over the RESULT — converted 5/6, and cost fewer + * tool calls than control in 5/6 questions. Advice loses to evidence. + * + * WHAT IT SHIPS: paths, never snippets. With 5 hits carrying path+lines+snippet + * and no directive, conversion was 2/6 and in 2/6 sessions the model answered + * with ZERO tool calls straight off the snippets — the snippet SUBSTITUTES for + * verification. Three bare paths plus a directive provoke the read instead. + * Prefetch buys turns and citation precision, NOT accuracy: all 18 answers were + * correct in all three arms. + * + * IT RUNS ON EVERY PROMPT THE USER TYPES. A crash or a hang here is the worst + * failure in this skill, so every path is fail-open and silent: `{}` on stdout, + * exit 0, no matter what. The search is spawned with a hard 3 s cap (the hook's + * own registered timeout is 5 s); see the cooldown block below for what a + * failure and a timeout each cost. + * + * IT NEVER BUILDS AN INDEX. semble builds lazily inside the call and a cold + * build takes minutes, so a 3 s child can only ever kill it half-done — burning + * the cap on every prompt and making no progress, forever. The hook therefore + * stats the cache directory first and stays silent (`why=cold-index`, cost ~4 + * stat calls) until something that is ALLOWED to take minutes has built it: the + * MCP server, or `semble-project.sh warm`/`smoke` during install. + * + * Pure ESM, Node built-ins only. Helpers are inlined on purpose: this file + * travels alone into a user's .claude/hooks/ and must have no imports. + */ +import { + appendFileSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync, +} from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// --- inlined helpers ------------------------------------------------------- +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +function output(response) { + let text = '{}'; + try { + text = JSON.stringify(response === undefined ? {} : response); + } catch { + text = '{}'; + } + process.stdout.write(text + '\n'); +} + +function warn(message) { + try { + process.stderr.write('[semble-prefetch] ' + message + '\n'); + } catch { + /* stderr is best-effort */ + } +} +// --- telemetry (best-effort, never throws, never changes hook output) ------ +const TELEMETRY_SRC = 'prefetch'; +const TELEMETRY_MAX_BYTES = 2_000_000; +const TELEMETRY_KEEP_LINES = 1000; + +/** + * Appends one JSONL record to .claude/semble/telemetry.jsonl. Single + * appendFileSync, never read-modify-write. Every failure is swallowed: a hook + * that cannot measure itself must still behave exactly as if it had. + */ +function telemetry(cwd, sid, ev, extra) { + try { + const file = join(cwd, '.claude', 'semble', 'telemetry.jsonl'); + try { + if (statSync(file).size > TELEMETRY_MAX_BYTES) { + const kept = readFileSync(file, 'utf8').split('\n').filter((l) => l).slice(-TELEMETRY_KEEP_LINES); + writeFileSync(file, kept.join('\n') + '\n'); + } + } catch { + /* no file yet, or the trim failed - append anyway */ + } + const rec = { + ts: new Date().toISOString(), + ev, + src: TELEMETRY_SRC, + sid: typeof sid === 'string' ? sid : '', + ...(extra || {}), + }; + appendFileSync(file, JSON.stringify(rec) + '\n'); + } catch { + /* telemetry must never break a hook */ + } +} +// --------------------------------------------------------------------------- + +/** Anti-storm guard, not a rate limiter: gate v3 already suppresses ~64% of prompts. */ +const THROTTLE_MS = 30_000; +/** + * A search that FAILED — no uvx, non-zero exit, unparseable output — is a + * standing condition: nothing about the next prompt will change it, so it parks + * the mechanism for ten minutes rather than paying the cap on every keystroke. + */ +const COOLDOWN_MS = 600_000; +/** + * A search that TIMED OUT is a different event and gets a tenth of the penalty. + * Against a warm index a search is ~0.6 s, so hitting a 3 s cap means transient + * load, not a broken install — and the ten-minute park was measured silencing + * prefetch for whole sessions off one slow call. The cold-index case, which is + * what used to make this fire on every fresh repo, no longer reaches the child + * at all: `indexReady` gates it out for free. + */ +const TIMEOUT_COOLDOWN_MS = 60_000; +/** Hard cap on the child. The registered hook timeout is 5 s; measured median is 0.6 s. */ +const SEARCH_TIMEOUT_MS = 3_000; +const TOP_K = 3; + +/** + * The searcher. MUST stay byte-identical to the MCP registration written by + * semble-mcp.sh (`SEMBLE_PIN_SPEC` and `SEMBLE_CONTENT_ARGS` in + * scripts/lib/semble-common.sh) — semble keys its cache directory by project + * path ALONE but rejects a cached index whose content-type set differs, so a + * mismatched set here would make the hook and the server evict each other's + * index on every alternation. tests/suite-hooks.mjs asserts the two agree. + */ +const PIN_SPEC = 'semble[mcp]==0.5.4'; +const CONTENT_ARGS = ['code', 'docs', 'config']; + +// ── gate v3 ──────────────────────────────────────────────────────────────── +// INTENT and (DOMAIN or repo-reference), minus four suppressors. Measured on 61 +// real user prompts: fires 36%, precision 55%, recall 71%, F1 0.62. The old v1 +// rule required a code-domain noun, which a vocabulary-mismatch question by +// construction never has — that one clause was what capped recall. +// 55% precision is the honest ceiling of lexical rules; roughly half of the +// firings are pure overhead, which is affordable only because a firing costs +// one 0.6 s search and ~90 tokens. + +const META = /^(да|нет|ок|окей|ага|угу|yes|no|ok|okay|sure|thanks|спасибо|go|next|стоп|stop|y|n|\d+)[\s.!?)]*$/i; +const CODEWORD_ONLY = /^(\+\+[a-z]{1,3}\s*)+$/i; + +const INTENT = new RegExp([ + '\\b(where|how|why|what|which|who|when)\\b', + '\\b(find|show|explain|check|look|search|locate|list|describe|trace|inspect|review|audit|analyz|understand|figure out|walk me)\\w*\\b', + '\\b(is|are|does|do|can|could|should)\\s+\\w+', + // JS \b is ASCII-only, so Cyrillic needs explicit boundaries. + '(? 2000) return { fire: false, why: 'too-long' }; + if (/^https?:\/\/\S+$/.test(body)) return { fire: false, why: 'bare-url' }; + if (/^[~./][\w./-]+$/.test(body)) return { fire: false, why: 'bare-path' }; + + if (!INTENT.test(body)) return { fire: false, why: 'no-intent' }; + if (!DOMAIN.test(body) && !REPOREF.test(body)) return { fire: false, why: 'no-domain-no-reporef' }; + // Suppressors read the WHOLE prompt, codewords included — that is how the 61-prompt + // precision/recall was measured, and a `++m` prefix must not change a verdict. + const s = suppressor(p); + if (s) return { fire: false, why: s }; + return { fire: true, why: 'behaviour-or-vocab' }; +} + +// ── distiller ────────────────────────────────────────────────────────────── +// Measured against handing semble the raw prompt: hit@3 11/16 vs 9/16, +// MRR 0.674 vs 0.398, paired 8 wins / 3 losses / 5 ties. The assumption that +// rewriting the query would hurt is refuted; the distiller stays. +const STOP = new Set(` +a an the this that these those there here it its is are was were be been being do does did done +what where when why how which who whom whose can could should would will shall may might must +i you he she we they me him her us them my your our their mine yours +and or but if then else so because as of in on at to for from with without by about into over under +not no yes just only also very much many more most some any all every each other another same +find show tell give want need let make take get go come know think see look use using used +across whole entire everything anything something please thanks okay ok well now then still yet +file files place places way ways thing things stuff part parts list complete summarise summarize +different inside vs versus current legacy across repo repository project +и в во не что он на я с со как а то все она так его но да ты к у же вы за бы по только ее мне было +вот от меня еще нет о из ему теперь когда даже ну вдруг ли если уже или ни быть был него до вас +нибудь опять уж вам ведь там потом себя ничего ей может они тут где есть надо ней для мы тебя их +чем была сам чтоб без будто чего раз тоже себе под будет ж тогда кто этот того потому этого какой +совсем ним здесь этом один почти мой тем чтобы нее сейчас были куда зачем всех никогда можно при +наконец два об другой хоть после над больше тот через эти нас про всего них какая много разве три +эту моя впрочем хорошо свою этой перед иногда лучше чуть том нельзя такой им более всегда конечно +всю между нужно давай слушай смотри окей значит просто вообще типа блять блядь ебать нахуй хуй сука +ёпта ёкта ебаный ебучий погоди скажи расскажи покажи сделай сделать нужен нужна нужны имеешь виду +`.trim().split(/\s+/).filter(Boolean)); + +/** Keyword query: code-shaped tokens first (highest signal), then content words. */ +function distill(prompt, max = 9) { + const p = String(prompt || '') + .replace(/^(\s*\+\+[a-z]{1,3}\s*)+/i, '') + .replace(/```[\s\S]*?```/g, ' '); + + const strong = []; + for (const re of [ + /`([^`]{2,40})`/g, + /\b([\w-]+\.(?:mjs|js|ts|tsx|py|json|md|sh|yml|yaml|java|kt|go|rs|toml))\b/g, + /\b([a-z]+(?:[-_][a-z0-9]+)+)\b/gi, + /\b([a-z]+[A-Z][A-Za-z]*)\b/g, + ]) { + for (const m of p.matchAll(re)) strong.push(m[1]); + } + + const words = []; + for (const raw of p.toLowerCase().match(/[\p{L}][\p{L}\d]{2,}/gu) || []) { + if (!STOP.has(raw)) words.push(raw); + } + + const seen = new Set(); + const out = []; + for (const t of [...strong, ...words]) { + const k = t.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + out.push(t); + if (out.length >= max) break; + } + return out.join(' '); +} + +// ── state, throttle, cooldown ────────────────────────────────────────────── + +/** {kind:'missing'|'corrupt'|'ok', state} — same reader as the session hook. */ +function readState(cwd) { + const file = join(cwd, '.claude', 'semble', 'state.json'); + let st; + try { + st = statSync(file); + } catch { + return { kind: 'missing' }; + } + if (!st.isFile()) return { kind: 'corrupt' }; + let raw; + try { + raw = readFileSync(file, 'utf8'); + } catch { + return { kind: 'corrupt' }; + } + if (!raw.trim()) return { kind: 'missing' }; + try { + const state = JSON.parse(raw); + if (state === null || typeof state !== 'object' || Array.isArray(state)) return { kind: 'corrupt' }; + return { kind: 'ok', state }; + } catch { + return { kind: 'corrupt' }; + } +} + +/** + * Is semble USABLE in this repo — not "has verification finished". + * + * `phase === 'ready'` would deadlock: semble builds its index lazily inside a + * tool call, so nothing advances the phase until something calls semble. + * `completed` containing "mcp" is the registration proxy — semble-mcp.sh writes + * it in the same checkpoint patch that registers the server. Reading + * ~/.claude.json on every prompt to check for real would cost megabytes of + * parse per keystroke. `prereq_ready` is denied because the add-failed rollback + * lands there with `completed` still holding "mcp". + */ +function stateGate(read) { + if (read.kind === 'missing') return { ok: false, why: 'no-state', phase: '', enabled: false }; + if (read.kind !== 'ok') return { ok: false, why: 'corrupt', phase: '', enabled: false }; + const state = read.state; + const phase = typeof state.phase === 'string' ? state.phase : ''; + const enabled = state.enabled !== false; + if (!enabled) return { ok: false, why: 'disabled', phase, enabled }; + if (phase === 'disabled') return { ok: false, why: 'disabled', phase, enabled }; + if (phase === 'error') return { ok: false, why: 'error', phase, enabled }; + if (phase === 'prereq_ready') return { ok: false, why: 'not-registered', phase, enabled }; + const completed = Array.isArray(state.completed) ? state.completed : []; + if (completed.indexOf('mcp') < 0) return { ok: false, why: 'no-mcp', phase, enabled }; + return { ok: true, why: 'ok', phase, enabled, state }; +} + +// ── cache location ───────────────────────────────────────────────────────── +// The MCP server is registered with an explicit SEMBLE_CACHE_LOCATION. semble +// keys its cache directory by project path ALONE, so it cannot notice that two +// consumers disagree about the ROOT those directories live under: each one just +// finds nothing and builds its own copy. Before this was passed through, the +// hook indexed into semble's default root while the server used the registered +// one — 40 MB of duplicated index across two repos, and every first firing was +// a from-scratch build that the 3 s cap killed. + +/** Mirrors sc_cache_root_code in scripts/lib/semble-common.sh. */ +function defaultCacheRoot() { + const home = process.env.HOME || homedir(); + if (process.platform === 'darwin') return join(home, 'Library', 'Caches', 'semble-code'); + return join(process.env.XDG_CACHE_HOME || join(home, '.cache'), 'semble-code'); +} + +/** state.json carries `cacheRoot`, written by semble-mcp.sh from the same helper. */ +function cacheRootOf(state) { + const r = state && typeof state.cacheRoot === 'string' ? state.cacheRoot : ''; + return r || defaultCacheRoot(); +} + +/** + * Mirrors sc_repo_hash / semble cache.py: sha256 of the resolved project path. + * + * Always computed from the path we are about to hand the child, never read from + * state.json. A hash carried over from another checkout — .claude/ copied into a + * fork, a moved or renamed repo, a git worktree, claude started in a + * subdirectory — would vouch for a FOREIGN index, and the readiness check would + * wave through a spawn against a genuinely cold path: the 3 s timeout this guard + * exists to prevent. A mismatch must read as cold (silent, free) instead. + */ +function repoHashOf(_state, cwd) { + let path = cwd; + try { + path = realpathSync(cwd); + } catch { /* unresolvable: hash what we were given, same as semble would */ } + try { + return createHash('sha256').update(path).digest('hex'); + } catch { + return ''; + } +} + +/** semble writes all four; a partial set is a build that never finished. */ +const INDEX_FILES = ['chunks.json', 'metadata.json', 'bm25_index', 'semantic_index']; + +/** + * Is there an index to search? Four existsSync calls, no child process — the + * cheapest possible answer to the question that used to cost a 3 s timeout and + * a ten-minute cooldown on every fresh repo. + */ +function indexReady(root, hash) { + if (!root || !hash) return false; + try { + const dir = join(root, hash, 'index'); + return INDEX_FILES.every((n) => existsSync(join(dir, n))); + } catch { + return false; + } +} + +const markerFile = (cwd) => join(cwd, '.claude', 'semble', '.prefetch-ts'); + +/** + * `{t, cool}` epoch-ms in ONE file so the install needs exactly one .gitignore + * line. Unreadable or malformed reads as "no marker": a corrupt throttle must + * fail OPEN, the same as every other input here. + */ +function readMarker(cwd) { + try { + const m = JSON.parse(readFileSync(markerFile(cwd), 'utf8')); + if (m === null || typeof m !== 'object' || Array.isArray(m)) return {}; + return m; + } catch { + return {}; + } +} + +function writeMarker(cwd, patch) { + try { + writeFileSync(markerFile(cwd), JSON.stringify({ ...readMarker(cwd), ...patch })); + } catch (e) { + warn('marker write failed: ' + e.message); // ignored on purpose + } +} + +const fresh = (v, window) => typeof v === 'number' && Number.isFinite(v) + && Date.now() - v >= 0 && Date.now() - v < window; + +/** + * How long the armed cooldown lasts, written alongside it by whoever armed it. + * Clamped into [0, COOLDOWN_MS]: a corrupt or hostile marker must never be able + * to park the mechanism for longer than the hook's own maximum. + */ +function coolWindow(marker) { + const w = marker && marker.coolMs; + if (typeof w !== 'number' || !Number.isFinite(w) || w < 0) return COOLDOWN_MS; + return Math.min(w, COOLDOWN_MS); +} + +// ── search ───────────────────────────────────────────────────────────────── + +/** + * `{hits, why}`. `hits === null` is the signal to park the mechanism, and `why` + * says for how long: `search-timeout` (transient, one minute) or + * `search-failed` (standing condition, ten minutes). `hits === []` — a search + * that ran and found nothing — is not a failure and parks nothing. + * + * SEMBLE_CACHE_LOCATION is the load-bearing env entry: without it the child + * silently uses semble's default root, which is NOT the root the MCP server was + * registered with, and the two build separate copies of the same index. + */ +function search(cwd, cacheRoot, query) { + let out; + try { + out = execFileSync( + 'uvx', + ['--from', PIN_SPEC, 'semble', 'search', query, cwd, + '--content', ...CONTENT_ARGS, '-k', String(TOP_K), '--max-snippet-lines', '0'], + { + cwd, + encoding: 'utf8', + maxBuffer: 4 << 20, + timeout: SEARCH_TIMEOUT_MS, + killSignal: 'SIGKILL', + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, SEMBLE_CACHE_LOCATION: cacheRoot }, + }, + ); + } catch (e) { + // ETIMEDOUT is set by spawnSync itself, so it is not confusable with a + // child that merely exited non-zero (ENOENT, rc!=0 — no `code` or a + // different one). + return { hits: null, why: (e && e.code === 'ETIMEDOUT') ? 'search-timeout' : 'search-failed' }; + } + try { + const parsed = JSON.parse(out); + const results = Array.isArray(parsed && parsed.results) ? parsed.results : null; + if (results === null) return { hits: null, why: 'search-failed' }; + return { + hits: results + .filter((h) => h && typeof h.file_path === 'string' && h.file_path) + .slice(0, TOP_K), + why: 'ok', + }; + } catch { + return { hits: null, why: 'search-failed' }; + } +} + +/** + * The framing that converted 5/6. Three parts, all load-bearing: + * named PROVENANCE (what produced this and from what), bare PATHS with no + * snippet (a snippet substitutes for the read; a path provokes it), and an + * explicit DIRECTIVE including permission to reject the candidates. + */ +function render(hits) { + return [ + 'Retrieval note (automatic, from a semantic index of THIS repository, built by semble over the working tree).', + 'These candidate locations were ranked for the question above before you started:', + ...hits.map((h, i) => ' ' + (i + 1) + '. ' + h.file_path + + (typeof h.start_line === 'number' ? ':' + h.start_line : '')), + '', + 'Open the candidates that look right BEFORE running any search of your own; they are already ranked.', + 'If none of them answers the question, say so and search normally.', + 'Name the file you actually used in your answer.', + ].join('\n'); +} + +function decide(input, cwd) { + const sid = typeof input.session_id === 'string' ? input.session_id : ''; + const prompt = typeof input.prompt === 'string' ? input.prompt : ''; + const t0 = Date.now(); + const skip = (why, extra) => { + telemetry(cwd, sid, 'prefetch', { fired: false, why, ...(extra || {}) }); + return {}; + }; + + const g = stateGate(readState(cwd)); + if (!g.ok) return skip(g.why, { phase: g.phase, enabled: g.enabled }); + + const marker = readMarker(cwd); + if (fresh(marker.cool, coolWindow(marker))) return skip('cooldown'); + if (fresh(marker.t, THROTTLE_MS)) return skip('throttled'); + + const gate = gateV3(prompt); + if (!gate.fire) return skip(gate.why); + + const query = distill(prompt); + if (!query) return skip('empty-query'); + + // Cheap and non-punitive: no index yet means nothing to search, not a + // failure. No child is spawned and no cooldown is armed, so the very next + // prompt after the MCP server (or `semble-project.sh warm`) builds the index + // fires normally. + const cacheRoot = cacheRootOf(g.state); + if (!indexReady(cacheRoot, repoHashOf(g.state, cwd))) return skip('cold-index'); + + const r = search(cwd, cacheRoot, query); + const hits = r.hits; + const ms = Date.now() - t0; + if (hits === null) { + writeMarker(cwd, { + cool: Date.now(), + coolMs: r.why === 'search-timeout' ? TIMEOUT_COOLDOWN_MS : COOLDOWN_MS, + }); + return skip(r.why, { q: query.slice(0, 120), ms }); + } + if (!hits.length) return skip('no-hits', { q: query.slice(0, 120), ms }); + + writeMarker(cwd, { t: Date.now() }); + telemetry(cwd, sid, 'prefetch', { + fired: true, + why: gate.why, + q: query.slice(0, 120), + n: hits.length, + ms, + paths: hits.map((h) => h.file_path), + }); + return { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: render(hits), + }, + }; +} + +async function main() { + let cwd = process.cwd(); + try { + let input = {}; + try { + input = await readStdin(); + } catch { + input = {}; // malformed/empty stdin: stay silent + } + if (!input || typeof input !== 'object' || Array.isArray(input)) input = {}; + if (typeof input.cwd === 'string' && input.cwd) cwd = input.cwd; + output(decide(input, cwd)); + } catch (e) { + warn('hook error: ' + (e && e.message)); + output({}); + } +} + +// Run only when executed as the hook. Importing the file (tests, corpus +// replays) must not consume stdin. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); + +export { + COOLDOWN_MS, CONTENT_ARGS, INDEX_FILES, PIN_SPEC, THROTTLE_MS, TIMEOUT_COOLDOWN_MS, + cacheRootOf, defaultCacheRoot, distill, gateV3, indexReady, render, repoHashOf, +}; diff --git a/brewcode/skills/semble-setup/assets/semble-reminder.mjs b/brewcode/skills/semble-setup/assets/semble-reminder.mjs deleted file mode 100644 index 6da5f25..0000000 --- a/brewcode/skills/semble-setup/assets/semble-reminder.mjs +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env node -/** - * brewcode:semble-setup — PreToolUse hook (self-contained, installed into a project). - * Registered twice: matcher "Bash" and matcher "Grep". - * - * ADVISORY ONLY. It emits at most one `additionalContext` line and NEVER a - * permissionDecision, a deny, or an updatedInput — it cannot block, alter or - * slow a search. Exact / exhaustive rg / grep / find stay untouched by design: - * `isExactIntent()` is biased to silence and any doubt returns true. - * - * Never spawns a process, never probes for a daemon (semble has none), always - * prints exactly one JSON object, always exits 0. - * - * Pure ESM, Node built-ins only. readStdin/output are inlined on purpose: this - * file travels alone into a user's .claude/hooks/ and must have no imports. - */ -import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -// --- inlined helpers ------------------------------------------------------- -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return JSON.parse(Buffer.concat(chunks).toString('utf8')); -} - -function output(response) { - let text = '{}'; - try { - text = JSON.stringify(response === undefined ? {} : response); - } catch { - text = '{}'; - } - process.stdout.write(text + '\n'); -} - -function warn(message) { - try { - process.stderr.write('[semble-reminder] ' + message + '\n'); - } catch { - /* stderr is best-effort */ - } -} -// --- telemetry (best-effort, never throws, never changes hook output) ------ -const TELEMETRY_SRC = 'reminder'; -const TELEMETRY_MAX_BYTES = 2_000_000; -const TELEMETRY_KEEP_LINES = 1000; - -/** - * Appends one JSONL record to .claude/semble/telemetry.jsonl. Single - * appendFileSync, never read-modify-write. Every failure is swallowed: a hook - * that cannot measure itself must still behave exactly as if it had. - */ -function telemetry(cwd, sid, ev, extra) { - try { - const file = join(cwd, '.claude', 'semble', 'telemetry.jsonl'); - try { - if (statSync(file).size > TELEMETRY_MAX_BYTES) { - const kept = readFileSync(file, 'utf8').split('\n').filter((l) => l).slice(-TELEMETRY_KEEP_LINES); - writeFileSync(file, kept.join('\n') + '\n'); - } - } catch { - /* no file yet, or the trim failed - append anyway */ - } - const rec = { - ts: new Date().toISOString(), - ev, - src: TELEMETRY_SRC, - sid: typeof sid === 'string' ? sid : '', - ...(extra || {}), - }; - appendFileSync(file, JSON.stringify(rec) + '\n'); - } catch { - /* telemetry must never break a hook */ - } -} -// --------------------------------------------------------------------------- - -const THROTTLE_MS = 600_000; - -// A search binary at a command boundary (start, |, ;, &, &&, ||, subshell). -// The `m` flag is load-bearing: heredocs and multi-line scripts are ~5% of all -// search-shaped Bash commands, and without it `^` only ever matched offset 0. -const SEARCH_RE = /(?:^|[|;&(]|&&|\|\|)\s*(?:command\s+)?(grep|egrep|fgrep|ugrep|rg|ag|ack|find|bfs)\b/m; -// (a) literal / enumeration / verification flags. -const FLAG_RE = /(^|\s)-{1,2}(F|fixed-strings|w|word-regexp|l|files-with-matches|L|files-without-match|c|count|o|only-matching)(=|\s|$)/; -// (e) piped into an enumeration tool. -const PIPE_RE = /\|\s*(?:command\s+)?(wc|sort|uniq|head|tail|cut|awk)\b/; -// (d) find/bfs filename search. -const FIND_FLAG_RE = /(^|\s)-(name|path|iname|type)(=|\s|$)/; -// (b) regex metacharacters: \ ^ $ * + ? ( ) [ ] { } | -const META = '\\^$*+?()[]{}|'; -// (c) looks like a filename. -const FILEISH_RE = /\.[A-Za-z0-9]{1,6}$/; - -/** - * Splits the text following a search binary into shell-ish tokens, stopping at - * the first unquoted pipeline boundary — only the FIRST search command of a - * pipeline is ever examined. - */ -function tokenize(text) { - const tokens = []; - let raw = ''; - let value = ''; - let quote = ''; - const push = () => { - if (raw.length) tokens.push({ raw, value }); - raw = ''; - value = ''; - }; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - if (quote) { - raw += ch; - if (ch === quote) quote = ''; - else value += ch; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - raw += ch; - continue; - } - if (ch === '|' || ch === ';' || ch === '&' || ch === '\n' || ch === ')') break; - if (ch === ' ' || ch === '\t') { - push(); - continue; - } - raw += ch; - value += ch; - } - push(); - return tokens; -} - -/** First non-flag argument after the search binary; one quote layer stripped. */ -function extract(command) { - const m = SEARCH_RE.exec(command); - if (!m) return null; - const bin = m[1]; - const tokens = tokenize(command.slice(m.index + m[0].length)); - for (const t of tokens) { - if (t.raw.startsWith('-')) continue; - return { bin, pattern: t.value }; - } - return { bin, pattern: null }; -} - -/** Bias to silence: any doubt returns true. Rules (a)-(g) of the design. */ -function isExactIntent(command, pattern, bin) { - if (typeof pattern !== 'string') return true; // (g) extraction failed - if (FLAG_RE.test(command)) return true; // (a) - if (PIPE_RE.test(command)) return true; // (e) - if ((bin === 'find' || bin === 'bfs') && FIND_FLAG_RE.test(command)) return true; // (d) - for (const ch of pattern) if (META.indexOf(ch) >= 0) return true; // (b) - if (pattern.indexOf('/') >= 0) return true; // (c) - if (FILEISH_RE.test(pattern)) return true; // (c) - if (pattern.trim().length < 3) return true; // (f) - return false; -} - -/** {kind:'missing'|'corrupt'|'ok', state} — same reader as the session hook. */ -function readState(cwd) { - const file = join(cwd, '.claude', 'semble', 'state.json'); - let st; - try { - st = statSync(file); - } catch { - return { kind: 'missing' }; - } - if (!st.isFile()) return { kind: 'corrupt' }; - let raw; - try { - raw = readFileSync(file, 'utf8'); - } catch { - return { kind: 'corrupt' }; - } - if (!raw.trim()) return { kind: 'missing' }; - try { - const state = JSON.parse(raw); - if (state === null || typeof state !== 'object' || Array.isArray(state)) return { kind: 'corrupt' }; - return { kind: 'ok', state }; - } catch { - return { kind: 'corrupt' }; - } -} - -function throttled(cwd) { - const marker = join(cwd, '.claude', 'semble', '.reminder-ts'); - try { - const age = Date.now() - statSync(marker).mtimeMs; - return age >= 0 && age < THROTTLE_MS; - } catch { - return false; // no marker yet - } -} - -function touch(cwd) { - try { - writeFileSync(join(cwd, '.claude', 'semble', '.reminder-ts'), ''); - } catch (e) { - warn('throttle write failed: ' + e.message); // ignored on purpose - } -} - -/** - * Is semble USABLE in this repo — not "has verification finished". - * - * The old gate was `phase === 'ready'`, which deadlocked: semble builds its - * index lazily inside a tool call, so without a nudge nothing ever calls the - * MCP, nothing verifies, and the phase never advances. Phase now only shapes - * the wording; it suppresses the nudge for the three phases where there is - * provably nothing to nudge toward. - * - * `completed` containing "mcp" is the registration proxy: semble-mcp.sh writes - * it in the same checkpoint patch that registers the server, so it is present - * from registration onward. Reading ~/.claude.json on every Bash call to check - * for real would cost megabytes of parse per search. It can go stale only if - * the user removes the server by hand without running `uninstall` (which would - * delete these hooks too) — the cost of that is one advisory line naming a tool - * that is not there, and `/brewcode:semble-setup status` reports the drift. - * `prereq_ready` is denied for the same reason: the add-failed rollback lands - * there with `completed` still holding "mcp". - */ -function gate(read) { - if (read.kind === 'missing') return { ok: false, why: 'no-state', phase: '', enabled: false }; - if (read.kind !== 'ok') return { ok: false, why: 'corrupt', phase: '', enabled: false }; - const state = read.state; - const phase = typeof state.phase === 'string' ? state.phase : ''; - const enabled = state.enabled !== false; - if (!enabled) return { ok: false, why: 'disabled', phase, enabled }; - if (phase === 'disabled') return { ok: false, why: 'disabled', phase, enabled }; - if (phase === 'error') return { ok: false, why: 'error', phase, enabled }; - if (phase === 'prereq_ready') return { ok: false, why: 'not-registered', phase, enabled }; - const completed = Array.isArray(state.completed) ? state.completed : []; - if (completed.indexOf('mcp') < 0) return { ok: false, why: 'no-mcp', phase, enabled }; - return { ok: true, why: 'ok', phase, enabled }; -} - -function message(cwd, phase) { - const cold = - phase === 'ready' - ? '' - : ' Verification has not finished (phase=' + - phase + - ') — the first call rebuilds the index and may take minutes.'; - return ( - 'semble: for intent/behavior questions try ONE mcp__semble_code__search first — repo="' + - cwd + - '", top_k=5, max_snippet_lines=10 — then open the hit at start_line. ' + - 'This grep is fine for exact/exhaustive matching; this is a reminder, not a block.' + - cold - ); -} - -/** PreToolUse stdin carries agent_id/agent_type inside a subagent only. */ -function agentOf(input) { - const sub = - Object.prototype.hasOwnProperty.call(input, 'agent_id') || - Object.prototype.hasOwnProperty.call(input, 'agent_type'); - return sub ? 'sub' : 'main'; -} - -function decide(input, cwd) { - const toolName = typeof input.tool_name === 'string' ? input.tool_name : ''; - if (toolName !== 'Bash' && toolName !== 'Grep') return {}; - - const sid = typeof input.session_id === 'string' ? input.session_id : ''; - const g = gate(readState(cwd)); - const record = (fired, why) => - telemetry(cwd, sid, 'gate', { fired, why, phase: g.phase, enabled: g.enabled }); - if (!g.ok) { - record(false, g.why); - return {}; - } - - const toolInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}; - - let command; - let bin; - let pattern; - if (toolName === 'Bash') { - command = typeof toolInput.command === 'string' ? toolInput.command : ''; - if (!command) { - record(false, 'no-match'); - return {}; - } - const found = extract(command); - if (!found) { - record(false, 'no-match'); // no search binary at a command boundary - return {}; - } - bin = found.bin; - pattern = found.pattern; - } else { - // Native Grep tool: the pattern IS the whole "command" for heuristic purposes. - pattern = typeof toolInput.pattern === 'string' ? toolInput.pattern : null; - command = pattern || ''; - bin = 'rg'; - const mode = toolInput.output_mode; - if (mode === 'files_with_matches' || mode === 'count') { - record(false, 'no-match'); // enumeration - return {}; - } - } - - if (command.toLowerCase().indexOf('semble') >= 0 || isExactIntent(command, pattern, bin)) { - record(false, 'no-match'); - return {}; - } - if (throttled(cwd)) { - record(false, 'throttled'); - return {}; - } - - touch(cwd); - record(true, 'ok'); - telemetry(cwd, sid, 'nudge', { - matcher: toolName, - agent: agentOf(input), - q: command.slice(0, 120), - }); - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: message(cwd, g.phase), - }, - }; -} - -async function main() { - let cwd = process.cwd(); - try { - let input = {}; - try { - input = await readStdin(); - } catch { - input = {}; // malformed/empty stdin: stay silent - } - if (!input || typeof input !== 'object' || Array.isArray(input)) input = {}; - if (typeof input.cwd === 'string' && input.cwd) cwd = input.cwd; - output(decide(input, cwd)); - } catch (e) { - warn('hook error: ' + (e && e.message)); - output({}); - } -} - -main(); diff --git a/brewcode/skills/semble-setup/assets/semble-session.mjs b/brewcode/skills/semble-setup/assets/semble-session.mjs index e7ef735..29d62a3 100644 --- a/brewcode/skills/semble-setup/assets/semble-session.mjs +++ b/brewcode/skills/semble-setup/assets/semble-session.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup /** * brewcode:semble-setup — SessionStart hook (self-contained, installed into a project). * @@ -10,7 +11,7 @@ * Pure ESM, Node built-ins only. readStdin/output are inlined on purpose: this * file travels alone into a user's .claude/hooks/ and must have no imports. */ -import { readFileSync, statSync } from 'node:fs'; +import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; // --- inlined helpers ------------------------------------------------------- @@ -37,6 +38,39 @@ function warn(message) { /* stderr is best-effort */ } } +// --- telemetry (best-effort, never throws, never changes hook output) ------ +const TELEMETRY_SRC = 'session'; +const TELEMETRY_MAX_BYTES = 2_000_000; +const TELEMETRY_KEEP_LINES = 1000; + +/** + * Appends one JSONL record to .claude/semble/telemetry.jsonl. Single + * appendFileSync, never read-modify-write. Every failure is swallowed: a hook + * that cannot measure itself must still behave exactly as if it had. + */ +function telemetry(cwd, sid, ev, extra) { + try { + const file = join(cwd, '.claude', 'semble', 'telemetry.jsonl'); + try { + if (statSync(file).size > TELEMETRY_MAX_BYTES) { + const kept = readFileSync(file, 'utf8').split('\n').filter((l) => l).slice(-TELEMETRY_KEEP_LINES); + writeFileSync(file, kept.join('\n') + '\n'); + } + } catch { + /* no file yet, or the trim failed - append anyway */ + } + const rec = { + ts: new Date().toISOString(), + ev, + src: TELEMETRY_SRC, + sid: typeof sid === 'string' ? sid : '', + ...(extra || {}), + }; + appendFileSync(file, JSON.stringify(rec) + '\n'); + } catch { + /* telemetry must never break a hook */ + } +} // --------------------------------------------------------------------------- const CORRUPT = { systemMessage: 'semble: state file is corrupt — run /brewcode:semble-setup status' }; @@ -81,13 +115,18 @@ function decide(cwd) { return { systemMessage: 'semble: disabled for this project' }; } if (phase === 'awaiting_reload') { + // The index is built lazily INSIDE a tool call, so telling the model to wait + // for `resume` is what kept verification from ever happening. Use it, and + // close the state out afterwards. return { systemMessage: 'semble: awaiting reload — run /brewcode:semble-setup resume', hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: - 'semble_code MCP was just registered; verification is pending. ' + - 'Run /brewcode:semble-setup resume before relying on semantic search.', + 'semble_code MCP is registered but not verified yet. It is usable now — ' + + 'mcp__semble_code__search (repo=' + cwd + ', top_k=5, max_snippet_lines=10); ' + + 'the first call rebuilds the index and may take minutes. ' + + 'Run /brewcode:semble-setup resume to close the state out.', }, }; } @@ -123,7 +162,16 @@ async function main() { if (input && typeof input === 'object' && typeof input.cwd === 'string' && input.cwd) { cwd = input.cwd; } - output(decide(cwd)); + const response = decide(cwd); + const hso = response && response.hookSpecificOutput; + if (hso && typeof hso.additionalContext === 'string') { + telemetry(cwd, typeof input.session_id === 'string' ? input.session_id : '', 'nudge', { + matcher: 'SessionStart', + agent: 'main', + q: '', + }); + } + output(response); } catch (e) { warn('hook error: ' + (e && e.message)); output({}); diff --git a/brewcode/skills/semble-setup/assets/semble-stats.mjs b/brewcode/skills/semble-setup/assets/semble-stats.mjs new file mode 100644 index 0000000..c9baf63 --- /dev/null +++ b/brewcode/skills/semble-setup/assets/semble-stats.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup +/** + * brewcode:semble-setup — PostToolUse / PostToolUseFailure hook (self-contained, + * installed into a project). PURE OBSERVER. + * + * It answers one question the other three hooks cannot: did a real semble call + * actually happen, and how does that compare to the ordinary search-shaped tool + * uses it was supposed to displace. It appends JSONL to + * `/.claude/semble/telemetry.jsonl` and NOTHING else — no + * `additionalContext`, no `permissionDecision`, no `systemMessage`. The reply is + * always the neutral `{}` and the exit code is always 0, so wiring it can never + * change what any tool call does. + * + * Registered on BOTH post-tool events, same matcher list: + * PostToolUse -> a call that succeeded (`ok:true`) + * PostToolUseFailure -> a call that errored or was interrupted (`ok:false`) + * On 2.1.226 a failed call does NOT fire PostToolUse, so without the second + * registration every failure would silently vanish from the denominator. + * + * Pure ESM, Node built-ins only. Helpers are inlined on purpose: this file + * travels alone into a user's .claude/hooks/ and must have no imports. + */ +import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// --- inlined helpers ------------------------------------------------------- +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +function output(response) { + let text = '{}'; + try { + text = JSON.stringify(response === undefined ? {} : response); + } catch { + text = '{}'; + } + process.stdout.write(text + '\n'); +} + +// --- telemetry (best-effort, never throws, never changes hook output) ------ +const TELEMETRY_SRC = 'stats'; +const TELEMETRY_MAX_BYTES = 2_000_000; +const TELEMETRY_KEEP_LINES = 1000; + +/** + * Appends one JSONL record to .claude/semble/telemetry.jsonl. Single + * appendFileSync, never read-modify-write. Every failure is swallowed: a hook + * that cannot measure itself must still behave exactly as if it had. + * Byte-identical in shape to the writer in semble-prefetch.mjs — the two files + * append to the SAME log and must stay in sync. + */ +function telemetry(cwd, sid, ev, extra) { + try { + const file = join(cwd, '.claude', 'semble', 'telemetry.jsonl'); + try { + if (statSync(file).size > TELEMETRY_MAX_BYTES) { + const kept = readFileSync(file, 'utf8').split('\n').filter((l) => l).slice(-TELEMETRY_KEEP_LINES); + writeFileSync(file, kept.join('\n') + '\n'); + } + } catch { + /* no file yet, or the trim failed - append anyway */ + } + const rec = { + ts: new Date().toISOString(), + ev, + src: TELEMETRY_SRC, + sid: typeof sid === 'string' ? sid : '', + ...(extra || {}), + }; + appendFileSync(file, JSON.stringify(rec) + '\n'); + } catch { + /* telemetry must never break a tool call */ + } +} +// --------------------------------------------------------------------------- + +/** The numerator: the two semble MCP tools. */ +const SEMBLE_TOOLS = ['mcp__semble_code__search', 'mcp__semble_code__find_related']; + +/** The denominator: search-shaped tools that semble is meant to displace. */ +const SEARCH_TOOLS = ['Bash', 'Grep', 'Glob']; + +/** + * Prefetch conversion, and the only reason this hook watches `Read`. + * `semble-prefetch.mjs` logs the candidate paths it injected; a conversion is an + * injected path being OPENED afterwards in the same session. Without an `open` + * record there is no way to compute that from the log without replaying the + * transcripts, so the number would only ever exist while someone was measuring. + * `Read` is never counted as a search: it is not a tool semble displaces. + */ +const OPEN_TOOLS = ['Read']; +const PATHMAX = 200; + +// The definition of "search-shaped" for the denominator. It outlived the +// retired advisory hook that first carried it; keep it in step with any other +// copy that appears. A search binary at a command boundary +// (start, |, ;, &, &&, ||, subshell); the `m` flag covers heredocs and +// multi-line scripts, where `^` alone only ever matched offset 0. +const SEARCH_RE = /(?:^|[|;&(]|&&|\|\|)\s*(?:command\s+)?(grep|egrep|fgrep|ugrep|rg|ag|ack|find|bfs)\b/m; + +const QMAX = 120; + +/** + * `main` | `sub`. On 2.1.226 the post-tool payload carries `agent_id` ONLY from + * inside a subagent, and `agent_type` from inside a subagent or on the main + * thread of a `--agent` session. Both keys are tested, matching + * semble-prefetch.mjs exactly: the conversion metric joins a `prefetch` to a + * later `open` on this field, so the two writers being consistently wrong about + * `--agent` sessions is strictly better than them disagreeing. + */ +function agentOf(input) { + if (!input || typeof input !== 'object') return 'unknown'; + const sub = + Object.prototype.hasOwnProperty.call(input, 'agent_id') || + Object.prototype.hasOwnProperty.call(input, 'agent_type'); + return sub ? 'sub' : 'main'; +} + +/** Non-negative integer milliseconds, or null when the payload had none. */ +function msOf(input) { + const d = input && input.duration_ms; + if (typeof d !== 'number' || !Number.isFinite(d) || d < 0) return null; + return Math.round(d); +} + +/** + * Did a PostToolUse response carry an error flag anyway? MCP results are + * delivered as `{content, isError}`; only the explicit error booleans count, a + * plain `error` key is too common a legitimate field name to trust. + */ +function responseFailed(resp) { + if (!resp || typeof resp !== 'object' || Array.isArray(resp)) return false; + return resp.isError === true || resp.is_error === true; +} + +/** The query text worth logging for a search-shaped tool, or null. */ +function queryOf(toolName, toolInput) { + const ti = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput) ? toolInput : {}; + if (toolName === 'Bash') { + const cmd = typeof ti.command === 'string' ? ti.command : ''; + if (!cmd || !SEARCH_RE.test(cmd)) return null; // not search-shaped: do not log + return cmd.slice(0, QMAX); + } + // Grep / Glob ARE search tools; the pattern is the whole query. + const pat = typeof ti.pattern === 'string' ? ti.pattern : ''; + return pat.slice(0, QMAX); +} + +function record(input, cwd) { + const ev = typeof input.hook_event_name === 'string' ? input.hook_event_name : ''; + if (ev !== 'PostToolUse' && ev !== 'PostToolUseFailure') return; + + const tool = typeof input.tool_name === 'string' ? input.tool_name : ''; + if (!tool) return; + + const sid = typeof input.session_id === 'string' ? input.session_id : ''; + const agent = agentOf(input); + const ms = msOf(input); + + if (SEMBLE_TOOLS.indexOf(tool) >= 0) { + const ok = ev === 'PostToolUse' && !responseFailed(input.tool_response); + telemetry(cwd, sid, 'call', { tool, ok, ...(ms === null ? {} : { ms }), agent }); + return; + } + if (OPEN_TOOLS.indexOf(tool) >= 0) { + const ti = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}; + const p = typeof ti.file_path === 'string' ? ti.file_path : ''; + if (!p) return; + // Both absolute and repo-relative forms are logged. The prefetch hook records + // semble's repo-relative `file_path`; Claude Code reads with an absolute path. + // Storing both spares the reader a cwd it does not have at read time. + const rel = p.indexOf(cwd + '/') === 0 ? p.slice(cwd.length + 1) : p; + telemetry(cwd, sid, 'open', { f: rel.slice(0, PATHMAX), abs: p.slice(0, PATHMAX), agent }); + return; + } + if (SEARCH_TOOLS.indexOf(tool) < 0) return; + + // A failed grep is still a search: `grep foo` with no match exits 1 and lands + // on PostToolUseFailure. Dropping those would gut the denominator. + const q = queryOf(tool, input.tool_input); + if (q === null) return; + telemetry(cwd, sid, 'search', { tool, q, agent }); +} + +async function main() { + let cwd = process.cwd(); + try { + let input = {}; + try { + input = await readStdin(); + } catch { + input = {}; // malformed/empty stdin: record nothing, stay neutral + } + if (!input || typeof input !== 'object' || Array.isArray(input)) input = {}; + if (typeof input.cwd === 'string' && input.cwd) cwd = input.cwd; + record(input, cwd); + } catch { + /* an observer that throws is worse than an observer that misses a sample */ + } + output({}); +} + +main(); diff --git a/brewcode/skills/semble-setup/assets/sembleignore.template b/brewcode/skills/semble-setup/assets/sembleignore.template new file mode 100644 index 0000000..b3e4201 --- /dev/null +++ b/brewcode/skills/semble-setup/assets/sembleignore.template @@ -0,0 +1,196 @@ +# brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup +# brewcode:semble — managed file. Regenerate with +# semble-guidance.sh install --part ignore --force +# Edit it freely: any change makes it `user_modified`, and the installer then +# leaves it alone (a backup is taken before --force overwrites). +# +# WHY THIS FILE EXISTS +# semble 0.5.4 builds its ignore set in index/file_walker.py:_load_ignore_for_dir +# from exactly two files per directory — ./.gitignore and ./.sembleignore. It +# never reads the user's global excludes file (core.excludesFile / ~/.gitignore*) +# and never asks git. So a directory that is invisible to `git status` only +# because of a GLOBAL ignore rule is still fully indexed and still comes back as +# search evidence. `.claude/` is the common case. +# +# ORDER MATTERS, AND IT WORKS IN OUR FAVOUR +# _load_ignore_for_dir concatenates ./.gitignore lines FIRST and ./.sembleignore +# lines SECOND into one GitIgnoreSpec, and _is_ignored keeps the LAST pattern +# that matched. A rule here therefore overrides a conflicting rule in the +# sibling .gitignore — including a `!` un-ignore. That is the only lever for the +# bypass described next. +# +# THE NEGATION BYPASS (file_walker.py:_is_ignored, the `found` flag) +# A `!` un-ignore pattern whose text ends in a file extension — `!keep.png`, +# `!web/docs/package-lock.json`, `!*.json` — sets `found = True`, and `_walk` +# then yields the file **even though its suffix belongs to no content type**. +# So a .gitignore negation can drag binaries and lockfiles into the corpus that +# `--content` alone can never reach, and no change to the content set removes +# them. Measured on this workspace: one negated `package-lock.json` was 552 +# chunks (5.9% of the whole index) and two negated `.png` files added 143 chunks +# of decoded binary garbage. The two blocks below exist to re-ignore exactly +# that class of file. +# +# Only paths that are never project source belong here. Leaving noise indexed is +# cheaper than hiding something you wanted to find. + +# --- Claude Code working directories --------------------------------------- +# Scratch, vendored upstream copies, generated reports and machine state. +# NOT excluded, because they are project-authored: .claude/skills/, +# .claude/agents/, .claude/rules/, .claude/commands/, .claude/hooks/, +# .claude/scripts/, .claude/tasks/. +.claude/tmp/ +.claude/reports/ +.claude/backups/ +.claude/logs/ +.claude/semble/ +.claude/projects/ +.claude/history/ +.claude/file-history/ +.claude/shell-snapshots/ +.claude/statsig/ +.claude/todos/ +.claude/ide/ + +# --- Build output and caches semble does not skip by default ---------------- +# Its built-in list already covers .git .hg .svn __pycache__ node_modules +# .venv venv .tox .mypy_cache .pytest_cache .ruff_cache .cache .semble .next +# dist build .eggs — these are the ones it misses. +target/ +coverage/ +htmlcov/ +.gradle/ +.astro/ +.turbo/ +.parcel-cache/ +.nuxt/ +.svelte-kit/ +.output/ +.docusaurus/ +.terraform/ +.dart_tool/ +_site/ + +# --- Vendored dependency trees ---------------------------------------------- +# Conventional names for "someone else's source, copied in". Every one of these +# is upstream code the question is never about. +vendor/ +third_party/ +bower_components/ +.yarn/ +Godeps/ + +# --- Generated bundles ------------------------------------------------------ +*.min.js +*.min.css +*.bundle.js +*.map + +# --- Binary and non-text assets --------------------------------------------- +# GENERIC AND ZERO-RISK. None of these suffixes maps to a language, so with a +# plain .gitignore these lines are a no-op. They earn their place only against +# the negation bypass above: when a `!logo.png` slips one through, semble reads +# it with errors="replace" and indexes the mojibake. There is no repo in which +# decoded binary is the answer to a question. +*.png +*.jpg +*.jpeg +*.gif +*.bmp +*.tiff +*.webp +*.avif +*.ico +*.icns +*.svgz +*.pdf +*.woff +*.woff2 +*.ttf +*.otf +*.eot +*.mp3 +*.mp4 +*.wav +*.mov +*.webm +*.zip +*.gz +*.tgz +*.bz2 +*.xz +*.7z +*.rar +*.jar +*.war +*.class +*.so +*.dylib +*.dll +*.exe +*.bin +*.o +*.a +*.pyc +*.pyo +*.wasm +*.db +*.sqlite +*.sqlite3 +*.parquet +*.avro +*.pack +*.idx + +# --- Dependency lockfiles --------------------------------------------------- +# Machine-written dependency resolution. `pnpm-lock.yaml` is a .yaml and so is +# in the config bucket outright; the rest reach the corpus only through the +# negation bypass. No lockfile has ever been the answer to a "how does this +# work" question, and one of them was 5.9% of this workspace's index. +package-lock.json +npm-shrinkwrap.json +yarn.lock +pnpm-lock.yaml +bun.lockb +composer.lock +Gemfile.lock +Cargo.lock +poetry.lock +uv.lock +Pipfile.lock +pdm.lock +go.sum +gradle.lockfile +packages.lock.json + +# --- Per-repo exclusions ---------------------------------------------------- +# NOTHING BELOW THIS LINE SHIPS PRE-FILLED. The rules above hold in any repo; +# the two biggest sources of wasted result slots do not, because they are +# layout-specific and no static pattern can recognise them: +# +# 1. DUPLICATE TREES — the same file committed at two or three paths (a +# mirror for another agent runtime, a vendored copy of your own plugin, a +# generated port). Semble has no dedup: N copies means N chances to fill a +# result slot with the same text. On this workspace three mirrors of one +# plugin tree were 2202 chunks and took 15 of 80 result slots across 16 +# queries. +# 2. LONG CHANGELOGS — RELEASE-NOTES.md / CHANGELOG.md. Genuinely useful for +# "when did X land", genuinely ruinous for "how does X work": a 24k-line +# changelog here was 503 chunks and took 9 of 80 slots. Exclude it only if +# you do not ask semble history questions. +# +# `semble-guidance.sh install --part ignore` MEASURES this repo and appends what +# it found below, in a delimited "measured candidates" block: +# duplicate trees (byte-identical copies of files that live somewhere else) and +# paths carrying a disproportionate share of the corpus, with exact chunk counts +# when an index exists and byte share before that. Every proposal is written +# COMMENTED OUT and excludes nothing until you uncomment it - a wrong exclusion +# fails silently, so the scan proposes and you decide. Re-running only ever adds +# paths it has never proposed; your edits inside that block survive. +# +# To see the same measurement without installing: +# +# scripts/semble-project.sh candidates +# +# Then add them here, one per line, with a comment saying why. Root-anchor a +# path that must not match deeper copies of the same name (`/skills/` hits only +# the top-level directory; `skills/` would hit every `*/skills/` in the repo). diff --git a/brewcode/skills/semble-setup/references/engine-landscape.md b/brewcode/skills/semble-setup/references/engine-landscape.md index 0f5a15f..503a0e9 100644 --- a/brewcode/skills/semble-setup/references/engine-landscape.md +++ b/brewcode/skills/semble-setup/references/engine-landscape.md @@ -40,7 +40,7 @@ ### Тип -Embedding-based семантический поиск по коду. MCP-сервер `semble_code`. Ставится как `semble[mcp]`, мы пиним `0.5.2`. +Embedding-based семантический поиск по коду. MCP-сервер `semble_code`. Ставится как `semble[mcp]`, мы пиним `0.5.4`. ### Что делает @@ -98,7 +98,7 @@ Embedding-based семантический поиск по коду. MCP-сер | Последний GitHub release | `v0.5.3`, 2026-08-03 | | Тег `v0.5.4` | существует в `git/refs/tags`, но GitHub Release для него НЕ опубликован | | PyPI latest | `0.5.4`, upload 2026-08-06T07:00:12Z | -| Наш пин | `0.5.2` (PyPI upload 2026-07-21T08:43:38Z) — дрейф 2 минорных патча / 16 дней | +| Наш пин | `0.5.4` — дрейфа нет (поднят с `0.5.2` 2026-08-08) | > Поправка к исходной фактуре: `0.5.4` вышел на PyPI 2026-08-06, но GitHub Release на него отсутствует — тег есть, релиза нет. Формулировка «released 2026-08-06» верна только для PyPI. @@ -144,14 +144,15 @@ Recall при фиксированном token-бюджете: | Артефакт | Событие / matcher | Поведение | |----------|-------------------|-----------| | `assets/semble-session.mjs` | `SessionStart`, без matcher | читает `/.claude/semble/state.json`; при `phase === 'ready'` шлёт `systemMessage` + `additionalContext`. Никогда не блокирует | -| `assets/semble-reminder.mjs` | `PreToolUse`, зарегистрирован ДВАЖДЫ: matcher `Bash` и matcher `Grep` | advisory-only, throttle 600 s по mtime `/.claude/semble/.reminder-ts`. Явно никогда не `permissionDecision` / `deny` / `updatedInput` | -| `assets/semble-explore.mjs` | `SubagentStart`, matcher `Explore` | срабатывает при `input.agent_type === 'Explore'` | +| `assets/semble-prefetch.mjs` | `UserPromptSubmit`, без matcher | gate v3 -> дистилляция промпта -> ОДИН `uvx … semble search` (жёсткий cap 3 s, SIGKILL) -> `additionalContext` с top-3 ПУТЯМИ без сниппетов. Троттл 30 s, cooldown 600 s по `/.claude/semble/.prefetch-ts`. Fail-open: любая ошибка -> `{}` и exit 0 | +| `assets/semble-stats.mjs` | `PostToolUse` + `PostToolUseFailure`, один pipe-matcher | чистый наблюдатель: JSONL в `/.claude/semble/telemetry.jsonl`, всегда `{}` | +| ~~`assets/semble-reminder.mjs`~~ / ~~`assets/semble-explore.mjs`~~ | ретайрены в 5.0.0 | обе эмитили только advisory `additionalContext`; конверсия 0/18 (main) и 0/11 (Explore) при доказанной доставке. `install`/`upgrade` удаляет файлы и снимает их строки | | `.claude/rules/semble-first.md` | - | правило «semantic-first» | | маркерный блок в `CLAUDE.md` | - | инструкция для сессии | | `semble-agents.sh` | - | патчит frontmatter агентов, добавляя 2 MCP-тула в `tools:` | | permissions | - | allow-only | -Телеметрии использования у нас нет: в `state.json` нет ни одного счётчика вызовов. Ключи — `schema` (константа 1), `phase`, `enabled`, `scope`, `cacheRoot`, `repoHash`, `completed[]`, `notes[]`, `updatedAt` (ISO-время последней записи, `sc_state_patch` в `scripts/lib/semble-common.sh:341`). +Телеметрии использования у нас нет: в `state.json` нет ни одного счётчика вызовов. Ключи — `schema` (константа 1), `phase`, `enabled`, `scope`, `cacheRoot`, `repoHash`, `completed[]`, `notes[]`, `version`, `generated_by`, `last_updated` (дата последней записи, `YYYY-MM-DD`, `sc_state_patch` в `scripts/lib/semble-common.sh`). --- @@ -631,7 +632,7 @@ Git-хуки (отдельно от CC): `graphify hook install|uninstall|status | # | Дефект | Где | Что не так | Проверено | |---|--------|-----|-----------|-----------| -| 1 | Устаревший пин semble | наш пин `0.5.2` | upstream PyPI `0.5.4` (upload 2026-08-06); мы отстаём на 2 патча и 16 дней. GitHub Release для `0.5.4` не опубликован — есть только тег и пакет на PyPI | PyPI JSON API, GitHub releases/tags API | +| 1 | ~~Устаревший пин semble~~ ЗАКРЫТ 2026-08-08 | пин `0.5.4` | Пин поднят `0.5.2` -> `0.5.4`. `src/semble/index/` и `src/semble/mcp.py` побайтово идентичны между двумя sdist, `cache_version` остался `1`, совместимость кэша двусторонняя (замерено). GitHub Release для `0.5.4` по-прежнему не опубликован — есть только тег и пакет на PyPI | `diff -rq` по двум sdist; PyPI JSON API, GitHub releases/tags API | | 2 | Неточная ремарка про README | `brewcode/skills/semble-setup/SKILL.md:28` | SKILL.md правильно пишет «There is no watcher and no daemon», но добавляет «(Its own README claims otherwise; the code does not.)». Upstream README ничего подобного не заявляет — в нём нет ни одного вхождения `watch`/`daemon`/`background`. Скобку надо снять | grep по upstream README + `src/semble/mcp.py:29,212` | Дополнительно к учёту (не дефекты, но расхождения с ранее зафиксированной фактурой): diff --git a/brewcode/skills/semble-setup/references/hooks-roadmap.md b/brewcode/skills/semble-setup/references/hooks-roadmap.md index 4380f56..8ddf4c4 100644 --- a/brewcode/skills/semble-setup/references/hooks-roadmap.md +++ b/brewcode/skills/semble-setup/references/hooks-roadmap.md @@ -1,4 +1,14 @@ -# hooks-roadmap - текущая hook-поверхность semble и предложения к развитию. Проверено: 2026-08-08 +# hooks-roadmap - hook-поверхность semble и предложения к развитию. Проверено: 2026-08-08 + +> **УСТАРЕЛО в части «что лежит на диске» (5.0.0).** Раздел 1 описывает hook-слой +> ДО 5.0.0. `semble-reminder.mjs` (`PreToolUse` `Bash`/`Grep`) и `semble-explore.mjs` +> (`SubagentStart` `Explore`) РЕТАЙРЕНЫ: обе выдавали только advisory +> `additionalContext` и сконвертировали 0/18 и 0/11 при доказанной доставке. Их +> заменил `semble-prefetch.mjs` (`UserPromptSubmit`, без matcher), который сам +> выполняет поиск и отдаёт top-3 ПУТИ. Актуальная want-таблица - четыре строки: +> `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostToolUseFailure`. +> Источник правды - `scripts/semble-guidance.sh` (`SG_WANT_TABLE`) и `assets/INSTALL.md`. +> Раздел «Предложения» сохранён как есть: он про будущие события, а не про текущие. Документ фиксирует (a) что реально лежит на диске сегодня и (b) пять предложений, каждое сверено с настоящим контрактом хуков Claude Code. Реализация по нему не @@ -12,16 +22,18 @@ --- -## 1. Где какой хук уже применён +## 1. Где какой хук был применён ДО 5.0.0 (историческая фиксация) -Ассеты скилла (`assets/`) - то, что устанавливается: +Ассеты скилла (`assets/`) - то, что устанавливалось ДО 5.0.0. Актуальный набор - ТРИ файла +(`semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`); см. баннер выше и +`scripts/semble-guidance.sh` (`SG_LIVE` / `SG_WANT_TABLE`): | Событие | Matcher | Файл | Что эмитит | Блокирует? | |---------|---------|------|------------|------------| | `SessionStart` | нет (все) | `semble-session.mjs` | `systemMessage` + `hookSpecificOutput.additionalContext` (только при `phase === "ready"`) | нет | -| `PreToolUse` | `Bash` | `semble-reminder.mjs` | `hookSpecificOutput.additionalContext`, не чаще 1 раза в 600 s | нет, по контракту | -| `PreToolUse` | `Grep` | `semble-reminder.mjs` | то же (та же регистрация, второй matcher) | нет, по контракту | -| `SubagentStart` | `Explore` | `semble-explore.mjs` | `hookSpecificOutput.additionalContext` в транскрипт ПОРОЖДЁННОГО сабагента | нет | +| `PreToolUse` | `Bash` | ~~`semble-reminder.mjs`~~ РЕТАЙРЕН 5.0.0 | `hookSpecificOutput.additionalContext`, не чаще 1 раза в 600 s | нет, по контракту | +| `PreToolUse` | `Grep` | ~~`semble-reminder.mjs`~~ РЕТАЙРЕН 5.0.0 | то же (та же регистрация, второй matcher) | нет, по контракту | +| `SubagentStart` | `Explore` | ~~`semble-explore.mjs`~~ РЕТАЙРЕН 5.0.0 | `hookSpecificOutput.additionalContext` в транскрипт ПОРОЖДЁННОГО сабагента | нет | Общее для всех трёх: pure ESM, только Node built-ins, читают ровно один файл `/.claude/semble/state.json`, не спавнят процессов, всегда печатают один JSON-объект @@ -33,12 +45,14 @@ | Файл | Факт | |------|------| | `semble-session.mjs` | `phase === "ready"` -> `systemMessage: "semble: ready \| cache " + repoHash.slice(0,8)` (или `"unknown"`), `additionalContext` = "ONE `mcp__semble_code__search` first (repo=, top_k=5, max_snippet_lines=10), then open the hit at start_line". Ветки: `missing`/пустой -> `{}`; `corrupt` -> `"semble: state file is corrupt - run /brewcode:semble-setup status"`; `enabled===false` или `phase==="disabled"` -> `"semble: disabled for this project"`; `awaiting_reload` -> resume-nudge + `additionalContext`; `error` -> `"semble: error - ..."`; любая другая непустая `phase` -> `"semble: "` | -| `semble-reminder.mjs` | Header прямо запрещает `permissionDecision`, deny и `updatedInput`. `THROTTLE_MS = 600_000`. `SEARCH_RE = /(?:^\|[\|;&(]\|&&\|\|\|)\s*(?:command\s+)?(grep\|egrep\|fgrep\|ugrep\|rg\|ag\|ack\|find\|bfs)\b/`. Маркер троттла - mtime файла `/.claude/semble/.reminder-ts`; `writeFileSync` в `touch()` - ЕДИНСТВЕННАЯ runtime-запись во всей hook-системе semble. `isExactIntent()` смещён в молчание: любое сомнение -> `true` (правила a-g). Для нативного `Grep`: `output_mode` `files_with_matches`/`count` -> молчание. Команда, содержащая `semble` (lowercase) -> молчание | -| `semble-explore.mjs` | `SubagentStart`, гейт `input.agent_type === 'Explore'`, никакого троттла, `additionalContext` про прямой вызов `mcp__semble_code__search` без ToolSearch | +| ~~`semble-reminder.mjs`~~ (РЕТАЙРЕН 5.0.0) | Header прямо запрещает `permissionDecision`, deny и `updatedInput`. `THROTTLE_MS = 600_000`. `SEARCH_RE = /(?:^\|[\|;&(]\|&&\|\|\|)\s*(?:command\s+)?(grep\|egrep\|fgrep\|ugrep\|rg\|ag\|ack\|find\|bfs)\b/`. Маркер троттла - mtime файла `/.claude/semble/.reminder-ts`; `writeFileSync` в `touch()` - ЕДИНСТВЕННАЯ runtime-запись во всей hook-системе semble. `isExactIntent()` смещён в молчание: любое сомнение -> `true` (правила a-g). Для нативного `Grep`: `output_mode` `files_with_matches`/`count` -> молчание. Команда, содержащая `semble` (lowercase) -> молчание | +| ~~`semble-explore.mjs`~~ (РЕТАЙРЕН 5.0.0) | `SubagentStart`, гейт `input.agent_type === 'Explore'`, никакого троттла, `additionalContext` про прямой вызов `mcp__semble_code__search` без ToolSearch | ### Блоб settings.json (`assets/INSTALL.md` section 4 и `merge_settings()` в `semble-guidance.sh`) -Обе копии идентичны, `want`-таблица: +Обе копии идентичны, `want`-таблица ДО 5.0.0 (актуальная - `SG_WANT_TABLE` в +`scripts/semble-guidance.sh`: `SessionStart` / `UserPromptSubmit` / `PostToolUse` / +`PostToolUseFailure`): ``` ["SessionStart", null, "semble-session.mjs", 5] @@ -61,10 +75,11 @@ `state.json` не содержит ни одного числового/монотонного ключа (`schema, profile, projectRoot, approvedVersion, completed[], phase, enabled, scope, -cacheRoot, repoHash, notes[], resumePrompt, updatedAt, lastVerifiedAt`) -> телеметрии +cacheRoot, repoHash, notes[], resumePrompt, version, generated_by, last_updated, +last_verified_at`) -> телеметрии использования сегодня нет вообще. -### РАСХОЖДЕНИЯ: установленный экземпляр в этом репозитории +### РАСХОЖДЕНИЯ: установленный экземпляр в этом репозитории (снимок ДО 5.0.0) `/Users/maximus/IdeaProjects/claude-brewcode/.claude/` - установка устарела относительно ассетов: @@ -102,14 +117,14 @@ cacheRoot, repoHash, notes[], resumePrompt, updatedAt, lastVerifiedAt`) -> те ### Факты semble (перепроверены по установленному пакету) -Пакет: `uvx --from semble[mcp]==0.5.2 semble --content code docs config`, +Пакет: `uvx --from semble[mcp]==0.5.4 semble --content code docs config`, `SEMBLE_CACHE_LOCATION=~/Library/Caches/semble-code`, `alwaysLoad: true`. | # | Факт | Источник | |---|------|----------| | S1 | `_MIN_REVALIDATE_FACTOR = 3` - "Don't recheck staleness sooner than this many times the last build's duration". После сборки: `_revalidate_after[cache_key] = finished + (finished - start) * 3`. Внутри окна `_evict_if_stale` пропускается | `semble/mcp.py:29`, `:212` | | S2 | Watcher-а/демона нет; индекс строится ВНУТРИ tool-call. Значит первый вызов после истечения cooldown может занять секунды-минуты, и это происходит внутри одного `mcp__semble_code__search` | `semble/mcp.py` `_build_and_track`, `_evict_if_stale` | -| S3 | Semble уже ведёт свой лог `/savings.jsonl`, файл на диске подтверждён 2026-08-08, строки ровно такие: `{"ts": 1785954621.091636, "call": "search", "results": 5, "snippet_chars": 2548, "file_chars": 27879}` (`ts` - float epoch-seconds, НЕ ISO); CLI-команды `semble savings` и `semble clear savings`. Это счётчик СОБСТВЕННЫХ вызовов semble, глобальный на cache-root, без знаменателя (grep-вызовов) и без разделения по проектам/сессиям | `~/Library/Caches/semble-code/savings.jsonl`, `semble/cli.py:158,201,229` | +| S3 | Semble уже ведёт свой лог `/savings.jsonl`, файл на диске подтверждён 2026-08-08, строки ровно такие: `{"ts": 1785954621.091636, "call": "search", "results": 5, "snippet_chars": 2548, "file_chars": 27879}` (`ts` - float epoch-seconds, НЕ ISO); CLI-команды `semble savings` и `semble clear savings`. Это счётчик СОБСТВЕННЫХ вызовов semble, глобальный на cache-root, без знаменателя (grep-вызовов) и без разделения по проектам/сессиям | `~/Library/Caches/semble-code/savings.jsonl`, semble 0.5.4 `semble/cli.py:166` (`_clear_savings`), `:252` (subparser), `:280` (`savings`) | | S4 | Вне корпуса: `.html`/`.htm` (классифицируются как docs) и `.json`/`.json5`/`.csv`/`.tsv`/`.psv` | `assets/semble-first.md.template` -> `.claude/rules/semble-first.md` | --- @@ -162,11 +177,11 @@ cacheRoot, repoHash, notes[], resumePrompt, updatedAt, lastVerifiedAt`) -> те |------|----------| | Что | Сообщать модели, что индекс может быть холодным и первый вызов окажется медленным | | Зачем | S1/S2: watcher-а нет, ревалидация ленивая за `_MIN_REVALIDATE_FACTOR = 3`, пересборка происходит ВНУТРИ tool-call. Модель, получив многосекундный `mcp__semble_code__search`, склонна счесть инструмент сломанным и уйти в `rg` - ровно то поведение, которое вся обвязка пытается предотвратить | -| Событие + matcher | `SessionStart` (без matcher, расширение существующего `semble-session.mjs`) - одна фраза в уже эмитируемый `additionalContext`, когда с `lastVerifiedAt` прошло много времени | -| Механизм | Чистое чтение: сравнить `state.lastVerifiedAt` (уже есть в `state.json`) с текущим временем, и при превышении порога добавить в текст "первый вызов может строить индекс несколько секунд - это нормально, дождись его, не переключайся на rg". Никакого нового файла, никакого зонда, никакого спавна процесса - ограничение "хук не спавнит процессов" сохраняется | +| Событие + matcher | `SessionStart` (без matcher, расширение существующего `semble-session.mjs`) - одна фраза в уже эмитируемый `additionalContext`, когда с `last_verified_at` прошло много времени | +| Механизм | Чистое чтение: сравнить `state.last_verified_at` (уже есть в `state.json`, `YYYY-MM-DD`) с текущей датой, и при превышении порога добавить в текст "первый вызов может строить индекс несколько секунд - это нормально, дождись его, не переключайся на rg". Никакого нового файла, никакого зонда, никакого спавна процесса - ограничение "хук не спавнит процессов" сохраняется | | Почему не PreToolUse на MCP-вызове | Можно было бы вешать matcher `mcp__semble_code__search` (P5 - имя валидно как точная строка), но контекст, вставленный ПЕРЕД вызовом, модель прочитает уже после того, как вызов вернётся; предупреждать надо заранее, на старте сессии | -| Риск | Порог - эвристика; слишком низкий порог даёт постоянное предупреждение-шум. `lastVerifiedAt` отражает время верификации setup-а, а не время последней сборки индекса, то есть это приблизительный прокси, а не точная свежесть | -| ИЗБЫТОЧНОСТЬ | Установленный `.claude/rules/semble-first.md` УЖЕ несёт эту мысль дословно: "Semble has no background watcher. The index is (re)built inside a tool call and cached; the first call on a cold cache is slow, later calls are fast." Rule-файл автозагружается в тот же момент, что и `SessionStart`-хук, поэтому предложение НЕ добавляет новой позиции в контексте - оно дублирует уже присутствующий текст. Единственное, чего в rule нет, - "не переключайся на rg, дождись" как явная инструкция поведения. Правильный ход - ОДНА фраза в шаблон `semble-first.md.template`, без изменения хука вообще; вариант с хуком оправдан только если нужен порог по `lastVerifiedAt`, чего rule выразить не может | +| Риск | Порог - эвристика; слишком низкий порог даёт постоянное предупреждение-шум. `last_verified_at` отражает дату верификации setup-а, а не время последней сборки индекса, то есть это приблизительный прокси, а не точная свежесть | +| ИЗБЫТОЧНОСТЬ | Установленный `.claude/rules/semble-first.md` УЖЕ несёт эту мысль дословно: "Semble has no background watcher. The index is (re)built inside a tool call and cached; the first call on a cold cache is slow, later calls are fast." Rule-файл автозагружается в тот же момент, что и `SessionStart`-хук, поэтому предложение НЕ добавляет новой позиции в контексте - оно дублирует уже присутствующий текст. Единственное, чего в rule нет, - "не переключайся на rg, дождись" как явная инструкция поведения. Правильный ход - ОДНА фраза в шаблон `semble-first.md.template`, без изменения хука вообще; вариант с хуком оправдан только если нужен порог по `last_verified_at`, чего rule выразить не может | | Статус | **не реализовано; в текущем виде - в основном дубликат rule-файла** | ### 3.4 Coverage/completeness signal - статус: не реализовано diff --git a/brewcode/skills/semble-setup/references/intent-routing.md b/brewcode/skills/semble-setup/references/intent-routing.md index df11480..2c15f87 100644 --- a/brewcode/skills/semble-setup/references/intent-routing.md +++ b/brewcode/skills/semble-setup/references/intent-routing.md @@ -119,7 +119,7 @@ Unique winner != permission to run. `purge` still needs `--yes` **and** `--confi | 3 | `upgrade`: `обнови` = 1. `reindex`'s `обнови индекс` is a **phrase** and does not match a bare `обнови` | | — | winner **`upgrade`**, unique | -reason: `matched keyword: обнови`. Compares the recorded pin against the approved `0.5.2`; identical -> report `unchanged` and stop. State plainly in the report that `"обнови"` was read as *update the pinned version*, not *rebuild the index*, and offer `reindex` in **Next Step**. +reason: `matched keyword: обнови`. Compares the recorded pin against the approved `0.5.4`; identical -> report `unchanged` and stop. State plainly in the report that `"обнови"` was read as *update the pinned version*, not *rebuild the index*, and offer `reindex` in **Next Step**. ### R3 — `"настрой semble"` diff --git a/brewcode/skills/semble-setup/references/language-coverage.md b/brewcode/skills/semble-setup/references/language-coverage.md index fba1e77..4f91330 100644 --- a/brewcode/skills/semble-setup/references/language-coverage.md +++ b/brewcode/skills/semble-setup/references/language-coverage.md @@ -1,6 +1,6 @@ # Language coverage — what `--content code docs config` actually indexes -> Source of truth: semble 0.5.2, `src/semble/index/files.py` and `src/semble/index/file_walker.py`. +> Source of truth: semble 0.5.4, `src/semble/index/files.py` and `src/semble/index/file_walker.py`. > The tables below are generated from that source, not from the README. ## The one rule that explains everything @@ -15,6 +15,55 @@ Not by content, not by shebang, not by name. Three consequences: | The bucket is fixed per suffix | You cannot move `.html` into the code bucket. `--content` selects buckets, never suffixes | | An unmapped suffix is unreachable | `.mdx` and `.txt` are absent from `_EXTENSION_TO_LANGUAGE` altogether, so no content type — not even `all` — indexes them. `.mdx` is **not** an alias of `.md` | +…with exactly one hole, which is big enough to have cost this workspace 7.5% of +its index. See the next section before trusting "unreachable". + +## The negation bypass — the one way an unreachable suffix gets in + +`file_walker.py:_is_ignored` returns a pair, `(ignored, found)`. The second flag: + +```python +found = not ignored and isinstance(pat, str) and bool(Path(pat.rstrip("/")).suffix) +``` + +and `_walk` yields on `found or item.suffix.lower() in extensions`. So when a +`.gitignore` or `.sembleignore` pattern **un-ignores** a path (`!…`) and the +pattern text ends in a file extension, `found` is `True` and **the extension +filter is skipped entirely**. The file is indexed no matter which bucket — if +any — its suffix belongs to. + +Reproduced against semble 0.5.4 with `--content code` alone: + +```text +.gitignore: package-lock.json / !sub/package-lock.json / *.png / !keep.png +walk_files(root, get_extensions([CODE])) -> + ['a.py', 'keep.png', 'sub/package-lock.json'] # .png, .json: both in NO bucket +plain.json (never negated) # correctly absent +``` + +Three consequences worth stating plainly: + +1. **The content set cannot fix it.** The bypass runs before the extension test, + so `--content code` pulls the same files in as `--content code docs config`. + Dropping a bucket to shed a lockfile does nothing. +2. **It is how binaries enter the corpus.** `read_file_text` decodes with + `errors="replace"`, so a negated `.png` is indexed as mojibake. Measured on + `claude-brewcode`: `!web/docs/package-lock.json` -> **552 chunks, 5.9% of the + whole index, the only `.json` in it**; two negated `.png` -> **143 chunks**. +3. **`.sembleignore` is the cure.** `_load_ignore_for_dir` concatenates the + directory's `.gitignore` lines first and its `.sembleignore` lines second into + one `GitIgnoreSpec`, and `_is_ignored` keeps the **last** matching pattern. + A re-ignore in `.sembleignore` therefore beats the `.gitignore` negation: + +```text ++ .sembleignore: *.png / package-lock.json +walk_files(...) -> ['a.py', 'w.yml'] # both bypassers gone +``` + +That is why the shipped `sembleignore.template` carries a binary-suffix block and +a lockfile block that look like no-ops. Against a plain `.gitignore` they are; +against a negation they are the only lever. + ## Buckets in this corpus `semble_code` runs with `--content code docs config` — all three buckets, **342 suffixes** @@ -82,6 +131,32 @@ Sizing, measured on `claude-brewcode` with an isolated cache: | `code config` | 278 | 0 | 0 | 8.4 MB | | `code docs config` | 868 | 585 | 0 | 31 MB | +## Why `config` stays in the set + +`config` looks like the bucket to cut — it is 40 files and **53 of 9307 chunks, +0.57%** of the corpus on this workspace. It stays, on two measurements: + +| Question | Answer | +|----------|--------| +| What is in it here? | All six `.github/workflows/*.yml` (19 chunks) and four `docker-compose*.yml`. That is the entire CI/CD and deployment surface of the repo | +| What breaks without it? | Q12 "what does the docs deploy workflow do" and Q15 "how does the docs site get deployed" — both answered from `deploy-docs.yml`, which is `.yml`, which is `config`. Drop the bucket and both questions have no reachable answer at all | +| Was it buying the lockfile? | **No.** That was the premise for cutting it and it is wrong: `package-lock.json` entered through the `.gitignore` negation bypass above, which runs *before* the extension filter. `--content code` alone indexes it just the same | + +0.57% of the index for the whole CI surface is the cheapest bucket in the set. +The decision is: **keep `code docs config` unchanged.** `SEMBLE_CONTENT_ARGS` is +not edited, so no cache anywhere is invalidated. + +## Known corpus limits, and what they cost + +These are not cosmetic. Each one has a question shape it silently fails: + +| Limit | The question it kills | +|-------|-----------------------| +| `.json` unreachable | "every hook event registered across all four plugins" — registrations live in `hooks/hooks.json`. Semble returns prose *about* hooks and never the registry. Structurally unanswerable, at any content setting, after any cleanup | +| `.mdx`/`.txt` unmapped | Anything about an Astro/Docusaurus content page | +| top-k is a sample | "every place that…", "all N of…" — a ranked list of 5 is not an enumeration. `rg -l`/`-c` owns this | +| no dedup | A file committed at three paths gets three chances at the same five slots | + ## Files that are skipped even when the suffix matches | Rule | Threshold | Source | diff --git a/brewcode/skills/semble-setup/references/mcp-and-cache.md b/brewcode/skills/semble-setup/references/mcp-and-cache.md index 4078300..a5c5efc 100644 --- a/brewcode/skills/semble-setup/references/mcp-and-cache.md +++ b/brewcode/skills/semble-setup/references/mcp-and-cache.md @@ -1,14 +1,14 @@ # MCP registration and cache layout > Ground truth for `semble_code`: what gets registered, how it is detected, where the index lives, and how a rebuild is guarded. -> Verified against semble **0.5.2** (sdist) and Claude Code **2.1.223**. Owner: `scripts/semble-mcp.sh`, `scripts/semble-cache.sh`, `scripts/semble-state.sh`. +> Verified against semble **0.5.4** (sdist) and Claude Code **2.1.223**. Owner: `scripts/semble-mcp.sh`, `scripts/semble-cache.sh`, `scripts/semble-state.sh`. ## Constants | Item | Value | |------|-------| | Server name | `semble_code` | -| Pin | `'semble[mcp]==0.5.2'` — always single-quoted (zsh globs `[ ]`), never floating | +| Pin | `'semble[mcp]==0.5.4'` — always single-quoted (zsh globs `[ ]`), never floating | | Scope | `user` (CLI default is `local`, so `-s user` is mandatory) | | Content set | `--content code docs config` (three argv tokens, this order). `docs` is what makes `.md` searchable — `files.py` puts `markdown` in `_DOC_LANGUAGES`, so a `code config` corpus indexes **zero** markdown. Single source of truth: `SEMBLE_CONTENT_ARGS` in `lib/semble-common.sh`; the MCP registration and every CLI invocation must pass it verbatim | | `alwaysLoad` | `true` — **mandatory**. Top-level optional boolean on the stdio server object (Claude Code 2.1.226 zod schema; `isDeferredTool()` returns false when set). With `ENABLE_TOOL_SEARCH=true` the MCP tool schemas are deferred, so without this key the model must run `ToolSearch` before it can call `mcp__semble_code__search` — every nudge points at a tool it cannot see. There is **no `claude mcp add` flag** for it; only `add-json` can write it | @@ -27,7 +27,7 @@ Primary — what `semble-mcp.sh add` and `repair` run. `add-json` is the **only* form that can carry `alwaysLoad`, so it goes first: ```bash -claude mcp add-json semble_code -s user '{"type":"stdio","command":"uvx","args":["--from","semble[mcp]==0.5.2","semble","--content","code","docs","config"],"env":{"SEMBLE_CACHE_LOCATION":""},"alwaysLoad":true}' +claude mcp add-json semble_code -s user '{"type":"stdio","command":"uvx","args":["--from","semble[mcp]==0.5.4","semble","--content","code","docs","config"],"env":{"SEMBLE_CACHE_LOCATION":""},"alwaysLoad":true}' ``` Degraded fallback, used only when `add-json` exits non-zero. It is what @@ -37,7 +37,7 @@ Degraded fallback, used only when `add-json` exits non-zero. It is what ```bash claude mcp add semble_code -s user \ -e SEMBLE_CACHE_LOCATION="$HOME/Library/Caches/semble-code" \ - -- uvx --from 'semble[mcp]==0.5.2' semble --content code docs config + -- uvx --from 'semble[mcp]==0.5.4' semble --content code docs config ``` `add-json` refuses to overwrite an existing entry ("MCP server semble_code @@ -71,7 +71,7 @@ Non-negotiable notes: | State | Definition | Response | |-------|------------|----------| | `absent` | no `semble_code` in user/local/project | `install`: checkpoint -> `claude mcp add-json` -> `awaiting_reload`. `status`: "not registered", Next Step = install | -| `correct` | user scope, `command=uvx`, args exactly `--from semble[mcp]==0.5.2 semble --content code docs config`, `env.SEMBLE_CACHE_LOCATION` == code root, `type` absent or `stdio`, **and `alwaysLoad === true`** | no MCP mutation; continue to verification | +| `correct` | user scope, `command=uvx`, args exactly `--from semble[mcp]==0.5.4 semble --content code docs config`, `env.SEMBLE_CACHE_LOCATION` == code root, `type` absent or `stdio`, **and `alwaysLoad === true`** | no MCP mutation; continue to verification | | `stale_args` | present in exactly one scope, but command/args/env/type/`alwaysLoad` differ (old pin, floating spec, wrong content set, relative or wrong cache root, **missing `alwaysLoad`**) | show the exact before/after diff, confirm once, then `remove` + `add-json`, checkpoint, reload | | `wrong_scope` | args correct but scope is `local` or `project` | report; ask whether to migrate to `user` or keep. Migrate = add at user, remove from the other scope, checkpoint, reload | | `duplicate` | `semble_code` in more than one scope | ALWAYS ask which to keep; remove the others; back up `~/.claude.json` and `.mcp.json` first | @@ -133,7 +133,7 @@ Evaluated in that order. This approximates `get_validated_cache`; report `stale` ## Per-repo rebuild -There is **no CLI for it**: `semble clear index` wipes every index under the root (`cli.py:138-164`). The only correct rebuild is to delete one directory and let the next query rebuild it: +There is **no CLI for it**: `semble clear index` wipes every index under the root (`_clear_indexes`, `cli.py:147-163`). `semble clear orphans` (new in 0.5.4, `_clear_orphans`, `cli.py:176-199`) is narrower but still not per-repo — it deletes only the indexes whose recorded `root_path` no longer exists. The only correct rebuild is to delete one directory and let the next query rebuild it: ```bash rm -rf "/" @@ -203,7 +203,7 @@ semble-state.sh patch '' semble-state.sh clear --yes ``` -State lives in `/.claude/semble/state.json` (schema 1). Every write goes through `sc_state_patch`: unknown top-level keys are preserved verbatim, `completed` is union-merged, `updatedAt` is refreshed, the file is re-read and every patched key asserted. Unparseable state or a `schema` other than `1` ABORTs with exit 1 and writes nothing. +State lives in `/.claude/semble/state.json` (schema 1). Every write goes through `sc_state_patch`: unknown top-level keys are preserved verbatim, `completed` is union-merged, the installer-owned artifact metadata (`version` from `.claude-plugin/plugin.json`, `generated_by`, `last_updated` = `date +%F`) is refreshed, the file is re-read and every patched key asserted. Unparseable state or a `schema` other than `1` ABORTs with exit 1 and writes nothing. Legal phase transitions (`absent` is the no-file state; `clear` returns to it): @@ -219,7 +219,7 @@ Legal phase transitions (`absent` is the no-file state; `clear` returns to it): Identity transitions are legal (a re-run is idempotent). Anything else prints `⚠️ illegal phase transition -> ; state left unchanged` and exits 1 without writing. -**Self-heal from `absent`.** The state file is created only inside an MCP mutation, so a project that inherits an already-correct **user-scope** registration never gets one — `add` reports `unchanged` and the checkpoint is skipped. `phase awaiting_reload|verifying|ready` therefore initialises the file at `prereq_ready` and walks the forward chain `prereq_ready -> awaiting_reload -> verifying -> ready`, stopping at the requested phase; every hop is checked against the same table and written like any other patch. `--json` reports `"healed":true` with the `walked` array. `disabled` is **not** healable from `absent` (nothing was ever set up to disable) and `prereq_ready`/`error` need no heal — they are already legal from `absent`. +**Self-heal from `absent`.** The state file is created only inside an MCP mutation. A project that inherits an already-correct **user-scope** registration takes no mutation, so `add` writes the checkpoint on that path explicitly (`unchanged` describes the registration, not the state file) — but the heal stays the safety net for every other way a project can reach a close-out with no state file. `phase awaiting_reload|verifying|ready` therefore initialises the file at `prereq_ready` and walks the forward chain `prereq_ready -> awaiting_reload -> verifying -> ready`, stopping at the requested phase; every hop is checked against the same table and written like any other patch. `--json` reports `"healed":true` with the `walked` array. `disabled` is **not** healable from `absent` (nothing was ever set up to disable) and `prereq_ready`/`error` need no heal — they are already legal from `absent`. `complete` takes one or more STEPs: separate arguments or a single whitespace-separated string. All tokens are validated first — an unknown one exits 2 naming that token and writes nothing — then the accepted set is union-merged in one patch. diff --git a/brewcode/skills/semble-setup/references/output-contract.md b/brewcode/skills/semble-setup/references/output-contract.md index 6fe900a..30440a2 100644 --- a/brewcode/skills/semble-setup/references/output-contract.md +++ b/brewcode/skills/semble-setup/references/output-contract.md @@ -13,7 +13,7 @@ mode: (reason: ) scope: ## Before -cli: uv | uvx | semble pin 0.5.2 () | claude +cli: uv | uvx | semble pin 0.5.4 () | claude mcp: @ [] cache: | repo | | | docs root reserved: guidance: rule | CLAUDE.md | hooks /4 wired | permissions @@ -29,8 +29,8 @@ failed: ## Verification commands: smoke: -> results, top = :- score | skipped () -corpus: code config | repo | -uncovered: .html/.htm (docs bucket), .json/.json5/.csv/.tsv/.psv (excluded from every content type) -> use rg +corpus: code docs config | repo | +uncovered: .json/.json5/.csv/.tsv/.psv (no content type reaches them), .mdx/.txt (absent from _EXTENSION_TO_LANGUAGE) -> use rg ## Current Status @@ -71,7 +71,7 @@ Checkpoint: /.claude/semble/state.json | `commands` is verbatim and complete | Every command actually executed, one per line, exactly as run — including the ones that failed. Never a paraphrase, never a plan. Nothing that was not run may appear here. | | `scope` | Where `semble_code` is (or would be) registered. Default and expected value is `user`. | | `` | First 8 hex chars of the repo's sha256 cache-dir name. Empty when unresolvable. | -| `hooks /4 wired` | 4 = SessionStart + PreToolUse(`Bash`) + PreToolUse(`Grep`) + SubagentStart(`Explore`). Anything below 4 is half-wired — say so, do not round up to "installed". | +| `hooks /4 wired` | 4 = SessionStart(`semble-session.mjs`) + UserPromptSubmit(`semble-prefetch.mjs`) + PostToolUse(`semble-stats.mjs`) + PostToolUseFailure(`semble-stats.mjs`) — the last two share the matcher `mcp__semble_code__search\|mcp__semble_code__find_related\|Bash\|Grep\|Glob\|Read`. Anything below 4 is half-wired — say so, do not round up to "installed". | | `staleness` | One of `absent | incomplete | mismatch | stale | fresh | unknown`. `stale` is reported as **likely stale** — the check approximates semble's own validation. | | `smoke` | `skipped ()` when `SEMBLE_NO_NETWORK=1`, when the MCP is not yet live, or when the mode never warms. Reasons are concrete, never "n/a". | | `uncovered` | Printed on every invocation, verbatim as in the template. It is a standing limit of the corpus, not a per-run finding. | @@ -85,7 +85,7 @@ Checkpoint: /.claude/semble/state.json | Never write | Because | |-------------|---------| -| anything about a watcher, daemon, background indexer, or service being "started"/"running"/"stopped" | semble 0.5.2 has none. Staleness is re-checked inside each tool call behind a `3x last-build-duration` cooldown. | +| anything about a watcher, daemon, background indexer, or service being "started"/"running"/"stopped" | semble 0.5.4 has none. Staleness is re-checked inside each tool call behind a `3x last-build-duration` cooldown. | | `installed` when `hooks` < 4, or when the MCP is registered but never verified | Half-wired is a distinct state; report `partial`. | | `connected` from config alone | `connectivity` comes only from the exit status of `claude mcp get semble_code`; with no signal it stays `unknown`. | | `stale` as a certainty | The check approximates `get_validated_cache`; say `likely stale` and offer `reindex` rather than acting. | @@ -107,7 +107,7 @@ mode: status (reason: default) scope: user ## Before -cli: uv absent | uvx absent | semble pin 0.5.2 (uvx-ephemeral) | claude 2.1.223 +cli: uv absent | uvx absent | semble pin 0.5.4 (uvx-ephemeral) | claude 2.1.223 mcp: absent @ user [unknown] cache: /Users/me/Library/Caches/semble-code | repo — | 0 B | absent | docs root reserved: no guidance: rule absent | CLAUDE.md absent | hooks 0/4 wired | permissions no @@ -123,8 +123,8 @@ failed: none ## Verification commands: bash scripts/semble-status.sh --section all --json smoke: skipped (MCP not registered) -corpus: code config | repo — | unknown -uncovered: .html/.htm (docs bucket), .json/.json5/.csv/.tsv/.psv (excluded from every content type) -> use rg +corpus: code docs config | repo — | unknown +uncovered: .json/.json5/.csv/.tsv/.psv (no content type reaches them), .mdx/.txt (absent from _EXTENSION_TO_LANGUAGE) -> use rg ## Current Status not installed — uv/uvx missing and semble_code is not registered in any scope diff --git a/brewcode/skills/semble-setup/scripts/lib/semble-common.sh b/brewcode/skills/semble-setup/scripts/lib/semble-common.sh index e306a7a..81dd0a1 100644 --- a/brewcode/skills/semble-setup/scripts/lib/semble-common.sh +++ b/brewcode/skills/semble-setup/scripts/lib/semble-common.sh @@ -3,7 +3,7 @@ # Bash 3.2 compatible. No jq: all JSON goes through `node -e`. # Callers do: . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/semble-common.sh" -SEMBLE_PIN_VERSION="${SEMBLE_PIN_VERSION:-0.5.2}" +SEMBLE_PIN_VERSION="${SEMBLE_PIN_VERSION:-0.5.4}" SEMBLE_PIN_SPEC="semble[mcp]==${SEMBLE_PIN_VERSION}" SEMBLE_SERVER_NAME="semble_code" SEMBLE_UPSTREAM_NAME="semble" @@ -112,9 +112,33 @@ sc_claude_bin() { printf '%s\n' "${SEMBLE_CLAUDE_BIN:-claude}"; } sc_claude_version() { "$(sc_claude_bin)" --version 2>/dev/null | awk '{print $1}' || true; } # Version of a `uv tool install`ed semble, if any. Empty = uvx-ephemeral mode. +# +# `uv tool list` stays PRIMARY on purpose, even though semble 0.5.4 finally has +# `semble --version`. The binary on PATH is of UNKNOWN version by definition — +# that is what this function is asking — and on 0.5.3 and older `--version` is +# not in _CLI_DISPATCH_ARGS, so it falls through to argv-starts-the-server and +# BLOCKS. Asking `uv tool list` first is free and cannot hang. +# `--version` is used only as a FALLBACK, for a semble that is on PATH but not +# under `uv tool` (pipx, a venv, a manual install), which `uv tool list` reports +# as absent and which we would otherwise mislabel `uvx-ephemeral`. Bounded by +# sc_timeout, so an old build on that path costs one timeout, never a hang. sc_semble_tool_version() { - sc_have uv || return 0 - { uv tool list 2>/dev/null | awk '$1=="semble"{print $2; exit}' | tr -d 'v'; } || true + local v="" + if sc_have uv; then + v="$(uv tool list 2>/dev/null | awk '$1=="semble"{print $2; exit}' | tr -d 'v')" || v="" + [ -n "$v" ] && { printf '%s\n' "$v"; return 0; } + fi + sc_have semble || return 0 + [ "$(sc_semble_probe_arg)" = "--version" ] || return 0 + # 5 s, not SEMBLE_PROBE_TIMEOUT: no network is involved, a semble that answers + # `--version` answers in well under a second, and the binary on PATH may still + # be older than the pin — in which case this argv starts the server and the + # bound is the only thing that ends the call. + v="$(sc_timeout 5 semble --version 2>/dev/null)" || return 0 + case "$v" in + *[!0-9.]*|*' '*|'') return 0 ;; + *) printf '%s\n' "$v" ;; + esac } # Which binary backs sc_timeout: `timeout` (GNU/Linux) | `gtimeout` (coreutils @@ -147,6 +171,24 @@ sc_timeout_path() { # is muted by swapping the SHELL's fd 2 — the child already holds the real one. # 124 is reported only when the deadline passed AND the child died of a signal, # so an unreaped child that finished on its own still yields its own status. +# +# The deadline is WALL CLOCK, read from bash's own $SECONDS. It used to be the +# sum of the *requested* sleep durations, which is not the same number: every +# poll forks /bin/sleep, and under load a fork costs far more than the interval +# it was asked to wait. On a busy machine a nominal 1 s bound drifted to several +# seconds — the bound silently stopped being a bound, and the suite's timing +# assertion went red for the real reason rather than a fake one. $SECONDS has +# 1 s granularity and `local t0=$SECONDS` needs no reset, so the enclosing +# shell's own counter is left untouched. +# +# The test is `-gt`, not `-ge`, and that one character is the whole contract. +# $SECONDS counts ticks since the SHELL started, so t0 is captured somewhere +# inside a second, not on its edge: `SECONDS - t0 == secs` can come true as +# little as a few ms after t0 and would kill a command that still had almost +# its full budget left. `-gt` costs at most one extra second and buys the only +# guarantee worth having — NEVER early. The bound therefore fires in +# [secs, secs+1) plus one poll interval (<=250 ms) plus the 100 ms TERM->KILL +# grace. Callers bound 15-60 s probes with it, where a second of slack is noise. sc_timeout_watch() { local secs="${1:?sc_timeout needs seconds}"; shift local had_m=0; case "$-" in *m*) had_m=1 ;; esac @@ -154,9 +196,9 @@ sc_timeout_watch() { "$@" & local pid=$! [ "$had_m" = "1" ] || set +m - local slept=0 limit=$(( secs * 100 )) step=1 timedout=0 rc=0 + local t0=$SECONDS slept=0 step=1 timedout=0 rc=0 while kill -0 "$pid" 2>/dev/null; do - if [ "$slept" -ge "$limit" ]; then timedout=1; break; fi + if [ "$(( SECONDS - t0 ))" -gt "$secs" ]; then timedout=1; break; fi sleep "$(printf '0.%02d' "$step")" 2>/dev/null || sleep 1 slept=$(( slept + step )) if [ "$slept" -ge 100 ]; then step=25 @@ -193,16 +235,57 @@ sc_timeout() { # ok | no_network | no_uvx | timeout | failed. A timeout is NOT "the pin is # broken" — callers that report `resolvable: false` can name the real reason. SEMBLE_PROBE_REASON="" +# The X.Y.Z the probe read back off the resolved package, or empty when the +# `--help` fallback answered instead. Informational; nothing gates on it. +SEMBLE_PROBE_VERSION="" -# Resolvability probe. `--help` is in semble's CLI dispatch set, so it prints -# help and exits 0 — it never starts the stdio server. Bounded: on a cold uv -# cache this is an uncached network fetch, and status runs it in every mode. -# Exit: 0 resolvable | 124 timed out | 1 anything else. +# Which argv to probe the pin with. Only argv in semble's _CLI_DISPATCH_ARGS is +# safe: anything else falls through to "start the stdio server" and BLOCKS. +# `--help` has always been in that set; `--version` joined it in 0.5.4 +# (`cli.py:25`, `:215`). So the argv is chosen FROM THE PIN, never tried and +# fallen back from — a hang cannot be recovered by a fallback, only by a bound. +# Anything unparseable degrades to `--help`, which is safe at every version. +sc_semble_probe_arg() { + local v="${1-$SEMBLE_PIN_VERSION}" maj min pat rest + case "$v" in *.*.*) ;; *) printf -- '--help\n'; return 0 ;; esac + maj="${v%%.*}"; rest="${v#*.}"; min="${rest%%.*}"; pat="${rest#*.}"; pat="${pat%%[!0-9]*}" + case "$maj" in ''|*[!0-9]*) printf -- '--help\n'; return 0 ;; esac + case "$min" in ''|*[!0-9]*) printf -- '--help\n'; return 0 ;; esac + case "$pat" in ''|*[!0-9]*) printf -- '--help\n'; return 0 ;; esac + if [ "$maj" -gt 0 ] || [ "$min" -gt 5 ] || { [ "$min" -eq 5 ] && [ "$pat" -ge 4 ]; }; then + printf -- '--version\n' + else + printf -- '--help\n' + fi +} + +# Resolvability probe. Runs sc_semble_probe_arg's argv, so it prints and exits 0 +# and never starts the stdio server. On a >=0.5.4 pin that argv is `--version`: +# measured 0.26 s warm (identical to `--help`) and 2.5 s cold including the +# download, and it prints the resolved X.Y.Z — so the probe proves WHICH build +# uvx handed us, not merely that something resolved. Unparseable stdout is not +# treated as a broken pin: it retries `--help`, which is safe at every version. +# Bounded: on a cold uv cache this is an uncached network fetch, and status runs +# it in every mode. Exit: 0 resolvable | 124 timed out | 1 anything else. sc_semble_probe() { SEMBLE_PROBE_REASON="" + SEMBLE_PROBE_VERSION="" [ "${SEMBLE_NO_NETWORK:-}" = "1" ] && { SEMBLE_PROBE_REASON="no_network"; return 1; } sc_have uvx || { SEMBLE_PROBE_REASON="no_uvx"; return 1; } - local rc=0 + local rc=0 out="" arg + arg="$(sc_semble_probe_arg)" + if [ "$arg" = "--version" ]; then + out="$(sc_timeout "$SEMBLE_PROBE_TIMEOUT" uvx --from "$SEMBLE_PIN_SPEC" semble --version 2>/dev/null)" || rc=$? + case "$rc" in + 0) + case "$out" in + *[!0-9.]*|*' '*|'') : ;; + *) SEMBLE_PROBE_VERSION="$out"; SEMBLE_PROBE_REASON="ok"; return 0 ;; + esac ;; + 124) SEMBLE_PROBE_REASON="timeout"; return 124 ;; + esac + rc=0 + fi sc_timeout "$SEMBLE_PROBE_TIMEOUT" uvx --from "$SEMBLE_PIN_SPEC" semble --help >/dev/null 2>&1 || rc=$? case "$rc" in 0) SEMBLE_PROBE_REASON="ok"; return 0 ;; @@ -219,6 +302,35 @@ sc_state_file() { printf '%s\n' "$(sc_project_root)/.claude/semble/state.j sc_rule_file() { printf '%s\n' "$(sc_project_root)/.claude/rules/semble-first.md"; } sc_hooks_dir() { printf '%s\n' "$(sc_project_root)/.claude/hooks"; } +# ── artifact metadata ─────────────────────────────────────────────────────── +# Manifest by SELF-LOCATION: scripts/lib -> scripts -> semble-setup -> skills -> . +# Correct in the dev checkout AND in the installed cache. The version is NEVER hardcoded. +SEMBLE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SEMBLE_PLUGIN_JSON="$SEMBLE_LIB_DIR/../../../../.claude-plugin/plugin.json" +SEMBLE_GENERATED_BY="brewcode:semble-setup" + +# HARD FAIL, never a placeholder value. The sole caller is sc_state_patch, which STAMPS +# state.json - there is no soft-probe caller. A word like `unknown` carries none of `{}<>`, so +# setup-status's PLACEHLD test cannot catch it: `sort -V` would compare it against the real +# version and print a confident `AHEAD unknown > X.Y.Z`. The manifest ships with the plugin in +# the dev checkout and in the cache alike, so an unreadable one is a broken install - stop +# before anything is written. Same resolution as superreview-setup/scripts/generate.sh +# `_plugin_version` and teams-setup/scripts/detect-mode.sh. +sc_plugin_version() { + local v="" + if [ -f "$SEMBLE_PLUGIN_JSON" ]; then + v=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$SEMBLE_PLUGIN_JSON" 2>/dev/null | head -1 || true) + fi + case "$v" in + [0-9]*.[0-9]*.[0-9]*) printf '%s' "$v"; return 0 ;; + esac + printf '❌ cannot resolve the plugin version (X.Y.Z) from %s - refusing to stamp an artifact with a fake version\n' "$SEMBLE_PLUGIN_JSON" >&2 + return 1 +} + +# The ONE date spelling for every artifact this skill writes: `date +%F` = YYYY-MM-DD. +sc_today() { date +%F; } + # ── json ──────────────────────────────────────────────────────────────────── sc_require_node() { sc_have node || sc_die "node is required by brewcode:semble-setup scripts"; } @@ -345,11 +457,19 @@ process.stdout.write(typeof v==="object"?JSON.stringify(v):String(v));' } # sc_state_patch '' — read-modify-write, preserves unknown keys. +# Installer-owned keys (schema, profile, projectRoot, approvedVersion, version, generated_by, +# last_updated) are (re)written on every patch; `enabled`, `phase`, `completed` and `notes` are +# only ever changed by an explicit patch, never by this preamble. sc_state_patch() { sc_require_node [ "${SEMBLE_DRY_RUN:-}" = "1" ] && { sc_dry "state patch $1"; return 0; } + # Hoisted out of the env-prefix on purpose: a `sc_plugin_version` failure inside `VAR="$(...)"` + # would only kill the substitution subshell and hand node an EMPTY version. + local _pv + _pv="$(sc_plugin_version)" || sc_die "ABORT: nothing was written to $(sc_state_file)" SC_F="$(sc_state_file)" SC_PATCH="${1:?}" SC_SCHEMA="$SEMBLE_STATE_SCHEMA" \ - SC_ROOT="$(sc_project_root)" SC_VER="$SEMBLE_PIN_VERSION" node -e ' + SC_ROOT="$(sc_project_root)" SC_VER="$SEMBLE_PIN_VERSION" \ + SC_PLUGIN_VER="$_pv" SC_GENBY="$SEMBLE_GENERATED_BY" SC_TODAY="$(sc_today)" node -e ' const fs=require("fs"),path=require("path");const f=process.env.SC_F; let s={}; if(fs.existsSync(f)){const raw=fs.readFileSync(f,"utf8"); @@ -361,10 +481,16 @@ s.schema=Number(process.env.SC_SCHEMA); if(!s.profile) s.profile="code"; s.projectRoot=process.env.SC_ROOT; s.approvedVersion=process.env.SC_VER; +s.version=process.env.SC_PLUGIN_VER; +s.generated_by=process.env.SC_GENBY; if(!Array.isArray(s.completed)) s.completed=[]; if(Array.isArray(p.completed)){ for(const c of p.completed) if(s.completed.indexOf(c)<0) s.completed.push(c); delete p.completed; } +// pre-5.0 spellings: two incompatible ISO precisions (ms from JS, seconds from `date -u`). +// Both collapse to the standard YYYY-MM-DD names; the verify date is carried over, not dropped. +if(s.lastVerifiedAt&&!s.last_verified_at) s.last_verified_at=String(s.lastVerifiedAt).slice(0,10); +delete s.updatedAt; delete s.lastVerifiedAt; Object.assign(s,p); -s.updatedAt=new Date().toISOString(); +s.last_updated=process.env.SC_TODAY; fs.mkdirSync(path.dirname(f),{recursive:true}); fs.writeFileSync(f,JSON.stringify(s,null,2)+"\n"); const back=JSON.parse(fs.readFileSync(f,"utf8")); diff --git a/brewcode/skills/semble-setup/scripts/semble-guidance.sh b/brewcode/skills/semble-setup/scripts/semble-guidance.sh index 37b6b4d..a8ebb77 100755 --- a/brewcode/skills/semble-setup/scripts/semble-guidance.sh +++ b/brewcode/skills/semble-setup/scripts/semble-guidance.sh @@ -8,17 +8,53 @@ SC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SRC="$(cd "$SC_DIR/.." && pwd)/assets" TPL="$SRC/semble-first.md.template" +IGNORE_TPL="$SRC/sembleignore.template" SESSION_MJS="semble-session.mjs" -REMINDER_MJS="semble-reminder.mjs" -EXPLORE_MJS="semble-explore.mjs" -HOOK_MJS="$SESSION_MJS $REMINDER_MJS $EXPLORE_MJS" +PREFETCH_MJS="semble-prefetch.mjs" +STATS_MJS="semble-stats.mjs" +HOOK_MJS="$SESSION_MJS $PREFETCH_MJS $STATS_MJS" +# RETIRED, v5.0.0: the two advisory hooks. `semble-reminder.mjs` (PreToolUse +# Bash|Grep) and `semble-explore.mjs` (SubagentStart Explore) both existed only to +# emit an advisory `additionalContext`, measured at 0/18 and 0/11 conversion with +# delivery proven independently. They are superseded by semble-prefetch.mjs, which +# runs the search instead of recommending it. +# +# They stay named here FOREVER, not deleted from the list: an existing install has +# them wired into its project settings.json and copied into .claude/hooks/. The +# ownership predicate (`SG_MARKS`) must keep recognising them so the merge's +# stale-purge strips the entries, and `install`/`upgrade` must keep deleting the +# files — merely no longer writing them would leave every existing user running a +# dead hook on every Bash call forever. +RETIRED_MJS="semble-reminder.mjs semble-explore.mjs" +ALL_MJS="$HOOK_MJS $RETIRED_MJS" +# Marker files the retired hooks wrote. The migration drops their .gitignore line, +# so leaving the file behind turns an invisible throttle marker into an untracked +# file in the user's repo - a diff for a hook that no longer exists. +RETIRED_MARKERS=".claude/semble/.reminder-ts" +# JSON array of every basename this skill has ever owned, live and retired. +SG_MARKS='["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs","semble-reminder.mjs","semble-explore.mjs"]' +# JSON array of the LIVE basenames only — what `wanted` is built from. +SG_LIVE='["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs"]' # Canonical want-table: [event, matcher, script, timeout-in-SECONDS]. Single source # of truth for the merge AND for the status conformance check — mirrored verbatim in # assets/INSTALL.md §4. `timeout` is seconds; an entry without it inherits Claude # Code's 600 s default. Each row expands to the full desired hook entry # {matcher?, hooks:[{type:"command",command:"node",args:[],timeout}]}. -SG_WANT_TABLE='[["SessionStart",null,"semble-session.mjs",5],["PreToolUse","Bash","semble-reminder.mjs",5],["PreToolUse","Grep","semble-reminder.mjs",5],["SubagentStart","Explore","semble-explore.mjs",5]]' +# +# The two stats rows share one pipe-separated matcher. Claude Code 2.1.226 treats a +# matcher of only [A-Za-z0-9_|] (plus `, ` in some call sites) as an exact name list +# split on `|`, and anything else as an unanchored regex — `|`-only is the form that +# is an exact list under BOTH readings. PostToolUseFailure is a separate event, not a +# flag: on this build a failed call does not fire PostToolUse at all, so one row +# cannot cover both. +# +# `Read` is in the stats matcher for one reason: prefetch conversion. The only way +# to know whether an injected candidate path was actually opened is to observe the +# Read that opened it, and that observation is what turns "the hook fired" into a +# conversion number computable from the JSONL alone, without a re-run. +SG_STATS_MATCHER='mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read' +SG_WANT_TABLE='[["SessionStart",null,"semble-session.mjs",5],["UserPromptSubmit",null,"semble-prefetch.mjs",5],["PostToolUse","'"$SG_STATS_MATCHER"'","semble-stats.mjs",5],["PostToolUseFailure","'"$SG_STATS_MATCHER"'","semble-stats.mjs",5]]' MODE="" PART="all" @@ -28,11 +64,11 @@ FORCE=0 usage() { cat <<'EOF' semble-guidance.sh status [--json] -semble-guidance.sh install [--part rule|claudemd|hooks|permissions|all] [--json] [--force] -semble-guidance.sh remove [--part rule|claudemd|hooks|permissions|all] [--json] [--force] +semble-guidance.sh install [--part rule|ignore|claudemd|hooks|permissions|all] [--json] [--force] +semble-guidance.sh remove [--part rule|ignore|claudemd|hooks|permissions|all] [--json] [--force] --part default: all - --force overwrite / delete a user-modified rule file (always backed up first) + --force overwrite / delete a user-modified rule or .sembleignore (always backed up first) --json machine-readable report on stdout, nothing else Exit: 0 ok | 1 failure (nothing written) | 2 usage EOF @@ -57,7 +93,7 @@ while [ $# -gt 0 ]; do shift done case "$PART" in - rule|claudemd|hooks|permissions|all) ;; + rule|ignore|claudemd|hooks|permissions|all) ;; *) sc_err "unknown --part: $PART"; exit 2 ;; esac @@ -69,6 +105,10 @@ HOOKS_DIR="$(sc_hooks_dir)" SETTINGS="$(sc_project_settings)" CLAUDEMD="$ROOT/CLAUDE.md" GITIGNORE="$ROOT/.gitignore" +# semble reads ./.gitignore and ./.sembleignore per directory and nothing else — +# not core.excludesFile, not ~/.gitignore_global. A repo-root .sembleignore is the +# only way to keep globally-ignored trees (`.claude/`) out of the index. +IGNOREFILE="$ROOT/.sembleignore" want_part() { [ "$PART" = "all" ] || [ "$PART" = "$1" ]; } @@ -110,41 +150,106 @@ add_failed() { FAILED="${FAILED}${1} # ── status ────────────────────────────────────────────────────────────────── status_json() { - SG_RULE="$RULE" SG_TPL="$TPL" SG_CLAUDEMD="$CLAUDEMD" SG_HOOKS="$HOOKS_DIR" \ + SG_RULE="$RULE" SG_TPL="$TPL" SG_IGNORE="$IGNOREFILE" SG_IGNORE_TPL="$IGNORE_TPL" \ + SG_CLAUDEMD="$CLAUDEMD" SG_HOOKS="$HOOKS_DIR" \ SG_SETTINGS="$SETTINGS" SG_SEARCH="$SEMBLE_TOOL_SEARCH" SG_RELATED="$SEMBLE_TOOL_RELATED" \ - SG_WANT="$SG_WANT_TABLE" node -e ' + SG_WANT="$SG_WANT_TABLE" SG_STATS_MATCHER="$SG_STATS_MATCHER" \ + SG_MARKS="$SG_MARKS" SG_LIVE="$SG_LIVE" node -e ' const fs=require("fs"), path=require("path"); const rule=process.env.SG_RULE, tpl=process.env.SG_TPL, cmd=process.env.SG_CLAUDEMD; const dir=process.env.SG_HOOKS, sf=process.env.SG_SETTINGS; const BEGIN="", END=""; -const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"]; +const marks=JSON.parse(process.env.SG_MARKS); // every basename ever owned +const live=JSON.parse(process.env.SG_LIVE); // the ones still wired const tools=[process.env.SG_SEARCH,process.env.SG_RELATED]; const readSafe=f=>{ try{ return fs.readFileSync(f,"utf8"); }catch(e){ return null; } }; const out={schema:1, rule:{state:"absent",path:rule}, + ignore:{state:"absent",path:process.env.SG_IGNORE}, claudeMd:{state:"absent",path:cmd,malformed:false}, - hooks:{session:{file:"missing",wired:false},reminder:{file:"missing",wired:false}, - explore:{file:"missing",wired:false}, - settingsFile:sf,settingsParsable:true,staleEntries:0,wiredCount:0, + hooks:{session:{file:"missing",wired:false},prefetch:{file:"missing",wired:false}, + stats:{file:"missing",wired:false},retired:[], + settingsFile:sf,settingsParsable:true,staleEntries:0,wiredCount:0,wantCount:0, driftedCount:0,missingCount:0,duplicateCount:0,entries:[],drift:[]}, permissions:{allow:[],wired:false}}; +// The rule is copied byte for byte, stamp included, so a current install matches the template +// exactly. The four standard metadata keys are still stripped from BOTH sides before the +// verdict, because a pre-fix install carries an installer-written `last_updated:` and possibly +// an older `version:` -- that is a stale stamp, not a user edit. `stamp` records whether the +// installed rule carries the three baked keys at all; a pre-5.0 rule carries none of them. +const OWNED=["doc_type","version","generated_by","last_updated"]; +const STAMPED=["doc_type","version","generated_by"]; +const stripMeta=t=>{ + if(t===null||!t.startsWith("---\n")) return t; + const end=t.indexOf("\n---\n",3); if(end<0) return t; + return "---\n"+t.slice(4,end+1).split("\n").filter(l=>!OWNED.some(k=>l.startsWith(k+":"))).join("\n") + +"---\n"+t.slice(end+5); +}; +// The frontmatter `version:` VALUE, not just its presence. setup-status names this +// rule the stamp source for the whole skill (references row 2), and semble-status +// compares it against the running plugin to decide whether to prescribe `upgrade` - +// a project on stale artifacts otherwise reads perfectly healthy in its own status. +const stampVer=t=>{ if(t===null||!t.startsWith("---\n")) return ""; + const end=t.indexOf("\n---\n",3); if(end<0) return ""; + const m=t.slice(4,end+1).split("\n").find(l=>/^version:/.test(l)); + return m?m.slice(8).trim().replace(/^"|"$/g,""):""; }; const rr=readSafe(rule), tt=readSafe(tpl); -if(rr!==null) out.rule.state=(tt!==null&&rr===tt)?"managed":"user_modified"; +out.rule.version=stampVer(rr); +out.rule.templateVersion=stampVer(tt); +if(rr!==null){ + out.rule.state=(tt!==null&&stripMeta(rr)===stripMeta(tt))?"managed":"user_modified"; + out.rule.stamp=STAMPED.every(k=>new RegExp("^"+k+":","m").test(rr.split("\n---\n")[0]||"")); +} +// The `# brewcode-meta:` stamp line is dropped from BOTH sides for the same reason the +// rule strips its frontmatter keys: .claude/scripts/bump-version.sh rewrites that line on +// every release, and a byte compare would then mark every already-installed .sembleignore +// user_modified and silently skip it without --force. +const CAND_B="# --- brewcode:semble measured candidates ---"; +const CAND_E="# --- end brewcode:semble measured candidates ---"; +// Same two strips install uses (sg_strip_metaline): the release stamp line and +// the measured-candidates block. Miss either and every annotated .sembleignore +// reports user_modified, which freezes it against every future template update. +const stripLine=t=>{ if(t===null) return t; + const L=t.split("\n").filter(l=>!/^#\s*brewcode-meta:/.test(l)); + const b=L.indexOf(CAND_B), e=L.indexOf(CAND_E); + const k=(b>=0&&e>b)?L.slice(0,b).concat(L.slice(e+1)):L; + while(k.length&&k[k.length-1].trim()==="") k.pop(); + return k.join("\n")+"\n"; }; +const ii=readSafe(process.env.SG_IGNORE), it=readSafe(process.env.SG_IGNORE_TPL); +if(ii!==null) out.ignore.state=(it!==null&&stripLine(ii)===stripLine(it))?"managed":"user_modified"; +// `--force` over a hand-edited file, and removing one, copy it to .bak.. +// That copy holds user content that exists nowhere else, so it is never deleted +// automatically - but it is reported, or it sits in the repo unnoticed forever +// and turns up as an unexplained untracked file weeks later. +out.backups=[]; +for(const f of [rule,process.env.SG_IGNORE]){ + const d=path.dirname(f), b=path.basename(f); + let names=[]; try{ names=fs.readdirSync(d); }catch(e){ continue; } + for(const n of names.sort()) if(new RegExp("^"+b.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\.bak\\.[0-9]+$").test(n)) out.backups.push(path.join(d,n)); +} const cc=readSafe(cmd); if(cc!==null){ const b=cc.indexOf(BEGIN), e=cc.indexOf(END); if(b>=0&&e>b) out.claudeMd.state="present"; else if(b>=0||e>=0) out.claudeMd.malformed=true; } -out.hooks.session.file=fs.existsSync(path.join(dir,marks[0]))?"present":"missing"; -out.hooks.reminder.file=fs.existsSync(path.join(dir,marks[1]))?"present":"missing"; -out.hooks.explore.file=fs.existsSync(path.join(dir,marks[2]))?"present":"missing"; +out.hooks.session.file=fs.existsSync(path.join(dir,"semble-session.mjs"))?"present":"missing"; +out.hooks.prefetch.file=fs.existsSync(path.join(dir,"semble-prefetch.mjs"))?"present":"missing"; +out.hooks.stats.file=fs.existsSync(path.join(dir,"semble-stats.mjs"))?"present":"missing"; +// A retired .mjs still on disk is a half-migrated install: report it by name. +out.hooks.retired=marks.filter(m=>live.indexOf(m)<0&&fs.existsSync(path.join(dir,m))); let s=null; const raw=readSafe(sf); if(raw!==null&&raw.trim()){ try{ s=JSON.parse(raw); }catch(e){ out.hooks.settingsParsable=false; } } if(s!==null&&(typeof s!=="object"||Array.isArray(s))){ s=null; out.hooks.settingsParsable=false; } const argsOf=e=>((e&&e.hooks)||[]).flatMap(h=>(h&&h.args)||[]).filter(a=>typeof a==="string"); const matcherOf=e=>(e&&typeof e.matcher==="string")?e.matcher:null; const isMine=a=>marks.some(m=>a===m||a.endsWith("/"+m)||a.endsWith("\\"+m)); -const wanted=new Set(marks.map(m=>path.join(dir,m))); +// "Wanted" is an (event, matcher, path) TRIPLE, not a path. A path-only test called a +// retired REGISTRATION of a live script clean — e.g. semble-stats.mjs still wired on the +// pre-5.0.0 PostToolUse matcher, which fires the hook a second time on every Bash. The +// triple comes from the want table, which is built from the LIVE basenames only, so a +// retired script at the CURRENT hooks dir is stale exactly like a stale-path one. const want=JSON.parse(process.env.SG_WANT); +const wkey=(ev,m,a)=>JSON.stringify([ev,m,a]); +const wanted=new Set(want.map(w=>wkey(w[0],w[1],path.join(dir,w[2])))); const desiredHook=(full,timeout)=>({type:"command",command:"node",args:[full],timeout}); const deq=(a,b)=>{ // key-order-insensitive deep equal if(a===b) return true; @@ -160,7 +265,7 @@ if(s&&s.hooks&&typeof s.hooks==="object"&&!Array.isArray(s.hooks)){ const arr=Array.isArray(s.hooks[ev])?s.hooks[ev]:[]; for(const e of arr){ const mine=argsOf(e).filter(isMine); - if(mine.length&&!mine.every(a=>wanted.has(a))) stale++; + if(mine.length&&!mine.every(a=>wanted.has(wkey(ev,matcherOf(e),a)))) stale++; } } } @@ -192,10 +297,13 @@ for(const [ev,matcher,script,timeout] of want){ rowState[key]=row.state; out.hooks.entries.push(row); } +out.hooks.wantCount=want.length; const ok=k=>rowState[k]==="wired"; +const M=process.env.SG_STATS_MATCHER; out.hooks.session.wired=ok("SessionStart/*/semble-session.mjs"); -out.hooks.reminder.wired=ok("PreToolUse/Bash/semble-reminder.mjs")&&ok("PreToolUse/Grep/semble-reminder.mjs"); -out.hooks.explore.wired=ok("SubagentStart/Explore/semble-explore.mjs"); +out.hooks.prefetch.wired=ok("UserPromptSubmit/*/semble-prefetch.mjs"); +// stats spans two events; a half-wired pair is not wired. +out.hooks.stats.wired=ok("PostToolUse/"+M+"/semble-stats.mjs")&&ok("PostToolUseFailure/"+M+"/semble-stats.mjs"); const allow=(s&&s.permissions&&Array.isArray(s.permissions.allow))?s.permissions.allow:[]; out.permissions.allow=tools.filter(t=>allow.includes(t)); out.permissions.wired=tools.every(t=>allow.filter(x=>x===t).length===1); @@ -207,15 +315,22 @@ status_human() { SG_J="$1" node -e ' const j=JSON.parse(process.env.SG_J); const bad=(j.hooks.driftedCount||0)+(j.hooks.duplicateCount||0); -console.log("guidance: rule "+j.rule.state+" | CLAUDE.md "+(j.claudeMd.malformed?"malformed":j.claudeMd.state) - +" | hooks "+j.hooks.wiredCount+"/4 wired"+(bad?" ("+bad+" drifted - re-run install to repair)":"") +const total=j.hooks.wantCount||0; +console.log("guidance: rule "+j.rule.state+" | .sembleignore "+((j.ignore&&j.ignore.state)||"absent") + +" | CLAUDE.md "+(j.claudeMd.malformed?"malformed":j.claudeMd.state) + +" | hooks "+j.hooks.wiredCount+"/"+total+" wired"+(bad?" ("+bad+" drifted - re-run install to repair)":"") +" | permissions "+(j.permissions.wired?"yes":"no")); console.log("rule: "+j.rule.path); +if(j.ignore) console.log("ignore: "+j.ignore.path); for(const d of (j.hooks.drift||[])) console.log("drift: "+d.event+"/"+(d.matcher||"*")+"/"+d.script +" "+d.field+"="+JSON.stringify(d.actual)+" want "+JSON.stringify(d.expected)); -console.log("hooks: "+j.hooks.session.file+" session, "+j.hooks.reminder.file+" reminder, " - +j.hooks.explore.file+" explore" +console.log("hooks: "+j.hooks.session.file+" session, "+j.hooks.prefetch.file+" prefetch, " + +j.hooks.stats.file+" stats" +(j.hooks.staleEntries?" | "+j.hooks.staleEntries+" stale settings entr"+(j.hooks.staleEntries===1?"y":"ies"):"")); +if((j.backups||[]).length) console.log("backups: "+j.backups.length+" .bak file"+(j.backups.length===1?"":"s") + +" left by --force/remove (your content, delete when you no longer need it): "+j.backups.join(", ")); +if((j.hooks.retired||[]).length) console.log("retired: "+j.hooks.retired.join(", ") + +" still on disk - re-run install to finish the migration"); console.log("settings: "+j.hooks.settingsFile+(j.hooks.settingsParsable?"":" (UNPARSEABLE - fix it, nothing can be merged)")); ' } @@ -240,38 +355,271 @@ process.stdout.write(JSON.stringify({schema:1,mode:process.env.SG_MODE,part:proc return 0 } -# ── rule ──────────────────────────────────────────────────────────────────── -install_rule() { - [ -f "$TPL" ] || { add_failed "rule: template missing at $TPL"; return 0; } - if [ ! -f "$RULE" ]; then - if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "write $RULE" >/dev/null; add_changed "rule: would create $RULE"; return 0; fi - mkdir -p "$(dirname "$RULE")" - cp "$TPL" "$RULE" && add_changed "rule: created $RULE" || add_failed "rule: cannot write $RULE" - return 0 - fi - if cmp -s "$TPL" "$RULE"; then add_unchanged "rule: up to date $RULE"; return 0; fi - if [ "$FORCE" != "1" ]; then - add_skipped "rule: user_modified, left as is (re-run with --force to overwrite; a backup is taken) $RULE" - diff -u "$RULE" "$TPL" >&2 || true - return 0 - fi - if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "overwrite $RULE" >/dev/null; add_changed "rule: would overwrite $RULE"; return 0; fi - local b; b="$(sc_backup "$RULE")" - cp "$TPL" "$RULE" && add_changed "rule: overwrote $RULE (backup $b)" || add_failed "rule: cannot write $RULE" +# ── managed files: the rule and .sembleignore ─────────────────────────────── +# One asset, one destination, copied byte for byte. `$1` is the report label, `$2` the +# source asset, `$3` the destination, `$4` = `meta` when the destination is markdown +# carrying the four standard artifact-metadata keys in its frontmatter (the rule does; +# `.sembleignore` carries a `# brewcode-meta:` comment line instead). +# +# BOTH assets are mechanism (a) of setup-status/references/artifact-metadata.md: the stamp +# is BAKED INTO the plugin's own file by .claude/scripts/bump-version.sh and copied verbatim. +# Nothing is rewritten here. `setup-status` `cmp`s the installed file against the plugin +# asset, and any stamp written at install time would make that comparison read DIFFERS on +# every install forever. The installed rule must be byte-identical to the template. +# +# `meta` therefore changes only the managed/user_modified COMPARISON, never the bytes +# written: a pre-fix install carries an installer-written `last_updated:` the plugin file +# does not have, and older installs carry an older `version:`. That is a stale stamp, not a +# user edit, so those four keys are stripped from both sides before the verdict and the file +# is re-synced to the plugin bytes without --force and without a backup. + +# Content equality with the four standard keys removed from BOTH sides. Without the strip a +# release bump -- or a pre-fix install carrying an installer-written `last_updated` -- would +# report every repo in the world as user_modified and demand a --force. +# No top-level `return` here: node 24 rejects it in `-e` with "Illegal return statement", and a +# crashing strip would make sg_same_content compare "" to "" and call every file identical. +sg_strip_meta() { + SG_F="$1" node -e ' +const fs=require("fs");const OWNED=["doc_type","version","generated_by","last_updated"]; +const t=fs.readFileSync(process.env.SG_F,"utf8"); +const end=t.startsWith("---\n")?t.indexOf("\n---\n",3):-1; +process.stdout.write(end<0?t: + "---\n"+t.slice(4,end+1).split("\n").filter(l=>!OWNED.some(k=>l.startsWith(k+":"))).join("\n") + +"---\n"+t.slice(end+5));' } -remove_rule() { - [ -f "$RULE" ] || { add_unchanged "rule: already absent"; return 0; } - if [ -f "$TPL" ] && cmp -s "$TPL" "$RULE"; then - if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "rm $RULE" >/dev/null; add_changed "rule: would remove $RULE"; return 0; fi - rm -f "$RULE" && add_changed "rule: removed $RULE" || add_failed "rule: cannot remove $RULE" +# The `# brewcode-meta:` comment-line form of the same stamp, for assets with no YAML +# frontmatter. `.sembleignore` carries it on line 1 and bump-version.sh rewrites it on +# every release. Without this strip, `install` compared the two files byte for byte, so a +# version bump alone marked every already-installed .sembleignore `user_modified` and +# SILENTLY SKIPPED it without --force — no .sembleignore change could ever reach an +# existing install. The rule file was immune only because it uses the frontmatter form. +# Also drops the measured-candidates block: it is written by install AFTER the +# template lands, so comparing it against the template would mark every annotated +# .sembleignore user_modified and freeze the file - the same failure the stamp +# line caused before it was stripped. +sg_strip_metaline() { + SG_F="$1" node -e ' +const fs=require("fs"); +const B="# --- brewcode:semble measured candidates ---"; +const E="# --- end brewcode:semble measured candidates ---"; +const L=fs.readFileSync(process.env.SG_F,"utf8").split("\n").filter(l=>!/^#\s*brewcode-meta:/.test(l)); +const b=L.indexOf(B), e=L.indexOf(E); +const keep=(b>=0&&e>b)?L.slice(0,b).concat(L.slice(e+1)):L; +while(keep.length&&keep[keep.length-1].trim()==="") keep.pop(); +process.stdout.write(keep.join("\n")+"\n");' +} + +# $1 rendered/template, $2 destination, $3 stamp form (`meta` = YAML frontmatter, +# `metaline` = `# brewcode-meta:` comment). A failed or empty strip is "different", never +# "same": this verdict decides whether a user's file gets overwritten or deleted without +# a backup. +sg_same_content() { + local a b strip=sg_strip_meta + [ "${3:-meta}" != "metaline" ] || strip=sg_strip_metaline + [ -f "$2" ] || return 1 + a="$("$strip" "$1")" || return 1 + b="$("$strip" "$2")" || return 1 + [ -n "$a" ] && [ "$a" = "$b" ] +} + +install_managed() { + local L="$1" T="$2" D="$3" S="${4:-copy}" + [ -f "$T" ] || { add_failed "$L: template missing at $T"; return 0; } + local tmp="$D.rendered.$$" + mkdir -p "$(dirname "$D")" + cp "$T" "$tmp" || { rm -f "$tmp"; add_failed "$L: cannot render $T"; return 0; } + + if [ ! -f "$D" ]; then + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then rm -f "$tmp"; sc_dry "write $D" >/dev/null; add_changed "$L: would create $D"; return 0; fi + mv "$tmp" "$D" && add_changed "$L: created $D" || { rm -f "$tmp"; add_failed "$L: cannot write $D"; } return 0 fi - if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "backup+rm $RULE" >/dev/null; add_changed "rule: would back up and remove $RULE"; return 0; fi - local b; b="$(sc_backup "$RULE")" - rm -f "$RULE" && add_changed "rule: removed user-modified $RULE (backup $b)" || add_failed "rule: cannot remove $RULE" + if cmp -s "$tmp" "$D"; then rm -f "$tmp"; add_unchanged "$L: up to date $D"; return 0; fi + if [ "$S" != "copy" ] && sg_same_content "$tmp" "$D" "$S"; then + # Same prose, stale stamp: a re-sync, not an overwrite. No --force and no backup needed. + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then rm -f "$tmp"; sc_dry "re-sync $D" >/dev/null; add_changed "$L: would re-sync $D"; return 0; fi + mv "$tmp" "$D" && add_changed "$L: re-synced $D (metadata only)" || { rm -f "$tmp"; add_failed "$L: cannot write $D"; } + return 0 + fi + if [ "$FORCE" != "1" ]; then + add_skipped "$L: user_modified, left as is (re-run with --force to overwrite; a backup is taken) $D" + diff -u "$D" "$tmp" >&2 || true + rm -f "$tmp" + return 0 + fi + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then rm -f "$tmp"; sc_dry "overwrite $D" >/dev/null; add_changed "$L: would overwrite $D"; return 0; fi + local b; b="$(sc_backup "$D")" + mv "$tmp" "$D" && add_changed "$L: overwrote $D (backup $b)" || { rm -f "$tmp"; add_failed "$L: cannot write $D"; } } +remove_managed() { + local L="$1" T="$2" D="$3" S="${4:-copy}" + [ -f "$D" ] || { add_unchanged "$L: already absent"; return 0; } + local managed=1 + if [ -f "$T" ]; then + if [ "$S" != "copy" ]; then sg_same_content "$T" "$D" "$S" && managed=0 + else cmp -s "$T" "$D" && managed=0; fi + fi + if [ "$managed" = "0" ]; then + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "rm $D" >/dev/null; add_changed "$L: would remove $D"; return 0; fi + rm -f "$D" && add_changed "$L: removed $D" || add_failed "$L: cannot remove $D" + return 0 + fi + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "backup+rm $D" >/dev/null; add_changed "$L: would back up and remove $D"; return 0; fi + local b; b="$(sc_backup "$D")" + rm -f "$D" && add_changed "$L: removed user-modified $D (backup $b)" || add_failed "$L: cannot remove $D" +} + +install_rule() { install_managed rule "$TPL" "$RULE" meta; } +remove_rule() { remove_managed rule "$TPL" "$RULE" meta; } +# ── measured per-repo candidates ──────────────────────────────────────────── +# The shipped template's per-repo section is empty by design and no static +# pattern can fill it, so install measures THIS repo and writes what it found +# into a delimited block - commented out, every line. Excluding something the +# user wanted indexed is the worse error: it fails silently and they would never +# learn the answer was unreachable. So the block proposes and never decides. +# +# The block is stripped by sg_strip_metaline on both sides of the managed-file +# comparison, so its presence never marks .sembleignore user_modified and a +# template update still reaches an installed file. +SG_CAND_BEGIN='# --- brewcode:semble measured candidates ---' +SG_CAND_END='# --- end brewcode:semble measured candidates ---' + +sg_sibling() { [ -x "$SC_DIR/$1" ] && printf '%s\n' "$SC_DIR/$1" || true; } + +sg_cand_block() { # existing block body (between the markers), empty if none + [ -f "$IGNOREFILE" ] || return 0 + SG_F="$IGNOREFILE" SG_B="$SG_CAND_BEGIN" SG_E="$SG_CAND_END" node -e ' +const fs=require("fs"); +const L=fs.readFileSync(process.env.SG_F,"utf8").split("\n"); +const b=L.indexOf(process.env.SG_B), e=L.indexOf(process.env.SG_E); +process.stdout.write(b<0||e/dev/null)" || { add_skipped "candidates: scan failed, $IGNOREFILE left as is"; return 0; } + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "annotate $IGNOREFILE with measured candidates" >/dev/null + add_changed "candidates: would refresh the measured block in $IGNOREFILE"; return 0; fi + local n + n="$(SG_F="$IGNOREFILE" SG_KEEP="$keep" SG_CAND="$cand" node -e ' +const fs=require("fs"); +const f=process.env.SG_F; +const B="# --- brewcode:semble measured candidates ---"; +const E="# --- end brewcode:semble measured candidates ---"; +const L=fs.readFileSync(f,"utf8").split("\n"); +const b=L.indexOf(B), e=L.indexOf(E); +const body=(b>=0&&e>b)?L.slice(0,b).concat(L.slice(e+1)):L; +const keep=(process.env.SG_KEEP||"").split("\n").filter(l=>l.length); +// The block we read back includes the header this function wrote last time. +// Carrying it over would stack a second copy on every run. +while(keep.length&&/^# (Measured in THIS repo|PROPOSALS ONLY|Uncomment what you agree|adds paths it has never)/.test(keep[0])) keep.shift(); +let j={candidates:[],source:"filesystem",scanned:0}; +try{ j=JSON.parse(process.env.SG_CAND); }catch(err){} +// A path already named anywhere in the file - by the user, or by an earlier run +// they then edited - is never proposed again. Their decisions outlive the scan. +const seen=new Set(); +for(const l of body.concat(keep)){ const t=l.replace(/^#+\s*/,"").trim().split(/\s+/)[0]; if(t) seen.add(t); } +const lines=keep.slice(); +let added=0; +for(const c of (j.candidates||[])){ + if(seen.has(c.path))continue; + lines.push("# "+c.path+" "+c.kind+" "+(Math.round(c.share*1000)/10)+"% "+c.reason); + added++; +} +if(!lines.length){ process.stdout.write("0"); process.exit(0); } // nothing measured, nothing written +const head=[B, + "# Measured in THIS repo by `semble-project.sh candidates` (" + +(j.source==="index"?"exact chunk counts":"byte share, no index yet")+", "+j.scanned+" files scanned).", + "# PROPOSALS ONLY - every line below is commented out and excludes nothing.", + "# Uncomment what you agree with; delete what you do not. A re-run only ever", + "# adds paths it has never proposed, so your edits here survive."]; +const outLines=body.slice(); +while(outLines.length&&outLines[outLines.length-1].trim()==="") outLines.pop(); +outLines.push("",...head,...lines,E,""); +fs.writeFileSync(f,outLines.join("\n")); +process.stdout.write(String(added));')" || { add_failed "candidates: cannot annotate $IGNOREFILE"; return 0; } + [ "$n" = "0" ] && return 0 + add_changed "candidates: $n measured proposal(s) written into $IGNOREFILE, commented out - nothing is excluded until you uncomment one" +} + +# .sembleignore is written in two halves and the second half undoes what the first +# one reports: install_managed compares the bare template against a file that +# install_candidates has already annotated, `cmp` fails, the metaline strip finds +# them equal, the re-sync branch fires - and then install_candidates re-appends a +# byte-identical block. Net zero bytes, yet every `upgrade` reported a change to +# .sembleignore, forever, which is exactly the idempotence a user checks after a +# release. `changed` means the bytes moved; the verdict is taken from the bytes. +install_ignore_apply() { + # Capture the block BEFORE the template is written through: a re-sync replaces + # the file wholesale, and the user's uncommented decisions live in that block. + SG_CAND_KEEP="$(sg_cand_block)" + # Byte snapshot around BOTH halves, same discipline as run_settings (§13). + local snap="" c0="$CHANGED" f0="$FAILED" same=0 + if [ -f "$IGNOREFILE" ]; then + snap="$(mktemp "${TMPDIR:-/tmp}/semble-ignore.XXXXXX")" + cp "$IGNOREFILE" "$snap" + fi + install_managed ignore "$IGNORE_TPL" "$IGNOREFILE" metaline + install_candidates + if [ -n "$snap" ]; then + if cmp -s "$snap" "$IGNOREFILE"; then same=1; fi + rm -f "$snap" + fi + # A failure in either half keeps its own report: only a clean net-zero run collapses. + if [ "$same" = "1" ] && [ "$FAILED" = "$f0" ] && [ "$CHANGED" != "$c0" ]; then + CHANGED="$c0" + add_unchanged "ignore: up to date $IGNOREFILE" + fi +} + +# The prediction is produced by RUNNING the real thing against a throwaway copy, +# for the same reason: a verdict drawn from install_managed's comparison alone +# announces a re-sync the same command then undoes. Everything written lands in a +# temp dir, and the candidates scan behind it is read-only. +install_ignore_dry() { + local d real c0 u0 s0 skipped=0 + real="$IGNOREFILE" + d="$(mktemp -d "${TMPDIR:-/tmp}/semble-ignore-dry.XXXXXX")" \ + || { add_failed "ignore: cannot create a scratch dir to simulate $real"; return 0; } + if [ -f "$real" ]; then cp "$real" "$d/.sembleignore"; fi + c0="$CHANGED"; u0="$UNCHANGED"; s0="$SKIPPED" + IGNOREFILE="$d/.sembleignore" + SEMBLE_DRY_RUN=0 install_ignore_apply + IGNOREFILE="$real" + if [ "$SKIPPED" != "$s0" ]; then skipped=1; fi + # The simulation's own lines are past tense and name the scratch path. Drop them + # and report the one outcome the bytes support; FAILED is left exactly as it came. + CHANGED="$c0"; UNCHANGED="$u0"; SKIPPED="$s0" + sc_dry "install $real" >/dev/null + if [ "$skipped" = "1" ]; then + add_skipped "ignore: user_modified, would be left as is (re-run with --force to overwrite; a backup is taken) $real" + elif [ ! -f "$real" ]; then + add_changed "ignore: would create $real" + elif cmp -s "$real" "$d/.sembleignore"; then + add_unchanged "ignore: up to date $real" + else + add_changed "ignore: would update $real" + fi + rm -rf "$d" +} + +install_ignore() { + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then install_ignore_dry; else install_ignore_apply; fi +} +remove_ignore() { remove_managed ignore "$IGNORE_TPL" "$IGNOREFILE" metaline; } + # ── CLAUDE.md marker block ────────────────────────────────────────────────── claudemd_node() { SG_CLAUDEMD="$CLAUDEMD" SG_OP="$1" node -e ' @@ -284,7 +632,7 @@ const BLOCK=[BEGIN, "> Semantic search first: ONE `mcp__semble_code__search` with `repo` = absolute project root,", "> `top_k=5`, `max_snippet_lines=10` — then open the hit at `start_line`.", "> `rg`/Grep stays for exact identifiers, regexes, paths and exhaustive enumeration.", -"> Not indexed: `.html`, `.json`/`.csv`. Details: `.claude/rules/semble-first.md`.", +"> Not indexed: `.json`/`.csv`, `.mdx`/`.txt`. Details: `.claude/rules/semble-first.md`.", END].join("\n"); const exists=fs.existsSync(f); let raw=""; @@ -337,8 +685,34 @@ install_hook_files() { for f in $HOOK_MJS; do [ -f "$SRC/$f" ] || { add_failed "hooks: asset missing at $SRC/$f"; return 0; } done - if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "cp hooks -> $HOOKS_DIR" >/dev/null; add_changed "hooks: would copy 3 files into $HOOKS_DIR"; return 0; fi + local n=0; for f in $HOOK_MJS; do n=$((n+1)); done + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then + sc_dry "cp hooks -> $HOOKS_DIR" >/dev/null + add_changed "hooks: would copy $n files into $HOOKS_DIR" + for f in $RETIRED_MJS; do + [ -e "$HOOKS_DIR/$f" ] && add_changed "hooks: would remove retired $HOOKS_DIR/$f" + done + for f in $RETIRED_MARKERS; do + [ -e "$ROOT/$f" ] && add_changed "hooks: would remove retired marker $ROOT/$f" + done + return 0 + fi mkdir -p "$HOOKS_DIR" + # Migration: delete the retired advisory hooks. An install that merely stops WRITING + # them leaves the file on disk next to a settings entry the merge is about to strip, + # and any hand-restored entry would resurrect a hook we measured at zero effect. + for f in $RETIRED_MJS; do + [ -e "$HOOKS_DIR/$f" ] || continue + rm -f "$HOOKS_DIR/$f" \ + && add_changed "hooks: removed retired $HOOKS_DIR/$f" \ + || add_failed "hooks: cannot remove retired $HOOKS_DIR/$f" + done + for f in $RETIRED_MARKERS; do + [ -e "$ROOT/$f" ] || continue + rm -f "$ROOT/$f" \ + && add_changed "hooks: removed retired marker $ROOT/$f" \ + || add_failed "hooks: cannot remove retired marker $ROOT/$f" + done for f in $HOOK_MJS; do if [ -f "$HOOKS_DIR/$f" ] && cmp -s "$SRC/$f" "$HOOKS_DIR/$f"; then add_unchanged "hooks: $f already current" @@ -354,7 +728,7 @@ install_hook_files() { remove_hook_files() { local f - for f in $HOOK_MJS; do + for f in $ALL_MJS; do # retired files included: uninstall must leave nothing behind if [ -e "$HOOKS_DIR/$f" ]; then if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "rm $HOOKS_DIR/$f" >/dev/null; add_changed "hooks: would remove $HOOKS_DIR/$f"; continue; fi rm -f "$HOOKS_DIR/$f" && add_changed "hooks: removed $HOOKS_DIR/$f" || add_failed "hooks: cannot remove $HOOKS_DIR/$f" @@ -362,17 +736,24 @@ remove_hook_files() { add_unchanged "hooks: $f already absent" fi done + # The throttle markers are ours too, live and retired alike: removal drops their + # .gitignore line, so a marker left behind surfaces as an untracked file. + for f in .claude/semble/.prefetch-ts $RETIRED_MARKERS; do + [ -e "$ROOT/$f" ] || continue + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "rm $ROOT/$f" >/dev/null; add_changed "hooks: would remove marker $ROOT/$f"; continue; fi + rm -f "$ROOT/$f" && add_changed "hooks: removed marker $ROOT/$f" || add_failed "hooks: cannot remove marker $ROOT/$f" + done } # ── settings.json merge (canonical, see assets/INSTALL.md §merge) ─────────── merge_settings() { SG_SETTINGS="$SETTINGS" SG_HOOKS="$HOOKS_DIR" SG_DO_HOOKS="$1" SG_DO_PERMS="$2" \ SG_SEARCH="$SEMBLE_TOOL_SEARCH" SG_RELATED="$SEMBLE_TOOL_RELATED" \ - SG_WANT="$SG_WANT_TABLE" node -e ' + SG_WANT="$SG_WANT_TABLE" SG_MARKS="$SG_MARKS" node -e ' const fs=require("fs"), path=require("path"); const f=process.env.SG_SETTINGS, dir=process.env.SG_HOOKS; const doHooks=process.env.SG_DO_HOOKS==="1", doPerms=process.env.SG_DO_PERMS==="1"; -const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"]; +const marks=JSON.parse(process.env.SG_MARKS); // every basename ever owned, live + retired const want=JSON.parse(process.env.SG_WANT); const tools=[process.env.SG_SEARCH,process.env.SG_RELATED]; let s={}; @@ -387,7 +768,16 @@ if(fs.existsSync(f)){ const argsOf=e=>((e&&e.hooks)||[]).flatMap(h=>(h&&h.args)||[]).filter(a=>typeof a==="string"); const matcherOf=e=>(e&&typeof e.matcher==="string")?e.matcher:null; const isMine=a=>marks.some(m=>a===m||a.endsWith("/"+m)||a.endsWith("\\"+m)); -const wanted=new Set(marks.map(m=>path.join(dir,m))); +// MIGRATION, and the whole reason `live` exists separately from `marks`. `wanted` used to +// be built from `marks`, which meant every basename this skill had ever owned survived the +// purge below. It is now the want table itself, as (event, matcher, path) TRIPLES, which +// buys two things at once: a RETIRED script at the current hooks dir is stripped like a +// stale-path one (the v1 PreToolUse Bash|Grep reminder rows and the SubagentStart Explore +// row), and so is a retired REGISTRATION of a LIVE script — semble-stats.mjs wired on the +// pre-5.0.0 PostToolUse matcher would otherwise survive beside its replacement and fire +// the hook twice on every Bash call, silently doubling the telemetry denominator. +const wkey=(ev,m,a)=>JSON.stringify([ev,m,a]); +const wanted=new Set(want.map(w=>wkey(w[0],w[1],path.join(dir,w[2])))); const desiredHook=(full,timeout)=>({type:"command",command:"node",args:[full],timeout}); const hasArg=(h,full)=>((h&&h.args)||[]).filter(a=>typeof a==="string").includes(full); const deq=(a,b)=>{ // key-order-insensitive deep equal @@ -431,14 +821,21 @@ if(doHooks){ if(!Array.isArray(s.hooks[ev])) continue; s.hooks[ev]=s.hooks[ev].map(e=>{ // filter inside hooks[]: a hand-merged entry if(!e||!Array.isArray(e.hooks)) return e; // may hold a foreign hook next to a stale one + const m=matcherOf(e); const kept=e.hooks.filter(h=>{ const mine=((h&&h.args)||[]).filter(a=>typeof a==="string").filter(isMine); - return mine.length===0 || mine.every(a=>wanted.has(a)); + return mine.length===0 || mine.every(a=>wanted.has(wkey(ev,m,a))); }); if(kept.length===e.hooks.length) return e; return kept.length ? Object.assign({},e,{hooks:kept}) : null; // entry dies only when empty }).filter(e=>e!==null); } + // An event emptied by the purge is an event we retired (PreToolUse, SubagentStart on a + // v1 install). Leave no `"PreToolUse": []` husk behind; events in the want table are + // repopulated immediately below and are never dropped. + const wantEvents=new Set(want.map(w=>w[0])); + for(const ev of Object.keys(s.hooks)) + if(Array.isArray(s.hooks[ev])&&s.hooks[ev].length===0&&!wantEvents.has(ev)) delete s.hooks[ev]; for(const [ev,matcher,script,timeout] of want){ // reconcile, do not merely append s.hooks[ev]=Array.isArray(s.hooks[ev])?s.hooks[ev]:[]; const full=path.join(dir,script); @@ -473,11 +870,11 @@ console.log("OK merged "+f); unmerge_settings() { SG_SETTINGS="$SETTINGS" SG_DO_HOOKS="$1" SG_DO_PERMS="$2" \ - SG_SEARCH="$SEMBLE_TOOL_SEARCH" SG_RELATED="$SEMBLE_TOOL_RELATED" node -e ' + SG_SEARCH="$SEMBLE_TOOL_SEARCH" SG_RELATED="$SEMBLE_TOOL_RELATED" SG_MARKS="$SG_MARKS" node -e ' const fs=require("fs"); const f=process.env.SG_SETTINGS; const doHooks=process.env.SG_DO_HOOKS==="1", doPerms=process.env.SG_DO_PERMS==="1"; -const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"]; +const marks=JSON.parse(process.env.SG_MARKS); // retired basenames included: uninstall must clean them too const tools=[process.env.SG_SEARCH,process.env.SG_RELATED]; if(!fs.existsSync(f)){ console.log("no settings to clean: "+f); process.exit(0); } const raw=fs.readFileSync(f,"utf8"); @@ -548,8 +945,50 @@ run_settings() { # Outcome is VERIFIED by re-reading the file, never assumed from the exit status of # the write. When there is no .gitignore the line is created only inside a git repo; # outside one there is nothing to ignore, and that is reported as skipped, not "ok". -GI_LINE='.claude/semble/.reminder-ts' -gitignore_has_line() { grep -Fq "$GI_LINE" "$GITIGNORE" 2>/dev/null; } +GI_LINE='.claude/semble/.prefetch-ts' +# Retired with semble-reminder.mjs. Stripped by install AND remove so a migrated repo does +# not keep a .gitignore line for a marker file nothing writes any more. +GI_RETIRED='.claude/semble/.reminder-ts' +gitignore_has_line() { grep -Fqx "$GI_LINE" "$GITIGNORE" 2>/dev/null; } +gitignore_has_retired() { grep -Fqx "$GI_RETIRED" "$GITIGNORE" 2>/dev/null; } + +# Drops every line in $1 (space-separated) plus the `# brewcode:semble` header that +# immediately precedes one of them. +gitignore_drop() { + SG_GI="$GITIGNORE" SG_DROP="$1" node -e ' +const fs=require("fs"); const f=process.env.SG_GI; +const drop=new Set(process.env.SG_DROP.split(" ").filter(Boolean)); +const lines=fs.readFileSync(f,"utf8").split("\n"); +const out=[]; +for(let i=0;i/dev/null; add_changed "gitignore: would drop retired $GI_RETIRED"; return 0; fi + gitignore_drop "$GI_RETIRED" || { add_failed "gitignore: cannot rewrite $GITIGNORE"; return 0; } + if gitignore_has_retired; then add_failed "gitignore: $GI_RETIRED is still in $GITIGNORE" + else add_changed "gitignore: dropped retired $GI_RETIRED from $GITIGNORE"; fi +} gitignore_confirm() { # $1 = past-tense verb for the report if gitignore_has_line; then add_changed "gitignore: $1 $GI_LINE in $GITIGNORE" else add_failed "gitignore: wrote $GITIGNORE but $GI_LINE is not in it"; fi @@ -570,29 +1009,18 @@ install_gitignore() { add_unchanged "gitignore: already lists $GI_LINE"; return 0 fi if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "append to $GITIGNORE" >/dev/null; add_changed "gitignore: would append $GI_LINE"; return 0; fi - printf '\n# brewcode:semble\n%s\n' "$GI_LINE" >>"$GITIGNORE" || { add_failed "gitignore: cannot append to $GITIGNORE"; return 0; } + gitignore_append || { add_failed "gitignore: cannot append to $GITIGNORE"; return 0; } gitignore_confirm "appended" } remove_gitignore() { [ -f "$GITIGNORE" ] || { add_unchanged "gitignore: none"; return 0; } - if ! gitignore_has_line; then + if ! gitignore_has_line && ! gitignore_has_retired; then add_unchanged "gitignore: nothing to clean"; return 0 fi - if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "clean $GITIGNORE" >/dev/null; add_changed "gitignore: would drop .claude/semble/.reminder-ts"; return 0; fi - SG_GI="$GITIGNORE" node -e ' -const fs=require("fs"); const f=process.env.SG_GI; -const raw=fs.readFileSync(f,"utf8"); -const lines=raw.split("\n"); -const out=[]; -for(let i=0;i/dev/null; add_changed "gitignore: would drop $GI_LINE"; return 0; fi + gitignore_drop "$GI_LINE $GI_RETIRED" || { add_failed "gitignore: cannot rewrite $GITIGNORE"; return 0; } + if gitignore_has_line || gitignore_has_retired; then add_failed "gitignore: rewrote $GITIGNORE but a semble marker line is still in it" else add_changed "gitignore: dropped $GI_LINE from $GITIGNORE"; fi } @@ -605,9 +1033,11 @@ case "$MODE" in install) want_part rule && install_rule + want_part ignore && install_ignore want_part claudemd && do_claudemd install if want_part hooks; then install_hook_files + gitignore_migrate install_gitignore fi if want_part hooks && want_part permissions; then @@ -622,6 +1052,7 @@ case "$MODE" in remove) want_part rule && remove_rule + want_part ignore && remove_ignore want_part claudemd && do_claudemd remove if want_part hooks && want_part permissions; then run_settings unmerge 1 1 "hooks+permissions" diff --git a/brewcode/skills/semble-setup/scripts/semble-install.sh b/brewcode/skills/semble-setup/scripts/semble-install.sh index e78532c..7b6dfb2 100755 --- a/brewcode/skills/semble-setup/scripts/semble-install.sh +++ b/brewcode/skills/semble-setup/scripts/semble-install.sh @@ -12,13 +12,18 @@ # and never changes `all`'s exit code. Skipped when a timeout binary # already exists. Needs --yes when invoked directly. # semble primes the pinned uvx environment with -# `uvx --from 'semble[mcp]==0.5.2' semble --help`. -# --tool additionally runs `uv tool install 'semble[mcp]==0.5.2'`. +# `uvx --from 'semble[mcp]==0.5.4' semble --version`. +# --tool additionally runs `uv tool install 'semble[mcp]==0.5.4'`. # all check -> uv -> coreutils -> semble. # # Default mode is uvx-ephemeral: no `uv tool install`, so no `semble` lands on # PATH (a bare `semble` invocation starts a blocking MCP server). -# `--help` is the only safe semble probe: there is no --version/status/serve. +# Safe probes are exactly those argv in semble's CLI dispatch set. `--version` +# joined it in 0.5.4 (cli.py:25) and is preferred: same cost as `--help` but it +# prints the resolved X.Y.Z, so the prime run also proves WHICH build uvx served. +# The argv is picked from the pin by sc_semble_probe_arg, not tried and fallen +# back from: on an older SEMBLE_PIN_VERSION override `--version` is unrecognised +# argv and would start the blocking server, so those pins get `--help` instead. # # Exit: 0 ok | 1 failed | 2 bad usage | 3 precondition | 4 confirmation needed set -euo pipefail @@ -39,7 +44,7 @@ semble-install.sh [--yes] [--json] [--tool] --yes confirm the mutating steps --json emit a single JSON object (schema: DESIGN 9.2) - --tool also `uv tool install 'semble[mcp]==0.5.2'` (requires --yes) + --tool also `uv tool install 'semble[mcp]==0.5.4'` (requires --yes) -h/--help Honours SEMBLE_DRY_RUN=1 (print DRY , change nothing) and @@ -216,7 +221,8 @@ do_semble() { before_tool="$(sc_semble_tool_version)" if [ -n "$before_tool" ]; then SEMBLE_TOOL_INSTALLED="true"; fi - record "uvx --from '$SEMBLE_PIN_SPEC' semble --help" + local probe_arg; probe_arg="$(sc_semble_probe_arg)" + record "uvx --from '$SEMBLE_PIN_SPEC' semble $probe_arg" if [ "$TOOL" = "1" ]; then record "uv tool install '$SEMBLE_PIN_SPEC'"; fi if [ -z "$(probe_uvx)" ]; then @@ -228,7 +234,7 @@ do_semble() { return 0 fi if [ "$DRY" = "1" ]; then - sc_dry "uvx --from '$SEMBLE_PIN_SPEC' semble --help" >&2 + sc_dry "uvx --from '$SEMBLE_PIN_SPEC' semble $probe_arg" >&2 if [ "$TOOL" = "1" ]; then sc_dry "uv tool install '$SEMBLE_PIN_SPEC'" >&2; fi return 0 fi @@ -236,10 +242,31 @@ do_semble() { raise precondition "SEMBLE_NO_NETWORK=1 - the pin was not resolved" return 0 fi - if uvx --from "$SEMBLE_PIN_SPEC" semble --help >/dev/null 2>&1; then + # Deliberately UNBOUNDED, unlike sc_semble_probe: this is the priming run, and + # on a cold uv cache it downloads 52 packages (semble-grammars alone is 6.9 MiB). + # A 60 s bound would turn a slow but healthy first install into a hard failure. + # Safe to leave unbounded only because probe_arg is dispatch-set argv for THIS + # pin — see sc_semble_probe_arg; unrecognised argv would block forever here. + local got="" + if got="$(uvx --from "$SEMBLE_PIN_SPEC" semble "$probe_arg" 2>/dev/null)"; then SEMBLE_RESOLVABLE="true" + # Free extra check the old `--help` probe could not make: the prime run now + # reports which build uvx actually served. + if [ "$probe_arg" = "--version" ] && [ "$got" != "$SEMBLE_PIN_VERSION" ]; then + sc_warn "uvx resolved '$SEMBLE_PIN_SPEC' but semble reports '$got', not $SEMBLE_PIN_VERSION" >&2 + fi else - raise failed "uvx could not resolve $SEMBLE_PIN_SPEC" + # semble 0.5.4 depends on semble-grammars, which ships WHEELS ONLY: macOS + # x86_64/arm64, manylinux2014 x86_64/aarch64, win amd64/arm64. No sdist and + # no musllinux wheel, so on Alpine there is nothing to install and nothing to + # build. Naming it here saves the user a hunt through a pip resolver dump. + local musl="" f + for f in /lib/ld-musl-*.so.1; do [ -e "$f" ] && musl=1 || true; done + if [ -n "$musl" ]; then + raise failed "uvx could not resolve $SEMBLE_PIN_SPEC - this host is musl (Alpine), and semble >= 0.5.3 depends on semble-grammars, which publishes no musllinux wheel and no sdist. Use a glibc image." + else + raise failed "uvx could not resolve $SEMBLE_PIN_SPEC" + fi return 0 fi if [ "$TOOL" = "1" ]; then diff --git a/brewcode/skills/semble-setup/scripts/semble-mcp.sh b/brewcode/skills/semble-setup/scripts/semble-mcp.sh index 007ce95..4142607 100755 --- a/brewcode/skills/semble-setup/scripts/semble-mcp.sh +++ b/brewcode/skills/semble-setup/scripts/semble-mcp.sh @@ -141,6 +141,31 @@ mcp_checkpoint() { # phase -> awaiting_reload, ALWAYS before an add (DESIGN §9. "$STATE_SH" phase awaiting_reload >/dev/null } +# The MCP server is USER-scoped, so on the second project of the same machine +# `add` finds it already registered and returns without ever creating that +# project's state.json - and the state file is born only inside an MCP mutation. +# SKILL.md prescribed the checkpoint as a follow-up block the model had to +# remember to run; a skipped block left the project with no state file at all. +# The script guarantees it instead. +# +# Absent, prereq_ready or error -> the honest awaiting_reload checkpoint: the +# server exists but this session still cannot see it. verifying, ready and +# disabled -> an IDENTITY transition, which runs st_refresh_owned (cacheRoot, +# repoHash, resumePrompt) without walking a verified project backwards out of +# `ready` or silently re-enabling one the user disabled. +mcp_checkpoint_present() { + local p + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then + [ "$JSON" = "1" ] || sc_dry "state checkpoint: existing registration, refresh installer-owned fields" + return 0 + fi + p="$(sc_state_get phase 2>/dev/null || true)" + case "$p" in + verifying|ready|disabled) "$STATE_SH" phase "$p" >/dev/null || true ;; + *) mcp_checkpoint ;; + esac +} + # Backs up every file an MCP mutation can rewrite (DESIGN §9.3). `.mcp.json` is # backed up whenever it exists, NOT when $SCOPE = project: `repair` forces # SCOPE=user yet removes every scope in `mcp_scopes`, so keying the backup off @@ -247,7 +272,10 @@ process.stdout.write(JSON.stringify({schema:1,add:process.env.SC_A,addJson:proce if [ "$PRESENT" = "yes" ]; then case "$STATE" in correct|upstream_unpinned) - emit_result unchanged "$STATE" "$SEMBLE_SERVER_NAME is already registered as approved" + # `unchanged` is about the REGISTRATION. The per-project checkpoint is + # written here all the same - see mcp_checkpoint_present. + mcp_checkpoint_present + emit_result unchanged "$STATE" "$SEMBLE_SERVER_NAME is already registered as approved; project state checkpoint at phase=$(sc_state_get phase 2>/dev/null || printf 'absent')" exit 0 ;; *) emit_result precondition "$STATE" "registration exists but differs ($STATE); run: semble-mcp.sh repair --yes" diff --git a/brewcode/skills/semble-setup/scripts/semble-project.sh b/brewcode/skills/semble-setup/scripts/semble-project.sh index 0d14a06..86d1358 100755 --- a/brewcode/skills/semble-setup/scripts/semble-project.sh +++ b/brewcode/skills/semble-setup/scripts/semble-project.sh @@ -12,7 +12,7 @@ SP_SEARCH_TIMEOUT="${SP_SEARCH_TIMEOUT:-600}" # Never-walked directory names (semble/index/file_walker.py:14-33). SP_SKIP_DIRS=".git .hg .svn __pycache__ node_modules .venv venv .tox .mypy_cache .pytest_cache .ruff_cache .cache .semble .next dist build .eggs" -# Suffix -> bucket tables, generated from semble 0.5.2 +# Suffix -> bucket tables, generated from semble 0.5.4 # src/semble/index/files.py (_EXTENSION_TO_LANGUAGE minus _DOC/_CONFIG/_DATA_LANGUAGES). # Classification is by lowercased LAST suffix only; extensionless files never match. SP_EXT_CODE=".4th .ada .adb .ads .agda .al .as .asm .astro .awk .axi .axs .bash .bat .bb .bbappend .bbclass .bicep .blade .bq .brs .bsl .bzl .c .c3 .c3i .c3t .caddyfile .cairo .cbl .cc .cedar .cel .cfc .chatito .circom .cjs .ck .cl .clar .clj .cljc .cljs .cls .cmake .cmd .cob .cobol .conf .corn .cpp .cr .cs .cshtml .css .cst .cts .cu .cuda .cue .cxx .cylc .d .dart .dhall .dl .dockerfile .dot .dsp .eds .eex .el .elm .elv .enforce .eps .erb .erl .ex .exs .f .f03 .f08 .f90 .f95 .fc .fidl .filter .fir .fish .fnl .fs .fsd .fsi .fsx .fth .fun .g .gd .gdshader .gi .gleam .glsl .gn .gni .gnuplot .go .gotmpl .gp .gql .gradle .graphql .gren .groovy .gv .h .hack .hare .hbs .hcl .heex .hlsl .hoon .hpp .hrl .hs .http .hurl .hx .hxx .idr .inc .ino .ispc .j2 .jai .janet .java .jinja2 .jl .jq .js .jsonnet .jsx .just .k .kt .kts .lc .lds .lean .leex .less .libsonnet .liquid .lisp .ll .lua .luau .m .magik .makefile .matlab .meson .mjs .mk .ml .mli .mlir .mll .mojo .move .mts .nasm .ncl .nginx .nim .nims .ninja .nix .nqc .nu .nut .odin .p .pas .php .pkl .pl .plt .pm .pony .pp .prisma .pro .promql .prql .ps .ps1 .psd1 .psm1 .pug .purs .py .pyi .pyw .ql .qml .r .rasi .razor .rb .rbs .re .rego .res .resi .rkt .robot .roc .rs .s .scad .scala .scm .scss .sh .shtml .sig .slang .smali .smk .sml .sol .sp .sparql .sql .squirrel .st .stan .star .sv .svelte .svh .sw .swift .tact .tal .tape .tcl .td .templ .tera .tf .tfvars .tl .tla .trigger .ts .tsconfig .tsx .twig .typoscript .typst .v .vb .verilog .vhd .vhdl .vim .vrl .vue .w .wast .wat .wgsl .wl .yuck .zig .ziggy .zsh" @@ -27,6 +27,7 @@ sp_usage() { semble-project.sh — project corpus audit, cache warm/smoke, enable/disable/reindex semble-project.sh audit [--json] + semble-project.sh candidates [--json] semble-project.sh warm [--query STR] [--json] semble-project.sh smoke [--query STR] [--json] semble-project.sh enable [--yes] [--json] @@ -175,7 +176,7 @@ sp_state_json() { sc_require_node SP_F="$(sc_state_file)" node -e ' const fs=require("fs");const f=process.env.SP_F; -const out={present:false,phase:"absent",enabled:null,completed:[],updatedAt:null}; +const out={present:false,phase:"absent",enabled:null,completed:[],last_updated:null}; if(fs.existsSync(f)){ out.present=true; const raw=fs.readFileSync(f,"utf8"); @@ -183,7 +184,7 @@ if(fs.existsSync(f)){ out.phase=s.phase||"absent"; out.enabled=(typeof s.enabled==="boolean")?s.enabled:null; out.completed=Array.isArray(s.completed)?s.completed:[]; - out.updatedAt=s.updatedAt||null; + out.last_updated=s.last_updated||null; }catch(e){out.phase="malformed";} } process.stdout.write(JSON.stringify(out));' @@ -393,6 +394,179 @@ console.log(process.env.SP_DISC);' sc_ok "audit complete" } +# ── candidates (§ per-repo .sembleignore proposals) ───────────────────────── +# The shipped .sembleignore ships its per-repo section EMPTY, and no static +# pattern can fill it: the two things that actually waste result slots are +# layout-specific. This measures them in THIS repo. +# +# duplicate-tree - a directory whose files are, near enough all of them, +# byte-identical copies of files living somewhere else. +# Semble does not dedup, so N copies means N chances to +# spend one of five result slots on the same text. +# heavy-dir/file - a path carrying a disproportionate share of the corpus. +# Exact chunk counts when an index exists (read straight out +# of chunks.json), byte share as the fallback when it does not. +# +# Output is a PROPOSAL, never an exclusion: install writes it commented out. +# Excluding something the user wanted indexed is the worse error - it fails +# silently, and they would have no way to know the answer was never reachable. +sp_mode_candidates() { + local json="$1" root cachedir out + root="$(sc_project_root)" + cachedir="$(sc_repo_cache_dir "$root" 2>/dev/null || true)" + out="$(SP_ROOT="$root" SP_CHUNKS="${cachedir:+$cachedir/index/chunks.json}" \ + SP_C="$SP_EXT_CODE" SP_G="$SP_EXT_CONFIG" SP_D="$SP_EXT_DOCS" \ + SP_SKIPD="$SP_SKIP_DIRS" node -e ' +const fs=require("fs"),path=require("path"),crypto=require("crypto"); +const set=s=>new Set(String(s||"").split(" ").filter(Boolean)); +const docsExt=set(process.env.SP_D); +const ok=new Set([...set(process.env.SP_C),...set(process.env.SP_G),...docsExt]); +const skipDirs=set(process.env.SP_SKIPD); +const ROOT=process.env.SP_ROOT; +const files=[]; // {rel, size, hash} +function suffix(n){const i=n.lastIndexOf(".");return i<=0?"":n.slice(i).toLowerCase();} +function walk(dir,depth){ + let ents;try{ents=fs.readdirSync(dir,{withFileTypes:true});}catch(e){return;} + ents.sort((a,b)=>a.name1000000||st.size===0)continue; + let h=""; + try{ h=crypto.createHash("sha1").update(fs.readFileSync(p)).digest("hex"); }catch(e){ continue; } + files.push({rel:path.relative(ROOT,p),size:st.size,hash:h,docs:docsExt.has(suffix(en.name))}); + } +} +walk(ROOT,0); + +// Exact chunk counts when the index is on disk; byte share otherwise. Both are +// reported so the proposal always says which number it is standing on. +let chunks=null, source="filesystem"; +const cf=process.env.SP_CHUNKS; +if(cf&&fs.existsSync(cf)){ + try{ + const raw=JSON.parse(fs.readFileSync(cf,"utf8")); + const arr=Array.isArray(raw)?raw:(Array.isArray(raw&&raw.chunks)?raw.chunks:null); + if(arr){ chunks=new Map(); + for(const c of arr){ const k=c&&(c.file_path||c.path||c.file); if(!k)continue; + chunks.set(k,(chunks.get(k)||0)+1); } + source="index"; + } + }catch(e){ chunks=null; } +} +const weightOf=f=>chunks?(chunks.get(f.rel)||0):f.size; +const total=files.reduce((a,f)=>a+weightOf(f),0)||1; + +// ── A. duplicate trees ──────────────────────────────────────────────────── +const byHash=new Map(); +for(const f of files){ if(!byHash.has(f.hash))byHash.set(f.hash,[]); byHash.get(f.hash).push(f.rel); } +const under=(rel,d)=>rel===d||rel.startsWith(d+path.sep); +const dirs=new Map(); // dir -> {n, dup, weight, twins:Map} +for(const f of files){ + const parts=f.rel.split(path.sep); parts.pop(); + const group=byHash.get(f.hash); + for(let i=1;i<=parts.length;i++){ + const d=parts.slice(0,i).join(path.sep); + let e=dirs.get(d); if(!e){ e={n:0,dup:0,weight:0,twins:new Map()}; dirs.set(d,e); } + e.n++; e.weight+=weightOf(f); + const outside=group.filter(r=>!under(r,d)); + if(outside.length){ e.dup++; + for(const o of outside){ const td=path.dirname(o).split(path.sep)[0]||"."; e.twins.set(td,(e.twins.get(td)||0)+1); } } + } +} +// The 1% floor keeps the proposal list worth reading: a duplicate tree that +// costs nothing (often it is already excluded, so its chunk weight is zero) is +// noise, and noise in a proposal list is how the list stops being read. +const dupCand=[]; +for(const [d,e] of dirs){ if(e.n>=5&&e.dup/e.n>=0.9&&e.weight/total>=0.01) dupCand.push({dir:d,...e}); } +dupCand.sort((a,b)=>a.dir.length-b.dir.length); +const kept=[]; +for(const c of dupCand) if(!kept.some(k=>under(c.dir,k.dir))) kept.push(c); +// A mirror pair qualifies from both ends. Propose the copy, not the original: +// hidden directory first (a mirror for another runtime is nearly always dotted), +// then the shallower-weighted side, then the later path so the choice is stable. +const isHidden=d=>d.split(path.sep).some(s=>s.startsWith(".")); +const dropped=new Set(); +for(const a of kept) for(const b of kept){ + if(a===b||dropped.has(a.dir)||dropped.has(b.dir))continue; + if(!(a.twins.get(b.dir.split(path.sep)[0])>0&&b.twins.get(a.dir.split(path.sep)[0])>0))continue; + const loser=(isHidden(a.dir)!==isHidden(b.dir))?(isHidden(a.dir)?b:a) + :(a.weight!==b.weight?(a.weight>b.weight?b:a):(a.diry[1]-x[1])[0]; + out.push({path:"/"+c.dir+"/",base:c.dir,kind:"duplicate-tree",files:c.n,duplicates:c.dup, + weight:c.weight,share:+(c.weight/total).toFixed(4), + reason:c.dup+" of "+c.n+" files are byte-identical copies of files under "+(twin?twin[0]:"another path")}); +} + +// ── B. heavy directories and files ──────────────────────────────────────── +const covered=r=>out.some(o=>under(r,o.base)); +const tops=new Map(); +for(const f of files){ const d=f.rel.split(path.sep)[0]; + if(d===f.rel)continue; tops.set(d,(tops.get(d)||0)+weightOf(f)); } +// Only ever propose a PROSE-dominated directory. A source directory carrying +// 40% of the corpus is the repository, not noise, and proposing to exclude it +// would be wrong in every repo that has one - which is all of them. +const proseShare=d=>{ const f=files.filter(x=>under(x.rel,d)); return f.length?f.filter(x=>x.docs).length/f.length:0; }; +for(let [d,w] of tops){ + if(w/total<0.15||covered(d)||proseShare(d)<0.8)continue; + // Descend while one child still holds the overwhelming majority: naming + // `data/slack/` beats naming `data/` when the rest of data/ is wanted. + for(;;){ + const kids=new Map(); + for(const f of files){ if(!under(f.rel,d))continue; + const rest=f.rel.slice(d.length+1).split(path.sep); + if(rest.length<2)continue; + kids.set(d+path.sep+rest[0],(kids.get(d+path.sep+rest[0])||0)+weightOf(f)); } + const best=[...kids.entries()].sort((a,b)=>b[1]-a[1])[0]; + if(!best||best[1]/w<0.8)break; + d=best[0]; w=best[1]; + } + if(covered(d)||proseShare(d)<0.8)continue; + // Name the biggest single child too. The whole directory is rarely the right + // exclusion; one subtree inside it usually is, and the user can only pick the + // narrower path if the measurement hands it to them. + const sub=new Map(); + for(const f of files){ if(!under(f.rel,d))continue; + const rest=f.rel.slice(d.length+1).split(path.sep); + if(rest.length<2)continue; + sub.set(d+path.sep+rest[0],(sub.get(d+path.sep+rest[0])||0)+weightOf(f)); } + const big=[...sub.entries()].sort((a,b)=>b[1]-a[1])[0]; + const unit=source==="index"?"chunks":"bytes"; + out.push({path:"/"+d+"/",base:d,kind:"heavy-dir",files:files.filter(f=>under(f.rel,d)).length, + weight:w,share:+(w/total).toFixed(4), + reason:Math.round(w/total*100)+"% of the corpus "+unit+" sits under this one directory" + +(big&&big[1]/w>=0.33?"; most of it is /"+big[0]+"/ at "+Math.round(big[1]/total*100)+"%":"")}); +} +for(const f of files){ + const w=weightOf(f); + if(w/total<0.03||!f.docs||covered(f.rel))continue; + out.push({path:"/"+f.rel,base:f.rel,kind:"heavy-file",files:1,weight:w,share:+(w/total).toFixed(4), + reason:"one file is "+Math.round(w/total*100)+"% of the corpus "+(source==="index"?"chunks":"bytes")}); +} +out.sort((a,b)=>b.weight-a.weight); +process.stdout.write(JSON.stringify({schema:1,mode:"candidates",projectRoot:ROOT, + source,scanned:files.length,total,candidates:out,status:"ok"},null,2)+"\n");')" || { + sc_err "candidates: scan failed"; return 1 + } + if [ "$json" = "1" ]; then printf '%s\n' "$out"; return 0; fi + SP_OUT="$out" node -e ' +const j=JSON.parse(process.env.SP_OUT); +console.log("candidates: "+j.candidates.length+" from "+j.scanned+" indexable files (" + +(j.source==="index"?"exact chunk counts":"byte share - no index on disk yet")+")"); +for(const c of j.candidates) console.log(" "+c.path+" "+c.kind+" "+Math.round(c.share*1000)/10+"% "+c.reason); +if(!j.candidates.length) console.log(" none - nothing in this repo is a duplicate tree or a corpus hog");' + sc_ok "candidates complete" +} + # Record `completed` steps only for a project that already has a state file. sp_complete() { if sp_require_state; then sc_state_patch "$1" >/dev/null || true; fi @@ -425,7 +599,7 @@ sp_mode_smoke() { res="$(sp_run_search "$query")" status="$(sp_search_field "$res" status)" if [ "$status" = "ok" ]; then - sp_complete "{\"completed\":[\"warm\",\"smoke\"],\"lastVerifiedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" + sp_complete "{\"completed\":[\"warm\",\"smoke\"],\"last_verified_at\":\"$(sc_today)\"}" fi if [ "$json" = "1" ]; then printf '%s\n' "$res" @@ -597,7 +771,7 @@ main() { mode="$1"; shift case "$mode" in -h|--help) sp_usage; return 0 ;; - audit|warm|smoke|enable|disable|reindex) ;; + audit|candidates|warm|smoke|enable|disable|reindex) ;; *) sc_err "unknown subcommand: $mode"; sp_usage; return 2 ;; esac while [ $# -gt 0 ]; do @@ -614,6 +788,7 @@ main() { SP_JSON="$json" case "$mode" in audit) sp_mode_audit "$json" ;; + candidates) sp_mode_candidates "$json" ;; warm) sp_mode_warm "$json" "$query" ;; smoke) sp_mode_smoke "$json" "$query" ;; enable) sp_mode_enable "$json" "$yes" ;; diff --git a/brewcode/skills/semble-setup/scripts/semble-remove.sh b/brewcode/skills/semble-setup/scripts/semble-remove.sh index d6a69f4..067229f 100755 --- a/brewcode/skills/semble-setup/scripts/semble-remove.sh +++ b/brewcode/skills/semble-setup/scripts/semble-remove.sh @@ -192,7 +192,12 @@ process.stdout.write(((j.changed||[]).length)+" "+((j.skipped||[]).length));')" return 0 fi sr_rm_file "$root/.claude/rules/semble-first.md" "rule" + sr_rm_file "$root/.sembleignore" "ignore" sr_rm_file "$root/.claude/hooks/semble-session.mjs" "hook" + sr_rm_file "$root/.claude/hooks/semble-prefetch.mjs" "hook" + sr_rm_file "$root/.claude/hooks/semble-stats.mjs" "hook" + # Retired in v5.0.0. Still removed here: a repo that never ran the migrating + # install/upgrade still has these on disk, and uninstall must leave nothing. sr_rm_file "$root/.claude/hooks/semble-reminder.mjs" "hook" sr_rm_file "$root/.claude/hooks/semble-explore.mjs" "hook" sr_strip_claudemd @@ -329,9 +334,12 @@ sr_plan() { case "$flavour" in integration) sr_would "$root/.claude/rules/semble-first.md" + sr_would "$root/.sembleignore" sr_would "$root/.claude/hooks/semble-session.mjs" - sr_would "$root/.claude/hooks/semble-reminder.mjs" - sr_would "$root/.claude/hooks/semble-explore.mjs" + sr_would "$root/.claude/hooks/semble-prefetch.mjs" + sr_would "$root/.claude/hooks/semble-stats.mjs" + sr_would "$root/.claude/hooks/semble-reminder.mjs (retired v5.0.0)" + sr_would "$root/.claude/hooks/semble-explore.mjs (retired v5.0.0)" sr_would "$root/CLAUDE.md marker block $SR_CLAUDEMD_BEGIN .. $SR_CLAUDEMD_END" sr_would "$root/.claude/semble/" ;; @@ -339,9 +347,12 @@ sr_plan() { cli) sr_would "uv tool install of semble ($(sc_semble_tool_version 2>/dev/null || true))" ;; purge) sr_would "$root/.claude/rules/semble-first.md" + sr_would "$root/.sembleignore" sr_would "$root/.claude/hooks/semble-session.mjs" - sr_would "$root/.claude/hooks/semble-reminder.mjs" - sr_would "$root/.claude/hooks/semble-explore.mjs" + sr_would "$root/.claude/hooks/semble-prefetch.mjs" + sr_would "$root/.claude/hooks/semble-stats.mjs" + sr_would "$root/.claude/hooks/semble-reminder.mjs (retired v5.0.0)" + sr_would "$root/.claude/hooks/semble-explore.mjs (retired v5.0.0)" sr_would "$root/CLAUDE.md marker block $SR_CLAUDEMD_BEGIN .. $SR_CLAUDEMD_END" sr_would "$root/.claude/semble/" sr_would "$(sc_cache_root_code) (ENTIRE code cache root — every repo index under it)" diff --git a/brewcode/skills/semble-setup/scripts/semble-state.sh b/brewcode/skills/semble-setup/scripts/semble-state.sh index 5b29032..a2b7753 100755 --- a/brewcode/skills/semble-setup/scripts/semble-state.sh +++ b/brewcode/skills/semble-setup/scripts/semble-state.sh @@ -111,8 +111,9 @@ ST_RESUME_PROMPT="/brewcode:semble-setup resume" # Fields the INSTALLER owns: derived from the environment and the shipped # constants, never from whatever an older run happened to leave behind. They are # recomputed on every write, not only at file birth — `approvedVersion`, -# `projectRoot` and `schema` already work that way inside sc_state_patch, and -# these three were the ones that did not. No-op when the file does not exist yet +# `projectRoot`, `schema` and the artifact-metadata trio `version` / +# `generated_by` / `last_updated` already work that way inside sc_state_patch, +# and these three were the ones that did not. No-op when the file does not exist yet # (creating it here would bypass the phase machine). # NEVER touched here: enabled, phase, completed, notes — user and progress # state. That is the whole distinction: a re-run refreshes what the installer @@ -193,13 +194,13 @@ case "$SUB" in if [ "$JSON" = "1" ]; then SC_F="$FILE" node -e ' const fs=require("fs");const f=process.env.SC_F; -const out={schema:1,present:false,file:f,phase:"absent",enabled:null,completed:[],updatedAt:null,state:null}; +const out={schema:1,present:false,file:f,phase:"absent",enabled:null,completed:[],last_updated:null,state:null}; if(fs.existsSync(f)){const raw=fs.readFileSync(f,"utf8"); if(raw.trim()){ let s;try{s=JSON.parse(raw)}catch(e){console.error("ABORT: "+f+" is not valid JSON ("+e.message+")");process.exit(1)} out.present=true; out.state=s; out.phase=s.phase||"absent"; out.enabled=(typeof s.enabled==="boolean")?s.enabled:null; out.completed=Array.isArray(s.completed)?s.completed:[]; - out.updatedAt=s.updatedAt||null; } } + out.last_updated=s.last_updated||null; } } process.stdout.write(JSON.stringify(out));' printf '\n' else diff --git a/brewcode/skills/semble-setup/scripts/semble-status.sh b/brewcode/skills/semble-setup/scripts/semble-status.sh index fe3ba1f..8e0839c 100755 --- a/brewcode/skills/semble-setup/scripts/semble-status.sh +++ b/brewcode/skills/semble-setup/scripts/semble-status.sh @@ -2,6 +2,7 @@ # semble-status.sh - read-only status/doctor for the brewcode:semble-setup skill. # # Usage: semble-status.sh [--json] [--section SECTION] [--strict] +# [--section telemetry [--sid ID] [--last N]] # # STRICTLY READ-ONLY. It creates, modifies and deletes nothing, anywhere - # not the state file, not the reminder throttle marker, not the cache dir. @@ -21,11 +22,16 @@ usage() { semble-status.sh [--json] [--section SECTION] [--strict] --json emit a single JSON object (schema: DESIGN 9.1) - --section SECTION prereq|mcp|cache|guidance|agents|coverage|state|all - (default: all) + --section SECTION prereq|mcp|cache|guidance|agents|coverage|state|telemetry|all + (default: all; `telemetry` is NOT part of `all`) --strict exit 1 when verdict != ready -h, --help this text + --section telemetry reads .claude/semble/telemetry.jsonl and reports what the + hooks actually did. Window flags, telemetry only: + --sid ID only records from session ID (default: every session) + --last N only the last N records (applied after --sid) + Exit: 0 report produced | 1 strict failure or internal error | 2 bad usage Read-only: nothing is ever written. EOF @@ -34,6 +40,8 @@ EOF JSON_MODE=0 SECTION="all" STRICT=0 +SID="" +LAST="" while [ $# -gt 0 ]; do case "$1" in @@ -41,6 +49,10 @@ while [ $# -gt 0 ]; do --strict) STRICT=1 ;; --section) shift; SECTION="${1:-}" ;; --section=*) SECTION="${1#--section=}" ;; + --sid) shift; SID="${1:-}" ;; + --sid=*) SID="${1#--sid=}" ;; + --last) shift; LAST="${1:-}" ;; + --last=*) LAST="${1#--last=}" ;; -h|--help) usage; exit 0 ;; *) sc_err "unknown argument: $1" >&2; usage >&2; exit 2 ;; esac @@ -48,14 +60,272 @@ while [ $# -gt 0 ]; do done case "$SECTION" in - prereq|mcp|cache|guidance|agents|coverage|state|all) ;; + prereq|mcp|cache|guidance|agents|coverage|state|telemetry|all) ;; *) sc_err "unknown section: ${SECTION:-(empty)}" >&2; usage >&2; exit 2 ;; esac +if [ "$SECTION" != "telemetry" ] && { [ -n "$SID" ] || [ -n "$LAST" ]; }; then + sc_err "--sid/--last are only valid with --section telemetry" >&2; usage >&2; exit 2 +fi +case "$LAST" in + ''|*[!0-9]*) [ -z "$LAST" ] || { sc_err "--last must be a non-negative integer: $LAST" >&2; exit 2; } ;; +esac sc_require_node want() { [ "$SECTION" = "all" ] || [ "$SECTION" = "$1" ]; } +# ── telemetry (--section telemetry): the reader for semble-stats.mjs ───────── +# Deliberately NOT part of `all`: it answers a different question (did the hooks +# do anything) from a different source (.claude/semble/telemetry.jsonl), and the +# `all` report is an install-health report. Read-only like everything here. +TELEMETRY_READER="$(cat <<'NODEJS' +"use strict"; +const fs = require("fs"); +const E = process.env; +const file = E.SC_TELEMETRY_FILE; +const jsonMode = E.SC_JSONMODE === "1"; +const wantSid = E.SC_SID || ""; +const last = E.SC_LAST ? Number(E.SC_LAST) : 0; + +function out(s) { fs.writeSync(1, s); } + +let raw = null; +try { raw = fs.readFileSync(file, "utf8"); } catch (e) { raw = null; } + +const rep = { + schema: 1, + file: file, + present: raw !== null, + window: { sid: wantSid || null, last: last || null }, + records: 0, + malformed: 0, + unknownEv: {}, + hooks: {}, + gate: { fired: 0, skipped: 0, why: {} }, + nudge: { total: 0, main: 0, sub: 0, unknown: 0 }, + call: { total: 0, main: 0, sub: 0, unknown: 0, failed: 0 }, + search: { total: 0, main: 0, sub: 0, unknown: 0 }, + open: { total: 0 }, + // The prefetch hook. `why` is the suppressing clause, so a low fire rate is + // always attributable; `hits` and `ms` are the cost side. + prefetch: { + fired: 0, suppressed: 0, why: {}, hits: 0, injections: 0, + msMedian: null, msMax: null, + }, + // Post-release conversion, computable from this JSONL alone: + // injectedSessions - sessions where prefetch injected at least one candidate + // openedSessions - of those, sessions where an injected path was later OPENED + // pathsOpened/pathsInjected - the same question per candidate path + prefetchConversion: { + injectedSessions: 0, openedSessions: 0, sessionPct: null, + pathsInjected: 0, pathsOpened: 0, pathPct: null, + }, + conversion: { + sessionsWithNudge: 0, sessionsConverted: 0, conversionPct: null, + callsAfterNudge: 0, callsWithoutNudge: 0, + }, +}; + +// A truncated final line (the process died mid-append) and a record from a +// future schema are both expected, not exceptional: count and move on. +let recs = []; +if (raw !== null) { + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let r; + try { r = JSON.parse(line); } catch (e) { rep.malformed++; continue; } + if (r === null || typeof r !== "object" || Array.isArray(r)) { rep.malformed++; continue; } + recs.push(r); + } +} +if (wantSid) recs = recs.filter(function (r) { return r.sid === wantSid; }); +if (last > 0 && recs.length > last) recs = recs.slice(-last); +rep.records = recs.length; + +function bump(o, k) { o[k] = (o[k] || 0) + 1; } +function agentOf(r) { + return (r.agent === "main" || r.agent === "sub") ? r.agent : "unknown"; +} + +// nudges/calls per session, for the conversion join +const nudgeFirst = {}; // sid -> earliest nudge ts +const nudgeTs = {}; // sid -> sorted-enough list of nudge ts +const callsBySid = {}; // sid -> [ts] +// prefetch conversion join: sid -> [{p, ts}] injected, sid -> [{f, abs, ts}] opened +const injected = {}; +const opened = {}; +const msSamples = []; + +for (const r of recs) { + const src = typeof r.src === "string" ? r.src : "unknown"; + bump(rep.hooks, src); + const sid = typeof r.sid === "string" ? r.sid : ""; + const ts = typeof r.ts === "string" ? r.ts : ""; + switch (r.ev) { + case "gate": { + if (r.fired === true) rep.gate.fired++; else rep.gate.skipped++; + bump(rep.gate.why, typeof r.why === "string" && r.why ? r.why : "unknown"); + break; + } + case "nudge": { + rep.nudge.total++; + rep.nudge[agentOf(r)]++; + if (!nudgeTs[sid]) nudgeTs[sid] = []; + nudgeTs[sid].push(ts); + if (nudgeFirst[sid] === undefined || ts < nudgeFirst[sid]) nudgeFirst[sid] = ts; + break; + } + case "call": { + rep.call.total++; + rep.call[agentOf(r)]++; + if (r.ok === false) rep.call.failed++; + if (!callsBySid[sid]) callsBySid[sid] = []; + callsBySid[sid].push(ts); + break; + } + case "search": { + rep.search.total++; + rep.search[agentOf(r)]++; + break; + } + case "prefetch": { + bump(rep.prefetch.why, typeof r.why === "string" && r.why ? r.why : "unknown"); + if (typeof r.ms === "number" && isFinite(r.ms) && r.ms >= 0) msSamples.push(r.ms); + if (r.fired !== true) { rep.prefetch.suppressed++; break; } + rep.prefetch.fired++; + const paths = Array.isArray(r.paths) ? r.paths.filter(function (p) { return typeof p === "string" && p; }) : []; + rep.prefetch.hits += (typeof r.n === "number" && isFinite(r.n)) ? r.n : paths.length; + if (paths.length) rep.prefetch.injections++; + if (!injected[sid]) injected[sid] = []; + for (const p of paths) injected[sid].push({ p: p, ts: ts }); + break; + } + case "open": { + rep.open.total++; + if (!opened[sid]) opened[sid] = []; + opened[sid].push({ + f: typeof r.f === "string" ? r.f : "", + abs: typeof r.abs === "string" ? r.abs : "", + ts: ts, + }); + break; + } + default: + bump(rep.unknownEv, String(r.ev)); + } +} + +// The metric that matters: a nudge only counts if a semble call came AFTER it, +// in the same session. Equal timestamps do not count - same-millisecond means +// the call cannot have been caused by the nudge. +const nudgedSids = Object.keys(nudgeFirst); +rep.conversion.sessionsWithNudge = nudgedSids.length; +rep.conversion.sessionsConverted = nudgedSids.filter(function (sid) { + return (callsBySid[sid] || []).some(function (t) { return t > nudgeFirst[sid]; }); +}).length; +if (nudgedSids.length) { + rep.conversion.conversionPct = + Math.round((rep.conversion.sessionsConverted / nudgedSids.length) * 1000) / 10; +} +for (const sid of Object.keys(callsBySid)) { + for (const t of callsBySid[sid]) { + const after = (nudgeTs[sid] || []).some(function (n) { return n <= t; }); + if (after) rep.conversion.callsAfterNudge++; + else rep.conversion.callsWithoutNudge++; + } +} + +// Prefetch conversion. An injected path converts when a LATER `open` in the SAME +// session names it. semble reports repo-relative paths and Claude Code reads with +// an absolute one, so a match is "either recorded form ends with the injected +// path" - the stats hook records both forms precisely so this stays a suffix test +// and never needs a cwd the reader does not have. +const hit = function (sid, rec) { + return (opened[sid] || []).some(function (o) { + if (!(o.ts > rec.ts)) return false; + const cand = [o.f, o.abs]; + return cand.some(function (c) { return c === rec.p || (c && c.endsWith("/" + rec.p)); }); + }); +}; +const injSids = Object.keys(injected); +rep.prefetchConversion.injectedSessions = injSids.length; +rep.prefetchConversion.openedSessions = injSids.filter(function (sid) { + return injected[sid].some(function (rec) { return hit(sid, rec); }); +}).length; +for (const sid of injSids) { + for (const rec of injected[sid]) { + rep.prefetchConversion.pathsInjected++; + if (hit(sid, rec)) rep.prefetchConversion.pathsOpened++; + } +} +const pc = rep.prefetchConversion; +if (pc.injectedSessions) pc.sessionPct = Math.round((pc.openedSessions / pc.injectedSessions) * 1000) / 10; +if (pc.pathsInjected) pc.pathPct = Math.round((pc.pathsOpened / pc.pathsInjected) * 1000) / 10; +if (msSamples.length) { + const s = msSamples.slice().sort(function (a, b) { return a - b; }); + rep.prefetch.msMedian = s[Math.floor((s.length - 1) / 2)]; + rep.prefetch.msMax = s[s.length - 1]; +} + +if (jsonMode) { out(JSON.stringify(rep) + "\n"); process.exit(0); } + +if (!rep.present) { + out("no telemetry yet - " + file + "\n" + + "(the stats hook writes on the first tool call after `semble-guidance.sh install`)\n"); + process.exit(0); +} +const L = []; +const pairs = function (o) { + const k = Object.keys(o).sort(); + return k.length ? k.map(function (x) { return x + "=" + o[x]; }).join(" ") : "-"; +}; +L.push("# Semble telemetry"); +L.push(""); +L.push("file: " + file); +L.push("window: " + (wantSid ? "sid=" + wantSid : "all sessions") + + (last > 0 ? " | last " + last : "") + + " | " + rep.records + " records" + + (rep.malformed ? " | " + rep.malformed + " malformed (skipped)" : "")); +L.push("hooks: " + pairs(rep.hooks)); +L.push("gate: " + rep.gate.fired + " fired / " + rep.gate.skipped + " skipped [" + pairs(rep.gate.why) + "]"); +L.push("prefetch: " + rep.prefetch.fired + " fired / " + rep.prefetch.suppressed + " suppressed" + + " | " + rep.prefetch.hits + " candidates" + + (rep.prefetch.msMedian === null ? "" : " | " + rep.prefetch.msMedian + " ms median, " + + rep.prefetch.msMax + " ms max") + + " [" + pairs(rep.prefetch.why) + "]"); +L.push("opened: " + pc.pathsOpened + "/" + pc.pathsInjected + " injected paths" + + (pc.pathPct === null ? "" : " (" + pc.pathPct + "%)") + + " | " + pc.openedSessions + "/" + pc.injectedSessions + " sessions" + + (pc.sessionPct === null ? "" : " (" + pc.sessionPct + "%)") + + " | " + rep.open.total + " Read calls seen"); +L.push("nudge: " + rep.nudge.total + " total (main " + rep.nudge.main + ", sub " + rep.nudge.sub + + (rep.nudge.unknown ? ", unknown " + rep.nudge.unknown : "") + ") [retired hooks]"); +L.push("call: " + rep.call.total + " semble calls (main " + rep.call.main + ", sub " + rep.call.sub + + (rep.call.unknown ? ", unknown " + rep.call.unknown : "") + ") | " + rep.call.failed + " failed"); +L.push("search: " + rep.search.total + " search-shaped non-semble (main " + rep.search.main + + ", sub " + rep.search.sub + (rep.search.unknown ? ", unknown " + rep.search.unknown : "") + ")"); +const c = rep.conversion; +L.push("converted: " + c.sessionsConverted + "/" + c.sessionsWithNudge + " nudged sessions" + + (c.conversionPct === null ? "" : " (" + c.conversionPct + "%)") + + " | " + c.callsAfterNudge + " calls after a nudge, " + c.callsWithoutNudge + " unprompted"); +const denom = rep.search.total + rep.call.total; +L.push("share: " + (denom ? Math.round((rep.call.total / denom) * 1000) / 10 : 0) + + "% of search-shaped tool use went through semble"); +if (Object.keys(rep.unknownEv).length) { + L.push("unknown: " + pairs(rep.unknownEv) + " (newer schema - skipped)"); +} +out(L.join("\n") + "\n"); +process.exit(0); +NODEJS +)" + +if [ "$SECTION" = "telemetry" ]; then + SC_TELEMETRY_FILE="$(sc_project_root)/.claude/semble/telemetry.jsonl" \ + SC_JSONMODE="$JSON_MODE" SC_SID="$SID" SC_LAST="$LAST" \ + node -e "$TELEMETRY_READER" + exit 0 +fi + # sibling REL ARGS... -> the sibling's JSON on stdout, or an {"error":...} object. # Never fails: an absent, crashing or silent sibling degrades to an error object. sibling() { @@ -175,10 +445,10 @@ const stateFile = E.SC_STATE_FILE || ""; const stRead = readJson(stateFile); let stateSec; if (stRead.missing) { - stateSec = { present: false, phase: "absent", enabled: null, completed: [], updatedAt: null }; + stateSec = { present: false, phase: "absent", enabled: null, completed: [], last_updated: null }; } else if (stRead.bad) { stateSec = { - present: true, phase: "error", enabled: null, completed: [], updatedAt: null, + present: true, phase: "error", enabled: null, completed: [], last_updated: null, error: "state file is not valid JSON: " + stRead.bad, }; } else { @@ -188,7 +458,7 @@ if (stRead.missing) { phase: typeof s.phase === "string" ? s.phase : "absent", enabled: typeof s.enabled === "boolean" ? s.enabled : null, completed: Array.isArray(s.completed) ? s.completed : [], - updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : null, + last_updated: typeof s.last_updated === "string" ? s.last_updated : null, }; } @@ -251,27 +521,39 @@ if (guidRaw === null || isErr(guidRaw)) { } else { const h = (guidRaw.hooks && typeof guidRaw.hooks === "object") ? guidRaw.hooks : {}; const ses = (h.session && typeof h.session === "object") ? h.session : {}; - const rem = (h.reminder && typeof h.reminder === "object") ? h.reminder : {}; - const exp = (h.explore && typeof h.explore === "object") ? h.explore : {}; + const pre = (h.prefetch && typeof h.prefetch === "object") ? h.prefetch : {}; + const sta = (h.stats && typeof h.stats === "object") ? h.stats : {}; const rule = (guidRaw.rule && typeof guidRaw.rule === "object") ? guidRaw.rule : {}; + const ign = (guidRaw.ignore && typeof guidRaw.ignore === "object") ? guidRaw.ignore : {}; const cmd = (guidRaw.claudeMd && typeof guidRaw.claudeMd === "object") ? guidRaw.claudeMd : {}; const perm = (guidRaw.permissions && typeof guidRaw.permissions === "object") ? guidRaw.permissions : {}; guidSec = { rule: typeof rule.state === "string" ? rule.state : "absent", + ignore: typeof ign.state === "string" ? ign.state : "absent", claudeMd: typeof cmd.state === "string" ? cmd.state : "absent", settingsFile: typeof h.settingsFile === "string" ? h.settingsFile : "", hooks: { session: ses.file === "present" ? "present" : "missing", - reminder: rem.file === "present" ? "present" : "missing", - explore: exp.file === "present" ? "present" : "missing", + prefetch: pre.file === "present" ? "present" : "missing", + stats: sta.file === "present" ? "present" : "missing", }, + // Retired hook files still on disk (v1 installs). Non-empty means the + // migration has not run yet; `install`/`upgrade` deletes them. + retired: Array.isArray(h.retired) ? h.retired : [], permissionsWired: perm.wired === true, - // Read the authoritative sibling count (SessionStart + PreToolUse/Bash + - // PreToolUse/Grep + SubagentStart/Explore). Never re-derive it from the - // `wired` booleans: the reminder spans two matchers, so a half-wired - // reminder loses one entry. + // Read the authoritative sibling counts (SessionStart + UserPromptSubmit + + // PostToolUse + PostToolUseFailure). Never re-derive them from the `wired` + // booleans: stats spans two events, so a half-wired pair loses an entry. wiredCount: typeof h.wiredCount === "number" ? h.wiredCount : 0, + wantCount: typeof h.wantCount === "number" ? h.wantCount : 0, staleEntries: typeof h.staleEntries === "number" ? h.staleEntries : 0, + // The frontmatter `version:` of the installed rule, and the version the plugin + // on this machine would install. Staleness is otherwise visible only through + // /brewcode:setup-status, so a project running artifacts from an old release + // read `ready` in its own status with no prescription. One signal, one fix - + // the cross-plugin dashboard still owns the per-artifact breakdown. + version: typeof rule.version === "string" ? rule.version : "", + pluginVersion: typeof rule.templateVersion === "string" ? rule.templateVersion : "", }; } @@ -328,6 +610,36 @@ else if (mcpState === "absent" && (phase === "absent" || phase === "prereq_ready else if (mcpState === "correct" && phase === "ready") { verdict = "ready"; reason = "pinned server registered at user scope and verified"; } else { verdict = "partial"; reason = "mcp=" + mcpState + ", phase=" + phase; } +// A v1-shaped repo satisfies mcp=correct + phase=ready and would report +// `ready`/`none`, so nobody upgrading would ever be told to migrate. The +// project half of the install is part of the verdict: retired hook files +// still on disk, settings entries pointing at an older plugin version, or a +// missing sibling registration each downgrade `ready` to `partial`. +if (verdict === "ready" && guidSec && !isErr(guidSec)) { + const retired = Array.isArray(guidSec.retired) ? guidSec.retired : []; + const stale = typeof guidSec.staleEntries === "number" ? guidSec.staleEntries : 0; + const wired = typeof guidSec.wiredCount === "number" ? guidSec.wiredCount : 0; + const want = typeof guidSec.wantCount === "number" ? guidSec.wantCount : 0; + const why = []; + if (retired.length) { why.push("retired hooks on disk: " + retired.join(", ")); } + if (stale > 0) { why.push(stale + " stale settings " + (stale === 1 ? "entry" : "entries")); } + // want===0 means the wiring counts were not reported at all - never treat an + // absent count as a defect, or a trimmed report downgrades a healthy repo. + if (want > 0 && wired !== want) { why.push("hooks wired " + wired + "/" + want); } + if (why.length) { verdict = "partial"; reason = why.join("; "); } +} + +// Stale artifacts: everything is wired and byte-managed, but at the version of an +// older release. Both stamps must be present and readable - an unstamped pre-5.0 +// rule reports "" and must never be called stale on a missing value. +let stampStale = false; +if (verdict === "ready" && guidSec && !isErr(guidSec) + && guidSec.version && guidSec.pluginVersion && guidSec.version !== guidSec.pluginVersion) { + stampStale = true; + verdict = "partial"; + reason = "artifacts at " + guidSec.version + ", plugin at " + guidSec.pluginVersion; +} + let nextStep; if (verdict === "ready") { nextStep = "none"; @@ -342,6 +654,8 @@ if (verdict === "ready") { nextStep = "Run /brewcode:semble-setup enable"; } else if (verdict === "verifying") { nextStep = "Run /brewcode:semble-setup resume"; +} else if (stampStale) { + nextStep = "Run /brewcode:semble-setup upgrade"; } else { nextStep = "Run /brewcode:semble-setup install"; } @@ -410,9 +724,13 @@ if (jsonMode) { L.push("guidance: error: " + report.guidance.error); } else { const g = report.guidance; + const ver = g.version + ? " | version " + g.version + (g.pluginVersion && g.pluginVersion !== g.version + ? " (plugin " + g.pluginVersion + " - run /brewcode:semble-setup upgrade)" : "") + : ""; L.push("guidance: rule " + g.rule + " | CLAUDE.md " + g.claudeMd + - " | hooks " + g.wiredCount + "/4 wired" + - " | permissions " + (g.permissionsWired ? "yes" : "no")); + " | hooks " + g.wiredCount + "/" + (g.wantCount || 0) + " wired" + + " | permissions " + (g.permissionsWired ? "yes" : "no") + ver); } } if (report.agents) { diff --git a/brewcode/skills/semble-setup/tests/fixtures/README.md b/brewcode/skills/semble-setup/tests/fixtures/README.md index bc4ffa9..f471b35 100644 --- a/brewcode/skills/semble-setup/tests/fixtures/README.md +++ b/brewcode/skills/semble-setup/tests/fixtures/README.md @@ -52,7 +52,7 @@ config file and one shell script. | `malformed.json` | `malformed` (trailing comma - deliberately unparseable) | `correct.json`, `wrongscope.json` and `duplicate.json` hardcode the pin -`0.5.2`, so a suite using them must **not** set `SEMBLE_PIN_VERSION`. +`0.5.4`, so a suite using them must **not** set `SEMBLE_PIN_VERSION`. ### `settings/*.json` diff --git a/brewcode/skills/semble-setup/tests/fixtures/claude-json/correct.json b/brewcode/skills/semble-setup/tests/fixtures/claude-json/correct.json index 8e85b48..cbc3fb9 100644 --- a/brewcode/skills/semble-setup/tests/fixtures/claude-json/correct.json +++ b/brewcode/skills/semble-setup/tests/fixtures/claude-json/correct.json @@ -3,7 +3,7 @@ "semble_code": { "type": "stdio", "command": "uvx", - "args": ["--from", "semble[mcp]==0.5.2", "semble", "--content", "code", "docs", "config"], + "args": ["--from", "semble[mcp]==0.5.4", "semble", "--content", "code", "docs", "config"], "env": { "SEMBLE_CACHE_LOCATION": "__CACHE_ROOT_CODE__" }, "alwaysLoad": true } diff --git a/brewcode/skills/semble-setup/tests/fixtures/claude-json/duplicate.json b/brewcode/skills/semble-setup/tests/fixtures/claude-json/duplicate.json index f747ada..567958f 100644 --- a/brewcode/skills/semble-setup/tests/fixtures/claude-json/duplicate.json +++ b/brewcode/skills/semble-setup/tests/fixtures/claude-json/duplicate.json @@ -3,7 +3,7 @@ "semble_code": { "type": "stdio", "command": "uvx", - "args": ["--from", "semble[mcp]==0.5.2", "semble", "--content", "code", "docs", "config"], + "args": ["--from", "semble[mcp]==0.5.4", "semble", "--content", "code", "docs", "config"], "env": { "SEMBLE_CACHE_LOCATION": "__CACHE_ROOT_CODE__" }, "alwaysLoad": true } @@ -14,7 +14,7 @@ "semble_code": { "type": "stdio", "command": "uvx", - "args": ["--from", "semble[mcp]==0.5.2", "semble", "--content", "code", "docs", "config"], + "args": ["--from", "semble[mcp]==0.5.4", "semble", "--content", "code", "docs", "config"], "env": { "SEMBLE_CACHE_LOCATION": "__CACHE_ROOT_CODE__" }, "alwaysLoad": true } diff --git a/brewcode/skills/semble-setup/tests/fixtures/claude-json/wrongscope.json b/brewcode/skills/semble-setup/tests/fixtures/claude-json/wrongscope.json index b627546..b7b5f7d 100644 --- a/brewcode/skills/semble-setup/tests/fixtures/claude-json/wrongscope.json +++ b/brewcode/skills/semble-setup/tests/fixtures/claude-json/wrongscope.json @@ -6,7 +6,7 @@ "semble_code": { "type": "stdio", "command": "uvx", - "args": ["--from", "semble[mcp]==0.5.2", "semble", "--content", "code", "docs", "config"], + "args": ["--from", "semble[mcp]==0.5.4", "semble", "--content", "code", "docs", "config"], "env": { "SEMBLE_CACHE_LOCATION": "__CACHE_ROOT_CODE__" }, "alwaysLoad": true } diff --git a/brewcode/skills/semble-setup/tests/suite-core.mjs b/brewcode/skills/semble-setup/tests/suite-core.mjs index 73bcff0..d759795 100644 --- a/brewcode/skills/semble-setup/tests/suite-core.mjs +++ b/brewcode/skills/semble-setup/tests/suite-core.mjs @@ -28,7 +28,7 @@ const MCP_SH = join(SCRIPTS, 'semble-mcp.sh'); const CACHE_SH = join(SCRIPTS, 'semble-cache.sh'); const STATE_SH = join(SCRIPTS, 'semble-state.sh'); -const PIN = 'semble[mcp]==0.5.2'; +const PIN = 'semble[mcp]==0.5.4'; // ── isolated base ─────────────────────────────────────────────────────────── const BASE = realpathSync(mkdtempSync(join(tmpdir(), 'semble-b-'))); @@ -398,7 +398,7 @@ check('state.unknownkey.exit', okPatch.status, 0, 'patch succeeds'); check('state.unknownkey.preserved', afterPatch.customKey, { a: 1 }, 'unknown top-level keys survive verbatim'); check('state.unknownkey.phase', afterPatch.phase, 'prereq_ready', 'the patched key is applied'); check('state.unknownkey.projectRoot', afterPatch.projectRoot, PROJ, 'projectRoot is rewritten from sc_project_root'); -check('state.unknownkey.version', afterPatch.approvedVersion, '0.5.2', 'approvedVersion is the pin'); +check('state.unknownkey.version', afterPatch.approvedVersion, '0.5.4', 'approvedVersion is the pin'); // `disabled` is not healable from absent: nothing was ever set up to disable. resetProject(); @@ -556,8 +556,17 @@ const SCRIPT_TARGETS = [ ]; const CALLSITE_TARGETS = [...new Set([...SKILL_HOPS, ...SCRIPT_TARGETS])].sort(); +// Two hops in document order: the resume walk, and only that. The install-path +// checkpoint on the `correct` branch used to be an EXECUTE block here, which +// meant a project whose MCP an earlier project already registered kept no +// state.json at all whenever the model skipped the block; semble-mcp.sh writes +// it itself now (mcp_checkpoint / mcp_checkpoint_present), so the guarantee is +// asserted against the SCRIPT below rather than against prose. +const RESUME_HOPS = SKILL_HOPS; check('phase.callsites.skill', SKILL_HOPS, ['verifying', 'ready'], - 'SKILL.md drives resume through the verifying hop and only then to ready'); + 'SKILL.md drives resume through verifying and only then to ready; the checkpoint is the script job'); +check('phase.callsites.checkpoint', MCP_SRC.includes('"$STATE_SH" phase awaiting_reload'), true, + 'the awaiting_reload checkpoint is guaranteed by semble-mcp.sh, not by a SKILL.md block a model can skip'); check('phase.callsites.union', CALLSITE_TARGETS, ['awaiting_reload', 'disabled', 'error', 'prereq_ready', 'ready', 'verifying'], 'the call sites between them name all six settable phases'); @@ -573,7 +582,7 @@ function seedPhase(phase, extra = {}) { enabled: true, scope: 'user', projectRoot: PROJ, - approvedVersion: '0.5.2', + approvedVersion: '0.5.4', cacheRoot: CODE_ROOT, repoHash: '', completed: [], @@ -657,7 +666,7 @@ check('phase.callsites.matrix', callsiteMatrix, { // The resume walk, end to end, over a state file shaped like the two real // installs that were stuck: awaiting_reload with mcp/warm/smoke already done. seedPhase('awaiting_reload', { completed: ['mcp', 'warm', 'smoke'] }); -const resumeExits = SKILL_HOPS.map((h) => run(STATE_SH, ['phase', h]).status); +const resumeExits = RESUME_HOPS.map((h) => run(STATE_SH, ['phase', h]).status); const resumed = JSON.parse(readFileSync(STATE_FILE, 'utf8')); check('resume.walk.exits', resumeExits, [0, 0], 'both hops SKILL.md performs exit 0 from awaiting_reload'); check('resume.walk.phase', resumed.phase, 'ready', 'the walk lands on ready'); @@ -698,7 +707,7 @@ for (const [label, args] of [ check(`refresh.${label}.prompt`, after.resumePrompt, LIVE_PROMPT, 'resumePrompt is rewritten from the constant'); check(`refresh.${label}.cacheRoot`, after.cacheRoot, CODE_ROOT, 'cacheRoot is recomputed from the environment'); check(`refresh.${label}.repoHash`, after.repoHash, PROJ_HASH, 'repoHash is recomputed from the project root'); - check(`refresh.${label}.version`, after.approvedVersion, '0.5.2', 'approvedVersion stays the pin'); + check(`refresh.${label}.version`, after.approvedVersion, '0.5.4', 'approvedVersion stays the pin'); check(`refresh.${label}.enabled`, after.enabled, true, 'enabled is NOT reset as a side effect'); check(`refresh.${label}.completed`, after.completed, label === 'complete' ? ['mcp', 'warm', 'smoke', 'guidance'] : ['mcp', 'warm', 'smoke'], @@ -728,9 +737,22 @@ check('refresh.refused.bytes', sha(STATE_FILE), shaStale, // caches still carry the pre-rename `skills/semble` (brewcode/4.10.1 on this // machine), so an unset CLAUDE_PLUGIN_ROOT resolved to nothing - silently in // bash, and as a hard `no matches found` in zsh. -const SD_LINES = [...SKILL_MD.matchAll(/^SD="\$\{CLAUDE_SKILL_DIR:-.*\}"$/gm)].map((m) => m[0]); -check('sd.count', SD_LINES.length, 18, 'every EXECUTE block re-resolves the skill dir'); -check('sd.identical', new Set(SD_LINES).size, 1, 'all of them are the same line, character for character'); +// D7: `${CLAUDE_SKILL_DIR}` is a text substitution on the skill prompt, not an +// env var - getPromptForCommand does replace(/\$\{CLAUDE_SKILL_DIR\}/g, dir), +// and that regex matches the BARE literal only. The old spelling +// `SD="${CLAUDE_SKILL_DIR:-$(find ...)}"` was therefore never substituted: the +// shell saw an unset name and the cache fallback won on every single run. +const SD_LINES = [...SKILL_MD.matchAll( + /^SD="\$\{CLAUDE_SKILL_DIR\}"\n\[ -n "\$SD" \] \|\| SD="\$\(find [^\n]*\)"$/gm)].map((m) => m[0]); +// 20 since `upgrade` gained its second half (the Step 3.3b guidance+agents block): it is the only +// writer of the rule file whose frontmatter carries this install's version stamp, so without it +// `upgrade` could never clear a `stale` verdict. +check('sd.count', SD_LINES.length, 19, 'every EXECUTE block re-resolves the skill dir'); +check('sd.identical', new Set(SD_LINES).size, 1, 'all of them are the same block, character for character'); +// Prose may NAME the broken spelling (Step 0 explains why it is broken); no +// assignment may USE it. +check('sd.noBraceModifier', SKILL_MD.includes('="${CLAUDE_SKILL_DIR:-'), false, + 'no assignment uses the brace-modifier spelling - the substitution regex would not match it and the fallback would always win'); check('sd.noglob', SD_LINES[0].includes('ls -d'), false, 'the fallback uses find, not a shell glob that zsh turns into a hard error'); @@ -763,6 +785,18 @@ check('sd.layout.version-order', sdResolve([`${CACHE}/4.9.0/skills/semble-setup` check('sd.layout.none', sdResolve([]), '', 'no cache at all yields an empty SD, which the Step 0 guard turns into ❌'); check('sd.layout.decoy', sdResolve([`${CACHE}/4.11.0/skills/superreview`, `${CACHE}/4.11.0/agents/semble`]), '', 'neither a same-named agents dir nor a sibling skill is mistaken for the skill dir'); +// And the case the old spelling could never reach: the substitution DID happen, +// so line 1 already holds a literal path and the cache must not be consulted. +{ + const h = mkdtempSync(join(BASE, 'sdsubst-')); + mkdirSync(join(h, `${CACHE}/9.9.9/skills/semble-setup`), { recursive: true }); + const substituted = SD_LINES[0].replace('${CLAUDE_SKILL_DIR}', '/checkout/brewcode/skills/semble-setup'); + const r = spawnSync('bash', ['-c', `${substituted}\nprintf '%s' "$SD"`], { + encoding: 'utf8', env: { ...process.env, HOME: h, CLAUDE_SKILL_DIR: '' }, timeout: 20000, + }); + check('sd.substituted', r.stdout, '/checkout/brewcode/skills/semble-setup', + 'once the literal is substituted the fallback is skipped, even with a newer version in the cache'); +} resetProject(); @@ -1040,22 +1074,69 @@ check('timeout.path.stub', sh('sc_timeout_path', { SEMBLE_TIMEOUT_BIN: TIMEOUT_S 'the backing binary is reported by absolute path'); // Delegation to a real binary keeps every argument its own argv element. -const delegated = sh(`sc_timeout 42 printf '%s|%s\\n' 'semble[mcp]==0.5.2' two`, +const delegated = sh(`sc_timeout 42 printf '%s|%s\\n' 'semble[mcp]==0.5.4' two`, { SEMBLE_TIMEOUT_BIN: TIMEOUT_STUB }); -check('timeout.delegate.stdout', delegated.out, 'semble[mcp]==0.5.2|two', +check('timeout.delegate.stdout', delegated.out, 'semble[mcp]==0.5.4|two', 'the wrapped command runs with its argv intact'); check('timeout.delegate.log', readFileSync(TIMEOUT_LOG, 'utf8'), - "42 printf %s|%s\\n semble[mcp]==0.5.2 two\n", + "42 printf %s|%s\\n semble[mcp]==0.5.4 two\n", 'the binary was invoked with the seconds first and the pin as one word'); +// Elapsed IS the property under test here — "the watchdog waited out its bound" +// has no deterministic proxy — so the bound is asserted as a tolerance window, +// `Math.abs(elapsed - centre) <= tol`, never as a bare inequality. +// +// How the window is chosen, because a hand-picked one has been wrong here before +// (an earlier ceiling sat ~50 ms over the observed value and flaked): +// floor — pinned to the CONTRACTED bound (1000 ms for `sc_timeout 1`), i.e. +// tol is always centre minus the bound. A tol >= centre would admit +// elapsed = 0 and turn the assertion into a wildcard, which is worse +// than the inequality it replaced. +// centre — the value the algorithm predicts, not a measurement. sc_timeout_watch +// breaks on `SECONDS - t0 -gt secs`, and SECONDS counts WHOLE seconds +// since shell start, so the kill lands one whole second past the bound, +// plus the 0.1 s TERM->KILL grace: centre = (secs + 1) * 1000. +// ceiling — centre + tol, ~1 s of headroom over the measured spread (12 runs: +// `sc_timeout 1` 1963-2038 ms, `sc_timeout 2` 2790-3178 ms). Only a +// load stall of nearly a second can trip it. +// The upper side is also pinned deterministically and independently of the clock: +// the watched command needs 600 s and drops a marker only if it runs to +// completion, so the marker's ABSENCE proves the watchdog cut it off at any +// machine speed, with no tolerance to tune. { + const marker = join(BASE, 'watch-ran-to-completion'); const t0 = Date.now(); - const r = sh('sc_timeout 1 sleep 5', { SEMBLE_TIMEOUT_BIN: 'none' }); + const r = sh(`sc_timeout 1 sh -c 'sleep 600; : > ${marker}'`, { SEMBLE_TIMEOUT_BIN: 'none' }); const elapsed = Date.now() - t0; check('timeout.watch.code', r.status, 124, 'the watchdog reports 124, the GNU timeout convention'); - // Bash startup + a 100 ms TERM->KILL grace sit on top of the 1 s bound. - check('timeout.watch.elapsed', Math.abs(elapsed - 1350) <= 700, true, - 'it returns at ~1.35 s (+/-0.7), not after the full 5 s sleep'); + check('timeout.watch.early', existsSync(marker), false, + 'the 600 s command never completed — the watchdog cut it off instead of waiting it out'); + check('timeout.watch.floor', Math.abs(elapsed - 2000) <= 1000, true, + 'it waited out its full 1 s bound before killing (1000-3000 ms): a watchdog that fires early is worse than none'); +} +{ + // The bound is a parameter, not a constant: doubling it doubles the floor. + const marker = join(BASE, 'watch-ran-to-completion-2'); + const t0 = Date.now(); + const r = sh(`sc_timeout 2 sh -c 'sleep 600; : > ${marker}'`, { SEMBLE_TIMEOUT_BIN: 'none' }); + const elapsed = Date.now() - t0; + check('timeout.watch.scale.code', r.status, 124, 'a 2 s bound reports 124 the same way'); + check('timeout.watch.scale.early', existsSync(marker), false, 'and still cuts the command off'); + check('timeout.watch.scale.floor', Math.abs(elapsed - 3000) <= 1000, true, + 'a 2 s bound lands in 2000-4000 ms — the deadline tracks SECONDS, not a hardcoded interval'); +} +{ + // Regression guard for the drift bug: the deadline is wall clock, not the sum + // of the requested sleeps. Each poll forks /bin/sleep, so a slow fork used to + // stretch a nominal bound without limit. A 1 s bound around a command that + // sleeps 3 s must return well inside 3 s even though the poll backoff would + // have accumulated only a fraction of that in "intended" sleep time. + const t0 = Date.now(); + const r = sh("sc_timeout 1 sleep 3", { SEMBLE_TIMEOUT_BIN: 'none' }); + const elapsed = Date.now() - t0; + check('timeout.watch.wallclock.code', r.status, 124, 'the 3 s sleep was cut short, not awaited'); + check('timeout.watch.wallclock.floor', Math.abs(elapsed - 2000) <= 1000, true, + 'and the full 1 s bound was honoured first, then cut short well inside the 3 s sleep (1000-3000 ms)'); } { const t0 = Date.now(); @@ -1083,6 +1164,27 @@ check('timeout.watch.stderr', 'the grandchild died with the group instead of surviving the timeout'); } +// ── probe argv gate ──────────────────────────────────────────────────────── +// `--version` reached semble's _CLI_DISPATCH_ARGS in 0.5.4. On an older pin it +// is unrecognised argv, which starts the blocking stdio server — so the argv is +// picked from the pin up front. A hang cannot be undone by a fallback, only by +// never issuing it. Exact strings on purpose: a wildcard here would accept the +// hanging argv on an old pin, which is the whole failure this gate prevents. +check('probearg.pin', sh('printf %s "$SEMBLE_PIN_VERSION"').out, '0.5.4', + 'the shipped pin is 0.5.4'); +check('probearg.default', sh('sc_semble_probe_arg').out, '--version', + 'the shipped pin dispatches --version'); +for (const [v, want] of [ + ['0.5.4', '--version'], ['0.5.5', '--version'], ['0.6.0', '--version'], ['1.0.0', '--version'], + ['0.5.3', '--help'], ['0.5.2', '--help'], ['0.4.9', '--help'], ['0.5.10', '--version'], + ['', '--help'], ['0.5', '--help'], ['garbage', '--help'], ['0.5.x', '--help'], +]) { + check(`probearg.${v || 'empty'}`, sh(`sc_semble_probe_arg '${v}'`).out, want, + `pin ${v || '(empty)'} probes with ${want}`); +} +check('probearg.override', sh('sc_semble_probe_arg', { SEMBLE_PIN_VERSION: '0.5.2' }).out, '--help', + 'a SEMBLE_PIN_VERSION override back to 0.5.2 must NOT issue the hanging --version'); + // ── report ───────────────────────────────────────────────────────────────── console.log('suite-core.mjs (unit B: lib + mcp + cache + state)'); for (const line of results) console.log(line); diff --git a/brewcode/skills/semble-setup/tests/suite-hooks.mjs b/brewcode/skills/semble-setup/tests/suite-hooks.mjs index 98979e3..f1ca1a7 100644 --- a/brewcode/skills/semble-setup/tests/suite-hooks.mjs +++ b/brewcode/skills/semble-setup/tests/suite-hooks.mjs @@ -1,7 +1,9 @@ #!/usr/bin/env node /** - * suite-hooks.mjs — unit D: rule template, CLAUDE.md marker block, the three - * hooks, and the settings.json merge performed by semble-guidance.sh. + * suite-hooks.mjs — unit D: rule template, .sembleignore, CLAUDE.md marker + * block, the three hooks (session, prefetch, stats) and the settings.json merge + * performed by semble-guidance.sh — including the 5.0.0 migration that retires + * the two advisory hooks. * * Self-contained: inlines its own check()/run() helpers, runs standalone * (`node tests/suite-hooks.mjs`), and never touches the real ~/.claude, the @@ -12,13 +14,14 @@ * description. No branching decides which asserts run. */ import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, - readdirSync, realpathSync, statSync, chmodSync, + readdirSync, realpathSync, statSync, chmodSync, appendFileSync, symlinkSync, } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const HERE = join(fileURLToPath(import.meta.url), '..'); // tests/ const SKILL = join(HERE, '..'); // skills/semble-setup/ @@ -26,13 +29,28 @@ const ASSETS = join(SKILL, 'assets'); const SCRIPTS = join(SKILL, 'scripts'); const TEMPLATE_SRC = join(ASSETS, 'semble-first.md.template'); const SESSION_SRC = join(ASSETS, 'semble-session.mjs'); -const REMINDER_SRC = join(ASSETS, 'semble-reminder.mjs'); -const EXPLORE_SRC = join(ASSETS, 'semble-explore.mjs'); +const PREFETCH_SRC = join(ASSETS, 'semble-prefetch.mjs'); +const STATS_SRC = join(ASSETS, 'semble-stats.mjs'); const BASE = realpathSync(mkdtempSync(join(tmpdir(), 'semble-d-'))); const HOME = join(BASE, 'home'); mkdirSync(join(HOME, '.claude'), { recursive: true }); +// The registered cache root every fixture state points at, and the four files +// semble writes into `//index`. The prefetch hook refuses to +// spawn a search when they are not all there — a cold index cannot be built +// inside its 3 s cap, so trying is pure loss — which makes a warm index part of +// the baseline fixture and `{ cold: true }` the explicit way to remove it. +const CACHE = join(BASE, 'cache'); +const REPO_HASH = 'abcdef0123456789'.repeat(4); +const INDEX_FILE_NAMES = ['chunks.json', 'metadata.json', 'bm25_index', 'semantic_index']; +function warmIndex(root, hash, only) { + const dir = join(root, hash, 'index'); + mkdirSync(dir, { recursive: true }); + for (const n of (only || INDEX_FILE_NAMES)) writeFileSync(join(dir, n), '{}'); + return dir; +} + let passed = 0; let failed = 0; const results = []; @@ -90,11 +108,27 @@ function safeParse(str) { } // ── skill copy under the temp base (never runs from the repo tree) ────────── -const SKILL_COPY = join(BASE, 'skill'); +// The copy reproduces the REAL plugin layout — /skills/semble-setup/... — +// because scripts/lib/semble-common.sh resolves the artifact version by walking +// four levels up from its own location to /.claude-plugin/plugin.json. +// A flat copy sends that walk outside the temp tree and every installed artifact +// gets stamped version "unknown". +const PLUGIN_COPY = join(BASE, 'plugin'); +const SKILL_COPY = join(PLUGIN_COPY, 'skills', 'semble-setup'); +mkdirSync(join(PLUGIN_COPY, '.claude-plugin'), { recursive: true }); +copyFileSync(join(SKILL, '..', '..', '.claude-plugin', 'plugin.json'), + join(PLUGIN_COPY, '.claude-plugin', 'plugin.json')); +const PLUGIN_VERSION = JSON.parse( + readFileSync(join(PLUGIN_COPY, '.claude-plugin', 'plugin.json'), 'utf8')).version; mkdirSync(join(SKILL_COPY, 'scripts', 'lib'), { recursive: true }); mkdirSync(join(SKILL_COPY, 'assets'), { recursive: true }); copyFileSync(join(SCRIPTS, 'semble-guidance.sh'), join(SKILL_COPY, 'scripts', 'semble-guidance.sh')); -for (const f of ['semble-first.md.template', 'semble-session.mjs', 'semble-reminder.mjs', 'semble-explore.mjs']) { +// The candidate scanner is a sibling of the guidance script: without it the +// measured block is silently skipped, so J3 would prove nothing. +copyFileSync(join(SCRIPTS, 'semble-project.sh'), join(SKILL_COPY, 'scripts', 'semble-project.sh')); +chmodSync(join(SKILL_COPY, 'scripts', 'semble-project.sh'), 0o755); +for (const f of ['semble-first.md.template', 'sembleignore.template', + 'semble-session.mjs', 'semble-prefetch.mjs', 'semble-stats.mjs']) { copyFileSync(join(ASSETS, f), join(SKILL_COPY, 'assets', f)); } @@ -120,6 +154,9 @@ sc_rule_file() { printf '%s\\n' "$(sc_project_root)/.claude/rules/semble- sc_hooks_dir() { printf '%s\\n' "$(sc_project_root)/.claude/hooks"; } sc_require_node() { sc_have node || sc_die "node is required"; } sc_backup() { [ -f "$1" ] || return 0; local b="$1.bak.$(date +%s)"; cp "$1" "$b" && printf '%s\\n' "$b"; } +SEMBLE_GENERATED_BY="brewcode:semble-setup" +sc_plugin_version() { printf '%s' "${PLUGIN_VERSION}"; } +sc_today() { date +%F; } `; const usedRealLib = existsSync(REAL_LIB); if (usedRealLib) copyFileSync(REAL_LIB, LIB_COPY); @@ -127,7 +164,18 @@ else writeFileSync(LIB_COPY, LIB_STUB); const GUIDANCE = join(SKILL_COPY, 'scripts', 'semble-guidance.sh'); const TPL_TEXT = readFileSync(TEMPLATE_SRC, 'utf8'); +// The rule is a mechanism-(a) byte copy: its stamp is baked into the plugin template by +// bump-version.sh at release and copied verbatim, so the installed file must equal the +// template byte for byte. `last_updated` is deliberately absent -- a date written at install +// time would make setup-status's `cmp` read DIFFERS forever. +const META_KEYS = ['doc_type', 'version', 'generated_by', 'last_updated']; +const metaOf = (t) => Object.fromEntries(META_KEYS + .map((k) => [k, (new RegExp(`^${k}: (.*)$`, 'm').exec(t.split('\n---\n')[0] || '') || [])[1] ?? null])); + let projSeq = 0; +/** fixture project -> the cache root its state.json points at. */ +const projCache = new Map(); +const cacheOf = (p) => projCache.get(p) || ''; function freshProject(seed) { projSeq++; const dir = join(BASE, `proj${projSeq}`); @@ -137,7 +185,22 @@ function freshProject(seed) { } if (seed && seed.state !== undefined) { mkdirSync(join(dir, '.claude', 'semble'), { recursive: true }); - writeFileSync(join(dir, '.claude', 'semble', 'state.json'), seed.state); + // Each fixture gets its OWN cache root. The repo hash is shared, so without + // this a project seeded `cold: true` would find the warm index another + // fixture had already built and the cold case would silently never run. + const st = safeParse(seed.state); + const own = st && st.cacheRoot === CACHE; + const text = own ? JSON.stringify({ ...st, cacheRoot: join(CACHE, `proj${projSeq}`) }) : seed.state; + writeFileSync(join(dir, '.claude', 'semble', 'state.json'), text); + const eff = safeParse(text); + if (eff && typeof eff.cacheRoot === 'string') projCache.set(dir, eff.cacheRoot); + // Warm under the hash the hook computes from the project path itself, NOT + // the one state.json carries - that field is deliberately ignored now, so + // seeding by it would warm a directory nothing ever looks in. + if (eff && seed.cold !== true && eff.cacheRoot) { + warmIndex(eff.cacheRoot, createHash('sha256').update(realpathSync(dir)).digest('hex'), + seed.indexOnly); + } } if (seed && seed.stateDir === true) { mkdirSync(join(dir, '.claude', 'semble', 'state.json'), { recursive: true }); @@ -148,7 +211,11 @@ function freshProject(seed) { return dir; } -function guidance(proj, args) { +// SEMBLE_NO_CANDIDATES=1 by default: the measured-candidates block is a +// property of the repo being installed into, and letting it fire in every +// fixture would make unrelated expectations depend on the fixture's file mix. +// Section J3 turns it back on and tests it on purpose. +function guidance(proj, args, extraEnv = {}) { const r = spawnSync('bash', [GUIDANCE, ...args], { encoding: 'utf8', env: { @@ -156,6 +223,8 @@ function guidance(proj, args) { SEMBLE_PROJECT_ROOT: proj, SEMBLE_TEST_HOME: HOME, SEMBLE_NO_NETWORK: '1', + SEMBLE_NO_CANDIDATES: '1', + ...extraEnv, }, timeout: 30000, }); @@ -193,10 +262,30 @@ function countEntry(s, ev, matcher, full) { return arr.filter((e) => matcherOf(e) === matcher && argsOf(e).includes(full)).length; } +// The 5.0.0 want table: SessionStart, UserPromptSubmit and the stats pair. +// PreToolUse/Bash, PreToolUse/Grep and SubagentStart/Explore were RETIRED with +// the two advisory hooks; group M proves a v1-shaped file loses them on install. +const WANT_N = 4; +const STATS_MATCHER = 'mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read'; +/** One count per want row, in want-table order. */ +function wantCounts(proj, s) { + const { session, prefetch: pre, stats } = semblePaths(proj); + return [ + countEntry(s, 'SessionStart', null, session), + countEntry(s, 'UserPromptSubmit', null, pre), + countEntry(s, 'PostToolUse', STATS_MATCHER, stats), + countEntry(s, 'PostToolUseFailure', STATS_MATCHER, stats), + ]; +} + function semblePaths(proj) { const d = hooksDirOf(proj); return { session: join(d, 'semble-session.mjs'), + prefetch: join(d, 'semble-prefetch.mjs'), + stats: join(d, 'semble-stats.mjs'), + // retired in 5.0.0 — still addressable, because the migration is asserted + // on the files it must DELETE. reminder: join(d, 'semble-reminder.mjs'), explore: join(d, 'semble-explore.mjs'), }; @@ -207,42 +296,20 @@ const READY_STATE = (extra) => schema: 1, profile: 'code', projectRoot: '/x', - approvedVersion: '0.5.2', + approvedVersion: '0.5.4', phase: 'ready', enabled: true, scope: 'user', - cacheRoot: '/c', - repoHash: 'abcdef0123456789'.repeat(4), - completed: [], + cacheRoot: CACHE, + repoHash: REPO_HASH, + completed: ['mcp'], ...(extra || {}), }); -function reminderPayload(proj, toolName, toolInput) { - return JSON.stringify({ - session_id: 'S1', - cwd: proj, - hook_event_name: 'PreToolUse', - tool_name: toolName, - tool_input: toolInput, - }); -} - -const allReminderOutputs = []; -function reminder(proj, toolName, toolInput) { - const r = runNode(REMINDER_SRC, reminderPayload(proj, toolName, toolInput)); - allReminderOutputs.push(r.stdout); - return r; -} - -const EXPECTED_MSG = (cwd) => - 'semble: for intent/behavior questions try ONE mcp__semble_code__search first — repo="' + - cwd + - '", top_k=5, max_snippet_lines=10 — then open the hit at start_line. ' + - 'This grep is fine for exact/exhaustive matching; this is a reminder, not a block.'; - -const REMIND_OK = (cwd) => ({ - hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: EXPECTED_MSG(cwd) }, -}); +// The prefetch gate is "semble is usable here", not "phase === ready": semble +// builds its index lazily INSIDE a tool call, so a phase gate would deadlock. +// `completed` holding "mcp" is the registration proxy, so it belongs in the +// baseline fixture; the cases that drop it are explicit (P6). // ═══════════════════════════════════════════════════════════════════════════ // A. settings.json merge @@ -259,23 +326,34 @@ const REMIND_OK = (cwd) => ({ check('A1.statusTrailingNewline', guidance(p, ['status', '--json']).stdout.endsWith('\n'), true, 'status --json carries the same trailing newline'); const s = readSettings(p); - const { session, reminder: rem, explore: exp } = semblePaths(p); + const { session, prefetch: pre, stats, reminder: rem, explore: exp } = semblePaths(p); check('A1.sessionEntry', s.hooks.SessionStart, [ { hooks: [{ type: 'command', command: 'node', args: [session], timeout: 5 }] }, ], 'SessionStart entry has the exact contract shape with an explicit 5 s timeout'); - check('A1.preToolUse', s.hooks.PreToolUse, [ - { hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5 }], matcher: 'Bash' }, - { hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5 }], matcher: 'Grep' }, - ], 'the reminder is registered once under Bash and once under Grep'); - check('A1.subagentStart', s.hooks.SubagentStart, [ - { hooks: [{ type: 'command', command: 'node', args: [exp], timeout: 5 }], matcher: 'Explore' }, - ], 'the explore hook is registered once under SubagentStart with matcher Explore'); + check('A1.userPromptSubmit', s.hooks.UserPromptSubmit, [ + { hooks: [{ type: 'command', command: 'node', args: [pre], timeout: 5 }] }, + ], 'the prefetch hook is registered once on UserPromptSubmit with NO matcher - every prompt reaches it'); + check('A1.noRetiredEvents', + [Object.prototype.hasOwnProperty.call(s.hooks, 'PreToolUse'), + Object.prototype.hasOwnProperty.call(s.hooks, 'SubagentStart')], [false, false], + 'a fresh install registers neither of the events the two advisory hooks used'); check('A1.perm', s.permissions.allow, ['mcp__semble_code__search', 'mcp__semble_code__find_related'], 'both MCP tool names land in permissions.allow'); - check('A1.files', [existsSync(session), existsSync(rem), existsSync(exp)], [true, true, true], - 'all three .mjs assets were copied into .claude/hooks'); - check('A1.rule', readRaw(join(p, '.claude', 'rules', 'semble-first.md')), TPL_TEXT, - 'the rule file is byte-identical to the template'); + check('A1.files', [existsSync(session), existsSync(pre), existsSync(stats)], [true, true, true], + 'all three live .mjs assets were copied into .claude/hooks'); + check('A1.noRetiredFiles', [existsSync(rem), existsSync(exp)], [false, false], + 'and neither retired asset is written any more'); + const ruleText = readRaw(join(p, '.claude', 'rules', 'semble-first.md')); + check('A1.rule', ruleText, TPL_TEXT, + 'the installed rule is byte-identical to the plugin template, so setup-status cmp reads SAME'); + check('A1.ruleStamp', metaOf(ruleText), { + doc_type: 'llm', + version: `"${PLUGIN_VERSION}"`, + generated_by: '"brewcode:semble-setup"', + last_updated: null, + }, 'the rule carries the release-baked stamp and no install-time last_updated'); + check('A1.ruleNoDupes', META_KEYS.map((k) => ruleText.split('\n').filter((l) => l.startsWith(`${k}:`)).length), + [1, 1, 1, 0], 'each baked key appears exactly once; last_updated is absent'); } // A2 — repeat merge is a no-op (3 runs) @@ -292,13 +370,7 @@ const REMIND_OK = (cwd) => ({ check('A2.bytes2', after2, after1, 'settings.json is byte-identical after run 2'); check('A2.bytes3', after3, after1, 'settings.json is byte-identical after run 3'); const s = readSettings(p); - const { session, reminder: rem, explore: exp } = semblePaths(p); - check('A2.counts', [ - countEntry(s, 'SessionStart', null, session), - countEntry(s, 'PreToolUse', 'Bash', rem), - countEntry(s, 'PreToolUse', 'Grep', rem), - countEntry(s, 'SubagentStart', 'Explore', exp), - ], [1, 1, 1, 1], 'exactly one entry per event+matcher after three merges'); + check('A2.counts', wantCounts(p, s), [1, 1, 1, 1], 'exactly one entry per want row after three merges'); check('A2.permCounts', [ s.permissions.allow.filter((x) => x === 'mcp__semble_code__search').length, s.permissions.allow.filter((x) => x === 'mcp__semble_code__find_related').length, @@ -332,13 +404,8 @@ const FOREIGN = { check('A3.allow', s.permissions.allow, ['Bash(git *)', 'mcp__semble_code__search', 'mcp__semble_code__find_related'], 'the two tool names are appended after the existing allow entries'); - const { session, reminder: rem, explore: exp } = semblePaths(p); - check('A3.counts', [ - countEntry(s, 'SessionStart', null, session), - countEntry(s, 'PreToolUse', 'Bash', rem), - countEntry(s, 'PreToolUse', 'Grep', rem), - countEntry(s, 'SubagentStart', 'Explore', exp), - ], [1, 1, 1, 1], 'exactly one semble entry per event+matcher alongside the foreign ones'); + check('A3.counts', wantCounts(p, s), [1, 1, 1, 1], + 'exactly one semble entry per want row alongside the foreign ones'); } // A4 — unparseable settings ABORTs and writes nothing @@ -373,13 +440,7 @@ const FOREIGN = { const flat = Object.values(s.hooks).flat(); check('A5.staleGone', flat.filter((e) => argsOf(e).some((a) => a.startsWith(staleDir))).length, 0, 'zero entries still point at the old hooks dir'); - const { session, reminder: rem, explore: exp } = semblePaths(p); - check('A5.counts', [ - countEntry(s, 'SessionStart', null, session), - countEntry(s, 'PreToolUse', 'Bash', rem), - countEntry(s, 'PreToolUse', 'Grep', rem), - countEntry(s, 'SubagentStart', 'Explore', exp), - ], [1, 1, 1, 1], 'the new-dir entries were added exactly once each'); + check('A5.counts', wantCounts(p, s), [1, 1, 1, 1], 'the new-dir entries were added exactly once each'); check('A5.foreign', s.hooks.PreToolUse.filter((e) => argsOf(e).includes('/opt/foreign/other.mjs')).length, 1, 'the foreign Write entry survived the stale-path purge'); } @@ -388,7 +449,7 @@ const FOREIGN = { { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); - const { session, reminder: rem, explore: exp } = semblePaths(p); + const { session, prefetch: pre, stats } = semblePaths(p); const r = guidance(p, ['remove', '--part', 'all', '--json']); check('A6.exit', r.status, 0, 'remove --part all exits 0'); const s = readSettings(p); @@ -396,7 +457,7 @@ const FOREIGN = { 'the hooks object is deleted once every event array empties'); check('A6.permissionsKey', Object.prototype.hasOwnProperty.call(s, 'permissions'), false, 'the permissions object is deleted once allow empties'); - check('A6.files', [existsSync(session), existsSync(rem), existsSync(exp)], [false, false, false], + check('A6.files', [existsSync(session), existsSync(pre), existsSync(stats)], [false, false, false], 'all three .mjs files are deleted'); check('A6.rule', existsSync(join(p, '.claude', 'rules', 'semble-first.md')), false, 'the managed rule file is deleted'); } @@ -420,7 +481,7 @@ const FOREIGN = { const b = safeParse(before.stdout); check('A8.beforeRule', b.rule.state, 'absent', 'status reports an absent rule before install'); check('A8.beforeWired', - [b.hooks.session.wired, b.hooks.reminder.wired, b.hooks.explore.wired, b.permissions.wired], + [b.hooks.session.wired, b.hooks.prefetch.wired, b.hooks.stats.wired, b.permissions.wired], [false, false, false, false], 'nothing is reported as wired before install'); guidance(p, ['install', '--part', 'all', '--json']); const after = guidance(p, ['status', '--json']); @@ -428,13 +489,15 @@ const FOREIGN = { check('A8.afterRule', a.rule.state, 'managed', 'status reports the rule as managed after install'); check('A8.afterClaudeMd', a.claudeMd.state, 'present', 'status reports the CLAUDE.md block as present'); check('A8.afterWired', - [a.hooks.session.wired, a.hooks.reminder.wired, a.hooks.explore.wired, a.permissions.wired], + [a.hooks.session.wired, a.hooks.prefetch.wired, a.hooks.stats.wired, a.permissions.wired], [true, true, true, true], - 'session hook, reminder (both matchers), explore hook and permissions all report wired'); - check('A8.afterFiles', [a.hooks.session.file, a.hooks.reminder.file, a.hooks.explore.file], + 'session hook, prefetch hook, stats (both post-tool events) and permissions all report wired'); + check('A8.afterFiles', [a.hooks.session.file, a.hooks.prefetch.file, a.hooks.stats.file], ['present', 'present', 'present'], 'status sees all three hook files on disk'); + check('A8.retired', a.hooks.retired, [], 'and no retired file is left behind'); check('A8.stale', a.hooks.staleEntries, 0, 'no stale entries after a clean install'); - check('A8.wiredCount', a.hooks.wiredCount, 4, 'all 4 settings entries are counted as wired'); + check('A8.wiredCount', [a.hooks.wiredCount, a.hooks.wantCount], [WANT_N, WANT_N], + 'all 4 settings entries are counted as wired'); check('A8.exitReadOnly', before.status, 0, 'status exits 0'); } @@ -442,35 +505,35 @@ const FOREIGN = { { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); - const { explore: exp } = semblePaths(p); + const { prefetch: pre } = semblePaths(p); const s = readSettings(p); - delete s.hooks.SubagentStart; // the old two hooks stay wired + delete s.hooks.UserPromptSubmit; // the other rows stay wired writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); const a = safeParse(guidance(p, ['status', '--json']).stdout); - check('A9.wiredCount', a.hooks.wiredCount, 3, - 'dropping the SubagentStart entry reports 3 of 4, not "wired"'); - check('A9.exploreWired', [a.hooks.session.wired, a.hooks.reminder.wired, a.hooks.explore.wired], - [true, true, false], 'only the explore entry is reported as unwired'); - check('A9.exploreFileStillThere', [a.hooks.explore.file, existsSync(exp)], ['present', true], + check('A9.wiredCount', [a.hooks.wiredCount, a.hooks.wantCount], [WANT_N - 1, WANT_N], + 'dropping the UserPromptSubmit entry reports 3 of 4, not "wired"'); + check('A9.prefetchWired', [a.hooks.session.wired, a.hooks.stats.wired, a.hooks.prefetch.wired], + [true, true, false], 'only the prefetch entry is reported as unwired'); + check('A9.prefetchFileStillThere', [a.hooks.prefetch.file, existsSync(pre)], ['present', true], 'the file is still on disk — file presence and wiring are reported separately'); const human = guidance(p, ['status']).stdout; check('A9.human', human.includes('hooks 3/4 wired'), true, 'the human line spells the partial count out as 3/4'); } -// A10 — remove takes the explore registration with the file +// A10 — remove takes the prefetch registration with the file { const p = freshProject({}); guidance(p, ['install', '--part', 'hooks', '--json']); - const { explore: exp } = semblePaths(p); + const { prefetch: pre } = semblePaths(p); const r = guidance(p, ['remove', '--part', 'hooks', '--json']); check('A10.exit', r.status, 0, 'remove --part hooks exits 0'); const s = readSettings(p); - check('A10.registrationGone', Object.prototype.hasOwnProperty.call(s.hooks || {}, 'SubagentStart'), false, - 'the SubagentStart array emptied and its key was pruned'); - check('A10.fileGone', existsSync(exp), false, 'the explore .mjs is deleted too'); + check('A10.registrationGone', Object.prototype.hasOwnProperty.call(s.hooks || {}, 'UserPromptSubmit'), false, + 'the UserPromptSubmit array emptied and its key was pruned'); + check('A10.fileGone', existsSync(pre), false, 'the prefetch .mjs is deleted too'); check('A10.noDanglingPath', - JSON.stringify(s).includes('semble-explore.mjs'), false, + JSON.stringify(s).includes('semble-prefetch.mjs'), false, 'no settings entry is left pointing at the deleted file'); } @@ -481,7 +544,14 @@ const FOREIGN = { const p = freshProject({}); guidance(p, ['install', '--part', 'rule', '--json']); const rulePath = join(p, '.claude', 'rules', 'semble-first.md'); - check('B1.created', readRaw(rulePath), TPL_TEXT, 'a fresh rule file is byte-identical to the template'); + check('B1.created', readRaw(rulePath), TPL_TEXT, + 'a fresh rule file is the plugin template, byte for byte'); + check('B1.stamped', metaOf(readRaw(rulePath)), + { + doc_type: 'llm', version: `"${PLUGIN_VERSION}"`, + generated_by: '"brewcode:semble-setup"', last_updated: null, + }, + 'the stamp is the one baked at release; the installer writes nothing of its own'); const edited = TPL_TEXT + '\n\n'; writeFileSync(rulePath, edited); @@ -493,10 +563,59 @@ const FOREIGN = { check('B2.status', st.rule.state, 'user_modified', 'status reports user_modified'); const rf = guidance(p, ['install', '--part', 'rule', '--force', '--json']); - check('B3.forced', readRaw(rulePath), TPL_TEXT, '--force restores the template'); + check('B3.forced', readRaw(rulePath), TPL_TEXT, '--force restores the template byte for byte'); + check('B3.stampKept', metaOf(readRaw(rulePath)).last_updated, null, + '--force writes no install-time last_updated; the copy stays byte-identical'); check('B3.exit', rf.status, 0, 'the forced overwrite exits 0'); const backups = readdirSync(join(p, '.claude', 'rules')).filter((f) => f.startsWith('semble-first.md.bak.')); check('B3.backup', backups.length, 1, 'the overwritten user version was backed up first'); + check('B3.noTempLeft', readdirSync(join(p, '.claude', 'rules')).some((f) => f.includes('.rendered.')), false, + 'the render temp file never survives an install'); +} + +// B5. --force restores the template byte for byte. The rule is a byte copy, so a locally +// chosen doc_type and any extra frontmatter key go with the prose -- they survive only in the +// backup. Preserving them would break the byte identity setup-status's `cmp` depends on. +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'rule', '--json']); + const rulePath = join(p, '.claude', 'rules', 'semble-first.md'); + const tailored = readRaw(rulePath) + .replace(/^doc_type: llm$/m, 'doc_type: user') + .replace(/^description: /m, 'owner: docs-team\ndescription: ') + '\n\n'; + writeFileSync(rulePath, tailored); + const st = safeParse(guidance(p, ['status', '--json']).stdout); + check('B5.userModified', st.rule.state, 'user_modified', 'the tailored rule is user_modified on prose, not on its stamp'); + guidance(p, ['install', '--part', 'rule', '--force', '--json']); + const after = readRaw(rulePath); + check('B5.byteIdentical', after, TPL_TEXT, '--force restores the template byte for byte'); + check('B5.docTypeReset', metaOf(after).doc_type, 'llm', 'a locally chosen doc_type is replaced, not carried across'); + check('B5.unknownKeyDropped', /^owner: docs-team$/m.test(after), false, + 'frontmatter keys the template does not define go with the rest of the file'); + const bak = readdirSync(join(p, '.claude', 'rules')).filter((f) => f.startsWith('semble-first.md.bak.')); + check('B5.backup', bak.length, 1, 'the replaced file was backed up first'); + check('B5.backupIsTheUserFile', readRaw(join(p, '.claude', 'rules', bak[0])), tailored, + 'the backup holds the user version verbatim, local frontmatter included'); +} + +// B6. A rule installed by an older version of this skill carries an installer-written +// `last_updated` and, after a release, an older `version`. The four metadata keys are stripped +// from both sides before the managed verdict, and the file is re-synced to the plugin bytes +// without --force and without a backup. +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'rule', '--json']); + const rulePath = join(p, '.claude', 'rules', 'semble-first.md'); + writeFileSync(rulePath, readRaw(rulePath) + .replace(/^version: .*$/m, 'version: "4.0.0"') + .replace(/^(generated_by: .*)$/m, '$1\nlast_updated: "2020-01-01"')); + const st = safeParse(guidance(p, ['status', '--json']).stdout); + check('B6.stillManaged', st.rule.state, 'managed', 'a legacy stamp on unchanged prose is still a managed rule'); + const r = guidance(p, ['install', '--part', 'rule', '--json']); + check('B6.reSynced', readRaw(rulePath), TPL_TEXT, 'the legacy stamp is re-synced to the plugin bytes without --force'); + check('B6.reportedChanged', safeParse(r.stdout).changed.length, 1, 'the re-sync is reported as a change, not as unchanged'); + check('B6.noBackup', readdirSync(join(p, '.claude', 'rules')).filter((f) => f.startsWith('semble-first.md.bak.')).length, 0, + 'a metadata-only re-sync takes no backup: nothing of the user\'s was overwritten'); } // Template content — the three facts that make every generated call work @@ -515,6 +634,253 @@ const FOREIGN = { 'the rule denies the non-existent watcher'); } +// ═══════════════════════════════════════════════════════════════════════════ +// J. .sembleignore — same managed-file policy as the rule, no frontmatter +// +// semble 0.5.4 (index/file_walker.py:_load_ignore_for_dir) reads exactly two +// files per directory, ./.gitignore and ./.sembleignore, and nothing else — no +// core.excludesFile, no ~/.gitignore_global, no call to git. A repo-root +// .sembleignore is therefore the ONLY way to keep a globally-ignored tree such +// as .claude/ out of the index. Measured on this repo: 871 -> 590 indexed files. +// ═══════════════════════════════════════════════════════════════════════════ +const IGNORE_TPL_TEXT = readFileSync(join(ASSETS, 'sembleignore.template'), 'utf8'); +{ + const p = freshProject({}); + const ignorePath = join(p, '.sembleignore'); + const r = guidance(p, ['install', '--part', 'ignore', '--json']); + check('J1.exit', r.status, 0, 'install --part ignore exits 0'); + check('J1.created', readRaw(ignorePath), IGNORE_TPL_TEXT, + 'the ignore file is byte-identical to the template — it carries no frontmatter to stamp'); + check('J1.reported', safeParse(r.stdout).changed.length, 1, 'the creation is reported as exactly one change'); + check('J1.status', safeParse(guidance(p, ['status', '--json']).stdout).ignore, + { state: 'managed', path: ignorePath }, 'status reports it managed, at the repo root'); + + const rr = guidance(p, ['install', '--part', 'ignore', '--json']); + check('J1.idempotent', [rr.status, safeParse(rr.stdout).changed.length, safeParse(rr.stdout).unchanged.length], + [0, 0, 1], 'a second install is an honest no-op, reported as unchanged'); +} +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'ignore', '--json']); + const ignorePath = join(p, '.sembleignore'); + const edited = IGNORE_TPL_TEXT + '\n# my own exclusion\nfixtures/\n'; + writeFileSync(ignorePath, edited); + const r = guidance(p, ['install', '--part', 'ignore', '--json']); + check('J2.noClobber', readRaw(ignorePath), edited, + 'a user-edited .sembleignore is never blind-overwritten — the user may have excluded something deliberately'); + check('J2.exit', r.status, 0, 'the no-clobber path still exits 0'); + check('J2.reported', safeParse(r.stdout).skipped.length, 1, 'and it is reported as skipped, not as unchanged'); + check('J2.status', safeParse(guidance(p, ['status', '--json']).stdout).ignore.state, 'user_modified', + 'status reports user_modified'); + + const rf = guidance(p, ['install', '--part', 'ignore', '--force', '--json']); + check('J3.forced', readRaw(ignorePath), IGNORE_TPL_TEXT, '--force regenerates the template'); + check('J3.exit', rf.status, 0, 'the forced overwrite exits 0'); + check('J3.backup', readdirSync(p).filter((f) => f.startsWith('.sembleignore.bak.')).length, 1, + 'the overwritten user version was backed up first'); +} +// ═══════════════════════════════════════════════════════════════════════════ +// J3. measured per-repo candidates +// +// The template's per-repo section ships EMPTY and no static pattern can fill +// it: duplicate trees and corpus hogs are layout-specific. install measures the +// repo and writes what it found - commented out. Excluding something the user +// wanted indexed is the worse error, because it fails silently. +// ═══════════════════════════════════════════════════════════════════════════ +{ + const BEGIN = '# --- brewcode:semble measured candidates ---'; + const END = '# --- end brewcode:semble measured candidates ---'; + const ON = { SEMBLE_NO_CANDIDATES: '' }; + + // A repo with one honest source tree and one byte-identical mirror of it. + const p = freshProject({}); + const ignorePath = join(p, '.sembleignore'); + for (const d of ['src', '.mirror']) mkdirSync(join(p, d), { recursive: true }); + for (let i = 0; i < 8; i++) { + const body = `export function f${i}() { return ${i}; }\n`.repeat(40); + writeFileSync(join(p, 'src', `m${i}.mjs`), body); + writeFileSync(join(p, '.mirror', `m${i}.mjs`), body); // byte-identical copy + } + // And one prose file big enough to be a corpus hog on its own. + writeFileSync(join(p, 'CHANGELOG.md'), '- a release note line\n'.repeat(4000)); + + const r = guidance(p, ['install', '--part', 'ignore', '--json'], ON); + const text = readRaw(ignorePath); + const lines = text.split('\n'); + const bi = lines.indexOf(BEGIN); + const ei = lines.indexOf(END); + const body = lines.slice(bi + 1, ei); + check('J3.exit', r.status, 0, 'the annotated install exits 0'); + check('J3.blockPresent', + [lines.filter((l) => l === BEGIN || l === END), body.filter((l) => l.startsWith('# /')).length], + [[BEGIN, END], 2], + 'exactly one BEGIN then one END delimit the block, with both measured proposals between them'); + check('J3.mirror', body.some((l) => l.startsWith('# /.mirror/') && l.includes('duplicate-tree')), true, + 'the byte-identical mirror tree is proposed, and named as a duplicate tree'); + check('J3.notSrc', body.some((l) => l.startsWith('# /src/')), false, + 'the ORIGINAL tree is never proposed - excluding it would delete the repo from the corpus'); + check('J3.heavyFile', body.some((l) => l.startsWith('# /CHANGELOG.md') && l.includes('heavy-file')), true, + 'the one prose file carrying a large share of the corpus is proposed too'); + check('J3.allCommented', body.filter((l) => l.trim() && !l.startsWith('#')), [], + 'EVERY proposal is commented out - the scan proposes, it never excludes'); + check('J3.reported', safeParse(r.stdout).changed.some((l) => l.startsWith('candidates: 2 measured proposal')), true, + 'the report says how many proposals were written, and that they are inert'); + check('J3.evidence', body.filter((l) => l.startsWith('# /')).every((l) => /\d/.test(l)), true, + 'every proposal carries its measurement, so the user can judge it instead of trusting it'); + + // The block must not make the file look user-modified, or no template update + // could ever reach an annotated install again. + check('J3.stillManaged', safeParse(guidance(p, ['status', '--json'], ON).stdout).ignore.state, 'managed', + 'an annotated .sembleignore is still managed - the block is stripped before the comparison'); + + const r2 = guidance(p, ['install', '--part', 'ignore', '--json'], ON); + check('J3.idempotent', readRaw(ignorePath), text, + 'a second install re-proposes nothing and rewrites nothing'); + check('J3.idempotentReport', safeParse(r2.stdout).changed.filter((l) => l.startsWith('candidates:')), [], + 'and it does not claim to have written proposals it did not write'); + + // A decision the user made inside the block survives, uncommented, forever. + const decided = readRaw(ignorePath).replace('# /.mirror/', '/.mirror/'); + writeFileSync(ignorePath, decided); + const r3 = guidance(p, ['install', '--part', 'ignore', '--json'], ON); + check('J3.decisionKept', readRaw(ignorePath).includes('\n/.mirror/'), true, + 'an uncommented decision inside the block is never re-commented or dropped'); + check('J3.noDuplicate', (readRaw(ignorePath).match(/\/\.mirror\//g) || []).length, 1, + 'and the path is never proposed a second time next to the decision the user already made'); + check('J3.decidedManaged', safeParse(guidance(p, ['status', '--json'], ON).stdout).ignore.state, 'managed', + 'a user decision inside the block still leaves the file managed'); + check('J3.exit3', r3.status, 0, 'the run over a decided block exits 0'); + + // A user edit OUTSIDE the block is a real user_modified file: install skips it, + // and the annotator must not sneak a write in behind that skip. + const p2 = freshProject({}); + guidance(p2, ['install', '--part', 'ignore', '--json'], ON); + const edited = `${readRaw(join(p2, '.sembleignore'))}\n# mine\nfixtures/\n`; + writeFileSync(join(p2, '.sembleignore'), edited); + const r4 = guidance(p2, ['install', '--part', 'ignore', '--json'], ON); + check('J3.noClobber', readRaw(join(p2, '.sembleignore')), edited, + 'a user-modified .sembleignore is not annotated either - the skip means skip'); + check('J3.noClobberExit', r4.status, 0, 'and that run still exits 0'); + + // A repo with nothing worth proposing gets no block at all. + const p3 = freshProject({}); + writeFileSync(join(p3, 'only.mjs'), 'export const x = 1;\n'); + guidance(p3, ['install', '--part', 'ignore', '--json'], ON); + check('J3.emptyNoBlock', readRaw(join(p3, '.sembleignore')).includes(BEGIN), false, + 'a repo with no duplicate tree and no hog gets no block - an empty section is noise'); + check('J3.emptyByteIdentical', readRaw(join(p3, '.sembleignore')), IGNORE_TPL_TEXT, + 'and that file stays byte-identical to the template'); +} + +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const ignorePath = join(p, '.sembleignore'); + check('J4.installedByAll', existsSync(ignorePath), true, '--part all installs the ignore file too'); + const r = guidance(p, ['remove', '--part', 'ignore', '--json']); + check('J4.removeExit', r.status, 0, 'remove --part ignore exits 0'); + check('J4.gone', existsSync(ignorePath), false, 'and the file is really gone'); + check('J4.status', safeParse(guidance(p, ['status', '--json']).stdout).ignore.state, 'absent', + 'status agrees it is absent again'); +} +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const ignorePath = join(p, '.sembleignore'); + writeFileSync(ignorePath, IGNORE_TPL_TEXT + '\n# mine\n'); + const r = guidance(p, ['remove', '--part', 'all', '--json']); + // Same contract as the rule file: uninstall leaves nothing behind, but it + // never destroys an edit — the user's version is copied aside first. + check('J5.gone', existsSync(ignorePath), false, 'uninstall removes the ignore file even when it was edited'); + check('J5.backup', readdirSync(p).filter((f) => f.startsWith('.sembleignore.bak.')).length, 1, + 'the edited version was backed up before removal'); + check('J5.reported', safeParse(r.stdout).changed.some((s) => s.startsWith('ignore: removed user-modified')), true, + 'and the report says the removed file was user-modified, naming the backup'); +} +// J6 — the template content is the fix, so assert what it actually excludes. +{ + const has = (line) => new RegExp('^' + line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$', 'm').test(IGNORE_TPL_TEXT); + check('J6.tmp', has('.claude/tmp/'), true, + '.claude/tmp/ is excluded — 214 of this repo\'s 871 indexed files were vendored upstream copies living there'); + check('J6.reports', has('.claude/reports/'), true, '.claude/reports/ is excluded — generated agent output, not source'); + check('J6.projectAuthoredKept', + ['.claude/skills/', '.claude/agents/', '.claude/rules/', '.claude/commands/', '.claude/hooks/'].map(has), + [false, false, false, false, false], + 'nothing project-authored under .claude/ is excluded: hiding something the user wanted found is the worse error'); + check('J6.noDefaults', ['node_modules/', '.venv/', 'dist/', '__pycache__/'].map(has), [false, false, false, false], + 'semble\'s own _DEFAULT_IGNORED_DIRS are not repeated — only what it misses'); + check('J6.mechanism', IGNORE_TPL_TEXT.includes('_load_ignore_for_dir'), true, + 'the file names the exact upstream function that reads it, so the claim can be re-verified'); +} + +// J7 — the negation-bypass blocks. +// +// file_walker.py:_is_ignored sets `found = not ignored and Path(pat).suffix`, +// and _walk yields on `found or suffix in extensions`. So a `!` un-ignore whose +// text ends in an extension SKIPS the extension filter, and a suffix that +// belongs to no content type gets indexed anyway. Verified against semble 0.5.4: +// with `.gitignore` = `package-lock.json / !sub/package-lock.json / *.png / +// !keep.png`, walk_files(root, get_extensions([CODE])) returned +// ['a.py', 'keep.png', 'sub/package-lock.json'] — .png and .json are in NO +// bucket. Measured cost on this repo: 552 chunks of lockfile (5.9% of the whole +// index, and the only .json in it) plus 143 chunks of decoded PNG. +// +// The cure is a re-ignore here, and it works because _load_ignore_for_dir +// concatenates .gitignore lines FIRST and .sembleignore lines SECOND into one +// GitIgnoreSpec while _is_ignored keeps the LAST match. Re-verified: adding +// `*.png` + `package-lock.json` to .sembleignore reduced the same walk to +// ['a.py', 'w.yml']. +{ + const lines = IGNORE_TPL_TEXT.split('\n').map((l) => l.trim()); + const active = lines.filter((l) => l !== '' && !l.startsWith('#')); + const has = (pat) => active.includes(pat); + + check('J7.binaries', ['*.png', '*.jpg', '*.pdf', '*.woff2', '*.zip', '*.so', '*.sqlite'].map(has), + [true, true, true, true, true, true, true], + 'binary suffixes are re-ignored: against a plain .gitignore this is a no-op, against a negation it is the only lever'); + check('J7.lockfiles', ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock', 'go.sum'].map(has), + [true, true, true, true, true], + 'lockfiles are re-ignored — one of them was 5.9% of this workspace index'); + check('J7.pnpmReason', has('pnpm-lock.yaml'), true, + 'pnpm-lock.yaml especially: it is a .yaml, so unlike the others it reaches the config bucket with no bypass at all'); + + // A `!` line in OUR OWN template would re-open the exact hole the two blocks + // above close, and it would win, being last. + check('J7.noNegation', active.filter((l) => l.startsWith('!')), [], + 'the template contains no `!` un-ignore: one would re-open the bypass and, being last, would beat every rule above it'); + + check('J7.explains', ['_is_ignored', 'found'].map((s) => IGNORE_TPL_TEXT.includes(s)), [true, true], + 'the bypass is named in the file, not just worked around silently'); +} + +// J8 — the per-repo section ships EMPTY. +// +// Duplicate mirror trees (2202 chunks / 15 of 80 result slots here) and long +// changelogs (503 chunks / 9 of 80) were the two biggest wins in the round-2 +// measurement, and neither is generic: the generic form of "this tree is a copy +// of that tree" is content-hash dedup, not a filename, and a changelog is the +// right answer to "when did X land". Shipping either pre-filled would exclude +// something a user wanted indexed — the failure mode this template exists to +// avoid, and a silent one. They are documented instead, as commented guidance. +{ + const active = IGNORE_TPL_TEXT.split('\n').map((l) => l.trim()) + .filter((l) => l !== '' && !l.startsWith('#')); + check('J8.noRepoSpecific', + ['.codex/', '/skills/', 'RELEASE-NOTES.md', 'CHANGELOG.md', 'docs/'].filter((p) => active.includes(p)), + [], + 'nothing layout-specific to any one repo is shipped active — .codex/ is another agent runtime\'s directory, exactly as authored as .claude/skills/'); + check('J8.documented', + ['DUPLICATE TREES', 'LONG CHANGELOGS'].map((s) => IGNORE_TPL_TEXT.includes(s)), + [true, true], + 'both are explained in the per-repo section instead, with the measured cost, so the user can opt in knowingly'); + check('J8.recipe', IGNORE_TPL_TEXT.includes('semble-project.sh candidates'), true, + 'and the section names the command that measures the user\'s own offenders, instead of a snippet they have to run by hand'); + check('J8.proposalsOnly', IGNORE_TPL_TEXT.includes('COMMENTED OUT'), true, + 'the section states outright that measured proposals exclude nothing until the user uncomments one'); + check('J8.anchoring', IGNORE_TPL_TEXT.includes('/skills/` hits only'), true, + 'root-anchoring is spelled out: `skills/` would match every */skills/ in the repo, which is the dangerous default'); +} + // ═══════════════════════════════════════════════════════════════════════════ // C. CLAUDE.md marker block // ═══════════════════════════════════════════════════════════════════════════ @@ -529,8 +895,13 @@ const END = ''; check('C1.begin', once.split(BEGIN).length - 1, 1, 'exactly one BEGIN marker after insert'); check('C1.end', once.split(END).length - 1, 1, 'exactly one END marker after insert'); check('C1.kept', once.startsWith(pre.replace(/\s*$/, '')), true, 'the pre-existing content is kept verbatim at the top'); - check('C1.body', once.includes('> Not indexed: `.html`, `.json`/`.csv`. Details: `.claude/rules/semble-first.md`.'), true, - 'the block carries the verbatim last line of the design body'); + // `.html`/`.htm` ARE indexed — semble's files.py maps them to the docs bucket. + // The old line claimed otherwise and sent users to rg for a corpus semble + // already had. The rule template and assets/INSTALL.md carry the same fix. + check('C1.body', once.includes('> Not indexed: `.json`/`.csv`, `.mdx`/`.txt`. Details: `.claude/rules/semble-first.md`.'), true, + 'the block carries the verbatim last line of the design body, with .html absent from the not-indexed list'); + check('C1.htmlNotClaimedMissing', /Not indexed:[^\n]*\.html/.test(once), false, + 'no variant of the line may still call .html unindexed'); const r2 = guidance(p, ['install', '--part', 'claudemd', '--json']); const twice = readRaw(md); @@ -591,9 +962,11 @@ function session(proj, extra) { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: - 'semble_code MCP was just registered; verification is pending. Run /brewcode:semble-setup resume before relying on semantic search.', + 'semble_code MCP is registered but not verified yet. It is usable now — mcp__semble_code__search (repo=' + + p + ', top_k=5, max_snippet_lines=10); the first call rebuilds the index and may take minutes. ' + + 'Run /brewcode:semble-setup resume to close the state out.', }, - }, 'awaiting_reload -> resume nudge with additionalContext'); + }, 'awaiting_reload -> the tool is advertised as usable now, plus the resume close-out'); } { const p = freshProject({ state: READY_STATE({ phase: 'error' }) }); @@ -633,214 +1006,608 @@ function session(proj, extra) { } // ═══════════════════════════════════════════════════════════════════════════ -// E. PreToolUse reminder — advisory only +// P. UserPromptSubmit prefetch — the hook that replaced the two advisory ones +// +// The predecessors emitted `additionalContext` telling the model to prefer +// semble. They converted at ZERO — 0/18 on the main channel, 0/11 on the +// Explore/subagent channel — with delivery proven three independent ways. This +// hook runs the search itself and hands over the RESULT instead: 5/6 sessions +// opened an injected path, at fewer tool calls than control in 5/6 questions. +// +// FAIL-OPEN is the property under test above all others: this runs on every +// prompt the user types, so it is tested by breaking things, not by asserting +// the happy path. Every row below must end in `{}` on stdout and exit 0. // ═══════════════════════════════════════════════════════════════════════════ -{ - const p = freshProject({}); - const r = reminder(p, 'Bash', { command: 'rg "how does auth work"' }); - check('E1.noState', safeParse(r.stdout), {}, 'no state file -> silence'); - check('E1.exit', r.status, 0, 'exit 0 with no state file'); -} -{ - const p = freshProject({ state: READY_STATE() }); - const r = reminder(p, 'Bash', { command: 'rg "how does auth work"' }); - check('E2.emit', safeParse(r.stdout), REMIND_OK(p), 'a plain intent query gets the exact advisory string'); - check('E2.exit', r.status, 0, 'the emitting path exits 0'); - check('E2.marker', existsSync(join(p, '.claude', 'semble', '.reminder-ts')), true, 'the throttle marker is written on emit'); - const again = reminder(p, 'Bash', { command: 'rg "where do we persist sessions"' }); - check('E3.throttled', safeParse(again.stdout), {}, 'a second reminder inside the 600 s window is suppressed'); -} -const SILENT_BASH = [ - ['E4.filesWithMatches', 'rg -l foo', 'the -l enumeration flag'], - ['E5.regex', "rg 'foo.*bar'", 'a real regex pattern'], - ['E6.fixedStrings', 'grep -F literal .', 'the -F literal flag'], - ['E7.findName', "find . -name '*.ts'", 'a find -name filename search'], - ['E8.pipedWc', 'rg intent | wc -l', 'a pipeline into wc'], - ['E9.count', 'rg -c handler', 'the -c count flag'], - ['E10.wordRegexp', 'rg -w handler', 'the -w word-regexp flag'], - ['E11.onlyMatching', 'rg -o handler', 'the -o only-matching flag'], - ['E12.shortPattern', 'rg ab', 'a pattern shorter than 3 characters'], - ['E13.pathPattern', 'rg src/store/session', 'a path-like pattern'], - ['E14.fileName', 'rg session.ts', 'a filename-like pattern'], - ['E15.mentionsSemble', 'rg "semble cache layout"', 'a command that already mentions semble'], - ['E16.notASearch', 'npm run build', 'a command that is not a search at all'], - ['E17.midWord', 'echo ripgrep is nice', 'grep appearing mid-word, not at a command boundary'], - ['E18.sortPipe', 'rg handler | sort', 'a pipeline into sort'], -]; -for (const [name, command, why] of SILENT_BASH) { - const p = freshProject({ state: READY_STATE() }); - const r = reminder(p, 'Bash', { command }); - check(name, safeParse(r.stdout), {}, `silent on \`${command}\` because of ${why}`); -} +// ── telemetry readers (shared with the session hook further down) ─────────── +const telemetryFile = (p) => join(p, '.claude', 'semble', 'telemetry.jsonl'); +const telemetry = (p) => + (existsSync(telemetryFile(p)) ? readFileSync(telemetryFile(p), 'utf8') : '') + .split('\n').filter((l) => l).map((l) => JSON.parse(l)); +const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const dropTs = (r) => { const { ts, ...rest } = r; return rest; }; +/** Same, minus the wall-clock `ms` — a duration cannot be asserted exactly. */ +const dropMs = (r) => { const { ms, ...rest } = dropTs(r); return rest; }; +/** + * Telemetry record #i without its ts — or, when the fixture stopped producing + * that record, a readable diagnostic INSTEAD of a TypeError. A throw here kills + * the whole runner before it prints a single result, so a stale fixture used to + * take every later assertion in the file down with it. + */ +const trec = (p, i) => { + const recs = telemetry(p); + if (i < recs.length) return dropTs(recs[i]); + return 'MISSING telemetry record #' + i + ' — the fixture produced ' + + recs.length + ' record(s): ' + JSON.stringify(recs.map((r) => r.ev + (r.why ? ':' + r.why : ''))); +}; +/** Field `k` of telemetry record #i, or the same diagnostic string. */ +const tfield = (p, i, k) => { const r = trec(p, i); return typeof r === 'string' ? r : r[k]; }; -{ - const p = freshProject({ state: READY_STATE({ phase: 'awaiting_reload' }) }); - check('E19.mcpUnavailable', safeParse(reminder(p, 'Bash', { command: 'rg "how does auth work"' }).stdout), {}, - 'silent while the MCP is not verified yet (phase != ready)'); -} -{ - const p = freshProject({ state: READY_STATE({ enabled: false }) }); - check('E20.disabled', safeParse(reminder(p, 'Bash', { command: 'rg "how does auth work"' }).stdout), {}, - 'silent when the project has semble disabled'); -} -{ - const p = freshProject({ state: READY_STATE() }); - check('E21.otherTool', safeParse(reminder(p, 'Read', { file_path: '/x' }).stdout), {}, - 'silent for tools other than Bash and Grep'); -} -{ - const p = freshProject({ state: READY_STATE() }); - const r = reminder(p, 'Grep', { pattern: 'how does auth work' }); - check('E22.grepTool', safeParse(r.stdout), REMIND_OK(p), 'the native Grep tool gets the same advisory'); -} -{ - const p = freshProject({ state: READY_STATE() }); - check('E23.grepEnum', safeParse(reminder(p, 'Grep', { pattern: 'how does auth work', output_mode: 'files_with_matches' }).stdout), - {}, 'the native Grep tool in files_with_matches mode is enumeration -> silent'); -} -{ - const p = freshProject({ state: READY_STATE() }); - check('E24.emptyCommand', safeParse(reminder(p, 'Bash', {}).stdout), {}, 'silent when tool_input carries no command'); -} -{ - const p = freshProject({ stateDir: true }); - const r = reminder(p, 'Bash', { command: 'rg "how does auth work"' }); - check('E25.stateDir', [r.status, safeParse(r.stdout)], [0, {}], 'reminder: state.json as a directory -> {} exit 0'); -} -{ - const bad = runNode(REMINDER_SRC, '{ not json'); - const empty = runNode(REMINDER_SRC, ''); - allReminderOutputs.push(bad.stdout, empty.stdout); - check('E26.badStdin', [bad.status, safeParse(bad.stdout)], [0, {}], 'reminder: malformed stdin -> {} exit 0'); - check('E27.emptyStdin', [empty.status, safeParse(empty.stdout)], [0, {}], 'reminder: empty stdin -> {} exit 0'); -} +// ── a stub `uvx` — the only way to exercise the search without a real index ── +// It records its argv (so the frozen flag set is observable), optionally +// sleeps past the hook's 3 s child cap, then prints SEMBLE_STUB_OUT and exits +// SEMBLE_STUB_RC. +const STUB_BIN = join(BASE, 'stub-bin'); +mkdirSync(STUB_BIN, { recursive: true }); +const STUB_UVX = join(STUB_BIN, 'uvx'); +writeFileSync(STUB_UVX, [ + '#!/usr/bin/env bash', + 'printf "%s\\n" "$*" >> "$SEMBLE_STUB_LOG"', + // The env the child is handed, recorded separately from argv: the cache root + // is passed by environment, not by flag, and it is the one thing that has to + // agree with the MCP registration. + 'printf "%s\\n" "${SEMBLE_CACHE_LOCATION-}" >> "$SEMBLE_STUB_ENVLOG"', + 'if [ -n "${SEMBLE_STUB_SLEEP:-}" ]; then sleep "$SEMBLE_STUB_SLEEP"; fi', + 'printf "%s" "${SEMBLE_STUB_OUT:-}"', + 'exit "${SEMBLE_STUB_RC:-0}"', + '', +].join('\n')); +chmodSync(STUB_UVX, 0o755); -// E28 — the reminder can never block, in any recorded output -{ - const joined = allReminderOutputs.join('\n'); - check('E28.noDecision', joined.includes('permissionDecision'), false, 'no recorded output ever carries permissionDecision'); - check('E28.noDeny', joined.includes('"deny"'), false, 'no recorded output ever carries a deny'); - check('E28.noUpdatedInput', joined.includes('updatedInput'), false, 'no recorded output ever carries updatedInput'); - check('E28.oneObject', allReminderOutputs.every((o) => o.trim().split('\n').length === 1), true, - 'every reminder invocation printed exactly one line of JSON'); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// X. SubagentStart explore hook — matcher-gated, advisory only -// ═══════════════════════════════════════════════════════════════════════════ -const allExploreOutputs = []; -function explore(proj, agentType, extra) { - const payload = { session_id: 'S1', cwd: proj, hook_event_name: 'SubagentStart', ...(extra || {}) }; - if (agentType !== undefined) payload.agent_type = agentType; - const r = runNode(EXPLORE_SRC, JSON.stringify(payload)); - allExploreOutputs.push(r.stdout); - return r; -} - -const EXPLORE_OK = (cwd) => ({ - hookSpecificOutput: { - hookEventName: 'SubagentStart', - additionalContext: - 'semble: call mcp__semble_code__search directly first (repo="' + cwd + - '", top_k=5) for intent/behavior questions — it is already available, no ' + - 'ToolSearch needed. rg/Grep stay for exact/exhaustive matches.', - }, +// `content` is in the fixture on purpose: the hook asks for +// --max-snippet-lines 0, but if a future semble ever returns a snippet anyway +// it must still never reach the model. +const HIT = (file_path, start_line) => + ({ file_path, start_line, end_line: start_line + 8, score: 0.81, content: 'SNIPPET-MUST-NOT-SHIP' }); +const STUB_HITS = JSON.stringify({ + query: 'q', + results: [HIT('src/store/session.ts', 41), HIT('src/hooks/prefetch.mjs', 12), HIT('docs/design.md', 3)], }); +// The measured framing, written out here in full ON PURPOSE: provenance, bare +// paths, directive. Any edit to the hook's wording has to be made twice, and +// the second time is this line — which is where the 5/6-conversion evidence is +// recorded. +const RENDERED = [ + 'Retrieval note (automatic, from a semantic index of THIS repository, built by semble over the working tree).', + 'These candidate locations were ranked for the question above before you started:', + ' 1. src/store/session.ts:41', + ' 2. src/hooks/prefetch.mjs:12', + ' 3. docs/design.md:3', + '', + 'Open the candidates that look right BEFORE running any search of your own; they are already ranked.', + 'If none of them answers the question, say so and search normally.', + 'Name the file you actually used in your answer.', +].join('\n'); +const PREFETCH_OK = { + hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: RENDERED }, +}; + +/** A prompt that passes gate v3: INTENT (`how`) + DOMAIN (`hook`), no suppressor. */ +const Q = 'how does the session hook decide what to emit'; +/** and the distiller's deterministic output for it. */ +const Q_DISTILLED = 'session hook decide emit'; + +const allPrefetchOutputs = []; +let stubSeq = 0; +/** + * @param opts {out, rc, sleep, path, stdin, extra} — every field is a way to + * break the hook. Returns the stub's recorded argv lines as `argv`, so + * "the search was never spawned" is a direct assertion, not an inference. + */ +function prefetch(proj, prompt, opts) { + const o = opts || {}; + stubSeq++; + const log = join(BASE, `uvx-${stubSeq}.log`); + const envLog = join(BASE, `uvx-${stubSeq}.env`); + const payload = { + session_id: 'S1', cwd: proj, hook_event_name: 'UserPromptSubmit', prompt, ...(o.extra || {}), + }; + const r = spawnSync(process.execPath, [PREFETCH_SRC], { + input: Object.prototype.hasOwnProperty.call(o, 'stdin') ? o.stdin : JSON.stringify(payload), + encoding: 'utf8', + cwd: BASE, + timeout: 20000, + env: { + ...process.env, + PATH: Object.prototype.hasOwnProperty.call(o, 'path') ? o.path : `${STUB_BIN}:${process.env.PATH}`, + SEMBLE_STUB_LOG: log, + SEMBLE_STUB_ENVLOG: envLog, + SEMBLE_STUB_OUT: Object.prototype.hasOwnProperty.call(o, 'out') ? o.out : STUB_HITS, + SEMBLE_STUB_RC: String(o.rc === undefined ? 0 : o.rc), + SEMBLE_STUB_SLEEP: o.sleep === undefined ? '' : String(o.sleep), + }, + }); + const out = { + stdout: r.stdout || '', + stderr: r.stderr || '', + status: r.status, + argv: existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter((l) => l) : [], + cacheEnv: existsSync(envLog) ? readFileSync(envLog, 'utf8').split('\n').filter((l) => l) : [], + }; + allPrefetchOutputs.push(out.stdout); + return out; +} + +const markerOf = (p) => { + const f = join(p, '.claude', 'semble', '.prefetch-ts'); + return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null; +}; + +const { + gateV3, distill, PIN_SPEC, CONTENT_ARGS, THROTTLE_MS, COOLDOWN_MS, TIMEOUT_COOLDOWN_MS, + INDEX_FILES, cacheRootOf, defaultCacheRoot, indexReady, repoHashOf, +} = await import(pathToFileURL(PREFETCH_SRC).href); + +// P1 — the whole point of the release: a ranked list of paths, and nothing else. { const p = freshProject({ state: READY_STATE() }); - const r = explore(p, 'Explore'); - check('X1.ready', safeParse(r.stdout), EXPLORE_OK(p), 'Explore on a ready project gets the exact advisory string'); - check('X1.exit', r.status, 0, 'the emitting path exits 0'); - const again = explore(p, 'Explore'); - check('X2.noThrottle', safeParse(again.stdout), EXPLORE_OK(p), - 'a second spawn is advised again — this hook has no throttle'); - check('X2.noMarker', existsSync(join(p, '.claude', 'semble', '.reminder-ts')), false, - 'the explore hook never writes the reminder throttle marker'); + const r = prefetch(p, Q); + check('P1.fires', safeParse(r.stdout), PREFETCH_OK, + 'a gate-passing prompt on a ready project gets the exact provenance+paths+directive block'); + check('P1.exit', r.status, 0, 'the emitting path exits 0'); + check('P1.noSnippet', r.stdout.includes('SNIPPET-MUST-NOT-SHIP'), false, + 'a snippet in the search result never reaches the model — 2/6 sessions answered off snippets with ZERO tool calls'); + check('P1.marker', typeof (markerOf(p) || {}).t, 'number', 'a successful firing stamps the throttle marker'); + check('P1.noCool', Object.prototype.hasOwnProperty.call(markerOf(p) || {}, 'cool'), false, + 'and never the failure cooldown'); + check('P1.telemetryKeys', telemetry(p).map((x) => Object.keys(x)), + [['ts', 'ev', 'src', 'sid', 'fired', 'why', 'q', 'n', 'ms', 'paths']], + 'exactly one record, with exactly the contracted keys in order'); + check('P1.record', dropMs(telemetry(p)[0]), { + ev: 'prefetch', src: 'prefetch', sid: 'S1', fired: true, why: 'behaviour-or-vocab', + q: Q_DISTILLED, n: 3, paths: ['src/store/session.ts', 'src/hooks/prefetch.mjs', 'docs/design.md'], + }, 'the record carries the injected PATHS — that array is the conversion denominator'); + check('P1.ms', typeof telemetry(p)[0].ms, 'number', 'and a numeric duration'); + check('P1.iso', ISO_RE.test(telemetry(p)[0].ts), true, 'the ts is an ISO-8601 instant'); } -const SILENT_AGENTS = [ - ['X3.generalPurpose', 'general-purpose'], - ['X4.plan', 'Plan'], - ['X5.projectAgent', 'brewcode:bash-expert'], - ['X6.lowercase', 'explore'], - ['X7.emptyType', ''], + +// P2 — the frozen search invocation. semble keys its cache by project path +// ALONE but rejects an index whose content-type set differs, so a mismatch here +// makes the hook and the MCP server evict each other's index on every alternation. +{ + const p = freshProject({ state: READY_STATE() }); + const r = prefetch(p, Q); + check('P2.argv', r.argv, [ + `--from semble[mcp]==0.5.4 semble search ${Q_DISTILLED} ${p} --content code docs config -k 3 --max-snippet-lines 0`, + ], 'the child is spawned exactly once, with the pinned spec, the distilled query and the frozen flags'); + const lib = readFileSync(REAL_LIB, 'utf8'); + check('P2.pinParity', PIN_SPEC, 'semble[mcp]==' + (/^SEMBLE_PIN_VERSION="\$\{SEMBLE_PIN_VERSION:-([^}"]+)\}"/m.exec(lib) || [])[1], + 'PIN_SPEC equals the pin the MCP registration uses'); + check('P2.contentParity', CONTENT_ARGS.join(' '), (/^SEMBLE_CONTENT_ARGS="([^"]+)"/m.exec(lib) || [])[1], + 'CONTENT_ARGS equals SEMBLE_CONTENT_ARGS byte for byte'); + check('P2.snippetsOff', r.argv[0].includes('--max-snippet-lines 0'), true, + 'snippets are switched off at the source, not filtered afterwards'); + check('P2.cacheEnv', r.cacheEnv, [cacheOf(p)], + 'and the child is handed SEMBLE_CACHE_LOCATION — semble keys its cache dir by project path ' + + 'ALONE, so it cannot notice that the hook and the server disagree about the ROOT: each just ' + + 'builds its own 20 MB copy, and the hook\'s copy is always cold'); +} + +// P2b — the cache root: which one, where it comes from, and what happens when +// the index it names is not there. This is the pair of defects that produced +// 0/8 firings in the first live round — a duplicate index under semble's +// default root, built inside a 3 s cap it could never finish, whose timeout +// then armed the ten-minute cooldown on the very first prompt of every session. +{ + check('P2b.rootFromState', cacheRootOf({ cacheRoot: '/somewhere/semble-code' }), '/somewhere/semble-code', + 'the root comes from state.json, written by semble-mcp.sh from the same helper that registers the server'); + check('P2b.rootFallback', [cacheRootOf({}), cacheRootOf(null), cacheRootOf({ cacheRoot: '' })], + [defaultCacheRoot(), defaultCacheRoot(), defaultCacheRoot()], + 'a state with no usable cacheRoot falls back to sc_cache_root_code\'s answer for this platform'); + check('P2b.rootIsCodeRoot', defaultCacheRoot().split('/').pop(), 'semble-code', + 'which is `semble-code`, NOT semble\'s own default `semble` — that one character was 40 MB of duplicate index'); + check('P2b.rootMatchesLib', + defaultCacheRoot() === spawnSync('bash', ['-c', + `. ${JSON.stringify(REAL_LIB)}; sc_cache_root_code`], + { encoding: 'utf8' }).stdout.trim(), true, + 'and it is byte-equal to what the shell helper the MCP registration uses computes'); + check('P2b.hashComputed', repoHashOf({}, '/x'), + createHash('sha256').update('/x').digest('hex'), + 'the repo hash is sha256(project path) — byte-matching semble cache.py and sc_repo_hash'); + check('P2b.hashIgnoresState', repoHashOf({ repoHash: REPO_HASH }, '/x'), + createHash('sha256').update('/x').digest('hex'), + 'and is NEVER taken from state.json: a hash carried over from another checkout (copied .claude/, ' + + 'moved repo, worktree, subdirectory) would vouch for a FOREIGN index and let the child spawn ' + + 'against a genuinely cold path — the 3 s timeout the readiness check exists to prevent'); + { + const real = mkdtempSync(join(tmpdir(), 'semble-hash-')); + const link = join(mkdtempSync(join(tmpdir(), 'semble-link-')), 'alias'); + symlinkSync(real, link); + check('P2b.hashResolvesSymlink', repoHashOf({}, link), repoHashOf({}, realpathSync(real)), + 'the path is resolved first, so a symlinked checkout keys the same index semble itself builds'); + } + check('P2b.indexFiles', INDEX_FILES, ['chunks.json', 'metadata.json', 'bm25_index', 'semantic_index'], + 'all four artefacts semble writes have to be there for the index to count as built'); +} +{ + const p = freshProject({ state: READY_STATE(), cold: true }); + const r = prefetch(p, Q); + check('P2b.coldSilent', [r.status, safeParse(r.stdout)], [0, {}], 'no index -> silence, exit 0'); + check('P2b.coldNoSpawn', r.argv, [], + 'and NO child: a cold build takes minutes, so a 3 s child can only ever kill it half-done, ' + + 'forever, at the price of the cap on every prompt'); + check('P2b.coldWhy', tfield(p, 0, 'why'), 'cold-index', + 'recorded as cold-index — its own token, so a fresh install is never mistaken for a broken one'); + check('P2b.coldNoCooldown', markerOf(p), null, + 'and it arms NOTHING: the prompt right after the MCP server builds the index must fire'); +} +for (const missing of INDEX_FILES) { + const only = INDEX_FILES.filter((n) => n !== missing); + const p = freshProject({ state: READY_STATE(), indexOnly: only }); + const r = prefetch(p, Q); + check(`P2b.partial.${missing}`, [safeParse(r.stdout), r.argv, tfield(p, 0, 'why')], [{}, [], 'cold-index'], + `an index missing ${missing} is a build that never finished, not an index`); +} +{ + const p = freshProject({ state: READY_STATE(), cold: true }); + prefetch(p, Q); + warmIndex(cacheOf(p), createHash('sha256').update(realpathSync(p)).digest('hex')); + const r = prefetch(p, Q); + check('P2b.warmsUp', safeParse(r.stdout), PREFETCH_OK, + 'and the very next prompt after the index appears fires — the cold case parks nothing'); + check('P2b.unitReady', + [indexReady(cacheOf(p), createHash('sha256').update(realpathSync(p)).digest('hex')), + indexReady(cacheOf(p), 'deadbeef'), indexReady('', REPO_HASH)], + [true, false, false], 'indexReady is total: an unknown hash or an empty root is "not ready", never a throw'); +} + +// P3 — throttle: an anti-storm guard, not a rate limiter. +{ + const p = freshProject({ state: READY_STATE() }); + prefetch(p, Q); + const again = prefetch(p, 'where in this codebase does the installer merge settings'); + check('P3.throttled', safeParse(again.stdout), {}, `a second firing inside the ${THROTTLE_MS} ms window is suppressed`); + check('P3.noSpawn', again.argv, [], 'and the search is never spawned — the throttle is checked before the gate'); + check('P3.recorded', dropTs(telemetry(p)[1]), { ev: 'prefetch', src: 'prefetch', sid: 'S1', fired: false, why: 'throttled' }, + 'the suppression is attributable in the log'); +} + +// P4 — gate v3, unit-tested on the shapes the 61-prompt corpus is made of. +// INTENT and (DOMAIN or REPOREF), minus four suppressors. Measured: fires 36%, +// precision 55%, recall 71%, F1 0.62. 55% is the honest ceiling of lexical +// rules — do not "improve" this without re-running the corpus. +const GATE_ROWS = [ + ['P4.empty', '', false, 'empty', 'nothing to search for'], + ['P4.slash', '/brewcode:semble-setup status and then show me the hooks', false, 'slash-command', + 'a slash command is an instruction to the CLI, not a question about code'], + ['P4.codeword', '++m', false, 'codeword-only', 'a bare codeword carries no question'], + ['P4.meta', 'ok', false, 'meta-reply', 'a meta-reply continues a turn, it does not open one'], + ['P4.metaRu', 'да', false, 'meta-reply', 'the Russian half of the same list'], + ['P4.short', 'how does auth work', false, 'too-short', 'under 30 characters there is not enough to distill'], + ['P4.long', 'x'.repeat(2001), false, 'too-long', 'over 2000 characters is a pasted document, not a question'], + ['P4.url', 'https://example.com/some/quite/long/path/page', false, 'bare-url', 'a bare URL is a reference, not a query'], + ['P4.path', './scripts/lib/semble-common.sh', false, 'bare-path', 'a bare path is already the answer'], + ['P4.noIntent', 'the installer rewrites the managed rule file in place, byte for byte', false, 'no-intent', + 'a statement is not a question'], + ['P4.noDomain', 'why did everything become so slow yesterday afternoon', false, 'no-domain-no-reporef', + 'an intent with no code noun and no repo anchor is not about this repo'], + ['P4.self', 'что ты сделал с конфигом хука в прошлый раз', false, 'self-reference', + 'the answer is in the transcript, not on disk'], + ['P4.taskref', 'в проекте где мы решили задачу #12 про хуки', false, 'task-reference', + 'a tracker number is not a code identifier'], + ['P4.literal', 'where is scripts/semble-guidance.sh referenced from in this repo', false, 'exact-literal', + 'an exact path is rg territory — rg won 2/2 there, 20x faster'], + ['P4.enum', 'list all the places where the hook writes telemetry in this repo', false, 'enumeration', + 'exhaustive enumeration: rg won 5/5, semble 2/5'], + ['P4.behaviour', Q, true, 'behaviour-or-vocab', 'INTENT plus a code noun is the core firing case'], + ['P4.reporef', 'у нас всё стало медленно работать после последнего изменения — почему', true, 'behaviour-or-vocab', + 'REPOREF alone readmits the vocabulary-mismatch class — this clause IS v3, and it is what lifted recall'], + ['P4.opinionExempt', 'как ты думаешь, где в этом плагине живёт логика кэша', true, 'behaviour-or-vocab', + '`как ты думаешь` solicits an opinion and is explicitly exempted from the self-reference suppressor'], ]; -for (const [name, agentType] of SILENT_AGENTS) { - const p = freshProject({ state: READY_STATE() }); - check(name, safeParse(explore(p, agentType).stdout), {}, - `silent for agent_type=\`${agentType}\` — only the exact string Explore is matched`); +for (const [name, prompt, fire, why, msg] of GATE_ROWS) { + check(name, gateV3(prompt), { fire, why }, msg); } + +// P4b — the two load-bearing details, measured that way and easy to break. { - const p = freshProject({ state: READY_STATE() }); - check('X8.missingType', safeParse(explore(p, undefined).stdout), {}, 'silent when agent_type is absent'); + check('P4b.codewordBody', gateV3('++mmm how does the hook decide'), { fire: false, why: 'too-short' }, + 'INTENT/DOMAIN/REPOREF read the codeword-STRIPPED body: 30 raw characters minus `++mmm ` is too short'); + check('P4b.codewordFires', gateV3('++m ' + Q), { fire: true, why: 'behaviour-or-vocab' }, + 'and a codeword prefix never suppresses a prompt that would otherwise fire'); + check('P4b.suppressorWholePrompt', gateV3('++m что ты сделал с конфигом хука в прошлый раз'), + { fire: false, why: 'self-reference' }, + 'the four suppressors read the WHOLE prompt, codewords included — that is how the corpus was scored'); } + +// P5 — the distiller. Measured against handing semble the raw prompt: hit@3 +// 11/16 vs 9/16, MRR 0.674 vs 0.398, paired 8 wins / 3 losses / 5 ties. { - const p = freshProject({ state: READY_STATE() }); - check('X9.nonStringType', safeParse(explore(p, 42).stdout), {}, 'silent when agent_type is not a string'); -} -{ - const p = freshProject({}); - const r = explore(p, 'Explore'); - check('X10.noState', [r.status, safeParse(r.stdout)], [0, {}], 'no state file -> {} exit 0'); -} -{ - const p = freshProject({ state: '{,}' }); - const r = explore(p, 'Explore'); - check('X11.corrupt', [r.status, safeParse(r.stdout)], [0, {}], - 'a corrupt state file -> {} exit 0 — the explore hook never reports state health'); -} -{ - const p = freshProject({ stateDir: true }); - const r = explore(p, 'Explore'); - check('X12.stateDir', [r.status, safeParse(r.stdout)], [0, {}], 'state.json as a directory -> {} exit 0'); -} -{ - const p = freshProject({ state: READY_STATE({ enabled: false }) }); - check('X13.disabled', safeParse(explore(p, 'Explore').stdout), {}, 'silent when the project has semble disabled'); -} -const SILENT_PHASES = ['awaiting_reload', 'verifying', 'disabled', 'error', 'prereq_ready']; -for (const phase of SILENT_PHASES) { - const p = freshProject({ state: READY_STATE({ phase }) }); - check(`X14.phase.${phase}`, safeParse(explore(p, 'Explore').stdout), {}, - `silent while phase is \`${phase}\` — only phase=ready is advised`); -} -{ - const bad = runNode(EXPLORE_SRC, '{ not json'); - const empty = runNode(EXPLORE_SRC, ''); - allExploreOutputs.push(bad.stdout, empty.stdout); - check('X15.badStdin', [bad.status, safeParse(bad.stdout)], [0, {}], 'explore: malformed stdin -> {} exit 0'); - check('X16.emptyStdin', [empty.status, safeParse(empty.stdout)], [0, {}], 'explore: empty stdin -> {} exit 0'); -} -{ - const joined = allExploreOutputs.join('\n'); - check('X17.noDecision', joined.includes('permissionDecision'), false, 'no recorded output ever carries permissionDecision'); - check('X17.noDeny', joined.includes('"deny"'), false, 'no recorded output ever carries a deny'); - check('X17.oneObject', allExploreOutputs.every((o) => o.trim().split('\n').length === 1), true, - 'every explore invocation printed exactly one line of JSON'); + check('P5.plain', distill(Q), Q_DISTILLED, 'stop-words are dropped and the code nouns survive in order'); + check('P5.codeFirst', distill('why is `merge_settings` slow in semble-guidance.sh and prefetch'), + 'merge_settings semble-guidance.sh semble-guidance merge settings slow semble guidance prefetch', + 'code-shaped tokens rank ahead of content words: backticked span, then .ext filename, then kebab/snake'); + check('P5.codeword', distill('++m ' + Q), Q_DISTILLED, 'a codeword prefix is stripped before distilling'); + check('P5.fence', distill('what does ```const x = 1``` do in the parser'), 'parser', + 'a fenced code block is removed outright — pasted code is not a query'); + check('P5.cap', distill('where do we validate the incoming webhook payload signature' + + ' before the router dispatches it onto the queue worker').split(' ').length, 9, + 'at most 9 keywords reach semble'); + check('P5.empty', distill(''), '', 'an empty prompt distills to the empty string, which the caller treats as a skip'); } // ═══════════════════════════════════════════════════════════════════════════ -// F. static guarantees of both hook files +// P6. FAIL-OPEN. Every row breaks something; every row must print exactly {} +// and exit 0. This is the highest-priority property in the skill: the hook +// runs on every prompt the user types. +// ═══════════════════════════════════════════════════════════════════════════ + +// State-shaped refusals, each recorded with its own reason token. +const STATE_ROWS = [ + ['P6.noState', undefined, 'no-state', '', false, 'an install whose state file was deleted'], + ['P6.corrupt', '{,}', 'corrupt', '', false, 'an unparseable state file'], + ['P6.empty', '', 'no-state', '', false, 'an empty state file'], + ['P6.disabledFlag', READY_STATE({ enabled: false }), 'disabled', 'ready', false, 'semble switched off for the project'], + ['P6.phaseDisabled', READY_STATE({ phase: 'disabled' }), 'disabled', 'disabled', true, 'phase disabled'], + ['P6.phaseError', READY_STATE({ phase: 'error' }), 'error', 'error', true, 'phase error'], + ['P6.prereqReady', READY_STATE({ phase: 'prereq_ready' }), 'not-registered', 'prereq_ready', true, + 'the add-failed rollback state, which still carries completed[mcp]'], + ['P6.noMcp', READY_STATE({ completed: ['prereq'] }), 'no-mcp', 'ready', true, 'the MCP server was never registered'], + ['P6.completedMissing', JSON.stringify({ phase: 'ready', enabled: true }), 'no-mcp', 'ready', true, + 'a state file with no completed array at all'], +]; +for (const [name, state, why, phase, enabled, msg] of STATE_ROWS) { + const p = freshProject(state === undefined ? {} : { state }); + // The state dir is the installer's, never the hook's. Seeding it for the + // no-state row is what makes the refusal RECORDABLE; the never-installed + // variant, where there is nowhere to write at all, is P6.noLitter below. + mkdirSync(join(p, '.claude', 'semble'), { recursive: true }); + const r = prefetch(p, Q); + check(name, [r.status, safeParse(r.stdout), r.argv.length], [0, {}, 0], `silent, exit 0, no search spawned: ${msg}`); + check(`${name}.why`, telemetry(p).map(dropTs), + [{ ev: 'prefetch', src: 'prefetch', sid: 'S1', fired: false, why, phase, enabled }], + `and the refusal is recorded as why=${why}`); +} +{ + const p = freshProject({ stateDir: true }); + const r = prefetch(p, Q); + check('P6.stateDir', [r.status, safeParse(r.stdout)], [0, {}], 'state.json as a DIRECTORY -> {} exit 0'); +} +{ + const p = freshProject({}); + prefetch(p, Q); + check('P6.noLitter', [existsSync(join(p, '.claude', 'semble')), existsSync(telemetryFile(p))], [false, false], + 'a repo without semble gets no .claude/semble directory and no telemetry file from the hook'); +} + +// Broken stdin — the hook cannot even find out which project it is in. +const STDIN_ROWS = [ + ['P6.badStdin', '{ not json', 'malformed JSON'], + ['P6.emptyStdin', '', 'empty stdin'], + ['P6.arrayStdin', '[]', 'a JSON array where an object was contracted'], + ['P6.stringStdin', '"just a string"', 'a bare JSON string'], + ['P6.nullStdin', 'null', 'JSON null'], +]; +for (const [name, stdin, msg] of STDIN_ROWS) { + const p = freshProject({ state: READY_STATE() }); + const r = prefetch(p, Q, { stdin }); + check(name, [r.status, safeParse(r.stdout)], [0, {}], `${msg} -> {} exit 0`); +} +{ + const p = freshProject({ state: READY_STATE() }); + const r = prefetch(p, undefined, { extra: { prompt: 42 } }); + check('P6.nonStringPrompt', [r.status, safeParse(r.stdout), tfield(p, 0, 'why')], [0, {}, 'empty'], + 'a non-string prompt is read as empty, never coerced'); +} + +// Search-side failures. A FAILURE is a standing condition — nothing about the +// next prompt fixes a missing uvx — so it parks the mechanism for the full ten +// minutes instead of paying the 3 s child cap on every following prompt. +const SEARCH_FAIL = [ + ['P6.noUvx', { path: '' }, 'uvx is not on PATH at all'], + ['P6.nonZero', { rc: 1 }, 'the search exits non-zero'], + ['P6.garbageOut', { out: 'not json' }, 'the search prints unparseable output'], + ['P6.wrongShape', { out: '{"results":"nope"}' }, 'results is not an array'], + ['P6.emptyOut', { out: '' }, 'the search prints nothing at all'], +]; +for (const [name, opts, msg] of SEARCH_FAIL) { + const p = freshProject({ state: READY_STATE() }); + const r = prefetch(p, Q, opts); + check(name, [r.status, safeParse(r.stdout)], [0, {}], `${msg} -> {} exit 0`); + check(`${name}.cooldown`, typeof (markerOf(p) || {}).cool, 'number', + `and the ${COOLDOWN_MS} ms cooldown is armed so the next prompt costs nothing`); + check(`${name}.coolMs`, (markerOf(p) || {}).coolMs, COOLDOWN_MS, + 'for the FULL window — a failure is a standing condition, not a slow moment'); + check(`${name}.why`, tfield(p, 0, 'why'), 'search-failed', 'recorded as search-failed'); +} + +// The timeout path, exercised for real: the child sleeps past the 3 s cap. +// A timeout is NOT a failure: against a warm index a search is ~0.6 s, so the +// cap means transient load and the penalty is a tenth of the failure penalty. +{ + const p = freshProject({ state: READY_STATE() }); + const t0 = Date.now(); + const r = prefetch(p, Q, { sleep: 10 }); + const elapsed = Date.now() - t0; + check('P6.timeout', [r.status, safeParse(r.stdout)], [0, {}], 'a hanging search is SIGKILLed and the hook stays silent'); + // The 3 s SEARCH_TIMEOUT_MS cap is the only thing that ends this call, so the + // window is centred on it: floor 1000 ms proves the cap was actually waited on + // rather than the search being skipped outright, ceiling 5000 ms is the + // registered hook timeout. The child sleeps 10 s, so a broken cap overshoots + // by 5 s and cannot squeeze inside the window. + check('P6.timeoutBudget', Math.abs(elapsed - 3000) <= 2000, true, + 'it returns in 1000-5000 ms: the 3 s cap fired inside the registered 5 s hook timeout, not after the child would have finished'); + check('P6.timeoutCooldown', typeof (markerOf(p) || {}).cool, 'number', 'the hang arms a cooldown too'); + check('P6.timeoutWhy', tfield(p, 0, 'why'), 'search-timeout', + 'recorded as search-timeout — a distinct event from search-failed, and attributable in telemetry'); + check('P6.timeoutCoolMs', (markerOf(p) || {}).coolMs, TIMEOUT_COOLDOWN_MS, + `parked for ${TIMEOUT_COOLDOWN_MS} ms, not ${COOLDOWN_MS} ms`); +} + +// The window written by whoever armed the cooldown is the window that is +// honoured — and it is clamped, so no marker can park the hook past the max. +{ + const p = freshProject({ state: READY_STATE() }); + writeFileSync(join(p, '.claude', 'semble', '.prefetch-ts'), + JSON.stringify({ cool: Date.now() - 90_000, coolMs: TIMEOUT_COOLDOWN_MS })); + check('P6.shortWindowExpires', safeParse(prefetch(p, Q).stdout), PREFETCH_OK, + 'a 60 s park is over 90 s later and the hook fires again'); +} +{ + const p = freshProject({ state: READY_STATE() }); + writeFileSync(join(p, '.claude', 'semble', '.prefetch-ts'), + JSON.stringify({ cool: Date.now() - 90_000, coolMs: COOLDOWN_MS })); + const r = prefetch(p, Q); + check('P6.longWindowHolds', [safeParse(r.stdout), r.argv], [{}, []], + 'while a 600 s park at the same age is still in force and spawns nothing'); +} +{ + const p = freshProject({ state: READY_STATE() }); + writeFileSync(join(p, '.claude', 'semble', '.prefetch-ts'), + JSON.stringify({ cool: Date.now() - COOLDOWN_MS - 1000, coolMs: 9e15 })); + check('P6.windowClamped', safeParse(prefetch(p, Q).stdout), PREFETCH_OK, + 'a nonsense window is clamped to the maximum, so a corrupt marker can never park the hook forever'); +} + +// A search that RAN and found nothing is not a failure and must not park it. +{ + const p = freshProject({ state: READY_STATE() }); + const r = prefetch(p, Q, { out: '{"results":[]}' }); + check('P6.noHits', [r.status, safeParse(r.stdout), tfield(p, 0, 'why')], [0, {}, 'no-hits'], + 'an empty result set is silence, recorded as no-hits'); + check('P6.noHitsNoCooldown', markerOf(p), null, + 'and it arms NOTHING — `[]` means the index answered, only `null` means it could not be trusted'); +} + +// The cooldown really suppresses the next prompt, without spawning anything. +{ + const p = freshProject({ state: READY_STATE() }); + prefetch(p, Q, { rc: 1 }); + const second = prefetch(p, Q); + check('P6.cooldownHolds', [safeParse(second.stdout), second.argv], [{}, []], + 'the prompt after a failure is silent and spawns no child'); + check('P6.cooldownWhy', tfield(p, 1, 'why'), 'cooldown', 'recorded as cooldown, distinct from throttled'); +} + +// A corrupt or unwritable marker must fail OPEN — the throttle is a guard, not a gate. +{ + const p = freshProject({ state: READY_STATE() }); + writeFileSync(join(p, '.claude', 'semble', '.prefetch-ts'), '{,}'); + check('P6.markerCorrupt', safeParse(prefetch(p, Q).stdout), PREFETCH_OK, + 'a corrupt marker reads as no marker and the hook still fires'); +} +{ + const good = freshProject({ state: READY_STATE() }); + const bad = freshProject({ state: READY_STATE() }); + const expected = prefetch(good, Q); + chmodSync(join(bad, '.claude', 'semble'), 0o500); // readable, not writable + const r = prefetch(bad, Q); + chmodSync(join(bad, '.claude', 'semble'), 0o700); + check('P6.unwritable', r.stdout, expected.stdout, + 'an unwritable .claude/semble leaves the returned JSON byte-identical — marker and telemetry are both best-effort'); + check('P6.unwritableExit', r.status, 0, 'and the hook still exits 0'); + check('P6.unwritableNoFile', existsSync(telemetryFile(bad)), false, 'nothing was written'); + check('P6.unwritableWarned', r.stderr.includes('[semble-prefetch] marker write failed'), true, + 'the failure is reported on stderr, where it cannot corrupt the hook protocol on stdout'); +} + +// P7 — the hook can never block, in any recorded output. +{ + const joined = allPrefetchOutputs.join('\n'); + check('P7.noDecision', joined.includes('permissionDecision'), false, 'no recorded output ever carries permissionDecision'); + check('P7.noDeny', joined.includes('"deny"'), false, 'no recorded output ever carries a deny'); + check('P7.noUpdatedInput', joined.includes('updatedInput'), false, 'no recorded output ever carries updatedInput'); + check('P7.oneObject', allPrefetchOutputs.every((o) => o.trim().split('\n').length === 1), true, + 'every invocation printed exactly one line of JSON'); + const src = readFileSync(PREFETCH_SRC, 'utf8'); + check('P7.sourceNeverDecides', src.includes('permissionDecision'), false, + 'the source contains no permissionDecision field at all'); + check('P7.mainGuarded', src.includes('import.meta.url === pathToFileURL(process.argv[1]).href'), true, + 'main() runs only as the entry point — importing the file for the corpus replays must not consume stdin'); + check('P7.noSnippetField', src.includes("'content'"), false, + 'the renderer never touches a `content` field: paths convert 5/6, snippets 2/6'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Y. telemetry contract shared by the session hook and the log itself +// ═══════════════════════════════════════════════════════════════════════════ + +// Y1 — session hook: a nudge record only when additionalContext was really returned. +{ + const p = freshProject({ state: READY_STATE() }); + session(p); + check('Y1.ready', telemetry(p).map(dropTs), + [{ ev: 'nudge', src: 'session', sid: 'S1', matcher: 'SessionStart', agent: 'main', q: '' }], + 'the ready session emits exactly one nudge record and no gate record'); +} +{ + const p = freshProject({ state: READY_STATE({ phase: 'error' }) }); + session(p); + check('Y2.noContext', existsSync(telemetryFile(p)), false, + 'a systemMessage without additionalContext is not a nudge and writes nothing'); +} + +// Y3 — sid attribution comes straight off the hook input, never invented. +{ + const p = freshProject({ state: READY_STATE() }); + prefetch(p, Q, { extra: { session_id: 'S9' } }); + check('Y3.sid', tfield(p, 0, 'sid'), 'S9', 'sid is the hook input session_id'); +} +{ + const p = freshProject({ state: READY_STATE() }); + prefetch(p, Q, { stdin: JSON.stringify({ cwd: p, hook_event_name: 'UserPromptSubmit', prompt: Q }) }); + check('Y3.emptySid', tfield(p, 0, 'sid'), '', 'a missing session_id becomes the empty string, never undefined'); +} + +// Y4 — the 2 MB size guard trims instead of growing without bound. +{ + const p = freshProject({ state: READY_STATE() }); + // The fixture's size is fixed by its own construction, so it is asserted + // exactly: a 285-byte filler plus a newline, 11000 times = 3146000 bytes. + const filler = JSON.stringify({ ts: '2026-01-01T00:00:00.000Z', ev: 'prefetch', src: 'prefetch', sid: 'X', pad: 'y'.repeat(200) }); + const KEPT = (filler + '\n').repeat(1000); + writeFileSync(telemetryFile(p), (filler + '\n').repeat(11000)); + const before = statSync(telemetryFile(p)).size; + prefetch(p, Q); + const lines = telemetry(p); + check('Y4.oversize', before, 3_146_000, 'the fixture is exactly 3146000 bytes, over the 2 MB guard'); + check('Y4.trimmed', lines.length, 1001, 'the last 1000 lines are kept, then the new record is appended'); + check('Y4.tail', lines[lines.length - 1].fired, true, 'the new record is at the end'); + check('Y4.smaller', readFileSync(telemetryFile(p), 'utf8').slice(0, KEPT.length), KEPT, + 'the file shrank to byte-exactly the last 1000 filler lines, then the new record'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// F. static guarantees of the three shipped hook files // ═══════════════════════════════════════════════════════════════════════════ { - const src = [readFileSync(SESSION_SRC, 'utf8'), readFileSync(REMINDER_SRC, 'utf8'), - readFileSync(EXPLORE_SRC, 'utf8')]; - const both = src.join('\n'); - check('F1.noChildProcess', both.includes('child_process'), false, 'no hook imports child_process'); - check('F2.noSpawn', both.includes('spawn('), false, 'no hook spawns a process'); - check('F3.noPgrep', both.includes('pgrep'), false, 'no hook probes for a daemon with pgrep'); - check('F4.noExecSync', both.includes('execSync'), false, 'no hook shells out'); - check('F5.reminderNeverDecides', readFileSync(REMINDER_SRC, 'utf8').includes('permissionDecision:'), false, - 'the reminder source contains no permissionDecision field'); - check('F6.notABlock', readFileSync(REMINDER_SRC, 'utf8').includes('this is a reminder, not a block.'), true, - 'the advisory text contains the words "reminder, not a block"'); + const sessionSrc = readFileSync(SESSION_SRC, 'utf8'); + const prefetchSrc = readFileSync(PREFETCH_SRC, 'utf8'); + const statsSrc = readFileSync(STATS_SRC, 'utf8'); + const src = [sessionSrc, prefetchSrc, statsSrc]; + // The passive pair must stay pure readers. Prefetch is the ONE hook allowed a + // child, and only because handing over a result is the thing that converts. + const passive = sessionSrc + '\n' + statsSrc; + check('F1.passiveNoChildProcess', passive.includes('child_process'), false, + 'neither the session hook nor the stats observer imports child_process'); + check('F2.passiveNoSpawn', passive.includes('spawn('), false, 'and neither spawns a process'); + check('F3.noPgrep', src.join('\n').includes('pgrep'), false, 'no hook probes for a daemon with pgrep'); + check('F4.prefetchExecFile', prefetchSrc.includes('execFileSync('), true, + 'the prefetch hook spawns with execFileSync - argv, so the distilled query can never be word-split'); + check('F5.noShell', [prefetchSrc.includes('execSync('), prefetchSrc.includes('shell:')], [false, false], + 'and never through a shell'); + check('F6.hardCap', + [prefetchSrc.includes('timeout: SEARCH_TIMEOUT_MS'), prefetchSrc.includes("killSignal: 'SIGKILL'")], + [true, true], 'the child carries a hard cap and a kill signal, both inside the registered 5 s hook timeout'); check('F7.shebang', src.every((s) => s.startsWith('#!/usr/bin/env node')), true, 'every hook carries a node shebang'); - const checks = [SESSION_SRC, REMINDER_SRC, EXPLORE_SRC] + const checks = [SESSION_SRC, PREFETCH_SRC, STATS_SRC] .map((f) => spawnSync(process.execPath, ['--check', f]).status); check('F8.nodeCheck', checks, [0, 0, 0], 'node --check passes on all three hook files'); - check('F9.exploreNeverDecides', readFileSync(EXPLORE_SRC, 'utf8').includes('permissionDecision'), false, - 'the explore hook source contains no permissionDecision field'); + check('F9.neverDecides', src.map((x) => x.includes('permissionDecision:')), [false, false, false], + 'no shipped hook emits a permissionDecision field - none of them can block a call'); + check('F10.retiredGone', [existsSync(join(ASSETS, 'semble-reminder.mjs')), existsSync(join(ASSETS, 'semble-explore.mjs'))], + [false, false], 'the two advisory hooks are gone from assets/ - they are not shipped, only cleaned up'); } // ═══════════════════════════════════════════════════════════════════════════ @@ -879,14 +1646,14 @@ const PRE_MD = '# CLAUDE.md\n\n## Overview\n\nproject text\n'; // H1 — install aborts on an unparseable settings.json BEFORE touching anything { const p = freshProject({ settings: BROKEN_SETTINGS, claudeMd: PRE_MD }); - const { session, reminder: rem, explore: exp } = semblePaths(p); + const { session, prefetch: pre, stats } = semblePaths(p); const r = guidance(p, ['install', '--part', 'all', '--json']); check('H1.exit', r.status, 1, 'install --part all over unparseable settings exits 1'); check('H1.abort', (r.stdout + r.stderr).includes('ABORT'), true, 'the failure names ABORT'); check('H1.settings', readRaw(settingsPath(p)), BROKEN_SETTINGS, 'the unparseable settings file is byte-identical'); check('H1.rule', existsSync(join(p, '.claude', 'rules', 'semble-first.md')), false, 'no rule file was written'); check('H1.claudeMd', readRaw(join(p, 'CLAUDE.md')), PRE_MD, 'CLAUDE.md is byte-identical, no marker block'); - check('H1.hookFiles', [existsSync(session), existsSync(rem), existsSync(exp)], [false, false, false], + check('H1.hookFiles', [existsSync(session), existsSync(pre), existsSync(stats)], [false, false, false], 'no .mjs hook file was written'); check('H1.hooksDir', existsSync(hooksDirOf(p)), false, 'the .claude/hooks directory was never created'); } @@ -897,10 +1664,10 @@ const PRE_MD = '# CLAUDE.md\n\n## Overview\n\nproject text\n'; guidance(p, ['install', '--part', 'all', '--json']); const rulePath = join(p, '.claude', 'rules', 'semble-first.md'); const mdPath = join(p, 'CLAUDE.md'); - const { session, reminder: rem, explore: exp } = semblePaths(p); + const { session, prefetch: pre, stats } = semblePaths(p); const before = { - rule: readRaw(rulePath), md: readRaw(mdPath), session: readRaw(session), reminder: readRaw(rem), - explore: readRaw(exp), + rule: readRaw(rulePath), md: readRaw(mdPath), session: readRaw(session), prefetch: readRaw(pre), + stats: readRaw(stats), }; writeFileSync(settingsPath(p), BROKEN_SETTINGS); const r = guidance(p, ['remove', '--part', 'all', '--json']); @@ -909,8 +1676,8 @@ const PRE_MD = '# CLAUDE.md\n\n## Overview\n\nproject text\n'; check('H2.settings', readRaw(settingsPath(p)), BROKEN_SETTINGS, 'the unparseable settings file is byte-identical'); check('H2.rule', readRaw(rulePath), before.rule, 'the rule file is still there, byte-identical'); check('H2.claudeMd', readRaw(mdPath), before.md, 'the CLAUDE.md marker block is still there, byte-identical'); - check('H2.hookFiles', [readRaw(session), readRaw(rem), readRaw(exp)], - [before.session, before.reminder, before.explore], + check('H2.hookFiles', [readRaw(session), readRaw(pre), readRaw(stats)], + [before.session, before.prefetch, before.stats], 'all three .mjs hook files are still there, byte-identical — settings.json may still reference them'); } @@ -962,19 +1729,13 @@ const FOREIGN_HOOK = { type: 'command', command: 'node', args: ['/opt/foreign/gu check('H5.exit', r.status, 0, 'merge over a mixed foreign+stale entry exits 0'); const s = readSettings(p); const pre = (s.hooks || {}).PreToolUse || []; - const { session, reminder: rem, explore: exp } = semblePaths(p); check('H5.mixedEntry', pre[0] || null, { matcher: 'Bash', hooks: [FOREIGN_HOOK] }, 'the mixed entry keeps the foreign hook and loses only the stale semble hook'); check('H5.staleGone', Object.values(s.hooks || {}).flat().filter((e) => argsOf(e).some((a) => a.startsWith(staleDir))).length, 0, 'zero hooks still point at the old hooks dir'); - check('H5.counts', [ - countEntry(s, 'SessionStart', null, session), - countEntry(s, 'PreToolUse', 'Bash', rem), - countEntry(s, 'PreToolUse', 'Grep', rem), - countEntry(s, 'SubagentStart', 'Explore', exp), - ], [1, 1, 1, 1], 'exactly one current entry per event+matcher'); - check('H5.preToolUseSize', pre.length, 3, - 'the repaired foreign entry plus the two appended semble entries'); + check('H5.counts', wantCounts(p, s), [1, 1, 1, 1], 'exactly one current entry per want row'); + check('H5.preToolUseSize', pre.length, 1, + 'PreToolUse now holds the repaired foreign entry ALONE - 5.0.0 wants no row on that event'); } // H6 — unmerge: a hand-merged foreign hook in a semble entry survives @@ -982,14 +1743,13 @@ const FOREIGN_HOOK = { type: 'command', command: 'node', args: ['/opt/foreign/gu const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); const s = readSettings(p); - const idx = s.hooks.PreToolUse.findIndex((e) => matcherOf(e) === 'Bash'); - s.hooks.PreToolUse[idx].hooks.unshift(FOREIGN_HOOK); + s.hooks.UserPromptSubmit[0].hooks.unshift(FOREIGN_HOOK); writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); const r = guidance(p, ['remove', '--part', 'hooks', '--json']); check('H6.exit', r.status, 0, 'unmerge over a hand-merged mixed entry exits 0'); const t = readSettings(p); const left = t.hooks || {}; - check('H6.foreignKept', left.PreToolUse || null, [{ matcher: 'Bash', hooks: [FOREIGN_HOOK] }], + check('H6.foreignKept', left.UserPromptSubmit || null, [{ hooks: [FOREIGN_HOOK] }], 'the hand-merged foreign hook survives; only the semble hook is stripped'); check('H6.sessionGone', Object.prototype.hasOwnProperty.call(left, 'SessionStart'), false, 'the SessionStart array emptied and its key was pruned'); @@ -1053,52 +1813,59 @@ function driftTimeouts(p, value) { check('I1.idempotent', j2.changed, [], 'the run after the repair changes nothing'); } -// I2 — the real broken shape: 3 drifted rows + the missing SubagentStart row +// I2 — the real broken shape: drifted rows plus one missing outright { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); const clean = readRaw(settingsPath(p)); const s = readSettings(p); - delete s.hooks.SubagentStart; // installed before 4.7.0 shipped the row + delete s.hooks.UserPromptSubmit; // installed before 5.0.0 shipped the row writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); driftTimeouts(p, 5000); const before = safeParse(guidance(p, ['status', '--json']).stdout); check('I2.statusBefore', - [before.hooks.wiredCount, before.hooks.driftedCount, before.hooks.missingCount], - [0, 3, 1], 'status calls the real broken shape 0/4 wired, 3 drifted, 1 missing'); + [before.hooks.wiredCount, before.hooks.wantCount, before.hooks.driftedCount, before.hooks.missingCount], + [0, WANT_N, WANT_N - 1, 1], 'status calls the real broken shape 0/4 wired, 3 drifted, 1 missing'); const r = guidance(p, ['install', '--part', 'hooks', '--json']); check('I2.exit', r.status, 0, 'the repair run exits 0'); - check('I2.bytes', readRaw(settingsPath(p)), clean, - 'one re-run restores the file to exactly the clean-install bytes'); + // Key ORDER differs by construction here and only here: UserPromptSubmit was + // deleted outright, so the merge re-appends it after the later want rows. Every value must + // still be identical to a clean install - repair, not append. + const canon = (raw) => { + const o = JSON.parse(raw); + if (o.hooks) o.hooks = Object.fromEntries(Object.entries(o.hooks).sort((a, b) => (a[0] < b[0] ? -1 : 1))); + return JSON.stringify(o, null, 2) + '\n'; + }; + check('I2.bytes', canon(readRaw(settingsPath(p))), canon(clean), + 'one re-run restores the file to the clean-install content, event key order aside'); + check('I2.keySet', Object.keys(readSettings(p).hooks).sort(), Object.keys(JSON.parse(clean).hooks).sort(), + 'and to exactly the clean-install set of hook events'); const after = safeParse(guidance(p, ['status', '--json']).stdout); check('I2.statusAfter', [after.hooks.wiredCount, after.hooks.driftedCount, after.hooks.missingCount, after.hooks.drift.length], - [4, 0, 0, 0], 'after the repair status reports 4/4 wired with an empty drift list'); + [WANT_N, 0, 0, 0], 'after the repair status reports 4/4 wired with an empty drift list'); } // I3 — a foreign hook sharing a drifted entry survives merge AND unmerge { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); - const { reminder: rem } = semblePaths(p); + const { prefetch: pre } = semblePaths(p); const s = readSettings(p); - const i = s.hooks.PreToolUse.findIndex((e) => matcherOf(e) === 'Bash'); - s.hooks.PreToolUse[i].hooks.unshift(FOREIGN_HOOK); - s.hooks.PreToolUse[i].hooks[1].timeout = 5000; // the semble hook next to it drifted - s.hooks.PreToolUse[i].note = 'hand-edited'; // a foreign entry-level key + s.hooks.UserPromptSubmit[0].hooks.unshift(FOREIGN_HOOK); + s.hooks.UserPromptSubmit[0].hooks[1].timeout = 5000; // the semble hook next to it drifted + s.hooks.UserPromptSubmit[0].note = 'hand-edited'; // a foreign entry-level key writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); const r = guidance(p, ['install', '--part', 'hooks', '--json']); check('I3.exit', r.status, 0, 'merge over a mixed drifted entry exits 0'); - const e = readSettings(p).hooks.PreToolUse.find((x) => matcherOf(x) === 'Bash'); - check('I3.repaired', e, { - hooks: [FOREIGN_HOOK, { type: 'command', command: 'node', args: [rem], timeout: 5 }], - matcher: 'Bash', + check('I3.repaired', readSettings(p).hooks.UserPromptSubmit[0], { + hooks: [FOREIGN_HOOK, { type: 'command', command: 'node', args: [pre], timeout: 5 }], note: 'hand-edited', }, 'only the semble hook is rewritten - the foreign hook and the foreign entry key are untouched'); const r2 = guidance(p, ['remove', '--part', 'hooks', '--json']); check('I3.unmergeExit', r2.status, 0, 'unmerge over the same entry exits 0'); - check('I3.unmergeKept', readSettings(p).hooks.PreToolUse, - [{ hooks: [FOREIGN_HOOK], matcher: 'Bash', note: 'hand-edited' }], + check('I3.unmergeKept', readSettings(p).hooks.UserPromptSubmit, + [{ hooks: [FOREIGN_HOOK], note: 'hand-edited' }], 'unmerge strips only the semble hook and keeps the entry alive for the foreign one'); } @@ -1106,31 +1873,28 @@ function driftTimeouts(p, value) { { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); - const { reminder: rem, session, explore: exp } = semblePaths(p); + const { prefetch: pre, stats } = semblePaths(p); const s = readSettings(p); - const dupe = JSON.parse(JSON.stringify(s.hooks.PreToolUse.find((e) => matcherOf(e) === 'Bash'))); + const dupe = JSON.parse(JSON.stringify(s.hooks.UserPromptSubmit[0])); dupe.hooks[0].timeout = 5000; - s.hooks.PreToolUse.push(dupe); // plain duplicate - s.hooks.PreToolUse.push({ // duplicate carrying a foreign hook - matcher: 'Grep', - hooks: [FOREIGN_HOOK, { type: 'command', command: 'node', args: [rem], timeout: 9 }], + s.hooks.UserPromptSubmit.push(dupe); // plain duplicate + s.hooks.PostToolUse.push({ // duplicate carrying a foreign hook + matcher: STATS_MATCHER, + hooks: [FOREIGN_HOOK, { type: 'command', command: 'node', args: [stats], timeout: 9 }], }); writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); const r = guidance(p, ['install', '--part', 'hooks', '--json']); check('I4.exit', r.status, 0, 'a duplicated entry is repaired, not a fatal ABORT'); check('I4.noAbort', (r.stdout + r.stderr).includes('ABORT'), false, 'nothing reports ABORT'); const t = readSettings(p); - check('I4.counts', [ - countEntry(t, 'SessionStart', null, session), - countEntry(t, 'PreToolUse', 'Bash', rem), - countEntry(t, 'PreToolUse', 'Grep', rem), - countEntry(t, 'SubagentStart', 'Explore', exp), - ], [1, 1, 1, 1], 'exactly one entry per event+matcher survives the de-duplication'); - check('I4.foreignSurvived', t.hooks.PreToolUse.filter((e) => argsOf(e).includes('/opt/foreign/guard.mjs')), - [{ matcher: 'Grep', hooks: [FOREIGN_HOOK] }], + check('I4.counts', wantCounts(p, t), [1, 1, 1, 1], 'exactly one entry per want row survives the de-duplication'); + check('I4.foreignSurvived', t.hooks.PostToolUse.filter((e) => argsOf(e).includes('/opt/foreign/guard.mjs')), + [{ matcher: STATS_MATCHER, hooks: [FOREIGN_HOOK] }], 'the foreign hook riding on the duplicate outlives the duplicate'); + check('I4.unusedPrefetchPath', pre.endsWith('semble-prefetch.mjs'), true, + 'the duplicated row is the prefetch one'); const a = safeParse(guidance(p, ['status', '--json']).stdout); - check('I4.statusClean', [a.hooks.wiredCount, a.hooks.duplicateCount], [4, 0], + check('I4.statusClean', [a.hooks.wiredCount, a.hooks.duplicateCount], [WANT_N, 0], 'status confirms the file is healthy afterwards'); } @@ -1142,21 +1906,22 @@ function driftTimeouts(p, value) { check('I5.clean', [clean.hooks.wiredCount, clean.hooks.driftedCount, clean.hooks.missingCount, clean.hooks.duplicateCount, clean.hooks.drift.length], - [4, 0, 0, 0, 0], 'a correct install reports 4 wired and no drift'); + [WANT_N, 0, 0, 0, 0], 'a correct install reports 4 wired and no drift'); check('I5.cleanEntries', clean.hooks.entries.map((e) => e.state), - ['wired', 'wired', 'wired', 'wired'], 'every want row is reported wired individually'); + ['wired', 'wired', 'wired', 'wired'], + 'every want row is reported wired individually'); const s = readSettings(p); - s.hooks.PreToolUse.find((e) => matcherOf(e) === 'Bash').hooks[0].timeout = 5000; + s.hooks.UserPromptSubmit[0].hooks[0].timeout = 5000; writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); const a = safeParse(guidance(p, ['status', '--json']).stdout); - check('I5.driftCounts', [a.hooks.wiredCount, a.hooks.driftedCount, a.hooks.missingCount], [3, 1, 0], + check('I5.driftCounts', [a.hooks.wiredCount, a.hooks.driftedCount, a.hooks.missingCount], [WANT_N - 1, 1, 0], 'a timeout:5000 entry is counted as drifted, never rounded up to wired'); check('I5.driftDetail', a.hooks.drift, [{ - event: 'PreToolUse', matcher: 'Bash', script: 'semble-reminder.mjs', + event: 'UserPromptSubmit', matcher: null, script: 'semble-prefetch.mjs', field: 'timeout', expected: 5, actual: 5000, }], 'the drift array names event, matcher, script and the exact field that differs'); - check('I5.reminderWired', a.hooks.reminder.wired, false, - 'wired means present AND conforming - the drifted reminder is not wired'); + check('I5.prefetchWired', a.hooks.prefetch.wired, false, + 'wired means present AND conforming - the drifted prefetch row is not wired'); check('I5.otherRows', a.hooks.entries.map((e) => e.state), ['wired', 'drifted', 'wired', 'wired'], 'only the drifted row changes state'); check('I5.human', guidance(p, ['status']).stdout.includes('hooks 3/4 wired (1 drifted - re-run install to repair)'), @@ -1188,13 +1953,13 @@ function driftTimeouts(p, value) { check('I6.orderNoRewrite', readRaw(settingsPath(p)), reordered, 'key order alone is not drift - the entry is left byte-identical'); const a = safeParse(guidance(p, ['status', '--json']).stdout); - check('I6.orderStatus', [a.hooks.wiredCount, a.hooks.driftedCount], [4, 0], + check('I6.orderStatus', [a.hooks.wiredCount, a.hooks.driftedCount], [WANT_N, 0], 'status agrees that a reordered but equal hook is wired'); } // I7 — the .gitignore outcome is verified, and the absent case is decided out loud { - const GI = '.claude/semble/.reminder-ts'; + const GI = '.claude/semble/.prefetch-ts'; const p1 = freshProject({}); // .gitignore exists, no line writeFileSync(join(p1, '.gitignore'), 'node_modules/\n'); const j1 = safeParse(guidance(p1, ['install', '--part', 'hooks', '--json']).stdout); @@ -1233,6 +1998,327 @@ function driftTimeouts(p, value) { 'the marker line is really gone from the file'); check('I7.removeKept', readRaw(join(p1, '.gitignore')).startsWith('node_modules/\n'), true, 'the pre-existing .gitignore content is preserved'); + + // install -> remove -> install must be byte-idempotent. It was not: the drop + // left a trailing blank line and the append prepended another, so every cycle + // grew the file by one blank - a diff in the user's repo for doing nothing. + check('I7.removeRestores', readRaw(join(p1, '.gitignore')), 'node_modules/\n', + 'removal restores the file byte for byte, with no blank line left behind'); + const cycle = []; + for (let i = 0; i < 3; i++) { + guidance(p1, ['install', '--part', 'hooks', '--json']); + cycle.push(readRaw(join(p1, '.gitignore'))); + guidance(p1, ['remove', '--part', 'hooks', '--json']); + cycle.push(readRaw(join(p1, '.gitignore'))); + } + check('I7.cycleStable', cycle, + [`node_modules/\n\n# brewcode:semble\n${GI}\n`, 'node_modules/\n', + `node_modules/\n\n# brewcode:semble\n${GI}\n`, 'node_modules/\n', + `node_modules/\n\n# brewcode:semble\n${GI}\n`, 'node_modules/\n'], + 'three install/remove cycles produce exactly two byte-identical states, never a growing blank run'); + + // The same must hold when the migration drops the retired v1 line first. + const p4 = freshProject({}); + writeFileSync(join(p4, '.gitignore'), + `node_modules/\n\n# brewcode:semble\n.claude/semble/.reminder-ts\n`); + guidance(p4, ['install', '--part', 'hooks', '--json']); + check('I7.migrateFile', readRaw(join(p4, '.gitignore')), + `node_modules/\n\n# brewcode:semble\n${GI}\n`, + 'migrating off the retired marker leaves one blank line, not two'); + guidance(p4, ['install', '--part', 'hooks', '--json']); + check('I7.migrateStable', readRaw(join(p4, '.gitignore')), + `node_modules/\n\n# brewcode:semble\n${GI}\n`, + 'and a second install over the migrated file changes nothing'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// M. migration off the v1 hook layer +// +// The single most breakable part of 5.0.0. A user upgrading from 4.x has a +// settings.json full of rows for hooks that no longer exist, and two orphan +// .mjs files on disk. `wanted` is built from SG_LIVE while ownership is decided +// by SG_MARKS, so a retired basename is still recognised as ours (and purged) +// without ever being re-added. Building `wanted` from the marks list was the +// pre-5.0.0 bug that made retired rows immortal. +// ═══════════════════════════════════════════════════════════════════════════ +{ + const p = freshProject({}); + const d = hooksDirOf(p); + mkdirSync(d, { recursive: true }); + const { session, stats, reminder: rem, explore: exp } = semblePaths(p); + const H = (f, t) => ({ type: 'command', command: 'node', args: [f], timeout: t === undefined ? 5 : t }); + // Exactly the shape 4.x left behind, down to the stats matcher predating `|Read`. + const OLD_STATS = 'mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob'; + writeFileSync(settingsPath(p), JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [H(session)] }], + PreToolUse: [ + { matcher: 'Write', hooks: [FOREIGN_HOOK] }, + { matcher: 'Bash', hooks: [H(rem)] }, + { matcher: 'Grep', hooks: [H(rem)] }, + ], + SubagentStart: [{ matcher: 'Explore', hooks: [H(exp)] }], + PostToolUse: [{ matcher: OLD_STATS, hooks: [H(stats)] }], + PostToolUseFailure: [{ matcher: OLD_STATS, hooks: [H(stats)] }], + }, + permissions: { allow: ['mcp__semble_code__search', 'mcp__semble_code__find_related'] }, + }, null, 2) + '\n'); + for (const f of [session, stats, rem, exp]) writeFileSync(f, '// v1 leftover\n'); + writeFileSync(join(p, '.gitignore'), 'node_modules/\n.claude/semble/.reminder-ts\n'); + mkdirSync(join(p, '.claude', 'semble'), { recursive: true }); + writeFileSync(join(p, '.claude', 'semble', '.reminder-ts'), '{"t":1}'); + + const before = safeParse(guidance(p, ['status', '--json']).stdout); + check('M1.retiredSeen', before.hooks.retired, ['semble-reminder.mjs', 'semble-explore.mjs'], + 'status names the retired files it can see on disk, in want-table order'); + check('M1.wiredBefore', [before.hooks.wiredCount, before.hooks.wantCount], [1, WANT_N], + 'only SessionStart carries over: prefetch did not exist, and both stats rows sit on the' + + ' pre-5.0.0 matcher, which is a different want row and not a drifted one'); + check('M1.staleBefore', before.hooks.staleEntries, 5, + 'five owned entries are wired somewhere the want table does not want them - the two' + + ' reminder rows, the explore row, and BOTH stats rows on the retired matcher'); + + const r = guidance(p, ['install', '--part', 'hooks', '--json']); + check('M2.exit', r.status, 0, 'install over a v1-shaped settings file exits 0'); + const s = readSettings(p); + check('M2.counts', wantCounts(p, s), [1, 1, 1, 1], 'every current want row is wired exactly once'); + check('M2.noRetiredPath', [JSON.stringify(s).includes('semble-reminder.mjs'), + JSON.stringify(s).includes('semble-explore.mjs')], [false, false], + 'NEITHER retired basename survives anywhere in settings.json - the whole point of the migration'); + check('M2.subagentStartGone', Object.prototype.hasOwnProperty.call(s.hooks, 'SubagentStart'), false, + 'SubagentStart emptied and the now-meaningless key was deleted, not left as []'); + check('M2.preToolUseKept', s.hooks.PreToolUse, [{ matcher: 'Write', hooks: [FOREIGN_HOOK] }], + 'PreToolUse survives with the foreign entry ALONE - the purge is ours-only, per entry'); + check('M2.filesGone', [existsSync(rem), existsSync(exp)], [false, false], + 'both orphan .mjs files are deleted from .claude/hooks'); + check('M2.filesKept', [existsSync(session), existsSync(semblePaths(p).prefetch), existsSync(stats)], + [true, true, true], 'and all three live hooks are on disk'); + check('M2.statsMatchers', s.hooks.PostToolUse.map((e) => e.matcher), [STATS_MATCHER], + 'ONE PostToolUse row, on the current matcher: a surviving pre-5.0.0 row would fire the' + + ' observer a second time on every Bash and silently double the denominator'); + check('M2.statsFailureMatchers', s.hooks.PostToolUseFailure.map((e) => e.matcher), [STATS_MATCHER], + 'and the same on the failure event'); + const changed = (safeParse(r.stdout) || {}).changed || []; + check('M2.reported', [changed.includes('hooks: removed retired ' + rem), + changed.includes('hooks: removed retired ' + exp)], [true, true], + 'install reports both deletions by full path instead of doing them silently'); + + const a = safeParse(guidance(p, ['status', '--json']).stdout); + check('M3.after', [a.hooks.wiredCount, a.hooks.wantCount, a.hooks.driftedCount, + a.hooks.missingCount, a.hooks.duplicateCount, a.hooks.staleEntries], + [WANT_N, WANT_N, 0, 0, 0, 0], 'the migrated project is indistinguishable from a fresh install'); + check('M3.retiredEmpty', a.hooks.retired, [], 'nothing retired is left to report'); + check('M3.retiredMarkerGone', existsSync(join(p, '.claude', 'semble', '.reminder-ts')), false, + 'the retired hook\'s marker file goes with it: the migration drops its .gitignore line, so a ' + + 'marker left behind turns an invisible throttle file into an untracked diff in the user\'s repo'); + check('M3.gitignore', readFileSync(join(p, '.gitignore'), 'utf8'), + 'node_modules/\n\n# brewcode:semble\n.claude/semble/.prefetch-ts\n', + 'the retired .reminder-ts line is DROPPED and the prefetch marker added - never both,' + + ' and never an orphan ignore line for a file nothing writes any more'); + + const snap = readRaw(settingsPath(p)); + guidance(p, ['install', '--part', 'hooks', '--json']); + check('M4.idempotent', readRaw(settingsPath(p)), snap, + 'a second install over the migrated file is byte-identical - migration converges in one pass'); +} + +// M5 — a v1 install whose retired ROWS are gone but whose FILES linger +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const { reminder: rem } = semblePaths(p); + writeFileSync(rem, '// orphan\n'); + const a = safeParse(guidance(p, ['status', '--json']).stdout); + check('M5.seen', [a.hooks.retired, a.hooks.wiredCount], [['semble-reminder.mjs'], WANT_N], + 'an orphan file is reported even when settings.json is already perfect'); + guidance(p, ['install', '--part', 'hooks', '--json']); + check('M5.swept', [existsSync(rem), + safeParse(guidance(p, ['status', '--json']).stdout).hooks.retired], [false, []], + 'and install sweeps it - file cleanup does not depend on a matching settings row'); +} + +// M6 — remove/uninstall knows the retired names too +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const { session, prefetch: pre, stats, reminder: rem, explore: exp } = semblePaths(p); + const s = readSettings(p); + s.hooks.PreToolUse = [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5 }] }]; + writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); + writeFileSync(rem, '// orphan\n'); + writeFileSync(exp, '// orphan\n'); + mkdirSync(join(p, '.claude', 'semble'), { recursive: true }); + for (const m of ['.prefetch-ts', '.reminder-ts']) { + writeFileSync(join(p, '.claude', 'semble', m), '{"t":1}'); + } + guidance(p, ['remove', '--part', 'all', '--json']); + const t = readSettings(p); + check('M6.settings', JSON.stringify(t.hooks || {}), '{}', + 'unmerge reads the FULL ownership list, so a hand-restored retired row is removed too'); + check('M6.files', [session, pre, stats, rem, exp].map((f) => existsSync(f)), + [false, false, false, false, false], 'and every owned .mjs goes, live or retired'); + check('M6.markers', ['.prefetch-ts', '.reminder-ts'] + .map((f) => existsSync(join(p, '.claude', 'semble', f))), [false, false], + 'and so do both throttle markers - remove drops their .gitignore line, so anything left ' + + 'behind resurfaces as an untracked file'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// N. a release must not look like a user edit (defect b) +// +// bump-version.sh stamps `# brewcode-meta: version=X.Y.Z` into line 1 of +// sembleignore.template. `install_ignore` used to compare with `cmp -s`, so +// EVERY version bump flipped every installed .sembleignore to `user_modified` +// and it silently stopped being updated. The compare now runs in `metaline` +// mode: the meta comment is stripped from BOTH sides first. +// ═══════════════════════════════════════════════════════════════════════════ +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'ignore', '--json']); + const f = join(p, '.sembleignore'); + check('N1.managed', safeParse(guidance(p, ['status', '--json']).stdout).ignore.state, 'managed', + 'a fresh install is managed'); + + // Simulate a release: rewrite ONLY the stamp line of the shipped template. + const tpl = join(SKILL_COPY, 'assets', 'sembleignore.template'); + const orig = readFileSync(tpl, 'utf8'); + const bumped = orig.replace(/^# brewcode-meta: version=[^\s]+/m, '# brewcode-meta: version=99.9.9'); + check('N2.stampMoved', bumped !== orig, true, 'the fixture really did change line 1'); + writeFileSync(tpl, bumped); + check('N2.stillManaged', safeParse(guidance(p, ['status', '--json']).stdout).ignore.state, 'managed', + 'a version bump ALONE must never make an untouched .sembleignore look user-modified'); + + // A real edit still registers, bumped stamp or not. + appendFileSync(f, '\n# my own rule\nvendor/\n'); + check('N3.userModified', safeParse(guidance(p, ['status', '--json']).stdout).ignore.state, 'user_modified', + 'appending a real line is still detected as a user edit'); + + // And because it was never mistaken for one, the bumped template lands. + writeFileSync(f, readFileSync(f, 'utf8').replace('\n# my own rule\nvendor/\n', '')); + guidance(p, ['install', '--part', 'ignore', '--json']); + check('N4.updated', readFileSync(f, 'utf8'), bumped, + 'install over the unmodified file writes the new template through, stamp included'); + writeFileSync(tpl, orig); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// O. upgrade must be able to CLEAR staleness, and uninstall must not lie +// +// Four review waves checked that INSTALL stamps correctly and none checked that +// a stamp can ever CHANGE. `setup-status` row 2 reads this install's version out +// of the frontmatter of .claude/rules/semble-first.md, and semble-guidance.sh is +// its only writer - so an `upgrade` that never calls the script reported success, +// left the stamp where it was, and the next `status` printed `stale` forever. +// Same omission kept the two v5.0.0-retired hooks on disk indefinitely. +// ═══════════════════════════════════════════════════════════════════════════ + +// O1 — the release loop: bumped template + retired hooks -> ONE guidance install +// restamps the rule and deletes both retired files. +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const rule = join(p, '.claude', 'rules', 'semble-first.md'); + const { reminder: rem, explore: exp } = semblePaths(p); + const verOf = (f) => (readFileSync(f, 'utf8').match(/^version: "([^"]+)"/m) || [])[1]; + const installed = verOf(rule); + check('O1.installed', typeof installed === 'string' && /^\d+\.\d+\.\d+$/.test(installed), true, + 'the fresh install carries the template\'s baked X.Y.Z stamp'); + + // pre-5.0.0 install: both retired advisory hooks still on disk + writeFileSync(rem, '// legacy advisory hook, retired in v5.0.0\n'); + writeFileSync(exp, '// legacy advisory hook, retired in v5.0.0\n'); + + // simulate the release: bump ONLY the baked stamp of the shipped template + const tpl = join(SKILL_COPY, 'assets', 'semble-first.md.template'); + const orig = readFileSync(tpl, 'utf8'); + writeFileSync(tpl, orig.replace(/^version: "[^"]+"/m, 'version: "99.9.9"')); + check('O1.stale', verOf(rule), installed, 'before the upgrade the installed rule still reads the OLD version'); + + const r = guidance(p, ['install', '--part', 'all', '--json']); + check('O1.exit', r.status, 0, 'the upgrade run exits 0'); + check('O1.restamped', verOf(rule), '99.9.9', + 'the rule frontmatter now reads the NEW version - this is what clears `stale`'); + check('O1.resync', safeParse(r.stdout).changed.filter((l) => l.startsWith('rule:')), + [`rule: re-synced ${rule} (metadata only)`], + 'and it took the metadata-only re-sync branch, not a forced overwrite'); + check('O1.retiredGone', [existsSync(rem), existsSync(exp)], [false, false], + 'the same run deletes both v5.0.0-retired hooks'); + check('O1.byteIdentical', readFileSync(rule, 'utf8'), readFileSync(tpl, 'utf8'), + 'the restamped rule is byte-identical to the template, so setup-status cmp still reads SAME'); + + const second = guidance(p, ['install', '--part', 'all', '--json']); + check('O1.idempotent', safeParse(second.stdout).changed, [], + 'a second upgrade at the same version changes nothing'); + writeFileSync(tpl, orig); +} + +// O2 — a hand-edited rule is NEVER restamped behind the user's back +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const rule = join(p, '.claude', 'rules', 'semble-first.md'); + appendFileSync(rule, '\n## my own section\n'); + const mine = readFileSync(rule, 'utf8'); + const tpl = join(SKILL_COPY, 'assets', 'semble-first.md.template'); + const orig = readFileSync(tpl, 'utf8'); + writeFileSync(tpl, orig.replace(/^version: "[^"]+"/m, 'version: "99.9.9"')); + const r = guidance(p, ['install', '--part', 'all', '--json']); + check('O2.untouched', readFileSync(rule, 'utf8'), mine, + 'the hand-edited rule survives the upgrade byte for byte'); + check('O2.reported', safeParse(r.stdout).skipped.some((l) => l.startsWith('rule: user_modified')), true, + 'and the run says so, instead of silently leaving a stale stamp'); + writeFileSync(tpl, orig); +} + +// O3 — the sibling-less fallback removal path leaves no registered hook behind. +// It deleted session + prefetch but not stats, so an uninstall left a wired +// semble-stats.mjs on disk. +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const { session, prefetch: pre, stats, reminder: rem, explore: exp } = semblePaths(p); + writeFileSync(rem, '// orphan\n'); + writeFileSync(exp, '// orphan\n'); + + // A scripts dir holding semble-remove.sh and its lib but NO semble-guidance.sh. + const lone = join(BASE, 'lone-remove'); + mkdirSync(join(lone, 'lib'), { recursive: true }); + copyFileSync(REMOVE_COPY, join(lone, 'semble-remove.sh')); + copyFileSync(join(SKILL_COPY, 'scripts', 'lib', 'semble-common.sh'), join(lone, 'lib', 'semble-common.sh')); + const r = spawnSync('bash', [join(lone, 'semble-remove.sh'), 'integration', '--yes', '--json'], { + encoding: 'utf8', + env: { ...process.env, SEMBLE_PROJECT_ROOT: p, SEMBLE_TEST_HOME: HOME, SEMBLE_NO_NETWORK: '1' }, + timeout: 30000, + }); + check('O3.exit', r.status, 0, 'the fallback removal exits 0'); + check('O3.files', [session, pre, stats, rem, exp].map((f) => existsSync(f)), [false, false, false, false, false], + 'every owned hook file goes - semble-stats.mjs included, or uninstall orphans a REGISTERED hook'); + check('O3.ignore', existsSync(join(p, '.sembleignore')), false, + 'and the repo-root .sembleignore goes with them'); +} + +// O4 — the confirmation plan must not under-report what the run deletes +{ + const p = freshProject({}); + guidance(p, ['install', '--part', 'all', '--json']); + const planOf = (flavour) => { + const r = spawnSync('bash', [REMOVE_COPY, flavour, '--json'], { + encoding: 'utf8', + env: { ...process.env, SEMBLE_PROJECT_ROOT: p, SEMBLE_TEST_HOME: HOME, SEMBLE_NO_NETWORK: '1' }, + timeout: 30000, + }); + return { status: r.status, would: safeParse(r.stdout || '{}').wouldDelete || [] }; + }; + for (const flavour of ['integration', 'purge']) { + const { status, would } = planOf(flavour); + check(`O4.${flavour}.exit`, status, 4, `${flavour} without confirmation exits 4 and deletes nothing`); + const named = (needle) => would.some((l) => l.includes(needle)); + check(`O4.${flavour}.plan`, + ['.sembleignore', 'semble-session.mjs', 'semble-prefetch.mjs', 'semble-stats.mjs', + 'semble-reminder.mjs', 'semble-explore.mjs'].map(named), + [true, true, true, true, true, true], + 'the plan names every file the run really removes - a user confirms this list'); + } } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/brewcode/skills/semble-setup/tests/suite-integration.mjs b/brewcode/skills/semble-setup/tests/suite-integration.mjs index d044893..546e319 100644 --- a/brewcode/skills/semble-setup/tests/suite-integration.mjs +++ b/brewcode/skills/semble-setup/tests/suite-integration.mjs @@ -42,7 +42,7 @@ const REMOVE_SH = join(SCRIPTS, 'semble-remove.sh'); const STATE_SH = join(SCRIPTS, 'semble-state.sh'); const SERVER = 'semble_code'; -const PIN_SPEC = 'semble[mcp]==0.5.2'; +const PIN_SPEC = 'semble[mcp]==0.5.4'; const TOOL_SEARCH = 'mcp__semble_code__search'; const TOOL_RELATED = 'mcp__semble_code__find_related'; @@ -356,7 +356,7 @@ check('wired: report top-level keys', keysOf(R), 'every §9.1 section is present in --section all'); check('wired: schema + platform + projectRoot', [R.schema, R.platform, R.projectRoot], [1, 'darwin', P1], 'header fields carry the resolved project root'); -check('wired: pin', R.pin, { approved: '0.5.2', spec: PIN_SPEC }, 'the approved pin is reported verbatim'); +check('wired: pin', R.pin, { approved: '0.5.4', spec: PIN_SPEC }, 'the approved pin is reported verbatim'); // §SCOPE 1 - not a single section may be an {"error": ...} placeholder. const SECTIONS = ['prereq', 'mcp', 'cache', 'guidance', 'agents', 'coverage', 'state']; @@ -426,7 +426,7 @@ check('wired: cache roots + docs reservation', [CACHE_CODE, CACHE_DOCS, false, []], 'injected roots, no docs marker, no other repo indexed yet'); // state -check('wired: state keys', keysOf(R.state), ['completed', 'enabled', 'phase', 'present', 'updatedAt'], +check('wired: state keys', keysOf(R.state), ['completed', 'enabled', 'last_updated', 'phase', 'present'], 'the §9.1 state shape'); check('wired: state phase', [R.state.present, R.state.phase], [true, 'ready'], 'the real state file drives the state section'); @@ -443,29 +443,35 @@ check('wired: --strict exit', strict.status, 0, '--strict exits 0 exactly when t // ── 2a. guidance ──────────────────────────────────────────────────────────── check('guidance: keys', keysOf(R.guidance), - ['claudeMd', 'hooks', 'permissionsWired', 'rule', 'settingsFile', 'staleEntries', 'wiredCount'], - 'the §9.1 guidance shape plus the derived wiredCount'); -check('guidance: hooks sub-keys', keysOf(R.guidance.hooks), ['explore', 'reminder', 'session'], - 'hooks collapses to three file-presence strings'); + ['claudeMd', 'hooks', 'ignore', 'permissionsWired', 'pluginVersion', 'retired', 'rule', + 'settingsFile', 'staleEntries', 'version', 'wantCount', 'wiredCount'], + 'the §9.1 guidance shape plus the derived wiredCount/wantCount, the migration list, and the ' + + 'installed-vs-plugin stamp pair that carries the stale-artifacts signal'); +check('guidance: hooks sub-keys', keysOf(R.guidance.hooks), ['prefetch', 'session', 'stats'], + 'hooks collapses to three file-presence strings, one per LIVE hook'); check('guidance: flattened states', - [R.guidance.rule, R.guidance.claudeMd, R.guidance.hooks.session, R.guidance.hooks.reminder, - R.guidance.hooks.explore], + [R.guidance.rule, R.guidance.claudeMd, R.guidance.hooks.session, R.guidance.hooks.prefetch, + R.guidance.hooks.stats], [rawGuid.rule.state, rawGuid.claudeMd.state, - rawGuid.hooks.session.file, rawGuid.hooks.reminder.file, rawGuid.hooks.explore.file], + rawGuid.hooks.session.file, rawGuid.hooks.prefetch.file, rawGuid.hooks.stats.file], 'each flattened field equals the sibling sub-object it was taken from'); check('guidance: installed states', [R.guidance.rule, R.guidance.claudeMd, - R.guidance.hooks.session, R.guidance.hooks.reminder, R.guidance.hooks.explore], + R.guidance.hooks.session, R.guidance.hooks.prefetch, R.guidance.hooks.stats], ['managed', 'present', 'present', 'present', 'present'], 'after a real install: managed rule, marker block in CLAUDE.md, all three hook files copied'); +check('guidance: nothing retired left over', [R.guidance.retired, rawGuid.hooks.retired], [[], []], + 'a fresh install has no v1 hook file to migrate away'); check('guidance: settingsFile + staleEntries + permissionsWired', [R.guidance.settingsFile, R.guidance.staleEntries, R.guidance.permissionsWired], [join(P1, '.claude/settings.json'), 0, true], 'project settings path, no stale entries, both tool permissions wired'); -check('guidance: wiredCount fully wired', [R.guidance.wiredCount, rawGuid.hooks.wiredCount], [4, 4], - 'all four entries (SessionStart + PreToolUse/Bash + PreToolUse/Grep + SubagentStart/Explore)' - + ' are registered'); +check('guidance: wiredCount fully wired', + [R.guidance.wiredCount, R.guidance.wantCount, rawGuid.hooks.wiredCount, rawGuid.hooks.wantCount], + [4, 4, 4, 4], + 'all four entries (SessionStart + UserPromptSubmit/prefetch + PostToolUse/stats' + + ' + PostToolUseFailure/stats) are registered'); -// Partial wiring: strip the PreToolUse/Grep entry only. Independent truth is +// Partial wiring: strip the UserPromptSubmit entry only. Independent truth is // 3 of 4 registered entries, which is also what semble-guidance.sh reports. const P2 = join(WORLD, 'p-partial'); const p2Env = { SEMBLE_PROJECT_ROOT: P2 }; @@ -476,25 +482,25 @@ check('partial: guidance install exit', const P2_SETTINGS = join(P2, '.claude/settings.json'); const p2Settings = readJson(P2_SETTINGS); -const p2Pre = p2Settings.hooks.PreToolUse; -p2Settings.hooks.PreToolUse = p2Pre.filter((e) => e.matcher !== 'Grep'); +const p2Ups = p2Settings.hooks.UserPromptSubmit; +delete p2Settings.hooks.UserPromptSubmit; writeFileSync(P2_SETTINGS, `${JSON.stringify(p2Settings, null, 2)}\n`); -check('partial: exactly one Grep entry was removed', - [p2Pre.length, p2Settings.hooks.PreToolUse.length], [2, 1], - 'the reminder was registered under Bash and Grep; only Grep is gone'); +check('partial: exactly one prefetch entry existed to remove', + [p2Ups.length, p2Settings.hooks.UserPromptSubmit], [1, undefined], + 'the prefetch hook is registered once, unmatched, and that single row is now gone'); const rawGuid2 = safeParse(run(GUIDANCE_SH, ['status', '--json'], p2Env).stdout); const R2 = safeParse(runStatus(['--section', 'guidance', '--json'], p2Env).stdout); check('partial: sibling counts 3 of 4 entries', - [rawGuid2.hooks.session.wired, rawGuid2.hooks.reminder.wired, rawGuid2.hooks.explore.wired, - rawGuid2.hooks.wiredCount], - [true, false, true, 3], - 'semble-guidance.sh counts registered entries: SessionStart + Bash + Explore = 3,' - + ' reminder not fully wired'); + [rawGuid2.hooks.session.wired, rawGuid2.hooks.prefetch.wired, + rawGuid2.hooks.stats.wired, rawGuid2.hooks.wiredCount, rawGuid2.hooks.wantCount], + [true, false, true, 3, 4], + 'semble-guidance.sh counts registered entries: SessionStart + both stats rows = 3,' + + ' prefetch not wired'); check('partial: guidance.wiredCount agrees with the sibling', - [R2.guidance.wiredCount, rawGuid2.hooks.wiredCount], [3, 3], - 'status reads guidance.hooks.wiredCount instead of re-deriving it, so a half-wired reminder' - + ' (SessionStart + Bash + Explore present, Grep gone) reports 3/4 on both sides'); + [R2.guidance.wiredCount, rawGuid2.hooks.wiredCount, R2.guidance.wantCount], [3, 3, 4], + 'status reads guidance.hooks.wiredCount instead of re-deriving it, so a missing prefetch' + + ' row reports 3/4 on both sides'); check('partial: section filter emits guidance only', keysOf(R2), ['generatedAt', 'guidance', 'nextStep', 'pin', 'platform', 'projectRoot', 'schema', 'verdict'], '--section guidance adds exactly one section to the header + verdict'); @@ -519,7 +525,7 @@ const R3 = safeParse(runStatus(['--section', 'all', '--json'], p3Env).stdout); check('bare: wiredCount zero both sides', [R3.guidance.wiredCount, rawGuid3.hooks.wiredCount], [0, 0], 'nothing installed => 0/4 on both sides'); check('bare: guidance states', [R3.guidance.rule, R3.guidance.claudeMd, - R3.guidance.hooks.session, R3.guidance.hooks.reminder, R3.guidance.hooks.explore, + R3.guidance.hooks.session, R3.guidance.hooks.prefetch, R3.guidance.hooks.stats, R3.guidance.permissionsWired], ['absent', 'absent', 'missing', 'missing', 'missing', false], 'an untouched project reports everything absent'); @@ -713,8 +719,8 @@ check('lifecycle: guidance install exit', lifeGuid.status, 0, 'guidance installe check('lifecycle: guidance artefacts on disk', [existsSync(join(LIFE, '.claude/rules/semble-first.md')), existsSync(join(LIFE, '.claude/hooks/semble-session.mjs')), - existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs')), - existsSync(join(LIFE, '.claude/hooks/semble-explore.mjs'))], + existsSync(join(LIFE, '.claude/hooks/semble-prefetch.mjs')), + existsSync(join(LIFE, '.claude/hooks/semble-stats.mjs'))], [true, true, true, true], 'rule + all three hook assets landed in the project'); // resume: a new session observes the live server, verifies, goes ready @@ -766,8 +772,8 @@ check('lifecycle: guidance + state removed', [existsSync(join(LIFE, '.claude/semble')), existsSync(join(LIFE, '.claude/rules/semble-first.md')), existsSync(join(LIFE, '.claude/hooks/semble-session.mjs')), - existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs')), - existsSync(join(LIFE, '.claude/hooks/semble-explore.mjs'))], + existsSync(join(LIFE, '.claude/hooks/semble-prefetch.mjs')), + existsSync(join(LIFE, '.claude/hooks/semble-stats.mjs'))], [false, false, false, false, false], 'state dir, rule and all three hooks are gone'); check('lifecycle: ~/.claude.json byte-identical', sha(LIFE_CJ), claudeJsonBefore, 'remove integration leaves the MCP registration untouched'); diff --git a/brewcode/skills/semble-setup/tests/suite-project.mjs b/brewcode/skills/semble-setup/tests/suite-project.mjs index 271d5a2..4dbfca3 100644 --- a/brewcode/skills/semble-setup/tests/suite-project.mjs +++ b/brewcode/skills/semble-setup/tests/suite-project.mjs @@ -24,7 +24,7 @@ const MCP_SH = join(SCRIPTS, 'semble-mcp.sh'); const GUIDANCE_SH = join(SCRIPTS, 'semble-guidance.sh'); const STATUS_SH = join(SCRIPTS, 'semble-status.sh'); -const PIN_SPEC = 'semble[mcp]==0.5.2'; +const PIN_SPEC = 'semble[mcp]==0.5.4'; const SERVER = 'semble_code'; // GIVEN: a fresh isolated temp base. realpathSync mirrors the `cd && pwd -P` @@ -236,6 +236,64 @@ check('audit disclosure mdx', auditFull.disclosure.includes('.mdx'), true, check('audit is read-only', existsSync(join(COV, '.claude')), false, 'audit wrote nothing into the project'); +// ═══════════════════════════════════════════════════════════════════════════ +// 1b. candidates — measured per-repo .sembleignore proposals +// +// The shipped template's per-repo section is empty because the two things that +// actually waste result slots are layout-specific. This mode measures them. +// Every assertion here is about what it must NOT propose as much as what it +// must: a wrong exclusion fails silently, and silence is the worse error. +// ═══════════════════════════════════════════════════════════════════════════ +const CAND = join(BASE, 'repo-cand'); +for (let i = 0; i < 10; i++) { + const body = `export function f${i}() {\n return ${i};\n}\n${PAD}\n`; + write(join(CAND, 'src', `m${i}.ts`), body); + write(join(CAND, '.mirror', `m${i}.ts`), body); // byte-identical mirror + write(join(CAND, 'lib', `u${i}.ts`), `${body}// unique ${i}\n`); +} +write(join(CAND, 'CHANGELOG.md'), '- one release note line\n'.repeat(6000)); +write(join(CAND, 'notes', 'a.md'), `# a\n${PAD}\n`); + +const candR = run(PROJECT_SH, ['candidates', '--json'], { SEMBLE_PROJECT_ROOT: CAND }); +const cand = safeParse(candR.stdout); +const byPath = Object.fromEntries((cand.candidates || []).map((c) => [c.path, c])); +check('candidates.exit', candR.status, 0, 'the scan exits 0'); +check('candidates.source', cand.source, 'filesystem', + 'with no index on disk the scan says outright that it is standing on byte share'); +check('candidates.mirror', (byPath['/.mirror/'] || {}).kind, 'duplicate-tree', + 'a tree whose every file is a byte-identical copy of a file elsewhere is proposed'); +check('candidates.mirrorCount', [(byPath['/.mirror/'] || {}).files, (byPath['/.mirror/'] || {}).duplicates], + [10, 10], 'the proposal carries the count it is based on, not an adjective'); +check('candidates.notSrc', Object.prototype.hasOwnProperty.call(byPath, '/src/'), false, + 'the ORIGINAL of the mirrored pair is never proposed - excluding it would delete the repo from the corpus'); +check('candidates.notLib', Object.prototype.hasOwnProperty.call(byPath, '/lib/'), false, + 'a tree of near-but-not-byte-identical files is not a duplicate tree and is left alone'); +check('candidates.changelog', (byPath['/CHANGELOG.md'] || {}).kind, 'heavy-file', + 'one prose file carrying a large share of the corpus is proposed on its own'); +check('candidates.noCode', (cand.candidates || []).filter((c) => c.kind === 'heavy-dir' && c.path === '/lib/'), [], + 'a SOURCE directory is never proposed as a corpus hog - a big source tree is the repo, not noise'); +check('candidates.evidence', (cand.candidates || []).every((c) => typeof c.reason === 'string' && /\d/.test(c.reason)), true, + 'every proposal states its measurement so the user can judge it'); +check('candidates.shares', + (cand.candidates || []).filter((c) => !(c.share > 0 && c.share <= 1)).map((c) => [c.path, c.share]), [], + 'every share is a real fraction of the corpus - any out-of-range one is named, not just counted'); +check('candidates.readOnly', existsSync(join(CAND, '.sembleignore')), false, + 'the scan proposes and writes nothing - install owns the file'); + +// A repo with no mirror and no hog must come back empty, or the block install +// writes becomes noise and stops being read. +const PLAIN = join(BASE, 'repo-plain'); +for (let i = 0; i < 6; i++) write(join(PLAIN, 'src', `p${i}.ts`), `export const p${i} = ${i};\n${PAD}\n`); +const plain = safeParse(run(PROJECT_SH, ['candidates', '--json'], { SEMBLE_PROJECT_ROOT: PLAIN }).stdout); +check('candidates.plainEmpty', plain.candidates, [], + 'an ordinary repo yields no proposals at all'); +check('candidates.plainScanned', plain.scanned, 6, 'and the scan still reports what it looked at'); + +const candHuman = run(PROJECT_SH, ['candidates'], { SEMBLE_PROJECT_ROOT: PLAIN }); +check('candidates.humanExit', candHuman.status, 0, 'the human form exits 0 too'); +check('candidates.humanEmpty', candHuman.stdout.includes('none - nothing in this repo'), true, + 'and says so in words rather than printing an empty list'); + // ═══════════════════════════════════════════════════════════════════════════ // 2. warm / smoke honour SEMBLE_NO_NETWORK // ═══════════════════════════════════════════════════════════════════════════ @@ -607,7 +665,7 @@ function forcePhase(root, phase) { if (phase === 'absent') return; write(join(dir, 'state.json'), `${JSON.stringify({ schema: 1, profile: 'code', phase, enabled: true, scope: 'user', - projectRoot: root, approvedVersion: '0.5.2', completed: [], + projectRoot: root, approvedVersion: '0.5.4', completed: [], }, null, 2)}\n`); } diff --git a/brewcode/skills/semble-setup/tests/suite-status.mjs b/brewcode/skills/semble-setup/tests/suite-status.mjs index 1940259..52b2a30 100755 --- a/brewcode/skills/semble-setup/tests/suite-status.mjs +++ b/brewcode/skills/semble-setup/tests/suite-status.mjs @@ -297,7 +297,7 @@ function clearState() { check('10-platform', j.platform, process.platform === 'darwin' ? 'darwin' : 'linux', 'platform must be the resolved uname family'); check('10-projectRoot', j.projectRoot, PROJECT, 'projectRoot must be the injected SEMBLE_PROJECT_ROOT'); - check('10-pin', j.pin, { approved: '0.5.2', spec: 'semble[mcp]==0.5.2' }, 'pin must be the approved 0.5.2 spec'); + check('10-pin', j.pin, { approved: '0.5.4', spec: 'semble[mcp]==0.5.4' }, 'pin must be the approved 0.5.4 spec'); for (const sec of ['mcp', 'cache', 'guidance', 'agents', 'coverage']) { check(`11-${sec}-error-type`, typeof (j[sec] || {}).error, 'string', @@ -307,7 +307,7 @@ function clearState() { } check('12-state', j.state, - { present: false, phase: 'absent', enabled: null, completed: [], updatedAt: null }, + { present: false, phase: 'absent', enabled: null, completed: [], last_updated: null }, 'a missing state file must read as phase=absent, present=false'); check('13-verdict', j.verdict, 'partial', 'no sibling can report -> mcp state unknown -> partial, never a false not_installed'); @@ -407,14 +407,14 @@ for (const [file, state, verdict, nextStep] of FIXTURE_STATES) { ]; for (const [tag, st, verdict, nextStep] of cases) { - writeState({ schema: 1, profile: 'code', ...st, completed: ['prereq'], updatedAt: '2026-08-02T18:10:47.000Z' }); + writeState({ schema: 1, profile: 'code', ...st, completed: ['prereq'], last_updated: '2026-08-02' }); const r = runStatus(['--json'], { SEMBLE_STUB_DETECT: DETECT_FILE }); const j = safeParse(r.stdout); check(`30-${tag}-exit`, r.status, 0, `phase=${st.phase} must exit 0 without --strict`); check(`30-${tag}-verdict`, j.verdict, verdict, `mcp=correct + phase=${st.phase} + enabled=${st.enabled} -> ${verdict}`); check(`30-${tag}-nextStep`, j.nextStep, nextStep, `phase=${st.phase} next step`); check(`30-${tag}-state`, j.state, - { present: true, phase: st.phase, enabled: st.enabled, completed: ['prereq'], updatedAt: '2026-08-02T18:10:47.000Z' }, + { present: true, phase: st.phase, enabled: st.enabled, completed: ['prereq'], last_updated: '2026-08-02' }, `phase=${st.phase} state section`); } @@ -424,7 +424,7 @@ for (const [file, state, verdict, nextStep] of FIXTURE_STATES) { dump: { user: null, local: null, project: null, upstreamUser: null, upstreamLocal: null, malformed: [], projectEnabled: null }, expected: {}, diff: [], connectivity: 'unknown', }); - writeState({ schema: 1, phase: 'prereq_ready', enabled: true, completed: [], updatedAt: '2026-08-02T18:10:47.000Z' }); + writeState({ schema: 1, phase: 'prereq_ready', enabled: true, completed: [], last_updated: '2026-08-02' }); const notInstalled = safeParse(runStatus(['--json'], { SEMBLE_STUB_DETECT: DETECT_FILE }).stdout); check('31-not-installed', notInstalled.verdict, 'not_installed', 'mcp=absent + phase=prereq_ready is not_installed, not partial'); @@ -439,6 +439,105 @@ for (const [file, state, verdict, nextStep] of FIXTURE_STATES) { check('32-bad-state-verdict', bj.verdict, 'error', 'phase=error is verdict=error'); } +// ═══════════════════════════════════════════════════════════════════════════ +// 4b. A v1-shaped project half downgrades ready -> partial +// +// mcp=correct + phase=ready is not enough: the whole migration path is +// unreachable if a repo still carrying the retired hooks, stale settings +// entries or a half-wired hook set reports `ready`/`none`. +// ═══════════════════════════════════════════════════════════════════════════ +{ + const GUID_STUB = join(STAGE, 'semble-guidance.sh'); + const GUID_FILE = join(BIN, 'guidance.json'); + writeFileSync(GUID_STUB, `#!/usr/bin/env bash +set -euo pipefail +cat "\${SEMBLE_STUB_GUIDANCE:?}" +`); + chmodSync(GUID_STUB, 0o755); + + installClaudeJson('correct.json'); + const dump = dumpFromClaudeJson('correct.json'); + setDetect({ schema: 1, state: 'correct', dump, expected: {}, diff: [], connectivity: 'connected' }); + writeState({ schema: 1, phase: 'ready', enabled: true, completed: ['prereq', 'mcp'], last_updated: '2026-08-02' }); + + const healthy = { + rule: { state: 'managed' }, ignore: { state: 'managed' }, claudeMd: { state: 'managed' }, + permissions: { wired: true }, + hooks: { + settingsFile: join(PROJECT, '.claude', 'settings.json'), + session: { file: 'present' }, prefetch: { file: 'present' }, stats: { file: 'present' }, + retired: [], wiredCount: 4, wantCount: 4, staleEntries: 0, + }, + }; + const withHooks = (patch) => { + const g = JSON.parse(JSON.stringify(healthy)); + Object.assign(g.hooks, patch); + return g; + }; + const guidRun = (payload) => { + writeFileSync(GUID_FILE, JSON.stringify(payload)); + const env = { SEMBLE_STUB_DETECT: DETECT_FILE, SEMBLE_STUB_GUIDANCE: GUID_FILE }; + const j = safeParse(runStatus(['--json'], env).stdout); + // `reason` lives only in the human report - the JSON envelope is fixed. + const line = runStatus([], env).stdout.split('\n').find((l) => / (ready|partial|verifying|disabled|error|reload_required|not_installed) - /.test(l)) || ''; + j.__reason = line.slice(line.indexOf(' - ') + 3); + return j; + }; + + const ok = guidRun(healthy); + check('33-healthy-verdict', ok.verdict, 'ready', + 'a fully migrated project half must leave the ready verdict alone'); + check('33-healthy-nextStep', ok.nextStep, 'none', 'ready means there is nothing to run'); + + // This is the exact platfrom shape that used to report ready/none. + const v1 = guidRun(withHooks({ + retired: ['semble-reminder.mjs', 'semble-explore.mjs'], + prefetch: { file: 'missing' }, wiredCount: 1, wantCount: 4, staleEntries: 5, + })); + check('34-v1-verdict', v1.verdict, 'partial', + 'a v1-shaped repo (retired hooks + stale entries + 1/4 wired) must never report ready'); + check('34-v1-nextStep', v1.nextStep, 'Run /brewcode:semble-setup install', + 'and it must name the command that performs the migration'); + check('34-v1-reason', v1.__reason, + 'retired hooks on disk: semble-reminder.mjs, semble-explore.mjs; 5 stale settings entries; hooks wired 1/4', + 'the reason must name all three defects, so the user knows what install will repair'); + + const retiredOnly = guidRun(withHooks({ retired: ['semble-reminder.mjs'] })); + check('35-retired-verdict', retiredOnly.verdict, 'partial', + 'a retired hook file still on disk is enough on its own'); + check('35-retired-reason', retiredOnly.__reason, 'retired hooks on disk: semble-reminder.mjs', + 'one retired file, one clause'); + + const staleOnly = guidRun(withHooks({ staleEntries: 1 })); + check('36-stale-verdict', staleOnly.verdict, 'partial', + 'a settings entry pointing at an older plugin version is enough on its own'); + check('36-stale-reason', staleOnly.__reason, '1 stale settings entry', + 'the singular clause is singular'); + + const halfWired = guidRun(withHooks({ wiredCount: 3, wantCount: 4 })); + check('37-wiring-verdict', halfWired.verdict, 'partial', + 'a missing sibling hook registration is enough on its own'); + check('37-wiring-reason', halfWired.__reason, 'hooks wired 3/4', 'the reason carries the counts'); + + // An absent count is not a defect: a report that never collected the wiring + // numbers must not be read as a half-wired repo. + const noCounts = guidRun(withHooks({ wiredCount: 0, wantCount: 0 })); + check('38-nocounts-verdict', noCounts.verdict, 'ready', + 'wantCount=0 means the counts were not reported - never downgrade on a missing measurement'); + + // The downgrade only ever applies to `ready`; it must not overwrite a + // stronger verdict that another signal already produced. + writeState({ schema: 1, phase: 'verifying', enabled: true, completed: [], last_updated: '2026-08-02' }); + const stillVerifying = guidRun(withHooks({ retired: ['semble-reminder.mjs'], staleEntries: 2 })); + check('39-precedence-verdict', stillVerifying.verdict, 'verifying', + 'a non-ready verdict outranks the guidance downgrade'); + check('39-precedence-nextStep', stillVerifying.nextStep, 'Run /brewcode:semble-setup resume', + 'and keeps its own next step'); + + rmSync(GUID_STUB, { force: true }); + writeState({ schema: 1, phase: 'ready', enabled: true, completed: ['prereq', 'mcp'], last_updated: '2026-08-02' }); +} + // ═══════════════════════════════════════════════════════════════════════════ // 5. --strict // ═══════════════════════════════════════════════════════════════════════════ @@ -447,12 +546,12 @@ for (const [file, state, verdict, nextStep] of FIXTURE_STATES) { const dump = dumpFromClaudeJson('correct.json'); setDetect({ schema: 1, state: 'correct', dump, expected: {}, diff: [], connectivity: 'connected' }); - writeState({ schema: 1, phase: 'ready', enabled: true, completed: ['prereq', 'mcp'], updatedAt: '2026-08-02T18:10:47.000Z' }); + writeState({ schema: 1, phase: 'ready', enabled: true, completed: ['prereq', 'mcp'], last_updated: '2026-08-02' }); const ready = runStatus(['--json', '--strict'], { SEMBLE_STUB_DETECT: DETECT_FILE }); check('40-strict-ready-exit', ready.status, 0, '--strict must exit 0 when verdict === ready'); check('40-strict-ready-verdict', safeParse(ready.stdout).verdict, 'ready', 'the ready verdict itself'); - writeState({ schema: 1, phase: 'verifying', enabled: true, completed: [], updatedAt: '2026-08-02T18:10:47.000Z' }); + writeState({ schema: 1, phase: 'verifying', enabled: true, completed: [], last_updated: '2026-08-02' }); const notReady = runStatus(['--json', '--strict'], { SEMBLE_STUB_DETECT: DETECT_FILE }); check('41-strict-notready-exit', notReady.status, 1, '--strict must exit 1 when verdict !== ready'); check('41-strict-notready-body', safeParse(notReady.stdout).verdict, 'verifying', @@ -542,7 +641,7 @@ for (const [file, state, verdict, nextStep] of FIXTURE_STATES) { installClaudeJson('correct.json'); const dump = dumpFromClaudeJson('correct.json'); setDetect({ schema: 1, state: 'correct', dump, expected: {}, diff: [], connectivity: 'connected' }); - writeState({ schema: 1, phase: 'ready', enabled: true, completed: ['prereq', 'mcp'], updatedAt: '2026-08-02T18:10:47.000Z' }); + writeState({ schema: 1, phase: 'ready', enabled: true, completed: ['prereq', 'mcp'], last_updated: '2026-08-02' }); const r = runStatus([], { SEMBLE_STUB_DETECT: DETECT_FILE }); const lines = r.stdout.split('\n'); @@ -579,8 +678,8 @@ writeFileSync(BREW_LOG, ''); check('83-check-status', cj.status, 'precondition', 'the status field must be precondition'); check('83-check-schema', cj.schema, 1, 'install schema is exactly 1'); check('83-check-uvx', cj.uvx, { present: false, version: '' }, 'uvx must be reported absent'); - check('83-check-spec', cj.semble.spec, 'semble[mcp]==0.5.2', 'the pinned spec is never floating'); - check('83-check-pin', cj.semble.pin, '0.5.2', 'the approved pin'); + check('83-check-spec', cj.semble.spec, 'semble[mcp]==0.5.4', 'the pinned spec is never floating'); + check('83-check-pin', cj.semble.pin, '0.5.4', 'the approved pin'); check('83-check-commands', cj.commands, [], 'check runs nothing, so it records no commands'); check('83-check-brewlog', readFileSync(BREW_LOG, 'utf8'), '', 'check must not invoke brew'); @@ -605,15 +704,15 @@ writeFileSync(BREW_LOG, ''); const sj = safeParse(semNoUvx.stdout); check('86-semble-nouvx-exit', semNoUvx.status, 3, '`semble` without uvx must exit 3'); check('86-semble-nouvx-commands', sj.commands, - ["uvx --from 'semble[mcp]==0.5.2' semble --help"], - 'the probe command is single-quoted (zsh globs the brackets) and uses --help, never bare semble'); + ["uvx --from 'semble[mcp]==0.5.4' semble --version"], + 'the probe command is single-quoted (zsh globs the brackets) and uses --version (dispatch-set argv on the 0.5.4 pin), never bare semble'); check('86-semble-resolvable', sj.semble.resolvable, false, 'nothing was resolved'); check('86-semble-toolInstalled', sj.semble.toolInstalled, false, 'default mode is uvx-ephemeral'); const semTool = runInstall(['semble', '--tool', '--json']); const stj = safeParse(semTool.stdout); check('87-tool-commands', stj.commands, - ["uvx --from 'semble[mcp]==0.5.2' semble --help", "uv tool install 'semble[mcp]==0.5.2'"], + ["uvx --from 'semble[mcp]==0.5.4' semble --version", "uv tool install 'semble[mcp]==0.5.4'"], '--tool adds exactly one extra command, still pinned and quoted'); const all = runInstall(['all', '--json']); @@ -747,7 +846,7 @@ writeFileSync(BREW_LOG, ''); check('94f-all-dry-note', adj.note, 'uv/uvx not on PATH; uvx is not on PATH - install uv first', 'the coreutils step adds nothing to the note when it is only skipped'); check('94f-all-dry-order', adj.commands, - ['brew install uv', 'brew install coreutils', "uvx --from 'semble[mcp]==0.5.2' semble --help"], + ['brew install uv', 'brew install coreutils', "uvx --from 'semble[mcp]==0.5.4' semble --version"], 'all runs check -> uv -> coreutils -> semble, in that order'); check('94f-all-dry-step', adj.timeout.coreutils.status, 'skipped', 'the coreutils step is reported in `all`'); check('94f-all-dry-brewlog', readFileSync(BREW_LOG, 'utf8'), '', 'a dry `all` installs nothing'); @@ -795,7 +894,7 @@ writeFileSync(BREW_LOG, ''); const correctCfg = JSON.parse(fixtureText(join('claude-json', 'correct.json'))).mcpServers.semble_code; check('99-correct-args', correctCfg.args, - ['--from', 'semble[mcp]==0.5.2', 'semble', '--content', 'code', 'docs', 'config'], + ['--from', 'semble[mcp]==0.5.4', 'semble', '--content', 'code', 'docs', 'config'], 'the correct fixture must carry the exact frozen argv'); check('99-correct-env', correctCfg.env, { SEMBLE_CACHE_LOCATION: CACHE_CODE }, 'placeholder substitution must yield an absolute cache root'); diff --git a/brewcode/skills/semble-setup/tests/suite-telemetry.mjs b/brewcode/skills/semble-setup/tests/suite-telemetry.mjs new file mode 100644 index 0000000..b5426af --- /dev/null +++ b/brewcode/skills/semble-setup/tests/suite-telemetry.mjs @@ -0,0 +1,652 @@ +#!/usr/bin/env node +/** + * suite-telemetry.mjs - assets/semble-stats.mjs (the measurement hook) and + * semble-status.sh --section telemetry (the reader), plus the guidance + * merge/unmerge/status round trip with the two new want rows present. + * + * Everything runs inside one mkdtemp base. The real repo .claude/ is never + * touched: every project root is a temp dir and the scripts are staged into it. + * + * Assertion policy: unconditional exact-equality checks with a description. + * No `> 0`, no "contains something" - the conversion numbers are asserted + * exactly, because a metric nobody can check is worse than no metric. + */ +import { spawnSync } from 'node:child_process'; +import { + mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, + appendFileSync, chmodSync, statSync, realpathSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const HERE = join(fileURLToPath(import.meta.url), '..'); +const SKILL = join(HERE, '..'); +const SCRIPTS = join(SKILL, 'scripts'); +const HOOK = join(SKILL, 'assets', 'semble-stats.mjs'); + +let passed = 0; +let failed = 0; +const results = []; + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return false; + if (typeof a !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const ak = Object.keys(a); + const bk = Object.keys(b); + if (ak.length !== bk.length) return false; + for (const k of ak) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false; + if (!deepEqual(a[k], b[k])) return false; + } + return true; +} +function trunc(s) { + const str = String(s); + return str.length > 600 ? `${str.slice(0, 600)}...` : str; +} +function check(name, actual, expected, message) { + if (deepEqual(actual, expected)) { + passed++; + results.push(` PASS ${name} (${message})`); + return; + } + failed++; + results.push(` FAIL ${name} (${message} | actual=${trunc(JSON.stringify(actual))}` + + ` expected=${trunc(JSON.stringify(expected))})`); +} +function safeParse(str) { + try { + return JSON.parse(str); + } catch (e) { + return { __PARSE_ERROR__: String(e && e.message), raw: String(str).slice(0, 400) }; + } +} + +// realpath: on macOS /var is a symlink to /private/var, and the scripts resolve +// the project root to its real path - the expectations must match that. +const BASE = realpathSync(mkdtempSync(join(tmpdir(), 'semble-telemetry-'))); +process.on('exit', () => { + try { rmSync(BASE, { recursive: true, force: true }); } catch { /* best effort */ } +}); + +// ── helpers ───────────────────────────────────────────────────────────────── +function newProject(name) { + const p = join(BASE, name); + mkdirSync(join(p, '.claude', 'semble'), { recursive: true }); + return p; +} +function telFile(p) { return join(p, '.claude', 'semble', 'telemetry.jsonl'); } + +/** Fire one payload at the hook. Returns {status, stdout, stderr}. */ +function fire(payload, cwdForProc) { + const r = spawnSync(process.execPath, [HOOK], { + input: JSON.stringify(payload), + encoding: 'utf8', + cwd: cwdForProc || BASE, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} +function lines(p) { + if (!existsSync(telFile(p))) return []; + return readFileSync(telFile(p), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); +} +/** A record with ts+sid dropped, so shapes can be compared exactly. */ +function shape(rec) { + const { ts, sid, ...rest } = rec; + return rest; +} + +const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function post(over) { + return { + session_id: 's1', + transcript_path: '/tmp/t.jsonl', + cwd: '/replaced-per-call', + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'rg -n foo src/' }, + tool_response: {}, + tool_use_id: 'toolu_x', + ...over, + }; +} + +/** semble-status.sh --section telemetry, run from inside a project. */ +function reader(p, args) { + const r = spawnSync('bash', [join(SCRIPTS, 'semble-status.sh'), ...args], { + cwd: p, encoding: 'utf8', + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} +function readerJson(p, args) { + const r = reader(p, ['--section', 'telemetry', '--json', ...args]); + return { status: r.status, json: safeParse(r.stdout) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. Neutral output on every event the hook handles +// ═══════════════════════════════════════════════════════════════════════════ +{ + const p = newProject('neutral'); + const cases = [ + ['PostToolUse/semble', post({ cwd: p, tool_name: 'mcp__semble_code__search', tool_input: { query: 'q' } })], + ['PostToolUse/Bash', post({ cwd: p })], + ['PostToolUse/Grep', post({ cwd: p, tool_name: 'Grep', tool_input: { pattern: 'foo' } })], + ['PostToolUse/Glob', post({ cwd: p, tool_name: 'Glob', tool_input: { pattern: '**/*.ts' } })], + ['PostToolUseFailure', post({ cwd: p, hook_event_name: 'PostToolUseFailure', error: 'boom' })], + ['PostToolUse/unhandled-tool', post({ cwd: p, tool_name: 'Read', tool_input: { file_path: 'x' } })], + ['PreToolUse/ignored', post({ cwd: p, hook_event_name: 'PreToolUse' })], + ['unknown-event', post({ cwd: p, hook_event_name: 'PostToolBatch' })], + ]; + for (const [label, payload] of cases) { + const r = fire(payload); + check(`1.neutral.${label}`, [r.status, r.stdout], [0, '{}\n'], + 'a pure observer replies with the neutral {} and exits 0 - it can never alter a tool call'); + } + const empty = spawnSync(process.execPath, [HOOK], { input: '', encoding: 'utf8', cwd: BASE }); + check('1.neutral.empty-stdin', [empty.status, empty.stdout], [0, '{}\n'], + 'empty stdin is neutral, not a crash'); + const junk = spawnSync(process.execPath, [HOOK], { input: 'not json', encoding: 'utf8', cwd: BASE }); + check('1.neutral.malformed-stdin', [junk.status, junk.stdout], [0, '{}\n'], + 'unparseable stdin is neutral, not a crash'); + const arr = spawnSync(process.execPath, [HOOK], { input: '[1,2]', encoding: 'utf8', cwd: BASE }); + check('1.neutral.array-stdin', [arr.status, arr.stdout], [0, '{}\n'], + 'a JSON array on stdin is not an object and is ignored'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. Exact record shapes +// ═══════════════════════════════════════════════════════════════════════════ +{ + const p = newProject('shapes'); + fire(post({ + cwd: p, session_id: 'sA', tool_name: 'mcp__semble_code__search', + tool_input: { query: 'q' }, tool_response: { results: [] }, duration_ms: 812, + })); + fire(post({ + cwd: p, session_id: 'sA', tool_name: 'mcp__semble_code__find_related', + tool_input: { file_path: 'a.ts', line: 4 }, agent_id: 'ag_7', agent_type: 'general-purpose', + })); + fire(post({ + cwd: p, session_id: 'sB', hook_event_name: 'PostToolUseFailure', + tool_name: 'mcp__semble_code__search', tool_input: { query: 'q' }, + error: 'not indexed', is_interrupt: false, duration_ms: 40, + })); + fire(post({ cwd: p, session_id: 'sA', tool_input: { command: "rg -n 'session persistence' src/" }, duration_ms: 120 })); + fire(post({ cwd: p, session_id: 'sB', tool_name: 'Glob', tool_input: { pattern: '**/*.ts' }, agent_id: 'ag_9' })); + const L = lines(p); + check('2.count', L.length, 5, 'five recordable payloads produced exactly five lines'); + check('2.call.success.shape', shape(L[0]), + { ev: 'call', src: 'stats', tool: 'mcp__semble_code__search', ok: true, ms: 812, agent: 'main' }, + 'a successful main-session semble call, with duration_ms carried through as ms'); + check('2.call.sub.no-duration', shape(L[1]), + { ev: 'call', src: 'stats', tool: 'mcp__semble_code__find_related', ok: true, agent: 'sub' }, + 'no duration_ms in the payload => the ms key is OMITTED, never invented as 0'); + check('2.call.failed.shape', shape(L[2]), + { ev: 'call', src: 'stats', tool: 'mcp__semble_code__search', ok: false, ms: 40, agent: 'main' }, + 'PostToolUseFailure is the only place a failed semble call is observable => ok:false'); + check('2.search.bash.shape', shape(L[3]), + { ev: 'search', src: 'stats', tool: 'Bash', q: "rg -n 'session persistence' src/", agent: 'main' }, + 'the denominator record carries the command verbatim and no ms'); + check('2.search.glob.shape', shape(L[4]), + { ev: 'search', src: 'stats', tool: 'Glob', q: '**/*.ts', agent: 'sub' }, + 'Glob is a search tool by definition; agent_id alone marks it as a subagent call'); + check('2.sid', L.map((r) => r.sid), ['sA', 'sA', 'sB', 'sA', 'sB'], + 'sid is the payload session_id, verbatim'); + check('2.ts.iso', L.every((r) => ISO.test(r.ts)), true, + 'every ts is a new Date().toISOString() string'); + + const q = newProject('shapes-q'); + fire(post({ cwd: q, tool_input: { command: 'rg ' + 'x'.repeat(400) } })); + fire(post({ cwd: q, session_id: 's1' })); + const M = lines(q); + check('2.q.truncation', M[0].q.length, 120, 'q is truncated to 120 characters'); + check('2.sid.missing', shape(fireAndLast(newProject('shapes-nosid'), { + hook_event_name: 'PostToolUse', tool_name: 'Grep', tool_input: { pattern: 'foo' }, + })), { ev: 'search', src: 'stats', tool: 'Grep', q: 'foo', agent: 'main' }, + 'a payload with no session_id still records, with sid ""'); +} + +function fireAndLast(p, over) { + fire({ cwd: p, ...over }); + const L = lines(p); + return L[L.length - 1]; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. The search-shaped filter: real commands, accepted and rejected +// ═══════════════════════════════════════════════════════════════════════════ +{ + const accept = [ + 'rg -n foo src/', + 'grep -rn TODO .', + "ugrep --include='*.ts' handler", + 'find . -name "*.mjs"', + 'bfs . -type f', + 'ls -la && rg session src/', + 'cat x.txt | grep foo', + 'egrep -c pattern file', + 'cd /tmp; find . -name x', + '(rg foo)', + 'set -e\ngrep -rn bar .', // the `m` flag: a search on a later line + 'npm run build || rg error log.txt', + ]; + const reject = [ + 'npm run build', + 'git status', + 'node --check x.mjs', + 'echo "grep is a tool"', // grep not at a command boundary + 'ls -la', + 'docker compose up -d', + 'python3 -c "import re"', + '', + ]; + const p = newProject('filter-accept'); + for (const cmd of accept) fire(post({ cwd: p, tool_input: { command: cmd } })); + check('3.accept.count', lines(p).length, accept.length, + 'every search-shaped Bash command is recorded in the denominator'); + check('3.accept.all-search', lines(p).every((r) => r.ev === 'search' && r.tool === 'Bash'), true, + 'and each one is a search record for Bash'); + + const r = newProject('filter-reject'); + for (const cmd of reject) fire(post({ cwd: r, tool_input: { command: cmd } })); + check('3.reject.count', lines(r).length, 0, + 'a Bash call that is not search-shaped writes nothing - the log must not fill with build noise'); + check('3.reject.no-file', existsSync(telFile(r)), false, + 'nothing recorded means no file is created at all'); + + const t = newProject('filter-tools'); + fire(post({ cwd: t, tool_name: 'Grep', tool_input: { pattern: 'anything' } })); + fire(post({ cwd: t, tool_name: 'Glob', tool_input: { pattern: '**/*' } })); + fire(post({ cwd: t, tool_name: 'Read', tool_input: { file_path: join(t, 'a.mjs') } })); + fire(post({ cwd: t, tool_name: 'Agent', tool_input: { prompt: 'find the handler' } })); + fire(post({ cwd: t, tool_name: 'Write', tool_input: { file_path: 'a', content: 'grep' } })); + check('3.tools', lines(t).filter((x) => x.ev === 'search').map((x) => x.tool), ['Grep', 'Glob'], + 'Grep and Glob always count as SEARCH; Write never does; Agent is deliberately excluded' + + ' (it is a spawn, and the subagent\'s own calls already arrive tagged agent:"sub")'); + check('3.read-is-open', lines(t).filter((x) => x.ev === 'open').map((x) => [x.f, x.abs]), + [['a.mjs', join(t, 'a.mjs')]], + 'a Read is never a search - it is an open record, the prefetch-conversion numerator,' + + ' carrying BOTH the repo-relative and the absolute form'); + check('3.read-not-search', lines(t).some((x) => x.ev === 'search' && x.tool === 'Read'), false, + 'semble does not displace opening a known file, so Read must never enter the denominator'); + + const ro = newProject('filter-read-nopath'); + fire(post({ cwd: ro, tool_name: 'Read', tool_input: {} })); + fire(post({ cwd: ro, tool_name: 'Read', tool_input: { file_path: 42 } })); + check('3.read-no-path', existsSync(telFile(ro)), false, + 'a Read with no usable file path writes nothing at all'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. A telemetry write failure leaves tool behaviour untouched +// ═══════════════════════════════════════════════════════════════════════════ +{ + // .claude/semble is a FILE, so join(...)/telemetry.jsonl cannot be created. + const p = join(BASE, 'unwritable'); + mkdirSync(join(p, '.claude'), { recursive: true }); + writeFileSync(join(p, '.claude', 'semble'), 'not a directory\n'); + const r = fire(post({ cwd: p, tool_name: 'mcp__semble_code__search', tool_input: { query: 'q' } })); + check('4.write-failure.output', [r.status, r.stdout], [0, '{}\n'], + 'the append throws ENOTDIR, is swallowed, and the hook still replies {} and exits 0'); + check('4.write-failure.silent-stderr', r.stderr, '', + 'and it does not spam stderr - Claude Code surfaces hook stderr to the user'); + + // A read-only directory: mkdir/append both fail. + const q = join(BASE, 'readonly'); + mkdirSync(join(q, '.claude', 'semble'), { recursive: true }); + chmodSync(join(q, '.claude', 'semble'), 0o500); + const r2 = fire(post({ cwd: q, tool_input: { command: 'rg -n foo .' } })); + chmodSync(join(q, '.claude', 'semble'), 0o700); + check('4.readonly-dir', [r2.status, r2.stdout], [0, '{}\n'], + 'an unwritable telemetry dir is a lost sample, never a broken tool call'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. The size guard +// ═══════════════════════════════════════════════════════════════════════════ +{ + const p = newProject('sizeguard'); + const filler = JSON.stringify({ + ts: '2026-01-01T00:00:00.000Z', ev: 'search', src: 'stats', sid: 'old', + tool: 'Bash', q: 'x'.repeat(400), agent: 'main', + }); + // 6 x 510000 = 3060000 bytes of prior records, over the 2 MB guard threshold. + // The size is fixed by the fixture, so it is asserted exactly: 509-byte filler, + // 1000 per chunk plus one newline each, six chunks. + const chunk = new Array(1000).fill(filler).join('\n') + '\n'; + for (let i = 0; i < 6; i++) appendFileSync(telFile(p), chunk); + const before = statSync(telFile(p)).size; + check('5.guard.precondition', before, 3_060_000, + 'the fixture is exactly 3060000 bytes, over the 2 MB threshold the guard trips on'); + fire(post({ cwd: p, tool_input: { command: 'rg -n trigger .' } })); + const after = lines(p); + check('5.guard.trimmed', after.length, 1001, + 'the guard keeps the last 1000 lines and then appends the new one'); + check('5.guard.new-record-last', shape(after[after.length - 1]), + { ev: 'search', src: 'stats', tool: 'Bash', q: 'rg -n trigger .', agent: 'main' }, + 'the newest record survives the trim - a guard that drops the sample it was called for is useless'); + check('5.guard.kept-are-tail', after.slice(0, 1000).every((r) => r.sid === 'old'), true, + 'the retained 1000 are the previous tail, still valid JSON'); + check('5.guard.under-threshold-untouched', (() => { + const q = newProject('sizeguard-small'); + appendFileSync(telFile(q), filler + '\n'); + fire(post({ cwd: q, tool_input: { command: 'rg -n x .' } })); + return lines(q).length; + })(), 2, 'a small file is never trimmed'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. The reader against a hand-built fixture with a KNOWN answer +// ═══════════════════════════════════════════════════════════════════════════ +// +// sA: nudge(main) @00 -> semble call @02 => converted +// sB: nudge(sub) @00 -> no call ever => NOT converted +// sC: no nudge -> semble call @05 => unprompted +// plus 2 skipped gates and 4 search records. +const FIXTURE = [ + { ts: '2026-01-01T00:00:00.000Z', ev: 'gate', src: 'reminder', sid: 'sA', fired: true, why: 'ok', phase: 'ready', enabled: true }, + { ts: '2026-01-01T00:00:00.001Z', ev: 'nudge', src: 'reminder', sid: 'sA', matcher: 'Bash', agent: 'main', q: 'rg session persistence' }, + { ts: '2026-01-01T00:00:01.000Z', ev: 'gate', src: 'reminder', sid: 'sB', fired: true, why: 'ok', phase: 'ready', enabled: true }, + { ts: '2026-01-01T00:00:01.001Z', ev: 'nudge', src: 'explore', sid: 'sB', matcher: 'Explore', agent: 'sub', q: 'where is auth' }, + { ts: '2026-01-01T00:00:02.000Z', ev: 'call', src: 'stats', sid: 'sA', tool: 'mcp__semble_code__search', ok: true, ms: 900, agent: 'main' }, + { ts: '2026-01-01T00:00:03.000Z', ev: 'call', src: 'stats', sid: 'sA', tool: 'mcp__semble_code__find_related', ok: true, agent: 'sub' }, + { ts: '2026-01-01T00:00:04.000Z', ev: 'gate', src: 'reminder', sid: 'sB', fired: false, why: 'throttled', phase: 'ready', enabled: true }, + { ts: '2026-01-01T00:00:04.500Z', ev: 'gate', src: 'reminder', sid: 'sC', fired: false, why: 'no-mcp', phase: 'verifying', enabled: true }, + { ts: '2026-01-01T00:00:05.000Z', ev: 'call', src: 'stats', sid: 'sC', tool: 'mcp__semble_code__search', ok: false, ms: 12, agent: 'main' }, + { ts: '2026-01-01T00:00:06.000Z', ev: 'search', src: 'stats', sid: 'sA', tool: 'Bash', q: 'rg -n foo', agent: 'main' }, + { ts: '2026-01-01T00:00:07.000Z', ev: 'search', src: 'stats', sid: 'sB', tool: 'Bash', q: 'grep -rn bar .', agent: 'sub' }, + { ts: '2026-01-01T00:00:08.000Z', ev: 'search', src: 'stats', sid: 'sB', tool: 'Grep', q: 'baz', agent: 'sub' }, + { ts: '2026-01-01T00:00:09.000Z', ev: 'search', src: 'stats', sid: 'sC', tool: 'Glob', q: '**/*.ts', agent: 'main' }, +]; +{ + const p = newProject('reader'); + writeFileSync(telFile(p), FIXTURE.map((r) => JSON.stringify(r)).join('\n') + '\n'); + + const { status, json: R } = readerJson(p, []); + check('6.exit', status, 0, 'the reader always exits 0 - it has no verdict to fail'); + check('6.records', [R.present, R.records, R.malformed], [true, 13, 0], + 'all 13 fixture records parsed, none malformed'); + check('6.hooks', R.hooks, { reminder: 5, explore: 1, stats: 7 }, + 'per-hook invocation counts: 5 reminder records, 1 explore, 7 from the stats hook'); + check('6.gate', R.gate, { fired: 2, skipped: 2, why: { ok: 2, throttled: 1, 'no-mcp': 1 } }, + 'the gate fired twice and skipped twice, broken down by why'); + check('6.nudge', R.nudge, { total: 2, main: 1, sub: 1, unknown: 0 }, + 'two nudges, one in the main session and one inside a subagent'); + check('6.call', R.call, { total: 3, main: 2, sub: 1, unknown: 0, failed: 1 }, + 'three real semble calls, one of which failed'); + check('6.search', R.search, { total: 4, main: 2, sub: 2, unknown: 0 }, + 'four search-shaped non-semble tool uses - the denominator'); + check('6.conversion', R.conversion, + { sessionsWithNudge: 2, sessionsConverted: 1, conversionPct: 50, callsAfterNudge: 2, callsWithoutNudge: 1 }, + 'sA nudged then called (converted), sB nudged and never called, sC called with no' + + ' preceding nudge => 1 of 2 sessions convert, 2 calls follow a nudge, 1 is unprompted'); + check('6.window', R.window, { sid: null, last: null }, 'the default window is every record'); + + const human = reader(p, ['--section', 'telemetry']); + check('6.human.exit', human.status, 0, 'the human report exits 0 too'); + check('6.human.lines', [ + human.stdout.includes('gate: 2 fired / 2 skipped [no-mcp=1 ok=2 throttled=1]'), + human.stdout.includes('nudge: 2 total (main 1, sub 1)'), + human.stdout.includes('call: 3 semble calls (main 2, sub 1) | 1 failed'), + human.stdout.includes('search: 4 search-shaped non-semble (main 2, sub 2)'), + human.stdout.includes('converted: 1/2 nudged sessions (50%) | 2 calls after a nudge, 1 unprompted'), + human.stdout.includes('share: 42.9% of search-shaped tool use went through semble'), + ], [true, true, true, true, true, true], + 'the terse human report states every number the JSON does (3/(3+4) = 42.9%)'); + + const sidR = readerJson(p, ['--sid', 'sA']); + check('6.sid.window', [sidR.json.records, sidR.json.window.sid], [5, 'sA'], + '--sid narrows to the five sA records'); + check('6.sid.conversion', sidR.json.conversion, + { sessionsWithNudge: 1, sessionsConverted: 1, conversionPct: 100, callsAfterNudge: 2, callsWithoutNudge: 0 }, + 'inside sA alone the nudge converted, both calls followed it'); + check('6.sid.unknown-session', readerJson(p, ['--sid', 'nope']).json.records, 0, + 'an unknown sid is an empty window, not an error'); + + const lastR = readerJson(p, ['--last', '4']); + check('6.last.window', [lastR.json.records, lastR.json.window.last], [4, 4], + '--last keeps only the newest 4 records'); + check('6.last.counts', [lastR.json.search.total, lastR.json.call.total, lastR.json.nudge.total], + [4, 0, 0], 'the newest four are all search records'); + check('6.last.oversized', readerJson(p, ['--last', '999']).json.records, 13, + '--last larger than the log is the whole log'); + check('6.sid+last', readerJson(p, ['--sid', 'sB', '--last', '2']).json.records, 2, + '--last is applied after --sid'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. The reader on absent / truncated / unknown-ev input +// ═══════════════════════════════════════════════════════════════════════════ +{ + const absent = newProject('reader-absent'); + const a = reader(absent, ['--section', 'telemetry']); + check('7.absent.exit', a.status, 0, 'an absent log is a normal state, not a failure'); + check('7.absent.message', a.stdout.includes('no telemetry yet'), true, + 'and it says so in plain words'); + const aj = readerJson(absent, []); + check('7.absent.json', [aj.json.present, aj.json.records, aj.json.conversion.conversionPct], + [false, 0, null], 'the JSON reports present:false with zeroed counters and a null rate'); + + const empty = newProject('reader-empty'); + writeFileSync(telFile(empty), ''); + check('7.empty-file', readerJson(empty, []).json.records, 0, + 'an existing but empty file is present with zero records'); + + const trunc2 = newProject('reader-truncated'); + writeFileSync(telFile(trunc2), + JSON.stringify(FIXTURE[4]) + '\n' + JSON.stringify(FIXTURE[9]) + '\n' + + '{"ts":"2026-01-01T00:00:10.000Z","ev":"call","src":"sta'); + const t = readerJson(trunc2, []); + check('7.truncated', [t.status, t.json.records, t.json.malformed, t.json.call.total], + [0, 2, 1, 1], 'a half-written final line is counted as malformed and skipped, the rest still counts'); + + const future = newProject('reader-future'); + writeFileSync(future && telFile(future), [ + JSON.stringify(FIXTURE[4]), + JSON.stringify({ ts: '2026-01-01T00:00:20.000Z', ev: 'rerank', src: 'stats', sid: 'sA', hits: 3 }), + JSON.stringify({ ts: '2026-01-01T00:00:21.000Z', ev: 'rerank', src: 'stats', sid: 'sA', hits: 1 }), + JSON.stringify({ ts: '2026-01-01T00:00:22.000Z', ev: 42, src: 'stats', sid: 'sA' }), + '[1,2,3]', + ].join('\n') + '\n'); + const f = readerJson(future, []); + check('7.unknown-ev', [f.status, f.json.records, f.json.malformed, f.json.unknownEv, f.json.call.total], + [0, 4, 1, { rerank: 2, 42: 1 }, 1], + 'records from a newer schema are counted by ev and skipped; a non-object line is malformed'); + + const bad = reader(newProject('reader-badflag'), ['--section', 'telemetry', '--last', 'abc']); + check('7.bad-last', bad.status, 2, '--last must be an integer - bad usage exits 2'); + const misplaced = reader(newProject('reader-misflag'), ['--section', 'state', '--sid', 'x']); + check('7.misplaced-window-flag', misplaced.status, 2, + '--sid outside --section telemetry is bad usage, not a silently ignored flag'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. Guidance merge / unmerge / status round trip with the new rows +// ═══════════════════════════════════════════════════════════════════════════ +const GUIDANCE = join(SCRIPTS, 'semble-guidance.sh'); +const STATS_MATCHER = 'mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read'; +// The 5.0.0 want table: SessionStart, UserPromptSubmit, and the stats pair. +// PreToolUse/Bash, PreToolUse/Grep and SubagentStart/Explore were RETIRED with +// the two advisory hooks; §10 proves a v1-shaped file loses them on install. +const WANT_N = 4; + +function guidance(p, args) { + const r = spawnSync('bash', [GUIDANCE, ...args], { cwd: p, encoding: 'utf8' }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} +function guidanceJson(p, args) { + const r = guidance(p, [...args, '--json']); + return { status: r.status, json: safeParse(r.stdout.trim().split('\n').pop()) }; +} +function settingsOf(p) { + const f = join(p, '.claude', 'settings.json'); + return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null; +} +{ + const p = newProject('guidance'); + const inst = guidance(p, ['install', '--part', 'all']); + check('8.install.exit', inst.status, 0, 'a full guidance install succeeds in a bare project'); + check('8.install.stats-file', existsSync(join(p, '.claude', 'hooks', 'semble-stats.mjs')), true, + 'semble-stats.mjs is copied alongside the other three'); + + const s = settingsOf(p); + const abs = join(p, '.claude', 'hooks', 'semble-stats.mjs'); + const wantEntry = { + matcher: STATS_MATCHER, + hooks: [{ type: 'command', command: 'node', args: [abs], timeout: 5 }], + }; + check('8.install.PostToolUse', s.hooks.PostToolUse, [wantEntry], + 'exactly one PostToolUse entry, matcher is the pipe list, timeout is 5 SECONDS'); + check('8.install.PostToolUseFailure', s.hooks.PostToolUseFailure, [wantEntry], + 'and the identical entry on PostToolUseFailure - a failed call fires only that event'); + check('8.install.matcher-is-exact-list', /^[a-zA-Z0-9_|]+$/.test(STATS_MATCHER), true, + 'the matcher is a pipe-only list, so Claude Code parses it as exact names under' + + ' both the strict and the permissive rule, never as a regex'); + + const st = guidanceJson(p, ['status']); + check('8.status.counts', + [st.json.hooks.wiredCount, st.json.hooks.wantCount, st.json.hooks.driftedCount, + st.json.hooks.missingCount, st.json.hooks.duplicateCount, st.json.hooks.drift.length], + [WANT_N, WANT_N, 0, 0, 0, 0], 'all four want rows are wired, nothing drifted or duplicated'); + check('8.status.stats-row', st.json.hooks.stats, { file: 'present', wired: true }, + 'the stats hook reports its own file and wiring'); + check('8.status.rows', + st.json.hooks.entries.map((e) => [e.event, e.matcher, e.script, e.state]), + [['SessionStart', null, 'semble-session.mjs', 'wired'], + ['UserPromptSubmit', null, 'semble-prefetch.mjs', 'wired'], + ['PostToolUse', STATS_MATCHER, 'semble-stats.mjs', 'wired'], + ['PostToolUseFailure', STATS_MATCHER, 'semble-stats.mjs', 'wired']], + 'the want table, in order, all wired'); + check('8.status.no-retired-rows', + st.json.hooks.entries.some((e) => /reminder|explore/.test(e.script)), false, + 'the retired scripts are not want rows any more - they are ownership marks only'); + check('8.status.human-denominator', + guidance(p, ['status']).stdout.includes('hooks ' + WANT_N + '/' + WANT_N + ' wired'), true, + 'the human line prints wiredCount/wantCount, never a hard-coded number'); + + // Idempotence. + const again = guidanceJson(p, ['install', '--part', 'hooks']); + check('8.reinstall.no-change', again.json.changed, [], + 'a second install changes nothing - the merge reconciles, it does not append'); + check('8.reinstall.still-one', settingsOf(p).hooks.PostToolUse.length, 1, + 'and there is still exactly one PostToolUse entry'); + + // Drift repair: the exact 5000-vs-5 bug, on the new row. + const f = join(p, '.claude', 'settings.json'); + const drifted = JSON.parse(readFileSync(f, 'utf8')); + drifted.hooks.PostToolUseFailure[0].hooks[0].timeout = 5000; + writeFileSync(f, JSON.stringify(drifted, null, 2) + '\n'); + const dj = guidanceJson(p, ['status']); + check('8.drift.detected', + [dj.json.hooks.wiredCount, dj.json.hooks.driftedCount, dj.json.hooks.stats.wired], + [WANT_N - 1, 1, false], 'timeout 5000 on the stats row is drifted, not wired - 83 minutes, not 5 seconds'); + check('8.drift.field', dj.json.hooks.drift, + [{ event: 'PostToolUseFailure', matcher: STATS_MATCHER, script: 'semble-stats.mjs', + field: 'timeout', expected: 5, actual: 5000 }], + 'the drift list names the exact field, expected and actual'); + guidance(p, ['install', '--part', 'hooks']); + check('8.drift.repaired', guidanceJson(p, ['status']).json.hooks.wiredCount, WANT_N, + 're-running install rewrites the drifted row in place'); + + // A foreign hook on the same event must survive both merge and unmerge. + const withForeign = JSON.parse(readFileSync(f, 'utf8')); + withForeign.hooks.PostToolUse.push({ + matcher: 'Write', + hooks: [{ type: 'command', command: 'node', args: ['/opt/other/formatter.mjs'], timeout: 30 }], + }); + withForeign.hooks.PostToolUse[0].hooks.push({ type: 'command', command: 'node', args: ['/opt/other/inline.mjs'] }); + writeFileSync(f, JSON.stringify(withForeign, null, 2) + '\n'); + guidance(p, ['install', '--part', 'hooks']); + const kept = settingsOf(p); + check('8.foreign.survives-merge', + [kept.hooks.PostToolUse.length, kept.hooks.PostToolUse[0].hooks.length, + kept.hooks.PostToolUse[1].matcher], + [2, 2, 'Write'], 'foreign entries and foreign hooks inside a semble entry are never touched'); + + const rm = guidance(p, ['remove', '--part', 'hooks']); + check('8.remove.exit', rm.status, 0, 'unmerge succeeds'); + const after = settingsOf(p); + check('8.remove.failure-event-gone', after.hooks.PostToolUseFailure, undefined, + 'PostToolUseFailure held only the semble hook, so the whole event array is deleted'); + check('8.remove.foreign-kept', + [after.hooks.PostToolUse.length, + after.hooks.PostToolUse.map((e) => e.hooks.flatMap((h) => h.args))], + [2, [['/opt/other/inline.mjs'], ['/opt/other/formatter.mjs']]], + 'the semble hook is stripped per-hook: the entry survives for its foreign sibling'); + check('8.remove.file-gone', existsSync(join(p, '.claude', 'hooks', 'semble-stats.mjs')), false, + 'the .mjs is deleted after the settings, never before'); + check('8.remove.status', (() => { + const z = guidanceJson(p, ['status']).json.hooks; + return [z.wiredCount, z.missingCount, z.stats.file, z.stats.wired]; + })(), [0, WANT_N, 'missing', false], 'status agrees that nothing is wired any more'); + + // Stale-path purge: an old install pointing at a different hooks dir. + const q = newProject('guidance-stale'); + mkdirSync(join(q, '.claude'), { recursive: true }); + writeFileSync(join(q, '.claude', 'settings.json'), JSON.stringify({ + hooks: { + PostToolUse: [{ + matcher: STATS_MATCHER, + hooks: [{ type: 'command', command: 'node', args: ['/old/elsewhere/semble-stats.mjs'], timeout: 5 }], + }], + }, + }, null, 2) + '\n'); + check('8.stale.seen', guidanceJson(q, ['status']).json.hooks.staleEntries, 1, + 'a semble-stats.mjs at a foreign path is reported as a stale entry'); + guidance(q, ['install', '--part', 'hooks']); + const purged = settingsOf(q); + check('8.stale.purged', + purged.hooks.PostToolUse.flatMap((e) => e.hooks.flatMap((h) => h.args)), + [join(q, '.claude', 'hooks', 'semble-stats.mjs')], + 'install drops the stale path and leaves exactly the current one'); + check('8.stale.clean', guidanceJson(q, ['status']).json.hooks.staleEntries, 0, + 'and status confirms the purge'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 9. Hook + reader, end to end through the installed copy +// ═══════════════════════════════════════════════════════════════════════════ +{ + const p = newProject('e2e'); + guidance(p, ['install', '--part', 'hooks']); + const installed = join(p, '.claude', 'hooks', 'semble-stats.mjs'); + const run = (payload) => spawnSync(process.execPath, [installed], { + input: JSON.stringify(payload), encoding: 'utf8', cwd: p, + }); + writeFileSync(telFile(p), JSON.stringify({ + ts: '2026-01-01T00:00:00.000Z', ev: 'nudge', src: 'reminder', sid: 'e2e', + matcher: 'Bash', agent: 'main', q: 'rg -n handler src/', + }) + '\n'); + const a = run(post({ cwd: p, session_id: 'e2e', tool_input: { command: 'rg -n handler src/' } })); + const b = run(post({ + cwd: p, session_id: 'e2e', tool_name: 'mcp__semble_code__search', + tool_input: { query: 'where is the handler' }, duration_ms: 640, + })); + check('9.installed.neutral', [a.status, a.stdout, b.status, b.stdout], [0, '{}\n', 0, '{}\n'], + 'the installed copy behaves exactly like the asset'); + const R = readerJson(p, []).json; + check('9.e2e.conversion', R.conversion, + { sessionsWithNudge: 1, sessionsConverted: 1, conversionPct: 100, callsAfterNudge: 1, callsWithoutNudge: 0 }, + 'a nudge followed by a real semble call in the same session is one converted session' + + ' - this is the whole point of the mechanism'); + check('9.e2e.denominator', [R.search.total, R.call.total], [1, 1], + 'one search-shaped Bash call and one semble call'); +} + +// ── report ────────────────────────────────────────────────────────────────── +for (const line of results) console.log(line); +console.log(''); +console.log('| suite-telemetry | Value |'); +console.log('|-----------------|-------|'); +console.log(`| assertions | ${passed + failed} |`); +console.log(`| passed | ${passed} |`); +console.log(`| failed | ${failed} |`); +console.log(`| temp base | ${BASE} |`); +console.log(failed === 0 ? '✅ suite-telemetry passed' : `❌ suite-telemetry failed: ${failed}`); +process.exit(failed === 0 ? 0 : 1); diff --git a/brewcode/skills/setup-status/README.md b/brewcode/skills/setup-status/README.md index 7d4b1c3..b54c526 100644 --- a/brewcode/skills/setup-status/README.md +++ b/brewcode/skills/setup-status/README.md @@ -26,42 +26,76 @@ capability. There is no `--run`, no `--fix`, no auto mode. Ten setups. Everything else in the suite (`text-optimize`, `secrets-scan`, `agents`, `rules`, `md-to-pdf`, …) is a recurring tool with no installed state and never appears in the report. -| Setup | Anchor it looks for | -|-------|---------------------| -| `/brewcode:teams-setup` | `.claude/teams/*/team.md` | -| `/brewcode:semble-setup` | `.claude/rules/semble-first.md` | -| `/brewcode:superreview-setup` | `.claude/skills/superreview/SKILL.md` | -| `/brewtools:task-board-setup` | `.claude/features/board.md` | -| `/brewtools:think-short-setup` | `.claude/hooks/think-short-session.mjs` (or the `~/.claude` twin) | -| `/brewtools:agent-deadline-setup` | `.claude/hooks/agent-deadline-guard.mjs` (or the twin) | -| `/brewtools:agent-router-setup` | `.claude/hooks/agent-router.mjs` | -| `/brewtools:manager-setup` | `.claude/brewtools/manager/state.json` | -| `/brewdoc:memory-sync-setup` | `.claude/skills/memory-sync/SKILL.md` | -| `/brewdoc:docsync-setup` | `.claude/docsync/config.json` | +| Setup | Anchor it looks for | Where its version stamp lives | +|-------|---------------------|-------------------------------| +| `/brewcode:teams-setup` | `.claude/teams/*/team.md` | the `\| Version \|` row of `team.md`'s header table | +| `/brewcode:semble-setup` | `.claude/rules/semble-first.md` | frontmatter `version:` of that rule | +| `/brewcode:superreview-setup` | `.claude/skills/superreview/SKILL.md` | frontmatter `version:` of the **emitted** skill — never `.template-baseline/` | +| `/brewtools:task-board-setup` | `.claude/features/board.md` | frontmatter `version:` of the anchor itself — `board.md` opens with the four-key block | +| `/brewtools:think-short-setup` | `.claude/hooks/think-short-session.mjs` (or the `~/.claude` twin) | `// brewcode-meta:` line after the shebang | +| `/brewtools:agent-deadline-setup` | `.claude/hooks/agent-deadline-guard.mjs` (or the twin) | `// brewcode-meta:` line after the shebang | +| `/brewtools:agent-router-setup` | `.claude/hooks/agent-router.mjs` | `// brewcode-meta:` line after the shebang | +| `/brewtools:manager-setup` | `.claude/brewtools/manager/state.json` | top-level `"version"`, falling back to the copied guard's meta line | +| `/brewdoc:memory-sync-setup` | `.claude/skills/memory-sync/SKILL.md` | frontmatter `version:` of the emitted skill | +| `/brewdoc:docsync-setup` | `.claude/docsync/config.json` | top-level `"version"` | ## States | State | Means | |-------|-------| | `missing` | anchor and every secondary artifact absent — never installed here. The anchor is decisive: a shared file such as a stray `.claude/agents/*.md` never counts as evidence | -| `disabled` | installed, then switched off on purpose (`disable`) — semble `enabled:false`, think-short's prompt renamed to `.disabled`, the manager wall at `hard:false`, agent-deadline/agent-router `enabled:false`. Reported as inactive, never as broken, and never queued in the run-list | -| `partial` | some artifacts present, some gone — a broken or half-removed install | -| `installed` | everything present and byte-identical to the installed plugin version | -| `installed (version unknown)` | everything present, but this setup leaves no version signal to check | -| `stale` | present, but a tracked file drifted from the plugin asset, the provenance stamp is behind, or a documented upgrade path was never run | +| `disabled` | installed, then switched off on purpose (`disable`) — a config flag flipped, or the entry file parked as `.disabled`. Reported as inactive with its real version, never as broken and never as missing, and never queued in the run-list | +| `partial` | some artifacts present, some gone — or a version stamp left as an unresolved `{PLACEHOLDER}`, meaning the generator never finished substituting | +| `installed` | stamp equals the installed plugin version, and every byte-copied file still matches its asset | +| `stale (X.Y.Z -> A.B.C)` | the stamp is a plugin version behind | +| `stale (legacy stamp)` | the artifact predates the metadata standard and carries no `version` at all | +| `stale (bytes drifted)` | right version, wrong bytes — a copied file was hand-edited or never re-copied | | `n/a` | that plugin is not installed | ## How staleness is decided -Honestly, or not at all. Four signals, and nothing else — no mtime heuristics, no guessing. +Two signals, answering two different questions. No mtime heuristics, no guessing. -| Signal | Used by | How | -|--------|---------|-----| -| **Checksum** | semble, think-short, agent-deadline, agent-router, manager, docsync | Those setups `cp` their hook files verbatim, so `cmp` against the plugin asset is exact | -| **Provenance stamp** | memory-sync | The emitted skill's last line carries `$//') ;; + *) + h=$(sed -n '1,40p' "$sf") + v=$({ printf '%s\n' "$h" | grep -o '^version:[[:space:]]*.*' || true; } | head -1 | sed 's/^version:[[:space:]]*//; s/^"//; s/"$//; s/[[:space:]]*$//') + [ -n "$v" ] || v=$({ printf '%s\n' "$h" | grep -oE '^\|[[:space:]]*Version[[:space:]]*\|[^|]*\|' || true; } | head -1 | sed 's/^.*Version[[:space:]]*|[[:space:]]*//; s/[[:space:]]*|$//') + [ -n "$v" ] || v=$({ sed -n '1,5p' "$sf" | grep -o 'brewcode-meta:.*' || true; } | head -1 | { grep -o 'version=[^ ]*' || true; } | sed 's/version=//') + sg=$({ printf '%s\n' "$h" | grep -o '^generated_by:[[:space:]]*.*' || true; } | head -1 | sed 's/^generated_by:[[:space:]]*//; s/^"//; s/"$//; s/[[:space:]]*$//') + [ -n "$sg" ] || sg=$({ printf '%s\n' "$h" | grep -oE '^\|[[:space:]]*Generated by[[:space:]]*\|[^|]*\|' || true; } | head -1 | sed 's/^.*Generated by[[:space:]]*|[[:space:]]*//; s/[[:space:]]*|$//; s/^`//; s/`$//') + [ -n "$sg" ] || sg=$({ sed -n '1,5p' "$sf" | grep -o 'brewcode-meta:.*' || true; } | head -1 | { grep -o 'generated_by=[^ ]*' || true; } | sed 's/generated_by=//; s/-->$//') ;; + esac + v=${v:-}; sg=${sg:-} + if [ -n "$so" ]; then + if [ -z "$sg" ]; then echo "OWNER-NONE $sf (expected $so)" + elif [ "$sg" != "$so" ]; then echo "OWNER-WRONG $sf ($sg, expected $so)"; fi + fi + if [ -z "$v" ]; then + r=$(grep -cE 'memory-sync template v|intent-guard template v|SKILL METADATA[^A-Za-z]*generated|`, `updatedAt` / `lastUpdated` / `checkedAt` …) | `stale (legacy stamp)` — the migration case, one `upgrade` restamps it | +| `LEGACY-NONE` | file exists and carries no stamp in any carrier or any retired spelling | `stale (legacy, unstamped)` | +| `PLACEHLD` | the stamp is still an unresolved token — `{...}` (sanctioned `{PLUGIN_VERSION}` or retired `{{PLUGIN_VERSION}}`) or a retired angle form (``, ``, ``) | `partial` — substitution never finished. On row 3 this is only ever read from the EMITTED file, never from `.template-baseline/` | +| `MISSING` | no such file **and no `.disabled` twin** | the row's anchor decides: `missing` or `partial` | + +The owner check prints an EXTRA line beside the version verdict, never instead of it — a file can be +`CURRENT` and owner-wrong at once, and that pair is the whole point: + +| Verdict | Means | Feeds Phase 3 as | +|---------|-------|------------------| +| `OWNER-WRONG` | `generated_by` names a different skill than the row that owns the artifact | `partial` — some other generator wrote this path. Name both skills; the fix is re-running the OWNING setup, and the user must be told the other one may overwrite it again | +| `OWNER-NONE` | the version stamp is present but `generated_by` is not | `stale (legacy stamp)` — a pre-standard or partial stamp. `references/artifact-metadata.md` §1 requires the field in every artifact and every carrier, so its absence beside a real `version` is an incomplete write, not a variant | +| (no line) | `generated_by` equals the expected owner | nothing — silence is the pass | + +**Why `generated_by` gets a reader and the other two fields do not.** Four fields are written; the +question for each is whether a reader can turn it into an ACTIONABLE verdict: + +| Field | Read here? | Why | +|-------|-----------|-----| +| `version` | yes — the headline | the only field that answers *is this behind the plugin*, and it maps to five of the six states | +| `generated_by` | yes | it is the ONE field whose wrong value is otherwise undetectable. Two setups can write the same path (`intent-guard.md` is emitted by both `superreview-setup` and `teams-setup`), a hand-copied artifact carries its source's owner, and a template pasted between skills carries the wrong one forever — none of which any other signal sees, because the version and the bytes can both be perfectly right. The action is concrete: re-run the owning setup | +| `last_updated` | **no** | it is a date, and no state in this skill's vocabulary is defined by one. It cannot disagree with `version` in any way `version` does not already report: a stale date on a current stamp means only that the release did not change the file, and an old date on an old stamp is the `BEHIND` the row already prints. Reading it would emit a verdict whose only fix is the `upgrade` already prescribed — noise on every row. It is also absent by design from every `.mjs`/`.sh` stamp and from `semble-first.md` (§1), so a reader would have to special-case half the roster to say nothing new. Never infer staleness from it, exactly as this skill never infers it from an mtime | +| `doc_type` | **no** | it is docsync's field and it is USER-OWNED: §1 says a repo that chose `user` or `skip` chose deliberately, and `semble-guidance.sh` preserves the destination's value even under `--force`. A value that differs from the template is the spec working, so there is no mismatch to report | + +**The `.disabled` fallback is what keeps a disabled install from reading as an unstamped one.** A +parked entry file (Phase 1b) is byte-identical to the live one, stamp included, so the block retries +`$f.disabled` before giving up and dispatches the carrier on the name with `.disabled` stripped. The +printed path keeps the suffix, so the row's version is real and its parked state is visible in the +same line. Without this a `disable` would turn every affected row into `MISSING` -> `missing`, i.e. +"never installed" — the exact misreading a reversible off-switch must not produce. + +The carriers the block understands are exactly the ones in `references/artifact-metadata.md` §2, and +`version` and `generated_by` are read out of the SAME carrier on every branch — never one from the +frontmatter and the other from a comment: + +| Carrier | `version` | `generated_by` | +|---------|-----------|----------------| +| `.json` | top-level `"version"` key | top-level `"generated_by"` key | +| `.mjs` / `.sh` | `version=` in the `brewcode-meta:` comment right after the shebang (only the first 5 lines are scanned, so a `version=` in the body cannot be mistaken for a stamp) | `generated_by=` in that same one line — both are pulled from the single `brewcode-meta:` match, so a second marker deeper in the file cannot supply half a stamp | +| anything else | frontmatter `version:` in the first 40 lines, then a `\| Version \| X.Y.Z \|` header-table row, then a `brewcode-meta:` marker in the first 5 lines | frontmatter `generated_by:`, then a `\| Generated by \| … \|` header-table row, then `generated_by=` in the `brewcode-meta:` marker | + +The `brewcode-meta:` fallback on the third row exists because `think-short-prompt.md` and the three +`memory-sync` references carry their stamp as an HTML comment — those bodies are injected into a +prompt or cited verbatim, so frontmatter would leak into the text. The `generated_by=` extraction +strips a trailing `-->` for exactly that reason: in `` +the owner is the last token before the comment close. + +Values are quoted in YAML and JSON (`version: "X.Y.Z"`, `generated_by: "brewcode:teams-setup"`) and +bare in the header table (`| Version | X.Y.Z |`); the block strips quotes either way, and backticks +off a header-table owner cell. + +**The `PLACEHLD` test is deliberately generic — never a list of known tokens.** The sanctioned +spelling is single-brace `{PLUGIN_VERSION}` (`references/artifact-metadata.md` §4), but retired +angle-bracket forms (``, ``, ``) and the retired double-brace +`{{PLUGIN_VERSION}}` still reach artifacts installed by older releases. So the test matches ANY +`{`, `}`, `<` or `>` in the value. A real version is `X.Y.Z` and can hold none of them, so this +never false-positives — and an unsubstituted token can never be fed to `sort -V`, which would +otherwise turn "substitution never ran" into a confident `BEHIND`/`AHEAD`. Never hardcode how many +placeholder spellings exist; the character test outlives the list. + +The `LEGACY-FMT` grep is the mirror image — it names the exact retired strings of +`references/artifact-metadata.md` §8. `SKILL METADATA` matches only when followed by `generated` +(`SKILL METADATA - generated `, dash spelling irrelevant): live skills use the bare words in +other sentences, and a bare-substring match would report a current artifact as legacy. + +**And its reach is the heredoc, which carries ARTIFACTS only.** The retired spellings of §8 are +*provenance* keys, so the grep is only ever meaningful on a file whose version is a staleness +signal. `references/artifact-metadata.md` §9 puts ephemeral runtime state out of scope entirely — +epoch-ms markers, TTL caches, `.claude/semble/state.json`, `.claude/docsync/state.json` — and none +of those may be added to the `STAMPS` heredoc even when a row lists them as secondaries. Feeding one +in is the only way this grep can false-positive: a `checkedAt`-style key inside a runtime cache is +correct code, not a legacy stamp. `brewcode/hooks/session-start.mjs` was the tree's last such +collision and now spells its TTL marker `fetchedAtMs`, so no shipped file trips the detector today — +but the scope rule, not the rename, is what keeps that true. + +> `last_updated` is deliberately ABSENT from `.mjs`/`.sh` stamps and from the byte-copied +> `semble-first.md` — a date there would churn the file on every release and break `cmp`. Missing +> `last_updated` on a mechanism-`a` asset is the spec working as designed, never a legacy stamp. Only +> `version` decides this skill's verdict. + +> **`|| true` on every no-match-tolerant command is MANDATORY in every fence in this file, not just +> this one.** `grep` exits 1 on no-match, `find` and `ls` exit non-zero on an absent path or an +> unmatched glob — and all three of those are the NORMAL case for a read-only probe that expects +> most things to be absent. Under `set -euo pipefail` the failure propagates out of the pipeline +> (`pipefail` carries it past `wc`, `sed`, `tr`, `head`) and out of the command substitution, and the +> block aborts before printing anything at all: the emptier the project, the less the dashboard says. +> The idiom is `x=$({ cmd || true; } | rest)` — the `|| true` goes on the command that legitimately +> fails, INSIDE the pipeline, so a genuine failure downstream still surfaces. `grep -c` needs +> `x=${x:-0}` after it as well: it prints `0` and exits 1 on no match, so `|| true` alone leaves the +> `0` but a bare `$(grep -c …)` on a missing file leaves the variable empty and `[ "$x" -gt 0 ]` +> then fails on an empty operand. +> +> Hardened here for the same reason: Phase 0's and Phase 5's `ls -d …/*/` (an uninstalled plugin is +> an unmatched glob — under `zsh` it is a hard `no matches found`), and Phase 1's `find -path` on a +> glob row. + +## Phase 2b — `cmp` the byte-copied files (corroboration) + +Only for rows whose roster cell names a `cmp` pair, and only when the anchor exists. Feed +`project|plugin` pairs built from the roster (absolute plugin paths from Phase 0). **EXECUTE** using Bash tool: @@ -202,18 +700,20 @@ PAIRS echo "OK" ``` -Row 9's stamp is not a `cmp` — read it directly: +`DIFFERS` on a file whose stamp reads `CURRENT` is the case the stamp alone cannot see: the copy came +from this plugin version but its bytes no longer match — a hand-edit, or an install that was never +re-run after a same-version rebuild. Report `stale (bytes drifted)` and name the file. -**EXECUTE** using Bash tool: +**Only a byte-STABLE copy belongs in `PAIRS`.** A file the install fills or appends to after copying +it — `memory-sync`'s `references/hard-sync.md` (BLOCKs filled by the generator's Phase 3) and the +repo-root `.sembleignore` (measured-candidates block appended by `install_candidates`) — differs on +every healthy project and must never be fed in; see the row-2 and row-9 carve-outs. Before adding a +pair, check the writer: if any mode writes that path after the `cp`, `cmp` answers a question nobody +asked, and the remedy the dashboard then prescribes destroys the content the installer put there. -```bash -f=.claude/skills/memory-sync/SKILL.md -[ -f "$f" ] && tail -1 "$f" | grep -o 'memory-sync template v[0-9.]*' || echo "UNSTAMPED" -echo "OK" -``` - -Compare that against `VERSION=` in `$BD/skills/memory-sync-setup/scripts/generate.sh` (read it with -the Read tool, do not re-derive it). +Both violations were found by RUNNING the loop against a healthy fixture, never by reading the +roster. Re-verify a pair the same way: install into a throwaway repo and check the loop prints +`SAME` on a fresh install. ## Phase 3 — Classify @@ -222,29 +722,57 @@ Exactly one state per row, in this order: | # | Condition | State | |---|-----------|-------| | 1 | The row's plugin has `ROOT=none` | `n/a` | -| 2 | Anchor MISS and every secondary MISS | `missing` | -| 3 | Phase 1b shows this row's off-switch thrown (`enabled:false`, `.hard != true`, or the `.disabled` prompt rename) | `disabled` | -| 4 | Anchor MISS but some secondary present, or anchor present with any secondary MISS | `partial` | -| 5 | All present, version signal says `DIFFERS` (any pair), or the stamp version != the plugin's, or the roster's absence signal fires | `stale` | -| 6 | All present, signal says `SAME` on every pair (or stamp matches) | `installed` | -| 7 | All present, roster cell says the signal does not exist | `installed (version unknown)` | +| 2 | Phase 1b shows this row's off-switch thrown — a config flag at `enabled:false`, row 6's `no-key`, `.hard != true`, or every deployed entry file `PARKED` | `disabled` | +| 3 | Anchor MISS and every secondary MISS, **in both spellings** — no `.disabled` twin anywhere on the row | `missing` | +| 4 | Anchor MISS but some secondary present, or anchor present with any secondary MISS, or a row-1/row-4 toggle left half `LIVE` half `PARKED`, or the stamp reads `PLACEHLD`, or Phase 2a printed `OWNER-WRONG` | `partial` | +| 5 | Stamp reads `BEHIND` or `AHEAD` | `stale (X.Y.Z -> A.B.C)` — print both versions | +| 6 | Stamp reads `LEGACY-FMT`, or `OWNER-NONE` beside a real version | `stale (legacy stamp)` | +| 7 | Stamp reads `LEGACY-NONE` | `stale (legacy, unstamped)` | +| 8 | The roster's absence signal fires (rows 1 and 4), or row 2's wiring signal fires (`retired[]`, `staleEntries`, or `wiredCount < wantCount`) | `stale` — name the artifact that is missing, or the wiring gap | +| 9 | Stamp `CURRENT`, but any `cmp` pair `DIFFERS` | `stale (bytes drifted)` — name the file | +| 10 | Stamp `CURRENT`, every `cmp` pair `SAME` (or the row defines none) | `installed` | +| 11 | Stamp `CURRENT` but a `cmp` source is `NOSRC` | `version unknown (plugin asset missing)` — the cache is incomplete, never `stale` | + +State vocabulary is unchanged — `n/a` · `missing` · `disabled` · `partial` · `stale` · `installed`. +`stale` takes one of four qualifiers when a stamp or byte signal fired — rules 5-7 and 9, matching +`references/artifact-metadata.md` §6: `(X -> Y)` a version behind or ahead, `(legacy stamp)` a +retired stamp format, `(legacy, unstamped)` no stamp at all, `(bytes drifted)` right version wrong +bytes. Rule 8's absence and wiring signals print a bare `stale` with no qualifier, naming the missing +artifact (or the wiring gap) in *found* instead. Nothing else is a state. Rules 2 and 3 are the two that stop false alarms: -- **Anchor MISS is decisive.** The anchor is the artifact only that setup writes. No anchor = not - installed, whatever else the project happens to contain. Never call a row `partial` on the strength - of a shared file (see the exclusivity note in the roster). -- **`disabled` outranks `partial` and `stale`.** think-short's `disable` renames its prompt away, so - the roster secondary `think-short-prompt.md` legitimately MISSes — reporting that as `partial` tells - the user to repair something they switched off on purpose. Inversely, a semble with - `enabled:false` or a manager wall with `hard:false` has every file in place and must NOT be +- **`disabled` is evaluated FIRST, ahead of `missing`, `partial` and `stale`.** Five setups + `disable` by parking their entry file, so on a disabled install the anchor itself is renamed away + and every later rule would misfire — `missing` ("never installed"), or `partial` ("repair this") + for something the user switched off on purpose. Inversely, a semble at `enabled:false`, a manager + wall at `hard:false` or a docsync at `enabled:false` has every file in place and must NOT be reported `installed`: the mechanism is inert. A `disabled` row's Command column offers `enable`, never `upgrade`, and it never enters the run-list. +- **A missing `enabled` key is resolved per row, from the reader, never by a house default.** Row 6 + (agent-deadline) is opt-in — `cfg.enabled !== true` — so `no-key` is `disabled`. Rows 7 and 10 are + opt-out, so a missing key is live and never reaches this rule. Applying one default to all three + inverts one of them, and an inverted row 6 is the worst of the two directions: it reports a + deadline as enforced when the guard returns on its first line. +- **Anchor MISS is decisive — but a `.disabled` twin is not a MISS.** The anchor is the artifact only + that setup writes. No anchor in EITHER spelling = not installed, whatever else the project happens + to contain. Never call a row `partial` on the strength of a shared file (see the exclusivity note + in the roster), and never call a parked artifact absent. -`installed (version unknown)` is the honest answer, not a defect to paper over. Never guess a -version, never infer staleness from a file's mtime, and never report a signal the roster does not -define. Rows 1 (`teams-setup`) and 4 (`task-board-setup`, apart from its absence signal) genuinely -have no version stamp — say so in the *found* column. +**The verdict for a disabled install is `disabled` plus its real version.** Print the stamp Phase 2a +read out of the parked file (or out of the untouched config), not `--`: the install has a version, it +is simply not active. `disabled` is never combined with a `stale` qualifier — if a switched-off row +is also behind, say so in the *found* column and still offer `enable`, because `upgrade` on a parked +install is what the owning setups explicitly refuse (`task-board-setup` STOPs, `memory-sync`'s +`validate` FAILs, `superreview`'s `validate` FAILs — all three tell the user to `enable` first). + +**Read the stamp; never guess a version.** The `version` field is now real data on every row, and +reading it is the job — rows 1 (`teams-setup`) and 4 (`task-board-setup`) used to be forced into +`version unknown` and now carry a stamp like the rest. What stays forbidden is unchanged in spirit: +never invent a version an artifact does not carry, never infer staleness from a file's mtime, never +derive a version from a directory name inside the project, and never report a signal this roster does +not define. An artifact with no stamp is `stale (legacy stamp)` — that is a fact about the install, +not licence to estimate what produced it. Two facts that look like staleness and are not: @@ -256,24 +784,67 @@ Two facts that look like staleness and are not: ## Phase 4 — Output -ONE table, rows in roster order, filtered by `$ARGUMENTS`. Answer in the language the user wrote in -(RU or EN) — translate the prose, never the paths or the commands. +Lead with ONE line, before anything else — how many rows are behind the installed plugin: -| Skill | State | Found | Command | -|-------|-------|-------|---------| -| `/brewcode:semble-setup` | stale | rule + 3 hooks present; `semble-reminder.mjs` DIFFERS vs brewcode 4.10.1 | `/brewcode:semble-setup upgrade "re-copy the hooks, the reminder hook drifted from the 4.10.1 asset"` | -| `/brewtools:task-board-setup` | stale | `board.md` + tracker present, `.claude/skills/task-spec/` absent | `/brewtools:task-board-setup upgrade "retrofit the spec + design layer onto the deployed board, keep every task id"` | -| `/brewdoc:docsync-setup` | missing | nothing under `.claude/docsync/` | `/brewdoc:docsync-setup install` | -| `/brewcode:teams-setup` | installed (version unknown) | `team.md` + `trace.jsonl` + `trace-ops.sh`; no version stamp exists for this setup | `/brewcode:teams-setup status` | -| `/brewtools:think-short-setup` | disabled | 4 hooks wired, prompt renamed to `think-short-prompt.md.disabled` — switched off on purpose | `/brewtools:think-short-setup enable` | -| `/brewtools:manager-setup` | n/a | brewtools not installed | `claude plugin install brewtools@claude-brewcode` | +``` +4 of 10 setups are behind the installed plugin (2 stale by version, 1 legacy stamp, 1 drifted bytes). +``` + +Count only rows whose stamp is not `CURRENT` plus `stale (bytes drifted)`. `missing`, `disabled` and +`n/a` are not "behind" — they are not installed, switched off, or not applicable. If the number is 0, +say `all 10 setups are at ` and still print the table. + +Then ONE table, rows in roster order, filtered by `$ARGUMENTS`. Answer in the language the user wrote +in (RU or EN) — translate the prose, never the paths or the commands. + +| Skill | State | Version | Found | Command | +|-------|-------|---------|-------|---------| +| `/brewcode:semble-setup` | stale (bytes drifted) | A.B.C = A.B.C | stamp current, but `semble-prefetch.mjs` DIFFERS from the A.B.C asset — the copy was hand-edited or never re-run | `/brewcode:semble-setup upgrade "re-copy the hooks, semble-prefetch.mjs drifted from the installed asset"` | +| `/brewtools:task-board-setup` | stale | X.Y.Z -> A.B.C | `board.md` + tracker present, `.claude/skills/task-spec/` absent | `/brewtools:task-board-setup upgrade "retrofit the spec + design layer onto the deployed board, keep every task id"` | +| `/brewdoc:memory-sync-setup` | stale (legacy stamp) | legacy -> A.B.C | emitted skill present, carries the retired `` line and no frontmatter `version:`. For the per-file drift count: `bash "$BD/skills/memory-sync-setup/scripts/generate.sh" status` (read-only, run it yourself) | `/brewdoc:memory-sync-setup upgrade "migrate the pre-5.0 tail stamp to provenance frontmatter — its restamp step rewrites version/last_updated/surface_files in place and drops the tail line, hand-edits untouched"` | +| `/brewcode:superreview-setup` | partial | `{PLUGIN_VERSION}` | emitted `SKILL.md` still holds an unresolved placeholder — substitution never finished | `/brewcode:superreview-setup install "re-emit, the previous run left {PLUGIN_VERSION} unsubstituted"` | +| `/brewdoc:docsync-setup` | missing | -- | nothing under `.claude/docsync/` | `/brewdoc:docsync-setup install` | +| `/brewcode:teams-setup` | installed | A.B.C | `team.md` (Version A.B.C) + `trace.jsonl` + `trace-ops.sh`, all bytes match | `/brewcode:teams-setup status` | +| `/brewtools:think-short-setup` | disabled | A.B.C | 4 hooks wired, prompt renamed to `think-short-prompt.md.disabled` — switched off on purpose | `/brewtools:think-short-setup enable` | +| `/brewtools:manager-setup` | n/a | -- | brewtools not installed | `claude plugin install brewtools@claude-brewcode` | + +In the sample above `A.B.C` stands for the installed plugin version and `X.Y.Z` for the artifact's +own stamp — the real report prints real numbers, and no literal version is ever carried in from this +file. + +**Version column format:** `X.Y.Z` when current, `X.Y.Z -> A.B.C` when behind (stamp then plugin), +`legacy -> A.B.C` for a retired stamp format, `unstamped -> A.B.C` when there is no stamp at all, the +raw token when a placeholder survived, `--` for `missing` and `n/a`. `X.Y.Z = X.Y.Z` reads "stamp matches, the problem is elsewhere" — use it on +`stale (bytes drifted)` so the reader is not left hunting for a version difference that does not exist. The **Command** column is a ready-to-paste line. For `stale` and `partial` it MUST carry a concrete fine-tune prompt naming what to refresh — the drifted file, the missing artifact, the layer that was -never retrofitted. A bare `upgrade` with no prompt is not acceptable output. +never retrofitted, the placeholder that never resolved. A bare `upgrade` with no prompt is not +acceptable output. + +> **A remedy MUST be able to clear the verdict it follows.** `upgrade` was for a long time the mode +> nobody owned: it refreshed content and left the stamp where it was, so `status` said `stale`, +> `upgrade` said success, and the next `status` said `stale` again. Every roster row now carries a +> **Remedy check** clause naming the code that proves its `upgrade` restamps. Two consequences bind +> this column: +> +> | Never emit | Because | +> |------------|---------| +> | the mode that JUST failed, as the fix for its own failure | that is the closed loop above. If a mode cannot clear its own verdict, name the one that can, or say plainly that no mode can and the user must act by hand | +> | `upgrade` on a row whose roster cell does not carry a Remedy check | an unverified remedy is a guess. Print the finding without a command rather than a command that does nothing | +> +> Three findings in this roster genuinely have NO mode that clears them, and each must be reported +> as such instead of dressed in a command: row 9's `hard-sync.md` (`DIFFERS` is the healthy state), +> a `REF DIFFERS:` hand-edit on row 9's other two references, and a prose hand-edit of row 2's +> `semble-first.md` (skipped as `user_modified`; only `--force` overwrites, and it is not a skill +> mode). Say what the user must diff and port by hand. +> +> `.sembleignore` is NOT a fourth: it is carved out of the `cmp` set, so this dashboard produces no +> byte verdict for it at all. `--force` on it overwrites the user's own uncommented exclusions — +> never print it. Use the canonical modes ONLY: `status` · `install` · `upgrade` · `enable` · `disable` · `uninstall` · -`purge`. The pre-5.0 verbs (`create`, `update`, `cleanup`, `init`, `on`, `off`, `setup`, `remove`, +`purge`. The retired verbs (`create`, `update`, `cleanup`, `init`, `on`, `off`, `setup`, `remove`, `reset`) were removed — `teams-setup` in particular now parses anything unknown as a TEAM NAME, so emitting `/brewcode:teams-setup cleanup "..."` would install a team called `cleanup`. Never print one. @@ -282,17 +853,21 @@ Two setups add extra verbs AFTER the canonical set, and those are live: `semble- extra verb only when the roster row's finding is exactly what it fixes; otherwise the canonical verb plus a free-text prompt. -Then the ordered run-list: +Then the ordered run-list. **The closing paragraph is not optional** — it is the one place the user +sees why this dashboard hands back commands instead of running them. Print it every time, even when +the list has one entry: ``` Run in this order, ONE PER SESSION: - 1. /brewtools:task-board-setup upgrade "..." <- broken/partial first - 2. /brewcode:semble-setup upgrade "..." <- stale next + 1. /brewcode:superreview-setup install "..." <- broken/partial first + 2. /brewtools:task-board-setup upgrade "..." <- stale next 3. /brewdoc:docsync-setup install <- new installs last -Each of these spawns several subagents and will ask you questions. Running two in one -session degrades both: the second one answers against the first one's stale analysis. -Start a fresh session per command. +Nothing above was run for you, by design. Each of these is an interactive generator: it +fans out several subagents, analyses the repo and asks you real questions. Two in one +session degrade each other — the context fills with the first one's analysis and the +second one's questions get answered against stale findings. Start a fresh session per +command. ``` Order: `partial` (broken install) -> `stale` -> `missing`. Within a tier, keep roster order. @@ -307,9 +882,9 @@ The self-updating property, as a WARNING. It never writes. ```bash for p in brewcode brewdoc brewtools brewui; do - r=$(ls -d "$HOME/.claude/plugins/cache/claude-brewcode/$p"/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::') + r=$({ ls -d "$HOME/.claude/plugins/cache/claude-brewcode/$p"/*/ 2>/dev/null || true; } | sort -V | tail -1 | sed 's:/*$::') [ -n "$r" ] || continue - find "$r/skills" -maxdepth 1 -type d -name '*-setup' 2>/dev/null | sed "s|.*/|$p:|" + { find "$r/skills" -maxdepth 1 -type d -name '*-setup' 2>/dev/null || true; } | sed "s|.*/|$p:|" done echo "OK" ``` @@ -334,7 +909,25 @@ warning — that is the user's call, in this repo, in a separate change. | All four plugin roots `none` | Report "no brewcode plugins installed" + `claude plugin install

@claude-brewcode`. Do not print an all-`missing` table. | | Running with `--plugin-dir` (dev mode, no cache dir) | Phase 0 finds no root. Say so: the report needs the installed cache to compare against; the repo checkout is not a substitute. | | Project has no `.claude/` | Every row `missing`. Print the table and the install run-list. | -| A `cmp` source path is `NOSRC` | The plugin cache is incomplete for that asset. Report `version unknown (plugin asset missing)`, never `stale`. | +| A `cmp` source path is `NOSRC` | The plugin cache is incomplete for that asset. Report `version unknown (plugin asset missing)` and say the byte check could not run — never `stale` on a missing source. | +| An artifact's stamp is `AHEAD` of the plugin | A dev checkout or `--plugin-dir` install newer than the cache. Report it as `stale` with the direction spelled out; do NOT tell the user to upgrade the project, tell them the cache is behind. | +| A mechanism-`a` asset has `version` but no `last_updated` | Correct by design — a date would churn the file every release and break `cmp`. Not a legacy stamp, not a finding. | +| `TASK_TEMPLATE.md` has no stamp (row 4) | Deliberate: its frontmatter is copied into every task card. Never report it. | +| `.claude/skills/memory-sync/references/hard-sync.md` differs from the plugin source (row 9) | **The healthy state, not a finding.** Its two BLOCK placeholders are filled per project by the generator's Phase 3 and `validate` fails while they are not. Never `cmp` it, never stamp-read it, never name it in a remedy. Its frozen stamp is likewise correct — `refresh_refs` refuses to overwrite filled content. | +| `generate.sh restamp` printed `REF DIFFERS:` for row 9's `memory-guide.md` or `agent-audit.md` | A genuine local edit; the file was left untouched on purpose. Report `stale (bytes drifted)`, say `upgrade` will not overwrite it, and tell the user to diff against `$BD/skills/memory-sync-setup/references/` and port by hand. There is no mode that clears it. | +| Row 2's `semble-first.md` is `DIFFERS` | Read *found* before prescribing. A metadata-only delta is re-synced by `upgrade` with no `--force`; a real prose hand-edit is SKIPPED as `user_modified` and only `--force` overwrites it — which is not a skill mode. Say which case it is, and hand back the `diff -u` route for the second. | +| The repo-root `.sembleignore` differs from `assets/sembleignore.template` (row 2) | **The healthy state, not a finding.** `install_candidates` appends a measured-candidates block after the copy, so a correct install differs by construction. Never `cmp` it, never report `stale (bytes drifted)` for it, and above all never prescribe `--force` — that backs up and overwrites the user's own uncommented exclusions. Its `# brewcode-meta:` stamp stays readable and IS the row's signal for it. | +| A `team.md` header row is `CURRENT` but an agent row's `Version` is behind (row 1) | A mixed roster from a partial `upgrade`, which is legitimate. Report the row `installed` and name the lagging agents in *found* — do not downgrade the whole row. | +| A version appears in `.template-baseline/` | Ignore it. That dir is raw template with placeholders unresolved by design; only the emitted artifact has a version. | +| An artifact exists only as `.disabled` | PARKED, never missing and never a `partial` trigger. The body is byte-identical, so read its stamp (Phase 2a retries `$f.disabled`) and report the row `disabled` at that version. | +| `.claude/docsync/config.json` carries no `enabled` key | ENABLED. All three docsync hooks read `c.enabled !== false`, so back-compat installs written before the key existed are live. A missing key is never `disabled` and never `partial`. Same for `.claude/brewtools/agent-router.json`. | +| `.claude/agent-deadline.json` carries no `enabled` key | **INERT — the opposite answer to docsync's.** `agent-deadline-guard.mjs:354` reads `cfg.enabled !== true`, so a key-less (or unparsable) config makes the guard return before touching anything. Report `disabled` and offer `enable`. Never carry docsync's default across to this row. | +| Phase 2a printed `OWNER-WRONG` on a file that is otherwise `CURRENT` | `partial`. The right version and the right bytes prove nothing about who wrote the file. Name both skills in *found* and offer the OWNING setup's `install`; warn that the other generator may claim the path again. | +| Phase 2a printed `OWNER-NONE` beside a real version | `stale (legacy stamp)` — an incomplete stamp, not a variant. §1 requires `generated_by` in every artifact and every carrier. One `upgrade` restamps it. | +| An artifact's `last_updated` or `doc_type` differs from the plugin's | Not a finding, and not read. `last_updated` is a date and no state is defined by one; `doc_type` is user-owned and deliberately preserved across re-installs. Report neither. | +| A team has some members live and some `.md.disabled` | Half-applied toggle -> `partial`, naming both halves. `intent-guard` parked or live is NEVER part of that count — `toggle-team.sh` skips it because it is shared with `superreview-setup`. | +| `upgrade` looks like the fix for a `disabled` row that is also behind | It is not. `task-board-setup upgrade`, `memory-sync validate` and `superreview validate` all fail on a parked install by design. Offer `enable`; the version gap is a *found*-column note. | +| Two carriers on one row disagree (row 8: `state.json` vs the guard's meta line) | The roster names the precedence — `state.json` first. Report the headline from it and mention the second value once. | | User asks "which of these should I install?" | Answer from the table only. Recommending a setup the project has no use for is noise — say when a row is legitimately skippable. | diff --git a/brewcode/skills/setup-status/references/artifact-metadata.md b/brewcode/skills/setup-status/references/artifact-metadata.md new file mode 100644 index 0000000..3a64f2b --- /dev/null +++ b/brewcode/skills/setup-status/references/artifact-metadata.md @@ -0,0 +1,715 @@ +--- +doc_type: llm +version: "5.1.0" +generated_by: "brewcode" +last_updated: "2026-08-09" +--- + +# Artifact metadata and versioning + +Normative. Every artifact any brewcode/brewdoc/brewtools/brewui skill writes into a +project draws its metadata from ONE vocabulary of four fields, spelled the same way and +resolved the same way. WHICH of the four an artifact must carry is decided by its +mechanism (section 3), not by its file extension - see "Which fields are required". +One number - the plugin version - answers "is this install current?". + +Audience: skill authors, generator authors, `setup-status`. + +--- + +## 1. Fields + +Exactly these four names. No synonyms, no extra provenance key, no reordering. The +"Required where" column is a summary; the mechanism table below is the authority. + +| Key | Type | Format | Example | Required where | +|-----|------|--------|---------|----------------| +| `version` | string | `X.Y.Z` semver, QUOTED in YAML/JSON | `"X.Y.Z"` | every artifact, every carrier | +| `generated_by` | string | `:`, QUOTED | `"brewdoc:docsync-setup"` | every artifact, every carrier | +| `last_updated` | string | `YYYY-MM-DD`, QUOTED, from `date +%F` | `"YYYY-MM-DD"` | every artifact EXCEPT a mechanism-`a` byte-copied one - see "Which fields are required" below | +| `doc_type` | enum | `llm` \| `user` \| `skip`, **UNQUOTED** | `llm` | `.md` frontmatter ONLY - docsync's field. NEVER in JSON. Generated artifacts are `llm` | + +`version` is the version of the **plugin that produced the artifact**, never a +per-template counter. `generated_by` is the producing skill, not the consuming one - +except on hand-maintained SHIPPED artifacts (the plugin's own agents, this spec doc), +where no skill produced the file and the value is the BARE plugin name (`"brewcode"`). + +**`doc_type` is the one unquoted value, and that is load-bearing.** +`brewcode/skills/rules/scripts/rules.sh:144` gates on `^doc_type: llm$` and HARD-FAILS +`doc_type: "llm"`. Quote the other three; never quote this one. + +**`doc_type` is plugin-owned on a mechanism-`a` artifact, not user-owned.** A re-install +RESTORES it. Under mechanism `a` the installed file is byte-identical to the plugin's, +so there is nothing to preserve: `brewcode/skills/semble-setup/scripts/semble-guidance.sh:180` +lists `doc_type` in `OWNED` alongside the other three, and `sg_strip_meta` (`:381-389`, its own +`OWNED` at `:383`) removes all four from BOTH sides before comparing. Prose-identical + stamp-different +therefore takes the metadata-only re-sync branch (`install_managed`, same file), which +`mv`s the plugin bytes over the destination with no `--force` and no backup - and a +locally chosen `doc_type: user` or `doc_type: skip` goes with them. That is asserted, not +tolerated: `brewcode/skills/semble-setup/tests/suite-hooks.mjs:592` (`B5.docTypeReset`) +requires the restored value to be `llm`. + +A repo that wants a different `doc_type` on a managed rule must change the prose too - +that makes the file `user_modified`, which IS preserved (and backed up before any +`--force` overwrite: `B5.backup`, `B5.backupIsTheUserFile`, same file `:596-597`). Only mechanism +`c` artifacts, which no installer rewrites, carry a durably user-chosen `doc_type`. + +### Key order + +`doc_type, version, generated_by, last_updated`, appended AFTER the file's own +frontmatter keys - i.e. immediately before the closing `---`, never at the top. An +artifact whose mechanism omits `last_updated` simply ends one key earlier; the order of +what remains never changes. + +A skill MAY add its own extra keys, and they MUST TRAIL the four. `memory-sync` writes +`surface_files` last, after `last_updated`; that is the sanctioned shape. A reader of +this spec ignores any key it does not own - an unknown trailing key is never a defect +and never a staleness signal. + +### Which fields are required - decided by MECHANISM, never by file extension + +| Artifact | Fields | +|----------|--------| +| mechanism `a`, byte-copied into the project | `version` + `generated_by`, and `doc_type` when the carrier is `.md` frontmatter. **NEVER `last_updated`** | +| mechanism `a`, hand-maintained SHIPPED file that is NOT copied anywhere (the 8 plugin agents, this spec doc) | all four - the date legitimately means "shipped in the release of that day" | +| mechanism `b` (substituted at install) and mechanism `c` (written by the model) | all applicable fields, `last_updated` included | + +A byte-copied asset omits `last_updated` because the value would be the RELEASE date, +which is identical in the plugin file and in the copy and therefore says nothing, while +rewriting it on every build churns the bytes and defeats the `cmp` drift signal that is +the whole point of mechanism `a`. This is not a `.mjs`/`.sh` carve-out - it is the +mechanism. `.claude/scripts/bump-version.sh` encodes exactly this split in its stamp +KINDS (`bump-version.sh:41-47`, `stamp_rewrite` at `:211-229`): + +| Kind | Writes | Used for | +|------|--------|----------| +| `fm` | `version`, `generated_by` | byte-copied `.md`-frontmatter assets | +| `fmd` | `version`, `generated_by`, **`last_updated`** | hand-maintained shipped `.md` (8 agents + this doc) | +| `mjs` / `sh` / `md` / `marker` | `version`, `generated_by` inside a `brewcode-meta:` fragment | byte-copied scripts and `.md` | + +`brewcode/skills/semble-setup/assets/semble-first.md.template` is the worked example: +kind `fm`, so its frontmatter carries `doc_type`, `version`, `generated_by` and no +`last_updated` - and `.claude/rules/semble-first.md` in this repo is the installed copy, +landing exactly that way. + +**Known contradiction, stated so nobody "fixes" the wrong side.** +`brewcode/skills/rules/scripts/rules.sh:140-146` validates rule files and REQUIRES all +six of `paths, description, doc_type, version, generated_by, last_updated`, with +`last_updated` a quoted `YYYY-MM-DD`. That is correct for what it validates: the files +`rules.sh` itself creates are mechanism `b`, rendered from +`brewcode/templates/rules/*.md.template` where `{LAST_UPDATED}` is substituted +(`rules.sh:98-104`). Its validation glob is `.claude/rules/avoid.md`, +`best-practice.md`, `*-avoid.md`, `*-best-practice.md` (`rules.sh:180`), so it does not +today reach `semble-first.md`. Two brewcode skills write the same directory under +different field sets and only the filename keeps them apart. **The mechanism rule above +is the tiebreaker**: a validator that widens its glob must exempt mechanism-`a` files +from `last_updated`, not demand the field. Do not add `last_updated` to a byte-copied +asset to satisfy a validator. + +--- + +## 2. Carriers + +| Carrier | Placement | Key order | +|---------|-----------|-----------| +| `.md` with YAML frontmatter | appended after the file's own keys, before the closing `---` | `doc_type, version, generated_by, last_updated` (+ skill-private keys trailing). Drop `last_updated` when the file is byte-copied (mechanism `a`, stamp kind `fm`) | +| `.json` | top level, snake_case | `version, generated_by, last_updated` - all three MANDATORY, `doc_type` FORBIDDEN | +| `.mjs` / `.sh` copied byte-for-byte | ONE comment line, immediately after the shebang if present, else line 1 | `version`, `generated_by` only - never `last_updated` | +| `.md` copied byte-for-byte, whose BODY is consumed verbatim | ONE line-1 HTML comment `` | `version`, `generated_by` only - never `last_updated` | +| markdown header table (`team.md` style) | three rows in the header table, in this order | `Version`, `Generated by`, `Last update` | + +**A JSON artifact carrying only `version` is INCOMPLETE, not a variant.** All three keys +travel together in every JSON carrier, written on every mode that writes the file at all +(`install`, `upgrade`, `enable`, `disable`, `level`). And `doc_type` never appears in +JSON - it is docsync's `.md` field, and a JSON config is not a doc. + +"Every writing mode" is the enforced part - a config the user last touched via `enable` +must not report the version that `install` left. Five implementations, all four carriers: + +| Config | Writer | Modes covered | +|--------|--------|---------------| +| `.claude/agent-deadline.json` | `brewtools/skills/agent-deadline-setup/assets/INSTALL.md` - one config block (`:140-193`, stamp `:187`, verify `:193`, hard-fail `:185`) reused by every mode | install `:267`/`:346`, upgrade `:423`, enable+disable `:467` (stamp `:507`). Contract stated at `:91`; no `level` mode exists here | +| `.claude/brewtools/agent-router.json` | `brewtools/skills/agent-router-setup/assets/INSTALL.md` - config block `:184-227` (stamp `:221`, verify `:227`) | install `:310`, upgrade `:406`, `level` `:446-448` (re-runs the block), enable+disable `:459` (stamp `:501`). Contract at `:127` | +| `.claude/brewtools/manager/state.json` | `brewtools/hooks/lib/manager-state.mjs:222-253` | every `writeState` call, unconditionally, with all other keys merged through. `generated_by` and `last_updated` always; `version` only when `pluginVersion()` resolves — see the never-`unknown` section for the one sanctioned omission | +| `.claude/docsync/config.json` | `brewdoc/skills/docsync-setup/SKILL.md:391-393` | install `:194`, upgrade `:322`, and `enable`/`disable` BACKFILL a config whose value already matches but whose provenance is missing or stale (`:366-372`, short-circuit guard `:388-389`) | +| `.claude/md-to-pdf.config.json` | `brewdoc/skills/md-to-pdf/SKILL.md` - shape `:86-91`, mandate `:94` | both write paths: engine choice `:98-105` and styles `:201-203`, each hard-failing on a missing version (`:101`, `:193-195`) | + +### `.md` frontmatter + +```yaml +--- +name: superreview +description: "Deep project-tailored review." +user-invocable: true +disable-model-invocation: true +doc_type: llm +version: "X.Y.Z" +generated_by: "brewcode:superreview-setup" +last_updated: "YYYY-MM-DD" +--- +``` + +Every example in this document uses `X.Y.Z` / `YYYY-MM-DD` on purpose. A literal +version in a normative document goes stale at the next bump and then teaches the +wrong number - the release stamper rewrites only this file's own frontmatter, never +its examples. + +### `.json` + +```json +{ + "docs": ["docs/**/*.md"], + "exclude": ["node_modules/**"], + "version": "X.Y.Z", + "generated_by": "brewdoc:docsync-setup", + "last_updated": "YYYY-MM-DD" +} +``` + +All three keys, always. `{"version": "X.Y.Z", "threshold_days": 30}` is a non-conforming +artifact, and `setup-status` can only report its version, never who wrote it or when. + +### `.mjs` + +```javascript +#!/usr/bin/env node +// brewcode-meta: version=X.Y.Z generated_by=brewdoc:docsync-setup +``` + +### `.sh` + +```bash +#!/usr/bin/env sh +# brewcode-meta: version=X.Y.Z generated_by=brewcode:teams-setup +``` + +### `.md` HTML comment - the fifth carrier + +A byte-copied `.md` whose whole body is consumed verbatim cannot use frontmatter: the +keys would leak into whatever consumes the body. It carries the same `brewcode-meta:` +fragment inside a line-1 HTML comment instead. + +```markdown + +``` + +Four files in production: `brewtools/skills/think-short-setup/assets/think-short-prompt.md` +(its body is injected into a prompt) and the three +`brewdoc/skills/memory-sync-setup/references/{memory-guide,agent-audit,hard-sync}.md`. +`bump-version.sh` calls these kinds `md` and `marker`; `setup-status` reads them with +its third `.md` fallback - frontmatter `version:` in the first 40 lines, then a +`| Version |` header row, then a `brewcode-meta:` marker in the first 5 lines +(`brewcode/skills/setup-status/SKILL.md:514`). + +**Quirk: `think-short-prompt.md:1` is the only stamp in the repo with a word BEFORE the +anchor** - ``. That is +legal, and legal by construction rather than by luck: `stamp_rewrite`'s non-frontmatter +branch is an UNANCHORED global substitution on the `brewcode-meta: version=... generated_by=...` +fragment (`bump-version.sh:225-226`), and every reader greps for the fragment, never for +a line start. So the comment may carry any prefix. Nothing strips the comment - the hook +reads the file and injects it whole (`think-short-prompt-counter.mjs:77-87`, +`think-short-session.mjs:84-93`); an HTML comment is simply inert in the injected text. + +Marker is literally `brewcode-meta:` wherever it appears - it is the grep anchor, not a +plugin name, and the string does not vary by plugin. brewui ships no stamped asset at +all, so the marker occurs in brewcode, brewdoc and brewtools only. No file has more than +one. + +### markdown header table + +```markdown +| Field | Value | +|-------|-------| +| Team | backend | +| Version | X.Y.Z | +| Generated by | brewcode:teams-setup | +| Last update | YYYY-MM-DD | +``` + +The three rows travel together, in that order - `Version`, then `Generated by`, then +`Last update` - after whatever rows the file itself owns. Values are BARE here; the +markdown cell is not YAML and nothing parses it as a scalar. A lone date row is a +retired signal (section 8). + +### NOT a carrier: `VERSIONED_DOCS` + +Seven shipped human-facing pages state the plugin version in a one-line header and are +**exempt from everything in this section**. They are not artifacts - nothing installs +them into a project, nothing `cmp`s them, `setup-status` never reads them: + +| Files | Header form | +|-------|-------------| +| `brewcode/README.md`, `brewdoc/README.md`, `brewtools/README.md`, `brewui/README.md` | `\| Version \| X.Y.Z \|` (line 7) | +| `brewcode/docs/file-tree.md` | `> Version: X.Y.Z` | +| `brewcode/docs/commands.md` | `**ver:** X.Y.Z` | +| `brewdoc/docs/commands.md` | `**Version:** X.Y.Z` | + +`.claude/scripts/bump-version.sh:88-94` holds the list as `VERSIONED_DOCS` and rewrites +it with `doc_rewrite` (`:100-111`) - six anchored `sed` expressions matching one version +literal each - then `doc_verify` (`:113-128`) fails the release if a file states any +version other than the new one. The anchoring is deliberate: these pages also contain +historical prose ("dropped in vX.Y.Z") that must never move. + +**So a lone `| Version |` row in a `| Field | Value |` table is compliant HERE and only +here.** The three-row rule above governs an installed artifact such as `team.md`, whose +version is a staleness signal a reader compares against the plugin. A plugin README's +version is a fact about the page. Do not add `| Generated by |` / `| Last update |` rows +to these seven, and do not report them non-compliant. The two lists are disjoint by +construction - `STAMPED_FILES` (30 rows) and `VERSIONED_DOCS` (7 rows) share no path. + +--- + +## 3. Mechanisms + +Pick by **how the artifact reaches the project**, not by file type. + +| # | Mechanism | Applies to | Stamp lives in | Written by | +|---|-----------|-----------|----------------|------------| +| a | BAKED AT RELEASE | assets copied byte-for-byte into the project, plus hand-maintained shipped `.md` | the PLUGIN's own file | `.claude/scripts/bump-version.sh`, on every bump | +| b | SUBSTITUTED AT INSTALL | templates that already run scalar substitution | the template, as `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}` | the generator, at install/upgrade | +| c | WRITTEN BY THE MODEL | prose-authored artifacts | nothing pre-exists | the model, per the exact lines SKILL.md dictates | + +Decision rule: + +| The generator ... | Mechanism | +|-------------------|-----------| +| `cp` / `install` the asset unchanged | **a** | +| already replaces `{TOKENS}` in a template before writing | **b** | +| tells the model to author the file | **c** | + +### Two hard constraints + +| Constraint | Forces | +|------------|--------| +| `setup-status` compares installed vs plugin asset with `cmp`. Any stamp written at install time makes the copy differ from its source, so every install reads `DIFFERS` forever | **a** is mandatory for every byte-for-byte copied asset - the stamp must already be in the plugin's file. The `cmp` half of this has ONE exception, immediately below | +| `superreview-setup` ships `.template-baseline/` holding RAW templates, and `setup-status` `cmp`s the baseline against the plugin templates. A baked value in a template makes every baseline file differ on every release | **b** is mandatory for `superreview-setup` - the token stays unresolved in the template and in the baseline | + +Under **a** the two signals stay independent: `cmp` detects drift, the stamp reports +the version. They do not interfere because plugin file and installed file are the same +bytes, stamp included. + +Under **c** the SKILL.md MUST state the exact lines to emit AND the exact command that +reads the version. The model never invents either. + +### Exception to constraint 1 - a byte-copied asset the install then FILLS + +An asset may be mechanism `a` - listed in `STAMPED_FILES`, stamped at release, `cp`d +verbatim by a generator - and still be legitimately never byte-STABLE, when a mode of +that generator writes project-specific content INTO the installed copy after copying it. +Such a file is **exempt from `cmp` and from stamp-reading by every reader**. It is NOT +reclassified: no install-time token substitution and no model authorship of the file as a +whole is involved, so it is not `b` and not `c`. The exemption lands on the readers, not +on the mechanism, and `bump-version.sh` keeps stamping it (kind `md`) because the +PLUGIN-side copy is the one being versioned. + +**The one file today** is `brewdoc/skills/memory-sync-setup/references/hard-sync.md`. +`generate.sh:398` `cp`s all three `EMITTED_REFS` (`:38`) verbatim, and this one carries two +BLOCK placeholders - `{PATHS_PRECISION_TABLE}` (`hard-sync.md:70-71`) and +`{OBVIOUS_VS_DOMAIN_TABLE}` (`:126-127`) - that the emitted skill's Phase 3 fills per +project. `validate` FAILS while either is open (`generate.sh:552-559`, `_open_tokens` at +`:216-231`, allow-list `RUNTIME_ALLOW` at `:49`), so a HEALTHY install differs from the +plugin source by construction. Its stamp is frozen for the same reason: `refresh_refs()` +(`generate.sh:514-532`) re-copies a reference only when the sole delta is the +`brewcode-meta:` line and otherwise prints `REF DIFFERS:` and leaves it alone (`:529`). +The consumer already implements the carve-out - `brewcode/skills/setup-status/SKILL.md:70`, +the row-9 note at `:110-113`, and `:793` where `DIFFERS` on this path is named the healthy +state. + +**The decision test for a NEW asset.** Ask it of the GENERATOR, never of the file: + +| Question | Answer | +|----------|--------| +| Does any mode of the generator WRITE to the installed path after `cp`ing it? | yes -> exempt from `cmp` + stamp-read; stays mechanism `a` | +| Does only a USER or a self-sync pass ever change it? | no exemption. `DIFFERS` there is a real finding | + +That distinction is what keeps the carve-out narrow. `memory-guide.md` and +`agent-audit.md` sit beside `hard-sync.md`, are `cp`d by the same loop, and CAN be +hand-edited by the emitted skill's Phase 4 self-sync - and they are NOT exempt, because +no generator mode writes into them. A "might get edited" file is not an exempt file. + +Audit that the list is still one file - every byte-copied `STAMPED_FILES` path carrying a +placeholder the install must fill. Kind `fmd` is dropped because those files are +hand-maintained and copied nowhere, and `$`-prefixed shell/JS expansions are dropped +because they are runtime code, not placeholders. Expect exactly one line: + +```bash +sed -n "/^STAMPED_FILES=/,/'\$/p" .claude/scripts/bump-version.sh \ + | sed "s/^STAMPED_FILES='//; s/'\$//" | awk -F'|' '$2 != "fmd" { print $1 }' \ + | while read -r f; do grep -qE '(^|[^\$])\{[A-Z][A-Z0-9_]+\}' "$f" && echo "$f"; done +``` + +A second file appearing here means either a second exemption to document HERE, or a file +that should have been mechanism `b` all along. Do not let the next reader infer a +permanent single-file carve-out from the count - the RULE is the generator test above, +and the count is only its current answer. + +### Stamps are also REFRESHED - `upgrade` owns that + +Mechanism `b` and `c` write a stamp at INSTALL; the mode that has to REWRITE it is +`upgrade`, and for a long time no generator owned it. The failure was silent and permanent: a +PATCHed artifact kept the stamp of whatever version first installed it, `status` printed +`stale`, prescribed `upgrade`, `upgrade` reported success, `status` printed `stale` again +- forever (stated at `brewtools/skills/task-board-setup/references/10-upgrade.md:282-291`). +**A PATCHed file must end up stamped exactly like an ADDed one.** Five generators now +enforce that, and an `upgrade` that cannot clear its own staleness is a defect: + +| Setup | How `upgrade` refreshes the stamp | +|-------|-----------------------------------| +| `semble-setup` | re-runs `semble-guidance.sh install --part all` - the ONLY writer of the rule's stamp (`brewcode/skills/semble-setup/SKILL.md:143`) | +| `superreview-setup` | `_restamp_meta` (`scripts/generate.sh:522`) over five artifacts in an UNCONDITIONAL loop, deliberately not gated on `IDENTICAL`/`DIFFERS` (`:620-629`); `intent-guard.md` is stamped separately by `write_intent_guard` (`:633`) | +| `task-board-setup` | step `U5b`, always runs, never gated - the trio on nine artifacts (`references/10-upgrade.md:282-306`) | +| `manager-setup` | `writeState('project', {}, cwd)` with an EMPTY partial (`SKILL.md:253`); `writeState` stamps the trio on every write while all other keys merge through (`brewtools/hooks/lib/manager-state.mjs:222-253`) | +| `memory-sync-setup` | `restamp` mode (`scripts/generate.sh:442`), the last step of `upgrade` (`SKILL.md:74`, `:127-133`); it also calls `refresh_refs()` (`:503`) to re-copy references whose only delta is the release stamp | + +Restamping is metadata-only, and each of these proves it: `superreview` gates on body +identity (`generate.sh:554-558`), `task-board` touches only the trio inside the first +frontmatter block and leaves `doc_type` as found (`10-upgrade.md:312-315`), +`memory-sync` aborts if anything but the provenance keys moved (`generate.sh:491`). + +### The mechanism-`a` manifest is a list, and the list is authoritative + +`STAMPED_FILES` in `.claude/scripts/bump-version.sh:51-80` is the complete set of +mechanism-`a` artifacts - **30 rows** today, `path|kind|generated_by`. A file listed +there and missing on disk FAILS the release (`stamp_verify`, `:293-299`); a file NOT +listed silently keeps a stale stamp forever. Adding a byte-copied asset means adding a +row in the same change. + +`a` and `b` are mutually exclusive per file, and the exclusion is enforced by +consequence, not by a check: a file carrying `{PLUGIN_VERSION}` must NOT be listed in +`STAMPED_FILES`, because a baked literal would make its raw `.template-baseline` copy +differ on every release - the exact superreview bug the comment at `bump-version.sh:28-31` +records. + +--- + +## 4. Version resolution + +Never hardcode a version. Never read it from a git tag. + +**The rule splits by ROLE.** A WRITER and a READER are resolving two different numbers +that happen to coincide most of the time. + +| Role | Resolves | From | +|------|----------|------| +| WRITER - any skill/script that STAMPS an artifact | the version of the plugin whose code is producing this file | `.claude-plugin/plugin.json` reached by self-location. Cache path FORBIDDEN | +| READER - `setup-status`, and the brewcode SessionStart hook | the version of the plugin INSTALLED on this machine | installed cache-directory basename first, that root's `.claude-plugin/plugin.json` `.version` second | + +A writer must never resolve from the cache: under `claude --plugin-dir ./brewcode` the +cache holds a DIFFERENT plugin than the one executing, so a dev run would bake the +cached version into a real artifact. Self-location cannot be wrong - it names the tree +the running code lives in. + +A reader has the opposite requirement. `setup-status` asks "is this project's artifact +current against the plugin the user actually has installed?", and the answer is a +property of the install, so the installed cache directory IS the authority; its basename +is the version. `brewcode/hooks/session-start.mjs` (`parseVersion`) resolves the same +number the same way, and `brewcode/skills/setup-status/SKILL.md:135-153` and `:311-325` +cite this section for it. One precedence, two consumers - do not invent a third, and do +not "fix" the reader to match the writer. + +The reader's fallbacks matter because the basename is not always a version: a +`--plugin-dir` or symlinked root has a name like `brewcode`, so a basename that does not +match `[0-9]*.[0-9]*.[0-9]*` falls through to `plugin.json`, and an unresolvable version +aborts rather than defaulting. + +### A writer that cannot resolve the version ABORTS - it never stamps `unknown` + +`unknown` is not a version. Written into an artifact it defeats every consumer at once: +`sort -V` accepts it, the `PLACEHLD` character test (`{`/`}`/`<`/`>`) does not catch it, +and `setup-status` reports a confident verdict on a value that means "the resolver +failed". So the resolver returns non-zero and the caller exits; the mode fails loudly +with nothing written. + +The rule is enforced, not merely stated - SIX writers in TWO shapes. Four ABORT, below. +Two treat the unresolvable case as an internal SENTINEL that is hard-failed on or dropped +before anything reaches disk. This is the complete set; a seventh writer copies one of +these two shapes and joins it. Nothing else is sanctioned. + +| Writer | Resolver | Fails at | +|--------|----------|----------| +| `brewcode/skills/semble-setup/scripts/lib/semble-common.sh` | `sc_plugin_version()` `:319-329`, `X.Y.Z` gate at `:324` | `:327` returns 1; caller `sc_state_patch:469` `|| sc_die` | +| `brewcode/skills/superreview-setup/scripts/generate.sh` | `_plugin_version()` `:172-191` | `:190` returns 1; caller `:214` `|| exit 1` | +| `brewcode/skills/teams-setup/scripts/detect-mode.sh` | `:11-20` | `:26-29` prints `ERROR:cannot resolve plugin version (X.Y.Z)` and `exit 1` | +| `brewcode/skills/e2e/scripts/detect-mode.sh` | `:10-19` | `:28-31` prints the same `ERROR:cannot resolve plugin version (X.Y.Z)` line and `exit 1` | + +The two SENTINEL writers reach the same outcome by the other road: + +`brewdoc/skills/memory-sync-setup/scripts/generate.sh:72` - `unknown` is an internal +SENTINEL that is immediately hard-failed on, never a value that can leave the script. + +`brewtools/hooks/lib/manager-state.mjs:69-81` - `pluginVersion()` returns `null`, never the +string `unknown`, and `writeState` then OMITS the `version` key instead of aborting +(`:242-252`, which also `delete`s a `version` inherited from an older state file, so merging +over that file cannot let this write keep claiming its predecessor's version). It is the ONE +writer here that does not fail the run, and the exception is forced by its role rather than +by convenience: this module is the HARD wall's off-switch (`set hard=false`) and running it +is the single Bash shape the guard self-exempts (`hardmode-guard.mjs:188-199`, anchored on +the shipped helper path at `:152`), so a writer that aborted would strand the user behind an +armed wall with no exit. The other five are generators - nothing is armed when they refuse. +Dropping the key is safe only because BOTH readers already treat its absence as UNKNOWN +rather than as a version: `setup-status` roster row 8 +(`brewcode/skills/setup-status/SKILL.md:89`) defines an absent `version` as the `missing` +signal and falls through to the copied guard's `brewcode-meta` line, and `manager-setup` +`status` computes `stale: (stateVersion && pluginVersion) ? ... : null` +(`brewtools/skills/manager-setup/SKILL.md:448`), so it is never compared as if it were real. +Do not copy this shape into a generator: it is licensed by an armed guard, not by taste. + +The two `detect-mode.sh` writers share one dialect on purpose - the same shape-gate +(`case "$PLUGIN_VERSION" in [0-9]*.[0-9]*.[0-9]* ) : ;; *) ... exit 1`) and the same +`ERROR:` wording - because both skills treat any `ERROR:` line from Phase 0 as STOP. e2e +extends it to `status` as well: a status run that cannot name the running version has no +version to compare a stamp against. Its docs match the code - +`e2e/references/mode-status.md:30-33`, `mode-rules.md:30-32` and `mode-install.md:176-179` +all name `stale (legacy, unstamped)` as the answer for a missing stamp and forbid +`unknown` outright. e2e is no longer an outlier. + +The rest of this section is the WRITER's rule. + +### In a skill script (shell) + +```bash +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PV=$(jq -r .version "$SCRIPT_DIR/../../../.claude-plugin/plugin.json") +``` + +Script self-location. `/skills//scripts/x.sh` -> `../../../` is the +plugin root, in the dev checkout and in the installed cache alike. + +### In a SKILL.md bash block + +Verified on Claude Code 2.1.226 (Bun v1.4.0 build): + +- `${CLAUDE_SKILL_DIR}` is **not** an environment variable. Nothing in the binary + assigns it to a process env. It is a prompt-render-time text substitution: when a + skill's prompt is built, the body is run through `replace(/\$\{CLAUDE_SKILL_DIR\}/g, dirname(SKILL.md))`. + It applies to plugin skills and to project/user `.claude/skills` alike. +- Because the pattern is the exact literal `${CLAUDE_SKILL_DIR}`, any brace-modifier + form is **not** matched. +- Claude Code additionally prepends `Base directory for this skill: ` to + every skill prompt, so the directory is always knowable from the prompt itself. +- `${CLAUDE_PLUGIN_ROOT}` is substituted in agent `.md` files, hook commands and MCP + configs, and exported for hook processes - but **not** into a skill body and **not** + into the Bash tool env. In a SKILL.md bash block it expands to empty. + +Ranked idioms: + +| Rank | Idiom | Failure mode | +|------|-------|--------------| +| 1 | `bash "${CLAUDE_SKILL_DIR}/scripts/.sh"` - the script resolves `PV` by self-location | none. Single source of truth; the version logic is testable outside a session | +| 2 | `PV=$(jq -r .version "${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json")` | correct for plugin skills only. A project skill under `.claude/skills/` has no plugin.json two levels up -> empty `PV`. Guard with `test -f` | +| 3 | cache glob `~/.claude/plugins/cache/claude-brewcode//*/` | **forbidden for a WRITER.** Resolves to the INSTALLED cache even under `claude --plugin-dir ./brewcode`, so a dev run stamps the cached version. Take the newest leaf (`sort -V \| tail -1`), never an arbitrary one. This is the READER's sanctioned first choice - see the role split above | +| X | `${CLAUDE_SKILL_DIR:-}` | **broken, silently.** The substitution regex does not match the `:-` form, so the token reaches the shell literally; the variable is unset there, so the fallback ALWAYS wins. Observed verbatim in transcripts. Never use a brace-modifier form on this token | +| X | `$CLAUDE_PLUGIN_ROOT` / `${CLAUDE_PLUGIN_ROOT}` in a bash block | expands to empty -> the path becomes `/skills/...` -> silent no-such-file or wrong file | + +Every Bash call is a fresh shell. Nothing persists between blocks - re-emit the +resolution line at the top of every block that needs `PV`. + +### In a template (mechanism b) + +**Exactly three spellings, single braces, nothing else:** + +| Token | Resolves to | +|-------|-------------| +| `{PLUGIN_VERSION}` | `version` | +| `{GENERATED_BY}` | `generated_by` | +| `{LAST_UPDATED}` | `last_updated` | + +Braces without `$` - the `$`-prefixed forms are shell expansions and are excluded from +placeholder validation. The generator resolves them; the template and its +`.template-baseline` copy keep them raw. + +Retired placeholder spellings, listed again in section 8: `{{PLUGIN_VERSION}}` and +every other double-brace form, ``, ``, ``, +`{P0.3 version}`, ``. + +**Why one spelling matters to the reader, not just the writer.** `setup-status` +classifies a stamp as `PLACEHLD` (substitution never ran) by testing the VALUE for any +`{`, `}`, `<` or `>` - a character test, never a list. An unrecognised placeholder that +slipped through the test would be handed to `sort -V` and reported as a confident +version verdict. Keep the character classes small and the spellings few; never make a +consumer enumerate them. + +--- + +## 5. YAML scalar safety + +Verified, not assumed: + +Dates below are a neutral `2001-02-03` on purpose - the point is the TYPE, not the day. + +| Parser | `last_updated: 2001-02-03` | `version: 1.2.3` | `version: 1.2` | +|--------|---------------------------|------------------|----------------| +| `Bun.YAML.parse` (the bun in the CC 2.1.226 build; CC parses frontmatter with this) | string `"2001-02-03"` | string | **number `1.2`** | +| js-yaml 3.14.2 (YAML 1.1 default schema) | **Date** `2001-02-03T00:00:00.000Z` | string | number | +| PyYAML 6.0.3 | **`datetime.date`** | string | float | + +So Claude Code itself does not mistype an unquoted date - YAML 1.2 core has no +timestamp type. Every YAML 1.1 parser does. Quoting is therefore mandatory for +cross-parser stability, and mandatory for `version` because a two-segment value would +become a number. + +**Rule: quote `version`, `generated_by` and `last_updated` in every YAML frontmatter +and every JSON value. Always. Leave `doc_type` UNQUOTED - it is an enum consumed by a +`^doc_type: llm$` grep, not a scalar anything types.** + +### Why quoting is safe for docsync + +`brewdoc/skills/docsync-setup/assets/docsync-track.mjs:88` strips a leading/trailing +`"` or `'` from every frontmatter value before use, so docsync's hand-rolled regex +parser sees the identical string quoted or bare. It never sees YAML types at all - +which is why the quoting rule costs nothing and buys cross-parser stability. + +The unquoted-`last_updated` migration is DONE: no shipped `.md` in any of the four +plugins carries a bare date today. An unquoted value found in a CONSUMER project is +still not a staleness signal - report the version, not the quoting. + +--- + +## 6. What `setup-status` does with the values + +| Stamp read | Verdict | +|------------|---------| +| `version` == installed plugin version | `installed` | +| `version` != installed plugin version (older or newer) | `stale` - name the two versions in the *found* column | +| no stamp at all | `stale (legacy, unstamped)` | +| an old-format stamp from section 8 | `stale (legacy stamp)` | +| the value still holds a placeholder - any `{`, `}`, `<` or `>` | `partial`, never a version verdict: substitution never finished | +| stamp present but the plugin asset is missing from the cache | `version unknown (plugin asset missing)`, never `stale` | + +An artifact carrying a retired-format stamp is reported stale so the user re-runs +`upgrade`. That is the intended migration, not a bug. + +`cmp` keeps its separate job: byte drift of a mechanism-`a` asset. `cmp` says +`DIFFERS` -> `stale`; the stamp says which version the project is on. Neither replaces +the other. + +Reading a stamp is a one-line grep, so it stays inside `setup-status`'s read-only +budget: + +```bash +grep -m1 -oE 'brewcode-meta: version=[0-9]+\.[0-9]+\.[0-9]+' "$f" +grep -m1 -E '^version:' "$f" | tr -d '"'"'"' ' +``` + +--- + +## 7. Template versions are retired + +`memory-sync`'s `VERSION=1.0.0` and `intent-guard`'s `template v2` are replaced by the +plugin version. One number, trivially comparable, already synced across all six JSON +files by `bump-version.sh`. Do not reintroduce a per-template counter. + +What is retired is the LITERAL, not the variable name. `memory-sync`'s +`generate.sh` still assigns `VERSION=`, but it is now +`VERSION=$(resolve_plugin_version)` reading `brewdoc/.claude-plugin/plugin.json` - and +that assignment is what writes the stamp. Auditing a generator, check what the variable +RESOLVES FROM; a `VERSION=` line is not itself evidence of anything. + +--- + +## 8. Retired spellings + +Never write these. If you find one, it is a legacy stamp - report stale, do not +translate it in place unless you are the skill that owns the artifact. + +| Retired | Replacement | +|---------|-------------| +| `updated` | `last_updated` | +| `updatedAt` | `last_updated` | +| `updated_at` | `last_updated` | +| `lastUpdated` | `last_updated` | +| `lastSetup` | `last_updated` | +| `lastVerifiedAt` | `last_updated` | +| `checkedAt` | `last_updated` | +| `` | `last_updated:` in frontmatter | +| `**Last Updated:**` (prose line) | `last_updated:` in frontmatter | +| `\| Last update \|` alone, used as the version signal | the three-row block: `\| Version \|` + `\| Generated by \|` + `\| Last update \|` | +| `memory-sync template vX.Y.Z` | `version: "X.Y.Z"` (plugin version) | +| `intent-guard template v2` | `version: "X.Y.Z"` (plugin version) | +| `SKILL METADATA - generated ` | the four fields of section 1 | +| `VERSION=1.0.0` or any literal per-template counter in a generator | resolve the plugin version from `.claude-plugin/plugin.json` (section 4). The VARIABLE is fine; the LITERAL is the defect | + +Retired PLACEHOLDER spellings - a template still carrying one emits an artifact +`setup-status` reports `PLACEHLD` / `partial`: + +| Retired | Replacement | +|---------|-------------| +| `{{PLUGIN_VERSION}}` (and every other double-brace form) | `{PLUGIN_VERSION}` | +| `{{GENERATED_BY}}` | `{GENERATED_BY}` | +| `{{LAST_UPDATED}}` | `{LAST_UPDATED}` | +| `` | `{PLUGIN_VERSION}` | +| `` | `{PLUGIN_VERSION}` | +| `{P0.3 version}` | `{PLUGIN_VERSION}` | +| `` | `{LAST_UPDATED}` | +| `` | `{LAST_UPDATED}` | + +--- + +## 9. Out of scope + +Not artifact metadata. Do not "fix" these to match this spec. + +| Thing | Why it stays | +|-------|--------------| +| task-card fields `created:` / `updated:` / `status:` / `priority:` on `.claude/features/**` Kanban cards | domain data of a task, not provenance of a generated file. Owned by `task-board-setup` | +| `.claude/reports/_/` directory naming (`YYYYMMDD-HHMMSS_`) | a directory convention, not a file stamp | +| runtime tmp markers with epoch timestamps | ephemeral state, never compared against a plugin version - see the naming rule below | +| the `.codex/` mirror's OWN manifests | see below - a separate version line, deliberately not bumped | + +### Runtime state must not spell itself like provenance + +An epoch-ms marker is out of scope, but a marker NAMED like a retired field is a live +hazard: `setup-status`'s legacy-format detector greps for `updatedAt|updated_at|lastUpdated|lastSetup|lastVerifiedAt|checkedAt` +(`brewcode/skills/setup-status/SKILL.md:451`, verdict `LEGACY-FMT` at `:475`). +`brewcode/hooks/session-start.mjs` renamed its TTL marker `checkedAt` -> `fetchedAtMs` +for exactly that reason, and says so at `session-start.mjs:112-113` (reads `:118-119`, +`:179-180`; writes `:147`, `:202`). + +**The rename is the belt; the SCOPE is the braces, and the scope is what actually holds.** +The detector only ever runs over the `STAMPS` heredoc, and only when no `version` was +extracted (`SKILL.md:450`, `:459`). That heredoc carries ARTIFACTS only - runtime state +(`.claude/semble/state.json`, `.claude/docsync/state.json`, epoch-ms markers, TTL caches) +may never be added to it (`SKILL.md:539-549`). So a runtime file cannot trip the detector +even if it spells a retired key. Keep both: name new runtime keys with an explicit unit +suffix (`fetchedAtMs`), and keep them out of `STAMPS`. + +### Deliberately unstamped artifacts - documented so they stop being re-flagged + +Each is a real file that carries no metadata, and each is CORRECT that way. An audit that +finds one has found the exemption, not a defect. + +| File | Why it carries no stamp | +|------|-------------------------| +| `brewtools/skills/agent-router-setup/assets/judge-prompt.md` | never copied into a project - its whole text is INLINED into `settings.json` as the tier-2 hook's `prompt` string (`agent-router-setup/SKILL.md:22`, `:70`, `README.md:148`). Nothing installs it, so nothing `cmp`s or stamp-reads it; the installed carrier that IS stamped is `agent-router.mjs`. Install-time checks assert only that it exists and is non-empty (`SKILL.md:89`, `:345`, `:348`) | +| `.claude/features/specs/SPEC_TEMPLATE.md`, `DESIGN_TEMPLATE.md` (task-board) | they are TEMPLATES inside the project: `/task-spec` copies each to `specs/-spec.md` / `-design.md` (`task-board-setup/references/09-spec-templates.md:7-8`), so a stamp in their frontmatter would be inherited by every derived card - and cards carry task DOMAIN data (`created:`/`updated:`/`status:`), exempt by the first row of this section. Same reasoning already applied to `TASK_TEMPLATE.md`, and U5b excludes all of them by name (`references/10-upgrade.md:293-295`) | +| `.claude/brewtools/manager/prompts/.md` and the `~/.claude` twin | USER-authored prompt-text overrides, not generated artifacts. The plugin default lives in `$BT_ROOT/skills/manager-setup/references/.md` and the override is the last entry of a three-step read precedence (`manager-setup/SKILL.md:89`, `:410`); the file's whole body is injected as prompt text, and `purge` deletes it outright (`SKILL.md:362`). Provenance of user content is not this spec's business | + +The shared test: a file is stampable only if it is INSTALLED as a durable plugin artifact +AND some reader compares its version. Inlined prompt text, a template whose frontmatter is +inherited by derived files, and user-authored overrides all fail that test. + +### The `.codex/` mirror version is NOT the plugin version + +NINE manifests carry their own version, not the plugin's - for each of brewcode, +brewdoc and brewtools: `/.codex/.codex-plugin/plugin.json`, +`/.codex/package/plugin.json`, and `.codex/plugins//.codex-plugin/plugin.json`. +(brewui has no mirror.) The value is the COMPATIBILITY MIRROR's version line, and +`bump-version.sh` deliberately does NOT move it with a release: +`.codex/scripts/validate-compat.mjs:9` pins it with `const VERSION = '4.0.6'` and the +check at `:86` accepts only that value or a `4.0.6+codex.` derived from it, +so bumping the manifests without bumping that constant fails validation. `4.0.6` is a +pinned constant, not a stale copy of the plugin version - it is the one literal in this +document that is supposed to be literal. `bump-version.sh:246-255` says the same thing +at the call site. + +Consequences, both directions: + +- A mirror manifest sitting several plugin releases behind is CORRECT. Never report it + stale, never "fix" it during a bump, never count it among the release-stamped files. +- Moving the mirror forward is a deliberate, separate change: the `VERSION` constant in + `validate-compat.mjs` and every mirror manifest move together, or `bump-version.sh` + fails on `validate-compat.mjs`. + +`.codex/` artifacts are generated from source by `generate-compat.mjs` on every bump, +so a metadata stamp inside a mirrored skill or agent is a COPY of the source file's +stamp - audit the source, never the mirror. diff --git a/brewcode/skills/skills/SKILL.md b/brewcode/skills/skills/SKILL.md index 19d323b..c2dac03 100644 --- a/brewcode/skills/skills/SKILL.md +++ b/brewcode/skills/skills/SKILL.md @@ -183,7 +183,7 @@ Claim one and any tooling keyed off these tokens sweeps unrelated history. Examples: a skill named `budget` invoked as `budget` omits the key; a skill named `fitness-nutrition` invoked as `fit` MUST declare `cli: fit`. -`version` is NOT semver -- no ordering, decreasing is as valid as increasing, build no comparison logic on it. MANDATORY when the skill's behaviour lives OUTSIDE its own directory (a binary on PATH, a wrapper shipped in an image, a remote service): editing that behaviour leaves the directory byte-identical, so consumers watching it see nothing. `updated:` is a human-facing date with no mechanical role and is NOT a substitute; the two coexist. +`version` is NOT semver -- no ordering, decreasing is as valid as increasing, build no comparison logic on it. MANDATORY when the skill's behaviour lives OUTSIDE its own directory (a binary on PATH, a wrapper shipped in an image, a remote service): editing that behaviour leaves the directory byte-identical, so consumers watching it see nothing. `last_updated:` is the human-facing date, has no mechanical role, and is NOT a substitute for `version`; the two coexist. Never spell it `updated`, `updatedAt` or `lastUpdated` -- see `brewcode/skills/setup-status/references/artifact-metadata.md` section 8. ### Prerequisite (improve only): Resolve Target diff --git a/brewcode/skills/superreview-setup/README.md b/brewcode/skills/superreview-setup/README.md index fd0a419..6a4233f 100644 --- a/brewcode/skills/superreview-setup/README.md +++ b/brewcode/skills/superreview-setup/README.md @@ -77,8 +77,10 @@ prose is the interface. with two entry points: `emit` (full generation) and `emit-agent` (the agent alone — no superreview skill needed, this is what `/brewcode:teams-setup` calls). It is never hand-written, never authored by `brewcode:agent-creator` (which may only ADAPT the seeded blocks), and never a domain expert. A usable existing file is **REUSED byte-untouched** — the writer -prints one status line, `INTENT_GUARD: CREATED ` or `INTENT_GUARD: REUSE ` — so local edits survive every -regeneration; an empty or frontmatter-less file counts as absent and is recreated. Its evidence tiers are baked in at +prints one status line, `INTENT_GUARD: CREATED|REUSE|MIGRATED ` — so local edits survive every +regeneration; an empty or frontmatter-less file counts as absent and is recreated. `MIGRATED` is the pre-5.0 case: +a file carrying the retired `intent-guard template vN` stamp gets its metadata restamped in place (the four +frontmatter keys + the tail anchor) with the tailored body preserved byte-for-byte. Its evidence tiers are baked in at emit time: `T1` tracker, `T2` specs, `T3` plans, `T4` policy files, `T5` the live session transcript. ## How review + standards-review are merged @@ -100,14 +102,24 @@ matrix and report scaffolding baked into it; scope + expert selection make the e Run inside the repo you want to wire up: ``` -/brewcode:superreview-setup [status|install|upgrade] "" [scope] +/brewcode:superreview-setup [status|install|upgrade|enable|disable|uninstall|purge] "" [scope] ``` | Verb | Effect | |------|--------| -| `status` | read-only: is the skill emitted, is `intent-guard.md` present, does `validate` pass | -| `install` | full generation (Phase 0 -> 4). Also the no-verb default | +| `status` | read-only: is the skill emitted, is it enabled or parked, is `intent-guard.md` present, does `validate` pass | +| `install` | full generation (Phase 0 -> 4). Also the default when a fine-tune prompt is given with no verb | | `upgrade` | refresh a live install from the template baseline without erasing tailoring | +| `enable` | rename `SKILL.md.disabled` back to `SKILL.md` — `/superreview` is offered again | +| `disable` | rename `SKILL.md` to `SKILL.md.disabled` — `/superreview` stops being discovered. `references/`, `.template-baseline/` and every tailoring stay on disk; reversible, nothing regenerated | +| `uninstall` | delete `.claude/skills/superreview/`. **Keeps** the review reports and `intent-guard.md` | +| `purge` | uninstall + delete `.claude/reports/*_superreview/`. Still keeps `intent-guard.md` | + +No arguments at all: `status` when the skill is already emitted, `install` when it is not. + +`intent-guard.md` survives all seven verbs — it is shared with `/brewcode:teams-setup`, and that skill +may be the one that put it there. `enable`/`disable` take effect in the NEXT session, since Claude Code +discovers skills at session start. - `` — what to emphasize in the emitted skill's focus ordering (e.g. "weight reuse highest", "always treat auth as P0"). Woven into the emitted Focus table + emphasis line. Scope discipline stays in rank 1 @@ -150,7 +162,7 @@ After generation, run the emitted skill in that project. Depth comes from how yo | File | Role | |------|------| | `SKILL.md` | The generator orchestrator | -| `scripts/generate.sh` | `scan` / `emit` / `emit-agent` / `upgrade` / `validate` | +| `scripts/generate.sh` | `scan` / `emit` / `emit-agent` / `upgrade` / `enable` / `disable` / `uninstall` / `purge` / `validate` | | `references/SKILL.md.template` | The emitted SKILL.md (placeholder slots) | | `references/agent-prompt.md` | Emitted runtime expert-selection procedure + domain-owner prompt contract | | `references/scope.md.template` | Emitted scope-discipline reference (baseline, ownership, taxonomy, delivery, closeout, gate) | @@ -167,7 +179,7 @@ the expected path, not a failure: it writes nothing and prints no `INTENT_GUARD: | Command | Effect | |---------|--------| -| `generate.sh upgrade` | The supported refresh. Writes NO live file. Stages a fresh emit under `.upgrade-staging/` and reports, per asset, the **new template vs the pristine `.template-baseline/` copy `emit` saved** — so `DIFFERS ( template line(s))` counts real template changes and never your tailoring. A deleted asset is restored RAW and labelled `MISSING -> restored (NEEDS PHASE 3)`. The generator ports each delta into the live file with targeted `Edit` calls, then promotes `.upgrade-staging/.template` to the new baseline | +| `generate.sh upgrade` | The supported refresh. Writes NO live file. Stages a fresh emit under `.upgrade-staging/` and reports, per asset, the **new template vs the pristine `.template-baseline/` copy `emit` saved** — so `DIFFERS ( template line(s))` counts real template changes and never your tailoring. The per-stack reference is re-derived from the installed tree (`UPGRADE_STACK=`), never re-defaulted, so a TypeScript/Go/Java-Kotlin install gets its own reference restamped. A deleted asset is restored RAW — scalars included, deliberately unresolved — and labelled `MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)`. The generator ports each delta into the live file with targeted `Edit` calls, then promotes `.upgrade-staging/.template` to the new baseline | | `SUPERREVIEW_FORCE=1 generate.sh emit` | Conscious destructive override: overwrites the live installation and **loses** every tailored + self-synced edit. Only on an explicit request for a clean regeneration | `.template-baseline/` and `.upgrade-staging/` each carry a `.gitignore` of `*`, so neither shows up in your diff --git a/brewcode/skills/superreview-setup/SKILL.md b/brewcode/skills/superreview-setup/SKILL.md index 9d0c5da..ad95715 100644 --- a/brewcode/skills/superreview-setup/SKILL.md +++ b/brewcode/skills/superreview-setup/SKILL.md @@ -3,7 +3,7 @@ name: brewcode:superreview-setup description: "Generates a project-tailored deep-review skill: domain-expert routing + scope discipline (blast radius, delivery, closeout) + mechanical gates + adversarial validation. Triggers: superreview, generate review skill, deep review skill, scope discipline review" user-invocable: true disable-model-invocation: true -argument-hint: "[status|install|upgrade] [scope]" +argument-hint: "[status|install|upgrade|enable|disable|uninstall|purge] [scope]" allowed-tools: [Read, Write, Edit, Glob, Grep, Bash, Agent, AskUserQuestion] model: opus --- @@ -66,25 +66,76 @@ plus optional `[scope]` hint. The fine-tune prompt is woven into the emitted ski ### Verb routing — resolve FIRST, before anything else -`$ARGUMENTS` may start with one of three verbs. Anything else is the fine-tune prompt and takes the -free-form path. Strip the verb before using the rest as the fine-tune prompt. +`$ARGUMENTS` may start with one of the seven canonical verbs, in this order: +`status | install | upgrade | enable | disable | uninstall | purge`. Anything else is the fine-tune +prompt and takes the free-form path. Strip the verb before using the rest as the fine-tune prompt. + +Removed aliases that must never be accepted or printed: `init`, `on`, `off`, `setup`, `remove`, +`reset`, `create`, `update`, `cleanup`. Recognize them in free text, echo the canonical verb back. | Verb | What runs | Writes? | |------|-----------|---------| -| `status` | read-only: does `.claude/skills/superreview/` exist, is `.claude/agents/intent-guard.md` present, is `.template-baseline/` there? Then `generate.sh validate` and report. **STOP** — no phases run | no | +| `status` | read-only: is `.claude/skills/superreview/` there, is it ENABLED or parked, is `.claude/agents/intent-guard.md` present, is `.template-baseline/` there? Then `generate.sh validate` and report. **STOP** — no phases run | no | | `install` | the full generate flow, Phase 0 -> Phase 4 below | yes | | `upgrade` | Phase 2b only (`generate.sh upgrade`), then Phase 3 for any `MISSING -> restored` asset, then Phase 4 `validate`. **STOP** | live files only via targeted Edit | -| *(no verb)* | same as `install`; the whole `$ARGUMENTS` is the fine-tune prompt | yes | +| `enable` | `generate.sh enable` — un-parks the installed skill. **STOP** | one rename | +| `disable` | `generate.sh disable` — parks the installed skill without deleting anything. **STOP** | one rename | +| `uninstall` | `generate.sh uninstall` — deletes the generated skill dir, KEEPS the reports and `intent-guard.md`. Confirm once. **STOP** | deletes | +| `purge` | `generate.sh purge` — uninstall + deletes `.claude/reports/*_superreview/`. Still keeps `intent-guard.md`. Confirm once, naming the report count. **STOP** | deletes | +| *(no args at all)* | `status` when `.claude/skills/superreview/` exists, otherwise `install` | status: no | +| *(no verb, but a prompt)* | same as `install`; the whole `$ARGUMENTS` is the fine-tune prompt | yes | **EXECUTE** using Bash tool (`status` only): ```bash -test -d .claude/skills/superreview && echo "installed" || echo "not_installed" +if test -f .claude/skills/superreview/SKILL.md; then echo "installed: enabled" +elif test -f .claude/skills/superreview/SKILL.md.disabled; then echo "installed: DISABLED (parked as SKILL.md.disabled — run 'enable' to restore)" +elif test -d .claude/skills/superreview; then echo "installed: BROKEN (dir present, no SKILL.md and no SKILL.md.disabled)" +else echo "not_installed"; fi test -f .claude/agents/intent-guard.md && echo "intent-guard: present" || echo "intent-guard: MISSING" test -d .claude/skills/superreview/.template-baseline && echo "baseline: present" || echo "baseline: absent (pre-baseline install)" +echo "reports: $({ find .claude/reports -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | wc -l | tr -d ' ') dir(s) — deleted by 'purge', kept by 'uninstall'" bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" validate && echo "✅ validate" || echo "❌ validate FAILED" ``` > `status` never writes and never asks. `not_installed` -> report it and offer `install`; nothing else. +> `installed: DISABLED` is a state, not a fault — report it and offer `enable`. `validate` fails on a +> disabled install (it looks for `SKILL.md`); say so rather than presenting it as a broken installation. + +--- + +### Modes: enable | disable | uninstall | purge + +| Mode | Generated skill dir | `references/` + `.template-baseline/` | Phase 3 tailoring | `.claude/reports/*_superreview/` | `intent-guard.md` | +|------|--------------------|---------------------------------------|-------------------|----------------------------------|-------------------| +| `enable` | `SKILL.md.disabled` -> `SKILL.md` | kept | kept | kept | kept | +| `disable` | `SKILL.md` -> `SKILL.md.disabled` | kept | kept | kept | kept | +| `uninstall` | **deleted** | deleted with it | lost | **kept** | kept | +| `purge` | **deleted** | deleted with it | lost | **deleted** | kept | + +**How the toggle works.** Claude Code discovers a project skill only through `

/SKILL.md`. +`disable` renames that ONE file to `SKILL.md.disabled`, so `/superreview` stops being offered while +`references/`, `.template-baseline/` and every Phase 3 tailoring stay byte-identical on disk. `enable` +renames it back. Nothing is regenerated in either direction, so no `version` is bumped and no +self-synced edit is at risk. Use `disable` to park a review setup that is temporarily noisy; use +`uninstall` when it should really go. Both take effect in the NEXT session — skills are discovered at +session start. + +**`intent-guard` is never touched by any of the four.** `generate.sh` (`emit`/`emit-agent`) is its +only writer, and it is shared with `/brewcode:teams-setup`, which may have put it there. Deleting or +parking it would silently break an unrelated team install. All four modes print it as `KEPT`. + +**Confirm before deleting.** `uninstall` and `purge` each `AskUserQuestion` exactly once, listing the +real paths (`find .claude/skills/superreview -type f | sort`) and, for `purge`, the number of review +reports being destroyed, with `uninstall` offered as the keep-the-reports alternative. A declined +confirmation ends the run cleanly — delete nothing. + +**EXECUTE** using Bash tool (the chosen verb, after confirmation where required): +```bash +bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" MODE_HERE && echo "✅ MODE_HERE" || echo "❌ MODE_HERE FAILED" +``` + +Then report the script's `MOVED:` / `REMOVED:` / `KEPT:` lines verbatim. Not installed at all -> +say so and **STOP**; never "disable" or "purge" something that was never emitted. ### Delegation (applies to every Task this generator spawns AND to the fan-out it emits) @@ -207,6 +258,7 @@ unconditionally by the emitted skill at BOTH depths, so the emitted skill is bro |------|--------| | **Single writer** | `scripts/generate.sh` is the ONLY writer of `.claude/agents/intent-guard.md`, via ONE shared implementation exposed as two subcommands: `emit` (full generation, Phase 2) and `emit-agent` (the agent alone, no superreview skill involved — this is what `/brewcode:teams-setup` calls instead of authoring its own copy). **Never hand-write the file.** `brewcode:agent-creator` may only ADAPT the seeded BLOCKs of an already-written file; it may never author it | | **Reuse wins** | a USABLE file already exists -> the writer prints `INTENT_GUARD: REUSE ` and leaves it BYTE-UNTOUCHED. An existing intent-guard is the project's own tuned version (or a sibling generator's) and outranks this template. Do not "refresh" it, do not diff-merge it, do not fill BLOCKs in it. "Usable" = non-empty AND carrying `name: intent-guard` frontmatter AND free of unresolved `{UPPER_SNAKE}` tokens; an empty, truncated or placeholder-laden file is treated as ABSENT and recreated | +| **Migrate, never re-emit** | a file carrying the RETIRED `` stamp is ours but pre-standard: the writer prints `INTENT_GUARD: MIGRATED ` and restamps METADATA ONLY — the four frontmatter keys and the tail anchor. Every tailored line survives byte-for-byte, so this is the `upgrade restamps it` path, not a regeneration. A file with NO stamp of either generation is the project's own hand-written agent and is only ever REUSED | | **No AskUserQuestion** | creation is not gated. Do not ask whether to create it; it is part of the emitted artifact, like `references/scope.md` | | **Roster scan** | note in Phase 1 whether the file is present (`generate.sh scan` reports it) so the Phase 5 summary can say CREATED vs REUSED | | **Not an expert** | never count it toward the domain-expert requirement, never put it in `DOMAIN_AGENTS_TABLE` / `FILE_GROUP_MAP` / `SIMPLIFY_AGENTS`, never make it `VALIDATOR_AGENT` or a scope-pass owner. `generate.sh validate` excludes it from the expert count for exactly this reason | @@ -244,20 +296,26 @@ bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" emit && echo "✅ emit" || echo " > `${CLAUDE_SKILL_DIR}/references/SKILL.md.template` exists and the target `.claude/` is writable. This writes `/.claude/skills/superreview/SKILL.md` (scalars substituted), copies `agent-prompt.md`, -`report-template.md` and `scope.md` (scalar-substituted), copies the chosen `${STACK_REF}` into the emitted +`report-template.md`, `scope.md` and the chosen `${STACK_REF}` (all scalar-substituted) into the emitted `references/`, saves the pristine templates to `.claude/skills/superreview/.template-baseline/` (what `upgrade` later diffs against), and **creates-or-reuses `/.claude/agents/intent-guard.md`** (template header -stripped, provenance stamp kept). Key off the ONE machine-readable status line the writer prints — the +stripped, provenance stamp kept). Every emitted artifact is stamped with the four standard metadata fields — +`doc_type: llm`, `version`, `generated_by: brewcode:superreview-setup`, `last_updated` — in its frontmatter; +you export NOTHING for them. `version` is read out of the plugin's own `.claude-plugin/plugin.json` by script +self-location and `last_updated` is `date +%F`. Both stay `{PLUGIN_VERSION}` / `{LAST_UPDATED}` in the raw +`.template-baseline/` copies, so a plain version bump makes `upgrade` report IDENTICAL, never a diff. +Key off the ONE machine-readable status line the writer prints — the `already installed` refusal path prints NO status line, because nothing was written: | Status line | Meaning | |-------------|---------| | `INTENT_GUARD: CREATED .claude/agents/intent-guard.md` | written from the template with SEEDED-DEFAULT BLOCKs — you MUST adapt all three in Phase 3 | | `INTENT_GUARD: REUSE .claude/agents/intent-guard.md` | the file is the project's own — touch NOTHING in it, skip its Phase 3 table | +| `INTENT_GUARD: MIGRATED .claude/agents/intent-guard.md` | a pre-standard file of ours was restamped in place (metadata only, tailored body preserved) — treat it exactly like REUSE: skip its Phase 3 table, edit nothing | > The same writer is available standalone as `generate.sh emit-agent` (agent only, no superreview skill required, > same env overrides `PROJECT_NAME` / `TRACKER_LABEL` / `SPEC_LOCATION` / `PLAN_LOCATION` / `POLICY_LOCATION`, -> same two status lines). `/brewcode:teams-setup` uses it; this generator does not need it, `emit` covers it. +> same three status lines). `/brewcode:teams-setup` uses it; this generator does not need it, `emit` covers it. ### Phase 2b — Already installed? `upgrade`, never re-emit @@ -270,7 +328,8 @@ REFUSES on a live installation. When it does: bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" upgrade && echo "✅ upgrade" || echo "❌ upgrade FAILED" ``` -It writes NO live file. It stages a fresh emit at `.claude/skills/superreview/.upgrade-staging/` (with the raw new +It rewrites no live file's CONTENT — the one thing it does write into a live file is the metadata restamp below. +It stages a fresh emit at `.claude/skills/superreview/.upgrade-staging/` (with the raw new templates under `.upgrade-staging/.template/`) and compares the NEW TEMPLATE against the pristine copies `emit` saved in `.claude/skills/superreview/.template-baseline/` — **never the live file against a template**, because a live file legitimately carries Phase 3 tailoring and Phase 4b self-sync edits that no template ever knew about. @@ -278,11 +337,37 @@ One line per asset: | Line | Meaning | What you do | |------|---------|-------------| -| `IDENTICAL (template unchanged since install)` | no template delta | nothing | +| `IDENTICAL (template unchanged since install)` | no template delta | nothing — but the file is still restamped, see below | | `DIFFERS ( template line(s))` | the TEMPLATE really changed | run the printed `diff `, then port ONLY those changes into the LIVE file with targeted **Edit** calls, keeping every tailored + self-synced line | -| `MISSING -> restored (NEEDS PHASE 3)` | a deleted asset was restored from the RAW template | **go to Phase 3 for that file** and fill its BLOCK placeholders — it is un-tailored, and Phase 4 `validate` fails on it otherwise | +| `MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)` | a deleted asset was restored from the RAW template | **go to Phase 3 for that file** and fill BOTH kinds of placeholder — the SCALARS too (`{PROJECT_NAME}`, `{STACK_LABEL}`, `{ARBITER_AGENT}`, …), because `upgrade` runs with a bare environment and deliberately does NOT re-guess them. `validate` lists every one by name | | `NO BASELINE - full diff, tailoring included` | install predates the baseline | the count is NOT a template delta; review the staged copy by hand and port only genuine template changes | +**The stack is re-derived, never re-defaulted.** The first line `upgrade` prints is +`UPGRADE_STACK=.md (derived from the installed tree)`. The per-stack reference was a Phase 1 DECISION +(`STACK_REF`), and `upgrade` runs with a bare environment, so it reads that decision back out of the installed tree — +whichever of `python.md` / `typescript-react.md` / `go.md` / `java-kotlin.md` is present in +`references/` or in `.template-baseline/references/` — instead of falling back to a default. Everything below +iterates that name: a wrong one would leave the project's real reference behind at the old version forever while +restamping a file the project does not have, so `/brewcode:setup-status` would report `stale` after every +successful upgrade. More than one present = a multi-stack install, and all of them are restamped. None +determinable prints `UPGRADE_STACK=none — ❌ NO per-stack reference found` and skips the stack doc only; the other +four artifacts are still restamped. `STACK_REF=.md` in the environment overrides the derivation. + +**The restamp — one `RESTAMP:` line per live file, and it is unconditional.** After the delta report, `upgrade` +refreshes `version` / `generated_by` / `last_updated` in the frontmatter of every live emitted file, in place: + +``` +RESTAMP: .claude/skills/superreview/SKILL.md version "A.B.C" -> "X.Y.Z", generated_by/last_updated refreshed (body untouched) +``` + +It is deliberately NOT gated on the verdict above. A plain version bump moves no template line, so every asset +reports `IDENTICAL` — and the emitted `SKILL.md` frontmatter `version:` is exactly what `/brewcode:setup-status` +reads to decide `stale`. An `upgrade` that skipped it reported success and left the stamp where it was, so the +next `status` printed `stale` again, forever. Nothing else in the file is touched: `doc_type` is preserved when +present (it is user-owned), the body is compared byte-for-byte afterwards, and any mismatch aborts the run before +anything is written — Phase 3 tailoring and Phase 4b self-sync edits survive intact. A second `upgrade` on the +same version is a no-op apart from `last_updated`. + Then, once the delta is applied (and any restored file has been through Phase 3), promote the new templates to the baseline and clean up with the command the script printed: `rm -rf && mv /.template && rm -rf ` — after which go to Phase 4. Both @@ -321,7 +406,7 @@ placeholder in the EMITTED files with content you build from Phase 1 analysis. | `{SHARED_SURFACES_TABLE}` | the concrete always-shared surfaces of THIS repo (public API/contract dirs, migrations, schema/registry files, CI workflows, dependency manifests, design tokens) | **In `/.claude/agents/intent-guard.md` — ONLY when the writer printed `INTENT_GUARD: CREATED`. On -`INTENT_GUARD: REUSE`, SKIP this table entirely and edit nothing in that file.** +`INTENT_GUARD: REUSE` or `INTENT_GUARD: MIGRATED`, SKIP this table entirely and edit nothing in that file.** > **The three BLOCK placeholders are already gone by now** — emit replaced each with a runnable GENERIC DEFAULT > block that ends in its own marker line. Key every Edit on the marker, not on the old `{TOKEN}`: your @@ -361,6 +446,11 @@ bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" validate && echo "✅ validate" | > The template checks above run ONLY against an agent file carrying the template stamp. A REUSED hand-written > intent-guard is byte-untouchable by contract, so validate says so and does not judge it by template rules. +> **Shell expansions are NOT placeholders.** The scan strips every `${UPPER_SNAKE}` before looking for tokens, so +> Phase 3 evidence commands may freely use `${BASE}`, `${HOME}`, `${CLAUDE_PLUGIN_ROOT}` or any other variable — +> only a BARE `{TOKEN}` is reported, and it is reported by name with no surrounding characters. Do not work around +> a false positive by adding the variable's name to the runtime allow-list. + > **`⚠️ UNTAILORED` is a WARNING, not a failure** (exit code unaffected): the agent still carries seeded generic > BLOCK defaults, i.e. the Phase 3 adaptation was skipped or incomplete. Go back to Phase 3, replace each named > block AND its marker, and re-run — never ship an UNTAILORED agent silently. @@ -433,7 +523,8 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe |---------|---------|-------------| | Emit target | `/.claude/skills/superreview/` | Where the generated skill is written | | Emit templates | `${CLAUDE_SKILL_DIR}/references/` | Source templates for the generation | -| Generation script | `${CLAUDE_SKILL_DIR}/scripts/generate.sh` | `scan` \| `emit` \| `emit-agent` \| `upgrade` \| `validate`. `emit-agent` writes ONLY `.claude/agents/intent-guard.md` (shared writer, no superreview skill required) — that is the entry point `/brewcode:teams-setup` calls | +| Generation script | `${CLAUDE_SKILL_DIR}/scripts/generate.sh` | `scan` \| `emit` \| `emit-agent` \| `upgrade` \| `enable` \| `disable` \| `uninstall` \| `purge` \| `validate`. `emit-agent` writes ONLY `.claude/agents/intent-guard.md` (shared writer, no superreview skill required) — that is the entry point `/brewcode:teams-setup` calls | +| Disabled marker | `/.claude/skills/superreview/SKILL.md.disabled` | What `disable` renames `SKILL.md` to. Its presence IS the disabled state — there is no config file. `enable` renames it back; `uninstall`/`purge` delete the whole dir either way | | Re-generation | `upgrade` (Phase 2b) | `emit` refuses on a live installation because the emitted skill self-syncs; `upgrade` stages the new templates and never writes a live file. `SUPERREVIEW_FORCE=1` overwrites and destroys self-synced edits | | Template baseline | `/.claude/skills/superreview/.template-baseline/` | Pristine copies of the templates `emit` generated from (git-ignored via its own `.gitignore`). `upgrade` diffs the NEW template against them, so the reported delta is the TEMPLATE's change and never the Phase 3 tailoring the live files carry. Absent (pre-baseline install) -> `upgrade` reports `NO BASELINE` and falls back to a live-vs-template diff | | Stack reference | one of `python.md \| java-kotlin.md \| typescript-react.md \| go.md` | Emitted per the dominant detected stack | @@ -461,6 +552,11 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe | Multi-stack repo | Pick dominant stack for `STACK_REF`; note secondaries in the agent/group tables | | `.claude/agents/intent-guard.md` already exists | REUSE it — the writer prints `INTENT_GUARD: REUSE ` and does not write the file. Never overwrite, never diff it into shape, never ask. Skip the Phase 3 BLOCK adaptation for it | | `.claude/agents/intent-guard.md` exists but is EMPTY / has no `name: intent-guard` frontmatter | Not a reusable file — the writer says so and RECREATES it from the template. Then the Phase 3 adaptation applies as for any CREATED file | +| `.claude/agents/intent-guard.md` carries the retired `intent-guard template vN` stamp | Pre-standard file of ours. The writer prints `INTENT_GUARD: MIGRATED `: the four metadata keys and the tail anchor are restamped, the tailored body is untouched. Do NOT run Phase 3 on it and do NOT re-emit it | +| `enable`/`disable`/`uninstall`/`purge` but nothing installed | The script exits 1 with `❌ not installed` (or `⚠️ nothing to uninstall`). Report it and **STOP** — never emit a fresh install as a "fix" for a removal verb | +| `enable` on a live install, `disable` on a parked one | The script prints `✅ already {enabled\|disabled}` and exits 0. Report it and **STOP**; do not rename | +| `validate` fails right after `disable` | Expected: `validate` looks for `SKILL.md`, which is now `SKILL.md.disabled`. Say "disabled, not broken" and offer `enable`. Never re-`emit` to "repair" it — that would destroy the Phase 4b self-synced edits the parked file still holds | +| `.claude/skills/superreview/` present with neither `SKILL.md` nor `SKILL.md.disabled` | Genuinely broken (a half-deleted install). Report the dir contents, offer `uninstall` then a fresh `install`. Do not guess which file to recreate | | `validate` prints `⚠️ UNTAILORED` | The Phase 3 BLOCK adaptation was skipped or partial (seeded markers survive). Warning, not a failure: go back to Phase 3, replace each seeded block + marker, re-run validate | | No tracker AND no spec/plan/policy dirs | Emit anyway with the defaults; the agent falls back to T5 (the session transcript) and reports its tier in every finding. Do NOT invent paths and do NOT skip the agent | | Target has no writable `.claude/agents/` | `emit` does `mkdir -p .claude/agents` first; a failure there is the same STOP as an unwritable `.claude/` | @@ -469,7 +565,8 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe | `emit` refuses — superreview already installed | Expected, not an error: the live skill carries Phase 4b self-sync corrections, and the refusal prints NO `INTENT_GUARD:` line. Go to Phase 2b and run `upgrade`. Only `SUPERREVIEW_FORCE=1` overwrites, and only on an explicit request for a clean regeneration | | `upgrade` says `DIFFERS` on a file the user hand-edited | `DIFFERS` counts TEMPLATE lines (new template vs `.template-baseline/`), never the user's tailoring. Port that template change onto the live file with Edit; never replace the file with the staged copy. Conflicting section -> ask before replacing it | | `upgrade` says `NO BASELINE` | The install predates `.template-baseline/`, so the printed count is a live-vs-template diff that INCLUDES Phase 3 tailoring — do not treat it as a template delta. Review the staged copy by hand, port only what the template really changed, then promote `.upgrade-staging/.template` to the baseline (command printed by the script) | -| `upgrade` says `MISSING -> restored (NEEDS PHASE 3)` | The restored file is a RAW template with unresolved BLOCK placeholders. Run Phase 3 on it BEFORE Phase 4 — going straight to `validate` fails on those placeholders | +| `upgrade` says `MISSING -> restored RAW` | The restored file is a RAW template: BOTH its BLOCK placeholders AND its scalars (`{PROJECT_NAME}`, `{STACK_LABEL}`, `{SOURCE_GLOB}`, the agent names) are unresolved, on purpose — `upgrade` has no environment to resolve them from and re-defaulting them would bake `this project` / `general-purpose` into a live file that `validate` then passes. Run Phase 3 on it BEFORE Phase 4; `validate` names every token | +| `upgrade` prints `UPGRADE_STACK=none — ❌ NO per-stack reference found` | The install carries none of `python.md` / `typescript-react.md` / `go.md` / `java-kotlin.md` (emitted without one, or it was deleted). The other four artifacts are still restamped; nothing is guessed. Re-run as `STACK_REF=.md generate.sh upgrade` to restore the right one — it then reports `MISSING -> restored RAW` | | Target `.claude/` not writable | STOP — ask the user to run from the repo root | --- @@ -482,13 +579,14 @@ Recap of the canonical shape the emitted SKILL.md implements (full text in `refe - `references/intent-guard.md.template` — the anti-drift agent (asked vs delivered), emitted to `.claude/agents/intent-guard.md` create-or-reuse. - `references/report-template.md` — emitted merged-report layout. - `references/{python,java-kotlin,typescript-react,go}.md` — per-stack reference docs (one is emitted). -- `scripts/generate.sh` — `scan` / `emit` / `emit-agent` / `upgrade` / `validate` (validate also enforces the +- `scripts/generate.sh` — `scan` / `emit` / `emit-agent` / `upgrade` / `enable` / `disable` / `uninstall` / + `purge` / `validate` (validate also enforces the domain-expert requirement; `emit-agent` is the shared intent-guard writer used standalone by `/brewcode:teams-setup`; `upgrade` refreshes a live installation without destroying its self-synced edits, diffing the NEW template against the pristine `.template-baseline/` copies `emit` saved). + diff --git a/brewcode/skills/superreview-setup/references/java-kotlin.md b/brewcode/skills/superreview-setup/references/java-kotlin.md index 64fe85a..f04dac2 100644 --- a/brewcode/skills/superreview-setup/references/java-kotlin.md +++ b/brewcode/skills/superreview-setup/references/java-kotlin.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Java/Kotlin Standards Reference Standards for Java/Kotlin enterprise projects. The project's own rules in `.claude/rules/*` + `.claude/convention/*` diff --git a/brewcode/skills/superreview-setup/references/python.md b/brewcode/skills/superreview-setup/references/python.md index 84c6159..eb177e0 100644 --- a/brewcode/skills/superreview-setup/references/python.md +++ b/brewcode/skills/superreview-setup/references/python.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Python Standards Reference GENERIC modern-Python guidance (type hints, docstrings, imports, exceptions, comprehensions, testing). The project's diff --git a/brewcode/skills/superreview-setup/references/report-template.md b/brewcode/skills/superreview-setup/references/report-template.md index 0dc0ce4..10957f1 100644 --- a/brewcode/skills/superreview-setup/references/report-template.md +++ b/brewcode/skills/superreview-setup/references/report-template.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Merged Report Layout (superreview Phase 4 — {PROJECT_NAME}) Output: `.claude/reports/{TIMESTAMP}_superreview/REPORT.md`. ONE consolidated, validated, P0->P3-sorted report. diff --git a/brewcode/skills/superreview-setup/references/scope.md.template b/brewcode/skills/superreview-setup/references/scope.md.template index 52c0909..668d9df 100644 --- a/brewcode/skills/superreview-setup/references/scope.md.template +++ b/brewcode/skills/superreview-setup/references/scope.md.template @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Scope Discipline Reference (superreview — {PROJECT_NAME}) SINGLE home of: sanctioned-scope resolution, sanction sources + precedence, the ownership map, the scope-creep diff --git a/brewcode/skills/superreview-setup/references/typescript-react.md b/brewcode/skills/superreview-setup/references/typescript-react.md index e4abe06..199707e 100644 --- a/brewcode/skills/superreview-setup/references/typescript-react.md +++ b/brewcode/skills/superreview-setup/references/typescript-react.md @@ -1,3 +1,10 @@ +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # TypeScript / Node / React Standards Reference Standards for TypeScript, Node.js and React projects. The project's own rules in `.claude/rules/*` + diff --git a/brewcode/skills/superreview-setup/scripts/generate.sh b/brewcode/skills/superreview-setup/scripts/generate.sh index 00670b9..33ab49f 100755 --- a/brewcode/skills/superreview-setup/scripts/generate.sh +++ b/brewcode/skills/superreview-setup/scripts/generate.sh @@ -11,23 +11,35 @@ # Also saves PRISTINE copies of the templates it emitted from under .template-baseline/ — that # baseline is what makes `upgrade` able to tell a TEMPLATE change apart from Phase 3 tailoring. # REFUSES to overwrite a live installation (the emitted skill SELF-SYNCS — Phase 4b — so its -# SKILL.md and references/scope.md carry edits no template knows about). SUPERREVIEW_FORCE=1 -# overrides and DESTROYS those edits. +# SKILL.md and references/scope.md carry edits no template knows about). "Live" = ANY emitted +# artifact on disk (see `_live_artifacts`), not SKILL.md alone: a PARTIAL install must not be +# re-substituted with DEFAULT scalars. SUPERREVIEW_FORCE=1 overrides and DESTROYS those edits. # upgrade - Refresh a LIVE installation without touching hand-edits (Phase 2b): stages a fresh emit next # to it and reports, per file, the NEW TEMPLATE vs the .template-baseline/ copy — IDENTICAL | -# DIFFERS (real template delta) | MISSING -> restored (NEEDS PHASE 3) | NO BASELINE (pre-baseline -# install: falls back to live-vs-template, tailoring included). Live files are never written; -# the AI applies the template delta with targeted Edit calls. +# DIFFERS (real template delta) | MISSING -> restored RAW (NEEDS PHASE 3) | NO BASELINE (pre-baseline +# install: falls back to live-vs-template, tailoring included). Live file CONTENT is never +# written; the AI applies the template delta with targeted Edit calls. The one live write is an +# UNCONDITIONAL metadata restamp (version/generated_by/last_updated, one `RESTAMP:` line per +# file, body compared byte-for-byte) — without it a version bump, which reports IDENTICAL on +# every asset, could never clear the `stale` verdict setup-status reads off the emitted +# SKILL.md frontmatter. The per-stack reference is RE-DERIVED from the installed tree +# (see `_installed_stack_refs`), never re-defaulted — see `UPGRADE_STACK=` on stdout. +# Runs on ANY live install, SKILL.md included in the restorable set — so it is also the remedy +# for a partially damaged install, which `emit` refuses to touch. A DISABLED install (parked +# SKILL.md.disabled) is refused with `enable` as its remedy, never silently resurrected. # emit-agent - Create-or-reuse /.claude/agents/intent-guard.md ONLY. No superreview skill is # written, read or required. Used by /brewcode:teams-setup, which must not author its own copy. # Prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED ` | -# `INTENT_GUARD: REUSE `. Diagnostics go to stderr and never break that contract. +# `INTENT_GUARD: REUSE ` | `INTENT_GUARD: MIGRATED ` (a pre-standard agent of +# ours, restamped in place — tailored body preserved). Diagnostics go to stderr and never +# break that contract. # validate - Fail if any unresolved setup-time {PLACEHOLDER} remains (Phase 4) # # Env overrides (honored by BOTH emit and emit-agent; SUPERREVIEW_FORCE=1 lets emit overwrite a live install): # PROJECT_NAME, TRACKER_LABEL, SPEC_LOCATION, PLAN_LOCATION, POLICY_LOCATION # (emit also honors STACK_LABEL, STACK_REF, SOURCE_GLOB, PATHSPEC_GLOBS, ARBITER_AGENT, -# VALIDATOR_AGENT, SCOPE_AGENT_A, SCOPE_AGENT_B) +# VALIDATOR_AGENT, SCOPE_AGENT_A, SCOPE_AGENT_B; upgrade honors STACK_REF as an override of the +# stack it derives from the installed tree, and ignores the rest — see upgrade_skill) set -euo pipefail @@ -37,6 +49,9 @@ MODE="${1:-emit}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(dirname "$SCRIPT_DIR")" REFS="$SKILL_DIR/references" +# Plugin manifest by SELF-LOCATION: skills/superreview-setup -> skills -> . +# Correct in the dev checkout AND in the installed cache. The version is NEVER hardcoded. +PLUGIN_JSON="$SKILL_DIR/../../.claude-plugin/plugin.json" # Target is the current working directory (the repo being reviewed) TARGET=".claude/skills/superreview" @@ -46,10 +61,17 @@ STAGING="$TARGET/.upgrade-staging" # Pristine copies of the templates the live install was emitted from. `upgrade` diffs the NEW template against # these, so Phase 3 tailoring in the live files can never be mistaken for a template change. BASELINE="$TARGET/.template-baseline" +# Where `disable` parks SKILL.md. Read by `enable`/`disable` and by `upgrade`, which must tell a DISABLED +# install apart from one whose SKILL.md was deleted. +DISABLED_MARK="$TARGET/SKILL.md.disabled" # The one agent file this script owns, and the provenance stamp that proves a file came out of this pipeline. IG_PATH=".claude/agents/intent-guard.md" -IG_STAMP_PREFIX="`). Its presence +# without IG_STAMP_PREFIX is what proves a file came out of THIS pipeline before the artifact-metadata standard — +# i.e. ours, migratable, and never to be confused with a hand-written agent that carries no stamp at all. +IG_LEGACY_STAMP_RE='`), append the current anchor block. + awk ' + //) drop = 0; next } + { print } + ' "$_bd/agent.md" > "$_bd/agent.next" + printf '\n' >> "$_bd/agent.next" + cat "$_bd/tail" >> "$_bd/agent.next" + cat -s "$_bd/agent.next" > "$_bd/agent.md" + + # POST-CONDITIONS. Both edits are pattern-driven; a silent miss would ship a half-migrated agent that + # still reads as legacy to `setup-status`. + for _k in doc_type version generated_by last_updated; do + grep -q "^${_k}:" "$_bd/agent.md" || { echo "❌ migration aborted: $_k missing after restamp" >&2; return 1; } + done + grep -qF "$IG_STAMP_PREFIX" "$_bd/agent.md" || { echo "❌ migration aborted: current tail anchor not written" >&2; return 1; } + grep -qE "$IG_LEGACY_STAMP_RE" "$_bd/agent.md" && { echo "❌ migration aborted: retired stamp survived" >&2; return 1; } + grep -q '^name:[[:space:]]*intent-guard[[:space:]]*$' "$_bd/agent.md" || { echo "❌ migration aborted: frontmatter name lost" >&2; return 1; } + + mv "$_bd/agent.md" "$IG_PATH" + rm -rf "$_bd"; _bd="" + echo "INTENT_GUARD: MIGRATED $IG_PATH" } write_intent_guard() { @@ -242,14 +453,22 @@ write_intent_guard() { resolve_scalars mkdir -p .claude/agents - if _ig_usable; then - echo "INTENT_GUARD: REUSE $IG_PATH" - return 0 - fi - # Diagnostic only — STDERR, so stdout keeps carrying exactly one `INTENT_GUARD:` status line. - if [ -e "$IG_PATH" ]; then - echo "⚠️ $IG_PATH exists but is empty, has no 'name: intent-guard' frontmatter, or still carries unresolved {PLACEHOLDER} tokens — recreating from template" >&2 - fi + case "$(_ig_kind)" in + CURRENT|FOREIGN) + echo "INTENT_GUARD: REUSE $IG_PATH" + return 0 + ;; + LEGACY) + # Ours, pre-standard. Restamp instead of recreating: the body is the project's own tailoring. + echo "ℹ️ $IG_PATH carries the retired 'intent-guard template vN' stamp — restamping metadata in place, body preserved" >&2 + _ig_migrate + return 0 + ;; + BROKEN) + # Diagnostic only — STDERR, so stdout keeps carrying exactly one `INTENT_GUARD:` status line. + echo "⚠️ $IG_PATH exists but is empty, has no 'name: intent-guard' frontmatter, or still carries unresolved {PLACEHOLDER} tokens — recreating from template" >&2 + ;; + esac # The agent must be RUNNABLE straight out of emit — the emitted skill spawns it at BOTH depths, so a # half-filled agent file breaks a QUICK run entirely. The three BLOCKs therefore get stack-generic @@ -334,10 +553,16 @@ emit_skill() { # The emitted skill SELF-SYNCS (its Phase 4b corrects its own routing table, gates, baseline and shared # surfaces). A blind re-emit would silently erase every one of those corrections, so a live installation is # never overwritten: `upgrade` refreshes it, and SUPERREVIEW_FORCE=1 is the conscious destructive override. - if [ -f "$TARGET/SKILL.md" ] && [ "${SUPERREVIEW_FORCE:-0}" != "1" ]; then - echo "❌ superreview is already installed at $TARGET/SKILL.md" - echo " It SELF-SYNCS (Phase 4b) — overwriting it destroys those in-place corrections." - echo " Use 'generate.sh upgrade' (live files preserved), or SUPERREVIEW_FORCE=1 to overwrite and LOSE them." + # The guard keys on the WHOLE artifact set, not on SKILL.md alone: a PARTIALLY damaged install (SKILL.md + # deleted, every tailored reference still in place) used to slip past it, and emit then re-substituted those + # references with DEFAULT scalars — `this project`, the generic scope, `python.md` over a TypeScript install. + _installed="$(_live_artifact_list)" + if [ -n "$_installed" ] && [ "${SUPERREVIEW_FORCE:-0}" != "1" ]; then + echo "❌ superreview is already installed at $TARGET/ — live artifact(s): $_installed" + echo " It SELF-SYNCS (Phase 4b) — overwriting it destroys those in-place corrections, and re-emitting" + echo " re-substitutes EVERY file with DEFAULT scalars (this project / the project stack / python.md)." + echo " Use 'generate.sh upgrade' (live files preserved; a MISSING one is restored RAW), or SUPERREVIEW_FORCE=1" + echo " to overwrite and LOSE them." exit 1 fi @@ -358,7 +583,7 @@ emit_skill() { echo "✅ $TARGET_REFS/scope.md" if [ -f "$REFS/$STACK_REF" ]; then - cp "$REFS/$STACK_REF" "$TARGET_REFS/$STACK_REF" + _subst "$REFS/$STACK_REF" "$TARGET_REFS/$STACK_REF" echo "✅ $TARGET_REFS/$STACK_REF" else echo "⚠️ stack reference not found: $REFS/$STACK_REF (emitted without per-stack doc)" @@ -380,6 +605,62 @@ emit_skill() { echo " — then run: generate.sh validate" } +# ── shared: in-place metadata restamp ────────────────────────────────────────── +# First frontmatter block of a file -> stdout. One reader for every check below, so "the frontmatter" means +# the same lines everywhere — `references/scope.md` carries a second `---` in its body and must not confuse it. +_fm_block() { awk 'NR == 1 && $0 == "---" { f = 1; next } f && $0 == "---" { exit } f { print }' "$1"; } +# Everything AFTER that block -> stdout. Used as the did-not-touch-the-body proof. +_fm_body() { awk 'NR == 1 && $0 == "---" { f = 1; next } f == 1 && $0 == "---" { f = 2; next } f == 2 { print }' "$1"; } + +# Refresh ONLY `version` / `generated_by` / `last_updated` in a LIVE file's own frontmatter, in place. +# $1 = live file, $2 = its freshly substituted staging counterpart — the single source for the spelling of the +# three values, exactly as `_ig_migrate` takes them from the substituted template. `doc_type` is PRESERVED when +# present (§1 of the artifact-metadata spec: it is user-owned) and seeded as `llm` only when the file has none. +# The body is copied through untouched and then compared byte-for-byte; a mismatch aborts rather than shipping a +# file whose Phase 3 tailoring or Phase 4b self-sync edits were silently mangled. +_restamp_meta() { + _live="$1"; _src="$2" + if [ "$(head -1 "$_live")" != "---" ]; then + echo "⚠️ RESTAMP: $_live has no frontmatter block — left untouched" >&2 + return 0 + fi + _bd="$(mktemp -d)" + _fm_block "$_src" | grep -E '^(version|generated_by|last_updated):[[:space:]]' > "$_bd/meta" || true + if [ "$(grep -c . "$_bd/meta" || true)" -ne 3 ]; then + echo "❌ restamp aborted: $_src frontmatter carries no version/generated_by/last_updated trio" >&2 + return 1 + fi + # Materialise the live frontmatter once: a `grep -q` / `head -1` on a live pipe can SIGPIPE the awk + # upstream, and under `pipefail` that reads as a failure (repo rule avoid#7). + _fm_block "$_live" > "$_bd/live.fm" + _was=$(sed -n 's/^version:[[:space:]]*//p' "$_bd/live.fm" | sed -n 1p || true) + _needdt=0 + grep -q '^doc_type:' "$_bd/live.fm" || _needdt=1 + + awk -v metaf="$_bd/meta" -v needdt="$_needdt" ' + NR == 1 && $0 == "---" { fm = 1; print; next } + fm == 1 && $0 == "---" { + if (needdt == 1) print "doc_type: llm" + while ((getline l < metaf) > 0) print l + close(metaf); fm = 2; print; next + } + fm == 1 && /^(version|generated_by|last_updated):[[:space:]]/ { next } + { print } + ' "$_live" > "$_bd/next" + + # POST-CONDITIONS. The body must be identical, and the result must satisfy the same frontmatter gate + # `validate` applies — one dialect, checked here so a bad restamp never reaches the user's tree. + _fm_body "$_live" > "$_bd/body.old"; _fm_body "$_bd/next" > "$_bd/body.new" + cmp -s "$_bd/body.old" "$_bd/body.new" \ + || { echo "❌ restamp aborted: $_live body changed — nothing written" >&2; return 1; } + _check_meta_frontmatter "$_bd/next" \ + || { echo "❌ restamp aborted: $_live would fail the metadata gate — nothing written" >&2; return 1; } + + mv "$_bd/next" "$_live" + rm -rf "$_bd"; _bd="" + echo "RESTAMP: $_live version ${_was:-(none)} -> \"$PLUGIN_VERSION\", generated_by/last_updated refreshed (body untouched)" +} + # ── upgrade: refresh a LIVE installation, hand-edits preserved ────────────────── # The emitted skill is EXPECTED to have self-modified (its Phase 4b SELF-SYNC) and to carry Phase 3 tailoring, so # no live file is ever written over AND no live file is ever the diff baseline: comparing a tailored install to a @@ -391,10 +672,52 @@ upgrade_skill() { echo "=== superreview: upgrade ===" validate_templates - if [ ! -f "$TARGET/SKILL.md" ]; then - echo "❌ nothing to upgrade: $TARGET/SKILL.md does not exist — run 'generate.sh emit' first" + # A LIVE INSTALL is the requirement, not SKILL.md specifically. Gating on SKILL.md alone left the one state + # this mode exists for — SKILL.md deleted, every tailored reference intact — with `emit` as its only advertised + # remedy, and `emit` re-defaults every one of those references. The restore loop below already handles a + # missing artifact correctly (RAW, out of `.template/`, NEEDS PHASE 3), and SKILL.md is in that set. + _installed="$(_live_artifact_list)" + if [ -z "$_installed" ]; then + echo "❌ nothing to upgrade: no superreview artifact under $TARGET/ — run 'generate.sh emit' first" exit 1 fi + if [ ! -f "$TARGET/SKILL.md" ]; then + if [ -f "$DISABLED_MARK" ]; then + # Parked, not damaged. Restoring a RAW SKILL.md here would resurrect the skill behind the user's back and + # leave two copies of it, so the remedy is the reversible one that already exists. + echo "❌ superreview is DISABLED: $DISABLED_MARK is parked in place of $TARGET/SKILL.md" + echo " Run 'generate.sh enable' first, then upgrade." + exit 1 + fi + echo "ℹ️ $TARGET/SKILL.md is MISSING from an otherwise live install — it is restored RAW below (NEEDS PHASE 3);" + echo " every other artifact keeps its tailoring untouched." + fi + + # STACK — re-derived from the INSTALLED tree BEFORE any scalar resolves. `resolve_scalars` would otherwise fall + # back to `python.md`, and every loop below iterates `references/$STACK_REF`: on a TypeScript/Go/Java-Kotlin + # install the project's REAL reference would never be staged, never be restamped, and stay behind at the old + # version forever — so `setup-status` keeps printing `stale` after a successful upgrade. An explicit STACK_REF in + # the environment still wins (documented override); nothing else re-defaults. + if [ -n "${STACK_REF:-}" ]; then + STACK_REFS="$STACK_REF" + echo "UPGRADE_STACK=$STACK_REFS (STACK_REF override)" + else + STACK_REFS="$(_installed_stack_refs | tr '\n' ' ' | sed 's/[[:space:]]*$//')" + if [ -n "$STACK_REFS" ]; then + # >1 = multi-stack install: all of them are live artifacts, all of them get restamped. The scalar keeps the + # first, which is only ever substituted into TEXT (`references/{STACK_REF}` prose). + STACK_REF="${STACK_REFS%% *}" + echo "UPGRADE_STACK=$STACK_REFS (derived from the installed tree)" + else + # NOT determinable: emitted without a per-stack doc, or the reference was deleted by hand. Guessing is the + # bug this block exists to remove, so nothing is guessed and nothing per-stack is staged — but the run does + # NOT abort: the other four artifacts still need their restamp or `status` reads `stale` forever. + STACK_REF="none" + echo "UPGRADE_STACK=none — ❌ NO per-stack reference found in $TARGET_REFS/ or $BASELINE/references/" + echo " (candidates: $(_stack_catalog | tr '\n' ' ' | sed 's/[[:space:]]*$//')). No stack doc is staged or restamped; the other four" + echo " artifacts are. Re-run as: STACK_REF=.md generate.sh upgrade — it is then restored RAW." + fi + fi resolve_scalars rm -rf "$STAGING" @@ -406,20 +729,30 @@ upgrade_skill() { _subst "$REFS/report-template.md" "$STAGING/references/report-template.md" _subst "$REFS/scope.md.template" "$STAGING/references/scope.md" # `|| true`: a missing per-stack ref must not abort the run under `set -e`. - { [ -f "$REFS/$STACK_REF" ] && cp "$REFS/$STACK_REF" "$STAGING/references/$STACK_REF"; } || true + for _s in $STACK_REFS; do + { [ -f "$REFS/$_s" ] && _subst "$REFS/$_s" "$STAGING/references/$_s"; } || true + done # Raw NEW templates, in the same shape as the baseline — this pair is what the delta is computed from. copy_raw_templates "$STAGING/.template" echo "UPGRADE_STAGING=$STAGING" echo "UPGRADE_BASELINE=$BASELINE" + # The live artifact set: four stack-independent files plus every per-stack reference this install carries. + _rels="SKILL.md references/agent-prompt.md references/report-template.md references/scope.md" + for _s in $STACK_REFS; do _rels="$_rels references/$_s"; done + _restored=0 - for _rel in "SKILL.md" "references/agent-prompt.md" "references/report-template.md" \ - "references/scope.md" "references/$STACK_REF"; do + for _rel in $_rels; do [ -f "$STAGING/$_rel" ] || continue if [ ! -f "$TARGET/$_rel" ]; then - cp "$STAGING/$_rel" "$TARGET/$_rel" + # RAW, from `.template/` — never the substituted staging copy. `upgrade` runs with a bare environment, so + # every scalar in that copy would be the DEFAULT ("this project", "the project stack", `general-purpose`, + # `Explore`), i.e. the install-time decision silently re-guessed and baked into a live file that `validate` + # then passes. Restoring RAW makes each one an unresolved {TOKEN} that `validate` lists by name, which is + # exactly the NEEDS PHASE 3 contract. The metadata trio is refreshed by the restamp loop below. + cp "$STAGING/.template/$_rel" "$TARGET/$_rel" _restored=$((_restored+1)) - echo "UPGRADE: $_rel MISSING -> restored (NEEDS PHASE 3)" + echo "UPGRADE: $_rel MISSING -> restored RAW (NEEDS PHASE 3: scalar AND block placeholders)" elif [ -f "$BASELINE/$_rel" ]; then if cmp -s "$BASELINE/$_rel" "$STAGING/.template/$_rel"; then echo "UPGRADE: $_rel IDENTICAL (template unchanged since install — live file untouched)" @@ -435,7 +768,19 @@ upgrade_skill() { fi done - # Same create-or-reuse writer as emit: a usable intent-guard.md is REUSED byte-untouched. + # Restamp the LIVE files. Unconditional, and deliberately NOT gated on the IDENTICAL/DIFFERS verdict above: + # a plain version bump changes no template line, so every asset reports IDENTICAL — yet the emitted + # `SKILL.md` frontmatter `version:` is exactly what setup-status reads to decide `stale`. Without this an + # `upgrade` reported success and left the stamp untouched, so the next `status` printed `stale` forever. + # Same `$_rels` set as the delta report above — including the project's REAL per-stack reference, whatever it is. + for _rel in $_rels; do + [ -f "$TARGET/$_rel" ] || continue + [ -f "$STAGING/$_rel" ] || continue + _restamp_meta "$TARGET/$_rel" "$STAGING/$_rel" || exit 1 + done + + # Same writer as emit: a current or hand-written intent-guard.md is REUSED byte-untouched, and a + # pre-standard one of ours is MIGRATED here — this is the `upgrade restamps it` path setup-status promises. write_intent_guard echo "" @@ -446,6 +791,29 @@ upgrade_skill() { echo " rm -rf \"$BASELINE\" && mv \"$STAGING/.template\" \"$BASELINE\" && rm -rf \"$STAGING\" && generate.sh validate" } +# Artifact-metadata frontmatter gate: the four keys, in D2 order, quoted exactly as +# `brewcode/skills/rules/scripts/rules.sh:140-146` already requires — one dialect, not a second one. +# Prints one line per defect, returns 1 when any fired. +_check_meta_frontmatter() { + _f="$1"; _bad=0 + _fm=$(awk 'NR == 1 && $0 == "---" { f = 1; next } f && $0 == "---" { exit } f { print }' "$_f") + for _k in doc_type version generated_by last_updated; do + printf '%s\n' "$_fm" | grep -q "^${_k}:" || { echo "❌ $_f frontmatter missing metadata key: $_k"; _bad=1; } + done + printf '%s\n' "$_fm" | grep -q '^doc_type: llm$' \ + || { echo "❌ $_f doc_type must be exactly 'llm', UNQUOTED"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^version: "[0-9]+\.[0-9]+\.[0-9]+"$' \ + || { echo "❌ $_f version must be a QUOTED X.Y.Z"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^generated_by: "[^"]+"$' \ + || { echo "❌ $_f generated_by must be a QUOTED :"; _bad=1; } + printf '%s\n' "$_fm" | grep -Eq '^last_updated: "[0-9]{4}-[0-9]{2}-[0-9]{2}"$' \ + || { echo "❌ $_f last_updated must be a QUOTED YYYY-MM-DD"; _bad=1; } + _order=$(printf '%s\n' "$_fm" | grep -oE '^(doc_type|version|generated_by|last_updated)' | tr '\n' ' ' || true) + [ "$_order" = "doc_type version generated_by last_updated " ] \ + || { echo "❌ $_f metadata keys out of order [$_order] — must be doc_type, version, generated_by, last_updated"; _bad=1; } + return "$_bad" +} + # ── validate: no setup-time {PLACEHOLDER} may remain ──────────────────────────── validate_emit() { echo "=== superreview: validate ===" @@ -456,13 +824,18 @@ validate_emit() { fi # Runtime tokens the emitted skill legitimately keeps (resolved at REVIEW time, not GENERATION time). + # This list is NOT the shell-variable escape hatch — `_scan_tokens` handles `${VAR}` now. `MAIN`, `ROOT`, `TOK` + # and `REPORT_DIR` occur in SKILL.md.template ONLY as `${…}` expansions and never as bare tokens; they are kept + # here as harmless no-ops rather than removed, but do NOT add a name here to silence a shell variable — that is + # the workaround that hid the collision until an adapted artifact used a variable nobody had allowlisted. _runtime='MODE|DEPTH|BRANCH|SCOPE|FILES|COUNT|TIMESTAMP|FOCUS|FILE_LIST|AGENT_LIST|CANDIDATES|MERGED|PATHSPEC|MAIN|SHA|FOLDER|GROUP|AGENT|N|OC|SC|K|U|D|ROOT|TOK|RANGE|REPORT_DIR|SCOPE_BASELINE|OWNERSHIP|GATE_RESULTS|PR_ISSUE_JSON|INTENT_VERDICT|USER_REQUEST' _errors=0 - for f in "$TARGET/SKILL.md" "$TARGET_REFS/agent-prompt.md" "$TARGET_REFS/report-template.md" \ - "$TARGET_REFS/scope.md"; do + # Every emitted reference, not a fixed list: the per-stack ref is substituted too, so an unresolved + # metadata token in it must fail the gate like any other. + for f in "$TARGET/SKILL.md" "$TARGET_REFS"/*.md; do [ -f "$f" ] || continue - _unresolved=$(grep -oE '\{[A-Z_]+\}' "$f" | sort -u | grep -vE "^\{(${_runtime})\}$" || true) + _unresolved=$(_scan_tokens "$f" | sort -u | grep -vE "^\{(${_runtime})\}$" || true) if [ -n "$_unresolved" ]; then echo "❌ unresolved setup-time placeholders in $f:" echo "$_unresolved" @@ -519,21 +892,29 @@ EOF _errors=$((_errors+1)) fi done - # intent-guard is EXECUTED at both depths: an empty or frontmatter-less file is as broken as a missing one. - if ! _ig_usable; then - if [ -e "$IG_PATH" ]; then + # intent-guard is EXECUTED at both depths: an empty or frontmatter-less file is as broken as a missing one, + # and a LEGACY one is a live agent whose restamp never ran — both are failures with a one-command fix. + _ig_state="$(_ig_kind)" + case "$_ig_state" in + BROKEN) echo "❌ unusable emitted asset: $IG_PATH (empty, no 'name: intent-guard' frontmatter, or unresolved {PLACEHOLDER} tokens) — re-run 'generate.sh emit-agent'" - else + _errors=$((_errors+1)) + ;; + LEGACY) + echo "❌ pre-standard emitted asset: $IG_PATH still carries the retired 'intent-guard template vN' stamp and none of the four metadata keys — run 'generate.sh emit-agent' (or 'upgrade') to restamp it; the tailored body is preserved" + _errors=$((_errors+1)) + ;; + ABSENT) echo "❌ missing emitted asset: $IG_PATH" - fi - _errors=$((_errors+1)) - fi + _errors=$((_errors+1)) + ;; + esac # (c2) TEMPLATE-DERIVED agents only. A file carrying the template stamp came out of this pipeline, so every # {PLACEHOLDER} in it must be resolved (scalars by emit, the three BLOCKs by AI Edit in SKILL.md Phase 3). # A REUSED hand-written intent-guard is byte-untouchable by contract — it is not judged by template rules. - if _ig_usable && grep -qF "$IG_STAMP_PREFIX" "$IG_PATH"; then - _ig_unresolved=$(grep -oE '\{[A-Z_]+\}' "$IG_PATH" | sort -u || true) + if [ "$_ig_state" = "CURRENT" ]; then + _ig_unresolved=$(_scan_tokens "$IG_PATH" | sort -u || true) if [ -n "$_ig_unresolved" ]; then echo "❌ unresolved placeholders in $IG_PATH (no token is runtime here):" echo "$_ig_unresolved" @@ -543,6 +924,7 @@ EOF echo "❌ $IG_PATH still carries the TEMPLATE HEADER comment — emit must strip it" _errors=$((_errors+1)) fi + _check_meta_frontmatter "$IG_PATH" || _errors=$((_errors+1)) # (c3) TAILORING. Seeded BLOCK defaults are a runnable floor, not the target: a run that skipped the # Phase 3 adaptation ships boilerplate and would otherwise pass every gate silently. WARN, not fail — # `emit-agent` is a legitimate standalone path whose adaptation happens in the caller's own flow. @@ -552,8 +934,8 @@ EOF grep -nF "$IG_SEED_MARK" "$IG_PATH" || true echo " INTENT_GUARD: UNTAILORED $IG_PATH ($_ig_seeded seeded block(s)) — run SKILL.md Phase 3 and replace each block + its marker" fi - elif _ig_usable; then - echo "ℹ️ $IG_PATH carries no template stamp — treated as the project's own hand-written agent, not checked against the template" + elif [ "$_ig_state" = "FOREIGN" ]; then + echo "ℹ️ $IG_PATH carries no template stamp of any generation — treated as the project's own hand-written agent, not checked against the template" fi # (d) DOMAIN EXPERTS — a review routed only to generic agents is a degraded review. @@ -610,19 +992,105 @@ EOF exit "$_errors" } +# --- enable / disable ------------------------------------------------------ +# Claude Code discovers a project skill only through /SKILL.md. Parking that ONE file as +# SKILL.md.disabled makes /superreview vanish while references/, .template-baseline/ and every +# Phase 3 tailoring stay exactly where they are, so the toggle is reversible and lossless. +# intent-guard is NEVER parked: it is shared with /brewcode:teams-setup and belongs to whichever +# install put it there. ($DISABLED_MARK is defined next to $TARGET, above.) + +toggle_skill() { + _want="$1" # enable | disable + if [ "$_want" = "disable" ]; then _from="$TARGET/SKILL.md"; _to="$DISABLED_MARK" + else _from="$DISABLED_MARK"; _to="$TARGET/SKILL.md"; fi + + echo "=== superreview: $_want ===" + if [ ! -d "$TARGET" ]; then + echo "❌ not installed: $TARGET does not exist — run 'generate.sh emit' first" + exit 1 + fi + if [ -f "$_to" ] && [ ! -f "$_from" ]; then + echo "✅ already ${_want}d — $_to is in place, nothing to move" + exit 0 + fi + if [ ! -f "$_from" ]; then + echo "❌ broken installation: neither $TARGET/SKILL.md nor $DISABLED_MARK exists" + exit 1 + fi + mv "$_from" "$_to" + echo "MOVED: $_from -> $_to" + echo "KEPT: $TARGET_REFS/ $BASELINE/ $IG_PATH" + echo "✅ $_want (takes effect in the NEXT session — skills are discovered at session start)" +} + +# --- uninstall / purge ----------------------------------------------------- +# uninstall removes the MACHINERY (the generated skill dir); purge additionally removes the DATA +# (the review reports it produced). Same machinery/data split as /brewtools:task-board-setup. +# intent-guard survives BOTH: shared with /brewcode:teams-setup, and deleting it would break a +# team install that has nothing to do with superreview. +REPORT_GLOB=".claude/reports" + +remove_skill() { + _purge="$1" # 0 = uninstall, 1 = purge + _label=$([ "$_purge" = "1" ] && echo purge || echo uninstall) + echo "=== superreview: $_label ===" + + _found=0 + if [ -d "$TARGET" ]; then + rm -rf "$TARGET" + echo "REMOVED: $TARGET/ (SKILL.md, references/, .template-baseline/, any staging)" + _found=1 + else + echo "SKIP: $TARGET/ absent" + fi + + if [ "$_purge" = "1" ]; then + _reports=$({ find "$REPORT_GLOB" -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | sort) + if [ -n "$_reports" ]; then + printf '%s\n' "$_reports" | while IFS= read -r _d; do + [ -n "$_d" ] || continue + rm -rf "$_d" + echo "REMOVED: $_d/" + done + _found=1 + else + echo "SKIP: no .claude/reports/*_superreview/ to remove" + fi + else + _rc=$({ find "$REPORT_GLOB" -maxdepth 1 -type d -name '*_superreview' 2>/dev/null || true; } | wc -l | tr -d ' ') + echo "KEPT: $_rc review report dir(s) under $REPORT_GLOB/ — 'purge' deletes those too" + fi + + if [ -f "$IG_PATH" ]; then + echo "KEPT: $IG_PATH — shared with /brewcode:teams-setup, never deleted by either skill" + fi + + [ "$_found" = "1" ] || { echo "⚠️ nothing to $_label — superreview was not installed here"; exit 0; } + echo "✅ $_label" +} + case "$MODE" in scan) scan_target ;; emit) emit_skill ;; emit-agent) emit_agent_only ;; upgrade) upgrade_skill ;; + enable) toggle_skill enable ;; + disable) toggle_skill disable ;; + uninstall) remove_skill 0 ;; + purge) remove_skill 1 ;; validate) validate_emit ;; *) - echo "Usage: generate.sh " + echo "Usage: generate.sh " echo " emit refuses to overwrite a live installation (SUPERREVIEW_FORCE=1 overrides, DESTROYS self-sync edits)" echo " emit-agent create-or-reuse /.claude/agents/intent-guard.md ONLY (no superreview skill needed);" - echo " prints 'INTENT_GUARD: CREATED ' or 'INTENT_GUARD: REUSE '" + echo " prints 'INTENT_GUARD: CREATED|REUSE|MIGRATED ' (MIGRATED = pre-standard agent restamped in place)" echo " upgrade refresh a live installation; reports NEW template vs .template-baseline/ (the real template" echo " delta, tailoring excluded), restores missing assets RAW (NEEDS PHASE 3), never overwrites" + echo " enable rename .claude/skills/superreview/SKILL.md.disabled back to SKILL.md" + echo " disable rename .claude/skills/superreview/SKILL.md to SKILL.md.disabled — /superreview stops being" + echo " discovered; references/, .template-baseline/ and all tailoring are untouched, reversible" + echo " uninstall delete .claude/skills/superreview/; KEEPS the review reports and intent-guard.md" + echo " purge uninstall + delete .claude/reports/*_superreview/; still keeps intent-guard.md" exit 1 ;; esac diff --git a/brewcode/skills/teams-setup/README.md b/brewcode/skills/teams-setup/README.md index e53db5f..e06c134 100644 --- a/brewcode/skills/teams-setup/README.md +++ b/brewcode/skills/teams-setup/README.md @@ -17,14 +17,18 @@ Analyzes the project, proposes agent variants (minimal/balanced/maximum), create | Status | `/brewcode:teams-setup status ` | Read-only report: agent health, success rates, issues, insights | | Install | `/brewcode:teams-setup install [prompt]` | Analyze project, propose team, create agents + tracking framework | | Upgrade | `/brewcode:teams-setup upgrade ` | Analyze performance, tune or replace underperformers | +| Enable | `/brewcode:teams-setup enable ` | Restore a disabled team: every parked `.md.disabled` is renamed back to `.md` | +| Disable | `/brewcode:teams-setup disable ` | Park the team without deleting it: each `.md` becomes `.md.disabled`, so Claude Code stops discovering it. `team.md`, `trace.jsonl` and the archive are untouched | | Uninstall | `/brewcode:teams-setup uninstall ` | Archive old tracking data, remove inactive agents | | Purge | `/brewcode:teams-setup purge ` | Total removal: every domain agent + `.claude/teams//` incl. the archive. Confirmed once, not recoverable | No arguments: `status` of the first existing team, or `install` of a team named `default` when none exists. -`enable` / `disable` are rejected with an error — a team either exists or it does not. The same parser guard makes `purge` a mode instead of a team name: in earlier versions any unrecognised first word became a team name, so `/brewcode:teams-setup purge` installed a team called `purge`. +The verb always comes first and the optional `` after it. That parser guard is why `purge` is a mode instead of a team name: in earlier versions any unrecognised first word became a team name, so `/brewcode:teams-setup purge` installed a team called `purge`. -`purge` keeps exactly one thing: `.claude/agents/intent-guard.md`, shared with `/brewcode:superreview-setup`. +`disable` is a rename, not a deletion — the roster rows stay in `team.md` with `Status: disabled`, and `verify-team.sh` reports `DISABLED` per parked member and still exits PASS. `enable` puts it all back. Both take effect for the NEXT session: agent discovery is read at session start. + +`purge` keeps exactly one thing: `.claude/agents/intent-guard.md`, shared with `/brewcode:superreview-setup`. It removes both `.md` and `.md.disabled`, so purging a disabled team leaves nothing behind. ## Examples @@ -41,6 +45,12 @@ No arguments: `status` of the first existing team, or `install` of a team named # Tune agents based on tracking data /brewcode:teams-setup upgrade backend +# Park the team without losing it -- agents leave the roster, history stays +/brewcode:teams-setup disable backend + +# Put it back +/brewcode:teams-setup enable backend + # Clean up after a long project phase /brewcode:teams-setup uninstall backend @@ -176,9 +186,10 @@ Every team gets `intent-guard` in addition to its domain agents. It is an **anti **Single writer (idempotent):** `teams` never authors this file. It runs `superreview-setup/scripts/generate.sh emit-agent`, which creates it from the shared template or reuses an -existing one and prints `INTENT_GUARD: CREATED|REUSE `. On `REUSE` -- typically because +existing one and prints `INTENT_GUARD: CREATED|REUSE|MIGRATED `. On `REUSE` -- typically because `/brewcode:superreview-setup` ran first -- the file is left exactly as is and only the `team.md` roster row is -added. On `CREATE`, one `agent-creator` pass tailors the three seeded generic blocks (project +added. `MIGRATED` means a pre-5.0 file of ours was restamped in place (metadata only, tailored body +preserved); treat it like `REUSE` -- no adaptation pass. On `CREATE`, one `agent-creator` pass tailors the three seeded generic blocks (project invariants, drift examples, evidence commands) and touches nothing else -- frontmatter and header stay as emitted. Both skills therefore converge on one shared file produced by one pipeline, never two variants. diff --git a/brewcode/skills/teams-setup/SKILL.md b/brewcode/skills/teams-setup/SKILL.md index 98ca8f9..e32e498 100644 --- a/brewcode/skills/teams-setup/SKILL.md +++ b/brewcode/skills/teams-setup/SKILL.md @@ -3,7 +3,7 @@ name: brewcode:teams-setup description: "Creates and manages dynamic teams of domain agents. Triggers: create team, agent team, team status, cleanup team." user-invocable: true disable-model-invocation: true -argument-hint: "[status [name]|install [name] [prompt]|upgrade [name]|uninstall [name]|purge [name]]" +argument-hint: "[status [name]|install [name] [prompt]|upgrade [name]|enable [name]|disable [name]|uninstall [name]|purge [name]]" allowed-tools: [Read, Write, Edit, Glob, Grep, Bash, Agent, AskUserQuestion, Skill] model: opus --- @@ -25,11 +25,28 @@ Manage dynamic teams of domain-specific agents with tracking framework. bash "${CLAUDE_SKILL_DIR}/scripts/detect-mode.sh" "$ARGUMENTS" && echo "OK" || echo "FAILED" ``` -Output: `MODE:`, `TEAM_NAME:`, `PROMPT:` (optional). Store all three. +Output: `MODE:`, `TEAM_NAME:`, `PROMPT:` (optional), plus the artifact-metadata scalars +`PLUGIN_VERSION:`, `GENERATED_BY:`, `LAST_UPDATED:`. Store all of them. -`MODE` is one of `status | install | upgrade | uninstall | purge`. The script prints `ERROR:...` and -exits 1 for `enable` / `disable` — teams-setup has no enable/disable state. On any `ERROR:` line: -report it verbatim and **STOP**. Never guess a mode, and never treat a canonical verb as a team name. +> **Artifact metadata — every file this skill writes.** `team.md` and every generated domain agent carry +> `version` = `PLUGIN_VERSION:`, `generated_by` = `GENERATED_BY:` (`brewcode:teams-setup`), +> `last_updated` = `LAST_UPDATED:`, and `doc_type: llm` on the agents. Take the values from the output +> above — never hardcode a version, never call `date` a second time with a different format, and never +> stamp a "template version": the plugin version replaces it. +> `.claude/agents/intent-guard.md` is the ONE exception: `generate.sh emit-agent` stamps it with +> `generated_by: brewcode:superreview-setup`, and teams never touches those keys. + +`MODE` is one of the canonical seven, in this order: `status | install | upgrade | enable | disable | +uninstall | purge`. On any `ERROR:` line: report it verbatim and **STOP**. Never guess a mode, and +never treat a canonical verb as a team name — `install enable` creates a team NAMED `enable`, so the +verb always comes first and the optional `[name]` positional after it. + +> **How a team is enabled or disabled.** Claude Code discovers a project agent only through +> `.claude/agents/.md`. `disable` renames each member to `.md.disabled`; `enable` renames +> it back. The file body, `team.md`, `trace.jsonl`, `trace-archive.jsonl` and the cursor are untouched +> either way, so the toggle is fully reversible and loses no configuration and no history. It is NOT +> an uninstall: nothing is deleted. `intent-guard` is never parked — it is shared with +> `/brewcode:superreview-setup`, exactly as in UNINSTALL and PURGE. --- @@ -172,6 +189,26 @@ If "Mixed" -- ask model per agent in C3. Store as `DEFAULT_MODEL` (default: opus 1. Read `${CLAUDE_SKILL_DIR}/references/agent-template.md` 2. For each agent, spawn `Task(subagent_type="brewcode:agent-creator")` — ONE agent file per spawn, never "create the whole team" in one task. Prompt carries GOAL (this roster is being built for {TEAM_NAME}; siblings own the other domains), ROLE (owns `.claude/agents/{name}.md` only), SCOPE (that file; out of bounds: other agents, team.md, project source), CONTEXT (mission + domain + project analysis from C1 are settled; model={DEFAULT_MODEL or per-agent} chosen in C2; the 3-4 sibling agent-creators in this batch own {COLLEAGUE_NAMES} — stay off their domains and do not duplicate their triggers), CONSUMER (C4 writes `.claude/teams/{TEAM_NAME}/team.md` from your path + description line, C5 quorum-reviews the file, and colleagues re-delegate to it by domain via the Task Acceptance Protocol), DONE (file written, `description` <= 100 chars (optimal ~80), single line, role + 2-3 triggers, no `` blocks; report path + description line). + + Every spawn prompt MUST also carry the template path and the four metadata lines, resolved — the + subagent cannot see Phase 1's output, so **replace `{PLUGIN_VERSION}` and `{LAST_UPDATED}` below with + the literal values from the Phase 1 `PLUGIN_VERSION:` / `LAST_UPDATED:` lines before you send the + prompt.** A token that reaches the subagent ships verbatim into the agent file, and `setup-status` + then reports that agent `partial` forever. Those two spellings are the only sanctioned ones — never an + angle form, never a double brace: + + ``` + CONTEXT (cont.): structure from ${CLAUDE_SKILL_DIR}/references/agent-template.md — read it first. + DONE (cont.): the frontmatter ends with exactly these four keys, in this order, AFTER the agent's + own keys (name, description, model, tools — leave those byte-untouched, `tools` above all): + doc_type: llm + version: "{PLUGIN_VERSION}" + generated_by: "brewcode:teams-setup" + last_updated: "{LAST_UPDATED}" + ``` + + `verify-team.sh` re-reads every generated agent's frontmatter and FAILS on a wrong order, a missing + key or wrong quoting, so a prompt that shipped a token does not pass C4. 3. Batch 3-4 agents in parallel per message 4. After each batch, optimize: ``` @@ -203,22 +240,27 @@ bash "${CLAUDE_SKILL_DIR}/../superreview-setup/scripts/generate.sh" emit-agent & ``` It creates-or-reuses ONLY `.claude/agents/intent-guard.md` (superreview does not need to have run) and -prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED ` or -`INTENT_GUARD: REUSE `. Diagnostics (e.g. "recreating from template") go to stderr and never -add a second status line. +prints exactly one `INTENT_GUARD:` line on STDOUT: `INTENT_GUARD: CREATED `, +`INTENT_GUARD: REUSE ` or `INTENT_GUARD: MIGRATED ` (a pre-standard file of ours, restamped +in place — metadata only, tailored body preserved). Diagnostics (e.g. "recreating from template") go to +stderr and never add a second status line. > **STOP if FAILED** -- report the script output; do not fall back to hand-authoring the file. **Step 2 — sanity-check the emitted file** (a pre-existing file may be empty, truncated or -placeholder-laden; `-f` alone proves nothing): +placeholder-laden; `-f` alone proves nothing). This runs on the REUSE path too, where `$f` is somebody's +already-adapted agent whose evidence block legitimately holds shell expansions — so strip `${VAR}` FIRST +and match bare tokens on what is left. Without the strip a `${BASE}` scores as an unresolved placeholder, +and this step's remedy is `rm -f`: it would delete a tailored file. ```bash f=.claude/agents/intent-guard.md -[ -s "$f" ] && grep -q '^name: intent-guard' "$f" && ! grep -q '{[A-Z_]\{2,\}}' "$f" && echo "SANE" || echo "CORRUPT" +[ -s "$f" ] && grep -q '^name: intent-guard' "$f" \ + && ! sed 's/\${[A-Z_][A-Z_]*}//g' "$f" | grep -q '{[A-Z_]\{2,\}}' && echo "SANE" || echo "CORRUPT" ``` - `CORRUPT` -> `rm -f .claude/agents/intent-guard.md`, re-run Step 1 once (a fresh emit is now a `CREATED`), re-check. Still `CORRUPT` -> **STOP** and report; do not patch it by hand. -**Step 3 — adapt the seeded BLOCKs.** Only on `INTENT_GUARD: CREATED`. On `REUSE` skip this step -entirely: the existing file is already project-adapted and must not be rewritten or "refreshed". +**Step 3 — adapt the seeded BLOCKs.** Only on `INTENT_GUARD: CREATED`. On `REUSE` or `MIGRATED` skip this +step entirely: the existing file is already project-adapted and must not be rewritten or "refreshed". `emit-agent` seeds three BLOCKs with GENERIC marked defaults. Spawn ONE `Task(subagent_type="brewcode:agent-creator")`, alone (not batched with the domain agents), to replace @@ -262,11 +304,22 @@ Task(subagent_type="brewcode:agent-creator", prompt=" ") ``` -**Step 4 — verify:** +**Step 4 — verify.** FOUR counts, one grep per line, in this order. Each pattern matches the ARTIFACT, +never prose ABOUT it: the emitted agent legitimately keeps a tail comment that NAMES the stripped +`TEMPLATE HEADER`, so an unanchored `grep -c 'TEMPLATE HEADER'` reports `1` on every healthy file and +turns this gate into an unpassable loop. Match the header's opening line, not the phrase. Same reason the +placeholder count strips `${VAR}` first: `{PROJECT_NAME}` is a token, `${CLAUDE_PLUGIN_ROOT}` in an adapted +evidence command is not, and only a strip-then-match tells them apart — a `$`-guard inside the pattern +mis-handles adjacent tokens. `|| true` on every line: zero matches is the happy path for three of the four +counts (repo rule avoid#7), and a count must still PRINT under `set -o pipefail`, especially when it is the +one going red. + ```bash f=.claude/agents/intent-guard.md -grep -c '{[A-Z_]\{2,\}}' "$f"; grep -c 'TEMPLATE HEADER' "$f"; grep -c '^name: intent-guard' "$f" -grep -c 'SEEDED-DEFAULT' "$f" +sed 's/\${[A-Z_][A-Z_]*}//g' "$f" | grep -c '{[A-Z_]\{2,\}}' || true # 0 — unresolved placeholder +grep -c '^`) instead of +> frontmatter. `generate.sh restamp` -- the mandatory last step of EVERY `upgrade`, below -- migrates it in one +> call: it writes the five frontmatter keys and DELETES the tail line. Report the migration explicitly when +> `STAMP_FORMAT` came back `legacy`; after `restamp` it must read `frontmatter`. Never hand-`Edit` the stamp. -The generator's ONLY footprint in the target is `/.claude/skills/memory-sync/` -- it registers no hooks, -writes no settings and touches no config. Uninstall therefore is exactly that one directory. +### Mode: upgrade -1. Run the `status` bash block above. `NOT INSTALLED` -> report "nothing to uninstall" and STOP. -2. List what will be deleted -- **EXECUTE** using Bash tool: +Refresh an existing installation against the current repo AND the current plugin version. Every hand-edit +survives: `upgrade` never runs `emit`, never re-copies a file that carries content, and touches the stamp +only through `restamp`, which is proven metadata-only. + +1. Run the `status` bash block above. `NOT INSTALLED` -> STOP (see Error Handling). `INSTALLED=parked` -> + the install is DISABLED, not broken: say so and offer `enable`; upgrading a parked install is a no-op the + user did not ask for. +2. Take the `status` drift list as the refresh worklist and run Phase 1 (re-scan) -> Phase 3 (targeted + `Edit`s): re-enumerate the surface, refresh the batch / fact / invariant tables, ADD sections for memory + layers the project gained. PRESERVE every hand-edited section; `AskUserQuestion` before REPLACING one. +3. **Restamp -- ALWAYS, whatever `STAMP_FORMAT` said, and never skipped because "the format is already + current".** An install in the current format that is merely a version behind has no other route to a + fresh stamp, and Phase 4 `validate` hard-fails on a stale one. + + **EXECUTE** using Bash tool: + ```bash + bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" restamp && echo "✅ restamp" || echo "❌ restamp FAILED" + ``` + + > **STOP if ❌** -- it never half-writes: the body is compared before and after and the file is left + > untouched unless the ONLY change is the metadata block. + + It rewrites `version` / `last_updated` / `surface_files` (and adds `doc_type` / `generated_by` when they + are missing), drops a surviving pre-5.0 tail stamp, and re-copies a `references/*.md` ONLY when that file's + sole difference from the plugin source is the release stamp line. Report its `RESTAMPED:` / `REF …` lines + verbatim. A `REF DIFFERS:` line is a decision for you, not a failure -- `hard-sync.md` always differs + because Phase 3 filled its two BLOCKs; diff it against the plugin source and port real prose changes by + hand, never by re-copying over the filled tables. +4. Phase 4 `validate`, then the Phase 5 report. + +### Mode: enable / disable + +A rename, nothing more. Use `disable` to park a `/memory-sync` that should stop being offered for a while without +losing a single hand-edit; use `uninstall` when it should really go. + +1. Run the `status` bash block above. `NOT INSTALLED` -> report "nothing to {enable|disable}" and STOP. Never emit + a fresh install as a "fix" for a toggle verb. +2. **EXECUTE** using Bash tool (substitute the resolved verb): + ```bash + bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" MODE_HERE && echo "✅ MODE_HERE" || echo "❌ MODE_HERE FAILED" + ``` +3. Report the script's `MOVED:` / `KEPT:` lines verbatim. `✅ already {enabled|disabled}` is a clean no-op, not a + failure -- report it and STOP. +4. Say that the change lands in the NEXT session: skills are discovered at session start. + +> `validate` FAILS on a disabled installation, because it looks for `SKILL.md` and finds `SKILL.md.disabled`. +> That is the toggle working, not a broken install. Never re-`emit` to "repair" it -- `emit` would destroy the +> SELF-SYNC hand-edits the parked file still carries. `enable` is the fix. + +### Mode: uninstall / purge + +The generator's ONLY footprint in the target is `/.claude/skills/memory-sync/` (plus, after a crashed emit, +a `.memory-sync-emit.*` staging dir beside it) -- it registers no hooks, writes no settings and touches no config. +`uninstall` removes exactly the emit manifest; `purge` removes the directory outright. + +1. Run the `status` bash block above. `NOT INSTALLED` -> report "nothing to {uninstall|purge}" and STOP. +2. List what is there -- **EXECUTE** using Bash tool: ```bash ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")}" find "$ROOT/.claude/skills/memory-sync" -type f | sort ``` -3. **ASK** via `AskUserQuestion`: "Delete `/.claude/skills/memory-sync/` (N files)? Hand-edits are lost." - Options: **Yes, delete** / **Cancel**. -4. On confirmation -- **EXECUTE** using Bash tool: +3. **ASK** via `AskUserQuestion`, ONCE, naming the real count: + - `uninstall`: "Delete the 4 emitted files under `/.claude/skills/memory-sync/` (N files present)? + Hand-edits to them are lost; anything you added yourself is kept." + Options: **Yes, uninstall** / **Purge instead (deletes the whole dir)** / **Cancel**. + - `purge`: "Delete `/.claude/skills/memory-sync/` entirely (N files)? Nothing is recoverable." + Options: **Yes, purge** / **Uninstall instead (keeps files I added)** / **Cancel**. + + Anything but the affirmative -> switch to the other verb or **STOP**. A declined confirmation deletes nothing. +4. On confirmation -- **EXECUTE** using Bash tool (substitute the confirmed verb): ```bash - ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")}" - rm -rf "$ROOT/.claude/skills/memory-sync" \ - && test ! -d "$ROOT/.claude/skills/memory-sync" \ - && echo "✅ memory-sync removed" || { echo "❌ removal FAILED"; exit 1; } + bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" MODE_HERE && echo "✅ MODE_HERE" || echo "❌ MODE_HERE FAILED" ``` -5. Report the exact file list removed. `/memory-sync` disappears on the next session reload. +5. Report the script's `REMOVED:` / `KEPT:` lines verbatim. After `uninstall`, any `KEPT:` list is the exact reason + to offer `purge`. `/memory-sync` disappears on the next session reload. --- @@ -231,8 +306,22 @@ bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" emit && echo "✅ emit" || echo " This writes the FOUR-file tree: `/.claude/skills/memory-sync/SKILL.md` with scalars substituted, plus `references/memory-guide.md`, `references/agent-audit.md` and `references/hard-sync.md` copied into the emitted -`references/`. It stamps provenance on the LAST line of the emitted SKILL.md (template version + date + -`{SURFACE_COUNTS}`) -- that stamp is what `status` and `upgrade` read. +`references/`. It stamps provenance into the emitted SKILL.md's YAML FRONTMATTER, appended after the +skill's own keys: + +```yaml +doc_type: llm # bare enum, never quoted +version: "X.Y.Z" # the brewdoc PLUGIN version, read from +generated_by: "brewdoc:memory-sync-setup" # brewdoc/.claude-plugin/plugin.json by +last_updated: "YYYY-MM-DD" # script self-location -- never hardcoded +surface_files: "38 files: 3 root, 5 nested CLAUDE.md, ..." # skill-specific, AFTER the four +``` + +That frontmatter is what `status`, `validate` and `upgrade` read. `surface_files` is the drift input: +`status` compares its leading integer against a live re-count. There is no private template version any +more, and no tail-line stamp -- a pre-5.0 install still carrying +`` on its last line is detected and reported as +`VERDICT=STALE-LEGACY`, which means "run `upgrade` to migrate it". ### Phase 3 -- Fill the BLOCK placeholders (AI Edit) @@ -274,7 +363,9 @@ bash "${CLAUDE_SKILL_DIR}/scripts/generate.sh" validate && echo "✅ validate" | ``` > **STOP if ❌** -- `validate` fails on: any surviving `{PLACEHOLDER}` in an emitted file, a missing emitted asset, -> or a cited `references/*.md` that does not exist. Fix via Edit and re-run. +> a cited `references/*.md` that does not exist, or provenance frontmatter that is missing or a version behind. +> Placeholder / reference failures are fixed via `Edit`; a stamp failure is fixed by `generate.sh restamp`, which +> the failure message names -- never by `emit`, never by `MEMORY_SYNC_FORCE=1`. Re-run after the fix. Then assert by hand (validate cannot resolve agent names): **every agent name the emitted skill spawns resolves** to a real `/.claude/agents/*.md` or a built-in (`Explore` / `Plan` / `general-purpose`). An invented @@ -358,8 +449,10 @@ the single list -- do not restate it here. | Emit target | `/.claude/skills/memory-sync/` | Where the generated skill is written | | Emit material | `${CLAUDE_SKILL_DIR}/references/` | `SKILL.md.template`, `memory-guide.md`, `agent-audit.md`, `hard-sync.md` -- four files emitted | | Emitted default depth | `NORMAL` | `HARD` is per-run, from the emitted skill's own arguments; nothing is regenerated to switch | -| Generation script | `${CLAUDE_SKILL_DIR}/scripts/generate.sh` | `scan` \| `emit` \| `validate` \| `status` | -| Mode | `status` when installed, else `install` | `status` (read-only) \| `install` \| `upgrade` \| `uninstall` | +| Generation script | `${CLAUDE_SKILL_DIR}/scripts/generate.sh` | `scan` \| `emit` \| `validate` \| `restamp` \| `status` \| `enable` \| `disable` \| `uninstall` \| `purge` | +| Provenance refresh | `generate.sh restamp` | Metadata-only, idempotent, mandatory tail of `upgrade`. Rewrites `version` / `last_updated` / `surface_files`, adds `doc_type` / `generated_by` when absent, deletes a pre-5.0 tail stamp, re-copies a reference ONLY when its sole difference from the plugin source is the release stamp. Aborts rather than write if anything outside the metadata block would move | +| Mode | `status` when installed, else `install` | `status` (read-only) \| `install` \| `upgrade` \| `enable` \| `disable` \| `uninstall` \| `purge` | +| Disabled marker | `/.claude/skills/memory-sync/SKILL.md.disabled` | What `disable` renames `SKILL.md` to. Its presence IS the disabled state -- there is no config file to keep in sync | | Overwrite | refused | `emit` never overwrites a live installation; `MEMORY_SYNC_FORCE=1` overrides and DESTROYS hand-edits | | Emitted default scope | `session` | The emitted skill's own default; every scope sweeps the whole surface | | Non-growth | prime directive | Every emitted-skill run ends each file `<=` its original line count, total delta `<= 0` | @@ -376,9 +469,18 @@ the single list -- do not restate it here. | Emit material missing under `${CLAUDE_SKILL_DIR}/references/` | ERROR "missing emit material: `` -- reinstall brewdoc". STOP. Never improvise a template | | `install` but `/.claude/skills/memory-sync/` already exists | STOP. "memory-sync already installed. Use `upgrade` to refresh it, or `status` to see its drift." Never overwrite | | `upgrade` but nothing installed | STOP. "Nothing to upgrade -- run `/brewdoc:memory-sync-setup install` to generate it first" | -| `uninstall` but nothing installed | Report "nothing to uninstall". Never `rm -rf` a path that does not exist as if it did | +| `uninstall`/`purge` but nothing installed | The script prints `⚠️ nothing to {uninstall\|purge}` and exits 0. Report it. Never `rm -rf` a path that does not exist as if it did | +| `enable`/`disable` but nothing installed | The script exits 1 with `❌ FAILED: not installed`. Report it and STOP -- never emit a fresh install as a "fix" for a toggle verb | +| `enable` on a live install, `disable` on a parked one | The script prints `✅ already {enabled\|disabled}` and exits 0. Report it and STOP; do not rename | +| `validate` or `status` run against a DISABLED install | `status` reports `INSTALLED=parked` / `PARKED=yes` / `NOTE_PARKED=…` and prefixes its verdict `PARKED - ` (the staleness answer is still computed, read out of the parked file). `validate` FAILS -- it looks for `SKILL.md` and by design finds only `SKILL.md.disabled`. Say "disabled, not missing" and offer `enable`. Never re-`emit` -- it would destroy the SELF-SYNC hand-edits the parked file still holds | +| `restamp` on a parked install | Refuses with `❌ FAILED: memory-sync is PARKED at …` and exits 1, writing nothing. `enable` first, then restamp. Never stamp a file the toggle owns | +| `uninstall` leaves files behind (`KEPT:` list non-empty) | Correct, not a failure: those files were never written by `emit`. Show the list and offer `purge` if the user wants the directory gone | | `upgrade` finds hand-edited sections | PRESERVE them. Refresh enumerated tables and ADD new sections; show the diff and AskUserQuestion before REPLACING any section whose content diverges from the template baseline. Declined = no edit, continue cleanly | -| No provenance stamp in the installed skill | Treat as hand-written: `status` reports `UNSTAMPED`, `upgrade` is additive-only and asks before every replacement | +| No provenance frontmatter in the installed skill | Treat as hand-written: `status` reports `STAMP_FORMAT=none` + `META_*=UNSTAMPED`, `upgrade` is additive-only and asks before every replacement | +| Installed skill carries the pre-5.0 TAIL stamp (``) | `status` reports `STAMP_FORMAT=legacy`, `NOTE_LEGACY=…` and `VERDICT=STALE-LEGACY`; `validate` FAILS. `generate.sh restamp` migrates it in one call -- five frontmatter keys written, tail line deleted -- and it runs at the end of every `upgrade` anyway. Never crash on the old format, never treat it as in sync | +| `validate` fails with `stamped version A != plugin version B` | The install is a plugin version behind. Run `generate.sh restamp` (metadata only, hand-edits untouched), then re-run `validate`. This is the failure the message names; `emit` / `MEMORY_SYNC_FORCE=1` are NOT the remedy and would destroy the SELF-SYNC edits | +| `validate` reports a missing `references/*.md` while `SKILL.md` is present | `emit` cannot be the fix -- it refuses over a live install. Run `generate.sh restamp`: it re-copies a MISSING reference from the plugin (nothing local to lose) and reports `REF RESTORED:` | +| `restamp` prints `REF DIFFERS:` for a reference | Not a failure. That file's content differs from the plugin source -- `hard-sync.md` ALWAYS does (Phase 3 filled its two BLOCKs), the other two only after a hand-edit or a plugin prose change. Nothing is overwritten: diff against `${CLAUDE_SKILL_DIR}/references/` and port real changes by hand | | Target has no `.claude/agents/` | Emit anyway; `{EXPERT_ROSTER_TABLE}` says `none -- batches owned by general-purpose`, the agent batch is dropped from `{BATCH_TABLE}`, and the re-audit reduces to the skill roster | | Target has no `.claude/rules/` or conventions | Emit with the batches that DO exist; never emit a batch pointing at a nonexistent dir | | Only a root CLAUDE.md exists | Emit a single-batch skill and say so -- a one-file surface is a legitimate result, an invented batch is not | @@ -401,7 +503,8 @@ the single list -- do not restate it here. - `references/hard-sync.md` -- the `HARD`-depth passes: `paths:` precision audit + obvious-knowledge purge, with their verdict vocabulary and reporting contract (emitted; holds `{PATHS_PRECISION_TABLE}` + `{OBVIOUS_VS_DOMAIN_TABLE}`). -- `scripts/generate.sh` -- `scan` / `emit` / `validate` / `status`. +- `scripts/generate.sh` -- `scan` / `emit` / `validate` / `restamp` / `status` / `enable` / `disable` / + `uninstall` / `purge`. # Agent and Skill Re-Audit The standing best-practice audit `/memory-sync` runs on EVERY agent file and EVERY skill file, on EVERY run, at @@ -25,9 +26,9 @@ crosses `/` while a Claude Code glob does not - so a git probe cannot judge glob ```bash ls .claude/agents/*.md .claude/skills/*/SKILL.md .claude/skills/*/references/*.md 2>/dev/null || true # roster -grep -n '^tools:\|^allowed-tools:\|^name:\|^model:' # declared contract -grep -oE 'mcp__[a-z0-9_]+__' | sort -u # MCP servers claimed -ls -d 2>/dev/null | head -3 # ownership glob resolves (filesystem, not git) +grep -n '^tools:\|^allowed-tools:\|^name:\|^model:' "" # declared contract +grep -oE 'mcp__[a-z0-9_]+__' "" | sort -u # MCP servers claimed +ls -d "" 2>/dev/null | head -3 # ownership glob resolves (filesystem, not git) ``` ## AGENT files (`.claude/agents/*.md`) diff --git a/brewdoc/skills/memory-sync-setup/references/hard-sync.md b/brewdoc/skills/memory-sync-setup/references/hard-sync.md index 6e70779..67997db 100644 --- a/brewdoc/skills/memory-sync-setup/references/hard-sync.md +++ b/brewdoc/skills/memory-sync-setup/references/hard-sync.md @@ -1,3 +1,4 @@ + # Hard Sync The two aggressive DELETION passes of `/memory-sync`. Cited by the emitted skill's Phase 2 batch prompt at diff --git a/brewdoc/skills/memory-sync-setup/references/memory-guide.md b/brewdoc/skills/memory-sync-setup/references/memory-guide.md index 595c774..dc5bd63 100644 --- a/brewdoc/skills/memory-sync-setup/references/memory-guide.md +++ b/brewdoc/skills/memory-sync-setup/references/memory-guide.md @@ -1,3 +1,4 @@ + # Memory Guide Where a fact BELONGS, how to compress it, and what never gets written at all. Cited by every `/memory-sync` diff --git a/brewdoc/skills/memory-sync-setup/scripts/generate.sh b/brewdoc/skills/memory-sync-setup/scripts/generate.sh index b37cf35..a1613fd 100755 --- a/brewdoc/skills/memory-sync-setup/scripts/generate.sh +++ b/brewdoc/skills/memory-sync-setup/scripts/generate.sh @@ -12,13 +12,25 @@ set -euo pipefail -VERSION="1.0.0" MODE="${1:-emit}" # Self-location: scripts/generate.sh -> skills/memory-sync-setup/scripts -> skills/memory-sync-setup SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(dirname "$SCRIPT_DIR")" REFS="$SKILL_DIR/references" +PLUGIN_JSON="$SKILL_DIR/../../.claude-plugin/plugin.json" + +# The artifact stamp carries the PLUGIN version - there is no private template version any more. +# Resolved by self-location, never hardcoded; jq when present, grep+sed otherwise. +resolve_plugin_version() { + [ -f "$PLUGIN_JSON" ] || { echo "unknown"; return 0; } + _v="" + command -v jq >/dev/null 2>&1 && _v=$(jq -r '.version // empty' "$PLUGIN_JSON" 2>/dev/null || true) + [ -n "$_v" ] || _v=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$PLUGIN_JSON" 2>/dev/null | head -1 | sed -e 's/.*:[[:space:]]*"//' -e 's/"$//' || true) + [ -n "$_v" ] || _v="unknown" + printf '%s\n' "$_v" +} +VERSION=$(resolve_plugin_version) # Target paths are relative to the resolved ROOT (see resolve_root - every mode cd's there first). TARGET=".claude/skills/memory-sync" @@ -26,7 +38,13 @@ TARGET_REFS="$TARGET/references" EMITTED_REFS="memory-guide.md agent-audit.md hard-sync.md" EMITTED_N=3 -STAMP_PREFIX="\n' \ - "$STAMP_PREFIX" "$VERSION" "$(date +%F)" "$SURFACE_COUNTS" >> "$_stage/SKILL.md" || _emit_abort + # Provenance stamp - YAML frontmatter of the emitted SKILL.md; `status`, `validate` and `upgrade` read it. + _stamp_frontmatter "$_stage/SKILL.md" || _emit_abort for r in $EMITTED_REFS; do cp "$REFS/$r" "$_stage/references/$r" || _emit_abort; done { rm -rf "$TARGET" && mv "$_stage" "$TARGET"; } || _emit_abort @@ -356,6 +420,118 @@ emit_skill() { echo "Next: AI fills the BLOCK placeholders via Edit (SKILL.md Phase 3) - validate FAILS until then." } +# ── restamp ───────────────────────────────────────────────────────────────────── +# The FINAL step of `upgrade`, ALWAYS run and never conditional on the stamp format. It refreshes the +# provenance keys of an ALREADY INSTALLED SKILL.md - version / last_updated / surface_files, plus +# doc_type and generated_by when absent - and drops a surviving pre-5.0 tail stamp. Nothing else moves: +# the body, every SELF-SYNC hand-edit and every foreign frontmatter key are copied through verbatim, +# and the copy is PROVEN byte-identical below before the new file is kept. Without this, an install +# already in the current format but on an older plugin version had no path to a fresh stamp at all: +# `upgrade` refreshed the tables, `validate` then hard-failed on the stale version, and the only escape +# was MEMORY_SYNC_FORCE=1 emit - which destroys exactly the hand-edits `upgrade` exists to preserve. +_meta_body() { + awk -v legacy="$LEGACY_STAMP_PREFIX" ' + BEGIN { n = 0 } + n < 2 && /^---[[:space:]]*$/ { n++; next } + n < 2 { next } + index($0, legacy) > 0 { next } + { print } + ' "$1" +} + +restamp_skill() { + echo "=== memory-sync-setup: restamp ===" + echo "TARGET=$ROOT" + _f="$TARGET/SKILL.md" + if [ ! -f "$_f" ]; then + if [ -f "$DISABLED_MARK" ]; then + echo "❌ FAILED: memory-sync is PARKED at $DISABLED_MARK - run 'generate.sh enable' first, then restamp" + else + echo "❌ FAILED: not installed - $ROOT/$_f does not exist. Run 'generate.sh emit' first." + fi + exit 1 + fi + + _was=$(_fm_meta "$_f" version); [ -n "$_was" ] || _was="(unstamped)" + _wasl=$(_legacy_stamp "$_f") + _dt=$(_fm_meta "$_f" doc_type) + case "$_dt" in llm|user|skip) ;; *) _dt="$META_DOC_TYPE" ;; esac + _sfv=$(printf '%s' "${SURFACE_COUNTS:-$(derive_surface_counts)}" | tr -d '"' | tr '\n\r' ' ') + _lu=$(date +%F) + + _bd=$(mktemp -d "${TMPDIR:-/tmp}/memory-sync-restamp.XXXXXX") || { echo "❌ FAILED: cannot create a temp dir"; exit 1; } + cp "$_f" "$_bd/orig" || { echo "❌ FAILED: cannot read $_f"; exit 1; } + _meta_body "$_bd/orig" > "$_bd/pre" + + if ! awk -v dt="$_dt" -v ver="$VERSION" -v gb="$META_GENERATED_BY" -v lu="$_lu" -v sf="$_sfv" \ + -v legacy="$LEGACY_STAMP_PREFIX" ' + BEGIN { n = 0; done = 0 } + NR == 1 && $0 != "---" { exit 3 } + /^---[[:space:]]*$/ { + n++ + if (n == 2 && !done) { + printf "doc_type: %s\nversion: \"%s\"\ngenerated_by: \"%s\"\nlast_updated: \"%s\"\nsurface_files: \"%s\"\n", dt, ver, gb, lu, sf + done = 1 + } + print; next + } + n == 1 && /^(doc_type|version|generated_by|last_updated|surface_files):/ { next } + n >= 2 && index($0, legacy) > 0 { next } + { print } + END { if (!done) exit 3 } + ' "$_bd/orig" > "$_bd/new"; then + echo "❌ FAILED: $_f has no YAML frontmatter block to stamp - nothing was written." + echo " Add the skill's own frontmatter first, or re-install into a clean target." + exit 1 + fi + + _meta_body "$_bd/new" > "$_bd/post" + # (the temp dir stays alive for refresh_refs below - it is cleared at the end of this function) + if ! cmp -s "$_bd/pre" "$_bd/post"; then + echo "❌ FAILED: restamp would have changed more than the provenance keys - aborted, $_f untouched." + exit 1 + fi + cp "$_bd/new" "$_f" || { echo "❌ FAILED: cannot write $_f"; exit 1; } + + echo "RESTAMPED: $_f" + echo " version: $_was -> $VERSION" + echo " last_updated: -> $_lu" + echo " generated_by: -> $META_GENERATED_BY" + echo " doc_type: -> $_dt" + echo " surface_files: -> $_sfv" + [ -z "$_wasl" ] || echo "REMOVED: pre-5.0 tail stamp -> $_wasl" + refresh_refs + rm -rf "$_bd"; _bd="" + echo "✅ restamp (metadata keys only - body and every hand-edit verified byte-identical)" +} + +# The 3 references are mechanism-`a` byte copies: their version stamp is BAKED at release into the +# plugin's own file, so an installed copy only becomes current by being copied again. `upgrade` never +# re-copied them, which left `setup-status`'s `cmp` reporting DIFFERS forever with no mode that could +# clear it. Re-copy only where that is PROVABLY lossless - the sole difference is the release stamp +# line. Anything else (a SELF-SYNC hand-edit, hard-sync.md's two filled BLOCKs, prose that moved in a +# newer release) is reported and left alone: a re-copy there would destroy content. +refresh_refs() { + for r in $EMITTED_REFS; do + _src="$REFS/$r"; _dst="$TARGET_REFS/$r" + # An ABSENT reference has nothing to preserve, and `emit` cannot restore it (it refuses while + # SKILL.md exists), so copying it here is the only non-destructive route back to a complete install. + if [ ! -f "$_dst" ]; then + mkdir -p "$TARGET_REFS" && cp "$_src" "$_dst" && echo "REF RESTORED: $_dst (was missing - copied from the plugin)" + continue + fi + if cmp -s "$_src" "$_dst"; then echo "REF OK: $_dst (byte-identical to the plugin source)"; continue; fi + awk '!/brewcode-meta:/' "$_src" > "$_bd/rs" + awk '!/brewcode-meta:/' "$_dst" > "$_bd/rd" + if cmp -s "$_bd/rs" "$_bd/rd"; then + cp "$_src" "$_dst" && echo "REF RECOPIED: $_dst (differed ONLY in the release stamp - nothing to lose)" + else + echo "REF DIFFERS: $_dst - content differs from $_src. NOT touched (hand-edit, filled BLOCKs, or" + echo " prose that moved in a newer release). Diff the two and port changes by hand." + fi + done +} + # ── validate ──────────────────────────────────────────────────────────────────── validate_emit() { echo "=== memory-sync-setup: validate ===" @@ -365,7 +541,9 @@ validate_emit() { _missing=0 [ -f "$TARGET/SKILL.md" ] || { echo "❌ FAILED: missing emitted file: $TARGET/SKILL.md - run 'generate.sh emit' first"; _missing=1; _errors=$((_errors+1)); } for r in $EMITTED_REFS; do - if [ ! -f "$TARGET_REFS/$r" ]; then echo "❌ FAILED: missing emitted file: $TARGET_REFS/$r"; _missing=1; _errors=$((_errors+1)); fi + # `emit` refuses while SKILL.md exists, so it can never be the remedy here - `restamp` re-copies a + # missing reference from the plugin, which is lossless because there is no local content to keep. + if [ ! -f "$TARGET_REFS/$r" ]; then echo "❌ FAILED: missing emitted file: $TARGET_REFS/$r - run 'generate.sh restamp' to restore it from the plugin"; _missing=1; _errors=$((_errors+1)); fi done [ "$_missing" -eq 0 ] && echo "✅ all emitted files present (SKILL.md + $EMITTED_N references)" @@ -397,17 +575,39 @@ validate_emit() { done [ "$_refbad" -eq 0 ] && echo "✅ references consistent both ways (every citation resolves, every emitted reference is cited)" - # (4) provenance stamp, on the LAST line - _lastline=$(tail -1 "$TARGET/SKILL.md") - case "$_lastline" in - "$STAMP_PREFIX"*) echo "✅ provenance stamp present: $_lastline" ;; - *) - if grep -qF "$STAMP_PREFIX" "$TARGET/SKILL.md" - then echo "❌ FAILED: $TARGET/SKILL.md: provenance stamp is not the LAST line" - else echo "❌ FAILED: $TARGET/SKILL.md: provenance stamp absent (expected a last line starting '$STAMP_PREFIX')" - fi - _errors=$((_errors+1)) ;; - esac + # (4) provenance frontmatter - the four standard keys plus surface_files + _mv=$(_fm_meta "$TARGET/SKILL.md" version) + _mg=$(_fm_meta "$TARGET/SKILL.md" generated_by) + _ml=$(_fm_meta "$TARGET/SKILL.md" last_updated) + _md=$(_fm_meta "$TARGET/SKILL.md" doc_type) + _ms=$(_fm_meta "$TARGET/SKILL.md" surface_files) + _legacy=$(_legacy_stamp "$TARGET/SKILL.md") + if [ -z "$_mv" ] || [ -z "$_mg" ] || [ -z "$_ml" ] || [ -z "$_md" ] || [ -z "$_ms" ]; then + if [ -n "$_legacy" ]; then + echo "❌ FAILED: $TARGET/SKILL.md carries the pre-5.0 TAIL stamp, not provenance frontmatter:" + echo " $_legacy" + echo " -> run \`generate.sh restamp\`: it writes doc_type/version/generated_by/last_updated/surface_files" + echo " and deletes the tail line, touching nothing else (this is also the last step of \`upgrade\`)" + else + _lack="" + for _k in $META_KEYS; do + [ -n "$(_fm_meta "$TARGET/SKILL.md" "$_k")" ] || _lack="$_lack${_lack:+, }$_k" + done + echo "❌ FAILED: $TARGET/SKILL.md: provenance frontmatter incomplete (missing: $_lack)" + echo " -> run \`generate.sh restamp\` to write all five keys in place (hand-edits untouched)" + fi + _errors=$((_errors+1)) + elif [ "$_mv" != "$VERSION" ]; then + # Never name `upgrade` here: `upgrade` is the mode whose tail runs this check, so pointing back at it + # was a closed loop with MEMORY_SYNC_FORCE=1 (hand-edit destroying) as the only documented escape. + echo "❌ FAILED: $TARGET/SKILL.md: stamped version $_mv != plugin version $VERSION" + echo " -> run \`generate.sh restamp\`: it refreshes version/last_updated/surface_files in place," + echo " body and hand-edits untouched, and is the mandatory last step of \`upgrade\`" + _errors=$((_errors+1)) + else + echo "✅ provenance frontmatter present: doc_type=$_md version=$_mv generated_by=$_mg last_updated=$_ml surface_files=\"$_ms\"" + [ -z "$_legacy" ] || echo "⚠️ a pre-5.0 tail stamp also survives in this file - delete that line, the frontmatter supersedes it" + fi if [ "$_errors" -eq 0 ]; then echo "✅ validate PASSED"; else echo "❌ FAILED: $_errors check(s) failed"; fi exit "$_errors" @@ -419,9 +619,19 @@ status_report() { echo "TARGET=$ROOT" echo "SKILL_PATH=$ROOT/$TARGET" - if [ ! -f "$TARGET/SKILL.md" ]; then + echo "PLUGIN_VERSION=$VERSION" + + # PARKED (SKILL.md renamed to SKILL.md.disabled by `disable`) is a THIRD state, never collapsed into + # absent: the body, the 3 references and every SELF-SYNC hand-edit are still on disk, so the stamp is + # read out of the parked file and reported at its real version. Only `enable` brings it back. + _skf="$TARGET/SKILL.md"; _parked=no + if [ ! -f "$_skf" ] && [ -f "$DISABLED_MARK" ]; then _skf="$DISABLED_MARK"; _parked=yes; fi + + if [ ! -f "$_skf" ]; then # Same KEY set as the installed branch - a parser keyed on any row must not go blind on a fresh target. - echo "INSTALLED=no"; echo "STAMP_VERSION=none"; echo "STAMP_DATE=none"; echo "STAMP_SURFACE=none" + echo "INSTALLED=no"; echo "PARKED=no"; echo "STAMP_FORMAT=none" + echo "META_DOC_TYPE=none"; echo "META_VERSION=none"; echo "META_GENERATED_BY=none" + echo "META_LAST_UPDATED=none"; echo "META_SURFACE=none" echo "SURFACE_FILES_NOW=$(surface_total)"; echo "SURFACE_FILES_STAMPED=unknown" echo "MISSING_FILES=$(( 1 + EMITTED_N ))" echo "OPEN_PLACEHOLDERS=$( { _open_tokens || true; } | awk '{ print $NF }' | sort -u | wc -l | tr -d ' ')" @@ -430,21 +640,50 @@ status_report() { return 0 fi - echo "INSTALLED=yes" + if [ "$_parked" = yes ]; then + echo "INSTALLED=parked"; echo "PARKED=yes" + echo "NOTE_PARKED=disabled, not missing - $DISABLED_MARK holds the body and every SELF-SYNC hand-edit; run \`enable\` to bring /memory-sync back" + else + echo "INSTALLED=yes"; echo "PARKED=no" + fi _drifts=0 - _stamp=$(grep -F "$STAMP_PREFIX" "$TARGET/SKILL.md" | tail -1 || true) - if [ -z "$_stamp" ]; then - _sv=UNSTAMPED; _sd=UNSTAMPED; _ss=UNSTAMPED; _stamped_n=unknown + # Provenance: frontmatter (5.0+) | legacy tail stamp (pre-5.0) | none. Read from the live SKILL.md, + # or from the parked SKILL.md.disabled - both carry the same stamp. + _sv=$(_fm_meta "$_skf" version) + _sg=$(_fm_meta "$_skf" generated_by) + _sd=$(_fm_meta "$_skf" last_updated) + _st=$(_fm_meta "$_skf" doc_type) + _ss=$(_fm_meta "$_skf" surface_files) + _legacy=$(_legacy_stamp "$_skf") + _fmt=frontmatter + if [ -z "$_sv" ] && [ -n "$_legacy" ]; then + # Pre-5.0 install: parse what the old tail stamp holds, never crash on it, count it as drift. + _fmt=legacy + _sv=$(printf '%s\n' "$_legacy" | sed -e "s|^.*${LEGACY_STAMP_PREFIX}||" -e 's/ .*//') + _sd=$(printf '%s\n' "$_legacy" | sed -e 's/.* emitted //' -e 's/ .*//') + _ss=$(printf '%s\n' "$_legacy" | sed -e 's/.*| surface: //' -e 's/ -->$//') + _sg="brewdoc:memory-sync-setup"; _st=unknown + echo "NOTE_LEGACY=pre-5.0 tail stamp (template v$_sv) - no provenance frontmatter; \`generate.sh restamp\` migrates it (it is also the last step of \`upgrade\`)" + _drifts=$((_drifts+1)) + elif [ -z "$_sv" ]; then + _fmt=none + _sv=UNSTAMPED; _sd=UNSTAMPED; _ss=UNSTAMPED; _sg=UNSTAMPED; _st=UNSTAMPED + fi + [ -n "$_sd" ] || _sd=unknown + [ -n "$_st" ] || _st=unknown + if [ "$_ss" = UNSTAMPED ] || [ -z "$_ss" ]; then + _stamped_n=unknown else - _sv=$(printf '%s\n' "$_stamp" | sed -e "s|^${STAMP_PREFIX}||" -e 's/ .*//') - _sd=$(printf '%s\n' "$_stamp" | sed -e 's/.* emitted //' -e 's/ .*//') - _ss=$(printf '%s\n' "$_stamp" | sed -e 's/.*| surface: //' -e 's/ -->$//') _stamped_n=$(printf '%s\n' "$_ss" | grep -oE '^[0-9]+' || true) [ -n "$_stamped_n" ] || _stamped_n=unknown - [ "$_sv" = "$VERSION" ] || { echo "NOTE_VERSION=template version moved $_sv -> $VERSION"; _drifts=$((_drifts+1)); } fi - echo "STAMP_VERSION=$_sv"; echo "STAMP_DATE=$_sd"; echo "STAMP_SURFACE=$_ss" + if [ "$_fmt" = frontmatter ] && [ "$_sv" != "$VERSION" ]; then + echo "NOTE_VERSION=plugin version moved $_sv -> $VERSION"; _drifts=$((_drifts+1)) + fi + echo "STAMP_FORMAT=$_fmt" + echo "META_DOC_TYPE=$_st"; echo "META_VERSION=$_sv"; echo "META_GENERATED_BY=$_sg" + echo "META_LAST_UPDATED=$_sd"; echo "META_SURFACE=$_ss" _now=$(surface_total) echo "SURFACE_FILES_NOW=$_now" @@ -467,22 +706,129 @@ status_report() { [ "$_open" -gt 0 ] && _drifts=$((_drifts + _open)) echo "DEFAULT_BRANCH=$(derive_branch)"; echo "GIT_VISIBILITY=$(derive_git_visibility)"; echo "DRIFTS=$_drifts" - if [ "$_drifts" -eq 0 ]; then echo "VERDICT=IN SYNC"; else echo "VERDICT=STALE ($_drifts drifts)"; fi + if [ "$_fmt" = legacy ]; then _verdict="STALE-LEGACY ($_drifts drifts)" + elif [ "$_drifts" -eq 0 ]; then _verdict="IN SYNC" + else _verdict="STALE ($_drifts drifts)" + fi + # PARKED carries its staleness with it - the two answers are orthogonal and both are reported. + [ "$_parked" = no ] || _verdict="PARKED - $_verdict" + echo "VERDICT=$_verdict" return 0 } +# ── enable / disable ──────────────────────────────────────────────────────────── +# Claude Code discovers a project skill only through /SKILL.md. Parking that ONE file as +# SKILL.md.disabled withdraws /memory-sync from the roster while the emitted references AND every +# hand-edit the skill accumulated through its SELF-SYNC phase stay byte-identical on disk. Fully +# reversible, nothing regenerated, no provenance stamp touched. +DISABLED_MARK="$TARGET/SKILL.md.disabled" + +toggle_skill() { + _want="$1" # enable | disable + if [ "$_want" = "disable" ]; then _from="$TARGET/SKILL.md"; _to="$DISABLED_MARK" + else _from="$DISABLED_MARK"; _to="$TARGET/SKILL.md"; fi + + echo "=== memory-sync-setup: $_want ===" + echo "TARGET=$ROOT" + if [ ! -d "$TARGET" ]; then + echo "❌ FAILED: not installed - $ROOT/$TARGET does not exist. Run 'generate.sh emit' first." + exit 1 + fi + if [ -f "$_to" ] && [ ! -f "$_from" ]; then + echo "✅ already ${_want}d - $_to is in place, nothing to move" + exit 0 + fi + if [ ! -f "$_from" ]; then + echo "❌ FAILED: broken installation - neither $TARGET/SKILL.md nor $DISABLED_MARK exists" + exit 1 + fi + mv "$_from" "$_to" || { echo "❌ FAILED: could not rename $_from"; exit 1; } + echo "MOVED: $_from -> $_to" + for r in $EMITTED_REFS; do [ -f "$TARGET_REFS/$r" ] && echo "KEPT: $TARGET_REFS/$r"; done + echo "✅ $_want (takes effect in the NEXT session - skills are discovered at session start)" +} + +# ── uninstall / purge ─────────────────────────────────────────────────────────── +# `emit` writes exactly SKILL.md + $EMITTED_REFS, so that manifest is also the removal manifest: +# uninstall deletes precisely what this generator wrote, and NOTHING it did not. Anything else +# sitting in the dir came from the user, so it survives and is reported. +# purge deletes the whole directory plus any crashed-emit staging left under .claude/skills/. +# The generator has no hooks, no settings entries and no config file anywhere else in the target, +# so these two paths are its entire footprint. +remove_skill() { + _purge="$1" # 0 = uninstall, 1 = purge + _label=$([ "$_purge" = "1" ] && echo purge || echo uninstall) + echo "=== memory-sync-setup: $_label ===" + echo "TARGET=$ROOT" + + if [ ! -d "$TARGET" ]; then + echo "⚠️ nothing to $_label - memory-sync is not installed at $ROOT/$TARGET" + exit 0 + fi + + if [ "$_purge" = "1" ]; then + rm -rf "$TARGET" + echo "REMOVED: $TARGET/ (whole directory, user-added files included)" + _stale=$({ find "$(dirname "$TARGET")" -maxdepth 1 -type d -name '.memory-sync-emit.*' 2>/dev/null || true; } | sort) + if [ -n "$_stale" ]; then + printf '%s\n' "$_stale" | while IFS= read -r _d; do + [ -n "$_d" ] || continue + rm -rf "$_d"; echo "REMOVED: $_d/ (staging left by a crashed emit)" + done + else + echo "SKIP: no .memory-sync-emit.* staging leftovers" + fi + echo "✅ purge" + return 0 + fi + + # uninstall: the emit manifest, and only the emit manifest. + for f in "$TARGET/SKILL.md" "$DISABLED_MARK"; do + [ -f "$f" ] && { rm -f "$f"; echo "REMOVED: $f"; } + done + for r in $EMITTED_REFS; do + [ -f "$TARGET_REFS/$r" ] && { rm -f "$TARGET_REFS/$r"; echo "REMOVED: $TARGET_REFS/$r"; } + done + rmdir "$TARGET_REFS" 2>/dev/null || true + rmdir "$TARGET" 2>/dev/null || true + + if [ -d "$TARGET" ]; then + echo "KEPT: $TARGET/ still holds files this generator never wrote -" + find "$TARGET" -type f | sort | sed 's/^/ /' + echo " 'purge' removes them too." + else + echo "REMOVED: $TARGET/ (empty after the manifest was removed)" + fi + echo "✅ uninstall" +} + case "$MODE" in - scan) resolve_root; scan_target ;; - emit) resolve_root; emit_skill ;; - validate) resolve_root; validate_emit ;; - status) resolve_root; status_report ;; + scan) resolve_root; scan_target ;; + emit) resolve_root; emit_skill ;; + validate) resolve_root; validate_emit ;; + restamp) resolve_root; restamp_skill ;; + status) resolve_root; status_report ;; + enable) resolve_root; toggle_skill enable ;; + disable) resolve_root; toggle_skill disable ;; + uninstall) resolve_root; remove_skill 0 ;; + purge) resolve_root; remove_skill 1 ;; *) - echo "Usage: generate.sh (default: emit)" + echo "Usage: generate.sh (default: emit)" echo " scan read-only surface report + derived DEFAULT_BRANCH= / GIT_VISIBILITY= / MEMORY_DIR= /" echo " TRACKER_NOTE= / SURFACE_COUNTS= / PROJECT_NAME= for pass-back to emit" echo " emit atomically write $TARGET (refuses to overwrite; MEMORY_SYNC_FORCE=1 overrides)" - echo " validate fail on unresolved {PLACEHOLDER}, missing file, broken reference, missing stamp" - echo " status machine-greppable KEY=value drift report, always exit 0" + echo " validate fail on unresolved {PLACEHOLDER}, missing file, broken reference, missing/stale provenance frontmatter" + echo " restamp refresh ONLY the provenance keys of an installed SKILL.md (version/last_updated/" + echo " surface_files, + doc_type/generated_by when absent) and drop a pre-5.0 tail stamp." + echo " Body and every hand-edit are verified byte-identical. Mandatory last step of \`upgrade\`" + echo " status machine-greppable KEY=value drift report, always exit 0. INSTALLED=yes|parked|no -" + echo " a parked install (SKILL.md.disabled) is reported PARKED, never NOT INSTALLED" + echo " enable rename $TARGET/SKILL.md.disabled back to SKILL.md" + echo " disable rename $TARGET/SKILL.md to SKILL.md.disabled - /memory-sync stops being discovered;" + echo " the references and every SELF-SYNC hand-edit stay on disk, reversible" + echo " uninstall delete exactly what emit wrote (SKILL.md + $EMITTED_N references); user-added files in" + echo " that dir are KEPT and listed" + echo " purge delete $TARGET/ outright + any .memory-sync-emit.* staging leftovers" exit 1 ;; esac diff --git a/brewdoc/skills/my-claude/SKILL.md b/brewdoc/skills/my-claude/SKILL.md index 871ec56..47cb5f4 100644 --- a/brewdoc/skills/my-claude/SKILL.md +++ b/brewdoc/skills/my-claude/SKILL.md @@ -56,6 +56,29 @@ Create if not exists: `mkdir -p .claude/brewdoc/my-claude` This is the only supported target — there is no `~/.claude` or plugin-data fallback. +### Provenance frontmatter (every generated doc, all three modes) + +Every `.md` this skill writes opens with this block, before the `#` heading. `doc_type` is BARE; the other three are QUOTED. Resolve the values — never hardcode them. + +**EXECUTE** using Bash tool before writing the document: +```bash +PJ="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json" +PV=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version||'')" "$PJ" 2>/dev/null || true) +[ -n "$PV" ] || { echo "❌ cannot read version from $PJ — reinstall brewdoc"; exit 1; } +echo "PLUGIN_VERSION=$PV"; echo "LAST_UPDATED=$(date +%F)" +``` + +```yaml +--- +doc_type: user +version: "{PLUGIN_VERSION}" +generated_by: "brewdoc:my-claude" +last_updated: "{LAST_UPDATED}" +--- +``` + +Re-generating an existing doc REFRESHES all three quoted values and leaves a hand-edited `doc_type` (`llm` / `skip`) as the user set it. + ## INDEX Tracking Append entry to `.claude/brewdoc/INDEX.jsonl`: diff --git a/brewtools/.claude-plugin/plugin.json b/brewtools/.claude-plugin/plugin.json index 3101f08..1a92d15 100644 --- a/brewtools/.claude-plugin/plugin.json +++ b/brewtools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brewtools", - "version": "5.0.0", + "version": "5.1.0", "description": "Brewtools - universal utilities for Claude Code: text optimization, humanization, secrets scanning", "author": { "name": "Maksim Kochetkov", diff --git a/brewtools/.codex/skills/manager-setup/SKILL.md b/brewtools/.codex/skills/manager-setup/SKILL.md index b5530d7..4266f56 100644 --- a/brewtools/.codex/skills/manager-setup/SKILL.md +++ b/brewtools/.codex/skills/manager-setup/SKILL.md @@ -9,7 +9,21 @@ This skill configures ambient prompt guidance only. It does not create, claim, o ## Intent and scope -Resolve `status`, `on`, `off`, `level`, `edit`, or `reset`, then choose project state at `.codex/brewtools/manager/state.json` or personal prompt overrides under `~/.codex/manager/`. Obtain confirmation before global writes. +Resolve exactly one canonical mode -- `status`, `install`, `upgrade`, `enable`, `disable`, `uninstall`, `purge` -- plus the extras `level` and `edit`, then choose project state at `.codex/brewtools/manager/state.json` or personal prompt overrides under `~/.codex/manager/`. Obtain confirmation before global writes. With no mode given, resolve `status` when state already exists and `install` otherwise. `on`, `off`, `setup`, `remove`, `reset`, `create`, `update` and `cleanup` are not modes: read them as the canonical verb, echo the canonical name back, and never print a retired alias as a command. + +## Modes + +| Mode | Effect | +|------|--------| +| `status` | Show hook registration, state source, level, override paths, and the no-security-wall limitation. Writes nothing, asks nothing. | +| `install` | Register the `SessionStart` and `UserPromptSubmit` handlers for this project and arm ambient prompt state. Idempotent: a second run leaves exactly one entry per event. | +| `upgrade` | Re-register the handlers from the current plugin version and restamp the version recorded in state, keeping the armed flag, the level and every override verbatim. It asks nothing, and it is the only thing that clears a stale version report. | +| `enable` | Arm ambient prompt state only. With nothing registered there is no handler to arm, so report not-installed and route the user to `install`. | +| `disable` | Disarm ambient prompt state only. Never touches registration: the handlers stay registered and no-op while disarmed. | +| `uninstall` | Deregister the handlers. State and prompt overrides are KEPT, so a later `install` returns to the same level and the same customized text. | +| `purge` | `uninstall` plus deletion of `.codex/brewtools/manager/` and, in personal scope, the personal prompt override. The only destructive mode: state exactly what will be deleted before running it. | +| `level` | Set balanced or strict prompt wording. State only; it does not change sandbox or authorization. | +| `edit` | Update or remove prompt overrides after showing the diff. Changes injected text only, never registration or arm state. | ## Behavior @@ -17,9 +31,7 @@ Resolve `status`, `on`, `off`, `level`, `edit`, or `reset`, then choose project - `++a`: architecture-first guidance. - `++rr`: anti-regression review guidance. - `++r`: two-pass review guidance. -- `on` / `off`: enable or disable ambient prompt state only. -- `level`: set balanced or strict prompt wording; it does not change sandbox or authorization. -- `edit` / `reset`: update or remove prompt overrides after showing the diff. -- `status`: show hook registration, state source, level, override paths, and the no-security-wall limitation. + +The codewords are hook-driven: they fire on every prompt regardless of the mode state above. `status` explains them and `edit` customizes their text; no mode turns them off. The plugin uses `SessionStart` and `UserPromptSubmit` hooks. Preserve unrelated hook entries and review changed definitions with `/hooks`. diff --git a/brewtools/.codex/skills/task-board-setup/SKILL.md b/brewtools/.codex/skills/task-board-setup/SKILL.md index 941b46d..2c14785 100644 --- a/brewtools/.codex/skills/task-board-setup/SKILL.md +++ b/brewtools/.codex/skills/task-board-setup/SKILL.md @@ -7,6 +7,22 @@ description: "Creates a Codex file-based task board. Explicit user invocation on Create exactly one Codex-owned file board; never create or mirror it under another assistant namespace. +## Modes + +Resolve exactly one canonical mode from `status`, `install`, `upgrade`, `enable`, `disable`, `uninstall`, `purge` -- a standalone token only, never a word that merely appears inside a sentence. With no mode given, a deployed board (`.codex/features/board.md` exists) resolves to `status` and an empty target resolves to `install`. `init`, `on`, `off`, `setup`, `remove`, `reset`, `create`, `update` and `cleanup` are not modes: read them as the canonical verb, echo the canonical name back, and never print a retired alias as a command. + +| Mode | Effect | +|------|--------| +| `status` | Read-only inventory of the target board. Writes nothing, delegates nothing, asks nothing. A parked `.disabled` twin is reported as parked, never as missing. | +| `install` | Run the phases below and deploy the board into the resolved target. | +| `upgrade` | Retrofit onto an already deployed board instead of the fresh-init phases. Recover the existing findings from the deployed artifacts rather than re-deriving them, ask for anything unrecoverable, write new files outright, and gate every edit of an existing file behind its own diff and confirmation. Never renumber and never delete. The metadata restamp is ungated and always runs -- it is the only thing that clears a stale version report. | +| `enable` | Restore parked machinery by renaming each `.disabled` twin back to the filename discovery keys on. Writes no content. | +| `disable` | Park the machinery by renaming the task-tracker agent, the `task-board` and `task-spec` skills and the task rule to `.disabled`. Bodies are untouched and every task is kept. | +| `uninstall` | Remove the generated agent, skills and rule plus any `.disabled` twin of them. `.codex/features/**` is KEPT: the generated pieces are machinery, the board is the user's data. | +| `purge` | `uninstall` plus deletion of `.codex/features/**`. Confirm first, stating the task counts that will be destroyed, and offer `uninstall` as the alternative that keeps them. | + +`status`, `enable`, `disable`, `uninstall` and `purge` replace the phases below; run the `status` inventory afterwards as the proof. Optimization of `AGENTS.md` is never reverted by any mode -- say so in the report and point at version history. + ## P0: resolve target and directive 1. Resolve the target repository, language, release marker style, exclusions, and whether optional AGENTS.md optimization is requested. diff --git a/brewtools/.codex/skills/task-board-setup/references/01-analysis.md b/brewtools/.codex/skills/task-board-setup/references/01-analysis.md index bf5b3ea..4b345cb 100644 --- a/brewtools/.codex/skills/task-board-setup/references/01-analysis.md +++ b/brewtools/.codex/skills/task-board-setup/references/01-analysis.md @@ -102,7 +102,7 @@ DOMAIN_AGENTS: |-------|-----------------|-----------| | | , | | agent = the frontmatter `name` (plugin agents as `plugin:name`). domains covered = the repo areas the agent is competent in, as SHORT UPPER-KEBAB segments, comma-separated. specialty = one line from its description/body. The value is pasted verbatim into the emitted skill, so an incomplete table !=renders. -- If TARGET has no .codex/agents/ (or it holds no agent .md), return this exact line instead of a table: +- If TARGET has no .codex/agents/ (or it holds no agent .toml), return this exact line instead of a table: (none found -- fall back to the built-in Plan agent and say so in Evidence) ARCHITECT_AGENT: diff --git a/brewtools/.codex/skills/task-board-setup/references/04-tasks-rule.md b/brewtools/.codex/skills/task-board-setup/references/04-tasks-rule.md index 989bff4..2101608 100644 --- a/brewtools/.codex/skills/task-board-setup/references/04-tasks-rule.md +++ b/brewtools/.codex/skills/task-board-setup/references/04-tasks-rule.md @@ -37,6 +37,10 @@ Authoritative rules: `TRACKER.md` section 10. These rows mirror it in one line e --- paths: - ".codex/features/**" +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" --- [DICT: GROOM=backlog triage, FM=frontmatter, TT=task-tracker agent] diff --git a/brewtools/.codex/skills/task-board-setup/references/05-features-templates.md b/brewtools/.codex/skills/task-board-setup/references/05-features-templates.md index ca06d65..3d5c9a2 100644 --- a/brewtools/.codex/skills/task-board-setup/references/05-features-templates.md +++ b/brewtools/.codex/skills/task-board-setup/references/05-features-templates.md @@ -1,12 +1,14 @@ # 05 -- Step 4b: `.codex/features/**` file templates -Write each block below to its path under `TARGET/.codex/features/`. Substitute `{{REPO_NAME}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{CLOSE_MARKER_SHORT}}` (ref 03 map), `{{TODAY}}` (ISO date), plus the `{{SPEC_*}}` placeholders defined below (all gated by `SPEC_MODE`). +Write each block below to its path under `TARGET/.codex/features/`. Substitute `{{REPO_NAME}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{CLOSE_MARKER_SHORT}}` (ref 03 map), `{{TODAY}}` (ISO date), the metadata trio `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}` (SKILL.md Placeholder map), plus the `{{SPEC_*}}` placeholders defined below (all gated by `SPEC_MODE`). + +> **Metadata stamp.** `board.md`, `PROGRESS.md`, `TRACKER.md`, `INDEX.md` and `backlog/README.md` each open with the four-key frontmatter block (`doc_type, version, generated_by, last_updated`). It is UNGATED -- identical in both `SPEC_MODE` states -- and records WHO GENERATED the file and WHEN, so a later plugin version can detect an old-shape scaffold. It is provenance, not live state: nothing rewrites it after generation except `upgrade` (ref 10). `TASK_TEMPLATE.md` gets NO stamp -- its frontmatter is copied into every task card, where those keys would become card data. The `board.md` here is the EMPTY skeleton (counts 0). The Step-4c doc sweep fills it from the migrated docs. ## Spec-mode placeholders (gate: `SPEC_MODE=on`) -Every placeholder below shares ONE gate: `SPEC_MODE`. Exactly TWO kinds -- `line` and `inline`. When `SPEC_MODE=off`, the emitted control files MUST be byte-identical to the pre-spec-layer originals PLUS this file's UNGATED session-progress sites (`PROGRESS.md` itself, `TRACKER.md` section 2's layout line, `TRACKER.md` section 8 step 4, the `INDEX.md` Control-files row) -- baseline in BOTH modes, never removed: +Every placeholder below shares ONE gate: `SPEC_MODE`. Exactly TWO kinds -- `line` and `inline`. When `SPEC_MODE=off`, the emitted control files MUST be byte-identical to the pre-spec-layer originals PLUS this file's UNGATED session-progress sites (`PROGRESS.md` itself, `TRACKER.md` section 2's layout line, `TRACKER.md` section 8 step 4, the `INDEX.md` Control-files row) AND the four-key metadata frontmatter on the five control files -- baseline in BOTH modes, never removed: - **Line placeholders** (`{{SPEC_FEATURE_TABLE_HEAD_ON}}`, `{{SPEC_FEATURE_TABLE_HEAD_OFF}}`, `{{SPEC_FM_LINE}}`, `{{SPEC_SCOPE_BLOCK}}`, `{{SPEC_BOARD_COL_NOTE}}`, `{{SPEC_TRACKER_SECTION}}`, `{{SPEC_INDEX_ROWS}}`) occupy a line of their own. When off, REMOVE the entire line -- !=leave it blank. - `{{SPEC_FEATURE_TABLE_HEAD_ON}}` / `{{SPEC_FEATURE_TABLE_HEAD_OFF}}` are the two ARMS of that same gate, both `line` kind: on -> expand `_ON`, remove the `_OFF` line; off -> expand `_OFF`, remove the `_ON` line. Exactly one arm survives every run. !=a third kind. @@ -77,6 +79,13 @@ CORRECTION to the column list above: Progress + Todo have SIX columns, `id | tit ## `board.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # {{REPO_NAME}} sub-agent task Board > Canonical task list + status. Procedure: [`TRACKER.md`](TRACKER.md). New-task template: @@ -124,12 +133,21 @@ The `specs` count on the **Counts** line keeps its meaning in both modes: number Ungated -- written in BOTH `SPEC_MODE` states, at init, before any task exists. ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Session progress -- {{REPO_NAME}} > [`board.md`](board.md) owns the task LIST + status. THIS file owns what the SESSION did about it. > !=a second board: no task table, no per-task detail (that is the task's `## Notes`). > Five fields, overwritten in place -- one snapshot, never an append-only log. {{LANG}} only. > Kept current by the main session; rewritten by the `task-tracker` agent on every run. +> The `Updated` field below is the SESSION snapshot date; frontmatter `last_updated` is generator +> provenance and is NOT touched on a rewrite. - **Updated:** {{TODAY}} - **In flight:** -- (task ids being worked right now) @@ -143,6 +161,13 @@ Ungated -- written in BOTH `SPEC_MODE` states, at init, before any task exists. ## `TRACKER.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # TRACKER -- {{REPO_NAME}} task/feature tracker procedure > Canonical procedure for the `.codex/features/` task board. The board (`board.md`) @@ -400,6 +425,13 @@ Running log: decisions, blockers, PR/commit/report links. ## `INDEX.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Features -- control-file index > `board.md` is the **canonical** task list + status. This index just maps the control @@ -432,5 +464,12 @@ Running log: decisions, blockers, PR/commit/report links. ## `backlog/README.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + Ungroomed inbox. Drop raw ideas as *.md; task-tracker grooms into todo/ or trashes. See ../TRACKER.md. ``` diff --git a/brewtools/.codex/skills/task-board-setup/references/08-task-spec-skill.md b/brewtools/.codex/skills/task-board-setup/references/08-task-spec-skill.md index 0f960ce..2d69ac7 100644 --- a/brewtools/.codex/skills/task-board-setup/references/08-task-spec-skill.md +++ b/brewtools/.codex/skills/task-board-setup/references/08-task-spec-skill.md @@ -17,6 +17,10 @@ The `description:` triggers stay bilingual EN+RU regardless of `{{LANG}}` (model name: task-spec description: "Authors the product spec and the system-design doc for a task on this repo's board, fanning out to the repo's own domain architect agents -- never designed solo. Writes .codex/features/specs/-spec.md and -design.md, then syncs the task frontmatter and board.md. Triggers: system design, architect this, design doc, design document, write the spec, spec out, spec this task, plan this task, architecture for, technical design, design review, needs a spec, системный дизайн, спека, спеку, напиши спеку, архитектура задачи, спланируй задачу, продумай архитектуру, распиши решение, дизайн-документ, с помощью архитектора, привлеки архитекторов. user: продумай архитектуру для T-{{FIRST_DOMAIN}}-SLUG Plain prose, no skill named, but this is a design request for a board task -- run task-spec in design mode and fan out to the domain architects. user: this one touches the API and the storage layer, write the spec before anyone codes Multi-domain + explicit spec ask = the needs-spec heuristic; run task-spec full mode, one architect per touched domain. user: scope changed on BUG-{{FIRST_DOMAIN}}-SLUG, the spec is stale now Existing docs plus a changed task Scope -> task-spec refresh, preserving D#, Q# and AQ# ids and the Scope status cells. " argument-hint: " [full | design | refresh] [-n|--noask]" +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" --- # task-spec (spec + system design) diff --git a/brewtools/.codex/skills/task-board-setup/references/10-upgrade.md b/brewtools/.codex/skills/task-board-setup/references/10-upgrade.md index b541161..5c53c17 100644 --- a/brewtools/.codex/skills/task-board-setup/references/10-upgrade.md +++ b/brewtools/.codex/skills/task-board-setup/references/10-upgrade.md @@ -1,6 +1,6 @@ # 10 -- upgrade mode: retrofit the spec layer onto a deployed board -Placeholders used: `{{DOMAIN_AGENTS}}`, `{{ARCHITECT_AGENT}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{EXCLUSIONS}}`, `{{TODAY}}`, `{{REPO_NAME}}`, `{{CLOSE_MARKER}}`, `{{CLOSE_MARKER_SHORT}}`. +Placeholders used: `{{DOMAIN_AGENTS}}`, `{{ARCHITECT_AGENT}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{EXCLUSIONS}}`, `{{TODAY}}`, `{{REPO_NAME}}`, `{{CLOSE_MARKER}}`, `{{CLOSE_MARKER_SHORT}}`, `{PLUGIN_VERSION}`, `{GENERATED_BY}`, `{LAST_UPDATED}`. `SPEC_MODE` and `CMD_DECOMPOSED` are GATE variables, never tokens in an emitted body. In upgrade mode `SPEC_MODE` is FORCED `on` and `CMD_DECOMPOSED` is FORCED `false` (upgrade never runs P5.5) -- see U2. [DICT: TT=task-tracker agent (installed), TB=task-board skill (installed), BRD=board.md, FEAT=.codex/features, FM=frontmatter, ADD=write a file that does not exist, PATCH=insert a block into an existing file, MARK=idempotency marker text] @@ -106,9 +106,11 @@ DETECT table: | task files missing `spec:` FM | `backfill-needed` | BACKFILL (gated) \| SKIP if 0 | | task files with no frontmatter | `skipped-no-frontmatter` | SKIP always, named in the report | -A file whose MARK is SPLIT (`s` spec layer, `p` session-progress layer) reports ONE probe line per row; the two are independent install units and a file can be SKIP for one and PATCH for the other. If every row is SKIP and `backfill-needed=0` -> report `upgrade: no-op, spec layer already installed` and STOP. That is the rerun path. +A file whose MARK is SPLIT (`s` spec layer, `p` session-progress layer) reports ONE probe line per row; the two are independent install units and a file can be SKIP for one and PATCH for the other. If every row is SKIP and `backfill-needed=0` -> the CONTENT layer is already installed: skip U2's request_user_input, skip U3-U5, **still run U5b**, then report `upgrade: content already installed, metadata restamped to ` and stop. -> `ADD (drift)` = a PATCH target is missing entirely. Do not fail: emit the full file from its reference template and NOTE the drift in the report -- the deployment is incomplete, the user should know. A drift-ADD MUST resolve EVERY token that reference's header declares -- its `Substitute ...` line AND every gated placeholder declared elsewhere in that header (see U2), including `{{CLOSE_MARKER}}` / `{{CLOSE_MARKER_SHORT}}` and, for `task-tracker.md`, the two `CMD_DECOMPOSED` line placeholders. +> **U5b is NOT part of that no-op.** This is the single commonest upgrade: the user ran `codex plugin update`, every content row is already SKIP, and the ONLY thing out of date is the version stamp — which is exactly what `setup-status` reads and exactly what it told the user to fix by running `upgrade`. An early STOP here reinstates the bug this file was changed to remove: `status` says `stale`, `upgrade` says `no-op`, forever. U5b needs only `{PLUGIN_VERSION}`/`{GENERATED_BY}`/`{LAST_UPDATED}`, which are re-resolved fresh and never recovered, so it runs with nothing from U2's recovery table. + +> `ADD (drift)` = a PATCH target is missing entirely. Do not fail: emit the full file from its reference template and NOTE the drift in the report -- the deployment is incomplete, the user should know. A drift-ADD MUST resolve EVERY token that reference's header declares -- its `Substitute ...` line AND every gated placeholder declared elsewhere in that header (see U2), including `{{CLOSE_MARKER}}` / `{{CLOSE_MARKER_SHORT}}` and, for `task-tracker.toml`, the two `CMD_DECOMPOSED` line placeholders. --- @@ -123,14 +125,15 @@ Values already baked into the installed artifacts are RECOVERED by reading, !=re | `{{LANG}}` | `TARGET/.codex/rules/tasks.md` rule 10 | ` only.` Cross-check TT invariant `{{LANG}}-only headings + FM` | | `{{EXCLUSIONS}}` | `TARGET/.codex/agents/task-tracker.toml` Scope line | `EXCLUSIONS (never read-to-modify, never write): ...`. Cross-check the finishing-checklist line `app code untouched (...)` | | `{{CLOSE_MARKER_SHORT}}` | installed siblings | cite by TEXT, !=line number. `tasks.md` rule-table row 10 `Closing: record in ## Notes` (ref 04) -> `TRACKER.md` lifecycle `progress -> closed` row, Invariants `Closing a task: keep updated current and record `, grooming step 5 `On done: ship, ... record ` (ref 05, 3 sites) -> `task-board/SKILL.md` invariants line ` only. Closing records in ## Notes.` (ref 03). First hit wins | -| `{{CLOSE_MARKER}}` | installed sibling | `task-tracker.md` Invariants row 5 `Closing records the closing marker in ## Notes + bumps updated: ` (ref 02). Cross-check its closing step `Append outcome + the closing marker to ## Notes` | -| `CMD_DECOMPOSED` | GATE, !=token | upgrade never runs P5.5 -> FORCED `false`. In a `task-tracker.md` drift-ADD, ref 02 carries `{{CMD_DECOMPOSED_NOTE}}` and `{{CMD_DECOMPOSED_INVARIANT}}` (declared in 02's own `Plus, IF P5.5 ran ...` header paragraph, !=in its `Substitute` line). False -> DELETE both placeholder LINES whole, !=leave blank, !=emit the token. The Invariants table then ends at row 7 | -| `{{DOMAIN_AGENTS}}` | NEW -- discover | spawn Agent C per `references/01-analysis.md` over `TARGET/.codex/agents/**`, excluding `task-tracker.md`. Returns a COMPLETE table incl. header + `\|---\|` separator. Empty -> the literal line `(none found -- fall back to the built-in Plan agent and say so in Evidence)` | +| `{{CLOSE_MARKER}}` | installed sibling | `task-tracker.toml` Invariants row 5 `Closing records the closing marker in ## Notes + bumps updated: ` (ref 02). Cross-check its closing step `Append outcome + the closing marker to ## Notes` | +| `CMD_DECOMPOSED` | GATE, !=token | upgrade never runs P5.5 -> FORCED `false`. In a `task-tracker.toml` drift-ADD, ref 02 carries `{{CMD_DECOMPOSED_NOTE}}` and `{{CMD_DECOMPOSED_INVARIANT}}` (declared in 02's own `Plus, IF P5.5 ran ...` header paragraph, !=in its `Substitute` line). False -> DELETE both placeholder LINES whole, !=leave blank, !=emit the token. The Invariants table then ends at row 7 | +| `{{DOMAIN_AGENTS}}` | NEW -- discover | spawn Agent C per `references/01-analysis.md` over `TARGET/.codex/agents/**`, excluding `task-tracker.toml`. Returns a COMPLETE table incl. header + `\|---\|` separator. Empty -> the literal line `(none found -- fall back to the built-in Plan agent and say so in Evidence)` | | `{{ARCHITECT_AGENT}}` | NEW -- from Agent C | best architecture-capable project agent name, else `Plan` | | `AGENT_GAPS` | NEW -- from Agent C | REPORT-ONLY, !=a token in any emitted body. Every `{{DOMAINS}}` entry with no owning agent in the `{{DOMAIN_AGENTS}}` table -- those domains fall back to the built-in `Plan` in `/task-spec`'s design fan-out. Empty -> `none`. Carry it to the U6 report, never drop it | | `{{REPO_NAME}}` / `{{TODAY}}` | trivial | basename of TARGET / ISO date | +| `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}` | NEVER recovered | re-resolve fresh per the SKILL.md "Resolving `{PLUGIN_VERSION}`..." bash block. An upgrade is a NEW write by a NEW plugin version -- an old stamp recovered off the installed file would be a lie | -Recovery conflicts (e.g. `tasks.md` and `task-tracker.md` disagree on DOMAINS) -> surface both, let the user pick via request_user_input. A value that cannot be recovered at all -> ask, !=guess. +Recovery conflicts (e.g. `tasks.md` and `task-tracker.toml` disagree on DOMAINS) -> surface both, let the user pick via request_user_input. A value that cannot be recovered at all -> ask, !=guess. > `RELEASE_STYLE` is NOT re-derived from git. `{{CLOSE_MARKER}}` / `{{CLOSE_MARKER_SHORT}}` are recovered verbatim from an installed sibling per the rows above; if no sibling carries one and a drift-ADD needs it -> ASK. Never emit an unresolved `{{TOKEN}}` into a live repo. @@ -159,7 +162,7 @@ mkdir -p "$TARGET/.codex/features/specs" && echo "OK specs dir" || echo "FAIL sp | Emit | From | Substitute | |------|------|------------| | `TARGET/.codex/skills/task-spec/SKILL.md` | `references/08-task-spec-skill.md` | exactly the tokens in `08`'s own header `Substitute ...` line -- read it, !=re-enumerate here | -| `TARGET/.codex/features/PROGRESS.md` | `references/05-features-templates.md`, `## PROGRESS.md` block | `{{REPO_NAME}}`, `{{LANG}}`, `{{TODAY}}`. Written EMPTY (all five fields `--`); the board's live state is never back-filled into it -- `task-tracker` rewrites it on its next run | +| `TARGET/.codex/features/PROGRESS.md` | `references/05-features-templates.md`, `## PROGRESS.md` block | `{{REPO_NAME}}`, `{{LANG}}`, `{{TODAY}}`, plus the metadata trio. Written EMPTY (all five fields `--`); the board's live state is never back-filled into it -- `task-tracker` rewrites it on its next run | | `TARGET/.codex/features/specs/SPEC_TEMPLATE.md` | `references/09-spec-templates.md` | exactly the tokens in `09`'s own header `Substitute ...` line | | `TARGET/.codex/features/specs/DESIGN_TEMPLATE.md` | `references/09-spec-templates.md` | same header line as above | @@ -276,9 +279,103 @@ Option 1 leaves the field absent on every existing task; that is a legal state f --- +## U5b. RESTAMP the metadata trio (ALWAYS runs, never gated, never asked) + +**This is the step that lets `upgrade` clear its own staleness.** `setup-status` row 4 reads the +frontmatter `version:` of the anchor `.codex/features/board.md`. `board.md` is in the U4 PATCH +set, not the U3 ADD set — so before this step existed, an upgrade edited the anchor's TABLE and +left its STAMP on whatever version installed it. `status` printed `stale`, prescribed `upgrade`, +`upgrade` reported success, and the next `status` printed `stale` again, forever. An ADDed file +was born with a fresh stamp; a PATCHed or SKIPped one never got one. **A PATCHED file must end up +stamped exactly like an ADDED one** — which is what this block enforces, by restamping all nine +unconditionally. + +Nine stamped artifacts — the same nine `setup-status` row 4 names. `TASK_TEMPLATE.md` is +deliberately UNSTAMPED (its frontmatter is copied into every task card), and task CARDS under +`backlog/todo/progress/closed` never carry these keys at all. Neither is touched here. + +| # | Artifact | Emitted by ref | +|---|----------|----------------| +| 1 | `.codex/features/board.md` (the ANCHOR) | 05 | +| 2 | `.codex/features/TRACKER.md` | 05 | +| 3 | `.codex/features/INDEX.md` | 05 | +| 4 | `.codex/features/PROGRESS.md` | 05 | +| 5 | `.codex/features/backlog/README.md` | 05 | +| 6 | `.codex/agents/task-tracker.toml` | 02 | +| 7 | `.codex/rules/tasks.md` | 04 | +| 8 | `.codex/skills/task-board/SKILL.md` | 03 | +| 9 | `.codex/skills/task-spec/SKILL.md` | 08 | + +Ordering: run AFTER U3/U4/U5, before U6. A file this run ADDed is already correct and the restamp +is a no-op on it — that is the point, one code path for both. + +**Scope — the trio and nothing else.** Only `version`, `generated_by` and `last_updated`, only +inside the file's OWN first frontmatter block, only when line 1 is `---`. `doc_type` is left +exactly as found (a user who set `user`/`skip` keeps it: these are mechanism-`b` artifacts, not +byte-copies, so nothing restores it). Body, tables, hand-edits, task content: untouched. A legacy +install whose frontmatter predates the trio gets the three keys INSERTED immediately before the +closing `---`, which is how `stale (legacy, unstamped)` clears. + +First re-resolve the three values — SKILL.md "Resolving `{PLUGIN_VERSION}` / `{GENERATED_BY}` / +`{LAST_UPDATED}`", run verbatim. Fresh values only; an old stamp read off the installed file +would be a lie (U2). Then, **EXECUTE** using shell: + +```bash +TARGET="" +test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unset or not a dir -- restamp did NOT run"; exit 1; } +PV=""; GB="brewtools:task-board-setup"; LU="" +case "$PV" in [0-9]*.[0-9]*.[0-9]*) ;; *) echo "MISS PLUGIN_VERSION='$PV' is not X.Y.Z -- restamp did NOT run"; exit 1 ;; esac +case "$LU" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; *) echo "MISS LAST_UPDATED='$LU' is not YYYY-MM-DD -- restamp did NOT run"; exit 1 ;; esac +ok=0; miss=0; skip=0 +for rel in .codex/features/board.md .codex/features/TRACKER.md .codex/features/INDEX.md \ + .codex/features/PROGRESS.md .codex/features/backlog/README.md \ + .codex/agents/task-tracker.toml .codex/rules/tasks.md \ + .codex/skills/task-board/SKILL.md .codex/skills/task-spec/SKILL.md; do + f="$TARGET/$rel" + test -f "$f" || { echo "ABSENT $rel"; skip=$((skip+1)); continue; } + test "$(head -n 1 "$f")" = "---" || { echo "SKIP $rel (no frontmatter -- never synthesized)"; skip=$((skip+1)); continue; } + awk -v pv="$PV" -v gb="$GB" -v lu="$LU" ' + NR == 1 { print; next } + !done && /^---[ \t]*$/ { + if (!sv) print "version: \"" pv "\"" + if (!sg) print "generated_by: \"" gb "\"" + if (!sl) print "last_updated: \"" lu "\"" + done = 1; print; next + } + !done && /^version:/ { print "version: \"" pv "\""; sv = 1; next } + !done && /^generated_by:/ { print "generated_by: \"" gb "\""; sg = 1; next } + !done && /^last_updated:/ { print "last_updated: \"" lu "\""; sl = 1; next } + { print } + ' "$f" > "$f.restamp.tmp" && mv "$f.restamp.tmp" "$f" \ + || { rm -f "$f.restamp.tmp"; echo "MISS $rel rewrite failed"; miss=$((miss+1)); continue; } + if grep -qxF "version: \"$PV\"" "$f" && grep -qxF "generated_by: \"$GB\"" "$f" \ + && grep -qxF "last_updated: \"$LU\"" "$f"; then + echo "STAMP $rel"; ok=$((ok+1)) + else + echo "MISS $rel stamp not applied (no closing --- ?)"; miss=$((miss+1)) + fi +done +echo "restamped=$ok absent-or-skipped=$skip miss=$miss" +test "$miss" -eq 0 && echo "✅ restamp clean" || echo "❌ restamp FAILED" +``` + +> **STOP if ❌** — a MISS means an artifact still reports the old version, so the next +> `$brewcode:setup-status` prints `stale` again and the user is back in the loop this step exists +> to break. Fix the named file and re-run the block; it is idempotent. + +`ABSENT` is normal, not a MISS: `task-spec/SKILL.md` never existed on a `SPEC_MODE=off` board the +user declined to upgrade, and `PROGRESS.md` is absent on a board that predates it and whose `1p` +row was declined. A second `upgrade` re-runs this block and writes the identical bytes. + +Not restamped, on purpose: `TASK_TEMPLATE.md` (unstamped by design), every task card, and a +parked `.disabled` file — `upgrade` refuses to run on a DISABLED board at all (SKILL.md +guard), so `enable` first, then `upgrade`. + +--- + ## U6. Verify + report -**Leftover-placeholder gate.** This block is self-contained and owned by THIS file (`PU` does not run P5). It scans the SAME path set the fresh path's P5 gate scans -- NOT just the ADD set. A drift-ADD writes whole files under `.codex/agents/`, `.codex/rules/` and `.codex/features/`, so an ADD-set-only scan would miss exactly the paths most likely to carry an unresolved token. +**Leftover-placeholder gate.** This block is self-contained and owned by THIS file (`PU` does not run P5). It scans the SAME path set the fresh path's P5 gate scans -- NOT just the ADD set. A drift-ADD writes whole files under `.codex/agents/`, `.codex/rules/` and `.codex/features/`, so an ADD-set-only scan would miss exactly the paths most likely to carry an unresolved token. It catches BOTH brace families: this skill's own DOUBLE-brace tokens and the SINGLE-brace metadata tokens `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}`. Same self-contained rule as the header note: re-establish `TARGET` literally, assert it, then run. @@ -287,7 +384,8 @@ Same self-contained rule as the header note: re-establish `TARGET` literally, as TARGET="" test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unset or not a dir -- gate did NOT run"; exit 1; } T="$TARGET"; F="$T/.codex/features" -LEFT="$(grep -rn '{{' "$F" "$T/.codex/rules/tasks.md" "$T/.codex/agents/task-tracker.toml" \ +LEFT="$(grep -rnE '\{\{|\{(PLUGIN_VERSION|GENERATED_BY|LAST_UPDATED)\}' \ + "$F" "$T/.codex/rules/tasks.md" "$T/.codex/agents/task-tracker.toml" \ "$T/.codex/skills/task-board" "$T/.codex/skills/task-spec" 2>/dev/null || true)" test -z "$LEFT" && echo "OK no leftover placeholders" \ || { echo "MISS leftover placeholders:"; echo "$LEFT"; } @@ -304,7 +402,26 @@ Read the probe output as: > **A `PATCH`/`ABSENT`/non-zero line is a MISS only if the user did not decline it.** A declined patch or a declined backfill is expected: report it as `declined`, !=retry, !=re-emit. -Rerun safety: a second `upgrade` on the same TARGET must reach U1, find every row SKIP with `backfill-needed=0`, print `upgrade: no-op, spec layer already installed`, and exit having written nothing. The excluded files (`closed/`, `backlog/README.md`, FM-less) are out of the denominator in BOTH U1 and here, so the count converges. +**Stamp gate.** The restamp is verified here too, independently of U5b's own check, because the anchor's stamp IS the staleness signal: **EXECUTE** using shell: +```bash +TARGET="" +test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unset or not a dir -- stamp gate did NOT run"; exit 1; } +PV="" +bad=0 +for rel in .codex/features/board.md .codex/features/TRACKER.md .codex/features/INDEX.md \ + .codex/features/PROGRESS.md .codex/features/backlog/README.md \ + .codex/agents/task-tracker.toml .codex/rules/tasks.md \ + .codex/skills/task-board/SKILL.md .codex/skills/task-spec/SKILL.md; do + f="$TARGET/$rel"; test -f "$f" || continue + # SAME skip rule as U5b, or the gate fails a file U5b correctly refused to touch. + test "$(head -n 1 "$f")" = "---" || { echo "SKIP $rel (no frontmatter -- U5b never stamps it)"; continue; } + grep -qxF "version: \"$PV\"" "$f" || { echo "MISS stale stamp: $rel -> $(grep -m1 '^version:' "$f" || echo '(none)')"; bad=$((bad+1)); } +done +test "$bad" -eq 0 && echo "OK every frontmatter-carrying artifact stamped $PV" || echo "❌ $bad artifact(s) still stale -- re-run U5b" +``` +A clean run prints `OK every frontmatter-carrying artifact stamped ` -- silence means the block did not run, !=PASS. A `SKIP` line is not a failure: it is a file whose line 1 is not `---`, which U5b refuses to touch by the same rule that protects a hand-authored task file. + +Rerun safety: a second `upgrade` on the same TARGET must reach U1, find every row SKIP with `backfill-needed=0`, run U5b (which rewrites the identical bytes, since the plugin version has not moved), print `upgrade: content already installed, metadata restamped to `, and change nothing on disk. The excluded files (`closed/`, `backlog/README.md`, FM-less) are out of the denominator in BOTH U1 and here, so the count converges. Report the probe rows as these buckets: @@ -312,6 +429,7 @@ Report the probe rows as these buckets: |--------|---------| | added | files written (ADD set) + `specs/` dir if created | | patched | files edited + which MARK was inserted into each | +| restamped | U5b: `/9` artifacts now carrying `version ""`, and every `ABSENT`/`SKIP` by name. **ALWAYS printed**, including on the content-no-op path -- it is the bucket that proves the next `setup-status` will read `installed` instead of `stale` | | skipped-already-present | ADD files that existed, PATCH files whose MARK was found | | declined | patches / backfill the user rejected -- named, so a later rerun can pick them up | | half-state | coherence pairs from U4b the user chose to leave incomplete | @@ -333,6 +451,10 @@ Report the probe rows as these buckets: | Condition | Response | |-----------|----------| | `board.md` missing | Not an upgrade. STOP -- tell the user to run a fresh `$brewtools:task-board-setup install ` | +| Every U4 row SKIP and `backfill-needed=0` | NOT a reason to stop before U5b. Skip U2's question and U3-U5, run U5b, report `content already installed, metadata restamped`. An `upgrade` that reports success without moving the stamp leaves `setup-status` printing `stale` forever | +| A stamped artifact is present but its frontmatter has no `version:` (pre-standard install) | U5b INSERTS the trio before the closing `---`. That is how `stale (legacy, unstamped)` clears -- !=report it and move on | +| A stamped artifact's line 1 is not `---` | SKIP it, never synthesize frontmatter (same rule as a task file). Name it in the `restamped` bucket | +| `doc_type` in a restamped file | Left exactly as found. These are mechanism-`b` artifacts, not byte-copies -- a locally chosen `user`/`skip` is the user's, and U5b owns only the trio | | ADD target already present | SKIP it; !=overwrite. Report as skipped-already-present | | `PROGRESS.md` present (any content, hand-edited or stale) | NEVER rewritten by upgrade -- it is an ADD-set file, so present = SKIP. `task-tracker` refreshes it on its next run | | PATCH target file missing | ADD it whole from its reference template with every token resolved, and NOTE the drift in the report | diff --git a/brewtools/.codex/skills/text-human/SKILL.md b/brewtools/.codex/skills/text-human/SKILL.md index 161b378..1879233 100644 --- a/brewtools/.codex/skills/text-human/SKILL.md +++ b/brewtools/.codex/skills/text-human/SKILL.md @@ -163,8 +163,8 @@ Files are edited in place. No backups -- use git to revert. /text-human src/main/java/OrderService.java # code flow, single file /text-human 3be67487 # mixed flow, commit /text-human src/main/java/services/ # mixed flow, folder -/text-human review this reddit reply: # social flow, inline text -/text-human humanize this blog post: # article flow +/text-human review this reddit reply: "" # social flow, inline text +/text-human humanize this blog post: "" # article flow /text-human clean the javadoc in PaymentApi.java # code flow, CLEAN-ONLY /text-human 3be67487 also drop all @author tags # mixed + custom rule /text-human src/ only strip AI artifacts, no inject # custom prompt overrides diff --git a/brewtools/.codex/skills/think-short-setup/SKILL.md b/brewtools/.codex/skills/think-short-setup/SKILL.md index 0dd7064..a225573 100644 --- a/brewtools/.codex/skills/think-short-setup/SKILL.md +++ b/brewtools/.codex/skills/think-short-setup/SKILL.md @@ -7,16 +7,23 @@ description: "Installs or removes terse-mode hooks. Explicit user invocation onl ## Resolve intent and target -1. Resolve `install` or `remove`, then project or personal scope. Show the exact target before mutation. +1. Resolve exactly one canonical mode from `status`, `install`, `upgrade`, `enable`, `disable`, `uninstall`, `purge`, then project or personal scope. Show the exact target before mutation. With no mode given, resolve `status` when the assets are already present and `install` otherwise. `on`, `off`, `setup`, `remove`, `reset`, `create`, `update` and `cleanup` are not modes: read them as the canonical verb and echo the canonical name back. -## Install or remove +## Modes -2. For install, copy the two native scripts and prompt described by `assets/INSTALL.md`, merge `SessionStart` and `UserPromptSubmit` entries by exact command string, and preserve unrelated hooks. -3. For removal, delete only matching command entries and the three copied assets; remove empty directories only when owned by this workflow. +| Mode | Effect | +|------|--------| +| `status` | Report scope, registered entries, copied asset paths and their recorded version. Writes nothing. | +| `install` | Copy the two native scripts and the prompt described by `assets/INSTALL.md`, merge `SessionStart` and `UserPromptSubmit` entries by exact command string, and preserve unrelated hooks. | +| `upgrade` | Re-copy the same assets from the current plugin version and re-register any entry that went missing, restamping the recorded version. Keeps the parked-or-active state as it was. | +| `enable` | Restore parked assets by renaming each `.disabled` twin back to the filename the handler resolves. | +| `disable` | Park the copied assets by renaming them `.disabled`, leaving the bodies byte-identical, so the registered handlers no-op. | +| `uninstall` | Delete only the matching command entries and the three copied assets, plus any `.disabled` twin of them; remove empty directories only when owned by this workflow. | +| `purge` | `uninstall` plus removal of the workflow's own directory and any personal-scope override. State what will be deleted first. | ## Verify and report -4. Validate JSON, run both hook scripts with valid and malformed fixtures, and confirm repeated install/remove is idempotent. -5. Report the changed paths and require review through `/hooks`. +2. Validate JSON, run both hook scripts with valid and malformed fixtures, and confirm a repeated `install`, `upgrade` or `uninstall` is idempotent. +3. Report the changed paths and require review through `/hooks`. Handlers use one command string, timeout values in seconds, and no matcher for `UserPromptSubmit`. This Codex variant does not install a sub-agent prompt-rewrite hook. diff --git a/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt-counter.mjs b/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt-counter.mjs index aa90b7e..4d332dd 100644 --- a/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt-counter.mjs +++ b/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt-counter.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup /** * think-short — UserPromptSubmit hook (self-contained, no plugin-root deps). * diff --git a/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt.md b/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt.md index 1387d68..bc4a1bb 100644 --- a/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt.md +++ b/brewtools/.codex/skills/think-short-setup/assets/think-short-prompt.md @@ -1,4 +1,4 @@ - + Be terse. Lead with results. Use ASCII unless the requested artifact requires other text. Think short: keep internal reasoning minimal and do not narrate exploration. Search before opening large files. Prefer focused edits and parallel read-only checks. diff --git a/brewtools/.codex/skills/think-short-setup/assets/think-short-session.mjs b/brewtools/.codex/skills/think-short-setup/assets/think-short-session.mjs index b49898d..9f12213 100644 --- a/brewtools/.codex/skills/think-short-setup/assets/think-short-session.mjs +++ b/brewtools/.codex/skills/think-short-setup/assets/think-short-session.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup /** * think-short — SessionStart hook (self-contained, no plugin-root deps). * diff --git a/brewtools/README.md b/brewtools/README.md index cae3d16..471a4ef 100644 --- a/brewtools/README.md +++ b/brewtools/README.md @@ -4,7 +4,7 @@ | Field | Value | |-------|-------| -| Version | 5.0.0 | +| Version | 5.1.0 | | Skills | 12 | | Agents | 3 | | Hooks | 2 | @@ -158,6 +158,28 @@ brewtools/ > **Brewtools vs Brewcode:** Brewtools provides standalone text utilities with no lifecycle dependencies. Brewcode is a task execution engine with infinite context and session handoff. Both install from the same `claude-brewcode` marketplace but operate independently. +## Artifact metadata + +Every artifact a `-setup` skill installs into your project carries the same four fields, so you can +tell at a glance what wrote a file and which plugin version it was written at. + +| Field | Values | Where | +|-------|--------|-------| +| `doc_type` | `llm` \| `user` \| `skip` -- unquoted | `.md` frontmatter only, never JSON | +| `version` | `"X.Y.Z"` -- plugin version at install time | all carriers | +| `generated_by` | `":"` | all carriers | +| `last_updated` | `"YYYY-MM-DD"` | all carriers except a byte-copied `.mjs`/`.sh`/`.md` | + +A byte-copied asset omits `last_updated`: the value would be the release date, +identical in the plugin file and the copy, so rewriting it on every build would churn bytes and +defeat the `cmp` drift check that mechanism exists for. The four keys always sit after the file's +own keys, in that order. JSON artifacts carry the same three snake_case keys at top level (no +`doc_type`) in every writing mode. Five carriers exist: JSON keys, `.md` frontmatter, a +`// brewcode-meta:` / `# brewcode-meta:` one-liner on line 2 of a byte-copied `.mjs`/`.sh`, a header +table in `team.md`, and `` on line 1 of a byte-copied `.md`. Versions +always come from `.claude-plugin/plugin.json`, never hardcoded. +`/brewcode:setup-status` reads these back across every setup skill installed here. + ## Hooks | Hook | Event | Purpose | diff --git a/brewtools/agents/deploy-admin.md b/brewtools/agents/deploy-admin.md index bc3de26..718216e 100644 --- a/brewtools/agents/deploy-admin.md +++ b/brewtools/agents/deploy-admin.md @@ -4,6 +4,10 @@ description: "GitHub Actions deployment: workflows, releases, GHCR, CI/CD. Trigg model: inherit maxTurns: 80 tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, WebFetch, WebSearch +doc_type: llm +version: "5.1.0" +generated_by: "brewtools" +last_updated: "2026-08-09" --- # Deploy Admin @@ -249,5 +253,3 @@ If any operation reveals: - [ ] CI/CD runs verified green - [ ] No secrets exposed in logs or output - [ ] Deployment health verified (if deploy) - - diff --git a/brewtools/agents/ssh-admin.md b/brewtools/agents/ssh-admin.md index 26256a0..1786ffb 100644 --- a/brewtools/agents/ssh-admin.md +++ b/brewtools/agents/ssh-admin.md @@ -4,6 +4,10 @@ description: "Linux server admin: SSH, Docker, systemd, Nginx, SSL. Triggers: ss model: inherit maxTurns: 80 tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, WebFetch, WebSearch +doc_type: llm +version: "5.1.0" +generated_by: "brewtools" +last_updated: "2026-08-09" --- # SSH Admin @@ -175,5 +179,3 @@ systemctl --failed --no-pager - [ ] Services restarted after config changes - [ ] No hardcoded credentials in commands or files - [ ] Docker Compose uses `mem_limit`/`cpus` (never `deploy.resources.*`) - - diff --git a/brewtools/agents/text-optimizer.md b/brewtools/agents/text-optimizer.md index 969e81f..faa18d9 100644 --- a/brewtools/agents/text-optimizer.md +++ b/brewtools/agents/text-optimizer.md @@ -6,6 +6,10 @@ maxTurns: 60 color: magenta tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, AskUserQuestion skills: brewtools:text-optimize +doc_type: llm +version: "5.1.0" +generated_by: "brewtools" +last_updated: "2026-08-09" --- # Text Optimizer Agent diff --git a/brewtools/hooks/hardmode-guard.mjs b/brewtools/hooks/hardmode-guard.mjs index 25b8f65..968e1f1 100644 --- a/brewtools/hooks/hardmode-guard.mjs +++ b/brewtools/hooks/hardmode-guard.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:manager-setup // brewtools:manager-setup — HARD wall guard (PreToolUse, matcher "*"). // // SELF-CONTAINED — copied into /.claude/brewtools/manager/ by diff --git a/brewtools/hooks/lib/manager-state.mjs b/brewtools/hooks/lib/manager-state.mjs index 9ea5fbe..68f2a5a 100644 --- a/brewtools/hooks/lib/manager-state.mjs +++ b/brewtools/hooks/lib/manager-state.mjs @@ -1,7 +1,14 @@ +// brewcode-meta: version=5.1.0 generated_by=brewtools:manager-setup // brewtools:manager-setup — Manager mode state resolver/writer. -// State shape: { hard:boolean, level:'strict'|'balanced', mode:'full' }. +// State shape: { hard:boolean, level:'strict'|'balanced', mode:'full' } +// + artifact metadata written by writeState: version/generated_by/last_updated. // hard — HARD wall toggle (PreToolUse guard physically denies main-session tools) // level — HARD wall strictness: 'strict' (deny all non-read) | 'balanced' (allow read-only bash/search) +// metadata — stamped on WRITE only. DEFAULT_STATE deliberately carries no version: +// it is the answer for "no state file exists", and a version there would +// claim provenance for a file nothing ever stamped. Same reason a write that +// cannot resolve the version OMITS the key rather than stamping 'unknown' — +// see pluginVersion() and its call site in writeState. // mode — vestigial informational field, ALWAYS 'full'. No user action sets it; // kept so status/readers of state.mode keep working. planmode is NOT a stored // mode — ++m derives it at runtime from permission_mode === 'plan'; planmode @@ -25,6 +32,54 @@ import { pathToFileURL } from 'node:url'; const DEFAULT_STATE = { hard: false, level: 'balanced', mode: 'full' }; const VALID_SCOPES = new Set(['project', 'global']); +const GENERATED_BY = 'brewtools:manager-setup'; + +/** + * Today's date in LOCAL time, `YYYY-MM-DD` — the spec mandates `date +%F`, which is local. + * `toISOString()` is UTC and would stamp tomorrow's (or yesterday's) date depending on the + * offset, and this value feeds staleness comparison. + * @returns {string} + */ +function localDate() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** + * Version of the manager-setup that owns THIS copy of the module, by self-location. + * Plugin layout (hooks/lib/) -> ../../.claude-plugin/plugin.json. + * A copy installed into a project has no plugin.json above it, so it falls back to + * its own baked `brewcode-meta` stamp — which is the version it was copied at. + * Never a literal: the state file must record the version that actually wrote it. + * + * Returns `null`, NEVER the string 'unknown', when both carriers are unreadable. + * `unknown` is not a version — `sort -V` accepts it and the `PLACEHLD` character test + * does not catch it, so a consumer would report a confident verdict on a resolver + * failure. `null` is an internal sentinel that never leaves this module: `writeState` + * OMITS the `version` key instead of stamping it (see the call site). + * + * It returns rather than throws on purpose. This module is the HARD wall's off-switch + * (`set hard=false`) and the single Bash shape the guard self-exempts; a writer that + * aborted here would leave the user behind an armed wall with no exit. The other four + * writers in this repo abort because they are generators — nothing is armed when they + * refuse. Both hooks (`session-start.mjs`, `manager-prompt.mjs`) import only + * `resolveState`, so no hook path reaches this function at all. + * @returns {string|null} semver, or null when unresolvable + */ +function pluginVersion() { + const here = path.dirname(new URL(import.meta.url).pathname); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', '..', '.claude-plugin', 'plugin.json'), 'utf8')); + if (pkg && /^\d+\.\d+\.\d+/.test(pkg.version)) return pkg.version; + } catch {} + try { + const first = fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n', 1)[0]; + const m = /brewcode-meta: version=(\d+\.\d+\.\d+)/.exec(first); + if (m) return m[1]; + } catch {} + return null; +} + function resolveHome(p) { if (!p) return p; if (p === '~') return process.env.HOME || os.homedir(); @@ -83,7 +138,14 @@ export function resolveState(cwd = process.cwd()) { const level = (project && project.level) ? project.level : DEFAULT_STATE.level; const mode = (project && project.mode) ?? (global && global.mode) ?? DEFAULT_STATE.mode; const source = project ? 'project' : (global ? 'global' : 'default'); - return clampLevel(clampMode({ hard, level, mode, source })); + // Unknown keys of the PROJECT file (version/generated_by/last_updated and anything + // a future release adds) pass through untouched. Nothing is invented: a file that + // carries no version resolves without one, so a stale state stays visibly stale. + const resolved = clampLevel(clampMode({ ...(project || {}), hard, level, mode, source })); + // Legacy `doc_type` from a pre-spec write is dropped on READ too, not only on write: + // otherwise every consumer sees it until the next write happens to occur. + delete resolved.doc_type; + return resolved; } catch { return { ...DEFAULT_STATE, source: 'default' }; } @@ -166,7 +228,30 @@ export async function writeState(scope, partial, cwd = process.cwd()) { if (!token) throw new Error('could not acquire lock'); try { const existing = readJsonSafe(filePath) || {}; - const merged = { ...existing, ...partial }; + // Provenance is stamped by the WRITER, never by a reader/merge: the version is the + // one of the module doing this write, so setup-status reading the raw file sees the + // real age of the state. No `doc_type`: it is a frontmatter-only field, and JSON + // carriers never take it — state.json is machine state, not a doc, either way. + const version = pluginVersion(); + const merged = { + ...existing, + ...partial, + generated_by: GENERATED_BY, + last_updated: localDate() + }; + if (version) merged.version = version; + else { + // Unresolvable version: DROP the key rather than stamp a fake one. An absent + // `version` is a documented reader path — setup-status row 8 treats it as the + // `missing` signal and falls through to the copied guard's `brewcode-meta` line, + // and manager-setup `status` computes `stale: (stateVersion && pluginVersion) ? + // ... : null`, so it is never compared as if it were a real version. Deleting a + // stale inherited key matters: merging over an older state must not let this + // write keep claiming that older file's version as its own. + delete merged.version; + process.stderr.write('[manager-state] plugin version unresolvable — wrote state without a version key\n'); + } + delete merged.doc_type; // legacy key from pre-spec writes; JSON carriers never take it writeAtomic(filePath, merged); return { file: filePath, action: 'written', state: merged }; } finally { @@ -245,6 +330,19 @@ async function runCli(argv) { } } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +// argv[1] must be realpath'd before comparing: Node resolves ESM specifiers through +// symlinks, so on a path like /var/... -> /private/var/... the raw argv URL never matches +// import.meta.url and the CLI silently no-ops with exit 0 — i.e. the documented HARD-wall +// off-switch `set hard=false` would appear to succeed while writing nothing. +function invokedDirectly() { + if (!process.argv[1]) return false; + try { + return import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href; + } catch { + return import.meta.url === pathToFileURL(process.argv[1]).href; + } +} + +if (invokedDirectly()) { await runCli(process.argv.slice(2)); } diff --git a/brewtools/skills/agent-deadline-setup/README.md b/brewtools/skills/agent-deadline-setup/README.md index 4fde460..1fcb891 100644 --- a/brewtools/skills/agent-deadline-setup/README.md +++ b/brewtools/skills/agent-deadline-setup/README.md @@ -49,7 +49,7 @@ The skill always reports status first, states its plan before asking anything, t |------|-----------|---------------|--------|-------| | `status` | — | — | — | — | | `install` | copied | entries merged | written | — | -| `upgrade` | re-copied | entries re-merged | values preserved | kept | +| `upgrade` | re-copied | entries re-merged | behavior values preserved, metadata re-stamped | kept | | `enable` | kept | kept | `enabled:true` | kept | | `disable` | kept | kept | `enabled:false` | kept | | `uninstall` | deleted | entries stripped | **kept** | kept | @@ -85,13 +85,17 @@ Project config wins over global; a malformed project config is skipped and the g "enabled": true, "defaultMinutes": 20, "byAgentType": {}, - "hardStopRatio": 2 + "hardStopRatio": 2, + "version": "{PLUGIN_VERSION}", + "generated_by": "brewtools:agent-deadline-setup", + "last_updated": "{LAST_UPDATED}" } ``` | Key | Meaning | |-----|---------| | `enabled` | must be exactly `true`; anything else = off | +| `version` / `generated_by` / `last_updated` | provenance, written on every config write. No `doc_type` — that field is `.md` frontmatter only. `version` is the brewtools version that wrote the file; `status` compares it against the installed plugin so a shape change from a later version is visible. Inert at runtime — the hooks read only the four keys above | | `defaultMinutes` | budget for every agent type; default `20` | | `byAgentType` | per-type overrides, e.g. `{"Explore": 10}`; empty = one limit for all | | `hardStopRatio` | optional, default `2`, must be `>1` — multiple of the budget after which the allowance drops from the finalize set to `Write, Edit` | diff --git a/brewtools/skills/agent-deadline-setup/SKILL.md b/brewtools/skills/agent-deadline-setup/SKILL.md index adcca6b..7f4a8dc 100644 --- a/brewtools/skills/agent-deadline-setup/SKILL.md +++ b/brewtools/skills/agent-deadline-setup/SKILL.md @@ -46,10 +46,12 @@ Actually allowed past 100%: those 7 **plus** `TaskCreate`, `BashOutput`, `TaskOu ## BT_ROOT Resolver (use in EVERY bash block) -`$CLAUDE_PLUGIN_ROOT` is NOT inherited by the Bash tool in main-conversation slash invocations. Resolve dynamically: +The plugin root is resolved from the skill's OWN directory (the `CLAUDE_SKILL_DIR` prompt substitution), never from `CLAUDE_PLUGIN_ROOT` -- that env var is not exported to a skill's Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -d "$BT_ROOT/skills/agent-deadline-setup/assets" || { echo "❌ FAILED — BT_ROOT invalid: $BT_ROOT"; exit 1; } ``` @@ -70,7 +72,9 @@ Run this before anything else, in EVERY mode. Never install, re-install or remov **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } A="$BT_ROOT/skills/agent-deadline-setup/assets" test -f "$A/INSTALL.md" && test -f "$A/agent-deadline-guard.mjs" && test -f "$A/agent-deadline-cleanup.mjs" || { echo "❌ FAILED — assets incomplete under BT_ROOT=$BT_ROOT"; exit 1; } echo "ASSETS_DIR=$A" @@ -82,8 +86,11 @@ for S in "$PWD/.claude:project" "$HOME/.claude:global"; do W=$({ grep -o 'agent-deadline-[a-z]*\.mjs' "$D/settings.json" 2>/dev/null || true; } | sort -u | wc -l | tr -d ' '); W=${W:-0} CFG=none; [ -s "$D/agent-deadline.json" ] && CFG=$(tr -d '\n ' < "$D/agent-deadline.json"); CFG=${CFG:-none} EN=n/a; case "$CFG" in *'"enabled":true'*) EN=true;; *'"enabled":false'*) EN=false;; esac - echo "$N: guard=$G cleanup=$C settings_refs=$W enabled=$EN config=$CFG" + CV=$({ jq -r '.version // empty' "$D/agent-deadline.json" 2>/dev/null || true; }); CV=${CV:-n/a} + echo "$N: guard=$G cleanup=$C settings_refs=$W enabled=$EN config_version=$CV config=$CFG" done +PV=$({ jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true; }); PV=${PV:-n/a} +echo "plugin_version=$PV" echo "✅ status" ``` @@ -96,14 +103,42 @@ Field meanings — do not paraphrase them into something stronger: | `guard` / `cleanup` | `yes`/`no` — hook FILE present in that scope's `hooks/` | | `settings_refs` | count of DISTINCT `agent-deadline-*.mjs` scripts referenced in that scope's `settings.json`; `0` = not wired, `2` = fully wired, `1` = half-wired → repair | | `enabled` | `true`/`false` parsed from the config; `n/a` = no config or no `enabled` key | +| `config_version` | the config's `version` key vs `plugin_version` on the last line. Different = the config was written by an older brewtools and may predate a shape change -> offer `upgrade`. `n/a` on either side (pre-metadata config, or no config) = unknown, NOT "current" | | `config` | whitespace-stripped config contents, or literal `none` | `settings_refs` is a textual count, not a JSON validation — it does not prove the entries are well-formed or attached to the right events. Read the output into a state table and PRINT it to the user: -| Scope | Hook files | settings.json wired | Config | Effective | -|-------|-----------|---------------------|--------|-----------| +| Scope | Hook files | settings.json wired | Config | Config ver | Stale | Effective | +|-------|-----------|---------------------|--------|------------|-------|-----------| + +### Config metadata (the three standard JSON keys) + +Every mode that writes `agent-deadline.json` (`install`, `upgrade`, `enable`, `disable`) leaves these three keys in it alongside the behavior keys. `doc_type` is a `.md`-frontmatter field only and never appears in a JSON carrier: + +```json +{ "version": "{PLUGIN_VERSION}", "generated_by": "brewtools:agent-deadline-setup", "last_updated": "{LAST_UPDATED}" } +``` + +Resolve `version` and `last_updated` — never hardcode either. **EXECUTE** using Bash tool: + +```bash +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } +PV=$(jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true) +PV=${PV:-$(basename "$BT_ROOT")} +echo "PLUGIN_VERSION=$PV LAST_UPDATED=$(date +%F)" +``` + +> **Why the bare form.** `CLAUDE_SKILL_DIR` is a TEXT SUBSTITUTION on the skill prompt, not an env var: CC 2.1.226 rewrites only the EXACT dollar-brace literal `{CLAUDE_SKILL_DIR}` (`replace(/\$\{CLAUDE_SKILL_DIR\}/g, dirname(skillPath))` and a string-pattern `replaceAll`). A brace-modifier form such as `:-fallback` inside the braces is therefore NOT matched, reaches the shell verbatim, and its fallback ALWAYS wins. `CLAUDE_PLUGIN_ROOT` is a real env var but is exported only to hook processes and MCP servers -- never to a skill's Bash tool -- so it is ALWAYS empty here. The skill dir is correct in a cache install AND in a `--plugin-dir` dev run; the cache glob below it is a last-resort fallback only, and it would name the INSTALLED plugin. + +| Guarantee | Why it holds | +|-----------|--------------| +| The hooks ignore them | `loadConfig()` accepts any non-array JSON object and reads only `enabled`, `defaultMinutes`, `byAgentType`, `hardStopRatio`; unknown keys are inert | +| `enabled` semantics unchanged | The gate stays `cfg.enabled !== true` -> off. Adding sibling keys touches nothing | +| Cannot make a valid file unparseable | Written by the runbook's node block that re-serializes the whole object with `JSON.stringify` — never appended as raw text. An invalid project config is skipped and the GLOBAL one takes over, which is a silent behavior change, so a hand-appended line is a defect | Effective = `guard=yes cleanup=yes settings_refs=2 enabled=true`. Anything else is NOT effective — say so plainly instead of reporting a half-state as installed. Project config wins over global; a broken project config is skipped and global is used. @@ -170,7 +205,7 @@ Every spawn prompt MUST carry: > **The budget only survives if it reaches the SHELL.** `MINUTES`/`OVERRIDES`/`RUNBOOK` written as prose in the prompt are just text — the runbook's node blocks read them from `process.env`, and an un-exported `MINUTES` now ABORTS the config write (no built-in `20` fallback) instead of silently losing the user's choice. The spawn prompt below therefore carries the literal `export` line the agent must run FIRST, in the same Bash invocation as every runbook block. Substitute the chosen values into that `export` line, not only into the CONTEXT table. -Spawn (substitute `MODE`, `SCOPE`, `MINUTES`, `OVERRIDES`, `HARD_STOP_RATIO`, `RUNBOOK`, `ASSETS_DIR` from Steps 1-4 — into BOTH the CONTEXT block and the `export` line): +Spawn (substitute `MODE`, `SCOPE`, `MINUTES`, `OVERRIDES`, `HARD_STOP_RATIO`, `RUNBOOK`, `ASSETS_DIR`, `PLUGIN_VERSION`, `LAST_UPDATED` from Steps 1-4 and the Config-metadata block — into BOTH the CONTEXT block and the `export` line): ``` Task(subagent_type="brewcode:hook-creator", prompt=" @@ -198,9 +233,9 @@ CONTEXT: MANDATORY FIRST BASH COMMAND — the runbook's node blocks read these from the ENVIRONMENT, not from this prompt. Run this VERBATIM as the first line of EVERY Bash call that executes a runbook block (a new Bash call does NOT inherit exports from the previous one): - export RUNBOOK='RUNBOOK' MINUTES='MINUTES' OVERRIDES='OVERRIDES' HARD_STOP_RATIO='HARD_STOP_RATIO' + export RUNBOOK='RUNBOOK' MINUTES='MINUTES' OVERRIDES='OVERRIDES' HARD_STOP_RATIO='HARD_STOP_RATIO' PLUGIN_VERSION='PLUGIN_VERSION' LAST_UPDATED='LAST_UPDATED' Then verify before writing anything: - echo \"MINUTES=\$MINUTES OVERRIDES=\$OVERRIDES HARD_STOP_RATIO=\$HARD_STOP_RATIO RUNBOOK=\$RUNBOOK\" + echo \"MINUTES=\$MINUTES OVERRIDES=\$OVERRIDES HARD_STOP_RATIO=\$HARD_STOP_RATIO RUNBOOK=\$RUNBOOK PV=\$PLUGIN_VERSION LU=\$LAST_UPDATED\" If MINUTES prints empty, STOP and report — the config block ABORTS on an empty MINUTES by design; re-export it rather than hardcoding a number. Drop HARD_STOP_RATIO from the export line when the user did not set it. @@ -212,13 +247,21 @@ CONTEXT: existing config, export them, then replay the copy + config + merge blocks for SCOPE. Uninstall = strip entries by those two basenames, drop empty event arrays, delete the 2 files, KEEP the config. Purge = uninstall + delete config + tmp state. + METADATA: every mode that WRITES the config (install, upgrade, enable, disable) must leave + these three keys in agent-deadline.json: version=\$PLUGIN_VERSION, + generated_by=\"brewtools:agent-deadline-setup\", last_updated=\$LAST_UPDATED. No doc_type — + it is a .md-frontmatter field and never belongs in a JSON carrier. Set them INSIDE + the runbook's node block that re-serializes the object with JSON.stringify — never by + appending text to the file. An invalid project config is SKIPPED and the global one silently + takes over, so a hand-edited append is a defect, not a shortcut. Do NOT touch enabled while + doing it: the hooks require it to be exactly true. CONSUMER: Step 6 reports your result to the user; the settings.json you write is loaded by the NEXT Claude Code session, so a malformed merge breaks that session instead of failing here — report the exact paths you touched so they can be checked. DONE: report the settings.json path, the hooks dir, the config path with its final contents, and the runbook 'Verify' output if you ran it. The reported config MUST show defaultMinutes = MINUTES — a 20 where the user asked for something else is a FAILURE, - not a detail. + not a detail — and version = \$PLUGIN_VERSION. Prove the file still parses: jq . . ") ``` @@ -229,6 +272,7 @@ Re-run the Step 1 status block and print the refreshed table, plus: - what changed (files, settings.json, config values), - **a NEW session is required for hook WIRING changes** (install/upgrade/uninstall/purge) — `/reload-plugins` is not needed, these are plain settings.json hooks; - **config VALUE changes** (`enabled`, `defaultMinutes`, `byAgentType`, `hardStopRatio`) are read live — no restart; +- the config `version` now written into the file, and whether it matches `plugin_version`; - the soft-deadline caveat: time is sampled at tool-call boundaries only; pair with `BASH_MAX_TIMEOUT_MS` for long single commands. --- @@ -252,7 +296,7 @@ Re-run the Step 1 status block and print the refreshed table, plus: | Condition | Response | |-----------|----------| | `BT_ROOT` resolves but `$BT_ROOT/skills/agent-deadline-setup/assets` missing | ERROR: `agent-deadline: assets not found under $BT_ROOT — plugin cache incomplete.` STOP. | -| Neither `$CLAUDE_PLUGIN_ROOT` set nor any cached plugin dir found | ERROR: `agent-deadline: cannot locate plugin root — install/update brewtools first.` STOP. | +| Neither the skill dir nor any cached plugin dir yields `.claude-plugin/plugin.json` | ERROR: `agent-deadline: cannot locate plugin root — install/update brewtools first.` STOP. | | Status shows fully installed + vague intent | Print status, list available operations, STOP. Do not re-install. | | Scope unspecified | AskUserQuestion: Project / Global / Both. Never guess. | | Global scope chosen (or asked about) | BEFORE writing anything, state the cost: matcher is `.*`, so ~58 ms median (p90 62.5 ms) is added to EVERY tool call of EVERY session in EVERY repo, main sessions included. Say it in the question or the plan, never only in the final report. | @@ -272,7 +316,9 @@ Verify the 3 assets exist and the hooks parse before delegating. **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } A="$BT_ROOT/skills/agent-deadline-setup/assets" test -d "$A" || { echo "❌ smoke FAILED — assets dir missing: $A"; exit 1; } for f in agent-deadline-guard.mjs agent-deadline-cleanup.mjs INSTALL.md; do diff --git a/brewtools/skills/agent-deadline-setup/assets/INSTALL.md b/brewtools/skills/agent-deadline-setup/assets/INSTALL.md index 342697a..9be383b 100644 --- a/brewtools/skills/agent-deadline-setup/assets/INSTALL.md +++ b/brewtools/skills/agent-deadline-setup/assets/INSTALL.md @@ -75,7 +75,10 @@ Global: `~/.claude/agent-deadline.json` — fallback. "enabled": true, "defaultMinutes": 20, "byAgentType": {}, - "hardStopRatio": 2 + "hardStopRatio": 2, + "version": "X.Y.Z", + "generated_by": "brewtools:agent-deadline-setup", + "last_updated": "YYYY-MM-DD" } ``` @@ -85,6 +88,7 @@ Global: `~/.claude/agent-deadline.json` — fallback. | `defaultMinutes` | budget for every agent type; default `20` if missing/invalid | | `byAgentType` | optional per-type overrides, e.g. `{"Explore": 10, "brewtools:text-optimizer": 45}`. Empty by default = one limit for all | | `hardStopRatio` | optional, default `2`, must be `> 1` — multiple of the budget past which the allow-list shrinks from the finalize set to `Write, Edit`. Anything `<= 1` or non-numeric falls back to `2`. Omit the key entirely to take the default | +| `version` / `generated_by` / `last_updated` | provenance, MANDATORY, re-stamped by every mode that writes this file (install, upgrade, enable, disable). Inert at runtime — `loadConfig()` reads only the four keys above. Never `doc_type`: that is a `.md`-frontmatter field | Budget = `byAgentType[agent_type] ?? defaultMinutes`. `agent_type` is the value Claude Code puts in the payload — plain (`Explore`, `developer`) or @@ -102,6 +106,8 @@ before adding an override; a typo silently falls back to `defaultMinutes`. | `MINUTES` | skill (user's answer) | `defaultMinutes` to write; REQUIRED, no default — empty aborts the config block | | `OVERRIDES` | skill (user's answer) | `byAgentType` JSON object; default `{}` | | `HARD_STOP_RATIO` | skill (optional) | `hardStopRatio` to write; leave UNSET to omit the key and take the hook default `2` | +| `PLUGIN_VERSION` | skill (optional) | `X.Y.Z` for the metadata stamp. OPTIONAL: unset/malformed falls back to `/../../../.claude-plugin/plugin.json`, resolved by the block itself. Never a literal | +| `LAST_UPDATED` | skill (optional) | `YYYY-MM-DD` for the stamp; unset falls back to the LOCAL date the block computes | | `CFG` / `SETTINGS` / `HOOKS_DIR` | scope | project = `$PWD/.claude/...`, global = `$HOME/.claude/...` | These are read from `process.env` by the node blocks below — they must be REAL shell @@ -120,14 +126,33 @@ prefix the block. NEVER hardcode the budget — the user picked it; `MINUTES`/`OVERRIDES` carry it. ONE scope per run: set the vars for THAT scope only and never touch the other. +Every write of the config also stamps the three mandatory JSON metadata keys — +`version`, `generated_by`, `last_updated` (never `doc_type`: that is a `.md` +frontmatter field). `version` is resolved from `.claude-plugin/plugin.json`, never +hardcoded; `last_updated` is the LOCAL date. + **EXECUTE** config write (read-modify-write, Bash tool). Set `CFG` per scope: ``` # project: CFG="$PWD/.claude/agent-deadline.json" # global: CFG="$HOME/.claude/agent-deadline.json" (Bash ONLY — protected path) -CFG="$PWD/.claude/agent-deadline.json" OVERRIDES="${OVERRIDES:-}" HARD_STOP_RATIO="${HARD_STOP_RATIO:-}" node -e ' +SRC="$(dirname "$RUNBOOK")" +CFG="$PWD/.claude/agent-deadline.json" PJSON="$SRC/../../../.claude-plugin/plugin.json" OVERRIDES="${OVERRIDES:-}" HARD_STOP_RATIO="${HARD_STOP_RATIO:-}" node -e ' const fs=require("fs"), p=require("path"); const f=process.env.CFG; +const GB="brewtools:agent-deadline-setup"; +function pluginVersion(){ // env first, plugin.json fallback; NEVER a literal + const ev=(process.env.PLUGIN_VERSION||"").trim(); + if(/^[0-9]+\.[0-9]+\.[0-9]+$/.test(ev)) return ev; + try{ const j=JSON.parse(fs.readFileSync(process.env.PJSON||"","utf8")); if(typeof j.version==="string"&&j.version.trim()) return j.version.trim(); }catch{} + return ""; +} +function today(){ // LOCAL date, like date +%F - never toISOString (UTC) + const ev=(process.env.LAST_UPDATED||"").trim(); + if(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(ev)) return ev; + const d=new Date(); + return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); +} let c={}; if(fs.existsSync(f)){ const raw=fs.readFileSync(f,"utf8"); @@ -156,10 +181,16 @@ if(!hadEnabled) c.enabled=true; // reinstall must NOT silently re-e c.defaultMinutes=m; c.byAgentType=Object.assign({},keep,ov); // existing overrides survive a reinstall if(hsr!==undefined) c.hardStopRatio=hsr; // absent env keeps whatever was there (or nothing) +const pv=pluginVersion(); +if(!pv){ console.error("ABORT: cannot resolve plugin version - export PLUGIN_VERSION=X.Y.Z or fix PJSON: "+process.env.PJSON); process.exit(1); } +const lu=today(); +c.version=pv; c.generated_by=GB; c.last_updated=lu; // the 3 mandatory JSON metadata keys, on EVERY write +delete c.doc_type; // frontmatter-only field; a JSON carrier never takes it fs.mkdirSync(p.dirname(f),{recursive:true}); fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n"); const back=JSON.parse(fs.readFileSync(f,"utf8")); // post-write verification if(back.defaultMinutes!==m){ console.error("ABORT: verification failed for "+f); process.exit(1); } +if(back.version!==pv||back.generated_by!==GB||back.last_updated!==lu){ console.error("ABORT: metadata verification failed for "+f); process.exit(1); } console.log("OK wrote "+f+" "+JSON.stringify(back)); if(back.enabled!==true) console.log("NOTE: enabled=false was preserved from the existing config - run ENABLE to switch it on"); ' && echo "✅ config" || echo "❌ FAILED" @@ -422,7 +453,8 @@ process.stdout.write(String(m)); — it overwrites both hook files with the current ones and `node --check`s them. 3. Re-run the **Config** block for that scope with `MINUTES` exported above — `enabled`, `byAgentType` and `hardStopRatio` are all preserved, so a disabled setup stays disabled, - and only keys a newer version introduced are added. + only keys a newer version introduced are added, and `version` / `generated_by` / + `last_updated` are re-stamped to the current plugin. 4. Re-run the **merge settings** block for that scope — it drops its own stale-path entries first, so a moved hooks dir converges. @@ -437,12 +469,28 @@ Nothing is asked and nothing is deleted. Upgrade ONE scope per run; "both" is tw Flip `enabled` in the config. Hooks stay wired and become no-ops — they read the config on every call, so this takes effect immediately, no restart. -**EXECUTE** using Bash tool — set `CFG` for the ONE scope you were asked about: +**EXECUTE** using Bash tool — set `CFG` for the ONE scope you were asked about. +`RUNBOOK` must be exported here too: this is a config WRITE, so it re-stamps the three +metadata keys. ``` # project: CFG="$PWD/.claude/agent-deadline.json" # global: CFG="$HOME/.claude/agent-deadline.json" -CFG="$PWD/.claude/agent-deadline.json" node -e ' +SRC="$(dirname "$RUNBOOK")" +CFG="$PWD/.claude/agent-deadline.json" PJSON="$SRC/../../../.claude-plugin/plugin.json" node -e ' const fs=require("fs"), p=require("path"); const f=process.env.CFG; +const GB="brewtools:agent-deadline-setup"; +function pluginVersion(){ + const ev=(process.env.PLUGIN_VERSION||"").trim(); + if(/^[0-9]+\.[0-9]+\.[0-9]+$/.test(ev)) return ev; + try{ const j=JSON.parse(fs.readFileSync(process.env.PJSON||"","utf8")); if(typeof j.version==="string"&&j.version.trim()) return j.version.trim(); }catch{} + return ""; +} +function today(){ // LOCAL date, like date +%F - never toISOString (UTC) + const ev=(process.env.LAST_UPDATED||"").trim(); + if(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(ev)) return ev; + const d=new Date(); + return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); +} let c={defaultMinutes:20,byAgentType:{}}; if(fs.existsSync(f)){ const raw=fs.readFileSync(f,"utf8"); @@ -453,14 +501,20 @@ if(fs.existsSync(f)){ } } c.enabled = process.env.ON === "1"; +const pv=pluginVersion(); +if(!pv){ console.error("ABORT: cannot resolve plugin version - export PLUGIN_VERSION=X.Y.Z or fix PJSON: "+process.env.PJSON); process.exit(1); } +const lu=today(); +c.version=pv; c.generated_by=GB; c.last_updated=lu; // every write stamps the 3 mandatory keys +delete c.doc_type; // frontmatter-only field; a JSON carrier never takes it fs.mkdirSync(p.dirname(f),{recursive:true}); fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n"); const back=JSON.parse(fs.readFileSync(f,"utf8")); if(back.enabled!==c.enabled){ console.error("ABORT: verification failed for "+f); process.exit(1); } +if(back.version!==pv||back.generated_by!==GB||back.last_updated!==lu){ console.error("ABORT: metadata verification failed for "+f); process.exit(1); } console.log((back.enabled?"ENABLED ":"DISABLED ")+f); ' && echo "✅ toggled" || echo "❌ FAILED" ``` -Prefix `ON=1` to enable, omit it (or `ON=0`) to disable. +Prefix `ON=1` on the `CFG=...` line to enable, omit it (or `ON=0`) to disable. > **STOP if ❌** — fix before continuing. diff --git a/brewtools/skills/agent-deadline-setup/assets/agent-deadline-cleanup.mjs b/brewtools/skills/agent-deadline-setup/assets/agent-deadline-cleanup.mjs index cbb4a67..674f3c9 100644 --- a/brewtools/skills/agent-deadline-setup/assets/agent-deadline-cleanup.mjs +++ b/brewtools/skills/agent-deadline-setup/assets/agent-deadline-cleanup.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:agent-deadline-setup /** * agent-deadline — SubagentStop hook (self-contained, Node built-ins only). * diff --git a/brewtools/skills/agent-deadline-setup/assets/agent-deadline-guard.mjs b/brewtools/skills/agent-deadline-setup/assets/agent-deadline-guard.mjs index 6f5c7f4..61dcb61 100644 --- a/brewtools/skills/agent-deadline-setup/assets/agent-deadline-guard.mjs +++ b/brewtools/skills/agent-deadline-setup/assets/agent-deadline-guard.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:agent-deadline-setup /** * agent-deadline — PreToolUse hook (self-contained, Node built-ins only). * diff --git a/brewtools/skills/agent-router-setup/README.md b/brewtools/skills/agent-router-setup/README.md index 4530f7f..6f4407d 100644 --- a/brewtools/skills/agent-router-setup/README.md +++ b/brewtools/skills/agent-router-setup/README.md @@ -64,7 +64,7 @@ The skill always reports status first, states its plan before asking anything, t |------|-----------|---------------|--------|-------------| | `status` | — | — | — | — | | `install` | copied | entry merged | written | — | -| `upgrade` | re-copied | entries re-merged | values preserved | kept | +| `upgrade` | re-copied | entries re-merged | behavior values preserved, metadata re-stamped | kept | | `enable` | kept | kept | `enabled:true` | kept | | `disable` | kept | kept | `enabled:false` | kept | | `uninstall` | deleted | entries stripped | **kept** | kept | @@ -102,14 +102,18 @@ The tier-2 judge prompt is **inlined into `settings.json`**, not copied — re-r "genericTypes": ["general-purpose", "worker"], "neverFlag": ["Explore", "Plan", "statusline-setup", "output-style-setup", "brewcode:agent-creator", "brewcode:skill-creator", "brewcode:hook-creator", "brewcode:bash-expert"], "minScore": 3, - "margin": 2 + "margin": 2, + "version": "{PLUGIN_VERSION}", + "generated_by": "brewtools:agent-router-setup", + "last_updated": "{LAST_UPDATED}" } ``` | Key | Meaning | |-----|---------| | `enabled` | only exactly `false` turns it off. Any other value — and no config file at all — means ON with these defaults. A config that exists but does not PARSE is different: the feature goes fully silent | -| `level` | `fast` / `strict` — a record of what is wired, ignored by tier 1 itself. Editing it by hand does NOT add or remove the tier-2 entry; run `level strict` / `level fast` | +| `level` | `fast` / `strict` — a RECORD of what was wired at install time, ignored by tier 1 itself and enforced by nothing. Editing it by hand does NOT add or remove the tier-2 entry; run `level strict` / `level fast`. `status` prints it as `level (recorded)` next to the settings.json `tier2` count — that count, not this key, is what actually decides whether the judge fires | +| `version` / `generated_by` / `last_updated` | provenance, written on every config write. No `doc_type` — that field is `.md` frontmatter only. `version` is the brewtools version that wrote the file; `status` compares it against the installed plugin so a shape change from a later version is visible. Inert at runtime — the hook ignores unlisted keys | | `genericTypes` | the types policed at all; anything else exits at step 5 | | `neverFlag` | never flagged whatever the task says; eight entries by default (four fixed + the four intent experts). Auto-unioned with every `intents[].expert` at load time — a custom `intents` table exempts its own experts without touching this key | | `minScore` | minimum roster score before a project agent can win | diff --git a/brewtools/skills/agent-router-setup/SKILL.md b/brewtools/skills/agent-router-setup/SKILL.md index 529ab64..3c5189a 100644 --- a/brewtools/skills/agent-router-setup/SKILL.md +++ b/brewtools/skills/agent-router-setup/SKILL.md @@ -55,10 +55,12 @@ Config and roster are read from the **nearest ancestor of `cwd` holding a `.clau ## BT_ROOT Resolver (use in EVERY bash block) -`$CLAUDE_PLUGIN_ROOT` is NOT inherited by the Bash tool in main-conversation slash invocations. Resolve dynamically: +The plugin root is resolved from the skill's OWN directory (the `CLAUDE_SKILL_DIR` prompt substitution), never from `CLAUDE_PLUGIN_ROOT` -- that env var is not exported to a skill's Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -d "$BT_ROOT/skills/agent-router-setup/assets" || { echo "❌ FAILED — BT_ROOT invalid: $BT_ROOT"; exit 1; } ``` @@ -80,7 +82,9 @@ Run this before anything else, in EVERY mode. Never install, re-install or remov **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } A="$BT_ROOT/skills/agent-router-setup/assets" test -f "$A/INSTALL.md" && test -f "$A/agent-router.mjs" && test -f "$A/judge-prompt.md" || { echo "❌ FAILED — assets incomplete under BT_ROOT=$BT_ROOT"; exit 1; } echo "ASSETS_DIR=$A" @@ -92,8 +96,12 @@ T2=$({ grep -c 'agent-router: checking agent fit' "$D/settings.json" 2>/dev/null CFG=none; [ -s "$D/brewtools/agent-router.json" ] && CFG=$(tr -d '\n ' < "$D/brewtools/agent-router.json"); CFG=${CFG:-none} EN=n/a; case "$CFG" in *'"enabled":true'*) EN=true;; *'"enabled":false'*) EN=false;; esac LV=n/a; case "$CFG" in *'"level":"strict"'*) LV=strict;; *'"level":"fast"'*) LV=fast;; esac +CV=$({ jq -r '.version // empty' "$D/brewtools/agent-router.json" 2>/dev/null || true; }); CV=${CV:-n/a} +PV=$({ jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true; }); PV=${PV:-n/a} +STALE=n/a; [ "$CV" != "n/a" ] && [ "$PV" != "n/a" ] && { [ "$CV" = "$PV" ] && STALE=no || STALE=yes; } R=$({ ls "$D/agents/"*.md 2>/dev/null || true; } | wc -l | tr -d ' ') -echo "project: hook_file=$H tier1_refs=$T1 tier2_refs=$T2 enabled=$EN level=$LV roster=$R" +echo "project: hook_file=$H tier1_refs=$T1 tier2_refs=$T2 enabled=$EN level_recorded=$LV roster=$R" +echo "version: config=$CV plugin=$PV stale=$STALE" echo "config=$CFG" echo "✅ status" ``` @@ -107,18 +115,50 @@ Field meanings — do not paraphrase them into something stronger: | `hook_file` | `yes`/`no` — `agent-router.mjs` present in `/.claude/hooks/` | | `tier1_refs` | textual count of `agent-router.mjs` mentions in `settings.json`; `0` = not wired, `1` = wired, `>1` = duplicate -> repair | | `tier2_refs` | count of the tier-2 `statusMessage` marker; `0` = tier 2 off, `1` = tier 2 wired | -| `enabled` / `level` | parsed from the config; `n/a` = no config or no such key | +| `enabled` | parsed from the config; `n/a` = no config or no such key | +| `level_recorded` | the `level` VALUE stored in the config. It is a RECORD of an install-time choice, **not** proof of what is wired — nothing keeps it honest. `tier2_refs` is the authority on whether the LLM judge actually fires | +| `version` / `plugin` / `stale` | the config's `version` key vs the installed brewtools version. `stale=yes` = the config was written by an older plugin and may predate a shape change -> offer `upgrade`. `n/a` on either side (pre-metadata config, or no config) = unknown, NOT "current" | | `roster` | number of `.claude/agents/*.md` files — **`0` means the hook has nothing to route TO**; say so before installing | These are textual counts, not JSON validation — they do not prove the entries are well-formed or attached to the right event. Read the output into a state table and PRINT it to the user: -| Hook file | tier1 wired | tier2 wired | enabled | level | roster | -|-----------|-------------|-------------|---------|-------|--------| +| Hook file | tier1 wired | tier2 wired | enabled | level (recorded) | tier2 actual | config ver | stale | roster | +|-----------|-------------|-------------|---------|------------------|--------------|------------|-------|--------| Effective = `hook_file=yes tier1_refs=1 enabled=true`. Anything else is NOT effective — say so plainly instead of reporting a half-state as installed. +> **Never print `level` alone as if it were the truth.** Put `tier2_refs` next to it: `level_recorded=strict` with `tier2_refs=0` means the judge is NOT wired, and the config is lying. Report that mismatch explicitly and offer `level strict` (or `level fast`) to reconcile — the config value alone adds and removes nothing. + +### Config metadata (the three standard JSON keys) + +Every mode that WRITES `agent-router.json` — `install`, `upgrade`, `enable`, `disable` and both `level` operations — writes these three keys alongside the behavior keys. `doc_type` is a `.md`-frontmatter field only and never appears in a JSON carrier: + +```json +{ "version": "{PLUGIN_VERSION}", "generated_by": "brewtools:agent-router-setup", "last_updated": "{LAST_UPDATED}" } +``` + +Resolve `version` and `last_updated` — never hardcode either. **EXECUTE** using Bash tool: + +```bash +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } +PV=$(jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true) +PV=${PV:-$(basename "$BT_ROOT")} +echo "PLUGIN_VERSION=$PV LAST_UPDATED=$(date +%F)" +``` + +> **Why the bare form.** `CLAUDE_SKILL_DIR` is a TEXT SUBSTITUTION on the skill prompt, not an env var: CC 2.1.226 rewrites only the EXACT dollar-brace literal `{CLAUDE_SKILL_DIR}` (`replace(/\$\{CLAUDE_SKILL_DIR\}/g, dirname(skillPath))` and a string-pattern `replaceAll`). A brace-modifier form such as `:-fallback` inside the braces is therefore NOT matched, reaches the shell verbatim, and its fallback ALWAYS wins. `CLAUDE_PLUGIN_ROOT` is a real env var but is exported only to hook processes and MCP servers -- never to a skill's Bash tool -- so it is ALWAYS empty here. The skill dir is correct in a cache install AND in a `--plugin-dir` dev run; the cache glob below it is a last-resort fallback only, and it would name the INSTALLED plugin. + +| Guarantee | Why it holds | +|-----------|--------------| +| The hook ignores them | Config keys the hook does not name are ignored (`INSTALL.md` Config: *"Any key not listed above is ignored"*), so metadata cannot change routing | +| `enabled` semantics unchanged | Only exactly `false` disables; adding sibling keys touches nothing | +| Cannot make a valid file unparseable | They are written by the runbook's node block that re-serializes the whole object with `JSON.stringify` — never appended as raw text. A hand-appended line could break the file, and an unparseable config silently disables the whole feature | +| `disable`/`enable` refresh `last_updated` too | Any write to the config is a write; the stamp records when the file was last written, not when it was first installed | + ### Early exit If it is already installed the way the user could want it and **the intent is not explicit** (no argument, or vague like "роутер агентов"), PRINT the status, list the operations available (`upgrade`, `enable`, `disable`, `uninstall`, `purge`, `level fast|strict`) and **STOP**. Do not re-install, do not ask a chain of questions. @@ -181,7 +221,7 @@ Every spawn prompt MUST carry: > **The level only survives if it reaches the SHELL.** `LEVEL`/`RUNBOOK` written as prose in the prompt are just text — the runbook's node blocks read them from `process.env`, and an empty `LEVEL` ABORTS the config and merge blocks (no silent `fast` fallback) instead of losing the user's choice. The spawn prompt below therefore carries the literal `export` line the agent must run FIRST, in the same Bash invocation as every runbook block. Substitute the chosen values into that `export` line, not only into the CONTEXT table. -Spawn (substitute `MODE`, `LEVEL`, `RUNBOOK`, `ASSETS_DIR` from Steps 1-4 — into BOTH the CONTEXT block and the `export` line): +Spawn (substitute `MODE`, `LEVEL`, `RUNBOOK`, `ASSETS_DIR`, `PLUGIN_VERSION`, `LAST_UPDATED` from Steps 1-4 and the Config-metadata block — into BOTH the CONTEXT block and the `export` line): ``` Task(subagent_type="brewcode:hook-creator", prompt=" @@ -209,9 +249,9 @@ CONTEXT: a runbook block (a new Bash call does NOT inherit exports from the previous one). MODE=upgrade runs the 'UPGRADE' section, which is the INSTALL blocks replayed with the level read back from the existing config — never a level the user did not pick: - export RUNBOOK='RUNBOOK' LEVEL='LEVEL' + export RUNBOOK='RUNBOOK' LEVEL='LEVEL' PLUGIN_VERSION='PLUGIN_VERSION' LAST_UPDATED='LAST_UPDATED' Then verify before writing anything: - echo \"LEVEL=\$LEVEL RUNBOOK=\$RUNBOOK\" + echo \"LEVEL=\$LEVEL RUNBOOK=\$RUNBOOK PV=\$PLUGIN_VERSION LU=\$LAST_UPDATED\" If LEVEL prints empty, STOP and report — the config and merge blocks ABORT on an empty LEVEL by design; re-export it rather than hardcoding a value. Follow the runbook at RUNBOOK exactly and use ITS commands — it self-locates its source via @@ -222,12 +262,22 @@ CONTEXT: Uninstall = strip own entries (tier-1 by basename, tier-2 by statusMessage), drop empty event arrays, delete agent-router.mjs, KEEP the config. Purge = uninstall + delete the config + delete the tmp markers. + METADATA: every mode that WRITES the config (install, upgrade, enable, disable, level) must + leave these three keys in agent-router.json: + version=\$PLUGIN_VERSION, generated_by=\"brewtools:agent-router-setup\", + last_updated=\$LAST_UPDATED. No doc_type — it is a .md-frontmatter field and never belongs + in a JSON carrier. Set them INSIDE the runbook's node block that re-serializes the + object with JSON.stringify — never by appending text to the file. An unparseable config + silently disables the whole feature, so a hand-edited append is a defect, not a shortcut. + Do NOT touch enabled or level while doing it: enabled is off only when exactly false, and + level is a record of what is wired. CONSUMER: Step 6 reports your result to the user; the settings.json you write is loaded by the NEXT Claude Code session, so a malformed merge breaks that session instead of failing here — report the exact paths you touched so they can be checked. DONE: report the settings.json path, the hooks dir, the config path with its final contents, and the runbook 'Verify' output if you ran it. The reported config MUST show - level = LEVEL — a 'fast' where the user asked for 'strict' is a FAILURE, not a detail. + level = LEVEL — a 'fast' where the user asked for 'strict' is a FAILURE, not a detail — + and version = \$PLUGIN_VERSION. Prove the file still parses: jq . . ") ``` @@ -237,7 +287,8 @@ Re-run the Step 1 status block and print the refreshed table, plus: - what changed (file, settings.json, config values), - **a NEW session is required for hook WIRING changes** (install / upgrade / level / uninstall / purge — the tier-2 entry is part of the wiring) — `/reload-plugins` is not needed, this is a plain settings.json hook; -- **config VALUE changes** (`enabled`, `genericTypes`, `neverFlag`, `minScore`, `margin`, `intents`) are read live — no restart. `level` in the config is only a record of what is wired; changing it by hand does NOT add or remove the tier-2 entry, run `level strict` / `level fast` for that; +- **config VALUE changes** (`enabled`, `genericTypes`, `neverFlag`, `minScore`, `margin`, `intents`) are read live — no restart. `level` in the config is only a record of what is wired; changing it by hand does NOT add or remove the tier-2 entry, run `level strict` / `level fast` for that. Report it as `level (recorded)` next to `tier2_refs`, never as the wiring itself; +- the config `version` now written into the file, and whether `stale` flipped to `no`; - the honest limits, at minimum: tier 2 costs a model call on every `Agent` spawn, tier 1 matches words not meaning, everything fails open. --- @@ -248,7 +299,7 @@ Re-run the Step 1 status block and print the refreshed table, plus: |------|--------|-----------|---------------|--------|-------------| | `status` | report only | — | — | — | — | | `install` | wire tier 1 (+ tier 2 if `strict`) | copied | entry merged | written | — | -| `upgrade` | re-emit from the current plugin version at the ALREADY-configured level | re-copied | entries re-merged | values preserved | kept | +| `upgrade` | re-emit from the current plugin version at the ALREADY-configured level | re-copied | entries re-merged | behavior values preserved, metadata re-stamped | kept | | `enable` | `enabled:true` | kept | kept | edited | kept | | `disable` | `enabled:false` — hook stays wired, becomes a no-op | kept | kept | edited | kept | | `uninstall` | unwire | deleted | entries stripped | **kept** | kept | @@ -265,7 +316,7 @@ Re-install is a no-op. Scope is PROJECT only — the roster is per-project, so t | Condition | Response | |-----------|----------| | `BT_ROOT` resolves but `$BT_ROOT/skills/agent-router-setup/assets` missing | ERROR: `agent-router: assets not found under $BT_ROOT — plugin cache incomplete.` STOP. | -| Neither `$CLAUDE_PLUGIN_ROOT` set nor any cached plugin dir found | ERROR: `agent-router: cannot locate plugin root — install/update brewtools first.` STOP. | +| Neither the skill dir nor any cached plugin dir yields `.claude-plugin/plugin.json` | ERROR: `agent-router: cannot locate plugin root — install/update brewtools first.` STOP. | | Status shows installed + vague intent | Print status, list available operations, STOP. Do not re-install. | | User asks for a global install | Refuse and explain: the roster is per-project, `~/.claude/*` is protected, and a global hook would route every repo against one repo's agents. Offer the project install. | | `strict` requested (or asked about) | BEFORE writing anything, state the cost: all matching hooks run in parallel and tier 1 cannot gate tier 2, so a haiku call fires on EVERY `Agent` spawn. Say it in the question or the plan, never only in the final report. | @@ -286,7 +337,9 @@ Verify the 3 assets exist and the hook parses before delegating. **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } A="$BT_ROOT/skills/agent-router-setup/assets" test -d "$A" || { echo "❌ smoke FAILED — assets dir missing: $A"; exit 1; } for f in agent-router.mjs judge-prompt.md INSTALL.md; do diff --git a/brewtools/skills/agent-router-setup/assets/INSTALL.md b/brewtools/skills/agent-router-setup/assets/INSTALL.md index 4007cea..3425c26 100644 --- a/brewtools/skills/agent-router-setup/assets/INSTALL.md +++ b/brewtools/skills/agent-router-setup/assets/INSTALL.md @@ -108,7 +108,10 @@ picks. "margin": 2, "intents": [ { "match": "regex", "expert": "brewcode:skill-creator", "label": "skill authoring" } - ] + ], + "version": "X.Y.Z", + "generated_by": "brewtools:agent-router-setup", + "last_updated": "YYYY-MM-DD" } ``` @@ -121,6 +124,7 @@ picks. | `minScore` | minimum roster score (step 7) before a project agent can win | | `margin` | how far the winner must lead the runner-up; inside the margin it is a nudge, not a deny | | `intents` | OPTIONAL override of the step-6 routes; `{ "match": , "expert": , "label": }`. **Omit the key to keep the hook's built-in 4 routes** — see the warning below | +| `version` / `generated_by` / `last_updated` | provenance, MANDATORY, re-stamped by every mode that writes this file (install, upgrade, enable, disable, level). Inert at runtime — the hook ignores unlisted keys. Never `doc_type`: that is a `.md`-frontmatter field | There is no nudge-threshold key. The nudge floor is DERIVED as `max(1, ceil(minScore / 2))`: a best score at or above it, without a clear win, @@ -152,6 +156,8 @@ WIRING changes (install / level / uninstall / purge) need a new session. |-----|--------|---------| | `RUNBOOK` | skill | absolute path to THIS file (source dir = its dirname) | | `LEVEL` | skill (user's answer) | `fast` or `strict`; REQUIRED for install/level — empty aborts | +| `PLUGIN_VERSION` | skill (optional) | `X.Y.Z` for the metadata stamp. OPTIONAL: unset/malformed falls back to `/../../../.claude-plugin/plugin.json`, resolved by the block itself. Never a literal | +| `LAST_UPDATED` | skill (optional) | `YYYY-MM-DD` for the stamp; unset falls back to the LOCAL date the block computes | These are read from `process.env` by the node blocks below — they must be REAL shell variables, exported before the block runs: @@ -166,13 +172,32 @@ and the blocks ABORT loudly rather than writing a silent `fast` over the level t user picked. Each Bash call starts a fresh shell — re-export in EVERY call, or prefix the block. +Every write of the config also stamps the three mandatory JSON metadata keys — +`version`, `generated_by`, `last_updated` (never `doc_type`: that is a `.md` +frontmatter field). `version` is resolved from `.claude-plugin/plugin.json`, never +hardcoded; `last_updated` is the LOCAL date. + **EXECUTE** config write (read-modify-write, Bash tool): ``` -CFG="$PWD/.claude/brewtools/agent-router.json" node -e ' +SRC="$(dirname "$RUNBOOK")" +CFG="$PWD/.claude/brewtools/agent-router.json" PJSON="$SRC/../../../.claude-plugin/plugin.json" node -e ' const fs=require("fs"), p=require("path"); const f=process.env.CFG; const level=(process.env.LEVEL||"").trim(); +const GB="brewtools:agent-router-setup"; +function pluginVersion(){ // env first, plugin.json fallback; NEVER a literal + const ev=(process.env.PLUGIN_VERSION||"").trim(); + if(/^[0-9]+\.[0-9]+\.[0-9]+$/.test(ev)) return ev; + try{ const j=JSON.parse(fs.readFileSync(process.env.PJSON||"","utf8")); if(typeof j.version==="string"&&j.version.trim()) return j.version.trim(); }catch{} + return ""; +} +function today(){ // LOCAL date, like date +%F - never toISOString (UTC) + const ev=(process.env.LAST_UPDATED||"").trim(); + if(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(ev)) return ev; + const d=new Date(); + return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); +} if(level!=="fast"&&level!=="strict"){ console.error("ABORT: LEVEL must be fast|strict, got: "+JSON.stringify(level)+" - export it before this block"); process.exit(1); } let c={}; if(fs.existsSync(f)){ @@ -190,10 +215,16 @@ if(!has("genericTypes")) c.genericTypes=["general-purpose","worker"]; // hand- if(!has("neverFlag")) c.neverFlag=["Explore","Plan","statusline-setup","output-style-setup","brewcode:agent-creator","brewcode:skill-creator","brewcode:hook-creator","brewcode:bash-expert"]; if(!has("minScore")) c.minScore=3; if(!has("margin")) c.margin=2; +const pv=pluginVersion(); +if(!pv){ console.error("ABORT: cannot resolve plugin version - export PLUGIN_VERSION=X.Y.Z or fix PJSON: "+process.env.PJSON); process.exit(1); } +const lu=today(); +c.version=pv; c.generated_by=GB; c.last_updated=lu; // the 3 mandatory JSON metadata keys, on EVERY write +delete c.doc_type; // frontmatter-only field; a JSON carrier never takes it fs.mkdirSync(p.dirname(f),{recursive:true}); fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n"); const back=JSON.parse(fs.readFileSync(f,"utf8")); // post-write verification if(back.level!==level){ console.error("ABORT: verification failed for "+f); process.exit(1); } +if(back.version!==pv||back.generated_by!==GB||back.last_updated!==lu){ console.error("ABORT: metadata verification failed for "+f); process.exit(1); } console.log("OK wrote "+f+" "+JSON.stringify(back)); if(back.enabled!==true) console.log("NOTE: enabled=false was preserved from the existing config - run ENABLE to switch it on"); ' && echo "✅ config" || echo "❌ FAILED" @@ -288,7 +319,8 @@ Run every block from the REPO ROOT (`$PWD` is used throughout) with `RUNBOOK` an `SRC="$(dirname "$RUNBOOK")"`. (Do not rely on any plugin env var — it is injected as prompt text and expands to empty in Bash.) 3. Write `/.claude/brewtools/agent-router.json` — run the **EXECUTE config - write** block in the *Config* section above, unchanged. + write** block in the *Config* section above. It also stamps `version` / + `generated_by` / `last_updated`; do not strip those lines out of it. 4. Merge the hook entries into `/.claude/settings.json` (create `{}` if absent), `` = `/.claude/hooks` (**EXECUTE** merge, below). @@ -398,7 +430,8 @@ process.stdout.write(lv); 2. Re-run the **EXECUTE copy** block from *INSTALL* — it overwrites `agent-router.mjs` with the current one and `node --check`s it. 3. Re-run the **EXECUTE config write** block from *Config* — with `LEVEL` unchanged it - only adds keys that a newer version introduced; `enabled`, `genericTypes`, + only adds keys that a newer version introduced and re-stamps `version` / + `generated_by` / `last_updated` to the current plugin; `enabled`, `genericTypes`, `neverFlag`, `minScore`, `margin` and `intents` are all preserved as-is. 4. Re-run the **EXECUTE merge settings** block from *INSTALL* — it strips its own stale entries first, so a moved hooks dir converges and the tier-2 judge prompt is @@ -431,11 +464,27 @@ tier-2 entry ALSO stays wired and keeps costing a model call per spawn: use `level fast` first if that is what you want stopped. **EXECUTE** using Bash tool. The block below DISABLES as written; to ENABLE, prefix -the whole line with `ON=1 ` (i.e. `ON=1 CFG="$PWD/..." node -e '...'`). Any other -value of `ON`, or no `ON` at all, disables. Re-running either direction is a no-op. +the `CFG=...` line with `ON=1 ` (i.e. `ON=1 CFG="$PWD/..." PJSON="..." node -e '...'`). +Any other value of `ON`, or no `ON` at all, disables. Re-running either direction is a +no-op. `RUNBOOK` must be exported here too — this is a config WRITE, so it re-stamps the +three metadata keys. ``` -CFG="$PWD/.claude/brewtools/agent-router.json" node -e ' +SRC="$(dirname "$RUNBOOK")" +CFG="$PWD/.claude/brewtools/agent-router.json" PJSON="$SRC/../../../.claude-plugin/plugin.json" node -e ' const fs=require("fs"), p=require("path"); const f=process.env.CFG; +const GB="brewtools:agent-router-setup"; +function pluginVersion(){ + const ev=(process.env.PLUGIN_VERSION||"").trim(); + if(/^[0-9]+\.[0-9]+\.[0-9]+$/.test(ev)) return ev; + try{ const j=JSON.parse(fs.readFileSync(process.env.PJSON||"","utf8")); if(typeof j.version==="string"&&j.version.trim()) return j.version.trim(); }catch{} + return ""; +} +function today(){ // LOCAL date, like date +%F - never toISOString (UTC) + const ev=(process.env.LAST_UPDATED||"").trim(); + if(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(ev)) return ev; + const d=new Date(); + return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); +} let c={}; if(fs.existsSync(f)){ const raw=fs.readFileSync(f,"utf8"); @@ -446,10 +495,16 @@ if(fs.existsSync(f)){ } } c.enabled = process.env.ON === "1"; +const pv=pluginVersion(); +if(!pv){ console.error("ABORT: cannot resolve plugin version - export PLUGIN_VERSION=X.Y.Z or fix PJSON: "+process.env.PJSON); process.exit(1); } +const lu=today(); +c.version=pv; c.generated_by=GB; c.last_updated=lu; // every write stamps the 3 mandatory keys +delete c.doc_type; // frontmatter-only field; a JSON carrier never takes it fs.mkdirSync(p.dirname(f),{recursive:true}); fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n"); const back=JSON.parse(fs.readFileSync(f,"utf8")); if(back.enabled!==c.enabled){ console.error("ABORT: verification failed for "+f); process.exit(1); } +if(back.version!==pv||back.generated_by!==GB||back.last_updated!==lu){ console.error("ABORT: metadata verification failed for "+f); process.exit(1); } console.log((back.enabled?"ENABLED ":"DISABLED ")+f); ' && echo "✅ toggled" || echo "❌ FAILED" ``` diff --git a/brewtools/skills/agent-router-setup/assets/agent-router.mjs b/brewtools/skills/agent-router-setup/assets/agent-router.mjs index 96265df..40f8d27 100755 --- a/brewtools/skills/agent-router-setup/assets/agent-router.mjs +++ b/brewtools/skills/agent-router-setup/assets/agent-router.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:agent-router-setup /** * agent-router - PreToolUse hook for the `Agent` tool (Node built-ins only, ESM). * @@ -425,7 +426,7 @@ function claimDeny(sessionId, root, text) { if (!ensureStateRoot()) return false; const session = safeSegment(sessionId) || 'nosession'; const dir = path.join(STATE_ROOT, session); - const marker = path.join(dir, sha1(`${root}${normalizeText(text)}`).slice(0, 32)); + const marker = path.join(dir, sha1(`${root}\0${normalizeText(text)}`).slice(0, 32)); try { lstatSync(marker); return false; // already denied once diff --git a/brewtools/skills/deploy/README.md b/brewtools/skills/deploy/README.md new file mode 100644 index 0000000..e31af4d --- /dev/null +++ b/brewtools/skills/deploy/README.md @@ -0,0 +1,111 @@ +# Deploy + +GitHub Actions deployment: workflows, releases, GHCR, CI/CD with safety gates and persistent config. Detects mode from your prompt, walks a phase-based flow (setup, create workflow, release, trigger deploy, monitor runs), and generates a companion `deploy-admin` agent that reads project GitHub/workflow/server config from `CLAUDE.local.md`. + +User-invocable only — `user-invocable: true` and `disable-model-invocation: true` in the frontmatter, so the model never auto-activates it. You type `/brewtools:deploy` or nothing runs. Not a `-setup` skill: it does not implement `status | install | upgrade | enable | disable | uninstall | purge` — those verbs are reserved for skills that install a mechanism you use afterward. `deploy` is a recurring tool with its own mode set (below). + +## Quick Start + +``` +/brewtools:deploy +``` + +No GitHub config in `CLAUDE.local.md` yet → setup. Config exists → monitor. + +## Modes + +| Mode | How to trigger | What it does | +|------|---------------|--------------| +| Setup | `setup`, `check`, `prerequisites`, `init` (or no config yet) | Verify `gh` auth, detect repo, check secrets, check SSH integration, discover workflows, persist config, generate `deploy-admin` agent | +| Create | `create`, `new workflow`, `add workflow` | Generate a new GitHub Actions workflow YAML from a template, persist it to config | +| Release | `release`, `bump`, `version`, `tag`, `publish` | Probe project release tooling, bump version, changelog, confirmation gate, commit + tag + push, monitor CI, verify published artifact | +| Deploy | `deploy`, `trigger`, `dispatch`, `run workflow` | List deployable workflows, select one, confirmation gate, trigger, watch the run, optional VPS health check | +| Monitor | `monitor`, `watch`, `status`, `check runs`, `logs` (default when config exists and no args) | Recent workflow runs, workflow states, releases, failed-run logs | +| Update agent | `update agent`, `refresh`, `rescan` | Re-discover workflows, regenerate `deploy-admin` agent from fresh data | + +## Examples + +### Good Usage + +```bash +# First run in a repo with no GitHub config -- walks setup +/brewtools:deploy + +# Scaffold a new workflow +/brewtools:deploy create a workflow that builds and pushes to GHCR + +# Cut a release +/brewtools:deploy release + +# Trigger a specific deploy workflow +/brewtools:deploy deploy the vps-deploy workflow + +# Check recent CI runs and releases +/brewtools:deploy status +``` + +### Common Mistakes + +```bash +# Expecting a bump/changelog script that does not exist in this repo +/brewtools:deploy release +# The skill probes .claude/scripts/, scripts/, package.json, Makefile first -- +# a "none" result is not a failure, it asks which files carry the version. + +# Assuming release pushes without confirmation +/brewtools:deploy release +# Step 5 is an AskUserQuestion gate before commit+tag+push -- always confirm first. + +# Running deploy/release without gh auth +/brewtools:deploy release +# P1 env check fails fast: run `gh auth login` first. +``` + +## What It Does + +| Phase | Name | Description | +|-------|------|-------------| +| P0 | Mode detection | Parses `$ARGUMENTS` for keywords, or falls back to config-presence default | +| P1 | Environment + config check | `gh` auth/version/repo/secrets check, loads existing `CLAUDE.local.md` GitHub config | +| P2 | Setup | Verify auth, detect repo, check secrets, check SSH integration section, discover workflows, persist config, gitignore `CLAUDE.local.md`, generate `deploy-admin` agent | +| P3 | Create workflow | Pick a workflow type (build+push GHCR / deploy to VPS / release / security scan / custom), write YAML, update config | +| P4 | Release | Probe tooling, bump version, changelog, confirmation gate, commit+tag+push, post-release hook, monitor CI, verify artifact | +| P5 | Deploy | List active workflows, select, confirmation gate, trigger, watch run, VPS health check if applicable | +| P6 | Monitor | Recent runs, workflow states, releases, failed-run logs, config refresh | +| Mode: update-agent | Re-discover workflows, regenerate `deploy-admin` agent | + +Every confirmation gate (release Step 5, deploy Step 4) runs in the main conversation via AskUserQuestion — never delegated to a subagent. + +## Companion Agent + +The skill generates `.claude/agents/deploy-admin.md` during setup, parametrized from the project's own GitHub config, workflow inventory and (if present) SSH server targets. The skill drives the phase-based flow directly in-session; `deploy-admin` is what you delegate a bounded release/deploy/monitor unit to afterward, or what the skill itself spawns via `Task` for a self-contained deliverable. Both share the same safety classification (READ/CREATE free, MODIFY/SERVICE/DELETE/PRIVILEGE gated) and both read `CLAUDE.local.md` for project-specific config — the skill's `references/safety-rules.md` and the agent's frontmatter body carry the same table. + +## Output + +```markdown +# Deploy [MODE] + +## Detection +| Field | Value | +## Environment +| Component | Status | +## Actions Taken +- [action 1] +## Status +[success / partial / failed] +``` + +## Tips + +- Run `/brewtools:deploy` with no arguments first in a new repo — it tells you whether setup or monitor is about to run before you commit to a mode. +- `release` is safe to interrupt at the confirmation gate (Step 5) — nothing is pushed until you approve. +- If a step reports "no post-release script" or "no external artifact to verify", that is expected for projects without one — not a failure. +- Delegate a multi-repo or multi-environment release to several `deploy-admin` spawns, one per target, rather than one agent looping over all of them. + +## Documentation + +| Link | Target | +|------|--------| +| Plugin overview | [brewtools/README.md](../../README.md) | +| Companion agent | [deploy-admin](../../agents/deploy-admin.md) | +| Docs site | https://doc-claude.brewcode.app/brewtools/skills/deploy/ | diff --git a/brewtools/skills/deploy/SKILL.md b/brewtools/skills/deploy/SKILL.md index ca42343..899c67e 100644 --- a/brewtools/skills/deploy/SKILL.md +++ b/brewtools/skills/deploy/SKILL.md @@ -190,9 +190,29 @@ EXEC: ```bash cat "${CLAUDE_SKILL_DIR}/templates/deploy-admin-agent.md.template" ``` -Replace placeholders: `{{GITHUB_CONFIG}}`=GH CFG table | `{{WORKFLOW_INVENTORY}}`=WFs table | `{{SERVER_TARGETS}}`=SSH Servers (or "No SSH servers CFG") | `{{SECRETS_LIST}}`=secret names | `{{LAST_UPDATED}}`=current ISO timestamp. +Resolve the metadata stamp (never hardcode a version). EXEC: +```bash +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } +PV=$(jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true) +PV=${PV:-$(basename "$BT_ROOT")} +echo "PLUGIN_VERSION=$PV LAST_UPDATED=$(date +%F)" +``` +> **Why the bare form.** `CLAUDE_SKILL_DIR` is a TEXT SUBSTITUTION on the skill prompt, not an env var: CC 2.1.226 rewrites only the EXACT dollar-brace literal `{CLAUDE_SKILL_DIR}` (`replace(/\$\{CLAUDE_SKILL_DIR\}/g, dirname(skillPath))` and a string-pattern `replaceAll`). A brace-modifier form such as `:-fallback` inside the braces is therefore NOT matched, reaches the shell verbatim, and its fallback ALWAYS wins. `CLAUDE_PLUGIN_ROOT` is a real env var but is exported only to hook processes and MCP servers -- never to a skill's Bash tool -- so it is ALWAYS empty here. The skill dir is correct in a cache install AND in a `--plugin-dir` dev run; the cache glob below it is a last-resort fallback only, and it would name the INSTALLED plugin. + +Replace placeholders: `{{GITHUB_CONFIG}}`=GH CFG table | `{{WORKFLOW_INVENTORY}}`=WFs table | `{{SERVER_TARGETS}}`=SSH Servers (or "No SSH servers CFG") | `{{SECRETS_LIST}}`=secret names | `{PLUGIN_VERSION}`=`PV` above | `{LAST_UPDATED}`=`date +%F` (`YYYY-MM-DD`, quoted in the frontmatter). Write to `.claude/agents/deploy-admin.md`. +Leftover-token gate -- BOTH brace families (this skill's `{{...}}` tokens and the single-brace metadata ones). **EXECUTE** using Bash tool: +```bash +F="$PWD/.claude/agents/deploy-admin.md" +test -f "$F" || { echo "❌ FAILED -- $F not written"; exit 1; } +LEFT="$(grep -nE '\{\{|\{(PLUGIN_VERSION|GENERATED_BY|LAST_UPDATED)\}' "$F" || true)" +test -z "$LEFT" && echo "✅ no leftover placeholders" || { echo "❌ FAILED -- leftover placeholders:"; echo "$LEFT"; } +``` +> **STOP if ❌** -- re-substitute before continuing. + --- ## P3: Create WF @@ -303,7 +323,8 @@ git push && git push --tags && echo "OK push" || echo "FAILED push" Only if POST_SCRIPT was found in Step 0. Otherwise SKIP and report "no post-release script". EXEC: ```bash -bash && echo "OK post-release" || echo "FAILED post-release" +POST_SCRIPT="" +bash "$POST_SCRIPT" && echo "OK post-release" || echo "FAILED post-release" ``` ### Step 8: Monitor CI @@ -429,7 +450,7 @@ bash "${CLAUDE_SKILL_DIR}/scripts/deploy-local-ops.sh" read-github 2>/dev/null ### Step 4: Regenerate Agent Read TPL, replace placeholders with fresh data, write to `.claude/agents/deploy-admin.md`. -Set `{{LAST_UPDATED}}` = current timestamp. Report what changed. +Re-resolve `{PLUGIN_VERSION}` + `{LAST_UPDATED}` exactly as in P2 Step 8 -- a regeneration is a new write, so the stamp is refreshed, never carried over. Report what changed. diff --git a/brewtools/skills/deploy/templates/deploy-admin-agent.md.template b/brewtools/skills/deploy/templates/deploy-admin-agent.md.template index e176edd..058035f 100644 --- a/brewtools/skills/deploy/templates/deploy-admin-agent.md.template +++ b/brewtools/skills/deploy/templates/deploy-admin-agent.md.template @@ -4,12 +4,14 @@ model: opus # description MUST be <=100 chars, single line description: "GitHub Actions and deployment agent with live workflow inventory." tools: Read, Write, Edit, Bash, Grep, Glob, AskUserQuestion +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "brewtools:deploy" +last_updated: "{LAST_UPDATED}" --- # Deploy Admin Agent -> Last updated: {{LAST_UPDATED}} - ## GitHub Config {{GITHUB_CONFIG}} diff --git a/brewtools/skills/manager-setup/README.md b/brewtools/skills/manager-setup/README.md index 648600f..c87a4d1 100644 --- a/brewtools/skills/manager-setup/README.md +++ b/brewtools/skills/manager-setup/README.md @@ -86,7 +86,7 @@ The HARD wall is an **installed-into-the-project** `PreToolUse` guard, NOT a plu | Runtime kill-switch | `/.claude/brewtools/manager/state.json` `{hard}` | `enable`/`disable` flip this only — never touch `settings.local.json` | `install` = copy guard + off-switch CLI + register (idempotent) + arm. `/reload` only on first install. -`upgrade` = re-copy both files + re-register if missing, state untouched. Aborts when not installed. Also backfills the off-switch CLI into projects installed before it existed. +`upgrade` = re-copy both files + re-register if missing + restamp `state.json`'s `version`/`generated_by`/`last_updated` (empty-partial `writeState`, so `hard`/`level` survive verbatim — that is what clears the `stale` verdict `setup-status` reads off the same key). Aborts when not installed. Also backfills the off-switch CLI into projects installed before it existed. `enable` / `disable` = flip `state.hard` only. Guard stays registered; while disabled it no-ops. `uninstall` = TWO Bash calls: the bare exempt disarm, then deregister + delete copies. Then `/reload`. `purge` = `uninstall` + delete `.claude/brewtools/manager/` (state + prompt overrides). diff --git a/brewtools/skills/manager-setup/SKILL.md b/brewtools/skills/manager-setup/SKILL.md index 5a4b190..7e15290 100644 --- a/brewtools/skills/manager-setup/SKILL.md +++ b/brewtools/skills/manager-setup/SKILL.md @@ -59,10 +59,12 @@ model: sonnet ### BT_ROOT Resolver -`$CLAUDE_PLUGIN_ROOT` is NOT inherited by the Bash tool in main-conversation slash invocations. Every Bash block resolves `BT_ROOT` dynamically (no hardcoded version): +The plugin root is resolved from the skill's OWN directory (the `CLAUDE_SKILL_DIR` prompt substitution), never from `CLAUDE_PLUGIN_ROOT` -- that env var is not exported to a skill's Bash tool. Every Bash block resolves `BT_ROOT` this way (no hardcoded version): ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/lib/manager-state.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } ``` @@ -144,7 +146,9 @@ If the action is ambiguous or signals conflict (e.g. enable + disable, a task th **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/hardmode-guard.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } node --input-type=module -e " import {writeState} from '${BT_ROOT}/hooks/lib/manager-state.mjs'; @@ -190,18 +194,35 @@ After the block: > The command in the registered entry uses an ABSOLUTE path to the copied guard and a `# brewtools-manager-guard` tag comment so `uninstall` can find it. Scope is always `project` — there is no global wall, never pass `'global'`. -### upgrade (re-emit the guard from the current plugin version — arm state untouched) +### upgrade (re-emit the guard from the current plugin version — arm state kept, provenance restamped) -`upgrade` replays the install against the CURRENT plugin version so a `claude plugin update` finally reaches an already-installed project: it re-copies `hardmode-guard.mjs` **and `manager-state.mjs`** and re-registers the entry if it went missing. A project installed before the off-switch CLI existed has no project copy of `manager-state.mjs`; `upgrade` is what backfills it, so run it once after updating brewtools. It **never calls `writeState`** — `hard` and `level` are preserved exactly, so a disarmed wall stays disarmed and an armed one stays armed. It asks nothing. +`upgrade` replays the install against the CURRENT plugin version so a `claude plugin update` finally reaches an already-installed project: it re-copies `hardmode-guard.mjs` **and `manager-state.mjs`** and re-registers the entry if it went missing. A project installed before the off-switch CLI existed has no project copy of `manager-state.mjs`; `upgrade` is what backfills it, so run it once after updating brewtools. It asks nothing. + +> **It restamps `state.json`, and ONLY the metadata trio.** `setup-status` row 8 reads the +> top-level `"version"` of `.claude/brewtools/manager/state.json` as the headline; the guard's +> `brewcode-meta:` line is SECOND precedence, consulted only when that key is absent. So an +> upgrade that re-copied the guard but left `state.json` alone reported the old version forever +> and `status` printed `stale` after every `upgrade` — the staleness could never be cleared. +> The fix is the docsync-setup shape (`brewdoc/skills/docsync-setup/SKILL.md` mode `upgrade`): +> call `writeState('project', {}, cwd)` — an EMPTY partial. `writeState` merges +> `{...existing, ...partial}` and then stamps `version` / `generated_by` / `last_updated`, so with +> nothing in the partial it rewrites the trio and **nothing else**. `hard` and `level` are +> preserved byte-for-byte out of the existing file: a disarmed wall stays disarmed, an armed one +> stays armed, a customized `level` survives. That is what `stateUntouched` used to promise and it +> still holds for the ARM state — the block now reports `armStatePreserved` + `stateRestamped` so +> the two are not conflated. It ABORTS when the project has no wall installed. `upgrade` must never be a back door that arms a wall the user never asked for — an uninstalled project is told to run `install`. **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/hardmode-guard.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } node --input-type=module -e " import fs from 'node:fs'; import path from 'node:path'; +import {writeState, resolveStatePath} from '${BT_ROOT}/hooks/lib/manager-state.mjs'; const cwd = process.cwd(); const src = '${BT_ROOT}/hooks/hardmode-guard.mjs'; const dir = path.join(cwd, '.claude', 'brewtools', 'manager'); @@ -225,8 +246,16 @@ const tmp = settings + '.tmp'; fs.mkdirSync(path.dirname(settings), {recursive:true}); fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); fs.renameSync(tmp, settings); -console.log(JSON.stringify({guardReplaced:true, guard, newlyRegistered, stateUntouched:true})); -" && echo "✅ wall upgraded (arm state preserved)" || echo "❌ FAILED upgrade" +// Restamp the metadata trio ONLY — empty partial, so hard/level/mode and every +// unknown key merge through from the existing file untouched. +let before = null; +try { before = JSON.parse(fs.readFileSync(resolveStatePath('project', cwd),'utf8')); } catch {} +const w = await writeState('project', {}, cwd); +const armStatePreserved = !before || (w.state.hard === before.hard && w.state.level === before.level); +console.log(JSON.stringify({guardReplaced:true, guard, newlyRegistered, + stateRestamped:{version:w.state.version, generated_by:w.state.generated_by, last_updated:w.state.last_updated}, + hard:w.state.hard, level:w.state.level, armStatePreserved})); +" && echo "✅ wall upgraded (arm state preserved, state.json restamped)" || echo "❌ FAILED upgrade" ``` Surface the `/reload` note only when `newlyRegistered:true`. @@ -237,7 +266,9 @@ Surface the `/reload` note only when `newlyRegistered:true`. **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/lib/manager-state.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } node --input-type=module -e " import {writeState} from '${BT_ROOT}/hooks/lib/manager-state.mjs'; @@ -289,7 +320,9 @@ node /.claude/brewtools/manager/manager-state.mjs set hard=false **EXECUTE step 2** using Bash tool (only after step 1 printed its JSON): ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/lib/manager-state.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } node --input-type=module -e " import fs from 'node:fs'; import path from 'node:path'; @@ -332,7 +365,9 @@ This is the only destructive action. Say what will be deleted BEFORE running it, **EXECUTE** using Bash tool as a THIRD call, after both `uninstall` steps (step 1 disarms — without it this block is denied by the armed wall; step 2 deregisters). Substitute `SCOPE`: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/lib/manager-prompts.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } node --input-type=module -e " import {resolvePromptPath} from '${BT_ROOT}/hooks/lib/manager-prompts.mjs'; @@ -367,7 +402,7 @@ Read merged state, resolve BOTH mode blocks, detect whether the guard is registe 1. **How `++m` works** — ALWAYS, per-turn, hook-driven (`manager-prompt.mjs`), independent of this skill. `++m` is plan-aware: it injects the planmode block (full + plan addon) when `permission_mode === 'plan'`, else the plain full block — there is NO separate `++mp` codeword. Show BOTH resolved blocks (full + planmode) so the user sees each variant. Also state: when the HARD wall is armed, the Manager (full) block is ALSO ambient-injected every turn with no codeword needed (codewords and wall injection are independent). The session-start banner is the other read-only plugin layer. 2. **The wall delivery model** — it is INSTALLED INTO this project, not a plugin hook: registered (once) in `/.claude/settings.local.json` (personal, gitignored), gated at runtime by project `state.json {hard}`. Report BOTH: is it registered? is it armed (`hard`)? 3. **Current WALL state for THIS project** — `hard` armed/disarmed, `level` strict/balanced, and a brief allowlist summary (what main session may/may not do). -4. **How the verbs work** — `install` = install+arm (`/reload` only on FIRST install), `upgrade` = re-emit the guard with the arm state preserved, `enable` = arm an installed wall, `disable` = disarm only (registration kept), `uninstall` = deregister (state + prompt overrides kept), `purge` = uninstall + delete state and overrides, `level` = strictness. +4. **How the verbs work** — `install` = install+arm (`/reload` only on FIRST install), `upgrade` = re-emit the guard with the arm state preserved and `state.json`'s metadata trio restamped to this plugin version, `enable` = arm an installed wall, `disable` = disarm only (registration kept), `uninstall` = deregister (state + prompt overrides kept), `purge` = uninstall + delete state and overrides, `level` = strictness. > **WHILE THE WALL IS ARMED, DO NOT RUN THE BASH BLOCK BELOW** — its `BT_ROOT=` prelude and `&& echo` tail are exactly what the guard denies. Build the same report with always-allowed tools instead: > - wall state → Bash, VERBATIM, nothing appended: `node /.claude/brewtools/manager/manager-state.mjs get` @@ -378,7 +413,9 @@ Read merged state, resolve BOTH mode blocks, detect whether the guard is registe **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/lib/manager-state.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } node --input-type=module -e " import {resolveState} from '${BT_ROOT}/hooks/lib/manager-state.mjs'; @@ -396,15 +433,29 @@ try { const arr = cfg && cfg.hooks && Array.isArray(cfg.hooks.PreToolUse) ? cfg.hooks.PreToolUse : []; registered = arr.some(m => Array.isArray(m.hooks) && m.hooks.some(h => typeof h.command==='string' && (h.command.includes('brewtools-manager-guard') || h.command.includes('hardmode-guard.mjs')))); } catch {} +// Version is read from the RAW project state file, never from resolveState(): a merge with +// DEFAULT_STATE would hand an old file the current version and hide the staleness. +let stateVersion = null; +try { + const raw = JSON.parse(fs.readFileSync(path.join(cwd,'.claude','brewtools','manager','state.json'),'utf8')); + stateVersion = (raw && typeof raw.version === 'string') ? raw.version : null; +} catch {} +let pluginVersion = null; +try { pluginVersion = JSON.parse(fs.readFileSync(path.join(root,'.claude-plugin','plugin.json'),'utf8')).version || null; } catch {} console.log(JSON.stringify({ hard: st.hard, level: st.level, mode: st.mode, stateSource: st.source, - registered, settings, + registered, settings, stateVersion, pluginVersion, + stale: (stateVersion && pluginVersion) ? (stateVersion !== pluginVersion) : null, promptSource: { full: full.source, planmode: plan.source }, blocks: { full: full.text, planmode: plan.text } }, null, 2)); " && echo "✅ status" || echo "❌ FAILED status" ``` +> `stateVersion` is `null` on any state file written before the metadata keys existed, and on a project with no state file. `null` means UNKNOWN — never report it as up to date. `stale: true` -> recommend `upgrade`. +> +> **Dependency (owner of `brewtools/hooks/lib/manager-state.mjs`):** this reads `version` off `state.json` verbatim. It needs `DEFAULT_STATE` / `writeState` to persist `version` (plugin `X.Y.Z`), `generated_by: "brewtools:manager-setup"` and `last_updated` (`YYYY-MM-DD`) — the JSON trio, never `doc_type` — and `resolveState` to keep passing unknown keys through untouched. That has landed; on a state file written before the metadata keys existed `stateVersion` simply stays `null` — this block cannot break. + Render using the canonical status block in `references/hard.md`, filling in `hard`, `level`, `stateSource`, prompt sources, and pasting both resolved blocks under their headers. Shape: ``` # Manager — status @@ -426,8 +477,9 @@ They fire on every prompt that contains them. This skill never turns them on or Delivery: INSTALLED into this project (not a plugin hook). Registered once in .claude/settings.local.json (personal, gitignored), gated at runtime by .claude/brewtools/manager/state.json {hard}. When armed, the main session physically cannot Write/Edit/WebFetch — only delegate (Task/Agent), read (Read/Grep/Glob), and track (TodoWrite). For Bash: at level=strict ALL Bash is denied; at balanced only mutating Bash is denied — read-only inspection allowed. Allowlist summary: +State version: plugin: <"— run upgrade" when stale> Install: /brewtools:manager-setup install (install+arm; /reload only on FIRST install) -Upgrade: /brewtools:manager-setup upgrade (re-copy the guard from this plugin version; arm state kept) +Upgrade: /brewtools:manager-setup upgrade (re-copy the guard + restamp state.json; arm state kept) Enable: /brewtools:manager-setup enable (arm an already-installed wall) Disable: /brewtools:manager-setup disable (disarm only — registration kept, guard no-ops) Uninstall: /brewtools:manager-setup uninstall (deregister from settings.local.json, then /reload) @@ -445,7 +497,9 @@ Operates on the Manager prompt text (internal mode `full`). If no project/global **EXECUTE** using Bash tool (substitute `SCOPE`): ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -f "$BT_ROOT/hooks/lib/manager-prompts.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } node --input-type=module -e " import {resolvePromptPath, resolvePrompt} from '${BT_ROOT}/hooks/lib/manager-prompts.mjs'; @@ -520,7 +574,7 @@ This skill follows the same Manager rules it installs. For any real implementati | `install`/`upgrade` requested but `$BT_ROOT/hooks/hardmode-guard.mjs` missing | ERROR: `manager-setup: guard source not found under $BT_ROOT — reinstall brewtools.` STOP. | | `uninstall`/`purge` requested while `state.hard` is true | Run the bare exempt disarm command as its own FIRST Bash call, then the deregistration block — never edit settings under an armed wall, and never merge the two calls. | | Any Bash block here denied by the guard with `Manager HARD wall is ON` | You appended something to the exempt command, or the wall is armed and you used a `BT_ROOT=`/`&& echo` block. Re-issue the bare `node /.claude/brewtools/manager/manager-state.mjs set hard=false`, or delegate the block to a subagent. | -| Neither `$CLAUDE_PLUGIN_ROOT` set nor any cached plugin dir found | ERROR: `manager-setup: cannot locate plugin root — install/update brewtools first.` STOP. | +| Neither the skill dir nor any cached plugin dir yields `.claude-plugin/plugin.json` | ERROR: `manager-setup: cannot locate plugin root — install/update brewtools first.` STOP. | | Intent ambiguous / conflicting (incl. hard-one-shot vs manager-run) | `AskUserQuestion` with candidate actions. | | `resolvePrompt` returns `source:'missing'` | ERROR: `manager-setup: no prompt found for — reinstall brewtools.` STOP. | | `--scope global` requested for `install`/`upgrade`/`enable`/`disable`/`uninstall`/`level` | Ignore the global scope, write `project`, and note: the wall is project-only. | diff --git a/brewtools/skills/ssh/README.md b/brewtools/skills/ssh/README.md new file mode 100644 index 0000000..a7bdd15 --- /dev/null +++ b/brewtools/skills/ssh/README.md @@ -0,0 +1,112 @@ +# SSH + +SSH server management: connect, configure, deploy, administer Linux servers with safety gates and persistent config. Discovers or connects to a server, classifies every command by risk (READ/CREATE free, MODIFY/SERVICE/DELETE/PRIVILEGE gated), and generates a companion `ssh-admin` agent that reads server inventory from `CLAUDE.local.md`. + +User-invocable only — `user-invocable: true` and `disable-model-invocation: true` in the frontmatter, so the model never auto-activates it. You type `/brewtools:ssh` or nothing runs. Not a `-setup` skill: it does not implement `status | install | upgrade | enable | disable | uninstall | purge` — those verbs are reserved for skills that install a mechanism you use afterward. `ssh` is a recurring tool with its own mode set (below). + +## Quick Start + +``` +/brewtools:ssh +``` + +No server configured yet → setup. Server(s) configured, no args → execute (asks which one). + +## Modes + +| Mode | How to trigger | What it does | +|------|---------------|--------------| +| Setup | `setup`, `new server`, `add server` (or no server configured) | Gather connection details, discover/try SSH keys, fall back to password + key install, add `~/.ssh/config` entry, discover the server, persist to config, generate `ssh-admin` agent | +| Connect | `connect to`, `ssh to`, `login` | Uses the single configured server, or asks which one if multiple | +| Configure | `configure`, `config`, `harden` | Asks which server, then executes the requested config change under the same command classification | +| Execute | any other text (default when servers are configured and args are non-empty) | Plans and classifies the requested commands, executes after confirmation gates where required | +| Update agent | `update agent`, `refresh agent`, `refresh` | Re-discovers up to 3 configured servers per run, refreshes `ssh-admin` agent | + +## Examples + +### Good Usage + +```bash +# First run, no servers configured -- walks connection setup +/brewtools:ssh + +# Add a new server +/brewtools:ssh setup new server vps-main 203.0.113.5 + +# Connect to the configured default +/brewtools:ssh connect to vps-main + +# Run a read-only check -- no confirmation needed +/brewtools:ssh check disk space on vps-main + +# Restart a service -- MODIFY/SERVICE, asks for confirmation first +/brewtools:ssh restart the app container on vps-main +``` + +### Common Mistakes + +```bash +# Expecting a destructive command to run without confirmation +/brewtools:ssh remove the old docker volume on vps-main +# DELETE classification always asks via AskUserQuestion, with an explicit warning. + +# Sending more than 5 commands in one invocation +/brewtools:ssh run these 8 commands on vps-main... +# Phase 5 caps at 5 SSH commands per invocation; beyond that it delegates to ssh-admin. + +# Assuming password auth works non-interactively +/brewtools:ssh setup new server ... +# BatchMode=yes is required; a key-auth failure falls back to a one-time +# ssh-copy-id step the USER runs manually -- Claude Code cannot enter a password. +``` + +## What It Does + +| Phase | Name | Description | +|-------|------|-------------| +| Phase 0 | Mode detection | Parses `$ARGUMENTS` for keywords, or falls back to server-presence default | +| Phase 1 | Environment + config check | SSH key/agent check, loads existing `CLAUDE.local.md` server list | +| Phase 2 | Connection setup | Gather host/user/port/name, try key auth, fall back to password + generated key, write `~/.ssh/config` entry, final connection test | +| Phase 3 | Server discovery | OS, kernel, arch, Docker version, disk, running containers, services, current user/groups | +| Phase 4 | Persist config | Update `CLAUDE.local.md`, gitignore it, generate `ssh-admin` agent, optionally set as default server | +| Phase 5 | Execute | Classify requested commands (READ/CREATE/MODIFY/SERVICE/DELETE/PRIVILEGE), confirmation gate for MODIFY+, execute directly or delegate to `ssh-admin` for multi-step work | +| Phase 6 | Session report | Server, mode, actions, changes, status; refreshes config/agent if server state changed | +| Mode: update-agent | Re-discover up to 3 configured servers, refresh `ssh-admin` agent | + +Confirmation gates for MODIFY/SERVICE/DELETE/PRIVILEGE commands run in the main conversation via AskUserQuestion and are never delegated. + +## Companion Agent + +The skill generates `.claude/agents/ssh-admin.md` during Phase 4 setup, parametrized from the discovered server inventory. The skill drives connection setup and small execute requests directly in-session; for a bounded multi-command job on one host it delegates to `ssh-admin` via `Task` (one agent per host — a multi-server job is split into one spawn per server, never one agent looping over all of them). Both share the same command-classification table (READ/CREATE free, MODIFY/SERVICE/DELETE/PRIVILEGE gated) and both read `CLAUDE.local.md` for server inventory. + +## Output + +```markdown +# SSH [MODE] + +## Detection +| Field | Value | +## Environment +| Component | Status | +## Server: [NAME] +| Property | Value | +## Actions Taken +- [action 1] +## Status +[success / partial / failed] +``` + +## Tips + +- Run `/brewtools:ssh` with no arguments first — it tells you whether setup or execute is about to run before you commit to a mode. +- READ/CREATE commands execute freely; MODIFY/SERVICE ask once, DELETE/PRIVILEGE ask with an explicit destructive-action warning. +- If key auth fails during setup, expect a manual `ssh-copy-id` step — Claude Code cannot enter an interactive password. +- Delegate a multi-server task to several `ssh-admin` spawns, one per host, rather than one agent looping over all of them. + +## Documentation + +| Link | Target | +|------|--------| +| Plugin overview | [brewtools/README.md](../../README.md) | +| Companion agent | [ssh-admin](../../agents/ssh-admin.md) | +| Docs site | https://doc-claude.brewcode.app/brewtools/skills/ssh/ | diff --git a/brewtools/skills/ssh/SKILL.md b/brewtools/skills/ssh/SKILL.md index 7135700..ce79245 100644 --- a/brewtools/skills/ssh/SKILL.md +++ b/brewtools/skills/ssh/SKILL.md @@ -287,13 +287,34 @@ grep -q "CLAUDE.local.md" .gitignore 2>/dev/null && echo "EXISTS" || (echo "CLAU cat "${CLAUDE_SKILL_DIR}/templates/ssh-admin-agent.md.template" ``` +Resolve the metadata stamp (never hardcode a version). **EXECUTE** using Bash tool: +```bash +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } +PV=$(jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true) +PV=${PV:-$(basename "$BT_ROOT")} +echo "PLUGIN_VERSION=$PV LAST_UPDATED=$(date +%F)" +``` +> **Why the bare form.** `CLAUDE_SKILL_DIR` is a TEXT SUBSTITUTION on the skill prompt, not an env var: CC 2.1.226 rewrites only the EXACT dollar-brace literal `{CLAUDE_SKILL_DIR}` (`replace(/\$\{CLAUDE_SKILL_DIR\}/g, dirname(skillPath))` and a string-pattern `replaceAll`). A brace-modifier form such as `:-fallback` inside the braces is therefore NOT matched, reaches the shell verbatim, and its fallback ALWAYS wins. `CLAUDE_PLUGIN_ROOT` is a real env var but is exported only to hook processes and MCP servers -- never to a skill's Bash tool -- so it is ALWAYS empty here. The skill dir is correct in a cache install AND in a `--plugin-dir` dev run; the cache glob below it is a last-resort fallback only, and it would name the INSTALLED plugin. + Replace placeholders in template: - `{{SERVER_INVENTORY}}` -- server table from CLAUDE.local.md - `{{SERVER_DETAILS}}` -- discovered OS/Docker/disk info per server -- `{{LAST_UPDATED}}` -- current ISO timestamp +- `{PLUGIN_VERSION}` -- `PV` from the block above +- `{LAST_UPDATED}` -- `date +%F` (`YYYY-MM-DD`), quoted in the frontmatter Write result to `.claude/agents/ssh-admin.md` using Write tool. +Leftover-token gate -- BOTH brace families (this skill's `{{...}}` tokens and the single-brace metadata ones). **EXECUTE** using Bash tool: +```bash +F="$PWD/.claude/agents/ssh-admin.md" +test -f "$F" || { echo "❌ FAILED -- $F not written"; exit 1; } +LEFT="$(grep -nE '\{\{|\{(PLUGIN_VERSION|GENERATED_BY|LAST_UPDATED)\}' "$F" || true)" +test -z "$LEFT" && echo "✅ no leftover placeholders" || { echo "❌ FAILED -- leftover placeholders:"; echo "$LEFT"; } +``` +> **STOP if ❌** -- re-substitute before continuing. + ### Step 4: Default Server If this is the first server, set as default automatically. @@ -441,7 +462,7 @@ bash "${CLAUDE_SKILL_DIR}/scripts/server-discover.sh" "USER@HOST" PORT && echo " ### Step 3: Update Config & Agent -Update CLAUDE.local.md with fresh data for each server. Regenerate `.claude/agents/ssh-admin.md` from template with updated inventory. Set `{{LAST_UPDATED}}` to current timestamp. Report what changed since last update. +Update CLAUDE.local.md with fresh data for each server. Regenerate `.claude/agents/ssh-admin.md` from template with updated inventory. Re-resolve `{PLUGIN_VERSION}` and `{LAST_UPDATED}` exactly as in Install Step 3 -- a regeneration is a new write, so the stamp is refreshed, never carried over. Report what changed since last update. diff --git a/brewtools/skills/ssh/templates/ssh-admin-agent.md.template b/brewtools/skills/ssh/templates/ssh-admin-agent.md.template index a5fb040..8eb2c21 100644 --- a/brewtools/skills/ssh/templates/ssh-admin-agent.md.template +++ b/brewtools/skills/ssh/templates/ssh-admin-agent.md.template @@ -4,12 +4,14 @@ model: opus # description MUST be <=100 chars, single line description: "SSH server admin with live inventory. Runs remote commands with safety classification." tools: Read, Write, Edit, Bash, Grep, Glob, AskUserQuestion +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "brewtools:ssh" +last_updated: "{LAST_UPDATED}" --- # SSH Admin Agent -> Last updated: {{LAST_UPDATED}} - ## Server Inventory {{SERVER_INVENTORY}} diff --git a/brewtools/skills/task-board-setup/README.md b/brewtools/skills/task-board-setup/README.md index ee27afc..e0a085e 100644 --- a/brewtools/skills/task-board-setup/README.md +++ b/brewtools/skills/task-board-setup/README.md @@ -6,7 +6,7 @@ |-------|-------| | Command | `/brewtools:task-board-setup` | | Model | opus | -| Arguments | `[status\|install\|upgrade\|uninstall\|purge]` (optional) `[target repo path]` (empty = current dir) `["free-text directive"]` (optional) | +| Arguments | `[status\|install\|upgrade\|enable\|disable\|uninstall\|purge]` (optional) `[target repo path]` (empty = current dir) `["free-text directive"]` (optional) | ## Overview @@ -78,6 +78,8 @@ A repo that already has `.claude/features/board.md` cannot be re-installed. Use Upgrade is **additive only**: it writes the new `task-spec` skill and the spec templates outright, recovers the original findings from the deployed artifacts, and re-runs the domain-agent inventory. Every edit of an existing file is shown as a diff and gated behind AskUserQuestion. Existing task ids, scope ids and board rows are never renumbered or deleted; backfilled `spec:` values are `pending` or `none`, never `full`. +It also **restamps** the nine stamped artifacts -- `version`, `generated_by`, `last_updated` in their frontmatter, nothing else. That step is ungated and runs even when the content layer is already complete, because the `version:` of `.claude/features/board.md` is what `/brewcode:setup-status` reads: without it, `status` would keep reporting `stale` after every successful `upgrade`. + ## CLAUDE.md optimization (optional, gated) An opt-in phase that runs once the board is in place. It is strictly **propose-only** -- every change is behind AskUserQuestion, nothing is rewritten without your yes. It: @@ -108,6 +110,12 @@ The free-text directive (argument 2) tunes this phase: toggle individual sub-ste # Retrofit the spec + design layer onto a repo that already has a board /brewtools:task-board-setup upgrade ../repo +# Pause the board: park the agent/skills/rule, tasks and files all stay +/brewtools:task-board-setup disable ../repo + +# Resume it -- nothing is regenerated +/brewtools:task-board-setup enable ../repo + # Remove the generated agent/skills/rule, KEEP every task under .claude/features/** /brewtools:task-board-setup uninstall ../repo @@ -121,11 +129,15 @@ The free-text directive (argument 2) tunes this phase: toggle individual sub-ste |------|----------------------|------------------------|------| | `status` | — | — | never — read-only | | `install` | written | written | full P1 confirmation | -| `upgrade` | spec layer added | additive edits only | per-file diff gate | +| `upgrade` | spec layer added + metadata restamped | additive edits only + metadata restamped | per-file diff gate (the restamp is never asked) | +| `disable` | renamed to `*.disabled` | untouched | never | +| `enable` | renamed back | untouched | never | | `uninstall` | deleted | **kept** | one confirmation | | `purge` | deleted | deleted | one confirmation, task counts stated | -No verb = `status` when a board is already deployed at the target, `install` when it is not. `init`, `setup`, `on`, `off`, `remove` and `reset` are no longer command words. +Canonical order: `status | install | upgrade | enable | disable | uninstall | purge`. No verb = `status` when a board is already deployed at the target, `install` when it is not. `init`, `on`, `off`, `setup`, `remove`, `reset`, `create`, `update` and `cleanup` are no longer command words. + +`disable` is the reversible pause, not a removal. Claude Code discovers a project agent only as `.claude/agents/.md`, a project skill only as `/SKILL.md`, and auto-loads a rule only as `.claude/rules/*.md` -- so renaming those four to `*.disabled` is the entire switch. Every byte survives, `.claude/features/**` is never touched, and `enable` moves them back without re-running a single analysis agent. `uninstall` and `purge` delete the parked twins along with the live files, so a DISABLED board leaves nothing orphaned. ## ID convention (deployed) diff --git a/brewtools/skills/task-board-setup/SKILL.md b/brewtools/skills/task-board-setup/SKILL.md index 9d00dce..2b2bead 100644 --- a/brewtools/skills/task-board-setup/SKILL.md +++ b/brewtools/skills/task-board-setup/SKILL.md @@ -3,11 +3,9 @@ name: brewtools:task-board-setup description: "Generator: deploys a file-based Kanban into any repo via multi-agent analysis, an optional spec + system-design layer (task-spec skill, per-task spec/design docs, domain-architect fan-out), and an optional gated CLAUDE.md-optimization pass. `upgrade` retrofits the spec layer onto an already-deployed board. Triggers: init task board, scaffold kanban, task tracker, upgrade task board, канбан-доска, спек-слой." user-invocable: true disable-model-invocation: true -argument-hint: "[status|install|upgrade|uninstall|purge] [target repo path | empty = cwd] [free-text directive, e.g. 'also dedupe rules', 'skip module split']" +argument-hint: "[status|install|upgrade|enable|disable|uninstall|purge] [target repo path | empty = cwd] [free-text directive, e.g. 'also dedupe rules', 'skip module split']" allowed-tools: [Read, Write, Edit, Bash, Glob, Grep, Agent, AskUserQuestion] model: opus -meta: - phases: [P0, PS, PU, PR, P1, P2, P3, P3.5, P4, P5, P5.5] --- [DICT: TT=task-tracker agent (generated), TB=task-board skill (generated), BRD=board.md, FEAT=.claude/features, EXCL=source-path exclusions, REL=release style (vX.Y.Z tag | commit SHA | no tag), DOM=domain id segment, FM=frontmatter, TS=task-spec skill (generated), SPEC_MODE=spec+design layer opt-in, PS=status phase, PU=upgrade phase, PR=uninstall/purge phase] @@ -79,12 +77,12 @@ DONE: files written under closed/ + backlog/, and a manifest: docs migrated by s ## P0: Resolve verb + target repo + parse directive `$ARGUMENTS` carries THREE optional, order-independent things: (a) a MODE verb, (b) a target repo PATH, (c) a free-text DIRECTIVE that tunes the optional CLAUDE.md-optimization phase (e.g. "also dedupe rules", "skip module split", "report only"). Disambiguate: -- A standalone token (case-insensitive) from the canonical set `status | install | upgrade | uninstall | purge` sets `MODE` and is CONSUMED -- it never reaches `DIR`. A word merely containing one of them inside a sentence (e.g. "upgrade the rules wording") is NOT the verb; only a standalone token is. Two conflicting verbs -> `AskUserQuestion`. +- A standalone token (case-insensitive) from the canonical set `status | install | upgrade | enable | disable | uninstall | purge` sets `MODE` and is CONSUMED -- it never reaches `DIR`. A word merely containing one of them inside a sentence (e.g. "upgrade the rules wording") is NOT the verb; only a standalone token is. Two conflicting verbs -> `AskUserQuestion`. - A token that resolves to an existing directory (abs, or relative to cwd) = the PATH. Empty / unresolvable-as-dir = cwd. - Everything else (the remaining free text) = `DIR`, passed verbatim to P5.5. If no path-like token is present, the whole non-verb argument is `DIR` and `TARGET`=cwd. - If ambiguous (e.g. a bare word that is both a plausible relative dir and a directive verb), prefer PATH only if it resolves to an existing dir; else treat as DIR. -> `init`, `on`, `off`, `setup`, `remove` and `reset` are NOT verbs any more. Recognize `init`/`setup` in free text as a synonym of `install` and `remove`/`reset` as a synonym of `uninstall`/`purge` (ask which), then always echo the canonical verb back. Never print a removed alias as a command. +> `init`, `on`, `off`, `setup`, `remove`, `reset`, `create`, `update` and `cleanup` are NOT verbs any more. Recognize `init`/`setup`/`create` in free text as a synonym of `install`, `update` as a synonym of `upgrade`, `on`/`off` as synonyms of `enable`/`disable`, and `remove`/`reset`/`cleanup` as a synonym of `uninstall`/`purge` (ask which), then always echo the canonical verb back. Never print a removed alias as a command. **No verb given** -- resolve `MODE` from the board itself, after `TARGET` is known: a deployed board (`TARGET/.claude/features/board.md` exists) -> `status`; nothing deployed -> `install` into that `TARGET`. A bare path on a fresh repo therefore still installs, and a bare invocation on a repo that already has a board reports instead of touching anything. @@ -98,17 +96,19 @@ test -n "$TARGET" && test -d "$TARGET" && echo "TARGET=$TARGET" && echo "OK" || > **Shell state does NOT survive between Bash tool calls.** Every call is a fresh shell: a variable another block assigned is EMPTY here. So EVERY later block that consumes `TARGET` MUST open by re-establishing it literally -- `TARGET=""`, with the actual resolved path written in, !=the variable name, !=a re-derivation. Same for anything derived from it (`F`, `T`). This applies to all blocks below without exception. -> **Gate blocks assert before they test.** A block whose SILENCE (or whose sole `OK` line) is read as PASS MUST first prove it ran, as its first statement: -> ```bash -> test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unresolved -- re-resolve per P0"; exit 1; } -> ``` +> **Gate blocks assert before they test.** A block whose SILENCE (or whose sole `OK` line) is read as PASS MUST first prove it ran, by opening with this exact statement: + +```bash +test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unresolved -- re-resolve per P0"; exit 1; } +``` + > Without it an empty `TARGET` makes the test run against a nonexistent path, the error gets eaten by `2>/dev/null` / `|| true`, and the gate reports PASS having checked nothing. "No output == PASS" is true ONLY when the gate actually ran. > `{{ARGUMENTS_PATH_OR_DOT}}` is resolved inline in P0 (the parsed path-like token, or `.`), not a template-emit placeholder -- it is absent from the Placeholder map by design. -Record `DIR` = the remaining free text (may be empty) and `MODE` (`status|install|upgrade|uninstall|purge`, or unset); hold both. +Record `DIR` = the remaining free text (may be empty) and `MODE` (`status|install|upgrade|enable|disable|uninstall|purge`, or unset); hold both. -**Branch on board presence.** An existing `TARGET/.claude/features/board.md` means the board is already deployed. `install` refuses it; `upgrade`, `uninstall` and `purge` EXPECT it. +**Branch on board presence.** An existing `TARGET/.claude/features/board.md` means the board is already deployed. `install` refuses it; `upgrade`, `enable`, `disable`, `uninstall` and `purge` EXPECT it. **EXECUTE** using Bash tool: ```bash @@ -126,6 +126,9 @@ Resolve an unset `MODE` here: `EXISTS` -> `status`, `FRESH` -> `install`. Then d | `install` | `EXISTS` | STOP. "Board already deployed. To retrofit the spec + design layer onto it, re-run as `/brewtools:task-board-setup upgrade `. To operate the existing board, use `/task-board`." Do not overwrite | | `upgrade` | `EXISTS` | go to **PU** -- control transfers to `references/10-upgrade.md`. Skip P1-P5.5 entirely | | `upgrade` | `FRESH` | STOP. "Nothing to upgrade: no `.claude/features/board.md` in TARGET. Run `/brewtools:task-board-setup install ` to deploy a fresh board" | +| `enable` | `EXISTS` | go to **PE** with `WANT=enable` | +| `disable` | `EXISTS` | go to **PE** with `WANT=disable` | +| `enable` / `disable` | `FRESH` | run **PS** instead and report that nothing is deployed. There is no machinery to toggle | | `uninstall` | `EXISTS` | go to **PR** with `KEEP_DATA=true` | | `purge` | `EXISTS` | go to **PR** with `KEEP_DATA=false` | | `uninstall` / `purge` | `FRESH` | run **PS** instead and report that nothing is deployed. Do not delete anything on a guess | @@ -137,7 +140,8 @@ test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unresolved -- re-r PARTIAL="" for p in .claude/agents/task-tracker.md .claude/skills/task-board/SKILL.md .claude/rules/tasks.md \ .claude/skills/task-spec/SKILL.md; do - test -f "$TARGET/$p" && PARTIAL="$PARTIAL $p" + # A parked `.disabled` twin still occupies the slot -- an install over it would orphan it. + test -f "$TARGET/$p" -o -f "$TARGET/$p.disabled" && PARTIAL="$PARTIAL $p" done test -z "$PARTIAL" && echo "CLEAN" || echo "PARTIAL:$PARTIAL" ``` @@ -147,17 +151,20 @@ test -z "$PARTIAL" && echo "CLEAN" || echo "PARTIAL:$PARTIAL" ## PS: Status (read-only inventory of the TARGET) -Runs for `MODE=status` -- the default on an already-deployed board -- and as the fallback when `uninstall`/`purge` find nothing. **Writes nothing, spawns nothing, asks nothing.** +Runs for `MODE=status` -- the default on an already-deployed board -- as the fallback when `enable`/`disable`/`uninstall`/`purge` find nothing, and as the proof block after **PE** and **PR**. **Writes nothing, spawns nothing, asks nothing.** **EXECUTE** using Bash tool: ```bash TARGET="" test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unresolved -- re-resolve per P0"; exit 1; } C="$TARGET/.claude"; F="$C/features" +# A `.disabled` twin is a PARKED artifact (see PE), not a missing one -- never report it as MISS. for p in agents/task-tracker.md skills/task-board/SKILL.md rules/tasks.md skills/task-spec/SKILL.md \ features/board.md features/PROGRESS.md features/TRACKER.md features/TASK_TEMPLATE.md features/INDEX.md \ features/specs/SPEC_TEMPLATE.md features/specs/DESIGN_TEMPLATE.md; do - test -f "$C/$p" && echo " ok $p" || echo " MISS $p" + if test -f "$C/$p"; then echo " ok $p" + elif test -f "$C/$p.disabled"; then echo " off $p (parked as $(basename "$p").disabled)" + else echo " MISS $p"; fi done for d in backlog todo progress closed specs; do n=$(ls -1 "$F/$d"/*.md 2>/dev/null | wc -l | tr -d ' ') @@ -171,11 +178,12 @@ Report, in this shape: task-board-setup — status target: deployed: yes|no|partial (board.md present / absent / some artifacts only) -spec layer: on|off (.claude/skills/task-spec/SKILL.md present?) +machinery: enabled|DISABLED|mixed (every artifact live / every one parked as .disabled / some of each) +spec layer: on|off|parked (.claude/skills/task-spec/SKILL.md present / absent / .disabled) tasks: backlog=N todo=N progress=N closed=N specs=N -next: install | upgrade | nothing to do +next: install | upgrade | enable | nothing to do ``` -`partial` -> name the missing artifacts and say a fresh `install` refuses to overwrite; the user must clean them first. `deployed: yes` + no spec layer -> `next: upgrade`. +`partial` -> name the missing artifacts and say a fresh `install` refuses to overwrite; the user must clean them first. `deployed: yes` + no spec layer -> `next: upgrade`. `machinery: DISABLED` -> `next: enable`, and say the tasks are all still there. `machinery: mixed` -> list which side each artifact is on and recommend re-running the verb that was interrupted. --- @@ -201,12 +209,60 @@ Rules that bind the whole phase: - **Additive only.** New files (`task-spec` skill, `SPEC_TEMPLATE.md`, `DESIGN_TEMPLATE.md`) are written outright. No existing task file, board row, agent, skill or rule is rewritten wholesale. - **Every edit of an existing file is gated:** show the exact diff, then **AskUserQuestion** per file. Declined = no edit, continue cleanly. +- **The metadata restamp (`10-upgrade.md` U5b) is UNGATED and always runs**, including when every content row is already SKIP. It rewrites `version` / `generated_by` / `last_updated` in the frontmatter of the nine stamped artifacts and nothing else -- that is the ONLY thing that clears the `stale` verdict `/brewcode:setup-status` reads off `board.md`. An `upgrade` that reports success without moving the stamp sends the user round the same loop next session. - **Never renumber, never delete.** Existing task ids, scope ids and closed tasks are untouchable. `board.md` rows are never REORDERED and existing cell content is never CHANGED -- the one allowed row edit is APPENDING the new `spec` cell holding `--` to each existing Progress/Todo row, per `10-upgrade.md` U4 (header + separator cells patch with it; a 6-column header over 5-cell rows is corruption, not caution). `spec:` FM backfill is opt-in and !=run by default -- the default writes nothing to task files. When the user accepts it, the value is `pending` or `none` per the needs-spec heuristic -- never `full`. > `PU` is a thin handoff: `10-upgrade.md` owns detect, verify and report. Do NOT reuse P5 here. --- +## PE: Enable / Disable (park or restore the machinery, keep every task) + +Runs for `MODE=enable` / `MODE=disable` on a deployed board. Replaces P1-P5.5. Writes no content, deletes nothing, spawns nothing. + +Claude Code discovers a project agent only as `.claude/agents/.md`, a project skill only as `/SKILL.md`, and auto-loads a rule only as `.claude/rules/*.md`. Withholding that one filename is therefore the whole switch: + +| Artifact | `disable` | `enable` | +|----------|-----------|----------| +| `.claude/agents/task-tracker.md` | -> `task-tracker.md.disabled` | back | +| `.claude/skills/task-board/SKILL.md` | -> `SKILL.md.disabled` | back | +| `.claude/skills/task-spec/SKILL.md` (when the spec layer is deployed) | -> `SKILL.md.disabled` | back | +| `.claude/rules/tasks.md` | -> `tasks.md.disabled` | back | +| `.claude/features/**` (board, control files, every task and spec) | **untouched** | untouched | + +`disable` leaves the board fully readable as plain markdown and every generated file byte-identical -- only the extension Claude Code keys on is withheld. Nothing is regenerated on `enable`: no re-analysis, no subagents, no confirmation of FINDINGS. This is the reversible pause; `uninstall` is the removal. + +Skill directories are parked at their `SKILL.md`, never by renaming the directory -- `references/` beside it must keep resolving for anyone reading the files by hand. + +**EXECUTE** using Bash tool (substitute `WANT`): +```bash +TARGET="" +test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unresolved -- re-resolve per P0"; exit 1; } +C="$TARGET/.claude" +WANT=WANT # enable | disable +MOVED=0; NOOP=0; MISSING=0 +for p in agents/task-tracker.md skills/task-board/SKILL.md skills/task-spec/SKILL.md rules/tasks.md; do + live="$C/$p"; parked="$C/$p.disabled" + if [ "$WANT" = "disable" ]; then from="$live"; to="$parked"; else from="$parked"; to="$live"; fi + if [ -f "$from" ]; then + mv "$from" "$to" && echo " MOVED $p -> $(basename "$to")" && MOVED=$((MOVED + 1)) + elif [ -f "$to" ]; then + echo " NOOP $p already $WANT""d"; NOOP=$((NOOP + 1)) + else + echo " ABSENT $p (not deployed)"; MISSING=$((MISSING + 1)) + fi +done +echo "WANT=$WANT MOVED=$MOVED NOOP=$NOOP ABSENT=$MISSING" +test "$MOVED" -gt 0 -o "$NOOP" -gt 0 && echo "OK" || echo "FAIL nothing to toggle" +``` +> **STOP if FAIL** -- none of the four artifacts is present in either state; the deployment is broken, report it and offer `install` after a `purge`. + +`ABSENT skills/task-spec/SKILL.md` alone is EXPECTED on a board installed with `SPEC_MODE=off` -- it is not an error. `MOVED=0` with `NOOP>0` means the board was already in the requested state: say so, change nothing else. + +Then run the `PS` block again and print its report -- it is the proof, not the `OK` line. Close by naming the reversal verb and stating that `.claude/features/**` was not touched, so every task survived. + +--- + ## PR: Uninstall / Purge (remove what this skill deployed) Runs for `MODE=uninstall` (`KEEP_DATA=true`) and `MODE=purge` (`KEEP_DATA=false`). Replaces P1-P5.5. @@ -217,6 +273,7 @@ Runs for `MODE=uninstall` (`KEEP_DATA=true`) and `MODE=purge` (`KEEP_DATA=false` | `.claude/skills/task-board/` | yes | yes | | `.claude/skills/task-spec/` | yes | yes | | `.claude/rules/tasks.md` | yes | yes | +| any `.disabled` twin of the four above (parked by `disable`) | yes | yes | | `.claude/features/**` (board, control files, every task and spec) | **KEPT** | yes | The split is deliberate: the generated agent/skills/rule are MACHINERY, `.claude/features/**` is the user's DATA -- every task they ever wrote. `uninstall` unwires the machinery and leaves the data readable; only `purge` deletes the tasks. @@ -229,10 +286,13 @@ TARGET="" test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unresolved -- re-resolve per P0"; exit 1; } C="$TARGET/.claude" KEEP_DATA=KEEP_DATA # true for uninstall, false for purge -rm -f "$C/agents/task-tracker.md" "$C/rules/tasks.md" +# The `.disabled` twins go too -- removing a DISABLED board would otherwise leave the parked files behind. +rm -f "$C/agents/task-tracker.md" "$C/agents/task-tracker.md.disabled" \ + "$C/rules/tasks.md" "$C/rules/tasks.md.disabled" rm -rf "$C/skills/task-board" "$C/skills/task-spec" test "$KEEP_DATA" = "false" && rm -rf "$C/features" -test ! -e "$C/agents/task-tracker.md" && test ! -e "$C/skills/task-board" && echo "OK removed" || echo "FAIL still present" +test ! -e "$C/agents/task-tracker.md" && test ! -e "$C/agents/task-tracker.md.disabled" \ + && test ! -e "$C/skills/task-board" && echo "OK removed" || echo "FAIL still present" ``` Then run the `PS` block again and print its report -- it is the proof, not the `OK` line. @@ -278,6 +338,8 @@ The reference templates carry these placeholders. Derive each from the confirmed > **Order is fixed, substitution is TWO-PASS.** Pass 1: expand the gated placeholders (inventory below). Pass 2: substitute the base placeholders in the table below over the WHOLE result. A gated expansion may itself contain a base token (`02`'s `{{SPEC_TRIGGERS}}` expansion contains `{{FIRST_DOMAIN}}`); the reverse never happens. Reversing the passes emits a literal `{{FIRST_DOMAIN}}`. +> **Two brace spellings, on purpose.** This skill's own tokens are DOUBLE-brace (`{{DOMAINS}}`, `{{TODAY}}`, `{{SPEC_*}}` ...). The three metadata tokens are SINGLE-brace -- `{PLUGIN_VERSION}`, `{GENERATED_BY}`, `{LAST_UPDATED}` -- the repo-wide spelling fixed by `brewcode/skills/setup-status/references/artifact-metadata.md`. Substitute both sets in pass 2; a leftover `{PLUGIN_VERSION}` in an emitted file is as broken as a leftover `{{DOMAINS}}`. + | Placeholder | Owner refs | Derivation | |-------------|-----------|------------| | `{{DOMAINS}}` | 01,02,04,05,08,10 | confirmed domain id-segment list, comma-separated (e.g. `HTML, KV, SITE`) | @@ -286,6 +348,9 @@ The reference templates carry these placeholders. Derive each from the confirmed | `{{REPO_NAME}}` | 05,08,09,10 | basename of `TARGET` | | `{{LANG}}` | 02,03,04,05,08,09,10 | confirmed doc language | | `{{TODAY}}` | 05,08,09,10 | today's date, ISO (`YYYY-MM-DD`) | +| `{PLUGIN_VERSION}` | 02,03,04,05,08,10 | brewtools plugin version, `X.Y.Z`. Resolved by the bash block below -- NEVER hardcoded, never guessed | +| `{GENERATED_BY}` | 02,03,04,05,08,10 | the literal `brewtools:task-board-setup` | +| `{LAST_UPDATED}` | 02,03,04,05,08,10 | same value as `{{TODAY}}`, quoted in YAML frontmatter. Metadata spelling of the date; `{{TODAY}}` stays the prose/card spelling | | `{{CLOSE_MARKER}}` | 02,10 | derived from `RELEASE_STYLE`: `vtag` -> `"vX.Y.Z tag + commit SHA"`; `sha` -> `"commit SHA"`; `none` -> `"date / no tag / superseded / cancelled"`. Exact per-ref wording maps live in `02` and `03` | | `{{CLOSE_MARKER_SHORT}}` | 03,04,05,10 | same enum, short form: `vtag` -> `"vX.Y.Z tag"`; `sha` -> `"commit SHA"`; `none` -> `"no tag"`. `04` and `05` reuse `03`'s map | | `{{DOMAIN_AGENTS}}` | 08,10 | a COMPLETE markdown table from Agent C's inventory of TARGET `.claude/agents/**` -- header row + `\|---\|` separator + one row per agent, columns exactly `agent \| domains covered \| specialty`. Consumers paste it bare, so a bodiless expansion renders as literal pipe text. Exception: no agents found -> the non-table literal line `(none found -- fall back to the built-in Plan agent and say so in Evidence)` | @@ -293,6 +358,24 @@ The reference templates carry these placeholders. Derive each from the confirmed | `{{RELEASE_STYLE}}` | 02 (header) | INPUT enum `vtag\|sha\|none`. Gate variable ONLY -- NOT a literal token in any emitted body; it picks the close-marker wording above | | `{{SPEC_MODE}}` | 03,04,09 (headers) | `on` \| `off`, as confirmed in P1. Gate variable ONLY -- like `{{RELEASE_STYLE}}` it is NOT a literal token in any template and is never substituted into an emitted body; it selects which gated blocks expand | +### Resolving `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}` + +Run ONCE, before P2, and hold the three values for every emitted file. **EXECUTE** using Bash tool: +```bash +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } +PV=$(jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true) +PV=${PV:-$(basename "$BT_ROOT")} +echo "PLUGIN_VERSION=$PV" +echo "GENERATED_BY=brewtools:task-board-setup" +echo "LAST_UPDATED=$(date +%F)" +``` +> **Why the bare form.** `CLAUDE_SKILL_DIR` is a TEXT SUBSTITUTION on the skill prompt, not an env var: CC 2.1.226 rewrites only the EXACT dollar-brace literal `{CLAUDE_SKILL_DIR}` (`replace(/\$\{CLAUDE_SKILL_DIR\}/g, dirname(skillPath))` and a string-pattern `replaceAll`). A brace-modifier form such as `:-fallback` inside the braces is therefore NOT matched, reaches the shell verbatim, and its fallback ALWAYS wins. `CLAUDE_PLUGIN_ROOT` is a real env var but is exported only to hook processes and MCP servers -- never to a skill's Bash tool -- so it is ALWAYS empty here. The skill dir is correct in a cache install AND in a `--plugin-dir` dev run; the cache glob below it is a last-resort fallback only, and it would name the INSTALLED plugin. +> If `PLUGIN_VERSION` comes back empty or non-`X.Y.Z`, STOP and report -- do not emit a file with a guessed or literal-placeholder version. + +These three feed the four-key metadata frontmatter (`doc_type: llm`, `version`, `generated_by`, `last_updated`) on every emitted artifact: the `task-tracker` agent (02), the `task-board` (03) and `task-spec` (08) skills, the `tasks.md` rule (04), and the five `.claude/features/**` control files (05). `doc_type` is the literal `llm` -- no placeholder. Per-task CARD frontmatter (`id/title/status/priority/owner/created/updated/tags/links/spec`) is domain data and never carries these keys. + ### Gated placeholders -- the convention The spec layer adds gated blocks inside otherwise-unchanged templates, following the `{{CMD_DECOMPOSED_NOTE}}` convention already used in `references/02-task-tracker-agent.md`. Every gated placeholder has exactly ONE of TWO kinds -- `line` or `inline` -- declared in the header of its owning reference file, alongside its expansion. That header is the source of truth for the EXPANSION TEXT and the whitespace handling; the inventory below is the complete name / kind / gate index. @@ -305,7 +388,7 @@ The spec layer adds gated blocks inside otherwise-unchanged templates, following | Kind `inline` | the token sits inside a line that exists in BOTH modes. Condition TRUE -> replace the TOKEN with the expansion. Condition FALSE -> delete the TOKEN only; the line stays | | inline whitespace | declared per site by its own reference file. BOTH forms are legal, do NOT unify them: some sites carry a single space BEFORE the token, deleted together with it (`02`); others carry no leading space and the expansion supplies its own (`03`, `05`). Follow the reference header, never a global rule | | `SPEC_MODE=off` result | the emitted artifact is byte-identical to the pre-spec-layer output. This holds only if every token was resolved against its OWN condition -- an `_OFF` arm dropped as if it were an `on` token breaks byte-identity | -| Verification | after substitution, `grep -n '{{'` the written file -- any surviving `{{...}}` is an unresolved placeholder and a defect. P5 executes this over every emitted path | +| Verification | after substitution, `grep -nE '\{\{\|\{(PLUGIN_VERSION\|GENERATED_BY\|LAST_UPDATED)\}'` the written file -- any surviving `{{...}}` OR single-brace metadata token is an unresolved placeholder and a defect. P5 executes this over every emitted path | #### Gated placeholder inventory (complete) @@ -469,12 +552,13 @@ done ``` > If `SPEC_MODE=off`, skip that loop -- and assert the inverse: none of those three paths may exist. -**Leftover-placeholder gate.** No emitted body legitimately contains `{{`, so every hit is an unresolved placeholder. Runs in BOTH modes over every emitted path (the `SPEC_MODE=on` paths simply do not exist when `off`). `|| true` keeps a clean run's rc=1 from aborting the block -- which is exactly why the block MUST assert `TARGET` first: with `TARGET` empty the grep hits a nonexistent path, rc=2 is swallowed by `2>/dev/null` + `|| true`, and the gate prints `OK` having read nothing. **EXECUTE** using Bash tool: +**Leftover-placeholder gate.** BOTH brace families, or it misses half the tokens: this skill's own tokens are DOUBLE-brace (`{{DOMAINS}}` ...) and the three metadata tokens are SINGLE-brace (`{PLUGIN_VERSION}`, `{GENERATED_BY}`, `{LAST_UPDATED}`). No emitted body legitimately contains either, so every hit is an unresolved placeholder. Runs in BOTH modes over every emitted path (the `SPEC_MODE=on` paths simply do not exist when `off`). `|| true` keeps a clean run's rc=1 from aborting the block -- which is exactly why the block MUST assert `TARGET` first: with `TARGET` empty the grep hits a nonexistent path, rc=2 is swallowed by `2>/dev/null` + `|| true`, and the gate prints `OK` having read nothing. **EXECUTE** using Bash tool: ```bash TARGET="" test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unresolved -- re-resolve per P0"; exit 1; } test -d "$TARGET/.claude/features" || { echo "MISS nothing emitted -- gate did not run"; exit 1; } -LEFT="$(grep -rn '{{' "$TARGET/.claude/features" "$TARGET/.claude/rules/tasks.md" \ +LEFT="$(grep -rnE '\{\{|\{(PLUGIN_VERSION|GENERATED_BY|LAST_UPDATED)\}' \ + "$TARGET/.claude/features" "$TARGET/.claude/rules/tasks.md" \ "$TARGET/.claude/agents/task-tracker.md" "$TARGET/.claude/skills/task-board" \ "$TARGET/.claude/skills/task-spec" 2>/dev/null || true)" test -z "$LEFT" && echo "OK no leftover placeholders" || { echo "MISS leftover placeholders:"; echo "$LEFT"; } @@ -517,6 +601,9 @@ Pass it `TARGET`, `DIR` (the directive parsed in P0), and `EXCLUSIONS`/`MODULES` | Condition | Response | |-----------|----------| | `TARGET` not a dir | STOP, ask for valid path | +| `upgrade` on a DISABLED board (`.claude/rules/tasks.md.disabled` present, `tasks.md` absent) | STOP. The recovered FINDINGS are read from the deployed artifacts and a parked file is not deployed. Report it and tell the user to run `enable` first, then `upgrade` | +| `enable`/`disable` and every one of the four artifacts is absent in BOTH states | STOP -- the deployment is broken. Report it; do not create files, the toggle never generates | +| `install` over a board whose artifacts are parked as `.disabled` | refused by the MAJOR-4 partial guard -- a `.disabled` twin still occupies the slot. Tell the user to `enable` (to resume) or `purge` (to start over) | | board.md `FRESH` but other primary artifacts present (partial prior run) | STOP -- report partial deployment; ask the user whether to clean and redo. Do NOT blindly overwrite. `upgrade` is not the fix (fresh-init path only; the upgrade path skips this guard) | | Reference template missing under `${CLAUDE_SKILL_DIR}/references` (incl. `08-task-spec-skill.md`, `09-spec-templates.md`, `10-upgrade.md` when `SPEC_MODE=on` or `upgrade`) | ERROR: reference not found -- reinstall brewtools. STOP. | | `SPEC_MODE=on` but `AGENT_GAPS` covers EVERY domain (no project agents at all) | ALLOWED -- proceed, `{{DOMAIN_AGENTS}}` becomes the literal `(none found ...)` line and every domain falls back to `Plan`. But SURFACE it loudly in the P5 report and suggest `/brewcode:agents` to author domain agents, then `upgrade` | diff --git a/brewtools/skills/task-board-setup/references/02-task-tracker-agent.md b/brewtools/skills/task-board-setup/references/02-task-tracker-agent.md index 67087ec..1500287 100644 --- a/brewtools/skills/task-board-setup/references/02-task-tracker-agent.md +++ b/brewtools/skills/task-board-setup/references/02-task-tracker-agent.md @@ -149,6 +149,10 @@ description: "Owns the file-based task board under .claude/features/ -- create/m model: sonnet tools: Read, Write, Edit, Glob, Grep, Bash color: yellow +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" --- [DICT: BRD=board.md, BKL=backlog, TPL=TASK_TEMPLATE.md, FM=frontmatter, TRK=TRACKER.md] diff --git a/brewtools/skills/task-board-setup/references/03-task-board-skill.md b/brewtools/skills/task-board-setup/references/03-task-board-skill.md index 9a80e9c..9182dd5 100644 --- a/brewtools/skills/task-board-setup/references/03-task-board-skill.md +++ b/brewtools/skills/task-board-setup/references/03-task-board-skill.md @@ -83,6 +83,10 @@ name: task-board description: "Views and updates this repo's file-based task board at .claude/features/. Triggers: show the board, task board, board status, what's in progress, add a task, create task, move task to progress, close task, dump to backlog, groom backlog.{{SPEC_DESC_TRIGGERS}}" argument-hint: "[view | add | move | backlog | groom]" allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" --- # Task Board (dashboard) diff --git a/brewtools/skills/task-board-setup/references/04-tasks-rule.md b/brewtools/skills/task-board-setup/references/04-tasks-rule.md index fda3b76..11e2479 100644 --- a/brewtools/skills/task-board-setup/references/04-tasks-rule.md +++ b/brewtools/skills/task-board-setup/references/04-tasks-rule.md @@ -37,6 +37,10 @@ Authoritative rules: `TRACKER.md` section 10. These rows mirror it in one line e --- paths: - ".claude/features/**" +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" --- [DICT: GROOM=backlog triage, FM=frontmatter, TT=task-tracker agent] diff --git a/brewtools/skills/task-board-setup/references/05-features-templates.md b/brewtools/skills/task-board-setup/references/05-features-templates.md index 3e4fe16..211db76 100644 --- a/brewtools/skills/task-board-setup/references/05-features-templates.md +++ b/brewtools/skills/task-board-setup/references/05-features-templates.md @@ -1,12 +1,14 @@ # 05 -- Step 4b: `.claude/features/**` file templates -Write each block below to its path under `TARGET/.claude/features/`. Substitute `{{REPO_NAME}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{CLOSE_MARKER_SHORT}}` (ref 03 map), `{{TODAY}}` (ISO date), plus the `{{SPEC_*}}` placeholders defined below (all gated by `SPEC_MODE`). +Write each block below to its path under `TARGET/.claude/features/`. Substitute `{{REPO_NAME}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{CLOSE_MARKER_SHORT}}` (ref 03 map), `{{TODAY}}` (ISO date), the metadata trio `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}` (SKILL.md Placeholder map), plus the `{{SPEC_*}}` placeholders defined below (all gated by `SPEC_MODE`). + +> **Metadata stamp.** `board.md`, `PROGRESS.md`, `TRACKER.md`, `INDEX.md` and `backlog/README.md` each open with the four-key frontmatter block (`doc_type, version, generated_by, last_updated`). It is UNGATED -- identical in both `SPEC_MODE` states -- and records WHO GENERATED the file and WHEN, so a later plugin version can detect an old-shape scaffold. It is provenance, not live state: nothing rewrites it after generation except `upgrade` (ref 10). `TASK_TEMPLATE.md` gets NO stamp -- its frontmatter is copied into every task card, where those keys would become card data. The `board.md` here is the EMPTY skeleton (counts 0). The Step-4c doc sweep fills it from the migrated docs. ## Spec-mode placeholders (gate: `SPEC_MODE=on`) -Every placeholder below shares ONE gate: `SPEC_MODE`. Exactly TWO kinds -- `line` and `inline`. When `SPEC_MODE=off`, the emitted control files MUST be byte-identical to the pre-spec-layer originals PLUS this file's UNGATED session-progress sites (`PROGRESS.md` itself, `TRACKER.md` section 2's layout line, `TRACKER.md` section 8 step 4, the `INDEX.md` Control-files row) -- baseline in BOTH modes, never removed: +Every placeholder below shares ONE gate: `SPEC_MODE`. Exactly TWO kinds -- `line` and `inline`. When `SPEC_MODE=off`, the emitted control files MUST be byte-identical to the pre-spec-layer originals PLUS this file's UNGATED session-progress sites (`PROGRESS.md` itself, `TRACKER.md` section 2's layout line, `TRACKER.md` section 8 step 4, the `INDEX.md` Control-files row) AND the four-key metadata frontmatter on the five control files -- baseline in BOTH modes, never removed: - **Line placeholders** (`{{SPEC_FEATURE_TABLE_HEAD_ON}}`, `{{SPEC_FEATURE_TABLE_HEAD_OFF}}`, `{{SPEC_FM_LINE}}`, `{{SPEC_SCOPE_BLOCK}}`, `{{SPEC_BOARD_COL_NOTE}}`, `{{SPEC_TRACKER_SECTION}}`, `{{SPEC_INDEX_ROWS}}`) occupy a line of their own. When off, REMOVE the entire line -- !=leave it blank. - `{{SPEC_FEATURE_TABLE_HEAD_ON}}` / `{{SPEC_FEATURE_TABLE_HEAD_OFF}}` are the two ARMS of that same gate, both `line` kind: on -> expand `_ON`, remove the `_OFF` line; off -> expand `_OFF`, remove the `_ON` line. Exactly one arm survives every run. !=a third kind. @@ -77,6 +79,13 @@ CORRECTION to the column list above: Progress + Todo have SIX columns, `id | tit ## `board.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # {{REPO_NAME}} Task Board > Canonical task list + status. Procedure: [`TRACKER.md`](TRACKER.md). New-task template: @@ -124,12 +133,21 @@ The `specs` count on the **Counts** line keeps its meaning in both modes: number Ungated -- written in BOTH `SPEC_MODE` states, at init, before any task exists. ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Session progress -- {{REPO_NAME}} > [`board.md`](board.md) owns the task LIST + status. THIS file owns what the SESSION did about it. > !=a second board: no task table, no per-task detail (that is the task's `## Notes`). > Five fields, overwritten in place -- one snapshot, never an append-only log. {{LANG}} only. > Kept current by the main session; rewritten by the `task-tracker` agent on every run. +> The `Updated` field below is the SESSION snapshot date; frontmatter `last_updated` is generator +> provenance and is NOT touched on a rewrite. - **Updated:** {{TODAY}} - **In flight:** -- (task ids being worked right now) @@ -143,6 +161,13 @@ Ungated -- written in BOTH `SPEC_MODE` states, at init, before any task exists. ## `TRACKER.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # TRACKER -- {{REPO_NAME}} task/feature tracker procedure > Canonical procedure for the `.claude/features/` task board. The board (`board.md`) @@ -400,6 +425,13 @@ Running log: decisions, blockers, PR/commit/report links. ## `INDEX.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + # Features -- control-file index > `board.md` is the **canonical** task list + status. This index just maps the control @@ -432,5 +464,12 @@ Running log: decisions, blockers, PR/commit/report links. ## `backlog/README.md` ```markdown +--- +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" +--- + Ungroomed inbox. Drop raw ideas as *.md; task-tracker grooms into todo/ or trashes. See ../TRACKER.md. ``` diff --git a/brewtools/skills/task-board-setup/references/08-task-spec-skill.md b/brewtools/skills/task-board-setup/references/08-task-spec-skill.md index 4cba7e0..34f66af 100644 --- a/brewtools/skills/task-board-setup/references/08-task-spec-skill.md +++ b/brewtools/skills/task-board-setup/references/08-task-spec-skill.md @@ -19,6 +19,10 @@ description: "Authors the product spec and the system-design doc for a task on t argument-hint: " [full | design | refresh] [-n|--noask]" allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent, AskUserQuestion model: opus +doc_type: llm +version: "{PLUGIN_VERSION}" +generated_by: "{GENERATED_BY}" +last_updated: "{LAST_UPDATED}" --- # task-spec (spec + system design) diff --git a/brewtools/skills/task-board-setup/references/10-upgrade.md b/brewtools/skills/task-board-setup/references/10-upgrade.md index 632c9e0..146e3c8 100644 --- a/brewtools/skills/task-board-setup/references/10-upgrade.md +++ b/brewtools/skills/task-board-setup/references/10-upgrade.md @@ -1,6 +1,6 @@ # 10 -- upgrade mode: retrofit the spec layer onto a deployed board -Placeholders used: `{{DOMAIN_AGENTS}}`, `{{ARCHITECT_AGENT}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{EXCLUSIONS}}`, `{{TODAY}}`, `{{REPO_NAME}}`, `{{CLOSE_MARKER}}`, `{{CLOSE_MARKER_SHORT}}`. +Placeholders used: `{{DOMAIN_AGENTS}}`, `{{ARCHITECT_AGENT}}`, `{{DOMAINS}}`, `{{FIRST_DOMAIN}}`, `{{LANG}}`, `{{EXCLUSIONS}}`, `{{TODAY}}`, `{{REPO_NAME}}`, `{{CLOSE_MARKER}}`, `{{CLOSE_MARKER_SHORT}}`, `{PLUGIN_VERSION}`, `{GENERATED_BY}`, `{LAST_UPDATED}`. `SPEC_MODE` and `CMD_DECOMPOSED` are GATE variables, never tokens in an emitted body. In upgrade mode `SPEC_MODE` is FORCED `on` and `CMD_DECOMPOSED` is FORCED `false` (upgrade never runs P5.5) -- see U2. [DICT: TT=task-tracker agent (installed), TB=task-board skill (installed), BRD=board.md, FEAT=.claude/features, FM=frontmatter, ADD=write a file that does not exist, PATCH=insert a block into an existing file, MARK=idempotency marker text] @@ -106,7 +106,9 @@ DETECT table: | task files missing `spec:` FM | `backfill-needed` | BACKFILL (gated) \| SKIP if 0 | | task files with no frontmatter | `skipped-no-frontmatter` | SKIP always, named in the report | -A file whose MARK is SPLIT (`s` spec layer, `p` session-progress layer) reports ONE probe line per row; the two are independent install units and a file can be SKIP for one and PATCH for the other. If every row is SKIP and `backfill-needed=0` -> report `upgrade: no-op, spec layer already installed` and STOP. That is the rerun path. +A file whose MARK is SPLIT (`s` spec layer, `p` session-progress layer) reports ONE probe line per row; the two are independent install units and a file can be SKIP for one and PATCH for the other. If every row is SKIP and `backfill-needed=0` -> the CONTENT layer is already installed: skip U2's AskUserQuestion, skip U3-U5, **still run U5b**, then report `upgrade: content already installed, metadata restamped to ` and stop. + +> **U5b is NOT part of that no-op.** This is the single commonest upgrade: the user ran `claude plugin update`, every content row is already SKIP, and the ONLY thing out of date is the version stamp — which is exactly what `setup-status` reads and exactly what it told the user to fix by running `upgrade`. An early STOP here reinstates the bug this file was changed to remove: `status` says `stale`, `upgrade` says `no-op`, forever. U5b needs only `{PLUGIN_VERSION}`/`{GENERATED_BY}`/`{LAST_UPDATED}`, which are re-resolved fresh and never recovered, so it runs with nothing from U2's recovery table. > `ADD (drift)` = a PATCH target is missing entirely. Do not fail: emit the full file from its reference template and NOTE the drift in the report -- the deployment is incomplete, the user should know. A drift-ADD MUST resolve EVERY token that reference's header declares -- its `Substitute ...` line AND every gated placeholder declared elsewhere in that header (see U2), including `{{CLOSE_MARKER}}` / `{{CLOSE_MARKER_SHORT}}` and, for `task-tracker.md`, the two `CMD_DECOMPOSED` line placeholders. @@ -129,6 +131,7 @@ Values already baked into the installed artifacts are RECOVERED by reading, !=re | `{{ARCHITECT_AGENT}}` | NEW -- from Agent C | best architecture-capable project agent name, else `Plan` | | `AGENT_GAPS` | NEW -- from Agent C | REPORT-ONLY, !=a token in any emitted body. Every `{{DOMAINS}}` entry with no owning agent in the `{{DOMAIN_AGENTS}}` table -- those domains fall back to the built-in `Plan` in `/task-spec`'s design fan-out. Empty -> `none`. Carry it to the U6 report, never drop it | | `{{REPO_NAME}}` / `{{TODAY}}` | trivial | basename of TARGET / ISO date | +| `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}` | NEVER recovered | re-resolve fresh per the SKILL.md "Resolving `{PLUGIN_VERSION}`..." bash block. An upgrade is a NEW write by a NEW plugin version -- an old stamp recovered off the installed file would be a lie | Recovery conflicts (e.g. `tasks.md` and `task-tracker.md` disagree on DOMAINS) -> surface both, let the user pick via AskUserQuestion. A value that cannot be recovered at all -> ask, !=guess. @@ -159,7 +162,7 @@ mkdir -p "$TARGET/.claude/features/specs" && echo "OK specs dir" || echo "FAIL s | Emit | From | Substitute | |------|------|------------| | `TARGET/.claude/skills/task-spec/SKILL.md` | `references/08-task-spec-skill.md` | exactly the tokens in `08`'s own header `Substitute ...` line -- read it, !=re-enumerate here | -| `TARGET/.claude/features/PROGRESS.md` | `references/05-features-templates.md`, `## PROGRESS.md` block | `{{REPO_NAME}}`, `{{LANG}}`, `{{TODAY}}`. Written EMPTY (all five fields `--`); the board's live state is never back-filled into it -- `task-tracker` rewrites it on its next run | +| `TARGET/.claude/features/PROGRESS.md` | `references/05-features-templates.md`, `## PROGRESS.md` block | `{{REPO_NAME}}`, `{{LANG}}`, `{{TODAY}}`, plus the metadata trio. Written EMPTY (all five fields `--`); the board's live state is never back-filled into it -- `task-tracker` rewrites it on its next run | | `TARGET/.claude/features/specs/SPEC_TEMPLATE.md` | `references/09-spec-templates.md` | exactly the tokens in `09`'s own header `Substitute ...` line | | `TARGET/.claude/features/specs/DESIGN_TEMPLATE.md` | `references/09-spec-templates.md` | same header line as above | @@ -276,9 +279,103 @@ Option 1 leaves the field absent on every existing task; that is a legal state f --- +## U5b. RESTAMP the metadata trio (ALWAYS runs, never gated, never asked) + +**This is the step that lets `upgrade` clear its own staleness.** `setup-status` row 4 reads the +frontmatter `version:` of the anchor `.claude/features/board.md`. `board.md` is in the U4 PATCH +set, not the U3 ADD set — so before this step existed, an upgrade edited the anchor's TABLE and +left its STAMP on whatever version installed it. `status` printed `stale`, prescribed `upgrade`, +`upgrade` reported success, and the next `status` printed `stale` again, forever. An ADDed file +was born with a fresh stamp; a PATCHed or SKIPped one never got one. **A PATCHED file must end up +stamped exactly like an ADDED one** — which is what this block enforces, by restamping all nine +unconditionally. + +Nine stamped artifacts — the same nine `setup-status` row 4 names. `TASK_TEMPLATE.md` is +deliberately UNSTAMPED (its frontmatter is copied into every task card), and task CARDS under +`backlog/todo/progress/closed` never carry these keys at all. Neither is touched here. + +| # | Artifact | Emitted by ref | +|---|----------|----------------| +| 1 | `.claude/features/board.md` (the ANCHOR) | 05 | +| 2 | `.claude/features/TRACKER.md` | 05 | +| 3 | `.claude/features/INDEX.md` | 05 | +| 4 | `.claude/features/PROGRESS.md` | 05 | +| 5 | `.claude/features/backlog/README.md` | 05 | +| 6 | `.claude/agents/task-tracker.md` | 02 | +| 7 | `.claude/rules/tasks.md` | 04 | +| 8 | `.claude/skills/task-board/SKILL.md` | 03 | +| 9 | `.claude/skills/task-spec/SKILL.md` | 08 | + +Ordering: run AFTER U3/U4/U5, before U6. A file this run ADDed is already correct and the restamp +is a no-op on it — that is the point, one code path for both. + +**Scope — the trio and nothing else.** Only `version`, `generated_by` and `last_updated`, only +inside the file's OWN first frontmatter block, only when line 1 is `---`. `doc_type` is left +exactly as found (a user who set `user`/`skip` keeps it: these are mechanism-`b` artifacts, not +byte-copies, so nothing restores it). Body, tables, hand-edits, task content: untouched. A legacy +install whose frontmatter predates the trio gets the three keys INSERTED immediately before the +closing `---`, which is how `stale (legacy, unstamped)` clears. + +First re-resolve the three values — SKILL.md "Resolving `{PLUGIN_VERSION}` / `{GENERATED_BY}` / +`{LAST_UPDATED}`", run verbatim. Fresh values only; an old stamp read off the installed file +would be a lie (U2). Then, **EXECUTE** using Bash tool: + +```bash +TARGET="" +test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unset or not a dir -- restamp did NOT run"; exit 1; } +PV=""; GB="brewtools:task-board-setup"; LU="" +case "$PV" in [0-9]*.[0-9]*.[0-9]*) ;; *) echo "MISS PLUGIN_VERSION='$PV' is not X.Y.Z -- restamp did NOT run"; exit 1 ;; esac +case "$LU" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; *) echo "MISS LAST_UPDATED='$LU' is not YYYY-MM-DD -- restamp did NOT run"; exit 1 ;; esac +ok=0; miss=0; skip=0 +for rel in .claude/features/board.md .claude/features/TRACKER.md .claude/features/INDEX.md \ + .claude/features/PROGRESS.md .claude/features/backlog/README.md \ + .claude/agents/task-tracker.md .claude/rules/tasks.md \ + .claude/skills/task-board/SKILL.md .claude/skills/task-spec/SKILL.md; do + f="$TARGET/$rel" + test -f "$f" || { echo "ABSENT $rel"; skip=$((skip+1)); continue; } + test "$(head -n 1 "$f")" = "---" || { echo "SKIP $rel (no frontmatter -- never synthesized)"; skip=$((skip+1)); continue; } + awk -v pv="$PV" -v gb="$GB" -v lu="$LU" ' + NR == 1 { print; next } + !done && /^---[ \t]*$/ { + if (!sv) print "version: \"" pv "\"" + if (!sg) print "generated_by: \"" gb "\"" + if (!sl) print "last_updated: \"" lu "\"" + done = 1; print; next + } + !done && /^version:/ { print "version: \"" pv "\""; sv = 1; next } + !done && /^generated_by:/ { print "generated_by: \"" gb "\""; sg = 1; next } + !done && /^last_updated:/ { print "last_updated: \"" lu "\""; sl = 1; next } + { print } + ' "$f" > "$f.restamp.tmp" && mv "$f.restamp.tmp" "$f" \ + || { rm -f "$f.restamp.tmp"; echo "MISS $rel rewrite failed"; miss=$((miss+1)); continue; } + if grep -qxF "version: \"$PV\"" "$f" && grep -qxF "generated_by: \"$GB\"" "$f" \ + && grep -qxF "last_updated: \"$LU\"" "$f"; then + echo "STAMP $rel"; ok=$((ok+1)) + else + echo "MISS $rel stamp not applied (no closing --- ?)"; miss=$((miss+1)) + fi +done +echo "restamped=$ok absent-or-skipped=$skip miss=$miss" +test "$miss" -eq 0 && echo "✅ restamp clean" || echo "❌ restamp FAILED" +``` + +> **STOP if ❌** — a MISS means an artifact still reports the old version, so the next +> `/brewcode:setup-status` prints `stale` again and the user is back in the loop this step exists +> to break. Fix the named file and re-run the block; it is idempotent. + +`ABSENT` is normal, not a MISS: `task-spec/SKILL.md` never existed on a `SPEC_MODE=off` board the +user declined to upgrade, and `PROGRESS.md` is absent on a board that predates it and whose `1p` +row was declined. A second `upgrade` re-runs this block and writes the identical bytes. + +Not restamped, on purpose: `TASK_TEMPLATE.md` (unstamped by design), every task card, and a +parked `.disabled` file — `upgrade` refuses to run on a DISABLED board at all (SKILL.md +guard), so `enable` first, then `upgrade`. + +--- + ## U6. Verify + report -**Leftover-placeholder gate.** This block is self-contained and owned by THIS file (`PU` does not run P5). It scans the SAME path set the fresh path's P5 gate scans -- NOT just the ADD set. A drift-ADD writes whole files under `.claude/agents/`, `.claude/rules/` and `.claude/features/`, so an ADD-set-only scan would miss exactly the paths most likely to carry an unresolved token. +**Leftover-placeholder gate.** This block is self-contained and owned by THIS file (`PU` does not run P5). It scans the SAME path set the fresh path's P5 gate scans -- NOT just the ADD set. A drift-ADD writes whole files under `.claude/agents/`, `.claude/rules/` and `.claude/features/`, so an ADD-set-only scan would miss exactly the paths most likely to carry an unresolved token. It catches BOTH brace families: this skill's own DOUBLE-brace tokens and the SINGLE-brace metadata tokens `{PLUGIN_VERSION}` / `{GENERATED_BY}` / `{LAST_UPDATED}`. Same self-contained rule as the header note: re-establish `TARGET` literally, assert it, then run. @@ -287,7 +384,8 @@ Same self-contained rule as the header note: re-establish `TARGET` literally, as TARGET="" test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unset or not a dir -- gate did NOT run"; exit 1; } T="$TARGET"; F="$T/.claude/features" -LEFT="$(grep -rn '{{' "$F" "$T/.claude/rules/tasks.md" "$T/.claude/agents/task-tracker.md" \ +LEFT="$(grep -rnE '\{\{|\{(PLUGIN_VERSION|GENERATED_BY|LAST_UPDATED)\}' \ + "$F" "$T/.claude/rules/tasks.md" "$T/.claude/agents/task-tracker.md" \ "$T/.claude/skills/task-board" "$T/.claude/skills/task-spec" 2>/dev/null || true)" test -z "$LEFT" && echo "OK no leftover placeholders" \ || { echo "MISS leftover placeholders:"; echo "$LEFT"; } @@ -304,7 +402,26 @@ Read the probe output as: > **A `PATCH`/`ABSENT`/non-zero line is a MISS only if the user did not decline it.** A declined patch or a declined backfill is expected: report it as `declined`, !=retry, !=re-emit. -Rerun safety: a second `upgrade` on the same TARGET must reach U1, find every row SKIP with `backfill-needed=0`, print `upgrade: no-op, spec layer already installed`, and exit having written nothing. The excluded files (`closed/`, `backlog/README.md`, FM-less) are out of the denominator in BOTH U1 and here, so the count converges. +**Stamp gate.** The restamp is verified here too, independently of U5b's own check, because the anchor's stamp IS the staleness signal: **EXECUTE** using Bash tool: +```bash +TARGET="" +test -n "$TARGET" && test -d "$TARGET" || { echo "MISS TARGET unset or not a dir -- stamp gate did NOT run"; exit 1; } +PV="" +bad=0 +for rel in .claude/features/board.md .claude/features/TRACKER.md .claude/features/INDEX.md \ + .claude/features/PROGRESS.md .claude/features/backlog/README.md \ + .claude/agents/task-tracker.md .claude/rules/tasks.md \ + .claude/skills/task-board/SKILL.md .claude/skills/task-spec/SKILL.md; do + f="$TARGET/$rel"; test -f "$f" || continue + # SAME skip rule as U5b, or the gate fails a file U5b correctly refused to touch. + test "$(head -n 1 "$f")" = "---" || { echo "SKIP $rel (no frontmatter -- U5b never stamps it)"; continue; } + grep -qxF "version: \"$PV\"" "$f" || { echo "MISS stale stamp: $rel -> $(grep -m1 '^version:' "$f" || echo '(none)')"; bad=$((bad+1)); } +done +test "$bad" -eq 0 && echo "OK every frontmatter-carrying artifact stamped $PV" || echo "❌ $bad artifact(s) still stale -- re-run U5b" +``` +A clean run prints `OK every frontmatter-carrying artifact stamped ` -- silence means the block did not run, !=PASS. A `SKIP` line is not a failure: it is a file whose line 1 is not `---`, which U5b refuses to touch by the same rule that protects a hand-authored task file. + +Rerun safety: a second `upgrade` on the same TARGET must reach U1, find every row SKIP with `backfill-needed=0`, run U5b (which rewrites the identical bytes, since the plugin version has not moved), print `upgrade: content already installed, metadata restamped to `, and change nothing on disk. The excluded files (`closed/`, `backlog/README.md`, FM-less) are out of the denominator in BOTH U1 and here, so the count converges. Report the probe rows as these buckets: @@ -312,6 +429,7 @@ Report the probe rows as these buckets: |--------|---------| | added | files written (ADD set) + `specs/` dir if created | | patched | files edited + which MARK was inserted into each | +| restamped | U5b: `/9` artifacts now carrying `version ""`, and every `ABSENT`/`SKIP` by name. **ALWAYS printed**, including on the content-no-op path -- it is the bucket that proves the next `setup-status` will read `installed` instead of `stale` | | skipped-already-present | ADD files that existed, PATCH files whose MARK was found | | declined | patches / backfill the user rejected -- named, so a later rerun can pick them up | | half-state | coherence pairs from U4b the user chose to leave incomplete | @@ -333,6 +451,10 @@ Report the probe rows as these buckets: | Condition | Response | |-----------|----------| | `board.md` missing | Not an upgrade. STOP -- tell the user to run a fresh `/brewtools:task-board-setup install ` | +| Every U4 row SKIP and `backfill-needed=0` | NOT a reason to stop before U5b. Skip U2's question and U3-U5, run U5b, report `content already installed, metadata restamped`. An `upgrade` that reports success without moving the stamp leaves `setup-status` printing `stale` forever | +| A stamped artifact is present but its frontmatter has no `version:` (pre-standard install) | U5b INSERTS the trio before the closing `---`. That is how `stale (legacy, unstamped)` clears -- !=report it and move on | +| A stamped artifact's line 1 is not `---` | SKIP it, never synthesize frontmatter (same rule as a task file). Name it in the `restamped` bucket | +| `doc_type` in a restamped file | Left exactly as found. These are mechanism-`b` artifacts, not byte-copies -- a locally chosen `user`/`skip` is the user's, and U5b owns only the trio | | ADD target already present | SKIP it; !=overwrite. Report as skipped-already-present | | `PROGRESS.md` present (any content, hand-edited or stale) | NEVER rewritten by upgrade -- it is an ADD-set file, so present = SKIP. `task-tracker` refreshes it on its next run | | PATCH target file missing | ADD it whole from its reference template with every token resolved, and NOTE the drift in the report | diff --git a/brewtools/skills/text-human/SKILL.md b/brewtools/skills/text-human/SKILL.md index 749be3b..dff0ad8 100644 --- a/brewtools/skills/text-human/SKILL.md +++ b/brewtools/skills/text-human/SKILL.md @@ -159,8 +159,8 @@ Files are edited in place. No backups -- use git to revert. /text-human src/main/java/OrderService.java # code flow, single file /text-human 3be67487 # mixed flow, commit /text-human src/main/java/services/ # mixed flow, folder -/text-human review this reddit reply: # social flow, inline text -/text-human humanize this blog post: # article flow +/text-human review this reddit reply: "" # social flow, inline text +/text-human humanize this blog post: "" # article flow /text-human clean the javadoc in PaymentApi.java # code flow, CLEAN-ONLY /text-human 3be67487 also drop all @author tags # mixed + custom rule /text-human src/ only strip AI artifacts, no inject # custom prompt overrides diff --git a/brewtools/skills/think-short-setup/SKILL.md b/brewtools/skills/think-short-setup/SKILL.md index 868d29a..cfde654 100644 --- a/brewtools/skills/think-short-setup/SKILL.md +++ b/brewtools/skills/think-short-setup/SKILL.md @@ -28,10 +28,12 @@ All three read `think-short-prompt.md` from their OWN directory and emit `{}` wh ## BT_ROOT Resolver (use in EVERY bash block) -`$CLAUDE_PLUGIN_ROOT` is NOT inherited by the Bash tool in main-conversation slash invocations. Resolve dynamically: +The plugin root is resolved from the skill's OWN directory (the `CLAUDE_SKILL_DIR` prompt substitution), never from `CLAUDE_PLUGIN_ROOT` -- that env var is not exported to a skill's Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } test -d "$BT_ROOT/skills/think-short-setup/assets" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; } ``` @@ -50,7 +52,9 @@ Run this before anything else, in EVERY mode. Never install, re-install or remov **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } A="$BT_ROOT/skills/think-short-setup/assets" for f in INSTALL.md think-short-session.mjs think-short-prompt-counter.mjs think-short-task.mjs think-short-prompt.md; do test -f "$A/$f" || { echo "❌ FAILED — assets incomplete under BT_ROOT=$BT_ROOT (missing $f)"; exit 1; } @@ -246,7 +250,7 @@ Re-install is a no-op. One target per run; "both" is two runs. | Condition | Response | |-----------|----------| | `BT_ROOT` resolves but `$BT_ROOT/skills/think-short-setup/assets` missing | ERROR: `think-short: assets not found under $BT_ROOT — plugin cache incomplete.` STOP. | -| Neither `$CLAUDE_PLUGIN_ROOT` set nor any cached plugin dir found | ERROR: `think-short: cannot locate plugin root — install/update brewtools first.` STOP. | +| Neither the skill dir nor any cached plugin dir yields `.claude-plugin/plugin.json` | ERROR: `think-short: cannot locate plugin root — install/update brewtools first.` STOP. | | Status shows installed + vague intent | Print status, list available operations, STOP. Do not re-install. | | Target unspecified | AskUserQuestion: Project / Global. Never guess. | | Mode ambiguous between install and removal | AskUserQuestion. Never guess a destructive mode. | @@ -268,7 +272,9 @@ Verify the 5 assets exist and the scripts parse before delegating. **EXECUTE** using Bash tool: ```bash -BT_ROOT="${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::')}" +SD="${CLAUDE_SKILL_DIR}" +if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi +[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; } A="$BT_ROOT/skills/think-short-setup/assets" test -d "$A" || { echo "❌ assets dir missing"; exit 1; } for f in think-short-session.mjs think-short-prompt-counter.mjs think-short-task.mjs think-short-prompt.md INSTALL.md; do diff --git a/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs b/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs index 42e84ee..9fe9262 100644 --- a/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs +++ b/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup /** * think-short — UserPromptSubmit hook (self-contained, no plugin-root deps). * diff --git a/brewtools/skills/think-short-setup/assets/think-short-prompt.md b/brewtools/skills/think-short-setup/assets/think-short-prompt.md index 90a4871..f9a0e2c 100644 --- a/brewtools/skills/think-short-setup/assets/think-short-prompt.md +++ b/brewtools/skills/think-short-setup/assets/think-short-prompt.md @@ -1,4 +1,4 @@ - + Be terse. Results first, no preamble/filler/sycophancy. ASCII only. Think short: minimal internal reasoning, no exploring aloud. Grep before Read. Edit over Write. Parallel calls in one message. diff --git a/brewtools/skills/think-short-setup/assets/think-short-session.mjs b/brewtools/skills/think-short-setup/assets/think-short-session.mjs index b49898d..9f12213 100644 --- a/brewtools/skills/think-short-setup/assets/think-short-session.mjs +++ b/brewtools/skills/think-short-setup/assets/think-short-session.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup /** * think-short — SessionStart hook (self-contained, no plugin-root deps). * diff --git a/brewtools/skills/think-short-setup/assets/think-short-task.mjs b/brewtools/skills/think-short-setup/assets/think-short-task.mjs index b56fc82..5e34b67 100644 --- a/brewtools/skills/think-short-setup/assets/think-short-task.mjs +++ b/brewtools/skills/think-short-setup/assets/think-short-task.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup /** * think-short — PreToolUse hook for Task|Agent (self-contained, no plugin-root deps). * @@ -41,25 +42,33 @@ const FAMILY_PLUGINS = ['brewcode', 'brewtools', 'brewdoc']; // PreToolUse entry is NOT a reason to yield. Matched by basename, because the // setup skills install project-local copies whose paths carry no plugin marker // (e.g. `/.claude/hooks/agent-router.mjs`). -// Keep in sync with the `.mjs` files under */hooks/ and */skills/*/assets/. -const FAMILY_HOOK_FILES = [ - 'agent-router.mjs', - 'agent-deadline-guard.mjs', - 'agent-deadline-cleanup.mjs', - 'hardmode-guard.mjs', - 'manager-prompt.mjs', - 'forced-eval.mjs', - 'session-start.mjs', - 'semble-reminder.mjs', - 'semble-session.mjs', - 'semble-explore.mjs', - 'docsync-gate.mjs', - 'docsync-track.mjs', - 'docsync-watch.mjs', - 'think-short-session.mjs', - 'think-short-prompt-counter.mjs', +// +// A STEM prefix set, not a file list: every family hook is named after the setup +// skill that installs it, so a hook added, renamed or retired inside an existing +// family is matched without editing this file. An exact list rotted exactly that +// way — it still named `semble-reminder.mjs` / `semble-explore.mjs` after 5.0.0 +// retired them, and never learned `semble-prefetch.mjs` / `semble-stats.mjs`. +// Adding a whole NEW family (a new setup skill with a new hook name stem) is the +// only edit this still needs. +const FAMILY_HOOK_STEMS = [ + 'semble', // brewcode:semble-setup — semble-session/-prefetch/-stats + 'docsync', // brewdoc:docsync-setup — docsync-track/-watch/-gate + 'think-short', // brewtools:think-short-setup + 'agent-deadline', // brewtools:agent-deadline-setup + 'agent-router', // brewtools:agent-router-setup + 'manager-prompt', // brewtools plugin hook + 'manager-state', // brewtools:manager-setup — copied beside the guard + 'hardmode-guard', // brewtools:manager-setup + 'forced-eval', // brewcode plugin hook + 'session-start', // brewcode + brewtools plugin hooks ]; +// Anchored on a path/quote/space boundary so `foo-semble-x.mjs` is not a match, +// and applied to the JSON-serialised hook entry, where separators are `/` or `\\`. +const FAMILY_HOOK_RE = new RegExp( + `(?:^|[^A-Za-z0-9_-])(?:${FAMILY_HOOK_STEMS.join('|')})[A-Za-z0-9-]*\\.mjs` +); + async function readStdin() { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); @@ -118,7 +127,7 @@ function classifyEntry(entry, sourcePath) { const ref = JSON.stringify((entry && entry.hooks) || []); if (ref.includes(SELF_BASENAME)) return 'self'; // A shipped family hook file, wherever it was installed from. - if (FAMILY_HOOK_FILES.some(f => ref.includes(f))) return 'family'; + if (FAMILY_HOOK_RE.test(ref)) return 'family'; // Anything else coming out of a family plugin's own cache directory. for (const plugin of FAMILY_PLUGINS) { const marker = path.join('claude-brewcode', plugin); diff --git a/brewui/.claude-plugin/plugin.json b/brewui/.claude-plugin/plugin.json index ad8777f..88f4678 100644 --- a/brewui/.claude-plugin/plugin.json +++ b/brewui/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brewui", - "version": "5.0.0", + "version": "5.1.0", "description": "Brewui -- placeholder for future UI/visual/creative tools", "author": { "name": "Maksim Kochetkov", diff --git a/brewui/README.md b/brewui/README.md index 725a1d8..201fe9d 100644 --- a/brewui/README.md +++ b/brewui/README.md @@ -4,7 +4,7 @@ | Field | Value | |-------|-------| -| Version | 3.18.0 | +| Version | 5.1.0 | | Skills | 0 | ## Install diff --git a/web/docs/src/content/docs/brewcode/agents.mdx b/web/docs/src/content/docs/brewcode/agents.mdx index b7fb915..f6a5c8e 100644 --- a/web/docs/src/content/docs/brewcode/agents.mdx +++ b/web/docs/src/content/docs/brewcode/agents.mdx @@ -40,11 +40,14 @@ Purpose column = the agent's own `description` frontmatter, verbatim. That strin Bash/sh script creation + + Internal — rules file organization + -:::note[Internal agents] -`bc-rules-organizer` is internal. It is spawned automatically by brewcode skills and hooks, is not user-invokable, and has no dedicated page. -::: + +`bc-rules-organizer` is internal — spawned only by [`/brewcode:rules`](/brewcode/skills/rules/), never user-invokable directly. + ## Scope guard — shared by all 4 public agents @@ -68,7 +71,7 @@ Agents deliver for the CONSUMER, not the literal wording: the result must be usa | Model | Agents | Meaning | |-------|--------|---------| | **inherit** | [skill-creator](/brewcode/agents/skill-creator/), [agent-creator](/brewcode/agents/agent-creator/), [hook-creator](/brewcode/agents/hook-creator/), [bash-expert](/brewcode/agents/bash-expert/) | Runs on the session's model — you control the tier | -| **haiku** | bc-rules-organizer | Pinned: cheap file reorganization | +| **haiku** | [bc-rules-organizer](/brewcode/agents/bc-rules-organizer/) | Pinned: cheap file reorganization | Agents are launched automatically by brewcode skills via the Task API. diff --git a/web/docs/src/content/docs/brewcode/agents/bc-rules-organizer.mdx b/web/docs/src/content/docs/brewcode/agents/bc-rules-organizer.mdx new file mode 100644 index 0000000..f66bcb8 --- /dev/null +++ b/web/docs/src/content/docs/brewcode/agents/bc-rules-organizer.mdx @@ -0,0 +1,143 @@ +--- +title: "Rules Organizer" +description: "Internal agent that organizes .claude/rules/*.md with path-specific frontmatter and LLM-optimized tables. Spawned only by /brewcode:rules." +order: 1210 +--- +import { Callout, Card, CardGrid, Steps, UpdateNotice } from '../../../../components/mdx'; + +# Rules Organizer + + +**Internal agent — no direct or automatic use.** `bc-rules-organizer` is spawned only by [`/brewcode:rules`](/brewcode/skills/rules/), never by you directly and never auto-selected by Claude for an unrelated prompt. It exists as a page here because it ships as one of brewcode's 5 agents and deserves the same reference treatment as the other 4. + + +## Quick reference + +| Field | Value | +|-------|-------| +| Model | `haiku` — pinned, cheap file reorganization | +| Tools | Read, Write, Edit, Glob, Grep, Bash, Agent | +| Write access | `.claude/rules/` directory only | +| `maxTurns` | 60 — anti-loop stop, not a budget | +| Invocation | Spawned by `/brewcode:rules` via the Task tool — not a slash command, no manual trigger | + +## Scope guard + +Rules Organizer never sizes or splits a brief itself — `/brewcode:rules` already batches and confirms the rule set before the single spawn, so scope is fixed to what it was handed. + +| Situation | What the agent does | +|-----------|---------------------| +| Brief matches the accepted rule batch | Writes only inside `.claude/rules/` — every other path is off-limits | +| Request reaches beyond rule organization | Reports it back instead of expanding scope | +| Brief omits CONTEXT (what the skill already did) or CONSUMER (who reads the rules next) | States the assumption in the report, or asks once — never invents scope | +| `maxTurns: 60` is hit mid-run | Run aborts; rules already written survive, next run resumes from the last file logged in the report | + +## What it does + +Rules Organizer takes rules extracted from a source file (CLAUDE.md, docs, code) and turns them into `.claude/rules/*.md` files with correct `paths:` frontmatter — one file per logical scope, deduplicated against everything already there, formatted as numbered tables instead of prose. + +It owns two authoritative table formats (`| # | Avoid | Instead | Why |` and `| # | Practice | Context | Source |`), a 3-check dedup protocol that catches near-duplicates and avoid/best-practice antonym pairs, and a hard 20-row-per-file ceiling that forces a split into `{prefix}-avoid.md` / `{prefix}-best-practice.md` once a file grows past it. + +## Example + +```text +/brewcode:rules "extract logging and SQL conventions from CLAUDE.md" +``` + +The skill reads the source, proposes rule candidates, asks you to accept/reject in batches, then spawns `bc-rules-organizer` once with the accepted set. Expected result: `.claude/rules/logging.md` and `.claude/rules/sql-best-practice.md` created or updated, each with `paths:` frontmatter, numbered tables, and a report listing files created/updated plus a rule count. + +## Workflow + + +
  • +
    + Analysis +

    Reads the source file completely, identifies rule categories, maps each to a path pattern (or auto-detects from project structure), checks existing rules first.

    +
    +
  • +
  • +
    + Extraction +

    Groups rules by logical scope (component, API, test, build, module) and classifies each as anti-pattern (avoid) or best practice.

    +
    +
  • +
  • +
    + Optimization and dedup +

    Converts prose to tables, applies abbreviations, adds lazy links for detail. Runs the 3-Check Dedup Protocol: within-file similarity, cross-file avoid/best-practice antonym pairs, and a CLAUDE.md duplicate check — anything already in CLAUDE.md is skipped, never re-added.

    +
    +
  • +
  • +
    + File creation +

    Writes or updates files under .claude/rules/ — global avoid.md/best-practice.md with no paths:, or {'{prefix}'}-avoid.md style files scoped with quoted glob patterns. Max 20 rows per table; splits into a new specialized file once exceeded.

    +
    +
  • +
  • +
    + Checkpoint per file +

    Appends each finished file (path + what changed) to .claude/reports/YYYYMMDD-HHMMSS_rules-organizer/report.md right after writing it — not held to the end. If maxTurns is hit, written rules survive; the run resumes from the last file listed in that report.

    +
    +
  • +
  • +
    + Optimize and report +

    Spawns brewtools:text-optimizer once per created/updated file, all in one message — skipped with a note if brewtools is not installed, never a blocker. Returns a final report: files created/updated table, rule counts, and next-step checklist.

    +
    +
  • +
    + +
    +Technical details — frontmatter, dedup protocol, file naming + +### `paths:` frontmatter — the only supported field + +Source: [code.claude.com/docs/en/memory](https://code.claude.com/docs/en/memory.md#path-specific-rules). Only `paths:` is a valid field — `globs`, `alwaysApply`, `description` are not. + +```yaml +--- +paths: + - "src/components/**/*.tsx" + - "!src/components/**/*.test.tsx" +--- +``` + +Patterns must be quoted (`"**/*.tsx"`, not bare). Bug [#16299](https://github.com/anthropics/claude-code/issues/16299): all rules load at session start regardless of `paths:` — lazy loading is not actually working upstream, so the agent still scopes files correctly but the load-time benefit doesn't apply yet. + +Rules that fire before a file is in context — search policy, tool-choice policy, delegation policy — stay unscoped (no `paths:`), because `paths:` only matches files already in context. + +### 3-Check Dedup Protocol + +| Check | Scope | Action | +|-------|-------|--------| +| 1. Within-file | Same target file | >70% similarity skip; 40-70% merge | +| 2. Cross-file antonym | Paired avoid/best-practice file | Same concept as opposite — keep the avoid entry, delete the best-practice one | +| 3. CLAUDE.md duplicate | Project CLAUDE.md | Already documented there — skip entirely, `"CLAUDE.md"` is a forbidden Source value | + +### File naming + +| Pattern | Example | Content | +|---------|---------|---------| +| Global | `avoid.md`, `best-practice.md` | No `paths:` | +| Path-scoped pair | `{'{prefix}'}-avoid.md`, `{'{prefix}'}-best-practice.md` | Common prefixes: test, sql, api, security, kotlin, java, react | +| Domain-specific | `bq-core.md`, `logging.md` | Mixed avoid + best-practice tables under one `paths:` scope | + +### Version stamping (v5.1.0+) + +Its frontmatter `version`, `generated_by`, and `last_updated` are rewritten at release, never edited by hand. + +
    + + + + The only entry point that spawns this agent — interactive extraction and batching. + + + Agent definition, table formats, and dedup protocol in full. + + + All brewcode skills and agents in one place. + + + + diff --git a/web/docs/src/content/docs/brewcode/hooks.mdx b/web/docs/src/content/docs/brewcode/hooks.mdx index ac77e95..4870003 100644 --- a/web/docs/src/content/docs/brewcode/hooks.mdx +++ b/web/docs/src/content/docs/brewcode/hooks.mdx @@ -15,7 +15,7 @@ They inject context and manage session state. Brewcode registers 2 hooks in `hoo | # | Hook | Event | Matcher | Channel | Timeout | Purpose | |---|------|-------|---------|---------|---------|---------| -| 1 | forced-eval | UserPromptSubmit | -- | additionalContext | 1s | Manager-role + split-discipline reminder | +| 1 | forced-eval | UserPromptSubmit | -- | additionalContext | 2s | Manager-role + split-discipline reminder | | 2 | session-start | SessionStart | -- | additionalContext | 3s | Session init, permission_mode tag | ## Execution flow @@ -67,7 +67,7 @@ There is no skill-activation nudge -- modern models pick skills on their own. |-----------|-------| | Event | UserPromptSubmit | | Channel | additionalContext | -| Timeout | 1000 ms | +| Timeout | 2000 ms | **Exact injected text:** diff --git a/web/docs/src/content/docs/brewcode/overview.mdx b/web/docs/src/content/docs/brewcode/overview.mdx index b84d0d6..4c18085 100644 --- a/web/docs/src/content/docs/brewcode/overview.mdx +++ b/web/docs/src/content/docs/brewcode/overview.mdx @@ -92,7 +92,7 @@ brewcode/ ├── hooks/ │ ├── hooks.json # 2 hooks (SessionStart, UserPromptSubmit) │ ├── session-start.mjs # Version-check, plan-symlink, permission tag -│ ├── forced-eval.mjs # [ROLE] delegate + [SPLIT] bounded units +│ ├── forced-eval.mjs # [ROLE] delegate + [SPLIT] bounded units + [BRANCH] default-to-main │ └── lib/ │ └── utils.mjs # I/O, version cache, configuration ├── agents/ # 5 agents @@ -111,6 +111,7 @@ brewcode/ │ ├── agents/ # Agent management │ ├── teams-setup/ # Dynamic agent teams │ └── e2e/ # E2E testing orchestration +├── modes/ # Manager mode └── templates/ # Rule templates ``` @@ -121,6 +122,35 @@ A **`-setup`** skill installs a mechanism — agents, hooks, a rule, a generated
    +## Artifact metadata + +v5.1.0 standardizes what every `-setup` skill stamps onto what it installs, across all four +plugins. Version always comes from `.claude-plugin/plugin.json`, never hardcoded and never the +literal `unknown` — a writer that cannot resolve it refuses to write rather than stamp a fake. + +| Field | Values | Where | +|-------|--------|-------| +| `doc_type` | `llm` \| `user` \| `skip`, unquoted | `.md` frontmatter only, never JSON | +| `version` | quoted `"X.Y.Z"` | every carrier | +| `generated_by` | quoted `":"` | every carrier | +| `last_updated` | quoted `"YYYY-MM-DD"` | every carrier except a byte-copied `.mjs`/`.sh`/`.md` — the date would just be the release date and would churn the drift check | + +Five carriers, depending on the file type it is stamping: + +| Carrier | Applies to | +|---------|-----------| +| JSON top-level keys | JSON artifacts | +| `.md` frontmatter | generated `.md` skills and docs | +| `// brewcode-meta:` one-liner, line 2 | byte-copied `.mjs`/`.sh` | +| header table (`\| Version \|`, `\| Generated by \|`, `\| Last update \|`) | `team.md` | +| ``, line 1 | byte-copied `.md` | + +[`/brewcode:setup-status`](/brewcode/skills/setup-status/) is the +payoff — it reads these stamps back and compares them against the installed plugin version across +all ten `-setup` skills to report installed, stale or partial. See +[`superreview-setup`](/brewcode/skills/superreview-setup/#version-and-ownership) for one skill's +full version of this contract. + ## Components in detail diff --git a/web/docs/src/content/docs/brewcode/skills.mdx b/web/docs/src/content/docs/brewcode/skills.mdx index b20d5ab..a8db9ed 100644 --- a/web/docs/src/content/docs/brewcode/skills.mdx +++ b/web/docs/src/content/docs/brewcode/skills.mdx @@ -26,7 +26,7 @@ Setup skills draw their verbs from one vocabulary, in this order: A bare invocation means `status` when the mechanism is installed and `install` when it is not — with one deliberate exception: [`semble-setup`](/brewcode/skills/semble-setup/) always defaults to `status`, because its `install` reaches outside the project to run `brew install uv`. -A skill implements the verbs that mean something for it and rejects the rest with an error rather than guessing. [`teams-setup`](/brewcode/skills/teams-setup/) implements `status`, `install`, `upgrade`, `uninstall` and `purge`, and rejects `enable` / `disable` — a team either exists or it does not. Skill-specific extras come after the canonical set, never in place of it: `reindex`, `optimize` and `resume` on `semble-setup`. +All ten `-setup` skills implement all seven verbs, via one of two mechanisms: a live config flag the reader re-checks on every invocation, or entry-file parking, where the filename discovery keys on is renamed `.disabled` with the body left byte-identical. [`teams-setup`](/brewcode/skills/teams-setup/) uses parking: `enable`/`disable` move each roster member's agent file between `.md` and `.md.disabled`. Skill-specific extras come after the canonical set, never in place of it: `reindex`, `optimize` and `resume` on `semble-setup`. ## Summary table diff --git a/web/docs/src/content/docs/brewcode/skills/semble-setup.mdx b/web/docs/src/content/docs/brewcode/skills/semble-setup.mdx index dae9bce..c137006 100644 --- a/web/docs/src/content/docs/brewcode/skills/semble-setup.mdx +++ b/web/docs/src/content/docs/brewcode/skills/semble-setup.mdx @@ -19,7 +19,7 @@ import { Badge, Callout, Card, CardGrid, Steps, UpdateNotice } from '../../../.. | No-arg default | **always `status`** — see the exception note below | | Model | opus | | Tools | Read, Bash, AskUserQuestion | -| MCP server | `semble_code`, registered at **user** scope, pin `semble[mcp]==0.5.2` | +| MCP server | `semble_code`, registered at **user** scope, pin `semble[mcp]==0.5.4` | Every other `-setup` skill treats a bare invocation as `status` when installed and `install` when not. `semble-setup` breaks that rule on purpose: `install` runs `brew install uv`, a **machine-level** mutation outside the project. A bare `/brewcode:semble-setup` must never be able to trigger it, so empty input always resolves to read-only `status`. To install, type the verb. @@ -31,7 +31,7 @@ Every other `-setup` skill treats a bare invocation as `status` when installed a It always prints the current state before changing anything. Every mutation goes through a script under `scripts/` — the skill only decides which one to run and reports the result. -Semble has no Homebrew formula of its own. The skill installs `uv` via `brew` (a machine-level step gated by an explicit confirmation) and then runs the pinned `uvx --from 'semble[mcp]==0.5.2' semble --content code config` — never a floating version. +Semble has no Homebrew formula of its own. The skill installs `uv` via `brew` (a machine-level step gated by an explicit confirmation) and then runs the pinned `uvx --from 'semble[mcp]==0.5.4' semble --content ` — never a floating version. See Corpus below for the actual token list. ## When to use @@ -54,27 +54,69 @@ Semble has no Homebrew formula of its own. The skill installs `uv` via `brew` (a /brewcode:semble-setup install ``` -``` -uv missing — plan: brew install uv; uvx --from 'semble[mcp]==0.5.2' semble --help -[AskUserQuestion] Install uv via Homebrew now? -> Install -✅ uv resolved, pin 0.5.2 confirmed -✅ docs cache root reserved (RESERVED-FOR-DOCS.txt) -✅ semble_code registered at user scope +`install` probes `uv`, asks once before the machine-level `brew install uv`, registers the MCP server, then wires everything that does not need a live server — the rule, `.sembleignore`, the `CLAUDE.md` block, the three hooks, permissions and agent migration — before stopping at the reload checkpoint: +```text +# Semble install + +## Detection +project: /abs/project/root +prompt: "install" +mode: install (reason: matched keyword "install") +scope: user + +## Before +cli: uv absent | uvx absent | semble pin 0.5.4 (uvx-ephemeral) | claude 2.1.226 +mcp: absent @ user [unknown] +cache: /Users/me/Library/Caches/semble-code | repo — | 0 B | absent | docs root reserved: no +guidance: rule absent | CLAUDE.md absent | hooks 0/4 wired | permissions no +agents: 4 total | 2 inherit | 0 patched | 2 conflict | 0 skipped +state: phase=absent enabled=null completed=[] + +## Actions +changed: brew install uv, semble_code registered @ user, docs cache root reserved, rule installed, .sembleignore installed, CLAUDE.md block installed, 3 hooks wired, permissions merged, 2 agents patched +unchanged: none +skipped: none +failed: none + +## Verification +commands: bash scripts/semble-install.sh all --yes --json; bash scripts/semble-cache.sh reserve-docs --json; bash scripts/semble-mcp.sh add --scope user --yes --json; bash scripts/semble-guidance.sh install --part all --json; bash scripts/semble-agents.sh apply --scope project --yes --json +smoke: skipped (MCP not yet live in this session) +corpus: code docs config | repo | unknown +uncovered: .json/.json5/.csv/.tsv/.psv (no content type reaches them), .mdx/.txt (absent from _EXTENSION_TO_LANGUAGE) -> use rg + +## Current Status +reload required — semble_code registered, wiring complete, session has not restarted + +## Next Step Reload Claude Code (new session), then run: /brewcode:semble-setup resume Checkpoint: /abs/project/root/.claude/semble/state.json ``` -After a fresh session: +After a fresh session, `resume` re-checks status, runs the smoke query (the one step that actually needed a live server), and re-applies the same wiring idempotently so any drift self-repairs: ``` /brewcode:semble-setup resume ``` -``` -smoke query ok — cold index built in 23s, 7.6 MiB on disk -rule + hooks + permissions installed, 2 project agents migrated -phase: ready +```text +## Actions +changed: phase -> verifying, smoke query ok, phase -> ready +unchanged: rule, .sembleignore, CLAUDE.md block, 3 hooks, permissions, 2 agents (already wired by install) +skipped: none +failed: none + +## Verification +commands: bash scripts/semble-state.sh phase verifying --json; bash scripts/semble-project.sh smoke --json; bash scripts/semble-guidance.sh install --part all --json; bash scripts/semble-agents.sh apply --scope project --yes --json; bash scripts/semble-state.sh phase ready --json +smoke: how sessions are persisted -> 5 results, top = src/store/session.ts:41-58 score 0.83 +corpus: code docs config | repo | unknown +uncovered: .json/.json5/.csv/.tsv/.psv (no content type reaches them), .mdx/.txt (absent from _EXTENSION_TO_LANGUAGE) -> use rg + +## Current Status +ready — MCP verified, wiring confirmed, agents migrated + +## Next Step +none ``` Subsequent searches are sub-second and require the absolute repo path: @@ -92,43 +134,43 @@ Subsequent searches are sub-second and require the absolute repo path: ## Workflow -
  • +
  • Status first, every mode

    Runs semble-status.sh --section all --json — read-only, writes nothing under the project, the cache root or ~/.claude/settings.json. Prints the pre-mutation Before snapshot.

  • -
  • +
  • Resolve the mode

    Applies the 5-step routing algorithm to $ARGUMENTS: empty input is always status, never a mutation; a pending reload routes straight to resume; otherwise the highest count of matched keywords wins. States the resolved mode and the reason before acting.

  • -
  • +
  • Install — prerequisite gate

    semble-install.sh <check|uv|coreutils|semble|all>all runs check -> uv -> coreutils -> semble. Probes without --yes first. uv is a hard gate: missing, it asks one AskUserQuestion before running brew install uv — a machine-level mutation outside the project, never silent. coreutils is a soft, optional offer for the same question — see Technical details below.

  • -
  • +
  • - Install — register and checkpoint -

    Reserves the separate docs cache root, registers semble_code at user scope, then writes a reload checkpoint and stops. The server does not exist for the running session — no smoke query is attempted.

    + Install — register and wire +

    Reserves the separate docs cache root, registers semble_code at user scope, then wires everything that does not need a live MCP server — the semble-first rule, .sembleignore, the CLAUDE.md block, the three hooks, permissions and project agent migration — before writing a reload checkpoint and stopping. Only the smoke query and phase -> ready wait for the new session.

  • -
  • +
  • - Resume — verify and wire -

    Re-checks status; if the MCP state is not correct, falls back into install instead of verifying. Runs a smoke query (up to 600s on a cold embedding-model download), installs the semble-first rule, hooks and permissions, then migrates project agents' tools: allowlists.

    + Resume — verify and self-repair +

    Re-checks status; if the MCP state is not correct, falls back into install instead of verifying. Otherwise runs the smoke query (up to 600s on a cold embedding-model download) and an idempotent re-run of the same rule/hooks/permissions/agent wiring — self-repairing any drift, not repeating work install already did — then closes the state at phase: ready.

  • -
  • +
  • Other modes — one delegation each -

    enable/disable flip a flag, deleting nothing. reindex and purge run dry first (exit 4), show the exact paths, then require one confirmation before the destructive pass. optimize only reads. upgrade compares the recorded pin against 0.5.2 and no-ops if identical.

    +

    enable/disable flip a flag, deleting nothing. reindex and purge run dry first (exit 4), show the exact paths, then require one confirmation before the destructive pass. optimize only reads. upgrade compares the recorded pin against 0.5.4 and no-ops if identical.

  • -
  • +
  • Report

    Re-runs status after the last write and prints six fixed sections: Detection, Before, Actions, Verification, Current Status, Next Step — including every command actually run and an uncovered: line on every invocation.

    @@ -143,15 +185,15 @@ Subsequent searches are sub-second and require the absolute repo path: | Mode | Effect | Mutates | |------|--------|---------| | `status` | full report: prereqs, MCP, cache, guidance, agents, coverage, state — **the default on empty input** | no | -| `install` | install `uv`, register `semble_code` at user scope, checkpoint for reload | yes | -| `upgrade` | compare the recorded pin against `0.5.2`, re-register if different | yes | +| `install` | install `uv`, register `semble_code` at user scope, wire the rule, `.sembleignore`, `CLAUDE.md`, hooks, permissions and agent migration, checkpoint for reload | yes | +| `upgrade` | compare the recorded pin against `0.5.4`, re-register if different, then unconditionally re-sync the rule, `.sembleignore`, hooks and permissions — the only path that moves the artifact version stamp | yes | | `enable` | turn back on: verify, warm, phase -> `ready` | yes | | `disable` | `enabled=false` — hooks go silent, nothing deleted | yes | | `uninstall` | four flavours: `integration` / `mcp` / `cli` / `purge` | yes | | `purge` | everything, including the code cache root — typed confirmation required | yes | | `reindex` | extra: delete exactly this repo's cache dir (confirmed), then warm | yes | -| `optimize` | extra: read-only audit fan-out with concrete recommendations | no | -| `resume` | extra: after reload — smoke query, rule + hooks + permissions, agent migration | yes | +| `optimize` | extra: read-only audit fan-out with concrete recommendations — reads current cache size, staleness and entry count via `semble-cache.sh info --json` (figures vary by machine/repo, no fixed number published) | no | +| `resume` | extra: after reload — smoke query, then an idempotent self-repair re-run of the same wiring, `phase -> ready` | yes | | `warm` | free-text intent ("warm", "прогрей") — pre-builds the index, deletes nothing | yes (cache write only) | The first seven rows are the canonical vocabulary shared by every `-setup` skill. `reindex`, `optimize` and `resume` are semble-specific extras with no equivalent elsewhere. The retired verbs `setup`, `update` and `remove` are gone; free-text intent in RU or EN still routes to the right mode. @@ -170,14 +212,32 @@ The first seven rows are the canonical vocabulary shared by every `-setup` skill | Surface | Location | |---------|----------| | MCP server | `~/.claude.json` `.mcpServers.semble_code`, user scope — `-s user` is mandatory, the CLI default is `local` | -| Command | `uvx --from 'semble[mcp]==0.5.2' semble --content code config` | +| Command | `uvx --from 'semble[mcp]==0.5.4' semble --content ` (Corpus below has the actual token list) | | Code cache root | absolute `SEMBLE_CACHE_LOCATION`: macOS `~/Library/Caches/semble-code` / Linux `${XDG_CACHE_HOME:-~/.cache}/semble-code` | | Docs cache root | same path with a `semble-docs` leaf — created empty, **reserved, never registered** | | State | `/.claude/semble/state.json` | | Rule | `/.claude/rules/semble-first.md` | -| Hooks | `semble-session.mjs` (SessionStart) + `semble-reminder.mjs` (PreToolUse `Bash`/`Grep`, advisory) + `semble-explore.mjs` (SubagentStart `Explore` — primes the spawned Explore subagent to call `mcp__semble_code__search` directly, skipping its own `ToolSearch`) — `hooks /4 wired` in status, silent unless phase is `ready` and `enabled` | +| Ignore file | `/.sembleignore` — keeps generated/vendored trees out of the corpus; managed like the rule (user edits reported, never clobbered) and gets an appended, **commented-out** block of measured candidates (duplicate trees, disproportionately heavy directories) — nothing is excluded until the user uncomments a line | +| CLAUDE.md | a marked `` block | +| Hooks | three hook files copied into `.claude/hooks/` — `semble-session.mjs` (SessionStart — state + reload messaging), `semble-prefetch.mjs` (UserPromptSubmit — runs one semble search on the prompt, injects the top-3 result **paths**, no snippets), `semble-stats.mjs` (PostToolUse **and** PostToolUseFailure — two separate events, one script) — but **four** `settings.json` registrations, each `"timeout": 5` (seconds); status reports that as `hooks /4 wired` — never 4 files. The earlier advisory pair (`semble-reminder.mjs`, `semble-explore.mjs`) is retired: measured 0/18 and 0/11 conversion with delivery independently confirmed, so `install`/`upgrade` deletes them and un-wires their rows | | Agents | project `.claude/agents/**/*.md` get the two tool names added to `tools:`; global agents are never touched | +### Version tracking + +`status` compares the version stamped into the installed artifacts against the plugin running on this machine, folded into the same `guidance:` line: + +```text +guidance: rule managed | CLAUDE.md present | hooks 4/4 wired | permissions yes | version X.Y.Z (plugin A.B.C - run /brewcode:semble-setup upgrade) +``` + +The stamp lives in the frontmatter of `.claude/rules/semble-first.md` (`version:`) and on line 2 of each hook file (`// brewcode-meta: version=X.Y.Z generated_by=brewcode:semble-setup`). When the two disagree, the overall verdict drops from `ready` to `partial` and **Next Step** becomes `Run /brewcode:semble-setup upgrade`. Stamping never fabricates a value it cannot resolve — the resolver refuses to write rather than bake in a fake version. + +`upgrade`'s idempotence was tightened alongside it: a run with nothing to change now reports `unchanged`, not `changed` — the `.sembleignore` half snapshots itself before and after so a template re-sync followed by the candidates re-append collapses to a net-zero result instead of a false positive on every run. And because the MCP server is registered at **user** scope, a second project on the same machine used to short-circuit on "already registered" and never receive its own `state.json`; every project now gets one, regardless of what an earlier project already wrote. + + +`unchanged` from `install`/`upgrade` is never a promise of byte-identity with the plugin template — a stale metadata stamp on otherwise identical prose re-syncs and reports itself as `re-synced ... (metadata only)`, and a managed `.sembleignore` legitimately differs from the shipped template the moment `install` appends its measured-candidates block. Read `Actions`, not just the verdict, before assuming nothing moved. + + ### Reload boundary A newly registered MCP server is not usable until a **new Claude Code session**. `install` writes the checkpoint and stops there — it never claims success it cannot verify. `/brewcode:semble-setup resume` continues at the smoke query in the new session. @@ -189,7 +249,7 @@ Both `mcp__semble_code__search` and `mcp__semble_code__find_related` require an ### Corpus and coverage - Corpus is `--content code config`. `.html`/`.htm` are **not indexed** — semble classifies HTML as docs. `.json`/`.json5`/`.csv`/`.tsv`/`.psv` are excluded from every content type, unreachable even with `--content all`. `rg` stays the tool for those, and for exact identifiers, regexes, and exhaustive enumeration. + Corpus is the `--content` set named by `SEMBLE_CONTENT_ARGS` (currently `code docs config`) — `docs` is mandatory: markdown lives in semble's doc-language bucket, so a `code config` corpus indexes zero `.md` files. `.html`/`.htm` **are** indexed, in the docs bucket. `.json`/`.json5`/`.csv`/`.tsv`/`.psv` are excluded from every content type, unreachable even with `--content all`; `.mdx`/`.txt` are absent from semble's extension table entirely. `rg` stays the tool for those, and for exact identifiers, regexes, and exhaustive enumeration. ### Honest limits @@ -212,20 +272,12 @@ Both `mcp__semble_code__search` and `mcp__semble_code__find_related` require an | `cli` | kept | kept | kept | kept | uninstalled | | `purge` | removed | removed | removed | code root removed | typed confirmation | -### Measured on this repo - -| Metric | Value | -|--------|-------| -| Cold index build | 23s | -| Cache size on disk | 7.6 MiB | -| Subsequent queries | sub-second | - Full plugin overview — all skills, agents, and hooks in one place. - Manages the project agent roster — the `resume` step patches those same agents' `tools:` allowlists. + Manages the project agent roster — the `install` step patches those same agents' `tools:` allowlists; `resume` re-applies it idempotently. Read-only dashboard: it `cmp`s the three semble hooks and the rule against the plugin assets to decide installed vs stale. diff --git a/web/docs/src/content/docs/brewcode/skills/setup-status.mdx b/web/docs/src/content/docs/brewcode/skills/setup-status.mdx index 823a4a9..4973c6e 100644 --- a/web/docs/src/content/docs/brewcode/skills/setup-status.mdx +++ b/web/docs/src/content/docs/brewcode/skills/setup-status.mdx @@ -27,9 +27,9 @@ You open an unfamiliar repo and want one answer: what of the brewcode suite is a Every probe is an existence check, a `cmp` against the installed plugin asset, or a one-line grep. Nothing is created, edited or deleted — that is a **capability**, not a policy: `allowed-tools` carries no `Write`, no `Edit` and no `Agent`, so the skill physically cannot mutate your project or spawn a subagent that could. -Staleness is decided honestly or not at all. Four signals and nothing else — no mtime heuristics, no guessing. Where a setup leaves no signal, the report says `installed (version unknown)` and means it. +Staleness is decided from three signals, never mtime and never a guess: the artifact's own **version stamp** (which plugin release produced it), its **owner stamp** — `generated_by` compared against the setup that should have written the path — and `cmp` against the plugin asset, which only corroborates the other two. A `cmp` source missing from the plugin cache reports `version unknown (plugin asset missing)`, never `stale`. -A setup you switched off on purpose is reported `disabled`, not broken: the state is evaluated before `partial` and `stale`, its command column offers `enable`, and it never enters the run-list. +A setup you switched off on purpose is reported `disabled`, not broken: the state is evaluated before `missing`, `partial` and `stale`, its command column offers `enable`, and it never enters the run-list. ## Why it refuses to run the setups @@ -56,25 +56,37 @@ Ask it to "install everything" or "fix them all" and it refuses once, plainly, t ``` ``` -roster: 10/10 in sync +4 of 10 setups are behind the installed plugin (2 stale by version, 1 legacy stamp, 1 stale by wiring). -| Skill | State | Found | Command -| /brewtools:task-board-setup | stale | board.md + tracker present, .claude/skills/task-spec/ absent | /brewtools:task-board-setup upgrade "retrofit the spec + design layer onto the deployed board, keep every task id" -| /brewcode:semble-setup | stale | rule + 3 hooks present; semble-reminder.mjs DIFFERS vs brewcode 4.10.1 | /brewcode:semble-setup upgrade "re-copy the hooks, the reminder hook drifted from the 4.10.1 asset" -| /brewcode:teams-setup | installed (version unknown) | team.md + trace.jsonl + trace-ops.sh; no version stamp exists for this setup | /brewcode:teams-setup status -| /brewtools:think-short-setup | disabled | 4 hooks wired, prompt renamed to think-short-prompt.md.disabled | /brewtools:think-short-setup enable -| /brewdoc:docsync-setup | missing | nothing under .claude/docsync/ | /brewdoc:docsync-setup install -| /brewtools:manager-setup | n/a | brewtools not installed | claude plugin install brewtools@claude-brewcode +| Skill | State | Version | Found | Command +| /brewcode:semble-setup | stale | A.B.C | rule + all three live hook files current; semble-explore.mjs (RETIRED, not one of the three live hooks) is still on disk — wiring shows only 3 of the 4 settings.json entries the current version wants | /brewcode:semble-setup install "remove the retired hook file, re-wire all four settings entries" +| /brewtools:task-board-setup | stale (X.Y.Z -> A.B.C) | X.Y.Z -> A.B.C | board.md + tracker present, .claude/skills/task-spec/ absent | /brewtools:task-board-setup upgrade "retrofit the spec + design layer onto the deployed board, keep every task id" +| /brewtools:agent-deadline-setup | stale (X.Y.Z -> A.B.C) | X.Y.Z -> A.B.C | guard + config present, JSON trio never restamped since an older release | /brewtools:agent-deadline-setup upgrade "restamp the config trio at the current budget" +| /brewdoc:memory-sync-setup | stale (legacy stamp) | legacy -> A.B.C | emitted SKILL.md has no frontmatter version: (the current carrier); the retired tail survives only as the legacy detector | /brewdoc:memory-sync-setup upgrade "migrate the pre-5.0 tail stamp to provenance frontmatter" +| /brewdoc:docsync-setup | missing | -- | nothing under .claude/docsync/ | /brewdoc:docsync-setup install +| /brewcode:teams-setup | installed | A.B.C | team.md (Version A.B.C) + trace.jsonl + trace-ops.sh, all bytes match | /brewcode:teams-setup status +| /brewtools:think-short-setup | disabled | A.B.C | 4 hooks wired, prompt renamed to think-short-prompt.md.disabled | /brewtools:think-short-setup enable +| /brewtools:manager-setup | n/a | -- | brewtools not installed | claude plugin install brewtools@claude-brewcode Run in this order, ONE PER SESSION: - 1. /brewtools:task-board-setup upgrade "..." <- broken/partial first - 2. /brewcode:semble-setup upgrade "..." <- stale next - 3. /brewdoc:docsync-setup install <- new installs last + 1. /brewcode:semble-setup install "..." <- stale first + 2. /brewtools:task-board-setup upgrade "..." + 3. /brewtools:agent-deadline-setup upgrade "..." + 4. /brewdoc:memory-sync-setup upgrade "..." + 5. /brewdoc:docsync-setup install <- new installs last Each of these spawns several subagents and will ask you questions. Running two in one session degrades both. Start a fresh session per command. + +think-short-setup is disabled on purpose — enable with /brewtools:think-short-setup enable. + +roster: 10/10 in sync ``` +`A.B.C` stands for the installed plugin version and `X.Y.Z` for an artifact's own, older stamp in the samples above — the real report always prints real numbers, never these placeholders. + +The last line is separate: it is Phase 5's roster self-check, not part of the table above — it confirms the skill's own roster still matches every `*-setup` directory shipped in the installed plugins. + The **Command** column is ready to paste. For `stale` and `partial` it always carries a concrete fine-tune prompt naming what to refresh — a bare `upgrade` with no prompt is not acceptable output. Only canonical verbs appear there (`status` · `install` · `upgrade` · `enable` · `disable` · `uninstall` · `purge`), plus the extras two setups genuinely add after them: `reindex | optimize | resume` for [`semble-setup`](/brewcode/skills/semble-setup/), and `level <...>` for `agent-router-setup` and `manager-setup`. ## Workflow @@ -95,19 +107,19 @@ The **Command** column is ready to paste. For `stale` and `partial` it always ca
  • Read the disable switches -

    Five setups leave a real off-switch on disk, and each is probed directly rather than inferred: .claude/semble/state.json .enabled, a think-short-prompt.md.disabled rename in the hooks dir, .claude/brewtools/manager/state.json .hard, .claude/agent-deadline.json .enabled, .claude/brewtools/agent-router.json .enabled. The other five setups have no switch and can never be disabled.

    +

    All ten setups leave a real off-switch on disk, probed directly rather than inferred — the two mechanisms are detailed in Two rules that stop false alarms below. An absent key means OFF on the opt-in agent-deadline row, but ON on the opt-out agent-router and docsync rows — the two defaults ship side by side.

  • Version signals -

    Only for rows whose anchor exists and whose roster cell defines a signal. cmp the project copy against the plugin asset, read the provenance stamp, or diff the pristine template baseline. A missing plugin asset reports version unknown (plugin asset missing), never stale.

    +

    Only for rows whose anchor exists and whose roster cell defines a signal. Read the version stamp, compare generated_by against the row's own owner, then cmp the project copy against the plugin asset to corroborate. A missing plugin asset reports version unknown (plugin asset missing), never stale.

  • Classify -

    Exactly one state per row, evaluated in a fixed order: n/a, then missing, then disabled, then partial, then stale, then installed, then installed (version unknown). The two orderings that stop false alarms: anchor MISS wins outright, and disabled outranks both partial and stale.

    +

    Exactly one state per row, evaluated in this fixed order: n/a, then disabled, then missing, then partial, then stale, then installed — why disabled outranks missing is explained in Two rules that stop false alarms below.

  • @@ -126,6 +138,10 @@ The **Command** column is ready to paste. For `stale` and `partial` it always ca ## Technical details + +Every earlier release verified that an artifact was stamped at install; none verified the stamp could ever move. `upgrade` used to refresh content and leave the version stamp untouched — `status` reported `stale`, `upgrade` reported success, the next `status` reported `stale` again, forever. All ten setups now close that loop: install at an old version, bump the plugin, `status` reports `stale`, `upgrade` restamps it, `status` reports `installed`, and a second `upgrade` is a no-op with the artifact body byte-identical. A handful of findings genuinely have no clearing mode — a `.template-baseline/` diff on `superreview-setup`, a hand-edited `memory-sync` reference, a hand-edited `semble-first.md` — and this skill reports those as a diff to port by hand, never dressed up as a command that would do nothing. + + ### The 10 setups it covers | Setup | Anchor artifact | @@ -145,41 +161,99 @@ Recurring tools never appear in the report — they have no installed state and ### States -| State | Means | -|-------|-------| -| `missing` | the anchor is absent — never installed here. The anchor is decisive: a shared file the project happens to contain is not evidence | -| `disabled` | every file is in place but the mechanism is switched off on purpose. Reported as inactive, never as broken; command column offers `enable` | -| `partial` | anchor present with a secondary missing, or a secondary present with no anchor — a broken or half-removed install | -| `installed` | everything present and byte-identical to the installed plugin version | -| `installed (version unknown)` | everything present, but this setup leaves no version signal to check | -| `stale` | a tracked file drifted from the plugin asset, the provenance stamp is behind, or a documented upgrade path was never run | -| `n/a` | that plugin is not installed | +Evaluated in this fixed order: + +| # | State | Means | +|---|-------|-------| +| 1 | `n/a` | that plugin is not installed | +| 2 | `disabled` | installed, then switched off on purpose — a config flag flipped, or the entry file parked as `.disabled`. Reported as inactive with its real version, never as broken and never as missing, and never queued in the run-list | +| 3 | `missing` | the anchor is absent in both spellings, no `.disabled` twin either — never installed here. A shared file the project happens to contain is not evidence | +| 4 | `partial` | some artifacts present and some gone, a version stamp left as an unresolved `{PLACEHOLDER}`, a row-1/row-4 toggle caught half-parked, or `generated_by` naming the wrong setup | +| 5 | `stale` | the version stamp is behind, ahead, retired, or missing, or the bytes drifted from the plugin asset — one of the four qualifiers below. An absence signal or semble's wiring signal fires with no stamp or byte problem at all, and prints a bare `stale` naming the missing artifact in the Found column instead | +| 6 | `installed` | the stamp equals the installed plugin version and every `cmp` pair matches | + +`stale` carries one of four qualifiers when a stamp or byte signal fired. Two cases print a bare +`stale` with no qualifier and name the missing artifact in the Found column instead: an absence +signal (a deployed board with no `task-spec` skill, a complete team with no `trace-ops.sh`) and +semble's wiring signal (`retired[]`/`staleEntries` when every file on disk is already byte-current). + +| Qualifier | Means | +|-----------|-------| +| `(X.Y.Z -> A.B.C)` | the stamp is a plugin version behind (or ahead, printed the same way) | +| `(legacy stamp)` | a retired stamp spelling is present instead of the current carrier | +| `(legacy, unstamped)` | the artifact carries no stamp in any carrier at all | +| `(bytes drifted)` | the version stamp is current but a `cmp` pair `DIFFERS` | + +One case survives outside this vocabulary: a `cmp` source missing from the plugin cache reports `version unknown (plugin asset missing)`, never `stale` — the cache is incomplete, not the project. ### Two rules that stop false alarms **Anchor MISS is decisive, and every secondary must be exclusive.** The anchor is the artifact only that setup writes. A shared file — any hand-written `.claude/agents/*.md`, or `intent-guard.md`, which both `superreview-setup` and `teams-setup` can emit — is not evidence that this setup ran, and listing one as a secondary made `teams-setup` report a broken `partial` install (and jump to the top of the run-list) in every project that merely had an agent file. So `teams-setup`'s secondaries are now `.claude/teams/*/trace.jsonl` and `.claude/teams/*/trace-ops.sh`, `superreview-setup` no longer claims `intent-guard.md`, and a setup with no exclusive secondary is decided by its anchor alone. -**`disabled` is evaluated before `partial` and `stale`,** because both readings are wrong on a deliberately switched-off setup. `think-short-setup disable` renames its prompt file away, so the roster secondary legitimately MISSes — calling that `partial` tells you to repair something you turned off. Inversely, semble at `enabled: false` or a manager wall at `hard: false` has every file in place and byte-identical, and reporting it `installed` would hide that the mechanism is inert. +**`disabled` is evaluated before `missing`, `partial` and `stale`,** because all three readings are wrong on a deliberately switched-off setup. Five setups disable by parking the anchor file itself — `superreview-setup disable` renames `SKILL.md` away, so checking presence first would report `missing` ("never installed") for something the user turned off on purpose. `think-short-setup disable` renames its prompt file away, so the roster secondary legitimately MISSes — calling that `partial` tells you to repair something you turned off. Inversely, semble at `enabled: false` or a manager wall at `hard: false` has every file in place and byte-identical, and reporting it `installed` would hide that the mechanism is inert. -| Setup | Off-switch probed | Disabled when | -|-------|-------------------|---------------| -| `semble-setup` | `.claude/semble/state.json` | `.enabled` is `false` — every file stays in place | -| `think-short-setup` | the hooks dir (project or `~/.claude`) | `think-short-prompt.md.disabled` present, `think-short-prompt.md` absent — hooks stay wired and no-op | -| `manager-setup` | `.claude/brewtools/manager/state.json` | `.hard` is not `true` — the wall is disarmed, not broken | -| `agent-deadline-setup` | `.claude/agent-deadline.json` (or the `~/.claude` twin) | `"enabled": false` | -| `agent-router-setup` | `.claude/brewtools/agent-router.json` | `"enabled": false` | +All **ten** setups leave a real off-switch on disk, under two mechanisms: a **live config flag** the reader re-checks on every invocation, or **entry-file parking**, where the one filename Claude Code discovers is renamed to `.disabled` with the body left byte-identical. -The other five setups leave no switch and can never be reported `disabled`. +| Setup | Mechanism | Off-switch | Disabled when | +|-------|-----------|-----------|---------------| +| `teams-setup` | entry-file parking | `.claude/agents/.md.disabled` | every roster member of `team.md` is parked. `intent-guard` is never parked — it is shared with `superreview-setup` | +| `semble-setup` | config flag | `.claude/semble/state.json` | `.enabled` is `false` — every file stays in place | +| `superreview-setup` | entry-file parking | `.claude/skills/superreview/SKILL.md.disabled` | present, `SKILL.md` gone. `references/` stays readable | +| `task-board-setup` | entry-file parking | any of `task-tracker.md`, `task-board/SKILL.md`, `task-spec/SKILL.md`, `rules/tasks.md` as `.disabled` | every deployed one of the four is parked; `.claude/features/**` untouched | +| `think-short-setup` | entry-file parking | the hooks dir (project or `~/.claude`) | `think-short-prompt.md.disabled` present, `think-short-prompt.md` absent — hooks stay wired and no-op | +| `agent-deadline-setup` | config flag | `.claude/agent-deadline.json` (or the `~/.claude` twin) | `"enabled": false` **or the key absent** — opt-in, so a key-less config is inert | +| `agent-router-setup` | config flag | `.claude/brewtools/agent-router.json` | `"enabled": false`. **An absent key means enabled** — opt-out, the hook defaults `enabled: true` | +| `manager-setup` | config flag | `.claude/brewtools/manager/state.json` | `.hard` is not `true` — the wall is disarmed, not broken | +| `memory-sync-setup` | entry-file parking | `.claude/skills/memory-sync/SKILL.md.disabled` | present, `SKILL.md` gone. The 3 references and every self-synced hand-edit stay | +| `docsync-setup` | config flag | `.claude/docsync/config.json` | `"enabled": false`. **An absent key means enabled** — opt-out, for back-compat installs written before the key existed | + +Three rows read an absent key differently, and conflating them inverts a row: `agent-deadline` is opt-in (`cfg.enabled !== true`), so no key means OFF. `agent-router` and `docsync` are opt-out (`c.enabled !== false`), so no key means ON. ### How staleness is decided -| Signal | Used by | How | -|--------|---------|-----| -| **Checksum** | semble-setup, think-short-setup, agent-deadline-setup, agent-router-setup, manager-setup, docsync-setup | Those setups `cp` their hook files verbatim, so `cmp` against the plugin asset is exact | -| **Provenance stamp** | memory-sync-setup | The emitted skill's last line carries `` on line 1. `/brewcode:setup-status` reads these lines to tell an install running an older brewtools apart from one on the current version — `upgrade` is what refreshes them. + ## Removal `uninstall` deletes all four copied files (the three hook scripts and `think-short-prompt.md`) and strips the corresponding three entries from `settings.json`, keeping the tmp markers. `purge` does the same and deletes the marker directory too. Neither touches entries added by other skills or plugins. diff --git a/web/docs/src/content/docs/faq.mdx b/web/docs/src/content/docs/faq.mdx index 8cb19e9..7c55488 100644 --- a/web/docs/src/content/docs/faq.mdx +++ b/web/docs/src/content/docs/faq.mdx @@ -87,7 +87,7 @@ A `-setup` suffix marks a skill that installs a mechanism into the project — a There are ten `-setup` skills. They share one mode vocabulary — `status | install | upgrade | enable | disable | uninstall | purge` — and a bare invocation reports instead of installing once something is already there. -Skill-specific verbs come after the canonical set, never in place of it. A setup with no on/off state rejects `enable` and `disable` with an error instead of quietly doing something else: `/brewcode:teams-setup enable` is an error, because a team either exists on disk or does not. +Skill-specific verbs come after the canonical set, never in place of it. All ten setups implement all seven canonical verbs. Two mechanisms do the work: a live config flag that the reader re-checks on every invocation (semble, agent-deadline, agent-router, manager, docsync), or entry-file parking, where the filename discovery keys on is renamed `.disabled` with the body left byte-identical (teams, superreview, task-board, think-short, memory-sync). Each setup is an interactive generator: it fans out subagents, analyses the repo and asks you real questions. Two in one session degrade each other. Start with [`/brewcode:setup-status`](/brewcode/skills/setup-status/) and work its list, one setup per fresh session — full details on the [Full Setup](/full-setup/) page. diff --git a/web/docs/src/content/docs/full-setup.mdx b/web/docs/src/content/docs/full-setup.mdx index e3f628e..154cb16 100644 --- a/web/docs/src/content/docs/full-setup.mdx +++ b/web/docs/src/content/docs/full-setup.mdx @@ -61,27 +61,39 @@ Six verdicts, and the order between them matters: | Verdict | Meaning | |---------|---------| -| `installed` | anchor and every secondary present, version signal matches | -| `installed (version unknown)` | all present, but this setup ships no version signal to compare — AI-authored files have no stamp. The honest answer, not a defect | -| `stale` | all present, but a version signal differs from the plugin's, or an absence signal fires (a team without `trace-ops.sh`, a board without the `task-spec` skill) | -| `disabled` | you turned it off on purpose. Five setups leave a probeable off-switch: `.enabled:false`, `hard:false`, or think-short's `think-short-prompt.md.disabled` rename | -| `partial` | anchor missing but some secondary present, or anchor present with a secondary missing — an interrupted install | -| `missing` | anchor and every secondary absent | +| `n/a` | the plugin that owns this row is not installed. Never `missing` | +| `disabled` | you turned it off on purpose. All ten setups leave a probeable off-switch — a live config flag or an entry-file rename — see below | +| `missing` | anchor and every secondary absent, in both the live and `.disabled` spelling | +| `partial` | anchor missing but some secondary present (or the reverse), a toggle left half-applied, an unresolved `{PLUGIN_VERSION}` placeholder, or the wrong skill's `generated_by` wrote the file | +| `stale` | all present, but a version signal disagrees, or an absence signal fires (a team without `trace-ops.sh`, a board without the `task-spec` skill), or semble's wiring signal fires. A version or byte signal takes one of four qualifiers: `(X -> Y)` — the stamp is behind or ahead of the plugin, `(legacy stamp)` — a retired stamp spelling survives, `(legacy, unstamped)` — no stamp in any carrier, `(bytes drifted)` — the stamp matches but a `cmp` pair differs. An absence signal or semble's wiring signal prints a bare `stale` and names the missing artifact in the Found column | +| `installed` | anchor and every secondary present, version stamp matches the plugin, every `cmp` pair agrees | -`disabled` outranks `partial` and `stale`, and that ordering is the point. `think-short-setup disable` -renames its prompt file away, so the secondary legitimately goes missing; calling that `partial` would -send you to re-install a mechanism that is working exactly as you configured it. Equally, a disabled -row is never reported as `installed` — the files are there but the mechanism is inert, so the row's -command column offers `enable`. +`installed (version unknown)` is retired: every anchor in the roster now carries a real version stamp, +including `teams-setup` and `task-board-setup`, which used to ship unstamped. What remains as an edge +case, not a seventh state, is `version unknown (plugin asset missing)` — the plugin cache is missing +the asset a `cmp` needs to compare against, so the byte check could not run at all. That is never +reported as `stale`. -A plugin you have not installed makes all of its rows `n/a`, never `missing`. +`disabled` outranks `missing`, `partial` and `stale`, and that ordering is the point. Five setups +disable by parking the anchor file itself — `superreview-setup disable` renames `SKILL.md` away, so +checking presence first would report `missing` ("never installed") for something the user turned off +on purpose. `think-short-setup disable` renames its prompt file away, so the secondary legitimately +goes missing; calling that `partial` would send you to re-install a mechanism that is working exactly +as you configured it. Equally, a disabled row is never reported as `installed` — the files are there +but the mechanism is inert, so the row's command column offers `enable`. + +All ten setups leave a probeable off-switch, under exactly two mechanisms: a **live config flag** +(`semble`, `agent-deadline`, `agent-router`, `manager`, `docsync` — the reader re-checks the key on +every invocation) or **entry-file parking** (`teams`, `superreview`, `task-board`, `think-short`, +`memory-sync` — the one filename discovery keys on is renamed `.disabled` and back, body +byte-identical, nothing deleted). ## The ten setups | Skill | Plugin | What it installs | |-------|--------|------------------| | [`teams-setup`](/brewcode/skills/teams-setup/) | brewcode | `.claude/teams/*/team.md` plus 5-20 project agents in `.claude/agents/*.md`, and `.claude/agents/intent-guard.md` | -| [`semble-setup`](/brewcode/skills/semble-setup/) | brewcode | the `semble_code` MCP server (user scope), `.claude/rules/semble-first.md`, three hooks in `.claude/hooks/` (`semble-session`, `semble-reminder`, `semble-explore`), `.claude/semble/state.json` | +| [`semble-setup`](/brewcode/skills/semble-setup/) | brewcode | the `semble_code` MCP server (user scope), `.claude/rules/semble-first.md`, three hook files in `.claude/hooks/` (`semble-session`, `semble-prefetch`, `semble-stats`) wired as four `settings.json` entries (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostToolUseFailure`, each `timeout: 5` seconds), `.claude/semble/state.json`, the repo-root `.sembleignore`, and the `<!-- BEGIN brewcode:semble -->` managed block in `CLAUDE.md` | | [`superreview-setup`](/brewcode/skills/superreview-setup/) | brewcode | a project `/superreview` skill at `.claude/skills/superreview/` with its `references/` and a `.template-baseline/` copy, plus `.claude/agents/intent-guard.md` | | [`task-board-setup`](/brewtools/skills/task-board-setup/) | brewtools | `.claude/features/board.md` and the board tree, `.claude/agents/task-tracker.md`, the `task-board` and `task-spec` skills under `.claude/skills/`, `.claude/rules/tasks.md`, `.claude/features/PROGRESS.md` | | [`think-short-setup`](/brewtools/skills/think-short-setup/) | brewtools | terse-mode hooks in `.claude/hooks/` (or `~/.claude/hooks/` for global scope): `think-short-session`, `think-short-prompt-counter`, `think-short-task`, `think-short-prompt.md` | @@ -220,23 +232,23 @@ just reports. install it, say so: `/brewcode:semble-setup install`. -Not every setup implements all seven — the ones with no on/off state skip `enable` and `disable`, and -say so instead of guessing. `/brewcode:teams-setup enable` exits with an error rather than falling back -to `install`; a team is either present in `.claude/teams/` or it is not, there is no armed/disarmed -state to flip. +All ten implement the full seven verbs — the five setups that previously lacked `enable`/`disable` +gained them in v5.1.0. The on/off state uses the same two mechanisms described above. +`teams-setup enable`/`disable` is a real rename of every roster member to `.md.disabled`, +documented in its own SKILL.md. | Skill | Modes it accepts | |-------|------------------| | `semble-setup` | all seven, plus `reindex`, `optimize`, `resume` | -| `think-short-setup` | all seven, plus `project` / `global` scope | +| `superreview-setup` | all seven, plus a free-text fine-tune prompt and an optional scope | +| `teams-setup` | all seven — each taking an optional team `[name]` | +| `task-board-setup` | all seven, plus an optional target repo path and a free-text directive | +| `think-short-setup` | all seven, plus `project` / `global` scope and a free-text intent | | `agent-deadline-setup` | all seven, plus `project` / `global` and a minutes budget | | `agent-router-setup` | all seven, plus `level fast` / `level strict` | | `manager-setup` | all seven, plus `level strict` / `level balanced` and `edit` | -| `task-board-setup` | `status`, `install`, `upgrade`, `uninstall`, `purge` | -| `docsync-setup` | `status`, `install`, `upgrade`, `uninstall`, `purge`, plus `sync`, `reread`, `frontmatter` | -| `teams-setup` | `status`, `install`, `upgrade`, `uninstall`, `purge` — each taking an optional team `[name]` | -| `memory-sync-setup` | `status`, `install`, `upgrade`, `uninstall` | -| `superreview-setup` | `status`, `install`, `upgrade` | +| `memory-sync-setup` | all seven, plus a free-text fine-tune prompt | +| `docsync-setup` | all seven, plus `sync`, `reread`, `frontmatter` | ## Getting back out of the manager wall diff --git a/web/docs/src/content/docs/license.mdx b/web/docs/src/content/docs/license.mdx index d296a6c..d97c625 100644 --- a/web/docs/src/content/docs/license.mdx +++ b/web/docs/src/content/docs/license.mdx @@ -1,6 +1,7 @@ --- title: License description: MIT License for claude-brewcode plugins and documentation +order: 6 --- import Badge from '../../components/mdx/Badge.astro'; diff --git a/web/docs/src/utils/navigation.ts b/web/docs/src/utils/navigation.ts index d8777cf..2dd9c00 100644 --- a/web/docs/src/utils/navigation.ts +++ b/web/docs/src/utils/navigation.ts @@ -48,6 +48,7 @@ export const navigation: NavSection[] = [ { title: 'agent-creator', slug: 'brewcode/agents/agent-creator' }, { title: 'hook-creator', slug: 'brewcode/agents/hook-creator' }, { title: 'bash-expert', slug: 'brewcode/agents/bash-expert' }, + { title: 'bc-rules-organizer', slug: 'brewcode/agents/bc-rules-organizer' }, ], }, { title: 'Hooks', slug: 'brewcode/hooks' },