feat(semble): install SubagentStart Explore hook that primes semble-first search

This commit is contained in:
kochetkov-ma
2026-08-02 22:05:13 +01:00
parent aebe29c277
commit d1b2a55ed3
11 changed files with 431 additions and 97 deletions
+2 -2
View File
@@ -60,7 +60,7 @@ Free text in Russian or English routes to one mode. The full table and the resol
| State | `<repo>/.claude/semble/state.json` |
| Rule | `<repo>/.claude/rules/semble-first.md` |
| CLAUDE.md | a marked `<!-- BEGIN brewcode:semble -->` block |
| Hooks | `<repo>/.claude/hooks/semble-session.mjs` (SessionStart) + `semble-reminder.mjs` (PreToolUse, advisory only) |
| Hooks | `<repo>/.claude/hooks/semble-session.mjs` (SessionStart) + `semble-reminder.mjs` (PreToolUse, advisory only) + `semble-explore.mjs` (SubagentStart, matcher `Explore` — tells the spawned Explore subagent it can call `mcp__semble_code__search` without a `ToolSearch` first) |
| Permissions | `<repo>/.claude/settings.json` -> exactly `mcp__semble_code__search` and `mcp__semble_code__find_related`, never a wildcard |
| Agents | `<repo>/.claude/agents/**/*.md` get the two tool names; agents with no `tools:` key inherit and are left untouched. Global agents are never touched by `setup` |
@@ -132,7 +132,7 @@ 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 `.html` / `.json` file is never found | not in this corpus by design | use `rg` |
| Status says `partial` | half-wired (e.g. hooks 1/3) | `/brewcode:semble setup` re-runs idempotently |
| Status says `partial` | half-wired (e.g. hooks 1/4) | `/brewcode:semble setup` re-runs idempotently |
| `malformed` | `~/.claude.json` or `.mcp.json` is not valid JSON | the skill refuses to write; fix that file by hand, then re-run |
## License
+1 -1
View File
@@ -311,7 +311,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), refreshes the `<!-- BEGIN brewcode:semble -->` block in `CLAUDE.md`, copies the two hooks into `.claude/hooks/`, and merges the settings + the two exact permission entries. Every step is idempotent. Then the project agents are audited and patched — **project scope only**; global agents are never touched by `setup`/`resume`.
`install --part all` writes the `semble-first` rule (never blind-overwriting a user-edited file), refreshes the `<!-- BEGIN brewcode:semble -->` block in `CLAUDE.md`, copies the three hooks into `.claude/hooks/` (SessionStart, PreToolUse advisory, SubagentStart/`Explore`), and merges the settings + the two exact permission entries. Every step is idempotent. Then the project agents are audited and patched — **project scope only**; global agents are never touched by `setup`/`resume`.
**EXECUTE** using Bash tool:
+55 -16
View File
@@ -12,9 +12,10 @@ wires nothing on its own.
| — (marker block) | `<repo>/CLAUDE.md` | — | 6 lines between HTML markers |
| `semble-session.mjs` | `<repo>/.claude/hooks/` | SessionStart | `systemMessage` + `additionalContext` |
| `semble-reminder.mjs` | `<repo>/.claude/hooks/` | PreToolUse (`Bash`, `Grep`) | `additionalContext` ONLY — advisory |
| `semble-explore.mjs` | `<repo>/.claude/hooks/` | SubagentStart (`Explore`) | `additionalContext` into the SPAWNED subagent |
> 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. Neither hook
> stdin, never throws, prints exactly one JSON object and exits 0. No hook
> spawns a process, calls `pgrep`, or implies a daemon: **semble has no watcher
> and no daemon** — the index is rebuilt inside a tool call and cached.
@@ -26,7 +27,7 @@ scripts/semble-guidance.sh status [--json]
scripts/semble-guidance.sh remove [--part ...|all] [--force] [--json]
```
`install --part all` does, in order: rule -> CLAUDE.md block -> copy the two
`install --part all` does, in order: rule -> CLAUDE.md block -> copy the three
`.mjs` -> `.gitignore` line -> settings hooks + permissions merge. Every step is
idempotent and re-runnable. `--json` prints one object
`{schema,mode,part,changed,unchanged,skipped,failed}` and nothing else; a
@@ -157,11 +158,30 @@ The `Grep` matcher is registered even though native `Grep`/`Glob` are no-ops on
the macOS Claude Code build (search there goes through `Bash`). Other builds
still have the tool; the entry is inert where it is not.
### `semble-explore.mjs` — SubagentStart, matcher `Explore`
The built-in `Explore` subagent type has the semble MCP tools available but not
pre-listed in its own tool set, so it has to `ToolSearch` its way to
`mcp__semble_code__search` before it can call it — and usually reaches for `rg`
instead. `SubagentStart`'s `additionalContext` lands in the SPAWNED subagent's
own transcript, not the parent's, so the reminder arrives before its first move.
Reads exactly one file, `<cwd>/.claude/semble/state.json`. Returns `{}` unless
ALL of these hold: `agent_type === "Explore"`, the state file parses,
`enabled !== false`, and `phase === "ready"`. Otherwise:
```json
{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"semble: call mcp__semble_code__search directly first (repo=\"<cwd>\", top_k=5) for intent/behavior questions — it is already available, no ToolSearch needed. rg/Grep stay for exact/exhaustive matches."}}
```
Advisory only, like the reminder: no `permissionDecision`, no throttle, no
process. The matcher is exactly `Explore` — no other subagent type is touched.
---
## 4. settings.json entry shape
`<absdir>` = absolute path of the hooks dir the two files were copied into
`<absdir>` = absolute path of the hooks dir the three files were copied into
(`<repo>/.claude/hooks`).
```json
@@ -173,6 +193,9 @@ still have the tool; the entry is inert where it is not.
"PreToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "node", "args": ["<absdir>/semble-reminder.mjs"], "timeout": 5000 } ] },
{ "matcher": "Grep", "hooks": [ { "type": "command", "command": "node", "args": ["<absdir>/semble-reminder.mjs"], "timeout": 5000 } ] }
],
"SubagentStart": [
{ "matcher": "Explore", "hooks": [ { "type": "command", "command": "node", "args": ["<absdir>/semble-explore.mjs"], "timeout": 5000 } ] }
]
},
"permissions": {
@@ -187,7 +210,7 @@ stall every tool call in the session for a minute. 5000 is ~80x the measured
runtime of these hooks.
The marker for all semble entries is `args` containing a path whose basename is
`semble-session.mjs` or `semble-reminder.mjs` — which is exactly why the
`semble-session.mjs`, `semble-reminder.mjs` or `semble-explore.mjs` — which is exactly why the
`{hooks:[{type,command:"node",args:[abs],timeout}]}` form is mandatory. An entry
written as `command: "node /abs/x.mjs"` has no `args` and would be invisible to
both the stale-path purge and the uninstall.
@@ -207,8 +230,9 @@ both the stale-path purge and the uninstall.
alone — deduping on the path would silently drop the `Grep` registration.
4. Merge `permissions.allow` with the two tool names, deduped.
5. **Re-read the written file and assert**: exactly 1 `SessionStart` entry,
exactly 1 `PreToolUse`/`Bash`, exactly 1 `PreToolUse`/`Grep`, and each tool
name present exactly once in `permissions.allow`. Any other count exits 1.
exactly 1 `PreToolUse`/`Bash`, exactly 1 `PreToolUse`/`Grep`, exactly 1
`SubagentStart`/`Explore`, and each tool name present exactly once in
`permissions.allow`. Any other count exits 1.
**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
@@ -218,10 +242,11 @@ the only path that aborts on a broken file and verifies afterwards:
SETTINGS="$PWD/.claude/settings.json" HOOKS_DIR="$PWD/.claude/hooks" node -e '
const fs=require("fs"), path=require("path");
const f=process.env.SETTINGS, dir=process.env.HOOKS_DIR;
const marks=["semble-session.mjs","semble-reminder.mjs"];
const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"];
const want=[["SessionStart",null,"semble-session.mjs",5000],
["PreToolUse","Bash","semble-reminder.mjs",5000],
["PreToolUse","Grep","semble-reminder.mjs",5000]];
["PreToolUse","Grep","semble-reminder.mjs",5000],
["SubagentStart","Explore","semble-explore.mjs",5000]];
const tools=["mcp__semble_code__search","mcp__semble_code__find_related"];
let s={};
if(fs.existsSync(f)){
@@ -276,15 +301,16 @@ console.log("OK merged "+f);
> EXACTLY as it was: `model`, `env`, `permissions.deny` and every foreign hook
> are intact. Fix the JSON by hand, then re-run.
**EXECUTE** copy the two hook files first (project, Bash tool; `SRC` = the
**EXECUTE** copy the three hook files first (project, Bash tool; `SRC` = the
directory holding THIS runbook, i.e. the skill's `assets/`):
```
SRC="$(dirname "$RUNBOOK")"
DST="$PWD/.claude/hooks"
mkdir -p "$DST" && \
cp "$SRC/semble-session.mjs" "$SRC/semble-reminder.mjs" "$DST/" && \
cp "$SRC/semble-session.mjs" "$SRC/semble-reminder.mjs" "$SRC/semble-explore.mjs" "$DST/" && \
node --check "$DST/semble-session.mjs" && node --check "$DST/semble-reminder.mjs" && \
node --check "$DST/semble-explore.mjs" && \
echo "✅ copied + verified in $DST" || echo "❌ FAILED"
```
@@ -292,7 +318,7 @@ echo "✅ copied + verified in $DST" || echo "❌ FAILED"
### Scope
These hooks are project-scoped by design: both read
These hooks are project-scoped by design: all three read
`<cwd>/.claude/semble/state.json`, so a **global** install into `~/.claude/` is
inert in every project that has no semble state — silent, but it still pays a
Node start-up per `Bash` call everywhere. Install per project. If a global
@@ -322,7 +348,7 @@ creates one. Uninstall removes exactly those two lines.
Do NOT unwire the hooks to mute them. Flip the project state instead:
`enabled:false` (or `phase:"disabled"`) in `<repo>/.claude/semble/state.json`
makes both hooks go quiet immediately — they read the state on every call, so no
makes all three hooks go quiet immediately — they read the state on every call, so no
restart is needed. That is what `/brewcode:semble disable` and `enable` do via
`semble-project.sh`; the rule, the CLAUDE.md block, the hook files and the
settings entries all stay in place.
@@ -332,12 +358,16 @@ settings entries all stay in place.
## 6. UNINSTALL
`scripts/semble-guidance.sh remove --part all` — or the equivalent by hand. It
strips settings by the two basenames, deletes an event array that empties, the
strips settings by the three basenames, deletes an event array that empties, the
`hooks` object if it empties, only the two permission strings (and `allow` /
`permissions` if they empty), then deletes the two `.mjs` files, the managed rule
`permissions` if they empty), then deletes the three `.mjs` files, the managed rule
file and the CLAUDE.md marker range. Foreign hook entries and every other
settings key are never touched.
> Removing the files without removing the registration is the one failure that
> hurts: Claude Code then runs `node <deleted path>` on every SessionStart and
> every matching tool call. Settings first, files second.
**EXECUTE** using Bash tool (project):
```
@@ -345,7 +375,7 @@ export HOOKS_DIR="$PWD/.claude/hooks" SETTINGS="$PWD/.claude/settings.json"
node -e '
const fs=require("fs");
const f=process.env.SETTINGS;
const marks=["semble-session.mjs","semble-reminder.mjs"];
const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"];
const tools=["mcp__semble_code__search","mcp__semble_code__find_related"];
if(!fs.existsSync(f)){ console.log("no settings to clean: "+f); process.exit(0); }
const raw=fs.readFileSync(f,"utf8");
@@ -377,8 +407,9 @@ const left=Object.values(back.hooks||{}).flat().filter(e=>argsOf(e).some(isMine)
const perm=((back.permissions&&back.permissions.allow)||[]).filter(x=>tools.includes(x)).length;
if(left!==0||perm!==0){ console.error("ABORT: verification failed - "+left+" hook / "+perm+" permission entries still in "+f); process.exit(1); }
console.log("OK cleaned "+f);
' && rm -f "$HOOKS_DIR/semble-session.mjs" "$HOOKS_DIR/semble-reminder.mjs" \
' && rm -f "$HOOKS_DIR/semble-session.mjs" "$HOOKS_DIR/semble-reminder.mjs" "$HOOKS_DIR/semble-explore.mjs" \
&& test ! -e "$HOOKS_DIR/semble-session.mjs" && test ! -e "$HOOKS_DIR/semble-reminder.mjs" \
&& test ! -e "$HOOKS_DIR/semble-explore.mjs" \
&& echo "✅ uninstalled from $HOOKS_DIR" || echo "❌ FAILED"
```
@@ -409,6 +440,14 @@ echo '{"cwd":"<repo>","hook_event_name":"PreToolUse","tool_name":"Bash","tool_in
# 5. exact search -> {} (must NEVER be anything else)
echo '{"cwd":"<repo>","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rg -l foo"}}' \
| node <absdir>/semble-reminder.mjs; echo " exit=$?"
# 6. Explore subagent on a ready project -> one additionalContext naming search
echo '{"cwd":"<repo>","hook_event_name":"SubagentStart","agent_type":"Explore"}' \
| node <absdir>/semble-explore.mjs; echo " exit=$?"
# 7. any other subagent type -> {} (must NEVER be anything else)
echo '{"cwd":"<repo>","hook_event_name":"SubagentStart","agent_type":"general-purpose"}' \
| node <absdir>/semble-explore.mjs; echo " exit=$?"
```
Full regression: `node tests/suite-hooks.mjs` from the skill dir — it runs the
@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* brewcode:semble SubagentStart hook (self-contained, installed into a project).
* Registered with matcher "Explore" only.
*
* The built-in Explore subagent type has semble's MCP tools available but not
* pre-listed in its own tool set, so it has to ToolSearch its way to
* mcp__semble_code__search before it can call it. This hook's additionalContext
* lands in the SPAWNED subagent's own transcript (not the parent's verified
* against SubagentStart semantics), so it can call semble directly first.
*
* Never spawns a process, never probes for a daemon (semble has none), always
* prints exactly one JSON object, always exits 0.
*
* Pure ESM, Node built-ins only. readStdin/output are inlined on purpose: this
* file travels alone into a user's .claude/hooks/ and must have no imports.
*/
import { readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
// --- inlined helpers -------------------------------------------------------
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function output(response) {
let text = '{}';
try {
text = JSON.stringify(response === undefined ? {} : response);
} catch {
text = '{}';
}
process.stdout.write(text + '\n');
}
function warn(message) {
try {
process.stderr.write('[semble-explore] ' + message + '\n');
} catch {
/* stderr is best-effort */
}
}
// ---------------------------------------------------------------------------
/** {kind:'missing'|'corrupt'|'ok', state} — same reader as the other semble hooks. */
function readState(cwd) {
const file = join(cwd, '.claude', 'semble', 'state.json');
let st;
try {
st = statSync(file);
} catch {
return { kind: 'missing' };
}
if (!st.isFile()) return { kind: 'corrupt' };
let raw;
try {
raw = readFileSync(file, 'utf8');
} catch {
return { kind: 'corrupt' };
}
if (!raw.trim()) return { kind: 'missing' };
try {
const state = JSON.parse(raw);
if (state === null || typeof state !== 'object' || Array.isArray(state)) return { kind: 'corrupt' };
return { kind: 'ok', state };
} catch {
return { kind: 'corrupt' };
}
}
function message(cwd) {
return (
'semble: call mcp__semble_code__search directly first (repo="' + cwd +
'", top_k=5) for intent/behavior questions — it is already available, no ' +
'ToolSearch needed. rg/Grep stay for exact/exhaustive matches.'
);
}
function decide(input, cwd) {
const agentType = typeof input.agent_type === 'string' ? input.agent_type : '';
if (agentType !== 'Explore') return {};
const read = readState(cwd);
if (read.kind !== 'ok') return {};
const state = read.state;
if (state.enabled === false) return {};
if (state.phase !== 'ready') return {};
return {
hookSpecificOutput: {
hookEventName: 'SubagentStart',
additionalContext: message(cwd),
},
};
}
async function main() {
let cwd = process.cwd();
try {
let input = {};
try {
input = await readStdin();
} catch {
input = {}; // malformed/empty stdin: stay silent
}
if (!input || typeof input !== 'object' || Array.isArray(input)) input = {};
if (typeof input.cwd === 'string' && input.cwd) cwd = input.cwd;
output(decide(input, cwd));
} catch (e) {
warn('hook error: ' + (e && e.message));
output({});
}
}
main();
@@ -16,7 +16,7 @@ scope: <user|project|local>
cli: uv <ver|absent> | uvx <ver|absent> | semble pin 0.5.2 (<uvx-ephemeral|uv-tool ver>) | claude <ver>
mcp: <state> @ <scope> [<connectivity>]
cache: <code root> | repo <hash8> | <size> | <staleness> | docs root reserved: <yes|no>
guidance: rule <state> | CLAUDE.md <state> | hooks <n>/3 wired | permissions <yes|no>
guidance: rule <state> | CLAUDE.md <state> | hooks <n>/4 wired | permissions <yes|no>
agents: <total> total | <inherit> inherit | <patched> patched | <conflict> conflict | <skipped> skipped
state: phase=<phase> enabled=<bool> completed=[...]
@@ -71,7 +71,7 @@ Checkpoint: <abs>/.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`. |
| `<hash8>` | First 8 hex chars of the repo's sha256 cache-dir name. Empty when unresolvable. |
| `hooks <n>/3 wired` | 3 = SessionStart + PreToolUse(`Bash`) + PreToolUse(`Grep`). Anything below 3 is half-wired — say so, do not round up to "installed". |
| `hooks <n>/4 wired` | 4 = SessionStart + PreToolUse(`Bash`) + PreToolUse(`Grep`) + SubagentStart(`Explore`). Anything below 4 is half-wired — say so, do not round up to "installed". |
| `staleness` | One of `absent | incomplete | mismatch | stale | fresh | unknown`. `stale` is reported as **likely stale** — the check approximates semble's own validation. |
| `smoke` | `skipped (<reason>)` 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: <abs>/.claude/semble/state.json
| Never write | Because |
|-------------|---------|
| anything about a watcher, daemon, background indexer, or service being "started"/"running"/"stopped" | semble 0.5.2 has none. Staleness is re-checked inside each tool call behind a `3x last-build-duration` cooldown. |
| `installed` when `hooks` < 3, or when the MCP is registered but never verified | Half-wired is a distinct state; report `partial`. |
| `installed` when `hooks` < 4, or when the MCP is registered but never verified | Half-wired is a distinct state; report `partial`. |
| `connected` from config alone | `connectivity` comes only from the exit status of `claude mcp get semble_code`; with no signal it stays `unknown`. |
| `stale` as a certainty | The check approximates `get_validated_cache`; say `likely stale` and offer `reindex` rather than acting. |
| 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.2 (uvx-ephemeral) | claude 2.1.220
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/3 wired | permissions no
guidance: rule absent | CLAUDE.md absent | hooks 0/4 wired | permissions no
agents: 7 total | 3 inherit | 0 patched | 4 conflict | 0 skipped
state: phase=absent enabled=null completed=[]
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# semble-guidance.sh — rule file, CLAUDE.md marker block, the two hooks,
# semble-guidance.sh — rule file, CLAUDE.md marker block, the three hooks,
# settings.json wiring and permission entries for brewcode:semble.
# Contracts: DESIGN §9.8 and §10. All JSON goes through `node -e`, never jq.
set -euo pipefail
@@ -10,6 +10,8 @@ SRC="$(cd "$SC_DIR/.." && pwd)/assets"
TPL="$SRC/semble-first.md.template"
SESSION_MJS="semble-session.mjs"
REMINDER_MJS="semble-reminder.mjs"
EXPLORE_MJS="semble-explore.mjs"
HOOK_MJS="$SESSION_MJS $REMINDER_MJS $EXPLORE_MJS"
MODE=""
PART="all"
@@ -65,7 +67,7 @@ want_part() { [ "$PART" = "all" ] || [ "$PART" = "$1" ]; }
# Preflight: settings.json must be a parseable JSON object BEFORE anything is
# written or deleted. The merge/unmerge step runs last, so its own ABORT would
# otherwise leave the rule, the CLAUDE.md block and the two .mjs files half
# otherwise leave the rule, the CLAUDE.md block and the three .mjs files half
# applied (install) or already gone (remove) — DESIGN §10 merge discipline.
settings_is_object() {
[ -f "$SETTINGS" ] || return 0
@@ -107,13 +109,14 @@ const fs=require("fs"), path=require("path");
const rule=process.env.SG_RULE, tpl=process.env.SG_TPL, cmd=process.env.SG_CLAUDEMD;
const dir=process.env.SG_HOOKS, sf=process.env.SG_SETTINGS;
const BEGIN="<!-- BEGIN brewcode:semble -->", END="<!-- END brewcode:semble -->";
const marks=["semble-session.mjs","semble-reminder.mjs"];
const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"];
const tools=[process.env.SG_SEARCH,process.env.SG_RELATED];
const readSafe=f=>{ try{ return fs.readFileSync(f,"utf8"); }catch(e){ return null; } };
const out={schema:1,
rule:{state:"absent",path:rule},
claudeMd:{state:"absent",path:cmd,malformed:false},
hooks:{session:{file:"missing",wired:false},reminder:{file:"missing",wired:false},
explore:{file:"missing",wired:false},
settingsFile:sf,settingsParsable:true,staleEntries:0,wiredCount:0},
permissions:{allow:[],wired:false}};
const rr=readSafe(rule), tt=readSafe(tpl);
@@ -124,6 +127,7 @@ if(cc!==null){ const b=cc.indexOf(BEGIN), e=cc.indexOf(END);
else if(b>=0||e>=0) out.claudeMd.malformed=true; }
out.hooks.session.file=fs.existsSync(path.join(dir,marks[0]))?"present":"missing";
out.hooks.reminder.file=fs.existsSync(path.join(dir,marks[1]))?"present":"missing";
out.hooks.explore.file=fs.existsSync(path.join(dir,marks[2]))?"present":"missing";
let s=null; const raw=readSafe(sf);
if(raw!==null&&raw.trim()){ try{ s=JSON.parse(raw); }catch(e){ out.hooks.settingsParsable=false; } }
if(s!==null&&(typeof s!=="object"||Array.isArray(s))){ s=null; out.hooks.settingsParsable=false; }
@@ -131,7 +135,7 @@ const argsOf=e=>((e&&e.hooks)||[]).flatMap(h=>(h&&h.args)||[]).filter(a=>typeof
const matcherOf=e=>(e&&typeof e.matcher==="string")?e.matcher:null;
const isMine=a=>marks.some(m=>a===m||a.endsWith("/"+m)||a.endsWith("\\"+m));
const wanted=new Set(marks.map(m=>path.join(dir,m)));
let stale=0,sess=0,bash=0,grep=0;
let stale=0,sess=0,bash=0,grep=0,expl=0;
if(s&&s.hooks&&typeof s.hooks==="object"&&!Array.isArray(s.hooks)){
for(const ev of Object.keys(s.hooks)){
const arr=Array.isArray(s.hooks[ev])?s.hooks[ev]:[];
@@ -142,13 +146,15 @@ if(s&&s.hooks&&typeof s.hooks==="object"&&!Array.isArray(s.hooks)){
const m=matcherOf(e), a=argsOf(e);
if(ev==="SessionStart"&&a.includes(path.join(dir,marks[0]))) sess++;
if(ev==="PreToolUse"&&a.includes(path.join(dir,marks[1]))){ if(m==="Bash")bash++; if(m==="Grep")grep++; }
if(ev==="SubagentStart"&&a.includes(path.join(dir,marks[2]))&&m==="Explore") expl++;
}
}
}
out.hooks.staleEntries=stale;
out.hooks.session.wired=(sess===1);
out.hooks.reminder.wired=(bash===1&&grep===1);
out.hooks.wiredCount=(sess===1?1:0)+(bash===1?1:0)+(grep===1?1:0);
out.hooks.explore.wired=(expl===1);
out.hooks.wiredCount=(sess===1?1:0)+(bash===1?1:0)+(grep===1?1:0)+(expl===1?1:0);
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);
@@ -160,9 +166,10 @@ status_human() {
SG_J="$1" node -e '
const j=JSON.parse(process.env.SG_J);
console.log("guidance: rule "+j.rule.state+" | CLAUDE.md "+(j.claudeMd.malformed?"malformed":j.claudeMd.state)
+" | hooks "+j.hooks.wiredCount+"/3 wired | permissions "+(j.permissions.wired?"yes":"no"));
+" | hooks "+j.hooks.wiredCount+"/4 wired | permissions "+(j.permissions.wired?"yes":"no"));
console.log("rule: "+j.rule.path);
console.log("hooks: "+j.hooks.session.file+" session, "+j.hooks.reminder.file+" reminder"
console.log("hooks: "+j.hooks.session.file+" session, "+j.hooks.reminder.file+" reminder, "
+j.hooks.explore.file+" explore"
+(j.hooks.staleEntries?" | "+j.hooks.staleEntries+" stale settings entr"+(j.hooks.staleEntries===1?"y":"ies"):""));
console.log("settings: "+j.hooks.settingsFile+(j.hooks.settingsParsable?"":" (UNPARSEABLE - fix it, nothing can be merged)"));
'
@@ -282,12 +289,12 @@ do_claudemd() {
# ── hook files ──────────────────────────────────────────────────────────────
install_hook_files() {
local f
for f in "$SESSION_MJS" "$REMINDER_MJS"; do
for f in $HOOK_MJS; do
[ -f "$SRC/$f" ] || { add_failed "hooks: asset missing at $SRC/$f"; return 0; }
done
if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "cp hooks -> $HOOKS_DIR" >/dev/null; add_changed "hooks: would copy 2 files into $HOOKS_DIR"; return 0; fi
if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "cp hooks -> $HOOKS_DIR" >/dev/null; add_changed "hooks: would copy 3 files into $HOOKS_DIR"; return 0; fi
mkdir -p "$HOOKS_DIR"
for f in "$SESSION_MJS" "$REMINDER_MJS"; do
for f in $HOOK_MJS; do
if [ -f "$HOOKS_DIR/$f" ] && cmp -s "$SRC/$f" "$HOOKS_DIR/$f"; then
add_unchanged "hooks: $f already current"
continue
@@ -302,7 +309,7 @@ install_hook_files() {
remove_hook_files() {
local f
for f in "$SESSION_MJS" "$REMINDER_MJS"; do
for f in $HOOK_MJS; do
if [ -e "$HOOKS_DIR/$f" ]; then
if [ "${SEMBLE_DRY_RUN:-}" = "1" ]; then sc_dry "rm $HOOKS_DIR/$f" >/dev/null; add_changed "hooks: would remove $HOOKS_DIR/$f"; continue; fi
rm -f "$HOOKS_DIR/$f" && add_changed "hooks: removed $HOOKS_DIR/$f" || add_failed "hooks: cannot remove $HOOKS_DIR/$f"
@@ -319,10 +326,11 @@ merge_settings() {
const fs=require("fs"), path=require("path");
const f=process.env.SG_SETTINGS, dir=process.env.SG_HOOKS;
const doHooks=process.env.SG_DO_HOOKS==="1", doPerms=process.env.SG_DO_PERMS==="1";
const marks=["semble-session.mjs","semble-reminder.mjs"];
const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"];
const want=[["SessionStart",null,"semble-session.mjs",5000],
["PreToolUse","Bash","semble-reminder.mjs",5000],
["PreToolUse","Grep","semble-reminder.mjs",5000]];
["PreToolUse","Grep","semble-reminder.mjs",5000],
["SubagentStart","Explore","semble-explore.mjs",5000]];
const tools=[process.env.SG_SEARCH,process.env.SG_RELATED];
let s={};
if(fs.existsSync(f)){
@@ -393,7 +401,7 @@ unmerge_settings() {
const fs=require("fs");
const f=process.env.SG_SETTINGS;
const doHooks=process.env.SG_DO_HOOKS==="1", doPerms=process.env.SG_DO_PERMS==="1";
const marks=["semble-session.mjs","semble-reminder.mjs"];
const marks=["semble-session.mjs","semble-reminder.mjs","semble-explore.mjs"];
const tools=[process.env.SG_SEARCH,process.env.SG_RELATED];
if(!fs.existsSync(f)){ console.log("no settings to clean: "+f); process.exit(0); }
const raw=fs.readFileSync(f,"utf8");
@@ -194,6 +194,7 @@ process.stdout.write(((j.changed||[]).length)+" "+((j.skipped||[]).length));')"
sr_rm_file "$root/.claude/rules/semble-first.md" "rule"
sr_rm_file "$root/.claude/hooks/semble-session.mjs" "hook"
sr_rm_file "$root/.claude/hooks/semble-reminder.mjs" "hook"
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"
}
@@ -330,6 +331,7 @@ sr_plan() {
sr_would "$root/.claude/rules/semble-first.md"
sr_would "$root/.claude/hooks/semble-session.mjs"
sr_would "$root/.claude/hooks/semble-reminder.mjs"
sr_would "$root/.claude/hooks/semble-explore.mjs"
sr_would "$root/CLAUDE.md marker block $SR_CLAUDEMD_BEGIN .. $SR_CLAUDEMD_END"
sr_would "$root/.claude/semble/"
;;
@@ -339,6 +341,7 @@ sr_plan() {
sr_would "$root/.claude/rules/semble-first.md"
sr_would "$root/.claude/hooks/semble-session.mjs"
sr_would "$root/.claude/hooks/semble-reminder.mjs"
sr_would "$root/.claude/hooks/semble-explore.mjs"
sr_would "$root/CLAUDE.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)"
@@ -252,6 +252,7 @@ if (guidRaw === null || isErr(guidRaw)) {
const h = (guidRaw.hooks && typeof guidRaw.hooks === "object") ? guidRaw.hooks : {};
const ses = (h.session && typeof h.session === "object") ? h.session : {};
const rem = (h.reminder && typeof h.reminder === "object") ? h.reminder : {};
const exp = (h.explore && typeof h.explore === "object") ? h.explore : {};
const rule = (guidRaw.rule && typeof guidRaw.rule === "object") ? guidRaw.rule : {};
const cmd = (guidRaw.claudeMd && typeof guidRaw.claudeMd === "object") ? guidRaw.claudeMd : {};
const perm = (guidRaw.permissions && typeof guidRaw.permissions === "object") ? guidRaw.permissions : {};
@@ -262,11 +263,13 @@ if (guidRaw === null || isErr(guidRaw)) {
hooks: {
session: ses.file === "present" ? "present" : "missing",
reminder: rem.file === "present" ? "present" : "missing",
explore: exp.file === "present" ? "present" : "missing",
},
permissionsWired: perm.wired === true,
// Read the authoritative sibling count (SessionStart + PreToolUse/Bash +
// PreToolUse/Grep). Never re-derive it from the two `wired` booleans: the
// reminder spans two matchers, so a half-wired reminder loses one entry.
// PreToolUse/Grep + SubagentStart/Explore). Never re-derive it from the
// `wired` booleans: the reminder spans two matchers, so a half-wired
// reminder loses one entry.
wiredCount: typeof h.wiredCount === "number" ? h.wiredCount : 0,
staleEntries: typeof h.staleEntries === "number" ? h.staleEntries : 0,
};
@@ -408,7 +411,7 @@ if (jsonMode) {
} else {
const g = report.guidance;
L.push("guidance: rule " + g.rule + " | CLAUDE.md " + g.claudeMd +
" | hooks " + g.wiredCount + "/3 wired" +
" | hooks " + g.wiredCount + "/4 wired" +
" | permissions " + (g.permissionsWired ? "yes" : "no"));
}
}
+189 -33
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* suite-hooks.mjs unit D: rule template, CLAUDE.md marker block, the two
* suite-hooks.mjs unit D: rule template, CLAUDE.md marker block, the three
* hooks, and the settings.json merge performed by semble-guidance.sh.
*
* Self-contained: inlines its own check()/run() helpers, runs standalone
@@ -27,6 +27,7 @@ const SCRIPTS = join(SKILL, 'scripts');
const TEMPLATE_SRC = join(ASSETS, 'semble-first.md.template');
const SESSION_SRC = join(ASSETS, 'semble-session.mjs');
const REMINDER_SRC = join(ASSETS, 'semble-reminder.mjs');
const EXPLORE_SRC = join(ASSETS, 'semble-explore.mjs');
const BASE = realpathSync(mkdtempSync(join(tmpdir(), 'semble-d-')));
const HOME = join(BASE, 'home');
@@ -93,7 +94,7 @@ const SKILL_COPY = join(BASE, 'skill');
mkdirSync(join(SKILL_COPY, 'scripts', 'lib'), { recursive: true });
mkdirSync(join(SKILL_COPY, 'assets'), { recursive: true });
copyFileSync(join(SCRIPTS, 'semble-guidance.sh'), join(SKILL_COPY, 'scripts', 'semble-guidance.sh'));
for (const f of ['semble-first.md.template', 'semble-session.mjs', 'semble-reminder.mjs']) {
for (const f of ['semble-first.md.template', 'semble-session.mjs', 'semble-reminder.mjs', 'semble-explore.mjs']) {
copyFileSync(join(ASSETS, f), join(SKILL_COPY, 'assets', f));
}
@@ -194,7 +195,11 @@ function countEntry(s, ev, matcher, full) {
function semblePaths(proj) {
const d = hooksDirOf(proj);
return { session: join(d, 'semble-session.mjs'), reminder: join(d, 'semble-reminder.mjs') };
return {
session: join(d, 'semble-session.mjs'),
reminder: join(d, 'semble-reminder.mjs'),
explore: join(d, 'semble-explore.mjs'),
};
}
const READY_STATE = (extra) =>
@@ -254,7 +259,7 @@ const REMIND_OK = (cwd) => ({
check('A1.statusTrailingNewline', guidance(p, ['status', '--json']).stdout.endsWith('\n'), true,
'status --json carries the same trailing newline');
const s = readSettings(p);
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = semblePaths(p);
check('A1.sessionEntry', s.hooks.SessionStart, [
{ hooks: [{ type: 'command', command: 'node', args: [session], timeout: 5000 }] },
], 'SessionStart entry has the exact contract shape with an explicit 5000 ms timeout');
@@ -262,9 +267,13 @@ const REMIND_OK = (cwd) => ({
{ hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5000 }], matcher: 'Bash' },
{ hooks: [{ type: 'command', command: 'node', args: [rem], timeout: 5000 }], matcher: 'Grep' },
], 'the reminder is registered once under Bash and once under Grep');
check('A1.subagentStart', s.hooks.SubagentStart, [
{ hooks: [{ type: 'command', command: 'node', args: [exp], timeout: 5000 }], matcher: 'Explore' },
], 'the explore hook is registered once under SubagentStart with matcher Explore');
check('A1.perm', s.permissions.allow, ['mcp__semble_code__search', 'mcp__semble_code__find_related'],
'both MCP tool names land in permissions.allow');
check('A1.files', [existsSync(session), existsSync(rem)], [true, true], 'both .mjs assets were copied into .claude/hooks');
check('A1.files', [existsSync(session), existsSync(rem), existsSync(exp)], [true, true, true],
'all three .mjs assets were copied into .claude/hooks');
check('A1.rule', readRaw(join(p, '.claude', 'rules', 'semble-first.md')), TPL_TEXT,
'the rule file is byte-identical to the template');
}
@@ -283,12 +292,13 @@ const REMIND_OK = (cwd) => ({
check('A2.bytes2', after2, after1, 'settings.json is byte-identical after run 2');
check('A2.bytes3', after3, after1, 'settings.json is byte-identical after run 3');
const s = readSettings(p);
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = semblePaths(p);
check('A2.counts', [
countEntry(s, 'SessionStart', null, session),
countEntry(s, 'PreToolUse', 'Bash', rem),
countEntry(s, 'PreToolUse', 'Grep', rem),
], [1, 1, 1], 'exactly one entry per event+matcher after three merges');
countEntry(s, 'SubagentStart', 'Explore', exp),
], [1, 1, 1, 1], 'exactly one entry per event+matcher 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,
@@ -322,12 +332,13 @@ const FOREIGN = {
check('A3.allow', s.permissions.allow,
['Bash(git *)', 'mcp__semble_code__search', 'mcp__semble_code__find_related'],
'the two tool names are appended after the existing allow entries');
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = semblePaths(p);
check('A3.counts', [
countEntry(s, 'SessionStart', null, session),
countEntry(s, 'PreToolUse', 'Bash', rem),
countEntry(s, 'PreToolUse', 'Grep', rem),
], [1, 1, 1], 'exactly one semble entry per event+matcher alongside the foreign ones');
countEntry(s, 'SubagentStart', 'Explore', exp),
], [1, 1, 1, 1], 'exactly one semble entry per event+matcher alongside the foreign ones');
}
// A4 — unparseable settings ABORTs and writes nothing
@@ -362,12 +373,13 @@ const FOREIGN = {
const flat = Object.values(s.hooks).flat();
check('A5.staleGone', flat.filter((e) => argsOf(e).some((a) => a.startsWith(staleDir))).length, 0,
'zero entries still point at the old hooks dir');
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = semblePaths(p);
check('A5.counts', [
countEntry(s, 'SessionStart', null, session),
countEntry(s, 'PreToolUse', 'Bash', rem),
countEntry(s, 'PreToolUse', 'Grep', rem),
], [1, 1, 1], 'the new-dir entries were added exactly once each');
countEntry(s, 'SubagentStart', 'Explore', exp),
], [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');
}
@@ -376,7 +388,7 @@ const FOREIGN = {
{
const p = freshProject({});
guidance(p, ['install', '--part', 'all', '--json']);
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = 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);
@@ -384,7 +396,8 @@ const FOREIGN = {
'the hooks object is deleted once every event array empties');
check('A6.permissionsKey', Object.prototype.hasOwnProperty.call(s, 'permissions'), false,
'the permissions object is deleted once allow empties');
check('A6.files', [existsSync(session), existsSync(rem)], [false, false], 'both .mjs files are deleted');
check('A6.files', [existsSync(session), existsSync(rem), existsSync(exp)], [false, false, false],
'all three .mjs files are deleted');
check('A6.rule', existsSync(join(p, '.claude', 'rules', 'semble-first.md')), false, 'the managed rule file is deleted');
}
@@ -406,20 +419,61 @@ const FOREIGN = {
const before = guidance(p, ['status', '--json']);
const b = safeParse(before.stdout);
check('A8.beforeRule', b.rule.state, 'absent', 'status reports an absent rule before install');
check('A8.beforeWired', [b.hooks.session.wired, b.hooks.reminder.wired, b.permissions.wired],
[false, false, false], 'nothing is reported as wired before install');
check('A8.beforeWired',
[b.hooks.session.wired, b.hooks.reminder.wired, b.hooks.explore.wired, b.permissions.wired],
[false, false, false, false], 'nothing is reported as wired before install');
guidance(p, ['install', '--part', 'all', '--json']);
const after = guidance(p, ['status', '--json']);
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.reminder.wired, a.permissions.wired],
[true, true, true], 'session hook, reminder (both matchers) and permissions all report wired');
check('A8.afterWired',
[a.hooks.session.wired, a.hooks.reminder.wired, a.hooks.explore.wired, a.permissions.wired],
[true, true, true, true],
'session hook, reminder (both matchers), explore hook and permissions all report wired');
check('A8.afterFiles', [a.hooks.session.file, a.hooks.reminder.file, a.hooks.explore.file],
['present', 'present', 'present'], 'status sees all three hook files on disk');
check('A8.stale', a.hooks.staleEntries, 0, 'no stale entries after a clean install');
check('A8.wiredCount', a.hooks.wiredCount, 3, 'all 3 settings entries are counted as wired');
check('A8.wiredCount', a.hooks.wiredCount, 4, 'all 4 settings entries are counted as wired');
check('A8.exitReadOnly', before.status, 0, 'status exits 0');
}
// A9 — a half-wired install is reported honestly, never rounded up
{
const p = freshProject({});
guidance(p, ['install', '--part', 'all', '--json']);
const { explore: exp } = semblePaths(p);
const s = readSettings(p);
delete s.hooks.SubagentStart; // the old two hooks stay wired
writeFileSync(settingsPath(p), JSON.stringify(s, null, 2) + '\n');
const a = safeParse(guidance(p, ['status', '--json']).stdout);
check('A9.wiredCount', a.hooks.wiredCount, 3,
'dropping the SubagentStart entry reports 3 of 4, not "wired"');
check('A9.exploreWired', [a.hooks.session.wired, a.hooks.reminder.wired, a.hooks.explore.wired],
[true, true, false], 'only the explore entry is reported as unwired');
check('A9.exploreFileStillThere', [a.hooks.explore.file, existsSync(exp)], ['present', true],
'the file is still on disk — file presence and wiring are reported separately');
const human = guidance(p, ['status']).stdout;
check('A9.human', human.includes('hooks 3/4 wired'), true,
'the human line spells the partial count out as 3/4');
}
// A10 — remove takes the explore registration with the file
{
const p = freshProject({});
guidance(p, ['install', '--part', 'hooks', '--json']);
const { explore: exp } = semblePaths(p);
const r = guidance(p, ['remove', '--part', 'hooks', '--json']);
check('A10.exit', r.status, 0, 'remove --part hooks exits 0');
const s = readSettings(p);
check('A10.registrationGone', Object.prototype.hasOwnProperty.call(s.hooks || {}, 'SubagentStart'), false,
'the SubagentStart array emptied and its key was pruned');
check('A10.fileGone', existsSync(exp), false, 'the explore .mjs is deleted too');
check('A10.noDanglingPath',
JSON.stringify(s).includes('semble-explore.mjs'), false,
'no settings entry is left pointing at the deleted file');
}
// ═══════════════════════════════════════════════════════════════════════════
// B. rule file policy
// ═══════════════════════════════════════════════════════════════════════════
@@ -672,23 +726,121 @@ for (const [name, command, why] of SILENT_BASH) {
'every reminder invocation printed exactly one line of JSON');
}
// ═══════════════════════════════════════════════════════════════════════════
// X. SubagentStart explore hook — matcher-gated, advisory only
// ═══════════════════════════════════════════════════════════════════════════
const allExploreOutputs = [];
function explore(proj, agentType, extra) {
const payload = { session_id: 'S1', cwd: proj, hook_event_name: 'SubagentStart', ...(extra || {}) };
if (agentType !== undefined) payload.agent_type = agentType;
const r = runNode(EXPLORE_SRC, JSON.stringify(payload));
allExploreOutputs.push(r.stdout);
return r;
}
const EXPLORE_OK = (cwd) => ({
hookSpecificOutput: {
hookEventName: 'SubagentStart',
additionalContext:
'semble: call mcp__semble_code__search directly first (repo="' + cwd +
'", top_k=5) for intent/behavior questions — it is already available, no ' +
'ToolSearch needed. rg/Grep stay for exact/exhaustive matches.',
},
});
{
const p = freshProject({ state: READY_STATE() });
const r = explore(p, 'Explore');
check('X1.ready', safeParse(r.stdout), EXPLORE_OK(p), 'Explore on a ready project gets the exact advisory string');
check('X1.exit', r.status, 0, 'the emitting path exits 0');
const again = explore(p, 'Explore');
check('X2.noThrottle', safeParse(again.stdout), EXPLORE_OK(p),
'a second spawn is advised again — this hook has no throttle');
check('X2.noMarker', existsSync(join(p, '.claude', 'semble', '.reminder-ts')), false,
'the explore hook never writes the reminder throttle marker');
}
const SILENT_AGENTS = [
['X3.generalPurpose', 'general-purpose'],
['X4.plan', 'Plan'],
['X5.projectAgent', 'brewcode:developer'],
['X6.lowercase', 'explore'],
['X7.emptyType', ''],
];
for (const [name, agentType] of SILENT_AGENTS) {
const p = freshProject({ state: READY_STATE() });
check(name, safeParse(explore(p, agentType).stdout), {},
`silent for agent_type=\`${agentType}\` — only the exact string Explore is matched`);
}
{
const p = freshProject({ state: READY_STATE() });
check('X8.missingType', safeParse(explore(p, undefined).stdout), {}, 'silent when agent_type is absent');
}
{
const p = freshProject({ state: READY_STATE() });
check('X9.nonStringType', safeParse(explore(p, 42).stdout), {}, 'silent when agent_type is not a string');
}
{
const p = freshProject({});
const r = explore(p, 'Explore');
check('X10.noState', [r.status, safeParse(r.stdout)], [0, {}], 'no state file -> {} exit 0');
}
{
const p = freshProject({ state: '{,}' });
const r = explore(p, 'Explore');
check('X11.corrupt', [r.status, safeParse(r.stdout)], [0, {}],
'a corrupt state file -> {} exit 0 — the explore hook never reports state health');
}
{
const p = freshProject({ stateDir: true });
const r = explore(p, 'Explore');
check('X12.stateDir', [r.status, safeParse(r.stdout)], [0, {}], 'state.json as a directory -> {} exit 0');
}
{
const p = freshProject({ state: READY_STATE({ enabled: false }) });
check('X13.disabled', safeParse(explore(p, 'Explore').stdout), {}, 'silent when the project has semble disabled');
}
const SILENT_PHASES = ['awaiting_reload', 'verifying', 'disabled', 'error', 'prereq_ready'];
for (const phase of SILENT_PHASES) {
const p = freshProject({ state: READY_STATE({ phase }) });
check(`X14.phase.${phase}`, safeParse(explore(p, 'Explore').stdout), {},
`silent while phase is \`${phase}\` — only phase=ready is advised`);
}
{
const bad = runNode(EXPLORE_SRC, '{ not json');
const empty = runNode(EXPLORE_SRC, '');
allExploreOutputs.push(bad.stdout, empty.stdout);
check('X15.badStdin', [bad.status, safeParse(bad.stdout)], [0, {}], 'explore: malformed stdin -> {} exit 0');
check('X16.emptyStdin', [empty.status, safeParse(empty.stdout)], [0, {}], 'explore: empty stdin -> {} exit 0');
}
{
const joined = allExploreOutputs.join('\n');
check('X17.noDecision', joined.includes('permissionDecision'), false, 'no recorded output ever carries permissionDecision');
check('X17.noDeny', joined.includes('"deny"'), false, 'no recorded output ever carries a deny');
check('X17.oneObject', allExploreOutputs.every((o) => o.trim().split('\n').length === 1), true,
'every explore invocation printed exactly one line of JSON');
}
// ═══════════════════════════════════════════════════════════════════════════
// F. static guarantees of both hook files
// ═══════════════════════════════════════════════════════════════════════════
{
const src = [readFileSync(SESSION_SRC, 'utf8'), readFileSync(REMINDER_SRC, 'utf8')];
const src = [readFileSync(SESSION_SRC, 'utf8'), readFileSync(REMINDER_SRC, 'utf8'),
readFileSync(EXPLORE_SRC, 'utf8')];
const both = src.join('\n');
check('F1.noChildProcess', both.includes('child_process'), false, 'neither hook imports child_process');
check('F2.noSpawn', both.includes('spawn('), false, 'neither hook spawns a process');
check('F3.noPgrep', both.includes('pgrep'), false, 'neither hook probes for a daemon with pgrep');
check('F4.noExecSync', both.includes('execSync'), false, 'neither hook shells out');
check('F1.noChildProcess', both.includes('child_process'), false, 'no hook imports child_process');
check('F2.noSpawn', both.includes('spawn('), false, 'no hook spawns a process');
check('F3.noPgrep', both.includes('pgrep'), false, 'no hook probes for a daemon with pgrep');
check('F4.noExecSync', both.includes('execSync'), false, 'no hook shells out');
check('F5.reminderNeverDecides', readFileSync(REMINDER_SRC, 'utf8').includes('permissionDecision:'), false,
'the reminder source contains no permissionDecision field');
check('F6.notABlock', readFileSync(REMINDER_SRC, 'utf8').includes('this is a reminder, not a block.'), true,
'the advisory text contains the words "reminder, not a block"');
check('F7.shebang', src.every((s) => s.startsWith('#!/usr/bin/env node')), true, 'both hooks carry a node shebang');
const checks = [SESSION_SRC, REMINDER_SRC].map((f) => spawnSync(process.execPath, ['--check', f]).status);
check('F8.nodeCheck', checks, [0, 0], 'node --check passes on both hook files');
check('F7.shebang', src.every((s) => s.startsWith('#!/usr/bin/env node')), true, 'every hook carries a node shebang');
const checks = [SESSION_SRC, REMINDER_SRC, EXPLORE_SRC]
.map((f) => spawnSync(process.execPath, ['--check', f]).status);
check('F8.nodeCheck', checks, [0, 0, 0], 'node --check passes on all three hook files');
check('F9.exploreNeverDecides', readFileSync(EXPLORE_SRC, 'utf8').includes('permissionDecision'), false,
'the explore hook source contains no permissionDecision field');
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -727,14 +879,15 @@ const PRE_MD = '# CLAUDE.md\n\n## Overview\n\nproject text\n';
// H1 — install aborts on an unparseable settings.json BEFORE touching anything
{
const p = freshProject({ settings: BROKEN_SETTINGS, claudeMd: PRE_MD });
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = semblePaths(p);
const r = guidance(p, ['install', '--part', 'all', '--json']);
check('H1.exit', r.status, 1, 'install --part all over unparseable settings exits 1');
check('H1.abort', (r.stdout + r.stderr).includes('ABORT'), true, 'the failure names ABORT');
check('H1.settings', readRaw(settingsPath(p)), BROKEN_SETTINGS, 'the unparseable settings file is byte-identical');
check('H1.rule', existsSync(join(p, '.claude', 'rules', 'semble-first.md')), false, 'no rule file was written');
check('H1.claudeMd', readRaw(join(p, 'CLAUDE.md')), PRE_MD, 'CLAUDE.md is byte-identical, no marker block');
check('H1.hookFiles', [existsSync(session), existsSync(rem)], [false, false], 'neither .mjs hook file was written');
check('H1.hookFiles', [existsSync(session), existsSync(rem), existsSync(exp)], [false, false, false],
'no .mjs hook file was written');
check('H1.hooksDir', existsSync(hooksDirOf(p)), false, 'the .claude/hooks directory was never created');
}
@@ -744,9 +897,10 @@ const PRE_MD = '# CLAUDE.md\n\n## Overview\n\nproject text\n';
guidance(p, ['install', '--part', 'all', '--json']);
const rulePath = join(p, '.claude', 'rules', 'semble-first.md');
const mdPath = join(p, 'CLAUDE.md');
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = semblePaths(p);
const before = {
rule: readRaw(rulePath), md: readRaw(mdPath), session: readRaw(session), reminder: readRaw(rem),
explore: readRaw(exp),
};
writeFileSync(settingsPath(p), BROKEN_SETTINGS);
const r = guidance(p, ['remove', '--part', 'all', '--json']);
@@ -755,8 +909,9 @@ const PRE_MD = '# CLAUDE.md\n\n## Overview\n\nproject text\n';
check('H2.settings', readRaw(settingsPath(p)), BROKEN_SETTINGS, 'the unparseable settings file is byte-identical');
check('H2.rule', readRaw(rulePath), before.rule, 'the rule file is still there, byte-identical');
check('H2.claudeMd', readRaw(mdPath), before.md, 'the CLAUDE.md marker block is still there, byte-identical');
check('H2.hookFiles', [readRaw(session), readRaw(rem)], [before.session, before.reminder],
'both .mjs hook files are still there, byte-identical — settings.json may still reference them');
check('H2.hookFiles', [readRaw(session), readRaw(rem), readRaw(exp)],
[before.session, before.reminder, before.explore],
'all three .mjs hook files are still there, byte-identical — settings.json may still reference them');
}
// H3 — a second install reports nothing as changed
@@ -807,7 +962,7 @@ const FOREIGN_HOOK = { type: 'command', command: 'node', args: ['/opt/foreign/gu
check('H5.exit', r.status, 0, 'merge over a mixed foreign+stale entry exits 0');
const s = readSettings(p);
const pre = (s.hooks || {}).PreToolUse || [];
const { session, reminder: rem } = semblePaths(p);
const { session, reminder: rem, explore: exp } = semblePaths(p);
check('H5.mixedEntry', pre[0] || null, { matcher: 'Bash', hooks: [FOREIGN_HOOK] },
'the mixed entry keeps the foreign hook and loses only the stale semble hook');
check('H5.staleGone', Object.values(s.hooks || {}).flat().filter((e) => argsOf(e).some((a) => a.startsWith(staleDir))).length, 0,
@@ -816,7 +971,8 @@ const FOREIGN_HOOK = { type: 'command', command: 'node', args: ['/opt/foreign/gu
countEntry(s, 'SessionStart', null, session),
countEntry(s, 'PreToolUse', 'Bash', rem),
countEntry(s, 'PreToolUse', 'Grep', rem),
], [1, 1, 1], 'exactly one current entry per event+matcher');
countEntry(s, 'SubagentStart', 'Explore', exp),
], [1, 1, 1, 1], 'exactly one current entry per event+matcher');
check('H5.preToolUseSize', pre.length, 3,
'the repaired foreign entry plus the two appended semble entries');
}
@@ -444,26 +444,28 @@ check('wired: --strict exit', strict.status, 0, '--strict exits 0 exactly when t
check('guidance: keys', keysOf(R.guidance),
['claudeMd', 'hooks', 'permissionsWired', 'rule', 'settingsFile', 'staleEntries', 'wiredCount'],
'the §9.1 guidance shape plus the derived wiredCount');
check('guidance: hooks sub-keys', keysOf(R.guidance.hooks), ['reminder', 'session'],
'hooks collapses to two file-presence strings');
check('guidance: hooks sub-keys', keysOf(R.guidance.hooks), ['explore', 'reminder', 'session'],
'hooks collapses to three file-presence strings');
check('guidance: flattened states',
[R.guidance.rule, R.guidance.claudeMd, R.guidance.hooks.session, R.guidance.hooks.reminder],
[R.guidance.rule, R.guidance.claudeMd, R.guidance.hooks.session, R.guidance.hooks.reminder,
R.guidance.hooks.explore],
[rawGuid.rule.state, rawGuid.claudeMd.state,
rawGuid.hooks.session.file, rawGuid.hooks.reminder.file],
rawGuid.hooks.session.file, rawGuid.hooks.reminder.file, rawGuid.hooks.explore.file],
'each flattened field equals the sibling sub-object it was taken from');
check('guidance: installed states', [R.guidance.rule, R.guidance.claudeMd,
R.guidance.hooks.session, R.guidance.hooks.reminder],
['managed', 'present', 'present', 'present'],
'after a real install: managed rule, marker block in CLAUDE.md, both hook files copied');
R.guidance.hooks.session, R.guidance.hooks.reminder, R.guidance.hooks.explore],
['managed', 'present', 'present', 'present', 'present'],
'after a real install: managed rule, marker block in CLAUDE.md, all three hook files copied');
check('guidance: settingsFile + staleEntries + permissionsWired',
[R.guidance.settingsFile, R.guidance.staleEntries, R.guidance.permissionsWired],
[join(P1, '.claude/settings.json'), 0, true],
'project settings path, no stale entries, both tool permissions wired');
check('guidance: wiredCount fully wired', [R.guidance.wiredCount, rawGuid.hooks.wiredCount], [3, 3],
'all three entries (SessionStart + PreToolUse/Bash + PreToolUse/Grep) are registered');
check('guidance: wiredCount fully wired', [R.guidance.wiredCount, rawGuid.hooks.wiredCount], [4, 4],
'all four entries (SessionStart + PreToolUse/Bash + PreToolUse/Grep + SubagentStart/Explore)'
+ ' are registered');
// Partial wiring: strip the PreToolUse/Grep entry only. Independent truth is
// 2 of 3 registered entries, which is also what semble-guidance.sh reports.
// 3 of 4 registered entries, which is also what semble-guidance.sh reports.
const P2 = join(WORLD, 'p-partial');
const p2Env = { SEMBLE_PROJECT_ROOT: P2 };
write(join(P2, 'src/app.py'), `def main():\n return 1\n# ${PAD}\n`);
@@ -482,14 +484,16 @@ check('partial: exactly one Grep entry was removed',
const rawGuid2 = safeParse(run(GUIDANCE_SH, ['status', '--json'], p2Env).stdout);
const R2 = safeParse(runStatus(['--section', 'guidance', '--json'], p2Env).stdout);
check('partial: sibling counts 2 of 3 entries',
[rawGuid2.hooks.session.wired, rawGuid2.hooks.reminder.wired, rawGuid2.hooks.wiredCount],
[true, false, 2],
'semble-guidance.sh:128 counts registered entries: SessionStart + Bash = 2, reminder not fully wired');
check('partial: sibling counts 3 of 4 entries',
[rawGuid2.hooks.session.wired, rawGuid2.hooks.reminder.wired, rawGuid2.hooks.explore.wired,
rawGuid2.hooks.wiredCount],
[true, false, true, 3],
'semble-guidance.sh counts registered entries: SessionStart + Bash + Explore = 3,'
+ ' reminder not fully wired');
check('partial: guidance.wiredCount agrees with the sibling',
[R2.guidance.wiredCount, rawGuid2.hooks.wiredCount], [2, 2],
[R2.guidance.wiredCount, rawGuid2.hooks.wiredCount], [3, 3],
'status reads guidance.hooks.wiredCount instead of re-deriving it, so a half-wired reminder'
+ ' (SessionStart + Bash present, Grep gone) reports 2/3 on both sides');
+ ' (SessionStart + Bash + Explore present, Grep gone) reports 3/4 on both sides');
check('partial: section filter emits guidance only', keysOf(R2),
['generatedAt', 'guidance', 'nextStep', 'pin', 'platform', 'projectRoot', 'schema', 'verdict'],
'--section guidance adds exactly one section to the header + verdict');
@@ -512,10 +516,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/3 on both sides');
'nothing installed => 0/4 on both sides');
check('bare: guidance states', [R3.guidance.rule, R3.guidance.claudeMd,
R3.guidance.hooks.session, R3.guidance.hooks.reminder, R3.guidance.permissionsWired],
['absent', 'absent', 'missing', 'missing', false], 'an untouched project reports everything absent');
R3.guidance.hooks.session, R3.guidance.hooks.reminder, R3.guidance.hooks.explore,
R3.guidance.permissionsWired],
['absent', 'absent', '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])), [],
'even with nothing installed every section is produced by its real sibling');
@@ -700,8 +706,9 @@ check('lifecycle: guidance install exit', lifeGuid.status, 0, 'guidance installe
check('lifecycle: guidance artefacts on disk',
[existsSync(join(LIFE, '.claude/rules/semble-first.md')),
existsSync(join(LIFE, '.claude/hooks/semble-session.mjs')),
existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs'))],
[true, true, true], 'rule + both hook assets landed in the project');
existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs')),
existsSync(join(LIFE, '.claude/hooks/semble-explore.mjs'))],
[true, true, true, true], 'rule + all three hook assets landed in the project');
// resume: a new session observes the live server, verifies, goes ready
run(STATE_SH, ['phase', 'verifying'], lifeEnv);
@@ -752,8 +759,9 @@ check('lifecycle: guidance + state removed',
[existsSync(join(LIFE, '.claude/semble')),
existsSync(join(LIFE, '.claude/rules/semble-first.md')),
existsSync(join(LIFE, '.claude/hooks/semble-session.mjs')),
existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs'))],
[false, false, false, false], 'state dir, rule and both hooks are gone');
existsSync(join(LIFE, '.claude/hooks/semble-reminder.mjs')),
existsSync(join(LIFE, '.claude/hooks/semble-explore.mjs'))],
[false, false, false, false, false], 'state dir, rule and all three hooks are gone');
check('lifecycle: ~/.claude.json byte-identical', sha(LIFE_CJ), claudeJsonBefore,
'remove integration leaves the MCP registration untouched');
check('lifecycle: claude mcp remove was never called',
@@ -168,7 +168,7 @@ Subsequent searches are sub-second and require the absolute repo path:
| Docs cache root | same path with a `semble-docs` leaf — created empty, **reserved, never registered** |
| State | `<repo>/.claude/semble/state.json` |
| Rule | `<repo>/.claude/rules/semble-first.md` |
| Hooks | SessionStart + a PreToolUse advisory reminder |
| Hooks | `semble-session.mjs` (SessionStart) + `semble-reminder.mjs` (PreToolUse `Bash`/`Grep`, advisory) + `semble-explore.mjs` (SubagentStart `Explore` — primes the spawned Explore subagent to call `mcp__semble_code__search` directly, skipping its own `ToolSearch`) — `hooks <n>/4 wired` in status, silent unless phase is `ready` and `enabled` |
| Agents | project `.claude/agents/**/*.md` get the two tool names added to `tools:`; global agents are never touched |
### Reload boundary