@haposoft/cafekit 0.18.1 → 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 (93) 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 +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  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 +1 -1
  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-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -0,0 +1,136 @@
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 { spawnSync } = require('node:child_process');
8
+ const { test } = require('node:test');
9
+ const SOURCE = path.resolve(__dirname, '../../src');
10
+
11
+ function fixture(run) {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-native-boundaries-'));
13
+ const hooks = path.join(root, '.codex/hooks');
14
+ try {
15
+ fs.cpSync(path.join(SOURCE, 'codex/hooks'), hooks, { recursive: true });
16
+ for (const name of ['runtime-path-safety', 'privacy-command-analysis', 'privacy-paths', 'secret-keywords', 'inspect-paths', 'update-check']) {
17
+ fs.copyFileSync(path.join(SOURCE, `claude/hooks/lib/${name}.cjs`), path.join(hooks, `lib/${name}.cjs`));
18
+ }
19
+ fs.cpSync(path.join(SOURCE, 'claude/scripts'), path.join(root, '.codex/scripts'), { recursive: true });
20
+ fs.writeFileSync(path.join(root, '.gitignore'), '.codex/\n');
21
+ for (const args of [['init', '-q'], ['config', 'user.email', 'probe@example.invalid'], ['config', 'user.name', 'Probe'], ['add', '.gitignore'], ['commit', '-qm', 'base']]) {
22
+ assert.equal(spawnSync('git', ['-C', root, ...args]).status, 0);
23
+ }
24
+ const hook = (name, payload = {}) => spawnSync(process.execPath, [path.join(hooks, `${name}.cjs`)], {
25
+ cwd: root, input: JSON.stringify({ cwd: root, session_id: 'a', ...payload }), encoding: 'utf8',
26
+ });
27
+ run({ root, hooks, hook });
28
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
29
+ }
30
+ function task(root, name, status = 'pending', receipt = '') {
31
+ const dir = path.join(root, 'specs/demo'); 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, name), `# Task\nStatus: ${status}\n## Dependencies\n- none\n## Verification Plan\n- Command: node --test\n${receipt}`);
34
+ }
35
+
36
+ test('privacy walks nested operation arrays but ignores prose', () => fixture(({ hook }) => {
37
+ for (const tool_input of [{ operations: [{ path: 'credentials.json' }] }, { operations: [[{ file_path: 'credentials.json' }]] }, { paths: ['credentials.json'] }]) {
38
+ const result = hook('privacy-block', { tool_name: 'mcp__files__read', tool_input });
39
+ assert.match(result.stdout, /APPROVE CAFEKIT PRIVACY/);
40
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason, /y \(or yes \/ có\)/);
41
+ }
42
+ assert.equal(hook('privacy-block', { tool_name: 'mcp__files__read', tool_input: { messages: ['credentials.json', { text: 'credentials.json' }] } }).stdout, '');
43
+ }));
44
+
45
+ test('privacy-block allows ls when configured specs escapes the project', () => fixture(({ root, hook }) => {
46
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
47
+ const result = hook('privacy-block', { tool_name: 'Bash', tool_input: { command: 'ls' } });
48
+ assert.equal(result.status, 0);
49
+ assert.equal(result.stdout, '');
50
+ }));
51
+
52
+ test('privacy-block issues approval for a sensitive read when configured specs escapes the project', () => fixture(({ root, hook }) => {
53
+ fs.writeFileSync(path.join(root, '.env'), '');
54
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
55
+ const result = hook('privacy-block', { tool_name: 'Bash', tool_input: { command: 'cat .env' } });
56
+ assert.equal(result.status, 0);
57
+ const output = JSON.parse(result.stdout).hookSpecificOutput;
58
+ assert.equal(output.permissionDecision, 'deny');
59
+ assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY [a-f0-9]{24}/);
60
+ assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
61
+ }));
62
+
63
+ test('privacy-approval grants a pending read when configured specs escapes the project', () => fixture(({ root, hook }) => {
64
+ fs.writeFileSync(path.join(root, '.env'), '');
65
+ const payload = { tool_name: 'Bash', tool_input: { command: 'cat .env' } };
66
+ const pending = hook('privacy-block', payload);
67
+ assert.equal(pending.status, 0);
68
+ const approval = pending.stdout.match(/APPROVE CAFEKIT PRIVACY [a-f0-9]{24}/);
69
+ assert.ok(approval, 'the sensitive read must issue a pending approval');
70
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
71
+ const granted = hook('privacy-approval', { prompt: approval[0] });
72
+ assert.equal(granted.status, 0);
73
+ assert.match(granted.stdout, /User approved the pending sensitive access/);
74
+ const allowed = hook('privacy-block', payload);
75
+ assert.equal(allowed.status, 0);
76
+ assert.equal(allowed.stdout, '');
77
+ }));
78
+
79
+ test('session startup revokes only its own pending requests and grants', () => fixture(({ root, hooks, hook }) => {
80
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
81
+ const request = { projectRoot: root, sessionCwd: root, sessionId: 'a', filePath: 'credentials.json' };
82
+ const grant = privacy.createPending(request);
83
+ assert.equal(privacy.approvePending({ ...request, requestId: grant.requestId }).ok, true);
84
+ const pending = privacy.createPending(request);
85
+ for (const session_id of ['b', undefined]) {
86
+ assert.equal(hook('session', { session_id, hook_event_name: 'SessionStart', source: 'compact' }).status, 0);
87
+ assert.equal(privacy.hasGrant(request), true);
88
+ assert.ok(fs.existsSync(path.join(hooks, `.privacy/pending-${pending.requestId}.json`)));
89
+ }
90
+ assert.equal(hook('session', { hook_event_name: 'SessionStart', source: 'resume' }).status, 0);
91
+ assert.equal(privacy.hasGrant(request), false);
92
+ assert.equal(privacy.approvePending({ ...request, requestId: pending.requestId }).ok, false);
93
+ }));
94
+
95
+ test('cache persistence failure does not replace receipt authority', () => fixture(({ root, hooks, hook }) => {
96
+ task(root, 'task-01-work.md');
97
+ fs.mkdirSync(path.join(hooks, '.logs/spec-gate-last.json'), { recursive: true });
98
+ assert.equal(hook('spec-gate', { hook_event_name: 'Stop' }).stdout, '');
99
+ task(root, 'task-01-work.md', 'done');
100
+ const result = hook('spec-gate', { hook_event_name: 'Stop', explicitFeature: 'demo' });
101
+ assert.match(JSON.parse(result.stdout).reason, /lack a verification receipt/);
102
+ assert.doesNotMatch(result.stdout, /EISDIR|controlled failure/);
103
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{"spec":{"completion_gate":false}}');
104
+ assert.match(hook('spec-gate', { hook_event_name: 'Stop' }).stdout, /no completion-gate bypass/);
105
+ }));
106
+
107
+ test('build output directories are allowed while inspection commands stay blocked', () => fixture(({ hook }) => {
108
+ for (const command of ['vite build --outDir ./dist', 'tsc --outDir ./build', 'npm run build -- --outDir ./dist', 'cargo build --target-dir ./target']) {
109
+ assert.equal(hook('inspect-block', { tool_name: 'exec_command', tool_input: { command } }).status, 0, command);
110
+ }
111
+ for (const command of ['cat dist/index.js', 'find ./dist', 'grep x dist/index.js', 'vite build --outDir ./dist && cat dist/index.js', 'npm exec cat ./dist/index.js', 'vite build --outDir ./dist $(cat dist/index.js)']) {
112
+ assert.equal(hook('inspect-block', { tool_name: 'exec_command', tool_input: { command } }).status, 2, command);
113
+ }
114
+ assert.equal(hook('inspect-block', { tool_input: { path: './dist/index.js' } }).status, 2);
115
+ }));
116
+
117
+ test('one gate invocation captures one stable snapshot for multiple receipts and rechecks next run', () => fixture(({ root, hook }) => {
118
+ task(root, 'task-06-pending.md');
119
+ const provenanceFile = path.join(root, '.codex/scripts/provenance.cjs');
120
+ const trace = path.join(root, '.codex/manifest-count');
121
+ fs.writeFileSync(provenanceFile, fs.readFileSync(provenanceFile, 'utf8').replace('function manifest(root, specsRoot) {', `function manifest(root, specsRoot) { fs.appendFileSync(${JSON.stringify(trace)}, 'x');`));
122
+ const provenance = require(provenanceFile);
123
+ const context = provenance.deriveRuntimeContext({ projectRoot: root, specsRoot: path.join(root, 'specs'), specFile: path.join(root, 'specs/demo/plan.md'), featureName: 'demo', runtimeSession: 'a' });
124
+ const receipt = `## Receipt\nVerification: PASS\nCommand: node --test\nExit: 0\nBase: ${context.base}\nHead: ${context.head}\n\`\`\`text\nnode --test\n1 test passed\n\`\`\`\n`;
125
+ for (let n = 1; n <= 5; n += 1) task(root, `task-0${n}-work.md`, 'done', receipt);
126
+ for (const args of [['add', 'specs'], ['commit', '-qm', 'packet']]) assert.equal(spawnSync('git', ['-C', root, ...args]).status, 0);
127
+ fs.writeFileSync(trace, '');
128
+ assert.equal(hook('spec-gate', { hook_event_name: 'Stop' }).stdout, '');
129
+ assert.equal(fs.readFileSync(trace, 'utf8').length, 2);
130
+ fs.writeFileSync(path.join(root, 'changed.js'), 'changed');
131
+ fs.appendFileSync(path.join(root, 'specs/demo/task-01-work.md'), '\n');
132
+ const blocked = hook('spec-gate', { hook_event_name: 'Stop' }).stdout;
133
+ assert.match(blocked, /provenance/);
134
+ assert.match(blocked, /lines are present but are not the pair/);
135
+ assert.equal(fs.readFileSync(trace, 'utf8').length, 4);
136
+ }));
@@ -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
@@ -2248,7 +2380,7 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2248
2380
  const questionSkill = path.join(root, '.agents', 'skills', 'ask', 'SKILL.md');
