@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.
- package/README.md +5 -3
- package/bin/__tests__/codex-hooks.test.js +125 -0
- package/bin/__tests__/codex-native.test.js +95 -0
- package/bin/__tests__/develop-contract.test.js +43 -1
- package/bin/__tests__/grok-envelope.test.js +271 -0
- package/bin/__tests__/grok-platform.test.js +133 -0
- package/bin/__tests__/hook-payload.test.js +194 -0
- package/bin/__tests__/omp-bridge.test.js +168 -0
- package/bin/__tests__/omp-hooks.test.js +178 -0
- package/bin/__tests__/omp-runtime.test.js +150 -0
- package/bin/__tests__/orca-session.test.js +225 -0
- package/bin/__tests__/orca-skill.test.js +210 -0
- package/bin/__tests__/package-inventory.test.js +1 -0
- package/bin/__tests__/runtime-dir.test.js +158 -0
- package/bin/__tests__/runtime-schema.test.js +5 -3
- package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
- package/bin/install.js +5 -5
- package/bin/lib/context.js +50 -3
- package/bin/lib/i18n.js +3 -30
- package/bin/phases/codex-hooks.js +90 -17
- package/bin/phases/copy-payload.js +7 -0
- package/bin/phases/omp-runtime.js +140 -0
- package/bin/phases/root-config.js +3 -0
- package/bin/phases/select-platform.js +22 -1
- package/bin/phases/summary.js +0 -4
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
- package/src/claude/hooks/__tests__/state.test.js +2 -0
- package/src/claude/hooks/agent.cjs +12 -7
- package/src/claude/hooks/completion-authority.cjs +10 -4
- package/src/claude/hooks/docs-sync.cjs +2 -1
- package/src/claude/hooks/inspect-block.cjs +27 -7
- package/src/claude/hooks/lib/config.cjs +5 -2
- package/src/claude/hooks/lib/counter.cjs +7 -5
- package/src/claude/hooks/lib/hook-payload.cjs +160 -0
- package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
- package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
- package/src/claude/hooks/precompact.cjs +2 -1
- package/src/claude/hooks/privacy-block.cjs +4 -2
- package/src/claude/hooks/rules.cjs +4 -2
- package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
- package/src/claude/hooks/semantic-review-authority.cjs +6 -4
- package/src/claude/hooks/session.cjs +16 -8
- package/src/claude/hooks/spec-gate.cjs +18 -8
- package/src/claude/hooks/spec-state.cjs +9 -5
- package/src/claude/hooks/state.cjs +6 -4
- package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
- package/src/claude/hooks/usage.cjs +5 -4
- package/src/claude/migration-manifest.json +3 -0
- package/src/claude/rules/skill-domain-routing.md +1 -0
- package/src/claude/rules/state-sync.md +7 -1
- package/src/claude/rules/workflow.md +2 -0
- package/src/claude/scripts/provenance.cjs +11 -4
- package/src/claude/scripts/spec-receipt.cjs +65 -4
- package/src/claude/skills/orca/SKILL.md +51 -0
- package/src/claude/skills/specs/SKILL.md +10 -11
- package/src/codex/hooks/session.cjs +6 -1
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/hooks.json +18 -18
- package/src/codex/rules/state-sync.md +7 -1
- package/src/omp/extensions/cafekit-bridge.mjs +233 -0
- package/src/omp/hooks/privacy-block.cjs +250 -0
- package/src/omp/runtime.json +33 -0
- package/bin/lib/rtk-run.js +0 -28
- 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
|
|
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 =
|
|
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,
|
|
30
|
-
path.join(cwd,
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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' &&
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
89
|
-
if (fs.existsSync(path.join(
|
|
90
|
-
rulesCount += countRulesInDir(path.join(
|
|
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(
|
|
94
|
+
const projectSettings = path.join(projectRuntime, 'settings.json');
|
|
93
95
|
mcpCount += countMcpServersInFile(projectSettings);
|
|
94
96
|
hooksCount += countHooksInFile(projectSettings);
|
|
95
|
-
const localSettings = path.join(
|
|
97
|
+
const localSettings = path.join(projectRuntime, 'settings.local.json');
|
|
96
98
|
mcpCount += countMcpServersInFile(localSettings);
|
|
97
99
|
hooksCount += countHooksInFile(localSettings);
|
|
98
100
|
}
|