@haposoft/cafekit 0.16.0 → 0.16.1

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 (57) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/codex-hooks.test.js +125 -0
  3. package/bin/__tests__/develop-contract.test.js +43 -1
  4. package/bin/__tests__/grok-envelope.test.js +271 -0
  5. package/bin/__tests__/grok-platform.test.js +133 -0
  6. package/bin/__tests__/hook-payload.test.js +194 -0
  7. package/bin/__tests__/omp-bridge.test.js +168 -0
  8. package/bin/__tests__/omp-hooks.test.js +178 -0
  9. package/bin/__tests__/omp-runtime.test.js +150 -0
  10. package/bin/__tests__/runtime-dir.test.js +158 -0
  11. package/bin/__tests__/runtime-schema.test.js +5 -3
  12. package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
  13. package/bin/install.js +5 -5
  14. package/bin/lib/context.js +50 -3
  15. package/bin/lib/i18n.js +3 -30
  16. package/bin/phases/copy-payload.js +7 -0
  17. package/bin/phases/omp-runtime.js +140 -0
  18. package/bin/phases/root-config.js +3 -0
  19. package/bin/phases/select-platform.js +22 -1
  20. package/bin/phases/summary.js +0 -4
  21. package/package.json +1 -1
  22. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
  23. package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
  24. package/src/claude/hooks/__tests__/state.test.js +2 -0
  25. package/src/claude/hooks/agent.cjs +12 -7
  26. package/src/claude/hooks/completion-authority.cjs +10 -4
  27. package/src/claude/hooks/docs-sync.cjs +2 -1
  28. package/src/claude/hooks/inspect-block.cjs +27 -7
  29. package/src/claude/hooks/lib/config.cjs +5 -2
  30. package/src/claude/hooks/lib/counter.cjs +7 -5
  31. package/src/claude/hooks/lib/hook-payload.cjs +160 -0
  32. package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
  33. package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
  34. package/src/claude/hooks/precompact.cjs +2 -1
  35. package/src/claude/hooks/privacy-block.cjs +4 -2
  36. package/src/claude/hooks/rules.cjs +4 -2
  37. package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
  38. package/src/claude/hooks/semantic-review-authority.cjs +6 -4
  39. package/src/claude/hooks/session.cjs +10 -8
  40. package/src/claude/hooks/spec-gate.cjs +18 -8
  41. package/src/claude/hooks/spec-state.cjs +9 -5
  42. package/src/claude/hooks/state.cjs +6 -4
  43. package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
  44. package/src/claude/hooks/usage.cjs +5 -4
  45. package/src/claude/migration-manifest.json +2 -0
  46. package/src/claude/rules/state-sync.md +7 -1
  47. package/src/claude/rules/workflow.md +2 -0
  48. package/src/claude/scripts/provenance.cjs +11 -4
  49. package/src/claude/scripts/spec-receipt.cjs +65 -4
  50. package/src/claude/skills/specs/SKILL.md +10 -11
  51. package/src/codex/hooks/spec-state.cjs +1 -1
  52. package/src/codex/rules/state-sync.md +7 -1
  53. package/src/omp/extensions/cafekit-bridge.mjs +233 -0
  54. package/src/omp/hooks/privacy-block.cjs +250 -0
  55. package/src/omp/runtime.json +33 -0
  56. package/bin/lib/rtk-run.js +0 -28
  57. package/bin/phases/setup-rtk.js +0 -159
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Phase: Oh My Pi runtime.
5
+ *
6
+ * omp discovers `.claude/skills` and `.agents/skills` on its own, so CafeKit copies no
7
+ * skill payload here. What omp does not have is CafeKit's enforcement chain, so this
8
+ * phase provisions what carries it:
9
+ *
10
+ * .omp/hooks/ the gate scripts: the Claude set, with omp's overlay on top
11
+ * .omp/extensions/ the bridge omp auto-loads, which dispatches those scripts
12
+ * .omp/runtime.json the hooks' configuration, beside the schema that describes it
13
+ * .omp/rules/ the rules the hooks inject
14
+ *
15
+ * The hooks are portable: they derive their runtime directory from their own location,
16
+ * so the Claude bytes run unchanged under `.omp/`. Only the files that genuinely differ
17
+ * for omp live in `src/omp/hooks/` and are written over the Claude set afterwards, so a
18
+ * change to a shared hook reaches omp without anyone remembering to re-fork it.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { PLATFORMS } = require('../lib/context');
24
+ const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
25
+ const { report, treeAction } = require('./report');
26
+ const { copyRulesDirectory } = require('./claude-runtime');
27
+
28
+ const SRC = path.join(__dirname, '../../src');
29
+ const CLAUDE_SRC = path.join(SRC, 'claude');
30
+ const OMP_SRC = path.join(SRC, 'omp');
31
+
32
+ /** Files under `src/omp/hooks/` that replace their Claude counterpart, relative to the hooks dir. */
33
+ function overlayFiles(dir = path.join(OMP_SRC, 'hooks'), prefix = '') {
34
+ if (!fs.existsSync(dir)) return [];
35
+ const found = [];
36
+ for (const name of fs.readdirSync(dir)) {
37
+ const rel = prefix ? `${prefix}/${name}` : name;
38
+ if (fs.statSync(path.join(dir, name)).isDirectory()) found.push(...overlayFiles(path.join(dir, name), rel));
39
+ else found.push(rel);
40
+ }
41
+ return found.sort();
42
+ }
43
+
44
+ /**
45
+ * The Claude hook set is the list the migration manifest ships to `.claude/`, not a
46
+ * directory walk: the source tree also holds `__tests__/`, which must never install.
47
+ */
48
+ function claudeHookFiles(ctx) {
49
+ const files = ctx.manifest?.runtime?.files || [];
50
+ return files.filter((rel) => rel.startsWith('hooks/')).map((rel) => rel.slice('hooks/'.length));
51
+ }
52
+
53
+ function writeSourceFile(ctx, platformKey, src, dest, label) {
54
+ const platform = PLATFORMS[platformKey];
55
+ const { action } = writeManagedFile({
56
+ src,
57
+ dest,
58
+ platformFolder: platform.folder,
59
+ ctx,
60
+ tracker: ctx.trackers[platformKey]
61
+ });
62
+ report(ctx, action, label);
63
+ }
64
+
65
+ function installHooks(ctx, platformKey) {
66
+ const platform = PLATFORMS[platformKey];
67
+ const overlay = new Set(overlayFiles());
68
+ // Claude set first, skipping anything the overlay is about to replace so a file is
69
+ // written once and the manifest records the bytes that actually land.
70
+ for (const rel of claudeHookFiles(ctx)) {
71
+ if (overlay.has(rel)) continue;
72
+ writeSourceFile(
73
+ ctx,
74
+ platformKey,
75
+ path.join(CLAUDE_SRC, 'hooks', rel),
76
+ path.join(platform.folder, 'hooks', rel),
77
+ `omp hooks: ${rel}`
78
+ );
79
+ }
80
+ for (const rel of overlay) {
81
+ writeSourceFile(
82
+ ctx,
83
+ platformKey,
84
+ path.join(OMP_SRC, 'hooks', rel),
85
+ path.join(platform.folder, 'hooks', rel),
86
+ `omp hooks (overlay): ${rel}`
87
+ );
88
+ }
89
+ }
90
+
91
+ function installRuntimeFiles(ctx, platformKey) {
92
+ const platform = PLATFORMS[platformKey];
93
+ writeSourceFile(
94
+ ctx,
95
+ platformKey,
96
+ path.join(OMP_SRC, 'runtime.json'),
97
+ path.join(platform.folder, 'runtime.json'),
98
+ 'omp runtime: runtime.json'
99
+ );
100
+ // One canonical runtime schema lives in the Claude source tree; every installed
101
+ // runtime references it as ./runtime.schema.json beside its runtime.json.
102
+ writeSourceFile(
103
+ ctx,
104
+ platformKey,
105
+ path.join(CLAUDE_SRC, 'runtime.schema.json'),
106
+ path.join(platform.folder, 'runtime.schema.json'),
107
+ 'omp runtime schema'
108
+ );
109
+ }
110
+
111
+ function installBridge(ctx, platformKey) {
112
+ const platform = PLATFORMS[platformKey];
113
+ const bridgeSource = path.join(OMP_SRC, 'extensions');
114
+ const bridgeDest = path.join(platform.folder, 'extensions');
115
+ if (fs.existsSync(bridgeSource)) {
116
+ const bridge = copyManagedTree({
117
+ src: bridgeSource,
118
+ dest: bridgeDest,
119
+ platformFolder: platform.folder,
120
+ ctx,
121
+ tracker: ctx.trackers[platformKey]
122
+ });
123
+ report(ctx, treeAction(bridge), 'omp bridge extension');
124
+ } else if (!ctx.dryRun) {
125
+ // A half-provisioned install is visibly empty rather than silently absent.
126
+ fs.mkdirSync(bridgeDest, { recursive: true });
127
+ ctx.ui.detail(' ✓ omp extensions directory created (bridge not present)');
128
+ }
129
+ }
130
+
131
+ function installOmpRuntime(ctx, platformKey) {
132
+ if (platformKey !== 'omp') return;
133
+ installHooks(ctx, platformKey);
134
+ installRuntimeFiles(ctx, platformKey);
135
+ installBridge(ctx, platformKey);
136
+ copyRulesDirectory(ctx, platformKey);
137
+ return ctx;
138
+ }
139
+
140
+ module.exports = { installOmpRuntime, overlayFiles, claudeHookFiles };
@@ -110,6 +110,9 @@ function ensureGitignore(ctx) {
110
110
  // Local runtime payload — reinstall with npx; keep out of git
111
111
  '.claude/',
112
112
  '.codex/',
113
+ // omp auto-executes every file in .omp/extensions/, so a committed copy
114
+ // would run arbitrary code on clone. Keep it out of git like the rest.
115
+ '.omp/',
113
116
  '.agents/'
114
117
  ];
