From 3abdf6bfe9b32779139718db30d4f674c95d2f0e Mon Sep 17 00:00:00 2001 From: kochetkov-ma Date: Sun, 9 Aug 2026 10:55:33 +0100 Subject: [PATCH] v5.2.0: semble advisory hooks restored - 6 settings entries, every-Nth cadence, delivery proven end-to-end --- .claude-plugin/marketplace.json | 10 +- .../superreview-setup/scripts/generate.sh | 4 +- .../skills/teams-setup/scripts/toggle-team.sh | 2 +- .../skills/teams-setup/scripts/trace-ops.sh | 2 +- .../skills/teams-setup/scripts/verify-team.sh | 4 +- .../assets/think-short-prompt-counter.mjs | 2 +- .../assets/think-short-prompt.md | 2 +- .../assets/think-short-session.mjs | 2 +- .codex/scripts/generate-compat.mjs | 37 +- RELEASE-NOTES.md | 35 +- brewcode/.claude-plugin/plugin.json | 2 +- .../superreview-setup/scripts/generate.sh | 4 +- .../skills/teams-setup/scripts/toggle-team.sh | 2 +- .../skills/teams-setup/scripts/trace-ops.sh | 2 +- .../skills/teams-setup/scripts/verify-team.sh | 4 +- brewcode/README.md | 9 +- brewcode/agents/agent-creator.md | 2 +- brewcode/agents/bash-expert.md | 2 +- brewcode/agents/bc-rules-organizer.md | 2 +- brewcode/agents/hook-creator.md | 2 +- brewcode/agents/skill-creator.md | 2 +- brewcode/docs/commands.md | 4 +- brewcode/docs/file-tree.md | 8 +- brewcode/package.json | 4 +- brewcode/skills/semble-setup/README.md | 8 +- brewcode/skills/semble-setup/SKILL.md | 4 +- .../skills/semble-setup/assets/INSTALL.md | 188 +++-- .../assets/semble-first.md.template | 2 +- .../semble-setup/assets/semble-prefetch.mjs | 50 +- .../semble-setup/assets/semble-reminder.mjs | 385 ++++++++++ .../semble-setup/assets/semble-session.mjs | 2 +- .../semble-setup/assets/semble-stats.mjs | 2 +- .../semble-setup/assets/semble-subagent.mjs | 195 +++++ .../semble-setup/assets/sembleignore.template | 2 +- .../references/engine-landscape.md | 11 +- .../semble-setup/references/hooks-roadmap.md | 53 +- .../references/output-contract.md | 8 +- .../semble-setup/scripts/semble-guidance.sh | 161 ++-- .../semble-setup/scripts/semble-remove.sh | 16 +- .../semble-setup/scripts/semble-state.sh | 2 +- .../semble-setup/scripts/semble-status.sh | 114 ++- .../skills/semble-setup/tests/suite-hooks.mjs | 716 +++++++++++++++--- .../semble-setup/tests/suite-integration.mjs | 70 +- .../semble-setup/tests/suite-status.mjs | 19 +- .../semble-setup/tests/suite-telemetry.mjs | 72 +- brewcode/skills/setup-status/README.md | 2 +- brewcode/skills/setup-status/SKILL.md | 2 +- .../references/artifact-metadata.md | 2 +- .../skills/teams-setup/scripts/trace-ops.sh | 2 +- brewdoc/.claude-plugin/plugin.json | 2 +- brewdoc/README.md | 2 +- brewdoc/docs/commands.md | 2 +- .../docsync-setup/assets/docsync-gate.mjs | 2 +- .../docsync-setup/assets/docsync-track.mjs | 2 +- .../docsync-setup/assets/docsync-watch.mjs | 2 +- .../references/agent-audit.md | 2 +- .../memory-sync-setup/references/hard-sync.md | 2 +- .../references/memory-guide.md | 2 +- brewtools/.claude-plugin/plugin.json | 2 +- .../assets/think-short-prompt-counter.mjs | 2 +- .../assets/think-short-prompt.md | 2 +- .../assets/think-short-session.mjs | 2 +- brewtools/README.md | 2 +- brewtools/agents/deploy-admin.md | 2 +- brewtools/agents/ssh-admin.md | 2 +- brewtools/agents/text-optimizer.md | 2 +- brewtools/hooks/hardmode-guard.mjs | 2 +- brewtools/hooks/lib/manager-state.mjs | 2 +- .../assets/agent-deadline-cleanup.mjs | 2 +- .../assets/agent-deadline-guard.mjs | 2 +- .../assets/agent-router.mjs | 2 +- .../assets/think-short-prompt-counter.mjs | 2 +- .../assets/think-short-prompt.md | 2 +- .../assets/think-short-session.mjs | 2 +- .../assets/think-short-task.mjs | 8 +- brewui/.claude-plugin/plugin.json | 2 +- brewui/README.md | 2 +- .../docs/brewcode/skills/semble-setup.mdx | 53 +- .../docs/brewcode/skills/setup-status.mdx | 6 +- web/docs/src/content/docs/full-setup.mdx | 2 +- 80 files changed, 1946 insertions(+), 414 deletions(-) create mode 100644 brewcode/skills/semble-setup/assets/semble-reminder.mjs create mode 100644 brewcode/skills/semble-setup/assets/semble-subagent.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d96f78c..2bc1e18 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.1.0" + "version": "5.2.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.1.0", + "version": "5.2.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.1.0", + "version": "5.2.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.1.0", + "version": "5.2.0", "category": "productivity", "keywords": [ "brewtools", @@ -100,7 +100,7 @@ { "name": "brewui", "description": "Placeholder for future UI/visual/creative tools (currently empty, installable)", - "version": "5.1.0", + "version": "5.2.0", "category": "productivity", "keywords": [ "ui", diff --git a/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh b/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh index 6999024..b6773e2 100755 --- a/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh +++ b/.codex/plugins/brewcode/skills/superreview-setup/scripts/generate.sh @@ -114,7 +114,7 @@ scan_target() { "$(grep -m1 '^name:' "$f" | sed 's/^name:[[:space:]]*//')" \ "$(grep -m1 '^description:' "$f" | sed 's/^description:[[:space:]]*//' | cut -c1-220)" done - _n=$(find .codex/agents -maxdepth 1 -type f -name "*.md" 2>/dev/null | wc -l | tr -d ' ') + _n=$(find .codex/agents -maxdepth 1 -type f -name "*.toml" 2>/dev/null | wc -l | tr -d ' ') echo "agents=$_n" [ "$_n" -eq 0 ] && echo "⚠️ NO domain experts — superreview routed to generic agents is a DEGRADED review" else @@ -1062,7 +1062,7 @@ remove_skill() { fi if [ -f "$IG_PATH" ]; then - echo "KEPT: $IG_PATH — shared with $brewcode:teams-setup, never deleted by either skill" + 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; } diff --git a/.codex/plugins/brewcode/skills/teams-setup/scripts/toggle-team.sh b/.codex/plugins/brewcode/skills/teams-setup/scripts/toggle-team.sh index ae8c72c..b7189d5 100755 --- a/.codex/plugins/brewcode/skills/teams-setup/scripts/toggle-team.sh +++ b/.codex/plugins/brewcode/skills/teams-setup/scripts/toggle-team.sh @@ -76,7 +76,7 @@ done < "$TEAM_MD" printf 'ACTION:%s\nTEAM:%s\nMOVED:%s\nSKIPPED:%s\nMISSING:%s\n' "$ACTION" "$TEAM_NAME" "$MOVED" "$SKIPPED" "$MISSING" if [ "$MISSING" -gt 0 ]; then - echo "❌ FAILED -- $MISSING roster member(s) have no file at all; run $brewcode:teams-setup status" + echo "❌ FAILED -- $MISSING roster member(s) have no file at all; run \$brewcode:teams-setup status" exit 1 fi echo "✅ $ACTION" diff --git a/.codex/plugins/brewcode/skills/teams-setup/scripts/trace-ops.sh b/.codex/plugins/brewcode/skills/teams-setup/scripts/trace-ops.sh index a407c43..2338752 100755 --- a/.codex/plugins/brewcode/skills/teams-setup/scripts/trace-ops.sh +++ b/.codex/plugins/brewcode/skills/teams-setup/scripts/trace-ops.sh @@ -1,5 +1,5 @@ #!/bin/sh -# brewcode-meta: version=5.1.0 generated_by=brewcode:teams-setup +# brewcode-meta: version=5.2.0 generated_by=brewcode:teams-setup set -eu USAGE="Usage: trace-ops.sh [args...]" diff --git a/.codex/plugins/brewcode/skills/teams-setup/scripts/verify-team.sh b/.codex/plugins/brewcode/skills/teams-setup/scripts/verify-team.sh index db8a2e0..02c3f4f 100755 --- a/.codex/plugins/brewcode/skills/teams-setup/scripts/verify-team.sh +++ b/.codex/plugins/brewcode/skills/teams-setup/scripts/verify-team.sh @@ -106,7 +106,7 @@ if [ -f "$TEAM_DIR/team.md" ]; then case "$meta_order" in "") echo "WARN: $TEAM_DIR/team.md has no | Version | / | Generated by | / | Last update | header rows" - echo " (team predates the artifact-metadata standard). Fix: run $brewcode:teams-setup upgrade," + echo " (team predates the artifact-metadata standard). Fix: run \$brewcode:teams-setup upgrade," echo " which fills them from the PLUGIN_VERSION: / GENERATED_BY: / LAST_UPDATED: lines of" echo " detect-mode.sh and appends the trailing Version column to the ## Agents table." ;; @@ -166,7 +166,7 @@ if [ -f "$TEAM_DIR/team.md" ]; then set -e case "$meta_rc" in 0) echo "OK" ;; - 2) echo "OK (no artifact metadata -- agent predates the standard; $brewcode:teams-setup upgrade restamps it)" ;; + 2) echo "OK (no artifact metadata -- agent predates the standard; \$brewcode:teams-setup upgrade restamps it)" ;; *) echo "FAIL"; printf '%s\n' "$meta_out"; FAIL=1 ;; esac elif [ -f ".codex/agents/${agent}.toml.disabled" ]; then diff --git a/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs b/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs index 4d332dd..5fe1a94 100644 --- a/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs +++ b/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-prompt-counter.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup +// brewcode-meta: version=5.2.0 generated_by=brewtools:think-short-setup /** * think-short — UserPromptSubmit hook (self-contained, no plugin-root deps). * diff --git a/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-prompt.md b/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-prompt.md index bc4a1bb..15a014b 100644 --- a/.codex/plugins/brewtools/skills/think-short-setup/assets/think-short-prompt.md +++ b/.codex/plugins/brewtools/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/.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 9f12213..ea687f5 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,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup +// brewcode-meta: version=5.2.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 e2f1eb1..e68f5bf 100644 --- a/.codex/scripts/generate-compat.mjs +++ b/.codex/scripts/generate-compat.mjs @@ -53,7 +53,22 @@ function readFrontmatter(source) { return { values, body: source.slice(match[0].length) }; } -function transformText(value, { agent = false } = {}) { +function isShellAsset(file) { + return /\.(?:sh|bash)$/.test(file); +} + +// Codex invokes a skill as `$plugin:skill`, so every skill reference is rewritten to that sigil. +// In a shell asset the sigil must stay LITERAL: mirrored scripts run under `set -eu`, and a +// double-quoted "... $brewcode:teams-setup ..." aborts them with `brewcode: unbound variable`. +// Emit `\$` there instead -- it prints as `$` from double-quoted, unquoted and unquoted-heredoc +// text alike. Comment lines expand nothing, so they keep the bare form. +function skillSigil(shell, text, offset) { + if (!shell) return '$'; + const lineStart = text.lastIndexOf('\n', offset) + 1; + return /^\s*#/.test(text.slice(lineStart, offset)) ? '$' : '\\$'; +} + +function transformText(value, { agent = false, shell = false } = {}) { let text = value .replaceAll('${CLAUDE_SKILL_DIR}', '') .replaceAll('$CLAUDE_SKILL_DIR', '') @@ -70,8 +85,8 @@ function transformText(value, { agent = false } = {}) { .replaceAll('~/.claude', '~/.codex') .replaceAll('.claude/', '.codex/') .replaceAll('.claude\\', '.codex\\') - .replace(/\/brew(code|doc|tools):([a-z0-9-]+)/g, (_, family, name) => `$brew${family}:${name}`) - .replace(/Skill\(skill="([^"]+)"\)/g, '$$$1') + .replace(/\/brew(code|doc|tools):([a-z0-9-]+)/g, (_, family, name, offset, full) => `${skillSigil(shell, full, offset)}brew${family}:${name}`) + .replace(/Skill\(skill="([^"]+)"\)/g, (_, name, offset, full) => `${skillSigil(shell, full, offset)}${name}`) .replace(/\bTask\(/g, 'spawn_agent(') .replace(/\bTask tool\b/gi, 'sub-agent collaboration tools') .replace(/\bTask calls?\b/gi, 'sub-agent calls') @@ -149,6 +164,13 @@ function codexAgentExtension(text) { 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')); + // `find .codex/agents -maxdepth 1 -type f -name "*.md"`: the path rewrite lands on the + // directory, the glob keeps the Claude extension, so the mirrored count is always 0 and the + // script reports "no domain experts" with real `.toml` agents present. Anchored on a `find` + // name predicate on a line that also names the agents dir -- a `*.md` glob anywhere else, + // including a `find` over docs or references, is untouched. + text = text.replace(/^.*\.codex\/agents\b.*$/gm, line => + /(?:^|[\s|(])find\s/.test(line) ? line.replace(/(-i?name\s+["']?\*)\.md\b/g, '$1.toml') : line); // 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 @@ -168,6 +190,7 @@ function codexAgentExtension(text) { } function nativeWorkflowText(value, options = {}) { + const shell = options.shell === true; return codexAgentExtension(transformText(value, options) .replaceAll('$code:', '$brewcode:') .replaceAll('$doc:', '$brewdoc:') @@ -195,13 +218,13 @@ function nativeWorkflowText(value, options = {}) { .replace(/\bmodel\s*=/g, 'reasoning_tier=') .replace(/\bprompt\s*=/g, 'message=') .replace(/\brun_in_background\s*[:=]\s*(?:false|true)/g, 'execution=foreground') - .replace(/Skill\s*\(\s*skill\s*=\s*["']([^"']+)["']\s*,\s*args\s*=\s*["']([^"']*)["']\s*\)/g, (_, name, args) => `Invoke \`$${name}\` with arguments \`${args}\``) + .replace(/Skill\s*\(\s*skill\s*=\s*["']([^"']+)["']\s*,\s*args\s*=\s*["']([^"']*)["']\s*\)/g, (_, name, args, offset, full) => `Invoke \`${skillSigil(shell, full, offset)}${name}\` with arguments \`${args}\``) .replace(/\bclaude\s+-p\b/g, 'codex exec') .replace(/\bclaude\s+--version\b/g, 'codex --version') .replace(/\bcodex plugin install\b/g, 'codex plugin add') .replace(/\bcodex plugin marketplace update\b/g, 'codex plugin marketplace upgrade') .replace(/\bcodex plugin update\s+([a-z0-9-]+@[a-z0-9-]+)/g, 'codex plugin remove $1 && codex plugin add $1') - .replace(/Skill\s*\(\s*skill\s*=\s*["']([^"']+)["']\s*\)/g, (_, name) => `$${name}`); + .replace(/Skill\s*\(\s*skill\s*=\s*["']([^"']+)["']\s*\)/g, (_, name, offset, full) => `${skillSigil(shell, full, offset)}${name}`); } function writeFile(file, content, mode) { @@ -228,7 +251,7 @@ function copyTransformedTree(sourceDir, targetDir) { if (data.includes(0)) { fs.writeFileSync(target, data); } else { - fs.writeFileSync(target, nativeWorkflowText(data.toString('utf8')), 'utf8'); + fs.writeFileSync(target, nativeWorkflowText(data.toString('utf8'), { shell: isShellAsset(target) }), 'utf8'); } fs.chmodSync(target, fs.statSync(source).mode & 0o777); } @@ -545,7 +568,7 @@ function copySelected(source, target) { const data = fs.readFileSync(source); fs.mkdirSync(path.dirname(target), { recursive: true }); if (data.includes(0)) fs.writeFileSync(target, data); - else fs.writeFileSync(target, nativeWorkflowText(data.toString('utf8')), 'utf8'); + else fs.writeFileSync(target, nativeWorkflowText(data.toString('utf8'), { shell: isShellAsset(target) }), 'utf8'); fs.chmodSync(target, fs.statSync(source).mode & 0o777); } diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 861bfeb..f698ba4 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -2,6 +2,39 @@ --- +## v5.2.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/) | [full-setup](https://doc-claude.brewcode.app/full-setup/) + +### brewcode + +#### Added + +- **semble-setup: the two advisory hooks are back, and this time delivery is proven rather than assumed.** `semble-reminder.mjs` returns on `PreToolUse` with the single matcher `Bash|Grep`; `semble-subagent.mjs` is new and replaces `semble-explore.mjs` on `SubagentStart`. The want-table goes from **3 hook files / 4 settings entries to 5 files / 6 entries**, all still `timeout: 5` (seconds), and status now prints `hooks /6 wired` +- **`semble-subagent.mjs` carries NO `matcher` key at all.** An absent matcher is what matches every agent type — `"*"` would have been wrong. `semble-explore.mjs` was pinned to `agent_type === 'Explore'` and reached exactly one of them; the replacement was verified live on `Explore`, `Plan` and `general-purpose`, all three quoting the injected text back verbatim +- **The reminder counter is PROJECT-GLOBAL and survives across sessions.** It is not per-session state: a fresh session inherits the residual phase, so the nudge may land on the very first search or only on the fifth. "Every fifth search" is a property of the project, not of the session — documented on the skill page, in `SKILL.md` and in the README + +#### Fixed + +- **The v5.0.0 retirement rationale was withdrawn: `0/18` measured nothing.** The claim was "0 of 18 conversion on the main channel with delivery independently confirmed". The reminder hook fired **zero times** across those 18 sessions — its own `isExactIntent` gate, self-described as biased to silence, suppressed 74 of 113 evaluations, 37 were `disabled` and 2 throttled, for a lifetime rate of 14 nudges in 2718 evaluations (0.52%). The denominator 18 counted trial *sessions*, not deliveries. `0/11` on the subagent channel WAS a real measurement, but of a single agent type and of text that undercut itself with the phrase "this is a reminder, not a block". The delivery channel itself was never broken: in the CC 2.1.226 binary `PreToolUse` and `SubagentStart` both accept `additionalContext` in the zod union and both reach the model over the same unfiltered path +- **Cadence replaces the throttle.** The 600-second timer and its `.claude/semble/.reminder-ts` marker are retired for good. The hook now fires on every Nth **eligible** search, `N = state.reminderEvery` (default 5), counted in `.claude/semble/reminder.json` with an atomic write and a reset on corruption. Ineligible calls never advance the counter +- **The gate keeps only the strong suppressors** — `-l`/`-L`/`-c`/`-o` and their long forms, `output_mode: files_with_matches|count` on the native `Grep`, `find`/`bfs` filename predicates, a pattern containing `/`, a filename-shaped pattern. Suppression on regex metacharacters, `-F`/`-w`, piping into `wc`/`sort` and short patterns is gone. Replayed on the real historical stream of 2543 recorded search calls: the old gate let 229 through and fired 32; the new one lets 1023 through and fires 204 +- **Conversion is now reported per source, not pooled.** `conversion.bySource` splits `session`/`reminder`/`subagent`/`prefetch`/`explore`, each with its own explicit `measure` (`injected-path-opened` for prefetch, `semble-call-after` for the rest). The old single denominator let 126 `SessionStart` firings drown every other channel. A channel that never fired prints `0/0` instead of vanishing — `0/0` is "never delivered", `0/N` is "delivered and ignored", and only the second is evidence about the advice +- **`install` stages every hook asset before wiring it.** The asset is copied to a temporary path, `node --check`ed there, and only then moved into place; `prune_want_table()` drops any row whose file did not land. A corrupt asset can no longer be installed and registered in `settings.json` +- **The settings merge is a reconcile, not an append.** `wanted` is keyed on (event, matcher, path) triples, so a v1-shaped repo has its `SubagentStart`/`Explore` row **replaced** rather than left beside the new one, and its two `PreToolUse` rows (`Bash`, `Grep`) collapse into the single `Bash|Grep` row +- **`.gitignore` gets the directory `.claude/semble/`, not one marker file.** `telemetry.jsonl` (verbatim shell commands and distilled prompt text, trimmed at 2 MB), `state.json` and `reminder.json` all live there and were previously uncovered +- **`semble-prefetch.mjs` argv order corrected** — options first, then `--`, then `query cwd`. A leading-dash one-token query was parsed by argparse as an option, failed, and armed the 600-second cooldown. A completed-but-empty search now writes the marker too, so `no-hits` arms the 30-second throttle and never the cooldown +- **Documentation reconciled at every level against the six-entry world** — `semble-setup.mdx`, `setup-status.mdx`, `full-setup.mdx`, both skill READMEs, `references/output-contract.md` (printed verbatim on every invocation, so its `n/4` legend shipped to the user on each run), `references/hooks-roadmap.md`, `assets/INSTALL.md`, `brewcode/docs/file-tree.md`, and the stale header comments in `semble-guidance.sh`, `semble-state.sh` and `semble-reminder.mjs` (which still claimed it was registered twice) +- **`references/output-contract.md` and the MDX examples now print lines the code can actually emit** — the `agents:` status line was missing the `need patch` field that `semble-status.sh` always writes, and its example numbers contradicted their own Actions text +- **v5.1.0's `setup-status` state list named two verdicts that never existed.** `stale (drift)` and `stale (behind X.Y.Z)` were invented; the four real qualifiers are `stale (X.Y.Z -> A.B.C)`, `stale (legacy stamp)`, `stale (legacy, unstamped)` and `stale (bytes drifted)`. That line in the v5.1.0 body was corrected in place rather than left to mislead +- **`.codex/` mirror generator made shell- and extension-aware.** `/brewcode:x` was blanket-rewritten to `$brewcode:x` including inside `.sh` assets running under `set -eu`, so the mirrored `superreview-setup/generate.sh` died on `uninstall` with an unbound variable and reported `EXIT_CODE=0` — a false success. The mirror's agent count also globbed `-name "*.md"` while codex agents are `.toml`, so it always counted 0 and always printed the DEGRADED warning + +#### Changed + +- **`suite-integration.mjs` covers all five hook assets** on both the install and the removal path. It previously asserted only three, so an install that silently stopped copying `semble-reminder.mjs` or `semble-subagent.mjs` passed green. Suite total is now 1727 assertions across 7 suites + +--- + ## 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/) @@ -75,7 +108,7 @@ - **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: the `installed (version unknown)` state is retired.** Its replacements are specific: `stale (X.Y.Z -> A.B.C)`, `stale (legacy stamp)`, `stale (legacy, unstamped)`, `stale (bytes drifted)`, 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 diff --git a/brewcode/.claude-plugin/plugin.json b/brewcode/.claude-plugin/plugin.json index c81dddd..3578436 100644 --- a/brewcode/.claude-plugin/plugin.json +++ b/brewcode/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brewcode", - "version": "5.1.0", + "version": "5.2.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/superreview-setup/scripts/generate.sh b/brewcode/.codex/skills/superreview-setup/scripts/generate.sh index 6999024..b6773e2 100755 --- a/brewcode/.codex/skills/superreview-setup/scripts/generate.sh +++ b/brewcode/.codex/skills/superreview-setup/scripts/generate.sh @@ -114,7 +114,7 @@ scan_target() { "$(grep -m1 '^name:' "$f" | sed 's/^name:[[:space:]]*//')" \ "$(grep -m1 '^description:' "$f" | sed 's/^description:[[:space:]]*//' | cut -c1-220)" done - _n=$(find .codex/agents -maxdepth 1 -type f -name "*.md" 2>/dev/null | wc -l | tr -d ' ') + _n=$(find .codex/agents -maxdepth 1 -type f -name "*.toml" 2>/dev/null | wc -l | tr -d ' ') echo "agents=$_n" [ "$_n" -eq 0 ] && echo "⚠️ NO domain experts — superreview routed to generic agents is a DEGRADED review" else @@ -1062,7 +1062,7 @@ remove_skill() { fi if [ -f "$IG_PATH" ]; then - echo "KEPT: $IG_PATH — shared with $brewcode:teams-setup, never deleted by either skill" + 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; } diff --git a/brewcode/.codex/skills/teams-setup/scripts/toggle-team.sh b/brewcode/.codex/skills/teams-setup/scripts/toggle-team.sh index ae8c72c..b7189d5 100755 --- a/brewcode/.codex/skills/teams-setup/scripts/toggle-team.sh +++ b/brewcode/.codex/skills/teams-setup/scripts/toggle-team.sh @@ -76,7 +76,7 @@ done < "$TEAM_MD" printf 'ACTION:%s\nTEAM:%s\nMOVED:%s\nSKIPPED:%s\nMISSING:%s\n' "$ACTION" "$TEAM_NAME" "$MOVED" "$SKIPPED" "$MISSING" if [ "$MISSING" -gt 0 ]; then - echo "❌ FAILED -- $MISSING roster member(s) have no file at all; run $brewcode:teams-setup status" + echo "❌ FAILED -- $MISSING roster member(s) have no file at all; run \$brewcode:teams-setup status" exit 1 fi echo "✅ $ACTION" diff --git a/brewcode/.codex/skills/teams-setup/scripts/trace-ops.sh b/brewcode/.codex/skills/teams-setup/scripts/trace-ops.sh index a407c43..2338752 100755 --- a/brewcode/.codex/skills/teams-setup/scripts/trace-ops.sh +++ b/brewcode/.codex/skills/teams-setup/scripts/trace-ops.sh @@ -1,5 +1,5 @@ #!/bin/sh -# brewcode-meta: version=5.1.0 generated_by=brewcode:teams-setup +# brewcode-meta: version=5.2.0 generated_by=brewcode:teams-setup set -eu USAGE="Usage: trace-ops.sh [args...]" diff --git a/brewcode/.codex/skills/teams-setup/scripts/verify-team.sh b/brewcode/.codex/skills/teams-setup/scripts/verify-team.sh index db8a2e0..02c3f4f 100755 --- a/brewcode/.codex/skills/teams-setup/scripts/verify-team.sh +++ b/brewcode/.codex/skills/teams-setup/scripts/verify-team.sh @@ -106,7 +106,7 @@ if [ -f "$TEAM_DIR/team.md" ]; then case "$meta_order" in "") echo "WARN: $TEAM_DIR/team.md has no | Version | / | Generated by | / | Last update | header rows" - echo " (team predates the artifact-metadata standard). Fix: run $brewcode:teams-setup upgrade," + echo " (team predates the artifact-metadata standard). Fix: run \$brewcode:teams-setup upgrade," echo " which fills them from the PLUGIN_VERSION: / GENERATED_BY: / LAST_UPDATED: lines of" echo " detect-mode.sh and appends the trailing Version column to the ## Agents table." ;; @@ -166,7 +166,7 @@ if [ -f "$TEAM_DIR/team.md" ]; then set -e case "$meta_rc" in 0) echo "OK" ;; - 2) echo "OK (no artifact metadata -- agent predates the standard; $brewcode:teams-setup upgrade restamps it)" ;; + 2) echo "OK (no artifact metadata -- agent predates the standard; \$brewcode:teams-setup upgrade restamps it)" ;; *) echo "FAIL"; printf '%s\n' "$meta_out"; FAIL=1 ;; esac elif [ -f ".codex/agents/${agent}.toml.disabled" ]; then diff --git a/brewcode/README.md b/brewcode/README.md index f94d4e0..adb4607 100644 --- a/brewcode/README.md +++ b/brewcode/README.md @@ -4,7 +4,7 @@ | Field | Value | |-------|-------| -| Version | 5.1.0 | +| Version | 5.2.0 | | Skills | 9 | | Agents | 5 | | Hooks | 2 | @@ -86,10 +86,9 @@ No arguments = `status` when the mechanism is installed, `install` when it is no `/brewcode:semble-setup`, which **always** defaults to `status`, so a bare invocation can never start a machine-level package install. -Each skill implements the modes that mean something for it and rejects the rest with an error instead of -guessing: `/brewcode:teams-setup` covers `status | install | upgrade | uninstall | purge` and rejects -`enable` / `disable`, since a team either exists or it does not. Skill-specific extras come after the -canonical set, never in place of it (`semble-setup`: `reindex | optimize | resume`). +Every `-setup` skill implements the full canonical set: `status | install | upgrade | enable | disable | +uninstall | purge`. Skill-specific extras come after it, never in place of it (`semble-setup`: `reindex | +optimize | resume`; `/brewcode:teams-setup` keeps a `[name]` positional after the canonical modes). | Skill | Purpose | |-------|---------| diff --git a/brewcode/agents/agent-creator.md b/brewcode/agents/agent-creator.md index 616e867..567ebf0 100644 --- a/brewcode/agents/agent-creator.md +++ b/brewcode/agents/agent-creator.md @@ -6,7 +6,7 @@ maxTurns: 80 color: cyan tools: Read, Write, Edit, Glob, Grep, Bash, Agent, WebFetch, WebSearch, AskUserQuestion doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewcode" last_updated: "2026-08-09" --- diff --git a/brewcode/agents/bash-expert.md b/brewcode/agents/bash-expert.md index f400b4c..ad9cf31 100644 --- a/brewcode/agents/bash-expert.md +++ b/brewcode/agents/bash-expert.md @@ -6,7 +6,7 @@ maxTurns: 60 color: green tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewcode" last_updated: "2026-08-09" --- diff --git a/brewcode/agents/bc-rules-organizer.md b/brewcode/agents/bc-rules-organizer.md index 4a0abdd..a1b972b 100644 --- a/brewcode/agents/bc-rules-organizer.md +++ b/brewcode/agents/bc-rules-organizer.md @@ -5,7 +5,7 @@ model: haiku maxTurns: 60 tools: Read, Write, Edit, Glob, Grep, Bash, Agent doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewcode" last_updated: "2026-08-09" --- diff --git a/brewcode/agents/hook-creator.md b/brewcode/agents/hook-creator.md index 40358f6..e6b4728 100644 --- a/brewcode/agents/hook-creator.md +++ b/brewcode/agents/hook-creator.md @@ -6,7 +6,7 @@ maxTurns: 80 color: yellow tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewcode" last_updated: "2026-08-09" --- diff --git a/brewcode/agents/skill-creator.md b/brewcode/agents/skill-creator.md index 79db495..348efe4 100644 --- a/brewcode/agents/skill-creator.md +++ b/brewcode/agents/skill-creator.md @@ -6,7 +6,7 @@ maxTurns: 80 color: green tools: Read, Write, Edit, Glob, Grep, Bash, Agent, AskUserQuestion doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewcode" last_updated: "2026-08-09" --- diff --git a/brewcode/docs/commands.md b/brewcode/docs/commands.md index 3612abc..18588b0 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.1.0 | **Author:** Maksim Kochetkov | **License:** MIT +> **ver:** 5.2.0 | **Author:** Maksim Kochetkov | **License:** MIT ## Naming @@ -96,7 +96,7 @@ Each row gets exactly one state, evaluated in order: `n/a` -> `disabled` -> `mis | 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 | -| Frontmatter trio | memory-sync | `version`/`generated_by`/`last_updated` in the emitted `SKILL.md`'s YAML frontmatter vs the plugin's own version. A trailing `` block | -| 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 | +| 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) + `semble-reminder.mjs` (PreToolUse `Bash\|Grep` — fires every Nth eligible search, `N = state.reminderEvery`, DEF 5, counter in `.claude/semble/reminder.json`) + `semble-subagent.mjs` (SubagentStart, **no matcher, so every agent type** — the semble-first brief inside the subagent's own context). `semble-explore.mjs` is retired for good, superseded by `semble-subagent.mjs`; `install`/`upgrade` deletes that file and replaces its settings row. The 5.0.0 note claiming the two advisory hooks "converted at 0/18 and 0/11 with delivery proven" was wrong on the first number: the reminder hook fired zero times across those 18 sessions (gate suppressed 74/113, 37 `disabled`, 2 throttled), so `0/18` counted sessions, not deliveries. `0/11` was a real measurement, but of one agent type and of self-undercutting text. The channel itself always delivered. Per-channel conversion on the accumulated telemetry is reminder 2/10 sessions, explore 1/7 — the restored hooks' own numbers are not measured yet | | 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` | @@ -159,8 +159,8 @@ Every script takes `--json` and uses the same exit codes: `0` ok · `1` hard fai | 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 `.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 | +| Status says `partial` | half-wired — `hooks N/6 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 6/6 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 | ## Documentation diff --git a/brewcode/skills/semble-setup/SKILL.md b/brewcode/skills/semble-setup/SKILL.md index ca7a182..e55459f 100644 --- a/brewcode/skills/semble-setup/SKILL.md +++ b/brewcode/skills/semble-setup/SKILL.md @@ -392,7 +392,7 @@ echo "RC=$RC" ### 4.2 Guidance, permissions and agents -`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`. +`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 five hook files into `.claude/hooks/` (`semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`, `semble-reminder.mjs`, `semble-subagent.mjs`), **deletes the one retired hook** (`semble-explore.mjs`, superseded for good by `semble-subagent.mjs`) and merges the **six** settings entries they wire — SessionStart, UserPromptSubmit, PostToolUse and PostToolUseFailure on the stats matcher, PreToolUse on `Bash|Grep`, and an **unmatched** SubagentStart (an absent matcher matches every agent type), 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 two `PreToolUse` rows collapsed into the single `Bash|Grep` row and its `SubagentStart`/`Explore` row **replaced** by the unmatched `semble-subagent.mjs` row — purged by the stale-triple filter, then re-pushed by the want loop, never left side by side — while the retired `.claude/semble/.reminder-ts` ignore line and marker file are dropped (the restored reminder fires every Nth eligible search — `N = state.reminderEvery`, DEF 5 — with the counter in `.claude/semble/reminder.json`, project-global and persisted across sessions, so a fresh session inherits the residual phase rather than restarting the count; already covered by the directory ignore). The `.gitignore` line install writes is the DIRECTORY `.claude/semble/`, not a single marker: `state.json` and `telemetry.jsonl` (verbatim commands + distilled prompt text, trimmed at 2 MB) live there too. Both narrower lines it supersedes — `.reminder-ts` and `.prefetch-ts` — are stripped by install and by remove. **`--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: @@ -512,7 +512,7 @@ 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. +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 five 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 retired `semble-explore.mjs`** if the install predates the migration, and re-merges the settings entries and permissions. **EXECUTE** using Bash tool: diff --git a/brewcode/skills/semble-setup/assets/INSTALL.md b/brewcode/skills/semble-setup/assets/INSTALL.md index 836ad6f..5e91d9a 100644 --- a/brewcode/skills/semble-setup/assets/INSTALL.md +++ b/brewcode/skills/semble-setup/assets/INSTALL.md @@ -14,11 +14,12 @@ wires nothing on its own. | `semble-session.mjs` | `/.claude/hooks/` | SessionStart | `systemMessage` + `additionalContext` | | `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 `{}` | +| `semble-reminder.mjs` | `/.claude/hooks/` | PreToolUse `Bash\|Grep` | `additionalContext` — one throttled nudge before a text search | +| `semble-subagent.mjs` | `/.claude/hooks/` | SubagentStart (no matcher — every agent type) | `additionalContext` — the semble-first brief inside the subagent's own context | -> 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. +> Retired for good: `semble-explore.mjs` (SubagentStart `Explore` only). +> `semble-subagent.mjs` supersedes it and covers every agent type. `install` and +> `upgrade` DELETE that file and replace its settings row — 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. Only @@ -36,7 +37,7 @@ scripts/semble-guidance.sh remove [--part ...|all] [--force] [--json] ``` `install --part all` does, in order: rule -> `.sembleignore` -> CLAUDE.md block --> copy the three `.mjs` (deleting any retired one) -> `.gitignore` line +-> copy the five `.mjs` (deleting any retired one) -> `.gitignore` line -> settings hooks + permissions merge. Every step is idempotent and re-runnable. `--json` prints one object @@ -201,16 +202,39 @@ Reads exactly one file, `/.claude/semble/state.json`. ### `semble-prefetch.mjs` — UserPromptSubmit, no matcher -**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 hands over a result, where the advisory hooks hand over advice.** It runs +the search itself and injects paths: 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. That is a different job from the advisory channel, not a +replacement for it — both are installed. + +> **Correcting the 5.0.0 retirement rationale.** 5.0.0 deleted +> `semble-reminder.mjs` and `semble-explore.mjs` citing "0/18 and 0/11 measured +> conversion". Re-examined against the raw telemetry, that claim does not hold: +> +> - **The delivery channel was never broken.** `PreToolUse` and `SubagentStart` +> both accept `additionalContext` and both deliver it — confirmed in the zod +> union, in the consumption path, and in live sessions. +> - **`0/18` measured nothing.** The reminder hook fired ZERO times across those +> 18 sessions: its own gate suppressed 74 of 113 invocations, 37 more were +> `disabled`, 2 throttled. Lifetime fire rate was 14/2718 = 0.52%. The 18 is a +> count of sessions, not of deliveries — a rate with an empty numerator AND an +> empty denominator. +> - **`0/11` was a real measurement**, but of one agent type (`Explore` was the +> only matcher) and of text that ended by undercutting its own instruction. +> +> Per-channel conversion on the accumulated telemetry, once the sources are no +> longer lumped into one denominator: reminder 2/10 sessions, explore 1/7. The +> lumped 6/80 (7.5%) that the old reader printed was dominated by the 126 +> SessionStart nudges in the same bucket. +> +> Both hooks are therefore restored — the reminder under its own name, the +> subagent one as `semble-subagent.mjs` on every agent type. **What the restored +> design has not yet measured is its own conversion**: the gate is rewritten and +> the subagent text is new, so the honest statement today is that the channel +> delivers and the old numbers were not evidence against it. Nothing here claims +> the restored hooks convert well; `--section telemetry` now reports each channel +> separately so the next release can say something measured. > **It buys turns and citation precision, not correctness.** All 18 answers were > correct in all three arms (control, snippet-framing, path-framing). Nothing in @@ -249,8 +273,9 @@ ten minutes until the MCP server has warmed the same cache directory. A separate 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. +`{"t":,"cool":}`. A search that ran and matched nothing +arms the 30 s `t` throttle like any other completed search — only a search that +could not be trusted (`null`) arms `cool`. #### Decision order @@ -438,10 +463,12 @@ Every record carries `ts` (`new Date().toISOString()`), `ev`, `src`, and `sid` | `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` | +| `gate` | `reminder` | `fired` (bool), `why`, `phase`, `enabled`, `tool_use_id` — one per PreToolUse invocation, fired or not | +| `nudge` | `reminder` | `matcher` (the tool name), `agent`, `tool_use_id`, `n`, `every`, `q` | +| `nudge` | `subagent` | `agent_type`, `agent_id` — one per subagent start that was nudged | -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. +`src:"explore"` appears only in logs written before 5.0.0. The reader still +counts it as its own channel; nothing writes it 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 @@ -453,6 +480,24 @@ 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. +**Conversion is reported PER SOURCE, never lumped.** A nudge converts when a +semble `call` in the same `sid` has a `ts` strictly greater than the earliest +nudge *of that source* in that session. `conversion.bySource` carries one slot +per channel — `session`, `reminder`, `subagent`, `prefetch`, plus any source +seen only in an old log — each with `nudges` / `sessions` / `converted` / +`sessionPct` / `callsAfter` and a `measure` field naming what conversion means +there (`semble-call-after` for the three advisory channels; +`injected-path-opened` for prefetch, which already ran the search, so "a semble +call followed" would be meaningless). `reminder` additionally reports +`toolUses` (distinct `tool_use_id`) and `subagent` reports `agentTypes` (a count +per agent type). + +The top-level `sessionsWithNudge`/`sessionsConverted`/`conversionPct` fields are +unchanged in shape and still lump every channel together — they are kept for +compatibility, and they are the fields that produced the false "0/18" reading: +126 SessionStart nudges shared a denominator with 30 firings from the two +advisory hooks, so no channel's own rate was ever visible. + Writer rules, binding on every hook that logs: - Every write is wrapped in `try/catch` and swallowed. A telemetry failure must @@ -470,7 +515,7 @@ version are **counted and skipped**, never fatal. ## 4. settings.json entry shape -`` = absolute path of the hooks dir the three files were copied into +`` = absolute path of the hooks dir the five files were copied into (`/.claude/hooks`). ```json @@ -487,6 +532,12 @@ version are **counted and skipped**, never fatal. ], "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 } ] } + ], + "PreToolUse": [ + { "matcher": "Bash|Grep", "hooks": [ { "type": "command", "command": "node", "args": ["/semble-reminder.mjs"], "timeout": 5 } ] } + ], + "SubagentStart": [ + { "hooks": [ { "type": "command", "command": "node", "args": ["/semble-subagent.mjs"], "timeout": 5 } ] } ] }, "permissions": { @@ -499,16 +550,19 @@ version are **counted and skipped**, never fatal. 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. +~500x the runtime of the other four. The marker for all semble entries is `args` containing a path whose basename is -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 +one of the **six names this skill has ever owned** — +`semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`, +`semble-reminder.mjs`, `semble-subagent.mjs` and the retired +`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 +step-2 purge removes it — which is also what REPLACES a stale +`SubagentStart`/`Explore` row with the unmatched `semble-subagent.mjs` row +rather than leaving the two side by side. 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 @@ -550,16 +604,20 @@ both the stale-path purge and the uninstall. 5. **Assert BEFORE the write, then re-read and assert again**: exactly 1 `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. + matcher>`, exactly 1 `PreToolUse`/`Bash|Grep`, exactly 1 unmatched + `SubagentStart`, 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.** An event array left empty by step 2 and + absent from the want table is removed rather than left as `[]` litter. An + event that still holds a foreign hook is left alone. `PreToolUse` and + `SubagentStart` are want events again, so their arrays are never dropped — + a v1-shaped file's `PreToolUse`/`Bash` + `PreToolUse`/`Grep` pair collapses + into the single `Bash|Grep` row, and a stale `SubagentStart`/`Explore` row + pointing at the retired `semble-explore.mjs` is purged by step 2 and + **replaced** by the unmatched `semble-subagent.mjs` row in step 3. **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 @@ -572,15 +630,19 @@ const fs=require("fs"), path=require("path"); const f=process.env.SETTINGS, dir=process.env.HOOKS_DIR; // 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 + "semble-reminder.mjs","semble-subagent.mjs", + "semble-explore.mjs"]; // last one retired for good // 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 live=["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs", + "semble-reminder.mjs","semble-subagent.mjs"]; const STATS="mcp__semble_code__search|mcp__semble_code__find_related|Bash|Grep|Glob|Read"; const want=[["SessionStart",null,"semble-session.mjs",5], ["UserPromptSubmit",null,"semble-prefetch.mjs",5], ["PostToolUse",STATS,"semble-stats.mjs",5], - ["PostToolUseFailure",STATS,"semble-stats.mjs",5]]; + ["PostToolUseFailure",STATS,"semble-stats.mjs",5], + ["PreToolUse","Bash|Grep","semble-reminder.mjs",5], + ["SubagentStart",null,"semble-subagent.mjs",5]]; // no matcher = every agent type const tools=["mcp__semble_code__search","mcp__semble_code__find_related"]; let s={}; if(fs.existsSync(f)){ @@ -684,22 +746,23 @@ console.log("OK merged "+f); ### Drift, and what `status` must report Presence is not health. `semble-guidance.sh status --json` compares every want row -against the file field by field and reports the four rows as `wired` / `drifted` / +against the file field by field and reports the six rows as `wired` / `drifted` / `duplicate` / `missing`: ```json "hooks": { - "wiredCount": 3, "wantCount": 4, "driftedCount": 1, "missingCount": 0, "duplicateCount": 0, + "wiredCount": 5, "wantCount": 6, "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"] + "retired": ["semble-explore.mjs"] } ``` -`hooks.session.wired` / `hooks.prefetch.wired` / `hooks.stats.wired` and +`hooks.session.wired` / `hooks.prefetch.wired` / `hooks.stats.wired` / +`hooks.reminder.wired` / `hooks.subagent.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 @@ -717,7 +780,7 @@ naming `event`, `matcher`, `script`, `field`, `expected` and `actual`. A missing 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, and DELETE any retired one left by +**EXECUTE** copy the five 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/`): @@ -726,10 +789,12 @@ 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-prefetch.mjs" "$SRC/semble-stats.mjs" "$DST/" && \ -rm -f "$DST/semble-reminder.mjs" "$DST/semble-explore.mjs" && \ +cp "$SRC/semble-session.mjs" "$SRC/semble-prefetch.mjs" "$SRC/semble-stats.mjs" \ + "$SRC/semble-reminder.mjs" "$SRC/semble-subagent.mjs" "$DST/" && \ +rm -f "$DST/semble-explore.mjs" && \ node --check "$DST/semble-session.mjs" && node --check "$DST/semble-prefetch.mjs" && \ -node --check "$DST/semble-stats.mjs" && \ +node --check "$DST/semble-stats.mjs" && node --check "$DST/semble-reminder.mjs" && \ +node --check "$DST/semble-subagent.mjs" && \ echo "✅ copied + verified in $DST" || echo "❌ FAILED" ``` @@ -753,15 +818,20 @@ override it. `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 +throttle and the 600 s failure cooldown share it. It is not the only thing in +that directory: `state.json` lives there, `semble-reminder.mjs` keeps its +counter in `reminder.json`, and every hook appends +`telemetry.jsonl`, which records verbatim shell commands and distilled prompt +text and is trimmed at 2 MB. So the ignored unit is the DIRECTORY. Install +appends ``` # brewcode:semble -.claude/semble/.prefetch-ts +.claude/semble/ ``` -and, migrating a v1 repo, drops the retired `.claude/semble/.reminder-ts` line. +and drops the two lines it supersedes — the retired `.claude/semble/.reminder-ts` +of a v1 repo and the per-marker `.claude/semble/.prefetch-ts` of a 5.1.0 one. 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 @@ -785,7 +855,8 @@ 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 `semble-session.mjs` and `semble-prefetch.mjs` go quiet immediately +makes `semble-session.mjs`, `semble-prefetch.mjs`, `semble-reminder.mjs` and +`semble-subagent.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 @@ -797,12 +868,12 @@ settings entries all stay in place. ## 6. UNINSTALL `scripts/semble-guidance.sh remove --part all` — or the equivalent by hand. It -strips settings by all **five** owned basenames, retired ones included — **per +strips settings by all **six** owned basenames, the retired one 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 all five `.mjs` files (the three live +`permissions` if they empty), then deletes all six `.mjs` files (the five 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. @@ -821,7 +892,7 @@ const f=process.env.SETTINGS; // 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"]; + "semble-reminder.mjs","semble-subagent.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"); @@ -859,10 +930,11 @@ const perm=((back.permissions&&back.permissions.allow)||[]).filter(x=>tools.incl 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-prefetch.mjs" \ - "$HOOKS_DIR/semble-stats.mjs" "$HOOKS_DIR/semble-reminder.mjs" "$HOOKS_DIR/semble-explore.mjs" \ + "$HOOKS_DIR/semble-stats.mjs" "$HOOKS_DIR/semble-reminder.mjs" \ + "$HOOKS_DIR/semble-subagent.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" \ + && test ! -e "$HOOKS_DIR/semble-subagent.mjs" && test ! -e "$HOOKS_DIR/semble-explore.mjs" \ && echo "✅ uninstalled from $HOOKS_DIR" || echo "❌ FAILED" ``` diff --git a/brewcode/skills/semble-setup/assets/semble-first.md.template b/brewcode/skills/semble-setup/assets/semble-first.md.template index 1924e29..3f37542 100644 --- a/brewcode/skills/semble-setup/assets/semble-first.md.template +++ b/brewcode/skills/semble-setup/assets/semble-first.md.template @@ -3,7 +3,7 @@ paths: - "**/*" description: semble-first — one semantic search, then read the exact line; rg stays for exact matching doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewcode:semble-setup" --- diff --git a/brewcode/skills/semble-setup/assets/semble-prefetch.mjs b/brewcode/skills/semble-setup/assets/semble-prefetch.mjs index c319fe5..60936c0 100644 --- a/brewcode/skills/semble-setup/assets/semble-prefetch.mjs +++ b/brewcode/skills/semble-setup/assets/semble-prefetch.mjs @@ -1,17 +1,25 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup +// brewcode-meta: version=5.2.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. + * a project). It runs alongside the advisory hooks, it does not replace them. * - * 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. + * WHY IT EXISTS. Prefetch runs the search itself and hands over the RESULT: + * measured 5/6 sessions opened an injected path, at fewer tool calls than + * control in 5/6 questions. That is a different mechanism from advice, aimed at + * the same goal. + * + * NOT A REPLACEMENT FOR THE ADVISORY HOOKS. 5.0.0 deleted `semble-reminder.mjs` + * and `semble-explore.mjs` citing "0/18 and 0/11 conversion". Both hooks are + * back, because that rationale did not survive review: the delivery channel was + * never broken (PreToolUse and SubagentStart both accept and deliver + * `additionalContext`), and `0/18` had an empty numerator AND an empty + * denominator — the reminder fired zero times in those 18 sessions (its own gate + * suppressed 74/113, 37 were `disabled`, 2 throttled; lifetime 14/2718 = 0.52%). + * The `0/11` figure was real but covered a single agent type on text that + * undercut itself. Per-channel conversion on the accumulated log is reminder + * 2/10 sessions and explore 1/7 — not zero. The restored hooks' own conversion + * is NOT yet measured. * * 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 @@ -435,7 +443,8 @@ function coolWindow(marker) { * `{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. + * that ran and found nothing — is not a failure: it arms the ordinary 30 s + * throttle, never a cooldown. * * 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 @@ -446,8 +455,15 @@ function search(cwd, cacheRoot, query) { try { out = execFileSync( 'uvx', - ['--from', PIN_SPEC, 'semble', 'search', query, cwd, - '--content', ...CONTENT_ARGS, '-k', String(TOP_K), '--max-snippet-lines', '0'], + // Options FIRST, then `--`, then the positionals. The distiller puts backticked + // text at the front, so a prompt like ``what does `-k` do`` distils to exactly + // `-k`, and argparse reads a lone leading-dash argv as an option: rc!=0 -> + // search-failed -> the ten-minute cooldown, for a well-formed question. `--` + // has to come after the flags — argparse stops parsing options at it too, so + // trailing flags would be swallowed as positionals. + ['--from', PIN_SPEC, 'semble', 'search', + '--content', ...CONTENT_ARGS, '-k', String(TOP_K), '--max-snippet-lines', '0', + '--', query, cwd], { cwd, encoding: 'utf8', @@ -537,7 +553,13 @@ function decide(input, cwd) { }); return skip(r.why, { q: query.slice(0, 120), ms }); } - if (!hits.length) return skip('no-hits', { q: query.slice(0, 120), ms }); + if (!hits.length) { + // The search ran, so the normal 30 s throttle applies — not the failure cooldown. + // Without it an empty result set armed nothing and the next prompt spawned another + // child immediately: the throttle only ever existed after a firing. + writeMarker(cwd, { t: Date.now() }); + return skip('no-hits', { q: query.slice(0, 120), ms }); + } writeMarker(cwd, { t: Date.now() }); telemetry(cwd, sid, 'prefetch', { diff --git a/brewcode/skills/semble-setup/assets/semble-reminder.mjs b/brewcode/skills/semble-setup/assets/semble-reminder.mjs new file mode 100644 index 0000000..05aecba --- /dev/null +++ b/brewcode/skills/semble-setup/assets/semble-reminder.mjs @@ -0,0 +1,385 @@ +#!/usr/bin/env node +/** + * brewcode:semble-setup — PreToolUse hook (self-contained, installed into a project). + * Registered once: PreToolUse, matcher "Bash|Grep". + * + * ADVISORY ONLY. It emits at most one `additionalContext` line and never a + * permission decision, a deny, or a rewritten tool input — it cannot block, + * alter or slow a search. Always prints exactly one JSON object, always exits 0. + * + * Two things changed after the v5.0.0 removal, and they are the whole point: + * + * 1. CADENCE. The old 10-minute throttle fired 14 times in 2718 evaluations + * (0.52%). It is replaced by an every-Nth counter over ELIGIBLE searches: + * count in .claude/semble/reminder.json, inject when count % N === 0, + * N = state.reminderEvery (default 5). Ineligible calls never advance it. + * 2. GATE. `isExactIntent()` used to be "biased to silence: any doubt returns + * true", and regex metacharacters alone suppressed most real intent + * searches. Only genuine enumeration / literal-lookup shapes survive: + * -l / -c / -o (and their long forms), a pattern holding '/', a + * filename-shaped pattern, and find/bfs filename predicates. + * + * Never spawns a process, never probes for a daemon (semble has none). + * + * 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, renameSync, 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 */ + } +} +// --------------------------------------------------------------------------- + +/** Injection cadence: one nudge per N eligible searches. Overridable per project. */ +const DEFAULT_EVERY = 5; +const COUNTER_NAME = 'reminder.json'; + +// 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; +// Enumeration / verification flags: the caller wants a file list or a count, +// which semble cannot produce. -F/-w (literal/word) were dropped: "find every +// use of this word" is exactly the intent question semble answers best. +const FLAG_RE = /(^|\s)-{1,2}(l|files-with-matches|L|files-without-match|c|count|o|only-matching)(=|\s|$)/; +// find/bfs filename predicate — a filename lookup, not a behaviour question. +const FIND_FLAG_RE = /(^|\s)-(name|path|iname|type)(=|\s|$)/; +// 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 }; +} + +/** + * True when the call is a genuine enumeration or literal lookup — the only + * shapes where semble has nothing to add. Everything else is now allowed + * through; the every-Nth counter, not the gate, is what limits volume. + */ +function isExactIntent(command, pattern, bin) { + if (typeof pattern !== 'string') return true; // no argument to judge + if (FLAG_RE.test(command)) return true; // -l / -c / -o enumeration + if ((bin === 'find' || bin === 'bfs') && FIND_FLAG_RE.test(command)) return true; + if (pattern.indexOf('/') >= 0) return true; // a path, not a concept + if (FILEISH_RE.test(pattern)) return true; // a filename, not a concept + 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' }; + } +} + +/** state.reminderEvery when it is a positive integer, else the default. */ +function everyOf(state) { + const n = state && state.reminderEvery; + if (Number.isInteger(n) && n >= 1) return n; + return DEFAULT_EVERY; +} + +/** + * Advances the eligible-search counter and returns its new value. A corrupt, + * absent or otherwise unreadable counter resets to 0, so the next eligible + * call is number 1 — the hook degrades to "silent for N-1 calls", never to a + * crash. The write is tmp+rename so a reader never sees a half-written file. + */ +function bumpCounter(cwd) { + const file = join(cwd, '.claude', 'semble', COUNTER_NAME); + let count = 0; + try { + const obj = JSON.parse(readFileSync(file, 'utf8')); + if (obj !== null && typeof obj === 'object' && !Array.isArray(obj) + && Number.isInteger(obj.count) && obj.count >= 0) count = obj.count; + } catch { + count = 0; // absent or corrupt — start over + } + const next = count + 1; + try { + const tmp = file + '.' + process.pid + '.tmp'; + writeFileSync(tmp, JSON.stringify({ count: next }) + '\n'); + renameSync(tmp, file); + } catch (e) { + warn('counter write failed: ' + e.message); // cadence degrades, nothing else + } + return next; +} + +/** + * Is semble USABLE in this repo — not "has verification finished". + * + * A `phase === 'ready'` gate deadlocks: semble builds its index lazily inside a + * tool call, so without a nudge nothing calls the MCP, nothing verifies, and + * the phase never advances. Phase only suppresses the three states 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. Reading + * ~/.claude.json on every Bash call to check for real would cost megabytes of + * parse per search. `prereq_ready` is denied because 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, state }; +} + +/** + * One directive line. It names the exact call and the exact params, and says in + * one clause when grep is still right. No "this is only a reminder" clause — + * the old text undercut itself and was ignored 11 times out of 11. + */ +function message(cwd, phase) { + const cold = + phase === 'ready' + ? '' + : ' Index not verified yet (phase=' + phase + '); the first call builds it.'; + return ( + 'semble: call mcp__semble_code__search FIRST for this — repo="' + cwd + + '", top_k=5, max_snippet_lines=10 — then open the hit at start_line. ' + + 'Keep grep for exact identifiers, literal strings and exhaustive -l/-c enumeration.' + + 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 : ''; + // Claude Code writes a `hook_additional_context` transcript attachment under + // this same id whenever injected text truly reaches the model, so recording + // it is what makes "delivered and ignored" distinguishable from "never + // delivered" after the fact. + const tuid = typeof input.tool_use_id === 'string' ? input.tool_use_id : ''; + const g = gate(readState(cwd)); + const record = (fired, why, extra) => + telemetry(cwd, sid, 'gate', { fired, why, phase: g.phase, enabled: g.enabled, tool_use_id: tuid, ...(extra || {}) }); + 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 {}; + } + + // Eligible. Only eligible calls advance the counter, so N really means + // "one nudge per N searches semble could have answered". + const every = everyOf(g.state); + const n = bumpCounter(cwd); + if (n % every !== 0) { + record(false, 'cadence', { n, every }); + return {}; + } + + record(true, 'ok', { n, every }); + telemetry(cwd, sid, 'nudge', { + matcher: toolName, + agent: agentOf(input), + tool_use_id: tuid, + n, + every, + 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 29d62a3..6e4447f 100644 --- a/brewcode/skills/semble-setup/assets/semble-session.mjs +++ b/brewcode/skills/semble-setup/assets/semble-session.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup +// brewcode-meta: version=5.2.0 generated_by=brewcode:semble-setup /** * brewcode:semble-setup — SessionStart hook (self-contained, installed into a project). * diff --git a/brewcode/skills/semble-setup/assets/semble-stats.mjs b/brewcode/skills/semble-setup/assets/semble-stats.mjs index c9baf63..2986625 100644 --- a/brewcode/skills/semble-setup/assets/semble-stats.mjs +++ b/brewcode/skills/semble-setup/assets/semble-stats.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup +// brewcode-meta: version=5.2.0 generated_by=brewcode:semble-setup /** * brewcode:semble-setup — PostToolUse / PostToolUseFailure hook (self-contained, * installed into a project). PURE OBSERVER. diff --git a/brewcode/skills/semble-setup/assets/semble-subagent.mjs b/brewcode/skills/semble-setup/assets/semble-subagent.mjs new file mode 100644 index 0000000..81d05ce --- /dev/null +++ b/brewcode/skills/semble-setup/assets/semble-subagent.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +/** + * brewcode:semble-setup — SubagentStart hook (self-contained, installed into a project). + * + * Registered with NO matcher, so it covers EVERY agent type. `matchQuery` for + * SubagentStart is the AGENT TYPE, not a tool name; an entry without a matcher + * key matches all of them (verified live on CC 2.1.226 — a matcher-less + * SubagentStart entry fired for agent_type "Explore"). Its predecessor, + * semble-explore.mjs, was pinned to "Explore" alone and is retired for good. + * + * A spawned subagent inherits none of the parent's context: it has never seen + * the semble rule, the session hook's line, or any earlier nudge. So this fires + * on every spawn with no throttle and no counter — there is nothing to + * de-duplicate against. + * + * ADVISORY ONLY: one `additionalContext` line, never a decision of any kind. + * Never spawns a process, 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-subagent] ' + message + '\n'); + } catch { + /* stderr is best-effort */ + } +} +// --- telemetry (best-effort, never throws, never changes hook output) ------ +const TELEMETRY_SRC = 'subagent'; +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' }; + } +} + +/** Identical usability gate to the reminder hook — see its comment for why phase !== 'ready' still fires. */ +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 }; +} + +/** + * Self-sufficient by design: the subagent cannot look anything up in the + * parent's context, so the line has to carry availability, the exact call and + * the exact fallback on its own. + */ +function message(cwd) { + return ( + 'semble: mcp__semble_code__search is already available to you — no ToolSearch needed. ' + + 'Start any "where/how/why does X work" question with ONE call: repo="' + cwd + + '", top_k=5, max_snippet_lines=10, then open the hit at start_line. ' + + 'Use rg only for exact identifiers, literal strings and exhaustive enumeration.' + ); +} + +function decide(input, cwd) { + const sid = typeof input.session_id === 'string' ? input.session_id : ''; + const agentType = typeof input.agent_type === 'string' ? input.agent_type : ''; + // agent_id is the join key: the spawned subagent's own transcript is named + // after it, which is what makes "was this line actually delivered" auditable. + const agentId = typeof input.agent_id === 'string' ? input.agent_id : ''; + // A real SubagentStart payload always names the agent type. Its absence means + // malformed or empty stdin, and a hook that cannot tell what it is talking to + // says nothing — and writes nothing, since `cwd` is a guess at that point. + if (!agentType) return {}; + const g = gate(readState(cwd)); + const record = (fired, why) => + telemetry(cwd, sid, 'gate', { + fired, why, phase: g.phase, enabled: g.enabled, agent_type: agentType, agent_id: agentId, + }); + + if (!g.ok) { + record(false, g.why); + return {}; + } + + record(true, 'ok'); + telemetry(cwd, sid, 'nudge', { + matcher: 'SubagentStart', + agent: 'sub', + agent_type: agentType, + agent_id: agentId, + q: '', + }); + 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/sembleignore.template b/brewcode/skills/semble-setup/assets/sembleignore.template index b3e4201..e9d7d7d 100644 --- a/brewcode/skills/semble-setup/assets/sembleignore.template +++ b/brewcode/skills/semble-setup/assets/sembleignore.template @@ -1,4 +1,4 @@ -# brewcode-meta: version=5.1.0 generated_by=brewcode:semble-setup +# brewcode-meta: version=5.2.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 diff --git a/brewcode/skills/semble-setup/references/engine-landscape.md b/brewcode/skills/semble-setup/references/engine-landscape.md index 503a0e9..78c1e23 100644 --- a/brewcode/skills/semble-setup/references/engine-landscape.md +++ b/brewcode/skills/semble-setup/references/engine-landscape.md @@ -146,7 +146,16 @@ Recall при фиксированном token-бюджете: | `assets/semble-session.mjs` | `SessionStart`, без matcher | читает `/.claude/semble/state.json`; при `phase === 'ready'` шлёт `systemMessage` + `additionalContext`. Никогда не блокирует | | `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` удаляет файлы и снимает их строки | +| `assets/semble-reminder.mjs` | `PreToolUse`, matcher `Bash\|Grep` | advisory `additionalContext` перед текстовым поиском, счётчик в `/.claude/semble/reminder.json`. Никогда не блокирует | +| `assets/semble-subagent.mjs` | `SubagentStart`, БЕЗ matcher (= любой `agent_type`) | advisory `additionalContext` в контекст порождённого сабагента; пишет `agent_type`/`agent_id` в телеметрию | +| ~~`assets/semble-explore.mjs`~~ | ретайрен окончательно | был прибит к одному `agent_type === 'Explore'`; заменён `semble-subagent.mjs`. `install`/`upgrade` удаляет файл и ЗАМЕНЯЕТ его строку | + +> Ретайр-обоснование 5.0.0 («0/18 и 0/11 при доказанной доставке») отозвано. +> Канал доставки не был сломан; `0/18` считало сессии, а не доставки - reminder +> не сработал ни разу (гейт подавил 74/113, 37 `disabled`, 2 throttled; +> за всё время 14/2718 = 0.52%). `0/11` - реальное измерение, но по одному типу +> агента и по самоподрывающему тексту. Поканальная конверсия: reminder 2/10 +> сессий, explore 1/7. Конверсия восстановленных хуков ещё не измерена. | `.claude/rules/semble-first.md` | - | правило «semantic-first» | | маркерный блок в `CLAUDE.md` | - | инструкция для сессии | | `semble-agents.sh` | - | патчит frontmatter агентов, добавляя 2 MCP-тула в `tools:` | diff --git a/brewcode/skills/semble-setup/references/hooks-roadmap.md b/brewcode/skills/semble-setup/references/hooks-roadmap.md index 8ddf4c4..930d15b 100644 --- a/brewcode/skills/semble-setup/references/hooks-roadmap.md +++ b/brewcode/skills/semble-setup/references/hooks-roadmap.md @@ -1,13 +1,31 @@ # 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`. +> **АКТУАЛЬНОЕ СОСТОЯНИЕ.** Раздел 1 - историческая фиксация ДО 5.0.0; +> ретайр-обоснование 5.0.0 отозвано. Advisory-хуки ВОССТАНОВЛЕНЫ: +> `semble-reminder.mjs` (`PreToolUse` `Bash|Grep`) и `semble-subagent.mjs` +> (`SubagentStart`, БЕЗ matcher - отсутствующий matcher совпадает с любым +> `agent_type`). `semble-explore.mjs` ретайрен окончательно: он был прибит к +> одному `Explore`. +> +> Почему обоснование 5.0.0 не выдержало проверки: +> - канал доставки НИКОГДА не был сломан - `PreToolUse` и `SubagentStart` оба +> принимают и доставляют `additionalContext` (zod-union, путь потребления, +> живые сессии); +> - `0/18` не измеряло ничего: reminder не сработал НИ РАЗУ в этих 18 сессиях +> (собственный гейт подавил 74/113, ещё 37 `disabled`, 2 throttled; за всё +> время 14/2718 = 0.52%). 18 - это счёт сессий, а не доставок; +> - `0/11` - настоящее измерение, но по ОДНОМУ типу агента и по тексту, который +> сам себя подрывал в последнем предложении. +> +> Поканальная конверсия на накопленной телеметрии: reminder 2/10 сессий, +> explore 1/7 - не ноль. Прежняя суммарная цифра 6/80 (7.5%) была лумпом, в +> котором доминировали 126 `SessionStart`-нуджей. Конверсия ВОССТАНОВЛЕННЫХ +> хуков пока НЕ измерена - гейт переписан, текст сабагента новый. +> +> Актуальная want-таблица - ШЕСТЬ строк: `SessionStart`, `UserPromptSubmit`, +> `PostToolUse`, `PostToolUseFailure`, `PreToolUse`/`Bash|Grep`, `SubagentStart` +> (без matcher). Источник правды - `scripts/semble-guidance.sh` +> (`SG_WANT_TABLE`) и `assets/INSTALL.md`. > Раздел «Предложения» сохранён как есть: он про будущие события, а не про текущие. Документ фиксирует (a) что реально лежит на диске сегодня и (b) пять предложений, @@ -24,16 +42,17 @@ ## 1. Где какой хук был применён ДО 5.0.0 (историческая фиксация) -Ассеты скилла (`assets/`) - то, что устанавливалось ДО 5.0.0. Актуальный набор - ТРИ файла -(`semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`); см. баннер выше и +Ассеты скилла (`assets/`) - то, что устанавливалось ДО 5.0.0. Актуальный набор - ПЯТЬ файлов +(`semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`, `semble-reminder.mjs`, +`semble-subagent.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`~~ РЕТАЙРЕН 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` в транскрипт ПОРОЖДЁННОГО сабагента | нет | +| `PreToolUse` | `Bash` | `semble-reminder.mjs` (ДО 5.0.0 - две отдельные строки) | `hookSpecificOutput.additionalContext`, не чаще 1 раза в 600 s | нет, по контракту | +| `PreToolUse` | `Grep` | то же | то же (та же регистрация, второй matcher) | нет, по контракту | +| `SubagentStart` | `Explore` | ~~`semble-explore.mjs`~~ РЕТАЙРЕН ОКОНЧАТЕЛЬНО, заменён `semble-subagent.mjs` без matcher | `hookSpecificOutput.additionalContext` в транскрипт ПОРОЖДЁННОГО сабагента | нет | Общее для всех трёх: pure ESM, только Node built-ins, читают ровно один файл `/.claude/semble/state.json`, не спавнят процессов, всегда печатают один JSON-объект @@ -45,14 +64,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`~~ (РЕТАЙРЕН 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 | +| `semble-reminder.mjs` (снимок ДО 5.0.0; восстановленная версия переписана - счётчик `.claude/semble/reminder.json`, каждый N-й вызов, `tool_use_id` в телеметрии) | 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-subagent.mjs`: без гейта по типу, пишет `agent_type`/`agent_id` в телеметрию | ### Блоб settings.json (`assets/INSTALL.md` section 4 и `merge_settings()` в `semble-guidance.sh`) Обе копии идентичны, `want`-таблица ДО 5.0.0 (актуальная - `SG_WANT_TABLE` в `scripts/semble-guidance.sh`: `SessionStart` / `UserPromptSubmit` / `PostToolUse` / -`PostToolUseFailure`): +`PostToolUseFailure` / `PreToolUse`+`Bash|Grep` / `SubagentStart` без matcher): ``` ["SessionStart", null, "semble-session.mjs", 5] @@ -70,7 +89,7 @@ |----------|--------------------| | Ключ дедупликации | `event + matcher + full path` (не путь один) - иначе регистрация `Grep` молча терялась бы, т.к. reminder законно встречается дважды | | Prune стальных путей | сравнение по ПОЛНОМУ пути с `wanted`; фильтрация внутри `entry.hooks[]`, entry удаляется только когда опустел; чужой hook рядом со стальным выживает | -| Post-write assertion | перечитать файл и потребовать ровно 1 вхождение на каждую из 4 пар event+matcher+script и ровно 1 вхождение каждого tool в `permissions.allow`, иначе `exit 1` | +| Post-write assertion | перечитать файл и потребовать ровно 1 вхождение на каждую из 6 троек event+matcher+script и ровно 1 вхождение каждого tool в `permissions.allow`, иначе `exit 1` | | Abort-гейт | `settings.json` не-JSON или не-объект -> ничего не пишется, `exit 1`; в скрипте это preflight ДО записи rule/CLAUDE.md/hooks | `state.json` не содержит ни одного числового/монотонного ключа diff --git a/brewcode/skills/semble-setup/references/output-contract.md b/brewcode/skills/semble-setup/references/output-contract.md index 30440a2..3b6fa85 100644 --- a/brewcode/skills/semble-setup/references/output-contract.md +++ b/brewcode/skills/semble-setup/references/output-contract.md @@ -16,7 +16,7 @@ scope: cli: uv | uvx | semble pin 0.5.4 () | claude mcp: @ [] cache: | repo | | | docs root reserved: -guidance: rule | CLAUDE.md | hooks /4 wired | permissions +guidance: rule | CLAUDE.md | hooks /6 wired | permissions agents: total | inherit | patched | conflict | skipped state: phase= enabled= completed=[...] @@ -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(`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". | +| `hooks /6 wired` | 6 = SessionStart(`semble-session.mjs`) + UserPromptSubmit(`semble-prefetch.mjs`) + PostToolUse(`semble-stats.mjs`) + PostToolUseFailure(`semble-stats.mjs`) + PreToolUse(`semble-reminder.mjs`, matcher `Bash\|Grep`) + SubagentStart(`semble-subagent.mjs`, no matcher — matches every agent type). Anything below 6 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. | @@ -86,7 +86,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.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`. | +| `installed` when `hooks` < 6, 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. | | a result field named `line` | Results carry `file_path`, `start_line`, `end_line`, `score` and optional `content`. | @@ -110,7 +110,7 @@ scope: user 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 +guidance: rule absent | CLAUDE.md absent | hooks 0/6 wired | permissions no agents: 7 total | 3 inherit | 0 patched | 4 conflict | 0 skipped state: phase=absent enabled=null completed=[] diff --git a/brewcode/skills/semble-setup/scripts/semble-guidance.sh b/brewcode/skills/semble-setup/scripts/semble-guidance.sh index a8ebb77..1dde60d 100755 --- a/brewcode/skills/semble-setup/scripts/semble-guidance.sh +++ b/brewcode/skills/semble-setup/scripts/semble-guidance.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# semble-guidance.sh — rule file, CLAUDE.md marker block, the three hooks, +# semble-guidance.sh — rule file, CLAUDE.md marker block, the five hooks, # settings.json wiring and permission entries for brewcode:semble-setup. # Contracts: DESIGN §9.8 and §10. All JSON goes through `node -e`, never jq. set -euo pipefail @@ -12,29 +12,42 @@ IGNORE_TPL="$SRC/sembleignore.template" SESSION_MJS="semble-session.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. +REMINDER_MJS="semble-reminder.mjs" +SUBAGENT_MJS="semble-subagent.mjs" +HOOK_MJS="$SESSION_MJS $PREFETCH_MJS $STATS_MJS $REMINDER_MJS $SUBAGENT_MJS" +# RESTORED after v5.1.0. Both advisory hooks are live again, redesigned. # -# 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" +# v5.1.0 deleted them citing "measured conversion 0/18 and 0/11 with delivery proven +# independently". Two thirds of that is wrong: +# - The channel was never broken. `PreToolUse` and `SubagentStart` both accept and +# deliver `additionalContext` on this build - confirmed in the schema union, in the +# consumption path, and live. +# - `0/18` measured NOTHING. The reminder hook fired zero times across those 18 +# sessions: its own gate suppressed 74 of 113 invocations, `disabled` 37, throttle 2 +# (lifetime fire rate 14/2718 = 0.52%). 18 is a count of sessions, not deliveries. +# - `0/11` WAS a real measurement, but only of `SubagentStart` matcher `Explore` - one +# agent type - on text that ended by undercutting its own advice. +# `semble-reminder.mjs` returns under its own name; the Explore hook is replaced by +# `semble-subagent.mjs` on a matcher-less row, so it covers EVERY agent type +# (Claude Code keys a `SubagentStart` matcher on `agent_type`, and an absent matcher +# matches all of them). +# +# RETIRED for good: `semble-explore.mjs`. The name never comes back. It stays listed +# here because an existing install has it wired into settings.json and copied into +# .claude/hooks/ — the ownership predicate (`SG_MARKS`) must keep recognising it so the +# merge's stale-purge strips its row, and `install`/`upgrade` must keep deleting the +# file, which no longer ships. +RETIRED_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. +# Marker files a retired hook wrote. `.reminder-ts` was the v1 reminder's throttle +# stamp; the restored hook keeps its counter in `.claude/semble/reminder.json` +# instead, which the `.claude/semble/` .gitignore line already covers. Leaving the +# old dotfile behind turns an invisible marker into an untracked file in the repo. 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"]' +SG_MARKS='["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs","semble-reminder.mjs","semble-subagent.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"]' +SG_LIVE='["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs","semble-reminder.mjs","semble-subagent.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 @@ -53,8 +66,16 @@ SG_LIVE='["semble-session.mjs","semble-prefetch.mjs","semble-stats.mjs"]' # 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. +# +# The reminder row shares the `|`-only exact-list form: `Bash|Grep` is the pair of +# tools whose use is the moment a semantic search would have been the cheaper move. +# The subagent row carries NO matcher on purpose. Claude Code resolves a +# `SubagentStart` matcher against `agent_type`, and the filter is +# `!matcher || matches(agent_type, matcher)` — so an absent matcher is the only form +# that covers every agent type, present and future, including project-local ones this +# skill has never heard of. `Explore` alone (the v1 row) covered exactly one. 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]]' +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],["PreToolUse","Bash|Grep","semble-reminder.mjs",5],["SubagentStart",null,"semble-subagent.mjs",5]]' MODE="" PART="all" @@ -168,7 +189,8 @@ const out={schema:1, ignore:{state:"absent",path:process.env.SG_IGNORE}, claudeMd:{state:"absent",path:cmd,malformed:false}, hooks:{session:{file:"missing",wired:false},prefetch:{file:"missing",wired:false}, - stats:{file:"missing",wired:false},retired:[], + stats:{file:"missing",wired:false},reminder:{file:"missing",wired:false}, + subagent:{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}}; @@ -234,6 +256,8 @@ if(cc!==null){ const b=cc.indexOf(BEGIN), e=cc.indexOf(END); 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"; +out.hooks.reminder.file=fs.existsSync(path.join(dir,"semble-reminder.mjs"))?"present":"missing"; +out.hooks.subagent.file=fs.existsSync(path.join(dir,"semble-subagent.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); @@ -304,6 +328,8 @@ out.hooks.session.wired=ok("SessionStart/*/semble-session.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"); +out.hooks.reminder.wired=ok("PreToolUse/Bash|Grep/semble-reminder.mjs"); +out.hooks.subagent.wired=ok("SubagentStart/*/semble-subagent.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); @@ -325,7 +351,8 @@ 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.prefetch.file+" prefetch, " - +j.hooks.stats.file+" stats" + +j.hooks.stats.file+" stats, "+((j.hooks.reminder||{}).file||"missing")+" reminder, " + +((j.hooks.subagent||{}).file||"missing")+" subagent" +(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(", ")); @@ -681,7 +708,7 @@ do_claudemd() { # ── hook files ────────────────────────────────────────────────────────────── install_hook_files() { - local f + local f stage for f in $HOOK_MJS; do [ -f "$SRC/$f" ] || { add_failed "hooks: asset missing at $SRC/$f"; return 0; } done @@ -698,9 +725,10 @@ install_hook_files() { 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. + # Migration: delete `semble-explore.mjs`, which no longer ships under that name. An + # install that merely stops WRITING it 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 whose + # replacement (semble-subagent.mjs, all agent types) is already wired. for f in $RETIRED_MJS; do [ -e "$HOOKS_DIR/$f" ] || continue rm -f "$HOOKS_DIR/$f" \ @@ -718,14 +746,32 @@ install_hook_files() { add_unchanged "hooks: $f already current" continue fi - if cp "$SRC/$f" "$HOOKS_DIR/$f" && node --check "$HOOKS_DIR/$f"; then + # Staged, then moved into place only once `node --check` passes. Checking AFTER + # the copy left a broken asset installed and still wired: every prompt of the + # user's next session then ran a hook Claude Code cannot parse. + # The staged name still ends in .mjs: the assets are ESM, and `node --check` on a + # path with any other suffix parses them as CommonJS and rejects every `export`. + stage="$HOOKS_DIR/.semble-staging.$$.$f" + if cp "$SRC/$f" "$stage" && node --check "$stage" 2>/dev/null && mv -f "$stage" "$HOOKS_DIR/$f"; then add_changed "hooks: installed $HOOKS_DIR/$f" else + rm -f "$stage" add_failed "hooks: cannot install $HOOKS_DIR/$f" fi done } +# A want row whose hook file is not on disk must not be wired: an entry pointing at a +# missing file is a hook that fails on every prompt. A file that survived a failed +# upgrade (the previous, valid copy) is still there and stays wired. +prune_want_table() { + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then return 0; fi + SG_WANT_TABLE="$(SG_WANT="$SG_WANT_TABLE" SG_HOOKS="$HOOKS_DIR" node -e ' +const fs=require("fs"), path=require("path"); const d=process.env.SG_HOOKS; +process.stdout.write(JSON.stringify( + JSON.parse(process.env.SG_WANT).filter((r) => fs.existsSync(path.join(d, r[2])))));')" +} + remove_hook_files() { local f for f in $ALL_MJS; do # retired files included: uninstall must leave nothing behind @@ -736,9 +782,9 @@ 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 + # The throttle markers and counters are ours too, live and retired alike: removal drops + # their .gitignore line, so anything left behind surfaces as an untracked file. + for f in .claude/semble/.prefetch-ts .claude/semble/reminder.json $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" @@ -770,12 +816,19 @@ 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)); // 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. +// purge below. It is now the want table itself, as (event, matcher, path) TRIPLES, and that +// one change carries every shape migration this skill has: +// - a RETIRED script at the current hooks dir is stripped exactly like a stale-path one, +// so a v1 `SubagentStart`/`Explore` row pointing at semble-explore.mjs is REPLACED by +// the matcher-less semble-subagent.mjs row rather than left standing beside it; +// - a retired REGISTRATION of a LIVE script goes the same way — the v1 pair of +// `PreToolUse`/`Bash` + `PreToolUse`/`Grep` reminder rows collapses into the single +// `PreToolUse`/`Bash|Grep` row, and 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. +// A v5.1.0-shaped file, where the reminder and subagent rows are simply absent, converges +// through the same loop: nothing to purge, two rows appended. Both starting shapes and a +// clean project therefore land on the identical six-row result. 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}); @@ -945,12 +998,26 @@ 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/.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' +# The whole directory, not one marker: the install also puts state.json there and all +# five hooks append telemetry.jsonl, which carries verbatim shell commands and distilled +# prompt text and is trimmed at 2 MB. A per-marker line left the rest permanently +# untracked-and-committable in any repo that does not already ignore `.claude/`. +GI_LINE='.claude/semble/' +# Superseded by the directory line and stripped by install AND remove: `.reminder-ts` +# retired with semble-reminder.mjs, `.prefetch-ts` is now covered by GI_LINE. A migrated +# repo must not keep either beside the new line. +GI_RETIRED='.claude/semble/.reminder-ts .claude/semble/.prefetch-ts' gitignore_has_line() { grep -Fqx "$GI_LINE" "$GITIGNORE" 2>/dev/null; } -gitignore_has_retired() { grep -Fqx "$GI_RETIRED" "$GITIGNORE" 2>/dev/null; } +# Space-separated list of the retired lines actually present, so the report names what +# it dropped instead of the whole catalogue. +gitignore_retired_present() { + local l out='' + for l in $GI_RETIRED; do + if grep -Fqx "$l" "$GITIGNORE" 2>/dev/null; then out="$out $l"; fi + done + printf '%s' "${out# }" +} +gitignore_has_retired() { [ -n "$(gitignore_retired_present)" ]; } # Drops every line in $1 (space-separated) plus the `# brewcode:semble` header that # immediately precedes one of them. @@ -983,11 +1050,12 @@ fs.writeFileSync(f,head+"# brewcode:semble\n"+process.env.SG_LINE+"\n");' # Migration, install side: silently retire the old marker line if it is there. gitignore_migrate() { [ -f "$GITIGNORE" ] || return 0 - gitignore_has_retired || return 0 - if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "drop $GI_RETIRED from $GITIGNORE" >/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 + local present; present="$(gitignore_retired_present)" + [ -n "$present" ] || return 0 + if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "drop $present from $GITIGNORE" >/dev/null; add_changed "gitignore: would drop retired $present"; return 0; fi + gitignore_drop "$present" || { add_failed "gitignore: cannot rewrite $GITIGNORE"; return 0; } + if gitignore_has_retired; then add_failed "gitignore: $present is still in $GITIGNORE" + else add_changed "gitignore: dropped retired $present 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" @@ -1037,6 +1105,7 @@ case "$MODE" in want_part claudemd && do_claudemd install if want_part hooks; then install_hook_files + prune_want_table gitignore_migrate install_gitignore fi diff --git a/brewcode/skills/semble-setup/scripts/semble-remove.sh b/brewcode/skills/semble-setup/scripts/semble-remove.sh index 067229f..32dc239 100755 --- a/brewcode/skills/semble-setup/scripts/semble-remove.sh +++ b/brewcode/skills/semble-setup/scripts/semble-remove.sh @@ -196,9 +196,11 @@ process.stdout.write(((j.changed||[]).length)+" "+((j.skipped||[]).length));')" 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-subagent.mjs" "hook" + # Retired for good: replaced by semble-subagent.mjs. Still removed here — a repo + # that never ran the migrating install/upgrade still has it on disk, and uninstall + # must leave nothing behind. sr_rm_file "$root/.claude/hooks/semble-explore.mjs" "hook" sr_strip_claudemd sr_skipped "settings: semble-guidance.sh unavailable — .claude/settings.json entries not unwired" @@ -338,8 +340,9 @@ sr_plan() { sr_would "$root/.claude/hooks/semble-session.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/hooks/semble-reminder.mjs" + sr_would "$root/.claude/hooks/semble-subagent.mjs" + sr_would "$root/.claude/hooks/semble-explore.mjs (retired, superseded by semble-subagent.mjs)" sr_would "$root/CLAUDE.md marker block $SR_CLAUDEMD_BEGIN .. $SR_CLAUDEMD_END" sr_would "$root/.claude/semble/" ;; @@ -351,8 +354,9 @@ sr_plan() { sr_would "$root/.claude/hooks/semble-session.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/hooks/semble-reminder.mjs" + sr_would "$root/.claude/hooks/semble-subagent.mjs" + sr_would "$root/.claude/hooks/semble-explore.mjs (retired, superseded by semble-subagent.mjs)" 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 a2b7753..c2c6260 100755 --- a/brewcode/skills/semble-setup/scripts/semble-state.sh +++ b/brewcode/skills/semble-setup/scripts/semble-state.sh @@ -48,7 +48,7 @@ EOF # reached `ready` first. `absent -> disabled` stays illegal: you cannot # disable a setup that never happened. # NOT legal, deliberately: awaiting_reload -> ready. `verifying` is the only -# gate in front of `ready`, and `ready` is what the three hooks read to decide +# gate in front of `ready`, and `ready` is what the five hooks read to decide # whether to advertise the MCP tools. The resume path therefore writes # `verifying` before it starts verifying and `ready` only after (SKILL.md §4). # Identity transitions are legal so a re-run is idempotent. diff --git a/brewcode/skills/semble-setup/scripts/semble-status.sh b/brewcode/skills/semble-setup/scripts/semble-status.sh index 8e0839c..3242f86 100755 --- a/brewcode/skills/semble-setup/scripts/semble-status.sh +++ b/brewcode/skills/semble-setup/scripts/semble-status.sh @@ -120,12 +120,51 @@ const rep = { injectedSessions: 0, openedSessions: 0, sessionPct: null, pathsInjected: 0, pathsOpened: 0, pathPct: null, }, + // `sessionsWithNudge`/`sessionsConverted`/`conversionPct` are the LUMPED figures: + // every nudge-emitting channel share one denominator. They are kept for schema + // compatibility and they are not a per-channel measurement — reading them as one + // is what produced the "0/18" claim that retired the reminder hook. The channel a + // number belongs to lives in `bySource`, keyed by the emitting hook's `src`. + // + // `converted` does NOT mean the same thing in every channel, so each entry names + // its own `measure`: + // semble-call-after a semble MCP call landed later in the same session + // injected-path-opened a path prefetch injected was later actually Read + // Attribution overlaps by design: one semble call can convert a session for the + // session channel AND the reminder channel. What must never happen is one + // channel's firings sitting in another channel's denominator. conversion: { sessionsWithNudge: 0, sessionsConverted: 0, conversionPct: null, callsAfterNudge: 0, callsWithoutNudge: 0, + bySource: {}, }, }; +// Every channel that can emit a nudge, listed so a channel that fired zero times +// still reports 0/0 instead of vanishing from the output. `explore` is the retired +// SubagentStart/Explore hook: it is NOT listed, so it appears only in a JSONL that +// actually holds its records, and disappears once those age out of the window. +const NUDGE_SOURCES = ["session", "reminder", "subagent", "prefetch"]; +const MEASURE = { + session: "semble-call-after", + reminder: "semble-call-after", + subagent: "semble-call-after", + explore: "semble-call-after", + prefetch: "injected-path-opened", +}; +function srcSlot(src) { + if (!rep.conversion.bySource[src]) { + rep.conversion.bySource[src] = { + nudges: 0, sessions: 0, converted: 0, sessionPct: null, + callsAfter: 0, measure: MEASURE[src] || "semble-call-after", + }; + } + return rep.conversion.bySource[src]; +} +for (const s of NUDGE_SOURCES) srcSlot(s); +rep.conversion.bySource.reminder.toolUses = 0; +rep.conversion.bySource.subagent.agentTypes = {}; + // 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 = []; @@ -148,9 +187,22 @@ function agentOf(r) { } // nudges/calls per session, for the conversion join -const nudgeFirst = {}; // sid -> earliest nudge ts +const nudgeFirst = {}; // sid -> earliest nudge ts (all channels lumped) const nudgeTs = {}; // sid -> sorted-enough list of nudge ts const callsBySid = {}; // sid -> [ts] +// The same two joins kept PER CHANNEL. Without this split a channel that fires +// often lends its sessions to a channel that never fired, and the quotient stops +// describing either of them. +const srcFirst = {}; // src -> sid -> earliest nudge ts of THAT src +const srcTs = {}; // src -> sid -> [ts] +const reminderTuids = {};// tool_use_id -> 1, so a retried PreToolUse is not double counted +function noteNudge(src, sid, ts) { + srcSlot(src).nudges++; + if (!srcFirst[src]) { srcFirst[src] = {}; srcTs[src] = {}; } + if (!srcTs[src][sid]) srcTs[src][sid] = []; + srcTs[src][sid].push(ts); + if (srcFirst[src][sid] === undefined || ts < srcFirst[src][sid]) srcFirst[src][sid] = ts; +} // prefetch conversion join: sid -> [{p, ts}] injected, sid -> [{f, abs, ts}] opened const injected = {}; const opened = {}; @@ -173,6 +225,11 @@ for (const r of recs) { if (!nudgeTs[sid]) nudgeTs[sid] = []; nudgeTs[sid].push(ts); if (nudgeFirst[sid] === undefined || ts < nudgeFirst[sid]) nudgeFirst[sid] = ts; + noteNudge(src, sid, ts); + const tuid = typeof r.tool_use_id === "string" ? r.tool_use_id : ""; + if (src === "reminder" && tuid) reminderTuids[tuid] = 1; + const at = typeof r.agent_type === "string" && r.agent_type ? r.agent_type : "unknown"; + if (src === "subagent") bump(rep.conversion.bySource.subagent.agentTypes, at); break; } case "call": { @@ -193,6 +250,7 @@ for (const r of recs) { 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++; + noteNudge("prefetch", sid, ts); // its own channel, its own denominator 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++; @@ -261,6 +319,34 @@ for (const sid of injSids) { 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; + +// Per-channel conversion. Each channel is joined against its OWN nudge timestamps, +// so a channel that never fired reports 0/0 and contributes nothing to any other +// channel's rate. The prefetch channel converts on a path being opened, not on a +// later semble call - it already RAN the search, so "did they call semble after" +// would answer a question nobody asked; its numbers come from prefetchConversion. +for (const src of Object.keys(srcFirst)) { + const slot = srcSlot(src); + const sids = Object.keys(srcFirst[src]); + slot.sessions = sids.length; + slot.converted = sids.filter(function (sid) { + return (callsBySid[sid] || []).some(function (t) { return t > srcFirst[src][sid]; }); + }).length; + for (const sid of Object.keys(callsBySid)) { + for (const t of callsBySid[sid]) { + if ((srcTs[src][sid] || []).some(function (n) { return n <= t; })) slot.callsAfter++; + } + } +} +rep.conversion.bySource.prefetch.converted = pc.openedSessions; +rep.conversion.bySource.prefetch.sessions = pc.injectedSessions; +rep.conversion.bySource.reminder.toolUses = Object.keys(reminderTuids).length; +for (const src of Object.keys(rep.conversion.bySource)) { + const slot = rep.conversion.bySource[src]; + slot.sessionPct = slot.sessions + ? Math.round((slot.converted / slot.sessions) * 1000) / 10 + : null; +} if (msSamples.length) { const s = msSamples.slice().sort(function (a, b) { return a - b; }); rep.prefetch.msMedian = s[Math.floor((s.length - 1) / 2)]; @@ -299,7 +385,7 @@ L.push("opened: " + pc.pathsOpened + "/" + pc.pathsInjected + " injected path + (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]"); + + (rep.nudge.unknown ? ", unknown " + rep.nudge.unknown : "") + ")"); 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 @@ -307,7 +393,20 @@ L.push("search: " + rep.search.total + " search-shaped non-semble (main " + r 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"); + + " | " + c.callsAfterNudge + " calls after a nudge, " + c.callsWithoutNudge + " unprompted" + + " [ALL channels lumped - per channel below]"); +// One line per channel. A channel that never fired prints 0/0, which is the +// distinction the retirement decision missed: 0/0 is "never delivered", 0/N is +// "delivered and ignored", and only the second is evidence about the advice. +for (const src of Object.keys(c.bySource).sort()) { + const s = c.bySource[src]; + L.push(" " + (src + " ").slice(0, 9) + s.converted + "/" + s.sessions + " sessions" + + (s.sessionPct === null ? "" : " (" + s.sessionPct + "%)") + + " | " + s.nudges + " fired" + + (s.toolUses === undefined ? "" : ", " + s.toolUses + " distinct tool_use_id") + + (s.agentTypes === undefined ? "" : ", agents [" + pairs(s.agentTypes) + "]") + + " | " + s.measure); +} 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"); @@ -523,6 +622,8 @@ if (guidRaw === null || isErr(guidRaw)) { const ses = (h.session && typeof h.session === "object") ? h.session : {}; const pre = (h.prefetch && typeof h.prefetch === "object") ? h.prefetch : {}; const sta = (h.stats && typeof h.stats === "object") ? h.stats : {}; + const rem = (h.reminder && typeof h.reminder === "object") ? h.reminder : {}; + const sub = (h.subagent && typeof h.subagent === "object") ? h.subagent : {}; 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 : {}; @@ -536,14 +637,17 @@ if (guidRaw === null || isErr(guidRaw)) { session: ses.file === "present" ? "present" : "missing", prefetch: pre.file === "present" ? "present" : "missing", stats: sta.file === "present" ? "present" : "missing", + reminder: rem.file === "present" ? "present" : "missing", + subagent: sub.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 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. + // PostToolUse + PostToolUseFailure + PreToolUse + SubagentStart). 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, diff --git a/brewcode/skills/semble-setup/tests/suite-hooks.mjs b/brewcode/skills/semble-setup/tests/suite-hooks.mjs index f1ca1a7..f5264a8 100644 --- a/brewcode/skills/semble-setup/tests/suite-hooks.mjs +++ b/brewcode/skills/semble-setup/tests/suite-hooks.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node /** * 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. + * block, the five hooks (session, prefetch, stats, reminder, subagent) and the + * settings.json merge performed by semble-guidance.sh — including the migration + * that reverses v5.1.0: the reminder hook comes back under its own name, the + * SubagentStart row moves off `Explore` onto every agent type, and + * `semble-explore.mjs` stays retired for good. * * Self-contained: inlines its own check()/run() helpers, runs standalone * (`node tests/suite-hooks.mjs`), and never touches the real ~/.claude, the @@ -31,6 +33,8 @@ const TEMPLATE_SRC = join(ASSETS, 'semble-first.md.template'); const SESSION_SRC = join(ASSETS, 'semble-session.mjs'); const PREFETCH_SRC = join(ASSETS, 'semble-prefetch.mjs'); const STATS_SRC = join(ASSETS, 'semble-stats.mjs'); +const REMINDER_SRC = join(ASSETS, 'semble-reminder.mjs'); +const SUBAGENT_SRC = join(ASSETS, 'semble-subagent.mjs'); const BASE = realpathSync(mkdtempSync(join(tmpdir(), 'semble-d-'))); const HOME = join(BASE, 'home'); @@ -128,7 +132,8 @@ copyFileSync(join(SCRIPTS, 'semble-guidance.sh'), join(SKILL_COPY, 'scripts', 's 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']) { + 'semble-session.mjs', 'semble-prefetch.mjs', 'semble-stats.mjs', + 'semble-reminder.mjs', 'semble-subagent.mjs']) { copyFileSync(join(ASSETS, f), join(SKILL_COPY, 'assets', f)); } @@ -262,19 +267,23 @@ 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; +// The want table: SessionStart, UserPromptSubmit, the stats pair, the reminder +// row on `Bash|Grep`, and the subagent row with NO matcher (a SubagentStart +// matcher is keyed on agent_type, so absent == every agent type). The v1 shape - +// PreToolUse/Bash + PreToolUse/Grep + SubagentStart/Explore - is REPLACED, not +// kept beside these; group M proves it on a real v1-shaped file. +const WANT_N = 6; 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); + const { session, prefetch: pre, stats, reminder, subagent } = 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), + countEntry(s, 'PreToolUse', 'Bash|Grep', reminder), + countEntry(s, 'SubagentStart', null, subagent), ]; } @@ -284,9 +293,10 @@ function semblePaths(proj) { 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'), + subagent: join(d, 'semble-subagent.mjs'), + // retired for good, superseded by semble-subagent.mjs — still addressable, + // because the migration is asserted on the file it must DELETE. explore: join(d, 'semble-explore.mjs'), }; } @@ -326,23 +336,26 @@ const READY_STATE = (extra) => check('A1.statusTrailingNewline', guidance(p, ['status', '--json']).stdout.endsWith('\n'), true, 'status --json carries the same trailing newline'); const s = readSettings(p); - const { session, prefetch: pre, stats, reminder: rem, explore: exp } = semblePaths(p); + const { session, prefetch: pre, stats, reminder: rem, subagent: sub, 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.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.reminderEntry', s.hooks.PreToolUse, [ + { matcher: 'Bash|Grep', hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5 }] }, + ], 'the reminder hook is registered once on PreToolUse with the `|`-only exact tool list'); + check('A1.subagentEntry', s.hooks.SubagentStart, [ + { hooks: [{ type: 'command', command: 'node', args: [sub], timeout: 5 }] }, + ], 'the subagent hook carries NO matcher, which is the only form that covers every agent type'); 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(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'); + check('A1.files', [existsSync(session), existsSync(pre), existsSync(stats), + existsSync(rem), existsSync(sub)], [true, true, true, true, true], + 'all five live .mjs assets were copied into .claude/hooks'); + check('A1.noRetiredFiles', existsSync(exp), false, + 'and the one retired asset is never written'); 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'); @@ -370,7 +383,7 @@ const READY_STATE = (extra) => 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); - check('A2.counts', wantCounts(p, s), [1, 1, 1, 1], 'exactly one entry per want row after three merges'); + check('A2.counts', wantCounts(p, s), [1, 1, 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, @@ -404,7 +417,7 @@ 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'); - check('A3.counts', wantCounts(p, s), [1, 1, 1, 1], + check('A3.counts', wantCounts(p, s), [1, 1, 1, 1, 1, 1], 'exactly one semble entry per want row alongside the foreign ones'); } @@ -440,16 +453,18 @@ 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'); - check('A5.counts', wantCounts(p, s), [1, 1, 1, 1], 'the new-dir entries were added exactly once each'); + check('A5.counts', wantCounts(p, s), [1, 1, 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'); + check('A5.reminderMigrated', s.hooks.PreToolUse.map((e) => e.matcher).sort(), ['Bash|Grep', 'Write'], + 'the v1 PreToolUse/Bash reminder row is REPLACED by the Bash|Grep row, not left beside it'); } // A6 — uninstall leaves zero markers and prunes empty containers { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); - const { session, prefetch: pre, stats } = semblePaths(p); + const { session, prefetch: pre, stats, reminder: rem, subagent: sub } = 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); @@ -457,8 +472,9 @@ 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(pre), existsSync(stats)], [false, false, false], - 'all three .mjs files are deleted'); + check('A6.files', [existsSync(session), existsSync(pre), existsSync(stats), + existsSync(rem), existsSync(sub)], [false, false, false, false, false], + 'all five .mjs files are deleted'); check('A6.rule', existsSync(join(p, '.claude', 'rules', 'semble-first.md')), false, 'the managed rule file is deleted'); } @@ -481,23 +497,26 @@ 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.prefetch.wired, b.hooks.stats.wired, b.permissions.wired], - [false, false, false, false], 'nothing is reported as wired before install'); + [b.hooks.session.wired, b.hooks.prefetch.wired, b.hooks.stats.wired, + b.hooks.reminder.wired, b.hooks.subagent.wired, b.permissions.wired], + [false, false, false, false, false, false], 'nothing is reported as wired before install'); guidance(p, ['install', '--part', 'all', '--json']); const after = guidance(p, ['status', '--json']); const a = safeParse(after.stdout); 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.prefetch.wired, a.hooks.stats.wired, a.permissions.wired], - [true, true, true, true], - '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'); + [a.hooks.session.wired, a.hooks.prefetch.wired, a.hooks.stats.wired, + a.hooks.reminder.wired, a.hooks.subagent.wired, a.permissions.wired], + [true, true, true, true, true, true], + 'session, prefetch, stats (both post-tool events), reminder, subagent and permissions all report wired'); + check('A8.afterFiles', [a.hooks.session.file, a.hooks.prefetch.file, a.hooks.stats.file, + a.hooks.reminder.file, a.hooks.subagent.file], + ['present', 'present', 'present', 'present', 'present'], 'status sees all five 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, a.hooks.wantCount], [WANT_N, WANT_N], - 'all 4 settings entries are counted as wired'); + 'all 6 settings entries are counted as wired'); check('A8.exitReadOnly', before.status, 0, 'status exits 0'); } @@ -511,14 +530,14 @@ const FOREIGN = { writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n'); const a = safeParse(guidance(p, ['status', '--json']).stdout); check('A9.wiredCount', [a.hooks.wiredCount, a.hooks.wantCount], [WANT_N - 1, WANT_N], - 'dropping the UserPromptSubmit entry reports 3 of 4, not "wired"'); + 'dropping the UserPromptSubmit entry reports 5 of 6, 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'); + check('A9.human', human.includes('hooks 5/6 wired'), true, + 'the human line spells the partial count out as 5/6'); } // A10 — remove takes the prefetch registration with the file @@ -1179,8 +1198,9 @@ const { 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'); + `--from semble[mcp]==0.5.4 semble search --content code docs config -k 3 --max-snippet-lines 0 -- ${Q_DISTILLED} ${p}`, + ], 'the child is spawned exactly once, with the pinned spec, the frozen flags, then `--` and the' + + ' distilled query — options before the separator, because argparse stops reading flags at it'); 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'); @@ -1194,6 +1214,27 @@ const { + 'builds its own 20 MB copy, and the hook\'s copy is always cold'); } +// P2c — a distilled query that STARTS WITH A DASH. The backtick rule puts backticked +// text at the front of the query, so ``what does `-k` do…`` distils to exactly `-k`; +// semble's argparse reads a lone leading-dash argv as an option, the child exits +// non-zero, and a well-formed question buys a ten-minute cooldown. Verified against the +// pinned 0.5.4 CLI: `-- ` parses, `--` before the flags does not. +{ + const DASH_PROMPT = 'what does `-k` do to all of it and why so then'; + check('P2c.distill', distill(DASH_PROMPT), '-k', + 'the distiller really does produce a bare leading-dash token for this prompt'); + check('P2c.gate', gateV3(DASH_PROMPT).fire, true, + 'and the gate fires on it, so that token really reaches argv'); + const p = freshProject({ state: READY_STATE() }); + const r = prefetch(p, DASH_PROMPT); + check('P2c.argv', r.argv, [ + `--from semble[mcp]==0.5.4 semble search --content code docs config -k 3 --max-snippet-lines 0 -- -k ${p}`, + ], 'the `-k` query sits AFTER the `--` separator, the one position argparse can only read as a positional'); + check('P2c.fires', safeParse(r.stdout), PREFETCH_OK, 'the search runs and its hits are injected as normal'); + check('P2c.noCooldown', Object.prototype.hasOwnProperty.call(markerOf(p) || {}, 'cool'), false, + 'and NO cooldown is armed - ten minutes of silence was the entire cost of the defect'); +} + // 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 @@ -1470,14 +1511,22 @@ for (const [name, opts, msg] of SEARCH_FAIL) { '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. +// A search that RAN and found nothing is not a failure: it arms the ORDINARY throttle. +// It used to arm nothing at all — the `no-hits` return sat above writeMarker — so every +// following prompt paid another uvx child for the same empty answer. { 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'); + check('P6.noHitsNoCooldown', Object.prototype.hasOwnProperty.call(markerOf(p) || {}, 'cool'), false, + 'and it arms NO cooldown — `[]` means the index answered, only `null` means it could not be trusted'); + check('P6.noHitsThrottle', typeof (markerOf(p) || {}).t, 'number', + 'but it DOES stamp the 30 s throttle: the search ran, and repeating it on the very next prompt buys nothing'); + const second = prefetch(p, Q, { out: '{"results":[]}' }); + check('P6.noHitsThrottleHolds', [safeParse(second.stdout), second.argv, tfield(p, 1, 'why')], + [{}, [], 'throttled'], + 'so the next prompt spawns NO child and is recorded as throttled, not as a second no-hits'); } // The cooldown really suppresses the next prompt, without spawning anything. @@ -1579,19 +1628,365 @@ for (const [name, opts, msg] of SEARCH_FAIL) { } // ═══════════════════════════════════════════════════════════════════════════ -// F. static guarantees of the three shipped hook files +// R. PreToolUse reminder — every-Nth cadence and the widened gate +// +// Restored after the v5.0.0 removal with two changes that ARE the fix: the +// 10-minute throttle became a counter over eligible searches, and the gate lost +// its "any doubt returns true" bias. Replayed over the real historical stream +// (2543 recorded search calls) the old gate let 229 through and fired 32; this +// one lets 1023 through and fires 204. +// ═══════════════════════════════════════════════════════════════════════════ +const counterFile = (p) => join(p, '.claude', 'semble', 'reminder.json'); +const counterOf = (p) => safeParse(existsSync(counterFile(p)) ? readFileSync(counterFile(p), 'utf8') : 'null'); + +/** One PreToolUse call. `tool_input` is passed through verbatim so Grep cases work too. */ +function reminder(proj, toolInput, extra) { + return runNode(REMINDER_SRC, JSON.stringify({ + session_id: 'R1', + cwd: proj, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_use_id: 'toolu_R1', + tool_input: toolInput, + ...(extra || {}), + })); +} +const remindOut = (p, command, extra) => safeParse(reminder(p, { command }, extra).stdout); +const ctxOf = (out) => ((out || {}).hookSpecificOutput || {}).additionalContext || ''; +/** Fired-or-not per command, in order. A helper, not branching inside a test body. */ +const burst = (p, commands) => commands.map((c) => ctxOf(remindOut(p, c)) !== ''); +/** N distinct eligible searches: an intent question, no flag, no path, no filename. */ +const eligibleRun = (n) => Array.from({ length: n }, (_, i) => `rg "retry backoff policy ${i + 1}"`); +const REMIND_TEXT = (p) => + 'semble: call mcp__semble_code__search FIRST for this — repo="' + p + + '", top_k=5, max_snippet_lines=10 — then open the hit at start_line. ' + + 'Keep grep for exact identifiers, literal strings and exhaustive -l/-c enumeration.'; +const REMIND_OK = (p) => ({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: REMIND_TEXT(p) }, +}); + +// R1 — the cadence itself: 15 eligible calls, N=5, exactly three injections. +{ + const p = freshProject({ state: READY_STATE() }); + const seen = burst(p, eligibleRun(15)); + check('R1.pattern', seen, + [false, false, false, false, true, false, false, false, false, true, false, false, false, false, true], + 'across 15 eligible searches the nudge lands on exactly the 5th, 10th and 15th'); + check('R1.count', seen.filter(Boolean).length, 3, 'three injections for fifteen eligible calls - one per five'); + check('R1.counter', counterOf(p), { count: 15 }, 'the counter file holds every eligible call, not just the firing ones'); +} + +// R2 — a corrupt counter resets to 0 instead of throwing or freezing the cadence. +{ + const p = freshProject({ state: READY_STATE() }); + burst(p, eligibleRun(3)); + writeFileSync(counterFile(p), '{not json,,,'); + const seen = burst(p, eligibleRun(5)); + check('R2.recovered', seen, [false, false, false, false, true], + 'a corrupt counter restarts the cadence from zero - the 5th call after it still fires'); + check('R2.counter', counterOf(p), { count: 5 }, 'and the file is rewritten with a clean integer'); +} +{ + const p = freshProject({ state: READY_STATE() }); + writeFileSync(counterFile(p), JSON.stringify({ count: -7 })); + check('R3.negative', burst(p, eligibleRun(5)), [false, false, false, false, true], + 'a negative count is rejected the same way a corrupt file is'); + check('R3.counterAfter', counterOf(p), { count: 5 }, 'and the counter is exactly the number of eligible calls since'); +} + +// R4 — ineligible calls must not advance the counter, or N would mean nothing. +{ + const p = freshProject({ state: READY_STATE() }); + burst(p, eligibleRun(4)); + const skipped = burst(p, ['rg -l "retry backoff policy X"', 'rg -c "handler"', 'grep -o "token" ']); + check('R4.skipped', skipped, [false, false, false], 'the three suppressed calls inject nothing'); + check('R4.counterUnmoved', counterOf(p), { count: 4 }, 'and leave the counter exactly where the 4 eligible calls left it'); + check('R4.fifth', burst(p, ['rg "retry backoff policy 5"']), [true], + 'so the next ELIGIBLE call is number five and fires'); +} + +// R5 — N is configuration, read off state.json. +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 1 }) }); + check('R5.every1', burst(p, eligibleRun(3)), [true, true, true], 'reminderEvery:1 nudges on every eligible search'); +} +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 3 }) }); + check('R5.every3', burst(p, eligibleRun(6)), [false, false, true, false, false, true], 'reminderEvery:3 nudges on the 3rd and the 6th'); +} +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 0 }) }); + check('R5.zeroIgnored', burst(p, eligibleRun(5)), [false, false, false, false, true], + 'reminderEvery:0 is not a valid cadence and falls back to the default 5'); +} +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 'many' }) }); + check('R5.stringIgnored', burst(p, eligibleRun(5)), [false, false, false, false, true], + 'a non-integer reminderEvery falls back to the default 5 as well'); +} + +// R6 — the suppressors that survived. reminderEvery:1 removes the cadence from +// the picture, so a `false` here is the GATE and nothing else. +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 1 }) }); + const suppressed = [ + 'rg -l "session store"', + 'rg --files-with-matches "session store"', + 'grep -c "session store" ', + 'rg --count "session store"', + 'grep -o "session store" ', + 'rg "src/store/session.ts"', + 'rg "session.ts"', + 'find . -name "session"', + 'bfs . -iname "session"', + 'rg "how does semble decide"', + ]; + check('R6.suppressed', burst(p, suppressed), suppressed.map(() => false), + 'enumeration flags, a path pattern, a filename-shaped pattern, a find-by-name and any mention of semble stay silent'); + check('R6.counterUntouched', existsSync(counterFile(p)), false, + 'and none of them ever created the counter file - suppression happens before the count'); +} + +// R7 — the shapes the old "any doubt returns true" gate suppressed and this one +// allows. These ten are the 4.5x eligibility gain, one case per reason. +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 1 }) }); + const allowed = [ + 'rg "handle.*event"', // regex metacharacter + 'rg "^export function"', // anchor + 'rg "(retry|backoff)"', // alternation + 'rg "queue\\[0\\]"', // bracket + 'rg -F "session expiry" ', // literal flag, no longer a suppressor + 'rg -w "dispatch" ', // word flag, no longer a suppressor + 'rg "id"', // two characters, no longer too short + 'rg "session store" | sort', // piped, no longer a suppressor + 'cd /tmp && rg "session expiry"', // search at a command boundary + 'ugrep "how sessions persist"', // a non-rg binary + ]; + check('R7.allowed', burst(p, allowed), allowed.map(() => true), + 'regexes, literal/word flags, short patterns and piped searches all reach the model now'); +} + +// R8 — state gating. Every row silent, and none of them counts. +{ + const rows = [ + ['missing', undefined], + ['corrupt', '{,}'], + ['disabledFlag', READY_STATE({ enabled: false })], + ['disabledPhase', READY_STATE({ phase: 'disabled' })], + ['error', READY_STATE({ phase: 'error' })], + ['prereq', READY_STATE({ phase: 'prereq_ready' })], + ['noMcp', READY_STATE({ completed: [] })], + ]; + const seen = rows.map(([, state]) => { + const p = freshProject(state === undefined ? {} : { state }); + return [ctxOf(remindOut(p, 'rg "session expiry"')), existsSync(counterFile(p))]; + }); + check('R8.silent', seen, rows.map(() => ['', false]), + 'no state, corrupt state, either disabled form, error, prereq_ready and a missing mcp checkpoint all inject nothing and count nothing'); +} +{ + // phase !== 'ready' still fires on purpose: semble builds its index lazily + // inside a tool call, so a ready-gate would deadlock. The wording says so. + const p = freshProject({ state: READY_STATE({ phase: 'awaiting_reload', reminderEvery: 1 }) }); + check('R8.cold', safeParse(reminder(p, { command: 'rg "session expiry"' }).stdout), { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: REMIND_TEXT(p) + ' Index not verified yet (phase=awaiting_reload); the first call builds it.', + }, + }, 'a registered-but-unverified project still gets the nudge, with the cold-index clause appended'); +} + +// R9 — hostile input. +{ + const bad = runNode(REMINDER_SRC, 'not json at all'); + const empty = runNode(REMINDER_SRC, ''); + check('R9.badStdin', [bad.status, safeParse(bad.stdout)], [0, {}], 'reminder: malformed stdin -> {} exit 0'); + check('R9.emptyStdin', [empty.status, safeParse(empty.stdout)], [0, {}], 'reminder: empty stdin -> {} exit 0'); +} +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 1 }) }); + const other = safeParse(reminder(p, { command: 'rg "session expiry"' }, { tool_name: 'Write' }).stdout); + const noInput = safeParse(reminder(p, undefined).stdout); + const noCommand = safeParse(reminder(p, { description: 'no command key' }).stdout); + check('R9.wrongTool', [other, noInput, noCommand], [{}, {}, {}], + 'a non-search tool, an absent tool_input and an absent command all produce the empty object'); + check('R9.exit', reminder(p, { command: 'rg "session expiry"' }).status, 0, 'and the firing path exits 0 too'); +} + +// R10 — the native Grep tool path. +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 1 }) }); + const g = safeParse(reminder(p, { pattern: 'how sessions persist' }, { tool_name: 'Grep' }).stdout); + check('R10.grepFires', g, REMIND_OK(p), 'the Grep tool gets the identical line'); + check('R10.grepMatcher', trec(p, 1).matcher, 'Grep', 'and the nudge records Grep as the matcher'); +} +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 1 }) }); + const modes = ['files_with_matches', 'count'].map((output_mode) => + safeParse(reminder(p, { pattern: 'how sessions persist', output_mode }, { tool_name: 'Grep' }).stdout)); + check('R10.grepEnumeration', modes, [{}, {}], 'Grep in files_with_matches or count mode is enumeration and stays silent'); +} + +// R11 — telemetry: the join key that makes delivery auditable, plus the cadence +// fields. The old hook recorded neither, which is why 0/18 measured nothing. +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 2 }) }); + reminder(p, { command: 'rg "session expiry"' }, { tool_use_id: 'toolu_AAA' }); + reminder(p, { command: 'rg "queue drain"' }, { tool_use_id: 'toolu_BBB' }); + check('R11.records', telemetry(p).map(dropTs), [ + { ev: 'gate', src: 'reminder', sid: 'R1', fired: false, why: 'cadence', phase: 'ready', enabled: true, tool_use_id: 'toolu_AAA', n: 1, every: 2 }, + { ev: 'gate', src: 'reminder', sid: 'R1', fired: true, why: 'ok', phase: 'ready', enabled: true, tool_use_id: 'toolu_BBB', n: 2, every: 2 }, + { ev: 'nudge', src: 'reminder', sid: 'R1', matcher: 'Bash', agent: 'main', tool_use_id: 'toolu_BBB', n: 2, every: 2, q: 'rg "queue drain"' }, + ], 'every record carries the PreToolUse tool_use_id, and the nudge names the exact call it was attached to'); +} +{ + const p = freshProject({ state: READY_STATE({ reminderEvery: 1 }) }); + reminder(p, { command: 'rg "session expiry"' }, { agent_id: 'a1c5a07', agent_type: 'general-purpose' }); + check('R11.subAttribution', trec(p, 1).agent, 'sub', 'a PreToolUse arriving from inside a subagent is attributed to sub, not main'); +} +{ + const p = freshProject({ state: READY_STATE() }); + reminder(p, { command: 'rg "session expiry"' }, { tool_use_id: 12345 }); + check('R11.nonStringTuid', trec(p, 0).tool_use_id, '', 'a non-string tool_use_id becomes the empty string, never undefined'); +} + +// R12 — static guarantees specific to the two restored files. +{ + const remSrc = readFileSync(REMINDER_SRC, 'utf8'); + const subSrc = readFileSync(join(ASSETS, 'semble-subagent.mjs'), 'utf8'); + const both = [remSrc, subSrc]; + check('R12.shebang', both.map((s) => s.startsWith('#!/usr/bin/env node')), [true, true], 'both restored hooks carry a node shebang'); + check('R12.nodeCheck', + [REMINDER_SRC, join(ASSETS, 'semble-subagent.mjs')].map((f) => spawnSync(process.execPath, ['--check', f]).status), + [0, 0], 'node --check passes on both'); + check('R12.advisory', both.map((s) => s.includes('permissionDecision') || s.includes('updatedInput')), [false, false], + 'neither can decide or rewrite anything - the word does not appear in either file'); + check('R12.noProcess', both.map((s) => s.includes('child_process') || s.includes('spawn(')), [false, false], + 'and neither spawns a process'); + check('R12.retiredMarker', both.map((s) => s.includes('.reminder-ts')), [false, false], + 'the retired throttle marker path is gone from both files'); + check('R12.retiredName', remSrc.includes('semble-explore.mjs'), false, + 'and the reminder never names the retired hook file'); + check('R12.explorePastTense', subSrc.includes('semble-explore.mjs, was pinned'), true, + 'the subagent hook names semble-explore.mjs exactly once, as a retired predecessor'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// S. SubagentStart — one line into EVERY spawned subagent +// +// The deleted semble-explore.mjs matched agent_type "Explore" alone. This one +// registers with no matcher at all, which on CC 2.1.226 means every agent type +// (verified live: a matcher-less SubagentStart entry fired for "Explore"). +// No throttle and no counter: a fresh subagent remembers no earlier injection. +// ═══════════════════════════════════════════════════════════════════════════ +function subagent(proj, agentType, extra) { + return runNode(SUBAGENT_SRC, JSON.stringify({ + session_id: 'S1', + cwd: proj, + hook_event_name: 'SubagentStart', + prompt_id: 'p1', + agent_id: 'a1c5a0755e38bab4a', + agent_type: agentType, + ...(extra || {}), + })); +} +const SUB_TEXT = (p) => + 'semble: mcp__semble_code__search is already available to you — no ToolSearch needed. ' + + 'Start any "where/how/why does X work" question with ONE call: repo="' + p + + '", top_k=5, max_snippet_lines=10, then open the hit at start_line. ' + + 'Use rg only for exact identifiers, literal strings and exhaustive enumeration.'; +const SUB_OK = (p) => ({ + hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext: SUB_TEXT(p) }, +}); + +// S1 — every agent type, not just Explore. +{ + const p = freshProject({ state: READY_STATE() }); + const types = ['Explore', 'Plan', 'general-purpose', 'docs-writer', 'statusline-setup', 'brewcode:skill-creator']; + check('S1.allTypes', types.map((t) => safeParse(subagent(p, t).stdout)), types.map(() => SUB_OK(p)), + 'six different agent types each receive the identical directive - the Explore-only matcher is gone'); + check('S1.exact', safeParse(subagent(p, 'Explore').stdout), SUB_OK(p), 'and the injected string is exactly this'); +} + +// S2 — no throttle: repeated spawns each get their own line. +{ + const p = freshProject({ state: READY_STATE() }); + const runs = [1, 2, 3].map(() => ctxOf(safeParse(subagent(p, 'general-purpose').stdout))); + check('S2.noThrottle', runs, [SUB_TEXT(p), SUB_TEXT(p), SUB_TEXT(p)], + 'three consecutive spawns all fire - there is nothing for a fresh subagent to remember'); + check('S2.noCounter', existsSync(counterFile(p)), false, 'and the subagent hook keeps no counter of its own'); +} + +// S3 — telemetry carries agent_type and agent_id, which makes per-agent-type +// conversion computable and per-injection delivery auditable. +{ + const p = freshProject({ state: READY_STATE() }); + subagent(p, 'docs-writer', { agent_id: 'a99' }); + check('S3.records', telemetry(p).map(dropTs), [ + { ev: 'gate', src: 'subagent', sid: 'S1', fired: true, why: 'ok', phase: 'ready', enabled: true, agent_type: 'docs-writer', agent_id: 'a99' }, + { ev: 'nudge', src: 'subagent', sid: 'S1', matcher: 'SubagentStart', agent: 'sub', agent_type: 'docs-writer', agent_id: 'a99', q: '' }, + ], 'one gate record and one nudge record, both naming the agent type and the agent id'); +} + +// S4 — state gating, same seven rows as the reminder. +{ + const rows = [ + ['missing', undefined], + ['corrupt', '{,}'], + ['disabledFlag', READY_STATE({ enabled: false })], + ['disabledPhase', READY_STATE({ phase: 'disabled' })], + ['error', READY_STATE({ phase: 'error' })], + ['prereq', READY_STATE({ phase: 'prereq_ready' })], + ['noMcp', READY_STATE({ completed: [] })], + ]; + const seen = rows.map(([, state]) => { + const p = freshProject(state === undefined ? {} : { state }); + const r = subagent(p, 'Explore'); + return [r.status, safeParse(r.stdout)]; + }); + check('S4.silent', seen, rows.map(() => [0, {}]), + 'no state, corrupt state, either disabled form, error, prereq_ready and a missing mcp checkpoint all inject nothing'); +} +{ + const p = freshProject({ state: READY_STATE({ phase: 'verifying' }) }); + check('S4.notReadyStillFires', safeParse(subagent(p, 'Explore').stdout), SUB_OK(p), + 'a registered-but-unverified project still gets the line - the index is built inside the first call'); +} + +// S5 — hostile input. Without an agent_type there is nothing to talk to, and +// cwd is a guess, so the hook writes nothing at all. +{ + const bad = runNode(SUBAGENT_SRC, 'not json at all'); + const empty = runNode(SUBAGENT_SRC, ''); + check('S5.badStdin', [bad.status, safeParse(bad.stdout)], [0, {}], 'subagent: malformed stdin -> {} exit 0'); + check('S5.emptyStdin', [empty.status, safeParse(empty.stdout)], [0, {}], 'subagent: empty stdin -> {} exit 0'); +} +{ + const p = freshProject({ state: READY_STATE() }); + const r = runNode(SUBAGENT_SRC, JSON.stringify({ session_id: 'S1', cwd: p, hook_event_name: 'SubagentStart' })); + check('S5.noAgentType', [r.status, safeParse(r.stdout), existsSync(telemetryFile(p))], [0, {}, false], + 'a payload with no agent_type injects nothing and writes no telemetry'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// F. static guarantees of the five shipped hook files // ═══════════════════════════════════════════════════════════════════════════ { 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 + const reminderSrc = readFileSync(REMINDER_SRC, 'utf8'); + const subagentSrc = readFileSync(SUBAGENT_SRC, 'utf8'); + const src = [sessionSrc, prefetchSrc, statsSrc, reminderSrc, subagentSrc]; + // The passive hooks 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; + // The two advisory hooks are back on that same rule: they emit text, they never + // spawn - a PreToolUse hook that forks sits in front of every Bash call. + const passive = [sessionSrc, statsSrc, reminderSrc, subagentSrc].join('\n'); 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'); + 'no passive hook - session, stats, reminder, subagent - imports child_process'); + check('F2.passiveNoSpawn', passive.includes('spawn('), false, 'and none of them 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'); @@ -1601,13 +1996,16 @@ for (const [name, opts, msg] of SEARCH_FAIL) { [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, PREFETCH_SRC, STATS_SRC] + const checks = [SESSION_SRC, PREFETCH_SRC, STATS_SRC, REMINDER_SRC, SUBAGENT_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.neverDecides', src.map((x) => x.includes('permissionDecision:')), [false, false, false], + check('F8.nodeCheck', checks, [0, 0, 0, 0, 0], 'node --check passes on all five hook files'); + check('F9.neverDecides', src.map((x) => x.includes('permissionDecision:')), + [false, false, 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'); + check('F10.shipped', [existsSync(REMINDER_SRC), existsSync(SUBAGENT_SRC)], [true, true], + 'both advisory hooks ship again - the channel they use was verified to deliver'); + check('F10.retiredGone', existsSync(join(ASSETS, 'semble-explore.mjs')), false, + 'and semble-explore.mjs is gone from assets/ for good - superseded, only ever cleaned up'); } // ═══════════════════════════════════════════════════════════════════════════ @@ -1731,11 +2129,14 @@ const FOREIGN_HOOK = { type: 'command', command: 'node', args: ['/opt/foreign/gu const pre = (s.hooks || {}).PreToolUse || []; 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.currentRow', pre[1] || null, + { matcher: 'Bash|Grep', hooks: [{ type: 'command', command: 'node', args: [semblePaths(p).reminder], timeout: 5 }] }, + 'and the current reminder row is appended beside it, on its own matcher'); 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', 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'); + check('H5.counts', wantCounts(p, s), [1, 1, 1, 1, 1, 1], 'exactly one current entry per want row'); + check('H5.preToolUseSize', pre.length, 2, + 'PreToolUse holds exactly the repaired foreign entry and the one current reminder row'); } // H6 — unmerge: a hand-merged foreign hook in a semble entry survives @@ -1802,7 +2203,7 @@ function driftTimeouts(p, value) { check('I1.exit', r.status, 0, 'a re-run over a drifted install exits 0'); const t = readSettings(p); check('I1.timeouts', Object.values(t.hooks).flat().flatMap((e) => e.hooks.map((h) => h.timeout)), - [5, 5, 5, 5], 'every drifted timeout:5000 is rewritten to the contract value 5 seconds'); + [5, 5, 5, 5, 5, 5], 'every drifted timeout:5000 is rewritten to the contract value 5 seconds'); check('I1.bytes', readRaw(settingsPath(p)), clean, 'the repaired file is byte-identical to a clean install - repair, not append'); const j = safeParse(r.stdout); @@ -1825,7 +2226,7 @@ function driftTimeouts(p, value) { const before = safeParse(guidance(p, ['status', '--json']).stdout); check('I2.statusBefore', [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'); + [0, WANT_N, WANT_N - 1, 1], 'status calls the real broken shape 0/6 wired, 5 drifted, 1 missing'); const r = guidance(p, ['install', '--part', 'hooks', '--json']); check('I2.exit', r.status, 0, 'the repair run exits 0'); // Key ORDER differs by construction here and only here: UserPromptSubmit was @@ -1843,7 +2244,7 @@ function driftTimeouts(p, value) { const after = safeParse(guidance(p, ['status', '--json']).stdout); check('I2.statusAfter', [after.hooks.wiredCount, after.hooks.driftedCount, after.hooks.missingCount, after.hooks.drift.length], - [WANT_N, 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 6/6 wired with an empty drift list'); } // I3 — a foreign hook sharing a drifted entry survives merge AND unmerge @@ -1887,7 +2288,7 @@ function driftTimeouts(p, value) { 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', wantCounts(p, t), [1, 1, 1, 1], 'exactly one entry per want row survives the de-duplication'); + check('I4.counts', wantCounts(p, t), [1, 1, 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'); @@ -1906,9 +2307,9 @@ function driftTimeouts(p, value) { check('I5.clean', [clean.hooks.wiredCount, clean.hooks.driftedCount, clean.hooks.missingCount, clean.hooks.duplicateCount, clean.hooks.drift.length], - [WANT_N, 0, 0, 0, 0], 'a correct install reports 4 wired and no drift'); + [WANT_N, 0, 0, 0, 0], 'a correct install reports 6 wired and no drift'); check('I5.cleanEntries', clean.hooks.entries.map((e) => e.state), - ['wired', 'wired', 'wired', 'wired'], + ['wired', 'wired', 'wired', 'wired', 'wired', 'wired'], 'every want row is reported wired individually'); const s = readSettings(p); s.hooks.UserPromptSubmit[0].hooks[0].timeout = 5000; @@ -1923,9 +2324,9 @@ function driftTimeouts(p, value) { 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)'), - true, 'the human line spells the drift out instead of printing 4/4'); + ['wired', 'drifted', 'wired', 'wired', 'wired', 'wired'], 'only the drifted row changes state'); + check('I5.human', guidance(p, ['status']).stdout.includes('hooks 5/6 wired (1 drifted - re-run install to repair)'), + true, 'the human line spells the drift out instead of printing 6/6'); } // I6 — field-level, not stringify-level: key order is not drift, a missing field is @@ -1959,7 +2360,11 @@ function driftTimeouts(p, value) { // I7 — the .gitignore outcome is verified, and the absent case is decided out loud { - const GI = '.claude/semble/.prefetch-ts'; + // The DIRECTORY, not the throttle marker. `.claude/semble/` also holds state.json and + // telemetry.jsonl — 1.25 MB / 5778 lines on this workspace, carrying verbatim shell + // commands and distilled prompt text. A per-marker line left all of that untracked and + // `git add .`-able in any repo that does not already ignore `.claude/`. + const GI = '.claude/semble/'; const p1 = freshProject({}); // .gitignore exists, no line writeFileSync(join(p1, '.gitignore'), 'node_modules/\n'); const j1 = safeParse(guidance(p1, ['install', '--part', 'hooks', '--json']).stdout); @@ -2029,23 +2434,100 @@ function driftTimeouts(p, value) { 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'); + + // I7b — the 5.1.0 line is superseded, not kept beside the new one. An install that + // wrote `.claude/semble/.prefetch-ts` must converge on the directory line ALONE; + // leaving both would park a narrower duplicate in the user's repo forever. + const p5 = freshProject({}); + writeFileSync(join(p5, '.gitignore'), + 'node_modules/\n\n# brewcode:semble\n.claude/semble/.prefetch-ts\n'); + guidance(p5, ['install', '--part', 'hooks', '--json']); + check('I7b.supersede', readRaw(join(p5, '.gitignore')), + `node_modules/\n\n# brewcode:semble\n${GI}\n`, + 'the superseded .prefetch-ts line is dropped and the directory line takes its place - never both'); + guidance(p5, ['remove', '--part', 'hooks', '--json']); + check('I7b.remove', readRaw(join(p5, '.gitignore')), 'node_modules/\n', + 'remove strips exactly what install now writes, and nothing the user put there'); + const cycle5 = []; + for (let i = 0; i < 2; i++) { + guidance(p5, ['install', '--part', 'hooks', '--json']); + cycle5.push(readRaw(join(p5, '.gitignore'))); + guidance(p5, ['remove', '--part', 'hooks', '--json']); + cycle5.push(readRaw(join(p5, '.gitignore'))); + } + check('I7b.cycleStable', cycle5, + [`node_modules/\n\n# brewcode:semble\n${GI}\n`, 'node_modules/\n', + `node_modules/\n\n# brewcode:semble\n${GI}\n`, 'node_modules/\n'], + 'and two install/remove cycles over the migrated file produce exactly two byte-identical states'); +} + +// I8 — a corrupt hook asset never lands installed, and never lands wired. +// `node --check` used to run AFTER the copy, so a broken asset was reported failed and +// then left on disk AND registered in settings.json: every prompt of the user's next +// session ran a hook Claude Code cannot parse. The check now runs on a staged copy. +{ + const STATS_ASSET = join(SKILL_COPY, 'assets', 'semble-stats.mjs'); + const GOOD = readFileSync(STATS_ASSET, 'utf8'); + const BROKEN = 'export function (\n'; // a syntax error in ESM and CJS alike + + const p = freshProject({}); + writeFileSync(STATS_ASSET, BROKEN); + const j = safeParse(guidance(p, ['install', '--part', 'hooks', '--json']).stdout); + const { session, prefetch: pre, stats } = semblePaths(p); + check('I8.failed', j.failed.filter((l) => l.startsWith('hooks:')), + [`hooks: cannot install ${stats}`], 'the broken asset is reported failed exactly once, by full path'); + check('I8.absent', existsSync(stats), false, + 'and it is NOT on disk: the check runs on the staged copy, before the move into place'); + check('I8.noStageLeft', readdirSync(hooksDirOf(p)).filter((f) => f.includes('semble-staging')), [], + 'the rejected staging file is deleted, not left as litter beside the real hooks'); + check('I8.siblings', [existsSync(session), existsSync(pre)], [true, true], + 'the two valid hooks still install — one bad asset is not a whole-install failure'); + check('I8.unwired', JSON.stringify(readSettings(p)).includes('semble-stats.mjs'), false, + 'and NOTHING in settings.json points at the file that is not there'); + check('I8.siblingsWired', Object.keys(readSettings(p).hooks).sort(), + ['PreToolUse', 'SessionStart', 'SubagentStart', 'UserPromptSubmit'], + 'only the events whose hook file actually exists are wired - the two stats events are' + + ' the only ones missing, because semble-stats.mjs is the broken asset'); + + // A broken asset arriving OVER a good install: the working copy is kept byte for + // byte and stays wired. Rolling a user back to no hook is worse than no upgrade. + const q = freshProject({}); + writeFileSync(STATS_ASSET, GOOD); + guidance(q, ['install', '--part', 'hooks', '--json']); + const beforeFile = readRaw(semblePaths(q).stats); + const beforeSettings = readRaw(settingsPath(q)); + writeFileSync(STATS_ASSET, BROKEN); + const j2 = safeParse(guidance(q, ['install', '--part', 'hooks', '--json']).stdout); + check('I8.upgradeFailed', j2.failed.filter((l) => l.startsWith('hooks:')), + [`hooks: cannot install ${semblePaths(q).stats}`], 'the failed upgrade is reported the same way'); + check('I8.upgradeKeepsFile', readRaw(semblePaths(q).stats), beforeFile, + 'the previous, valid copy is left byte-identical'); + check('I8.upgradeKeepsWiring', readRaw(settingsPath(q)), beforeSettings, + 'and stays wired — a hook that is on disk and parses is not unwired by a bad release'); + writeFileSync(STATS_ASSET, GOOD); } // ═══════════════════════════════════════════════════════════════════════════ // 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. +// The single most breakable part of the installer. A user upgrading from 4.x has +// a settings.json full of rows in shapes the want table no longer uses, and an +// orphan .mjs on disk. `wanted` is built from the want table as (event, matcher, +// path) TRIPLES while ownership is decided by SG_MARKS, so a retired basename or +// a retired REGISTRATION 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. +// +// The v1 rows do not merely disappear here: PreToolUse/Bash + PreToolUse/Grep +// collapse into one PreToolUse/`Bash|Grep` row, and SubagentStart/`Explore` +// becomes a matcher-less SubagentStart row on semble-subagent.mjs. Replaced, +// never left standing beside the new one, never silently kept. // ═══════════════════════════════════════════════════════════════════════════ { const p = freshProject({}); const d = hooksDirOf(p); mkdirSync(d, { recursive: true }); - const { session, stats, reminder: rem, explore: exp } = semblePaths(p); + const { session, stats, reminder: rem, subagent: sub, 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'; @@ -2069,39 +2551,46 @@ function driftTimeouts(p, value) { 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.retiredSeen', before.hooks.retired, ['semble-explore.mjs'], + 'status names the ONE retired file it can see on disk; semble-reminder.mjs is live again' + + ' and is reported as a live hook, not as a leftover'); 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'); + 'only SessionStart carries over: prefetch did not exist, both stats rows sit on the' + + ' pre-5.0.0 matcher, and the two v1 reminder rows and the Explore row are all on' + + ' matchers the want table does not use'); 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'); + + ' v1 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.counts', wantCounts(p, s), [1, 1, 1, 1, 1, 1], 'every current want row is wired exactly once'); + check('M2.noRetiredPath', JSON.stringify(s).includes('semble-explore.mjs'), false, + 'the retired basename survives nowhere in settings.json - the whole point of the migration'); + check('M2.subagentStartRow', s.hooks.SubagentStart, + [{ hooks: [{ type: 'command', command: 'node', args: [sub], timeout: 5 }] }], + 'the SubagentStart/Explore row is REPLACED by the matcher-less subagent row - one row,' + + ' not the old one plus the new one, and not an empty [] husk'); + check('M2.preToolUseRows', s.hooks.PreToolUse, + [{ matcher: 'Write', hooks: [FOREIGN_HOOK] }, + { matcher: 'Bash|Grep', hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5 }] }], + 'the two v1 reminder rows collapse into ONE Bash|Grep row and the foreign Write entry is' + + ' untouched - the purge is ours-only, per entry'); + check('M2.filesGone', existsSync(exp), false, + 'the orphan semble-explore.mjs is deleted from .claude/hooks'); + check('M2.filesKept', [existsSync(session), existsSync(semblePaths(p).prefetch), existsSync(stats), + existsSync(rem), existsSync(sub)], [true, true, true, true, true], + 'and all five live hooks are on disk - the v1 reminder stub was overwritten by the shipped asset'); 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'); + check('M2.reported', [changed.includes('hooks: removed retired ' + exp), + changed.includes('hooks: installed ' + rem)], [true, true], + 'install reports the deletion AND the reinstatement by full path, never silently'); const a = safeParse(guidance(p, ['status', '--json']).stdout); check('M3.after', [a.hooks.wiredCount, a.hooks.wantCount, a.hooks.driftedCount, @@ -2109,11 +2598,12 @@ function driftTimeouts(p, value) { [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'); + 'the v1 throttle marker goes even though the reminder hook is back: the restored hook counts in ' + + '.claude/semble/reminder.json, and .reminder-ts has no writer left, so a file kept here would ' + + 'be an untracked diff for nothing'); 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,' + 'node_modules/\n\n# brewcode:semble\n.claude/semble/\n', + 'the retired .reminder-ts line is DROPPED and the directory line added - never both,' + ' and never an orphan ignore line for a file nothing writes any more'); const snap = readRaw(settingsPath(p)); @@ -2126,13 +2616,13 @@ function driftTimeouts(p, value) { { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); - const { reminder: rem } = semblePaths(p); - writeFileSync(rem, '// orphan\n'); + const { explore: exp } = semblePaths(p); + writeFileSync(exp, '// orphan\n'); const a = safeParse(guidance(p, ['status', '--json']).stdout); - check('M5.seen', [a.hooks.retired, a.hooks.wiredCount], [['semble-reminder.mjs'], WANT_N], + check('M5.seen', [a.hooks.retired, a.hooks.wiredCount], [['semble-explore.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), + check('M5.swept', [existsSync(exp), safeParse(guidance(p, ['status', '--json']).stdout).hooks.retired], [false, []], 'and install sweeps it - file cleanup does not depend on a matching settings row'); } @@ -2141,11 +2631,10 @@ function driftTimeouts(p, value) { { const p = freshProject({}); guidance(p, ['install', '--part', 'all', '--json']); - const { session, prefetch: pre, stats, reminder: rem, explore: exp } = semblePaths(p); + const { session, prefetch: pre, stats, reminder: rem, subagent: sub, explore: exp } = semblePaths(p); const s = readSettings(p); - s.hooks.PreToolUse = [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5 }] }]; + s.hooks.SubagentStart = [{ matcher: 'Explore', hooks: [{ type: 'command', command: 'node', args: [exp], 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']) { @@ -2155,8 +2644,8 @@ function driftTimeouts(p, value) { 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.files', [session, pre, stats, rem, sub, exp].map((f) => existsSync(f)), + [false, 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 ' @@ -2218,15 +2707,14 @@ function driftTimeouts(p, value) { 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 { 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'); + // pre-5.0.0 install: the one retired advisory hook still on disk + writeFileSync(exp, '// legacy advisory hook, superseded by semble-subagent.mjs\n'); // simulate the release: bump ONLY the baked stamp of the shipped template const tpl = join(SKILL_COPY, 'assets', 'semble-first.md.template'); @@ -2241,8 +2729,8 @@ function driftTimeouts(p, value) { 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.retiredGone', existsSync(exp), false, + 'the same run deletes the retired hook'); 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'); @@ -2315,8 +2803,8 @@ function driftTimeouts(p, value) { 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], + 'semble-reminder.mjs', 'semble-subagent.mjs', 'semble-explore.mjs'].map(named), + [true, 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 546e319..86ae2f8 100644 --- a/brewcode/skills/semble-setup/tests/suite-integration.mjs +++ b/brewcode/skills/semble-setup/tests/suite-integration.mjs @@ -447,18 +447,21 @@ check('guidance: keys', keysOf(R.guidance), '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: hooks sub-keys', keysOf(R.guidance.hooks), + ['prefetch', 'reminder', 'session', 'stats', 'subagent'], + 'hooks collapses to five file-presence strings, one per LIVE hook'); check('guidance: flattened states', [R.guidance.rule, R.guidance.claudeMd, R.guidance.hooks.session, R.guidance.hooks.prefetch, - R.guidance.hooks.stats], + R.guidance.hooks.stats, R.guidance.hooks.reminder, R.guidance.hooks.subagent], [rawGuid.rule.state, rawGuid.claudeMd.state, - rawGuid.hooks.session.file, rawGuid.hooks.prefetch.file, rawGuid.hooks.stats.file], + rawGuid.hooks.session.file, rawGuid.hooks.prefetch.file, rawGuid.hooks.stats.file, + rawGuid.hooks.reminder.file, rawGuid.hooks.subagent.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.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'); + R.guidance.hooks.session, R.guidance.hooks.prefetch, R.guidance.hooks.stats, + R.guidance.hooks.reminder, R.guidance.hooks.subagent], +['managed', 'present', 'present', 'present', 'present', 'present', 'present'], +'after a real install: managed rule, marker block in CLAUDE.md, all five 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', @@ -467,12 +470,12 @@ check('guidance: settingsFile + staleEntries + permissionsWired', 'project settings path, no stale entries, both tool permissions wired'); 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'); + [6, 6, 6, 6], + 'all six entries (SessionStart + UserPromptSubmit/prefetch + PostToolUse/stats' + + ' + PostToolUseFailure/stats + PreToolUse/reminder + SubagentStart/subagent) are registered'); // Partial wiring: strip the UserPromptSubmit entry only. Independent truth is -// 3 of 4 registered entries, which is also what semble-guidance.sh reports. +// 5 of 6 registered entries, which is also what semble-guidance.sh reports. const P2 = join(WORLD, 'p-partial'); const p2Env = { SEMBLE_PROJECT_ROOT: P2 }; write(join(P2, 'src/app.py'), `def main():\n return 1\n# ${PAD}\n`); @@ -491,16 +494,17 @@ check('partial: exactly one prefetch entry existed to remove', 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', +check('partial: sibling counts 5 of 6 entries', [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'); + rawGuid2.hooks.stats.wired, rawGuid2.hooks.reminder.wired, rawGuid2.hooks.subagent.wired, + rawGuid2.hooks.wiredCount, rawGuid2.hooks.wantCount], + [true, false, true, true, true, 5, 6], + 'semble-guidance.sh counts registered entries: SessionStart + both stats rows + reminder' + + ' + subagent = 5, prefetch not wired'); check('partial: guidance.wiredCount agrees with the sibling', - [R2.guidance.wiredCount, rawGuid2.hooks.wiredCount, R2.guidance.wantCount], [3, 3, 4], + [R2.guidance.wiredCount, rawGuid2.hooks.wiredCount, R2.guidance.wantCount], [5, 5, 6], 'status reads guidance.hooks.wiredCount instead of re-deriving it, so a missing prefetch' - + ' row reports 3/4 on both sides'); + + ' row reports 5/6 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'); @@ -523,11 +527,12 @@ write(join(P3, 'CLAUDE.md'), `# CLAUDE.md\n\n${PAD}\n`); const rawGuid3 = safeParse(run(GUIDANCE_SH, ['status', '--json'], p3Env).stdout); 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'); + 'nothing installed => 0/6 on both sides'); check('bare: guidance states', [R3.guidance.rule, R3.guidance.claudeMd, R3.guidance.hooks.session, R3.guidance.hooks.prefetch, R3.guidance.hooks.stats, + R3.guidance.hooks.reminder, R3.guidance.hooks.subagent, R3.guidance.permissionsWired], -['absent', 'absent', 'missing', 'missing', 'missing', false], +['absent', 'absent', 'missing', 'missing', 'missing', 'missing', 'missing', false], 'an untouched project reports everything absent'); check('bare: no section degraded to an error placeholder', SECTIONS.filter((k) => hasError(R3[k])), [], @@ -720,8 +725,19 @@ 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-prefetch.mjs')), - existsSync(join(LIFE, '.claude/hooks/semble-stats.mjs'))], - [true, true, true, true], 'rule + all three hook assets landed in the project'); + existsSync(join(LIFE, '.claude/hooks/semble-stats.mjs')), + existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs')), + existsSync(join(LIFE, '.claude/hooks/semble-subagent.mjs'))], + [true, true, true, true, true, true], 'rule + all five hook assets landed in the project'); +check('lifecycle: hooks dir holds exactly the five current assets', + readdirSync(join(LIFE, '.claude/hooks')).sort(), + ['semble-prefetch.mjs', 'semble-reminder.mjs', 'semble-session.mjs', + 'semble-stats.mjs', 'semble-subagent.mjs'], + 'install --part all writes the whole want-table file set and leaves no retired v1 hook behind'); +check('lifecycle: install wired the full want table', + (() => { const h = safeParse(run(GUIDANCE_SH, ['status', '--json'], lifeEnv).stdout).hooks; + return [h.wiredCount, h.wantCount]; })(), + [6, 6], 'six settings entries over five files: stats spans PostToolUse + PostToolUseFailure'); // resume: a new session observes the live server, verifies, goes ready run(STATE_SH, ['phase', 'verifying'], lifeEnv); @@ -773,8 +789,14 @@ check('lifecycle: guidance + state removed', existsSync(join(LIFE, '.claude/rules/semble-first.md')), existsSync(join(LIFE, '.claude/hooks/semble-session.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'); + existsSync(join(LIFE, '.claude/hooks/semble-stats.mjs')), + existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs')), + existsSync(join(LIFE, '.claude/hooks/semble-subagent.mjs'))], + [false, false, false, false, false, false, false], + 'state dir, rule and all five hooks are gone'); +check('lifecycle: no semble hook file survives the removal', + readdirSync(join(LIFE, '.claude/hooks')).filter((f) => f.startsWith('semble-')).sort(), [], + 'remove integration sweeps the hooks dir by name, not by the three-file v1 list'); check('lifecycle: ~/.claude.json byte-identical', sha(LIFE_CJ), claudeJsonBefore, 'remove integration leaves the MCP registration untouched'); check('lifecycle: claude mcp remove was never called', diff --git a/brewcode/skills/semble-setup/tests/suite-status.mjs b/brewcode/skills/semble-setup/tests/suite-status.mjs index 52b2a30..b6714ca 100755 --- a/brewcode/skills/semble-setup/tests/suite-status.mjs +++ b/brewcode/skills/semble-setup/tests/suite-status.mjs @@ -466,7 +466,8 @@ cat "\${SEMBLE_STUB_GUIDANCE:?}" hooks: { settingsFile: join(PROJECT, '.claude', 'settings.json'), session: { file: 'present' }, prefetch: { file: 'present' }, stats: { file: 'present' }, - retired: [], wiredCount: 4, wantCount: 4, staleEntries: 0, + reminder: { file: 'present' }, subagent: { file: 'present' }, + retired: [], wiredCount: 6, wantCount: 6, staleEntries: 0, }, }; const withHooks = (patch) => { @@ -491,21 +492,21 @@ cat "\${SEMBLE_STUB_GUIDANCE:?}" // 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, + retired: ['semble-explore.mjs'], + prefetch: { file: 'missing' }, wiredCount: 1, wantCount: 6, staleEntries: 5, })); check('34-v1-verdict', v1.verdict, 'partial', - 'a v1-shaped repo (retired hooks + stale entries + 1/4 wired) must never report ready'); + 'a v1-shaped repo (retired hook + stale entries + 1/6 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', + 'retired hooks on disk: semble-explore.mjs; 5 stale settings entries; hooks wired 1/6', 'the reason must name all three defects, so the user knows what install will repair'); - const retiredOnly = guidRun(withHooks({ retired: ['semble-reminder.mjs'] })); + const retiredOnly = guidRun(withHooks({ retired: ['semble-explore.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', + check('35-retired-reason', retiredOnly.__reason, 'retired hooks on disk: semble-explore.mjs', 'one retired file, one clause'); const staleOnly = guidRun(withHooks({ staleEntries: 1 })); @@ -514,10 +515,10 @@ cat "\${SEMBLE_STUB_GUIDANCE:?}" check('36-stale-reason', staleOnly.__reason, '1 stale settings entry', 'the singular clause is singular'); - const halfWired = guidRun(withHooks({ wiredCount: 3, wantCount: 4 })); + const halfWired = guidRun(withHooks({ wiredCount: 5, wantCount: 6 })); 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'); + check('37-wiring-reason', halfWired.__reason, 'hooks wired 5/6', '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. diff --git a/brewcode/skills/semble-setup/tests/suite-telemetry.mjs b/brewcode/skills/semble-setup/tests/suite-telemetry.mjs index b5426af..65b4b2f 100644 --- a/brewcode/skills/semble-setup/tests/suite-telemetry.mjs +++ b/brewcode/skills/semble-setup/tests/suite-telemetry.mjs @@ -349,6 +349,15 @@ function fireAndLast(p, over) { // sB: nudge(sub) @00 -> no call ever => NOT converted // sC: no nudge -> semble call @05 => unprompted // plus 2 skipped gates and 4 search records. +// Per-source conversion slots. Every live channel is reported even at zero, so a +// channel that stopped firing reads as 0 instead of vanishing from the report. +const zeroSlot = (measure) => ({ + nudges: 0, sessions: 0, converted: 0, sessionPct: null, callsAfter: 0, measure, +}); +const SLOT_SESSION = zeroSlot('semble-call-after'); +const SLOT_SUBAGENT = { ...zeroSlot('semble-call-after'), agentTypes: {} }; +const SLOT_PREFETCH = zeroSlot('injected-path-opened'); + 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' }, @@ -383,9 +392,26 @@ const FIXTURE = [ 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 }, + { + sessionsWithNudge: 2, sessionsConverted: 1, conversionPct: 50, callsAfterNudge: 2, callsWithoutNudge: 1, + bySource: { + session: SLOT_SESSION, + reminder: { + nudges: 1, sessions: 1, converted: 1, sessionPct: 100, callsAfter: 2, + measure: 'semble-call-after', toolUses: 0, + }, + subagent: SLOT_SUBAGENT, + prefetch: SLOT_PREFETCH, + explore: { + nudges: 1, sessions: 1, converted: 0, sessionPct: 0, callsAfter: 0, + measure: 'semble-call-after', + }, + }, + }, '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'); + + ' preceding nudge => 1 of 2 sessions convert, 2 calls follow a nudge, 1 is unprompted;' + + ' per source the reminder channel converts 1/1 and the retired explore channel 0/1,' + + ' neither inflating the other'); check('6.window', R.window, { sid: null, last: null }, 'the default window is every record'); const human = reader(p, ['--section', 'telemetry']); @@ -404,8 +430,20 @@ const FIXTURE = [ 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'); + { + sessionsWithNudge: 1, sessionsConverted: 1, conversionPct: 100, callsAfterNudge: 2, callsWithoutNudge: 0, + bySource: { + session: SLOT_SESSION, + reminder: { + nudges: 1, sessions: 1, converted: 1, sessionPct: 100, callsAfter: 2, + measure: 'semble-call-after', toolUses: 0, + }, + subagent: SLOT_SUBAGENT, + prefetch: SLOT_PREFETCH, + }, + }, + 'inside sA alone the nudge converted, both calls followed it; the sB explore nudge is' + + ' outside the window so that channel is absent, not zero'); check('6.sid.unknown-session', readerJson(p, ['--sid', 'nope']).json.records, 0, 'an unknown sid is an empty window, not an error'); @@ -474,7 +512,7 @@ const STATS_MATCHER = 'mcp__semble_code__search|mcp__semble_code__find_related|B // 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; +const WANT_N = 6; function guidance(p, args) { const r = spawnSync('bash', [GUIDANCE, ...args], { cwd: p, encoding: 'utf8' }); @@ -513,7 +551,7 @@ function settingsOf(p) { 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'); + [WANT_N, WANT_N, 0, 0, 0, 0], 'all six 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', @@ -521,11 +559,14 @@ function settingsOf(p) { [['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']], + ['PostToolUseFailure', STATS_MATCHER, 'semble-stats.mjs', 'wired'], + ['PreToolUse', 'Bash|Grep', 'semble-reminder.mjs', 'wired'], + ['SubagentStart', null, 'semble-subagent.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'); + st.json.hooks.entries.some((e) => /explore/.test(e.script)), false, + 'semble-explore.mjs is retired for good - it is an ownership mark, never a want row;' + + ' the reminder is a live row again and semble-subagent.mjs supersedes it on all agent types'); 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'); @@ -632,7 +673,18 @@ function settingsOf(p) { '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 }, + { + sessionsWithNudge: 1, sessionsConverted: 1, conversionPct: 100, callsAfterNudge: 1, callsWithoutNudge: 0, + bySource: { + session: SLOT_SESSION, + reminder: { + nudges: 1, sessions: 1, converted: 1, sessionPct: 100, callsAfter: 1, + measure: 'semble-call-after', toolUses: 0, + }, + subagent: SLOT_SUBAGENT, + prefetch: SLOT_PREFETCH, + }, + }, '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], diff --git a/brewcode/skills/setup-status/README.md b/brewcode/skills/setup-status/README.md index b54c526..88f01a2 100644 --- a/brewcode/skills/setup-status/README.md +++ b/brewcode/skills/setup-status/README.md @@ -60,7 +60,7 @@ Two signals, answering two different questions. No mtime heuristics, no guessing |--------|----------|-----| | **version stamp** (headline) | which plugin version produced what is installed here? | every artifact a setup writes carries `version` and `generated_by`, plus `last_updated` everywhere except `.mjs`/`.sh` stamps and `doc_type` in `.md` frontmatter only — never in JSON. The field contract lives in `references/artifact-metadata.md`. Carriers: YAML frontmatter for `.md`, top-level keys for `.json`, a `brewcode-meta:` comment after the shebang for `.mjs` / `.sh`, a `\| Version \|` header row for `team.md` | | **owner stamp** | did the setup that owns this path actually write it? | `generated_by` vs the row's own `:`. A mismatch is `partial` and names both skills; a missing `generated_by` beside a real `version` is `stale (legacy stamp)` | -| **`cmp` vs the plugin asset** (corroborating) | was this file actually re-copied after the plugin update? | byte equality on the copied files — semble's rule + its 3 hooks (**not** `.sembleignore`), think-short's 4, agent-deadline's 2, agent-router's 1, the manager guard, docsync's 3, **two** of memory-sync's 3 references, and `trace-ops.sh` | +| **`cmp` vs the plugin asset** (corroborating) | was this file actually re-copied after the plugin update? | byte equality on the copied files — semble's rule + its 5 hooks (**not** `.sembleignore`), think-short's 4, agent-deadline's 2, agent-router's 1, the manager guard, docsync's 3, **two** of memory-sync's 3 references, and `trace-ops.sh` | Of the contract's four fields this skill reads exactly two. `last_updated` is not read: it is a date, and no state in the vocabulary below is defined by one — an old date on an old stamp is the `stale` diff --git a/brewcode/skills/setup-status/SKILL.md b/brewcode/skills/setup-status/SKILL.md index 95e0b04..71c14a0 100644 --- a/brewcode/skills/setup-status/SKILL.md +++ b/brewcode/skills/setup-status/SKILL.md @@ -80,7 +80,7 @@ ONE row. Nothing else in this file, and no script, encodes the roster. | # | Skill (command) | Plugin | Anchor artifact | Secondary artifacts | Version stamp — carrier & how to read | `cmp` corroboration | |---|-----------------|--------|-----------------|---------------------|--------------------------------------|---------------------| | 1 | `/brewcode:teams-setup` | brewcode | `.claude/teams/*/team.md` | `.claude/teams/*/trace.jsonl`, `.claude/teams/*/trace-ops.sh` | `team.md` header table: the `\| Version \| X.Y.Z \|` row of the `Field/Value` block, beside `\| Generated by \|` and `\| Last update \|`. Generated, substituted at install. **The Agents table also carries a per-agent trailing `Version` column** — `upgrade` rewrites only the rows it touches, so a roster may legitimately mix versions. The header row is the headline (version of the last write to `team.md`); if any agent row is behind it, say so in *found* | `trace-ops.sh` vs `$BC/skills/teams-setup/scripts/trace-ops.sh` (byte-copied, meta line baked at release). **Absence signal kept:** complete team with no `trace-ops.sh` = pre-standard install whose agents cannot trace -> `stale`. **Remedy check:** `upgrade`'s U4 rewrites the three header rows from the Phase 1 scalars, and C4 directs `upgrade` to re-`cp` the tracer ("Re-copy it in UPGRADE too (`cp` is idempotent) so a team created by an older version gains it") — so both the stamp and this absence clear. Per-agent `Version` cells move only for agents the run actually touched, which is why a mixed roster stays `installed` | -| 2 | `/brewcode:semble-setup` | brewcode | `.claude/rules/semble-first.md` | `.claude/hooks/semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`, `.claude/semble/state.json` | frontmatter `version:` of `.claude/rules/semble-first.md`. It is a **pure byte-copy**: the template carries baked `doc_type: llm` + `version` + `generated_by` and deliberately no `last_updated`, and the installer does NOT restamp on copy — so the installed rule stays byte-identical to the template and `cmp` must read `SAME`. `DIFFERS` here means a hand-edit or a rule never re-copied after the plugin update, never a stamping artefact | **all THREE live hooks** vs `$BC/skills/semble-setup/assets/*.mjs` (5.0.0 retired `semble-reminder.mjs` and `semble-explore.mjs`; if either file is still in `.claude/hooks/` the install predates the migration - report it in *found* and prescribe `upgrade`, never `DIFFERS`), plus the rule vs `assets/semble-first.md.template` — and NOTHING else. **The repo-root `.sembleignore` is byte-copied but never byte-STABLE: it is carved out of the `cmp` set** (its `# brewcode-meta:` stamp IS still read — see the row-2 carve-out below). `.sembleignore` sits at the REPO ROOT, not under `.claude/`, and is absent from installs predating it: report that presence check in *found*, never as a `cmp` verdict. `.claude/semble/state.json` is runtime state — never a stamp source, and its `approvedVersion` is the semble **package** version, not ours. **Wiring is a separate signal from bytes, and it is the one that catches a v1-shaped repo:** `.claude/settings.json` can list a hook that no longer exists, or list three of the four the current version wants, while every file on disk is byte-current. `semble-status.sh` reads it (`guidance.hooks`: `retired[]`, `staleEntries`, `wiredCount`/`wantCount`) and downgrades its own `ready` to `partial` for any of the three; report the same way — retired hooks on disk, stale settings entries, or `wiredCount < wantCount` is `stale`, prescribing `install`, no matter how current the stamps are. `wantCount: 0` means the counts were not reported at all and is never a defect. **This row's setup now carries its OWN version signal, and it agrees with ours:** `semble-status.sh` emits `guidance.version` (the installed rule's frontmatter `version:`) beside `guidance.pluginVersion` (what the plugin on this machine would install) at `:550-556`, and when both are non-empty and unequal it drops `ready` to `partial` with `reason: artifacts at X.Y.Z, plugin at A.B.C` (`:632-641`) and `nextStep: Run /brewcode:semble-setup upgrade` (`:657-658`). So row 2 is NOT a row where only the dashboard can see staleness — if the user already ran `semble-setup status`, quote its `nextStep` rather than inventing a different one. Both stamps empty (a pre-5.0 unstamped rule) is deliberately NOT stale there; this dashboard's `LEGACY-NONE` still covers it. **Remedy check:** `upgrade` unconditionally re-runs `semble-guidance.sh install --part all` (SKILL.md `### upgrade`), which is the ONLY writer of the rule's stamp — so it does clear `BEHIND`. It also `cp`s the three hooks with no user_modified guard (`install_hook_files`), so hook `DIFFERS` always clears. **`semble-first.md` is the exception:** `install_managed` re-syncs it only when the sole delta is the metadata block, and a real prose hand-edit is SKIPPED with a `diff -u` to stderr. Say so in *found* — that one needs `--force`, which is not a skill mode. `.sembleignore` takes the same skip branch inside the installer, but this dashboard never reaches that verdict for it: it is not `cmp`d at all, so **never prescribe `--force` on `.sembleignore`** — see the carve-out | +| 2 | `/brewcode:semble-setup` | brewcode | `.claude/rules/semble-first.md` | `.claude/hooks/semble-session.mjs`, `semble-prefetch.mjs`, `semble-stats.mjs`, `semble-reminder.mjs`, `semble-subagent.mjs`, `.claude/semble/state.json` | frontmatter `version:` of `.claude/rules/semble-first.md`. It is a **pure byte-copy**: the template carries baked `doc_type: llm` + `version` + `generated_by` and deliberately no `last_updated`, and the installer does NOT restamp on copy — so the installed rule stays byte-identical to the template and `cmp` must read `SAME`. `DIFFERS` here means a hand-edit or a rule never re-copied after the plugin update, never a stamping artefact | **all FIVE live hooks** vs `$BC/skills/semble-setup/assets/*.mjs` (`semble-explore.mjs` is retired for good, superseded by `semble-subagent.mjs`; if that file is still in `.claude/hooks/` the install predates the migration - report it in *found* and prescribe `upgrade`, never `DIFFERS`), plus the rule vs `assets/semble-first.md.template` — and NOTHING else. **The repo-root `.sembleignore` is byte-copied but never byte-STABLE: it is carved out of the `cmp` set** (its `# brewcode-meta:` stamp IS still read — see the row-2 carve-out below). `.sembleignore` sits at the REPO ROOT, not under `.claude/`, and is absent from installs predating it: report that presence check in *found*, never as a `cmp` verdict. `.claude/semble/state.json` is runtime state — never a stamp source, and its `approvedVersion` is the semble **package** version, not ours. **Wiring is a separate signal from bytes, and it is the one that catches a v1-shaped repo:** `.claude/settings.json` can list a hook that no longer exists, or list five of the six the current version wants, while every file on disk is byte-current. `semble-status.sh` reads it (`guidance.hooks`: `retired[]`, `staleEntries`, `wiredCount`/`wantCount`) and downgrades its own `ready` to `partial` for any of the three; report the same way — retired hooks on disk, stale settings entries, or `wiredCount < wantCount` is `stale`, prescribing `install`, no matter how current the stamps are. `wantCount: 0` means the counts were not reported at all and is never a defect. **This row's setup now carries its OWN version signal, and it agrees with ours:** `semble-status.sh` emits `guidance.version` (the installed rule's frontmatter `version:`) beside `guidance.pluginVersion` (what the plugin on this machine would install) at `:550-556`, and when both are non-empty and unequal it drops `ready` to `partial` with `reason: artifacts at X.Y.Z, plugin at A.B.C` (`:632-641`) and `nextStep: Run /brewcode:semble-setup upgrade` (`:657-658`). So row 2 is NOT a row where only the dashboard can see staleness — if the user already ran `semble-setup status`, quote its `nextStep` rather than inventing a different one. Both stamps empty (a pre-5.0 unstamped rule) is deliberately NOT stale there; this dashboard's `LEGACY-NONE` still covers it. **Remedy check:** `upgrade` unconditionally re-runs `semble-guidance.sh install --part all` (SKILL.md `### upgrade`), which is the ONLY writer of the rule's stamp — so it does clear `BEHIND`. It also `cp`s the five hooks with no user_modified guard (`install_hook_files`), so hook `DIFFERS` always clears. **`semble-first.md` is the exception:** `install_managed` re-syncs it only when the sole delta is the metadata block, and a real prose hand-edit is SKIPPED with a `diff -u` to stderr. Say so in *found* — that one needs `--force`, which is not a skill mode. `.sembleignore` takes the same skip branch inside the installer, but this dashboard never reaches that verdict for it: it is not `cmp`d at all, so **never prescribe `--force` on `.sembleignore`** — see the carve-out | | 3 | `/brewcode:superreview-setup` | brewcode | `.claude/skills/superreview/SKILL.md` | `.claude/skills/superreview/references/agent-prompt.md`, `.../report-template.md`, `.../scope.md`, `.claude/skills/superreview/.template-baseline/` | frontmatter `version:` of the **emitted** `.claude/skills/superreview/SKILL.md`, substituted at install from `{PLUGIN_VERSION}`. **Never read the version out of `.template-baseline/`** — that dir is the pristine template and its `version` is the unresolved `{PLUGIN_VERSION}` token by design, which is precisely why `upgrade` reports IDENTICAL across a version bump instead of a phantom diff. A placeholder in the *emitted* file means substitution never completed -> `partial`. **Remedy check:** `upgrade` restamps FIVE live files unconditionally — `SKILL.md` + `references/{agent-prompt,report-template,scope}.md` + the per-stack ref (`generate.sh` `_restamp_meta` loop) — and prints a `RESTAMP:` line for each even when the delta report says `IDENTICAL`, which is the normal outcome of a plain version bump. So `BEHIND` clears | the 4 baseline copies vs the plugin templates (mapping below) — answers "did the plugin's templates move since this project was tailored", which the emitted stamp cannot. Baseline dir absent -> pre-baseline install, report it in *found*, do not call it a version. **A baseline `DIFFERS` is NOT cleared by `upgrade` alone:** `upgrade` only stages the new templates and prints the promote command (`rm -rf && mv /.template && rm -rf `), which the user runs after porting the delta. Name both halves in the remedy | | 4 | `/brewtools:task-board-setup` | brewtools | `.claude/features/board.md` | `.claude/agents/task-tracker.md`, `.claude/skills/task-board/SKILL.md`, `.claude/skills/task-spec/SKILL.md`, `.claude/rules/tasks.md`, `.claude/features/PROGRESS.md` | frontmatter `version:` of the anchor itself — `board.md` now opens with the four-key block, substituted at install from `{PLUGIN_VERSION}` in the template. Nine artifacts are stamped: the anchor + the 5 secondaries above, plus `TRACKER.md`, `INDEX.md`, `backlog/README.md`. `TASK_TEMPLATE.md` is deliberately UNSTAMPED — its frontmatter is copied into every task card — so its lack of a stamp is never a defect. **Remedy check:** `upgrade` step `U5b` (`references/10-upgrade.md:282`) restamps all nine unconditionally, and `:109`/`:111` make it run even on the commonest path, where every content row is `SKIP` and the version stamp is the only thing out of date. So `BEHIND` clears | none copied verbatim. **Absence signal kept:** board present but `.claude/skills/task-spec/SKILL.md` missing = install predates the spec+design layer -> `stale`, the documented upgrade path was never run | | 5 | `/brewtools:think-short-setup` | brewtools | `.claude/hooks/think-short-session.mjs` (project) or `~/.claude/hooks/think-short-session.mjs` (global) | in the same dir: `think-short-prompt-counter.mjs`, `think-short-task.mjs`, `think-short-prompt.md`; plus a `think-short` reference in the matching `settings.json` | the `// brewcode-meta:` line right after the shebang of `think-short-session.mjs` (baked at release). `think-short-prompt.md` carries the same marker as an HTML comment on line 1, not frontmatter. There is no JSON carrier on this row. **Remedy check:** `upgrade` re-emits all four assets from the current plugin version, keeping the disabled state (SKILL.md mode table), so both the stamp and any `DIFFERS` clear | all 4 vs `$BT/skills/think-short-setup/assets/` | diff --git a/brewcode/skills/setup-status/references/artifact-metadata.md b/brewcode/skills/setup-status/references/artifact-metadata.md index 3a64f2b..b44c1a7 100644 --- a/brewcode/skills/setup-status/references/artifact-metadata.md +++ b/brewcode/skills/setup-status/references/artifact-metadata.md @@ -1,6 +1,6 @@ --- doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewcode" last_updated: "2026-08-09" --- diff --git a/brewcode/skills/teams-setup/scripts/trace-ops.sh b/brewcode/skills/teams-setup/scripts/trace-ops.sh index a407c43..2338752 100755 --- a/brewcode/skills/teams-setup/scripts/trace-ops.sh +++ b/brewcode/skills/teams-setup/scripts/trace-ops.sh @@ -1,5 +1,5 @@ #!/bin/sh -# brewcode-meta: version=5.1.0 generated_by=brewcode:teams-setup +# brewcode-meta: version=5.2.0 generated_by=brewcode:teams-setup set -eu USAGE="Usage: trace-ops.sh [args...]" diff --git a/brewdoc/.claude-plugin/plugin.json b/brewdoc/.claude-plugin/plugin.json index ef2d9c7..d311cae 100644 --- a/brewdoc/.claude-plugin/plugin.json +++ b/brewdoc/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brewdoc", - "version": "5.1.0", + "version": "5.2.0", "description": "Brewdoc - Claude Code documentation tools: my-claude installation docs, memory-sync generator", "author": { "name": "Maksim Kochetkov", diff --git a/brewdoc/README.md b/brewdoc/README.md index cf4638e..5b2e312 100644 --- a/brewdoc/README.md +++ b/brewdoc/README.md @@ -4,7 +4,7 @@ | Field | Value | |-------|-------| -| Version | 5.1.0 | +| Version | 5.2.0 | | Skills | 5 | | Agents | 0 | | Hooks | 0 | diff --git a/brewdoc/docs/commands.md b/brewdoc/docs/commands.md index 0c65968..4dbe4fe 100644 --- a/brewdoc/docs/commands.md +++ b/brewdoc/docs/commands.md @@ -4,7 +4,7 @@ description: Detailed description of all brewdoc plugin commands # Brewdoc Plugin Commands -> **Version:** 5.1.0 | **Author:** Maksim Kochetkov | **License:** MIT +> **Version:** 5.2.0 | **Author:** Maksim Kochetkov | **License:** MIT ## Quick Reference diff --git a/brewdoc/skills/docsync-setup/assets/docsync-gate.mjs b/brewdoc/skills/docsync-setup/assets/docsync-gate.mjs index ec424aa..cf06233 100644 --- a/brewdoc/skills/docsync-setup/assets/docsync-gate.mjs +++ b/brewdoc/skills/docsync-setup/assets/docsync-gate.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewdoc:docsync-setup +// brewcode-meta: version=5.2.0 generated_by=brewdoc:docsync-setup /** * docsync-gate — Stop hook (self-contained, project-local) * diff --git a/brewdoc/skills/docsync-setup/assets/docsync-track.mjs b/brewdoc/skills/docsync-setup/assets/docsync-track.mjs index 96a6ede..cd8621f 100644 --- a/brewdoc/skills/docsync-setup/assets/docsync-track.mjs +++ b/brewdoc/skills/docsync-setup/assets/docsync-track.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewdoc:docsync-setup +// brewcode-meta: version=5.2.0 generated_by=brewdoc:docsync-setup /** * docsync-track — PostToolUse:Write|Edit|MultiEdit hook (self-contained, project-local) * diff --git a/brewdoc/skills/docsync-setup/assets/docsync-watch.mjs b/brewdoc/skills/docsync-setup/assets/docsync-watch.mjs index c961d16..23d7e4f 100644 --- a/brewdoc/skills/docsync-setup/assets/docsync-watch.mjs +++ b/brewdoc/skills/docsync-setup/assets/docsync-watch.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewdoc:docsync-setup +// brewcode-meta: version=5.2.0 generated_by=brewdoc:docsync-setup /** * docsync-watch — PostToolUse:Read hook (self-contained, project-local) * diff --git a/brewdoc/skills/memory-sync-setup/references/agent-audit.md b/brewdoc/skills/memory-sync-setup/references/agent-audit.md index 9162975..3b59683 100644 --- a/brewdoc/skills/memory-sync-setup/references/agent-audit.md +++ b/brewdoc/skills/memory-sync-setup/references/agent-audit.md @@ -1,4 +1,4 @@ - + # 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 diff --git a/brewdoc/skills/memory-sync-setup/references/hard-sync.md b/brewdoc/skills/memory-sync-setup/references/hard-sync.md index 67997db..5539fc7 100644 --- a/brewdoc/skills/memory-sync-setup/references/hard-sync.md +++ b/brewdoc/skills/memory-sync-setup/references/hard-sync.md @@ -1,4 +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 dc5bd63..53db4f5 100644 --- a/brewdoc/skills/memory-sync-setup/references/memory-guide.md +++ b/brewdoc/skills/memory-sync-setup/references/memory-guide.md @@ -1,4 +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/brewtools/.claude-plugin/plugin.json b/brewtools/.claude-plugin/plugin.json index 1a92d15..7e3ae82 100644 --- a/brewtools/.claude-plugin/plugin.json +++ b/brewtools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brewtools", - "version": "5.1.0", + "version": "5.2.0", "description": "Brewtools - universal utilities for Claude Code: text optimization, humanization, secrets scanning", "author": { "name": "Maksim Kochetkov", 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 4d332dd..5fe1a94 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,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup +// brewcode-meta: version=5.2.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 bc4a1bb..15a014b 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 9f12213..ea687f5 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,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup +// brewcode-meta: version=5.2.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 471a4ef..7a2566d 100644 --- a/brewtools/README.md +++ b/brewtools/README.md @@ -4,7 +4,7 @@ | Field | Value | |-------|-------| -| Version | 5.1.0 | +| Version | 5.2.0 | | Skills | 12 | | Agents | 3 | | Hooks | 2 | diff --git a/brewtools/agents/deploy-admin.md b/brewtools/agents/deploy-admin.md index 718216e..f22d631 100644 --- a/brewtools/agents/deploy-admin.md +++ b/brewtools/agents/deploy-admin.md @@ -5,7 +5,7 @@ model: inherit maxTurns: 80 tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, WebFetch, WebSearch doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewtools" last_updated: "2026-08-09" --- diff --git a/brewtools/agents/ssh-admin.md b/brewtools/agents/ssh-admin.md index 1786ffb..5bc5d76 100644 --- a/brewtools/agents/ssh-admin.md +++ b/brewtools/agents/ssh-admin.md @@ -5,7 +5,7 @@ model: inherit maxTurns: 80 tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, WebFetch, WebSearch doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewtools" last_updated: "2026-08-09" --- diff --git a/brewtools/agents/text-optimizer.md b/brewtools/agents/text-optimizer.md index faa18d9..f340bfc 100644 --- a/brewtools/agents/text-optimizer.md +++ b/brewtools/agents/text-optimizer.md @@ -7,7 +7,7 @@ color: magenta tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, AskUserQuestion skills: brewtools:text-optimize doc_type: llm -version: "5.1.0" +version: "5.2.0" generated_by: "brewtools" last_updated: "2026-08-09" --- diff --git a/brewtools/hooks/hardmode-guard.mjs b/brewtools/hooks/hardmode-guard.mjs index 968e1f1..8a3c5d7 100644 --- a/brewtools/hooks/hardmode-guard.mjs +++ b/brewtools/hooks/hardmode-guard.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:manager-setup +// brewcode-meta: version=5.2.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 68f2a5a..843a216 100644 --- a/brewtools/hooks/lib/manager-state.mjs +++ b/brewtools/hooks/lib/manager-state.mjs @@ -1,4 +1,4 @@ -// brewcode-meta: version=5.1.0 generated_by=brewtools:manager-setup +// brewcode-meta: version=5.2.0 generated_by=brewtools:manager-setup // brewtools:manager-setup — Manager mode state resolver/writer. // State shape: { hard:boolean, level:'strict'|'balanced', mode:'full' } // + artifact metadata written by writeState: version/generated_by/last_updated. 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 674f3c9..4ee49d3 100644 --- a/brewtools/skills/agent-deadline-setup/assets/agent-deadline-cleanup.mjs +++ b/brewtools/skills/agent-deadline-setup/assets/agent-deadline-cleanup.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:agent-deadline-setup +// brewcode-meta: version=5.2.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 61dcb61..0c510e2 100644 --- a/brewtools/skills/agent-deadline-setup/assets/agent-deadline-guard.mjs +++ b/brewtools/skills/agent-deadline-setup/assets/agent-deadline-guard.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:agent-deadline-setup +// brewcode-meta: version=5.2.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/assets/agent-router.mjs b/brewtools/skills/agent-router-setup/assets/agent-router.mjs index 40f8d27..e26c879 100755 --- a/brewtools/skills/agent-router-setup/assets/agent-router.mjs +++ b/brewtools/skills/agent-router-setup/assets/agent-router.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:agent-router-setup +// brewcode-meta: version=5.2.0 generated_by=brewtools:agent-router-setup /** * agent-router - PreToolUse hook for the `Agent` tool (Node built-ins only, ESM). * 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 9fe9262..d5a6978 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,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup +// brewcode-meta: version=5.2.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 f9a0e2c..74be6e5 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 9f12213..ea687f5 100644 --- a/brewtools/skills/think-short-setup/assets/think-short-session.mjs +++ b/brewtools/skills/think-short-setup/assets/think-short-session.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup +// brewcode-meta: version=5.2.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 5e34b67..8c0ce71 100644 --- a/brewtools/skills/think-short-setup/assets/think-short-task.mjs +++ b/brewtools/skills/think-short-setup/assets/think-short-task.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// brewcode-meta: version=5.1.0 generated_by=brewtools:think-short-setup +// brewcode-meta: version=5.2.0 generated_by=brewtools:think-short-setup /** * think-short — PreToolUse hook for Task|Agent (self-contained, no plugin-root deps). * @@ -46,12 +46,12 @@ const FAMILY_PLUGINS = ['brewcode', 'brewtools', 'brewdoc']; // 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`. +// way — it named hooks semble had dropped and never learned the ones it added, +// and semble has since retired one name and restored two others. // 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 + 'semble', // brewcode:semble-setup — semble-session/-prefetch/-stats/-reminder/-subagent 'docsync', // brewdoc:docsync-setup — docsync-track/-watch/-gate 'think-short', // brewtools:think-short-setup 'agent-deadline', // brewtools:agent-deadline-setup diff --git a/brewui/.claude-plugin/plugin.json b/brewui/.claude-plugin/plugin.json index 88f4678..a538b7c 100644 --- a/brewui/.claude-plugin/plugin.json +++ b/brewui/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brewui", - "version": "5.1.0", + "version": "5.2.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 201fe9d..9af9645 100644 --- a/brewui/README.md +++ b/brewui/README.md @@ -4,7 +4,7 @@ | Field | Value | |-------|-------| -| Version | 5.1.0 | +| Version | 5.2.0 | | Skills | 0 | ## Install 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 c137006..ac1f831 100644 --- a/web/docs/src/content/docs/brewcode/skills/semble-setup.mdx +++ b/web/docs/src/content/docs/brewcode/skills/semble-setup.mdx @@ -54,7 +54,7 @@ Semble has no Homebrew formula of its own. The skill installs `uv` via `brew` (a /brewcode:semble-setup install ``` -`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: +`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 five hooks, permissions and agent migration — before stopping at the reload checkpoint: ```text # Semble install @@ -69,12 +69,12 @@ scope: user 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 +guidance: rule absent | CLAUDE.md absent | hooks 0/6 wired | permissions no +agents: 4 total | 2 inherit | 0 patched | 2 need patch | 0 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 +changed: brew install uv, semble_code registered @ user, docs cache root reserved, rule installed, .sembleignore installed, CLAUDE.md block installed, 5 hooks wired, permissions merged, 2 agents patched unchanged: none skipped: none failed: none @@ -102,7 +102,7 @@ After a fresh session, `resume` re-checks status, runs the smoke query (the one ```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) +unchanged: rule, .sembleignore, CLAUDE.md block, 5 hooks, permissions, 2 agents (already wired by install) skipped: none failed: none @@ -155,7 +155,7 @@ Subsequent searches are sub-second and require the absolute repo path:
  • 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.

    +

    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 five 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.

  • @@ -219,15 +219,50 @@ The first seven rows are the canonical vocabulary shared by every `-setup` skill | Rule | `/.claude/rules/semble-first.md` | | 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 | +| Hooks | five files in `.claude/hooks/` — `semble-session.mjs` (SessionStart), `semble-prefetch.mjs` (UserPromptSubmit), `semble-stats.mjs` (PostToolUse + PostToolUseFailure, stats matcher), `semble-reminder.mjs` (PreToolUse `Bash\|Grep`), `semble-subagent.mjs` (SubagentStart, **no matcher key** — matches every agent type) — six `settings.json` registrations total, each `"timeout": 5` (seconds). Status reports `hooks /6 wired` — never 6 files. `semble-explore.mjs` is retired for good, replaced by `semble-subagent.mjs`: `install`/`upgrade` deletes the file and replaces its settings row, never leaves both. See Retirement of the advisory pair and Reminder mechanic below | | Agents | project `.claude/agents/**/*.md` get the two tool names added to `tools:`; global agents are never touched | +### Retirement of the advisory pair + +The old rationale for retiring the advisory pair — "0/18 and 0/11 conversion, delivery independently confirmed" — was wrong on the first number: + +| Claim | Fact | +|-------|------| +| Delivery channel was broken | Never was. On CC 2.1.226 PreToolUse and SubagentStart both accept `additionalContext` in the zod union and reach the model over the same unfiltered path | +| `0/18` conversion | Measured nothing. The reminder hook fired zero times across those 18 sessions: its own gate (the then-current `isExactIntent`, biased to silence — any doubt returned `true`) suppressed 74 of 113 evaluations, 37 were `disabled`, 2 throttled. Lifetime rate was 14 nudges / 2718 evaluations = 0.52%. The denominator 18 counted trial sessions, not deliveries | +| `0/11` conversion | Real — but of one agent type (`Explore`, the one `semble-explore.mjs` was pinned to via `agent_type === 'Explore'`) and of text that undercut itself with "this is a reminder, not a block". The replacement, `semble-subagent.mjs`, carries no matcher key at all — which is what matches every agent type — and was verified live on Explore, Plan and general-purpose | + +### Reminder mechanic + +The 600-second throttle is gone. `semble-reminder.mjs` fires on every Nth eligible search call — `N = 5` by default, configurable via `reminderEvery` in `.claude/semble/state.json`. The counter lives in `.claude/semble/reminder.json`, written atomically, reset on corruption; the old `.claude/semble/.reminder-ts` marker is retired for good. + + +It survives across sessions. A fresh session inherits the residual phase — the reminder may land on the very first search of that session or only on the fifth. "Every fifth search" is a property of the project, not the session. + + +The gate kept only the strong suppressors — `-l`/`-L`/`-c`/`-o` and their long forms (`--files-with-matches`, `--files-without-match`, `--count`, `--only-matching`), `find`/`bfs` `-name`/`-iname`/`-path`/`-type`, a pattern containing `/`, a filename-shaped pattern, and — for the native `Grep` tool — `output_mode: files_with_matches|count` — and dropped suppression on regex metacharacters, `-F`/`-w`, piping into `wc`/`sort`, and short patterns. Replayed on the real historical stream of 2543 recorded search calls: the old gate let 229 through and fired 32; the new one lets 1023 through and fires 204. + +### Measured delivery and cost + +CC 2.1.226, 17 real sessions, joined on `tool_use_id` between the hook's own telemetry record and the `hook_additional_context` attachment in the transcript: + +| Channel | Delivery | Cost | +|---------|----------|------| +| Reminder (PreToolUse) | 20/20 = 100% | 188 tokens per injection | +| Subagent (SubagentStart) | 4/4 — Explore, Plan and general-purpose all quoted the injected text back verbatim | 189 tokens per injection | + +188 tokens per reminder injection, 189 per subagent injection — PreToolUse context is **not** deduplicated, so every injection is paid in full. + +Parent and subagent contexts are disjoint: the `SubagentStart` context is visible only inside the subagent and is written only to `/subagents/agent-.jsonl`, never to the parent transcript. + +`semble-status.sh` reports conversion per source (session / reminder / subagent / prefetch), not one pooled denominator — pooling let 126 `SessionStart` firings drown every other channel. Each slot carries its own `measure` (`injected-path-opened` for prefetch, `semble-call-after` for the rest); a channel that never fired prints `0/0` rather than vanishing — `0/0` is "never delivered", `0/N` is "delivered and ignored", and only the second is evidence about the advice. + ### 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) +guidance: rule managed | CLAUDE.md present | hooks 6/6 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. @@ -280,7 +315,7 @@ Both `mcp__semble_code__search` and `mcp__semble_code__find_related` require an 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. + Read-only dashboard: it `cmp`s the five semble hooks and the rule against the plugin assets to decide installed vs stale. SKILL.md, scripts, and the reference files behind every mode. 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 4973c6e..292b843 100644 --- a/web/docs/src/content/docs/brewcode/skills/setup-status.mdx +++ b/web/docs/src/content/docs/brewcode/skills/setup-status.mdx @@ -59,7 +59,7 @@ Ask it to "install everything" or "fix them all" and it refuses once, plainly, t 4 of 10 setups are behind the installed plugin (2 stale by version, 1 legacy stamp, 1 stale by wiring). | 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" +| /brewcode:semble-setup | stale | A.B.C | rule + all five live hook files current; semble-explore.mjs (RETIRED, superseded by semble-subagent.mjs) is still on disk — wiring shows only 5 of the 6 settings.json entries the current version wants | /brewcode:semble-setup install "remove the retired hook file, re-wire all six 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" @@ -217,7 +217,7 @@ Three signals, never mtime and never a guess. None answers another's question |--------|----------|-----| | **version stamp** (headline) | which plugin version produced what is installed here? | frontmatter `version:`, a top-level JSON key, a `brewcode-meta:` comment after the shebang, or `team.md`'s `\| Version \|` header row — whichever carrier the roster names for that row | | **owner stamp** | did the setup that owns this path actually write it? | `generated_by` compared against the row's own `:`. A mismatch is `partial`; a real `version` with no `generated_by` beside it is `stale (legacy stamp)` | -| **`cmp` vs the plugin asset** (corroborating) | was this file actually re-copied after the plugin update? | byte equality on the copied files — semble's rule + all three live hook files (never `.sembleignore`; those three files wire as four `settings.json` registrations, since `semble-stats.mjs` fires on both `PostToolUse` and `PostToolUseFailure`), think-short's 4, agent-deadline's 2, agent-router's 1, the manager guard, docsync's 3, **two** of memory-sync's 3 references, and `trace-ops.sh` | +| **`cmp` vs the plugin asset** (corroborating) | was this file actually re-copied after the plugin update? | byte equality on the copied files — semble's rule + all five live hook files (never `.sembleignore`; those five files wire as six `settings.json` registrations, since `semble-stats.mjs` fires on both `PostToolUse` and `PostToolUseFailure`), think-short's 4, agent-deadline's 2, agent-router's 1, the manager guard, docsync's 3, **two** of memory-sync's 3 references, and `trace-ops.sh` | `semble-setup` reads a fourth, independent signal: **wiring**. `.claude/settings.json` can list a hook that no longer exists, or omit one the current version wants, while every file on disk is already byte-current — `retired[]`, `staleEntries` and `wiredCount`/`wantCount` catch what a version-and-bytes check cannot see. @@ -282,7 +282,7 @@ Every fact the skill knows lives in ONE table in its `SKILL.md`. Adding a future Full plugin overview — all skills, agents, and hooks in one place. - One of the ten rows: its three hooks and rule are `cmp`d verbatim against the plugin assets. + One of the ten rows: its five hooks and rule are `cmp`d verbatim against the plugin assets. Another row: staleness comes from its `.template-baseline/` diff, so your tailoring never counts as drift. diff --git a/web/docs/src/content/docs/full-setup.mdx b/web/docs/src/content/docs/full-setup.mdx index 154cb16..b654c55 100644 --- a/web/docs/src/content/docs/full-setup.mdx +++ b/web/docs/src/content/docs/full-setup.mdx @@ -93,7 +93,7 @@ byte-identical, nothing deleted). | 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 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` | +| [`semble-setup`](/brewcode/skills/semble-setup/) | brewcode | the `semble_code` MCP server (user scope), `.claude/rules/semble-first.md`, five hook files in `.claude/hooks/` (`semble-session`, `semble-prefetch`, `semble-stats`, `semble-reminder`, `semble-subagent`) wired as six `settings.json` entries (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostToolUseFailure`, `PreToolUse` on `Bash\|Grep`, and an unmatched `SubagentStart`, each `timeout: 5` seconds), the `.claude/semble/` directory (`state.json`; the hooks later add `reminder.json` and `telemetry.jsonl` there at runtime — `.gitignore` gets the whole directory), 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` |