@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/bin/__tests__/codex-hooks.test.js +355 -12
  2. package/bin/__tests__/codex-native.test.js +1 -1
  3. package/bin/__tests__/develop-contract.test.js +1374 -78
  4. package/bin/__tests__/installer-safety.test.js +43 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +791 -0
  6. package/bin/__tests__/package-inventory.test.js +157 -0
  7. package/bin/__tests__/validator-grounding.test.js +707 -2
  8. package/package.json +1 -1
  9. package/src/claude/agents/code-auditor.md +8 -1
  10. package/src/claude/agents/spec-maker.md +107 -200
  11. package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
  12. package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
  13. package/src/claude/hooks/completion-authority-check.cjs +307 -0
  14. package/src/claude/hooks/completion-authority-state.cjs +525 -0
  15. package/src/claude/hooks/completion-authority.cjs +148 -0
  16. package/src/claude/hooks/session.cjs +33 -19
  17. package/src/claude/hooks/spec-gate.cjs +173 -61
  18. package/src/claude/hooks/spec-state.cjs +104 -45
  19. package/src/claude/migration-manifest.json +6 -1
  20. package/src/claude/scripts/provenance.cjs +315 -0
  21. package/src/claude/scripts/spec-resolver.cjs +450 -0
  22. package/src/claude/scripts/spec-scaffold.cjs +716 -41
  23. package/src/claude/scripts/validate-spec-output.cjs +355 -53
  24. package/src/claude/scripts/workflow-policy.cjs +1529 -162
  25. package/src/claude/settings/settings.json +8 -0
  26. package/src/claude/skills/backend-development/references/backend-security.md +52 -0
  27. package/src/claude/skills/code-review/SKILL.md +85 -66
  28. package/src/claude/skills/develop/SKILL.md +153 -285
  29. package/src/claude/skills/develop/references/quality-gate.md +112 -124
  30. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  31. package/src/claude/skills/specs/SKILL.md +170 -381
  32. package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
  33. package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
  34. package/src/claude/skills/specs/templates/spec-state.json +23 -2
  35. package/src/claude/skills/specs/templates/task.md +20 -0
  36. package/src/claude/skills/sync/SKILL.md +15 -8
  37. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  38. package/src/claude/skills/test/SKILL.md +141 -167
  39. package/src/codex/hooks/completion-authority-check.cjs +260 -0
  40. package/src/codex/hooks/completion-authority-state.cjs +525 -0
  41. package/src/codex/hooks/completion-authority.cjs +100 -0
  42. package/src/codex/hooks/lib/hook-context.cjs +42 -2
  43. package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
  44. package/src/codex/hooks/lib/spec-utils.cjs +53 -27
  45. package/src/codex/hooks/session.cjs +2 -0
  46. package/src/codex/hooks/spec-gate.cjs +132 -20
  47. package/src/codex/hooks/spec-state.cjs +56 -10
  48. package/src/codex/hooks.json +10 -0
  49. package/src/opencode/AGENTS.md +4 -2
  50. package/src/opencode/plugins/spec-gate.ts +296 -122
  51. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -10,14 +10,43 @@ const { test } = require('node:test');
10
10
  const ROOT = path.resolve(__dirname, '../..');
11
11
  const VALIDATOR = path.join(ROOT, 'src/claude/scripts/validate-spec-output.cjs');
12
12
  const GROUNDING = path.join(ROOT, 'src/claude/scripts/spec-ground.cjs');
13
+ const SCAFFOLD = path.join(ROOT, 'src/claude/scripts/spec-scaffold.cjs');
14
+ const RESOLVER = path.join(ROOT, 'src/claude/scripts/spec-resolver.cjs');
15
+ const POLICY = require(path.join(ROOT, 'src/claude/scripts/workflow-policy.cjs'));
16
+ const SPEC_RESOLVER = require(RESOLVER);
13
17
 
14
18
  function write(filePath, content) {
15
19
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
20
  fs.writeFileSync(filePath, content);
17
21
  }
18
22
 
19
- function run(script, args, cwd) {
20
- return spawnSync(process.execPath, [script, ...args], { cwd, encoding: 'utf8' });
23
+ function run(script, args, cwd, options = {}) {
24
+ return spawnSync(process.execPath, [script, ...args], {
25
+ cwd,
26
+ encoding: 'utf8',
27
+ ...options,
28
+ env: options.env ? { ...process.env, ...options.env } : process.env,
29
+ });
30
+ }
31
+
32
+ function snapshotTree(root) {
33
+ const entries = [];
34
+ function visit(directory) {
35
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
36
+ const fullPath = path.join(directory, entry.name);
37
+ const relative = path.relative(root, fullPath);
38
+ if (entry.isSymbolicLink()) {
39
+ entries.push(`${relative}\0symlink\0${fs.readlinkSync(fullPath)}`);
40
+ } else if (entry.isDirectory()) {
41
+ entries.push(`${relative}\0directory`);
42
+ visit(fullPath);
43
+ } else {
44
+ entries.push(`${relative}\0file\0${fs.readFileSync(fullPath).toString('base64')}`);
45
+ }
46
+ }
47
+ }
48
+ visit(root);
49
+ return entries.sort().join('\n');
21
50
  }
22
51
 
23
52
  function task({ file = 'task-R1-01-one.md', mapping = '1.1', related = '`src/one.js` | Modify', extra = '' } = {}) {
@@ -30,6 +59,7 @@ function createSpec(root, options = {}) {
30
59
  write(path.join(specDir, 'requirements.md'), options.requirements || '# Requirements\n\n### Requirement 1: One\n\n- **R1.1** Do one thing.\n');
31
60
  write(path.join(specDir, 'design.md'), options.design || '# Design\n');
32
61
  write(path.join(specDir, 'research.md'), '# Research\n\n## Evidence Summary\n- Internal evidence.\n');
62
+ write(path.join(specDir, 'feature-receipt.md'), '# Feature Verification Receipt\n\nVerification: PENDING\n\nStatus: in_progress\n');
33
63
  write(path.join(specDir, taskPath), options.task || task());
34
64
  write(path.join(specDir, 'spec.json'), JSON.stringify({
35
65
  schema_version: '2.0',
@@ -47,15 +77,690 @@ function createSpec(root, options = {}) {
47
77
  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
78
  validation: { status: 'not-run' },
49
79
  ready_for_implementation: false,
80
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: {} }),
50
81
  ...(options.spec || {}),
51
82
  }, null, 2));
52
83
  return specDir;
53
84
  }
