@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.
- package/bin/__tests__/codex-hooks.test.js +245 -0
- package/bin/__tests__/codex-native.test.js +463 -20
- package/bin/__tests__/package-inventory.test.js +484 -6
- package/package.json +1 -1
- package/src/claude/agents/spec-maker.md +14 -8
- package/src/claude/hooks/__tests__/spec-gate.test.js +77 -1
- package/src/claude/hooks/__tests__/state.test.js +249 -3
- package/src/claude/hooks/spec-state.cjs +30 -3
- package/src/claude/scripts/spec-receipt.cjs +76 -21
- package/src/claude/scripts/spec-resolver.cjs +144 -4
- package/src/claude/skills/specs/SKILL.md +17 -7
- package/src/claude/skills/specs/references/review.md +15 -0
- package/src/claude/skills/specs/references/templates.md +49 -20
- package/src/codex/hooks/spec-state.cjs +32 -4
|
@@ -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',
|
|
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
|
|
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, [
|
|
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, [
|
|
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) =>
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
81
|
-
const
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
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 =
|
|
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(
|
|
243
|
-
failures.push(...
|
|
244
|
-
|
|
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
|
};
|