@haposoft/cafekit 0.16.4 → 0.16.6

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.4-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.16.6-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');
@@ -1866,13 +1873,19 @@ test('Codex emits the required decision for all six receipt states', () => {
1866
1873
  assert.equal(decide(), 'accept', 'a committed, unchanged receipt on a clean tree must be accepted');
1867
1874
  assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
1868
1875
 
1869
- // 4. dirty tree outside the specs root, hook run from a nested directory with
1870
- // the change outside that directory's subtree.
1876
+ // 4. Contract change: an uncommitted file outside the specs root no longer
1877
+ // rebinds a committed receipt. Base is the last commit touching anything
1878
+ // outside the specs root, so every receipt written before a later commit
1879
+ // records an older Base by construction — measured at 52 of 52 on the
1880
+ // CafeKit repository. The condition therefore failed every historical
1881
+ // receipt at once whenever any unrelated file was dirty, and could not
1882
+ // tell a tampered receipt from an untouched one. Case 6 below still
1883
+ // catches the receipt that actually changed.
1871
1884
  fs.writeFileSync(outsideDirt, 'uncommitted\n');
1872
1885
  assert.ok(!path.relative(nested, outsideDirt).startsWith('..') === false,
1873
1886
  'the dirt must lie outside the working directory used for the run');
1874
- assert.equal(decide(), 'block', 'an uncommitted change outside the specs root must rebind');
1875
- assert.equal(sharedDecides(), 'block', 'the shared checker must agree');
1887
+ assert.equal(decide(), 'accept', 'unrelated dirt must not reopen a committed receipt');
1888
+ assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
1876
1889
  git('checkout', '--', 'src/outside.txt');
1877
1890
 
1878
1891
  // 5. work confined to the specs root does not reopen a committed receipt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
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
  }
@@ -1621,43 +1631,61 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
1621
1631
  }
1622
1632
  });
1623
1633
 
1624
- test('42. dirty tree outside specs requires binding, regardless of working directory', () => {
1625
- const dir = makeWorkflowFixture(boundReceipt());
1634
+ test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
1635
+ // Contract change. This used to require a block. `Base` is the last commit
1636
+ // touching anything outside the specs root, so a receipt written before any
1637
+ // later commit records an older `Base` by construction — 52 of 52 done
1638
+ // receipts on the CafeKit repository. Reopening on a dirty tree therefore
1639
+ // failed every historical receipt at once, every time, which blocked projects
1640
+ // on their first turn of a session with nothing changed. What still blocks is
1641
+ // the task file that actually differs from its committed bytes, asserted
1642
+ // below from both working directories, since the git call must pin the
1643
+ // project root rather than the working directory.
1644
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1626
1645
  try {
1627
1646
  commitAll(dir, 'receipt');
1628
1647
  fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
1629
1648
  fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
1630
1649
  clearCache();
1631
- const fromRoot = parseBlock(runHook({}, dir).stdout);
1632
- assert.ok(fromRoot && fromRoot.decision === 'block', 'an uncommitted change outside specs must rebind');
1633
- assert.match(fromRoot.reason, /\bprovenance\b/);
1650
+ assert.strictEqual(runHook({}, dir).stdout, '', 'unrelated dirt must not reopen a committed receipt');
1634
1651
  clearCache();
1635
- const fromSpecs = parseBlock(runHookFrom(dir, path.join(dir, 'specs', FEATURE)).stdout);
1636
- assert.ok(fromSpecs && fromSpecs.decision === 'block', 'the status check must pin the project root, not the working directory');
1637
- assert.match(fromSpecs.reason, /\bprovenance\b/);
1652
+ assert.strictEqual(runHookFrom(dir, path.join(dir, 'specs', FEATURE)).stdout, '',
1653
+ 'and must not reopen it when the hook runs from inside the specs root either');
1654
+
1655
+ const taskFile = path.join(dir, 'specs', FEATURE, 'task-01-demo.md');
1656
+ fs.writeFileSync(taskFile, fs.readFileSync(taskFile, 'utf8').replace(STALE_HEAD, 'd'.repeat(64)));
1657
+ for (const [label, run] of [['project root', () => runHook({}, dir)],
1658
+ ['specs root', () => runHookFrom(dir, path.join(dir, 'specs', FEATURE))]]) {
1659
+ clearCache();
1660
+ const body = parseBlock(run().stdout);
1661
+ assert.ok(body && body.decision === 'block', `an edited task file must rebind, run from the ${label}`);
1662
+ assert.match(body.reason, /\bprovenance\b/);
1663
+ }
1638
1664
  } finally {
1639
1665
  fs.rmSync(dir, { recursive: true, force: true });
1640
1666
  }
1641
1667
  });
