@haposoft/cafekit 0.18.1 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. package/src/codex/rules/state-sync.md +0 -60
@@ -260,6 +260,99 @@ function parseGeneratedTomlString(content, key) {
260
260
  return JSON.parse(match[1]);
261
261
  }
262
262
 
263
+ test('installed Codex instructions reference all ten runtime rules', () => {
264
+ inTempProject(root => {
265
+ const result = install(root);
266
+ assert.equal(result.status, 0, result.stderr);
267
+ const instructions = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
268
+ const rules = ['workflow', 'ai-dev-rules', 'orchestrator', 'manage-docs', 'skill-workflow-routing', 'skill-domain-routing', 'review-audit-self-decision', 'process-management', 'state-sync', 'hook-protocols'];
269
+ for (const rule of rules) {
270
+ assert.ok(instructions.includes(`.codex/rules/${rule}.md`), rule);
271
+ assert.ok(fs.existsSync(path.join(root, '.codex/rules', `${rule}.md`)), rule);
272
+ }
273
+ });
274
+ });
275
+
276
+ test('installed Codex orchestrator describes high reasoning on the session model', () => {
277
+ inTempProject(root => {
278
+ const source = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/rules/orchestrator.md'));
279
+ const result = install(root);
280
+ assert.equal(result.status, 0, result.stderr);
281
+ const content = fs.readFileSync(path.join(root, '.codex/rules/orchestrator.md'), 'utf8');
282
+ assert.doesNotMatch(content, /strongest|below[^\n]*fable/);
283
+ assert.match(content, /high reasoning effort/);
284
+ assert.match(content, /session model unless[^.]*configur/i);
285
+ assert.deepEqual(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/rules/orchestrator.md')), source);
286
+ });
287
+ });
288
+
289
+ test('installed hook protocols share malformed-output handling and Codex receipt guidance', () => {
290
+ inTempProject(root => {
291
+ const result = installPlatforms(root, ['claude', 'codex']);
292
+ assert.equal(result.status, 0, result.stderr);
293
+ const sentence = 'If hook output is malformed, stop and report the exact failure instead of guessing.';
294
+ for (const runtime of ['.claude', '.codex']) {
295
+ const content = fs.readFileSync(path.join(root, runtime, 'rules/hook-protocols.md'), 'utf8');
296
+ assert.ok(content.includes(sentence), runtime);
297
+ }
298
+ assert.match(fs.readFileSync(path.join(root, '.codex/rules/hook-protocols.md'), 'utf8'), /state-sync\.md/);
299
+ });
300
+ });
301
+
302
+ test('installed Claude ignore rules preserve env samples and ignore temporary files', () => {
303
+ inTempProject(root => {
304
+ const result = installPlatforms(root, ['claude']);
305
+ assert.equal(result.status, 0, result.stderr);
306
+ const lines = fs.readFileSync(path.join(root, '.claude/.gitignore'), 'utf8').split(/\r?\n/);
307
+ for (const line of ['!.env.sample', '!.env.template', '*.tmp']) assert.ok(lines.includes(line), line);
308
+ });
309
+ });
310
+
311
+ const READ_ONLY_ROLE_SUFFIX = '\n\n## Read-only role\n\nRead-only role: never create, never edit, and never delete files, and never run commands that write to disk; return findings in your reply.';
312
+
313
+ function assertInstalledAgentFields(content, name) {
314
+ const source = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/agents', `${name.replace(/_/g, '-')}.md`), 'utf8');
315
+ const { frontmatter, body } = require('../lib/codex-frontmatter').splitFrontmatter(source);
316
+ for (const key of ['name', 'description', 'developer_instructions']) {
317
+ assert.equal((content.match(new RegExp(`^${key} =`, 'gm')) || []).length, 1);
318
+ }
319
+ assert.equal(parseGeneratedTomlString(content, 'name'), name);
320
+ assert.equal(parseGeneratedTomlString(content, 'description'), normalizeCodexBody(frontmatter.description));
321
+ const suffix = ['brainstormer', 'code_auditor', 'inspector', 'researcher'].includes(name)
322
+ ? READ_ONLY_ROLE_SUFFIX : '';
323
+ const expectedBody = name === 'git_ops'
324
+ ? body.slice(0, body.indexOf('When instantiated as a team member'))
325
+ + body.split('\n').find(line => line.includes('NEVER arbitrarily use `force push`')).replace(/^3\. /, '- ') + '\n'
326
+ : body;
327
+ assert.equal(parseGeneratedTomlString(content, 'developer_instructions'), normalizeCodexBody(expectedBody).trim() + suffix);
328
+ }
329
+
330
+ for (const name of ['brainstormer', 'code_auditor', 'inspector', 'researcher']) {
331
+ test(`installed ${name} agent has exactly one read-only sandbox setting`, () => {
332
+ inTempProject(root => {
333
+ const result = install(root);
334
+ assert.equal(result.status, 0, result.stderr);
335
+ const content = fs.readFileSync(path.join(root, '.codex/agents', `${name}.toml`), 'utf8');
336
+ assert.deepEqual(content.match(/^sandbox_mode\s*=.*$/gm) || [], ['sandbox_mode = "read-only"']);
337
+ assert.ok(parseGeneratedTomlString(content, 'developer_instructions').endsWith(READ_ONLY_ROLE_SUFFIX));
338
+ assertInstalledAgentFields(content, name);
339
+ });
340
+ });
341
+ }
342
+
343
+ test('installed implementation agents retain their sandbox defaults', () => {
344
+ inTempProject(root => {
345
+ const result = install(root);
346
+ assert.equal(result.status, 0, result.stderr);
347
+ for (const name of ['implementer', 'test_runner', 'debugger', 'git_ops']) {
348
+ const content = fs.readFileSync(path.join(root, '.codex/agents', `${name}.toml`), 'utf8');
349
+ assert.doesNotMatch(content, /^sandbox_mode\s*=/m);
350
+ assert.doesNotMatch(parseGeneratedTomlString(content, 'developer_instructions'), /## Read-only role/);
351
+ assertInstalledAgentFields(content, name);
352
+ }
353
+ });
354
+ });
355
+
263
356
  function replaceGeneratedTomlString(content, key, value) {
264
357
  const pattern = new RegExp(`^${key} = .+$`, 'gm');
265
358
  const matches = [...content.matchAll(pattern)];
@@ -816,7 +909,7 @@ function brainstormProjectionIssues(files) {
816
909
  }
817
910
  if (!skill.includes('Persist only approved decisions and semantics, only with user authority')
818
911
  || !skill.includes('redact live secrets, credentials, private keys, access tokens, and unnecessary PII;')
819
- || !framework.includes('Do not write "user selected" unless direct user text or the native input tool confirms it.')) {
912
+ || !framework.includes('Do not write "user selected" unless direct user text or the question in the turn\'s final answer confirms it.')) {
820
913
  issues.add('approval-persistence');
821
914
  }
822
915
  for (const [issue, clause] of Object.entries(adaptiveClauses)) {
@@ -824,9 +917,9 @@ function brainstormProjectionIssues(files) {
824
917
  }
825
918
  const visible = `${skill}\n${framework}\n${agent}`;
826
919
  if (/\bAskUserQuestion\b/.test(visible)
827
- || /\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a structured user-input request\b/i.test(visible)
828
- || /\ban structured user-input request\b/i.test(visible)
829
- || /\ba structured user-input request\s+(?:calls|batches)\b/i.test(visible)) {
920
+ || /\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a question in the turn\'s final answer\b/i.test(visible)
921
+ || /\ban question in the turn\'s final answer\b/i.test(visible)
922
+ || /\ba question in the turn\'s final answer\s+(?:calls|batches)\b/i.test(visible)) {
830
923
  issues.add('projection-grammar');
831
924
  }
832
925
  return [...issues].sort();
@@ -1379,26 +1472,26 @@ test('Codex payload transform emits native skill and subagent syntax', () => {
1379
1472
 
1380
1473
  test('Codex payload transform keeps structured user-input grammar across determiners', () => {
1381
1474
  const cases = [
1382
- ['standalone bare token', 'AskUserQuestion', 'a structured user-input request'],
1383
- ['standalone code token', '`AskUserQuestion`', 'a structured user-input request'],
1384
- ['standalone multi-backtick token', '``AskUserQuestion``', 'a structured user-input request'],
1385
- ['standalone padded multi-backtick token', '`` AskUserQuestion ``', 'a structured user-input request'],
1386
- ['one', 'one AskUserQuestion call', 'one structured user-input request call'],
1387
- ['one multi-backtick', 'one ``AskUserQuestion`` call', 'one structured user-input request call'],
1388
- ['one padded multi-backtick', 'one `` AskUserQuestion `` call', 'one structured user-input request call'],
1389
- ['each', 'each AskUserQuestion call', 'each structured user-input request call'],
1390
- ['every', 'every AskUserQuestion batch', 'every structured user-input request batch'],
1391
- ['another', 'another AskUserQuestion call', 'another structured user-input request call'],
1392
- ['this', 'this AskUserQuestion call', 'this structured user-input request call'],
1393
- ['that', 'that AskUserQuestion call', 'that structured user-input request call'],
1394
- ['a', 'a AskUserQuestion call', 'a structured user-input request call'],
1395
- ['an', 'an AskUserQuestion call', 'a structured user-input request call'],
1396
- ['capitalized each', 'Each AskUserQuestion call', 'Each structured user-input request call'],
1397
- ['capitalized an', 'An AskUserQuestion call', 'A structured user-input request call'],
1398
- ['the', 'the AskUserQuestion call', 'the structured user-input request call'],
1399
- ['numeric', '27 AskUserQuestion calls', '27 structured user-input request calls'],
1400
- ['plural calls', 'AskUserQuestion calls', 'structured user-input request calls'],
1401
- ['plural batches', 'AskUserQuestion batches', 'structured user-input request batches']
1475
+ ['standalone bare token', 'AskUserQuestion', 'a question in the turn\'s final answer'],
1476
+ ['standalone code token', '`AskUserQuestion`', 'a question in the turn\'s final answer'],
1477
+ ['standalone multi-backtick token', '``AskUserQuestion``', 'a question in the turn\'s final answer'],
1478
+ ['standalone padded multi-backtick token', '`` AskUserQuestion ``', 'a question in the turn\'s final answer'],
1479
+ ['one', 'one AskUserQuestion call', 'one question in the turn\'s final answer call'],
1480
+ ['one multi-backtick', 'one ``AskUserQuestion`` call', 'one question in the turn\'s final answer call'],
1481
+ ['one padded multi-backtick', 'one `` AskUserQuestion `` call', 'one question in the turn\'s final answer call'],
1482
+ ['each', 'each AskUserQuestion call', 'each question in the turn\'s final answer call'],
1483
+ ['every', 'every AskUserQuestion batch', 'every question in the turn\'s final answer batch'],
1484
+ ['another', 'another AskUserQuestion call', 'another question in the turn\'s final answer call'],
1485
+ ['this', 'this AskUserQuestion call', 'this question in the turn\'s final answer call'],
1486
+ ['that', 'that AskUserQuestion call', 'that question in the turn\'s final answer call'],
1487
+ ['a', 'a AskUserQuestion call', 'a question in the turn\'s final answer call'],
1488
+ ['an', 'an AskUserQuestion call', 'a question in the turn\'s final answer call'],
1489
+ ['capitalized each', 'Each AskUserQuestion call', 'Each question in the turn\'s final answer call'],
1490
+ ['capitalized an', 'An AskUserQuestion call', 'A question in the turn\'s final answer call'],
1491
+ ['the', 'the AskUserQuestion call', 'the question in the turn\'s final answer call'],
1492
+ ['numeric', '27 AskUserQuestion calls', '27 question in the turn\'s final answer calls'],
1493
+ ['plural calls', 'AskUserQuestion calls', 'question in the turn\'s final answer calls'],
1494
+ ['plural batches', 'AskUserQuestion batches', 'question in the turn\'s final answer batches']
1402
1495
  ];
1403
1496
 
1404
1497
  for (const [name, input, expected] of cases) {
@@ -1406,27 +1499,27 @@ test('Codex payload transform keeps structured user-input grammar across determi
1406
1499
  assert.equal(actual, expected, name);
1407
1500
  assert.equal(normalizeCodexBody(actual, '/fixture/instructions.md'), expected, `${name} idempotence`);
1408
1501
  assert.doesNotMatch(actual, /\bAskUserQuestion\b/, `${name} Claude-only token`);
1409
- assert.doesNotMatch(actual, /\ban structured user-input request\b/i, `${name} incompatible article`);
1502
+ assert.doesNotMatch(actual, /\ban question in the turn\'s final answer\b/i, `${name} incompatible article`);
1410
1503
  assert.doesNotMatch(
1411
1504
  actual,
1412
- /\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a structured user-input request\b/i,
1505
+ /\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a question in the turn\'s final answer\b/i,
1413
1506
  `${name} doubled article`
1414
1507
  );
1415
1508
  }
1416
1509
 
1417
1510
  assert.equal(
1418
1511
  normalizeCodexBody('one\nAskUserQuestion call', '/fixture/instructions.md'),
1419
- 'one\nstructured user-input request call',
1512
+ 'one\nquestion in the turn\'s final answer call',
1420
1513
  'a single Markdown soft break must preserve its determiner relationship'
1421
1514
  );
1422
1515
  assert.equal(
1423
1516
  normalizeCodexBody('AskUserQuestion\nCalls', '/fixture/instructions.md'),
1424
- 'structured user-input request\nCalls',
1517
+ 'question in the turn\'s final answer\nCalls',
1425
1518
  'a single Markdown soft break must preserve a plural relationship'
1426
1519
  );
1427
1520
  assert.equal(
1428
1521
  normalizeCodexBody('one\n\nAskUserQuestion call', '/fixture/instructions.md'),
1429
- 'one\n\na structured user-input request call',
1522
+ 'one\n\na question in the turn\'s final answer call',
1430
1523
  'a paragraph break must not bind a prior determiner'
1431
1524
  );
1432
1525
  assert.equal(
@@ -1504,9 +1597,9 @@ test('Codex structured-input corpus oracle stays differential and production-awa
1504
1597
  assert.deepEqual(actualPaths, expectedPaths, 'every source occurrence needs an explicit projection oracle');
1505
1598
 
1506
1599
  const instructionExpectedSnippets = new Map([
1507
- ['src/claude/skills/git/SKILL.md', 'Present options via a structured user-input request — header'],
1508
- ['src/claude/skills/scout/SKILL.md', '**Fallback to a structured user-input request:**'],
1509
- ['src/claude/skills/sync/references/rebind-and-audit.md', 'End by asking with a structured user-input request when the host has']
1600
+ ['src/claude/skills/git/SKILL.md', 'Present options via a question in the turn\'s final answer — header'],
1601
+ ['src/claude/skills/scout/SKILL.md', '**Fallback to a question in the turn\'s final answer:**'],
1602
+ ['src/claude/skills/sync/references/rebind-and-audit.md', 'End by asking with a question in the turn\'s final answer when the host has']
1510
1603
  ]);
1511
1604
  for (const [relativePath, expectedSnippet] of instructionExpectedSnippets) {
1512
1605
  const sourcePath = path.join(PACKAGE_ROOT, relativePath);
@@ -1515,7 +1608,7 @@ test('Codex structured-input corpus oracle stays differential and production-awa
1515
1608
  const withSentinel = source.replace(/`?\bAskUserQuestion\b`?/g, sentinel);
1516
1609
  assert.notEqual(withSentinel, source, `${relativePath} differential control must replace a token`);
1517
1610
  const expected = normalizeCodexBody(withSentinel, sourcePath)
1518
- .replaceAll(sentinel, 'a structured user-input request');
1611
+ .replaceAll(sentinel, 'a question in the turn\'s final answer');
1519
1612
  const actual = normalizeCodexBody(source, sourcePath);
1520
1613
  assert.equal(actual, expected, `${relativePath} independent differential oracle`);
1521
1614
  assert.match(actual, new RegExp(expectedSnippet.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
@@ -1524,7 +1617,7 @@ test('Codex structured-input corpus oracle stays differential and production-awa
1524
1617
  const specMaker = fs.readFileSync(SPEC_MAKER_SOURCE_PATH, 'utf8');
1525
1618
  const convertedAgent = convertCodexAgentContent(specMaker, path.basename(SPEC_MAKER_SOURCE_PATH));
1526
1619
  assert.doesNotMatch(convertedAgent, /\bAskUserQuestion\b/);
1527
- assert.doesNotMatch(convertedAgent, /structured user-input request/);
1620
+ assert.doesNotMatch(convertedAgent, /question in the turn\'s final answer/);
1528
1621
 
1529
1622
  const sessionPath = path.join(PACKAGE_ROOT, 'src/claude/hooks/session.cjs');
1530
1623
  const session = normalizeCodexBody(fs.readFileSync(sessionPath, 'utf8'), sessionPath);
@@ -1996,6 +2089,45 @@ function sessionLauncher(projectRoot) {
1996
2089
  return { config, command: config.hooks.SessionStart[0].hooks[0].command };
1997
2090
  }
1998
2091
 
2092
+ test('parity regression: moved Codex checkout rebinds both launcher fields and preserves metadata', () => {
2093
+ inTempProject((root) => {
2094
+ const oldRoot = path.join(root, "old ' quoted project"), newRoot = path.join(root, "new ' quoted project");
2095
+ fs.mkdirSync(oldRoot);
2096
+ assert.equal(install(oldRoot).status, 0);
2097
+ const configPath = path.join(oldRoot, '.codex/hooks.json');
2098
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2099
+ config.hooks.SessionStart[0].matcher = 'custom-startup';
2100
+ config.hooks.SessionStart[0].hooks[0].timeout = 41;
2101
+ fs.writeFileSync(configPath, JSON.stringify(config));
2102
+ fs.renameSync(oldRoot, newRoot);
2103
+ const movedConfig = path.join(newRoot, '.codex/hooks.json');
2104
+ const before = fs.readFileSync(movedConfig, 'utf8');
2105
+ assert.equal(install(newRoot, ['--dry-run']).status, 0);
2106
+ assert.equal(fs.readFileSync(movedConfig, 'utf8'), before, 'dry run modified launchers');
2107
+ const upgraded = install(newRoot);
2108
+ assert.equal(upgraded.status, 0, `${upgraded.stdout}\n${upgraded.stderr}`);
2109
+ const rebound = sessionLauncher(newRoot);
2110
+ assert.equal(rebound.config.hooks.SessionStart[0].matcher, 'custom-startup');
2111
+ assert.equal(rebound.config.hooks.SessionStart[0].hooks[0].timeout, 41);
2112
+ const launched = runPosixLauncher(rebound.command, newRoot);
2113
+ assert.equal(launched.status, 0, launched.stderr);
2114
+ assert.match(launched.stdout, /Session startup\./);
2115
+ const windowsCommand = rebound.config.hooks.SessionStart[0].hooks[0].commandWindows.replace(/^node /, `"${process.execPath}" `);
2116
+ const launchedWindows = spawnSync(windowsCommand, { cwd: newRoot, encoding: 'utf8', input: JSON.stringify({ session_id: 'moved-windows', hook_event_name: 'SessionStart', source: 'startup', cwd: newRoot }), shell: true });
2117
+ assert.equal(launchedWindows.status, 0, launchedWindows.stderr);
2118
+ assert.match(launchedWindows.stdout, /Session startup\./);
2119
+ for (const { handler } of allHookLaunchers(rebound.config)) {
2120
+ const encoded = handler.commandWindows.match(/\s([A-Za-z0-9_-]+)$/)?.[1];
2121
+ assert.ok(encoded);
2122
+ const target = Buffer.from(encoded, 'base64url').toString('utf8');
2123
+ assert.equal(path.dirname(target), fs.realpathSync(path.join(newRoot, '.codex/hooks')));
2124
+ }
2125
+ const stable = fs.readFileSync(movedConfig, 'utf8');
2126
+ assert.equal(install(newRoot).status, 0);
2127
+ assert.equal(fs.readFileSync(movedConfig, 'utf8'), stable, 'reinstall changed stable hooks config');
2128
+ });
2129
+ });
2130
+
1999
2131
  test('Codex POSIX hook launchers run without Git and outside the repository root', () => {
2000
2132
  inTempProject((root) => {
2001
2133
  // A launcher that asks Git for its root gets nothing here and the outer root one
@@ -2194,7 +2326,10 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2194
2326
  );
2195
2327
  assert.equal(catalog.status, 0, catalog.stderr);
2196
2328
  const catalogData = JSON.parse(catalog.stdout);
2197
- assert.equal(catalogData.total, skillNames.length);
2329
+ // Explicit-only skills run only when named, so the catalog leaves them out of automatic routing.
2330
+ const explicitOnly = skillFiles.filter((file) => /^disable-model-invocation:\s*true\s*$/m.test(fs.readFileSync(file, 'utf8')));
2331
+ assert.ok(explicitOnly.length >= 1);
2332
+ assert.equal(catalogData.total, skillNames.length - explicitOnly.length);
2198
2333
  assert.equal(
2199
2334
  catalogData.root,
2200
2335
  fs.realpathSync(path.join(root, '.agents', 'skills'))
@@ -2248,7 +2383,7 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2248
2383
  const questionSkill = path.join(root, '.agents', 'skills', 'ask', 'SKILL.md');
2249
2384
  fs.appendFileSync(questionSkill, '\nUSER-CODEX-SENTINEL\n');
2250
2385
  // Fresh installs are exact. Refresh/upgrade intentionally documents the
2251
- // A skill file the manifest lists as obsolete is removed on refresh; edited skills stay.
2386
+ // Untracked obsolete files and edited current skills both stay on refresh.
2252
2387
  const obsoleteSpecsRule = path.join(
2253
2388
  root, '.agents', 'skills', 'specs', 'rules', 'design-principles.md'
2254
2389
  );
@@ -2261,8 +2396,8 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2261
2396
  assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
2262
2397
  assert.equal(
2263
2398
  fs.existsSync(obsoleteSpecsRule),
2264
- false,
2265
- 'Codex refresh removes obsolete skill files'
2399
+ true,
2400
+ 'Codex refresh preserves untracked obsolete skill files'
2266
2401
  );
2267
2402
 
2268
2403
  const metadataPath = path.join(root, '.codex', 'cafekit.json');
@@ -2277,9 +2412,11 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2277
2412
  assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
2278
2413
  assert.equal(
2279
2414
  fs.existsSync(obsoleteSpecsRule),
2280
- false,
2281
- 'Codex upgrade removes obsolete skill files'
2415
+ true,
2416
+ 'Codex upgrade preserves untracked obsolete skill files'
2282
2417
  );
2418
+ assert.match(sameVersion.stdout + sameVersion.stderr, /Preserved 1 obsolete runtime/);
2419
+ assert.match(upgrade.stdout + upgrade.stderr, /Preserved 1 obsolete runtime/);
2283
2420
  assert.ok(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8').startsWith(userInstructions));
2284
2421
 
2285
2422
  const forced = install(root, ['--force-overwrite']);
@@ -2509,7 +2646,7 @@ test('Codex installed Brainstorm skill reference and agent preserve proportional
2509
2646
  {
2510
2647
  name: 'decision-provenance-removed',
2511
2648
  source: 'framework',
2512
- from: 'Do not write "user selected" unless direct user text or the native input tool\n confirms it.',
2649
+ from: 'Do not write "user selected" unless direct user text or the question in the turn\'s final answer\n confirms it.',
2513
2650
  to: 'Write "user selected" for inferred defaults.',
2514
2651
  expected: ['approval-persistence']
2515
2652
  },
@@ -2517,7 +2654,7 @@ test('Codex installed Brainstorm skill reference and agent preserve proportional
2517
2654
  name: 'installed-grammar-corruption',
2518
2655
  source: 'skill',
2519
2656
  from: '## Completion bar',
2520
- to: 'one a structured user-input request call\n\n## Completion bar',
2657
+ to: 'one a question in the turn\'s final answer call\n\n## Completion bar',
2521
2658
  expected: ['projection-grammar']
2522
2659
  }
2523
2660
  ];
@@ -3079,28 +3216,32 @@ test('Codex installed code_auditor carries no Strict attestation marker', () =>
3079
3216
  });
3080
3217
  });
3081
3218
 
3082
- test('reinstall removes obsolete scripts and skill files from Codex and omp installs', () => {
3083
- inTempProject((root) => {
3219
+ test('reinstall preserves untracked Codex and omp runtime files', () => {
3220
+ for (const platform of ['codex', 'omp']) inTempProject(root => {
3084
3221
  const first = installPlatforms(root, ['codex', 'omp']);
3085
3222
  assert.equal(first.status, 0, `${first.stdout}\n${first.stderr}`);
3223
+ const runtime = `.${platform}`;
3086
3224
  const retired = ['change-firewall.cjs', 'spec-final-state.cjs'];
3087
- for (const runtime of ['.codex', '.omp']) {
3088
- for (const name of retired) fs.writeFileSync(path.join(root, runtime, 'scripts', name), 'legacy\n');
3089
- }
3225
+ for (const name of retired) fs.writeFileSync(path.join(root, runtime, 'scripts', name), 'legacy\n');
3226
+ // omp installs no skills, so a reinstall of omp alone must leave .agents/skills to Codex.
3090
3227
  const retiredSkillFiles = ['code-review/references/verification-gate.md', 'specs/references/archive-workflow.md'];
3091
- for (const rel of retiredSkillFiles) fs.writeFileSync(path.join(root, '.agents', 'skills', rel), 'legacy\n');
3092
- const again = installPlatforms(root, ['codex', 'omp'], ['--force-overwrite']);
3093
- assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
3094
3228
  for (const rel of retiredSkillFiles) {
3095
- assert.equal(fs.existsSync(path.join(root, '.agents', 'skills', rel)), false, `.agents/skills/${rel} must be removed`);
3229
+ const target = path.join(root, '.agents/skills', rel);
3230
+ fs.mkdirSync(path.dirname(target), { recursive: true });
3231
+ fs.writeFileSync(target, 'legacy\n');
3096
3232
  }
3097
- assert.equal(fs.existsSync(path.join(root, '.agents', 'skills', 'code-review', 'SKILL.md')), true, 'the code-review skill stays');
3098
- for (const runtime of ['.codex', '.omp']) {
3099
- for (const name of retired) {
3100
- assert.equal(fs.existsSync(path.join(root, runtime, 'scripts', name)), false, `${runtime}/scripts/${name} must be removed`);
3101
- }
3102
- assert.equal(fs.existsSync(path.join(root, runtime, 'scripts', 'spec-resolver.cjs')), true, `${runtime} keeps the resolver`);
3233
+ const again = installPlatforms(root, [platform], ['--force-overwrite']);
3234
+ assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
3235
+ const output = again.stdout + again.stderr;
3236
+ for (const rel of retiredSkillFiles) assert.equal(fs.existsSync(path.join(root, '.agents/skills', rel)), true, rel);
3237
+ for (const name of retired) assert.equal(fs.existsSync(path.join(root, runtime, 'scripts', name)), true, name);
3238
+ if (platform === 'codex') assert.match(output, /Preserved 4 obsolete runtime/);
3239
+ else {
3240
+ assert.match(output, /Preserved 2 obsolete runtime/);
3241
+ assert.doesNotMatch(output, /\.agents\/skills/);
3103
3242
  }
3243
+ assert.equal(fs.existsSync(path.join(root, '.agents/skills/code-review/SKILL.md')), true);
3244
+ assert.equal(fs.existsSync(path.join(root, runtime, 'scripts/spec-resolver.cjs')), true);
3104
3245
  });
3105
3246
  });
3106
3247
 
@@ -3222,4 +3363,3 @@ test('Claude and Codex installed rules preserve the ported review and process gu
3222
3363
  }
3223
3364
  });
3224
3365
  });
3225
-
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
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
+
10
+ const REPO = path.resolve(__dirname, '../../../..');
11
+ const SRC = path.resolve(__dirname, '../../src');
12
+ const SCRIPT = path.resolve(__dirname, '../../scripts/build-codex-plugin.mjs');
13
+ let lib;
14
+ let claudeLib;
15
+ let work;
16
+ let tree;
17
+
18
+ function walk(root, base = root) {
19
+ return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
20
+ const full = path.join(root, entry.name);
21
+ return entry.isDirectory() ? walk(full, base) : [path.relative(base, full)];
22
+ });
23
+ }
24
+
25
+ before(async () => {
26
+ lib = await import(SCRIPT);
27
+ claudeLib = await import(path.resolve(__dirname, '../../scripts/build-claude-plugin.mjs'));
28
+ work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-plugin-build-'));
29
+ tree = lib.build(path.join(work, 'cf-codex'));
30
+ });
31
+
32
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
33
+
34
+ test('inventory-and-policy: the Claude plugin skill list, Codex names, and explicit-only policies', () => {
35
+ const skills = fs.readdirSync(path.join(tree, 'skills')).sort();
36
+ // The Claude plugin's skills plus the Codex-only ones.
37
+ assert.deepEqual(skills, [...claudeLib.inScopeSkills(), ...lib.CODEX_ONLY_SKILLS].sort());
38
+ const { isExplicitOnlySkill, CODEX_POLICY_FILE, CODEX_EXPLICIT_ONLY_POLICY } = require('../phases/copy-payload');
39
+ for (const skill of skills) {
40
+ const text = fs.readFileSync(path.join(tree, 'skills', skill, 'SKILL.md'), 'utf8');
41
+ assert.match(text, new RegExp(`^---\\nname: cf-${skill}\\n`), `${skill} keeps the installer's Codex name`);
42
+ const policy = path.join(tree, 'skills', skill, CODEX_POLICY_FILE);
43
+ const skillSource = lib.CODEX_ONLY_SKILLS.includes(skill) ? path.join(SRC, 'codex/plugin/skills', skill) : path.join(SRC, 'claude/skills', skill);
44
+ if (isExplicitOnlySkill(skillSource)) {
45
+ assert.equal(fs.readFileSync(policy, 'utf8'), CODEX_EXPLICIT_ONLY_POLICY, `${skill} policy`);
46
+ } else {
47
+ assert.ok(!fs.existsSync(policy), `${skill} has an unexpected policy file`);
48
+ }
49
+ }
50
+ });
51
+
52
+ test('path-allowlist: Markdown reaches plugin files through {CAFEKIT_ROOT}, never an install path', () => {
53
+ const offenders = [];
54
+ for (const file of walk(tree).filter((f) => f.endsWith('.md'))) {
55
+ const text = fs.readFileSync(path.join(tree, file), 'utf8');
56
+ if (/\.codex[/\\](skills|references|scripts|rules|hooks|output-styles)\b/.test(text)) offenders.push(`${file}: .codex runtime path`);
57
+ if (/\.agents[/\\]skills\b/.test(text)) offenders.push(`${file}: .agents/skills path`);
58
+ if (/(?<!~\/)\.codex\/agents\//.test(text)) offenders.push(`${file}: project .codex/agents path`);
59
+ if (/\.claude[/\\](skills|references|scripts|rules)\b/.test(text)) offenders.push(`${file}: .claude runtime path`);
60
+ if (/\{CAFEKIT_ROOT\}\/scripts\//.test(text)) offenders.push(`${file}: script reached by path instead of {cafekit:<name>}`);
61
+ for (const [, rel] of text.matchAll(/\{CAFEKIT_ROOT\}\/([^\s`'")]+)/g)) {
62
+ const target = rel.replace(/[.,:;]+$/, '');
63
+ if (/[<*]/.test(target)) continue;
64
+ if (!fs.existsSync(path.join(tree, target))) offenders.push(`${file}: {CAFEKIT_ROOT}/${target} does not exist`);
65
+ }
66
+ }
67
+ assert.deepEqual(offenders, []);
68
+ const commands = lib.scriptCommands(tree);
69
+ assert.ok(commands.includes('provenance'), 'provenance is not in the command table');
70
+ for (const name of commands) assert.ok(fs.existsSync(path.join(tree, 'scripts', `${name}.cjs`)), `${name} has no script`);
71
+ });
72
+
73
+ test('manifest-and-marketplace: plugin.json and the repo marketplace carry the keys Codex reads', () => {
74
+ const manifest = JSON.parse(fs.readFileSync(path.join(tree, '.codex-plugin/plugin.json'), 'utf8'));
75
+ assert.equal(manifest.name, 'cf');
76
+ assert.equal(manifest.version, require('../../package.json').version);
77
+ for (const key of ['description', 'author', 'license', 'interface']) assert.ok(manifest[key], `plugin.json lacks ${key}`);
78
+ const market = lib.marketplace();
79
+ assert.equal(typeof market.name, 'string');
80
+ const entry = market.plugins.find((p) => p.name === 'cf');
81
+ assert.deepEqual(entry.source, { source: 'local', path: './plugins/cf-codex' });
82
+ assert.ok(entry.policy?.installation && entry.policy?.authentication && entry.category, 'entry lacks policy or category');
83
+ });
84
+
85
+ test('marketplace-not-ignored: git can commit .agents/plugins/marketplace.json', () => {
86
+ // --no-index: a tracked file would otherwise always read as not ignored.
87
+ const run = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/plugins/marketplace.json'], { cwd: REPO });
88
+ assert.equal(run.status, 1, 'the marketplace file is ignored by git');
89
+ const skills = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/skills/specs/SKILL.md'], { cwd: REPO });
90
+ assert.equal(skills.status, 0, 'npx-installed .agents/skills must stay ignored');
91
+ // A reinstall must not append `.agents/` after `!.agents/plugins/` and hide the marketplace again.
92
+ const { hasPattern } = require('../phases/root-config');
93
+ assert.ok(hasPattern(['.agents/*', '!.agents/plugins/'], '.agents/'));
94
+ });
95
+
96
+ test('claude-marketplace-guard: the Codex marketplace never points at the Claude plugin tree', () => {
97
+ const entry = lib.marketplace().plugins.find((p) => p.name === 'cf');
98
+ assert.notEqual(path.normalize(entry.source.path), path.normalize('./plugins/cf'));
99
+ assert.ok(!fs.existsSync(path.join(REPO, 'plugins/cf/.codex-plugin')), 'the Claude tree must not carry a Codex manifest');
100
+ });
101
+
102
+ test('check-detects-drift: --check fails after one byte changes in a fresh copy', () => {
103
+ const copy = lib.build(path.join(work, 'copy'));
104
+ const clean = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
105
+ assert.equal(clean.status, 0, clean.stderr);
106
+ fs.appendFileSync(path.join(copy, 'skills/specs/SKILL.md'), 'x');
107
+ const drift = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
108
+ assert.equal(drift.status, 1);
109
+ assert.match(drift.stderr, /changed skills\/specs\/SKILL\.md/);
110
+ });
111
+
112
+ // ── Runtime and hooks.json ─────────────────────────────────────────────────────
113
+
114
+ const RUNTIME_DIRS = ['hooks', 'rules', 'output-styles'];
115
+
116
+ function npxInstall() {
117
+ const repo = fs.mkdtempSync(path.join(work, 'npx-'));
118
+ spawnSync('git', ['init', '-q'], { cwd: repo });
119
+ const run = spawnSync('node', [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
120
+ cwd: repo, encoding: 'utf8', env: { ...process.env, HOME: fs.mkdtempSync(path.join(work, 'home-')) },
121
+ });
122
+ assert.equal(run.status, 0, run.stderr || run.stdout);
123
+ return repo;
124
+ }
125
+
126
+ function runtimeFiles(root) {
127
+ return RUNTIME_DIRS.flatMap((dir) => (fs.existsSync(path.join(root, dir)) ? walk(path.join(root, dir)).map((f) => `${dir}/${f}`) : []))
128
+ .concat(fs.existsSync(path.join(root, 'runtime.schema.json')) ? ['runtime.schema.json'] : [])
129
+ .filter((f) => f !== 'hooks/hooks.json' && !f.includes('/.logs/'))
130
+ .sort();
131
+ }
132
+
133
+ test('runtime-matches-npx: the plugin runtime holds the same files and bytes as an npx Codex install', () => {
134
+ const installed = path.join(npxInstall(), '.codex');
135
+ const expected = runtimeFiles(installed);
136
+ assert.deepEqual(runtimeFiles(tree), expected);
137
+ for (const file of expected) {
138
+ assert.ok(fs.readFileSync(path.join(tree, file)).equals(fs.readFileSync(path.join(installed, file))), `${file} differs from the npx install`);
139
+ }
140
+ });
141
+
142
+ test('requires-resolve: every relative require in the plugin hooks resolves inside the tree', () => {
143
+ const missing = [];
144
+ for (const file of walk(path.join(tree, 'hooks')).filter((f) => f.endsWith('.cjs'))) {
145
+ const full = path.join(tree, 'hooks', file);
146
+ for (const [, spec] of fs.readFileSync(full, 'utf8').matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) {
147
+ const target = path.resolve(path.dirname(full), spec);
148
+ if (![target, `${target}.cjs`, `${target}.js`].some((t) => fs.existsSync(t))) missing.push(`hooks/${file} → ${spec}`);
149
+ }
150
+ }
151
+ assert.deepEqual(missing, []);
152
+ });
153
+
154
+ function pluginHandlers() {
155
+ const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
156
+ return Object.entries(hooks).flatMap(([event, groups]) => groups.flatMap((group) => group.hooks.map((h) => ({ event, ...h }))));
157
+ }
158
+
159
+ test('hook-commands-fallback: every plugin hook has a POSIX command with a node PATH fallback and a Windows command', () => {
160
+ const template = JSON.parse(fs.readFileSync(path.join(SRC, 'codex/hooks.json'), 'utf8')).hooks;
161
+ const handlers = pluginHandlers();
162
+ // The template's runtime hooks plus the one plugin-only SessionStart hook.
163
+ const pluginOnly = handlers.filter((h) => h.command.includes('/plugin/hooks/'));
164
+ assert.deepEqual(pluginOnly.map((h) => h.event), ['SessionStart']);
165
+ assert.equal(handlers.length - pluginOnly.length, Object.values(template).flat().flatMap((g) => g.hooks).length);
166
+ for (const handler of handlers) {
167
+ const [, dir, script] = handler.command.match(/((?:plugin\/)?hooks)\/([a-z0-9-]+\.cjs)"$/) || [];
168
+ assert.ok(script && fs.existsSync(path.join(tree, dir, script)), `bad command ${handler.command}`);
169
+ assert.match(handler.command, /^PATH="\$PATH":'[^']+'(:'[^']+')* node "\$\{PLUGIN_ROOT\}\/(plugin\/)?hooks\/[a-z0-9-]+\.cjs"$/);
170
+ assert.equal(typeof handler.commandWindows, 'string');
171
+ assert.ok(handler.commandWindows.includes(script) && handler.commandWindows.includes('PLUGIN_ROOT'), handler.commandWindows);
172
+ }
173
+ });
174
+
175
+ test('no-build-machine-paths: hooks.json names no absolute path of the machine that built it', () => {
176
+ const text = fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8');
177
+ for (const local of [REPO, os.homedir(), path.dirname(process.execPath)]) assert.ok(!text.includes(local), `hooks.json contains ${local}`);
178
+ const windows = pluginHandlers()[0].commandWindows;
179
+ const decoded = spawnSync('node', ['-e', `${windows.replace(/^node -e "|"$/g, '').replace(/require\('module'\)\.runMain\(\)/, 'console.log(process.argv[1])')}`], {
180
+ encoding: 'utf8', env: { ...process.env, PLUGIN_ROOT: path.join(work, 'win-root') },
181
+ });
182
+ assert.equal(decoded.stdout.trim(), path.join(work, 'win-root', 'hooks', pluginHandlers()[0].command.match(/hooks\/([a-z0-9-]+\.cjs)"$/)[1]));
183
+ });
184
+
185
+ test('npx-hook-commands-unchanged: the npx install still gets absolute project launchers', () => {
186
+ const repo = npxInstall();
187
+ const { hooks } = JSON.parse(fs.readFileSync(path.join(repo, '.codex/hooks.json'), 'utf8'));
188
+ for (const handler of Object.values(hooks).flat().flatMap((g) => g.hooks)) {
189
+ assert.match(handler.command, /node '[^']+\/\.codex\/hooks\/[a-z0-9-]+\.cjs'$/);
190
+ assert.ok(handler.command.includes(fs.realpathSync(repo)));
191
+ assert.ok(!handler.command.includes('PLUGIN_ROOT'));
192
+ const encoded = handler.commandWindows.split(' ').pop();
193
+ assert.ok(Buffer.from(encoded, 'base64url').toString('utf8').startsWith(path.join(fs.realpathSync(repo), '.codex', 'hooks')), 'npx Windows launcher lost its project path');
194
+ }
195
+ });
196
+
197
+ const SAMPLE_PAYLOADS = {
198
+ SessionStart: { source: 'startup' },
199
+ UserPromptSubmit: { prompt: 'hello' },
200
+ PreToolUse: { tool_name: 'Bash', tool_input: { command: 'ls' } },
201
+ PermissionRequest: { tool_name: 'Bash', tool_input: { command: 'ls' } },
202
+ PostToolUse: { tool_name: 'apply_patch', tool_input: { command: '' }, tool_response: {} },
203
+ Stop: { stop_hook_active: false },
204
+ SubagentStart: { agent_type: 'debugger' },
205
+ SubagentStop: { agent_type: 'debugger', stop_hook_active: false },
206
+ PreCompact: { trigger: 'manual' },
207
+ };
208
+
209
+ test('hook-smoke: every generated plugin launcher runs its hook to exit 0 without a crash log', () => {
210
+ // A private root holding the tree, so every crash log a hook writes lands under it.
211
+ const sandbox = fs.mkdtempSync(path.join(work, 'smoke-'));
212
+ const smokeTree = lib.build(path.join(sandbox, 'plugin', 'cf-codex'));
213
+ const repo = path.join(sandbox, 'repo');
214
+ fs.mkdirSync(repo);
215
+ spawnSync('git', ['init', '-q'], { cwd: repo });
216
+ const data = path.join(sandbox, 'data');
217
+ const failures = [];
218
+ for (const handler of pluginHandlers()) {
219
+ const payload = { session_id: 'codex-smoke', hook_event_name: handler.event, cwd: repo, ...(SAMPLE_PAYLOADS[handler.event] || {}) };
220
+ const run = spawnSync('sh', ['-c', handler.command], {
221
+ cwd: repo, input: JSON.stringify(payload), encoding: 'utf8', timeout: 20000,
222
+ env: { ...process.env, HOME: path.join(sandbox, 'home'), PLUGIN_ROOT: smokeTree, PLUGIN_DATA: data, CLAUDE_PLUGIN_ROOT: smokeTree, CLAUDE_PLUGIN_DATA: data },
223
+ });
224
+ if (run.status !== 0) failures.push(`${handler.event} ${handler.command}: exit ${run.status} ${run.stderr.slice(0, 200)}`);
225
+ }
226
+ const crashLogs = walk(sandbox).filter((f) => f.endsWith('hook-log.jsonl'))
227
+ .filter((f) => /"status":"crash"/.test(fs.readFileSync(path.join(sandbox, f), 'utf8')));
228
+ for (const log of crashLogs) failures.push(`crash logged in ${log}: ${fs.readFileSync(path.join(sandbox, log), 'utf8').slice(0, 200)}`);
229
+ assert.deepEqual(failures, []);
230
+ });