54
85
 
86
+ function createBoundedSpec(root, name = 'bounded') {
87
+ const specDir = path.join(root, name);
88
+ const policy = POLICY.workflowPolicySnapshot({ riskSignals: {} });
89
+ write(path.join(specDir, 'requirements.md'), '# Requirements\n\nBounded requirements are filled by the feature owner.\n');
90
+ write(path.join(specDir, 'design.md'), '# Design\n\nBounded design is filled by the feature owner.\n');
91
+ write(path.join(specDir, 'feature-receipt.md'), '# Feature Verification Receipt\n\nVerification: PENDING\n\nStatus: in_progress\n\nBlocker: awaiting execution proof\n');
92
+ write(path.join(specDir, 'spec.json'), JSON.stringify({
93
+ schema_version: '2.0',
94
+ feature_name: name,
95
+ status: 'in_progress',
96
+ current_phase: 'design',
97
+ scope_lock: { source: 'fixture', in_scope: [], out_of_scope: [], expansion_policy: 'requires-user-approval' },
98
+ approvals: {
99
+ requirements: { generated: true, agent_validated: true, user_approved: false },
100
+ design: { generated: true, agent_validated: true, user_approved: false },
101
+ },
102
+ validation: { status: 'not-run' },
103
+ ready_for_implementation: false,
104
+ workflow_policy: policy,
105
+ }, null, 2));
106
+ return specDir;
107
+ }
108
+
109
+ function createPolicyOnlySpec(root, name, policy) {
110
+ const specDir = path.join(root, 'specs', name);
111
+ write(path.join(specDir, 'spec.json'), JSON.stringify({ workflow_policy: policy }, null, 2));
112
+ return specDir;
113
+ }
114
+
55
115
  function output(result) {
56
116
  return `${result.stdout}\n${result.stderr}`;
57
117
  }
58
118
 