115
118
  const prefix = ctx.dryRun ? '[dry-run] ' : '';
@@ -11,7 +11,8 @@ const path = require('path');
11
11
  const {
12
12
  PLATFORMS,
13
13
  detectPlatforms,
14
- getPlatformKeys
14
+ getPlatformKeys,
15
+ resolvePlatformAliases
15
16
  } = require('../lib/context');
16
17
  const { SUPPORTED, LANGUAGE_LABELS, OTHER_LABEL } = require('../lib/i18n');
17
18
 
@@ -36,7 +37,25 @@ function getInstalledLocale(platformKeys = getPlatformKeys()) {
36
37
  }
37
38
 
38
39
  /** First step: pick the installer/UI language (interactive only). */
40
+ /**
41
+ * Resolve host aliases on the requested platform list, once, before anything reads it.
42
+ *
43
+ * selectLanguage() filters this list through PLATFORMS to decide which installed runtime
44
+ * to take a saved locale from, so an unresolved alias would empty that list and fall back
45
+ * to scanning every platform folder — `--platform grok` in a project that also has
46
+ * `.codex/` would then silently take Codex's language.
47
+ */
48
+ function applyPlatformAliases(ctx) {
49
+ const requested = ctx.options.platforms || [];
50
+ if (requested.length === 0) return ctx;
51
+ const { platforms, aliased } = resolvePlatformAliases(requested);
52
+ ctx.options.platforms = platforms;
53
+ ctx.aliasedHost = aliased;
54
+ return ctx;
55
+ }
56
+
39
57
  async function selectLanguage(ctx) {
58
+ applyPlatformAliases(ctx);
40
59
  // Restore the saved locale BEFORE the interactivity check: a non-interactive
41
60
  // upgrade (--yes) must not forget the configured language. Skipping this left
42
61
  // ctx at the 'en' default, and patchRuntimeLocale then clobbered the user's
@@ -161,6 +180,7 @@ async function resolvePlatforms(ctx) {
161
180
  ctx.ui.info(ctx.t('installingFor', {
162
181
  names: platformNames(requested)
163
182
  }));
183
+ if (ctx.aliasedHost) ctx.ui.info(ctx.t('grokCompatNotice'));
164
184
  reportInstallMode(ctx);
165
185
  return ctx;
166
186
  }
@@ -244,6 +264,7 @@ function getInstalledPlatform() {
244
264
 
245
265
  module.exports = {
246
266
  selectLanguage,
267
+ applyPlatformAliases,
247
268
  promptPlatformSelection,
248
269
  resolvePlatforms,
249
270
  getInstalledPlatform,
@@ -59,10 +59,6 @@ function printSummary(ctx) {
59
59
  }[key];
60
60
  if (messageKey) lines.push(t(messageKey));
61
61
  }
62
- // rtk token-saver result (set by phases/setup-rtk.js when setup completed).
63
- if (ctx.rtkSetupRan) {
64
- lines.push(t('rtkSummary'));
65
- }
66
62
  const keySkills = skillsNeedingKeys(platforms);
67
63
  if (keySkills.length > 0) {
68
64
  lines.push(t('nsKeys', { skills: keySkills.join(', ') }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
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",
@@ -30,6 +30,8 @@ function installSemanticRuntime(target, runtime) {
30
30
  for (const name of ['semantic-review-authority.cjs', 'completion-authority-state.cjs']) {
31
31
  fs.copyFileSync(path.join(PACKAGE_ROOT, `src/${runtime}/hooks`, name), path.join(hooks, name));
32
32
  }
33
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-dir.cjs'), path.join(hooks, 'lib/runtime-dir.cjs'));
34
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/hook-payload.cjs'), path.join(hooks, 'lib/hook-payload.cjs'));
33
35
  fs.copyFileSync(
34
36
  path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
35
37
  path.join(hooks, 'lib/runtime-path-safety.cjs'),
@@ -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