@haposoft/cafekit 0.18.2 → 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 (118) hide show
  1. package/README.md +1 -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__/codex-hooks.test.js +38 -6
  7. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  8. package/bin/__tests__/codex-native.test.js +15 -8
  9. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  10. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  11. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  12. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  13. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  14. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  15. package/bin/__tests__/migrate-apply.test.js +373 -0
  16. package/bin/__tests__/migrate-cli.test.js +152 -0
  17. package/bin/__tests__/migrate-merged.test.js +269 -0
  18. package/bin/__tests__/migrate-plan.test.js +271 -0
  19. package/bin/__tests__/omp-bridge.test.js +81 -5
  20. package/bin/__tests__/omp-hooks.test.js +24 -0
  21. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  22. package/bin/__tests__/privacy-paths.test.js +184 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  24. package/bin/__tests__/vibe-run.test.js +215 -0
  25. package/bin/__tests__/vibe-skill.test.js +549 -0
  26. package/bin/install.js +99 -1
  27. package/bin/lib/backup.js +43 -24
  28. package/bin/lib/codex-install.js +7 -0
  29. package/bin/lib/context.js +5 -0
  30. package/bin/lib/lock.js +98 -18
  31. package/bin/lib/managed-writer.js +16 -10
  32. package/bin/lib/migrate-apply.js +202 -0
  33. package/bin/lib/migrate-merged.js +342 -0
  34. package/bin/lib/migrate-plan.js +419 -0
  35. package/bin/lib/plugin-inventory.js +33 -0
  36. package/bin/phases/claude-runtime.js +5 -3
  37. package/bin/phases/codex-hooks.js +38 -0
  38. package/bin/phases/codex-runtime.js +106 -74
  39. package/bin/phases/copy-payload.js +49 -1
  40. package/bin/phases/omp-runtime.js +4 -26
  41. package/bin/phases/root-config.js +5 -2
  42. package/package.json +1 -1
  43. package/src/claude/CLAUDE.md +1 -1
  44. package/src/claude/agents/advisor.md +55 -0
  45. package/src/claude/agents/debugger.md +2 -3
  46. package/src/claude/agents/deployer.md +2 -1
  47. package/src/claude/agents/docs-keeper.md +12 -0
  48. package/src/claude/agents/inspector.md +1 -1
  49. package/src/claude/agents/project-manager.md +2 -2
  50. package/src/claude/agents/researcher.md +0 -1
  51. package/src/claude/agents/spec-maker.md +4 -1
  52. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  53. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  54. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  55. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  56. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
  57. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  58. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
  59. package/src/claude/hooks/agent.cjs +5 -2
  60. package/src/claude/hooks/inspect-block.cjs +3 -1
  61. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  62. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  63. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  64. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  65. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  66. package/src/claude/hooks/plugin-context.cjs +80 -0
  67. package/src/claude/hooks/privacy-block.cjs +2 -1
  68. package/src/claude/hooks/rules.cjs +5 -2
  69. package/src/claude/hooks/session.cjs +4 -1
  70. package/src/claude/hooks/spec-state.cjs +5 -4
  71. package/src/claude/hooks/state.cjs +5 -1
  72. package/src/claude/migration-manifest.json +6 -1
  73. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  74. package/src/claude/plugin/core.md +71 -0
  75. package/src/claude/rules/manage-docs.md +1 -2
  76. package/src/claude/rules/review-audit-self-decision.md +1 -1
  77. package/src/claude/rules/skill-workflow-routing.md +1 -1
  78. package/src/claude/rules/state-sync.md +14 -0
  79. package/src/claude/rules/workflow.md +1 -1
  80. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  81. package/src/claude/scripts/provenance.cjs +40 -16
  82. package/src/claude/scripts/setup-statusline.cjs +88 -0
  83. package/src/claude/skills/adv/SKILL.md +139 -0
  84. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  85. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  86. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  87. package/src/claude/skills/code-review/SKILL.md +4 -1
  88. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  89. package/src/claude/skills/develop/SKILL.md +5 -3
  90. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  91. package/src/claude/skills/fix/SKILL.md +2 -2
  92. package/src/claude/skills/git/SKILL.md +4 -2
  93. package/src/claude/skills/git/references/merge-pr.md +125 -0
  94. package/src/claude/skills/specs/SKILL.md +2 -2
  95. package/src/claude/skills/specs/references/review.md +1 -1
  96. package/src/claude/skills/specs/references/templates.md +2 -0
  97. package/src/claude/skills/vibe/SKILL.md +69 -0
  98. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  99. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  100. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  101. package/src/codex/AGENTS.md +1 -1
  102. package/src/codex/hooks/inspect-block.cjs +7 -4
  103. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  104. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  105. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  106. package/src/codex/hooks/lib/state-store.cjs +5 -4
  107. package/src/codex/hooks/precompact.cjs +9 -8
  108. package/src/codex/hooks/privacy-approval.cjs +2 -2
  109. package/src/codex/hooks/privacy-block.cjs +5 -4
  110. package/src/codex/hooks/rules.cjs +12 -6
  111. package/src/codex/hooks/session.cjs +22 -10
  112. package/src/codex/plugin/core.md +76 -0
  113. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  114. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  115. package/src/codex/scripts/setup-agents.cjs +124 -0
  116. package/src/common/AGENTS.md +1 -1
  117. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  118. package/src/omp/hooks/privacy-block.cjs +2 -1
