klypix-mcp 1.61.0 → 1.62.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
@@ -431,6 +431,7 @@ The MCP verbs below are what agents call. These are what **you** call:
431
431
  | `npx klypix-mcp runtime` | Passive per-connection process/RAM attribution (`--json`, optional `--watch seconds`); never kills or deduplicates |
432
432
  | `npx klypix-mcp conformance` | Launch two real MCP clients against this build and verify coordination behaviour |
433
433
  | `npx klypix-mcp git-driver` | Register the lossless `.klypix` merge driver for a repo (`status` to check) |
434
+ | `npx klypix-mcp git-hook` | Wire the agent-neutral commit-capture hook: rationale-bearing `feat`/`fix`/`perf` commits from any agent, branch, or worktree card into the brain at commit time (`install`/`remove`/`status`; sessions auto-install it where the hook slots are free) |
434
435
  | `npx klypix-mcp diff [ref]` | Card-level brain diff against a git ref, as markdown |
435
436
  | `npx klypix-mcp pr-brief [ref]` | Brain cards referencing the files changed since a ref, as markdown |
436
437
  | `npx klypix-mcp garden-code` | Print the human approval code `brain_garden` requires |
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // `klypix-mcp git-hook [install|remove|status] [repo]` — wire the agent-neutral
3
+ // commit-capture hook (brain-git-hook.mjs) into a repo, so rationale-bearing
4
+ // feat/fix/perf commits from ANY agent/branch/worktree card into ./brain.klypix
5
+ // at commit time. Standalone use: node bin/klypix-git-hook.mjs <args>
6
+ import path from 'path';
7
+ import { gitCaptureHookStatus, installGitCaptureHook, removeGitCaptureHook } from '../src/git-capture-install.mjs';
8
+
9
+ const args = process.argv.slice(2).filter((a) => a !== 'git-hook');
10
+ const action = ['install', 'remove', 'status'].includes(args[0]) ? args[0] : 'status';
11
+ const repo = path.resolve(process.cwd(), args.find((a, i) => i > 0 || !['install', 'remove', 'status'].includes(a)) || '.');
12
+
13
+ const describe = (s) => `state: ${s.state}${s.hooksDir ? `\nhooks dir: ${s.hooksDir}` : ''}${s.hooks && Object.keys(s.hooks).length
14
+ ? '\n' + Object.entries(s.hooks).map(([n, st]) => ` ${n}: ${st}`).join('\n') : ''}`;
15
+
16
+ if (action === 'status') {
17
+ const s = gitCaptureHookStatus(repo);
18
+ console.log(describe(s));
19
+ if (s.state === 'custom-hookspath') console.log('core.hooksPath is set — add the managed block to that hooks dir yourself, or unset it and re-run.');
20
+ if (s.state === 'foreign') console.log('A non-sh hook occupies the slot — not touched. Chain it manually or convert it to sh.');
21
+ process.exit(s.state === 'installed' ? 0 : 1);
22
+ } else if (action === 'install') {
23
+ const r = installGitCaptureHook(repo);
24
+ if (!r.ok) { console.error(`Not installed: ${r.reason}.`); process.exit(1); }
25
+ console.log(r.changed.length ? `Installed/updated: ${r.changed.join(', ')} in ${r.hooksDir}` : 'Already current — nothing to change.');
26
+ if (r.skipped.length) console.log(`Skipped (non-sh foreign hook, not touched): ${r.skipped.join(', ')}`);
27
+ process.exit(0);
28
+ } else {
29
+ const r = removeGitCaptureHook(repo);
30
+ if (!r.ok) { console.error(`Nothing removed: ${r.reason}.`); process.exit(1); }
31
+ console.log(r.changed.length ? `Removed the managed block from: ${r.changed.join(', ')}` : 'No managed block found — nothing to remove.');
32
+ process.exit(0);
33
+ }
@@ -226,7 +226,7 @@ const flatten = (code) => code
226
226
  .replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
227
227
  // brain-doctor + agent-rules (the server's lazy `import('../src/brain-doctor.mjs')`
228
228
  // for the brain_doctor tool) → flat sibling refs in the runtime layout.
229
- .replace(/\.\.\/src\/(brain-doctor|agent-rules|mcp-presence|mcp-supervisor|mcp-auto-update|semantic-memory|runtime-inspector|project-graph)\.mjs/g, './$1.mjs')
229
+ .replace(/\.\.\/src\/(brain-doctor|agent-rules|mcp-presence|mcp-supervisor|mcp-auto-update|semantic-memory|runtime-inspector|project-graph|git-capture-install)\.mjs/g, './$1.mjs')
230
230
  .replace(/klypix-worker\.mjs/g, 'klypix-mcp-worker.mjs')
231
231
  .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
232
232
 
@@ -293,7 +293,7 @@ try {
293
293
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
294
294
  // file must never get a JS-comment banner) beside the flat server, which
295
295
  // resolves it via its ./canvas-view-app.html candidate path.
296
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
296
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
297
297
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
298
298
  }
299
299
  for (const [src, dst] of [
@@ -19,7 +19,7 @@ const PKG_VERSION = (() => {
19
19
  }
20
20
  })();
21
21
 
22
- const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'diff', 'pr-brief', 'uninstall', 'bench']);
22
+ const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'git-hook', 'diff', 'pr-brief', 'uninstall', 'bench']);
23
23
 
