@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 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.5-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)
@@ -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. dirty tree outside the specs root, hook run from a nested directory with
1870
- // the change outside that directory's subtree.
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(), 'block', 'an uncommitted change outside the specs root must rebind');
1875
- assert.equal(sharedDecides(), 'block', 'the shared checker must agree');
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.4",
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 requires binding, regardless of working directory', () => {
1625
- const dir = makeWorkflowFixture(boundReceipt());
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
- 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/);
1640
+ assert.strictEqual(runHook({}, dir).stdout, '', 'unrelated dirt must not reopen a committed receipt');
1634
1641
  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/);
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. 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.
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
- 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' });
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
- fs.writeFileSync(path.join(dir, 'src', 'hidden.txt'), 'tampered\n');
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 grant structure-only`);
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 a tampered one', () => {
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 the new mode works
1777
- // there, then prove it still blocks.
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', 'the completed-set branch must rebind on a dirty tree');
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|demo/);
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 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 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, 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 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 the file is committed, unchanged, and the
37
- tree outside the specs root is clean. The gate detects drift, not invention: a valid
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 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;
@@ -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, unchanged, and the tree outside the specs root is clean. It detects drift
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, 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 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 the file is committed, unchanged, and the
37
- tree outside the specs root is clean. The gate detects drift, not invention: a valid
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.