klypix-mcp 1.60.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
@@ -79,7 +79,7 @@ npx klypix-mcp conformance
79
79
 
80
80
  It runs in a temporary fixture and touches nothing else. It checks tool discovery, task memory,
81
81
  truthful peer reporting, overlap surfacing, proactive logging, and in-band delivery of a peer note.
82
- It verifies 12 required coordination behaviours — not the 19 tools, and not the retrieval engine.
82
+ It verifies 12 required coordination behaviours — not the 21 tools, and not the retrieval engine.
83
83
 
84
84
  ---
85
85
 
@@ -431,13 +431,14 @@ 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 |
437
438
 
438
439
  ---
439
440
 
440
- ## The 19 verbs
441
+ ## The 21 verbs
441
442
 
442
443
  | Tool | What it does |
443
444
  |---|---|
@@ -452,7 +453,9 @@ The MCP verbs below are what agents call. These are what **you** call:
452
453
  | `brain_message` | Session-to-session coordination notes (24h TTL, never written into the brain) |
453
454
  | `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing |
454
455
  | `brain_connect` | Find and draw related-but-unlinked cards |
455
- | `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context, with exact-path review proposals; Graphify artifacts are supported but never installed or run locally |
456
+ | `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context, with exact-path review proposals; external artifacts (e.g. Graphify) are supported but never installed or run locally |
457
+ | `project_map_scan` | KLYPIX's own zero-install scanner: gitignore-aware file inventory + file-level import edges (relative, tsconfig-alias, and monorepo-workspace imports resolved) written to `klypix-map/graph.json` — which then serves `project_map_context` automatically |
458
+ | `project_map_drift` | Read-only drift report: brain cards whose referenced files are gone or moved (with rename candidates), plus a headline when the checkout itself is behind its origin default branch |
456
459
  | `canvas_view` | Returns the board as a structured render spec plus a text summary, and declares an MCP Apps (SEP-1865) UI resource |
457
460
  | `read_canvas` | A canvas as markdown (cards, connection graph, `[[links]]`, `#tags`) |
458
461
  | `search_canvases` | Search across canvases by name and content |
@@ -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
  '',
@@ -2,7 +2,7 @@
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
- import { discoverProjectGraph } from '../src/project-graph.mjs';
5
+ import { discoverProjectGraph, scanNativeProjectMap, checkBrainDrift, brainDriftMarkdown } from '../src/project-graph.mjs';
6
6
 
7
7
  const here = path.dirname(fileURLToPath(import.meta.url));
8
8
  const template = path.resolve(here, '..', 'examples', 'github', 'klypix-project-map.yml');
@@ -22,8 +22,11 @@ function usage() {
22
22
  'KLYPIX Project Map',
23
23
  '',
24
24
  ' klypix-project-map status [project]',
25
+ ' klypix-project-map scan [project] native no-install map: file inventory + import edges -> klypix-map/graph.json',
26
+ ' klypix-project-map drift [project] check brain cards\' file references against the repo\'s real files (read-only)',
25
27
  ' klypix-project-map setup-github [project] [--force]',
26
28
  '',
29
+ 'scan writes only its own artifact (klypix-map/graph.json) inside the project; drift writes nothing.',
27
30
  'setup-github installs a read-only, pinned GitHub Actions workflow. It never installs or runs a provider on your computer.',
28
31
  ].join('\n'));
29
32
  }
@@ -42,6 +45,19 @@ try {
42
45
  console.log(`GitHub workflow: ${fs.existsSync(workflow) ? 'installed' : 'not installed'}`);
43
46
  process.exit(0);
44
47
  }
48
+ if (command === 'scan') {
49
+ const result = scanNativeProjectMap({ project: root });
50
+ console.log(`Project: ${result.projectRoot}`);
51
+ console.log(`Artifact: ${result.artifactRelative} (${result.viaGit ? 'gitignore-aware' : 'walk fallback'}${result.truncated ? ', TRUNCATED at file cap' : ''})`);
52
+ console.log(`Files: ${result.counts.files.toLocaleString()} | parsed code files: ${result.counts.parsedCodeFiles.toLocaleString()} | import edges: ${result.counts.importEdges.toLocaleString()}`);
53
+ console.log(`Workspace packages: ${result.counts.workspacePackages} | external packages: ${result.counts.externalPackages} | minified skipped: ${result.counts.skippedMinified}`);
54
+ process.exit(0);
55
+ }
56
+ if (command === 'drift') {
57
+ const result = await checkBrainDrift({ project: root });
58
+ console.log(brainDriftMarkdown(result));
59
+ process.exit(result.status === 'ready' ? 0 : 1);
60
+ }
45
61
  if (command !== 'setup-github') {
46
62
  usage();
47
63
  process.exit(1);
@@ -30,7 +30,7 @@ import {
30
30
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
31
31
  opBrainTaskContext,
32
32
  } from '../src/klypix-core.mjs';
33
- import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks } from '../src/project-graph.mjs';
33
+ import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks, scanNativeProjectMap, checkBrainDrift, brainDriftMarkdown } from '../src/project-graph.mjs';
34
34
  import { auditProject, compactAgentsBrief, linkProject, mcpServerEntry } from '../src/agent-rules.mjs';
