@haposoft/cafekit 0.18.0 → 0.18.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 (83) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/codex-hooks.test.js +169 -236
  3. package/bin/__tests__/codex-native.test.js +54 -114
  4. package/bin/__tests__/develop-contract.test.js +101 -1361
  5. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  6. package/bin/__tests__/package-inventory.test.js +1 -395
  7. package/bin/__tests__/spec-narrowing.test.js +30 -134
  8. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  9. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  10. package/bin/__tests__/validator-grounding.test.js +17 -2929
  11. package/bin/lib/managed-writer.js +2 -3
  12. package/bin/phases/codex-runtime.js +6 -3
  13. package/bin/phases/copy-payload.js +13 -28
  14. package/bin/phases/omp-runtime.js +6 -3
  15. package/package.json +2 -3
  16. package/src/claude/agents/code-auditor.md +9 -6
  17. package/src/claude/agents/debugger.md +3 -9
  18. package/src/claude/agents/docs-keeper.md +4 -7
  19. package/src/claude/agents/git-ops.md +2 -4
  20. package/src/claude/agents/implementer.md +6 -7
  21. package/src/claude/agents/inspector.md +2 -0
  22. package/src/claude/agents/project-manager.md +2 -2
  23. package/src/claude/agents/spec-maker.md +4 -4
  24. package/src/claude/agents/test-runner.md +8 -11
  25. package/src/claude/agents/ui-ux-designer.md +0 -1
  26. package/src/claude/hooks/__tests__/spec-gate.test.js +170 -330
  27. package/src/claude/hooks/__tests__/spec-state-touched.test.js +26 -0
  28. package/src/claude/hooks/spec-gate.cjs +8 -63
  29. package/src/claude/hooks/spec-state.cjs +47 -38
  30. package/src/claude/migration-manifest.json +12 -7
  31. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  32. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  33. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  34. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  35. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  36. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  37. package/src/claude/rules/ai-dev-rules.md +1 -2
  38. package/src/claude/rules/workflow.md +1 -1
  39. package/src/claude/scripts/spec-receipt.cjs +0 -114
  40. package/src/claude/scripts/spec-resolver.cjs +41 -457
  41. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  42. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  43. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  44. package/src/claude/skills/code-review/SKILL.md +25 -108
  45. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  46. package/src/claude/skills/develop/SKILL.md +22 -70
  47. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  48. package/src/claude/skills/research/SKILL.md +24 -55
  49. package/src/claude/skills/sync/SKILL.md +12 -34
  50. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  51. package/src/claude/skills/test/SKILL.md +39 -123
  52. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  53. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  54. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  55. package/src/claude/skills/web-testing/SKILL.md +2 -20
  56. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  57. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  58. package/src/codex/hooks/spec-gate.cjs +11 -68
  59. package/src/codex/hooks/spec-state.cjs +37 -41
  60. package/src/common/AGENTS.md +4 -10
  61. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  62. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  63. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  64. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  65. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  66. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  67. package/src/claude/hooks/lib/context.cjs +0 -527
  68. package/src/claude/hooks/lib/detect.cjs +0 -474
  69. package/src/claude/scripts/change-firewall.cjs +0 -1748
  70. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  71. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  72. package/src/claude/scripts/spec-final-state.cjs +0 -290
  73. package/src/claude/scripts/spec-ground.cjs +0 -650
  74. package/src/claude/scripts/spec-readiness.cjs +0 -425
  75. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  76. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  77. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  78. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  79. package/src/claude/skills/specs/templates/design.md +0 -82
  80. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  81. package/src/claude/skills/specs/templates/requirements.md +0 -42
  82. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  83. package/src/claude/skills/specs/templates/task.md +0 -47
@@ -1,11 +1,9 @@
1
1
  'use strict';
2
2
 
3
- // Two Stop hooks ask two different questions about the same repository, so they narrow an
4
- // ambiguous scan differently. The gate asks which packet still has unfinished work, so it
5
- // can validate that packet's receipts. Closeout approval asks which packet is claiming
6
- // closeout, so it can demand a one-time approval for it. Counting finished history as
7
- // ambiguity is what left a repository of accumulated features permanently blocked, with
8
- // the documented escape — an explicit feature target — unavailable.
3
+ // The Stop gate narrows an ambiguous scan by asking which packet still has unfinished
4
+ // work, so it can validate that packet's receipts. Counting finished history as ambiguity
5
+ // is what left a repository of accumulated features permanently blocked, with the
6
+ // documented escape — an explicit feature target — unavailable.
9
7
 
10
8
  const assert = require('node:assert/strict');
11
9
  const { spawnSync } = require('node:child_process');
@@ -38,7 +36,7 @@ function legacyPacket(root, name, { status, phase, taskStatus = 'done' } = {}) {
38
36
  }
39
37
 
40
38
  /** A process-first packet: plan.md plus one flat task file. */
