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