@haposoft/cafekit 0.15.1 → 0.16.0-rc.1

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 (99) hide show
  1. package/README.md +6 -2
  2. package/bin/__tests__/codex-hooks.test.js +140 -0
  3. package/bin/__tests__/codex-native.test.js +230 -2
  4. package/bin/__tests__/develop-contract.test.js +1120 -0
  5. package/bin/__tests__/installer-safety.test.js +553 -2
  6. package/bin/__tests__/package-inventory.test.js +258 -0
  7. package/bin/__tests__/secret-scanner.test.js +157 -0
  8. package/bin/__tests__/validator-grounding.test.js +713 -0
  9. package/bin/install.js +5 -1
  10. package/bin/lib/codex-install.js +21 -6
  11. package/bin/lib/context.js +17 -6
  12. package/bin/lib/copy-utils.js +31 -0
  13. package/bin/lib/i18n.js +12 -3
  14. package/bin/lib/instruction-blocks.js +85 -0
  15. package/bin/lib/managed-writer.js +4 -1
  16. package/bin/lib/opencode-install.js +75 -30
  17. package/bin/lib/skill-deps.js +45 -7
  18. package/bin/phases/claude-runtime.js +103 -4
  19. package/bin/phases/codex-runtime.js +41 -7
  20. package/bin/phases/post-install.js +29 -22
  21. package/bin/phases/root-config.js +89 -10
  22. package/bin/phases/select-platform.js +23 -0
  23. package/bin/phases/skills-setup.js +7 -3
  24. package/package.json +15 -5
  25. package/src/claude/CLAUDE.md +6 -122
  26. package/src/claude/agents/code-auditor.md +21 -22
  27. package/src/claude/agents/debugger.md +3 -3
  28. package/src/claude/agents/deployer.md +1 -1
  29. package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
  30. package/src/claude/agents/project-manager.md +1 -1
  31. package/src/claude/agents/spec-maker.md +2 -2
  32. package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
  33. package/src/claude/hooks/agent.cjs +7 -14
  34. package/src/claude/hooks/rules.cjs +33 -53
  35. package/src/claude/hooks/spec-gate.cjs +66 -21
  36. package/src/claude/hooks/spec-state.cjs +5 -0
  37. package/src/claude/migration-manifest.json +9 -2
  38. package/src/claude/runtime.json +0 -3
  39. package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
  40. package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
  41. package/src/claude/scripts/spec-ground.cjs +189 -83
  42. package/src/claude/scripts/validate-spec-output.cjs +294 -30
  43. package/src/claude/scripts/workflow-policy.cjs +609 -0
  44. package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
  45. package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
  46. package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
  47. package/src/claude/skills/backend-development/SKILL.md +4 -4
  48. package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
  49. package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
  50. package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
  51. package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
  52. package/src/claude/skills/backend-development/references/backend-security.md +3 -3
  53. package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
  54. package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
  55. package/src/claude/skills/brainstorm/SKILL.md +2 -3
  56. package/src/claude/skills/chrome-devtools/scripts/package-lock.json +261 -150
  57. package/src/claude/skills/chrome-devtools/scripts/package.json +1 -1
  58. package/src/claude/skills/code-review/SKILL.md +12 -8
  59. package/src/claude/skills/code-review/references/verification-gate.md +4 -4
  60. package/src/claude/skills/debug/SKILL.md +2 -0
  61. package/src/claude/skills/develop/SKILL.md +100 -52
  62. package/src/claude/skills/develop/references/parallel-waves.md +105 -26
  63. package/src/claude/skills/develop/references/quality-gate.md +58 -38
  64. package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
  65. package/src/claude/skills/git/SKILL.md +44 -50
  66. package/src/claude/skills/hotfix/SKILL.md +10 -8
  67. package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
  68. package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
  69. package/src/claude/skills/inspect/SKILL.md +12 -30
  70. package/src/claude/skills/mobile-development/SKILL.md +12 -12
  71. package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
  72. package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
  73. package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
  74. package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
  75. package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
  76. package/src/claude/skills/pptx/scripts/package-lock.json +868 -0
  77. package/src/claude/skills/pptx/scripts/package.json +1 -1
  78. package/src/claude/skills/question/SKILL.md +13 -13
  79. package/src/claude/skills/research/SKILL.md +2 -2
  80. package/src/claude/skills/specs/SKILL.md +32 -15
  81. package/src/claude/skills/specs/references/review.md +3 -3
  82. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  83. package/src/claude/skills/specs/templates/spec-state.json +7 -3
  84. package/src/claude/skills/sync/SKILL.md +26 -1
  85. package/src/claude/skills/sync/references/sync-protocols.md +3 -3
  86. package/src/claude/skills/test/SKILL.md +12 -4
  87. package/src/claude/skills/test/references/failure-triage.md +17 -17
  88. package/src/claude/skills/test/references/test-memory.md +2 -2
  89. package/src/codex/AGENTS.md +16 -24
  90. package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
  91. package/src/codex/hooks/rules.cjs +27 -26
  92. package/src/codex/hooks/spec-gate.cjs +19 -8
  93. package/src/codex/hooks/spec-state.cjs +10 -1
  94. package/src/codex/hooks.json +14 -14
  95. package/src/common/AGENTS.md +43 -0
  96. package/src/opencode/AGENTS.md +21 -121
  97. package/src/opencode/plugins/rules.ts +26 -34
  98. package/src/opencode/plugins/spec-gate.ts +283 -0
  99. package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Native spec-driven workflow and runtime bundle for Claude Code, Codex CLI, and OpenCode.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.15.1-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.16.0-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Native-orange.svg)](https://claude.ai/code)
8
8
  [![Codex%20CLI](https://img.shields.io/badge/Codex%20CLI-Native-111111.svg)](https://developers.openai.com/codex)
@@ -68,8 +68,10 @@ On install, CafeKit updates the **project-root** `.gitignore` with:
68
68
  ```text
69
69
  # CafeKit / Ecosystem
70
70
  specs/_shared/
71
- plans/
71
+ plans/*
72
+ !plans/*.md
72
73
  !plans/templates/
74
+ !plans/templates/**
73
75
  .cafekit-backup/
74
76
  .cafekit.lock
75
77
  .claude/
@@ -134,6 +136,8 @@ AGENTS.md
134
136
  CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
135
137
  and custom agents natively after the repository is trusted; no project
136
138
  `config.toml` is generated. Review and trust project hooks with `/hooks`.
139
+ On Windows, installed hook commands are bound to the canonical project path
140
+ without requiring Git and remain stable when a session starts in a subdirectory.
137
141
  CafeKit uses Codex's native status and usage UI instead of installing the
138
142
  Claude statusline.
139
143
 
@@ -27,6 +27,10 @@ function inHookFixture(run) {
27
27
  try {
28
28
  fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
29
29
  fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
30
+ fs.copyFileSync(
31
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'),
32
+ path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'),
33
+ );
30
34
  fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
31
35
  return run(root, hooks);
32
36
  } finally {
@@ -329,3 +333,139 @@ test('Codex state hook persists direct native event fields at project root', ()
329
333
  assert.equal(codexStateDir(root, ''), null);
330
334
  });
331
335
  });
336
+
337
+ test('Codex canonical receipt provenance requires both Base and Head', () => {
338
+ const receipt = require(path.join(CODEX_HOOKS, 'lib', 'spec-receipt.cjs'));
339
+ const onlyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
340
+ assert.ok(onlyBase.includes('provenance'), 'only Base should fail');
341
+ const onlyHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def\n');
342
+ assert.ok(onlyHead.includes('provenance'), 'only Head should fail');
343
+ const both = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
344
+ assert.deepEqual(both, [], 'both Base and Head should pass');
345
+ const onlyBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\n');
346
+ assert.ok(onlyBaseSha.includes('provenance'));
347
+ const bothSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\nhead_sha: def\n');
348
+ assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
349
+ // Empty / bare provenance must fail — non-empty same-line value required
350
+ const emptyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
351
+ assert.ok(emptyBase.includes('provenance'), 'empty Base: should fail');
352
+ const spacesBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: \nHead: def\n');
353
+ assert.ok(spacesBase.includes('provenance'), 'Base: spaces only should fail');
354
+ const emptyHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead:\n');
355
+ assert.ok(emptyHead.includes('provenance'), 'empty Head: should fail');
356
+ const bareBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha\nhead_sha: def\n');
357
+ assert.ok(bareBaseSha.includes('provenance'), 'bare base_sha without colon/value should fail');
358
+ const emptyBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha:\nhead_sha: def\n');
359
+ assert.ok(emptyBaseSha.includes('provenance'), 'empty base_sha: should fail');
360
+ const spacesBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: \nhead_sha: def\n');
361
+ assert.ok(spacesBaseSha.includes('provenance'), 'base_sha spaces only should fail');
362
+ const bareBoth = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
363
+ assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon should fail');
364
+ const validBaseHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
365
+ assert.deepEqual(validBaseHead, [], 'valid Base+Head with values should pass');
366
+ const validSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\nhead_sha: def456\n');
367
+ assert.deepEqual(validSha, [], 'valid base_sha+head_sha with values should pass');
368
+
369
+ // Integration via spec-gate hook: only Base blocks, both passes
370
+ inHookFixture((root, hooks) => {
371
+ const feature = path.join(root, 'specs', 'auth');
372
+ const taskPath = 'tasks/task-R0-01-auth.md';
373
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
374
+ const gate = path.join(hooks, 'spec-gate.cjs');
375
+ const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
376
+
377
+ // only Base -> block
378
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
379
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
380
+ fs.mkdirSync(payload.cwd, { recursive: true });
381
+ const blocked = runHook(gate, payload.cwd, payload);
382
+ assert.equal(JSON.parse(blocked.stdout).decision, 'block');
383
+ assert.match(JSON.parse(blocked.stdout).reason, /\bg\b|\bprovenance\b/);
384
+
385
+ // both -> no block
386
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
387
+ const passed = runHook(gate, payload.cwd, payload);
388
+ assert.equal(passed.stdout, '');
389
+
390
+ // empty Base: should block provenance — reset gate cache so re-checked as newly-done
391
+ try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
392
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
393
+ const blockedEmpty = runHook(gate, payload.cwd, payload);
394
+ assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
395
+ assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bg\b/);
396
+
397
+ // bare base_sha without colon should block — reset cache again
398
+ try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
399
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
400
+ const blockedBare = runHook(gate, payload.cwd, payload);
401
+ assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
402
+ assert.match(JSON.parse(blockedBare.stdout).reason, /\bg\b/);
403
+ });
404
+ });
405
+
406
+ test('Codex cache hardening: every done re-validated — mutation, deletion, malformed cache, unchanged valid', () => {
407
+ inHookFixture((root, hooks) => {
408
+ const feature = path.join(root, 'specs', 'auth');
409
+ const taskPath = 'tasks/task-R0-01-auth.md';
410
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
411
+ const gate = path.join(hooks, 'spec-gate.cjs');
412
+ const appCwd = path.join(root, 'packages', 'app');
413
+ fs.mkdirSync(appCwd, { recursive: true });
414
+ const payload = { cwd: appCwd, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
415
+ const cacheFile = path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json');
416
+ const validBody = 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n';
417
+
418
+ // first-run (no cache) with valid receipt → no block, cache seeded
419
+ try { fs.rmSync(cacheFile, { force: true }); } catch {}
420
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
421
+ fs.writeFileSync(path.join(feature, taskPath), validBody);
422
+ let res = runHook(gate, appCwd, payload);
423
+ assert.equal(res.stdout, '', 'first-run valid should not block');
424
+ assert.ok(fs.existsSync(cacheFile), 'cache file must be created on first valid run');
425
+ assert.equal(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).auth[taskPath], 'done');
426
+
427
+ // cache-hit unchanged valid → still no block (revalidation passes)
428
+ res = runHook(gate, appCwd, payload);
429
+ assert.equal(res.stdout, '', 'cache-hit unchanged valid must not block');
430
+
431
+ // mutation: remove Command/Verification → must block even though cache says done
432
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
433
+ res = runHook(gate, appCwd, payload);
434
+ assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
435
+ assert.match(JSON.parse(res.stdout).reason, /\bc\b|\be\b/);
436
+ // second hit still blocks
437
+ res = runHook(gate, appCwd, payload);
438
+ assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
439
+
440
+ // restore valid, then mutate provenance (remove Head) → block
441
+ fs.writeFileSync(path.join(feature, taskPath), validBody);
442
+ assert.equal(runHook(gate, appCwd, payload).stdout, '', 'restored valid should pass again');
443
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\n```\npass\n```\n');
444
+ res = runHook(gate, appCwd, payload);
445
+ assert.equal(JSON.parse(res.stdout).decision, 'block');
446
+ assert.match(JSON.parse(res.stdout).reason, /\bg\b/);
447
+
448
+ // deletion → block with check a
449
+ fs.writeFileSync(path.join(feature, taskPath), validBody);
450
+ assert.equal(runHook(gate, appCwd, payload).stdout, '');
451
+ fs.unlinkSync(path.join(feature, taskPath));
452
+ res = runHook(gate, appCwd, payload);
453
+ assert.equal(JSON.parse(res.stdout).decision, 'block');
454
+ assert.match(JSON.parse(res.stdout).reason, /\ba\b/);
455
+ // restore for next sub-test
456
+ fs.writeFileSync(path.join(feature, taskPath), validBody);
457
+ try { fs.rmSync(cacheFile, { force: true }); } catch {}
458
+ assert.equal(runHook(gate, appCwd, payload).stdout, '');
459
+
460
+ // malformed cache with valid receipt → must still pass (cache parse fail-open)
461
+ fs.writeFileSync(cacheFile, '{ malformed');
462
+ fs.writeFileSync(path.join(feature, taskPath), validBody);
463
+ res = runHook(gate, appCwd, payload);
464
+ assert.equal(res.stdout, '', 'malformed cache with valid receipt must not block');
465
+ // malformed cache with invalid receipt → must still block
466
+ fs.writeFileSync(cacheFile, '{ malformed again');
467
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
468
+ res = runHook(gate, appCwd, payload);
469
+ assert.equal(JSON.parse(res.stdout).decision, 'block', 'malformed cache with invalid receipt must block');
470
+ });
471
+ });
@@ -18,6 +18,7 @@ const {
18
18
  resolvePlatforms,
19
19
  selectLanguage
20
20
  } = require('../phases/select-platform');
21
+ const { MESSAGES } = require('../lib/i18n');
21
22
 
22
23
  const PACKAGE_ROOT = path.join(__dirname, '../..');
23
24
  const PACKAGE_VERSION = JSON.parse(
@@ -59,6 +60,12 @@ function allFiles(root, predicate) {
59
60
  return found;
60
61
  }
61
62
 
63
+ function allHookHandlers(config) {
64
+ return Object.values(config.hooks).flatMap((groups) => (
65
+ groups.flatMap((group) => group.hooks)
66
+ ));
67
+ }
68
+
62
69
  function parseGeneratedTomlString(content, key) {
63
70
  const match = content.match(new RegExp(`^${key} = (.+)$`, 'm'));
64
71
  assert.ok(match, `missing TOML key: ${key}`);
@@ -67,13 +74,13 @@ function parseGeneratedTomlString(content, key) {
67
74
 
68
75
  test('Codex payload transform emits native skill and subagent syntax', () => {
69
76
  const transformed = normalizeCodexBody(
70
- 'Agent(subagent_type="god-developer", prompt="Implement it", description="Code Feature")\n' +
77
+ 'Agent(subagent_type="implementer", prompt="Implement it", description="Code Feature")\n' +
71
78
  'Use `/specs auth`, `SendMessage`, `Bash`, `Read`, and `Edit`.'
72
79
  );
73
80
 
74
81
  assert.match(
75
82
  transformed,
76
- /spawn_agent\(agent_type="god_developer", fork_turns="none", message="Implement it", task_name="code_feature"\)/
83
+ /spawn_agent\(agent_type="implementer", fork_turns="none", message="Implement it", task_name="code_feature"\)/
77
84
  );
78
85
  assert.match(transformed, /\$hapo-specs auth/);
79
86
  assert.match(transformed, /`send_message`/);
@@ -196,6 +203,114 @@ test('same-version install can add Codex beside an existing runtime', async () =
196
203
  }
197
204
  });
198
205
 
206
+ test('resolvePlatforms interactive prompts to add more platforms when prior install exists', async () => {
207
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-add-platforms-'));
208
+ const originalCwd = process.cwd();
209
+ try {
210
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
211
+ fs.writeFileSync(
212
+ path.join(root, '.claude', 'cafekit.json'),
213
+ `${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
214
+ );
215
+ process.chdir(root);
216
+ const ctx = {
217
+ options: { platforms: [], forceOverwrite: false },
218
+ interactive: true,
219
+ dryRun: false,
220
+ ui: {
221
+ info() {},
222
+ confirm: async ({ message }) => {
223
+ if (message.includes('Existing platforms') || message.includes('addPlatformsPrompt')) return true;
224
+ if (message.includes('confirmAllDetected') || message.includes('existing configs')) return true;
225
+ return true;
226
+ },
227
+ select: async ({ message, options }) => {
228
+ if (message.includes('selectPlatform') || message.includes('Select platform')) {
229
+ return ['codex'];
230
+ }
231
+ return options[0].value;
232
+ },
233
+ isCancel: () => false
234
+ },
235
+ t: (key, vars) => {
236
+ const tpl = (MESSAGES.en && MESSAGES.en[key]) || key;
237
+ if (vars) return tpl.replace(/\{(\w+)\}/g, (_, name) => (vars[name] !== undefined ? String(vars[name]) : `{${name}}`));
238
+ return tpl;
239
+ }
240
+ };
241
+ await resolvePlatforms(ctx);
242
+ assert.deepEqual(ctx.platforms.sort(), ['claude', 'codex'].sort());
243
+ } finally {
244
+ process.chdir(originalCwd);
245
+ fs.rmSync(root, { recursive: true, force: true });
246
+ }
247
+ });
248
+
249
+ test('resolvePlatforms interactive keeps existing platforms when user declines to add more', async () => {
250
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-keep-platforms-'));
251
+ const originalCwd = process.cwd();
252
+ try {
253
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
254
+ fs.writeFileSync(
255
+ path.join(root, '.claude', 'cafekit.json'),
256
+ `${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
257
+ );
258
+ process.chdir(root);
259
+ const ctx = {
260
+ options: { platforms: [], forceOverwrite: false },
261
+ interactive: true,
262
+ dryRun: false,
263
+ ui: {
264
+ info() {},
265
+ confirm: async ({ message }) => {
266
+ // Check that the message is properly rendered without {names} placeholder
267
+ // If i18n uses {existing} but ctx passes {names}, the rendered message will contain {existing}
268
+ if (message.includes('Existing platforms') || message.includes('既存プラットフォーム') || message.includes('Nền tảng hiện có')) {
269
+ assert.ok(!message.includes('{'), `Message should not contain unrendered placeholder: ${message}`);
270
+ assert.ok(!message.includes('}'), `Message should not contain unrendered placeholder: ${message}`);
271
+ return false; // decline to add more
272
+ }
273
+ return true; // confirmAllDetected
274
+ },
275
+ select: async () => ['claude'],
276
+ isCancel: () => false
277
+ },
278
+ t: (key, vars) => {
279
+ const tpl = (MESSAGES.en && MESSAGES.en[key]) || key;
280
+ if (vars) return tpl.replace(/\{(\w+)\}/g, (_, name) => (vars[name] !== undefined ? String(vars[name]) : `{${name}}`));
281
+ return tpl;
282
+ }
283
+ };
284
+ await resolvePlatforms(ctx);
285
+ assert.deepEqual(ctx.platforms, ['claude']);
286
+ } finally {
287
+ process.chdir(originalCwd);
288
+ fs.rmSync(root, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ test('addPlatformsPrompt placeholder consistency across all locales', () => {
293
+ const expectedPlaceholders = ['names'];
294
+ const placeholderRegex = /\{(\w+)\}/g;
295
+
296
+ for (const [locale, messages] of Object.entries(MESSAGES)) {
297
+ const template = messages.addPlatformsPrompt;
298
+ assert.ok(template, `Missing addPlatformsPrompt key in locale: ${locale}`);
299
+
300
+ const placeholders = [];
301
+ let match;
302
+ while ((match = placeholderRegex.exec(template)) !== null) {
303
+ placeholders.push(match[1]);
304
+ }
305
+
306
+ assert.deepEqual(
307
+ placeholders,
308
+ expectedPlaceholders,
309
+ `Locale ${locale} addPlatformsPrompt has incorrect placeholders. Template: "${template}". Expected: ${JSON.stringify(expectedPlaceholders)}, Got: ${JSON.stringify(placeholders)}`
310
+ );
311
+ }
312
+ });
313
+
199
314
  test('same-version non-interactive install performs a selective refresh', async () => {
200
315
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-version-'));
201
316
  const originalCwd = process.cwd();
@@ -316,6 +431,64 @@ test('Codex dry-run leaves both managed roots untouched', () => {
316
431
  });
317
432
  });
318
433
 
434
+ test('Codex Windows hook launchers stay project-bound without Git from nested cwd', () => {
435
+ inTempProject((root) => {
436
+ const projectRoot = path.join(root, 'project with spaces');
437
+ fs.mkdirSync(projectRoot, { recursive: true });
438
+ const installed = install(projectRoot);
439
+ assert.equal(installed.status, 0, `${installed.stdout}\n${installed.stderr}`);
440
+
441
+ const config = JSON.parse(
442
+ fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8')
443
+ );
444
+ const handlers = allHookHandlers(config);
445
+ assert.equal(handlers.length, 14);
446
+ for (const handler of handlers) {
447
+ assert.doesNotMatch(handler.commandWindows, /\$\(/);
448
+ assert.doesNotMatch(handler.commandWindows, /\bgit\b/i);
449
+ assert.doesNotMatch(handler.commandWindows, /process\.cwd\(\)|existsSync/);
450
+ const encodedPath = handler.commandWindows.match(/\s([A-Za-z0-9_-]+)$/)?.[1];
451
+ assert.ok(encodedPath, `missing encoded hook path in: ${handler.commandWindows}`);
452
+ const target = Buffer.from(encodedPath, 'base64url').toString('utf8');
453
+ assert.equal(path.dirname(target), fs.realpathSync(path.join(projectRoot, '.codex', 'hooks')));
454
+ assert.equal(
455
+ fs.existsSync(target),
456
+ true,
457
+ `missing installed hook: ${path.basename(target)}`
458
+ );
459
+ }
460
+
461
+ const nested = path.join(projectRoot, 'nested', 'workspace');
462
+ fs.mkdirSync(nested, { recursive: true });
463
+ const shadowHooks = path.join(projectRoot, 'nested', '.codex', 'hooks');
464
+ const shadowMarker = path.join(root, 'shadow-hook-ran');
465
+ fs.mkdirSync(shadowHooks, { recursive: true });
466
+ fs.writeFileSync(
467
+ path.join(shadowHooks, 'session.cjs'),
468
+ `require('node:fs').writeFileSync(${JSON.stringify(shadowMarker)}, 'unsafe')\n`
469
+ );
470
+ const session = config.hooks.SessionStart[0].hooks[0];
471
+ const nodeCommand = session.commandWindows.replace(/^node /, `"${process.execPath}" `);
472
+ const noGitEnv = { ...process.env, PATH: '' };
473
+ const launched = spawnSync(nodeCommand, {
474
+ cwd: nested,
475
+ encoding: 'utf8',
476
+ input: JSON.stringify({
477
+ session_id: 'windows-launcher-test',
478
+ cwd: nested,
479
+ hook_event_name: 'SessionStart',
480
+ source: 'startup'
481
+ }),
482
+ env: noGitEnv,
483
+ shell: true
484
+ });
485
+ assert.equal(launched.status, 0, launched.stderr);
486
+ assert.match(launched.stdout, /Session startup\./);
487
+ assert.match(launched.stdout, /CafeKit project root:/);
488
+ assert.equal(fs.existsSync(shadowMarker), false);
489
+ });
490
+ });
491
+
319
492
  test('Codex install is project-local, native, and upgrade-safe', () => {
320
493
  inTempProject((root) => {
321
494
  const userInstructions = '# User rules\n\nKeep this exact.\n';
@@ -456,3 +629,58 @@ test('Codex install is project-local, native, and upgrade-safe', () => {
456
629
  assert.doesNotMatch(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
457
630
  });
458
631
  });
632
+
633
+ test('Codex install on top of existing Claude installation preserves content and adds Codex', () => {
634
+ inTempProject((root) => {
635
+ // Setup existing Claude installation with content
636
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
637
+ fs.writeFileSync(
638
+ path.join(root, '.claude', 'cafekit.json'),
639
+ `${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
640
+ );
641
+
642
+ // Existing CLAUDE.md with content
643
+ const claudeMdContent = '# CLAUDE.md\n\n## User Instructions\n\nThis is my existing CLAUDE.md content.\n';
644
+ fs.writeFileSync(path.join(root, 'CLAUDE.md'), claudeMdContent);
645
+
646
+ // Existing AGENTS.md with content
647
+ const agentsMdContent = '# AGENTS.md\n\n## User Rules\n\nKeep this exact.\n';
648
+ fs.writeFileSync(path.join(root, 'AGENTS.md'), agentsMdContent);
649
+
650
+ // Run Codex install
651
+ const result = install(root, ['--platform', 'codex']);
652
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
653
+
654
+ // Assert .claude/ and its content still exist
655
+ assert.equal(fs.existsSync(path.join(root, '.claude')), true, '.claude should exist');
656
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'cafekit.json')), true, '.claude/cafekit.json should exist');
657
+ const claudeMetadata = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'cafekit.json'), 'utf8'));
658
+ assert.equal(claudeMetadata.platform, 'claude', '.claude/cafekit.json should still be claude platform');
659
+
660
+ // Assert .codex/ is created with payload
661
+ assert.equal(fs.existsSync(path.join(root, '.codex')), true, '.codex should be created');
662
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks.json')), true, '.codex/hooks.json should exist');
663
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'runtime.json')), true, '.codex/runtime.json should exist');
664
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks', 'privacy-block.cjs')), true, '.codex/hooks/privacy-block.cjs should exist');
665
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'rules', 'workflow.md')), true, '.codex/rules/workflow.md should exist');
666
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'scripts', 'validate-spec-output.cjs')), true, '.codex/scripts/validate-spec-output.cjs should exist');
667
+ assert.equal(fs.existsSync(path.join(root, '.agents', '.gitignore')), true, '.agents/.gitignore should exist');
668
+ assert.equal(fs.existsSync(path.join(root, '.agents', 'skills', 'specs', 'SKILL.md')), true, '.agents/skills/specs/SKILL.md should exist');
669
+
670
+ // Assert .codex/cafekit.json has platform codex
671
+ const codexMetadata = JSON.parse(fs.readFileSync(path.join(root, '.codex', 'cafekit.json'), 'utf8'));
672
+ assert.equal(codexMetadata.platform, 'codex', '.codex/cafekit.json should have platform codex');
673
+ assert.equal(codexMetadata.version, PACKAGE_VERSION, '.codex/cafekit.json should have current version');
674
+
675
+ // Assert AGENTS.md root contains CODEX markers AND preserves original content
676
+ const agentsMd = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
677
+ assert.match(agentsMd, /<!-- CAFEKIT CODEX START -->/, 'AGENTS.md should have CODEX START marker');
678
+ assert.match(agentsMd, /<!-- CAFEKIT CODEX END -->/, 'AGENTS.md should have CODEX END marker');
679
+ assert.match(agentsMd, /Keep this exact\./, 'AGENTS.md should preserve original user content');
680
+ assert.ok(agentsMd.startsWith(agentsMdContent), 'AGENTS.md should start with original user content');
681
+
682
+ // Assert CLAUDE.md is unchanged
683
+ const claudeMd = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8');
684
+ assert.equal(claudeMd, claudeMdContent, 'CLAUDE.md should be unchanged');
685
+ });
686
+ });