@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 +1 -1
- package/bin/__tests__/codex-hooks.test.js +22 -9
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +129 -36
- package/src/claude/hooks/spec-gate.cjs +4 -4
- package/src/claude/hooks/spec-state.cjs +1 -1
- package/src/claude/rules/state-sync.md +9 -3
- package/src/claude/scripts/spec-receipt.cjs +21 -27
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/rules/state-sync.md +9 -3
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');
|
|
@@ -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.
|
|
1870
|
-
// the
|
|
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(), '
|
|
1875
|
-
assert.equal(sharedDecides(), '
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
1625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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.
|
|
1644
|
-
// `git status`
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
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
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1777
|
-
//
|
|
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', '
|
|
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
|
|
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
|
|
244
|
-
//
|
|
245
|
-
//
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// from
|
|
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
|
|
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,
|
|
@@ -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,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
|
|
36
|
-
|
|
37
|
-
|
|
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.
|