@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
@@ -0,0 +1,713 @@
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
+
10
+ const ROOT = path.resolve(__dirname, '../..');
11
+ const VALIDATOR = path.join(ROOT, 'src/claude/scripts/validate-spec-output.cjs');
12
+ const GROUNDING = path.join(ROOT, 'src/claude/scripts/spec-ground.cjs');
13
+
14
+ function write(filePath, content) {
15
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
+ fs.writeFileSync(filePath, content);
17
+ }
18
+
19
+ function run(script, args, cwd) {
20
+ return spawnSync(process.execPath, [script, ...args], { cwd, encoding: 'utf8' });
21
+ }
22
+
23
+ function task({ file = 'task-R1-01-one.md', mapping = '1.1', related = '`src/one.js` | Modify', extra = '' } = {}) {
24
+ return `# Task R1-01: One\n\n## Context\n- Existing source.\n\n## Constraints\n- Keep scope.\n\n## Steps\n- [ ] Update source\n - _Requirements: ${mapping}_\n\n## Requirements\n- 1.1 — One behavior\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| ${related} | Source | test |\n\n## Completion Criteria\n- [ ] Behavior works.\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/one.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n${extra}`;
25
+ }
26
+
27
+ function createSpec(root, options = {}) {
28
+ const specDir = path.join(root, options.name || 'spec');
29
+ const taskPath = 'tasks/task-R1-01-one.md';
30
+ write(path.join(specDir, 'requirements.md'), options.requirements || '# Requirements\n\n### Requirement 1: One\n\n- **R1.1** Do one thing.\n');
31
+ write(path.join(specDir, 'design.md'), options.design || '# Design\n');
32
+ write(path.join(specDir, 'research.md'), '# Research\n\n## Evidence Summary\n- Internal evidence.\n');
33
+ write(path.join(specDir, taskPath), options.task || task());
34
+ write(path.join(specDir, 'spec.json'), JSON.stringify({
35
+ schema_version: '2.0',
36
+ feature_name: 'fixture',
37
+ status: 'in_progress',
38
+ current_phase: 'tasks',
39
+ scope_lock: { source: 'fixture', in_scope: ['1'], out_of_scope: [], expansion_policy: 'requires-user-approval' },
40
+ task_files: [taskPath],
41
+ task_registry: {
42
+ [taskPath]: {
43
+ id: 'R1-01', title: 'One', status: 'pending', dependencies: [], blocker: null,
44
+ started_at: null, completed_at: null, last_updated_at: null,
45
+ },
46
+ },
47
+ approvals: { requirements: { generated: true, agent_validated: true, user_approved: true }, design: { generated: true, agent_validated: true, user_approved: true }, tasks: { generated: true, agent_validated: true, user_approved: true } },
48
+ validation: { status: 'not-run' },
49
+ ready_for_implementation: false,
50
+ ...(options.spec || {}),
51
+ }, null, 2));
52
+ return specDir;
53
+ }
54
+
55
+ function output(result) {
56
+ return `${result.stdout}\n${result.stderr}`;
57
+ }
58
+
59
+ test('validator rejects missing explicit requirement and sub-criterion mappings', () => {
60
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-mapping-'));
61
+ try {
62
+ const specDir = createSpec(root, { task: task({ mapping: '' }) });
63
+ const result = run(VALIDATOR, [specDir], ROOT);
64
+ assert.notEqual(result.status, 0);
65
+ assert.match(output(result), /R1: not covered|R1\.1: acceptance criterion not covered/);
66
+
67
+ const incidental = createSpec(root, {
68
+ name: 'incidental',
69
+ task: task({ mapping: '', file: 'task-R1-01-one.md' }).replace('Update source', 'Update R1 source'),
70
+ });
71
+ const incidentalResult = run(VALIDATOR, [incidental], ROOT);
72
+ assert.notEqual(incidentalResult.status, 0);
73
+ assert.match(output(incidentalResult), /R1: not covered/);
74
+
75
+ const partial = createSpec(root, {
76
+ name: 'partial',
77
+ requirements: '# Requirements\n\n### Requirement 1: One\n\n- **R1.1** First.\n- **R1.2** Second.\n',
78
+ });
79
+ const partialResult = run(VALIDATOR, [partial], ROOT);
80
+ assert.notEqual(partialResult.status, 0);
81
+ assert.match(output(partialResult), /R1\.2: acceptance criterion not covered/);
82
+ } finally {
83
+ fs.rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ test('validator rejects unknown, missing, and divergent contract copies', () => {
88
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-contract-'));
89
+ const design = '# Design\n\n<!-- contract:PAYLOAD -->\n```json\n{ "id": 1 }\n```\n';
90
+ try {
91
+ const unknown = createSpec(root, {
92
+ name: 'unknown', design,
93
+ task: task({ extra: 'Contracts: MISSING\n' }),
94
+ });
95
+ let result = run(VALIDATOR, [unknown], ROOT);
96
+ assert.notEqual(result.status, 0);
97
+ assert.match(output(result), /unknown contract "MISSING"/);
98
+
99
+ const noCanonical = createSpec(root, {
100
+ name: 'no-canonical',
101
+ task: task({ extra: 'Contracts: PAYLOAD\n' }),
102
+ });
103
+ result = run(VALIDATOR, [noCanonical], ROOT);
104
+ assert.notEqual(result.status, 0);
105
+ assert.match(output(result), /defines no canonical contract blocks/);
106
+
107
+ const missing = createSpec(root, {
108
+ name: 'missing', design,
109
+ task: task({ extra: 'Contracts: PAYLOAD\n' }),
110
+ });
111
+ result = run(VALIDATOR, [missing], ROOT);
112
+ assert.notEqual(result.status, 0);
113
+ assert.match(output(result), /contract "PAYLOAD" is missing/);
114
+
115
+ const divergent = createSpec(root, {
116
+ name: 'divergent', design,
117
+ task: task({ extra: 'Contracts: PAYLOAD\n\n```json\n{ "id": 2 }\n```\n' }),
118
+ });
119
+ result = run(VALIDATOR, [divergent], ROOT);
120
+ assert.notEqual(result.status, 0);
121
+ assert.match(output(result), /contract "PAYLOAD" body diverges/);
122
+ } finally {
123
+ fs.rmSync(root, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('validator rejects invalid ready approval and validation transitions', () => {
128
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-state-'));
129
+ try {
130
+ const specDir = createSpec(root, {
131
+ spec: {
132
+ ready_for_implementation: true,
133
+ approvals: { requirements: { generated: false, agent_validated: true, user_approved: true }, design: { generated: true, agent_validated: false, user_approved: true }, tasks: { generated: true, agent_validated: true, user_approved: false } },
134
+ validation: { status: 'not-run' },
135
+ },
136
+ });
137
+ const result = run(VALIDATOR, [specDir], ROOT);
138
+ assert.notEqual(result.status, 0);
139
+ assert.match(output(result), /requires generated and agent_validated and user_approved requirements evidence/);
140
+ assert.match(output(result), /requires generated and agent_validated and user_approved design evidence/);
141
+ assert.match(output(result), /validation evidence is incomplete/);
142
+
143
+ // Legacy approved field must fail closed with migration guidance
144
+ const legacyDir = createSpec(root, {
145
+ name: 'legacy',
146
+ spec: {
147
+ schema_version: undefined,
148
+ approvals: { requirements: { generated: true, approved: true }, design: { generated: true, agent_validated: true, user_approved: true }, tasks: { generated: true, agent_validated: true, user_approved: true } },
149
+ },
150
+ });
151
+ // Remove schema_version to simulate legacy
152
+ const legacySpecPath = path.join(legacyDir, 'spec.json');
153
+ const legacySpec = JSON.parse(fs.readFileSync(legacySpecPath, 'utf8'));
154
+ delete legacySpec.schema_version;
155
+ fs.writeFileSync(legacySpecPath, JSON.stringify(legacySpec, null, 2));
156
+ const legacyResult = run(VALIDATOR, [legacyDir], ROOT);
157
+ assert.notEqual(legacyResult.status, 0);
158
+ assert.match(output(legacyResult), /Legacy approval field "approved"/);
159
+
160
+ // Unsupported schema_version must fail closed
161
+ const unsupportedDir = createSpec(root, {
162
+ name: 'unsupported',
163
+ spec: { schema_version: '9.9' },
164
+ });
165
+ const unsupportedResult = run(VALIDATOR, [unsupportedDir], ROOT);
166
+ assert.notEqual(unsupportedResult.status, 0);
167
+ assert.match(output(unsupportedResult), /unsupported "9\.9"/);
168
+ } finally {
169
+ fs.rmSync(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ test('validator rejects malformed Related Files and dependency topology', () => {
174
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-shape-'));
175
+ try {
176
+ for (const [name, related, expected] of [
177
+ ['empty', '', /Related Files section must not be empty/],
178
+ ['action', '`src/one.js` | Rename', /unsupported Related Files action/],
179
+ ['absolute', '`\/tmp\/one.js` | Modify', /must be relative/],
180
+ ['traversal', '`..\/one.js` | Modify', /must be relative/],
181
+ ]) {
182
+ const specDir = createSpec(root, {
183
+ name,
184
+ task: related ? task({ related }) : task({ related: 'Path | Action | Description\n|---|---|---|' }),
185
+ });
186
+ const result = run(VALIDATOR, [specDir], ROOT);
187
+ assert.notEqual(result.status, 0);
188
+ assert.match(output(result), expected);
189
+ }
190
+
191
+ const cycle = createSpec(root, { name: 'cycle' });
192
+ const statePath = path.join(cycle, 'spec.json');
193
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
194
+ state.task_files.push('tasks/task-R1-02-two.md');
195
+ state.task_registry['tasks/task-R1-01-one.md'].dependencies = ['tasks/task-R1-02-two.md'];
196
+ state.task_registry['tasks/task-R1-02-two.md'] = { id: 'R1-02', title: 'Two', status: 'pending', dependencies: ['tasks/task-R1-01-one.md'], blocker: null, started_at: null, completed_at: null, last_updated_at: null };
197
+ write(path.join(cycle, 'tasks/task-R1-02-two.md'), task({ related: '`src/two.js` | Modify' }));
198
+ write(statePath, JSON.stringify(state, null, 2));
199
+ const cycleResult = run(VALIDATOR, [cycle], ROOT);
200
+ assert.notEqual(cycleResult.status, 0);
201
+ assert.match(output(cycleResult), /dependency cycle detected/);
202
+
203
+ const ordering = createSpec(root, {
204
+ name: 'ordering',
205
+ task: task({ related: '`src/generated.js` | Create' }),
206
+ });
207
+ const orderingStatePath = path.join(ordering, 'spec.json');
208
+ const orderingState = JSON.parse(fs.readFileSync(orderingStatePath, 'utf8'));
209
+ orderingState.task_files.push('tasks/task-R1-02-two.md');
210
+ orderingState.task_registry['tasks/task-R1-02-two.md'] = { id: 'R1-02', title: 'Two', status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null };
211
+ write(path.join(ordering, 'tasks/task-R1-02-two.md'), task({ related: '`src/generated.js` | Modify' }));
212
+ write(orderingStatePath, JSON.stringify(orderingState, null, 2));
213
+ const orderingResult = run(VALIDATOR, [ordering], ROOT);
214
+ assert.notEqual(orderingResult.status, 0);
215
+ assert.match(output(orderingResult), /must depend on creator/);
216
+ } finally {
217
+ fs.rmSync(root, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test('grounding rejects empty sections, unsafe paths, unsupported actions, and zero-match globs', () => {
222
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-grounding-'));
223
+ try {
224
+ write(path.join(root, 'src/one.js'), 'source\n');
225
+ const cases = [
226
+ ['empty', '# Task\n\n## Related Files\n\n## Completion Criteria\n', /Related Files section must not be empty/],
227
+ ['action', task({ related: '`src/one.js` | Rename' }), /unsupported Related Files action/],
228
+ ['absolute', task({ related: '`\/tmp\/one.js` | Read' }), /must be relative/],
229
+ ['traversal', task({ related: '`..\/one.js` | Read' }), /must be relative/],
230
+ ['glob', task({ related: '`src\/missing-*.js` | Read' }), /glob matches no paths/],
231
+ ];
232
+ for (const [name, content, expected] of cases) {
233
+ const specDir = path.join(root, name);
234
+ write(path.join(specDir, 'tasks/task-R1-01-one.md'), content);
235
+ const result = run(GROUNDING, [specDir, '--root', root], ROOT);
236
+ assert.notEqual(result.status, 0);
237
+ assert.match(output(result), expected);
238
+ }
239
+
240
+ const valid = path.join(root, 'valid');
241
+ write(path.join(valid, 'tasks/task-R1-01-one.md'), task({ related: '`src/*.js` | Read' }));
242
+ const result = run(GROUNDING, [valid, '--root', root], ROOT);
243
+ assert.equal(result.status, 0, output(result));
244
+ assert.match(result.stdout, /GROUNDED/);
245
+ } finally {
246
+ fs.rmSync(root, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ test('validator enforces Read before Modify for existing implementation', () => {
251
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-lifecycle-'));
252
+ try {
253
+ // Existing file Modify without preceding Read must fail
254
+ const missingRead = createSpec(root, {
255
+ name: 'missing-read',
256
+ task: task({ related: '`src/existing.js` | Modify' }),
257
+ });
258
+ const missingResult = run(VALIDATOR, [missingRead], ROOT);
259
+ assert.notEqual(missingResult.status, 0);
260
+ assert.match(output(missingResult), /has no preceding Read/);
261
+
262
+ // Same path with Read in same task earlier row should pass
263
+ const withReadSameTask = createSpec(root, {
264
+ name: 'with-read-same',
265
+ task: `# Task R1-01: One\n\n## Context\n- Existing.\n\n## Constraints\n- Keep.\n\n## Steps\n- [ ] Update\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/existing.js\` | Read | Inspect existing |\n| \`src/existing.js\` | Modify | Update |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/existing.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`,
266
+ });
267
+ const sameResult = run(VALIDATOR, [withReadSameTask], ROOT);
268
+ assert.equal(sameResult.status, 0, output(sameResult));
269
+
270
+ // Modify should fail if Read is only in unrelated task without dependency
271
+ const depRead = createSpec(root, { name: 'dep-read' });
272
+ const depStatePath = path.join(depRead, 'spec.json');
273
+ const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8'));
274
+ depState.task_files.push('tasks/task-R1-02-two.md');
275
+ depState.task_registry['tasks/task-R1-02-two.md'] = { id: 'R1-02', title: 'Two', status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null };
276
+ // Task 1 has Read, Task 2 modifies without depending
277
+ write(path.join(depRead, 'tasks/task-R1-01-one.md'), `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Read\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/existing.js\` | Read | Inspect |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/existing.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`);
278
+ write(path.join(depRead, 'tasks/task-R1-02-two.md'), task({ related: '`src/existing.js` | Modify' }));
279
+ write(depStatePath, JSON.stringify(depState, null, 2));
280
+ const depMissingResult = run(VALIDATOR, [depRead], ROOT);
281
+ assert.notEqual(depMissingResult.status, 0);
282
+ assert.match(output(depMissingResult), /has no preceding Read/);
283
+
284
+ // If Modify task depends on Read task, should pass
285
+ depState.task_registry['tasks/task-R1-02-two.md'].dependencies = ['tasks/task-R1-01-one.md'];
286
+ write(depStatePath, JSON.stringify(depState, null, 2));
287
+ const depPass = run(VALIDATOR, [depRead], ROOT);
288
+ assert.equal(depPass.status, 0, output(depPass));
289
+ } finally {
290
+ fs.rmSync(root, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test('validator rejects reachability without concrete anchor and accepts with path', () => {
295
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-reachability-'));
296
+ try {
297
+ const noAnchor = createSpec(root, {
298
+ name: 'no-anchor',
299
+ task: task({ related: '`src/one.js` | Modify' }).replace('Runtime reachability verification\n - Entrypoint/caller: src/one.js', 'Runtime reachability verification\n - Some vague phrase'),
300
+ });
301
+ // Add a Read to avoid lifecycle error, so we can isolate reachability check
302
+ const noAnchorContent = fs.readFileSync(path.join(noAnchor, 'tasks/task-R1-01-one.md'), 'utf8')
303
+ .replace('`src/one.js` | Modify', '`src/one.js` | Read\n| `src/one.js` | Modify');
304
+ // Keep lifecycle satisfied: Read before Modify in same task
305
+ write(path.join(noAnchor, 'tasks/task-R1-01-one.md'), noAnchorContent.replace('Some vague phrase', 'Runtime reachability verification'));
306
+ // Actually construct valid task with missing concrete anchor explicitly
307
+ write(path.join(noAnchor, 'tasks/task-R1-01-one.md'), `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Update\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/one.js\` | Read | Inspect |\n| \`src/one.js\` | Modify | Update |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Note: verified somehow\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`);
308
+ const noAnchorResult = run(VALIDATOR, [noAnchor], ROOT);
309
+ assert.notEqual(noAnchorResult.status, 0);
310
+ assert.match(output(noAnchorResult), /Runtime reachability verification must reference a concrete file path/);
311
+ } finally {
312
+ fs.rmSync(root, { recursive: true, force: true });
313
+ }
314
+ });
315
+
316
+ test('validator fails closed when 5+ tasks have no contract blocks', () => {
317
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-contract-closed-'));
318
+ try {
319
+ const specDir = createSpec(root, { name: 'five-no-contract' });
320
+ const statePath = path.join(specDir, 'spec.json');
321
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
322
+ for (let i = 2; i <= 5; i += 1) {
323
+ const taskFile = `tasks/task-R1-0${i}-extra.md`;
324
+ state.task_files.push(taskFile);
325
+ state.task_registry[taskFile] = { id: `R1-0${i}`, title: `Extra ${i}`, status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null };
326
+ write(path.join(specDir, taskFile), task({ related: '`src/extra.js` | Modify', mapping: '1.1' }));
327
+ }
328
+ // Need to ensure each extra task has Read before Modify to avoid lifecycle error
329
+ for (let i = 2; i <= 5; i += 1) {
330
+ const p = path.join(specDir, `tasks/task-R1-0${i}-extra.md`);
331
+ const c = fs.readFileSync(p, 'utf8').replace('`src/extra.js` | Modify', '`src/extra.js` | Read\n| `src/extra.js` | Modify');
332
+ const updated = c.replace('Entrypoint/caller: src/one.js', 'Entrypoint/caller: src/extra.js');
333
+ write(p, updated);
334
+ }
335
+ // Also fix first task to have Read
336
+ const first = fs.readFileSync(path.join(specDir, 'tasks/task-R1-01-one.md'), 'utf8').replace('`src/one.js` | Modify', '`src/one.js` | Read\n| `src/one.js` | Modify');
337
+ write(path.join(specDir, 'tasks/task-R1-01-one.md'), first);
338
+ write(statePath, JSON.stringify(state, null, 2));
339
+ const result = run(VALIDATOR, [specDir], ROOT);
340
+ assert.notEqual(result.status, 0);
341
+ assert.match(output(result), /5\+ task spec requires contract blocks/);
342
+ } finally {
343
+ fs.rmSync(root, { recursive: true, force: true });
344
+ }
345
+ });
346
+
347
+ test('grounding treats Create on existing file as error', () => {
348
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-grounding-create-existing-'));
349
+ try {
350
+ write(path.join(root, 'src/existing.js'), 'existing\n');
351
+ const specDir = path.join(root, 'spec-create-existing');
352
+ write(path.join(specDir, 'tasks/task-R1-01-one.md'), task({ related: '`src/existing.js` | Create' }));
353
+ const result = run(GROUNDING, [specDir, '--root', root], ROOT);
354
+ assert.notEqual(result.status, 0);
355
+ assert.match(output(result), /Create path already exists/);
356
+ } finally {
357
+ fs.rmSync(root, { recursive: true, force: true });
358
+ }
359
+ });
360
+
361
+ test('validator scopes requirement mapping to structured section, incidental mention does not cover', () => {
362
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-scope-'));
363
+ try {
364
+ // Task mentions R1 in Context but has no structured _Requirements mapping — should fail
365
+ const incidentalOnly = createSpec(root, {
366
+ name: 'incidental-scope',
367
+ task: `# Task R1-01: One\n\n## Context\nMention R1 and Requirement 1 incidental.\n\n## Constraints\n- Keep\n\n## Steps\n- [ ] Do work\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/one.js\` | Read | Inspect |\n| \`src/one.js\` | Modify | Update |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/one.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`,
368
+ });
369
+ const res = run(VALIDATOR, [incidentalOnly], ROOT);
370
+ assert.notEqual(res.status, 0);
371
+ assert.match(output(res), /R1: not covered/);
372
+ } finally {
373
+ fs.rmSync(root, { recursive: true, force: true });
374
+ }
375
+ });
376
+
377
+ test('validator enforces Create before Read/Modify/Delete within same task and cross-task dependencies', () => {
378
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-create-order-'));
379
+ try {
380
+ // Same-task: Create after Read must fail (dead-code regression)
381
+ const createAfterRead = createSpec(root, {
382
+ name: 'create-after-read',
383
+ task: `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Do work\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/new.js\` | Read | Read before create |\n| \`src/new.js\` | Create | Create after read |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/new.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`,
384
+ });
385
+ const carResult = run(VALIDATOR, [createAfterRead], ROOT);
386
+ assert.notEqual(carResult.status, 0);
387
+ assert.match(output(carResult), /Create must precede read for src\/new\.js/);
388
+
389
+ // Same-task: Create after Modify must fail
390
+ const createAfterModify = createSpec(root, {
391
+ name: 'create-after-modify',
392
+ task: `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Do work\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/new.js\` | Modify | Modify before create |\n| \`src/new.js\` | Create | Create after modify |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/new.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`,
393
+ });
394
+ const camResult = run(VALIDATOR, [createAfterModify], ROOT);
395
+ assert.notEqual(camResult.status, 0);
396
+ assert.match(output(camResult), /Create must precede modify for src\/new\.js/);
397
+
398
+ // Same-task: Create before Read/Modify must pass; repeated Reads must not be rejected
399
+ const createBefore = createSpec(root, {
400
+ name: 'create-before',
401
+ task: `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Do work\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/new.js\` | Create | Create first |\n| \`src/new.js\` | Read | Read after create |\n| \`src/new.js\` | Modify | Modify after create |\n| \`src/new.js\` | Read | Second read |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/new.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`,
402
+ });
403
+ const cbResult = run(VALIDATOR, [createBefore], ROOT);
404
+ assert.equal(cbResult.status, 0, output(cbResult));
405
+
406
+ // Valid same-task repeated reads without create must pass (not rejected unnecessarily)
407
+ const repeatedReads = createSpec(root, {
408
+ name: 'repeated-reads',
409
+ task: `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Do work\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/existing.js\` | Read | First read |\n| \`src/existing.js\` | Read | Second read |\n| \`src/existing.js\` | Modify | Modify after reads |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/existing.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`,
410
+ });
411
+ const rrResult = run(VALIDATOR, [repeatedReads], ROOT);
412
+ assert.equal(rrResult.status, 0, output(rrResult));
413
+
414
+ // Cross-task: Read without dependency must fail
415
+ const crossRead = createSpec(root, { name: 'cross-read-base' });
416
+ const crossReadStatePath = path.join(crossRead, 'spec.json');
417
+ const crossReadState = JSON.parse(fs.readFileSync(crossReadStatePath, 'utf8'));
418
+ // Task1 creates src/gen.js
419
+ write(path.join(crossRead, 'tasks/task-R1-01-one.md'), `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Create\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen.js\` | Create | Create gen |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/gen.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`);
420
+ crossReadState.task_files.push('tasks/task-R1-02-two.md');
421
+ crossReadState.task_registry['tasks/task-R1-02-two.md'] = { id: 'R1-02', title: 'Two', status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null };
422
+ write(path.join(crossRead, 'tasks/task-R1-02-two.md'), `# Task R1-02: Two\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Read gen\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen.js\` | Read | Read gen without dep |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/gen.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`);
423
+ write(crossReadStatePath, JSON.stringify(crossReadState, null, 2));
424
+ const crossReadFail = run(VALIDATOR, [crossRead], ROOT);
425
+ assert.notEqual(crossReadFail.status, 0);
426
+ assert.match(output(crossReadFail), /read of src\/gen\.js must depend on creator/);
427
+
428
+ // Cross-task Read with dependency must pass
429
+ crossReadState.task_registry['tasks/task-R1-02-two.md'].dependencies = ['tasks/task-R1-01-one.md'];
430
+ write(crossReadStatePath, JSON.stringify(crossReadState, null, 2));
431
+ const crossReadPass = run(VALIDATOR, [crossRead], ROOT);
432
+ assert.equal(crossReadPass.status, 0, output(crossReadPass));
433
+
434
+ // Cross-task Modify without dependency must fail, with dep must pass
435
+ const crossMod = createSpec(root, { name: 'cross-mod-base' });
436
+ const crossModStatePath = path.join(crossMod, 'spec.json');
437
+ const crossModState = JSON.parse(fs.readFileSync(crossModStatePath, 'utf8'));
438
+ write(path.join(crossMod, 'tasks/task-R1-01-one.md'), `# Task R1-01: One\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Create\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen2.js\` | Create | Create gen2 |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/gen2.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`);
439
+ crossModState.task_files.push('tasks/task-R1-02-two.md');
440
+ crossModState.task_registry['tasks/task-R1-02-two.md'] = { id: 'R1-02', title: 'Two', status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null };
441
+ write(path.join(crossMod, 'tasks/task-R1-02-two.md'), `# Task R1-02: Two\n\n## Context\n- X\n\n## Constraints\n- Y\n\n## Steps\n- [ ] Modify gen2\n - _Requirements: 1.1_\n\n## Requirements\n- 1.1 — One\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen2.js\` | Modify | Modify without dep |\n\n## Completion Criteria\n- [ ] Works\n\n## Evidence\n- Runtime reachability verification\n - Entrypoint/caller: src/gen2.js\n\n## Risk Assessment\n| Risk | Severity | Mitigation |\n|---|---|---|\n| None | Low | Tests |\n`);
442
+ write(crossModStatePath, JSON.stringify(crossModState, null, 2));
443
+ const crossModFail = run(VALIDATOR, [crossMod], ROOT);
444
+ assert.notEqual(crossModFail.status, 0);
445
+ assert.match(output(crossModFail), /modify of src\/gen2\.js must depend on creator/);
446
+ crossModState.task_registry['tasks/task-R1-02-two.md'].dependencies = ['tasks/task-R1-01-one.md'];
447
+ write(crossModStatePath, JSON.stringify(crossModState, null, 2));
448
+ const crossModPass = run(VALIDATOR, [crossMod], ROOT);
449
+ assert.equal(crossModPass.status, 0, output(crossModPass));
450
+ } finally {
451
+ fs.rmSync(root, { recursive: true, force: true });
452
+ }
453
+ });
454
+
455
+ test('grounding enforces Create ordering and cross-task dependency', () => {
456
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-grounding-order-'));
457
+ try {
458
+ // Same-task Create after Read must fail in grounding
459
+ const specDir = path.join(root, 'spec-order');
460
+ write(path.join(specDir, 'tasks/task-R1-01-one.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/new.js\` | Read | Read before create |\n| \`src/new.js\` | Create | Create after read |\n`);
461
+ write(path.join(specDir, 'spec.json'), JSON.stringify({
462
+ schema_version: '2.0',
463
+ task_files: ['tasks/task-R1-01-one.md'],
464
+ task_registry: { 'tasks/task-R1-01-one.md': { id: 'R1-01', title: 'One', status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null } }
465
+ }));
466
+ const r1 = run(GROUNDING, [specDir, '--root', root], ROOT);
467
+ assert.notEqual(r1.status, 0);
468
+ assert.match(`${r1.stdout}\n${r1.stderr}`, /Create must precede read/);
469
+
470
+ // Cross-task Read without dependency must fail in grounding
471
+ const specDir2 = path.join(root, 'spec-cross');
472
+ write(path.join(specDir2, 'tasks/task-R1-01-one.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen.js\` | Create | Create |\n`);
473
+ write(path.join(specDir2, 'tasks/task-R1-02-two.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen.js\` | Read | Read without dep |\n`);
474
+ write(path.join(specDir2, 'spec.json'), JSON.stringify({
475
+ schema_version: '2.0',
476
+ task_files: ['tasks/task-R1-01-one.md', 'tasks/task-R1-02-two.md'],
477
+ task_registry: {
478
+ 'tasks/task-R1-01-one.md': { id: 'R1-01', title: 'One', status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null },
479
+ 'tasks/task-R1-02-two.md': { id: 'R1-02', title: 'Two', status: 'pending', dependencies: [], blocker: null, started_at: null, completed_at: null, last_updated_at: null }
480
+ }
481
+ }));
482
+ const r2 = run(GROUNDING, [specDir2, '--root', root], ROOT);
483
+ assert.notEqual(r2.status, 0);
484
+ assert.match(`${r2.stdout}\n${r2.stderr}`, /read of src\/gen\.js must depend on creator/);
485
+
486
+ // Cross-task Read with dependency must pass (if file not found but created, dependency satisfies)
487
+ const spec2Json = JSON.parse(fs.readFileSync(path.join(specDir2, 'spec.json'), 'utf8'));
488
+ spec2Json.task_registry['tasks/task-R1-02-two.md'].dependencies = ['tasks/task-R1-01-one.md'];
489
+ fs.writeFileSync(path.join(specDir2, 'spec.json'), JSON.stringify(spec2Json, null, 2));
490
+ const r3 = run(GROUNDING, [specDir2, '--root', root], ROOT);
491
+ assert.equal(r3.status, 0, `${r3.stdout}\n${r3.stderr}`);
492
+
493
+ // Repeated reads without create must not be rejected unnecessarily (grounding should pass if files exist or fail only for missing file, not ordering)
494
+ write(path.join(root, 'src/existing.js'), 'x\n');
495
+ const specDir3 = path.join(root, 'spec-repeated');
496
+ write(path.join(specDir3, 'tasks/task-R1-01-one.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/existing.js\` | Read | First |\n| \`src/existing.js\` | Read | Second |\n`);
497
+ const r4 = run(GROUNDING, [specDir3, '--root', root], ROOT);
498
+ assert.equal(r4.status, 0, `${r4.stdout}\n${r4.stderr}`);
499
+
500
+ // Create on existing must remain error in grounding
501
+ const specDir4 = path.join(root, 'spec-create-existing2');
502
+ write(path.join(specDir4, 'tasks/task-R1-01-one.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/existing.js\` | Create | Create existing |\n`);
503
+ const r5 = run(GROUNDING, [specDir4, '--root', root], ROOT);
504
+ assert.notEqual(r5.status, 0);
505
+ assert.match(`${r5.stdout}\n${r5.stderr}`, /Create path already exists/);
506
+ } finally {
507
+ fs.rmSync(root, { recursive: true, force: true });
508
+ }
509
+ });
510
+
511
+ test('grounding fails closed when cross-task producer exists but registry missing', () => {
512
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-grounding-missing-registry-'));
513
+ try {
514
+ // Cross-task Create -> Read with missing spec.json must fail closed
515
+ const specDir = path.join(root, 'spec-missing-registry');
516
+ write(path.join(specDir, 'tasks/task-R1-01-one.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen.js\` | Create | Create |\n`);
517
+ write(path.join(specDir, 'tasks/task-R1-02-two.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/gen.js\` | Read | Read without registry |\n`);
518
+ // Intentionally no spec.json
519
+ const noRegistry = run(GROUNDING, [specDir, '--root', root], ROOT);
520
+ assert.notEqual(noRegistry.status, 0);
521
+ assert.match(`${noRegistry.stdout}\n${noRegistry.stderr}`, /task_registry is missing.*fail-closed|fail-closed/);
522
+
523
+ // Missing registry but also empty registry object must fail closed
524
+ write(path.join(specDir, 'spec.json'), JSON.stringify({ task_files: ['tasks/task-R1-01-one.md', 'tasks/task-R1-02-two.md'], task_registry: {} }));
525
+ const emptyRegistry = run(GROUNDING, [specDir, '--root', root], ROOT);
526
+ assert.notEqual(emptyRegistry.status, 0);
527
+ assert.match(`${emptyRegistry.stdout}\n${emptyRegistry.stderr}`, /fail-closed|must depend on creator|task_registry/);
528
+
529
+ // Valid single-task Create+Read with missing registry must still PASS (no cross-task)
530
+ const singleDir = path.join(root, 'spec-single-missing-registry');
531
+ write(path.join(singleDir, 'tasks/task-R1-01-one.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/single.js\` | Create | Create |\n| \`src/single.js\` | Read | Read same task |\n`);
532
+ // No spec.json for single-task
533
+ const singlePass = run(GROUNDING, [singleDir, '--root', root], ROOT);
534
+ assert.equal(singlePass.status, 0, `${singlePass.stdout}\n${singlePass.stderr}`);
535
+
536
+ // Existing-file Read without producer and missing registry must PASS (if file exists)
537
+ write(path.join(root, 'src/existing2.js'), 'x\n');
538
+ const existingDir = path.join(root, 'spec-existing-missing-registry');
539
+ write(path.join(existingDir, 'tasks/task-R1-01-one.md'), `# Task\n\n## Related Files\n| Path | Action | Description |\n|---|---|---|\n| \`src/existing2.js\` | Read | Read existing |\n`);
540
+ // No spec.json
541
+ const existingPass = run(GROUNDING, [existingDir, '--root', root], ROOT);
542
+ assert.equal(existingPass.status, 0, `${existingPass.stdout}\n${existingPass.stderr}`);
543
+ } finally {
544
+ fs.rmSync(root, { recursive: true, force: true });
545
+ }
546
+ });
547
+
548
+ test('benchmark gate fails Critical when both arms correctness is zero', () => {
549
+ const BENCHMARK = path.join(ROOT, 'scripts/benchmark-workflow.mjs');
550
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-benchmark-critical-zero-'));
551
+ try {
552
+ function canon(v) {
553
+ if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
554
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
555
+ return JSON.stringify(v);
556
+ }
557
+ function shaCanon(v) { const c = require('node:crypto'); return `sha256:${c.createHash('sha256').update(canon(v)).digest('hex')}`; }
558
+ function hash(v) { const c = require('node:crypto'); return `sha256:${c.createHash('sha256').update(JSON.stringify(v)).digest('hex')}`; }
559
+ function rawHash(buf) { const c = require('node:crypto'); return `sha256:${c.createHash('sha256').update(buf).digest('hex')}`; }
560
+ const corpus = {
561
+ schema_version: 'b1.v1',
562
+ status: 'frozen',
563
+ corpus_id: 'zero-critical-test',
564
+ tasks: [
565
+ { task_id: 'c1', lane: 'Critical', prompt: 'critical task', repo_sample: 'r/a', acceptance: { criteria: ['x'] }, risk: { level: 'high', reasons: ['x'] } },
566
+ { task_id: 'd1', lane: 'Direct', prompt: 'direct task', repo_sample: 'r/a', acceptance: { criteria: ['x'] }, risk: { level: 'low', reasons: ['x'] } },
567
+ ],
568
+ };
569
+ const corpusSha = shaCanon(corpus);
570
+ const corpusPath = path.join(root, 'corpus.json');
571
+ fs.writeFileSync(corpusPath, JSON.stringify(corpus));
572
+ function makeConfig(arm) {
573
+ const cfg = {
574
+ schema_version: 'b1.v1',
575
+ status: 'frozen',
576
+ experiment_id: 'exp-zero',
577
+ arm,
578
+ model: { name: 'm', version: 'v' },
579
+ reasoning_effort: 'standard',
580
+ repo: { identifier: 'r', commit: 'abc1234', clean_initial_tree_sha: hash('clean') },
581
+ permissions_fingerprint: hash('perm'),
582
+ tool_availability_fingerprint: hash('tool'),
583
+ corpus_sha256: corpusSha,
584
+ repeat_policy: { repeats_per_task: 1, context_isolated: true },
585
+ input_usd_per_1k: 0.01,
586
+ output_usd_per_1k: 0.02,
587
+ };
588
+ const without = { ...cfg }; delete without.config_sha256;
589
+ cfg.config_sha256 = shaCanon(without);
590
+ return cfg;
591
+ }
592
+ const baselineCfg = makeConfig('baseline');
593
+ const treatmentCfg = makeConfig('treatment');
594
+ const baselinePath = path.join(root, 'baseline.json');
595
+ const treatmentPath = path.join(root, 'treatment.json');
596
+ fs.writeFileSync(baselinePath, JSON.stringify(baselineCfg));
597
+ fs.writeFileSync(treatmentPath, JSON.stringify(treatmentCfg));
598
+ const artifactBytes = Buffer.from('artifact');
599
+ const artifactSha = rawHash(artifactBytes);
600
+ const receiptsDir = path.join(root, 'receipts');
601
+ fs.mkdirSync(receiptsDir, { recursive: true });
602
+ fs.writeFileSync(path.join(receiptsDir, 'artifact.json'), artifactBytes);
603
+ const receipts = [];
604
+ for (const t of corpus.tasks) {
605
+ for (const arm of ['baseline', 'treatment']) {
606
+ const cfg = arm === 'baseline' ? baselineCfg : treatmentCfg;
607
+ receipts.push({
608
+ task_id: t.task_id,
609
+ lane: t.lane,
610
+ arm,
611
+ repeat: 1,
612
+ model: { name: 'm', version: 'v' },
613
+ reasoning_effort: 'standard',
614
+ repo_commit: 'abc1234',
615
+ clean_initial_tree_sha: hash('clean'),
616
+ permissions_fingerprint: hash('perm'),
617
+ tool_availability_fingerprint: hash('tool'),
618
+ corpus_sha256: corpusSha,
619
+ config_sha256: cfg.config_sha256,
620
+ wall_ms: 100,
621
+ input_tokens: 10,
622
+ output_tokens: 10,
623
+ context_loaded_tokens: 100,
624
+ tool_calls: 1,
625
+ subagent_calls: 0,
626
+ correctness: false,
627
+ regression: false,
628
+ unsupported_completion_claim: false,
629
+ user_corrections: 0,
630
+ useful_reviewer_findings: 1,
631
+ false_positive_reviewer_findings: 0,
632
+ evidence: { artifact_ref: 'artifact.json', artifact_sha256: artifactSha, command: 'echo hi' },
633
+ });
634
+ }
635
+ }
636
+ const receiptsPath = path.join(receiptsDir, 'receipts.json');
637
+ fs.writeFileSync(receiptsPath, JSON.stringify({ receipts }));
638
+ const summary = spawnSync(process.execPath, [BENCHMARK, 'summarize', '--corpus', corpusPath, '--config', baselinePath, '--config', treatmentPath, '--receipts', receiptsPath], { encoding: 'utf8' });
639
+ assert.equal(summary.status, 0, `${summary.stdout}\n${summary.stderr}`);
640
+ const parsed = JSON.parse(summary.stdout);
641
+ assert.equal(parsed.rollout_recommendation.gates.Critical.pass, false, 'Critical should not pass with zero correctness');
642
+ assert.equal(parsed.rollout_recommendation.gates.Critical.quality_pass, false);
643
+ assert.equal(parsed.status, 'not-ready');
644
+ } finally {
645
+ fs.rmSync(root, { recursive: true, force: true });
646
+ }
647
+ });
648
+
649
+ test('benchmark validate handles empty and invalid evidence as not-ready/invalid', () => {
650
+ const BENCHMARK = path.join(ROOT, 'scripts/benchmark-workflow.mjs');
651
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-benchmark-empty-'));
652
+ try {
653
+ function canon(v) {
654
+ if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
655
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
656
+ return JSON.stringify(v);
657
+ }
658
+ function shaCanon(v) { const c = require('node:crypto'); return `sha256:${c.createHash('sha256').update(canon(v)).digest('hex')}`; }
659
+ function hash(v) { const c = require('node:crypto'); return `sha256:${c.createHash('sha256').update(JSON.stringify(v)).digest('hex')}`; }
660
+ const corpus = {
661
+ schema_version: 'b1.v1',
662
+ status: 'frozen',
663
+ corpus_id: 'empty-test',
664
+ tasks: [{ task_id: 't1', lane: 'Direct', prompt: 'p', repo_sample: 'r/a', acceptance: { criteria: ['x'] }, risk: { level: 'low', reasons: ['x'] } }],
665
+ };
666
+ const corpusSha = shaCanon(corpus);
667
+ const corpusPath = path.join(root, 'corpus.json');
668
+ fs.writeFileSync(corpusPath, JSON.stringify(corpus));
669
+ function makeConfig(arm) {
670
+ const cfg = {
671
+ schema_version: 'b1.v1', status: 'frozen', experiment_id: 'exp-empty', arm,
672
+ model: { name: 'm', version: 'v' }, reasoning_effort: 'standard',
673
+ repo: { identifier: 'r', commit: 'abc', clean_initial_tree_sha: hash('clean') },
674
+ permissions_fingerprint: hash('perm'), tool_availability_fingerprint: hash('tool'),
675
+ corpus_sha256: corpusSha, repeat_policy: { repeats_per_task: 1, context_isolated: true },
676
+ };
677
+ const without = { ...cfg }; delete without.config_sha256;
678
+ cfg.config_sha256 = shaCanon(without);
679
+ return cfg;
680
+ }
681
+ const cfg = makeConfig('baseline');
682
+ const cfgPath = path.join(root, 'cfg.json');
683
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg));
684
+ const emptyReceipts = path.join(root, 'empty.json');
685
+ fs.writeFileSync(emptyReceipts, JSON.stringify({ receipts: [] }));
686
+ const emptySummary = spawnSync(process.execPath, [BENCHMARK, 'summarize', '--corpus', corpusPath, '--config', cfgPath, '--receipts', emptyReceipts], { encoding: 'utf8' });
687
+ assert.equal(emptySummary.status, 0);
688
+ const emptyParsed = JSON.parse(emptySummary.stdout);
689
+ assert.ok(['not-ready', 'exploratory/no-live-runs'].includes(emptyParsed.status));
690
+
691
+ const artifactBytes = Buffer.from('artifact');
692
+ const rawHash = (b) => { const c = require('node:crypto'); return `sha256:${c.createHash('sha256').update(b).digest('hex')}`; };
693
+ const artifactSha = rawHash(artifactBytes);
694
+ const badReceipts = { receipts: [{
695
+ task_id: 't1', lane: 'Direct', arm: 'baseline', repeat: 1,
696
+ model: { name: 'm', version: 'v' }, reasoning_effort: 'standard',
697
+ repo_commit: 'abc', clean_initial_tree_sha: hash('clean'),
698
+ permissions_fingerprint: hash('perm'), tool_availability_fingerprint: hash('tool'),
699
+ corpus_sha256: corpusSha, config_sha256: cfg.config_sha256,
700
+ wall_ms: 100, input_tokens: 10, output_tokens: 10, context_loaded_tokens: 100, tool_calls: 1, subagent_calls: 0,
701
+ correctness: true, regression: false, unsupported_completion_claim: false,
702
+ user_corrections: 0, useful_reviewer_findings: 0, false_positive_reviewer_findings: 0,
703
+ evidence: { artifact_ref: 'missing.json', artifact_sha256: artifactSha, command: 'echo hi' },
704
+ }] };
705
+ const badPath = path.join(root, 'bad.json');
706
+ fs.writeFileSync(badPath, JSON.stringify(badReceipts));
707
+ const badValidation = spawnSync(process.execPath, [BENCHMARK, 'validate', '--corpus', corpusPath, '--config', cfgPath, '--receipts', badPath], { encoding: 'utf8' });
708
+ assert.equal(badValidation.status, 2);
709
+ assert.match(badValidation.stderr, /artifact.*does not exist|artifact_ref/);
710
+ } finally {
711
+ fs.rmSync(root, { recursive: true, force: true });
712
+ }
713
+ });