@haposoft/cafekit 0.16.7 → 0.18.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.
- package/README.md +14 -22
- package/bin/__tests__/codex-hooks-ownership.test.js +2 -2
- package/bin/__tests__/codex-hooks.test.js +169 -272
- package/bin/__tests__/codex-native.test.js +100 -202
- package/bin/__tests__/develop-contract.test.js +122 -1413
- package/bin/__tests__/docs-sync-on-demand.test.js +93 -0
- package/bin/__tests__/grok-envelope.test.js +0 -56
- package/bin/__tests__/hook-payload.test.js +4 -6
- package/bin/__tests__/legacy-authority-decoupled.test.js +154 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +221 -0
- package/bin/__tests__/omp-bridge.test.js +20 -17
- package/bin/__tests__/omp-hooks.test.js +0 -19
- package/bin/__tests__/optional-skill-inventory.test.js +112 -4
- package/bin/__tests__/orca-session.test.js +0 -2
- package/bin/__tests__/package-inventory.test.js +90 -925
- package/bin/__tests__/runtime-dir.test.js +0 -3
- package/bin/__tests__/runtime-schema.test.js +3 -2
- package/bin/__tests__/skill-routing-source.test.js +27 -15
- package/bin/__tests__/spec-narrowing.test.js +30 -161
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -437
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/ui-ux-skill.test.js +101 -0
- package/bin/__tests__/usage-hook-retired.test.js +114 -0
- package/bin/__tests__/validator-grounding.test.js +18 -2949
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/codex-runtime.js +27 -0
- package/bin/phases/copy-payload.js +13 -28
- package/bin/phases/omp-runtime.js +25 -0
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +3 -10
- package/src/claude/agents/brainstormer.md +7 -3
- package/src/claude/agents/code-auditor.md +44 -56
- package/src/claude/agents/debugger.md +8 -88
- package/src/claude/agents/deployer.md +3 -5
- package/src/claude/agents/docs-keeper.md +6 -14
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +12 -16
- package/src/claude/agents/inspector.md +4 -3
- package/src/claude/agents/project-manager.md +9 -17
- package/src/claude/agents/researcher.md +9 -3
- package/src/claude/agents/spec-maker.md +15 -15
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/hooks/__tests__/fixtures/statusline-default.golden +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +244 -333
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +160 -0
- package/src/claude/hooks/__tests__/state.test.js +68 -0
- package/src/claude/hooks/__tests__/statusline.test.js +140 -11
- package/src/claude/hooks/agent.cjs +2 -1
- package/src/claude/hooks/lib/hook-payload.cjs +2 -2
- package/src/claude/hooks/rules.cjs +2 -1
- package/src/claude/hooks/session.cjs +0 -1
- package/src/claude/hooks/spec-gate.cjs +17 -83
- package/src/claude/hooks/spec-state.cjs +142 -39
- package/src/claude/hooks/state.cjs +21 -2
- package/src/claude/migration-manifest.json +42 -22
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +2 -3
- package/src/claude/rules/hook-protocols.md +8 -15
- package/src/claude/rules/manage-docs.md +9 -23
- package/src/claude/rules/orchestrator.md +3 -4
- package/src/claude/rules/review-audit-self-decision.md +3 -3
- package/src/claude/rules/skill-domain-routing.md +1 -3
- package/src/claude/rules/skill-workflow-routing.md +4 -5
- package/src/claude/rules/state-sync.md +3 -12
- package/src/claude/rules/workflow.md +11 -19
- package/src/claude/runtime.schema.json +5 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -6
- package/src/claude/scripts/provenance.cjs +23 -5
- package/src/claude/scripts/spec-receipt.cjs +24 -121
- package/src/claude/scripts/spec-resolver.cjs +41 -458
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -32
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/{question → ask}/SKILL.md +9 -75
- package/src/claude/skills/brainstorm/SKILL.md +43 -131
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +29 -81
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -8
- package/src/claude/skills/debug/SKILL.md +29 -149
- package/src/claude/skills/develop/SKILL.md +26 -83
- package/src/claude/skills/develop/references/parallel-waves.md +0 -8
- package/src/claude/skills/develop/references/quality-gate.md +15 -13
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/docs/SKILL.md +2 -2
- package/src/claude/skills/docs/references/update-workflow.md +1 -1
- package/src/claude/skills/fix/SKILL.md +153 -0
- package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
- package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
- package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
- package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
- package/src/claude/skills/git/SKILL.md +48 -9
- package/src/claude/skills/git/references/commit-protocols.md +35 -16
- package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
- package/src/claude/skills/orca/SKILL.md +9 -8
- package/src/claude/skills/research/SKILL.md +17 -46
- package/src/claude/skills/scout/SKILL.md +61 -0
- package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
- package/src/claude/skills/specs/SKILL.md +22 -29
- package/src/claude/skills/specs/references/review.md +8 -6
- package/src/claude/skills/specs/references/templates.md +34 -41
- package/src/claude/skills/sync/SKILL.md +16 -45
- package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
- package/src/claude/skills/sync/references/sync-protocols.md +4 -13
- package/src/claude/skills/test/SKILL.md +39 -104
- package/src/claude/skills/test/references/execution-strategy.md +93 -21
- package/src/claude/skills/test/references/failure-triage.md +1 -1
- package/src/claude/skills/ui-ux/LICENSE +21 -0
- package/src/claude/skills/ui-ux/SKILL.md +497 -0
- package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
- package/src/claude/skills/ui-ux/references/budgets.md +113 -0
- package/src/claude/skills/ui-ux/references/checklist.md +271 -0
- package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
- package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
- package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
- package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
- package/src/claude/skills/ui-ux/references/components/button.md +142 -0
- package/src/claude/skills/ui-ux/references/components/card.md +113 -0
- package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
- package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
- package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
- package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
- package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
- package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
- package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
- package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
- package/src/claude/skills/ui-ux/references/components/input.md +161 -0
- package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
- package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
- package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
- package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
- package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
- package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
- package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
- package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
- package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
- package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
- package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
- package/src/claude/skills/ui-ux/references/design-process.md +650 -0
- package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
- package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
- package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
- package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
- package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
- package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
- package/src/claude/skills/ui-ux/references/principles.md +408 -0
- package/src/claude/skills/ui-ux/references/refactor.md +245 -0
- package/src/claude/skills/ui-ux/references/responsive.md +195 -0
- package/src/claude/skills/ui-ux/references/review.md +528 -0
- package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
- package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
- package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
- package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
- package/src/claude/skills/ui-ux/references/styles.md +311 -0
- package/src/claude/skills/ui-ux/references/system.md +219 -0
- package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
- package/src/claude/skills/ui-ux/references/tokens.css +362 -0
- package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
- package/src/claude/skills/ui-ux/scripts/package.json +9 -0
- package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +25 -80
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/claude/status.cjs +149 -144
- package/src/codex/AGENTS.md +8 -13
- package/src/codex/hooks/agent.cjs +2 -1
- package/src/codex/hooks/lib/spec-receipt.cjs +4 -32
- package/src/codex/hooks/lib/spec-utils.cjs +6 -52
- package/src/codex/hooks/rules.cjs +2 -1
- package/src/codex/hooks/session.cjs +0 -2
- package/src/codex/hooks/spec-gate.cjs +13 -81
- package/src/codex/hooks/spec-state.cjs +37 -41
- package/src/codex/hooks.json +0 -20
- package/src/codex/rules/state-sync.md +3 -12
- package/src/common/AGENTS.md +6 -12
- package/src/omp/extensions/cafekit-bridge.mjs +6 -6
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
- package/src/claude/hooks/completion-authority-check.cjs +0 -56
- package/src/claude/hooks/completion-authority-state.cjs +0 -527
- package/src/claude/hooks/completion-authority.cjs +0 -163
- package/src/claude/hooks/lib/context.cjs +0 -616
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/hooks/semantic-review-authority.cjs +0 -154
- package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
- package/src/claude/hooks/usage.cjs +0 -197
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3941
- package/src/claude/skills/code-review/references/verification-gate.md +0 -46
- package/src/claude/skills/delegate/SKILL.md +0 -124
- package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
- package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
- package/src/claude/skills/hotfix/SKILL.md +0 -289
- package/src/claude/skills/inspect/SKILL.md +0 -142
- package/src/claude/skills/loop/SKILL.md +0 -114
- package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
- package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
- package/src/claude/skills/route/SKILL.md +0 -94
- package/src/claude/skills/route/references/agent-timing.md +0 -46
- package/src/claude/skills/route/references/chaining-patterns.md +0 -53
- package/src/claude/skills/route/references/task-taxonomy.md +0 -48
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- package/src/codex/hooks/completion-authority-check.cjs +0 -67
- package/src/codex/hooks/completion-authority-state.cjs +0 -527
- package/src/codex/hooks/completion-authority.cjs +0 -109
- package/src/codex/hooks/semantic-review-authority.cjs +0 -130
- package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
- /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
- /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
- /package/src/claude/skills/{hotfix → fix}/references/workflow-specialized.md +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// docs-sync printed "Docs sync needed" at every Claude SessionStart, and nothing ever
|
|
4
|
+
// cleared it. It now runs only when the docs skill asks for it. The skill text is shared
|
|
5
|
+
// with Codex, whose copy crashes silently on an empty stdin, so the command feeds `{}`.
|
|
6
|
+
// Each case names the failure it prevents.
|
|
7
|
+
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { spawnSync, execSync } = require('node:child_process');
|
|
13
|
+
const { test } = require('node:test');
|
|
14
|
+
|
|
15
|
+
const PACKAGE_ROOT = path.join(__dirname, '..', '..');
|
|
16
|
+
const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
|
|
17
|
+
const DOCS_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/docs');
|
|
18
|
+
const ON_DEMAND = "echo '{}' | node .claude/hooks/docs-sync.cjs";
|
|
19
|
+
|
|
20
|
+
function inTempProject(run) {
|
|
21
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-docs-sync-'));
|
|
22
|
+
try {
|
|
23
|
+
const init = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
|
|
24
|
+
assert.equal(init.status, 0, init.stderr);
|
|
25
|
+
return run(root);
|
|
26
|
+
} finally {
|
|
27
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function install(root, platforms) {
|
|
32
|
+
const result = spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes'],
|
|
33
|
+
{ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
|
34
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function commands(settings, event) {
|
|
38
|
+
return (settings.hooks[event] || []).flatMap((group) => (group.hooks || []).map((hook) => hook.command));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A git project with code, docs and a `.sync_hash` that no longer matches the source. */
|
|
42
|
+
function makeStale(root) {
|
|
43
|
+
fs.writeFileSync(path.join(root, 'package.json'), '{"name":"fixture"}\n');
|
|
44
|
+
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Docs\n');
|
|
46
|
+
fs.writeFileSync(path.join(root, 'docs', '.sync_hash'), '0000000000000000000000000000000000000000\n');
|
|
47
|
+
execSync('git -c user.email=t@t -c user.name=t add -A && git -c user.email=t@t -c user.name=t commit -qm fixture', { cwd: root });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('fresh claude install runs no docs-sync at SessionStart', () => {
|
|
51
|
+
inTempProject((root) => {
|
|
52
|
+
install(root, ['claude']);
|
|
53
|
+
const settings = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8'));
|
|
54
|
+
assert.equal(commands(settings, 'SessionStart').some((c) => c.includes('docs-sync.cjs')), false);
|
|
55
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'hooks', 'docs-sync.cjs')), true, 'the hook stays installed for on-demand use');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('upgrade drops the SessionStart docs-sync entry', () => {
|
|
60
|
+
inTempProject((root) => {
|
|
61
|
+
install(root, ['claude']);
|
|
62
|
+
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
63
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
64
|
+
const kept = commands(settings, 'SessionStart');
|
|
65
|
+
settings.hooks.SessionStart[0].hooks.push({ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/docs-sync.cjs"' });
|
|
66
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
67
|
+
install(root, ['claude']);
|
|
68
|
+
const after = commands(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), 'SessionStart');
|
|
69
|
+
assert.equal(after.some((c) => c.includes('docs-sync.cjs')), false);
|
|
70
|
+
for (const command of kept) assert.ok(after.includes(command), `kept: ${command}`);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the docs skill runs docs-sync on demand', () => {
|
|
75
|
+
for (const rel of ['SKILL.md', 'references/update-workflow.md']) {
|
|
76
|
+
assert.ok(fs.readFileSync(path.join(DOCS_SKILL, rel), 'utf8').includes(ON_DEMAND), `${rel} names the on-demand command`);
|
|
77
|
+
}
|
|
78
|
+
const files = fs.readdirSync(DOCS_SKILL, { recursive: true }).filter((f) => f.endsWith('.md'));
|
|
79
|
+
for (const rel of files) {
|
|
80
|
+
assert.doesNotMatch(fs.readFileSync(path.join(DOCS_SKILL, rel), 'utf8'), /SessionStart docs-sync/, rel);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (const [runtime, folder] of [['claude', '.claude'], ['codex', '.codex']]) {
|
|
85
|
+
test(`the ${runtime === 'claude' ? 'Claude' : 'Codex'} copy prints its signal on demand`, () => {
|
|
86
|
+
inTempProject((root) => {
|
|
87
|
+
install(root, [runtime]);
|
|
88
|
+
makeStale(root);
|
|
89
|
+
const out = execSync(`echo '{}' | node ${folder}/hooks/docs-sync.cjs`, { cwd: root, encoding: 'utf8' });
|
|
90
|
+
assert.match(out, /Docs sync needed/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -134,36 +134,6 @@ test('a broad glob is denied with its full reason in JSON', () => {
|
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
test('a nested task write is denied under the grok write tool', () => {
|
|
138
|
-
withInstall((root) => {
|
|
139
|
-
const target = path.join(root, 'specs', 'demo', 'tasks', 'task-R0-01-x.md');
|
|
140
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
141
|
-
// grok creates files with `write`; `search_replace` edits an existing one. The guard
|
|
142
|
-
// fires on Write only, so the alias has to keep those two apart.
|
|
143
|
-
const r = runHook(root, 'task-scaffold-guard.cjs', {
|
|
144
|
-
hookEventName: 'pre_tool_use',
|
|
145
|
-
cwd: root,
|
|
146
|
-
toolName: 'write',
|
|
147
|
-
toolInput: { path: target, content: '# x' },
|
|
148
|
-
});
|
|
149
|
-
const { decision, reason } = decisionOf(r.stdout);
|
|
150
|
-
assert.equal(decision, 'deny');
|
|
151
|
-
assert.match(reason, /TASK SCAFFOLD REQUIRED/);
|
|
152
|
-
assert.match(reason, /spec-scaffold\.cjs/, 'the command the model must run has to reach it');
|
|
153
|
-
assert.equal(r.code, 2);
|
|
154
|
-
|
|
155
|
-
// The escape route the message names must stay open: an edit is not a create.
|
|
156
|
-
const edit = runHook(root, 'task-scaffold-guard.cjs', {
|
|
157
|
-
hookEventName: 'pre_tool_use',
|
|
158
|
-
cwd: root,
|
|
159
|
-
toolName: 'search_replace',
|
|
160
|
-
toolInput: { path: target, old_string: 'a', new_string: 'b' },
|
|
161
|
-
});
|
|
162
|
-
assert.equal(decisionOf(edit.stdout).decision, null, 'search_replace must not be treated as a create');
|
|
163
|
-
assert.equal(edit.code, 0);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
137
|
/** A packet the Stop gate will block: one done task with no receipt. */
|
|
168
138
|
function unprovenPacket(root) {
|
|
169
139
|
const feature = path.join(root, 'specs', 'demo');
|
|
@@ -206,32 +176,6 @@ test('the Stop gate still blocks an unproven done task', () => {
|
|
|
206
176
|
}, { git: true });
|
|
207
177
|
});
|
|
208
178
|
|
|
209
|
-
test('the approval hook takes its approve path on a grok prompt', () => {
|
|
210
|
-
withInstall((root) => {
|
|
211
|
-
// Registered without an argv mode, this hook picks its mode from the event name, and
|
|
212
|
-
// approve() returns immediately unless that name is exactly 'UserPromptSubmit'. An
|
|
213
|
-
// approval phrase with no pending request is therefore the oracle: only the approve
|
|
214
|
-
// path can print a rejection line, so the line proves the event name was translated.
|
|
215
|
-
// Under an unreadable envelope the mode fell back to the stop path on every prompt.
|
|
216
|
-
const nonce = 'a'.repeat(24);
|
|
217
|
-
const r = runHook(root, 'completion-authority.cjs', {
|
|
218
|
-
hookEventName: 'user_prompt_submit',
|
|
219
|
-
sessionId: 'grok-1',
|
|
220
|
-
cwd: root,
|
|
221
|
-
prompt: `APPROVE CAFEKIT COMPLETION ${nonce}`,
|
|
222
|
-
});
|
|
223
|
-
assert.match(r.stdout, /CafeKit completion approval rejected/,
|
|
224
|
-
'the approve path must run and refuse an approval that matches no pending request');
|
|
225
|
-
assert.equal(decisionOf(r.stdout).decision, null, 'and it must not block the prompt');
|
|
226
|
-
|
|
227
|
-
// An ordinary prompt stays silent on both counts.
|
|
228
|
-
const plain = runHook(root, 'completion-authority.cjs', {
|
|
229
|
-
hookEventName: 'user_prompt_submit', sessionId: 'grok-1', cwd: root, prompt: 'please continue',
|
|
230
|
-
});
|
|
231
|
-
assert.equal(plain.stdout.trim(), '', 'an ordinary prompt produces nothing');
|
|
232
|
-
}, { git: true });
|
|
233
|
-
});
|
|
234
|
-
|
|
235
179
|
test('the secret guardrail sees a grok prompt', () => {
|
|
236
180
|
withInstall((root) => {
|
|
237
181
|
// grok's spelling for the prompt field is unverified, so the reader accepts the Claude
|
|
@@ -49,8 +49,8 @@ test('read_file path becomes file_path', () => {
|
|
|
49
49
|
toolInput: { path: '/p/.env' },
|
|
50
50
|
});
|
|
51
51
|
assert.equal(out.tool_name, 'Read');
|
|
52
|
-
// privacy-block
|
|
53
|
-
//
|
|
52
|
+
// privacy-block and inspect-block both read file_path first, so the normalized key
|
|
53
|
+
// must be file_path.
|
|
54
54
|
assert.equal(out.tool_input.file_path, '/p/.env');
|
|
55
55
|
assert.equal(out.tool_input.path, '/p/.env', 'the original key stays for hooks that read it');
|
|
56
56
|
});
|
|
@@ -83,7 +83,6 @@ test('Stop and subagent fields map', () => {
|
|
|
83
83
|
subagentId: 'agent-7',
|
|
84
84
|
lastAssistantMessage: 'REVIEW: PASS',
|
|
85
85
|
});
|
|
86
|
-
// semantic-review-authority refuses to record an attestation without agent_id.
|
|
87
86
|
assert.equal(sub.hook_event_name, 'SubagentStop');
|
|
88
87
|
assert.equal(sub.agent_type, 'code-auditor');
|
|
89
88
|
assert.equal(sub.agent_id, 'agent-7');
|
|
@@ -102,9 +101,8 @@ test('every name reaching a Bash matcher becomes Bash', () => {
|
|
|
102
101
|
});
|
|
103
102
|
|
|
104
103
|
test('write and search_replace map to different Claude tools', () => {
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// reject the Edit-fill step the guard's own message tells the model to run.
|
|
104
|
+
// Grok creates files with `write` and edits them with `search_replace`; they must stay
|
|
105
|
+
// distinct Claude tools so a hook matcher on Write or on Edit sees the event it names.
|
|
108
106
|
assert.equal(normalizeToolName('write'), 'Write');
|
|
109
107
|
assert.equal(normalizeToolName('delete_file'), 'Write');
|
|
110
108
|
assert.equal(normalizeToolName('search_replace'), 'Edit');
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Stop gates and the session hooks must not depend on the completion-authority hook files,
|
|
4
|
+
// so those files can be deleted without blocking every Stop or crashing SessionStart. Each case
|
|
5
|
+
// runs against a real install (bin/install.js), with and without the five legacy files.
|
|
6
|
+
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const { spawnSync } = require('node:child_process');
|
|
12
|
+
const { test } = require('node:test');
|
|
13
|
+
|
|
14
|
+
const INSTALLER = path.join(__dirname, '..', 'install.js');
|
|
15
|
+
const LEGACY_FILES = [
|
|
16
|
+
'completion-authority.cjs',
|
|
17
|
+
'completion-authority-check.cjs',
|
|
18
|
+
'completion-authority-state.cjs',
|
|
19
|
+
'semantic-review-authority.cjs',
|
|
20
|
+
'task-scaffold-guard.cjs',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function inTempProject(run) {
|
|
24
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-decoupled-')));
|
|
25
|
+
try {
|
|
26
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'test@example.invalid'], ['config', 'user.name', 'Test'], ['commit', '--allow-empty', '-qm', 'base']]) {
|
|
27
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
28
|
+
assert.equal(result.status, 0, result.stderr);
|
|
29
|
+
}
|
|
30
|
+
const installed = spawnSync(process.execPath, [INSTALLER, '--platform', 'claude,codex', '--yes'],
|
|
31
|
+
{ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
|
32
|
+
assert.equal(installed.status, 0, `${installed.stdout}\n${installed.stderr}`);
|
|
33
|
+
return run(root);
|
|
34
|
+
} finally {
|
|
35
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function removeLegacyFiles(root) {
|
|
40
|
+
for (const runtime of ['.claude', '.codex']) {
|
|
41
|
+
for (const file of LEGACY_FILES) {
|
|
42
|
+
const target = path.join(root, runtime, 'hooks', file);
|
|
43
|
+
fs.rmSync(target, { force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runHook(root, kind, script, event, extra = {}) {
|
|
49
|
+
const hook = path.join(root, kind === 'claude' ? '.claude' : '.codex', 'hooks', script);
|
|
50
|
+
const result = spawnSync(process.execPath, [hook], {
|
|
51
|
+
cwd: root,
|
|
52
|
+
input: JSON.stringify({ hook_event_name: event, session_id: 's', stop_hook_active: false, cwd: root, ...extra }),
|
|
53
|
+
env: { ...process.env, PATH: '/usr/bin:/bin', ...(kind === 'claude' ? { CLAUDE_PROJECT_DIR: root } : {}) },
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
});
|
|
56
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
57
|
+
return result.stdout.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stopGate(root, kind) {
|
|
61
|
+
return runHook(root, kind, 'spec-gate.cjs', 'Stop');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isBlocked(stdout) {
|
|
65
|
+
return stdout !== '' && JSON.parse(stdout.split('\n').pop()).decision === 'block';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function blockReason(stdout) {
|
|
69
|
+
assert.equal(isBlocked(stdout), true, `expected a block, got: ${stdout}`);
|
|
70
|
+
return JSON.parse(stdout.split('\n').pop()).reason;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function provenance(root, taskFile) {
|
|
74
|
+
const result = spawnSync(process.execPath, [
|
|
75
|
+
path.join(root, '.claude', 'scripts', 'provenance.cjs'),
|
|
76
|
+
'--project-root', root, '--specs-root', 'specs', '--spec-file', taskFile,
|
|
77
|
+
'--feature-name', 'demo', '--session', 's', '--json',
|
|
78
|
+
], { cwd: root, encoding: 'utf8' });
|
|
79
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
80
|
+
const parsed = JSON.parse(result.stdout);
|
|
81
|
+
return { base: parsed.Base, head: parsed.Head };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeProcessFirst(root, { receipt }) {
|
|
85
|
+
const featureDir = path.join(root, 'specs', 'demo');
|
|
86
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
88
|
+
const taskFile = path.join(featureDir, 'task-01-demo.md');
|
|
89
|
+
const body = ['# Task 01: demo', '', 'Status: done', '', '## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: node --test', ''];
|
|
90
|
+
fs.writeFileSync(taskFile, body.join('\n'));
|
|
91
|
+
if (!receipt) return;
|
|
92
|
+
const { base, head } = provenance(root, taskFile);
|
|
93
|
+
fs.writeFileSync(taskFile, [...body, '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
94
|
+
`Base: ${base}`, `Head: ${head}`, '```text', '$ node --test', 'pass: 1', '```', ''].join('\n'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A 2.1 packet keeps its machine state in spec.json and its tasks in a nested tasks/ directory.
|
|
98
|
+
function writeLegacy(root, { status, doneTask = false }) {
|
|
99
|
+
const featureDir = path.join(root, 'specs', 'legacy');
|
|
100
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
101
|
+
const task = 'tasks/task-R1-01-legacy.md';
|
|
102
|
+
const spec = { schema_version: '2.1', feature_name: 'legacy', status, current_phase: 'implementation', task_registry: {} };
|
|
103
|
+
if (doneTask) spec.task_registry[task] = { status: 'done', completed_at: '2026-08-13T00:00:00.000Z', dependencies: [] };
|
|
104
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
|
|
105
|
+
if (doneTask) {
|
|
106
|
+
fs.writeFileSync(path.join(featureDir, task), ['# Task R1-01: legacy', '**Status:** done', '## Outcome', 'Legacy work.',
|
|
107
|
+
'## Verification Plan', '- **Command:** `node --test`', '- **Expected:** passes', ''].join('\n'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function checkProcessFirst(kind) {
|
|
112
|
+
inTempProject((root) => {
|
|
113
|
+
writeProcessFirst(root, { receipt: true });
|
|
114
|
+
assert.equal(stopGate(root, kind), '', 'valid receipt passes with the legacy files');
|
|
115
|
+
writeProcessFirst(root, { receipt: false });
|
|
116
|
+
assert.match(blockReason(stopGate(root, kind)), /missing_receipt/, 'no receipt blocks with the legacy files');
|
|
117
|
+
removeLegacyFiles(root);
|
|
118
|
+
assert.match(blockReason(stopGate(root, kind)), /missing_receipt/, 'no receipt blocks without the legacy files');
|
|
119
|
+
writeProcessFirst(root, { receipt: true });
|
|
120
|
+
assert.equal(stopGate(root, kind), '', 'valid receipt passes without the legacy files');
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// CafeKit no longer reads spec.json packets: a leftover one, closed or mid-execution, never
|
|
125
|
+
// blocks Stop on its own, and beside a process-first packet the decision is that packet's alone.
|
|
126
|
+
function checkLegacyIgnored(kind) {
|
|
127
|
+
for (const legacy of [{ status: 'done' }, { status: 'completed' }, { status: 'in_progress', doneTask: true }]) {
|
|
128
|
+
inTempProject((root) => {
|
|
129
|
+
writeLegacy(root, legacy);
|
|
130
|
+
removeLegacyFiles(root);
|
|
131
|
+
assert.equal(stopGate(root, kind), '', `${kind} ${legacy.status} legacy packet alone`);
|
|
132
|
+
writeProcessFirst(root, { receipt: false });
|
|
133
|
+
const reason = blockReason(stopGate(root, kind));
|
|
134
|
+
assert.match(reason, /missing_receipt/);
|
|
135
|
+
assert.doesNotMatch(reason, /task-R1-01-legacy\.md/);
|
|
136
|
+
writeProcessFirst(root, { receipt: true });
|
|
137
|
+
assert.equal(stopGate(root, kind), '', `${kind} ${legacy.status} legacy packet beside a proven process-first packet`);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkSession(kind) {
|
|
143
|
+
inTempProject((root) => {
|
|
144
|
+
removeLegacyFiles(root);
|
|
145
|
+
runHook(root, kind, 'session.cjs', 'SessionStart', { source: 'startup' });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test('claude gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('claude'));
|
|
150
|
+
test('codex gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('codex'));
|
|
151
|
+
test('claude gate ignores a leftover legacy packet', () => checkLegacyIgnored('claude'));
|
|
152
|
+
test('codex gate ignores a leftover legacy packet', () => checkLegacyIgnored('codex'));
|
|
153
|
+
test('claude session starts without the legacy files', () => checkSession('claude'));
|
|
154
|
+
test('codex session starts without the legacy files', () => checkSession('codex'));
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The five spec.json-era hooks (completion authority, its check and state modules, the semantic
|
|
4
|
+
// review authority and the scaffold guard) are gone from every runtime. Each case pins one way
|
|
5
|
+
// they could survive: a fresh install, an upgrade on Claude (settings prune matches by substring,
|
|
6
|
+
// so a user's own hook with the same file name must stay), an upgrade on Codex (whose tracker
|
|
7
|
+
// keys are `.codex/…` and whose handlers come in three shapes), an upgrade on omp, an omp bridge
|
|
8
|
+
// that still lists the removed gates, and the source lists themselves.
|
|
9
|
+
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { spawnSync } = require('node:child_process');
|
|
16
|
+
const { test } = require('node:test');
|
|
17
|
+
|
|
18
|
+
const PACKAGE_ROOT = path.join(__dirname, '..', '..');
|
|
19
|
+
const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
|
|
20
|
+
const SRC = path.join(PACKAGE_ROOT, 'src');
|
|
21
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
|
+
const MANIFEST = readJson(path.join(SRC, 'claude/migration-manifest.json'));
|
|
23
|
+
const SOURCE_SETTINGS = readJson(path.join(SRC, 'claude/settings/settings.json'));
|
|
24
|
+
const SOURCE_BRIDGE_PATH = path.join(SRC, 'omp/extensions/cafekit-bridge.mjs');
|
|
25
|
+
|
|
26
|
+
const NAMES = [
|
|
27
|
+
'completion-authority.cjs',
|
|
28
|
+
'completion-authority-check.cjs',
|
|
29
|
+
'completion-authority-state.cjs',
|
|
30
|
+
'semantic-review-authority.cjs',
|
|
31
|
+
'task-scaffold-guard.cjs',
|
|
32
|
+
];
|
|
33
|
+
const REGISTERED = ['completion-authority.cjs', 'semantic-review-authority.cjs', 'task-scaffold-guard.cjs'];
|
|
34
|
+
const FOREIGN = REGISTERED.map((name) => `node ~/my-hooks/${name}`);
|
|
35
|
+
|
|
36
|
+
function inTempProject(run) {
|
|
37
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-legacy-retired-'));
|
|
38
|
+
try {
|
|
39
|
+
const init = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
|
|
40
|
+
assert.equal(init.status, 0, init.stderr);
|
|
41
|
+
return run(root);
|
|
42
|
+
} finally {
|
|
43
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function install(root, platforms) {
|
|
48
|
+
const result = spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes'],
|
|
49
|
+
{ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
|
|
50
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function commands(settings) {
|
|
54
|
+
return Object.values(settings.hooks || {})
|
|
55
|
+
.flat()
|
|
56
|
+
.flatMap((group) => (group.hooks || []).map((hook) => hook.command));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mentionsName(text) {
|
|
60
|
+
return NAMES.filter((name) => text.includes(name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('fresh installs ship and register none of the five legacy hooks', () => {
|
|
64
|
+
inTempProject((root) => {
|
|
65
|
+
install(root, ['claude', 'codex', 'omp']);
|
|
66
|
+
for (const dir of ['.claude', '.codex', '.omp']) {
|
|
67
|
+
for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, dir, 'hooks', name)), false, `${dir}/hooks/${name}`);
|
|
68
|
+
}
|
|
69
|
+
const claude = commands(readJson(path.join(root, '.claude', 'settings.json')));
|
|
70
|
+
const codex = commands(readJson(path.join(root, '.codex', 'hooks.json')));
|
|
71
|
+
for (const command of [...claude, ...codex]) assert.deepEqual(mentionsName(command), [], command);
|
|
72
|
+
const bridge = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs'), 'utf8');
|
|
73
|
+
assert.deepEqual(mentionsName(bridge), []);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('claude upgrade removes the legacy hooks and their CafeKit entries but keeps foreign ones', () => {
|
|
78
|
+
inTempProject((root) => {
|
|
79
|
+
install(root, ['claude']);
|
|
80
|
+
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
81
|
+
const manifestPath = path.join(root, '.claude', 'cafekit-manifest.json');
|
|
82
|
+
const settings = readJson(settingsPath);
|
|
83
|
+
const kept = commands(settings);
|
|
84
|
+
|
|
85
|
+
const manifest = readJson(manifestPath);
|
|
86
|
+
for (const name of NAMES) {
|
|
87
|
+
fs.writeFileSync(path.join(root, '.claude', 'hooks', name), '// user edit\n');
|
|
88
|
+
manifest.files[`hooks/${name}`] = { sha256: '0'.repeat(64) };
|
|
89
|
+
}
|
|
90
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
91
|
+
|
|
92
|
+
const cmd = (name) => `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}"`;
|
|
93
|
+
settings.hooks.UserPromptSubmit[0].hooks.push(
|
|
94
|
+
{ type: 'command', command: cmd('completion-authority.cjs') },
|
|
95
|
+
...FOREIGN.map((command) => ({ type: 'command', command })),
|
|
96
|
+
);
|
|
97
|
+
settings.hooks.Stop[0].hooks.push({ type: 'command', command: cmd('completion-authority.cjs') });
|
|
98
|
+
settings.hooks.PreToolUse.push({ matcher: 'Write', hooks: [{ type: 'command', command: cmd('task-scaffold-guard.cjs') }] });
|
|
99
|
+
settings.hooks.SubagentStop[0].hooks.push({ type: 'command', command: cmd('semantic-review-authority.cjs') });
|
|
100
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
101
|
+
|
|
102
|
+
install(root, ['claude']);
|
|
103
|
+
for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, '.claude', 'hooks', name)), false, name);
|
|
104
|
+
const after = readJson(manifestPath);
|
|
105
|
+
for (const name of NAMES) assert.equal(Object.prototype.hasOwnProperty.call(after.files, `hooks/${name}`), false, `record ${name}`);
|
|
106
|
+
const afterCommands = commands(readJson(settingsPath));
|
|
107
|
+
for (const name of REGISTERED) {
|
|
108
|
+
assert.equal(afterCommands.some((command) => command.includes(`.claude/hooks/${name}`)), false, name);
|
|
109
|
+
}
|
|
110
|
+
for (const command of FOREIGN) assert.ok(afterCommands.includes(command), `foreign: ${command}`);
|
|
111
|
+
for (const command of kept) assert.ok(afterCommands.includes(command), `kept: ${command}`);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('codex upgrade removes the legacy hooks, their records and their CafeKit entries but keeps foreign ones', () => {
|
|
116
|
+
inTempProject((root) => {
|
|
117
|
+
install(root, ['codex']);
|
|
118
|
+
const hooksPath = path.join(root, '.codex', 'hooks.json');
|
|
119
|
+
const manifestPath = path.join(root, '.codex', 'cafekit-manifest.json');
|
|
120
|
+
const config = readJson(hooksPath);
|
|
121
|
+
const kept = commands(config);
|
|
122
|
+
|
|
123
|
+
// A record whose file is already gone must leave the manifest too.
|
|
124
|
+
const manifest = readJson(manifestPath);
|
|
125
|
+
for (const name of NAMES) {
|
|
126
|
+
if (name !== 'completion-authority-state.cjs') fs.writeFileSync(path.join(root, '.codex', 'hooks', name), '// user edit\n');
|
|
127
|
+
manifest.files[`.codex/hooks/${name}`] = { sha256: '0'.repeat(64) };
|
|
128
|
+
}
|
|
129
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
130
|
+
|
|
131
|
+
const posix = kept.find((command) => command.includes('spec-gate.cjs'));
|
|
132
|
+
assert.ok(posix, 'the installed spec-gate launcher is the template for the POSIX form');
|
|
133
|
+
const add = (event, command) => config.hooks[event].push({ matcher: '*', hooks: [{ type: 'command', command }] });
|
|
134
|
+
add('Stop', posix.split('spec-gate.cjs').join('completion-authority.cjs'));
|
|
135
|
+
add('PreToolUse', 'node ".codex/hooks/task-scaffold-guard.cjs"');
|
|
136
|
+
add('SubagentStop', "node 'C:\\work\\proj\\.codex\\hooks\\semantic-review-authority.cjs'");
|
|
137
|
+
add('UserPromptSubmit', FOREIGN[0]);
|
|
138
|
+
fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2));
|
|
139
|
+
|
|
140
|
+
install(root, ['codex']);
|
|
141
|
+
for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks', name)), false, name);
|
|
142
|
+
const after = readJson(manifestPath);
|
|
143
|
+
for (const name of NAMES) assert.equal(Object.prototype.hasOwnProperty.call(after.files, `.codex/hooks/${name}`), false, `record ${name}`);
|
|
144
|
+
const afterCommands = commands(readJson(hooksPath));
|
|
145
|
+
for (const name of REGISTERED) {
|
|
146
|
+
assert.equal(afterCommands.some((command) => command.includes(`.codex/hooks/${name}`) || command.includes(`.codex\\hooks\\${name}`)), false, name);
|
|
147
|
+
}
|
|
148
|
+
assert.ok(afterCommands.includes(FOREIGN[0]), 'a foreign hook with a legacy name survives');
|
|
149
|
+
for (const command of kept) assert.ok(afterCommands.includes(command), `kept: ${command}`);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('omp upgrade removes the legacy hooks and their records', () => {
|
|
154
|
+
inTempProject((root) => {
|
|
155
|
+
install(root, ['omp']);
|
|
156
|
+
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
157
|
+
const manifest = readJson(manifestPath);
|
|
158
|
+
// omp records keys relative to the project root (recordRoot '.').
|
|
159
|
+
for (const name of NAMES) {
|
|
160
|
+
fs.writeFileSync(path.join(root, '.omp', 'hooks', name), '// shipped by an older CafeKit\n');
|
|
161
|
+
manifest.files[`.omp/hooks/${name}`] = { sha256: '0'.repeat(64) };
|
|
162
|
+
}
|
|
163
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
164
|
+
|
|
165
|
+
install(root, ['omp']);
|
|
166
|
+
const after = readJson(manifestPath);
|
|
167
|
+
for (const name of NAMES) {
|
|
168
|
+
assert.equal(fs.existsSync(path.join(root, '.omp', 'hooks', name)), false, name);
|
|
169
|
+
assert.equal(Object.prototype.hasOwnProperty.call(after.files, `.omp/hooks/${name}`), false, `record ${name}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('omp upgrade replaces a pristine old bridge that still lists the removed gates', () => {
|
|
175
|
+
inTempProject((root) => {
|
|
176
|
+
install(root, ['omp']);
|
|
177
|
+
const bridgePath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
|
|
178
|
+
const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
|
|
179
|
+
const old = fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8')
|
|
180
|
+
.replace("'inspect-block.cjs']", "'inspect-block.cjs', 'task-scaffold-guard.cjs']")
|
|
181
|
+
.replace("'rules.cjs', 'spec-state.cjs'", "'rules.cjs', 'completion-authority.cjs', 'spec-state.cjs'")
|
|
182
|
+
.replace("'spec-gate.cjs', 'state.cjs'", "'spec-gate.cjs', 'completion-authority.cjs', 'state.cjs'");
|
|
183
|
+
assert.deepEqual(mentionsName(old).sort(), ['completion-authority.cjs', 'task-scaffold-guard.cjs'], 'the fixture lists the removed gates');
|
|
184
|
+
fs.writeFileSync(bridgePath, old);
|
|
185
|
+
const manifest = readJson(manifestPath);
|
|
186
|
+
manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: crypto.createHash('sha256').update(old).digest('hex') };
|
|
187
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
188
|
+
|
|
189
|
+
install(root, ['omp']);
|
|
190
|
+
const installed = fs.readFileSync(bridgePath, 'utf8');
|
|
191
|
+
assert.deepEqual(mentionsName(installed), []);
|
|
192
|
+
assert.equal(installed, fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8'));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('no source ships, registers or bridges the five legacy hooks', () => {
|
|
197
|
+
for (const name of NAMES) {
|
|
198
|
+
assert.equal(fs.existsSync(path.join(SRC, 'claude/hooks', name)), false, `claude ${name}`);
|
|
199
|
+
assert.equal(fs.existsSync(path.join(SRC, 'codex/hooks', name)), false, `codex ${name}`);
|
|
200
|
+
}
|
|
201
|
+
for (const test of ['completion-authority.test.js', 'semantic-review-authority.test.js']) {
|
|
202
|
+
assert.equal(fs.existsSync(path.join(SRC, 'claude/hooks/__tests__', test)), false, test);
|
|
203
|
+
}
|
|
204
|
+
const runtimeFiles = MANIFEST.runtime.files.join('\n');
|
|
205
|
+
assert.deepEqual(mentionsName(runtimeFiles), []);
|
|
206
|
+
assert.deepEqual(mentionsName(JSON.stringify(SOURCE_SETTINGS)), []);
|
|
207
|
+
assert.deepEqual(mentionsName(fs.readFileSync(path.join(SRC, 'codex/hooks.json'), 'utf8')), []);
|
|
208
|
+
assert.deepEqual(mentionsName(fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8')), []);
|
|
209
|
+
for (const name of NAMES) assert.ok(MANIFEST.obsolete.runtimeFiles.includes(`hooks/${name}`), name);
|
|
210
|
+
const substrings = MANIFEST.obsolete.settingsHookCommandSubstrings;
|
|
211
|
+
assert.equal(substrings[0], 'hooks/skill-router.cjs');
|
|
212
|
+
for (const name of REGISTERED) {
|
|
213
|
+
for (const entry of [`.claude/hooks/${name}`, `.codex/hooks/${name}`, `.codex\\hooks\\${name}`]) {
|
|
214
|
+
assert.ok(substrings.includes(entry), entry);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// The installer's merge drops an empty group, so only the source can show one.
|
|
218
|
+
for (const [event, groups] of Object.entries(SOURCE_SETTINGS.hooks)) {
|
|
219
|
+
for (const group of groups) assert.ok(group.hooks.length > 0, `${event} ${group.matcher} has no hooks`);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
@@ -17,7 +17,6 @@ const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
|
17
17
|
const BRIDGE = path.join(PACKAGE_ROOT, 'src/omp/extensions/cafekit-bridge.mjs');
|
|
18
18
|
const SETTINGS = require(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'));
|
|
19
19
|
const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'));
|
|
20
|
-
const { PREFIX } = require(path.join(PACKAGE_ROOT, 'src/claude/hooks/completion-authority-state.cjs'));
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* The hooks an omp install runs: the Claude set the manifest ships, then `src/omp/hooks/`
|
|
@@ -50,6 +49,11 @@ function registered(eventName) {
|
|
|
50
49
|
.filter(Boolean);
|
|
51
50
|
}
|
|
52
51
|
|
|
52
|
+
// Deliberate, named divergences while omp is not yet updated. Each must still be a real
|
|
53
|
+
// difference, so the test fails (and the entry must go) once omp catches up.
|
|
54
|
+
const CLAUDE_ONLY = { PostToolUse: ['spec-state.cjs'] };
|
|
55
|
+
const OMP_ONLY = { SessionStart: ['docs-sync.cjs'] };
|
|
56
|
+
|
|
53
57
|
test('the dispatch table mirrors settings.json for every event omp can deliver', () => {
|
|
54
58
|
const { DISPATCH } = bridge;
|
|
55
59
|
const pairs = [
|
|
@@ -58,8 +62,18 @@ test('the dispatch table mirrors settings.json for every event omp can deliver',
|
|
|
58
62
|
['tool_result', 'PostToolUse'], ['session_stop', 'Stop'],
|
|
59
63
|
];
|
|
60
64
|
for (const [omp, claude] of pairs) {
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
const claudeOnly = CLAUDE_ONLY[claude] || [];
|
|
66
|
+
const ompOnly = OMP_ONLY[claude] || [];
|
|
67
|
+
const claudeHooks = [...new Set(registered(claude))];
|
|
68
|
+
for (const hook of claudeOnly) {
|
|
69
|
+
assert.ok(claudeHooks.includes(hook) && !DISPATCH[omp].hooks.includes(hook), `${hook} is no longer Claude-only for ${claude}; drop it from CLAUDE_ONLY`);
|
|
70
|
+
}
|
|
71
|
+
for (const hook of ompOnly) {
|
|
72
|
+
assert.ok(!claudeHooks.includes(hook) && DISPATCH[omp].hooks.includes(hook), `${hook} is no longer omp-only for ${claude}; drop it from OMP_ONLY`);
|
|
73
|
+
}
|
|
74
|
+
const expected = [...claudeHooks.filter((hook) => !claudeOnly.includes(hook)), ...ompOnly].sort();
|
|
75
|
+
assert.deepEqual([...DISPATCH[omp].hooks].sort(), expected,
|
|
76
|
+
`${omp} must dispatch the hooks settings.json registers for ${claude}, minus CLAUDE_ONLY, plus OMP_ONLY`);
|
|
63
77
|
}
|
|
64
78
|
// omp has no subagent events; the gap is deliberate and documented, not forgotten.
|
|
65
79
|
assert.ok(!Object.values(DISPATCH).some((e) => e.event.startsWith('Subagent')));
|
|
@@ -83,7 +97,7 @@ test('each denial mechanism becomes an omp block carrying the hook reason', () =
|
|
|
83
97
|
assert.deepEqual([stop.block, stop.reason], [true, 'R1']);
|
|
84
98
|
const deny = interpretVerdict({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'R2' } }) }, 'privacy-block.cjs');
|
|
85
99
|
assert.deepEqual([deny.block, deny.reason], [true, 'R2']);
|
|
86
|
-
// inspect-block
|
|
100
|
+
// inspect-block exits 2 and writes the reason on STDOUT, not stderr.
|
|
87
101
|
const exit2 = interpretVerdict({ code: 2, stdout: 'SCOPE LIMIT EXCEEDED\nRestricted zones: node_modules', stderr: '' }, 'inspect-block.cjs');
|
|
88
102
|
assert.equal(exit2.block, true);
|
|
89
103
|
assert.match(exit2.reason, /SCOPE LIMIT EXCEEDED/, 'a stderr-only reader would drop this reason');
|
|
@@ -127,10 +141,10 @@ test('a slow hook blocks with its own name before omp cuts it off', async () =>
|
|
|
127
141
|
test('a crashing or missing gate blocks on gating events instead of silently allowing', async () => {
|
|
128
142
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-crash-'));
|
|
129
143
|
try {
|
|
130
|
-
// Only the crashing gate is under test; the other
|
|
144
|
+
// Only the crashing gate is under test; the other PreToolUse gate gets an allow stub,
|
|
131
145
|
// otherwise their absence is what blocks (see the missing-gate case below).
|
|
132
146
|
fs.writeFileSync(path.join(dir, 'privacy-block.cjs'), 'throw new Error("boom");');
|
|
133
|
-
|
|
147
|
+
fs.writeFileSync(path.join(dir, 'inspect-block.cjs'), 'process.exit(0);');
|
|
134
148
|
const crash = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: dir }, { dir });
|
|
135
149
|
// A bare exit 1 with no verdict is an allow under the hooks' own contract. Real gates
|
|
136
150
|
// never surface a crash this way: the fork's privacy-block catch-all converts its own
|
|
@@ -147,17 +161,6 @@ test('stop_hook_active short-circuits the gate so a blocked turn cannot loop for
|
|
|
147
161
|
assert.deepEqual([r.block, r.skipped], [false, 'stop_hook_active']);
|
|
148
162
|
});
|
|
149
163
|
|
|
150
|
-
test('the approval phrase reaches completion-authority through the input event', async () => {
|
|
151
|
-
const { DISPATCH, shapePayload } = bridge;
|
|
152
|
-
assert.ok(DISPATCH.input.hooks.includes('completion-authority.cjs'),
|
|
153
|
-
'the --approve path only runs on UserPromptSubmit; without this routing a blocked turn has no exit');
|
|
154
|
-
const phrase = `${PREFIX}${'a'.repeat(24)}`;
|
|
155
|
-
const p = shapePayload('input', { text: phrase, source: 'user' }, { cwd: '/tmp/p' });
|
|
156
|
-
assert.equal(p.hook_event_name, 'UserPromptSubmit', 'completion-authority selects --approve from this field');
|
|
157
|
-
assert.equal(p.prompt, phrase, 'the phrase must arrive byte-exact; the hook compares it exactly');
|
|
158
|
-
assert.ok(p.session_id, 'approve() returns early without a session id');
|
|
159
|
-
});
|
|
160
|
-
|
|
161
164
|
test('input injections flow back as a transform that keeps the user text intact', async () => {
|
|
162
165
|
const root = tempProject(false);
|
|
163
166
|
try {
|