@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.16.5-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.16.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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.16.5",
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
  }
@@ -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 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.
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 and unchanged.`);
229
+ lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime only while a committed task file no longer matches its committed bytes.`);
230
230
  } else {
231
231
  lines.push('- Sync `spec.json` + task Markdown status after verified work; task proof belongs in `receipts/<task-basename>.md`.');
232
232
  lines.push(`- Create \`feature-receipt.md\` once after final integration proof${featureReceiptPresent ? ' (present)' : ' (not required before closeout)'}.`);
@@ -32,11 +32,14 @@ Set `Status: done` only after the inline Receipt passes the
32
32
  `Verification: PASS`, runtime-derived Base and Head values, and a non-empty
33
33
  fenced block of current command output.
34
34
 
35
- A done task keeps that binding while its file differs from its committed bytes,
36
- and is validated on structure alone once that file is committed and unchanged. Work
37
- elsewhere in the tree does not reopen it: Base is the last commit touching anything
38
- outside the specs root, so every finished receipt records an older Base by
39
- construction and reopening on a dirty tree failed all of them at once.
35
+ A done task is bound to live Base and Head only while its own task file has a copy in
36
+ `HEAD` that its current bytes no longer match. A file that is committed and unchanged,
37
+ and a file that was never committed at all, are both validated on structure alone: the
38
+ first has a baseline it still matches, the second has no baseline to drift from, and
39
+ committing a packet is the user's choice that a gitignored specs root removes entirely.
40
+ Work elsewhere in the tree never reopens a receipt, because Base is the last commit
41
+ touching anything outside the specs root, so every finished receipt records an older
42
+ Base by construction. Every structural check runs in all cases.
40
43
  The gate detects drift, not invention: a valid
41
44
  Base and Head pair costs one command and no verification run, so neither mode proves
42
45
  that the command was executed. Missing, stale, contradictory,
@@ -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 until the task file is
131
- committed and unchanged; work elsewhere in the tree does not reopen it. It detects drift
130
+ done Receipt, binding Base and Head to the live runtime only while the task file has a
131
+ committed copy it no longer matches; work elsewhere in the tree never reopens it. It detects drift
132
132
  between a receipt and the tree, not invention: a valid pair costs one command and no
133
133
  verification run, so a receipt for a command that never ran satisfies it in either
134
134
  mode. C3 is where a human weighs the evidence. These checks are a final safety net,
@@ -156,7 +156,7 @@ try {
156
156
  }
157
157
  lines.push(
158
158
  '- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.',
159
- `- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime until the task file is committed and unchanged.`,
159
+ `- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime only while a committed task file no longer matches its committed bytes.`,
160
160
  '- Hooks revalidate receipt bytes but never grant approval.'
161
161
  );
162
162
  } else {
@@ -32,11 +32,14 @@ Set `Status: done` only after the inline Receipt passes the
32
32
  `Verification: PASS`, runtime-derived Base and Head values, and a non-empty
33
33
  fenced block of current command output.
34
34
 
35
- A done task keeps that binding while its file differs from its committed bytes,
36
- and is validated on structure alone once that file is committed and unchanged. Work
37
- elsewhere in the tree does not reopen it: Base is the last commit touching anything
38
- outside the specs root, so every finished receipt records an older Base by
39
- construction and reopening on a dirty tree failed all of them at once.
35
+ A done task is bound to live Base and Head only while its own task file has a copy in
36
+ `HEAD` that its current bytes no longer match. A file that is committed and unchanged,
37
+ and a file that was never committed at all, are both validated on structure alone: the
38
+ first has a baseline it still matches, the second has no baseline to drift from, and
39
+ committing a packet is the user's choice that a gitignored specs root removes entirely.
40
+ Work elsewhere in the tree never reopens a receipt, because Base is the last commit
41
+ touching anything outside the specs root, so every finished receipt records an older
42
+ Base by construction. Every structural check runs in all cases.
40
43
  The gate detects drift, not invention: a valid
41
44
  Base and Head pair costs one command and no verification run, so neither mode proves
42
45
  that the command was executed. Missing, stale, contradictory,