24
24
  const USAGE = [
25
25
  `klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
@@ -35,6 +35,7 @@ const USAGE = [
35
35
  ' garden-code [brain] print the human approval code for brain_garden',
36
36
  ' uninstall [--check|--yes|unlink] remove this install from the machine (--check inventories first; never deletes a .klypix)',
37
37
  ' git-driver [install|status] [repo] register the lossless .klypix merge driver for a repo (zero-command teams)',
38
+ ' git-hook [install|remove|status] wire the agent-neutral commit-capture hook (any agent/branch/worktree → brain cards)',
38
39
  ' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
39
40
  ' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
40
41
  '',
@@ -116,6 +116,11 @@ await runVerb('conformance', './klypix-conformance.mjs');
116
116
  // git ref, and print the brain cards touching a PR's changed files. One module,
117
117
  // three verbs (it reads argv[2] itself).
118
118
  await runVerb('git-driver', './klypix-git-driver.mjs');
119
+ // `npx klypix-mcp git-hook` — wire the agent-neutral commit-capture hook
120
+ // (brain-git-hook.mjs) into a repo's post-commit/post-merge, so feat/fix/perf
121
+ // commits with rationale bodies card into the brain from ANY agent, branch, or
122
+ // worktree at commit time (the Stop hook alone is blind to other worktrees).
123
+ await runVerb('git-hook', './klypix-git-hook.mjs');
119
124
  await runVerb('diff', './klypix-diff.mjs');
120
125
  await runVerb('pr-brief', './klypix-pr-brief.mjs');
121
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.61.0",
3
+ "version": "1.62.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -79,7 +79,7 @@
79
79
  "test:project-graph": "node test/project-graph.mjs",
80
80
  "bench": "node bin/klypix-mcp.mjs bench",
81
81
  "test:bench": "node test/bench.mjs",
82
- "test": "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/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
82
+ "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/agent-presence.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
83
83
  "test:memory": "node test/memory-runtime.mjs",
84
84
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
85
85
  "runtime": "node bin/klypix-runtime.mjs"
@@ -119,13 +119,88 @@ export function neutralizeMarkers(text) {
119
119
  return String(text || '').replace(/🧠(\s*)(BRAIN|MSG)/gi, '🧠·$2');
120
120
  }
121
121
 
122
+ // ── Machine-turn intent guard ───────────────────────────────────────────────
123
+ // UserPromptSubmit does not only carry human-typed text: harnesses inject task
124
+ // notifications, system reminders and slash-command wrappers as "user" prompts.
125
+ // One of those stored verbatim became a session's declared intent
126
+ // ("<task-notification> <task-id>…" — 2026-08-07 AgentLit field incident), so
127
+ // every peer surface showed tool-plumbing instead of what the session was doing.
128
+ // deriveIntentFromPrompt() extracts the human intent from a raw prompt, or
129
+ // returns null when the whole turn is machine-generated — callers must then KEEP
130
+ // the previous intent (and may still stamp activity), never store the junk and
131
+ // never clear a good intent because a background task happened to complete.
132
+ const MACHINE_TAGS = 'task-notification|system-reminder|system-warning|local-command-caveat'
133
+ + '|command-name|command-message|command-args|command-contents|local-command-stdout|local-command-stderr'
134
+ + '|ide_selection|ide_opened_file|ide_diagnostics|persisted-output|tool-use-error'
135
+ + '|session-start-hook|user-prompt-submit-hook|post-tool-use-hook|hook-[a-z0-9-]+';
136
+ const MACHINE_BLOCK_RE = new RegExp(
137
+ '^(?:<(' + MACHINE_TAGS + ')\\b[^>]*>[\\s\\S]*?(?:</\\1>|$)|\\[SYSTEM NOTIFICATION[^\\]]*\\])\\s*', 'i');
138
+ // Tag-shaped start AFTER stripping known blocks: an unrecognized harness tag is
139
+ // far more likely than a human opening a prompt with raw XML, and the cost of
140
+ // failing closed is only "previous intent kept" — never data loss.
141
+ const TAG_SHAPED_RE = /^<[a-z][\w.:-]*(?:\s|\/?>)/i;
142
+
143
+ export function deriveIntentFromPrompt(raw) {
144
+ let text = String(raw || '').replace(/\s+/g, ' ').trim();
145
+ if (!text) return null;
146
+ // A "[SYSTEM NOTIFICATION …]"-led turn is machine end to end — the prose
147
+ // after the bracket header is harness narration, never the user's intent.
148
+ if (/^\[SYSTEM NOTIFICATION/i.test(text)) return null;
149
+ for (let hops = 0; hops < 12; hops++) {
150
+ const m = MACHINE_BLOCK_RE.exec(text);
151
+ if (!m) break;
152
+ text = text.slice(m[0].length).trim();
153
+ }
154
+ if (!text || TAG_SHAPED_RE.test(text)) return null;
155
+ return text;
156
+ }
157
+
158
+ // True when a non-empty prompt/intent value is machine-generated end to end.
159
+ // Empty strings are NOT machine turns — brain_sync phase "complete" clears
160
+ // intent with '' deliberately, and that semantic must keep working.
161
+ export function looksMachineTurn(text) {
162
+ const t = String(text || '').trim();
163
+ return Boolean(t) && deriveIntentFromPrompt(t) === null;
164
+ }
165
+
122
166
  // tmp+rename so lock-free readers (readLane, messageFooter, peers' status
123
167
  // lines) can never parse a torn lane as an authoritative "0 peers / no
124
168
  // messages", and a crash mid-write can never destroy undelivered messages.
169
+ // On Windows, renaming over a destination a reader/AV momentarily holds open
170
+ // throws EPERM — one immediate retry wins that race, and on final failure the
171
+ // tmp is REMOVED before rethrowing (the field found dozens of orphaned
172
+ // `.tmp-<pid>-<rand>` files littering the sessions dir, 2026-08-07).
125
173
  function writeLaneFileAtomic(laneFile, payload) {
126
174
  const tmp = `${laneFile}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
127
175
  fs.writeFileSync(tmp, payload);
128
- fs.renameSync(tmp, laneFile);
176
+ try {
177
+ fs.renameSync(tmp, laneFile);
178
+ } catch (err) {
179
+ try { fs.renameSync(tmp, laneFile); }
180
+ catch { try { fs.unlinkSync(tmp); } catch { /* best-effort */ } throw err; }
181
+ }
182
+ sweepStaleTmpFiles(path.dirname(laneFile));
183
+ }
184
+
185
+ // Opportunistic janitor for tmp orphans left by crashes or the pre-fix rename
186
+ // path. Throttled to once per process per 10 minutes; only files matching our
187
+ // own tmp naming and older than 15 minutes are touched, so an in-flight write
188
+ // (millisecond lifetime) can never be swept.
189
+ let lastTmpSweep = 0;
190
+ export function sweepStaleTmpFiles(dir, { now = Date.now(), maxAgeMs = 15 * 60 * 1000, force = false } = {}) {
191
+ if (!force && now - lastTmpSweep < 10 * 60 * 1000) return 0;
192
+ lastTmpSweep = now;
193
+ let swept = 0;
194
+ try {
195
+ for (const name of fs.readdirSync(dir)) {
196
+ if (!/\.tmp-\d+(?:-[a-z0-9]+)?$|\.\d+\.tmp$/.test(name)) continue;
197
+ const full = path.join(dir, name);
198
+ try {
199
+ if (now - fs.statSync(full).mtimeMs > maxAgeMs) { fs.unlinkSync(full); swept++; }
200
+ } catch { /* raced or locked — next sweep */ }
201
+ }
202
+ } catch { /* dir unreadable — best-effort */ }
203
+ return swept;
129
204
  }
130
205
 
