@haposoft/cafekit 0.16.0 → 0.16.2

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.
Files changed (66) hide show
  1. package/README.md +5 -3
  2. package/bin/__tests__/codex-hooks.test.js +125 -0
  3. package/bin/__tests__/codex-native.test.js +95 -0
  4. package/bin/__tests__/develop-contract.test.js +43 -1
  5. package/bin/__tests__/grok-envelope.test.js +271 -0
  6. package/bin/__tests__/grok-platform.test.js +133 -0
  7. package/bin/__tests__/hook-payload.test.js +194 -0
  8. package/bin/__tests__/omp-bridge.test.js +168 -0
  9. package/bin/__tests__/omp-hooks.test.js +178 -0
  10. package/bin/__tests__/omp-runtime.test.js +150 -0
  11. package/bin/__tests__/orca-session.test.js +225 -0
  12. package/bin/__tests__/orca-skill.test.js +210 -0
  13. package/bin/__tests__/package-inventory.test.js +1 -0
  14. package/bin/__tests__/runtime-dir.test.js +158 -0
  15. package/bin/__tests__/runtime-schema.test.js +5 -3
  16. package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
  17. package/bin/install.js +5 -5
  18. package/bin/lib/context.js +50 -3
  19. package/bin/lib/i18n.js +3 -30
  20. package/bin/phases/codex-hooks.js +90 -17
  21. package/bin/phases/copy-payload.js +7 -0
  22. package/bin/phases/omp-runtime.js +140 -0
  23. package/bin/phases/root-config.js +3 -0
  24. package/bin/phases/select-platform.js +22 -1
  25. package/bin/phases/summary.js +0 -4
  26. package/package.json +1 -1
  27. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
  28. package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
  29. package/src/claude/hooks/__tests__/state.test.js +2 -0
  30. package/src/claude/hooks/agent.cjs +12 -7
  31. package/src/claude/hooks/completion-authority.cjs +10 -4
  32. package/src/claude/hooks/docs-sync.cjs +2 -1
  33. package/src/claude/hooks/inspect-block.cjs +27 -7
  34. package/src/claude/hooks/lib/config.cjs +5 -2
  35. package/src/claude/hooks/lib/counter.cjs +7 -5
  36. package/src/claude/hooks/lib/hook-payload.cjs +160 -0
  37. package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
  38. package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
  39. package/src/claude/hooks/precompact.cjs +2 -1
  40. package/src/claude/hooks/privacy-block.cjs +4 -2
  41. package/src/claude/hooks/rules.cjs +4 -2
  42. package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
  43. package/src/claude/hooks/semantic-review-authority.cjs +6 -4
  44. package/src/claude/hooks/session.cjs +16 -8
  45. package/src/claude/hooks/spec-gate.cjs +18 -8
  46. package/src/claude/hooks/spec-state.cjs +9 -5
  47. package/src/claude/hooks/state.cjs +6 -4
  48. package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
  49. package/src/claude/hooks/usage.cjs +5 -4
  50. package/src/claude/migration-manifest.json +3 -0
  51. package/src/claude/rules/skill-domain-routing.md +1 -0
  52. package/src/claude/rules/state-sync.md +7 -1
  53. package/src/claude/rules/workflow.md +2 -0
  54. package/src/claude/scripts/provenance.cjs +11 -4
  55. package/src/claude/scripts/spec-receipt.cjs +65 -4
  56. package/src/claude/skills/orca/SKILL.md +51 -0
  57. package/src/claude/skills/specs/SKILL.md +10 -11
  58. package/src/codex/hooks/session.cjs +6 -1
  59. package/src/codex/hooks/spec-state.cjs +1 -1
  60. package/src/codex/hooks.json +18 -18
  61. package/src/codex/rules/state-sync.md +7 -1
  62. package/src/omp/extensions/cafekit-bridge.mjs +233 -0
  63. package/src/omp/hooks/privacy-block.cjs +250 -0
  64. package/src/omp/runtime.json +33 -0
  65. package/bin/lib/rtk-run.js +0 -28
  66. package/bin/phases/setup-rtk.js +0 -159