119
+ function completeScaffoldedSpec(specDir) {
120
+ const taskPath = path.join(specDir, 'tasks', 'task-R1-01-artifact.md');
121
+ let taskText = fs.readFileSync(taskPath, 'utf8')
122
+ .replace(/\{\{[^}\n]+\}\}/g, 'filled')
123
+ .replace(/_Requirements:\s*[^_]+_/g, '_Requirements: 1.1_')
124
+ .replace(/\| `filled` \| Create \/ Modify \/ Delete \| filled \|/g, '| `src/one.js` | Read | inspect source |')
125
+ .replace('Entrypoint/caller: filled', 'Entrypoint/caller: src/one.js');
126
+ fs.writeFileSync(taskPath, taskText);
127
+ write(path.join(specDir, 'requirements.md'), '# Requirements\n\n### Requirement 1: Artifact\n\n- **R1.1** The task is verifiable.\n');
128
+ write(path.join(specDir, 'design.md'), '# Design\n');
129
+ write(path.join(specDir, 'research.md'), '# Research\n\n## Evidence Summary\n- Producer fixture.\n');
130
+ return JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'));
131
+ }
132
+
133
+ test('resolver binds feature identity and rejects cross-feature spec aliases', () => {
134
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-resolver-identity-'));
135
+ try {
136
+ const specsDir = path.join(root, 'specs');
137
+ write(path.join(specsDir, 'bar', 'spec.json'), JSON.stringify({ feature_name: 'bar', status: 'in_progress' }));
138
+ fs.mkdirSync(path.join(specsDir, 'foo'), { recursive: true });
139
+ fs.symlinkSync('../bar/spec.json', path.join(specsDir, 'foo', 'spec.json'));
140
+
141
+ const explicit = SPEC_RESOLVER.resolveActiveSpec({ projectRoot: root, explicitFeature: 'foo' });
142
+ assert.equal(explicit.error, 'explicit_malformed');
143
+ assert.match(explicit.reason, /does not belong to feature directory|canonical feature/);
144
+
145
+ const implicit = SPEC_RESOLVER.resolveActiveSpec({ projectRoot: root });
146
+ assert.equal(implicit.error, 'invalid_specs');
147
+ assert.deepEqual(implicit.candidates, ['foo']);
148
+ assert.doesNotMatch(implicit.reason, /Multiple active specs/);
149
+ assert.throws(
150
+ () => SPEC_RESOLVER.findAllActiveSpecs(root),
151
+ (error) => error.code === 'INVALID_SPECS' && /foo/.test(error.message),
152
+ );
153
+
154
+ const aliasRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-resolver-directory-alias-'));
155
+ try {
156
+ const aliasSpecs = path.join(aliasRoot, 'specs');
157
+ write(path.join(aliasSpecs, 'bar', 'spec.json'), JSON.stringify({ feature_name: 'bar', status: 'in_progress' }));
158
+ fs.symlinkSync('bar', path.join(aliasSpecs, 'foo'), 'dir');
159
+ const resolved = SPEC_RESOLVER.resolveActiveSpec({ projectRoot: aliasRoot });
160
+ assert.equal(resolved.featureName, 'bar');
161
+ assert.equal(SPEC_RESOLVER.findAllActiveSpecs(aliasRoot).length, 1);
162
+ } finally {
163
+ fs.rmSync(aliasRoot, { recursive: true, force: true });
164
+ }
165
+ } finally {
166
+ fs.rmSync(root, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test('validator rejects hidden task symlinks instead of filtering them', () => {
171
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-hidden-task-link-'));
172
+ try {
173
+ const specDir = createSpec(root, { name: 'hidden-task-link' });
174
+ fs.symlinkSync('task-R1-01-one.md', path.join(specDir, 'tasks', '.hidden-task.md'));
175
+ const result = run(VALIDATOR, [specDir], ROOT);
176
+ assert.notEqual(result.status, 0);
177
+ assert.match(output(result), /tasks\/\.hidden-task\.md: symlink is not allowed/);
178
+ } finally {
179
+ fs.rmSync(root, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test('validator rejects non-Markdown task artifacts instead of ignoring them', () => {
184
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-task-artifact-'));
185
+ try {
186
+ const specDir = createSpec(root, { name: 'non-markdown-task-artifact' });
187
+ write(path.join(specDir, 'tasks', 'notes.txt'), 'worker note\n');
188
+ const result = run(VALIDATOR, [specDir], ROOT);
189
+ assert.notEqual(result.status, 0);
190
+ assert.match(output(result), /tasks\/notes\.txt: unexpected task artifact/);
191
+ } finally {
192
+ fs.rmSync(root, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ test('validator never treats a completed feature receipt as execution authority', () => {
197
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-feature-receipt-'));
198
+ try {
199
+ const specDir = createBoundedSpec(root, 'completed-feature-receipt');
200
+ write(path.join(specDir, 'feature-receipt.md'), [
201
+ '# Feature Verification Receipt', '', 'Verification: PASS',
202
+ 'Command: npm test', 'Exit: 0', 'Base: a', 'Head: b',
203
+ ].join('\n'));
204
+ const result = run(VALIDATOR, [specDir], ROOT);
205
+ assert.notEqual(result.status, 0);
206
+ assert.match(output(result), /feature-receipt\.md: informational only/);
207
+ } finally {
208
+ fs.rmSync(root, { recursive: true, force: true });
209
+ }
210
+ });
211
+
212
+ test('validator rejects required and optional spec artifacts that symlink outside the spec', () => {
213
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-artifact-links-'));
214
+ const external = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-external-artifacts-'));
215
+ try {
216
+ const required = createSpec(root, { name: 'required-artifact-link' });
217
+ fs.unlinkSync(path.join(required, 'requirements.md'));
218
+ fs.symlinkSync(path.join(external, 'requirements.md'), path.join(required, 'requirements.md'));
219
+ write(path.join(external, 'requirements.md'), '# External requirements\n');
220
+ const requiredResult = run(VALIDATOR, [required], ROOT);
221
+ assert.notEqual(requiredResult.status, 0);
222
+ assert.match(output(requiredResult), /requirements\.md: symlink is not allowed/);
223
+
224
+ const optional = createSpec(root, { name: 'optional-artifact-link' });
225
+ fs.unlinkSync(path.join(optional, 'research.md'));
226
+ write(path.join(external, 'research.md'), '# External research\n');
227
+ fs.symlinkSync(path.join(external, 'research.md'), path.join(optional, 'research.md'));
228
+ const optionalResult = run(VALIDATOR, [optional], ROOT);
229
+ assert.notEqual(optionalResult.status, 0);
230
+ assert.match(output(optionalResult), /research\.md: symlink is not allowed/);
231
+
232
+ const bounded = createBoundedSpec(root, 'receipt-artifact-link');
233
+ fs.unlinkSync(path.join(bounded, 'feature-receipt.md'));
234
+ write(path.join(external, 'feature-receipt.md'), '# External receipt\n');
235
+ fs.symlinkSync(path.join(external, 'feature-receipt.md'), path.join(bounded, 'feature-receipt.md'));
236
+ const boundedResult = run(VALIDATOR, [bounded], ROOT);
237
+ assert.notEqual(boundedResult.status, 0);
238
+ assert.match(output(boundedResult), /feature-receipt\.md: symlink is not allowed/);
239
+ } finally {
240
+ fs.rmSync(root, { recursive: true, force: true });
241
+ fs.rmSync(external, { recursive: true, force: true });
242
+ }
243
+ });
244
+
245
+ test('Standard bounded readiness rejects scope and document placeholders but allows a pending receipt', () => {
246
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-standard-placeholders-'));
247
+ try {
248
+ const specDir = createBoundedSpec(root, 'standard-placeholders');
249
+ const specPath = path.join(specDir, 'spec.json');
250
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
251
+ spec.ready_for_implementation = true;
252
+ spec.approvals.requirements.user_approved = true;
253
+ spec.approvals.design.user_approved = true;
254
+ spec.validation = { status: 'completed' };
255
+ spec.timestamps = { validation_done: '2026-08-11T00:00:00+07:00' };
256
+ spec.scope_lock.source = '{{PROJECT_DESCRIPTION}}';
257
+ fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
258
+ write(path.join(specDir, 'requirements.md'), '# Requirements\n\n{{REQUIREMENTS}}\n');
259
+ write(path.join(specDir, 'design.md'), '# Design\n\nTBD\n');
260
+
261
+ const rejected = run(VALIDATOR, [specDir], ROOT);
262
+ assert.notEqual(rejected.status, 0);
263
+ assert.match(output(rejected), /scope_lock\.source.*concrete scope source/);
264
+ assert.match(output(rejected), /requirements\.md: Standard bounded readiness rejects placeholders/);
265
+ assert.match(output(rejected), /design\.md: Standard bounded readiness rejects placeholders/);
266
+ assert.doesNotMatch(output(rejected), /feature-receipt\.md: canonical feature receipt required/);
267
+
268
+ spec.scope_lock.source = 'The bounded feature scope is the one described here';
269
+ fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
270
+ write(path.join(specDir, 'requirements.md'), '# Requirements\n\nThe bounded behavior is explicitly defined.\n');
271
+ write(path.join(specDir, 'design.md'), '# Design\n\nThe bounded implementation boundary is explicitly defined.\n');
272
+ const accepted = run(VALIDATOR, [specDir], ROOT);
273
+ assert.equal(accepted.status, 0, output(accepted));
274
+ } finally {
275
+ fs.rmSync(root, { recursive: true, force: true });
276
+ }
277
+ });
278
+
279
+ test('tasks-only validates the persisted policy before any filesystem mutation', () => {
280
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-tasks-only-preflight-'));
281
+ try {
282
+ const standardPolicy = POLICY.workflowPolicySnapshot({ riskSignals: {} });
283
+ const cases = [
284
+ ['malformed', '{', /existing spec\.json is invalid JSON/],
285
+ ['null-state', 'null', /existing spec\.json must contain a JSON object/],
286
+ ['missing-snapshot', JSON.stringify({}), /existing spec must contain a persisted workflow_policy/],
287
+ ['null-snapshot', JSON.stringify({ workflow_policy: null }), /existing workflow_policy is invalid/],
288
+ ['extra-field', JSON.stringify({ workflow_policy: { ...standardPolicy, extra: true } }), /existing workflow_policy is invalid/],
289
+ ];
290
+ for (const [name, body, expected] of cases) {
291
+ const specDir = path.join(root, 'specs', name);
292
+ const specPath = path.join(specDir, 'spec.json');
293
+ write(specPath, body);
294
+ const before = fs.readFileSync(specPath, 'utf8');
295
+ const result = run(SCAFFOLD, [name, '--tasks', 'R1-01-one', '--tasks-only'], root);
296
+ assert.equal(result.status, 2, output(result));
297
+ assert.match(output(result), expected);
298
+ assert.equal(fs.readFileSync(specPath, 'utf8'), before);
299
+ assert.equal(fs.existsSync(path.join(specDir, 'tasks')), false);
300
+ assert.equal(fs.existsSync(path.join(specDir, 'research.md')), false);
301
+ }
302
+ } finally {
303
+ fs.rmSync(root, { recursive: true, force: true });
304
+ }
305
+ });
306
+
307
+ test('scaffold accepts only one safe alphanumeric-starting feature segment', () => {
308
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-feature-name-'));
309
+ try {
310
+ for (const feature of ['.', 'foo/bar', 'foo\\bar', '../escape', '..\\escape']) {
311
+ const before = snapshotTree(root);
312
+ const result = run(SCAFFOLD, [feature, '--lane', 'Standard'], root);
313
+ assert.equal(result.status, 2, `${feature}: ${output(result)}`);
314
+ assert.match(output(result), /feature|safe relative|path segment/i);
315
+ assert.equal(snapshotTree(root), before, `${feature} must not mutate the workspace`);
316
+ }
317
+ } finally {
318
+ fs.rmSync(root, { recursive: true, force: true });
319
+ }
320
+ });
321
+
322
+ test('validator requires a strict persisted workflow-policy snapshot and never falls back to prose or legacy tier', () => {
323
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-policy-boundary-'));
324
+ try {
325
+ const policy = POLICY.workflowPolicySnapshot({ riskSignals: {} });
326
+ const cases = [
327
+ ['missing', (state) => { delete state.workflow_policy; }, /missing persisted snapshot/],
328
+ ['explicit-null', (state) => { state.workflow_policy = null; }, /workflow_policy must be an object/],
329
+ ['malformed', (state) => { state.workflow_policy = { ...policy, proof_obligations: { needsExecutionProof: true } }; }, /proof_obligations must be an array/],
330
+ ['extra-field', (state) => { state.workflow_policy = { ...policy, extra: true }; }, /workflow_policy fields must be exactly/],
331
+ ['lane-mismatch', (state) => { state.workflow_policy = { ...policy, lane: 'Standard', automatic_lane: 'Critical' }; }, /lane cannot be lower than automatic_lane/],
332
+ ['risk-mismatch', (state) => { state.workflow_policy = { ...policy, risks: ['auth'] }; }, /lane must be at least Critical for its risks/],
333
+ ['obligation-mismatch', (state) => { state.workflow_policy = { ...policy, proof_obligations: ['needsExecutionProof'], actor_needs: [{ capability: 'execution-proof', independence: 'same-session' }] }; }, /proof_obligations do not match lane and risks/],
334
+ ];
335
+ for (const [name, mutate, expected] of cases) {
336
+ const specDir = createSpec(root, { name, spec: { workflow_policy: policy } });
337
+ const statePath = path.join(specDir, 'spec.json');
338
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
339
+ mutate(state);
340
+ write(statePath, JSON.stringify(state, null, 2));
341
+ const beforeRun = snapshotTree(specDir);
342
+ const result = run(VALIDATOR, [specDir], ROOT);
343
+ assert.notEqual(result.status, 0, `${name} unexpectedly passed`);
344
+ assert.match(output(result), expected);
345
+ assert.equal(snapshotTree(specDir), beforeRun, `${name} validator mutated the snapshot`);
346
+ }
347
+
348
+ const legacyDir = createSpec(root, {
349
+ name: 'legacy-ready',
350
+ spec: {
351
+ workflow_policy: undefined,
352
+ design_context: { execution_tier: 'Deep' },
353
+ ready_for_implementation: true,
354
+ approvals: {
355
+ requirements: { generated: true, agent_validated: true, user_approved: true },
356
+ design: { generated: true, agent_validated: true, user_approved: true },
357
+ tasks: { generated: true, agent_validated: true, user_approved: true },
358
+ },
359
+ validation: { status: 'completed' },
360
+ timestamps: { validation_done: '2026-08-11T00:00:00+07:00' },
361
+ },
362
+ });
363
+ const legacyBefore = snapshotTree(legacyDir);
364
+ const legacyResult = run(VALIDATOR, [legacyDir], ROOT);
365
+ assert.notEqual(legacyResult.status, 0);
366
+ assert.match(output(legacyResult), /execution_tier is a read-only legacy adapter/);
367
+ assert.doesNotMatch(output(legacyResult), /^PASS/m);
368
+ assert.equal(snapshotTree(legacyDir), legacyBefore);
369
+ } finally {
370
+ fs.rmSync(root, { recursive: true, force: true });
371
+ }
372
+ });
373
+
374
+ test('tasks-only preflights malformed topology, duplicates, and symlinks without mutation', () => {
375
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-topology-preflight-'));
376
+ try {
377
+ const policy = POLICY.workflowPolicySnapshot({ riskSignals: {} });
378
+ const taskPath = 'tasks/task-R1-01-one.md';
379
+ const registryEntry = {
380
+ id: 'R1-01', title: 'One', status: 'pending', dependencies: [], blocker: null,
381
+ started_at: null, completed_at: null, last_updated_at: null,
382
+ };
383
+ const cases = [
384
+ ['malformed-task-files', { workflow_policy: policy, task_files: 'not-an-array', task_registry: {} }, /task_files must be an array/],
385
+ ['malformed-registry', { workflow_policy: policy, task_files: [], task_registry: [] }, /task_registry must be an object/],
386
+ ['duplicate-task-files', { workflow_policy: policy, task_files: [taskPath, taskPath], task_registry: { [taskPath]: registryEntry } }, /duplicate path/],
387
+ ['unknown-dependency', { workflow_policy: policy, task_files: [taskPath], task_registry: { [taskPath]: { ...registryEntry, dependencies: ['tasks/task-R1-02-two.md'] } } }, /unknown task/],
388
+ ['unsafe-research-path', { workflow_policy: policy, research: '../outside' }, /spec\.research must be a safe relative path/],
389
+ ];
390
+ for (const [name, state, expected] of cases) {
391
+ const specDir = createPolicyOnlySpec(root, name, policy);
392
+ write(path.join(specDir, 'spec.json'), JSON.stringify(state, null, 2));
393
+ const before = snapshotTree(root);
394
+ const result = run(SCAFFOLD, [name, '--tasks', 'R1-02-two', '--tasks-only'], root);
395
+ assert.equal(result.status, 2, output(result));
396
+ assert.match(output(result), expected);
397
+ assert.equal(snapshotTree(root), before, `${name} mutated the existing topology`);
398
+ }
399
+
400
+ const conflictDir = createPolicyOnlySpec(root, 'conflicting-duplicate', policy);
401
+ write(path.join(conflictDir, 'tasks/task-R1-01-one.md'), 'already filled\n');
402
+ write(path.join(conflictDir, 'spec.json'), JSON.stringify({
403
+ workflow_policy: policy,
404
+ task_files: [taskPath],
405
+ task_registry: { [taskPath]: { ...registryEntry, title: 'Conflicting title' } },
406
+ }, null, 2));
407
+ const conflictBefore = snapshotTree(root);
408
+ const conflict = run(SCAFFOLD, ['conflicting-duplicate', '--tasks', 'R1-01-one', '--tasks-only'], root);
409
+ assert.equal(conflict.status, 2, output(conflict));
410
+ assert.match(output(conflict), /conflicting duplicate/);
411
+ assert.equal(snapshotTree(root), conflictBefore);
412
+
413
+ const duplicateDir = createPolicyOnlySpec(root, 'duplicate-cli', policy);
414
+ const duplicateBefore = snapshotTree(root);
415
+ const duplicate = run(SCAFFOLD, ['duplicate-cli', '--tasks', 'R1-01-one,R1-01-two', '--tasks-only'], root);
416
+ assert.equal(duplicate.status, 2, output(duplicate));
417
+ assert.match(output(duplicate), /duplicate task id.*R1-01/);
418
+ assert.equal(snapshotTree(root), duplicateBefore);
419
+
420
+ const symlinkDir = createPolicyOnlySpec(root, 'symlink-tasks', policy);
421
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-symlink-target-'));
422
+ fs.symlinkSync(outside, path.join(symlinkDir, 'tasks'), 'dir');
423
+ const symlinkBefore = snapshotTree(root);
424
+ const symlinkResult = run(SCAFFOLD, ['symlink-tasks', '--tasks', 'R1-01-one', '--tasks-only'], root);
425
+ assert.equal(symlinkResult.status, 2, output(symlinkResult));
426
+ assert.match(output(symlinkResult), /symlink/);
427
+ assert.equal(snapshotTree(root), symlinkBefore);
428
+ fs.rmSync(outside, { recursive: true, force: true });
429
+ } finally {
430
+ fs.rmSync(root, { recursive: true, force: true });
431
+ }
432
+ });
433
+
434
+ test('tasks-only commits transactionally and preserves exact bytes after an injected partial failure', () => {
435
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-transaction-'));
436
+ try {
437
+ const policy = POLICY.workflowPolicySnapshot({ riskSignals: {} });
438
+ createPolicyOnlySpec(root, 'partial-write', policy);
439
+ const before = snapshotTree(root);
440
+ const result = run(
441
+ SCAFFOLD,
442
+ ['partial-write', '--tasks', 'R1-01-one,R1-02-two', '--tasks-only'],
443
+ root,
444
+ { env: { CAFEKIT_SCAFFOLD_FAIL_AFTER_WRITES: '1' } },
445
+ );
446
+ assert.equal(result.status, 2, output(result));
447
+ assert.match(output(result), /injected failure.*transaction rolled back/);
448
+ assert.equal(snapshotTree(root), before);
449
+ } finally {
450
+ fs.rmSync(root, { recursive: true, force: true });
451
+ }
452
+ });
453
+
454
+ test('tasks-only is idempotent for an identical task request and preserves the snapshot bytes', () => {
455
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-idempotent-'));
456
+ try {
457
+ const policy = POLICY.workflowPolicySnapshot({ riskSignals: {} });
458
+ createPolicyOnlySpec(root, 'idempotent', policy);
459
+ const first = run(SCAFFOLD, ['idempotent', '--tasks', 'R1-01-one', '--tasks-only'], root);
460
+ assert.equal(first.status, 0, output(first));
461
+ const beforeSecond = snapshotTree(root);
462
+ const second = run(SCAFFOLD, ['idempotent', '--tasks', 'R1-01-one', '--tasks-only'], root);
463
+ assert.equal(second.status, 0, output(second));
464
+ assert.match(output(second), /0 new task stub\(s\)/);
465
+ assert.equal(snapshotTree(root), beforeSecond);
466
+ } finally {
467
+ fs.rmSync(root, { recursive: true, force: true });
468
+ }
469
+ });
470
+
471
+ test('tasks-only consumes the policy snapshot and escalates Standard auth monotonically', () => {
472
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-tasks-only-policy-'));
473
+ try {
474
+ const specDir = createPolicyOnlySpec(root, 'existing', POLICY.workflowPolicySnapshot({ riskSignals: {} }));
475
+ const escalated = run(SCAFFOLD, [
476
+ 'existing', '--tasks', 'R1-01-auth', '--risks', 'auth', '--tasks-only',
477
+ ], root);
478
+ assert.equal(escalated.status, 0, output(escalated));
479
+ assert.match(output(escalated), /1 new task stub\(s\)/);
480
+ assert.match(output(escalated), /research\.md created for needsResearchGrounding/);
481
+ const first = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'));
482
+ assert.equal(first.workflow_policy.lane, 'Critical');
483
+ assert.deepEqual(first.workflow_policy.risks, ['auth']);
484
+ assert.equal(first.workflow_policy.artifact_profile, 'strict');
485
+ assert.ok(first.workflow_policy.proof_obligations.includes('needsResearchGrounding'));
486
+ assert.equal(fs.existsSync(path.join(specDir, 'research.md')), true);
487
+ assert.equal(fs.existsSync(path.join(specDir, 'reports')), false);
488
+
489
+ const policyBytes = JSON.stringify(first.workflow_policy);
490
+ const noNewRisk = run(SCAFFOLD, ['existing', '--tasks', 'R1-02-next', '--tasks-only'], root);
491
+ assert.equal(noNewRisk.status, 0, output(noNewRisk));
492
+ assert.match(output(noNewRisk), /1 new task stub\(s\)/);
493
+ const second = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'));
494
+ assert.equal(JSON.stringify(second.workflow_policy), policyBytes);
495
+
496
+ const beforeRejectedLane = fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8');
497
+ const rejectedLane = run(SCAFFOLD, [
498
+ 'existing', '--tasks', 'R1-03-invalid', '--lane', 'Standard', '--tasks-only',
499
+ ], root);
500
+ assert.equal(rejectedLane.status, 2, output(rejectedLane));
501
+ assert.match(output(rejectedLane), /does not match persisted workflow_policy\.lane/);
502
+ assert.equal(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'), beforeRejectedLane);
503
+ assert.equal(fs.existsSync(path.join(specDir, 'tasks/task-R1-03-invalid.md')), false);
504
+ } finally {
505
+ fs.rmSync(root, { recursive: true, force: true });
506
+ }
507
+ });
508
+
509
+ test('scaffold emits bounded artifacts on demand without default research or reports ceremony', () => {
510
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-artifact-profile-'));
511
+ try {
512
+ const standardDefault = run(SCAFFOLD, ['standard-default', '--lane', 'Standard'], root);
513
+ assert.equal(standardDefault.status, 0, output(standardDefault));
514
+ const standardDir = path.join(root, 'specs', 'standard-default');
515
+ assert.deepEqual(fs.readdirSync(standardDir).sort(), [
516
+ 'design.md', 'feature-receipt.md', 'requirements.md', 'spec.json',
517
+ ]);
518
+
519
+ for (const args of [
520
+ ['standard-tasks', '--lane', 'Standard', '--tasks', 'R1-01-one'],
521
+ ['critical-no-research', '--lane', 'Critical', '--tasks', 'R1-01-one'],
522
+ ]) {
523
+ const result = run(SCAFFOLD, args, root);
524
+ assert.equal(result.status, 0, output(result));
525
+ const specDir = path.join(root, 'specs', args[0]);
526
+ assert.equal(fs.existsSync(path.join(specDir, 'research.md')), false);
527
+ assert.equal(fs.existsSync(path.join(specDir, 'reports')), false);
528
+ assert.equal(fs.existsSync(path.join(specDir, 'tasks/task-R1-01-one.md')), true);
529
+ }
530
+
531
+ for (const [name, risk] of [['critical-auth-case', 'Auth'], ['critical-auth-alias', 'authentication']]) {
532
+ const result = run(SCAFFOLD, [name, '--risks', risk, '--tasks', 'R1-01-one'], root);
533
+ assert.equal(result.status, 0, output(result));
534
+ const specDir = path.join(root, 'specs', name);
535
+ const spec = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'));
536
+ assert.equal(spec.workflow_policy.lane, 'Critical');
537
+ assert.equal(fs.existsSync(path.join(specDir, 'research.md')), true);
538
+ }
539
+
540
+ const unknown = run(SCAFFOLD, ['unknown-risk', '--risks', 'unclassified-risk'], root);
541
+ assert.equal(unknown.status, 0, output(unknown));
542
+ const unknownSpec = JSON.parse(fs.readFileSync(path.join(root, 'specs/unknown-risk/spec.json'), 'utf8'));
543
+ assert.equal(unknownSpec.workflow_policy.lane, 'Standard');
544
+ assert.equal(fs.existsSync(path.join(root, 'specs/unknown-risk/research.md')), false);
545
+
546
+ const authTemplate = run(SCAFFOLD, [
547
+ 'critical-auth-template', '--risks', 'auth', '--tasks', 'R1-01-artifact',
548
+ ], root);
549
+ assert.equal(authTemplate.status, 0, output(authTemplate));
550
+ const authTemplateDir = path.join(root, 'specs', 'critical-auth-template');
551
+ const unfilled = run(VALIDATOR, [authTemplateDir], ROOT);
552
+ assert.notEqual(unfilled.status, 0);
553
+ assert.match(output(unfilled), /Evidence Summary must contain concrete evidence/);
554
+ completeScaffoldedSpec(authTemplateDir);
555
+ const filled = run(VALIDATOR, [authTemplateDir], ROOT);
556
+ assert.equal(filled.status, 0, output(filled));
557
+ } finally {
558
+ fs.rmSync(root, { recursive: true, force: true });
559
+ }
560
+ });
561
+
562
+ test('strict validator requires numeric IDs, rejects phantom mappings, and grounds research content', () => {
563
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-strict-grounding-'));
564
+ try {
565
+ const strictPolicy = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
566
+ const noIds = createSpec(root, {
567
+ name: 'no-numeric-ids',
568
+ requirements: '# Requirements\n\nThe behavior has no numeric identifier.\n',
569
+ spec: { workflow_policy: strictPolicy },
570
+ });
571
+ const noIdsResult = run(VALIDATOR, [noIds], ROOT);
572
+ assert.notEqual(noIdsResult.status, 0);
573
+ assert.match(output(noIdsResult), /strict workflow requires numeric requirement IDs/);
574
+
575
+ const phantom = createSpec(root, {
576
+ name: 'phantom-mapping',
577
+ task: task({ mapping: '9.1' }),
578
+ spec: { workflow_policy: strictPolicy },
579
+ });
580
+ const phantomResult = run(VALIDATOR, [phantom], ROOT);
581
+ assert.notEqual(phantomResult.status, 0);
582
+ assert.match(output(phantomResult), /references unknown requirement R9/);
583
+
584
+ const researchPlaceholder = createSpec(root, {
585
+ name: 'research-placeholder',
586
+ task: task({ related: '`src/strict-only.js` | Read' }),
587
+ spec: { workflow_policy: strictPolicy },
588
+ });
589
+ write(path.join(researchPlaceholder, 'research.md'), '# Research\n\n## Evidence Summary\n- TBD\n');
590
+ const placeholderResult = run(VALIDATOR, [researchPlaceholder], ROOT);
591
+ assert.notEqual(placeholderResult.status, 0);
592
+ assert.match(output(placeholderResult), /Evidence Summary must contain concrete evidence/);
593
+
594
+ write(path.join(researchPlaceholder, 'research.md'), '# Research\n\n## Evidence Summary\n- Scout inspected `src/one.js` and confirmed the caller path.\n');
595
+ const concreteResult = run(VALIDATOR, [researchPlaceholder], ROOT);
596
+ assert.equal(concreteResult.status, 0, output(concreteResult));
597
+ } finally {
598
+ fs.rmSync(root, { recursive: true, force: true });
599
+ }
600
+ });
601
+
602
+ test('spec-ready accepts pending execution evidence without an audit receipt', () => {
603
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-spec-ready-'));
604
+ try {
605
+ const standardDir = createSpec(root, {
606
+ name: 'pending-standard',
607
+ task: task({ related: '`src/pending-only.js` | Read' }),
608
+ spec: {
609
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: {} }),
610
+ ready_for_implementation: true,
611
+ approvals: {
612
+ requirements: { generated: true, agent_validated: true, user_approved: true },
613
+ design: { generated: true, agent_validated: true, user_approved: true },
614
+ tasks: { generated: true, agent_validated: true, user_approved: true },
615
+ },
616
+ validation: { status: 'completed' },
617
+ timestamps: { validation_done: '2026-08-11T00:00:00+07:00' },
618
+ },
619
+ });
620
+ write(path.join(standardDir, 'feature-receipt.md'), '# Feature Verification Receipt\n\nVerification: PENDING\n\nStatus: in_progress\n');
621
+ write(path.join(standardDir, 'tasks/task-R1-01-one.md'), `${fs.readFileSync(path.join(standardDir, 'tasks/task-R1-01-one.md'), 'utf8')}\nVerification: PENDING\n`);
622
+ const standardResult = run(VALIDATOR, [standardDir], ROOT);
623
+ assert.equal(standardResult.status, 0, output(standardResult));
624
+
625
+ const criticalDir = createSpec(root, {
626
+ name: 'pending-critical',
627
+ task: task({ related: '`src/pending-only.js` | Read' }),
628
+ spec: {
629
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
630
+ ready_for_implementation: true,
631
+ approvals: {
632
+ requirements: { generated: true, agent_validated: true, user_approved: true },
633
+ design: { generated: true, agent_validated: true, user_approved: true },
634
+ tasks: { generated: true, agent_validated: true, user_approved: true },
635
+ },
636
+ validation: { status: 'completed' },
637
+ timestamps: { validation_done: '2026-08-11T00:00:00+07:00' },
638
+ },
639
+ });
640
+ const criticalResult = run(VALIDATOR, [criticalDir], ROOT);
641
+ assert.equal(criticalResult.status, 0, output(criticalResult));
642
+ } finally {
643
+ fs.rmSync(root, { recursive: true, force: true });
644
+ }
645
+ });
646
+
647
+ test('P1 bounded Standard validates without a task registry and fails closed on receipt or policy shape errors', () => {
648
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-bounded-'));
649
+ try {
650
+ const specDir = createBoundedSpec(root);
651
+ const valid = run(VALIDATOR, [specDir], ROOT);
652
+ assert.equal(valid.status, 0, output(valid));
653
+ const persisted = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'));
654
+ assert.equal(persisted.workflow_policy.artifact_profile, 'bounded');
655
+ assert.equal(Object.hasOwn(persisted, 'task_files'), false);
656
+ assert.equal(Object.hasOwn(persisted, 'task_registry'), false);
657
+
658
+ fs.unlinkSync(path.join(specDir, 'feature-receipt.md'));
659
+ const missingReceipt = run(VALIDATOR, [specDir], ROOT);
660
+ assert.notEqual(missingReceipt.status, 0);
661
+ assert.match(output(missingReceipt), /feature-receipt\.md: required/);
662
+
663
+ write(path.join(specDir, 'feature-receipt.md'), '# Feature Verification Receipt\n\nVerification: PENDING\n\nStatus: in_progress\n\nBlocker: awaiting execution proof\n');
664
+ const malformed = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'));
665
+ malformed.workflow_policy.extra = true;
666
+ fs.writeFileSync(path.join(specDir, 'spec.json'), JSON.stringify(malformed, null, 2));
667
+ const malformedResult = run(VALIDATOR, [specDir], ROOT);
668
+ assert.notEqual(malformedResult.status, 0);
669
+ assert.match(output(malformedResult), /workflow_policy fields must be exactly/);
670
+ } finally {
671
+ fs.rmSync(root, { recursive: true, force: true });
672
+ }
673
+ });
674
+
675
+ test('P1 Standard scaffold remains bounded while Critical risk requires a task bundle', () => {
676
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-lanes-'));
677
+ try {
678
+ const scaffoldSource = fs.readFileSync(SCAFFOLD, 'utf8');
679
+ assert.match(scaffoldSource, /--lane Standard\|Critical/);
680
+ assert.doesNotMatch(scaffoldSource, /\[--lane Direct\|/);
681
+ const direct = run(SCAFFOLD, ['direct-scaffold', '--lane', 'Direct'], root);
682
+ assert.equal(direct.status, 2, output(direct));
683
+ assert.match(output(direct), /Direct lane does not create a spec/);
684
+ assert.doesNotMatch(output(direct), /no trusted runtime issuer/i);
685
+
686
+ const standard = run(SCAFFOLD, ['bounded-standard', '--lane', 'Standard'], root);
687
+ assert.equal(standard.status, 0, output(standard));
688
+ const standardDir = path.join(root, 'specs', 'bounded-standard');
689
+ const standardSpec = JSON.parse(fs.readFileSync(path.join(standardDir, 'spec.json'), 'utf8'));
690
+ assert.equal(standardSpec.workflow_policy.lane, 'Standard');
691
+ assert.deepEqual(standardSpec.workflow_policy.proof_obligations, ['needsInspection', 'needsExecutionProof']);
692
+ assert.equal(fs.existsSync(path.join(standardDir, 'tasks')), false);
693
+ assert.equal(fs.existsSync(path.join(standardDir, 'task_registry')), false);
694
+ const standardValidation = run(VALIDATOR, [standardDir], ROOT);
695
+ assert.equal(standardValidation.status, 0, output(standardValidation));
696
+
697
+ const critical = run(SCAFFOLD, ['critical-risk', '--risks', 'auth'], root);
698
+ assert.equal(critical.status, 2, output(critical));
699
+ assert.match(output(critical), /Critical workflow policy requires a task bundle/);
700
+
701
+ const criticalHappy = run(SCAFFOLD, [
702
+ 'critical-happy',
703
+ '--risks', 'auth',
704
+ '--tasks', 'R1-01-artifact',
705
+ ], root);
706
+ assert.equal(criticalHappy.status, 0, output(criticalHappy));
707
+ const criticalDir = path.join(root, 'specs', 'critical-happy');
708
+ const criticalSpec = JSON.parse(fs.readFileSync(path.join(criticalDir, 'spec.json'), 'utf8'));
709
+ assert.equal(criticalSpec.workflow_policy.lane, 'Critical');
710
+ assert.equal(criticalSpec.workflow_policy.artifact_profile, 'strict');
711
+ assert.deepEqual(criticalSpec.workflow_policy.proof_obligations, [
712
+ 'needsInspection',
713
+ 'needsExecutionProof',
714
+ 'needsIndependentAudit',
715
+ 'needsResearchGrounding',
716
+ ]);
717
+ assert.deepEqual(criticalSpec.task_files, ['tasks/task-R1-01-artifact.md']);
718
+ assert.equal(fs.existsSync(path.join(criticalDir, 'research.md')), true);
719
+ completeScaffoldedSpec(criticalDir);
720
+ const criticalValidation = run(VALIDATOR, [criticalDir], ROOT);
721
+ assert.equal(criticalValidation.status, 0, output(criticalValidation));
722
+ } finally {
723
+ fs.rmSync(root, { recursive: true, force: true });
724
+ }
725
+ });
726
+
727
+ test('producer scaffold emits optional artifacts and validator accepts or rejects the canonical shape', () => {
728
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-artifacts-'));
729
+ try {
730
+ const ordinary = run(SCAFFOLD, ['ordinary', '--tasks', 'R1-01-artifact'], root);
731
+ assert.equal(ordinary.status, 0, output(ordinary));
732
+ const ordinarySpec = JSON.parse(fs.readFileSync(path.join(root, 'specs/ordinary/spec.json'), 'utf8'));
733
+ assert.equal(Object.hasOwn(ordinarySpec.task_registry['tasks/task-R1-01-artifact.md'], 'artifacts'), false);
734
+ completeScaffoldedSpec(path.join(root, 'specs/ordinary'));
735
+ const ordinaryAfterFill = run(VALIDATOR, [path.join(root, 'specs/ordinary')], ROOT);
736
+ assert.equal(ordinaryAfterFill.status, 0, output(ordinaryAfterFill));
737
+
738
+ const declared = run(SCAFFOLD, [
739
+ 'declared',
740
+ '--tasks',
741
+ 'R1-01-artifact',
742
+ '--artifacts',
743
+ JSON.stringify({ 'R1-01-artifact': ['dist/output.js'] }),
744
+ ], root);
745
+ assert.equal(declared.status, 0, output(declared));
746
+ const declaredDir = path.join(root, 'specs/declared');
747
+ const declaredSpec = completeScaffoldedSpec(declaredDir);
748
+ assert.deepEqual(declaredSpec.task_registry['tasks/task-R1-01-artifact.md'].artifacts, ['dist/output.js']);
749
+ const declaredValidation = run(VALIDATOR, [declaredDir], ROOT);
750
+ assert.equal(declaredValidation.status, 0, output(declaredValidation));
751
+
752
+ const malformedPath = path.join(declaredDir, 'spec.json');
753
+ const malformedSpec = JSON.parse(fs.readFileSync(malformedPath, 'utf8'));
754
+ malformedSpec.task_registry['tasks/task-R1-01-artifact.md'].artifacts = ['../outside'];
755
+ fs.writeFileSync(malformedPath, JSON.stringify(malformedSpec, null, 2));
756
+ const malformedValidation = run(VALIDATOR, [declaredDir], ROOT);
757
+ assert.notEqual(malformedValidation.status, 0);
758
+ assert.match(output(malformedValidation), /task_registry.*artifacts.*safe relative path/);
759
+ } finally {
760
+ fs.rmSync(root, { recursive: true, force: true });
761
+ }
762
+ });
763
+
59
764
  test('validator rejects missing explicit requirement and sub-criterion mappings', () => {
60
765
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-validator-mapping-'));
61
766
  try {