@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
@@ -0,0 +1,314 @@
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 { spawnSync } = require('node:child_process');
9
+ const Module = require('node:module');
10
+ const { convertCodexAgentContent, normalizeCodexBody } = require('../lib/codex-install');
11
+ const { sha256 } = require('../lib/manifest');
12
+
13
+ const ROOT = path.resolve(__dirname, '../..');
14
+ const STRATEGIST = fs.readFileSync(path.join(ROOT, 'src/claude/agents/strategist.md'), 'utf8');
15
+ const { splitFrontmatter } = require('../lib/codex-frontmatter');
16
+
17
+ function withInstalledSkills(check) {
18
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-routing-description-'));
19
+ try {
20
+ const result = spawnSync(process.execPath, [path.join(ROOT, 'bin/install.js'), '--platform', 'codex', '--with-document-skills', '-y'], { cwd: temp, encoding: 'utf8' });
21
+ assert.equal(result.status, 0, result.stderr);
22
+ check(temp);
23
+ } finally {
24
+ fs.rmSync(temp, { recursive: true, force: true });
25
+ }
26
+ }
27
+
28
+ function installedInstructionText(temp) {
29
+ const read = directory => fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
30
+ const file = path.join(directory, entry.name);
31
+ if (entry.isDirectory()) return read(file);
32
+ if (!/\.(?:md|toml)$/.test(file)) return [];
33
+ let content = fs.readFileSync(file, 'utf8');
34
+ if (file.endsWith('.toml')) {
35
+ content = content.replace(/^developer_instructions = (.+)$/m, (_line, value) => JSON.parse(value));
36
+ }
37
+ return [[path.relative(temp, file), content.replace(/\s+/g, ' ')]];
38
+ });
39
+ return [...read(path.join(temp, '.agents/skills')), ...read(path.join(temp, '.codex'))];
40
+ }
41
+
42
+ for (const [label, needle] of [
43
+ ['duplicate runtime roots', /(\.codex\/?)[\s,`]+\1/],
44
+ ['duplicate instruction filenames', /(`?AGENTS\.md`?)[\s,]+\1/],
45
+ ['AskUserQuestion', /AskUserQuestion/],
46
+ ['structured user-input', /structured user-input/],
47
+ ['native input tool', /native input tool/],
48
+ ['native structured', /native structured/],
49
+ ['Codex CLI own worktree', /Codex CLI's own worktree/],
50
+ ['runtime-specific nested worktree path', /\.codex\/worktrees\//]
51
+ ]) {
52
+ test(`installed Codex instruction corpus excludes ${label}`, () => {
53
+ withInstalledSkills(temp => {
54
+ const matches = installedInstructionText(temp).filter(([_file, text]) => needle.test(text)).map(([file]) => file);
55
+ assert.deepEqual(matches, [], label);
56
+ });
57
+ });
58
+ }
59
+
60
+ for (const needle of ['send_message(type', 'shutdown_', 'task-state inspection']) {
61
+ test(`installed Codex agents omit Team protocol ${needle}`, () => {
62
+ withInstalledSkills(temp => {
63
+ for (const name of ['git_ops', 'project_manager']) {
64
+ const text = fs.readFileSync(path.join(temp, '.codex/agents', `${name}.toml`), 'utf8');
65
+ assert.ok(!text.includes(needle), name);
66
+ }
67
+ });
68
+ });
69
+ }
70
+
71
+ test('installed Codex agents retain force-push and planning-owner boundaries', () => {
72
+ withInstalledSkills(temp => {
73
+ const texts = new Map(installedInstructionText(temp));
74
+ assert.ok(texts.get('.codex/agents/git_ops.toml').includes('NEVER arbitrarily use `force push` unless explicitly and securely designated.'));
75
+ assert.ok(texts.get('.codex/agents/project_manager.toml').includes('You aggregate after GATE-REVIEW and during explicitly invoked execution. An incomplete packet returns to the planning owner; do not expand it yourself.'));
76
+ assert.doesNotMatch(texts.get('.codex/agents/git_ops.toml'), /## Team Mode\s+3\. /);
77
+ });
78
+ });
79
+
80
+ test('installed Brainstorm asks a question in the final answer without a runtime possessive', () => {
81
+ withInstalledSkills(temp => {
82
+ for (const relative of ['SKILL.md', 'references/question-framework.md']) {
83
+ const text = fs.readFileSync(path.join(temp, '.agents/skills/brainstorm', relative), 'utf8').replace(/\s+/g, ' ');
84
+ assert.ok(text.includes("Use a question in the turn's final answer when available."), relative);
85
+ assert.ok(!text.includes("the runtime's question"), relative);
86
+ }
87
+ });
88
+ });
89
+
90
+ test('installed Codex host lists retain Claude names alongside converted single-host paths', () => {
91
+ withInstalledSkills(temp => {
92
+ const texts = new Map(installedInstructionText(temp));
93
+ assert.match(texts.get('.agents/skills/git/references/worktree-blueprint.md'), /\.claude/);
94
+ assert.match(texts.get('.agents/skills/ask/SKILL.md'), /CLAUDE\.md/);
95
+ assert.match(texts.get('.agents/skills/docs/references/init-workflow.md'), /\.codex\/scripts\//);
96
+ });
97
+ });
98
+
99
+ test('host-list preservation applies only to instruction assets including a missing source path', () => {
100
+ const source = 'Copy `.claude/`, `.codex/`; read `CLAUDE.md`, `AGENTS.md`; run `.claude/scripts/check.cjs`.';
101
+ const expected = 'Copy `.claude/`, `.codex/`; read `CLAUDE.md`, `AGENTS.md`; run `.codex/scripts/check.cjs`.';
102
+ assert.equal(normalizeCodexBody(source), expected);
103
+ assert.equal(normalizeCodexBody(source, 'guide.md'), expected);
104
+ assert.equal(normalizeCodexBody(source, 'script.cjs'), 'Copy `.codex/`, `.codex/`; read `AGENTS.md`, `AGENTS.md`; run `.codex/scripts/check.cjs`.');
105
+ });
106
+
107
+ test('standalone host filenames do not prevent conversion of a single-host file path on the same line', () => {
108
+ assert.equal(normalizeCodexBody('Read `CLAUDE.md`, `AGENTS.md`; load `.claude/CLAUDE.md`.', 'guide.md'),
109
+ 'Read `CLAUDE.md`, `AGENTS.md`; load `.codex/AGENTS.md`.');
110
+ });
111
+
112
+ test('installed Codex catalog help labels the shared skills root for Codex', () => {
113
+ withInstalledSkills(temp => {
114
+ const result = spawnSync(process.execPath, ['.codex/scripts/generate-skill-catalog.cjs', '--help'], { cwd: temp, encoding: 'utf8' });
115
+ assert.equal(result.status, 0, result.stderr);
116
+ assert.doesNotMatch(result.stdout.replace(/\s+/g, ' '), /\.agents\/skills for Claude/);
117
+ assert.match(result.stdout, /\.agents\/skills for Codex/);
118
+ });
119
+ });
120
+
121
+ test('installed skill descriptions carry Use when within the routing length limit', () => {
122
+ withInstalledSkills(temp => {
123
+ let checked = 0;
124
+ for (const name of fs.readdirSync(path.join(temp, '.agents/skills'))) {
125
+ const installed = path.join(temp, '.agents/skills', name, 'SKILL.md');
126
+ const source = path.join(ROOT, 'src/claude/skills', name, 'SKILL.md');
127
+ if (!fs.existsSync(installed) || !fs.existsSync(source)) continue;
128
+ const { frontmatter } = splitFrontmatter(fs.readFileSync(source, 'utf8'));
129
+ if (!frontmatter.when_to_use) continue;
130
+ const expected = `${normalizeCodexBody(frontmatter.description)} Use when: ${normalizeCodexBody(frontmatter.when_to_use)}`;
131
+ if (expected.length > 1024) continue;
132
+ const actual = JSON.parse(fs.readFileSync(installed, 'utf8').match(/^description: (.+)$/m)[1]);
133
+ const boundary = name === 'ask' ? ' A request to fix or change something is still answered without editing any file; point the user to $cf-fix.' : '';
134
+ assert.equal(actual, expected + boundary, name);
135
+ assert.ok(actual.length <= 1024, name);
136
+ checked++;
137
+ }
138
+ assert.ok(checked >= 18);
139
+ });
140
+ });
141
+
142
+ test('brainstorm description stays unchanged when Use when exceeds the routing length limit', () => {
143
+ withInstalledSkills(temp => {
144
+ const source = splitFrontmatter(fs.readFileSync(path.join(ROOT, 'src/claude/skills/brainstorm/SKILL.md'), 'utf8')).frontmatter;
145
+ assert.ok(`${source.description} Use when: ${source.when_to_use}`.length > 1024);
146
+ const installed = splitFrontmatter(fs.readFileSync(path.join(temp, '.agents/skills/brainstorm/SKILL.md'), 'utf8')).frontmatter;
147
+ assert.equal(installed.description, source.description);
148
+ });
149
+ });
150
+
151
+ for (const name of ['agent-browser', 'chrome-devtools', 'web-testing']) {
152
+ test(`installed ${name} description serializes Use when as a JSON string`, () => {
153
+ withInstalledSkills(temp => {
154
+ const content = fs.readFileSync(path.join(temp, '.agents/skills', name, 'SKILL.md'), 'utf8');
155
+ const description = JSON.parse(content.match(/^description: (.+)$/m)[1]);
156
+ const source = splitFrontmatter(fs.readFileSync(path.join(ROOT, 'src/claude/skills', name, 'SKILL.md'), 'utf8')).frontmatter;
157
+ assert.ok(description.endsWith(`Use when: ${normalizeCodexBody(source.when_to_use)}`));
158
+ });
159
+ });
160
+ }
161
+
162
+ test('Codex catalog emits the fix Use when text only once', () => {
163
+ withInstalledSkills(temp => {
164
+ const result = spawnSync(process.execPath, ['.codex/scripts/generate-skill-catalog.cjs', '--skills'], { cwd: temp, encoding: 'utf8' });
165
+ assert.equal(result.status, 0, result.stderr);
166
+ const when = normalizeCodexBody(splitFrontmatter(fs.readFileSync(path.join(ROOT, 'src/claude/skills/fix/SKILL.md'), 'utf8')).frontmatter.when_to_use);
167
+ assert.equal(result.stdout.split(when).length - 1, 1);
168
+ });
169
+ });
170
+
171
+ test('Use when projection applies without a source path and preserves quoted metadata', () => {
172
+ const source = '---\nname: cf:example\ndescription: Choose "one" option.\nwhen_to_use: When evidence is available.\n---\nBody\n';
173
+ const projected = normalizeCodexBody(source);
174
+ assert.equal(JSON.parse(projected.match(/^description: (.+)$/m)[1]), 'Choose "one" option. Use when: When evidence is available.');
175
+ });
176
+
177
+ test('strategist inherits native model, configures high reasoning and keeps advisory boundaries', () => {
178
+ const projected = convertCodexAgentContent(STRATEGIST, 'strategist.md');
179
+ assert.match(projected, /^model_reasoning_effort = "high"$/m);
180
+ assert.doesNotMatch(projected, /^model\s*=/m);
181
+ assert.doesNotMatch(projected, /fable|opus|sonnet|haiku|CLAUDE_CODE_SUBAGENT_MODEL|strongest available model|model override/);
182
+ assert.match(projected, /inherits the session model/);
183
+ assert.match(projected, /Advisory-only/);
184
+ assert.match(projected, /never implement, scaffold, or edit project files/);
185
+ assert.match(projected, /spawn_agent\(agent_type=\\"explorer\\", fork_turns=\\"none\\"/);
186
+ });
187
+
188
+ test('instruction projection converts Explore shorthand and native model guidance without rewriting executable code', () => {
189
+ const instruction = '`Task(Explore)`; set `CLAUDE_CODE_SUBAGENT_MODEL` in project settings.';
190
+ const converted = normalizeCodexBody(instruction, 'guide.md');
191
+ assert.doesNotMatch(converted, /Task\(Explore\)|CLAUDE_CODE_SUBAGENT_MODEL/);
192
+ assert.match(converted, /spawn_agent\(agent_type="explorer", fork_turns="none"/);
193
+ assert.match(converted, /configured agent model/);
194
+ assert.equal(normalizeCodexBody(instruction, 'script.js'), instruction);
195
+ });
196
+
197
+ for (const state of ['pristine', 'modified', 'untracked', 'dry-run']) {
198
+ test(`retired Codex init.json migration: ${state}`, () => {
199
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-projection-'));
200
+ const relative = '.agents/skills/specs/templates/init.json';
201
+ const target = path.join(temp, relative);
202
+ const original = '{"legacy":true}\n';
203
+ fs.mkdirSync(path.dirname(target), { recursive: true });
204
+ fs.writeFileSync(target, state === 'modified' ? '{"custom":true}\n' : original);
205
+ const custom = path.join(path.dirname(target), 'my-custom-template.json');
206
+ fs.writeFileSync(custom, '{"keep":true}\n');
207
+ const before = fs.readFileSync(target, 'utf8');
208
+ const runner = `
209
+ const { copyPlatformFiles } = require(${JSON.stringify(path.join(ROOT, 'bin/phases/copy-payload'))});
210
+ const { createTracker } = require(${JSON.stringify(path.join(ROOT, 'bin/lib/manifest'))});
211
+ const tracker = createTracker('.codex', 'test', { recordRoot: '.', allowedRoots: ['.codex', '.agents'] });
212
+ const ctx = {
213
+ dryRun: ${state === 'dry-run'}, options: {},
214
+ manifest: { skills: { required: [] }, agents: { required: [] } },
215
+ ownership: { '.codex': { files: ${JSON.stringify(state === 'untracked' ? {} : { [relative]: { sha256: sha256(original) } })} } },
216
+ trackers: { codex: tracker },
217
+ ui: { detail() {}, warn() {}, error() {} },
218
+ results: { installedSkills: 0, copied: 0, updated: 0, unchanged: 0, preserved: 0, missingDependencies: 0, preservedFiles: [] }
219
+ };
220
+ copyPlatformFiles(ctx, 'codex');
221
+ if (${JSON.stringify(state)} !== 'dry-run' && !tracker._pruned.has(${JSON.stringify(relative)})) throw new Error('ownership baseline was not pruned');
222
+ `;
223
+ try {
224
+ const result = spawnSync(process.execPath, ['-e', runner], { cwd: temp, encoding: 'utf8' });
225
+ assert.equal(result.status, 0, result.stderr);
226
+ if (state !== 'dry-run') assert.equal(fs.existsSync(target), false);
227
+ else assert.equal(fs.readFileSync(target, 'utf8'), before);
228
+ assert.equal(fs.readFileSync(custom, 'utf8'), '{"keep":true}\n');
229
+ } finally {
230
+ fs.rmSync(temp, { recursive: true, force: true });
231
+ }
232
+ });
233
+ }
234
+
235
+ test('shared and projected provenance ignore native generated roots while retaining source changes', () => {
236
+ const sourcePath = path.join(ROOT, 'src/claude/scripts/provenance.cjs');
237
+ const source = fs.readFileSync(sourcePath, 'utf8');
238
+ const projected = new Module(sourcePath);
239
+ projected._compile(normalizeCodexBody(source, sourcePath), sourcePath);
240
+ for (const runtime of [require(sourcePath), projected.exports]) {
241
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-projection-'));
242
+ const paths = ['.claude/runtime.json', '.claude/hooks/.logs/session.json', '.claude/.logs/events.json',
243
+ '.codex/session-state/session/context.json', '.codex/hooks/.privacy/request.json'];
244
+ const write = (relative, data) => {
245
+ const target = path.join(temp, relative);
246
+ fs.mkdirSync(path.dirname(target), { recursive: true });
247
+ fs.writeFileSync(target, data);
248
+ };
249
+ const git = (...args) => {
250
+ const result = spawnSync('git', ['-C', temp, ...args], { encoding: 'utf8' });
251
+ assert.equal(result.status, 0, result.stderr);
252
+ return result.stdout;
253
+ };
254
+ try {
255
+ write('app.js', 'export const value = 1;\n');
256
+ write('specs/feature/plan.md', '# Feature\n');
257
+ for (const relative of paths) write(relative, '{}\n');
258
+ git('init', '--quiet'); git('add', '.');
259
+ git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', 'commit', '--quiet', '-m', 'baseline');
260
+ const input = { projectRoot: temp, specsRoot: path.join(temp, 'specs'),
261
+ specFile: path.join(temp, 'specs/feature/plan.md'), featureName: 'feature', runtimeSession: 'fixture' };
262
+ const baseline = runtime.deriveRuntimeProvenance(input).Head;
263
+ const capturedGenerated = [];
264
+ for (const relative of paths) {
265
+ write(relative, '{"generated":true}\n');
266
+ assert.match(git('status', '--porcelain', '--', relative), /M/);
267
+ if (runtime.deriveRuntimeProvenance(input).Head !== baseline) capturedGenerated.push(relative);
268
+ write(relative, '{}\n');
269
+ }
270
+ write('app.js', 'export const value = 2;\n');
271
+ assert.notEqual(runtime.deriveRuntimeProvenance(input).Head, baseline);
272
+ assert.deepEqual(capturedGenerated, [], 'generated state changed Head');
273
+ } finally { fs.rmSync(temp, { recursive: true, force: true }); }
274
+ }
275
+ });
276
+
277
+ test('optional env functions read fake native runtime global env on Claude and projected Codex', () => {
278
+ const python = `import ast, json, sys
279
+ from pathlib import Path
280
+ from types import SimpleNamespace
281
+ case = json.load(sys.stdin)
282
+ tree = ast.parse(case['source'])
283
+ function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == case['function'])
284
+ env = {}
285
+ loaded = []
286
+ def load_dotenv(target):
287
+ loaded.append(str(target))
288
+ env['GEMINI_API_KEY'] = Path(target).read_text().strip()
289
+ scope = {'Path': Path, 'Optional': __import__('typing').Optional, '__file__': case['script'],
290
+ 'os': SimpleNamespace(getenv=lambda key: env.get(key)), 'load_dotenv': load_dotenv}
291
+ exec(compile(ast.Module(body=[function], type_ignores=[]), '<fixture>', 'exec'), scope)
292
+ result = scope[case['function']]()
293
+ assert (result if case['function'] == 'find_api_key' else env.get('GEMINI_API_KEY')) == 'fixture-native-key', loaded
294
+ assert loaded == [case['expected']], loaded
295
+ `;
296
+ for (const [file, functionName] of [['media_optimizer.py', 'load_env_files'], ['document_converter.py', 'find_api_key']]) {
297
+ const sourcePath = path.join(ROOT, 'src/claude/skills/ai-multimodal/scripts', file);
298
+ const original = fs.readFileSync(sourcePath, 'utf8');
299
+ for (const native of ['claude', 'codex']) {
300
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-native-env-'));
301
+ try {
302
+ const expected = path.join(temp, `.${native}`, '.env');
303
+ fs.mkdirSync(path.dirname(expected), { recursive: true });
304
+ fs.writeFileSync(expected, 'fixture-native-key');
305
+ const script = path.join(temp, native === 'claude' ? '.claude/skills' : '.agents/skills', 'ai-multimodal/scripts', file);
306
+ const result = spawnSync('python3', ['-c', python], { encoding: 'utf8', input: JSON.stringify({
307
+ source: native === 'claude' ? original : normalizeCodexBody(original, sourcePath),
308
+ function: functionName, script, expected
309
+ }) });
310
+ assert.equal(result.status, 0, `${native}/${file}: ${result.stderr}`);
311
+ } finally { fs.rmSync(temp, { recursive: true, force: true }); }
312
+ }
313
+ }
314
+ });
@@ -0,0 +1,170 @@
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
+ const SOURCE = path.resolve(__dirname, '../../src');
11
+
12
+ function fixture({ specs = 'specs', packets = ['auth-flow'] } = {}) {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-quiet-'));
14
+ const hooks = path.join(root, '.codex/hooks');
15
+ fs.cpSync(path.join(SOURCE, 'codex/hooks'), hooks, { recursive: true });
16
+ for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
17
+ fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib', name), path.join(hooks, 'lib', name));
18
+ }
19
+ fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
20
+ fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
21
+ fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
22
+ fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/runtime-path-safety.cjs'), path.join(hooks, 'lib/runtime-path-safety.cjs'));
23
+ fs.cpSync(path.join(SOURCE, 'claude/scripts'), path.join(root, '.codex/scripts'), { recursive: true });
24
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs } }));
25
+ fs.writeFileSync(path.join(root, '.gitignore'), '.codex/\n');
26
+ for (const args of [['init', '-q'], ['config', 'user.email', 'probe@example.invalid'], ['config', 'user.name', 'Probe'], ['add', '.gitignore'], ['commit', '-qm', 'base']]) {
27
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
28
+ assert.equal(result.status, 0, result.stderr);
29
+ }
30
+ for (const name of packets) {
31
+ const dir = path.join(root, specs, name); fs.mkdirSync(dir, { recursive: true });
32
+ fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
33
+ fs.writeFileSync(path.join(dir, 'task-01-work.md'), '# Work\nStatus: pending\n## Dependencies\n- none\n## Verification Plan\n- Command: node --test\n');
34
+ }
35
+ const run = (hook, payload, cwd = root) => {
36
+ const result = spawnSync(process.execPath, [path.join(hooks, `${hook}.cjs`)], {
37
+ cwd, input: JSON.stringify({ cwd, ...payload }), encoding: 'utf8',
38
+ });
39
+ assert.equal(result.status, 0, result.stderr);
40
+ return result.stdout.trim();
41
+ };
42
+ return { root, hooks, specs, run, prompt: (id, prompt, more = {}) => run('spec-state', { hook_event_name: 'UserPromptSubmit', session_id: id, prompt, ...more }), cleanup: () => fs.rmSync(root, { recursive: true, force: true }) };
43
+ }
44
+ function within(options, fn) { const fx = fixture(options); try { fn(fx); } finally { fx.cleanup(); } }
45
+ // Native shape pinned by openai/codex apply-patch tests: string stdout summary,
46
+ // not the empty object code mode returns (core/src/tools/context.rs).
47
+ function patch(fx, session, command, response, cwd = fx.root) {
48
+ if (response === undefined) {
49
+ const files = [];
50
+ for (const line of command.split('\n')) {
51
+ const header = line.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
52
+ if (header) files.push(`${{ Add: 'A', Update: 'M', Delete: 'D' }[header[1]]} ${header[2]}`);
53
+ else if (line.startsWith('*** Move to: ')) files[files.length - 1] = `M ${line.slice(13)}`;
54
+ }
55
+ response = `Success. Updated the following files:\n${files.join('\n')}\n`;
56
+ }
57
+ return fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: session, tool_name: 'apply_patch', tool_input: { command }, tool_response: response }, cwd);
58
+ }
59
+ function snapshot(fx, id, content, generated = new Date().toISOString()) {
60
+ const dir = path.join(fx.root, '.codex/session-state', crypto.createHash('sha256').update(id).digest('hex'));
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ fs.writeFileSync(path.join(dir, 'latest.md'), `# Session State\n<!-- Generated: ${generated} -->\n${content}\n`);
63
+ }
64
+
65
+ test('untouched sessions remain quiet even beside malformed and multiple active packets', () => {
66
+ within({ packets: ['auth-flow', 'billing-2'] }, (fx) => {
67
+ fs.writeFileSync(path.join(fx.root, 'specs/billing-2/task-01-work.md'), 'Status: invented\n');
68
+ assert.equal(fx.prompt('a', 'Explain README'), '');
69
+ assert.match(fx.prompt('a', '$cf-develop auth-flow'), /Spec state changed: `auth-flow`/);
70
+ assert.equal(fx.prompt('b', 'Continue'), '');
71
+ assert.match(fx.prompt('a', 'Continue'), /auth-flow/);
72
+ });
73
+ });
74
+ test('named packets narrow relevant ambiguity and explicit targets are remembered', () => {
75
+ within({ packets: ['auth-flow', 'billing-2'] }, (fx) => {
76
+ fs.mkdirSync(path.join(fx.root, 'specs/_shared'));
77
+ fs.writeFileSync(path.join(fx.root, 'specs/_shared/active-feature.json'), '{"featureName":"billing-2"}');
78
+ assert.match(fx.prompt('a', '/cf:develop auth-flow'), /Spec state changed: `auth-flow`/);
79
+ assert.match(fx.prompt('a', 'specs/billing-2'), /Spec state changed: `billing-2`/);
80
+ assert.match(fx.prompt('a', 'Continue'), /Multiple active specs/);
81
+ assert.match(fx.prompt('b', 'Continue', { explicitPath: 'specs/auth-flow/plan.md' }), /auth-flow/);
82
+ assert.match(fx.prompt('b', 'Continue'), /auth-flow/);
83
+ assert.match(fx.prompt('c', 'Continue', { explicitFeature: 'billing-2' }), /billing-2/);
84
+ });
85
+ });
86
+ test('native successful add/update/delete/move patches touch silently without loading policy or resolver', () => {
87
+ within({ packets: ['auth-flow', 'billing-2'] }, (fx) => {
88
+ fs.writeFileSync(path.join(fx.root, '.codex/scripts/workflow-policy.cjs'), 'throw new Error("must not load");');
89
+ fs.writeFileSync(path.join(fx.root, '.codex/scripts/spec-resolver.cjs'), 'throw new Error("must not resolve");');
90
+ for (const [session, body] of [
91
+ ['add', '*** Add File: specs/auth-flow/new.md\n+new'],
92
+ ['update', '*** Update File: specs/auth-flow/plan.md\n@@\n-old\n+new'],
93
+ ['delete', '*** Delete File: specs/auth-flow/deleted.md'],
94
+ ['move', '*** Update File: specs/auth-flow/old.md\n*** Move to: specs/billing-2/new.md\n@@\n-old\n+new'],
95
+ ]) assert.equal(patch(fx, session, `*** Begin Patch\n${body}\n*** End Patch`), '');
96
+ const records = fs.readdirSync(path.join(fx.hooks, '.logs')).filter((name) => name.startsWith('spec-touched-'));
97
+ assert.equal(records.length, 4);
98
+ assert.ok(records.some((name) => JSON.parse(fs.readFileSync(path.join(fx.hooks, '.logs', name))).length === 2));
99
+ assert.ok(!fs.existsSync(path.join(fx.hooks, '.logs/hook-log.jsonl')));
100
+ });
101
+ });
102
+ test('failed, malformed, shell and unrelated tool payloads never touch', () => {
103
+ within({}, (fx) => {
104
+ const command = '*** Begin Patch\n*** Add File: specs/auth-flow/new.md\n+new\n*** End Patch';
105
+ for (const response of [null, {}, 'unknown', 'Error: Failed to apply patch', { content: [{ type: 'text', text: 'Failed to find expected lines' }] }, { isError: true }, { success: false }, { exit_code: 1 }, { error: 'failed' }]) assert.equal(patch(fx, 'a', command, response), '');
106
+ for (const invalid of ['echo specs/auth-flow', '*** Begin Patch\n*** Add File: specs/auth-flow/new.md\n*** End Patch']) assert.equal(patch(fx, 'a', invalid), '');
107
+ fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: 'a', tool_name: 'apply_patch', tool_input: { command } });
108
+ fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: 'a', tool_name: 'exec_command', tool_input: { command } });
109
+ assert.equal(fx.prompt('a', 'Continue'), '');
110
+ });
111
+ });
112
+ test('custom root, nested cwd, deleted paths and symlink containment preserve canonical ownership', () => {
113
+ within({ specs: 'work/packets' }, (fx) => {
114
+ const nested = path.join(fx.root, 'nested'); fs.mkdirSync(nested);
115
+ assert.match(fx.prompt('absolute', path.join(fx.root, 'work/packets/auth-flow/plan.md')), /auth-flow/);
116
+ assert.equal(patch(fx, 'a', '*** Begin Patch\n*** Delete File: ../work/packets/auth-flow/deleted.md\n*** End Patch', 'Success. Updated the following files:\nD ../work/packets/auth-flow/deleted.md\n', nested), '');
117
+ assert.match(fx.prompt('a', 'Continue'), /auth-flow/);
118
+ assert.match(fx.prompt('b', 'work/packets/auth-flow'), /auth-flow/);
119
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-quiet-outside-'));
120
+ try {
121
+ fs.symlinkSync(outside, path.join(fx.root, 'work/packets/escape'));
122
+ patch(fx, 'c', '*** Begin Patch\n*** Add File: work/packets/escape/new.md\n+new\n*** End Patch');
123
+ assert.equal(fx.prompt('c', 'Continue'), '');
124
+ assert.match(fx.prompt('explicit', 'Continue', { explicitPath: 'work/packets/auth-flow/task-01-work.md' }), /auth-flow/);
125
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
126
+ });
127
+ });
128
+ test('legacy-compatible writes touch; failed writes and no-session observer do not share an unknown session', () => {
129
+ within({}, (fx) => {
130
+ for (const tool of ['Edit', 'Write', 'MultiEdit']) {
131
+ assert.equal(fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: tool, tool_name: tool, tool_input: { file_path: 'specs/auth-flow/plan.md' }, tool_response: { ok: true } }), '');
132
+ assert.match(fx.prompt(tool, 'Continue'), /auth-flow/);
133
+ }
134
+ fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: 'failed', tool_name: 'Write', tool_input: { file_path: 'specs/auth-flow/plan.md' }, tool_response: { success: false } });
135
+ assert.equal(fx.prompt('failed', 'Continue'), '');
136
+ fx.run('spec-state', { hook_event_name: 'PostToolUse', tool_name: 'Write', tool_input: { file_path: 'specs/auth-flow/plan.md' } });
137
+ assert.equal(fx.prompt('new', 'Continue'), '');
138
+ assert.match(fx.prompt(undefined, 'Continue'), /runtime_session must be a non-empty/, 'session-less prompt preserves the original provenance failure without inventing identity');
139
+ });
140
+ });
141
+ test('legacy migration notice stays once per session independently of packet touch', () => {
142
+ within({ packets: [] }, (fx) => {
143
+ const dir = path.join(fx.root, 'specs/old'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'spec.json'), '{}');
144
+ assert.match(fx.prompt('a', 'Hello'), /legacy Specs packet/);
145
+ assert.equal(fx.prompt('a', 'Hello'), '');
146
+ assert.match(fx.prompt('b', 'Hello'), /legacy Specs packet/);
147
+ });
148
+ });
149
+ test('state hides placeholders and invalid/expired timestamps but restores meaningful per-session content', () => {
150
+ within({ packets: [] }, (fx) => {
151
+ const filler = '## What Worked\n- (No completed tasks recorded)\n## Left\n- (No pending tasks recorded)\n## Agent\n- Completed at 12:30:40\n- Completed without a result message.\n## Files\n- (No file changes detected)';
152
+ snapshot(fx, 'a', filler);
153
+ assert.equal(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'a' }), '');
154
+ for (const timestamp of ['invalid', new Date(Date.now() - 8 * 86400000).toISOString()]) {
155
+ snapshot(fx, 'a', '- real work', timestamp);
156
+ assert.equal(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'a' }), '');
157
+ }
158
+ for (const content of ['- [ ] Build API', '## Agent Result\nVerified endpoint', '## Latest Assistant Message\nSaved edits', '## Key Files Modified\n- src/api.js']) {
159
+ snapshot(fx, 'a', content);
160
+ assert.match(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'a' }), /Prior Execution Context/);
161
+ assert.equal(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'b' }), '');
162
+ }
163
+ });
164
+ });
165
+ test('template keeps on-demand docs file and registers native mutation observer separately', () => {
166
+ const template = JSON.parse(fs.readFileSync(path.join(SOURCE, 'codex/hooks.json')));
167
+ assert.ok(!JSON.stringify(template.hooks.SessionStart).includes('docs-sync.cjs'));
168
+ assert.ok(fs.existsSync(path.join(SOURCE, 'codex/hooks/docs-sync.cjs')));
169
+ assert.ok(template.hooks.PostToolUse.some((entry) => entry.matcher.includes('apply_patch') && entry.hooks.some((hook) => hook.command.includes('spec-state.cjs'))));
170
+ });
@@ -0,0 +1,70 @@
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, CODEX_RESEARCH_DEPTH_MARKER, CODEX_RESEARCH_DEPTH_NOTE } = require('../lib/codex-install');
8
+ const { splitFrontmatter } = require('../lib/codex-frontmatter');
9
+
10
+ const SKILLS = path.resolve(__dirname, '../../src/claude/skills');
11
+ const researchPath = path.join(SKILLS, 'research/SKILL.md');
12
+ const research = fs.readFileSync(researchPath, 'utf8');
13
+
14
+ function skillFiles(directory) {
15
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
16
+ const file = path.join(directory, entry.name);
17
+ return entry.isDirectory() ? skillFiles(file) : entry.name === 'SKILL.md' ? [file] : [];
18
+ });
19
+ }
20
+
21
+ function assertMarker() {
22
+ assert.equal(typeof CODEX_RESEARCH_DEPTH_MARKER, 'string');
23
+ assert.ok(CODEX_RESEARCH_DEPTH_MARKER.length > 0);
24
+ }
25
+
26
+ test('codex research projection carries the depth note immediately after unchanged frontmatter', () => {
27
+ assertMarker();
28
+ assert.equal(typeof CODEX_RESEARCH_DEPTH_NOTE, 'string');
29
+ const plain = normalizeCodexBody(research);
30
+ const projected = normalizeCodexBody(research, researchPath);
31
+ assert.match(projected, /^---\r?\n/);
32
+ const before = splitFrontmatter(plain), after = splitFrontmatter(projected);
33
+ assert.equal(after.frontmatter.name, 'cf-research');
34
+ assert.equal(after.frontmatter.description, before.frontmatter.description);
35
+ assert.deepEqual(after.frontmatter, before.frontmatter);
36
+ assert.equal(projected.slice(0, projected.length - after.body.length), plain.slice(0, plain.length - before.body.length));
37
+ assert.equal(after.body, `\n${CODEX_RESEARCH_DEPTH_NOTE}\n\n${before.body}`);
38
+ assert.equal(projected.split(CODEX_RESEARCH_DEPTH_MARKER).length - 1, 1);
39
+ assert.ok(CODEX_RESEARCH_DEPTH_NOTE.includes(CODEX_RESEARCH_DEPTH_MARKER));
40
+ assert.match(CODEX_RESEARCH_DEPTH_NOTE, /known option/);
41
+ assert.match(CODEX_RESEARCH_DEPTH_NOTE, /Quick/);
42
+ assert.match(CODEX_RESEARCH_DEPTH_NOTE, /outdated supporting rationale/i);
43
+ assert.match(CODEX_RESEARCH_DEPTH_NOTE, /changes the recommendation/);
44
+ assert.match(CODEX_RESEARCH_DEPTH_NOTE, /conflicting evidence/i);
45
+ assert.doesNotMatch(CODEX_RESEARCH_DEPTH_NOTE, /\b(?:Write|Edit|Read|Bash|WebSearch|WebFetch|AskUserQuestion|Drizzle|Prisma|BullMQ|pg-boss|billing|ADR-004)\b/);
46
+ });
47
+
48
+ test('research marker is absent from every other projected skill including ask', () => {
49
+ assertMarker();
50
+ const others = skillFiles(SKILLS).filter(file => file !== researchPath);
51
+ assert.ok(others.includes(path.join(SKILLS, 'ask/SKILL.md')));
52
+ for (const file of others) {
53
+ const source = fs.readFileSync(file, 'utf8');
54
+ const projected = normalizeCodexBody(source, file);
55
+ assert.ok(!projected.includes(CODEX_RESEARCH_DEPTH_MARKER), file);
56
+ // Ask intentionally has its own boundary; all other skill bodies stay identical.
57
+ if (![path.join(SKILLS, 'ask/SKILL.md'), path.join(SKILLS, 'specs/SKILL.md')].includes(file)) assert.equal(projected, normalizeCodexBody(source), file);
58
+ }
59
+ });
60
+
61
+ test('research note is scoped to the exact skill source path and supports Windows separators', () => {
62
+ assertMarker();
63
+ for (const sourcePath of ['', 'skills/research/templates/x.md', 'other-skills/research/SKILL.md']) {
64
+ const projected = normalizeCodexBody(research, sourcePath);
65
+ assert.ok(!projected.includes(CODEX_RESEARCH_DEPTH_MARKER), sourcePath);
66
+ assert.equal(projected, normalizeCodexBody(research), sourcePath);
67
+ }
68
+ const windows = normalizeCodexBody(research, 'C:\\project\\skills\\research\\SKILL.md');
69
+ assert.ok(windows.includes(CODEX_RESEARCH_DEPTH_MARKER));
70
+ });
@@ -0,0 +1,76 @@
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, CODEX_SPECS_SCOPE_MARKER, CODEX_SPECS_SCOPE_NOTE,
8
+ CODEX_RESEARCH_DEPTH_MARKER } = require('../lib/codex-install');
9
+ const { splitFrontmatter } = require('../lib/codex-frontmatter');
10
+
11
+ const SKILLS = path.resolve(__dirname, '../../src/claude/skills');
12
+ const specsPath = path.join(SKILLS, 'specs/SKILL.md');
13
+ const specs = fs.readFileSync(specsPath, 'utf8');
14
+
15
+ function skillFiles(directory) {
16
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
17
+ const file = path.join(directory, entry.name);
18
+ return entry.isDirectory() ? skillFiles(file) : entry.name === 'SKILL.md' ? [file] : [];
19
+ });
20
+ }
21
+
22
+ function assertMarker() {
23
+ assert.equal(typeof CODEX_SPECS_SCOPE_MARKER, 'string');
24
+ assert.ok(CODEX_SPECS_SCOPE_MARKER.length > 0);
25
+ }
26
+
27
+ test('codex specs projection carries the scope note immediately after unchanged frontmatter', () => {
28
+ assertMarker();
29
+ assert.equal(typeof CODEX_SPECS_SCOPE_NOTE, 'string');
30
+ const plain = normalizeCodexBody(specs);
31
+ const projected = normalizeCodexBody(specs, specsPath);
32
+ assert.match(projected, /^---\r?\n/);
33
+ const before = splitFrontmatter(plain), after = splitFrontmatter(projected);
34
+ assert.equal(after.frontmatter.name, 'cf-specs');
35
+ assert.deepEqual(after.frontmatter, before.frontmatter);
36
+ assert.equal(projected.slice(0, projected.length - after.body.length), plain.slice(0, plain.length - before.body.length));
37
+ assert.equal(after.body, `\n${CODEX_SPECS_SCOPE_NOTE}\n\n${before.body}`);
38
+ assert.equal(projected.split(CODEX_SPECS_SCOPE_MARKER).length - 1, 1);
39
+ assert.equal(normalizeCodexBody(projected, specsPath), projected);
40
+ assert.ok(CODEX_SPECS_SCOPE_NOTE.includes(CODEX_SPECS_SCOPE_MARKER));
41
+ assert.match(CODEX_SPECS_SCOPE_MARKER, /GATE-SCOPE.*final answer/);
42
+ assert.match(CODEX_SPECS_SCOPE_NOTE, /not in commentary or through `request_user_input`/);
43
+ assert.match(CODEX_SPECS_SCOPE_NOTE, /Do not send a summary after/);
44
+ assert.match(CODEX_SPECS_SCOPE_NOTE, /\[NEEDS CLARIFICATION: <question>\]/);
45
+ assert.match(CODEX_SPECS_SCOPE_NOTE, /real question.*not a placeholder/);
46
+ assert.match(CODEX_SPECS_SCOPE_NOTE, /EXPAND, KEEP, and CUT together/);
47
+ assert.match(CODEX_SPECS_SCOPE_NOTE, /Preserve.*NEEDS CLARIFICATION.*EXPAND.*KEEP.*CUT.*do not translate/);
48
+ assert.doesNotMatch(CODEX_SPECS_SCOPE_NOTE, /\b(?:Write|Edit|Read|Bash|WebSearch|WebFetch|AskUserQuestion)\b/);
49
+ assert.doesNotMatch(CODEX_SPECS_SCOPE_NOTE, /customer|sales|notif|khách|bán hàng|thông báo/i);
50
+ });
51
+
52
+ test('specs marker is absent from every other skill and research retains exactly one depth marker', () => {
53
+ assertMarker();
54
+ const others = skillFiles(SKILLS).filter(file => file !== specsPath);
55
+ for (const name of ['ask', 'research']) assert.ok(others.includes(path.join(SKILLS, name, 'SKILL.md')));
56
+ for (const file of others) {
57
+ const source = fs.readFileSync(file, 'utf8');
58
+ const projected = normalizeCodexBody(source, file);
59
+ assert.ok(!projected.includes(CODEX_SPECS_SCOPE_MARKER), file);
60
+ if (file === path.join(SKILLS, 'research/SKILL.md')) {
61
+ assert.equal(projected.split(CODEX_RESEARCH_DEPTH_MARKER).length - 1, 1);
62
+ } else if (file !== path.join(SKILLS, 'ask/SKILL.md')) {
63
+ assert.equal(projected, normalizeCodexBody(source), file);
64
+ }
65
+ }
66
+ });
67
+
68
+ test('specs note is scoped to the exact skill source path and supports Windows separators', () => {
69
+ assertMarker();
70
+ for (const sourcePath of ['', 'skills/specs/references/templates.md', 'other-skills/specs/SKILL.md']) {
71
+ const projected = normalizeCodexBody(specs, sourcePath);
72
+ assert.ok(!projected.includes(CODEX_SPECS_SCOPE_MARKER), sourcePath);
73
+ assert.equal(projected, normalizeCodexBody(specs), sourcePath);
74
+ }
75
+ assert.ok(normalizeCodexBody(specs, 'C:\\project\\skills\\specs\\SKILL.md').includes(CODEX_SPECS_SCOPE_MARKER));
76
+ });