klypix-mcp 1.80.0 → 1.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -669,7 +669,7 @@ keep lazy first-use indexing instead.
669
669
  file paths involved). The scope is versioned: an older metadata-only grant does not authorize note
670
670
  text and must be granted again. No current consent, no frames.
671
671
  - **`install` writes to your home directory:** `~/.claude/project-brain` (engine + runtime),
672
- `~/.claude/settings.json` (four hooks — written even if Claude Code is not installed),
672
+ `~/.claude/settings.json` (five hooks — written even if Claude Code is not installed),
673
673
  `~/.codex/AGENTS.md` (guidance block), and with `--codex-hooks`, `~/.codex/hooks.json`. It also
674
674
  writes `<cwd>/.codex/config.toml` **inside the project** you run it in, and removes any KLYPIX
675
675
  entry from the global `~/.codex/config.toml`. **`link` writes 14 files inside the project** you
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // klypix-install — lay the project-brain down into ~/.claude/project-brain from THIS
3
- // package and wire the 4 Claude Code hooks into ~/.claude/settings.json. This makes
3
+ // package and wire the 5 Claude Code hooks into ~/.claude/settings.json. This makes
4
4
  // npm the SINGLE delivery for the WHOLE brain (hook + engine + local MCP/A2A
5
5
  // servers), so one `gh release create` (OIDC auto-publish) + `npx klypix-mcp install`
6
6
  // updates every brain on a machine — the global ~/.claude/project-brain copy serves
@@ -427,7 +427,7 @@ try {
427
427
  type: 'module',
428
428
  }, null, 2));
429
429
 
430
- // 5) wire the 4 hooks into settings.json (refuse on invalid JSON; back up;
430
+ // 5) wire the 5 hooks into settings.json (refuse on invalid JSON; back up;
431
431
  // atomic). A background runtime-only update refreshes the scripts while
432
432
  // deliberately preserving every host/project config byte.
433
433
  const brainCmd = (arg) => `node "${fwd(path.join(BRAIN_DIR, 'global-brain-hook.mjs'))}"${arg ? ' ' + arg : ''}`;
@@ -436,6 +436,10 @@ try {
436
436
  ['UserPromptSubmit', { hooks: [{ type: 'command', command: brainCmd('--prompt'), timeout: 10 }] }],
437
437
  ['Stop', { hooks: [{ type: 'command', command: brainCmd('--capture') }] }],
438
438
  ['PostToolUse', { matcher: 'Bash|PowerShell|Edit|Write', hooks: [{ type: 'command', command: brainCmd('--live'), timeout: 10 }] }],
439
+ // Guard cards (2026-08-24): pre-action lane. Fast path — reads only the
440
+ // compiled guard sidecar; 'warn' injects context, 'block' denies with
441
+ // the card's message. Absent guards = stat + one tiny read, then no-op.
442
+ ['PreToolUse', { matcher: 'Bash|PowerShell|Edit|Write', hooks: [{ type: 'command', command: brainCmd('--guard'), timeout: 10 }] }],
439
443
  ];
440
444
  const stripOurs = (arr) => (Array.isArray(arr) ? arr : [])
441
445
  .map(g => (g && Array.isArray(g.hooks)) ? { ...g, hooks: g.hooks.filter(h => !(typeof h?.command === 'string' && h.command.includes(HOOK_MARK))) } : g)
