@haposoft/cafekit 0.16.0-rc.7 → 0.16.0-rc.8

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.
@@ -99,6 +99,10 @@ function makeWorkflowFixture(receiptLines = [], plannedCommand = 'node --test')
99
99
  '',
100
100
  'Status: done',
101
101
  '',
102
+ '## Dependencies',
103
+ '',
104
+ '- none',
105
+ '',
102
106
  '## Verification Plan',
103
107
  '',
104
108
  `- Command: ${plannedCommand}`,
@@ -1318,10 +1322,80 @@ test('32. process-v3 requires Receipt heading and runtime-bound provenance', ()
1318
1322
  }
1319
1323
  });
1320
1324
 
1325
+ test('32b. process-v3 ignores a Receipt heading and proof contained only in a fence', () => {
1326
+ const dir = makeWorkflowFixture([
1327
+ '~~~~markdown', '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1328
+ `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, '```text', 'pass: 1', '```', '~~~~',
1329
+ ]);
1330
+ try {
1331
+ clearCache();
1332
+ const body = parseBlock(runHook({}, dir).stdout);
1333
+ assert.ok(body, 'fenced-only process-v3 Receipt must block');
1334
+ assert.strictEqual(body.decision, 'block');
1335
+ assert.match(body.reason, /missing_receipt/);
1336
+ } finally {
1337
+ fs.rmSync(dir, { recursive: true, force: true });
1338
+ }
1339
+ });
1340
+
1341
+ test('32c. process-v3 ignores canonical Receipt fields contained only in its output fence', () => {
1342
+ const dir = makeWorkflowFixture([
1343
+ '## Receipt', '', '```text', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1344
+ `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, 'pass: 1', '```',
1345
+ ]);
1346
+ try {
1347
+ clearCache();
1348
+ const body = parseBlock(runHook({}, dir).stdout);
1349
+ assert.ok(body, 'fenced-only canonical fields must block');
1350
+ assert.strictEqual(body.decision, 'block');
1351
+ assert.match(body.reason, /verification_state/);
1352
+ assert.match(body.reason, /command/);
1353
+ assert.match(body.reason, /provenance/);
1354
+ } finally {
1355
+ fs.rmSync(dir, { recursive: true, force: true });
1356
+ }
1357
+ });
1358
+
1359
+ test('32d. process-v3 preserves explicit failures from the Receipt output fence', () => {
1360
+ const dir = makeWorkflowFixture([
1361
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1362
+ `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, '```text', '$ node --test',
1363
+ 'exit code: 1', 'FAIL', '```',
1364
+ ]);
1365
+ try {
1366
+ clearCache();
1367
+ const body = parseBlock(runHook({}, dir).stdout);
1368
+ assert.ok(body, 'explicit fenced output failure must block');
1369
+ assert.strictEqual(body.decision, 'block');
1370
+ assert.match(body.reason, /verification_state/);
1371
+ assert.match(body.reason, /exit_result/);
1372
+ } finally {
1373
+ fs.rmSync(dir, { recursive: true, force: true });
1374
+ }
1375
+ });
1376
+
1377
+ test('32e. process-v3 rejects output whose closing fence is shorter than its opener', () => {
1378
+ const dir = makeWorkflowFixture([
1379
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1380
+ `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, '````text', '$ node --test',
1381
+ 'pass: 1', '```',
1382
+ ]);
1383
+ try {
1384
+ clearCache();
1385
+ const body = parseBlock(runHook({}, dir).stdout);
1386
+ assert.ok(body, 'malformed output fence must block');
1387
+ assert.strictEqual(body.decision, 'block');
1388
+ assert.match(body.reason, /command_output/);
1389
+ } finally {
1390
+ fs.rmSync(dir, { recursive: true, force: true });
1391
+ }
1392
+ });
1393
+
1321
1394
  test('33. process-v3 done task with canonical inline Receipt passes', () => {
1322
1395
  const dir = makeWorkflowFixture([
1323
1396
  '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1324
- `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, '```text', '$ node --test', 'pass: 1', '```',
1397
+ `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, '```text', '$ node --test',
1398
+ 'Status: pending', '## Receipt', 'pass: 1', '```',
1325
1399
  ]);
1326
1400
  try {
1327
1401
  clearCache();
@@ -1360,6 +1434,7 @@ test('35. process-v3 Stop ignores a completed packet when one packet remains act
1360
1434
  fs.writeFileSync(path.join(activeDir, 'plan.md'), '# Active plan\n');
1361
1435
  fs.writeFileSync(path.join(activeDir, 'task-01-active.md'), [
1362
1436
  '# Task 01: active', '', 'Status: pending', '',
1437
+ '## Dependencies', '', '- none', '',
1363
1438
  '## Verification Plan', '', '- Command: node --test', '',
1364
1439
  ].join('\n'));
1365
1440
  clearCache();
@@ -1389,6 +1464,7 @@ test('36. process-v3 Stop accepts two completed packets with valid Receipts', ()
1389
1464
  });
1390
1465
  fs.writeFileSync(path.join(otherDir, 'task-01-other.md'), [
1391
1466
  '# Task 01: other', '', 'Status: done', '',
1467
+ '## Dependencies', '', '- none', '',
1392
1468
  '## Verification Plan', '', '- Command: node --test', '',
1393
1469
  '## Receipt', '',
1394
1470
  'Verification: PASS', 'Command: node --test', 'Exit: 0',
@@ -7,7 +7,253 @@ const fs = require('node:fs');
7
7
  const os = require('node:os');
8
8
  const path = require('node:path');
9
9
 
10
- const HOOK = path.join(__dirname, '..', 'state.cjs');
10
+ const CLAUDE_ROOT = path.join(__dirname, '..', '..');
11
+ const STATE_HOOK = path.join(CLAUDE_ROOT, 'hooks', 'state.cjs');
12
+ const POLICY = require(path.join(CLAUDE_ROOT, 'scripts', 'workflow-policy.cjs'));
13
+
14
+ function processTaskContent(title, status, dependency = 'none') {
15
+ return [
16
+ `# ${title}`, '', `Status: ${status}`, '',
17
+ '## Dependencies', '', `- ${dependency}`, '',
18
+ '## Verification Plan', '', '- Command: node --test', '',
19
+ ].join('\n');
20
+ }
21
+
22
+ function canonicalProcessTask(root, title, dependency = 'none') {
23
+ const context = POLICY.deriveRuntimeContext({
24
+ projectRoot: root,
25
+ specsRoot: path.join(root, 'specs'),
26
+ specFile: path.join(root, 'specs', 'auth', 'plan.md'),
27
+ featureName: 'auth',
28
+ runtimeSession: 'session-auth-state',
29
+ });
30
+ return [
31
+ processTaskContent(title, 'done', dependency),
32
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
33
+ `Base: ${context.base}`, `Head: ${context.head}`,
34
+ '```text', 'node --test', '1 test passed', '```', '',
35
+ ].join('\n');
36
+ }
37
+
38
+ function inInstalledProcessSpecStateFixture(run) {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-state-'));
40
+ try {
41
+ const hooks = path.join(dir, '.claude', 'hooks');
42
+ const scripts = path.join(dir, '.claude', 'scripts');
43
+ fs.mkdirSync(hooks, { recursive: true });
44
+ fs.mkdirSync(scripts, { recursive: true });
45
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
46
+ for (const file of [
47
+ 'provenance.cjs',
48
+ 'spec-receipt.cjs',
49
+ 'spec-resolver.cjs',
50
+ 'workflow-policy.cjs',
51
+ ]) {
52
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'scripts', file), path.join(scripts, file));
53
+ }
54
+
55
+ const feature = path.join(dir, 'specs', 'auth');
56
+ fs.mkdirSync(feature, { recursive: true });
57
+ fs.writeFileSync(path.join(feature, 'plan.md'), '# Auth plan\nSpecs-Contract: process-first-ready-v1\n');
58
+
59
+ for (const args of [
60
+ ['init', '-q'],
61
+ ['config', 'user.email', 'cafekit@example.invalid'],
62
+ ['config', 'user.name', 'CafeKit Test'],
63
+ ['commit', '--allow-empty', '-qm', 'fixture'],
64
+ ]) {
65
+ const git = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
66
+ assert.strictEqual(git.status, 0, git.stderr);
67
+ }
68
+
69
+ const writeTask = (basename, title, status, dependency = 'none') => {
70
+ fs.writeFileSync(
71
+ path.join(feature, basename),
72
+ processTaskContent(title, status, dependency),
73
+ );
74
+ };
75
+ const runState = () => spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
76
+ cwd: dir,
77
+ env: { ...process.env, PROJECT_ROOT: dir },
78
+ input: JSON.stringify({
79
+ cwd: dir,
80
+ featureName: 'auth',
81
+ session_id: 'session-auth-state',
82
+ hook_event_name: 'UserPromptSubmit',
83
+ prompt: 'Continue',
84
+ }),
85
+ encoding: 'utf8',
86
+ });
87
+ return run({
88
+ cacheFile: path.join(hooks, '.logs', 'tollgate-last.txt'),
89
+ dir,
90
+ feature,
91
+ hooks,
92
+ runState,
93
+ writePlan: (content) => fs.writeFileSync(path.join(feature, 'plan.md'), content),
94
+ writeTask,
95
+ });
96
+ } finally {
97
+ fs.rmSync(dir, { recursive: true, force: true });
98
+ }
99
+ }
100
+
101
+ test('Claude installed spec-state re-evaluates a standalone task when blocked becomes pending', () => {
102
+ inInstalledProcessSpecStateFixture(({ cacheFile, hooks, runState, writeTask }) => {
103
+ writeTask('task-01-auth.md', 'Task 01: auth', 'blocked');
104
+
105
+ const blocked = runState();
106
+ assert.strictEqual(blocked.status, 0, blocked.stderr);
107
+ assert.match(blocked.stdout, /0 pending, 1 blocked/);
108
+ assert.doesNotMatch(blocked.stdout, /Next unblocked/);
109
+ assert.ok(cacheFile.startsWith(`${hooks}${path.sep}`), 'cache must stay inside temporary installed hooks');
110
+ assert.strictEqual(fs.existsSync(cacheFile), true, 'installed cache must be created');
111
+ const blockedCache = fs.readFileSync(cacheFile, 'utf8');
112
+
113
+ writeTask('task-01-auth.md', 'Task 01: auth', 'pending');
114
+ const pending = runState();
115
+ assert.strictEqual(pending.status, 0, pending.stderr);
116
+ assert.match(pending.stdout, /Spec state changed: `auth`/);
117
+ assert.match(pending.stdout, /1 pending, 0 blocked/);
118
+ assert.match(pending.stdout, /Next unblocked: `task-01-auth\.md`/);
119
+ assert.notStrictEqual(fs.readFileSync(cacheFile, 'utf8'), blockedCache, 'state cache must change');
120
+ });
121
+ });
122
+
123
+ test('Claude installed spec-state requires current canonical dependency proof and keys receipt mutations', () => {
124
+ inInstalledProcessSpecStateFixture(({ cacheFile, dir, feature, hooks, runState, writeTask }) => {
125
+ const predecessor = 'task-01-semantic-review.md';
126
+ const dependent = 'task-02-implementation.md';
127
+ writeTask(predecessor, 'Task 01: semantic review', 'blocked');
128
+ writeTask(dependent, 'Task 02: implementation', 'pending', predecessor);
129
+ const dependentPath = path.join(feature, dependent);
130
+ const unchangedDependent = fs.readFileSync(dependentPath, 'utf8');
131
+
132
+ const blocked = runState();
133
+ assert.strictEqual(blocked.status, 0, blocked.stderr);
134
+ assert.match(blocked.stdout, /0 done \/ 2 total \(0 in_progress, 1 pending, 1 blocked\)/);
135
+ assert.doesNotMatch(blocked.stdout, /Next unblocked/);
136
+ assert.ok(cacheFile.startsWith(`${hooks}${path.sep}`), 'cache must stay inside temporary installed hooks');
137
+ assert.strictEqual(fs.existsSync(cacheFile), true, 'installed cache must be created');
138
+ const blockedCache = fs.readFileSync(cacheFile, 'utf8');
139
+
140
+ writeTask(predecessor, 'Task 01: semantic review', 'done');
141
+ const missing = runState();
142
+ assert.strictEqual(missing.status, 0, missing.stderr);
143
+ assert.doesNotMatch(missing.stdout, /Next unblocked/);
144
+ const missingCache = fs.readFileSync(cacheFile, 'utf8');
145
+ assert.notStrictEqual(missingCache, blockedCache, 'done without proof must change cached proof state');
146
+
147
+ fs.appendFileSync(path.join(feature, predecessor), '\n## Receipt\n\nVerification: PASS\n');
148
+ const malformed = runState();
149
+ assert.strictEqual(malformed.status, 0, malformed.stderr);
150
+ assert.doesNotMatch(malformed.stdout, /Next unblocked/);
151
+
152
+ fs.writeFileSync(path.join(feature, predecessor), canonicalProcessTask(dir, 'Task 01: semantic review'));
153
+ const ready = runState();
154
+ assert.strictEqual(ready.status, 0, ready.stderr);
155
+ assert.match(ready.stdout, /Next unblocked: `task-02-implementation\.md`/);
156
+ const readyCache = fs.readFileSync(cacheFile, 'utf8');
157
+ assert.notStrictEqual(readyCache, missingCache, 'canonical proof must change cached proof state');
158
+
159
+ fs.writeFileSync(
160
+ path.join(feature, predecessor),
161
+ fs.readFileSync(path.join(feature, predecessor), 'utf8').replace('Verification: PASS', 'Verification: FAIL'),
162
+ );
163
+ const mutated = runState();
164
+ assert.strictEqual(mutated.status, 0, mutated.stderr);
165
+ assert.doesNotMatch(mutated.stdout, /Next unblocked/);
166
+ assert.notStrictEqual(fs.readFileSync(cacheFile, 'utf8'), readyCache, 'receipt-only mutation must change cache');
167
+ assert.strictEqual(fs.readFileSync(dependentPath, 'utf8'), unchangedDependent, 'dependent must remain pending');
168
+ });
169
+ });
170
+
171
+ test('Claude installed spec-state keeps unversioned pending packets out of Next with migration guidance', () => {
172
+ inInstalledProcessSpecStateFixture(({ feature, runState, writePlan, writeTask }) => {
173
+ const fencedMarkerPlan = '# Auth plan\n\n```markdown\nSpecs-Contract: process-first-ready-v1\n```\n';
174
+ writePlan(fencedMarkerPlan);
175
+ writeTask('task-01-auth.md', 'Task 01: auth', 'pending');
176
+ const taskBytes = fs.readFileSync(path.join(feature, 'task-01-auth.md'));
177
+ const result = runState();
178
+ assert.strictEqual(result.status, 0, result.stderr);
179
+ assert.doesNotMatch(result.stdout, /Next unblocked/);
180
+ assert.match(result.stdout, /Migration required: add `Specs-Contract: process-first-ready-v1`/);
181
+ const cached = runState();
182
+ assert.strictEqual(cached.status, 0, cached.stderr);
183
+ assert.doesNotMatch(cached.stdout, /Next unblocked/);
184
+ assert.match(cached.stdout, /Add `Specs-Contract: process-first-ready-v1`/);
185
+
186
+ writePlan(fencedMarkerPlan.replace('# Auth plan\n', '# Auth plan\nSpecs-Contract: process-first-ready-v1\n'));
187
+ const marked = runState();
188
+ assert.strictEqual(marked.status, 0, marked.stderr);
189
+ assert.match(marked.stdout, /Spec state changed: `auth`/);
190
+ assert.match(marked.stdout, /Next unblocked: `task-01-auth\.md`/);
191
+ assert.deepStrictEqual(fs.readFileSync(path.join(feature, 'task-01-auth.md')), taskBytes, 'marker-only transition must not change task bytes');
192
+ });
193
+ });
194
+
195
+ test('Claude resolver ignores fenced workflow annotations and rejects fenced-only Status', () => {
196
+ inInstalledProcessSpecStateFixture(({ dir, feature, runState, writePlan, writeTask }) => {
197
+ const resolver = require(path.join(dir, '.claude', 'scripts', 'spec-resolver.cjs'));
198
+ const trailingFence = resolver.annotatedMarkdownLines([
199
+ '```markdown', '``` trailing', 'Specs-Contract: process-first-ready-v1',
200
+ 'Status: done', '## Receipt', '```',
201
+ ].join('\n'));
202
+ assert.deepStrictEqual(trailingFence.slice(2, 5).map(({ outsideFence }) => outsideFence), [false, false, false]);
203
+ const fourSpaceFence = resolver.annotatedMarkdownLines(' ```markdown\nStatus: pending');
204
+ assert.strictEqual(fourSpaceFence[1].outsideFence, true);
205
+ writePlan('# Auth plan\nSpecs-Contract: process-first-ready-v1\n');
206
+ fs.writeFileSync(path.join(feature, 'task-01-fenced-only.md'), [
207
+ '# Task 01', '', '```markdown', 'Status: done', '```', '',
208
+ '## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: node --test', '',
209
+ ].join('\n'));
210
+ const malformed = resolver.resolveWorkflowCandidate({ projectRoot: dir, runtime: {}, explicitFeature: 'auth' });
211
+ assert.strictEqual(malformed.error, 'explicit_malformed');
212
+ assert.match(malformed.reason, /must contain exactly one supported Status field/);
213
+
214
+ fs.rmSync(path.join(feature, 'task-01-fenced-only.md'));
215
+ writeTask('task-01-dependent.md', 'Task 01: dependent', 'pending', 'task-02-predecessor.md');
216
+ fs.writeFileSync(path.join(feature, 'task-02-predecessor.md'), [
217
+ '# Task 02', '', 'Status: pending', '', '## Dependencies', '', '- none', '',
218
+ '## Verification Plan', '', '- Command: node --test', '',
219
+ '~~~~markdown', 'Status: done', '## Dependencies', '- task-01-dependent.md', '## Receipt',
220
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', '```text', 'fake pass', '```', '~~~~', '',
221
+ ].join('\n'));
222
+ const result = runState();
223
+ assert.strictEqual(result.status, 0, result.stderr);
224
+ assert.match(result.stdout, /Next unblocked: `task-02-predecessor\.md`/);
225
+ assert.doesNotMatch(result.stdout, /Next unblocked: `task-01-dependent\.md`/);
226
+ });
227
+ });
228
+
229
+ test('Claude resolver accepts unique legacy task numbers, rejects ambiguity, and rejects exact cycles', () => {
230
+ inInstalledProcessSpecStateFixture(({ dir, feature, writePlan, writeTask }) => {
231
+ const resolver = require(path.join(dir, '.claude', 'scripts', 'spec-resolver.cjs'));
232
+ writePlan('# Auth plan\n');
233
+ writeTask('task-01-a.md', 'Task 01: a', 'pending');
234
+ writeTask('task-02-b.md', 'Task 02: b', 'pending', 'Task 01');
235
+ fs.writeFileSync(path.join(feature, 'task-03-c.md'), '# Task 03: c\n\nStatus: pending\n');
236
+ const unique = resolver.resolveWorkflowCandidate({ projectRoot: dir, runtime: {}, explicitFeature: 'auth' });
237
+ assert.deepStrictEqual(unique.taskRegistry['task-02-b.md'].dependencies, ['task-01-a.md']);
238
+ assert.deepStrictEqual(unique.taskRegistry['task-03-c.md'].dependencies, []);
239
+ assert.strictEqual(unique.workflowContract, null);
240
+ assert.strictEqual(unique.queueReady, false);
241
+
242
+ writeTask('task-01-second.md', 'Task 01: second', 'pending');
243
+ const ambiguous = resolver.resolveWorkflowCandidate({ projectRoot: dir, runtime: {}, explicitFeature: 'auth' });
244
+ assert.strictEqual(ambiguous.error, 'explicit_malformed');
245
+ assert.match(ambiguous.reason, /legacy dependency Task 01 maps to 2 task basenames/);
246
+
247
+ fs.rmSync(path.join(feature, 'task-01-second.md'));
248
+ fs.rmSync(path.join(feature, 'task-03-c.md'));
249
+ writePlan('# Auth plan\nSpecs-Contract: process-first-ready-v1\n');
250
+ writeTask('task-01-a.md', 'Task 01: a', 'pending', 'task-02-b.md');
251
+ writeTask('task-02-b.md', 'Task 02: b', 'pending', 'task-01-a.md');
252
+ const cycle = resolver.resolveWorkflowCandidate({ projectRoot: dir, runtime: {}, explicitFeature: 'auth' });
253
+ assert.strictEqual(cycle.error, 'explicit_malformed');
254
+ assert.match(cycle.reason, /dependency cycle detected: task-01-a\.md -> task-02-b\.md -> task-01-a\.md/);
255
+ });
256
+ });
11
257
 
12
258
  test('Stop snapshot includes tracked modifications and untracked files', () => {
13
259
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-'));
@@ -22,7 +268,7 @@ test('Stop snapshot includes tracked modifications and untracked files', () => {
22
268
 
23
269
  fs.writeFileSync(path.join(dir, 'tracked.txt'), 'after\n');
24
270
  fs.writeFileSync(path.join(dir, 'new-untracked.json'), '{}\n');
25
- const result = spawnSync(process.execPath, [HOOK], {
271
+ const result = spawnSync(process.execPath, [STATE_HOOK], {
26
272
  cwd: dir,
27
273
  input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
28
274
  encoding: 'utf8',
@@ -47,7 +293,7 @@ test('Stop snapshot includes untracked files before the first commit', () => {
47
293
  spawnSync('git', ['init', '-q'], { cwd: dir });
48
294
  fs.writeFileSync(path.join(dir, 'first-untracked.txt'), 'new\n');
49
295
 
50
- const result = spawnSync(process.execPath, [HOOK], {
296
+ const result = spawnSync(process.execPath, [STATE_HOOK], {
51
297
  cwd: dir,
52
298
  input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
53
299
  encoding: 'utf8',
@@ -132,13 +132,30 @@ try {
132
132
  acc[status] = (acc[status] || 0) + 1;
133
133
  return acc;
134
134
  }, {});
135
+ const featureDir = path.join(specsPath, featureName);
135
136
  const taskStatusByPath = new Map(taskEntries.map(([taskPath, task]) => [taskPath, task?.status || 'pending']));
137
+ const dependencyProof = processWorkflow
138
+ ? RECEIPT.workflowDependencyProofState(featureDir, taskRegistry, runtimeContext, POLICY)
139
+ : {};
140
+ const taskState = taskEntries.map(([taskPath, task]) => [
141
+ taskPath,
142
+ task?.status || 'pending',
143
+ [...(Array.isArray(task?.dependencies) ? task.dependencies : [])].sort(),
144
+ ]).sort(([left], [right]) => left.localeCompare(right));
145
+ const proofState = Object.entries(dependencyProof).map(([taskPath, proof]) => [
146
+ taskPath,
147
+ proof.done,
148
+ proof.valid,
149
+ proof.eligible,
150
+ proof.signature,
151
+ ]);
136
152
  const nextUnblocked = taskEntries.find(([, task]) => {
137
153
  const status = task?.status || 'pending';
138
154
  const deps = Array.isArray(task?.dependencies) ? task.dependencies : [];
139
- return status === 'pending' && deps.every((dep) => taskStatusByPath.get(dep) === 'done');
155
+ return resolved.queueReady !== false && status === 'pending' && deps.every((dep) => (
156
+ processWorkflow ? dependencyProof[dep]?.eligible === true : taskStatusByPath.get(dep) === 'done'
157
+ ));
140
158
  });
141
- const featureDir = path.join(specsPath, featureName);
142
159
  const featureReceiptPresent = !processWorkflow
143
160
  && RECEIPT.safeRead(featureDir, 'feature-receipt.md').status === 'ok';
144
161
 
@@ -157,6 +174,10 @@ try {
157
174
  Head: runtimeContext.head,
158
175
  contextId: runtimeContext.context_id,
159
176
  phase,
177
+ workflowContract: resolved.workflowContract || null,
178
+ queueReady: resolved.queueReady !== false,
179
+ taskState,
180
+ proofState,
160
181
  done: taskCounts.done || 0,
161
182
  total: taskEntries.length,
162
183
  featureReceiptPresent,
@@ -170,7 +191,10 @@ try {
170
191
 
171
192
  if (stateKey && lastKey === stateKey) {
172
193
  const stateTarget = processWorkflow ? 'task Markdown' : '`spec.json`';
173
- console.log(`\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${taskCounts.done || 0}/${taskEntries.length} tasks done). Tollgate active — sync ${stateTarget} when state changes.\n`);
194
+ const migration = processWorkflow && resolved.queueReady === false
195
+ ? ' Add `Specs-Contract: process-first-ready-v1` after the plan title before tasks can enter Next.'
196
+ : '';
197
+ console.log(`\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${taskCounts.done || 0}/${taskEntries.length} tasks done). Tollgate active — sync ${stateTarget} when state changes.${migration}\n`);
174
198
  process.exit(0);
175
199
  }
176
200
 
@@ -194,6 +218,9 @@ try {
194
218
  lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
195
219
  }
196
220
  if (processWorkflow) {
221
+ if (resolved.queueReady === false) {
222
+ lines.push('- Migration required: add `Specs-Contract: process-first-ready-v1` after the plan title before tasks can enter Next.');
223
+ }
197
224
  lines.push('- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.');
198
225
  lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop revalidates every task currently marked done.`);
199
226
  } else {
@@ -3,6 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { annotatedMarkdownLines } = require('./spec-resolver.cjs');
6
7
 
7
8
  const EVIDENCE_HEADINGS = new Set([
8
9
  'Evidence',
@@ -67,21 +68,46 @@ function evidenceBody(text, policy = {}) {
67
68
  }
68
69
 
69
70
  function workflowReceiptBody(text, policy = {}) {
70
- const lines = String(text || '').split('\n');
71
+ const lines = annotatedMarkdownLines(text);
71
72
  const starts = [];
72
73
  for (let index = 0; index < lines.length; index += 1) {
73
- if (/^##\s+Receipt\s*$/.test(lines[index])) starts.push(index + 1);
74
+ if (lines[index].outsideFence && /^##\s+Receipt\s*$/.test(lines[index].line)) starts.push(index + 1);
74
75
  }
75
76
  if (starts.length !== 1) return null;
76
77
 
77
78
  const start = starts[0];
78
79
  let end = lines.length;
79
80
  for (let index = start; index < lines.length; index += 1) {
80
- const heading = lines[index].match(/^(#{1,6})\s+/);
81
- const tap = typeof policy.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(lines[index]);
81
+ if (!lines[index].outsideFence) continue;
82
+ const heading = lines[index].line.match(/^(#{1,6})\s+/);
83
+ const tap = typeof policy.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(lines[index].line);
82
84
  if (heading && heading[1].length <= 2 && !tap) { end = index; break; }
83
85
  }
84
- return lines.slice(start, end).join('\n');
86
+ return lines.slice(start, end).map(({ line }) => line).join('\n');
87
+ }
88
+
89
+ function workflowCanonicalValidationBody(body) {
90
+ return annotatedMarkdownLines(body)
91
+ .filter(({ outsideFence }) => outsideFence)
92
+ .map(({ line }) => line)
93
+ .join('\n');
94
+ }
95
+
96
+ function hasNonEmptyFencedBlock(body) {
97
+ let open = false;
98
+ let nonEmpty = false;
99
+ for (const { line, fenceEvent } of annotatedMarkdownLines(body)) {
100
+ if (fenceEvent === 'open') {
101
+ open = true;
102
+ nonEmpty = false;
103
+ } else if (fenceEvent === 'close') {
104
+ if (open && nonEmpty) return true;
105
+ open = false;
106
+ } else if (open && line.trim() !== '') {
107
+ nonEmpty = true;
108
+ }
109
+ }
110
+ return false;
85
111
  }
86
112
 
87
113
  function fieldValues(body, name) {
@@ -105,22 +131,14 @@ function normalizedCommand(value) {
105
131
  function workflowVerificationCommand(taskText) {
106
132
  const sections = [];
107
133
  let current = null;
108
- let fence = null;
109
- for (const line of String(taskText || '').split('\n')) {
110
- const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
111
- if (marker) {
112
- const kind = marker[1][0];
113
- if (fence === null) fence = kind;
114
- else if (fence === kind) fence = null;
115
- continue;
116
- }
117
- if (fence !== null) continue;
118
- if (fence === null && /^##\s+Verification Plan\s*$/.test(line)) {
134
+ for (const { line, outsideFence } of annotatedMarkdownLines(taskText)) {
135
+ if (!outsideFence) continue;
136
+ if (/^##\s+Verification Plan\s*$/.test(line)) {
119
137
  current = [];
120
138
  sections.push(current);
121
139
  continue;
122
140
  }
123
- if (fence === null && current && /^#{1,2}\s+/.test(line)) {
141
+ if (current && /^#{1,2}\s+/.test(line)) {
124
142
  current = null;
125
143
  continue;
126
144
  }
@@ -227,7 +245,10 @@ function checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, policy)
227
245
  }
228
246
 
229
247
  const taskText = taskFile.bytes.toString('utf8');
230
- const statusLines = [...taskText.matchAll(/^\s*(?:\*\*)?Status(?:\*\*)?\s*:\s*(?:\*\*)?\s*([A-Za-z_-]+)(?:\*\*)?\s*$/gim)];
248
+ const statusLines = annotatedMarkdownLines(taskText)
249
+ .filter(({ outsideFence }) => outsideFence)
250
+ .map(({ line }) => line.match(/^\s*(?:\*\*)?Status(?:\*\*)?\s*:\s*(?:\*\*)?\s*([A-Za-z_-]+)(?:\*\*)?\s*$/i))
251
+ .filter(Boolean);
231
252
  const body = workflowReceiptBody(taskText, policy);
232
253
  const failures = [];
233
254
  if (statusLines.length !== 1 || statusLines[0][1].toLowerCase().replaceAll('-', '_') !== 'done') {
@@ -236,12 +257,15 @@ function checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, policy)
236
257
  if (body === null) {
237
258
  failures.push('missing_receipt');
238
259
  } else {
260
+ const canonicalBody = workflowCanonicalValidationBody(body);
239
261
  const options = typeof policy?.receiptValidatorOptions === 'function'
240
262
  ? policy.receiptValidatorOptions({}, { runtimeContext, requireProvenanceBinding: true })
241
263
  : { requireProvenanceBinding: true };
242
- failures.push(...canonicalFailures(body, {}, runtimeContext, policy));
243
- failures.push(...workflowCommandFailures(taskText, body));
244
- if (!/```[^\n]*\n[\s\S]*?\S[\s\S]*?\n```/m.test(body)) failures.push('command_output');
264
+ failures.push(...canonicalFailures(canonicalBody, {}, runtimeContext, policy));
265
+ failures.push(...canonicalFailures(body, {}, runtimeContext, policy)
266
+ .filter((failure) => ['placeholder', 'verification_state', 'exit_result'].includes(failure)));
267
+ failures.push(...workflowCommandFailures(taskText, canonicalBody));
268
+ if (!hasNonEmptyFencedBlock(body)) failures.push('command_output');
245
269
  if (!options.expectedProvenance) failures.push('provenance');
246
270
  }
247
271
  return {
@@ -256,6 +280,36 @@ function checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, policy)
256
280
  };
257
281
  }
258
282
 
283
+ function workflowProofSignature(taskPath, proof) {
284
+ const hash = crypto.createHash('sha256');
285
+ hash.update('cafekit-workflow-proof-v1\0');
286
+ hash.update(String(taskPath));
287
+ hash.update('\0');
288
+ hash.update(proof.taskFile?.bytes || Buffer.alloc(0));
289
+ hash.update('\0');
290
+ hash.update(proof.receiptBytes || Buffer.alloc(0));
291
+ hash.update('\0');
292
+ hash.update(String(proof.status || 'unknown'));
293
+ return hash.digest('hex');
294
+ }
295
+
296
+ function workflowDependencyProofState(featureDir, taskRegistry, runtimeContext, policy) {
297
+ const tasks = {};
298
+ for (const taskPath of Object.keys(taskRegistry || {}).sort()) {
299
+ const done = taskRegistry[taskPath]?.status === 'done';
300
+ const proof = checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, policy);
301
+ const valid = proof.failures.length === 0;
302
+ tasks[taskPath] = {
303
+ done,
304
+ valid,
305
+ eligible: done && valid,
306
+ signature: workflowProofSignature(taskPath, proof),
307
+ failures: [...proof.failures],
308
+ };
309
+ }
310
+ return tasks;
311
+ }
312
+
259
313
  function checkWorkflowReceiptSet(candidates, projectRoot, runtimeSession, policy) {
260
314
  const failures = [];
261
315
  for (const candidate of Array.isArray(candidates) ? candidates : []) {
@@ -298,5 +352,6 @@ module.exports = {
298
352
  evidenceBody,
299
353
  readTaskProof,
300
354
  safeRead,
355
+ workflowDependencyProofState,
301
356
  workflowReceiptBody,
302
357
  };