@haposoft/cafekit 0.16.5 → 0.16.7
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/README.md +1 -1
- package/bin/__tests__/codex-hooks.test.js +12 -5
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +112 -13
- package/src/claude/hooks/spec-gate.cjs +7 -6
- package/src/claude/hooks/spec-state.cjs +1 -1
- package/src/claude/rules/state-sync.md +8 -5
- package/src/claude/scripts/spec-receipt.cjs +38 -1
- package/src/claude/scripts/workflow-policy.cjs +22 -10
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/templates.md +4 -3
- package/src/codex/hooks/lib/spec-receipt.cjs +5 -0
- package/src/codex/hooks/spec-gate.cjs +3 -2
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/rules/state-sync.md +8 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/haposoft/cafekit)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://claude.ai/code)
|
|
8
8
|
[](https://developers.openai.com/codex)
|
|
@@ -1782,7 +1782,7 @@ test('Claude and Codex fail closed consistently for malformed cwd payloads', ()
|
|
|
1782
1782
|
});
|
|
1783
1783
|
});
|
|
1784
1784
|
|
|
1785
|
-
test('Codex emits the required decision for
|
|
1785
|
+
test('Codex emits the required decision for every receipt state', () => {
|
|
1786
1786
|
// Codex owns no checker of its own: both wrappers in .codex/hooks/lib forward to
|
|
1787
1787
|
// the shared module, so the two receipt modes arrive by inheritance. This case is
|
|
1788
1788
|
// the regression lock on that inheritance. Each state asserts the decision Codex
|
|
@@ -1847,12 +1847,19 @@ test('Codex emits the required decision for all six receipt states', () => {
|
|
|
1847
1847
|
return failures.length === 0 ? 'accept' : 'block';
|
|
1848
1848
|
};
|
|
1849
1849
|
|
|
1850
|
-
// 1. untracked task file —
|
|
1851
|
-
|
|
1850
|
+
// 1. untracked task file — no copy in HEAD, so there is no baseline to drift
|
|
1851
|
+
// from. Committing a packet is the user's choice and a project that
|
|
1852
|
+
// gitignores its specs root cannot do it at all, so binding here only failed
|
|
1853
|
+
// the receipt permanently once the source moved on. The structural checks
|
|
1854
|
+
// still run; state 6 below is the state that binds.
|
|
1855
|
+
assert.equal(decide(), 'accept', 'an uncommitted task file must not be bound');
|
|
1856
|
+
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1852
1857
|
|
|
1853
|
-
// 2. staged but not committed — `git add`
|
|
1858
|
+
// 2. staged but not committed — `git add` puts the file in the index, not in
|
|
1859
|
+
// HEAD, so it reads exactly like state 1.
|
|
1854
1860
|
git('add', '-A');
|
|
1855
|
-
assert.equal(decide(), '
|
|
1861
|
+
assert.equal(decide(), 'accept', 'staging does not create a committed baseline');
|
|
1862
|
+
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1856
1863
|
|
|
1857
1864
|
// 3. committed on a clean tree — the one accepting state.
|
|
1858
1865
|
git('commit', '-qm', 'receipt and source');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haposoft/cafekit",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.7",
|
|
4
4
|
"description": "Native spec-driven workflows for Claude Code and Codex CLI. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
|
|
5
5
|
"author": "Haposoft <nghialt@haposoft.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1317,14 +1317,18 @@ test('32. process-v3 requires Receipt heading and runtime-bound provenance', ()
|
|
|
1317
1317
|
fs.rmSync(legacyHeading, { recursive: true, force: true });
|
|
1318
1318
|
}
|
|
1319
1319
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1320
|
+
// Base and Head are compared against the runtime for a task file that has a copy in
|
|
1321
|
+
// HEAD and no longer matches it. That is the state a stale pair has to reach now:
|
|
1322
|
+
// committing a packet is the user's choice, so an uncommitted one is not bound.
|
|
1323
|
+
const stale = makeWorkflowFixture(boundReceipt());
|
|
1324
1324
|
try {
|
|
1325
|
+
commitAll(stale, 'receipt');
|
|
1326
|
+
const taskFile = path.join(stale, 'specs', FEATURE, 'task-01-demo.md');
|
|
1327
|
+
fs.writeFileSync(taskFile, fs.readFileSync(taskFile, 'utf8')
|
|
1328
|
+
.replace(/^Head: .*$/m, `Head: ${VALID_BASE}`));
|
|
1325
1329
|
clearCache();
|
|
1326
1330
|
const body = parseBlock(runHook({}, stale).stdout);
|
|
1327
|
-
assert.ok(body, 'stale process-v3 provenance should block');
|
|
1331
|
+
assert.ok(body, 'stale process-v3 provenance should block once the packet is committed');
|
|
1328
1332
|
assert.match(body.reason, /provenance/);
|
|
1329
1333
|
} finally {
|
|
1330
1334
|
fs.rmSync(stale, { recursive: true, force: true });
|
|
@@ -1534,13 +1538,15 @@ test('37. committed receipt on a clean tree survives later commits', () => {
|
|
|
1534
1538
|
}
|
|
1535
1539
|
});
|
|
1536
1540
|
|
|
1537
|
-
test('38.
|
|
1541
|
+
test('38. an uncommitted task file is not bound to live Base and Head', () => {
|
|
1542
|
+
// Contract change. Committing a packet is the user's choice, and a project that
|
|
1543
|
+
// gitignores its specs root cannot do it at all. A task file with no copy in HEAD
|
|
1544
|
+
// has no baseline to drift from, so binding it only failed it permanently once the
|
|
1545
|
+
// source moved on. Case 50 proves the structural checks still run on such a file.
|
|
1538
1546
|
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1539
1547
|
try {
|
|
1540
1548
|
clearCache();
|
|
1541
|
-
|
|
1542
|
-
assert.ok(body && body.decision === 'block', 'an untracked task file must keep full binding');
|
|
1543
|
-
assert.match(body.reason, /\bprovenance\b/);
|
|
1549
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'an uncommitted task file must not be bound');
|
|
1544
1550
|
} finally {
|
|
1545
1551
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1546
1552
|
}
|
|
@@ -1560,15 +1566,19 @@ test('38b. the same stale receipt is accepted once committed on a clean tree', (
|
|
|
1560
1566
|
}
|
|
1561
1567
|
});
|
|
1562
1568
|
|
|
1563
|
-
test('39. staged
|
|
1569
|
+
test('39. a staged task file reads the same as an uncommitted one', () => {
|
|
1570
|
+
// Staging puts the file in the index, not in HEAD, so there is still no committed
|
|
1571
|
+
// baseline to compare against and the selector treats it exactly like case 38.
|
|
1572
|
+
// Case 40 covers the state that does bind: committed, then modified.
|
|
1564
1573
|
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1565
1574
|
try {
|
|
1566
1575
|
const add = spawnSync('git', ['-C', dir, 'add', '-A'], { encoding: 'utf8' });
|
|
1567
1576
|
assert.strictEqual(add.status, 0, add.stderr);
|
|
1577
|
+
const inHead = spawnSync('git', ['-C', dir, 'ls-tree', '--name-only', 'HEAD', '--',
|
|
1578
|
+
`specs/${FEATURE}/task-01-demo.md`], { encoding: 'utf8' });
|
|
1579
|
+
assert.strictEqual(inHead.stdout.trim(), '', 'staging must not put the file in HEAD');
|
|
1568
1580
|
clearCache();
|
|
1569
|
-
|
|
1570
|
-
assert.ok(body && body.decision === 'block', 'git add alone must not grant structure-only');
|
|
1571
|
-
assert.match(body.reason, /\bprovenance\b/);
|
|
1581
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'a staged task file has no committed baseline either');
|
|
1572
1582
|
} finally {
|
|
1573
1583
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1574
1584
|
}
|
|
@@ -1832,6 +1842,95 @@ test('47. completed-set branch accepts committed receipts and blocks an edited o
|
|
|
1832
1842
|
}
|
|
1833
1843
|
});
|
|
1834
1844
|
|
|
1845
|
+
test('51. Receipt fields written as list items are accepted', () => {
|
|
1846
|
+
// Every neighbouring section writes its fields as list items — the Verification Plan's
|
|
1847
|
+
// own `- Command:` sits a few lines above — so continuing that house style inside the
|
|
1848
|
+
// Receipt is the natural mistake. It used to fail verification_state, command,
|
|
1849
|
+
// exit_result and command_identity at once, with a message telling the user to rewrite
|
|
1850
|
+
// a Receipt whose content was already correct.
|
|
1851
|
+
const bulleted = boundReceipt().map((line) => (
|
|
1852
|
+
/^(Verification|Command|Exit|Base|Head):/.test(line) ? `- ${line}` : line
|
|
1853
|
+
));
|
|
1854
|
+
assert.ok(bulleted.some((line) => line.startsWith('- Verification:')), 'the fixture must be bulleted');
|
|
1855
|
+
const dir = makeWorkflowFixture(bulleted);
|
|
1856
|
+
try {
|
|
1857
|
+
clearCache();
|
|
1858
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'a bulleted Receipt must be read, not rejected');
|
|
1859
|
+
} finally {
|
|
1860
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
test('52. the block names what to change, not just which check failed', () => {
|
|
1865
|
+
// The same sentence used to answer every receipt failure, including the ones where
|
|
1866
|
+
// the Receipt was present and one field simply could not be read.
|
|
1867
|
+
const dir = makeWorkflowFixture([
|
|
1868
|
+
'## Receipt', '', 'Command: node --test', 'Exit: 0',
|
|
1869
|
+
`Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, '```text', '$ node --test', 'pass: 1', '```',
|
|
1870
|
+
]);
|
|
1871
|
+
try {
|
|
1872
|
+
clearCache();
|
|
1873
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1874
|
+
assert.ok(body && body.decision === 'block');
|
|
1875
|
+
assert.match(body.reason, /add a `Verification: PASS` line/, 'the fix must name the missing field');
|
|
1876
|
+
assert.doesNotMatch(body.reason, /write a runtime-bound `## Receipt` with command output/,
|
|
1877
|
+
'the generic instruction must not stand in for a specific one');
|
|
1878
|
+
} finally {
|
|
1879
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1880
|
+
}
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test('49. an unversioned specs root does not keep every receipt bound', () => {
|
|
1884
|
+
// Committing specs/ is the user's choice, not the tool's requirement. Without a
|
|
1885
|
+
// committed copy of the task file there is no baseline, so the selector used to keep
|
|
1886
|
+
// full binding — which fails every receipt permanently after the first source edit.
|
|
1887
|
+
// A project that has chosen not to version its packets has no drift to detect.
|
|
1888
|
+
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1889
|
+
try {
|
|
1890
|
+
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
1891
|
+
fs.writeFileSync(path.join(dir, 'src', 'app.js'), 'module.exports = 1;\n');
|
|
1892
|
+
for (const args of [['add', 'src'], ['commit', '-qm', 'source only']]) {
|
|
1893
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
1894
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1895
|
+
}
|
|
1896
|
+
const tracked = spawnSync('git', ['-C', dir, 'ls-files', '--', 'specs'], { encoding: 'utf8' });
|
|
1897
|
+
assert.strictEqual(tracked.stdout.trim(), '', 'the specs root must be untracked for this case to mean anything');
|
|
1898
|
+
fs.writeFileSync(path.join(dir, 'src', 'app.js'), 'module.exports = 2;\n');
|
|
1899
|
+
clearCache();
|
|
1900
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'an unversioned specs root must not stay bound forever');
|
|
1901
|
+
} finally {
|
|
1902
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
test('50. an uncommitted task file is still checked, only not against Base and Head', () => {
|
|
1907
|
+
// Dropping the binding must not stop the gate reading the packet. A gitignored or
|
|
1908
|
+
// simply uncommitted specs root keeps every structural check: a failing exit, a
|
|
1909
|
+
// placeholder, an empty output block, and a command that does not match the
|
|
1910
|
+
// Verification Plan all still block.
|
|
1911
|
+
const cases = [
|
|
1912
|
+
[['## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 1',
|
|
1913
|
+
`Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`, '```text', '$ node --test', 'fail: 1', '```'], /exit_result/],
|
|
1914
|
+
[['## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
1915
|
+
`Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`, '```text', '```'], /command_output/],
|
|
1916
|
+
[['## Receipt', '', 'Verification: PASS', 'Command: pnpm test', 'Exit: 0',
|
|
1917
|
+
`Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`, '```text', '$ pnpm test', 'pass', '```'], /command_identity/],
|
|
1918
|
+
];
|
|
1919
|
+
for (const [receipt, expected] of cases) {
|
|
1920
|
+
const dir = makeWorkflowFixture(receipt);
|
|
1921
|
+
try {
|
|
1922
|
+
const tracked = spawnSync('git', ['-C', dir, 'ls-files', '--', 'specs'], { encoding: 'utf8' });
|
|
1923
|
+
assert.strictEqual(tracked.stdout.trim(), '', 'the packet must be uncommitted for this case to mean anything');
|
|
1924
|
+
clearCache();
|
|
1925
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1926
|
+
assert.ok(body && body.decision === 'block', `an uncommitted packet must still be checked: ${expected}`);
|
|
1927
|
+
assert.match(body.reason, expected);
|
|
1928
|
+
} finally {
|
|
1929
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1835
1934
|
test('48. the gate\'s own runtime state does not defeat structure mode', () => {
|
|
1836
1935
|
// The gate writes its cache under .claude/hooks/.logs on every Stop. If that
|
|
1837
1936
|
// write counted as a dirty tree, a project tracking its runtime directory could
|
|
@@ -150,7 +150,8 @@ try {
|
|
|
150
150
|
const lines = [`⚠️ Completion gate: ${checked.failures.length} done task(s) lack a verification receipt.`];
|
|
151
151
|
for (const failure of checked.failures) {
|
|
152
152
|
lines.push(`- \`${failure.featureName}/${failure.taskPath}\`: failed check(s) ${failure.failures.join(', ')}`);
|
|
153
|
-
|
|
153
|
+
const hint = typeof RECEIPT.receiptFixHint === 'function' ? RECEIPT.receiptFixHint(failure.failures) : null;
|
|
154
|
+
lines.push(` Fix in \`specs/${failure.featureName}/${failure.taskPath}\`: ${hint || 'write a runtime-bound `## Receipt` with the planned command'}.`);
|
|
154
155
|
}
|
|
155
156
|
emitBlock(lines.slice(0, 8).join('\n'));
|
|
156
157
|
process.exit(0);
|
|
@@ -240,10 +241,10 @@ try {
|
|
|
240
241
|
// task currently marked done, so a cached PASS cannot hide later mutations
|
|
241
242
|
// (deletion, placeholder, changed Verification/Command/Exit, missing
|
|
242
243
|
// output, edited artifact bytes). Base/Head are compared against the live
|
|
243
|
-
// runtime only while the task file
|
|
244
|
-
//
|
|
245
|
-
// work elsewhere in the tree
|
|
246
|
-
// not truth.
|
|
244
|
+
// runtime only while the task file has a copy in HEAD that it no longer
|
|
245
|
+
// matches; committed-and-unchanged and never-committed are both checked on
|
|
246
|
+
// structure alone, and work elsewhere in the tree never reopens a receipt.
|
|
247
|
+
// Cache is an optimization, not truth.
|
|
247
248
|
const featureCache = cacheExists ? (cache[featureName] || {}) : {};
|
|
248
249
|
const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
|
|
249
250
|
(taskRegistry[tp]?.status || 'pending') === 'done'
|
|
@@ -302,7 +303,7 @@ try {
|
|
|
302
303
|
lines.push(taskPath === 'feature-receipt.md'
|
|
303
304
|
? ` Fix: run final integration proof, then write \`specs/${featureName}/feature-receipt.md\`.`
|
|
304
305
|
: processWorkflow
|
|
305
|
-
? ` Fix
|
|
306
|
+
? ` Fix in \`specs/${featureName}/${path.posix.basename(taskPath)}\`: ${(typeof RECEIPT.receiptFixHint === 'function' && RECEIPT.receiptFixHint(fails)) || 'write a runtime-bound `## Receipt` with command output'}.`
|
|
306
307
|
: ` Fix: write canonical proof to \`specs/${featureName}/receipts/${path.posix.basename(taskPath)}\`; legacy \`## Evidence\` remains read-compatible.`);
|
|
307
308
|
}
|
|
308
309
|
if (completionBlocked) {
|
|
@@ -226,7 +226,7 @@ try {
|
|
|
226
226
|
lines.push('- Migration required: add `Specs-Contract: process-first-ready-v1` after the plan title before tasks can enter Next.');
|
|
227
227
|
}
|
|
228
228
|
lines.push('- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.');
|
|
229
|
-
lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime
|
|
229
|
+
lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime only while a committed task file no longer matches its committed bytes.`);
|
|
230
230
|
} else {
|
|
231
231
|
lines.push('- Sync `spec.json` + task Markdown status after verified work; task proof belongs in `receipts/<task-basename>.md`.');
|
|
232
232
|
lines.push(`- Create \`feature-receipt.md\` once after final integration proof${featureReceiptPresent ? ' (present)' : ' (not required before closeout)'}.`);
|
|
@@ -32,11 +32,14 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
32
32
|
`Verification: PASS`, runtime-derived Base and Head values, and a non-empty
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
|
-
A done task
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
A done task is bound to live Base and Head only while its own task file has a copy in
|
|
36
|
+
`HEAD` that its current bytes no longer match. A file that is committed and unchanged,
|
|
37
|
+
and a file that was never committed at all, are both validated on structure alone: the
|
|
38
|
+
first has a baseline it still matches, the second has no baseline to drift from, and
|
|
39
|
+
committing a packet is the user's choice that a gitignored specs root removes entirely.
|
|
40
|
+
Work elsewhere in the tree never reopens a receipt, because Base is the last commit
|
|
41
|
+
touching anything outside the specs root, so every finished receipt records an older
|
|
42
|
+
Base by construction. Every structural check runs in all cases.
|
|
40
43
|
The gate detects drift, not invention: a valid
|
|
41
44
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
42
45
|
that the command was executed. Missing, stale, contradictory,
|
|
@@ -113,7 +113,10 @@ function hasNonEmptyFencedBlock(body) {
|
|
|
113
113
|
|
|
114
114
|
function fieldValues(body, name) {
|
|
115
115
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
-
|
|
116
|
+
// A leading list marker is accepted: every neighbouring section of a task file writes
|
|
117
|
+
// its fields as list items, so continuing that style inside the Receipt is the
|
|
118
|
+
// natural mistake rather than a malformed receipt.
|
|
119
|
+
const matcher = new RegExp(`^\\s*(?:[-*+]\\s+)?${escaped}\\s*:\\s*(.*?)\\s*$`, 'i');
|
|
117
120
|
return String(body || '').split('\n').map((line) => line.match(matcher)).filter(Boolean).map((match) => match[1]);
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -208,6 +211,14 @@ function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
|
|
|
208
211
|
const specsRelative = toPosix(path.relative(root, specsRoot));
|
|
209
212
|
const escapes = (value) => !value || value === '..' || value.startsWith('../') || path.isAbsolute(value);
|
|
210
213
|
if (escapes(taskRelative) || escapes(specsRelative)) return 'binding';
|
|
214
|
+
// Whether the packet is committed at all is the user's choice, and for a project
|
|
215
|
+
// that gitignores its specs root it is not even available. A task file with no copy
|
|
216
|
+
// in HEAD therefore has no baseline to drift from, and binding it to live Base/Head
|
|
217
|
+
// only fails it permanently once the source moves on. Its structural checks still
|
|
218
|
+
// run in full. A git failure is not the same thing and stays fail-safe.
|
|
219
|
+
const inHead = tryGitBytes(root, ['ls-tree', '-z', '--name-only', 'HEAD', '--', taskRelative]);
|
|
220
|
+
if (inHead === null) return 'binding';
|
|
221
|
+
if (inHead.length === 0) return 'structure';
|
|
211
222
|
// `--filters` applies the same smudge/eol conversion the worktree copy has,
|
|
212
223
|
// so a checkout that converts line endings can still reach structure mode.
|
|
213
224
|
const committed = tryGitBytes(root, ['cat-file', '--filters', `HEAD:${taskRelative}`]);
|
|
@@ -327,6 +338,31 @@ function checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, policy)
|
|
|
327
338
|
};
|
|
328
339
|
}
|
|
329
340
|
|
|
341
|
+
// The gate used to answer every receipt failure with the same sentence — write a
|
|
342
|
+
// runtime-bound Receipt — which is the wrong instruction whenever the Receipt is
|
|
343
|
+
// present and one field simply could not be read. Name what to change instead.
|
|
344
|
+
const RECEIPT_FIX_HINTS = Object.freeze([
|
|
345
|
+
['missing_receipt', 'add exactly one `## Receipt` section at the end of the file'],
|
|
346
|
+
['task_status', 'the file needs exactly one `Status: done` line outside any fence'],
|
|
347
|
+
['verification_state', 'add a `Verification: PASS` line'],
|
|
348
|
+
['command', 'add a `Command:` line holding the exact command that was run'],
|
|
349
|
+
['command_identity', 'the `Command:` line must match the Verification Plan command exactly'],
|
|
350
|
+
['exit_result', 'add `Exit: 0`; a non-zero exit or a failure marker does not close a task'],
|
|
351
|
+
['command_output', 'add a non-empty fenced block holding the real command output'],
|
|
352
|
+
['placeholder', 'replace the placeholder values with real ones'],
|
|
353
|
+
['provenance', 'add runtime-derived `Base:` and `Head:` lines matching the current runtime'],
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Turn receipt check codes into one instruction naming what to change.
|
|
358
|
+
*/
|
|
359
|
+
function receiptFixHint(failures) {
|
|
360
|
+
const codes = new Set(Array.isArray(failures) ? failures : []);
|
|
361
|
+
const hints = RECEIPT_FIX_HINTS.filter(([code]) => codes.has(code)).map(([, hint]) => hint);
|
|
362
|
+
if (hints.length === 0) return null;
|
|
363
|
+
return hints.length === 1 ? hints[0] : `${hints.slice(0, -1).join('; ')}; and ${hints[hints.length - 1]}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
330
366
|
function workflowProofSignature(taskPath, proof) {
|
|
331
367
|
const hash = crypto.createHash('sha256');
|
|
332
368
|
hash.update('cafekit-workflow-proof-v1\0');
|
|
@@ -396,6 +432,7 @@ module.exports = {
|
|
|
396
432
|
checkTaskReceipt,
|
|
397
433
|
checkWorkflowReceiptSet,
|
|
398
434
|
checkWorkflowTaskReceipt,
|
|
435
|
+
receiptFixHint,
|
|
399
436
|
evidenceBody,
|
|
400
437
|
readTaskProof,
|
|
401
438
|
safeRead,
|
|
@@ -1884,6 +1884,18 @@ function flashState(taskRegistry = {}) {
|
|
|
1884
1884
|
.map(([taskPath]) => taskPath);
|
|
1885
1885
|
}
|
|
1886
1886
|
|
|
1887
|
+
// Every other section of a task file writes its fields as list items — the Verification
|
|
1888
|
+
// Plan's own `- Command:` sits a few lines above the Receipt — so continuing that house
|
|
1889
|
+
// style inside the Receipt is the natural mistake, and it used to fail five checks at
|
|
1890
|
+
// once with a message that pointed at the content instead of the dash. Accept either
|
|
1891
|
+
// form. The receipt body is cut to its own `## Receipt` section, so this cannot pick up
|
|
1892
|
+
// the Verification Plan's fields.
|
|
1893
|
+
const RECEIPT_FIELD_PREFIX = '\\s*(?:[-*+]\\s+)?';
|
|
1894
|
+
|
|
1895
|
+
function receiptFieldPattern(name, tail = '\\s*:', flags = '') {
|
|
1896
|
+
return new RegExp(`^${RECEIPT_FIELD_PREFIX}${name}${tail}`, flags);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1887
1899
|
function validateCanonicalReceipt(body, options = {}) {
|
|
1888
1900
|
if (typeof body !== 'string') return ['verification_state'];
|
|
1889
1901
|
const failures = [];
|
|
@@ -1900,15 +1912,15 @@ function validateCanonicalReceipt(body, options = {}) {
|
|
|
1900
1912
|
addFailure('verification_state');
|
|
1901
1913
|
}
|
|
1902
1914
|
// Unambiguous verification state: must be Verification: PASS exactly
|
|
1903
|
-
if (
|
|
1915
|
+
if (!receiptFieldPattern('Verification', '\\s*:\\s*PASS\\s*$', 'm').test(body)) {
|
|
1904
1916
|
addFailure('verification_state');
|
|
1905
1917
|
}
|
|
1906
1918
|
// Command must be present with non-empty concrete value on same line, not placeholder
|
|
1907
|
-
const cmdLine = body.split('\n').find((l) =>
|
|
1908
|
-
if (!cmdLine ||
|
|
1919
|
+
const cmdLine = body.split('\n').find((l) => receiptFieldPattern('Command(?:\\(s\\))?', '\\s*:', 'm').test(l)) || null;
|
|
1920
|
+
if (!cmdLine || !receiptFieldPattern('Command(?:\\(s\\))?', '\\s*:[ \\t]*\\S', 'm').test(cmdLine)) {
|
|
1909
1921
|
addFailure('command');
|
|
1910
1922
|
} else {
|
|
1911
|
-
const m = cmdLine.match(
|
|
1923
|
+
const m = cmdLine.match(receiptFieldPattern('Command(?:\\(s\\))?', '\\s*:[ \\t]*(.*)$', 'm'));
|
|
1912
1924
|
const val = m ? m[1].trim() : '';
|
|
1913
1925
|
if (isPlaceholderToken(val)) {
|
|
1914
1926
|
addFailure('command');
|
|
@@ -1921,15 +1933,15 @@ function validateCanonicalReceipt(body, options = {}) {
|
|
|
1921
1933
|
const exitValues = [];
|
|
1922
1934
|
const resultValues = [];
|
|
1923
1935
|
for (const line of lines) {
|
|
1924
|
-
if (
|
|
1925
|
-
const m = line.match(
|
|
1936
|
+
if (receiptFieldPattern('Exit', '\\s*:', 'i').test(line)) {
|
|
1937
|
+
const m = line.match(receiptFieldPattern('Exit', '\\s*:\\s*(.*)$', 'i'));
|
|
1926
1938
|
exitValues.push(m ? m[1].trim() : '');
|
|
1927
1939
|
} else if (/exit\s+code\s*[:=]/i.test(line)) {
|
|
1928
1940
|
const m = line.match(/exit\s+code\s*[:=]\s*(.*)$/i);
|
|
1929
1941
|
if (m) exitValues.push(m[1].trim());
|
|
1930
1942
|
}
|
|
1931
|
-
if (
|
|
1932
|
-
const m = line.match(
|
|
1943
|
+
if (receiptFieldPattern('Result', '\\s*:', 'i').test(line)) {
|
|
1944
|
+
const m = line.match(receiptFieldPattern('Result', '\\s*:\\s*(.+?)\\s*$', 'i'));
|
|
1933
1945
|
resultValues.push(m ? m[1].trim() : '');
|
|
1934
1946
|
}
|
|
1935
1947
|
}
|
|
@@ -1954,8 +1966,8 @@ function validateCanonicalReceipt(body, options = {}) {
|
|
|
1954
1966
|
// Without expected values this validates schema only; it does not claim identity binding.
|
|
1955
1967
|
const receiptLines = body.split('\n');
|
|
1956
1968
|
const readField = (name) => receiptLines
|
|
1957
|
-
.filter((line) =>
|
|
1958
|
-
.map((line) => line.replace(
|
|
1969
|
+
.filter((line) => receiptFieldPattern(name, '\\s*:', 'i').test(line))
|
|
1970
|
+
.map((line) => line.replace(receiptFieldPattern(name, '\\s*:\\s*', 'i'), '').trim());
|
|
1959
1971
|
const baseValues = readField('Base');
|
|
1960
1972
|
const headValues = readField('Head');
|
|
1961
1973
|
const baseShaValues = readField('base_sha');
|
|
@@ -127,8 +127,8 @@ it does not invent product approval, review independence, or runtime coverage.
|
|
|
127
127
|
The shared workflow resolver recognizes only a regular `plan.md` plus one or more
|
|
128
128
|
regular flat `task-*.md` files in one direct feature directory, and projects exact
|
|
129
129
|
`Status:` values without inferring blockers from prose. The Stop gate re-reads every
|
|
130
|
-
done Receipt, binding Base and Head to the live runtime
|
|
131
|
-
committed
|
|
130
|
+
done Receipt, binding Base and Head to the live runtime only while the task file has a
|
|
131
|
+
committed copy it no longer matches; work elsewhere in the tree never reopens it. It detects drift
|
|
132
132
|
between a receipt and the tree, not invention: a valid pair costs one command and no
|
|
133
133
|
verification run, so a receipt for a command that never ran satisfies it in either
|
|
134
134
|
mode. C3 is where a human weighs the evidence. These checks are a final safety net,
|
|
@@ -131,9 +131,10 @@ Tests: 3 passed, 3 total
|
|
|
131
131
|
```
|
|
132
132
|
````
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
Each field is its own line; a leading `- ` reads identically. The receipt is
|
|
135
|
+
invalid when it has a placeholder, missing output fence, zero executed tests
|
|
136
|
+
where tests are required, a failure marker, nonzero exit, stale Base/Head, or a
|
|
137
|
+
bare PASS claim without the command output.
|
|
137
138
|
|
|
138
139
|
## EARS sentence patterns
|
|
139
140
|
|
|
@@ -99,6 +99,10 @@ function checkFeatureReceipt(featureDir, runtimeContext) {
|
|
|
99
99
|
if (!policy) return { failures: ['shared_validator'], status: 'missing' };
|
|
100
100
|
return receiptHelper().checkFeatureReceipt(featureDir, runtimeContext, policy);
|
|
101
101
|
}
|
|
102
|
+
function receiptFixHint(failures) {
|
|
103
|
+
const helper = receiptHelper();
|
|
104
|
+
return typeof helper.receiptFixHint === 'function' ? helper.receiptFixHint(failures) : null;
|
|
105
|
+
}
|
|
102
106
|
function readTaskProof(featureDir, taskPath) {
|
|
103
107
|
return receiptHelper().readTaskProof(featureDir, taskPath, getSharedPolicy() || {});
|
|
104
108
|
}
|
|
@@ -115,6 +119,7 @@ module.exports = {
|
|
|
115
119
|
loadSharedPolicy,
|
|
116
120
|
loadSharedReceipt,
|
|
117
121
|
readTaskProof,
|
|
122
|
+
receiptFixHint,
|
|
118
123
|
safeTaskFile,
|
|
119
124
|
validateCanonicalReceipt,
|
|
120
125
|
};
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
checkWorkflowReceiptDetails,
|
|
17
17
|
checkWorkflowReceiptSet,
|
|
18
18
|
loadSharedPolicy,
|
|
19
|
+
receiptFixHint,
|
|
19
20
|
} = require('./lib/spec-receipt.cjs');
|
|
20
21
|
const { taskStatusMap } = require('./lib/spec-utils.cjs');
|
|
21
22
|
const FINAL_STATE = require('./completion-authority-check.cjs');
|
|
@@ -75,7 +76,7 @@ try {
|
|
|
75
76
|
const lines = [`Completion gate: ${checked.failures.length} done task(s) lack a verification receipt.`];
|
|
76
77
|
for (const failure of checked.failures) {
|
|
77
78
|
lines.push(`- \`${failure.featureName}/${failure.taskPath}\`: failed check(s) ${failure.failures.join(', ')}`);
|
|
78
|
-
lines.push(`
|
|
79
|
+
lines.push(` In \`specs/${failure.featureName}/${failure.taskPath}\`: ${receiptFixHint(failure.failures) || 'write a runtime-bound `## Receipt` with the planned command'}.`);
|
|
79
80
|
}
|
|
80
81
|
emitBlock(lines.slice(0, 8).join('\n'));
|
|
81
82
|
process.exit(0);
|
|
@@ -228,7 +229,7 @@ try {
|
|
|
228
229
|
lines.push(result.taskPath === 'feature-receipt.md'
|
|
229
230
|
? ` Run final integration proof, then write \`specs/${active.featureName}/feature-receipt.md\`.`
|
|
230
231
|
: processWorkflow
|
|
231
|
-
? `
|
|
232
|
+
? ` In \`specs/${active.featureName}/${path.posix.basename(result.taskPath)}\`: ${receiptFixHint(result.failures) || 'write a runtime-bound `## Receipt` with command output'}.`
|
|
232
233
|
: ` Write canonical proof to \`specs/${active.featureName}/receipts/${path.posix.basename(result.taskPath)}\`; legacy \`## Evidence\` remains read-compatible.`);
|
|
233
234
|
}
|
|
234
235
|
if (completionBlocked) {
|
|
@@ -156,7 +156,7 @@ try {
|
|
|
156
156
|
}
|
|
157
157
|
lines.push(
|
|
158
158
|
'- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.',
|
|
159
|
-
`- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime
|
|
159
|
+
`- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime only while a committed task file no longer matches its committed bytes.`,
|
|
160
160
|
'- Hooks revalidate receipt bytes but never grant approval.'
|
|
161
161
|
);
|
|
162
162
|
} else {
|
|
@@ -32,11 +32,14 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
32
32
|
`Verification: PASS`, runtime-derived Base and Head values, and a non-empty
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
|
-
A done task
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
A done task is bound to live Base and Head only while its own task file has a copy in
|
|
36
|
+
`HEAD` that its current bytes no longer match. A file that is committed and unchanged,
|
|
37
|
+
and a file that was never committed at all, are both validated on structure alone: the
|
|
38
|
+
first has a baseline it still matches, the second has no baseline to drift from, and
|
|
39
|
+
committing a packet is the user's choice that a gitignored specs root removes entirely.
|
|
40
|
+
Work elsewhere in the tree never reopens a receipt, because Base is the last commit
|
|
41
|
+
touching anything outside the specs root, so every finished receipt records an older
|
|
42
|
+
Base by construction. Every structural check runs in all cases.
|
|
40
43
|
The gate detects drift, not invention: a valid
|
|
41
44
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
42
45
|
that the command was executed. Missing, stale, contradictory,
|