@@ -506,12 +510,12 @@ try {
506
510
  // Codex needs native MCP tools, conditional guidance, and lifecycle presence.
507
511
  const codex = RUNTIME_ONLY ? null : wireCodex();
508
512
 
509
- // 8) READINESS check — re-read what we just wrote and confirm all 4 hooks actually
513
+ // 8) READINESS check — re-read what we just wrote and confirm all 5 hooks actually
510
514
  // took (a malformed pre-existing group, a partial merge, or a later hand-edit can
511
515
  // leave the brain LIVE but not LEARNING — liveness ≠ readiness). Warn, don't fail.
512
516
  const verify = RUNTIME_ONLY ? null : (() => { try { return JSON.parse(fs.readFileSync(SETTINGS, 'utf8')); } catch { return null; } })();
513
517
  const wiredFor = (evt) => Array.isArray(verify?.hooks?.[evt]) && verify.hooks[evt].some(g => Array.isArray(g?.hooks) && g.hooks.some(h => typeof h?.command === 'string' && h.command.includes(HOOK_MARK)));
514
- const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'].filter(e => !wiredFor(e));
518
+ const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse', 'PreToolUse'].filter(e => !wiredFor(e));
515
519
 
516
520
  releaseInstallLockSync(installLock);
517
521
  // Users who already enabled the optional local semantic runtime should not
@@ -538,13 +542,13 @@ try {
538
542
  if (!RUNTIME_ONLY) reportCodex(codex);
539
543
  console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
540
544
  if (RUNTIME_ONLY) console.log('✓ runtime-only update: host settings and project files were preserved');
541
- else if (!notWired.length) console.log('✓ wired 4 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) → settings.json');
545
+ else if (!notWired.length) console.log('✓ wired 5 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) · PreToolUse (--guard) → settings.json');
542
546
  else console.error(`⚠ readiness: ${notWired.length} hook(s) did NOT take (${notWired.join(', ')}) — the brain will read but not capture/sync. Re-run \`npx klypix-mcp install --force\` or check ${SETTINGS}.`);
543
547
  console.log(`✓ MCP supervisor runs from the local bundle (node ${fwd(path.join(BRAIN_DIR, 'klypix-mcp-server.mjs'))}) — compatible core updates activate without restarting the host.`);
544
548
  if (migrated) console.log(`✓ migrated ${migrated.file} klypix-canvas server: ${migrated.from} → ${migrated.to} (backup: .mcp.json.klypix-bak). Reconnect (/mcp) or restart to pick it up.`);
545
549
  if (!RUNTIME_ONLY) {
546
550
  console.log(' Claude Code keeps its existing auto-brief/capture hooks; Codex gets the brain_sync Context Gateway (compact task memory + clean peers + proactive/guaranteed conflict alerts) with no hook trust prompt.');
547
- console.log(' Enhanced Codex auto-context + pre-edit overlap guard: re-run with `--codex-hooks`, then approve/review KLYPIX once in a Codex surface that supports hook trust.');
551
+ console.log(' Enhanced Codex auto-context + pre-edit overlap warning: re-run with `--codex-hooks`, then approve/review KLYPIX once in a Codex surface that supports hook trust.');
548
552
  }
549
553
  console.log(' Compatible brain-core updates hot-swap behind the same MCP connection. Only the one-time legacy→supervisor migration, a supervisor change, or an intentionally breaking tool/protocol change needs reconnect.');
550
554
 
@@ -103,7 +103,7 @@ await runVerb('install', './klypix-install.mjs');
103
103
  await runVerb('link', './klypix-link.mjs');
104
104
 
105
105
  // `npx klypix-mcp doctor` — the brain's READ-ONLY self-check: is this machine's brain
106
- // current, are the 4 hooks wired, what verbs does it expose, who's live, is the harness
106
+ // current, are the 5 hooks wired, what verbs does it expose, who's live, is the harness
107
107
  // projection in sync? One verdict, one reconcile block. Exits 1 on drift (CI gate).
108
108
  await runVerb('doctor', './klypix-doctor.mjs');
109
109
 
@@ -683,12 +683,23 @@ server.registerTool('brain_note', {
683
683
  marker: z.enum(['', '?', '!', '+', '✓', '~']).optional().describe('(none)=decision · ?=open question · !=milestone · +=🛠️ skill (reusable how-to/gotcha; always resurfaces, never ages out) · ✓=resolve+archive the best-matching card · ~=update the matching card in place. Default: decision.'),
684
684
  area: z.string().optional().describe('Area/topic — routes the card into that titled container and becomes a #tag (e.g. "Auth", "Release").'),
685
685
  closes: z.string().optional().describe('Title or [[wikilink]] of a strategy/question card this note fulfils — resolves+archives it and draws a "closed by" arrow.'),
686
+ guard: z.object({
687
+ when: z.object({
688
+ tool: z.string().max(200).optional().describe('Regex matched against the tool name (e.g. "Bash", "Edit|Write").'),
689
+ command: z.string().max(200).optional().describe('Regex matched against the command string, for shell tools (e.g. "\\\\bgit\\\\s+stash\\\\b"). Anchor deliberately — an unanchored pattern also matches the words inside echo/commit-message text.'),
690
+ paths: z.array(z.string().max(200)).max(20).optional().describe('Path PREFIXES (forward-slash, project-relative) — the guard fires when the session\'s touched files match one. Prefixes, not regexes.'),
691
+ multiWorktree: z.literal(true).optional().describe('Fire only when the repo has more than one git worktree (probed live when the guard is in play).'),
692
+ }).optional().describe('When to interrupt — triggers are AND-ed; at least one required.'),
693
+ severity: z.enum(['warn', 'block']).optional().describe("warn (default) injects the message as context and the call proceeds; block DENIES the tool call with the message. 'block' is for irreversible actions and HUMAN-DIRECTED authoring only — never author block without explicit user instruction."),
694
+ message: z.string().max(500).optional().describe('What the interrupted session reads — say the trap and the safe alternative. Required unless remove:true.'),
695
+ remove: z.literal(true).optional().describe("Disarm: pass exactly { remove: true } with marker '~' matching the card to delete its machine trigger while keeping the prose rule."),
696
+ }).optional().describe("GUARD CARDS: make this '+' skill fire BEFORE a matching tool call runs (Claude Code PreToolUse denies on severity block; other hosts warn), not just resurface in briefs. The card stays a normal 🛠️ rule — ✓-resolving it retires the guard, ~ with {remove:true} disarms it."),
686
697
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
687
698
  },
688
- }, async ({ text, marker, area, closes, canvas }, extra) => {
699
+ }, async ({ text, marker, area, closes, guard, canvas }, extra) => {
689
700
  // Both 1.77 and 1.78 ride this call: the enrichment question (the asker's
690
701
  // vocabulary for retrieval) AND the per-session capture receipt below.
691
- const result = await opBrainNote({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), text, area, marker: marker || '', closes, via: extra.klypixClientName, enrichmentQuestion: mcpPresence.declaredIntent });
702
+ const result = await opBrainNote({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), text, area, marker: marker || '', closes, guard, via: extra.klypixClientName, enrichmentQuestion: mcpPresence.declaredIntent });
692
703
  // Per-session capture receipt — this is what stops the uncaptured-work nudge
693
704
  // from firing at a session that DID record its reasoning, just through MCP
694
705
  // rather than a 🧠 marker. The Stop hook and this server share one session-id
@@ -1019,7 +1030,7 @@ server.registerTool('brain_sync', {
1019
1030
 
1020
1031
  server.registerTool('brain_doctor', {
1021
1032
  title: 'Brain doctor — is this brain current, wired, and in sync?',
1022
- description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (deployed brain-core + optional npm currency), CLAUDE (existing 4-hook capture readiness), CODEX (automatic MCP presence plus optional enhanced-hook status), TOOLS (discoverable MCP verbs), SESSIONS (all active presence-adapter sessions across hosts, never recent-chat history), and HARNESS (projection drift). Use to answer "is my brain current, correctly installed, in sync, and who is actually live?" without file-spelunking. Never writes. SCOPE: only CLAUDE and CODEX get behavioural verdicts. HARNESS classifies the projected config/rules FILES on disk — a project can read fully ok while no other host has ever actually loaded them, so do not report a clean HARNESS as "Cursor/Cline/Windsurf/Copilot is working". The MCP-callable twin of `npx klypix-mcp doctor`.',
1033
+ description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (deployed brain-core + optional npm currency), CLAUDE (existing 5-hook capture readiness), CODEX (automatic MCP presence plus optional enhanced-hook status), TOOLS (discoverable MCP verbs), SESSIONS (all active presence-adapter sessions across hosts, never recent-chat history), and HARNESS (projection drift). Use to answer "is my brain current, correctly installed, in sync, and who is actually live?" without file-spelunking. Never writes. SCOPE: only CLAUDE and CODEX get behavioural verdicts. HARNESS classifies the projected config/rules FILES on disk — a project can read fully ok while no other host has ever actually loaded them, so do not report a clean HARNESS as "Cursor/Cline/Windsurf/Copilot is working". The MCP-callable twin of `npx klypix-mcp doctor`.',
1023
1034
  inputSchema: {
1024
1035
  project: z.string().optional().describe('Project dir to audit harness + peers for. Defaults to the server\'s working directory.'),
1025
1036
  check_npm: z.boolean().optional().describe('Also fetch npm latest to flag a stale brain (default false — this one does a network `npm view`).'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.80.0",
3
+ "version": "1.81.0",
4
4
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -83,7 +83,7 @@
83
83
  "bench": "node bin/klypix-mcp.mjs bench",
84
84
  "test:bench": "node test/bench.mjs",
85
85
  "pretest": "node test/publish-workflow.mjs",
86
- "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
86
+ "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
87
  "test:memory": "node test/memory-runtime.mjs",
88
88
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
89
89
  "runtime": "node bin/klypix-runtime.mjs"
@@ -68,7 +68,7 @@ const readText = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { ret
68
68
  const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; };
69
69
 
70
70
  const HOOK_MARK = 'global-brain-hook';
71
- const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'];
71
+ const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse', 'PreToolUse'];
72
72
  // Liveness windows imported from the canonical rule; literals are the LAST
73
73
  // RESORT for a bundle whose agent-presence predates the exports.
74
74
  const SESSION_FRESH_MS = Number(presenceLib?.SESSION_FRESH_MS) > 0
@@ -568,7 +568,14 @@ export function inspect(opts = {}) {
568
568
  autoUpdate: !autoUpdate.enabled
569
569
  ? 'off'
570
570
  : (autoUpdate.result === 'failed' || Number(autoUpdate.harness?.failed || 0) > 0 ? 'warning' : 'ok'),
571
- hooks: !hooks.settingsPresent ? 'absent' : (hooks.missing.length ? 'drift' : 'ok'),
571
+ // Grace for the 1.81 hook addition (adversarial review 2026-08-24): a
572
+ // machine whose only missing event is the NEW PreToolUse guard lane is a
573
+ // valid pre-guard install, not a drifted one — hard-failing every existing
574
+ // machine the day the event ships teaches people to ignore the doctor.
575
+ // Any OTHER missing event still reads as drift.
576
+ hooks: !hooks.settingsPresent ? 'absent'
577
+ : (hooks.missing.some((e) => e !== 'PreToolUse') ? 'drift'
578
+ : (hooks.missing.length ? 'warning' : 'ok')),
572
579
  // Codex MCP presence is the automatic baseline. Hooks are an OPTIONAL
573
580
  // enrichment layer, so off/unverified must never make the brain "drifted".
574
581
  codexHooks: codexHooks.error
@@ -767,8 +774,9 @@ export function render(r, opts = {}) {
767
774
  // Host adapters
768
775
  const hmark = r.layers.hooks === 'ok' ? ok : warn;
769
776
  if (!r.hooks.settingsPresent) L.push(`${hmark} ${c.bold}CLAUDE${c.rst} no ~/.claude/settings.json found`);
777
+ else if (r.hooks.missing.length === 1 && r.hooks.missing[0] === 'PreToolUse') L.push(`${hmark} ${c.bold}CLAUDE${c.rst} capture path intact; ${c.yel}guard lane not wired yet${c.rst} — \`npx klypix-mcp install\` adds the PreToolUse hook (guard cards)`);
770
778
  else if (r.hooks.missing.length) L.push(`${hmark} ${c.bold}CLAUDE${c.rst} half-wired — missing: ${c.yel}${r.hooks.missing.join(', ')}${c.rst} ${c.dim}(liveness up, readiness no)${c.rst}`);
771
- else L.push(`${hmark} ${c.bold}CLAUDE${c.rst} existing 4-hook capture path intact: ${r.hooks.wired.join(', ')}`);
779
+ else L.push(`${hmark} ${c.bold}CLAUDE${c.rst} existing 5-hook capture path intact: ${r.hooks.wired.join(', ')}`);
772
780
  const chmark = r.layers.codexHooks === 'warning' ? warn : ok;
773
781
  const smart = r.codexSmart?.globalInstructions
774
782
  ? 'approval-free Context Gateway active (task memory + clean peers + proactive/guaranteed alerts)'
@@ -781,7 +789,7 @@ export function render(r, opts = {}) {
781
789
  L.push(`${chmark} ${c.bold}CODEX${c.rst} automatic MCP presence + ${smart} · ${c.yel}native hooks configured but execution not verified${c.rst}`);
782
790
  L.push(` ${c.dim}Context Gateway memory/coordination already works. Once trusted, native hooks auto-inject task memory and warn before exact overlapping edits.${c.rst}`);
783
791
  } else {
784
- L.push(`${chmark} ${c.bold}CODEX${c.rst} automatic MCP presence + ${smart} · native auto-context + pre-edit guard active ${c.dim}(last observed ${r.codexHooks.lastExecutedAt})${c.rst}`);
792
+ L.push(`${chmark} ${c.bold}CODEX${c.rst} automatic MCP presence + ${smart} · native auto-context + pre-edit overlap warning active ${c.dim}(last observed ${r.codexHooks.lastExecutedAt})${c.rst}`);
785
793
  }
786
794
  // Commit-capture git hook — per-repo, informational (auto-installed at
787
795
  // session start where safe; foreign hooks are never edited automatically).
@@ -236,8 +236,12 @@ function formatConflictWarning(conflicts, event, sessions = []) {
236
236
  // Grow each shown id (floor 12) until unique — same-window UUIDv7 peers
237
237
  // otherwise render as one ambiguous prefix.
238
238
  const shortId = (id) => shortestUniqueSessionPrefix(sessions, id, 12) || String(id).slice(0, 12);
239
+ // Claims discipline (2026-08-24 audit): this hook is ADVISORY — the output
240
+ // envelope hardcodes `continue: true` and never denies a tool call — so the
241
+ // banner must say WARNING, never BLOCKING. Urgency comes from the imperative
242
+ // closing line, not from claiming a mechanism that does not exist.
239
243
  return [
240
- `KLYPIX BLOCKING exact-file overlap detected ${moment}:`,
244
+ `KLYPIX ⚠️ WARNING — exact-file overlap detected ${moment}:`,
241
245
  ...conflicts.map((peer) => {
242
246
  // Observed/declared distinction (1.70.0): a `*` marks a path whose claim
243
247
  // was adopted from live edits, not declared — real overlap, unconfirmed
@@ -249,6 +253,43 @@ function formatConflictWarning(conflicts, event, sessions = []) {
249
253
  ].join('\n');
250
254
  }
251
255
 
256
+ // Guard cards, Codex advisory tier (2026-08-24): read the compiled sidecar the
257
+ // Claude-side lanes build (one keying rule — brainFormat.guardSidecarPathFor)
258
+ // and evaluate against this tool event. Typeof-guarded so a bundle whose
259
+ // klypix-format predates guards degrades to silence, never a throw. This lane
260
+ // only ever WARNS: block-severity guards are named as such so the model treats
261
+ // them as a hard stop, but no mechanism here denies anything.
262
+ async function guardWarnings(projectDir, brainPath, input) {
263
+ try {
264
+ if (typeof brainFormat.evaluateGuards !== 'function'
265
+ || typeof brainFormat.ensureGuardSidecar !== 'function') return '';
266
+ // ensureGuardSidecar is currency-checked and BUILDS when absent — on a
267
+ // Codex-only machine no Claude lane ever compiles the sidecar, and before
268
+ // this the advisory tier was silently dead there (review 2026-08-24). This
269
+ // hook already pays the heavy import, so the occasional rebuild is fine.
270
+ const ensured = await brainFormat.ensureGuardSidecar(brainPath);
271
+ const guards = Array.isArray(ensured.guards) ? ensured.guards : [];
272
+ if (!guards.length) return '';
273
+ const files = touchedFiles(input, projectDir);
274
+ const cmd = toolInput(input).command;
275
+ const needWt = guards.some((g) => g?.when?.multiWorktree);
276
+ const fired = brainFormat.evaluateGuards(guards, {
277
+ toolName: toolName(input),
278
+ command: typeof cmd === 'string' ? cmd : '',
279
+ files: files.length ? files : null,
280
+ worktreeCount: needWt && typeof brainFormat.probeWorktreeCount === 'function'
281
+ ? brainFormat.probeWorktreeCount(projectDir) : null,
282
+ });
283
+ if (!fired.length) return '';
284
+ const lines = fired.map((f) => {
285
+ const hard = f.guard.severity === 'block' ? ' (BLOCK-severity rule — this host cannot enforce it; treat as a hard stop)' : '';
286
+ const note = f.unverified.length ? ` (could not verify: ${f.unverified.join(', ')})` : '';
287
+ return `- [${f.guard.area || 'brain'}]${hard} ${f.guard.message}${note}`;
288
+ });
289
+ return `🛡️ KLYPIX guard — a standing rule matches this action:\n${lines.join('\n')}\nApply the rule, or proceed deliberately if it does not fit this case.`;
290
+ } catch { return ''; }
291
+ }
292
+
252
293
  function queueConflictAlerts({ brainPath, sessionId, intent, conflicts, turnId, sessions = [] }) {
253
294
  const queued = [];
254
295
  const selfShortId = shortestUniqueSessionPrefix(sessions, sessionId, 12) || String(sessionId).slice(0, 12);
@@ -401,6 +442,11 @@ async function main() {
401
442
  if (event === 'PreToolUse' || event === 'PostToolUse') {
402
443
  emitSystemMessage([
403
444
  formatConflictWarning(conflicts, event, sessions),
445
+ // Guard cards, advisory tier (2026-08-24): this host cannot deny a tool
446
+ // call — Codex hook output is `continue: true` by design — so a matching
447
+ // guard renders as a warning; a block-severity guard says it should be
448
+ // treated as a hard stop, without claiming a mechanism this lane lacks.
449
+ ...(event === 'PreToolUse' ? [await guardWarnings(projectDir, brainPath, input)] : []),
404
450
  stampReceivedMessages(messages, Date.now(), undefined, sessionId),
405
451
  ], event);
406
452
  return;
@@ -3165,11 +3165,188 @@ function pruneCacheDir(keep = 40) {
3165
3165
  // mtime-keyed parse cache so we don't unzip the brain on every prompt.
3166
3166
  async function cachedStruct(lib) {
3167
3167
  let mtimeMs = 0; try { mtimeMs = fs.statSync(BRAIN).mtimeMs; } catch { /* */ }
3168
- try { const c = JSON.parse(fs.readFileSync(CACHE, 'utf8')); if (c && c.mtimeMs === mtimeMs && c.struct) return c.struct; } catch { /* miss */ }
3168
+ try {
3169
+ const c = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
3170
+ if (c && c.mtimeMs === mtimeMs && c.struct) { refreshGuardSidecar(lib, c.struct, mtimeMs); return c.struct; }
3171
+ } catch { /* miss */ }
3169
3172
  const { struct } = await lib.parseKlypix(fs.readFileSync(BRAIN));
3170
3173
  try { fs.writeFileSync(CACHE, JSON.stringify({ mtimeMs, struct })); pruneCacheDir(); } catch { /* cache is best-effort */ }
3174
+ refreshGuardSidecar(lib, struct, mtimeMs);
3171
3175
  return struct;
3172
3176
  }
3177
+ // ── Guard cards: sidecar compiler (2026-08-24) ───────────────────────────────
3178
+ // The PreToolUse --guard lane avoids parsing the brain (~1s measured on the
3179
+ // live brain) on its COMMON path — guards are COMPILED into a tiny per-brain
3180
+ // sidecar wherever the struct is already warm: every lane that goes through
3181
+ // cachedStruct (--prompt retrieval, status queries) plus the Stop capture lane
3182
+ // (wired in main() — the brain just changed there). The fast path adds a
3183
+ // currency stat: a stale/missing sidecar pays ONE rebuild via the shared
3184
+ // lib.ensureGuardSidecar, so a ✓-resolved guard stops firing on the very next
3185
+ // call, not at the next lucky prompt. worktreeCount stored here is a snapshot
3186
+ // fallback only — guardCheck probes LIVE when a multiWorktree guard is in play.
3187
+ // Key derivation MUST byte-match klypix-format.guardSidecarPathFor (asserted
3188
+ // by test/guard-cards.mjs G5): win32 folds the WHOLE path — hosts reach one
3189
+ // brain through differently-cased CWDs and a case-split key half-blinds them.
3190
+ const guardKeyNorm = (p) => (process.platform === 'win32'
3191
+ ? String(p).replace(/\\/g, '/').toLowerCase()
3192
+ : normBrainPath(p));
3193
+ const GUARDS_SIDECAR = path.join(os.homedir(), '.claude', 'project-brain', `.guards-${sha(guardKeyNorm(BRAIN))}.json`);
3194
+ const GUARDS_SEEN = path.join(os.homedir(), '.claude', 'project-brain', `.guards-seen-${sha(guardKeyNorm(BRAIN))}.json`);
3195
+ function refreshGuardSidecar(lib, struct, mtimeMs) {
3196
+ try {
3197
+ if (typeof lib.compileGuards !== 'function') return; // version-skew: older format lib
3198
+ try {
3199
+ const cur = JSON.parse(fs.readFileSync(GUARDS_SIDECAR, 'utf8'));
3200
+ if (cur && cur.mtimeMs === mtimeMs) return; // current — nothing to do
3201
+ } catch { /* absent/stale → rebuild */ }
3202
+ const guards = lib.compileGuards(struct);
3203
+ let worktreeCount = null;
3204
+ if (guards.some((g) => g.when && g.when.multiWorktree)) {
3205
+ try {
3206
+ const out = execFileSync('git', ['worktree', 'list', '--porcelain'], {
3207
+ cwd: CWD, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
3208
+ });
3209
+ worktreeCount = out.split('\n').filter((l) => l.startsWith('worktree ')).length;
3210
+ } catch { worktreeCount = null; } // unknown, never 0/1
3211
+ }
3212
+ fs.mkdirSync(path.dirname(GUARDS_SIDECAR), { recursive: true });
3213
+ // Atomic (tmp+rename): several sessions' hooks read this concurrently —
3214
+ // a torn half-write must never be readable as an empty guard set.
3215
+ const tmp = `${GUARDS_SIDECAR}.${process.pid}.tmp`;
3216
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, mtimeMs, builtAt: Date.now(), brainPath: BRAIN, worktreeCount, guards }));
3217
+ fs.renameSync(tmp, GUARDS_SIDECAR);
3218
+ } catch { /* best-effort here — guardCheck's own currency check rebuilds via ensureGuardSidecar when this lane failed */ }
3219
+ }
3220
+ // --guard (PreToolUse): the fast path. Budget discipline: stat + one tiny JSON
3221
+ // read on the common no-guards case; the heavy klypix-format import is paid
3222
+ // ONLY when a compiled guard's tool pattern could match this call. Never
3223
+ // throws, never blocks on its own failure (the harness is fail-open on hook
3224
+ // errors anyway — only a deliberate deny blocks).
3225
+ async function guardCheck() {
3226
+ try {
3227
+ const input = readHookInput();
3228
+ const toolName = String(input.tool_name || '');
3229
+ if (!toolName) return;
3230
+ // CURRENCY FIRST (adversarial review 2026-08-24, both criticals): a
3231
+ // sidecar that predates the brain's mtime may be denying from a card
3232
+ // the user already ✓-resolved — the advertised recovery loop was
3233
+ // impossible while this lane trusted stale compiled state. One statSync
3234
+ // (~0.03ms) keeps the common current-path fast; a mismatch (or missing
3235
+ // sidecar) pays ONE rebuild via the shared ensureGuardSidecar, after
3236
+ // which this brain is fast again until its next edit.
3237
+ let brainMtime = 0;
3238
+ try { brainMtime = fs.statSync(BRAIN).mtimeMs; } catch { return; }
3239
+ let sidecar = null;
3240
+ try { sidecar = JSON.parse(fs.readFileSync(GUARDS_SIDECAR, 'utf8')); } catch { sidecar = null; }
3241
+ let guards = (sidecar && sidecar.mtimeMs === brainMtime && Array.isArray(sidecar.guards)) ? sidecar.guards : null;
3242
+ let sidecarUnverifiable = false;
3243
+ if (!guards) {
3244
+ const lib = await import('./klypix-format.mjs');
3245
+ if (typeof lib.ensureGuardSidecar !== 'function') return; // version-skew: older format lib
3246
+ const ensured = await lib.ensureGuardSidecar(BRAIN);
3247
+ if (ensured.guards) { guards = ensured.guards; sidecar = ensured; }
3248
+ else if (sidecar && Array.isArray(sidecar.guards)) {
3249
+ // Rebuild failed but stale guards exist: they are UNVERIFIABLE
3250
+ // as current — blocks degrade to warn (refuse-not-exempt), and
3251
+ // warns still fire (a stale warn is at worst noise).
3252
+ guards = sidecar.guards; sidecarUnverifiable = true;
3253
+ } else return; // no guards ever compiled and rebuild failed → silent
3254
+ }
3255
+ if (!guards.length) return;
3256
+ // Inline tool prefilter (regex compile is cheap; the LIB import is
3257
+ // not). A pattern that fails to COMPILE stays in — the evaluator
3258
+ // reports it unverifiable rather than this filter silently dropping it.
3259
+ const might = guards.filter((g) => {
3260
+ const t = g?.when?.tool;
3261
+ if (!t) return true;
3262
+ try { return new RegExp(String(t).slice(0, 200), 'i').test(toolName); } catch { return true; }
3263
+ });
3264
+ if (!might.length) return;
3265
+ const lib = await import('./klypix-format.mjs');
3266
+ if (typeof lib.evaluateGuards !== 'function') return;
3267
+ const ti = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
3268
+ const command = typeof ti.command === 'string' ? ti.command : '';
3269
+ // paths input: the direct target for file tools; this session's
3270
+ // observed/declared scope (the live lane row) for shell commands —
3271
+ // `git push` names no paths, but the session's touched files do.
3272
+ // null = COULD NOT DETERMINE (out-of-project target, empty/absent lane
3273
+ // row): an empty list must never read as verified-no-match (review
3274
+ // 2026-08-24 — files:[] silently exempted paths guards).
3275
+ let files = null;
3276
+ const direct = [ti.file_path, ti.path, ti.notebook_path].find((v) => typeof v === 'string' && v);
3277
+ if (direct) { const rel = projRelPath(direct); files = rel ? [rel] : null; }
3278
+ else {
3279
+ try {
3280
+ const me = readSessions().find((s) => s.id === input.session_id);
3281
+ if (me && Array.isArray(me.files) && me.files.length) files = me.files;
3282
+ } catch { files = null; }
3283
+ }
3284
+ // multiWorktree is probed LIVE when a candidate needs it — the count
3285
+ // changes independently of the brain, so a snapshot both false-denies
3286
+ // (worktree removed since compile) and silently exempts (added since).
3287
+ // Rare path: only pays the bounded git spawn when such a guard survived
3288
+ // the tool prefilter. Probe failure → null → unverified → degrade.
3289
+ const worktreeCount = might.some((g) => g?.when?.multiWorktree)
3290
+ ? (typeof lib.probeWorktreeCount === 'function' ? lib.probeWorktreeCount(CWD) : null)
3291
+ : null;
3292
+ let fired = lib.evaluateGuards(might, { toolName, command, files, worktreeCount });
3293
+ if (sidecarUnverifiable) fired = fired.map((f) => ({ ...f, unverified: [...new Set([...f.unverified, 'sidecarCurrency'])] }));
3294
+ if (!fired.length) return;
3295
+ // A verified block denies. An UNVERIFIED block (an input was
3296
+ // unavailable) degrades to warn and says so — never silently exempt,
3297
+ // never false-block.
3298
+ const blocks = fired.filter((f) => f.guard.severity === 'block' && !f.unverified.length);
3299
+ if (blocks.length) {
3300
+ const b = blocks[0].guard;
3301
+ const title = String(b.title || '').slice(0, 80);
3302
+ process.stdout.write(JSON.stringify({
3303
+ hookSpecificOutput: {
3304
+ hookEventName: 'PreToolUse',
3305
+ permissionDecision: 'deny',
3306
+ permissionDecisionReason: `🛡️ KLYPIX guard [${b.area || 'brain'}]: ${b.message} (standing rule${title ? ` “${title}”` : ''} — if this block is wrong, retire it with brain_note marker '✓'${title ? ` text "${title}"` : ''}, or disarm with marker '~' and guard {remove:true}; the change takes effect on the next call)`,
3307
+ },
3308
+ }));
3309
+ return;
3310
+ }
3311
+ // Once-per-session dedup applies ONLY to plain warns. A degraded BLOCK
3312
+ // is standing in for a deny — it must keep firing on every matching
3313
+ // call, or the second dangerous command sails through in silence
3314
+ // (review 2026-08-24).
3315
+ const degraded = fired.filter((f) => f.guard.severity === 'block');
3316
+ const plainWarns = fired.filter((f) => f.guard.severity !== 'block');
3317
+ const sid = String(input.session_id || '');
3318
+ let seen = {};
3319
+ try { seen = JSON.parse(fs.readFileSync(GUARDS_SEEN, 'utf8')) || {}; } catch { seen = {}; }
3320
+ const mine = (seen[sid] && typeof seen[sid] === 'object') ? seen[sid] : {};
3321
+ const freshWarns = plainWarns.filter((f) => !mine[f.guard.id]);
3322
+ const show = [...degraded, ...freshWarns];
3323
+ if (!show.length) return;
3324
+ const lines = show.map((f) => {
3325
+ const note = f.unverified.length
3326
+ ? (f.guard.severity === 'block'
3327
+ ? ` (BLOCK-severity rule degraded to a warning — could not verify: ${f.unverified.join(', ')}; treat as a hard stop)`
3328
+ : ` (could not verify: ${f.unverified.join(', ')})`)
3329
+ : '';
3330
+ return `- [${f.guard.area || 'brain'}] ${f.guard.message}${note}`;
3331
+ });
3332
+ try {
3333
+ const now = Date.now();
3334
+ for (const f of freshWarns) mine[f.guard.id] = now;
3335
+ const pruned = {}; // drop sessions idle >24h
3336
+ for (const [k, v] of Object.entries({ ...seen, [sid]: mine })) {
3337
+ const newest = Math.max(0, ...Object.values(v && typeof v === 'object' ? v : {}));
3338
+ if (now - newest < 24 * 60 * 60 * 1000) pruned[k] = v;
3339
+ }
3340
+ fs.writeFileSync(GUARDS_SEEN, JSON.stringify(pruned));
3341
+ } catch { /* dedup is best-effort — worst case a warn repeats */ }
3342
+ process.stdout.write(JSON.stringify({
3343
+ hookSpecificOutput: {
3344
+ hookEventName: 'PreToolUse',
3345
+ additionalContext: `🛡️ KLYPIX guard — a standing rule matches what you are about to do:\n${lines.join('\n')}\nApply the rule, or proceed deliberately if it does not fit this case.`,
3346
+ },
3347
+ }));
3348
+ } catch { /* never throw — the guard lane must not be able to break a tool call by failing */ }
3349
+ }
3173
3350
  // Generic directory names anchor to a huge fraction of the brain, so they drown
3174
3351
  // out the prompt's real intent — keep precise basenames, drop the generic dirs.
3175
3352
  const GENERIC_DIRS = new Set(['src', 'app', 'lib', 'components', 'component', 'canvas', 'scripts', 'dist', 'build', 'public', 'tabs', 'interaction', 'items', 'hooks', 'utils', 'dashboard', 'core', 'api', 'test', 'tests', 'assets', 'styles', 'types', 'electron']);
@@ -3710,7 +3887,7 @@ function doctorFooter() {
3710
3887
  return Array.isArray(groups) && groups.some(g => Array.isArray(g?.hooks)
3711
3888
  && g.hooks.some(h => typeof h?.command === 'string' && h.command.includes('global-brain-hook')));
3712
3889
  };
3713
- const missing = [['UserPromptSubmit', 'per-prompt recall'], ['Stop', 'decision capture'], ['PostToolUse', 'live sync']]
3890
+ const missing = [['UserPromptSubmit', 'per-prompt recall'], ['Stop', 'decision capture'], ['PostToolUse', 'live sync'], ['PreToolUse', 'guard cards (pre-action warnings)']]
3714
3891
  .filter(([evt]) => !wiredFor(evt));
3715
3892
  if (!missing.length) return '';
3716
3893
  return '\n\n---\n## ⚠️ Brain half-wired — readiness\n'
@@ -4035,6 +4212,10 @@ async function main() {
4035
4212
  // one tiny locked append. It must NOT pay the lazy klypix-format import, so it
4036
4213
  // runs BEFORE everything else and returns. (Fires on Bash|PowerShell|Edit|Write.)
4037
4214
  if (process.argv.includes('--live')) { liveCapture(); return; }
4215
+ // --guard (PreToolUse) is the second fast path: stat + sidecar read on the
4216
+ // common case; it lazy-imports the lib itself only when a compiled guard's
4217
+ // tool pattern could match this call. Runs before the unconditional import.
4218
+ if (process.argv.includes('--guard')) { await guardCheck(); return; }
4038
4219
  const lib = await import('./klypix-format.mjs'); // lazy: only when a brain exists
4039
4220
  // Per-prompt retrieval runs on EVERY prompt — skip the registry write and
4040
4221
  // go straight to the (mtime-cached) ranked lookup to keep it cheap.
@@ -4046,6 +4227,11 @@ async function main() {
4046
4227
  // try/finally so the clear runs across capture()'s early returns AND a throw
4047
4228
  // (on throw, ship/milestone re-capture next Stop; a version is on disk — no loss).
4048
4229
  try { await capture(lib); } finally { clearLiveLedgerForSession(readHookInput().session_id); }
4230
+ // The capture just changed the brain — recompile the guard sidecar now
4231
+ // (off the critical path) so the next PreToolUse reads current state
4232
+ // without paying the rebuild itself. Best-effort; guardCheck's currency
4233
+ // stat is the backstop.
4234
+ try { if (typeof lib.ensureGuardSidecar === 'function') await lib.ensureGuardSidecar(BRAIN); } catch { /* backstopped */ }
4049
4235
  // Ambient version-currency: piggyback the post-session Stop hook to refresh the
4050
4236
  // npm-latest cache (≤ once/day, best-effort, failure-silent) so the next
4051
4237
  // SessionStart footer can surface a stale install with ZERO network. Awaited so
@@ -4064,7 +4250,7 @@ if (!process.env.KLYPIX_BRAIN_NO_MAIN) {
4064
4250
  // here. Now it leaves a breadcrumb — without breaking the never-throw,
4065
4251
  // always-exit-0 contract.
4066
4252
  try {
4067
- const mode = process.argv.includes('--prompt') ? 'prompt' : process.argv.includes('--capture') ? 'capture' : 'read';
4253
+ const mode = process.argv.includes('--prompt') ? 'prompt' : process.argv.includes('--capture') ? 'capture' : process.argv.includes('--guard') ? 'guard' : 'read';
4068
4254
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode, ok: false, err: String((e && e.message) || e).slice(0, 200) }, 500);
4069
4255
  } catch { /* even the breadcrumb is best-effort */ }
4070
4256
  }).finally(() => process.exit(EXIT_CODE));
@@ -32,7 +32,7 @@ import {
32
32
  brainLensData, lensToMarkdown, deathDateOfCard,
33
33
  statusContextToMarkdown, findFulfillmentCandidates,
34
34
  splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor,
35
- isFastDecayCard, isUnresolvedOpenCard, isSkillCard, DECAY_STALE_MS, formatDecayAge,
35
+ isFastDecayCard, isUnresolvedOpenCard, isSkillCard, validateGuard, guardSidecarPathFor, ensureGuardSidecar, DECAY_STALE_MS, formatDecayAge,
36
36
  isPlanCard, planFulfillmentFor, PLAN_PAIR_SIM_BRAIN, isAgconfTwinId,
37
37
  readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts,
38
38
  } from './klypix-format.mjs';
@@ -1222,14 +1222,25 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
1222
1222
  // open file any agent reads AND writes": a hookless client (Cursor/Cline/Desktop)
1223
1223
  // can now record a decision, ask an open question, mark a milestone, resolve a card,
1224
1224
  // or correct one — with the full lifecycle, not just a flat append.
1225
- export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via, enrichmentQuestion = '' }) {
1225
+ export async function opBrainNote({ vault, canvas, text: noteText, area, marker = '', closes, via, guard = null, enrichmentQuestion = '' }) {
1226
1226
  const t = brainTarget(vault, canvas);
1227
1227
  if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
1228
1228
  if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
1229
1229
  const file = t.file;
1230
1230
  if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
1231
1231
  if (!['', '?', '!', '✓', '~', '+'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · +=skill (reusable how-to) · ✓=resolve a matching card · ~=update a matching card.`);
1232
- const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
1232
+ // Guard cards (2026-08-24): a guard is authored on a '+' skill (or amended
1233
+ // via '~'). Validation is FAIL-LOUD — a malformed guard is an error naming
1234
+ // the defect, never a silently-dropped field (the exact bug class this
1235
+ // subsystem's audit found in the evidence/verify plumbing).
1236
+ let guardField = null;
1237
+ if (guard !== null && guard !== undefined) {
1238
+ if (marker !== '+' && marker !== '~') return err("guard rides a '+' skill card (or a '~' amendment of one) — pass marker: '+' with the guard.");
1239
+ const v = validateGuard(guard);
1240
+ if (!v.ok) return err(`Invalid guard: ${v.reason}`);
1241
+ guardField = v.guard;
1242
+ }
1243
+ const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', guard: guardField, createdVia: via || 'mcp' });
1233
1244
  // Deliver any queued out-of-session ship observations on THIS write. The
1234
1245
  // Claude Stop hook is not the only writer — an MCP-only or Codex-driven
1235
1246
  // project would otherwise queue observations that never drain (2026-07-29
@@ -1281,6 +1292,14 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
1281
1292
  // write-side ⏳/⚠️ nudges existed only on one host brand.
1282
1293
  const receipts = formatCaptureReceipts(s);
1283
1294
  const rc = receipts.length ? `\n${receipts.join('\n')}` : '';
1295
+ // Guard sidecar refresh (2026-08-24 review): this verb is the surface that
1296
+ // AUTHORS and RETIRES guards, and on a hookless machine (Codex/Cursor-only)
1297
+ // nothing else ever compiles them. Refresh when a guard was passed OR a
1298
+ // sidecar already exists for this brain (a guard system is in use — a
1299
+ // ✓/~ note may have just retired one). Guard-less projects pay nothing.
1300
+ try {
1301
+ if (guardField || fs.existsSync(guardSidecarPathFor(file))) await ensureGuardSidecar(file);
1302
+ } catch { /* best-effort — the hook's currency check is the backstop */ }
1284
1303
  return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in the KLYPIX app to see it.${corr}${rc}`)] };
1285
1304
  } catch (e) {
1286
1305
  return err(`brain_note failed (brain unchanged): ${e.message}`);
@@ -10,6 +10,7 @@ import JSZip from 'jszip';
10
10
  import path from 'path';
11
11
  import fs from 'fs';
12
12
  import os from 'os';
13
+ import crypto from 'crypto';
13
14
  import { execFileSync } from 'child_process';
14
15
  import { generateKeyBetween } from 'fractional-indexing';
15
16
 
@@ -381,6 +382,9 @@ export async function parseKlypix(buffer) {
381
382
  // else parsed from prose so non-hook-authored cards count too.
382
383
  verify: (typeof it.verify === 'string' && it.verify.trim()) ? it.verify.trim()
383
384
  : (it.type === 'text' ? parseVerifySuffix(it.content) : null),
385
+ // Guard trigger (guard cards, 2026-08-24) — additive machine field;
386
+ // exposed so compileGuards can read it off the parsed struct.
387
+ ...(it.guard && typeof it.guard === 'object' ? { guard: it.guard } : {}),
384
388
  // Machine death-date (epoch ms) written by the gardener at
385
389
  // consolidation — the prose "⤵ consolidated" stamp's reliable twin,
386
390
  // so as_of time-travel never depends on parsing prose.
@@ -515,6 +519,12 @@ export async function buildKlypix(spec) {
515
519
  if (card.type === 'text') {
516
520
  return {
517
521
  type: 'text', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
522
+ // Machine-field parity with the other writers (2026-08-24) —
523
+ // additive, ignored by older readers.
524
+ ...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
525
+ ...(Array.isArray(card.evidence) && card.evidence.length ? { evidence: card.evidence } : {}),
526
+ ...(typeof card.verify === 'string' && card.verify.trim() ? { verify: card.verify.trim() } : {}),
527
+ ...(card.guard && typeof card.guard === 'object' ? { guard: card.guard } : {}),
518
528
  content: String(card.text ?? ''), fontSize: FONT,
519
529
  // PLAIN text (no border) renders at max-content width unless
520
530
  // authoredWidth pins the wrap — without it a long single line
@@ -613,6 +623,7 @@ export async function appendToKlypix(buffer, addition) {
613
623
  zip.file(`items/${shard(a.id)}/${a.id}.json`, JSON.stringify({
614
624
  type: 'text', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
615
625
  ...(a.card.createdVia ? { createdVia: String(a.card.createdVia) } : {}),
626
+ ...(a.card.guard && typeof a.card.guard === 'object' ? { guard: a.card.guard } : {}),
616
627
  content: String(a.card.text), fontSize: FONT,
617
628
  color: a.card.color || '#1a1a1f', border: !!a.card.border, borderColor: '#1e1e2e',
618
629
  heading: !!a.card.heading, fontFamily: 'Thmanyah Sans',
@@ -793,6 +804,8 @@ export async function appendIntoContainers(buffer, addition) {
793
804
  ...(Array.isArray(card.evidence) && card.evidence.length ? { evidence: card.evidence } : {}),
794
805
  // Live-probe command for a fast-decay claim — additive, ignored by older readers.
795
806
  ...(typeof card.verify === 'string' && card.verify.trim() ? { verify: card.verify.trim() } : {}),
807
+ // Guard trigger — additive, ignored by older readers (guard cards).
808
+ ...(card.guard && typeof card.guard === 'object' ? { guard: card.guard } : {}),
796
809
  content: wrapped, fontSize: G.FONT,
797
810
  color: card.color || '#e8e8ed', border: true, borderColor: card.borderColor || card.color || 'rgba(16,185,129,0.45)',
798
811
  fillColor: 'rgba(18,18,26,0.85)', heading: !!card.heading, fontFamily: 'Thmanyah Sans',
@@ -1404,6 +1417,31 @@ export async function arrangeBrain(buffer, opts = {}) {
1404
1417
  canvas.connections = conns;
1405
1418
 
1406
1419
  // 4. Physically remove the losers (item file + position + order slot).
1420
+ // 3.5 Machine-field rescue (adversarial review 2026-08-24): a loser
1421
+ // twin can be the ONLY carrier of guard/evidence/verify — a ~ amendment
1422
+ // bumps createdAt and the survivor tiebreak prefers OLDEST, so the
1423
+ // amended, guard-carrying copy is exactly the copy that loses. The
1424
+ // lossless post-verify checks TEXTS, not machine fields, so this loss
1425
+ // shipped green. Merge absent machine fields onto the survivor before
1426
+ // the loser's bytes are deleted; prose is untouched.
1427
+ const readItemJson = async (id) => {
1428
+ const f = zip.file(`items/${shard(id)}/${id}.json`);
1429
+ if (!f) return null;
1430
+ try { return JSON.parse(await f.async('string')); } catch { return null; }
1431
+ };
1432
+ for (const loser of removed) {
1433
+ const sid = resolve(loser);
1434
+ if (!byId.has(sid) || removed.has(sid)) continue;
1435
+ const li = await readItemJson(loser);
1436
+ if (!li || (!li.guard && !li.evidence && !li.verify)) continue;
1437
+ const si = await readItemJson(sid);
1438
+ if (!si) continue;
1439
+ let changed = false;
1440
+ if (li.guard && typeof li.guard === 'object' && !si.guard) { si.guard = li.guard; changed = true; }
1441
+ if (Array.isArray(li.evidence) && li.evidence.length && !si.evidence) { si.evidence = li.evidence; changed = true; }
1442
+ if (typeof li.verify === 'string' && li.verify.trim() && !si.verify) { si.verify = li.verify; changed = true; }
1443
+ if (changed) zip.file(`items/${shard(sid)}/${sid}.json`, JSON.stringify(si));
1444
+ }
1407
1445
  for (const id of removed) {
1408
1446
  delete canvas.positions[id];
1409
1447
  try { zip.remove(`items/${shard(id)}/${id}.json`); } catch { /* */ }
@@ -4082,7 +4120,13 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
4082
4120
  const cands = [];
4083
4121
  for (const c of liveTextCards()) {
4084
4122
  if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
4085
- if (/🛠/.test(c.text)) continue; // skills are standing reference — a ✓ must never archive one (mirror the supersede guard)
4123
+ // Skills are standing reference — a ✓ must never archive one
4124
+ // (mirror the supersede guard). EXCEPTION (2026-08-24): a card
4125
+ // carrying a machine guard documents "✓-resolve retires the
4126
+ // guard" as its lifecycle contract, and the deny message sends
4127
+ // agents here — without this carve-out the advertised remedy
4128
+ // was impossible and the unmatched ✓ minted a junk 🏁 fallback.
4129
+ if (/🛠/.test(c.text) && !c.guard) continue;
4086
4130
  // A ✓ closes opens/claims, never a pure milestone — without this
4087
4131
  // a ✓ for a fulfilled claim could near-tie the very 🏁 that
4088
4132
  // fulfilled it (item text ⊆ milestone) and archive the milestone
@@ -4190,13 +4234,28 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
4190
4234
  // verifiedAt), so confirming/correcting a drifted fact marks it ✅.
4191
4235
  if (Array.isArray(u.evidence) && u.evidence.length) j.evidence = u.evidence;
4192
4236
  if (typeof u.verify === 'string' && u.verify.trim()) j.verify = u.verify.trim();
4237
+ // guard: replace, DISARM ({remove:true} deletes the machine
4238
+ // field — the only authorable off-switch), or preserve.
4239
+ if (u.guard && typeof u.guard === 'object') {
4240
+ if (u.guard.remove === true) delete j.guard; else j.guard = u.guard;
4241
+ }
4242
+ // A surviving/incoming guard must keep its 🛠 glyph — an
4243
+ // amendment that dropped it demoted the card out of every
4244
+ // skill-card lifecycle shield while it kept guarding
4245
+ // (gardener consolidation would then kill it silently).
4246
+ if (j.guard && !/🛠/.test(j.content)) j.content = `🛠️ ${j.content}`;
4193
4247
  });
4194
4248
  best.text = isTerseConfirm ? `${best.text}\n(re-affirmed ${today}: ${u.text})` : u.text;
4195
4249
  stats.updated++;
4196
4250
  } else if (!nearDupExists(u.text)) {
4197
4251
  // ~ fallback add is guarded like ✓'s: an unmatched ~ re-harvested
4198
4252
  // from the transcript tail must not stack a copy every turn.
4199
- cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}), ...(typeof u.verify === 'string' && u.verify.trim() ? { verify: u.verify.trim() } : {}) });
4253
+ // A ~ fallback that carries a live guard mints a SKILL card —
4254
+ // the 🛠 glyph is what grants gardener/supersede immunity, and
4255
+ // a glyph-less guard card was silently consolidatable while
4256
+ // still enforcing (review 2026-08-24). remove-sentinels add no
4257
+ // glyph and no field: an unmatched disarm is inert by design.
4258
+ cards.push({ text: (u.area ? `${u.area}: ` : '') + (u.guard && u.guard.remove !== true && !/🛠/.test(u.text) ? '🛠️ ' : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}), ...(typeof u.verify === 'string' && u.verify.trim() ? { verify: u.verify.trim() } : {}), ...(u.guard && typeof u.guard === 'object' && u.guard.remove !== true ? { guard: u.guard } : {}) });
4200
4259
  }
4201
4260
  }
4202
4261
 
@@ -4222,6 +4281,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
4222
4281
  if (card.createdVia) j.createdVia = String(card.createdVia);
4223
4282
  if (Array.isArray(card.evidence) && card.evidence.length) j.evidence = card.evidence;
4224
4283
  if (typeof card.verify === 'string' && card.verify.trim()) j.verify = card.verify.trim();
4284
+ if (card.guard && typeof card.guard === 'object') j.guard = card.guard;
4225
4285
  });
4226
4286
  best.text = String(card.text);
4227
4287
  cards.splice(i, 1);
@@ -5530,17 +5590,240 @@ export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
5530
5590
  // (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place) |
5531
5591
  // '+' (skill — a REUSABLE how-to/gotcha/procedure, standing reference that always
5532
5592
  // surfaces and never ages out, distinct from a point-in-time decision).
5533
- export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, verify = null, createdVia = 'mcp' } = {}) {
5593
+ // ── Guard cards (2026-08-24, founder go after the 13-agent audit) ────────────
5594
+ // A guard is a standing 🛠️ card that also declares WHEN it should interrupt a
5595
+ // tool call: { when: {tool?, command?, paths?, multiWorktree?}, severity, message }.
5596
+ // The card's prose is the human render; this structured field is the machine
5597
+ // half. Everything here is PURE — validation, compilation from a parsed
5598
+ // struct, and evaluation against one tool event — so the PreToolUse hook can
5599
+ // stay a stat+sidecar+regex fast path (never parses the brain, never spawns
5600
+ // git; measured brain parse is ~1s on the live brain, far over any hook budget).
5601
+ //
5602
+ // Trigger grammar is DELIBERATELY the honest expressible surface only
5603
+ // (audit §5): tool/command regexes, path PREFIXES, and one cached repo
5604
+ // predicate (multiWorktree). Staleness detection, response-shape checks and
5605
+ // semantic intent are NOT expressible here and must not be pretended in.
5606
+ //
5607
+ // Severity contract: 'warn' injects context; 'block' denies the call with the
5608
+ // card's message. Authoring 'block' is a deliberate human-directed act — an
5609
+ // agent must never author severity 'block' without explicit user instruction
5610
+ // (the deny names the card id, so a wrong block is corrected by ✓-resolving
5611
+ // or ~-amending that card).
5612
+ const GUARD_RE_MAX = 200;
5613
+ const GUARD_MSG_MAX = 500;
5614
+ const GUARD_PATHS_MAX = 20;
5615
+ // ONE keying rule for the compiled-guard sidecar, shared by every consumer
5616
+ // (the Claude --guard fast path, the Codex advisory lane, tests). The formula
5617
+ // mirrors global-brain-hook.mjs's cache keying (sha1-16 of the normalized
5618
+ // brain path) — test/guard-cards.mjs asserts the two derivations agree.
5619
+ export function guardSidecarPathFor(brainPath, home = os.homedir()) {
5620
+ // On Windows the WHOLE path is case-folded, not just the drive letter —
5621
+ // hosts reach the same brain through differently-cased CWDs (e:/ vs E:/,
5622
+ // observed live in this project's own doctor output), and a case-split key
5623
+ // would give each host its own half-blind sidecar (review 2026-08-24).
5624
+ // POSIX paths keep their case: there, distinct casings ARE distinct files.
5625
+ let norm = String(brainPath).replace(/\\/g, '/');
5626
+ norm = process.platform === 'win32' ? norm.toLowerCase() : norm.replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
5627
+ const key = crypto.createHash('sha1').update(norm).digest('hex').slice(0, 16);
5628
+ return path.join(home, '.claude', 'project-brain', `.guards-${key}.json`);
5629
+ }
5630
+ const compileGuardRegex = (source) => {
5631
+ const s = String(source || '').trim();
5632
+ if (!s || s.length > GUARD_RE_MAX) return null;
5633
+ try { return new RegExp(s, 'i'); } catch { return null; }
5634
+ };
5635
+ export function validateGuard(value) {
5636
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5637
+ return { ok: false, reason: 'guard must be an object: { when: {…}, severity?, message } — or { remove: true } to disarm' };
5638
+ }
5639
+ // Disarm sentinel (adversarial review 2026-08-24): a plain ~ amendment
5640
+ // rewrites prose but PRESERVES the machine field, so without this there was
5641
+ // no authorable way to switch a guard off short of archiving the card.
5642
+ if (value.remove === true) {
5643
+ const extra = Object.keys(value).filter((k) => k !== 'remove');
5644
+ if (extra.length) return { ok: false, reason: 'guard.remove takes no other fields — pass exactly { remove: true } to disarm, or a full guard to replace' };
5645
+ return { ok: true, guard: { remove: true } };
5646
+ }
5647
+ const when = value.when;
5648
+ if (!when || typeof when !== 'object' || Array.isArray(when)) {
5649
+ return { ok: false, reason: 'guard.when must be an object with at least one trigger (tool, command, paths, multiWorktree)' };
5650
+ }
5651
+ const out = { when: {}, severity: 'warn', message: '' };
5652
+ if (when.tool !== undefined) {
5653
+ if (typeof when.tool !== 'string' || !compileGuardRegex(when.tool)) {
5654
+ return { ok: false, reason: `guard.when.tool must be a valid regex source string (≤${GUARD_RE_MAX} chars)` };
5655
+ }
5656
+ out.when.tool = when.tool.trim();
5657
+ }
5658
+ if (when.command !== undefined) {
5659
+ if (typeof when.command !== 'string' || !compileGuardRegex(when.command)) {
5660
+ return { ok: false, reason: `guard.when.command must be a valid regex source string (≤${GUARD_RE_MAX} chars)` };
5661
+ }
5662
+ out.when.command = when.command.trim();
5663
+ }
5664
+ if (when.paths !== undefined) {
5665
+ if (!Array.isArray(when.paths) || !when.paths.length || when.paths.length > GUARD_PATHS_MAX
5666
+ || !when.paths.every((p) => typeof p === 'string' && p.trim() && p.length <= GUARD_RE_MAX)) {
5667
+ return { ok: false, reason: `guard.when.paths must be 1–${GUARD_PATHS_MAX} non-empty path-prefix strings (≤${GUARD_RE_MAX} chars each) — prefixes, not regexes` };
5668
+ }
5669
+ out.when.paths = when.paths.map((p) => p.trim().replace(/\\/g, '/'));
5670
+ }
5671
+ if (when.multiWorktree !== undefined) {
5672
+ if (when.multiWorktree !== true) return { ok: false, reason: 'guard.when.multiWorktree accepts only true (omit it otherwise)' };
5673
+ out.when.multiWorktree = true;
5674
+ }
5675
+ if (!Object.keys(out.when).length) {
5676
+ return { ok: false, reason: 'guard.when needs at least one trigger: tool, command, paths, or multiWorktree' };
5677
+ }
5678
+ if (value.severity !== undefined && value.severity !== 'warn' && value.severity !== 'block') {
5679
+ return { ok: false, reason: "guard.severity must be 'warn' or 'block' (default warn). 'block' is for irreversible actions and human-directed authoring only" };
5680
+ }
5681
+ if (value.severity === 'block') out.severity = 'block';
5682
+ const msg = String(value.message || '').trim();
5683
+ if (!msg || msg.length > GUARD_MSG_MAX) {
5684
+ return { ok: false, reason: `guard.message is required (≤${GUARD_MSG_MAX} chars) — it is what the interrupted session reads` };
5685
+ }
5686
+ out.message = msg;
5687
+ return { ok: true, guard: out };
5688
+ }
5689
+ // Live cards carrying a VALID guard field → the compiled list the sidecar
5690
+ // stores. Invalid guards are skipped (they still exist as prose cards); a
5691
+ // resolved/superseded/archived card stops guarding without any extra step.
5692
+ export function compileGuards(struct) {
5693
+ if (!struct || !Array.isArray(struct.cards)) return [];
5694
+ const out = [];
5695
+ for (const c of struct.cards) {
5696
+ if (c.type === 'container' || !c.guard) continue;
5697
+ if (c.guard.remove === true) continue; // disarmed via ~ amendment
5698
+ if (/^archive$/i.test(c.area || '')) continue;
5699
+ // Retirement test uses the ANCHORED stamp shapes, never a bare glyph
5700
+ // scan — a live guard whose prose merely mentions ✅ must keep guarding
5701
+ // (the glyph-in-prose trap this file already defends against elsewhere;
5702
+ // adversarial review 2026-08-24 caught the bare /↩|✅|⤵/ version).
5703
+ if (hasRetirementStamp(c.text)) continue;
5704
+ const v = validateGuard(c.guard);
5705
+ if (!v.ok) continue;
5706
+ out.push({ id: c.id, area: c.area || null, title: c.title || null, ...v.guard });
5707
+ }
5708
+ return out;
5709
+ }
5710
+ // Evaluate compiled guards against ONE tool event. Pure; every regex test is
5711
+ // try/caught and inputs are sliced so a hostile pattern or a huge command can
5712
+ // never hang the hook. Verdict per guard:
5713
+ // fired:false — a verifiable trigger said no
5714
+ // fired:true, unverified:[] — fire at the declared severity
5715
+ // fired:true, unverified:['paths'] — verifiable triggers passed but an
5716
+ // input was unavailable. Per the 2026-08-18 field rule ("a probe that
5717
+ // fails must refuse, never exempt") this NEVER silently exempts — but a
5718
+ // deny on unverified input would false-block, so the caller degrades
5719
+ // block→warn and SAYS what could not be verified.
5720
+ export function evaluateGuards(guards, { toolName = '', command = '', files = null, worktreeCount = null } = {}) {
5721
+ const results = [];
5722
+ const CMD_CAP = 16384;
5723
+ const raw = String(command || '');
5724
+ const cmd = raw.slice(0, CMD_CAP);
5725
+ const cmdTruncated = raw.length > CMD_CAP;
5726
+ const tool = String(toolName || '').slice(0, 200);
5727
+ for (const g of Array.isArray(guards) ? guards : []) {
5728
+ try {
5729
+ const unverified = [];
5730
+ let fired = true;
5731
+ // A trigger whose stored pattern fails to COMPILE at eval time
5732
+ // (hand-edited sidecar, corrupt entry) is an UNVERIFIABLE trigger,
5733
+ // never a silent drop — three review layers collapsed cannot-check
5734
+ // into checked-and-clear before this (2026-08-24).
5735
+ if (g.when.tool) {
5736
+ const re = compileGuardRegex(g.when.tool);
5737
+ if (!re) unverified.push('tool-pattern');
5738
+ else if (!re.test(tool)) fired = false;
5739
+ }
5740
+ if (fired && g.when.command) {
5741
+ const re = compileGuardRegex(g.when.command);
5742
+ if (!re) unverified.push('command-pattern');
5743
+ // A match on the truncated prefix is a real match; a NO-match on
5744
+ // a truncated command proves nothing — report it unverifiable
5745
+ // rather than letting truncation become exemption.
5746
+ else if (!re.test(cmd)) {
5747
+ if (cmdTruncated) unverified.push('command-truncated');
5748
+ else fired = false;
5749
+ }
5750
+ }
5751
+ if (fired && g.when.paths) {
5752
+ if (!Array.isArray(files)) unverified.push('paths');
5753
+ else if (!files.some((f) => {
5754
+ const file = String(f || '').replace(/\\/g, '/').toLowerCase();
5755
+ return g.when.paths.some((p) => file.startsWith(p.toLowerCase()));
5756
+ })) fired = false;
5757
+ }
5758
+ if (fired && g.when.multiWorktree) {
5759
+ if (!Number.isFinite(worktreeCount)) unverified.push('worktreeCount');
5760
+ else if (worktreeCount <= 1) fired = false;
5761
+ }
5762
+ if (fired) results.push({ guard: g, unverified });
5763
+ } catch { /* one bad guard never breaks the rest */ }
5764
+ }
5765
+ return results;
5766
+ }
5767
+
5768
+ // Live worktree probe — bounded, call-time. The count changes independently of
5769
+ // the brain, so a compiled snapshot can false-deny (worktree removed) or
5770
+ // silently exempt (worktree added); callers probe AT the moment a
5771
+ // multiWorktree guard is actually in play. null = could not verify.
5772
+ export function probeWorktreeCount(dir) {
5773
+ try {
5774
+ const out = execFileSync('git', ['worktree', 'list', '--porcelain'], {
5775
+ cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
5776
+ });
5777
+ return out.split('\n').filter((l) => l.startsWith('worktree ')).length;
5778
+ } catch { return null; }
5779
+ }
5780
+ // ONE currency-checked reader/rebuilder for the compiled-guard sidecar, shared
5781
+ // by every enforcement surface (Claude --guard, Codex advisory, brain_note's
5782
+ // post-write refresh). The adversarial review's two criticals were both "the
5783
+ // sidecar never learns the brain changed": a resolved guard kept denying with
5784
+ // a recovery message that could not work. This closes it at the read site —
5785
+ // stale (mtime mismatch) or missing ⇒ parse + recompile + ATOMIC write.
5786
+ // returns { guards, worktreeCount, mtimeMs, rebuilt } on success
5787
+ // returns { guards: null, stale: true } when the brain exists but the
5788
+ // rebuild failed — the caller must treat every guard as UNVERIFIABLE
5789
+ // (degrade block→warn), never as absent.
5790
+ // returns { guards: [] } when the brain itself is gone.
5791
+ export async function ensureGuardSidecar(brainPath, { home = os.homedir(), buildIfMissing = true } = {}) {
5792
+ const sidecarPath = guardSidecarPathFor(brainPath, home);
5793
+ let mtimeMs = 0;
5794
+ try { mtimeMs = fs.statSync(brainPath).mtimeMs; } catch { return { guards: [], worktreeCount: null, mtimeMs: 0, rebuilt: false }; }
5795
+ try {
5796
+ const cur = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
5797
+ if (cur && cur.mtimeMs === mtimeMs && Array.isArray(cur.guards)) {
5798
+ return { guards: cur.guards, worktreeCount: Number.isFinite(cur.worktreeCount) ? cur.worktreeCount : null, mtimeMs, rebuilt: false };
5799
+ }
5800
+ } catch { /* absent or unreadable → rebuild below */ }
5801
+ if (!buildIfMissing) return { guards: null, stale: true };
5802
+ try {
5803
+ const { struct } = await parseKlypix(fs.readFileSync(brainPath));
5804
+ const guards = compileGuards(struct);
5805
+ const worktreeCount = guards.some((g) => g.when && g.when.multiWorktree)
5806
+ ? probeWorktreeCount(path.dirname(brainPath)) : null;
5807
+ const payload = JSON.stringify({ v: 1, mtimeMs, builtAt: Date.now(), brainPath, worktreeCount, guards });
5808
+ fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
5809
+ const tmp = `${sidecarPath}.${process.pid}.tmp`;
5810
+ fs.writeFileSync(tmp, payload);
5811
+ fs.renameSync(tmp, sidecarPath);
5812
+ return { guards, worktreeCount, mtimeMs, rebuilt: true };
5813
+ } catch { return { guards: null, stale: true }; }
5814
+ }
5815
+
5816
+ export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, verify = null, guard = null, createdVia = 'mcp' } = {}) {
5534
5817
  const body = String(text).trim();
5535
5818
  if (!body) return { cards: [], resolutions: [], updates: [] };
5536
5819
  const a = String(area || '').trim();
5537
5820
  if (marker === '✓') return { cards: [], resolutions: [{ area: a, text: body }], updates: [] };
5538
- if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }] };
5821
+ if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}), ...(guard ? { guard } : {}) }] };
5539
5822
  const prefix = marker === '?' ? '❓ ' : marker === '!' ? '🏁 ' : marker === '+' ? '🛠️ ' : '';
5540
5823
  const borderColor = marker === '?' ? 'rgba(245,166,35,0.8)' : marker === '!' ? 'rgba(59,130,246,0.8)' : marker === '+' ? 'rgba(139,92,246,0.85)' : 'rgba(16,185,129,0.6)';
5541
5824
  const tag = a ? `\n#${a.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
5542
5825
  const cardText = (a ? `${a}: ${prefix}${body}` : `${prefix}${body}`) + tag;
5543
- return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }], resolutions: [], updates: [] };
5826
+ return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}), ...(guard ? { guard } : {}) }], resolutions: [], updates: [] };
5544
5827
  }
5545
5828
 
5546
5829
  /**
@@ -5624,6 +5907,8 @@ export async function buildKlypixMap(spec) {
5624
5907
  const h = measured[ci];
5625
5908
  items[id] = {
5626
5909
  type: 'text', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
5910
+ // Guard trigger — additive machine field (guard cards, 2026-08-24).
5911
+ ...(c.guard && typeof c.guard === 'object' ? { guard: c.guard } : {}),
5627
5912
  content: String(c.text), fontSize: FONT,
5628
5913
  color: c.color || '#e8e8ed', border: true,
5629
5914
  borderColor: c.color || 'rgba(16,185,129,0.35)',
package/src/uninstall.mjs CHANGED
@@ -309,7 +309,7 @@ export function planUnlink(projectDir, { sidecars = false } = {}) {
309
309
  }
310
310
 
311
311
  /**
312
- * MACHINE-GLOBAL plan — the engine bundle, the 4 Claude Code hooks, and the
312
+ * MACHINE-GLOBAL plan — the engine bundle, the 5 Claude Code hooks, and the
313
313
  * three Codex host files. Zero writes.
314
314
  */
315
315
  export function planUninstall({ home = os.homedir(), installDir, keepRegistry = false, keepSemantic = false } = {}) {