@@ -289,6 +289,8 @@ function installClaudeGate(root, policyMode = 'valid') {
289
289
  fs.mkdirSync(scripts, { recursive: true });
290
290
  fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
291
291
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
292
+ fs.copyFileSync(path.join(__dirname, '..', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
293
+ fs.copyFileSync(path.join(__dirname, '..', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
292
294
  fs.copyFileSync(
293
295
  path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'),
294
296
  path.join(hooks, 'lib', 'hook-state-dir.cjs'),
@@ -1486,3 +1488,339 @@ test('36. process-v3 Stop accepts two completed packets with valid Receipts', ()
1486
1488
  fs.rmSync(dir, { recursive: true, force: true });
1487
1489
  }
1488
1490
  });
1491
+
1492
+ // ── Receipt freshness: bound while written or altered, structure-only once committed on a clean tree ──
1493
+
1494
+ const STALE_BASE = 'f'.repeat(40);
1495
+ const STALE_HEAD = 'e'.repeat(40);
1496
+
1497
+ function commitAll(dir, message) {
1498
+ for (const args of [['add', '-A'], ['commit', '-qm', message, '--allow-empty']]) {
1499
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
1500
+ assert.strictEqual(result.status, 0, result.stderr);
1501
+ }
1502
+ }
1503
+
1504
+ function boundReceipt(base = VALID_BASE, head = VALID_HEAD) {
1505
+ return [
1506
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1507
+ `Base: ${base}`, `Head: ${head}`, '```text', '$ node --test', 'pass: 1', '```',
1508
+ ];
1509
+ }
1510
+
1511
+ function runHookFrom(dir, cwd) {
1512
+ const res = spawnSync(process.execPath, [HOOK], {
1513
+ cwd,
1514
+ input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'test', transcript_path: '/tmp/t', stop_hook_active: false, cwd: dir }),
1515
+ encoding: 'utf8',
1516
+ env: { ...process.env, PROJECT_ROOT: dir },
1517
+ });
1518
+ return { code: res.status, stdout: (res.stdout || '').trim(), stderr: res.stderr || '' };
1519
+ }
1520
+
1521
+ test('37. committed receipt on a clean tree survives later commits', () => {
1522
+ const dir = makeWorkflowFixture(boundReceipt());
1523
+ try {
1524
+ commitAll(dir, 'receipt');
1525
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
1526
+ fs.writeFileSync(path.join(dir, 'src', 'unrelated.txt'), 'later change\n');
1527
+ commitAll(dir, 'unrelated source change');
1528
+ clearCache();
1529
+ const result = runHook({}, dir);
1530
+ assert.strictEqual(result.code, 0, result.stderr);
1531
+ assert.strictEqual(result.stdout, '', 'a committed, unchanged receipt on a clean tree must stay accepted after unrelated commits');
1532
+ } finally {
1533
+ fs.rmSync(dir, { recursive: true, force: true });
1534
+ }
1535
+ });
1536
+
1537
+ test('38. untracked task file requires binding', () => {
1538
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1539
+ try {
1540
+ 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/);
1544
+ } finally {
1545
+ fs.rmSync(dir, { recursive: true, force: true });
1546
+ }
1547
+ });
1548
+
1549
+ test('38b. the same stale receipt is accepted once committed on a clean tree', () => {
1550
+ // Documents the accepted trade-off: once committed and clean, Base and Head
1551
+ // must be concrete but are no longer compared against the runtime.
1552
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1553
+ try {
1554
+ commitAll(dir, 'receipt');
1555
+ clearCache();
1556
+ const result = runHook({}, dir);
1557
+ assert.strictEqual(result.stdout, '', result.stderr);
1558
+ } finally {
1559
+ fs.rmSync(dir, { recursive: true, force: true });
1560
+ }
1561
+ });
1562
+
1563
+ test('39. staged but uncommitted task file requires binding', () => {
1564
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1565
+ try {
1566
+ const add = spawnSync('git', ['-C', dir, 'add', '-A'], { encoding: 'utf8' });
1567
+ assert.strictEqual(add.status, 0, add.stderr);
1568
+ 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/);
1572
+ } finally {
1573
+ fs.rmSync(dir, { recursive: true, force: true });
1574
+ }
1575
+ });
1576
+
1577
+ test('40. modified receipt requires binding', () => {
1578
+ const dir = makeWorkflowFixture(boundReceipt());
1579
+ try {
1580
+ commitAll(dir, 'receipt');
1581
+ const taskFile = path.join(dir, 'specs', FEATURE, 'task-01-demo.md');
1582
+ const context = workflowRuntimeContext(dir);
1583
+ const before = fs.readFileSync(taskFile, 'utf8');
1584
+ const after = before.replace(`Head: ${context.head}`, `Head: ${STALE_HEAD}`);
1585
+ assert.notStrictEqual(after, before, 'fixture must contain the bound Head');
1586
+ fs.writeFileSync(taskFile, after);
1587
+ clearCache();
1588
+ const body = parseBlock(runHook({}, dir).stdout);
1589
+ assert.ok(body && body.decision === 'block', 'a receipt edited after commit must be rebound');
1590
+ assert.match(body.reason, /\bprovenance\b/);
1591
+ } finally {
1592
+ fs.rmSync(dir, { recursive: true, force: true });
1593
+ }
1594
+ });
1595
+
1596
+ test('41. unborn HEAD fails closed before any receipt check', () => {
1597
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
1598
+ try {
1599
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
1600
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
1601
+ assert.strictEqual(result.status, 0, result.stderr);
1602
+ }
1603
+ const featureDir = path.join(dir, 'specs', FEATURE);
1604
+ fs.mkdirSync(featureDir, { recursive: true });
1605
+ fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
1606
+ fs.writeFileSync(path.join(featureDir, 'task-01-demo.md'), [
1607
+ '# Task 01: demo', '', 'Status: done', '', '## Dependencies', '', '- none', '',
1608
+ '## Verification Plan', '', '- Command: node --test', '', ...boundReceipt(STALE_BASE, STALE_HEAD), '',
1609
+ ].join('\n'));
1610
+ clearCache();
1611
+ const body = parseBlock(runHook({}, dir).stdout);
1612
+ assert.ok(body && body.decision === 'block', 'an unborn HEAD must block');
1613
+ // The block arrives from provenance derivation, which cannot resolve a base
1614
+ // commit here, so the receipt check is never reached. Asserting the reason
1615
+ // keeps this case honest: it guards fail-closed behavior on an unborn HEAD,
1616
+ // not the binding-mode selector. The selector's own HEAD-unreadable branch
1617
+ // is guarded by cases 38 and 39.
1618
+ assert.match(body.reason, /controlled failure/);
1619
+ } finally {
1620
+ fs.rmSync(dir, { recursive: true, force: true });
1621
+ }
1622
+ });
1623
+
1624
+ test('42. dirty tree outside specs requires binding, regardless of working directory', () => {
1625
+ const dir = makeWorkflowFixture(boundReceipt());
1626
+ try {
1627
+ commitAll(dir, 'receipt');
1628
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
1629
+ fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
1630
+ 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/);
1634
+ 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/);
1638
+ } finally {
1639
+ fs.rmSync(dir, { recursive: true, force: true });
1640
+ }
1641
+ });
1642
+
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.
1647
+ for (const flag of ['--skip-worktree', '--assume-unchanged']) {
1648
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1649
+ 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' });
1654
+ assert.strictEqual(marked.status, 0, marked.stderr);
1655
+ fs.writeFileSync(path.join(dir, 'src', 'hidden.txt'), 'tampered\n');
1656
+ const status = spawnSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' });
1657
+ assert.strictEqual(status.stdout.trim(), '', `${flag} must make the tree look clean for this case to mean anything`);
1658
+ clearCache();
1659
+ const body = parseBlock(runHook({}, dir).stdout);
1660
+ assert.ok(body && body.decision === 'block', `${flag} must not grant structure-only`);
1661
+ assert.match(body.reason, /\bprovenance\b/);
1662
+ } finally {
1663
+ fs.rmSync(dir, { recursive: true, force: true });
1664
+ }
1665
+ }
1666
+ });
1667
+
1668
+ test('43. structure checks still block in committed mode', () => {
1669
+ const dir = makeWorkflowFixture([
1670
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 1',
1671
+ `Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`, '```text', '$ node --test', 'fail: 1', '```',
1672
+ ]);
1673
+ try {
1674
+ commitAll(dir, 'receipt');
1675
+ clearCache();
1676
+ const body = parseBlock(runHook({}, dir).stdout);
1677
+ assert.ok(body && body.decision === 'block');
1678
+ assert.match(body.reason, /exit_result/);
1679
+ assert.doesNotMatch(body.reason, /\bprovenance\b/, 'committed mode must not report a provenance failure for a stale but concrete pair');
1680
+ } finally {
1681
+ fs.rmSync(dir, { recursive: true, force: true });
1682
+ }
1683
+ });
1684
+
1685
+ test('43b. committed mode still requires concrete Base and Head fields', () => {
1686
+ const dir = makeWorkflowFixture([
1687
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1688
+ `Base: ${STALE_BASE}`, '```text', '$ node --test', 'pass: 1', '```',
1689
+ ]);
1690
+ try {
1691
+ commitAll(dir, 'receipt without Head');
1692
+ clearCache();
1693
+ const body = parseBlock(runHook({}, dir).stdout);
1694
+ assert.ok(body && body.decision === 'block', 'a missing Head field must still block');
1695
+ assert.match(body.reason, /\bprovenance\b/);
1696
+ } finally {
1697
+ fs.rmSync(dir, { recursive: true, force: true });
1698
+ }
1699
+ });
1700
+
1701
+ test('44. edited artifact bytes still block in committed mode', () => {
1702
+ const artifactPath = 'output/bundle.js';
1703
+ const dir = makeWorkflowFixture([
1704
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1705
+ `Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`,
1706
+ `Artifact: ${artifactPath} (sha256:${FIXTURE_ARTIFACT_DIGEST})`,
1707
+ '```text', '$ node --test', 'pass: 1', '```',
1708
+ ]);
1709
+ try {
1710
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
1711
+ fs.writeFileSync(path.join(dir, artifactPath), FIXTURE_ARTIFACT);
1712
+ commitAll(dir, 'receipt and artifact');
1713
+ clearCache();
1714
+ const accepted = runHook({}, dir);
1715
+ assert.strictEqual(accepted.stdout, '', accepted.stderr);
1716
+ fs.writeFileSync(path.join(dir, artifactPath), 'tampered\n');
1717
+ commitAll(dir, 'tamper artifact');
1718
+ clearCache();
1719
+ const body = parseBlock(runHook({}, dir).stdout);
1720
+ assert.ok(body && body.decision === 'block', 'artifact bytes must still be verified in committed mode');
1721
+ assert.match(body.reason, /artifact_hash/);
1722
+ } finally {
1723
+ fs.rmSync(dir, { recursive: true, force: true });
1724
+ }
1725
+ });
1726
+
1727
+ test('45. the cache grants no authority across a state change', () => {
1728
+ // A cached PASS must not survive a later mutation. The first run accepts and
1729
+ // writes the cache; the second run sees tampered artifact bytes committed on a
1730
+ // still-clean tree and must block without the cache being cleared first.
1731
+ const artifactPath = 'output/cached.js';
1732
+ const dir = makeWorkflowFixture([
1733
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
1734
+ `Base: ${STALE_BASE}`, `Head: ${STALE_HEAD}`,
1735
+ `Artifact: ${artifactPath} (sha256:${FIXTURE_ARTIFACT_DIGEST})`,
1736
+ '```text', '$ node --test', 'pass: 1', '```',
1737
+ ]);
1738
+ try {
1739
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
1740
+ fs.writeFileSync(path.join(dir, artifactPath), FIXTURE_ARTIFACT);
1741
+ commitAll(dir, 'receipt and artifact');
1742
+ clearCache();
1743
+ const accepted = runHook({}, dir);
1744
+ assert.strictEqual(accepted.stdout, '', 'first run must accept and populate the cache');
1745
+ fs.writeFileSync(path.join(dir, artifactPath), 'tampered after the cached pass\n');
1746
+ commitAll(dir, 'tamper artifact, tree stays clean');
1747
+ const body = parseBlock(runHook({}, dir).stdout);
1748
+ assert.ok(body && body.decision === 'block', 'a cached pass must not hide a later mutation');
1749
+ assert.match(body.reason, /artifact_hash/);
1750
+ } finally {
1751
+ fs.rmSync(dir, { recursive: true, force: true });
1752
+ }
1753
+ });
1754
+
1755
+ test('46. a dirty file inside the specs root does not force binding', () => {
1756
+ // The status and index queries exclude the specs root on purpose: a receipt is
1757
+ // written into that root, so counting it would make every receipt invalidate
1758
+ // itself. Dropping the exclusion from either query must fail this case.
1759
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1760
+ try {
1761
+ commitAll(dir, 'receipt');
1762
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, 'plan.md'), '# Demo plan\n\nEdited after the receipt was committed.\n');
1763
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, 'notes-untracked.md'), 'scratch\n');
1764
+ const dirty = spawnSync('git', ['-C', dir, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' });
1765
+ assert.match(dirty.stdout, /specs\//, 'the specs root must actually be dirty for this case to mean anything');
1766
+ clearCache();
1767
+ const result = runHook({}, dir);
1768
+ assert.strictEqual(result.stdout, '', 'work inside the specs root must not reopen a committed receipt');
1769
+ } finally {
1770
+ fs.rmSync(dir, { recursive: true, force: true });
1771
+ }
1772
+ });
1773
+
1774
+ test('47. completed-set branch accepts committed receipts and blocks a tampered one', () => {
1775
+ // 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.
1778
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1779
+ try {
1780
+ const secondDir = path.join(dir, 'specs', 'second');
1781
+ fs.mkdirSync(secondDir, { recursive: true });
1782
+ fs.writeFileSync(path.join(secondDir, 'plan.md'), '# Second plan\n');
1783
+ fs.writeFileSync(path.join(secondDir, 'task-01-second.md'), [
1784
+ '# Task 01: second', '', 'Status: done', '', '## Dependencies', '', '- none', '',
1785
+ '## Verification Plan', '', '- Command: node --test', '', ...boundReceipt(STALE_BASE, STALE_HEAD), '',
1786
+ ].join('\n'));
1787
+ commitAll(dir, 'two completed packets');
1788
+ clearCache();
1789
+ const accepted = runHook({}, dir);
1790
+ assert.strictEqual(accepted.stdout, '', 'the completed-set branch must accept committed receipts on a clean tree');
1791
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
1792
+ fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
1793
+ clearCache();
1794
+ const body = parseBlock(runHook({}, dir).stdout);
1795
+ assert.ok(body && body.decision === 'block', 'the completed-set branch must rebind on a dirty tree');
1796
+ assert.match(body.reason, /\bprovenance\b/);
1797
+ assert.match(body.reason, /second|demo/);
1798
+ } finally {
1799
+ fs.rmSync(dir, { recursive: true, force: true });
1800
+ }
1801
+ });
1802
+
1803
+ test('48. the gate\'s own runtime state does not defeat structure mode', () => {
1804
+ // The gate writes its cache under .claude/hooks/.logs on every Stop. If that
1805
+ // write counted as a dirty tree, a project tracking its runtime directory could
1806
+ // never reach structure mode and the gate would defeat itself. The status query
1807
+ // excludes the same runtime-state roots the provenance manifest already ignores.
1808
+ const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
1809
+ try {
1810
+ const stateDir = path.join(dir, '.claude', 'hooks', '.logs');
1811
+ fs.mkdirSync(stateDir, { recursive: true });
1812
+ fs.writeFileSync(path.join(stateDir, 'spec-gate-last.json'), '{}\n');
1813
+ fs.writeFileSync(path.join(dir, '.claude', 'runtime.json'), '{}\n');
1814
+ commitAll(dir, 'receipt plus tracked runtime state');
1815
+ // Mutate the tracked runtime state exactly as a Stop would.
1816
+ fs.writeFileSync(path.join(stateDir, 'spec-gate-last.json'), '{"demo":{"task-01-demo.md":"done"}}\n');
1817
+ fs.writeFileSync(path.join(dir, '.claude', 'runtime.json'), '{"spec":{}}\n');
1818
+ const dirty = spawnSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' });
1819
+ assert.match(dirty.stdout, /\.claude\//, 'the runtime state must actually be dirty for this case to mean anything');
1820
+ clearCache();
1821
+ const result = runHook({}, dir);
1822
+ assert.strictEqual(result.stdout, '', 'generated runtime state must not be read as a dirty worktree');
1823
+ } finally {
1824
+ fs.rmSync(dir, { recursive: true, force: true });
1825
+ }
1826
+ });
@@ -44,6 +44,8 @@ function inInstalledProcessSpecStateFixture(run) {
44
44
  fs.mkdirSync(scripts, { recursive: true });
45
45
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
46
46
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
47
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
48
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
47
49
  fs.copyFileSync(
48
50
  path.join(CLAUDE_ROOT, 'hooks', 'lib', 'hook-state-dir.cjs'),
49
51
  path.join(hooks, 'lib', 'hook-state-dir.cjs'),
@@ -15,19 +15,23 @@ try {
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
- /** Read .claude/runtime.json */
18
+ /** Read <runtime>/runtime.json */
19
+ const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
19
20
  function readRuntime(cwd) {
20
21
  try {
21
- const p = path.join(cwd, '.claude', 'runtime.json');
22
+ const p = runtimePath(cwd, 'runtime.json');
22
23
  return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
23
24
  } catch { return null; }
24
25
  }
25
26
 
27
+ // Skills live beside the runtime only on Claude; Codex and omp read `.agents/skills`.
28
+ function skillsDir() { return runtimeDirName() === '.claude' ? '.claude/skills' : '.agents/skills'; }
29
+
26
30
  /** Resolve Python venv executable path if it exists */
27
31
  function resolveVenv(cwd) {
28
32
  const candidates = [
29
- path.join(cwd, '.claude', 'skills', '.venv', 'bin', 'python3'),
30
- path.join(cwd, '.claude', 'skills', '.venv', 'Scripts', 'python.exe'),
33
+ path.join(cwd, skillsDir(), '.venv', 'bin', 'python3'),
34
+ path.join(cwd, skillsDir(), '.venv', 'Scripts', 'python.exe'),
31
35
  ];
32
36
  return candidates.find(p => fs.existsSync(p)) || null;
33
37
  }
@@ -37,7 +41,8 @@ try {
37
41
  const stdin = fs.readFileSync(0, 'utf8').trim();
38
42
  if (!stdin) process.exit(0);
39
43
 
40
- const payload = JSON.parse(stdin);
44
+ const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
45
+ const payload = normalizeHookPayload(JSON.parse(stdin));
41
46
  // Use payload.cwd for monorepo support — subagent may run in a different dir
42
47
  const payloadCwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
43
48
  const agentCwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
@@ -67,13 +72,13 @@ try {
67
72
  lines.push('');
68
73
  }
69
74
 
70
- // Python venv (optional — if .claude/skills/.venv exists)
75
+ // Python venv (optional — if <skills>/.venv exists)
71
76
  const venv = resolveVenv(agentCwd);
72
77
 
73
78
  lines.push('## Rules');
74
79
  lines.push(`- Plans → ${plansPath}/ | Docs → ${docsPath}/`);
75
80
  if (venv) {
76
- lines.push(`- Python in .claude/skills/: use \`${venv}\``);
81
+ lines.push(`- Python in ${skillsDir()}/: use \`${venv}\``);
77
82
  lines.push('- Never use global pip install');
78
83
  }
79
84
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
6
7
  let STATE;
7
8
  let evaluateCloseout;
8
9
 
@@ -27,7 +28,7 @@ function projectRoot(payload = {}) {
27
28
  }
28
29
 
29
30
  const installedRoot = path.resolve(__dirname, '..', '..');
30
- const installedHook = path.join(installedRoot, '.claude', 'hooks', path.basename(__filename));
31
+ const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
31
32
  if (fs.existsSync(installedHook)) return installedRoot;
32
33
 
33
34
  const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
@@ -50,7 +51,7 @@ function sessionId(payload) {
50
51
 
51
52
  function readRuntime(root) {
52
53
  try {
53
- const file = path.join(root, '.claude', 'runtime.json');
54
+ const file = runtimePath(root, 'runtime.json');
54
55
  return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
55
56
  } catch { return {}; }
56
57
  }
@@ -135,9 +136,14 @@ function stop(payload) {
135
136
 
136
137
  let mode = process.argv[2] || null;
137
138
  try {
138
- const payload = readPayload();
139
+ const raw = readPayload();
140
+ // Read the loop guard before normalization: the catch below answers a block, so a
141
+ // reader failure must not be able to reach it. Both spellings are accepted here.
142
+ const hostLoop = raw.stop_hook_active === true || raw.stopHookActive === true;
143
+ const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
144
+ const payload = normalizeHookPayload(raw);
139
145
  mode = mode || (payload.hook_event_name === 'UserPromptSubmit' ? '--approve' : '--stop');
140
- if (mode === '--stop' && payload.stop_hook_active === true) {
146
+ if (mode === '--stop' && hostLoop) {
141
147
  // Host loop prevention is always silent, including when dependencies are unavailable.
142
148
  } else {
143
149
  const dependencyError = loadAuthority();
@@ -19,7 +19,8 @@ try {
19
19
 
20
20
  // Đọc stdin theo chuẩn hook
21
21
  const stdin = fs.readFileSync(0, 'utf8').trim();
22
- const payload = stdin ? JSON.parse(stdin) : {};
22
+ const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
23
+ const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
23
24
  const cwd = payload.cwd || process.cwd();
24
25
  const config = loadConfig({ cwd, includeProject: false, includeAssertions: false, includeLocale: false });
25
26
 
@@ -8,7 +8,7 @@
8
8
  * Blocks access to heavy directories that would flood the LLM context window.
9
9
  * Also warns on overly-broad glob patterns.
10
10
  *
11
- * Disable: set "inspect": { "enabled": false } in .claude/runtime.json
11
+ * Disable: set "inspect": { "enabled": false } in <runtime>/runtime.json
12
12
  *
13
13
  * Exit: 0 = allow, 2 = block
14
14
  */
@@ -60,19 +60,41 @@ try {
60
60
  return out.filter(Boolean);
61
61
  }
62
62
 
63
+ const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
63
64
  function readRuntime(cwd) {
64
65
  try {
65
- const p = path.join(cwd, '.claude', 'runtime.json');
66
+ const p = runtimePath(cwd, 'runtime.json');
66
67
  return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
67
68
  } catch { return {}; }
68
69
  }
69
70
 
70
71
  // ── Main ──────────────────────────────────────────────────────────────────
71
72
 
73
+ /**
74
+ * Deny the call with a reason every host can read.
75
+ *
76
+ * The reason is the JSON `permissionDecisionReason`, which Claude Code reads natively and
77
+ * grok honours "regardless of exit code"; the same text also goes to stderr, because on
78
+ * an exit-2 denial that is the channel Claude Code feeds back, and grok reads only the
79
+ * first stderr line. Exit 2 stays so a host that reads neither still blocks.
80
+ */
81
+ function denyWithReason(reason) {
82
+ process.stdout.write(JSON.stringify({
83
+ hookSpecificOutput: {
84
+ hookEventName: 'PreToolUse',
85
+ permissionDecision: 'deny',
86
+ permissionDecisionReason: reason
87
+ }
88
+ }) + '\n');
89
+ process.stderr.write(reason + '\n');
90
+ process.exit(2);
91
+ }
92
+
72
93
  const stdin = fs.readFileSync(0, 'utf8').trim();
73
94
  if (!stdin) process.exit(0);
74
95
 
75
- const data = JSON.parse(stdin);
96
+ const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
97
+ const data = normalizeHookPayload(JSON.parse(stdin));
76
98
  const toolName = data.tool_name || '';
77
99
  const toolInput = data.tool_input || {};
78
100
  const cwd = data.cwd || process.cwd();
@@ -88,12 +110,11 @@ try {
88
110
 
89
111
  // Broad glob check
90
112
  if (toolInput.pattern && isBroadGlob(toolInput.pattern)) {
91
- console.log(
113
+ denyWithReason(
92
114
  `SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n` +
93
115
  `Requested Pattern: ${toolInput.pattern}\n\n` +
94
116
  `Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).`
95
117
  );
96
- process.exit(2);
97
118
  }
98
119
 
99
120
  // Blocked directory check
@@ -101,12 +122,11 @@ try {
101
122
  for (const p of paths) {
102
123
  if (isBlockedPath(p)) {
103
124
  const blocked = p.replace(/\\/g, '/').split('/').find(s => BLOCKED_DIRS.includes(s));
104
- console.log(
125
+ denyWithReason(
105
126
  `SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n` +
106
127
  `Requested Path: ${p}\n` +
107
128
  `Restricted zones: ${BLOCKED_DIRS.join(', ')}`
108
129
  );
109
- process.exit(2);
110
130
  }
111
131
  }
112
132
 
@@ -9,7 +9,10 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
11
 
12
- const RUNTIME_CONFIG_PATH = '.claude/runtime.json';
12
+ const { runtimeDirName } = require('./runtime-dir.cjs');
13
+
14
+ // Derived from this library's own location so an install under .omp/ or .codex/ reads its own file.
15
+ const RUNTIME_CONFIG_PATH = `${runtimeDirName()}/runtime.json`;
13
16
 
14
17
  const CONFIG_PATH = RUNTIME_CONFIG_PATH;
15
18
 
@@ -461,7 +464,7 @@ function sanitizeConfig(config, projectRoot) {
461
464
  *
462
465
  * Resolution order (each layer overrides the previous):
463
466
  * 1. DEFAULT_CONFIG (hardcoded defaults)
464
- * 2. Runtime config (./.claude/runtime.json) - installed CafeKit runtime config
467
+ * 2. Runtime config (<runtime-dir>/runtime.json, derived by lib/runtime-dir.cjs) - installed CafeKit runtime config
465
468
  *
466
469
  * @param {Object} options - Options for config loading
467
470
  * @param {boolean} options.includeProject - Include project section (default: true)
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
+ const { runtimeDir } = require('./runtime-dir.cjs');
9
10
 
10
11
  function getMcpServerNames(filePath) {
11
12
  if (!fs.existsSync(filePath)) return new Set();
@@ -83,16 +84,17 @@ function countConfigs(cwd) {
83
84
 
84
85
  // Project scope
85
86
  if (cwd) {
87
+ const projectRuntime = runtimeDir(cwd);
86
88
  if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) claudeMdCount++;
87
89
  if (fs.existsSync(path.join(cwd, 'CLAUDE.local.md'))) claudeMdCount++;
88
- if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.md'))) claudeMdCount++;
89
- if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.local.md'))) claudeMdCount++;
90
- rulesCount += countRulesInDir(path.join(cwd, '.claude', 'rules'));
90
+ if (fs.existsSync(path.join(projectRuntime, 'CLAUDE.md'))) claudeMdCount++;
91
+ if (fs.existsSync(path.join(projectRuntime, 'CLAUDE.local.md'))) claudeMdCount++;
92
+ rulesCount += countRulesInDir(path.join(projectRuntime, 'rules'));
91
93
  mcpCount += countMcpServersInFile(path.join(cwd, '.mcp.json'));
92
- const projectSettings = path.join(cwd, '.claude', 'settings.json');
94
+ const projectSettings = path.join(projectRuntime, 'settings.json');
93
95
  mcpCount += countMcpServersInFile(projectSettings);
94
96
  hooksCount += countHooksInFile(projectSettings);
95
- const localSettings = path.join(cwd, '.claude', 'settings.local.json');
97
+ const localSettings = path.join(projectRuntime, 'settings.local.json');
96
98
  mcpCount += countMcpServersInFile(localSettings);
97
99
  hooksCount += countHooksInFile(localSettings);
98
100
  }