@haposoft/cafekit 0.16.5 → 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 +12 -5
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +74 -13
- 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 +8 -5
- package/src/claude/scripts/spec-receipt.cjs +8 -0
- 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 +8 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/haposoft/cafekit)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://claude.ai/code)
|
|
8
8
|
[](https://developers.openai.com/codex)
|
|
@@ -1782,7 +1782,7 @@ test('Claude and Codex fail closed consistently for malformed cwd payloads', ()
|
|
|
1782
1782
|
});
|
|
1783
1783
|
});
|
|
1784
1784
|
|
|
1785
|
-
test('Codex emits the required decision for
|
|
1785
|
+
test('Codex emits the required decision for every receipt state', () => {
|
|
1786
1786
|
// Codex owns no checker of its own: both wrappers in .codex/hooks/lib forward to
|
|
1787
1787
|
// the shared module, so the two receipt modes arrive by inheritance. This case is
|
|
1788
1788
|
// the regression lock on that inheritance. Each state asserts the decision Codex
|
|
@@ -1847,12 +1847,19 @@ test('Codex emits the required decision for all six receipt states', () => {
|
|
|
1847
1847
|
return failures.length === 0 ? 'accept' : 'block';
|
|
1848
1848
|
};
|
|
1849
1849
|
|
|
1850
|
-
// 1. untracked task file —
|
|
1851
|
-
|
|
1850
|
+
// 1. untracked task file — no copy in HEAD, so there is no baseline to drift
|
|
1851
|
+
// from. Committing a packet is the user's choice and a project that
|
|
1852
|
+
// gitignores its specs root cannot do it at all, so binding here only failed
|
|
1853
|
+
// the receipt permanently once the source moved on. The structural checks
|
|
1854
|
+
// still run; state 6 below is the state that binds.
|
|
1855
|
+
assert.equal(decide(), 'accept', 'an uncommitted task file must not be bound');
|
|
1856
|
+
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1852
1857
|
|
|
1853
|
-
// 2. staged but not committed — `git add`
|
|
1858
|
+
// 2. staged but not committed — `git add` puts the file in the index, not in
|
|
1859
|
+
// HEAD, so it reads exactly like state 1.
|
|
1854
1860
|
git('add', '-A');
|
|
1855
|
-
assert.equal(decide(), '
|
|
1861
|
+
assert.equal(decide(), 'accept', 'staging does not create a committed baseline');
|
|
1862
|
+
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1856
1863
|
|
|
1857
1864
|
// 3. committed on a clean tree — the one accepting state.
|
|
1858
1865
|
git('commit', '-qm', 'receipt and source');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haposoft/cafekit",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.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
|
}
|
|
@@ -1832,6 +1842,57 @@ test('47. completed-set branch accepts committed receipts and blocks an edited o
|
|
|
1832
1842
|
}
|
|
1833
1843
|
});
|
|
1834
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');
|
|
1863
|
+
} finally {
|
|
1864
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
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
|
+
|
|
1835
1896
|
test('48. the gate\'s own runtime state does not defeat structure mode', () => {
|
|
1836
1897
|
// The gate writes its cache under .claude/hooks/.logs on every Stop. If that
|
|
1837
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
|
-
// work elsewhere in the tree
|
|
246
|
-
// not 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,11 +32,14 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
32
32
|
`Verification: PASS`, runtime-derived Base and Head values, and a non-empty
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
|
-
A done task
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
A done task is bound to live Base and Head only while its own task file has a copy in
|
|
36
|
+
`HEAD` that its current bytes no longer match. A file that is committed and unchanged,
|
|
37
|
+
and a file that was never committed at all, are both validated on structure alone: the
|
|
38
|
+
first has a baseline it still matches, the second has no baseline to drift from, and
|
|
39
|
+
committing a packet is the user's choice that a gitignored specs root removes entirely.
|
|
40
|
+
Work elsewhere in the tree never reopens a receipt, because Base is the last commit
|
|
41
|
+
touching anything outside the specs root, so every finished receipt records an older
|
|
42
|
+
Base by construction. Every structural check runs in all cases.
|
|
40
43
|
The gate detects drift, not invention: a valid
|
|
41
44
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
42
45
|
that the command was executed. Missing, stale, contradictory,
|
|
@@ -208,6 +208,14 @@ function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
|
|
|
208
208
|
const specsRelative = toPosix(path.relative(root, specsRoot));
|
|
209
209
|
const escapes = (value) => !value || value === '..' || value.startsWith('../') || path.isAbsolute(value);
|
|
210
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';
|
|
211
219
|
// `--filters` applies the same smudge/eol conversion the worktree copy has,
|
|
212
220
|
// so a checkout that converts line endings can still reach structure mode.
|
|
213
221
|
const committed = tryGitBytes(root, ['cat-file', '--filters', `HEAD:${taskRelative}`]);
|
|
@@ -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,11 +32,14 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
32
32
|
`Verification: PASS`, runtime-derived Base and Head values, and a non-empty
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
|
-
A done task
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
A done task is bound to live Base and Head only while its own task file has a copy in
|
|
36
|
+
`HEAD` that its current bytes no longer match. A file that is committed and unchanged,
|
|
37
|
+
and a file that was never committed at all, are both validated on structure alone: the
|
|
38
|
+
first has a baseline it still matches, the second has no baseline to drift from, and
|
|
39
|
+
committing a packet is the user's choice that a gitignored specs root removes entirely.
|
|
40
|
+
Work elsewhere in the tree never reopens a receipt, because Base is the last commit
|
|
41
|
+
touching anything outside the specs root, so every finished receipt records an older
|
|
42
|
+
Base by construction. Every structural check runs in all cases.
|
|
40
43
|
The gate detects drift, not invention: a valid
|
|
41
44
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
42
45
|
that the command was executed. Missing, stale, contradictory,
|