2249
2381
  fs.appendFileSync(questionSkill, '\nUSER-CODEX-SENTINEL\n');
2250
2382
  // 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.
2383
+ // Untracked obsolete files and edited current skills both stay on refresh.
2252
2384
  const obsoleteSpecsRule = path.join(
2253
2385
  root, '.agents', 'skills', 'specs', 'rules', 'design-principles.md'
2254
2386
  );
@@ -2261,8 +2393,8 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2261
2393
  assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
2262
2394
  assert.equal(
2263
2395
  fs.existsSync(obsoleteSpecsRule),
2264
- false,
2265
- 'Codex refresh removes obsolete skill files'
2396
+ true,
2397
+ 'Codex refresh preserves untracked obsolete skill files'
2266
2398
  );
2267
2399
 
2268
2400
  const metadataPath = path.join(root, '.codex', 'cafekit.json');
@@ -2277,9 +2409,11 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
2277
2409
  assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
2278
2410
  assert.equal(
2279
2411
  fs.existsSync(obsoleteSpecsRule),
2280
- false,
2281
- 'Codex upgrade removes obsolete skill files'
2412
+ true,
2413
+ 'Codex upgrade preserves untracked obsolete skill files'
2282
2414
  );
2415
+ assert.match(sameVersion.stdout + sameVersion.stderr, /Preserved 1 obsolete runtime/);
2416
+ assert.match(upgrade.stdout + upgrade.stderr, /Preserved 1 obsolete runtime/);
2283
2417
  assert.ok(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8').startsWith(userInstructions));
2284
2418
 
2285
2419
  const forced = install(root, ['--force-overwrite']);
@@ -2509,7 +2643,7 @@ test('Codex installed Brainstorm skill reference and agent preserve proportional
2509
2643
  {
2510
2644
  name: 'decision-provenance-removed',
2511
2645
  source: 'framework',
2512
- from: 'Do not write "user selected" unless direct user text or the native input tool\n confirms it.',
2646
+ from: 'Do not write "user selected" unless direct user text or the question in the turn\'s final answer\n confirms it.',
2513
2647
  to: 'Write "user selected" for inferred defaults.',
2514
2648
  expected: ['approval-persistence']
2515
2649
  },
@@ -2517,7 +2651,7 @@ test('Codex installed Brainstorm skill reference and agent preserve proportional
2517
2651
  name: 'installed-grammar-corruption',
2518
2652
  source: 'skill',
2519
2653
  from: '## Completion bar',
2520
- to: 'one a structured user-input request call\n\n## Completion bar',
2654
+ to: 'one a question in the turn\'s final answer call\n\n## Completion bar',
2521
2655
  expected: ['projection-grammar']
2522
2656
  }
2523
2657
  ];
@@ -3079,28 +3213,28 @@ test('Codex installed code_auditor carries no Strict attestation marker', () =>
3079
3213
  });
