@haposoft/cafekit 0.16.4 → 0.16.5
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 +10 -4
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +55 -23
- 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 +5 -2
- package/src/claude/scripts/spec-receipt.cjs +13 -27
- package/src/claude/skills/specs/SKILL.md +1 -1
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/rules/state-sync.md +5 -2
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)
|
|
@@ -1866,13 +1866,19 @@ test('Codex emits the required decision for all six receipt states', () => {
|
|
|
1866
1866
|
assert.equal(decide(), 'accept', 'a committed, unchanged receipt on a clean tree must be accepted');
|
|
1867
1867
|
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1868
1868
|
|
|
1869
|
-
// 4.
|
|
1870
|
-
// the
|
|
1869
|
+
// 4. Contract change: an uncommitted file outside the specs root no longer
|
|
1870
|
+
// rebinds a committed receipt. Base is the last commit touching anything
|
|
1871
|
+
// outside the specs root, so every receipt written before a later commit
|
|
1872
|
+
// records an older Base by construction — measured at 52 of 52 on the
|
|
1873
|
+
// CafeKit repository. The condition therefore failed every historical
|
|
1874
|
+
// receipt at once whenever any unrelated file was dirty, and could not
|
|
1875
|
+
// tell a tampered receipt from an untouched one. Case 6 below still
|
|
1876
|
+
// catches the receipt that actually changed.
|
|
1871
1877
|
fs.writeFileSync(outsideDirt, 'uncommitted\n');
|
|
1872
1878
|
assert.ok(!path.relative(nested, outsideDirt).startsWith('..') === false,
|
|
1873
1879
|
'the dirt must lie outside the working directory used for the run');
|
|
1874
|
-
assert.equal(decide(), '
|
|
1875
|
-
assert.equal(sharedDecides(), '
|
|
1880
|
+
assert.equal(decide(), 'accept', 'unrelated dirt must not reopen a committed receipt');
|
|
1881
|
+
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1876
1882
|
git('checkout', '--', 'src/outside.txt');
|
|
1877
1883
|
|
|
1878
1884
|
// 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.5",
|
|
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",
|
|
@@ -1621,43 +1621,61 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
|
|
|
1621
1621
|
}
|
|
1622
1622
|
});
|
|
1623
1623
|
|
|
1624
|
-
test('42. dirty tree outside specs
|
|
1625
|
-
|
|
1624
|
+
test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
|
|
1625
|
+
// Contract change. This used to require a block. `Base` is the last commit
|
|
1626
|
+
// touching anything outside the specs root, so a receipt written before any
|
|
1627
|
+
// later commit records an older `Base` by construction — 52 of 52 done
|
|
1628
|
+
// receipts on the CafeKit repository. Reopening on a dirty tree therefore
|
|
1629
|
+
// failed every historical receipt at once, every time, which blocked projects
|
|
1630
|
+
// on their first turn of a session with nothing changed. What still blocks is
|
|
1631
|
+
// the task file that actually differs from its committed bytes, asserted
|
|
1632
|
+
// below from both working directories, since the git call must pin the
|
|
1633
|
+
// project root rather than the working directory.
|
|
1634
|
+
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1626
1635
|
try {
|
|
1627
1636
|
commitAll(dir, 'receipt');
|
|
1628
1637
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
1629
1638
|
fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
|
|
1630
1639
|
clearCache();
|
|
1631
|
-
|
|
1632
|
-
assert.ok(fromRoot && fromRoot.decision === 'block', 'an uncommitted change outside specs must rebind');
|
|
1633
|
-
assert.match(fromRoot.reason, /\bprovenance\b/);
|
|
1640
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'unrelated dirt must not reopen a committed receipt');
|
|
1634
1641
|
clearCache();
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1642
|
+
assert.strictEqual(runHookFrom(dir, path.join(dir, 'specs', FEATURE)).stdout, '',
|
|
1643
|
+
'and must not reopen it when the hook runs from inside the specs root either');
|
|
1644
|
+
|
|
1645
|
+
const taskFile = path.join(dir, 'specs', FEATURE, 'task-01-demo.md');
|
|
1646
|
+
fs.writeFileSync(taskFile, fs.readFileSync(taskFile, 'utf8').replace(STALE_HEAD, 'd'.repeat(64)));
|
|
1647
|
+
for (const [label, run] of [['project root', () => runHook({}, dir)],
|
|
1648
|
+
['specs root', () => runHookFrom(dir, path.join(dir, 'specs', FEATURE))]]) {
|
|
1649
|
+
clearCache();
|
|
1650
|
+
const body = parseBlock(run().stdout);
|
|
1651
|
+
assert.ok(body && body.decision === 'block', `an edited task file must rebind, run from the ${label}`);
|
|
1652
|
+
assert.match(body.reason, /\bprovenance\b/);
|
|
1653
|
+
}
|
|
1638
1654
|
} finally {
|
|
1639
1655
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1640
1656
|
}
|
|
1641
1657
|
});
|
|
1642
1658
|
|
|
1643
|
-
test('42b.
|
|
1644
|
-
// `git status`
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1659
|
+
test('42b. skip-worktree and assume-unchanged cannot hide an edited task file', () => {
|
|
1660
|
+
// Both index flags make `git status` report a modified file as clean. The mode
|
|
1661
|
+
// selector no longer consults status at all, but it must not become hideable
|
|
1662
|
+
// some other way: the committed-bytes check reads the file from disk and asks
|
|
1663
|
+
// git for `HEAD:<path>`, and neither consults the index. Marking the task file
|
|
1664
|
+
// with either flag must therefore change nothing.
|
|
1647
1665
|
for (const flag of ['--skip-worktree', '--assume-unchanged']) {
|
|
1648
1666
|
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1649
1667
|
try {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
const marked = spawnSync('git', ['-C', dir, 'update-index', flag, 'src/hidden.txt'], { encoding: 'utf8' });
|
|
1668
|
+
commitAll(dir, 'receipt');
|
|
1669
|
+
const taskRelative = `specs/${FEATURE}/task-01-demo.md`;
|
|
1670
|
+
const marked = spawnSync('git', ['-C', dir, 'update-index', flag, taskRelative], { encoding: 'utf8' });
|
|
1654
1671
|
assert.strictEqual(marked.status, 0, marked.stderr);
|
|
1655
|
-
|
|
1672
|
+
const taskFile = path.join(dir, taskRelative);
|
|
1673
|
+
fs.writeFileSync(taskFile, fs.readFileSync(taskFile, 'utf8').replace(STALE_HEAD, 'd'.repeat(64)));
|
|
1656
1674
|
const status = spawnSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' });
|
|
1657
1675
|
assert.strictEqual(status.stdout.trim(), '', `${flag} must make the tree look clean for this case to mean anything`);
|
|
1658
1676
|
clearCache();
|
|
1659
1677
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1660
|
-
assert.ok(body && body.decision === 'block', `${flag} must not
|
|
1678
|
+
assert.ok(body && body.decision === 'block', `${flag} must not hide an edited task file`);
|
|
1661
1679
|
assert.match(body.reason, /\bprovenance\b/);
|
|
1662
1680
|
} finally {
|
|
1663
1681
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -1771,10 +1789,10 @@ test('46. a dirty file inside the specs root does not force binding', () => {
|
|
|
1771
1789
|
}
|
|
1772
1790
|
});
|
|
1773
1791
|
|
|
1774
|
-
test('47. completed-set branch accepts committed receipts and blocks
|
|
1792
|
+
test('47. completed-set branch accepts committed receipts and blocks an edited one', () => {
|
|
1775
1793
|
// Two finished packets resolve to the completed-set branch, which is the path
|
|
1776
|
-
// that revalidated every done receipt on every Stop. Prove
|
|
1777
|
-
//
|
|
1794
|
+
// that revalidated every done receipt on every Stop. Prove committed receipts
|
|
1795
|
+
// survive both a clean and a dirty tree, then prove an edited one still blocks.
|
|
1778
1796
|
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1779
1797
|
try {
|
|
1780
1798
|
const secondDir = path.join(dir, 'specs', 'second');
|
|
@@ -1788,13 +1806,27 @@ test('47. completed-set branch accepts committed receipts and blocks a tampered
|
|
|
1788
1806
|
clearCache();
|
|
1789
1807
|
const accepted = runHook({}, dir);
|
|
1790
1808
|
assert.strictEqual(accepted.stdout, '', 'the completed-set branch must accept committed receipts on a clean tree');
|
|
1809
|
+
// Contract change: unrelated dirt no longer reopens committed receipts. This
|
|
1810
|
+
// used to assert a block, which is what made the gate unusable in a project
|
|
1811
|
+
// holding several finished packets — one untracked file failed every receipt
|
|
1812
|
+
// in the repository at once. Base is the last commit touching anything
|
|
1813
|
+
// outside the specs root, so a historical receipt records an older Base by
|
|
1814
|
+
// construction and the check fired on all of them, always.
|
|
1791
1815
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
1792
1816
|
fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
|
|
1793
1817
|
clearCache();
|
|
1818
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'unrelated dirt must not reopen committed receipts');
|
|
1819
|
+
|
|
1820
|
+
// What still blocks: the receipt that actually changed after being committed.
|
|
1821
|
+
fs.writeFileSync(
|
|
1822
|
+
path.join(secondDir, 'task-01-second.md'),
|
|
1823
|
+
fs.readFileSync(path.join(secondDir, 'task-01-second.md'), 'utf8').replace(STALE_HEAD, 'd'.repeat(64)),
|
|
1824
|
+
);
|
|
1825
|
+
clearCache();
|
|
1794
1826
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1795
|
-
assert.ok(body && body.decision === 'block', '
|
|
1827
|
+
assert.ok(body && body.decision === 'block', 'a receipt edited after commit must still be rebound');
|
|
1796
1828
|
assert.match(body.reason, /\bprovenance\b/);
|
|
1797
|
-
assert.match(body.reason, /second
|
|
1829
|
+
assert.match(body.reason, /second/);
|
|
1798
1830
|
} finally {
|
|
1799
1831
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1800
1832
|
}
|
|
@@ -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
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// truth.
|
|
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.
|
|
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
|
|
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.`);
|
|
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)'}.`);
|
|
@@ -33,8 +33,11 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
35
|
A done task keeps that binding while its file differs from its committed bytes,
|
|
36
|
-
and is validated on structure alone once
|
|
37
|
-
|
|
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.
|
|
40
|
+
The gate detects drift, not invention: a valid
|
|
38
41
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
39
42
|
that the command was executed. Missing, stale, contradictory,
|
|
40
43
|
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;
|
|
@@ -208,24 +212,6 @@ function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
|
|
|
208
212
|
// so a checkout that converts line endings can still reach structure mode.
|
|
209
213
|
const committed = tryGitBytes(root, ['cat-file', '--filters', `HEAD:${taskRelative}`]);
|
|
210
214
|
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
215
|
return 'structure';
|
|
230
216
|
}
|
|
231
217
|
|
|
@@ -128,7 +128,7 @@ The shared workflow resolver recognizes only a regular `plan.md` plus one or mor
|
|
|
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
130
|
done Receipt, binding Base and Head to the live runtime until the task file is
|
|
131
|
-
committed
|
|
131
|
+
committed and unchanged; work elsewhere in the tree does not reopen 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
|
|
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.`,
|
|
160
160
|
'- Hooks revalidate receipt bytes but never grant approval.'
|
|
161
161
|
);
|
|
162
162
|
} else {
|
|
@@ -33,8 +33,11 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
35
|
A done task keeps that binding while its file differs from its committed bytes,
|
|
36
|
-
and is validated on structure alone once
|
|
37
|
-
|
|
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.
|
|
40
|
+
The gate detects drift, not invention: a valid
|
|
38
41
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
39
42
|
that the command was executed. Missing, stale, contradictory,
|
|
40
43
|
placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
|