131
206
  function freshChannelSeen(channelSeen, now) {
@@ -242,10 +317,14 @@ export function upsertSession({
242
317
  // that never touch intent, so without intentAt a 100-minute-old intent renders
243
318
  // under "active just now" (2026-07-29 audit). Stamped only when the intent
244
319
  // VALUE actually changes; additive — old rows/readers are unaffected.
245
- const nextIntent = intent !== undefined
246
- ? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160)
320
+ // Defense-in-depth: a machine-generated value (harness notification, raw
321
+ // XML tag) is treated as "no update" — the derivation layer in the hooks
322
+ // is the primary guard, this catches any writer that skipped it.
323
+ const effectiveIntent = intent !== undefined && looksMachineTurn(intent) ? undefined : intent;
324
+ const nextIntent = effectiveIntent !== undefined
325
+ ? String(effectiveIntent || '').replace(/\s+/g, ' ').trim().slice(0, 160)
247
326
  : (previous.intent || '');
248
- const intentChanged = intent !== undefined && nextIntent !== (previous.intent || '');
327
+ const intentChanged = effectiveIntent !== undefined && nextIntent !== (previous.intent || '');
249
328
  // A connection heartbeat proves transport liveness, not that a task is in
250
329
  // progress. Stamp only events that carry real user/tool work so doctor can
251
330
  // distinguish an idle connected host from an active sync-silent session.
@@ -330,7 +409,9 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
330
409
  client: String(row.client || previous.client || 'unknown'),
331
410
  surface: row.surface ?? previous.surface ?? null,
332
411
  branch: row.branch ?? previous.branch ?? null,
333
- intent: String(row.intent || '').slice(0, 160),
412
+ // Same machine-turn guard as the local writer: a peer machine running a
413
+ // pre-guard build can relay junk intent — never mirror it verbatim.
414
+ intent: looksMachineTurn(row.intent) ? String(previous.intent || '') : String(row.intent || '').slice(0, 160),
334
415
  files: normalizeFiles(row.files),
335
416
  machine: String(row.machine),
336
417
  host: row.host ?? previous.host ?? null,
@@ -39,6 +39,8 @@ import { renderReceiptSummary, summarizeReceipts } from './finding-routing.mjs';
39
39
  // inspect() synchronous for its existing callers.
40
40
  let fmtLib = null;
41
41
  try { fmtLib = await import('./klypix-format.mjs'); } catch { fmtLib = null; }
42
+ let gitCaptureLib = null;
43
+ try { gitCaptureLib = await import('./git-capture-install.mjs'); } catch { gitCaptureLib = null; }
42
44
 
43
45
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
44
46
 
@@ -372,6 +374,15 @@ export function inspect(opts = {}) {
372
374
  mcpInstructions: true,
373
375
  globalInstructions: codexGlobalInstructionsInstalled(home),
374
376
  };
377
+ // Commit-capture git hook (2026-08-07): informational, never a drift layer —
378
+ // SessionStart auto-installs it where safe; doctor reports the cases it can't
379
+ // (foreign hook in the slot, core.hooksPath set, brain nested below the repo
380
+ // top). Guarded dynamic import: on a half-updated bundle that lacks the
381
+ // module, doctor must keep working, not crash at load (review-caught).
382
+ let gitCapture = { state: 'n/a', hooks: {} };
383
+ if (hasBrain && gitCaptureLib && typeof gitCaptureLib.gitCaptureHookStatus === 'function') {
384
+ try { gitCapture = gitCaptureLib.gitCaptureHookStatus(projectDir, { home }); } catch { gitCapture = { state: 'error', hooks: {} }; }
385
+ }
375
386
  const tools = inspectTools(brainDir, PKG_ROOT);
376
387
  const env = opts.env || process.env;
377
388
  // MCP passes the adopted live id explicitly. The CLI can inherit a host id,
@@ -418,6 +429,9 @@ export function inspect(opts = {}) {
418
429
  : (!codexHooks.installed
419
430
  ? 'optional'
420
431
  : (codexHooks.executionStatus === 'observed' ? 'ok' : 'warning')),
432
+ // Informational only — a repo the auto-installer can't wire (foreign hook,
433
+ // custom hooksPath) must never flip the machine verdict to DRIFTED.
434
+ gitCapture: gitCapture.state === 'installed' ? 'ok' : (gitCapture.state === 'n/a' ? 'n/a' : 'optional'),
421
435
  harness: hasBrain ? (harness.ok ? 'ok' : 'drift') : 'n/a',
422
436
  // DECAY-GUARD drifts when the protection is provably missing: the loaded
423
437
  // engine lacks classifyDecay, its renderer left the synthetic stale claim
@@ -456,9 +470,14 @@ export function inspect(opts = {}) {
456
470
  if (layers.decayGuard === 'drift') actions.push('npx klypix-mcp install # decay-aware status guard missing/stale — stale build/deploy claims can render as CURRENT state');
457
471
  for (const s of supervisors.impaired || []) actions.push(`/mcp reconnect # supervisor pid ${s.pid} is ${s.status} with no live worker — its tool calls hang until reconnected`);
458
472
  if (peers.twinGroups && peers.twinGroups.length) actions.push(`/mcp reconnect # ${peers.twinGroups.length} session(s) split across twin lane rows (channel merge not occurring) — reconnect adopts the merged id`);
473
+ if (hasBrain && (gitCapture.state === 'foreign' || Object.values(gitCapture.hooks || {}).some(s => s === 'foreign' || s === 'foreign-sh'))) {
474
+ actions.push('npx klypix-mcp git-hook install # a pre-existing git hook occupies post-commit/post-merge — this chains the commit-capture block after it (auto-install never edits a foreign hook)');
475
+ } else if (hasBrain && gitCapture.state === 'custom-hookspath') {
476
+ actions.push('git config core.hooksPath is set # commit-capture hook not auto-installable — add the managed block to that hooks dir or unset the config, then `npx klypix-mcp git-hook install`');
477
+ }
459
478
  }
460
479
 
461
- return { verdict, layers, drifted, readinessWarnings, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, tools, peers, sessions: peers, receipts: peers.receipts, receiptSessionId, harness, npm, decayGuard, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
480
+ return { verdict, layers, drifted, readinessWarnings, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, gitCapture, tools, peers, sessions: peers, receipts: peers.receipts, receiptSessionId, harness, npm, decayGuard, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
462
481
  }
463
482
 
464
483
  // One-line drift summary (empty when clean) — for a footer / status line.
@@ -580,6 +599,18 @@ export function render(r, opts = {}) {
580
599
  } else {
581
600
  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}`);
582
601
  }
602
+ // Commit-capture git hook — per-repo, informational (auto-installed at
603
+ // session start where safe; foreign hooks are never edited automatically).
604
+ if (r.gitCapture && r.gitCapture.state !== 'n/a') {
605
+ const gc = r.gitCapture;
606
+ const gmark = gc.state === 'installed' ? ok : warn;
607
+ const detail = gc.state === 'installed' ? 'post-commit/post-merge wired — any agent/branch/worktree cards its rationale-bearing commits'
608
+ : gc.state === 'custom-hookspath' ? `${c.yel}core.hooksPath set — not auto-installable (see actions)${c.rst}`
609
+ : gc.state === 'foreign' ? `${c.yel}foreign hook in the slot — run \`npx klypix-mcp git-hook install\` to chain it deliberately${c.rst}`
610
+ : gc.state === 'no-git' ? `${c.dim}not a git repo${c.rst}`
611
+ : `${c.dim}${gc.state} — installs automatically at the next session start${c.rst}`;
612
+ L.push(`${gmark} ${c.bold}GIT-CAP${c.rst} ${detail}`);
613
+ }
583
614
 
584
615
  // TOOLS
585
616
  L.push(`${ok} ${c.bold}TOOLS${c.rst} ${r.tools.count} MCP verb(s)${r.tools.hash ? ` ${c.dim}[#${r.tools.hash}, ${r.tools.source}]${c.rst}` : ''}${r.tools.count ? `: ${c.dim}${r.tools.names.join(', ')}${c.rst}` : ''}`);
@@ -60,7 +60,15 @@ function parseLog(raw) {
60
60
  return { hash: (p[0] || '').trim(), subject: (p[1] || '').trim(), body: (p[2] || '').trim() };
61
61
  }).filter(c => c.hash && c.subject);
62
62
  }
63
- const readPrev = () => { try { return fs.readFileSync(STATE, 'utf8').trim() || null; } catch { return null; } };
63
+ // The baseline is repo-writable and gets interpolated into git commands — accept
64
+ // ONLY a bare sha, or a malicious checkout gains shell execution at commit time
65
+ // (review-caught 2026-08-07). An invalid file re-baselines like a missing one.
66
+ const readPrev = () => {
67
+ try {
68
+ const s = fs.readFileSync(STATE, 'utf8').trim();
69
+ return /^[0-9a-f]{4,64}$/i.test(s) ? s : null;
70
+ } catch { return null; }
71
+ };
64
72
  const writePrev = (s) => { try { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, String(s || '')); } catch { /* best-effort */ } };
65
73
 
66
74
  async function main() {
@@ -5,6 +5,7 @@ import { execFileSync } from 'child_process';
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
7
  import {
8
+ deriveIntentFromPrompt,
8
9
  findProjectBrain,
9
10
  formatPresenceMessage,
10
11
  formatReceivedMessages,
@@ -239,10 +240,18 @@ async function main() {
239
240
  }
240
241
 
241
242
  const projectDir = path.dirname(brainPath);
242
- const prompt = input.prompt || input.user_prompt || input.userPrompt;
243
+ const rawPrompt = input.prompt || input.user_prompt || input.userPrompt;
244
+ // Machine-turn guard: a harness-injected "user" prompt (task notification,
245
+ // system reminder) must not become the session's declared intent — derive the
246
+ // human text, and on a machine turn keep the previous intent (undefined).
247
+ const prompt = rawPrompt === undefined ? undefined : (deriveIntentFromPrompt(rawPrompt) ?? undefined);
248
+ // A HUMAN prompt is a task boundary: reset the declared file scope. A machine
249
+ // turn is mid-task plumbing — it must keep the files exactly as it keeps the
250
+ // intent (review-caught: the guard preserved intent but wiped files[]).
251
+ const humanTurn = event === 'UserPromptSubmit' && prompt !== undefined;
243
252
  const files = event === 'PreToolUse' || event === 'PostToolUse'
244
253
  ? touchedFiles(input, projectDir)
245
- : (event === 'UserPromptSubmit' ? [] : undefined);
254
+ : (humanTurn ? [] : undefined);
246
255
  const sessions = upsertSession({
247
256
  brainPath,
248
257
  id: sessionId,
@@ -253,7 +262,7 @@ async function main() {
253
262
  branch: gitBranch(cwd),
254
263
  intent: prompt === undefined ? undefined : prompt,
255
264
  files,
256
- replaceFiles: event === 'UserPromptSubmit',
265
+ replaceFiles: humanTurn,
257
266
  event,
258
267
  channel: 'lifecycle',
259
268
  cwd,
@@ -275,6 +284,15 @@ async function main() {
275
284
  });
276
285
  }
277
286
  if (event === 'SessionStart') {
287
+ // Commit-capture completeness (2026-08-07): wire the agent-neutral git
288
+ // post-commit/post-merge hook when the slots are absent or already ours —
289
+ // never a foreign hook. Dynamic + guarded so a stale bundle no-ops.
290
+ const gitHookNotice = await (async () => {
291
+ try {
292
+ const ghl = await import(new URL('./git-capture-install.mjs', import.meta.url).href);
293
+ return typeof ghl.ensureGitCaptureHook === 'function' ? (ghl.ensureGitCaptureHook(projectDir).notice || '') : '';
294
+ } catch { return ''; }
295
+ })();
278
296
  emitSystemMessage([
279
297
  formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
280
298
  // Class-C ship observation — the engine's, not a Claude-hook-local copy.
@@ -282,6 +300,7 @@ async function main() {
282
300
  // is exactly where the incident class bites hardest (2026-07-29 review).
283
301
  // The queue drains at the next brain write from any host.
284
302
  observeShipDrift(projectDir),
303
+ gitHookNotice.trim(),
285
304
  stampReceivedMessages(messages),
286
305
  ]);
287
306
  return;
@@ -6,6 +6,7 @@ import fs from 'fs';
6
6
  import os from 'os';
7
7
  import path from 'path';
8
8
  import crypto from 'crypto';
9
+ import { sweepStaleTmpFiles } from './agent-presence.mjs';
9
10
 
10
11
  export const CODEX_PRESENCE_EVENTS = [
11
12
  'SessionStart',
@@ -88,7 +89,8 @@ function writeHooks(file, raw, data) {
88
89
  const temp = file + '.klypix-tmp';
89
90
  fs.writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', 'utf8');
90
91
  JSON.parse(fs.readFileSync(temp, 'utf8'));
91
- fs.renameSync(temp, file);
92
+ try { fs.renameSync(temp, file); }
93
+ catch (err) { try { fs.unlinkSync(temp); } catch { /* */ } throw err; }
92
94
  return { ok: true, backup };
93
95
  } catch (error) {
94
96
  return { ok: false, error: `Couldn't write ${file}: ${error?.message || error}` };
@@ -166,7 +168,16 @@ export function recordCodexHookExecution({
166
168
  fs.mkdirSync(path.dirname(status.receiptPath), { recursive: true });
167
169
  const temp = status.receiptPath + `.${process.pid}.tmp`;
168
170
  fs.writeFileSync(temp, JSON.stringify(receipt, null, 2), 'utf8');
169
- fs.renameSync(temp, status.receiptPath);
171
+ // Windows rename-over-open-destination throws EPERM (concurrent hook
172
+ // processes race on this receipt constantly): retry once, and never leave
173
+ // the tmp behind — the field found ~35 orphans from this exact line
174
+ // (2026-08-07). A losing racer's receipt is disposable; the winner's stands.
175
+ try { fs.renameSync(temp, status.receiptPath); }
176
+ catch {
177
+ try { fs.renameSync(temp, status.receiptPath); }
178
+ catch { try { fs.unlinkSync(temp); } catch { /* */ } return { ok: false, reason: 'rename-raced' }; }
179
+ }
180
+ sweepStaleTmpFiles(path.dirname(status.receiptPath));
170
181
  return { ok: true, path: status.receiptPath };
171
182
  } catch {
172
183
  return { ok: false, reason: 'write-failed' };
@@ -0,0 +1,215 @@
1
+ // git-capture-install — wire the agent-neutral commit-capture hook
2
+ // (brain-git-hook.mjs) into a repo's git hooks, so rationale-bearing
3
+ // feat/fix/perf commits card into ./brain.klypix from ANY agent (Cursor, Cline,
4
+ // Codex, a human terminal), on ANY branch, in ANY worktree — at commit time.
5
+ //
6
+ // Why this exists (2026-08-07 field diagnosis): brain-git-hook.mjs shipped for
7
+ // months but was installed NOWHERE — the desktop's installer path had zero UI
8
+ // call sites and no CLI verb existed. The only live commit capture was the
9
+ // Claude Code Stop hook, which walks `prev..HEAD` in the session's checkout —
10
+ // commits authored on another worktree's branch were invisible to it (the
11
+ // AgentLit `94b0b6b` incident). A post-commit hook fires in whatever worktree
12
+ // the commit happens in, closing worktree, branch, AND foreign-agent blindness
13
+ // with the SAME high-signal gates (feat|fix|perf + rationale body ≥ 12 chars)
14
+ // — this is NOT blind auto-capture.
15
+ //
16
+ // Contract: never touch a hook we don't own. Our lines live between marker
17
+ // comments; a foreign non-sh hook is reported, not modified. Removal is exact:
18
+ // only the managed block goes.
19
+ import fs from 'fs';
20
+ import os from 'os';
21
+ import path from 'path';
22
+ import { execFileSync } from 'child_process';
23
+ import { fileURLToPath } from 'url';
24
+
25
+ const HOOK_NAMES = ['post-commit', 'post-merge'];
26
+ const MARK_START = '# >>> klypix-brain capture >>>';
27
+ const MARK_END = '# <<< klypix-brain capture <<<';
28
+
29
+ const gitOut = (repoDir, args) => {
30
+ try {
31
+ return execFileSync('git', args, {
32
+ cwd: repoDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000,
33
+ }).trim();
34
+ } catch { return null; }
35
+ };
36
+
37
+ // Prefer the machine-global deployed engine (stable across repo moves and the
38
+ // path every other brain component already runs from); fall back to the sibling
39
+ // copy for repo-checkout use. An EPHEMERAL npx cache is never an acceptable
40
+ // target: the path is baked into a hook that must keep working for years, and
41
+ // npm evicts the cache — the hook would silently stop capturing (review-caught).
42
+ const EPHEMERAL_RE = /[\\/](_npx|_cacache|npm-cache[\\/]_)/i;
43
+ export function resolveHookScript(home = os.homedir()) {
44
+ const deployed = path.join(home, '.claude', 'project-brain', 'brain-git-hook.mjs');
45
+ if (fs.existsSync(deployed)) return deployed;
46
+ const sibling = path.join(path.dirname(fileURLToPath(import.meta.url)), 'brain-git-hook.mjs');
47
+ if (fs.existsSync(sibling) && !EPHEMERAL_RE.test(sibling)) return sibling;
48
+ return null; // nothing durable to point at — callers report 'no-engine'
49
+ }
50
+
51
+ // Single-quote for sh: immune to $, backticks and double quotes in the path
52
+ // (review-caught: a metacharacter path was an sh-injection into an every-commit
53
+ // hook). Embedded single quotes use the standard '\'' dance.
54
+ const shq = (s) => `'${String(s).replace(/\\/g, '/').replace(/'/g, `'\\''`)}'`;
55
+
56
+ function managedBlock(hookScript) {
57
+ const script = shq(hookScript);
58
+ return [
59
+ `${MARK_START} (managed by klypix-mcp — remove: npx klypix-mcp git-hook remove)`,
60
+ // [ -f ]: an uninstalled/evicted engine bundle degrades to a silent no-op
61
+ // instead of per-commit stderr noise (review-caught). command -v: a GUI git
62
+ // client without node on PATH must not fail the commit. No cwd argument:
63
+ // git runs hooks from the top of the CURRENT working tree and node's own
64
+ // process.cwd() is a proper native path in every sh flavor — passing
65
+ // "$(pwd)" handed node an MSYS/POSIX path under some git-for-windows setups.
66
+ `[ -f ${script} ] && command -v node >/dev/null 2>&1 && node ${script} || true`,
67
+ MARK_END,
68
+ ].join('\n');
69
+ }
70
+
71
+ // `git-hook remove` writes this marker so SessionStart auto-install respects
72
+ // the human's decision instead of silently re-wiring next session
73
+ // (review-caught: remove didn't stick). Explicit install clears it.
74
+ const OPTOUT_REL = path.join('.claude', 'git-capture-optout');
75
+ export function gitCaptureOptedOut(repoDir) {
76
+ return fs.existsSync(path.join(repoDir, OPTOUT_REL));
77
+ }
78
+
79
+ function hookFileState(file, hookScript) {
80
+ let raw = null;
81
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { state: 'absent', raw: null }; }
82
+ if (raw.includes(MARK_START)) {
83
+ const current = raw.includes(managedBlock(hookScript));
84
+ return { state: current ? 'ours' : 'ours-stale', raw };
85
+ }
86
+ const firstLine = raw.split('\n', 1)[0] || '';
87
+ const shCompatible = !firstLine.startsWith('#!') || /\b(sh|bash|dash|zsh)\b/.test(firstLine);
88
+ return { state: shCompatible ? 'foreign-sh' : 'foreign', raw };
89
+ }
90
+
91
+ const canonDir = (p) => {
92
+ try { return fs.realpathSync.native ? fs.realpathSync.native(p) : fs.realpathSync(p); }
93
+ catch { return path.resolve(p); }
94
+ };
95
+
96
+ export function gitCaptureHookStatus(repoDir, { home = os.homedir() } = {}) {
97
+ const commonDir = gitOut(repoDir, ['rev-parse', '--git-common-dir']);
98
+ if (!commonDir) return { state: 'no-git', hooks: {} };
99
+ if (gitOut(repoDir, ['config', 'core.hooksPath'])) return { state: 'custom-hookspath', hooks: {} };
100
+ // Containment: the hook runs from the worktree TOP and looks for
101
+ // ./brain.klypix there. A brain nested deeper in a larger repo can never be
102
+ // captured by this hook — installing would mutate the enclosing repo's hooks
103
+ // for nothing and report a false green (review-caught).
104
+ const top = gitOut(repoDir, ['rev-parse', '--show-toplevel']);
105
+ if (top && canonDir(top) !== canonDir(repoDir)) return { state: 'nested-brain', hooks: {}, worktreeTop: top };
106
+ const hooksDir = path.resolve(repoDir, commonDir, 'hooks');
107
+ const hookScript = resolveHookScript(home);
108
+ // No durable engine path to bake into a hook (running straight from an npx
109
+ // cache with no installed bundle) — say so rather than wiring a hook that
110
+ // dies at the next cache eviction.
111
+ if (!hookScript) return { state: 'no-engine', hooksDir, hookScript: null, hooks: {} };
112
+ const hooks = {};
113
+ for (const name of HOOK_NAMES) hooks[name] = hookFileState(path.join(hooksDir, name), hookScript).state;
114
+ const states = Object.values(hooks);
115
+ const state = states.every((s) => s === 'ours') ? 'installed'
116
+ : states.some((s) => s === 'foreign') ? 'foreign'
117
+ : states.every((s) => s === 'absent') ? 'absent'
118
+ : 'partial';
119
+ return { state, hooksDir, hookScript, hooks };
120
+ }
121
+
122
+ // Install or refresh the managed block. Foreign sh-compatible hooks get the
123
+ // block APPENDED (their lines run first, ours never exits non-zero); foreign
124
+ // non-sh hooks are skipped and reported. Idempotent.
125
+ export function installGitCaptureHook(repoDir, { home = os.homedir() } = {}) {
126
+ const status = gitCaptureHookStatus(repoDir, { home });
127
+ if (['no-git', 'custom-hookspath', 'nested-brain', 'no-engine'].includes(status.state)) {
128
+ return { ok: false, reason: status.state, changed: [], skipped: [] };
129
+ }
130
+ // Explicit install = the human opting back in — clear any remove-marker.
131
+ try { fs.unlinkSync(path.join(repoDir, OPTOUT_REL)); } catch { /* none */ }
132
+ const block = managedBlock(status.hookScript);
133
+ const changed = [], skipped = [];
134
+ fs.mkdirSync(status.hooksDir, { recursive: true });
135
+ for (const name of HOOK_NAMES) {
136
+ const file = path.join(status.hooksDir, name);
137
+ const { state, raw } = hookFileState(file, status.hookScript);
138
+ let next = null;
139
+ if (state === 'absent') next = `#!/bin/sh\n${block}\n`;
140
+ else if (state === 'ours-stale') {
141
+ next = raw.replace(new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`), block);
142
+ if (!next.includes(block)) next = null; // markers mangled — refuse to guess
143
+ } else if (state === 'foreign-sh') next = raw.replace(/\n*$/, '\n') + block + '\n';
144
+ else if (state === 'foreign') { skipped.push(name); continue; }
145
+ if (next === null) { if (state !== 'ours') skipped.push(name); continue; }
146
+ // LF endings are load-bearing: git's sh rejects CRLF shebangs.
147
+ fs.writeFileSync(file, next.replace(/\r\n/g, '\n'), 'utf8');
148
+ try { fs.chmodSync(file, 0o755); } catch { /* Windows — irrelevant */ }
149
+ changed.push(name);
150
+ }
151
+ return { ok: true, changed, skipped, hooksDir: status.hooksDir };
152
+ }
153
+
154
+ export function removeGitCaptureHook(repoDir, { home = os.homedir() } = {}) {
155
+ const status = gitCaptureHookStatus(repoDir, { home });
156
+ if (!status.hooksDir) return { ok: false, reason: status.state, changed: [] };
157
+ // Make the removal STICK: SessionStart auto-install honors this marker, so a
158
+ // human's `git-hook remove` is a durable decision, not a one-session pause.
159
+ try {
160
+ fs.mkdirSync(path.join(repoDir, '.claude'), { recursive: true });
161
+ fs.writeFileSync(path.join(repoDir, OPTOUT_REL),
162
+ 'The commit-capture git hook was removed on purpose (npx klypix-mcp git-hook remove).\n'
163
+ + 'Sessions will NOT auto-reinstall it while this file exists. Opt back in: npx klypix-mcp git-hook install\n');
164
+ } catch { /* marker is best-effort; removal below still happens */ }
165
+ const changed = [];
166
+ for (const name of HOOK_NAMES) {
167
+ const file = path.join(status.hooksDir, name);
168
+ let raw = null;
169
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { continue; }
170
+ if (!raw.includes(MARK_START)) continue;
171
+ const stripped = raw.replace(new RegExp(`\\n?${MARK_START}[\\s\\S]*?${MARK_END}\\n?`), '\n');
172
+ const residue = stripped.replace(/^#!\/bin\/sh\n?/, '').trim();
173
+ if (residue) fs.writeFileSync(file, stripped, 'utf8');
174
+ else fs.unlinkSync(file); // nothing but our scaffold — leave no husk
175
+ changed.push(name);
176
+ }
177
+ return { ok: true, changed };
178
+ }
179
+
180
+ // Session-start convenience: install when safe (fresh repo or our own stale
181
+ // block), refresh silently, and NEVER touch a foreign hook — that case is left
182
+ // for doctor to surface. Returns a one-line notice only on a real transition.
183
+ //
184
+ // Consent posture: this writes an executable file into the repo's .git/hooks
185
+ // without a prompt, so it is deliberately bounded — only files we fully own,
186
+ // only a repo whose top holds this brain, a one-line announcement on the first
187
+ // install, a removal that STICKS (.claude/git-capture-optout), and a machine
188
+ // kill switch (KLYPIX_GIT_CAPTURE=0) for anyone who wants none of it.
189
+ export function ensureGitCaptureHook(repoDir, { home = os.homedir(), env = process.env } = {}) {
190
+ if (/^(0|false|off|no)$/i.test(String(env?.KLYPIX_GIT_CAPTURE || ''))) {
191
+ return { changed: false, state: 'disabled', notice: '' };
192
+ }
193
+ if (gitCaptureOptedOut(repoDir)) return { changed: false, state: 'opted-out', notice: '' };
194
+ const status = gitCaptureHookStatus(repoDir, { home });
195
+ if (['installed', 'no-git', 'custom-hookspath', 'nested-brain', 'no-engine'].includes(status.state)) {
196
+ return { changed: false, state: status.state, notice: '' };
197
+ }
198
+ // Auto-install writes only files we fully own: every target must be absent or
199
+ // carry our markers. ANY foreign hook (even sh-compatible) defers to the
200
+ // explicit CLI — silently editing someone's custom hook is not a session
201
+ // side-effect. `npx klypix-mcp git-hook install` chains those deliberately.
202
+ if (Object.values(status.hooks).some((s) => s === 'foreign' || s === 'foreign-sh')) {
203
+ return { changed: false, state: status.state, notice: '' };
204
+ }
205
+ const firstInstall = Object.values(status.hooks).every((s) => s === 'absent');
206
+ const res = installGitCaptureHook(repoDir, { home });
207
+ if (!res.ok || !res.changed.length) return { changed: false, state: status.state, notice: '' };
208
+ return {
209
+ changed: true,
210
+ state: 'installed',
211
+ notice: firstInstall
212
+ ? '\n🔧 Installed the git commit-capture hook (post-commit/post-merge) — rationale-bearing feat/fix/perf commits from ANY agent, branch, or worktree now card into the brain. Remove: `npx klypix-mcp git-hook remove`.'
213
+ : '',
214
+ };
215
+ }
@@ -308,7 +308,37 @@ const laneCanon = (p) => { try { return fs.realpathSync.native(p); } catch { ret
308
308
  const SESSIONS_FILE = path.join(SESSIONS_DIR, `${sha(normBrainPath(laneCanon(BRAIN)))}.json`); // one lane-file per PROJECT (shared by its concurrent sessions)
309
309
  const SESSIONS_LOCK = SESSIONS_FILE + '.lock';
310
310
  // tmp+rename: lock-free readers must never parse a torn lane as an empty one.
311
- function writeLaneAtomic(payload) { const tmp = SESSIONS_FILE + '.tmp-' + process.pid; fs.writeFileSync(tmp, payload); fs.renameSync(tmp, SESSIONS_FILE); }
311
+ // Windows rename-over-open-destination throws EPERM (AV / a concurrent reader):
312
+ // one immediate retry wins the race; on final failure the tmp is removed before
313
+ // rethrowing so failures can't litter the sessions dir (field: dozens of
314
+ // orphaned tmp files, 2026-08-07). A throttled janitor sweeps pre-fix orphans.
315
+ function writeLaneAtomic(payload) {
316
+ const tmp = SESSIONS_FILE + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2, 8);
317
+ fs.writeFileSync(tmp, payload);
318
+ try { fs.renameSync(tmp, SESSIONS_FILE); }
319
+ catch (err) {
320
+ try { fs.renameSync(tmp, SESSIONS_FILE); }
321
+ catch { try { fs.unlinkSync(tmp); } catch { /* best-effort */ } throw err; }
322
+ }
323
+ sweepStaleTmp(SESSIONS_DIR);
324
+ }
325
+ // PARITY: same throttle/age/pattern contract as agent-presence.mjs
326
+ // sweepStaleTmpFiles() — duplicated because this hook stays free of sibling
327
+ // static imports by design (a broken sibling must never kill the brief).
328
+ let lastTmpSweep = 0;
329
+ function sweepStaleTmp(dir) {
330
+ const now = Date.now();
331
+ if (now - lastTmpSweep < 10 * 60 * 1000) return;
332
+ lastTmpSweep = now;
333
+ try {
334
+ for (const name of fs.readdirSync(dir)) {
335
+ if (!/\.tmp-\d+(?:-[a-z0-9]+)?$|\.\d+\.tmp$/.test(name)) continue;
336
+ const full = path.join(dir, name);
337
+ try { if (now - fs.statSync(full).mtimeMs > 15 * 60 * 1000) fs.unlinkSync(full); }
338
+ catch { /* raced or locked — next sweep */ }
339
+ }
340
+ } catch { /* dir unreadable — best-effort */ }
341
+ }
312
342
  // Pending-captures queue: when the BRAIN lock cannot be acquired, the batch is
313
343
  // queued here durably instead of written from a stale base (a best-effort write
314
344
  // could erase a peer's or the desktop app's just-merged cards — the exact lost
@@ -387,6 +417,38 @@ const pruneSessions = (list, now) => {
387
417
  // SCHEMA PARITY: this writer and agent-presence.mjs upsertSession() write the
388
418
  // SAME row shape (client/surface/cwd/hostPid/channelSeen/intentAt/intentSource) —
389
419
  // `...prev` first so either writer's fields survive the other's touch (T8 class).
420
+
421
+ // ── Machine-turn intent guard (BEHAVIOR PARITY with agent-presence.mjs
422
+ // deriveIntentFromPrompt — duplicated because this hook stays free of sibling
423
+ // static imports; test/intent-guard.mjs asserts both stay identical) ─────────
424
+ // UserPromptSubmit is not only human-typed text: harnesses inject task
425
+ // notifications, system reminders and slash-command wrappers as "user" prompts,
426
+ // and one stored verbatim became a session's declared intent
427
+ // ("<task-notification> <task-id>…" — 2026-08-07 AgentLit field incident).
428
+ // null ⇒ the turn is machine-generated: KEEP the previous intent (still stamp
429
+ // activity), never store the junk, never clear a good intent.
430
+ const MACHINE_TAGS = 'task-notification|system-reminder|system-warning|local-command-caveat'
431
+ + '|command-name|command-message|command-args|command-contents|local-command-stdout|local-command-stderr'
432
+ + '|ide_selection|ide_opened_file|ide_diagnostics|persisted-output|tool-use-error'
433
+ + '|session-start-hook|user-prompt-submit-hook|post-tool-use-hook|hook-[a-z0-9-]+';
434
+ const MACHINE_BLOCK_RE = new RegExp(
435
+ '^(?:<(' + MACHINE_TAGS + ')\\b[^>]*>[\\s\\S]*?(?:</\\1>|$)|\\[SYSTEM NOTIFICATION[^\\]]*\\])\\s*', 'i');
436
+ const TAG_SHAPED_RE = /^<[a-z][\w.:-]*(?:\s|\/?>)/i;
437
+ function deriveIntentFromPrompt(raw) {
438
+ let text = String(raw || '').replace(/\s+/g, ' ').trim();
439
+ if (!text) return null;
440
+ // A "[SYSTEM NOTIFICATION …]"-led turn is machine end to end — the prose
441
+ // after the bracket header is harness narration, never the user's intent.
442
+ if (/^\[SYSTEM NOTIFICATION/i.test(text)) return null;
443
+ for (let hops = 0; hops < 12; hops++) {
444
+ const m = MACHINE_BLOCK_RE.exec(text);
445
+ if (!m) break;
446
+ text = text.slice(m[0].length).trim();
447
+ }
448
+ if (!text || TAG_SHAPED_RE.test(text)) return null;
449
+ return text;
450
+ }
451
+
390
452
  function touchSession(sid, patch = {}) {
391
453
  if (!sid) return;
392
454
  try {
@@ -414,10 +476,15 @@ function touchSession(sid, patch = {}) {
414
476
  const wantsIntent = patch.intent !== undefined && !declaredFresh;
415
477
  const nextIntent = wantsIntent ? patch.intent : (prev.intent ?? '');
416
478
  const intentChanged = wantsIntent && nextIntent !== (prev.intent ?? '');
479
+ // `activity: true` records a work-shaped turn WITHOUT an intent
480
+ // update — a machine-generated prompt (task notification landing in
481
+ // a working session) proves the session is active, but its text
482
+ // must never become the declared intent.
417
483
  const recordsActivity = patch.intent !== undefined
418
484
  || patch.addFiles !== undefined
419
485
  || patch.files !== undefined
420
- || patch.ships !== undefined;
486
+ || patch.ships !== undefined
487
+ || patch.activity === true;
421
488
  list.push({
422
489
  ...prev, // unknown/foreign keys (an MCP writer's, a future field) survive this touch
423
490
  id: sid, pid: process.pid, project: path.basename(CWD),
@@ -477,7 +544,8 @@ function touchSession(sid, patch = {}) {
477
544
  kept[String(HOST_PID)] = { sessionId: sid, ts: now };
478
545
  const tmp = HOSTMAP_FILE + '.' + process.pid + '.tmp';
479
546
  fs.writeFileSync(tmp, JSON.stringify(kept));
480
- fs.renameSync(tmp, HOSTMAP_FILE);
547
+ try { fs.renameSync(tmp, HOSTMAP_FILE); }
548
+ catch (err) { try { fs.unlinkSync(tmp); } catch { /* */ } throw err; }
481
549
  } catch { /* best-effort */ }
482
550
  }
483
551
  } finally { if (got) releaseLock(SESSIONS_LOCK); }
@@ -839,7 +907,15 @@ function fileTagsFor(p) {
839
907
  // re-baselines if history was rewritten (rebase/reset) so a non-ancestor range
840
908
  // can never dump the whole history.
841
909
  const COMMIT_STATE = path.resolve(CWD, '.claude', 'brain-last-commit');
842
- const readLastCommit = () => { try { return fs.readFileSync(COMMIT_STATE, 'utf8').trim() || null; } catch { return null; } };
910
+ // The baseline is repo-writable and gets interpolated into git commands — accept
911
+ // ONLY a bare sha, or a malicious checkout gains shell execution (review-caught
912
+ // 2026-08-07). An invalid file re-baselines exactly like a missing one.
913
+ const readLastCommit = () => {
914
+ try {
915
+ const s = fs.readFileSync(COMMIT_STATE, 'utf8').trim();
916
+ return /^[0-9a-f]{4,64}$/i.test(s) ? s : null;
917
+ } catch { return null; }
918
+ };
843
919
  const writeLastCommit = (s) => { try { fs.mkdirSync(path.dirname(COMMIT_STATE), { recursive: true }); fs.writeFileSync(COMMIT_STATE, String(s || '')); } catch { /* best-effort */ } };
844
920
  const git = (args) => execSync(`git ${args}`, { cwd: CWD, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
845
921
 
@@ -1846,8 +1922,46 @@ async function capture(lib) {
1846
1922
  for (const u of (Array.isArray(b.updates) ? b.updates : [])) updates.push(u);
1847
1923
  }
1848
1924
  if (!cards.length && !resolutions.length && !updates.length) return null;
1849
- const res = await lib.captureIntoBrain(fs.readFileSync(BRAIN), {
1850
- cards: cards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) })),
1925
+ const brainBuf = fs.readFileSync(BRAIN);
1926
+ // Dual-channel commit dedup (2026-08-07): the auto-installed git
1927
+ // post-commit hook cards commits AT COMMIT TIME with a #commit-<7> tag,
1928
+ // deduping against the brain before it writes. This side must honor the
1929
+ // same contract — without it every rationale-bearing commit in a hooked
1930
+ // repo carded twice (feat → duplicate 🏁 cards; fix/perf → the git
1931
+ // hook's minutes-old card got supersede-archived by its own clone).
1932
+ // Scoped to createdVia==='commit' — cards from THIS channel only. A
1933
+ // human marker that merely cites "#commit-abc1234" must never be
1934
+ // swallowed by the auto-card that shares its hash (self-review catch:
1935
+ // filtering on the tag alone is a silent-loss bug, and silent loss is
1936
+ // the one thing the brain may never do).
1937
+ const isCommitCard = (c) => c && c.createdVia === 'commit' && /#commit-[0-9a-f]{7}/i.test(String(c.text || ''));
1938
+ let landedCards = cards;
1939
+ try {
1940
+ if (cards.some(isCommitCard)) {
1941
+ const { struct: cur } = await lib.parseKlypix(brainBuf);
1942
+ const already = new Set();
1943
+ for (const c of (cur.cards || []))
1944
+ for (const m of String(c.text || '').matchAll(/#commit-([0-9a-f]{7})/gi)) already.add(m[1].toLowerCase());
1945
+ landedCards = cards.filter(c => {
1946
+ if (!isCommitCard(c)) return true;
1947
+ const m = /#commit-([0-9a-f]{7})/i.exec(String(c.text || ''));
1948
+ return !already.has(m[1].toLowerCase());
1949
+ });
1950
+ }
1951
+ } catch { /* parse failed → land unfiltered; the supersede pass copes */ }
1952
+ if (!landedCards.length && !resolutions.length && !updates.length) {
1953
+ // Everything gathered was already in the brain (the git hook carded
1954
+ // it first). Advance every baseline exactly like a successful write —
1955
+ // the commits ARE durable — so the same range never re-scans.
1956
+ writeState(merged);
1957
+ writeLastCommit(newLastCommit);
1958
+ if (drainedPendingIds.size) updatePendingCaptures((current) => current.filter(b => b && !drainedPendingIds.has(b.id)));
1959
+ if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
1960
+ advanceShipBaseline(lib);
1961
+ return null;
1962
+ }
1963
+ const res = await lib.captureIntoBrain(brainBuf, {
1964
+ cards: landedCards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) })),
1851
1965
  resolutions,
1852
1966
  updates,
1853
1967
  });
@@ -1995,6 +2109,11 @@ async function promptRetrieve(lib) {
1995
2109
  const input = readHookInput();
1996
2110
  const sid = input.session_id;
1997
2111
  const prompt = input.prompt || input.user_prompt || input.userPrompt || '';
2112
+ // Machine-turn guard: a harness-injected "user" prompt (task notification,
2113
+ // system reminder, slash-command wrapper) carries NO human intent — it must
2114
+ // neither become the lane's intent nor drive retrieval (it used to do both:
2115
+ // junk intent for peers, garbage tokens against the brain). null ⇒ machine.
2116
+ const humanText = deriveIntentFromPrompt(prompt);
1998
2117
  // Status-vocab quarantine (2026-07-23): "what is remaining?" must not
1999
2118
  // lexically retrieve the stale cards that SAY "remaining:" — status words
2000
2119
  // describe the question's shape, not its subject, and are anti-correlated
@@ -2004,10 +2123,10 @@ async function promptRetrieve(lib) {
2004
2123
  // #file- tag hits re-serve the exact corpse the quarantine removed
2005
2124
  // (adversarial review traced the side door). The brief's computed "Area
2006
2125
  // status" section carries the current-state answer instead.
2007
- let ptoks = lib.queryTokens(prompt);
2126
+ let ptoks = lib.queryTokens(humanText || '');
2008
2127
  let statusShaped = false, statusStrong = false;
2009
2128
  if (typeof lib.splitQueryTokens === 'function') {
2010
- const sp = lib.splitQueryTokens(prompt);
2129
+ const sp = lib.splitQueryTokens(humanText || '');
2011
2130
  ptoks = sp.content;
2012
2131
  statusShaped = sp.statusShaped;
2013
2132
  // strong = the prompt IS a status question (phrase shape / nothing but
@@ -2028,8 +2147,15 @@ async function promptRetrieve(lib) {
2028
2147
  // OWN edits (set at Stop). Here we just refresh INTENT + branch + the heartbeat.
2029
2148
  const changedPaths = gitChangedPaths();
2030
2149
  const branch = gitBranch();
2031
- touchSession(sid, { intent: String(prompt).replace(/\s+/g, ' ').trim().slice(0, 80), branch });
2032
- const fileToks = (ptoks.length < 2 && !statusShaped) ? changedPaths.flatMap(fileQueryTokens) : [];
2150
+ // Human turn → latest prompt becomes the intent (80 chars). Machine turn →
2151
+ // keep the previous intent, but still stamp activity: a notification landing
2152
+ // here proves the session is mid-work, not idle.
2153
+ touchSession(sid, humanText !== null
2154
+ ? { intent: humanText.slice(0, 80), branch }
2155
+ : { activity: true, branch });
2156
+ // The git-diff fallback exists for TERSE HUMAN prompts ("go on") — a machine
2157
+ // turn must not fall through to it and retrieve against the whole diff.
2158
+ const fileToks = (ptoks.length < 2 && !statusShaped && humanText !== null) ? changedPaths.flatMap(fileQueryTokens) : [];
2033
2159
  const tokens = [...new Set(ptoks.concat(fileToks))];
2034
2160
  // Other live sessions in THIS repo — surfaced even when the prompt retrieves
2035
2161
  // nothing (a peer's presence/ship is itself the signal). Empty string when solo.
@@ -2043,7 +2169,7 @@ async function promptRetrieve(lib) {
2043
2169
  // only on a do/build request, only completed-work cards, only high confidence.
2044
2170
  // Matched on the PROMPT's stated intent (ptoks), not the git-diff fallback. A
2045
2171
  // false nudge erodes trust, so it's strict; the loose recall list still shows.
2046
- if (looksLikeWorkRequest(prompt) && typeof lib.detectRepeatWork === 'function') {
2172
+ if (looksLikeWorkRequest(humanText || '') && typeof lib.detectRepeatWork === 'function') {
2047
2173
  try { repeats = lib.detectRepeatWork(struct, ptoks, { topK: 2 }); } catch { /* best-effort */ }
2048
2174
  }
2049
2175
  }
@@ -2061,7 +2187,10 @@ async function promptRetrieve(lib) {
2061
2187
  try {
2062
2188
  const semlib = await import(new URL('./brain-semantic.mjs', import.meta.url).href);
2063
2189
  if (typeof semlib.semanticVecs === 'function') {
2064
- const sem = await semlib.semanticVecs(BRAIN, struct, prompt, { timeoutMs: 1500 });
2190
+ // Embed the DERIVED human text, never the raw prompt — a mixed
2191
+ // turn's stripped machine block must not re-contaminate the
2192
+ // semantic query (parity with the lexical path above).
2193
+ const sem = await semlib.semanticVecs(BRAIN, struct, humanText || '', { timeoutMs: 1500 });
2065
2194
  if (!sem) semMode = 'sem-unavailable';
2066
2195
  else {
2067
2196
  const fresh = Date.now() - 30 * 86_400_000;
@@ -2593,6 +2722,17 @@ async function read(lib) {
2593
2722
  // watching (tag/version drift vs the per-project sidecar) and queue them
2594
2723
  // for the Stop capture. The line rides BOTH emit tiers.
2595
2724
  const shipObsLine = observeOutOfSessionShips(lib);
2725
+ // Commit-capture completeness (2026-08-07): the Stop hook's commit walk is
2726
+ // blind to commits authored in OTHER worktrees/branches and to non-hooked
2727
+ // agents. Ensure the agent-neutral git post-commit/post-merge hook is wired
2728
+ // — writes only files we fully own (absent or marker-fenced ours), never a
2729
+ // foreign hook. Dynamic + guarded so a stale bundle degrades to a no-op.
2730
+ const gitHookNotice = await (async () => {
2731
+ try {
2732
+ const ghl = await import(new URL('./git-capture-install.mjs', import.meta.url).href);
2733
+ return typeof ghl.ensureGitCaptureHook === 'function' ? (ghl.ensureGitCaptureHook(CWD).notice || '') : '';
2734
+ } catch { return ''; }
2735
+ })();
2596
2736
  const { struct } = await lib.parseKlypix(fs.readFileSync(BRAIN));
2597
2737
  const { freshness, drifted } = computeFreshness(struct);
2598
2738
  // The FULL brief: tiered brief + every self-heal/health footer. Messages are
@@ -2603,7 +2743,7 @@ async function read(lib) {
2603
2743
  + ruleDraftsFooter(input.session_id, struct, { markShown: false })
2604
2744
  + receiptLine + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
2605
2745
  const emitFull = () => {
2606
- process.stdout.write(full + presenceLine + shipObsLine + messageFooter(input.session_id || '', input.transcript_path, lib));
2746
+ process.stdout.write(full + presenceLine + shipObsLine + gitHookNotice + messageFooter(input.session_id || '', input.transcript_path, lib));
2607
2747
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
2608
2748
  };
2609
2749
  // --full = everything to stdout (manual runs); also the fallback when the
@@ -2644,7 +2784,7 @@ async function read(lib) {
2644
2784
  // go right after the ultra brief, at the top of the visible window, never
2645
2785
  // after a stack of footers that could push them past a preview cut.
2646
2786
  const messages = messageFooter(input.session_id || '', input.transcript_path, lib);
2647
- const out = ultra + messages + presenceLine + shipObsLine + healLine + draftLine
2787
+ const out = ultra + messages + presenceLine + shipObsLine + gitHookNotice + healLine + draftLine
2648
2788
  + receiptLine
2649
2789
  + inflightFooter(input.session_id, struct)
2650
2790
  + selfCheckFooter() + doctorFooter() + versionCurrencyFooter();
@@ -516,7 +516,14 @@ export function createMcpPresence({
516
516
  includePresence: true,
517
517
  includeSolo: true,
518
518
  deliverMessages: true,
519
- intent: completing ? '' : intent,
519
+ // Intent semantics by phase (2026-08-07 — a checkpoint sync carrying an
520
+ // empty intent used to DECLARE that emptiness, masking the session as
521
+ // sync-silent for every peer): 'complete' clears deliberately; 'start' is
522
+ // a task boundary, so a missing intent clears the OLD task's intent
523
+ // rather than inheriting it; 'checkpoint' with a missing/empty intent
524
+ // keeps the previous declaration — it is a progress ping, not a recant.
525
+ intent: completing ? ''
526
+ : (compact(intent) ? intent : (nextPhase === 'start' ? '' : undefined)),
520
527
  files: completing ? [] : files,
521
528
  replaceFiles: completing || nextPhase === 'start',
522
529
  };