@@ -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
 
@@ -62,6 +66,22 @@ function sessionIdFrom(ctx) {
62
66
  } catch { return null; }
63
67
  }
64
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
+
65
85
  /** Build the Claude-shaped payload a CafeKit hook reads on stdin. */
66
86
  export function shapePayload(kind, event, ctx) {
67
87
  const entry = DISPATCH[kind];
@@ -93,7 +113,7 @@ export function shapePayload(kind, event, ctx) {
93
113
  case 'tool_result':
94
114
  payload.tool_name = event?.toolName ?? event?.tool_name ?? '';
95
115
  payload.tool_input = event?.input ?? {};
96
- 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) };
97
117
  payload.tool_use_id = event?.toolCallId ?? null;
98
118
  break;
99
119
  case 'session_stop':
@@ -143,12 +163,54 @@ export function interpretVerdict({ code, stdout = '', stderr = '' }, hookName) {
143
163
  return { block: Boolean(block), reason: block?.reason, inject: inject.join('\n').trim() };
144
164
  }
145
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
+
146
203
  /** Run one hook as a child process with a hard budget. Never throws. */
147
204
  export function dispatchHook(hookPath, payload, { cwd, timeoutMs = BRIDGE_TIMEOUT_MS } = {}) {
148
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
+ }
149
211
  let child;
150
212
  try {
151
- child = spawn(process.execPath, [hookPath], {
213
+ child = spawn(runner.command, [hookPath], {
152
214
  cwd,
153
215
  env: { ...process.env, PROJECT_ROOT: cwd },
154
216
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -167,7 +229,7 @@ export function dispatchHook(hookPath, payload, { cwd, timeoutMs = BRIDGE_TIMEOU
167
229
  child.stdout.on('data', (c) => out.push(c));
168
230
  child.stderr.on('data', (c) => err.push(c));
169
231
  child.on('error', (error) => { clearTimeout(timer); finish({ code: null, stdout: '', stderr: String(error?.message || error), timedOut: false, failed: true }); });
170
- 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 }); });
171
233
  try { child.stdin.write(JSON.stringify(payload)); child.stdin.end(); } catch { /* close handler still runs */ }
172
234
  });
173
235
  }
@@ -206,6 +268,12 @@ export async function runEvent(kind, event, ctx, { timeoutMs = BRIDGE_TIMEOUT_MS
206
268
  }
207
269
  const verdict = interpretVerdict(result, hookName);
208
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
+ }
209
277
  if (verdict.inject) injections.push(verdict.inject);
210
278
  }
211
279
  return { block: false, inject: injections.join('\n\n') };
@@ -56,7 +56,8 @@ try {
56
56
  const projectRoot = resolveProjectRoot(data);
57
57
  const runtime = readRuntime(projectRoot);
58
58
 
59
- if (runtime.privacyBlock === false) process.exit(0);
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);
60
61
 
61
62
  const paths = sensitivePaths(toolName, toolInput, cwd);
62
63
  if (paths.length) askForPermission(`Sensitive file access requires approval: ${displayNames(paths)}`);