1642
1668
 
1643
- test('42b. a change hidden by skip-worktree or assume-unchanged requires binding', () => {
1644
- // `git status` honors both index flags, so a tracked file outside the specs
1645
- // root can differ from HEAD while the tree reports clean. Structure mode must
1646
- // not be granted from a report that git itself is suppressing.
1669
+ test('42b. skip-worktree and assume-unchanged cannot hide an edited task file', () => {
1670
+ // Both index flags make `git status` report a modified file as clean. The mode
1671
+ // selector no longer consults status at all, but it must not become hideable
1672
+ // some other way: the committed-bytes check reads the file from disk and asks
1673
+ // git for `HEAD:<path>`, and neither consults the index. Marking the task file
1674
+ // with either flag must therefore change nothing.
1647
1675
  for (const flag of ['--skip-worktree', '--assume-unchanged']) {
1648
1676
  const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1649
1677
  try {
1650
- fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
1651
- fs.writeFileSync(path.join(dir, 'src', 'hidden.txt'), 'committed\n');
1652
- commitAll(dir, 'receipt and source');
1653
- const marked = spawnSync('git', ['-C', dir, 'update-index', flag, 'src/hidden.txt'], { encoding: 'utf8' });
1678
+ commitAll(dir, 'receipt');
1679
+ const taskRelative = `specs/${FEATURE}/task-01-demo.md`;
1680
+ const marked = spawnSync('git', ['-C', dir, 'update-index', flag, taskRelative], { encoding: 'utf8' });
1654
1681
  assert.strictEqual(marked.status, 0, marked.stderr);
1655
- fs.writeFileSync(path.join(dir, 'src', 'hidden.txt'), 'tampered\n');
1682
+ const taskFile = path.join(dir, taskRelative);
1683
+ fs.writeFileSync(taskFile, fs.readFileSync(taskFile, 'utf8').replace(STALE_HEAD, 'd'.repeat(64)));
1656
1684
  const status = spawnSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' });
1657
1685
  assert.strictEqual(status.stdout.trim(), '', `${flag} must make the tree look clean for this case to mean anything`);
1658
1686
  clearCache();
1659
1687
  const body = parseBlock(runHook({}, dir).stdout);
1660
- assert.ok(body && body.decision === 'block', `${flag} must not grant structure-only`);
1688
+ assert.ok(body && body.decision === 'block', `${flag} must not hide an edited task file`);
1661
1689
  assert.match(body.reason, /\bprovenance\b/);
1662
1690
  } finally {
1663
1691
  fs.rmSync(dir, { recursive: true, force: true });
@@ -1771,10 +1799,10 @@ test('46. a dirty file inside the specs root does not force binding', () => {
1771
1799
  }
1772
1800
  });
1773
1801
 
1774
- test('47. completed-set branch accepts committed receipts and blocks a tampered one', () => {
1802
+ test('47. completed-set branch accepts committed receipts and blocks an edited one', () => {
1775
1803
  // Two finished packets resolve to the completed-set branch, which is the path
1776
- // that revalidated every done receipt on every Stop. Prove the new mode works
1777
- // there, then prove it still blocks.
1804
+ // that revalidated every done receipt on every Stop. Prove committed receipts
1805
+ // survive both a clean and a dirty tree, then prove an edited one still blocks.
1778
1806
  const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1779
1807
  try {
1780
1808
  const secondDir = path.join(dir, 'specs', 'second');
@@ -1788,18 +1816,83 @@ test('47. completed-set branch accepts committed receipts and blocks a tampered
1788
1816
  clearCache();
1789
1817
  const accepted = runHook({}, dir);
1790
1818
  assert.strictEqual(accepted.stdout, '', 'the completed-set branch must accept committed receipts on a clean tree');
1819
+ // Contract change: unrelated dirt no longer reopens committed receipts. This
1820
+ // used to assert a block, which is what made the gate unusable in a project
1821
+ // holding several finished packets — one untracked file failed every receipt
1822
+ // in the repository at once. Base is the last commit touching anything
1823
+ // outside the specs root, so a historical receipt records an older Base by
1824
+ // construction and the check fired on all of them, always.
1791
1825
  fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
1792
1826
  fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
1793
1827
  clearCache();
1828
+ assert.strictEqual(runHook({}, dir).stdout, '', 'unrelated dirt must not reopen committed receipts');
1829
+
1830
+ // What still blocks: the receipt that actually changed after being committed.
1831
+ fs.writeFileSync(
1832
+ path.join(secondDir, 'task-01-second.md'),
1833
+ fs.readFileSync(path.join(secondDir, 'task-01-second.md'), 'utf8').replace(STALE_HEAD, 'd'.repeat(64)),
1834
+ );
1835
+ clearCache();
1794
1836
  const body = parseBlock(runHook({}, dir).stdout);
1795
- assert.ok(body && body.decision === 'block', 'the completed-set branch must rebind on a dirty tree');
1837
+ assert.ok(body && body.decision === 'block', 'a receipt edited after commit must still be rebound');
1796
1838
  assert.match(body.reason, /\bprovenance\b/);
1797
- assert.match(body.reason, /second|demo/);
1839
+ assert.match(body.reason, /second/);
1840
+ } finally {
1841
+ fs.rmSync(dir, { recursive: true, force: true });
1842
+ }
1843
+ });
1844
+
1845
+ test('49. an unversioned specs root does not keep every receipt bound', () => {
1846
+ // Committing specs/ is the user's choice, not the tool's requirement. Without a
1847
+ // committed copy of the task file there is no baseline, so the selector used to keep
1848
+ // full binding — which fails every receipt permanently after the first source edit.
1849
+ // A project that has chosen not to version its packets has no drift to detect.
1850
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1851
+ try {
1852
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
1853
+ fs.writeFileSync(path.join(dir, 'src', 'app.js'), 'module.exports = 1;\n');
1854
+ for (const args of [['add', 'src'], ['commit', '-qm', 'source only']]) {
1855
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
1856
+ assert.strictEqual(result.status, 0, result.stderr);
1857
+ }
1858
+ const tracked = spawnSync('git', ['-C', dir, 'ls-files', '--', 'specs'], { encoding: 'utf8' });
1859
+ assert.strictEqual(tracked.stdout.trim(), '', 'the specs root must be untracked for this case to mean anything');
1860
+ fs.writeFileSync(path.join(dir, 'src', 'app.js'), 'module.exports = 2;\n');
1861
+ clearCache();
1862
+ assert.strictEqual(runHook({}, dir).stdout, '', 'an unversioned specs root must not stay bound forever');
1798
1863
  } finally {
1799
1864
  fs.rmSync(dir, { recursive: true, force: true });
1800
1865
  }
1801
1866
  });
1802
1867
 
1868
+ test('50. an uncommitted task file is still checked, only not against Base and Head', () => {
1869
+ // Dropping the binding must not stop the gate reading the packet. A gitignored or
1870
+ // simply uncommitted specs root keeps every structural check: a failing exit, a
1871
+ // placeholder, an empty output block, and a command that does not match the
1872
+ // Verification Plan all still block.
1873
+ const cases = [
1874
+ [['## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 1',
1875
+ `Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`, '```text', '$ node --test', 'fail: 1', '```'], /exit_result/],
1876
+ [['## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1877
+ `Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`, '```text', '```'], /command_output/],
1878
+ [['## Receipt', '', 'Verification: PASS', 'Command: pnpm test', 'Exit: 0',
1879
+ `Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`, '```text', '$ pnpm test', 'pass', '```'], /command_identity/],
1880
+ ];
1881
+ for (const [receipt, expected] of cases) {
1882
+ const dir = makeWorkflowFixture(receipt);
1883
+ try {
1884
+ const tracked = spawnSync('git', ['-C', dir, 'ls-files', '--', 'specs'], { encoding: 'utf8' });
1885
+ assert.strictEqual(tracked.stdout.trim(), '', 'the packet must be uncommitted for this case to mean anything');
1886
+ clearCache();
1887
+ const body = parseBlock(runHook({}, dir).stdout);
1888
+ assert.ok(body && body.decision === 'block', `an uncommitted packet must still be checked: ${expected}`);
1889
+ assert.match(body.reason, expected);
1890
+ } finally {
1891
+ fs.rmSync(dir, { recursive: true, force: true });
1892
+ }
1893
+ }
1894
+ });
1895
+
1803
1896
  test('48. the gate\'s own runtime state does not defeat structure mode', () => {
1804
1897
  // The gate writes its cache under .claude/hooks/.logs on every Stop. If that
1805
1898
  // write counted as a dirty tree, a project tracking its runtime directory could
@@ -240,10 +240,10 @@ try {
240
240
  // task currently marked done, so a cached PASS cannot hide later mutations
241
241
  // (deletion, placeholder, changed Verification/Command/Exit, missing
242
242
  // output, edited artifact bytes). Base/Head are compared against the live
243
- // runtime only while the task file differs from its committed bytes or the
244
- // tree outside the specs root is dirty; a committed, unchanged receipt on a
245
- // clean tree is checked on structure alone. Cache is an optimization, not
246
- // truth.
243
+ // runtime only while the task file has a copy in HEAD that it no longer
244
+ // matches; committed-and-unchanged and never-committed are both checked on
245
+ // structure alone, and work elsewhere in the tree never reopens a receipt.
246
+ // Cache is an optimization, not truth.
247
247
  const featureCache = cacheExists ? (cache[featureName] || {}) : {};
248
248
  const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
249
249
  (taskRegistry[tp]?.status || 'pending') === 'done'
@@ -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, unchanged, and the tree outside the specs root is clean.`);
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,9 +32,15 @@ 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 the file is committed, unchanged, and the
37
- tree outside the specs root is clean. The gate detects drift, not invention: a valid
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.
43
+ The gate detects drift, not invention: a valid
38
44
  Base and Head pair costs one command and no verification run, so neither mode proves
39
45
  that the command was executed. Missing, stale, contradictory,
40
46
  placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
@@ -5,7 +5,6 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { spawnSync } = require('child_process');
7
7
  const { annotatedMarkdownLines } = require('./spec-resolver.cjs');
8
- const { RUNTIME_STATE_ROOTS } = require('./provenance.cjs');
9
8
 
10
9
  const EVIDENCE_HEADINGS = new Set([
11
10
  'Evidence',
@@ -185,14 +184,19 @@ function tryGitBytes(root, args) {
185
184
  return result.stdout;
186
185
  }
187
186
 
188
- // A receipt is bound to live Base/Head while it is being written or altered.
189
- // Once its task file matches the committed bytes and nothing outside the specs
190
- // root is uncommitted, later commits must not reopen it: rebinding every done
191
- // receipt on every Stop made each unrelated commit a refresh of all of them.
192
- // Every state that cannot be read as committed-and-clean keeps the binding,
193
- // including an unborn HEAD, a staged-only file, and any git failure. The git
194
- // calls pin the project root; a working-directory-relative status query run
195
- // from inside the specs root reports a dirty tree as clean.
187
+ // A receipt is bound to live Base/Head while its own task file is being written
188
+ // or altered, and validates on structure alone once that file matches its
189
+ // committed bytes. The binding used to require the whole tree outside the specs
190
+ // root to be clean as well. That condition was removed because it carried no
191
+ // information: Base is the last commit touching anything outside the specs root,
192
+ // so every receipt written before a later commit records an older Base by
193
+ // construction. Measured on this repository, 52 of 52 done receipts differed
194
+ // from the live Base, so one uncommitted unrelated file failed all of them at
195
+ // once and a tampered receipt was indistinguishable from an untouched one. The
196
+ // signal it was meant to carry — marking a task done and then editing the code —
197
+ // is still caught, because doing that leaves the task file uncommitted. Every
198
+ // state that cannot be read as committed-and-unchanged keeps the binding,
199
+ // including an unborn HEAD, a staged-only file, and any git failure.
196
200
  function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
197
201
  const root = runtimeContext && typeof runtimeContext.project_root === 'string' ? runtimeContext.project_root : null;
198
202
  const specsRoot = runtimeContext && typeof runtimeContext.specs_root === 'string' ? runtimeContext.specs_root : null;
@@ -204,28 +208,18 @@ function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
204
208
  const specsRelative = toPosix(path.relative(root, specsRoot));
205
209
  const escapes = (value) => !value || value === '..' || value.startsWith('../') || path.isAbsolute(value);
206
210
  if (escapes(taskRelative) || escapes(specsRelative)) return 'binding';
211
+ // Whether the packet is committed at all is the user's choice, and for a project
212
+ // that gitignores its specs root it is not even available. A task file with no copy
213
+ // in HEAD therefore has no baseline to drift from, and binding it to live Base/Head
214
+ // only fails it permanently once the source moves on. Its structural checks still
215
+ // run in full. A git failure is not the same thing and stays fail-safe.
216
+ const inHead = tryGitBytes(root, ['ls-tree', '-z', '--name-only', 'HEAD', '--', taskRelative]);
217
+ if (inHead === null) return 'binding';
218
+ if (inHead.length === 0) return 'structure';
207
219
  // `--filters` applies the same smudge/eol conversion the worktree copy has,
208
220
  // so a checkout that converts line endings can still reach structure mode.
209
221
  const committed = tryGitBytes(root, ['cat-file', '--filters', `HEAD:${taskRelative}`]);
210
222
  if (committed === null || !committed.equals(taskBytes)) return 'binding';
211
- // The same roots the provenance manifest calls generated runtime state rather
212
- // than source evidence. Without them the gate defeats itself: writing its own
213
- // cache dirties the tree, so a project that tracks its runtime directory could
214
- // never reach structure mode.
215
- const outsideSpecs = ['--', '.', `:(exclude,literal)${specsRelative}`,
216
- ...RUNTIME_STATE_ROOTS.map((entry) => `:(exclude,literal)${entry}`)];
217
- const status = tryGitBytes(root, ['status', '--porcelain', '--untracked-files=all', ...outsideSpecs]);
218
- if (status === null || status.length !== 0) return 'binding';
219
- // `git status` obeys skip-worktree and assume-unchanged, so a tracked file can
220
- // be modified while the tree reports clean. Require every index entry outside
221
- // the specs root to carry the plain cached tag `H`; any other tag, including
222
- // `S` and the lowercase assume-unchanged tags, keeps the binding.
223
- const indexTags = tryGitBytes(root, ['ls-files', '-v', ...outsideSpecs]);
224
- if (indexTags === null) return 'binding';
225
- const hidden = indexTags.toString('utf8').split('\n')
226
- .filter((line) => line.trim() !== '')
227
- .some((line) => line[0] !== 'H');
228
- if (hidden) return 'binding';
229
223
  return 'structure';
230
224
  }
231
225
 
@@ -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, unchanged, and the tree outside the specs root is clean. 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,
@@ -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, unchanged, and the tree outside the specs root is clean.`,
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,9 +32,15 @@ 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 the file is committed, unchanged, and the
37
- tree outside the specs root is clean. The gate detects drift, not invention: a valid
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.
43
+ The gate detects drift, not invention: a valid
38
44
  Base and Head pair costs one command and no verification run, so neither mode proves
39
45
  that the command was executed. Missing, stale, contradictory,
40
46
  placeholder, copied, failure, or zero-test evidence leaves the task unfinished.