@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 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
- [![Version](https://img.shields.io/badge/version-0.16.5-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.16.7-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Native-orange.svg)](https://claude.ai/code)
8
8
  [![Codex%20CLI](https://img.shields.io/badge/Codex%20CLI-Native-111111.svg)](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 all six receipt states', () => {
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 — never committed, so the binding stays.
1851
- assert.equal(decide(), 'block', 'an untracked task file must keep full binding');
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` alone must not read as committed.
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(), 'block', 'a staged-only task file must keep full binding');
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.5",
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
- const stale = makeWorkflowFixture([
1321
- '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1322
- `Base: ${VALID_BASE}`, `Head: ${VALID_BASE}`, '```text', 'pass', '```',
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. untracked task file requires binding', () => {
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
- const body = parseBlock(runHook({}, dir).stdout);
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 but uncommitted task file requires binding', () => {
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
- const body = parseBlock(runHook({}, dir).stdout);
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
- lines.push(` Fix: write a runtime-bound \`## Receipt\` with the planned command in \`specs/${failure.featureName}/${failure.taskPath}\`.`);
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 differs from its committed bytes; once it
244
- // is committed and unchanged the receipt is checked on structure alone, and
245
- // work elsewhere in the tree no longer reopens it. Cache is an optimization,
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: write a runtime-bound \`## Receipt\` with command output in \`specs/${featureName}/${path.posix.basename(taskPath)}\`.`
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 until the task file is committed and unchanged.`);
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 keeps that binding while its file differs from its committed bytes,
36
- and is validated on structure alone once that file is committed and unchanged. Work
37
- elsewhere in the tree does not reopen it: Base is the last commit touching anything
38
- outside the specs root, so every finished receipt records an older Base by
39
- construction and reopening on a dirty tree failed all of them at once.
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
- const matcher = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*?)\\s*$`, 'i');
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 (!/^\s*Verification:\s*PASS\s*$/m.test(body)) {
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) => /^\s*Command(?:\(s\))?\s*:/m.test(l)) || null;
1908
- if (!cmdLine || !/^\s*Command(?:\(s\))?\s*:[ \t]*\S/m.test(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(/^\s*Command(?:\(s\))?\s*:[ \t]*(.*)$/m);
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 (/^\s*Exit\s*:/i.test(line)) {
1925
- const m = line.match(/^\s*Exit\s*:\s*(.*)$/i);
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 (/^\s*Result\s*:/i.test(line)) {
1932
- const m = line.match(/^\s*Result\s*:\s*(.+?)\s*$/i);
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) => new RegExp(`^\\s*${name}\\s*:`, 'i').test(line))
1958
- .map((line) => line.replace(new RegExp(`^\\s*${name}\\s*:\\s*`, 'i'), '').trim());
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 until the task file is
131
- committed and unchanged; work elsewhere in the tree does not reopen it. It detects drift
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
- The receipt is invalid when it has a placeholder, missing output fence, zero
135
- executed tests where tests are required, a failure marker, nonzero exit, stale
136
- Base/Head, or a bare PASS claim without the command output.
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(` Write a runtime-bound \`## Receipt\` with the planned command in \`specs/${failure.featureName}/${failure.taskPath}\`.`);
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
- ? ` Write a runtime-bound \`## Receipt\` with command output in \`specs/${active.featureName}/${path.posix.basename(result.taskPath)}\`.`
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 until the task file is committed and unchanged.`,
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 keeps that binding while its file differs from its committed bytes,
36
- and is validated on structure alone once that file is committed and unchanged. Work
37
- elsewhere in the tree does not reopen it: Base is the last commit touching anything
38
- outside the specs root, so every finished receipt records an older Base by
39
- construction and reopening on a dirty tree failed all of them at once.
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,