35
35
  import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
36
36
  import {
@@ -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
 
@@ -348,6 +353,39 @@ server.registerTool('project_map_context', {
348
353
  });
349
354
  });
350
355
 
356
+ server.registerTool('project_map_scan', {
357
+ title: 'Scan the project natively - no-install file inventory + import map',
358
+ description: 'KLYPIX\'s own zero-install scanner: walks the repo (gitignore-aware, junk dirs like browser profiles and build output excluded), extracts FILE-LEVEL import edges for the JS/TS family (relative, tsconfig-alias, and monorepo-workspace imports resolved), and writes klypix-map/graph.json inside the project — the only thing it ever writes. The artifact then serves project_map_context automatically when no Graphify artifact exists. It deliberately does not build a per-symbol AST graph; deeper external artifacts remain importable through the same door.',
359
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
360
+ inputSchema: {
361
+ project: z.string().optional().describe('Absolute project root. Defaults to this MCP connection\'s configured project/vault.'),
362
+ },
363
+ }, async ({ project }) => {
364
+ const root = typeof project === 'string' && project.trim() ? path.resolve(project.trim()) : path.resolve(mcpPresence.vault);
365
+ const result = scanNativeProjectMap({ project: root });
366
+ return toContent({
367
+ blocks: [{ kind: 'text', text: `Native project map written to \`${result.artifactRelative}\` (${result.viaGit ? 'gitignore-aware' : 'walk fallback'}${result.truncated ? '; TRUNCATED at the file cap' : ''}).\nFiles: ${result.counts.files.toLocaleString()}; parsed code files: ${result.counts.parsedCodeFiles.toLocaleString()}; import edges: ${result.counts.importEdges.toLocaleString()}; workspace packages: ${result.counts.workspacePackages}; minified skipped: ${result.counts.skippedMinified}.` }],
368
+ structured: result,
369
+ });
370
+ });
371
+
372
+ server.registerTool('project_map_drift', {
373
+ title: 'Check brain cards against the repo\'s real files (drift report)',
374
+ description: 'Read-only drift check: every brain card that references THIS project\'s files is verified against the working tree. Reports cards whose referenced files are gone or moved (with rename candidates by unique basename), and headlines when the checkout itself is behind its origin default branch — in that state a "missing" file may simply not be in this checkout. Slash-joined name enumerations and other-project paths are recognized and skipped, not reported as drift. Nothing is written; fix cards with a CORRECTION marker or by editing them in KLYPIX.',
375
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
376
+ inputSchema: {
377
+ project: z.string().optional().describe('Absolute project root. Defaults to this MCP connection\'s configured project/vault.'),
378
+ brain: z.string().optional().describe('Optional brain filename/path inside the project. Defaults to brain.klypix / brain.any.'),
379
+ },
380
+ }, async ({ project, brain }) => {
381
+ const root = typeof project === 'string' && project.trim() ? path.resolve(project.trim()) : path.resolve(mcpPresence.vault);
382
+ const result = await checkBrainDrift({ project: root, brain });
383
+ return toContent({
384
+ blocks: [{ kind: 'text', text: brainDriftMarkdown(result) }],
385
+ structured: result,
386
+ });
387
+ });
388
+
351
389
  server.registerTool('brain_challenge', {
352
390
  title: 'Challenge a decision against the brain (argue back with receipts)',
353
391
  description: 'BEFORE committing to a significant decision, ask the brain to ARGUE BACK: prior decisions that deterministically contradict the claim (correction-cue / opposite-polarity evidence — never mere topical similarity), 🛠 standing rules that dispute it, approaches tried before and REVERSED (with the correction/successor as the receipt), and open questions it collides with. Candidates, not verdicts — silence means "no deterministic contradiction signal", not verified consistency. Cards captured by a DIFFERENT agent are flagged so you coordinate instead of overriding. Dismiss a confirmed-false pair (after capturing the claim) with brain_connect pairs + relationship:"not_contradiction". Defaults to the project brain ("brain").',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.60.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' };