mirror of
https://github.com/kochetkov-ma/claude-brewcode.git
synced 2026-09-14 20:16:41 +08:00
feat(semble): install SubagentStart Explore hook that primes semble-first search
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user