3080
3214
  });
3081
3215
 
3082
- test('reinstall removes obsolete scripts and skill files from Codex and omp installs', () => {
3083
- inTempProject((root) => {
3216
+ test('reinstall preserves untracked Codex runtime files while omp keeps its cleanup policy', () => {
3217
+ for (const platform of ['codex', 'omp']) inTempProject(root => {
3084
3218
  const first = installPlatforms(root, ['codex', 'omp']);
3085
3219
  assert.equal(first.status, 0, `${first.stdout}\n${first.stderr}`);
3220
+ const runtime = `.${platform}`;
3086
3221
  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
- }
3090
- 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}`);
3222
+ for (const name of retired) fs.writeFileSync(path.join(root, runtime, 'scripts', name), 'legacy\n');
3223
+ const retiredSkillFiles = platform === 'codex'
3224
+ ? ['code-review/references/verification-gate.md', 'specs/references/archive-workflow.md'] : [];
3094
3225
  for (const rel of retiredSkillFiles) {
3095
- assert.equal(fs.existsSync(path.join(root, '.agents', 'skills', rel)), false, `.agents/skills/${rel} must be removed`);
3096
- }
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`);
3226
+ const target = path.join(root, '.agents/skills', rel);
3227
+ fs.mkdirSync(path.dirname(target), { recursive: true });
3228
+ fs.writeFileSync(target, 'legacy\n');
3103
3229
  }
3230
+ const again = installPlatforms(root, [platform], ['--force-overwrite']);
3231
+ assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
3232
+ const preserved = platform === 'codex';
3233
+ for (const rel of retiredSkillFiles) assert.equal(fs.existsSync(path.join(root, '.agents/skills', rel)), preserved, rel);
3234
+ for (const name of retired) assert.equal(fs.existsSync(path.join(root, runtime, 'scripts', name)), preserved, name);
3235
+ if (preserved) assert.match(again.stdout + again.stderr, /Preserved 4 obsolete runtime/);
3236
+ assert.equal(fs.existsSync(path.join(root, '.agents/skills/code-review/SKILL.md')), true);
3237
+ assert.equal(fs.existsSync(path.join(root, runtime, 'scripts/spec-resolver.cjs')), true);
3104
3238
  });
3105
3239
  });
3106
3240
 
@@ -3222,4 +3356,3 @@ test('Claude and Codex installed rules preserve the ported review and process gu
3222
3356
  }
3223
3357
  });
3224
3358
  });
3225
-