@haposoft/cafekit 0.18.0 → 0.18.2

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 (162) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +102 -1362
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.18.0-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.18.2-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Native-orange.svg)](https://claude.ai/code)
8
8
  [![Codex%20CLI](https://img.shields.io/badge/Codex%20CLI-Native-111111.svg)](https://developers.openai.com/codex)
@@ -102,6 +102,10 @@ CLAUDE.md
102
102
  CafeKit owns only its marked block inside the project-root `CLAUDE.md`; project
103
103
  instructions outside that block are preserved across installs and upgrades.
104
104
 
105
+ Project notes (commands, files to leave unchanged, slow or expensive operations)
106
+ belong in the root AGENTS.md after the CAFEKIT CORE END marker; Claude Code reads
107
+ them through `@AGENTS.md` and Codex reads AGENTS.md directly.
108
+
105
109
  Codex CLI targets:
106
110
 
107
111
  ```text
@@ -132,9 +136,16 @@ stable when a session starts in a subdirectory. They also carry a PATH floor poi
132
136
  the Node.js that installed them, so a host launched from the macOS GUI — whose children
133
137
  inherit only `/usr/bin:/bin:/usr/sbin:/sbin` — can still find the interpreter. The floor
134
138
  is appended, so a version manager's current Node still wins in a normal terminal.
139
+ Reinstall after moving a project updates CafeKit-owned launcher paths and preserves
140
+ custom hooks. Docs sync runs on demand; startup omits empty prior context, and
141
+ Specs reminders follow the packets selected or successfully edited by that session.
135
142
  CafeKit uses Codex's native status and usage UI instead of installing the
136
143
  Claude statusline.
137
144
 
145
+ Project notes (commands, files to leave unchanged, slow or expensive operations)
146
+ belong in the root AGENTS.md after the CAFEKIT CORE END marker; Claude Code reads
147
+ them through `@AGENTS.md` and Codex reads AGENTS.md directly.
148
+
138
149
  To check the installed CafeKit package version:
139
150
 
140
151
  ```bash
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { test } = require('node:test');
8
+ const { mergeClaudeSettings } = require('../phases/claude-settings');
9
+ const manifest = require('../../src/claude/migration-manifest.json');
10
+ const template = require('../../src/claude/settings/settings.json');
11
+
12
+ const MATCHER = 'Read|Write|Edit|MultiEdit|NotebookEdit|Bash|Glob|Grep|mcp__.*';
13
+ const OLD = 'Read|Write|Edit|MultiEdit|Bash|Glob|Grep';
14
+ const templateGroup = template.hooks.PreToolUse[0];
15
+ const privacy = templateGroup.hooks.find(hook => hook.command.includes('/privacy-block.cjs'));
16
+ const inspect = templateGroup.hooks.find(hook => hook.command.includes('/inspect-block.cjs'));
17
+ const group = (matcher, hooks = [privacy, inspect]) => ({ matcher, hooks });
18
+
19
+ function withSettings(groups, run, dryRun = false) {
20
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-claude-settings-'));
21
+ const previousCwd = process.cwd();
22
+ try {
23
+ process.chdir(root);
24
+ fs.mkdirSync('.claude');
25
+ const settingsPath = path.join(root, '.claude/settings.json');
26
+ if (groups) fs.writeFileSync(settingsPath, JSON.stringify({ hooks: { PreToolUse: groups } }, null, 2));
27
+ const before = fs.existsSync(settingsPath) ? fs.readFileSync(settingsPath, 'utf8') : null;
28
+ const messages = [];
29
+ const ctx = {
30
+ manifest, dryRun, options: {}, results: { updated: 0, errors: 0 },
31
+ ui: { detail: message => messages.push(message), warn: message => messages.push(message) },
32
+ };
33
+ mergeClaudeSettings(ctx, 'claude');
34
+ const after = fs.readFileSync(settingsPath, 'utf8');
35
+ run({ before, after, groups: JSON.parse(after).hooks.PreToolUse, messages });
36
+ } finally {
37
+ process.chdir(previousCwd);
38
+ fs.rmSync(root, { recursive: true, force: true });
39
+ }
40
+ }
41
+
42
+ function assertNoDuplicateCommands(groups) {
43
+ const commands = groups.flatMap(entry => entry.hooks.map(hook => hook.command));
44
+ assert.equal(commands.length, new Set(commands).size);
45
+ }
46
+
47
+ test('(a) claude-settings updates the former matcher for managed privacy and inspect', () => {
48
+ withSettings([group(OLD)], ({ groups, messages }) => {
49
+ assert.equal(groups[0].matcher, MATCHER);
50
+ assert.deepEqual(groups[0].hooks, [privacy, inspect]);
51
+ assert.ok(messages.some(message => message.includes('matcher updated')));
52
+ assertNoDuplicateCommands(groups);
53
+ });
54
+ });
55
+
56
+ test('(b) claude-settings updates the former matcher without Grep', () => {
57
+ withSettings([group('Read|Write|Edit|MultiEdit|Bash|Glob')], ({ groups, messages }) => {
58
+ assert.equal(groups[0].matcher, MATCHER);
59
+ assert.ok(messages.some(message => message.includes('matcher updated')));
60
+ assertNoDuplicateCommands(groups);
61
+ });
62
+ });
63
+
64
+ test('(c) claude-settings preserves a mixed entry with a user command', () => {
65
+ const mixed = group(OLD, [privacy, inspect, { type: 'command', command: 'my-linter' }]);
66
+ withSettings([mixed], ({ groups, messages }) => {
67
+ assert.deepEqual(groups[0], mixed);
68
+ assert.equal(messages.some(message => message.includes('matcher updated')), false);
69
+ assertNoDuplicateCommands(groups);
70
+ });
71
+ });
72
+
73
+ test('(d) claude-settings preserves a custom matcher for managed commands', () => {
74
+ const custom = group('Read|Bash');
75
+ withSettings([custom], ({ groups, messages }) => {
76
+ assert.deepEqual(groups[0], custom);
77
+ assert.equal(messages.some(message => message.includes('matcher updated')), false);
78
+ });
79
+ });
80
+
81
+ test('(e) claude-settings installs the template matcher without duplicate entries', () => {
82
+ withSettings(null, ({ groups }) => {
83
+ assert.deepEqual(groups, template.hooks.PreToolUse);
84
+ assertNoDuplicateCommands(groups);
85
+ });
86
+ });
87
+
88
+ test('(f) claude-settings updates a split privacy entry without duplicate commands', () => {
89
+ withSettings([group(OLD, [privacy]), group(MATCHER, [inspect])], ({ groups, messages }) => {
90
+ assert.equal(groups.length, 2);
91
+ assert.ok(groups.every(entry => entry.matcher === MATCHER));
92
+ assert.deepEqual(groups.map(entry => entry.hooks), [[privacy], [inspect]]);
93
+ assert.ok(messages.some(message => message.includes('matcher updated')));
94
+ assertNoDuplicateCommands(groups);
95
+ });
96
+ });
97
+
98
+ test('(g) claude-settings prunes the retired inspector before updating privacy', () => {
99
+ const retired = { ...inspect, command: inspect.command.replace('inspect-block.cjs', 'inspector-block.cjs') };
100
+ withSettings([group(OLD, [retired, privacy])], ({ groups, messages }) => {
101
+ assert.equal(groups[0].matcher, MATCHER);
102
+ assert.deepEqual(groups[0].hooks, [privacy, inspect]);
103
+ assert.ok(messages.some(message => message.includes('matcher updated')));
104
+ assertNoDuplicateCommands(groups);
105
+ });
106
+ });
107
+
108
+ test('(h) claude-settings dry-run reports the matcher update without writing', () => {
109
+ withSettings([group(OLD)], ({ before, after, messages }) => {
110
+ assert.equal(after, before);
111
+ assert.ok(messages.some(message => message.includes('[dry-run] Settings: hook PreToolUse matcher updated')));
112
+ }, true);
113
+ });
114
+
115
+ test('(i) claude-settings preserves a null handler without updating the matcher', () => {
116
+ const entry = group(OLD, [null]);
117
+ assert.doesNotThrow(() => {
118
+ withSettings([entry], ({ groups, messages }) => {
119
+ assert.deepEqual(groups[0], entry);
120
+ assert.equal(messages.some(message => message.includes('matcher updated')), false);
121
+ });
122
+ });
123
+ });
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const crypto = require('node:crypto');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { spawnSync } = require('node:child_process');
9
+ const { test } = require('node:test');
10
+
11
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
12
+
13
+ test('installed Codex ask guard follows the observed hook payloads', async (t) => {
14
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-ask-guard-test-'));
15
+ t.after(() => fs.rmSync(temp, { recursive: true, force: true }));
16
+ const project = path.join(temp, 'project');
17
+ fs.mkdirSync(project);
18
+ const install = spawnSync(process.execPath, [path.join(__dirname, '../install.js'),
19
+ '--platform', 'codex', '--yes'], { cwd: project, encoding: 'utf8' });
20
+ assert.equal(install.status, 0, install.stderr + install.stdout);
21
+ const script = path.join(project, '.codex/hooks/ask-guard.cjs');
22
+ const config = JSON.parse(fs.readFileSync(path.join(project, '.codex/hooks.json'), 'utf8'));
23
+
24
+ function fixture() {
25
+ const stateRoot = fs.mkdtempSync(path.join(temp, 'state-'));
26
+ const session = crypto.randomUUID();
27
+ const sessionDir = (id = session) => path.join(stateRoot, sha256(id));
28
+ function call(event, extra = {}, id = session, override = stateRoot) {
29
+ return spawnSync(process.execPath, [script], {
30
+ cwd: project, encoding: 'utf8',
31
+ env: { ...process.env, CAFEKIT_ASK_GUARD_DIR: override },
32
+ input: JSON.stringify({ hook_event_name: event, cwd: project, session_id: id, ...extra })
33
+ });
34
+ }
35
+ const prompt = (text, id = session) => call('UserPromptSubmit', { prompt: text }, id);
36
+ const tool = (name = 'apply_patch', id = session) => call('PreToolUse', {
37
+ tool_name: name, tool_input: { command: '*** Begin Patch\n*** Update File: src/greet.js\n*** End Patch' }
38
+ }, id);
39
+ return { call, prompt, tool, session, stateRoot, sessionDir };
40
+ }
41
+
42
+ await t.test('real installer registers both events and a separate apply_patch group', () => {
43
+ assert.ok(fs.existsSync(script));
44
+ const isGuard = (handler) => /ask-guard\.cjs/.test(handler.command || '');
45
+ assert.ok(config.hooks.UserPromptSubmit.some((group) => group.hooks.some(isGuard)));
46
+ const group = config.hooks.PreToolUse.find((entry) => entry.hooks.some(isGuard));
47
+ assert.ok(group);
48
+ assert.equal(group.matcher, 'apply_patch');
49
+ assert.equal(group.hooks.length, 1);
50
+ const windowsPath = Buffer.from(group.hooks[0].commandWindows.split(' ').at(-1), 'base64url').toString('utf8');
51
+ assert.equal(fs.realpathSync(windowsPath), fs.realpathSync(script));
52
+ });
53
+ await t.test('explicit ask denies only its session and logs exactly one private row per denial', () => {
54
+ const f = fixture();
55
+ assert.equal(f.prompt(' $cf-ask "Sửa giúp luôn."\n').status, 0);
56
+ assert.equal(f.tool('apply_patch', crypto.randomUUID()).status, 0);
57
+ for (let count = 1; count <= 2; count++) {
58
+ const result = f.tool();
59
+ assert.equal(result.status, 2, result.stderr);
60
+ assert.match(result.stderr, /\$cf-fix/);
61
+ assert.doesNotMatch(result.stderr, /greet|Sửa giúp|Begin Patch/);
62
+ const rows = fs.readFileSync(path.join(f.sessionDir(), 'denials.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
63
+ assert.equal(rows.length, count);
64
+ for (const row of rows) {
65
+ assert.deepEqual(Object.keys(row).sort(), ['session_id_sha256', 'timestamp', 'tool_name']);
66
+ assert.equal(row.session_id_sha256, sha256(f.session));
67
+ assert.equal(row.tool_name, 'apply_patch');
68
+ assert.ok(Number.isFinite(Date.parse(row.timestamp)));
69
+ }
70
+ }
71
+ assert.equal(fs.existsSync(path.join(project, '.codex/hooks/.logs')), false);
72
+ });
73
+ await t.test('next non-ask prompt disables a previously persisted ask flag', () => {
74
+ const f = fixture();
75
+ assert.equal(f.prompt('$cf-ask reason').status, 0);
76
+ assert.equal(f.tool().status, 2);
77
+ assert.equal(f.prompt('Please fix it now').status, 0);
78
+ assert.equal(f.tool().status, 0);
79
+ });
80
+ await t.test('/cf:ask activates the guard', () => {
81
+ const f = fixture();
82
+ assert.equal(f.prompt('/cf:ask reason').status, 0);
83
+ assert.equal(f.tool().status, 2);
84
+ });
85
+ for (const prompt of ['Đừng dùng $cf-ask để sửa', '```\n$cf-ask reason\n```', '$cf-asker reason', '$CF-ASK reason']) {
86
+ await t.test(`non-invocation stays inactive: ${JSON.stringify(prompt)}`, () => {
87
+ const f = fixture();
88
+ assert.equal(f.prompt(prompt).status, 0);
89
+ assert.equal(f.tool().status, 0);
90
+ });
91
+ }
92
+ await t.test('Bash and Read are allowed during ask', () => {
93
+ const f = fixture();
94
+ assert.equal(f.prompt('$cf-ask').status, 0);
95
+ for (const name of ['Bash', 'Read', 'exec']) assert.equal(f.tool(name).status, 0);
96
+ assert.equal(f.tool().status, 2);
97
+ });
98
+ await t.test('path-like session IDs stay hashed within the state directory', () => {
99
+ const f = fixture();
100
+ assert.equal(f.prompt('$cf-ask reason', '../x').status, 0);
101
+ assert.equal(f.tool('apply_patch', '../x').status, 2);
102
+ assert.deepEqual(fs.readdirSync(f.stateRoot), [sha256('../x')]);
103
+ assert.equal(fs.existsSync(path.join(temp, 'x')), false);
104
+ });
105
+ await t.test('missing session IDs fail open and record a crash', () => {
106
+ const f = fixture();
107
+ assert.equal(f.call('UserPromptSubmit', { prompt: '$cf-ask' }, null).status, 0);
108
+ assert.equal(f.tool('apply_patch', null).status, 0);
109
+ const rows = fs.readFileSync(path.join(project, '.codex/hooks/.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
110
+ assert.ok(rows.some((row) => row.hook === 'ask-guard' && row.status === 'crash'));
111
+ assert.deepEqual(fs.readdirSync(f.stateRoot), []);
112
+ });
113
+ await t.test('state I/O errors fail open and record a crash without leaking input', () => {
114
+ const f = fixture();
115
+ const invalidRoot = path.join(temp, 'not-a-directory');
116
+ fs.writeFileSync(invalidRoot, 'file');
117
+ const result = f.call('PreToolUse', { tool_name: 'apply_patch', tool_input: { command: 'PRIVATE-INPUT' } }, f.session, invalidRoot);
118
+ assert.equal(result.status, 0);
119
+ const log = fs.readFileSync(path.join(project, '.codex/hooks/.logs/hook-log.jsonl'), 'utf8');
120
+ assert.doesNotMatch(log, /PRIVATE-INPUT/);
121
+ assert.equal(JSON.parse(log.trim().split('\n').at(-1)).hook, 'ask-guard');
122
+ });
123
+ });
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { test } = require('node:test');
7
+ const { normalizeCodexBody } = require('../lib/codex-install');
8
+ const { splitFrontmatter } = require('../lib/codex-frontmatter');
9
+
10
+ const ROOT = path.resolve(__dirname, '../..');
11
+ const skillPath = name => path.join(ROOT, 'src/claude/skills', name, 'SKILL.md');
12
+ const readSkill = name => fs.readFileSync(skillPath(name), 'utf8');
13
+
14
+ test('codex ask projection carries the answer-only instruction after frontmatter', () => {
15
+ const source = readSkill('ask');
16
+ const projected = normalizeCodexBody(source, skillPath('ask'));
17
+ assert.match(projected, /^---\r?\n/);
18
+ const { frontmatter, body } = splitFrontmatter(projected);
19
+ assert.equal(frontmatter.name, 'cf-ask');
20
+ assert.match(projected.slice(0, projected.length - body.length), /^name:\s*cf-ask$/m);
21
+ const boundary = 'A request to fix or change something is still answered without editing any file; point the user to $cf-fix.';
22
+ const originalDescription = splitFrontmatter(normalizeCodexBody(source)).frontmatter.description;
23
+ // A JSON-quoted scalar is valid YAML; verify the serialized value as well as the split header.
24
+ const description = JSON.parse(projected.match(/^description: (.+)$/m)[1]);
25
+ assert.equal(description, `${originalDescription} ${boundary}`);
26
+ assert.equal(frontmatter.description, description);
27
+ assert.ok(description.length <= 1024);
28
+ assert.match(body, /^\s*Rule 1 \(Codex\): never edit, create, or delete files in this skill,/);
29
+ const note = body.trimStart().split('\n\n')[0];
30
+ assert.match(note, /even when the user says fix it or sửa giúp luôn/);
31
+ assert.match(note, /Do not run commands that write files/);
32
+ assert.match(note, /Answer the cause with evidence and tell the user to run \$cf-fix\./);
33
+ assert.doesNotMatch(`${description}\n${note}`, /\b(?:Write|Edit|Read|Bash|WebSearch|WebFetch|AskUserQuestion)\b/);
34
+ assert.match(body, /\n\n# Ask Skill\n/);
35
+
36
+ for (const name of ['fix', 'develop']) {
37
+ const otherSource = readSkill(name);
38
+ const other = normalizeCodexBody(otherSource, skillPath(name));
39
+ assert.doesNotMatch(other, /Rule 1 \(Codex\):/);
40
+ assert.equal(splitFrontmatter(other).frontmatter.description,
41
+ splitFrontmatter(normalizeCodexBody(otherSource)).frontmatter.description);
42
+ assert.ok(!other.includes(boundary));
43
+ }
44
+ for (const sourcePath of ['', 'skills/ask/templates/question.md', 'other-skills/ask/SKILL.md']) {
45
+ const prompt = normalizeCodexBody(source, sourcePath);
46
+ assert.doesNotMatch(prompt, /Rule 1 \(Codex\):/);
47
+ assert.ok(!prompt.includes(boundary));
48
+ assert.equal(splitFrontmatter(prompt).frontmatter.description, originalDescription);
49
+ }
50
+ assert.match(normalizeCodexBody(source, 'C:\\project\\skills\\ask\\SKILL.md'), /Rule 1 \(Codex\):/);
51
+ });
@@ -17,7 +17,7 @@ const PACKAGE_ROOT = path.join(__dirname, '../..');
17
17
  const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
18
18
  const TEMPLATE = path.join(PACKAGE_ROOT, 'src/codex/hooks.json');
19
19
  const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'));
20
- const { hookScript } = require(path.join(PACKAGE_ROOT, 'bin/phases/codex-hooks.js'));
20
+ const { hookScript, repairLegacyCodexLaunchers } = require(path.join(PACKAGE_ROOT, 'bin/phases/codex-hooks.js'));
21
21
 
22
22
  const USER_STOP_COMMAND = 'node "$(git rev-parse --show-toplevel)/scripts/my-own-stop.js"';
23
23
  const USER_PRETOOL_COMMAND = 'echo user-owned-pretooluse';
@@ -79,6 +79,17 @@ function addUserHooks(root) {
79
79
  fs.writeFileSync(hooksPath(root), `${JSON.stringify(config, null, 2)}\n`);
80
80
  }
81
81
 
82
+ test('a fresh install registers the precompact capture hook', () => {
83
+ inTempProject((root) => {
84
+ assert.ok(managedScripts().PreCompact?.includes('precompact.cjs'));
85
+ const result = install(root);
86
+ assert.equal(result.status, 0, result.stderr);
87
+ const scripts = (readHooks(root).hooks.PreCompact || []).flatMap(group => group.hooks || []).map(handler => hookScript(handler.command));
88
+ assert.ok(scripts.includes('precompact.cjs'));
89
+ assert.ok(fs.existsSync(path.join(root, '.codex/hooks/precompact.cjs')));
90
+ });
91
+ });
92
+
82
93
  test('a fresh install registers every CafeKit hook under its own event', () => {
83
94
  inTempProject((root) => {
84
95
  const result = install(root);
@@ -237,3 +248,71 @@ test('hooks.json stays outside the ownership manifest, like settings.json', () =
237
248
  );
238
249
  });
239
250
  });
251
+
252
+ test('parity regression: foreign hooks with managed basenames stay exact and cannot suppress registration', () => {
253
+ inTempProject((root) => {
254
+ fs.mkdirSync(path.join(root, '.codex'));
255
+ const foreign = { type: 'command', command: `node '${root}/tools/hooks/session.cjs'`, timeout: 17 };
256
+ const custom = { type: 'command', command: `node '${root}/.codex/hooks/session.cjs' --custom`, timeout: 19 };
257
+ fs.writeFileSync(hooksPath(root), JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [foreign, custom] }] } }));
258
+ const result = install(root);
259
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
260
+ const handlers = readHooks(root).hooks.SessionStart.flatMap((group) => group.hooks);
261
+ assert.ok(handlers.some((entry) => JSON.stringify(entry) === JSON.stringify(foreign)), 'foreign hook was rewritten');
262
+ assert.ok(handlers.some((entry) => JSON.stringify(entry) === JSON.stringify(custom)), 'custom hook was rewritten');
263
+ assert.equal(handlers.filter((entry) => entry.command?.startsWith('PATH=') && hookScript(entry.command) === 'session.cjs').length, 1);
264
+ });
265
+ });
266
+
267
+ test('parity regression: repairing launchers preserves unknown and foreign command shapes', () => {
268
+ inTempProject((root) => {
269
+ const commands = [
270
+ `node '${root}/tools/hooks/check.cjs'`,
271
+ `node '${root}/tools/hooks/session.cjs'`,
272
+ `node '${root}/.codex/hooks/user-guard.cjs'`,
273
+ `node '${root}/.codex/hooks/session.cjs' --custom`,
274
+ ];
275
+ const config = { hooks: { Stop: [{ hooks: commands.map((command) => ({ type: 'command', command })) }] } };
276
+ assert.deepEqual(repairLegacyCodexLaunchers(config, root).config, config);
277
+ const windowsPrefix = 'node -e "process.argv[1]=Buffer.from(process.argv[1],\'base64url\').toString(\'utf8\');require(\'module\').runMain()" ';
278
+ const foreignWindows = windowsPrefix + Buffer.from(`${root}/tools/hooks/session.cjs`).toString('base64url');
279
+ const mixed = { type: 'command', command: 'node ".codex/hooks/session.cjs"', commandWindows: foreignWindows, timeout: 7 };
280
+ const next = repairLegacyCodexLaunchers({ hooks: { Stop: [{ hooks: [mixed] }] } }, root).config.hooks.Stop[0].hooks[0];
281
+ assert.equal(next.commandWindows, foreignWindows, 'foreign platform field was rebound');
282
+ assert.equal(next.timeout, 7);
283
+ assert.match(next.command, /^PATH=/);
284
+ for (const commandWindows of [windowsPrefix + 'bad-base64!', foreignWindows + ' --custom']) {
285
+ const unknown = { hooks: { Stop: [{ hooks: [{ type: 'command', commandWindows }] }] } };
286
+ assert.deepEqual(repairLegacyCodexLaunchers(unknown, root).config, unknown);
287
+ }
288
+ });
289
+ });
290
+
291
+ test('parity regression: upgrade removes only managed docs startup handlers', () => {
292
+ inTempProject((root) => {
293
+ assert.equal(install(root).status, 0);
294
+ const config = readHooks(root);
295
+ const managed = { type: 'command', command: `node '${root}/.codex/hooks/docs-sync.cjs'`, timeout: 23 };
296
+ const foreign = { type: 'command', command: `node '${root}/tools/hooks/docs-sync.cjs'` };
297
+ const custom = { type: 'command', command: `node '${root}/.codex/hooks/docs-sync.cjs' --custom` };
298
+ const hybrid = { ...managed, commandWindows: 'echo user-owned-windows-docs' };
299
+ config.hooks.SessionStart.push({ matcher: '*', hooks: [managed, foreign, custom, hybrid] });
300
+ config.hooks.Stop.push({ hooks: [{ ...managed }] });
301
+ fs.writeFileSync(hooksPath(root), JSON.stringify(config));
302
+ const before = fs.readFileSync(hooksPath(root), 'utf8');
303
+ const preview = install(root, ['--dry-run']);
304
+ assert.equal(preview.status, 0, preview.stderr);
305
+ assert.equal(fs.readFileSync(hooksPath(root), 'utf8'), before);
306
+ const result = install(root);
307
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
308
+ const upgraded = readHooks(root);
309
+ const startup = upgraded.hooks.SessionStart.flatMap((group) => group.hooks);
310
+ assert.ok(!startup.some((entry) => entry.command === managed.command), 'managed docs startup hook survived');
311
+ assert.ok(!startup.some((entry) => entry.command?.startsWith('PATH=') && hookScript(entry.command) === 'docs-sync.cjs' && !entry.commandWindows), 'managed docs startup launcher survived');
312
+ assert.ok(startup.some((entry) => entry.command === foreign.command));
313
+ assert.ok(startup.some((entry) => entry.command === custom.command));
314
+ assert.ok(startup.some((entry) => entry.commandWindows === hybrid.commandWindows), 'migration removed a foreign platform field');
315
+ assert.ok(upgraded.hooks.Stop.flatMap((group) => group.hooks).some((entry) => hookScript(entry.command) === 'docs-sync.cjs'));
316
+ assert.ok(fs.existsSync(path.join(root, '.codex/hooks/docs-sync.cjs')), 'on-demand docs hook was deleted');
317
+ });
318
+ });