41
- function workflowPacket(root, name, taskStatus) {
39
+ function workflowPacket(root, name, taskStatus, nextStatus = null) {
42
40
  const dir = path.join(root, 'specs', name);
43
41
  fs.mkdirSync(dir, { recursive: true });
44
42
  fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
@@ -46,6 +44,12 @@ function workflowPacket(root, name, taskStatus) {
46
44
  path.join(dir, 'task-01-x.md'),
47
45
  `# Task 01\n\nStatus: ${taskStatus}\n\n## Dependencies\n- none\n`
48
46
  );
47
+ if (nextStatus) {
48
+ fs.writeFileSync(
49
+ path.join(dir, 'task-02-y.md'),
50
+ `# Task 02\n\nStatus: ${nextStatus}\n\n## Dependencies\n- none\n`
51
+ );
52
+ }
49
53
  }
50
54
 
51
55
  function recordActiveFeature(root, contents) {
@@ -63,34 +67,19 @@ function gateResolution(root) {
63
67
 
64
68
  // ── The gate: which packet still has work ───────────────────────────────────────────
65
69
 
66
- test('a legacy repository resolves its one unfinished packet', () => {
70
+ test('a leftover legacy packet creates no ambiguity for the gate', () => {
67
71
  withProject((root) => {
68
72
  for (let index = 1; index <= 3; index += 1) {
69
- legacyPacket(root, `done-${index}`, { status: 'done', phase: 'closeout' });
73
+ legacyPacket(root, `history-${index}`, { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
70
74
  }
71
- legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
75
+ workflowPacket(root, 'active-now', 'pending');
72
76
  const resolved = gateResolution(root);
73
77
  assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
74
78
  assert.equal(resolved.featureName, 'active-now');
79
+ assert.deepEqual(RESOLVER.findLegacyPackets(root, {}), ['history-1', 'history-2', 'history-3']);
75
80
  });
76
81
  });
77
82
 
78
- test('a legacy registry written with the older vocabulary counts as finished', () => {
79
- // Issue #79's repository was upgraded from an older release whose task_registry used
80
- // `completed`. Today's validator allows only pending/in_progress/blocked/done, so
81
- // narrowing read those packets as unfinished and the block survived the fix that was
82
- // supposed to clear it. Measured on the reported shape before this: 18 candidates.
83
- for (const status of ['done', 'completed', 'complete']) {
84
- withProject((root) => {
85
- for (let index = 1; index <= 3; index += 1) {
86
- legacyPacket(root, `history-${index}`, { taskStatus: status });
87
- }
88
- legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
89
- assert.equal(gateResolution(root).featureName, 'active-now', `task status ${status} must read as finished`);
90
- });
91
- }
92
- });
93
-
94
83
  test('a process-first packet keeps the strict task vocabulary', () => {
95
84
  // The widened set is legacy-only. A process-first task saying `completed` is malformed,
96
85
  // not finished, and must not be silently read as done.
@@ -102,20 +91,6 @@ test('a process-first packet keeps the strict task vocabulary', () => {
102
91
  });
103
92
  });
104
93
 
105
- test('a legacy candidate never reaches the bulk branch', () => {
106
- // The bulk branch exits before the semantic-digest, FLASH_UNVERIFIED, feature-receipt
107
- // and completion-policy layers, so a legacy packet reaching it would turn a block into
108
- // a silent pass. With every legacy packet finished there is nothing to narrow to, and
109
- // the existing ambiguity must stand rather than the set being audited.
110
- withProject((root) => {
111
- legacyPacket(root, 'done-1', { status: 'done', phase: 'closeout' });
112
- legacyPacket(root, 'done-2', { status: 'done', phase: 'closeout' });
113
- const resolved = gateResolution(root);
114
- assert.notEqual(resolved.layoutKind, 'process-v3-completed-set');
115
- assert.ok(resolved.error, 'an all-legacy finished set must stay ambiguous');
116
- });
117
- });
118
-
119
94
  test('a process-first set still reaches the bulk branch', () => {
120
95
  withProject((root) => {
121
96
  workflowPacket(root, 'done-1', 'done');
@@ -132,101 +107,21 @@ test('one unfinished process-first packet still narrows', () => {
132
107
  });
133
108
  });
134
109
 
135
- // ── Closeout approval: which packet is claiming closeout ────────────────────────────
136
-
137
- test('one packet claiming closeout among finished ones resolves', () => {
138
- withProject((root) => {
139
- legacyPacket(root, 'shipped', { status: 'in_progress', phase: 'execution', taskStatus: 'done' });
140
- legacyPacket(root, 'closing', { status: 'done', phase: 'closeout' });
141
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
142
- assert.equal(resolved.error, undefined);
143
- assert.equal(resolved.featureName, 'closing');
144
- });
145
- });
146
-
147
- test('a lone finished packet still resolves', () => {
148
- // Four existing cases depend on this: closeout approval is claimed against a finished
149
- // spec, so a single packet must resolve whatever its status.
150
- for (const status of ['done', 'completed']) {
151
- withProject((root) => {
152
- legacyPacket(root, 'only', { status });
153
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
154
- assert.equal(resolved.featureName, 'only', `status ${status} must still resolve`);
155
- });
156
- }
157
- });
158
-
159
- test('a lone packet mid-execution still resolves', () => {
160
- // The closeout filter would drop this one, so the single-candidate return above it is
161
- // what keeps a repository holding exactly one in-flight packet resolvable. Without it
162
- // the hook goes silent for that repository instead of checking the packet.
163
- withProject((root) => {
164
- legacyPacket(root, 'solo', { status: 'in_progress', phase: 'implementation', taskStatus: 'pending' });
165
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
166
- assert.equal(resolved.featureName, 'solo');
167
- });
168
- });
169
-
170
- test('no packet claiming closeout resolves to null', () => {
171
- // null means nothing is in flight and the hook stays silent; an error would keep
172
- // blocking a repository where no closeout is being claimed at all.
173
- withProject((root) => {
174
- legacyPacket(root, 'a', { status: 'in_progress', phase: 'execution' });
175
- legacyPacket(root, 'b', { status: 'in_progress', phase: 'execution' });
176
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }), null);
177
- });
178
- });
179
-
180
- test('several packets claiming closeout stay ambiguous and name only those', () => {
181
- withProject((root) => {
182
- legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
183
- legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
184
- legacyPacket(root, 'working', { status: 'in_progress', phase: 'execution' });
185
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
186
- assert.equal(resolved.error, 'multiple_persisted');
187
- assert.deepEqual(resolved.candidates, ['closing-a', 'closing-b']);
188
- });
189
- });
190
-
191
110
  // ── The recorded active feature: the escape the block told users to take ────────────
192
111
 
193
- test('a recorded active feature resolves two packets claiming closeout', () => {
194
- withProject((root) => {
195
- legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
196
- legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
197
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).error, 'multiple_persisted');
198
- recordActiveFeature(root, '{"featureName": "closing-b"}\n');
199
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
200
- assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
201
- assert.equal(resolved.featureName, 'closing-b');
202
- });
203
- });
204
-
205
- test('a payload target beats the recorded file', () => {
206
- withProject((root) => {
207
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
208
- legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
209
- recordActiveFeature(root, '{"featureName": "alpha"}\n');
210
- const resolved = RESOLVER.resolvePersistedSpec({
211
- projectRoot: root, runtime: {}, target: { featureName: 'beta' },
212
- });
213
- assert.equal(resolved.featureName, 'beta', 'the caller must win');
214
- });
215
- });
216
-
217
112
  test('a blank recorded feature is ignored rather than malformed', () => {
218
113
  // explicitTargetValue turns a blank into null, and a null target becomes an
219
114
  // explicit_malformed error that blocks. The reader must return null instead, or every
220
115
  // project holding a stray file would be blocked by the very escape hatch.
221
116
  for (const contents of ['{"featureName": " "}', '{"featureName": 42}', '{}', 'not json', '[]']) {
222
117
  withProject((root) => {
223
- legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
118
+ workflowPacket(root, 'only', 'pending');
224
119
  recordActiveFeature(root, contents);
225
120
  assert.equal(
226
121
  RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null,
227
122
  `${contents} must be invisible`
228
123
  );
229
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
124
+ assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
230
125
  });
231
126
  }
232
127
  });
@@ -234,11 +129,12 @@ test('a blank recorded feature is ignored rather than malformed', () => {
234
129
  test('a recorded feature cannot escape the specs root', () => {
235
130
  // The reader adds no trust: the value goes through the resolver's existing checks.
236
131
  withProject((root) => {
237
- legacyPacket(root, 'a', { status: 'done', phase: 'closeout' });
238
- legacyPacket(root, 'b', { status: 'done', phase: 'closeout' });
132
+ workflowPacket(root, 'a', 'pending');
133
+ workflowPacket(root, 'b', 'pending');
239
134
  recordActiveFeature(root, '{"featureName": "../../etc"}');
240
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
241
- assert.ok(resolved.error, 'an escaping name must produce the existing error, not a resolution');
135
+ const target = RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} });
136
+ const resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, target });
137
+ assert.equal(resolved.error, 'explicit_malformed', 'an escaping name must produce the existing error, not a resolution');
242
138
  assert.notEqual(resolved.featureName, '../../etc');
243
139
  });
244
140
  });
@@ -248,7 +144,7 @@ test('a symlinked recorded feature is refused', () => {
248
144
  // project does not contain. The repository already refuses symlinked runtime
249
145
  // dependencies elsewhere; this reader follows the same rule.
250
146
  withProject((root) => {
251
- legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
147
+ workflowPacket(root, 'only', 'pending');
252
148
  const outside = path.join(root, 'outside.json');
253
149
  fs.writeFileSync(outside, '{"featureName": "only"}');
254
150
  const dir = path.join(root, 'specs', '_shared');
@@ -260,9 +156,9 @@ test('a symlinked recorded feature is refused', () => {
260
156
 
261
157
  test('an absent file leaves every project exactly as it was', () => {
262
158
  withProject((root) => {
263
- legacyPacket(root, 'only', { status: 'in_progress', phase: 'execution' });
159
+ workflowPacket(root, 'only', 'pending');
264
160
  assert.equal(RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null);
265
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
161
+ assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
266
162
  });
267
163
  });
268
164
 
@@ -272,8 +168,8 @@ test('the shared workflow resolver ignores the recorded file', () => {
272
168
  // so the two gates apply it at their own call sites instead. Neither of those suites
273
169
  // writes the file, so nothing else pins this containment.
274
170
  withProject((root) => {
275
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
276
- legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
171
+ workflowPacket(root, 'alpha', 'done');
172
+ workflowPacket(root, 'beta', 'done');
277
173
  recordActiveFeature(root, '{"featureName": "beta"}');
278
174
  const resolved = RESOLVER.resolveWorkflowCandidate({
279
175
  projectRoot: root, runtime: {}, target: null, includeCompleted: true,
@@ -288,7 +184,7 @@ test('the codex gate consults the file only after its payload', () => {
288
184
  assert.equal(typeof utils.readActiveFeatureTarget, 'function', 'the wrapper must be exported');
289
185
  assert.equal(typeof utils.extractExplicitTarget, 'function', 'the gate needs the payload extractor');
290
186
  withProject((root) => {
291
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
187
+ workflowPacket(root, 'alpha', 'done');
292
188
  recordActiveFeature(root, '{"featureName": "alpha"}');
293
189
  assert.deepEqual(
294
190
  utils.extractExplicitTarget({ featureName: 'beta' }) || utils.readActiveFeatureTarget({ projectRoot: root, runtime: {} }),
@@ -338,8 +234,8 @@ for (const [runtimeName, hook] of [['claude', 'src/claude/hooks/spec-gate.cjs']]
338
234
  // identity complaint whose stated remedy nothing could supply; naming the feature
339
235
  // makes the gate resolve it and report the violation that was there all along.
340
236
  gitProject((dir) => {
341
- legacyPacket(dir, 'alpha', { status: 'done', phase: 'closeout' });
342
- legacyPacket(dir, 'beta', { status: 'done', phase: 'closeout' });
237
+ workflowPacket(dir, 'alpha', 'done', 'pending');
238
+ workflowPacket(dir, 'beta', 'done', 'pending');
343
239
 
344
240
  const blocked = runGate(hook, dir);
345
241
  assert.match(blocked, /multiple active specs detected/, 'the reported block must reproduce');
@@ -5,21 +5,8 @@ const crypto = require('node:crypto');
5
5
  const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
- const { spawnSync } = require('node:child_process');
9
8
  const test = require('node:test');
10
9
 
11
- const ROOT = path.resolve(__dirname, '../..');
12
- const SCRIPTS = path.join(ROOT, 'src/claude/scripts');
13
- const POLICY = require(path.join(SCRIPTS, 'workflow-policy.cjs'));
14
- const PROVENANCE = require(path.join(SCRIPTS, 'provenance.cjs'));
15
- const RECEIPT = require(path.join(SCRIPTS, 'spec-receipt.cjs'));
16
- const SCAFFOLD = path.join(SCRIPTS, 'spec-scaffold.cjs');
17
- const VALIDATOR = path.join(SCRIPTS, 'validate-spec-output.cjs');
18
- const AUTHORING_VALIDATION = path.join(SCRIPTS, 'spec-authoring-validation.cjs');
19
- const RUNTIME_CLOSURE = require('./test-runtime-dependency-closure.cjs');
20
- const SEMANTIC_MODEL = require(path.join(SCRIPTS, 'spec-semantic-model.cjs'));
21
- const FEATURE = 'closeout-demo';
22
- const TASK = 'tasks/task-R1-01-closeout.md';
23
10
  const TEMP_RUN_ID = String(process.env.CAFEKIT_CLOSEOUT_TEMP_RUN_ID || `${process.pid}-${Date.now()}`)
24
11
  .replace(/[^a-zA-Z0-9_-]/g, '-');
25
12
  const TEMP_PREFIX = `cafekit-v2-closeout-owned-${TEMP_RUN_ID}`;
@@ -69,362 +56,3 @@ test('temp resource scope cleans exact owned paths and restores HOME after failu
69
56
  assert.equal(Object.hasOwn(process.env, 'HOME'), originalHome.present);
70
57
  if (originalHome.present) assert.equal(process.env.HOME, originalHome.value);
71
58
  });
72
-
73
- function gitRoot(resources) {
74
- const root = resources.directory('git-root');
75
- for (const args of [['init', '-q'], ['config', 'user.email', 'test@example.invalid'], ['config', 'user.name', 'Test'], ['commit', '--allow-empty', '-qm', 'base']]) {
76
- const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
77
- assert.equal(result.status, 0, result.stderr);
78
- }
79
- return root;
80
- }
81
-
82
- function runtime(root, session = 'execution-session') {
83
- return PROVENANCE.deriveRuntimeContext({
84
- projectRoot: root,
85
- specsRoot: path.join(root, 'specs'),
86
- specFile: path.join(root, 'specs', FEATURE, 'spec.json'),
87
- featureName: FEATURE,
88
- runtimeSession: session,
89
- });
90
- }
91
-
92
- function receiptBody(ctx, extra = '') {
93
- return [
94
- 'Verification: PASS',
95
- 'Command: node --test',
96
- 'Exit: 0',
97
- 'Result: PASS',
98
- 'Expected: focused verification passes',
99
- 'Observed: focused verification passed',
100
- `Base: ${ctx.base}`,
101
- `Head: ${ctx.head}`,
102
- extra,
103
- ].filter(Boolean).join('\n') + '\n';
104
- }
105
-
106
- function prepareCanonicalTaskless(root, { specStatus, featureReceipt }) {
107
- const scaffold = spawnSync(process.execPath, [SCAFFOLD, FEATURE, '--title', 'Closeout fixture'], { cwd: root, encoding: 'utf8' });
108
- assert.equal(scaffold.status, 0, scaffold.stderr);
109
- const featureDir = path.join(root, 'specs', FEATURE);
110
- fs.mkdirSync(path.join(root, 'src'), { recursive: true });
111
- fs.mkdirSync(path.join(root, 'test'), { recursive: true });
112
- fs.writeFileSync(path.join(root, 'src/final-state.js'), 'module.exports = { ready: true };\n');
113
- fs.writeFileSync(path.join(root, 'test/final-state.test.js'), "require('node:test')('final state', () => {});\n");
114
- fs.writeFileSync(path.join(featureDir, 'requirements.md'), `# Requirements
115
-
116
- ## Requirements
117
- ### Requirement 1: Closeout state
118
- - **R1.1**: When closeout is requested, the system shall accept current validated state and reject stale semantic state.
119
- - **R1.2**: The system shall prove the current digest through the grounded final-state boundary.
120
- `);
121
- fs.writeFileSync(path.join(featureDir, 'design.md'), `# Design
122
-
123
- ## Architecture
124
- The final-state authority recomputes current semantic state before closeout.
125
-
126
- ## Typed Anchors
127
- | ID | Type | Target | Role | Access | Action |
128
- |---|---|---|---|---|---|
129
- | A-D-01 | file | \`src/final-state.js\` | design boundary | read | read |
130
- | A-D-02 | file | \`test/final-state.test.js\` | separate proof boundary | read | read |
131
-
132
- ## Canonical Contracts & Invariants
133
- ### D1 — Current-state decision
134
- The current persisted artifacts are authoritative.
135
- ### I1 — Stale-state invariant
136
- A changed semantic artifact cannot retain readiness.
137
- ### C1 — Closeout contract
138
- Only a validated and grounded current digest reaches closeout.
139
-
140
- ## Verification Definitions
141
- - **V1**: Subject criteria R1.1; Subject owner A-D-01; Proof criteria R1.2; Proof owner A-D-02; Evidence anchor A-D-02; Decision refs D1, I1, C1; Method inspection \`src/final-state.js\` via A-D-01; Expected current validated state reaches closeout with the reviewed digest; Negative/failure stale semantic state is rejected before execution closeout; Reachability/grounding entrypoint \`src/final-state.js\` via A-D-01, A-D-02.
142
-
143
- ## Verification
144
- R1.1 and R1.2 are verified by V1 against D1, I1, and C1.
145
- `);
146
- const specPath = path.join(featureDir, 'spec.json');
147
- const seed = JSON.parse(fs.readFileSync(specPath, 'utf8'));
148
- const seedProjection = SEMANTIC_MODEL.modelFromMarkdown(featureDir, seed);
149
- assert.deepEqual(seedProjection.errors, []);
150
- seed.semantic_model = seedProjection.model;
151
- fs.writeFileSync(specPath, `${JSON.stringify(seed, null, 2)}\n`);
152
- // I21: only the C16 coordinator may set an authoring stage to `validated`,
153
- // and only together with the digest receipt that proves it fresh. The fixture
154
- // runs the real command instead of hand-authoring the enum.
155
- const authored = spawnSync(
156
- process.execPath,
157
- [AUTHORING_VALIDATION, featureDir, '--root', root],
158
- { cwd: root, encoding: 'utf8' },
159
- );
160
- assert.equal(authored.status, 0, `${authored.stdout}\n${authored.stderr}`);
161
- const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
162
- assert.equal(spec.authoring.requirements, 'validated');
163
- assert.equal(spec.authoring.design, 'validated');
164
- spec.validation.status = 'completed';
165
- spec.validation.semantic_review = {
166
- status: 'completed',
167
- semantic_digest: null,
168
- verdict: 'PASS',
169
- lifecycle_disposition: 'CONTINUE',
170
- findings: [],
171
- unresolved_decisions: [],
172
- graph_coverage: [
173
- 'criterion_local', 'cross_criterion', 'runtime_path',
174
- 'assumption_provenance', 'compatibility_migration',
175
- ].map((surface) => ({ surface, covered: true, notes: `${surface} reviewed for the closeout fixture.` })),
176
- repair_round: 0,
177
- reviewer_evidence: {
178
- assurance: 'Routine',
179
- summary: 'Closeout fixture reviewer confirmed both criteria against the grounded final-state boundary.',
180
- },
181
- reviewed_criteria: ['R1.1', 'R1.2'],
182
- counterexamples: [{
183
- criterion: 'R1.1', case_kind: 'failure',
184
- scenario: 'A requirements artifact changes after the reviewed digest was recorded.',
185
- expected: 'Final-state authority rejects closeout until the current digest is reviewed.',
186
- decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
187
- }, {
188
- criterion: 'R1.2', case_kind: 'adversarial',
189
- scenario: 'A closeout supplies execution provenance while retaining a digest for older authored artifacts.',
190
- expected: 'Final-state authority rejects the stale digest despite valid execution provenance.',
191
- decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
192
- }],
193
- };
194
- fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
195
- const digest = spawnSync(process.execPath, [VALIDATOR, featureDir, '--semantic-digest'], { cwd: root, encoding: 'utf8' });
196
- assert.equal(digest.status, 0, `${digest.stdout}\n${digest.stderr}`);
197
- spec.validation.semantic_review.semantic_digest = digest.stdout.trim();
198
- // C13: a completed receipt is only terminal when the durable history records
199
- // it. The entry binds the same semantic digest, the receipt's own canonical
200
- // digest, and the attempt index the receipt reports as repair_round.
201
- spec.validation.semantic_review_history.entries = [{
202
- sequence: 0,
203
- semantic_digest: spec.validation.semantic_review.semantic_digest,
204
- review_receipt_digest: `sha256:${crypto.createHash('sha256')
205
- .update(SEMANTIC_MODEL.stableJson(spec.validation.semantic_review), 'utf8').digest('hex')}`,
206
- verdict: 'PASS',
207
- lifecycle_disposition: 'CONTINUE',
208
- blocking_count: 0,
209
- attempt_index: spec.validation.semantic_review.repair_round,
210
- review_epoch: 0,
211
- }];
212
- spec.ready_for_implementation = true;
213
- spec.status = specStatus === 'complete' ? 'done' : specStatus;
214
- delete spec.current_phase;
215
- delete spec.phase;
216
- fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
217
- const ctx = runtime(root);
218
- if (featureReceipt) fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), receiptBody(ctx, `Feature: ${FEATURE}`));
219
- return { featureDir, ctx };
220
- }
221
-
222
- function prepare(root, { taskless = false, specStatus = 'in_progress', taskStatus = 'done', taskReceipt = true, legacy = false, featureReceipt = false } = {}) {
223
- if (taskless) return prepareCanonicalTaskless(root, { specStatus, featureReceipt });
224
- const featureDir = path.join(root, 'specs', FEATURE);
225
- fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
226
- const policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Routine' });
227
- const spec = {
228
- status: specStatus === 'complete' ? 'in_progress' : specStatus,
229
- current_phase: specStatus === 'complete' ? 'closeout' : 'implementation',
230
- feature_name: FEATURE, workflow_policy: policy, task_registry: {},
231
- };
232
- if (!taskless) spec.task_registry[TASK] = { status: taskStatus, completed_at: '2026-08-13T00:00:00.000Z', dependencies: [] };
233
- fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
234
- const ctx = runtime(root);
235
- if (!taskless) {
236
- const plan = [
237
- '# Task R1-01: closeout', `**Status:** ${taskStatus}`, '## Outcome', 'Closeout works.',
238
- '## Verification Plan', '- **Command:** `node --test`', '- **Expected:** focused verification passes',
239
- '- **Negative path:** not relevant', '- **Reachability:** not relevant',
240
- ].join('\n') + (legacy ? `\n## Evidence\n\n${receiptBody(ctx)}` : '\n');
241
- fs.writeFileSync(path.join(featureDir, TASK), plan);
242
- if (taskReceipt) {
243
- fs.mkdirSync(path.join(featureDir, 'receipts'), { recursive: true });
244
- fs.writeFileSync(path.join(featureDir, 'receipts', path.basename(TASK)), receiptBody(ctx, `Task: ${path.basename(TASK)}\nTask path: ${TASK}`));
245
- }
246
- }
247
- if (featureReceipt) fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), receiptBody(ctx, `Feature: ${FEATURE}`));
248
- return { featureDir, ctx };
249
- }
250
-
251
- function installGate(root, kind) {
252
- const runtimeDir = path.join(root, kind === 'claude' ? '.claude' : '.codex');
253
- fs.mkdirSync(path.join(runtimeDir, 'hooks'), { recursive: true });
254
- fs.mkdirSync(path.join(runtimeDir, 'hooks', 'lib'), { recursive: true });
255
- fs.mkdirSync(path.join(runtimeDir, 'scripts'), { recursive: true });
256
- // Copy the real transitive require closure rather than a hand-listed subset.
257
- // A hand-listed subset silently rots the moment one of these entrypoints
258
- // gains a new local dependency: the fixture keeps building, and the gate only
259
- // fails later with a bare MODULE_NOT_FOUND from inside the installed runtime.
260
- RUNTIME_CLOSURE.copyClaudeTestRuntime(ROOT, runtimeDir, [
261
- 'scripts/workflow-policy.cjs', 'scripts/provenance.cjs', 'scripts/spec-resolver.cjs',
262
- 'scripts/spec-receipt.cjs', 'scripts/validate-spec-output.cjs', 'scripts/spec-ground.cjs',
263
- 'scripts/spec-semantic-model.cjs', 'scripts/spec-final-state.cjs',
264
- 'hooks/lib/runtime-path-safety.cjs', 'hooks/lib/hook-state-dir.cjs', 'hooks/lib/runtime-dir.cjs',
265
- 'hooks/lib/hook-payload.cjs',
266
- ]);
267
- if (kind === 'claude') {
268
- fs.copyFileSync(path.join(ROOT, 'src/claude/hooks', 'spec-gate.cjs'), path.join(runtimeDir, 'hooks', 'spec-gate.cjs'));
269
- } else {
270
- fs.cpSync(path.join(ROOT, 'src/codex/hooks'), path.join(runtimeDir, 'hooks'), { recursive: true });
271
- }
272
- // The real installer materializes this canonical helper for both adapters.
273
- // Keep this narrow runtime fixture faithful to the same dependency closure.
274
- fs.copyFileSync(
275
- path.join(ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
276
- path.join(runtimeDir, 'hooks/lib/runtime-path-safety.cjs'),
277
- );
278
- return path.join(runtimeDir, 'hooks/spec-gate.cjs');
279
- }
280
-
281
- function gate(root, kind) {
282
- const home = path.join(root, '.claude', 'hooks', '.logs', 'test-home');
283
- fs.mkdirSync(home, { recursive: true });
284
- const hook = installGate(root, kind);
285
- const featureReceipt = path.join(root, 'specs', FEATURE, 'feature-receipt.md');
286
- if (fs.existsSync(featureReceipt)) {
287
- const current = runtime(root);
288
- fs.writeFileSync(featureReceipt, receiptBody(current, `Feature: ${FEATURE}`));
289
- assert.deepEqual(RECEIPT.checkFeatureReceipt(path.dirname(featureReceipt), current, POLICY).failures, []);
290
- }
291
- const result = spawnSync(process.execPath, [hook], {
292
- cwd: root,
293
- env: { ...process.env, HOME: home, USERPROFILE: home, ...(kind === 'claude' ? { CLAUDE_PROJECT_DIR: root } : {}) },
294
- input: JSON.stringify({ cwd: root, session_id: 'execution-session', hook_event_name: 'Stop', stop_hook_active: false }),
295
- encoding: 'utf8',
296
- });
297
- assert.equal(result.status, 0, result.stderr);
298
- return result.stdout.trim();
299
- }
300
-
301
- test('separate receipt passes; missing, traversal, symlink, and conflicting legacy proof fail closed', () => {
302
- withTempResources((resources) => {
303
- const root = gitRoot(resources);
304
- const { featureDir, ctx } = prepare(root);
305
- assert.deepEqual(RECEIPT.checkTaskReceipt(featureDir, TASK, { completed_at: '2026-08-13T00:00:00.000Z' }, ctx, POLICY).failures, []);
306
- fs.unlinkSync(path.join(featureDir, 'receipts', path.basename(TASK)));
307
- assert.ok(RECEIPT.checkTaskReceipt(featureDir, TASK, { completed_at: '2026-08-13T00:00:00.000Z' }, ctx, POLICY).failures.includes('missing_receipt'));
308
- assert.equal(RECEIPT.canonicalTaskReceiptPath('../task-x.md'), null);
309
- const outside = path.join(root, 'outside.md');
310
- fs.writeFileSync(outside, receiptBody(ctx));
311
- fs.mkdirSync(path.join(featureDir, 'receipts'), { recursive: true });
312
- fs.symlinkSync(outside, path.join(featureDir, 'receipts', path.basename(TASK)));
313
- assert.ok(RECEIPT.checkTaskReceipt(featureDir, TASK, { completed_at: '2026-08-13T00:00:00.000Z' }, ctx, POLICY).failures.includes('unsafe_path'));
314
- fs.unlinkSync(path.join(featureDir, 'receipts', path.basename(TASK)));
315
- const taskFile = path.join(featureDir, TASK);
316
- fs.appendFileSync(taskFile, `\n## Evidence\n\n${receiptBody(ctx)}`);
317
- assert.equal(RECEIPT.checkTaskReceipt(featureDir, TASK, { completed_at: '2026-08-13T00:00:00.000Z' }, ctx, POLICY).source, 'legacy');
318
- fs.writeFileSync(path.join(featureDir, 'receipts', path.basename(TASK)), receiptBody(ctx, `Task: ${path.basename(TASK)}\nTask path: ${TASK}`).replace('Command: node --test', 'Command: node --test changed'));
319
- assert.ok(RECEIPT.checkTaskReceipt(featureDir, TASK, { completed_at: '2026-08-13T00:00:00.000Z' }, ctx, POLICY).failures.includes('receipt_conflict'));
320
- });
321
- });
322
-
323
- test('Claude and Codex gates require task proof at every Stop and feature proof only at durable closeout', () => {
324
- withTempResources((resources) => {
325
- for (const kind of ['claude', 'codex']) {
326
- const early = gitRoot(resources);
327
- const invalidAlias = gitRoot(resources);
328
- const taskless = gitRoot(resources);
329
- const taskBearing = gitRoot(resources);
330
- prepare(early, { taskless: true, specStatus: 'in_progress' });
331
- assert.equal(gate(early, kind), '', `${kind} pre-closeout`);
332
- const { featureDir: invalidAliasDir } = prepare(invalidAlias, { taskless: true, specStatus: 'in_progress' });
333
- const invalidAliasFile = path.join(invalidAliasDir, 'spec.json');
334
- const invalidAliasSpec = JSON.parse(fs.readFileSync(invalidAliasFile, 'utf8'));
335
- invalidAliasSpec.status = 'completed';
336
- fs.writeFileSync(invalidAliasFile, `${JSON.stringify(invalidAliasSpec, null, 2)}\n`);
337
- assert.match(gate(invalidAlias, kind), /Legacy spec\.json closeout is no longer supported/, `${kind} blocks legacy 2.1 closeout with the migration message`);
338
- prepare(taskless, { taskless: true, specStatus: 'complete', featureReceipt: true });
339
- assert.match(gate(taskless, kind), /Legacy spec\.json closeout is no longer supported/, `${kind} taskless 2.1 closeout is retired`);
340
- fs.appendFileSync(path.join(taskless, 'specs', FEATURE, 'requirements.md'), '\nSemantic mutation after review.\n');
341
- assert.match(gate(taskless, kind), /Legacy spec\.json closeout is no longer supported/, `${kind} still blocks a changed 2.1 packet`);
342
- prepare(taskBearing, { taskReceipt: false, featureReceipt: false });
343
- assert.match(gate(taskBearing, kind), new RegExp(path.basename(TASK)), `${kind} unproven done task before closeout`);
344
- prepare(taskBearing, { taskReceipt: true, featureReceipt: false });
345
- assert.equal(gate(taskBearing, kind), '', `${kind} proven done task before closeout`);
346
- assert.equal(fs.existsSync(path.join(taskBearing, 'specs', FEATURE, 'feature-receipt.md')), false);
347
- prepare(taskBearing, { specStatus: 'complete', featureReceipt: false });
348
- assert.match(gate(taskBearing, kind), /feature-receipt\.md/);
349
- prepare(taskBearing, { specStatus: 'complete', featureReceipt: true });
350
- assert.equal(gate(taskBearing, kind), '');
351
- fs.appendFileSync(path.join(taskBearing, 'specs', FEATURE, 'receipts', path.basename(TASK)), 'Exit: 1\n');
352
- assert.match(gate(taskBearing, kind), new RegExp(path.basename(TASK)));
353
- }
354
- });
355
- });
356
-
357
- test('an unproven done dependency cannot advance a dependent task', () => {
358
- withTempResources((resources) => {
359
- for (const kind of ['claude', 'codex']) {
360
- const root = gitRoot(resources);
361
- const { featureDir } = prepare(root, { taskReceipt: false });
362
- const dependent = 'tasks/task-R1-02-dependent.md';
363
- const specFile = path.join(featureDir, 'spec.json');
364
- const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
365
- spec.task_registry[dependent] = { status: 'pending', completed_at: null, dependencies: [TASK] };
366
- fs.writeFileSync(specFile, JSON.stringify(spec));
367
- fs.writeFileSync(path.join(featureDir, dependent), '# Task R1-02: dependent\n\n**Status:** pending\n');
368
- const blocked = gate(root, kind);
369
- assert.match(blocked, new RegExp(path.basename(TASK)), `${kind} must reject the unproven done dependency`);
370
- assert.equal(JSON.parse(fs.readFileSync(specFile, 'utf8')).task_registry[dependent].status, 'pending');
371
- }
372
- });
373
- });
374
-
375
- test('shared taskless terminal predicate requires canonical 2.1 policy and physical tasklessness', () => {
376
- withTempResources((resources) => {
377
- const root = gitRoot(resources);
378
- const { featureDir } = prepare(root, { taskless: true, specStatus: 'complete' });
379
- const specPath = path.join(featureDir, 'spec.json');
380
- const canonical = JSON.parse(fs.readFileSync(specPath, 'utf8'));
381
- assert.equal(POLICY.isCanonicalTasklessTerminalSpec(canonical, featureDir), true);
382
-
383
- const legacy = {
384
- ...canonical,
385
- schema_version: '2.0',
386
- workflow_policy: POLICY.workflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Routine' }),
387
- };
388
- assert.equal(POLICY.isCanonicalTasklessTerminalSpec(legacy, featureDir), false);
389
-
390
- fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
391
- fs.writeFileSync(path.join(featureDir, 'tasks', 'hidden.md'), '# physical task\n');
392
- assert.equal(POLICY.isCanonicalTasklessTerminalSpec(canonical, featureDir), false);
393
- });
394
- });
395
-
396
- test('scaffold creates planning artifacts but no task or feature receipts', () => {
397
- withTempResources((resources) => {
398
- const root = resources.directory('scaffold-root');
399
- const result = spawnSync(process.execPath, [path.join(SCRIPTS, 'spec-scaffold.cjs'), 'no-receipt', '--specs-root', 'specs'], { cwd: root, encoding: 'utf8' });
400
- assert.equal(result.status, 0, result.stderr);
401
- const files = fs.readdirSync(path.join(root, 'specs', 'no-receipt'), { recursive: true }).map(String);
402
- assert.equal(files.some((file) => file.includes('receipt')), false);
403
- });
404
- });
405
-
406
- test('legacy receipt still requires provenance under a clean committed tree', () => {
407
- // The workflow task path may validate a committed, unchanged receipt on
408
- // structure alone; the legacy task and feature receipt paths must not.
409
- withTempResources((resources) => {
410
- const root = gitRoot(resources);
411
- const { featureDir } = prepare(root);
412
- const stale = { base: 'f'.repeat(40), head: 'e'.repeat(40) };
413
- fs.writeFileSync(
414
- path.join(featureDir, 'receipts', path.basename(TASK)),
415
- receiptBody(stale, `Task: ${path.basename(TASK)}\nTask path: ${TASK}`),
416
- );
417
- fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), receiptBody(stale, `Feature: ${FEATURE}`));
418
- for (const args of [['add', '-A'], ['commit', '-qm', 'committed legacy receipts']]) {
419
- const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
420
- assert.equal(result.status, 0, result.stderr);
421
- }
422
- const clean = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' });
423
- assert.equal(clean.stdout.trim(), '', 'fixture tree must be clean before the check');
424
- const ctx = runtime(root);
425
- const taskFailures = RECEIPT.checkTaskReceipt(featureDir, TASK, { completed_at: '2026-08-13T00:00:00.000Z' }, ctx, POLICY).failures;
426
- assert.ok(taskFailures.includes('provenance'), `legacy task receipts keep provenance binding when committed and clean; got ${JSON.stringify(taskFailures)}`);
427
- const featureFailures = RECEIPT.checkFeatureReceipt(featureDir, ctx, POLICY).failures;
428
- assert.ok(featureFailures.includes('provenance'), `feature receipts keep provenance binding when committed and clean; got ${JSON.stringify(featureFailures)}`);
429
- });
430
- });