@haposoft/cafekit 0.18.1 → 0.18.3

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.
Files changed (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. package/src/codex/rules/state-sync.md +0 -60
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Install the CafeKit agents shipped in the Codex plugin into the user's Codex agents
6
+ * directory ($CODEX_HOME/agents, default ~/.codex/agents). Codex loads no agents from a
7
+ * plugin, so this runs once after the plugin is installed or updated.
8
+ *
9
+ * Every shipped file starts with a marker naming the plugin, its version and the sha256
10
+ * of the body below it. A file counts as CafeKit's own only when its marker hash still
11
+ * matches its body; only those are replaced. Anything else stays byte for byte:
12
+ * a missing marker, an edited body, or a symlink.
13
+ *
14
+ * A file it cannot read is kept and reported, and the other agents still install.
15
+ * Takes no arguments: the target is never chosen by the caller.
16
+ * Exit: 0 done, 1 failure, 2 usage.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const crypto = require('crypto');
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const MARKER = /^# CafeKit agent: plugin=(\S+) version=(\S+) sha256=([0-9a-f]{64})\n/;
26
+
27
+ const sha256 = (text) => crypto.createHash('sha256').update(text).digest('hex');
28
+
29
+ /** 'absent' | 'symlink' | 'foreign' | 'modified' | 'own' */
30
+ function ownership(file) {
31
+ let stat;
32
+ try {
33
+ stat = fs.lstatSync(file);
34
+ } catch (error) {
35
+ if (error.code === 'ENOENT') return 'absent';
36
+ throw error;
37
+ }
38
+ if (stat.isSymbolicLink()) return 'symlink';
39
+ if (!stat.isFile()) return 'foreign';
40
+ const text = fs.readFileSync(file, 'utf8');
41
+ const marker = text.match(MARKER);
42
+ if (!marker) return 'foreign';
43
+ return sha256(text.slice(marker[0].length)) === marker[3] ? 'own' : 'modified';
44
+ }
45
+
46
+ // Write beside the target and rename over it, so a reader never sees half a file.
47
+ function writeAtomic(file, data, mode) {
48
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
49
+ try {
50
+ fs.writeFileSync(temp, data, { mode, flag: 'wx' });
51
+ fs.chmodSync(temp, mode);
52
+ fs.renameSync(temp, file);
53
+ } catch (error) {
54
+ fs.rmSync(temp, { force: true });
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ // Codex reads agents from its home, so a session started with CODEX_HOME installs there.
60
+ // A relative CODEX_HOME is refused rather than guessed, so it never falls back to ~/.codex.
61
+ function codexHome() {
62
+ const configured = process.env.CODEX_HOME;
63
+ if (!configured) return path.join(os.homedir(), '.codex');
64
+ if (!path.isAbsolute(configured)) throw new Error(`CODEX_HOME must be an absolute path: ${configured}`);
65
+ return configured;
66
+ }
67
+
68
+ function repositoryRoot() {
69
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 5000 });
70
+ return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : process.cwd();
71
+ }
72
+
73
+ function main(argv) {
74
+ if (argv.length > 0) {
75
+ process.stderr.write('setup-agents takes no arguments; it installs into $CODEX_HOME/agents (default ~/.codex/agents).\n');
76
+ return 2;
77
+ }
78
+ const sourceDir = path.join(__dirname, '..', 'agents');
79
+ const targetDir = path.join(codexHome(), 'agents');
80
+ const repoAgents = path.join(repositoryRoot(), '.codex', 'agents');
81
+ const files = fs.readdirSync(sourceDir).filter((name) => name.endsWith('.toml')).sort();
82
+ fs.mkdirSync(targetDir, { recursive: true });
83
+
84
+ const counts = {};
85
+ for (const name of files) {
86
+ const shipped = fs.readFileSync(path.join(sourceDir, name), 'utf8');
87
+ const target = path.join(targetDir, name);
88
+ let state;
89
+ try {
90
+ state = ownership(target);
91
+ } catch {
92
+ state = 'unreadable';
93
+ }
94
+ let action;
95
+ if (state === 'absent') {
96
+ writeAtomic(target, shipped, 0o644);
97
+ action = 'written';
98
+ } else if (state === 'own') {
99
+ if (fs.readFileSync(target, 'utf8') === shipped) {
100
+ action = 'unchanged';
101
+ } else {
102
+ writeAtomic(target, shipped, fs.statSync(target).mode & 0o777);
103
+ action = 'replaced';
104
+ }
105
+ } else {
106
+ action = { symlink: 'kept (symlink)', foreign: 'kept (not installed by CafeKit)', modified: 'kept (modified)', unreadable: 'kept (unreadable)' }[state];
107
+ }
108
+ counts[action] = (counts[action] || 0) + 1;
109
+ process.stdout.write(`${action}: ${target}\n`);
110
+ if (fs.existsSync(path.join(repoAgents, name))) {
111
+ process.stdout.write(`collision: ${path.join(repoAgents, name)} has the same agent name in this repository\n`);
112
+ }
113
+ }
114
+ const summary = Object.entries(counts).map(([action, count]) => `${count} ${action}`).join(', ');
115
+ process.stdout.write(`CafeKit agents in ${targetDir}: ${summary}\n`);
116
+ return 0;
117
+ }
118
+
119
+ try {
120
+ process.exitCode = main(process.argv.slice(2));
121
+ } catch (error) {
122
+ process.stderr.write(`setup-agents failed: ${error.message}\n`);
123
+ process.exitCode = 1;
124
+ }
@@ -8,7 +8,7 @@
8
8
  keeps canonical execution proof in its final inline `## Receipt`.
9
9
  - Specs uses three user decisions: GATE-SCOPE for scope, GATE-REVIEW for adversarial findings,
10
10
  and GATE-DONE for completion. Planning never starts implementation; implementation
11
- requires a new explicit user invocation.
11
+ requires a new explicit user invocation. A vibe run the user invokes delegates these gates as the state-sync rule's vibe-run exception states.
12
12
  - Synchronize only observed task state with surgical edits. Never invent proof,
13
13
  readiness, approval, review independence, or completed work.
14
14
  - `NO_TESTS` and `0 tests + exit 0` do not pass when the task requires automated tests.
@@ -9,7 +9,11 @@
9
9
  * payload the scripts read, runs the scripts as child processes, and translates their
10
10
  * verdicts back into omp's contract.
11
11
  *
12
- * Three facts read from the installed omp binary drive the design:
12
+ * Facts read from the installed omp binary drive the design:
13
+ * - omp auto-loads only `<name>.{ts,js}` and `<dir>/index.{ts,js}` from `.omp/extensions/`, so this
14
+ * file is `.js`; an `.mjs` bridge is never loaded and every gate is silently off.
15
+ * - omp is a Bun binary: `process.execPath` is omp itself, not node, so the hooks are run
16
+ * with a `node` found on PATH (see hookRunner).
13
17
  * - omp fails CLOSED at `tool_call`: a handler that throws or exceeds
14
18
  * `extensionHandlers.toolCallTimeoutMs` (default 30000) is replaced by omp's own
15
19
  * `{ block: true, reason: "Extension … timed out" }`. The bridge therefore keeps a
@@ -26,7 +30,7 @@
26
30
 
27
31
  import { spawn } from 'node:child_process';
28
32
  import { randomUUID } from 'node:crypto';
29
- import { existsSync } from 'node:fs';
33
+ import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
30
34
  import path from 'node:path';
31
35
  import { fileURLToPath } from 'node:url';
32
36
 
@@ -38,16 +42,16 @@ export const BRIDGE_TIMEOUT_MS = 8000;
38
42
  * Mirrors src/claude/settings/settings.json; `omp-bridge.test.js` fails if the two drift.
39
43
  */
40
44
  export const DISPATCH = {
41
- session_start: { event: 'SessionStart', hooks: ['session.cjs', 'docs-sync.cjs', 'state.cjs'] },
45
+ session_start: { event: 'SessionStart', hooks: ['session.cjs', 'state.cjs'] },
42
46
  session_compact: { event: 'SessionStart', hooks: ['session.cjs'], source: 'compact' },
43
47
  session_before_compact: { event: 'PreCompact', hooks: ['precompact.cjs'] },
44
48
  input: { event: 'UserPromptSubmit', hooks: ['secret-output-guardrail.cjs', 'rules.cjs', 'spec-state.cjs'] },
45
49
  tool_call: { event: 'PreToolUse', hooks: ['privacy-block.cjs', 'inspect-block.cjs'] },
46
- tool_result: { event: 'PostToolUse', hooks: ['state.cjs'] },
50
+ tool_result: { event: 'PostToolUse', hooks: ['state.cjs', 'spec-state.cjs'] },
47
51
  session_stop: { event: 'Stop', hooks: ['spec-gate.cjs', 'state.cjs'] },
48
52
  };
49
53
 
50
- /** One id per extension load. omp loads an extension once per session, so this is per-session. */
54
+ /** Fallback id per extension load, for an omp whose context has no session manager. */
51
55
  const SESSION_ID = `omp-${randomUUID()}`;
52
56
 
53
57
  export function hooksDir() {
@@ -55,13 +59,37 @@ export function hooksDir() {
55
59
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hooks');
56
60
  }
57
61
 
62
+ function sessionIdFrom(ctx) {
63
+ try {
64
+ const id = ctx?.sessionManager?.getSessionId?.();
65
+ return typeof id === 'string' && id ? id : null;
66
+ } catch { return null; }
67
+ }
68
+
69
+ /**
70
+ * Only the file paths of a tool result's details. An edit's details also carry the old and
71
+ * new text of every file it changed, which no hook needs on stdin.
72
+ */
73
+ function resultPaths(details) {
74
+ if (!details || typeof details !== 'object') return {};
75
+ const paths = (list) => (Array.isArray(list) ? list.filter((entry) => typeof entry?.path === 'string').map((entry) => ({ path: entry.path })) : undefined);
76
+ const out = {};
77
+ if (typeof details.path === 'string') out.path = details.path;
78
+ const perFile = paths(details.perFileResults);
79
+ if (perFile) out.perFileResults = perFile;
80
+ const changes = paths(details.fileChanges);
81
+ if (changes) out.fileChanges = changes;
82
+ return out;
83
+ }
84
+
58
85
  /** Build the Claude-shaped payload a CafeKit hook reads on stdin. */
59
86
  export function shapePayload(kind, event, ctx) {
60
87
  const entry = DISPATCH[kind];
61
88
  const cwd = ctx?.cwd || process.cwd();
62
89
  const payload = {
63
90
  hook_event_name: entry.event,
64
- session_id: event?.session_id || SESSION_ID,
91
+ // omp's own id first: session_stop carries it, and touch and state must stay in one place.
92
+ session_id: event?.session_id || sessionIdFrom(ctx) || SESSION_ID,
65
93
  cwd,
66
94
  };
67
95
  switch (kind) {
@@ -85,7 +113,7 @@ export function shapePayload(kind, event, ctx) {
85
113
  case 'tool_result':
86
114
  payload.tool_name = event?.toolName ?? event?.tool_name ?? '';
87
115
  payload.tool_input = event?.input ?? {};
88
- payload.tool_response = { content: event?.content ?? [], isError: event?.isError === true };
116
+ payload.tool_response = { content: event?.content ?? [], isError: event?.isError === true, details: resultPaths(event?.details) };
89
117
  payload.tool_use_id = event?.toolCallId ?? null;
90
118
  break;
91
119
  case 'session_stop':
@@ -135,12 +163,54 @@ export function interpretVerdict({ code, stdout = '', stderr = '' }, hookName) {
135
163
  return { block: Boolean(block), reason: block?.reason, inject: inject.join('\n').trim() };
136
164
  }
137
165
 
166
+ function isInside(root, target) {
167
+ const rel = path.relative(root, target);
168
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
169
+ }
170
+
171
+ function realOrSelf(target) {
172
+ try { return realpathSync(target); } catch { return path.resolve(target); }
173
+ }
174
+
175
+ /**
176
+ * The executable that runs a hook. Under node that is `process.execPath`. Under Bun (omp)
177
+ * it is the first `node` on PATH that sits in an absolute PATH entry outside the project,
178
+ * so a repository cannot plant its own `node` (`.`, `node_modules/.bin`) in front of the
179
+ * gates. Returns `{ command }` or `{ error }`; never throws.
180
+ */
181
+ export function hookRunner({ cwd = process.cwd(), env = process.env, bun = process.versions?.bun, platform = process.platform } = {}) {
182
+ if (!bun) return { command: process.execPath };
183
+ const project = realOrSelf(cwd);
184
+ const names = platform === 'win32'
185
+ ? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean).map((ext) => `node${ext.toLowerCase()}`)
186
+ : ['node'];
187
+ const delimiter = platform === 'win32' ? ';' : ':';
188
+ for (const dir of String(env.PATH || env.Path || '').split(delimiter)) {
189
+ if (!dir || !path.isAbsolute(dir)) continue;
190
+ for (const name of names) {
191
+ const candidate = path.join(dir, name);
192
+ try {
193
+ if (!statSync(candidate).isFile()) continue;
194
+ if (platform !== 'win32') accessSync(candidate, constants.X_OK);
195
+ } catch { continue; }
196
+ if (isInside(project, realOrSelf(candidate))) continue;
197
+ return { command: candidate };
198
+ }
199
+ }
200
+ return { error: 'node was not found on PATH outside the project' };
201
+ }
202
+
138
203
  /** Run one hook as a child process with a hard budget. Never throws. */
139
204
  export function dispatchHook(hookPath, payload, { cwd, timeoutMs = BRIDGE_TIMEOUT_MS } = {}) {
140
205
  return new Promise((resolve) => {
206
+ const runner = hookRunner({ cwd });
207
+ if (runner.error) {
208
+ resolve({ code: null, signal: null, stdout: '', stderr: runner.error, timedOut: false, failed: true });
209
+ return;
210
+ }
141
211
  let child;
142
212
  try {
143
- child = spawn(process.execPath, [hookPath], {
213
+ child = spawn(runner.command, [hookPath], {
144
214
  cwd,
145
215
  env: { ...process.env, PROJECT_ROOT: cwd },
146
216
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -159,7 +229,7 @@ export function dispatchHook(hookPath, payload, { cwd, timeoutMs = BRIDGE_TIMEOU
159
229
  child.stdout.on('data', (c) => out.push(c));
160
230
  child.stderr.on('data', (c) => err.push(c));
161
231
  child.on('error', (error) => { clearTimeout(timer); finish({ code: null, stdout: '', stderr: String(error?.message || error), timedOut: false, failed: true }); });
162
- child.on('close', (code) => { clearTimeout(timer); finish({ code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut: false, failed: false }); });
232
+ child.on('close', (code, signal) => { clearTimeout(timer); finish({ code, signal, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut: false, failed: false }); });
163
233
  try { child.stdin.write(JSON.stringify(payload)); child.stdin.end(); } catch { /* close handler still runs */ }
164
234
  });
165
235
  }
@@ -198,6 +268,12 @@ export async function runEvent(kind, event, ctx, { timeoutMs = BRIDGE_TIMEOUT_MS
198
268
  }
199
269
  const verdict = interpretVerdict(result, hookName);
200
270
  if (verdict.block) return { block: true, reason: verdict.reason, hook: hookName };
271
+ // A gate that dies without a verdict (a broken node shim, a crash, a kill) must not
272
+ // read as an allow: only 0 and 2 are answers a gate gives on purpose.
273
+ if (gating && result.code !== 0 && result.code !== 2) {
274
+ const how = result.signal ? `was killed by ${result.signal}` : `exited with code ${result.code}`;
275
+ return { block: true, reason: `CafeKit gate ${hookName} ${how}`, hook: hookName };
276
+ }
201
277
  if (verdict.inject) injections.push(verdict.inject);
202
278
  }
203
279
  return { block: false, inject: injections.join('\n\n') };
@@ -14,32 +14,23 @@
14
14
  * Exit: 0 (the JSON hook output carries the permission decision)
15
15
  */
16
16
 
17
- try {
18
- const fs = require('fs');
19
- const path = require('path');
20
- const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
17
+ const EVALUATION_FAILURE = 'Sensitive access could not be evaluated safely.';
21
18
 
22
- const RESTRICTED_PATTERNS = [
23
- /^\.env(?:[.\[*?{]|$)/i,
24
- /^credentials/i,
25
- /secrets?\.(ya?ml|json)$/i,
26
- /\.pem$/i,
27
- /\.key$/i,
28
- /\.p12$/i,
29
- /\.pfx$/i,
30
- /^id_(rsa|ed25519|ecdsa|dsa)$/i,
31
- /\.netrc$/i,
32
- /\.pgpass$/i,
33
- /kubeconfig/i,
34
- /\.keystore$/i,
35
- /\.jks$/i,
36
- /auth\.json$/i,
37
- /token(s)?\.json$/i
38
- ];
19
+ function askForPermission(reason) {
20
+ process.stdout.write(JSON.stringify({
21
+ hookSpecificOutput: {
22
+ hookEventName: 'PreToolUse',
23
+ // omp has no ask state on tool_call, so an ask under Claude becomes a denial here.
24
+ permissionDecision: 'deny',
25
+ permissionDecisionReason: reason
26
+ }
27
+ }) + '\n');
28
+ }
39
29
 
40
- const ALLOWED_EXEMPTIONS = [
41
- /\.env\.(example|sample|template|test)$/i
42
- ];
30
+ try {
31
+ const fs = require('fs');
32
+ const { sensitivePaths, displayNames } = require('./lib/privacy-paths.cjs');
33
+ const { projectRoot: resolveProjectRoot } = require('./lib/project-root.cjs');
43
34
 
44
35
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
45
36
  function readRuntime(cwd) {
@@ -51,200 +42,28 @@ try {
51
42
  }
52
43
  }
53
44
 
54
- function isSafe(filePath) {
55
- const base = path.basename(filePath);
56
- return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
57
- }
58
-
59
- function isSensitive(filePath) {
60
- const base = path.basename(filePath);
61
- return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
62
- }
63
-
64
- /**
65
- * Resolve a symlink to its real target so a harmless-looking name that points
66
- * at a sensitive file cannot slip through the basename check. Returns null
67
- * when the path cannot be resolved (missing file, broken link) — fail-open to
68
- * the original-path check in that case. See isSymlinkToSensitive for dangling handling.
69
- */
70
- function resolveTarget(filePath, cwd) {
71
- try {
72
- const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
73
- return fs.realpathSync(requested);
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- /**
80
- * When realpath fails, check whether the requested path is a symlink (or chain)
81
- * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
82
- * dangling benign link or link to allowed example/template -> false (allow).
83
- * Limit hops to avoid loops; malformed/looping -> fail closed.
84
- * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
85
- * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
86
- */
87
- function isSymlinkToSensitive(filePath, cwd) {
88
- try {
89
- const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
90
- let current = requested;
91
- let hops = 0;
92
- const MAX_HOPS = 8;
93
- while (hops < MAX_HOPS) {
94
- let stat;
95
- try {
96
- stat = fs.lstatSync(current);
97
- } catch {
98
- return false;
99
- }
100
- if (!stat.isSymbolicLink()) return false;
101
- let linkTarget;
102
- try {
103
- linkTarget = fs.readlinkSync(current);
104
- } catch {
105
- return true;
106
- }
107
- if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
108
- if (isSafe(linkTarget)) return false;
109
- const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
110
- if (next === current) return true;
111
- try {
112
- const real = fs.realpathSync(next);
113
- if (isSensitive(real) && !isSafe(real)) return true;
114
- if (isSafe(real)) return false;
115
- return false;
116
- } catch {
117
- current = next;
118
- hops += 1;
119
- continue;
120
- }
121
- }
122
- return true;
123
- } catch {
124
- return true;
125
- }
126
- }
127
-
128
- function extractBashPaths(command) {
129
- // The analyzer parses the command and returns the operands of every reading
130
- // command it recognises, nested shells and `find -exec` included. Treating
131
- // every unresolved expansion as sensitive, as this did before, asked on
132
- // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
133
- // people to click through the prompt that matters.
134
- // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
135
- // expands nothing inside it, so no word in the body is ever read. The
136
- // analyzer has no notion of heredocs and parses the body as shell, so prose
137
- // that quotes a command -- a PR body carrying `cat .env` in backticks --
138
- // came back as a real command substitution. Blank those bodies out first.
139
- // An unquoted delimiter (<<BODY) does expand, so it is left to the
140
- // analyzer.
141
- const scrubbed = String(command).replace(
142
- /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
143
- '$1$2$3$2'
144
- );
145
-
146
- const paths = new Set(commandAccess(scrubbed).paths);
147
-
148
- // Two shapes the analyzer cannot return. It drops any word carrying an
149
- // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
150
- // by name. And it only walks commands it recognises, so an unknown tool
151
- // handed a secret through a file-shaped option, such as
152
- // `docker compose --env-file .env`, is invisible to it.
153
- const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
154
- const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
155
- for (let index = 0; index < tokens.length; index += 1) {
156
- const token = tokens[index];
157
- const shaped = /^~|[*?[{]/.test(token);
158
- const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
159
- if (!shaped && !optionFed) continue;
160
- const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
161
- if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
162
- }
163
-
164
- return [...paths];
165
- }
166
-
167
- function extractPaths(toolName, input) {
168
- const paths = [];
169
- if (!input) return paths;
170
-
171
- for (const key of ['file_path', 'path']) {
172
- if (typeof input[key] === 'string' && input[key].trim()) {
173
- paths.push(input[key].trim());
174
- }
175
- }
176
-
177
- for (const key of ['paths', 'search_paths']) {
178
- if (Array.isArray(input[key])) {
179
- paths.push(...input[key].filter(Boolean));
180
- }
181
- }
182
-
183
- if (toolName === 'Bash' && typeof input.command === 'string') {
184
- paths.push(...extractBashPaths(input.command));
185
- }
186
-
187
- return paths.filter(Boolean);
188
- }
189
-
190
- function askForPermission(filePath) {
191
- const basename = path.basename(filePath);
192
- process.stdout.write(JSON.stringify({
193
- hookSpecificOutput: {
194
- hookEventName: 'PreToolUse',
195
- // omp has no ask state on tool_call, so an ask under Claude becomes a denial here.
196
- permissionDecision: 'deny',
197
- permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
198
- }
199
- }) + '\n');
200
- }
201
-
202
45
  const stdin = fs.readFileSync(0, 'utf8').trim();
203
- if (!stdin) process.exit(0);
46
+ if (!stdin) {
47
+ askForPermission(EVALUATION_FAILURE);
48
+ process.exit(0);
49
+ }
204
50
 
205
51
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
206
52
  const data = normalizeHookPayload(JSON.parse(stdin));
207
53
  const toolName = data.tool_name || '';
208
54
  const toolInput = data.tool_input || {};
209
55
  const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
210
- const runtime = readRuntime(cwd);
211
-
212
- if (runtime.privacyBlock === false) process.exit(0);
213
-
214
- const paths = extractPaths(toolName, toolInput);
215
- if (!paths.length) process.exit(0);
56
+ const projectRoot = resolveProjectRoot(data);
57
+ const runtime = readRuntime(projectRoot);
216
58
 
217
- for (const filePath of paths) {
218
- const target = resolveTarget(filePath, cwd);
59
+ // A plugin is trusted once for every repository, so a repository cannot switch it off.
60
+ if (runtime.privacyBlock === false && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
219
61
 
220
- // The real target is authoritative: an exempt-looking alias must not hide
221
- // a sensitive target, while a real .env.example target remains safe.
222
- if (target) {
223
- if (isSensitive(target) && !isSafe(target)) {
224
- askForPermission(filePath);
225
- process.exit(0);
226
- }
227
- if (isSafe(target)) continue;
228
- } else {
229
- if (isSymlinkToSensitive(filePath, cwd)) {
230
- askForPermission(filePath);
231
- process.exit(0);
232
- }
233
- }
234
-
235
- if (isSafe(filePath) || !isSensitive(filePath)) continue;
236
- askForPermission(filePath);
237
- process.exit(0);
238
- }
62
+ const paths = sensitivePaths(toolName, toolInput, cwd);
63
+ if (paths.length) askForPermission(`Sensitive file access requires approval: ${displayNames(paths)}`);
239
64
 
240
65
  process.exit(0);
241
66
  } catch (_) {
242
- process.stdout.write(JSON.stringify({
243
- hookSpecificOutput: {
244
- hookEventName: 'PreToolUse',
245
- permissionDecision: 'deny',
246
- permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
247
- }
248
- }) + '\n');
67
+ askForPermission(EVALUATION_FAILURE);
249
68
  process.exit(0);
250
69
  }
@@ -1,60 +0,0 @@
1
- # Process-first state synchronization
2
-
3
- ## Primary file state
4
-
5
- For new work, `specs/<feature>/plan.md` and its direct-child
6
- `task-NN-*.md` files are canonical, hand-editable state. Task files stay flat
7
- beside the plan; indexes and host task views are disposable projections.
8
-
9
- Each task has exactly one `Status:` field using `pending`, `in_progress`,
10
- `paused`, `blocked`, or `done`. The controller is the sole writer of task
11
- status and proof. A task's final `## Receipt` is its only canonical execution
12
- proof.
13
-
14
- ## Surgical synchronization
15
-
16
- Before changing state:
17
-
18
- 1. read the plan row and the task's Outcome, Acceptance, Dependencies,
19
- Verification Plan, Status, and Receipt;
20
- 2. confirm every dependency and owned path at the requested boundary;
21
- 3. edit only the observed Status, concrete blocker, implemented checkbox, or
22
- Receipt; and
23
- 4. re-read the file and confirm exactly one Status field and one Receipt
24
- section.
25
-
26
- Starting or resuming changes only Status to `in_progress`. Blocking records a
27
- specific cause without creating proof. Do not replace a whole packet to make a
28
- state transition.
29
-
30
- Set `Status: done` only after the inline Receipt passes the
31
- `validateCanonicalReceipt` contract: exact command, `Exit: 0`,
32
- `Verification: PASS`, runtime-derived Base and Head values, and a non-empty
33
- fenced block of current command output.
34
-
35
- A done task is bound to live Base and Head only while its own task file has a copy in
36
- `HEAD` that its current bytes no longer match. A file that is committed and unchanged,
37
- and a file that was never committed at all, are both validated on structure alone: the
38
- first has a baseline it still matches, the second has no baseline to drift from, and
39
- committing a packet is the user's choice that a gitignored specs root removes entirely.
40
- Work elsewhere in the tree never reopens a receipt, because Base is the last commit
41
- touching anything outside the specs root, so every finished receipt records an older
42
- Base by construction. Every structural check runs in all cases.
43
- The gate detects drift, not invention: a valid
44
- Base and Head pair costs one command and no verification run, so neither mode proves
45
- that the command was executed. Missing, stale, contradictory,
46
- placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
47
-
48
- Synchronization never fabricates commands, output, timestamps, completed
49
- work, reviewer identity, user approval, or product readiness. A passing command
50
- proves only the boundary it executed.
51
-
52
- ## Human gates and workflow ownership
53
-
54
- - GATE-SCOPE records the user's scope decision before the plan is written.
55
- - GATE-REVIEW records the user's disposition of deduplicated adversarial findings.
56
- - Develop starts only after a new explicit user invocation and executes one
57
- unblocked task at a time.
58
- - Sync changes only observed file state and never starts implementation.
59
- - GATE-DONE occurs after current receipts and limitations are shown; the user decides
60
- whether the requested feature is complete.