@haposoft/cafekit 0.18.1 → 0.18.3
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 +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, before, after } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
|
|
11
|
+
const BUILD = path.resolve(__dirname, '../../../../scripts/build-claude-plugin.mjs');
|
|
12
|
+
let work;
|
|
13
|
+
let tree;
|
|
14
|
+
|
|
15
|
+
before(() => {
|
|
16
|
+
work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-statusline-setup-'));
|
|
17
|
+
tree = path.join(work, "plug'in", 'cf');
|
|
18
|
+
const built = spawnSync('node', [BUILD, '--out', tree], { encoding: 'utf8' });
|
|
19
|
+
assert.equal(built.status, 0, built.stderr);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
after(() => fs.rmSync(work, { recursive: true, force: true }));
|
|
23
|
+
|
|
24
|
+
const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
25
|
+
|
|
26
|
+
// A private HOME per case: these tests must never touch the real ~/.claude.
|
|
27
|
+
function home() {
|
|
28
|
+
const dir = fs.mkdtempSync(path.join(work, 'home-'));
|
|
29
|
+
fs.mkdirSync(path.join(dir, '.claude'));
|
|
30
|
+
return { dir, data: path.join(dir, 'plugin-data') };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sessionStart(h, root = tree) {
|
|
34
|
+
const run = spawnSync('node', [path.join(root, 'hooks/plugin-context.cjs')], {
|
|
35
|
+
input: '{}', encoding: 'utf8',
|
|
36
|
+
env: { ...process.env, HOME: h.dir, CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: h.data },
|
|
37
|
+
});
|
|
38
|
+
assert.equal(run.status, 0, run.stderr);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setup(h, ...args) {
|
|
42
|
+
return spawnSync('node', [path.join(tree, 'scripts/setup-statusline.cjs'), ...args], {
|
|
43
|
+
encoding: 'utf8', env: { ...process.env, HOME: h.dir },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const launcher = (h) => path.join(h.data, 'status.cjs');
|
|
48
|
+
const settingsFile = (h) => path.join(h.dir, '.claude/settings.json');
|
|
49
|
+
|
|
50
|
+
test('launcher-written: SessionStart writes a launcher and a data pointer that run the statusline', () => {
|
|
51
|
+
const h = home();
|
|
52
|
+
sessionStart(h);
|
|
53
|
+
assert.equal(fs.readFileSync(path.join(h.dir, '.claude/cafekit-plugin-data'), 'utf8').trim(), h.data);
|
|
54
|
+
const run = spawnSync('node', [launcher(h)], { input: JSON.stringify({ model: { display_name: 'Test' }, workspace: { current_dir: work } }), encoding: 'utf8' });
|
|
55
|
+
assert.equal(run.status, 0, run.stderr);
|
|
56
|
+
assert.notEqual(run.stdout.trim(), '');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('quote-in-path: a plugin root containing a quote still loads', () => {
|
|
60
|
+
assert.ok(tree.includes("'"));
|
|
61
|
+
const h = home();
|
|
62
|
+
sessionStart(h);
|
|
63
|
+
assert.equal(spawnSync('node', ['--check', launcher(h)]).status, 0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('launcher-missing-root-silent: a removed plugin version makes the launcher exit 0 silently', () => {
|
|
67
|
+
const h = home();
|
|
68
|
+
const gone = path.join(work, 'gone', 'cf');
|
|
69
|
+
spawnSync('node', [BUILD, '--out', gone]);
|
|
70
|
+
sessionStart(h, gone);
|
|
71
|
+
fs.rmSync(path.dirname(gone), { recursive: true, force: true });
|
|
72
|
+
const run = spawnSync('node', [launcher(h)], { input: '{}', encoding: 'utf8' });
|
|
73
|
+
assert.equal(run.status, 0);
|
|
74
|
+
assert.equal(run.stdout, '');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('set-when-absent: setup adds statusLine and keeps every other key', () => {
|
|
78
|
+
const h = home();
|
|
79
|
+
sessionStart(h);
|
|
80
|
+
fs.writeFileSync(settingsFile(h), JSON.stringify({ theme: 'dark', permissions: { allow: ['Bash(ls:*)'] } }, null, 2));
|
|
81
|
+
const run = setup(h);
|
|
82
|
+
assert.equal(run.status, 0, run.stderr);
|
|
83
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile(h), 'utf8'));
|
|
84
|
+
assert.equal(settings.theme, 'dark');
|
|
85
|
+
assert.deepEqual(settings.permissions, { allow: ['Bash(ls:*)'] });
|
|
86
|
+
assert.equal(settings.statusLine.type, 'command');
|
|
87
|
+
assert.ok(settings.statusLine.command.includes(launcher(h)));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('replace-own-launcher: setup refreshes a statusLine that already points to the launcher', () => {
|
|
91
|
+
const h = home();
|
|
92
|
+
sessionStart(h);
|
|
93
|
+
fs.writeFileSync(settingsFile(h), JSON.stringify({ statusLine: { type: 'command', command: `node ${launcher(h)}`, padding: 4 } }));
|
|
94
|
+
assert.equal(setup(h).status, 0);
|
|
95
|
+
const { statusLine } = JSON.parse(fs.readFileSync(settingsFile(h), 'utf8'));
|
|
96
|
+
assert.equal(statusLine.command, `node ${JSON.stringify(launcher(h))}`);
|
|
97
|
+
assert.equal(statusLine.padding, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('keep-extended-launcher-command: a user command that wraps the launcher is not replaced', () => {
|
|
101
|
+
const h = home();
|
|
102
|
+
sessionStart(h);
|
|
103
|
+
for (const statusLine of [
|
|
104
|
+
{ type: 'command', command: `node "${launcher(h)}" | tee /tmp/status.log` },
|
|
105
|
+
{ type: 'command' },
|
|
106
|
+
'not-an-object',
|
|
107
|
+
]) {
|
|
108
|
+
fs.writeFileSync(settingsFile(h), JSON.stringify({ statusLine }));
|
|
109
|
+
const before = sha(settingsFile(h));
|
|
110
|
+
assert.equal(setup(h).status, 0);
|
|
111
|
+
assert.equal(sha(settingsFile(h)), before, `replaced ${JSON.stringify(statusLine)}`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('mode-preserved: a 0600 settings file stays 0600 after setup', () => {
|
|
116
|
+
const h = home();
|
|
117
|
+
sessionStart(h);
|
|
118
|
+
fs.writeFileSync(settingsFile(h), JSON.stringify({ env: { EXAMPLE_KEY: 'placeholder' } }), { mode: 0o600 });
|
|
119
|
+
fs.chmodSync(settingsFile(h), 0o600);
|
|
120
|
+
assert.equal(setup(h).status, 0);
|
|
121
|
+
assert.equal(fs.statSync(settingsFile(h)).mode & 0o777, 0o600);
|
|
122
|
+
assert.ok(JSON.parse(fs.readFileSync(settingsFile(h), 'utf8')).statusLine);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('launcher-broken-version-reports: a present but broken statusline is not silenced', () => {
|
|
126
|
+
const h = home();
|
|
127
|
+
const broken = path.join(work, 'broken', 'cf');
|
|
128
|
+
spawnSync('node', [BUILD, '--out', broken]);
|
|
129
|
+
sessionStart(h, broken);
|
|
130
|
+
fs.rmSync(path.join(broken, 'hooks/lib/color.cjs'));
|
|
131
|
+
const run = spawnSync('node', [launcher(h)], { input: '{}', encoding: 'utf8' });
|
|
132
|
+
assert.notEqual(run.status, 0);
|
|
133
|
+
assert.match(run.stderr, /color\.cjs/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('keep-foreign-status-cjs-byte-identical: another statusLine named status.cjs is left alone', () => {
|
|
137
|
+
const h = home();
|
|
138
|
+
sessionStart(h);
|
|
139
|
+
fs.writeFileSync(settingsFile(h), JSON.stringify({ statusLine: { type: 'command', command: 'node ~/tools/status.cjs' } }));
|
|
140
|
+
const before = sha(settingsFile(h));
|
|
141
|
+
const run = setup(h);
|
|
142
|
+
assert.equal(run.status, 0);
|
|
143
|
+
assert.equal(sha(settingsFile(h)), before);
|
|
144
|
+
assert.match(run.stdout + run.stderr, /already has a statusLine/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('malformed-exit-1: unparsable settings stay byte-identical and exit 1', () => {
|
|
148
|
+
const h = home();
|
|
149
|
+
sessionStart(h);
|
|
150
|
+
fs.writeFileSync(settingsFile(h), '{ "theme": "dark", // comment\n}');
|
|
151
|
+
const before = sha(settingsFile(h));
|
|
152
|
+
const run = setup(h);
|
|
153
|
+
assert.equal(run.status, 1);
|
|
154
|
+
assert.equal(sha(settingsFile(h)), before);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('symlink-preserved: a symlinked settings file is updated through its target', () => {
|
|
158
|
+
const h = home();
|
|
159
|
+
sessionStart(h);
|
|
160
|
+
const target = path.join(h.dir, 'dotfiles-settings.json');
|
|
161
|
+
fs.writeFileSync(target, '{}');
|
|
162
|
+
fs.symlinkSync(target, settingsFile(h));
|
|
163
|
+
assert.equal(setup(h).status, 0);
|
|
164
|
+
assert.ok(fs.lstatSync(settingsFile(h)).isSymbolicLink());
|
|
165
|
+
assert.ok(JSON.parse(fs.readFileSync(target, 'utf8')).statusLine);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('missing-pointer-exit-1: setup before any plugin session writes nothing', () => {
|
|
169
|
+
const h = home();
|
|
170
|
+
fs.writeFileSync(settingsFile(h), '{}');
|
|
171
|
+
const run = setup(h);
|
|
172
|
+
assert.equal(run.status, 1);
|
|
173
|
+
assert.equal(fs.readFileSync(settingsFile(h), 'utf8'), '{}');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('command-registered: the plugin ships /cf:setup-statusline calling the script', () => {
|
|
177
|
+
const command = fs.readFileSync(path.join(tree, 'commands/setup-statusline.md'), 'utf8');
|
|
178
|
+
assert.match(command, /\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/setup-statusline\.cjs/);
|
|
179
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// a real subprocess: a Stop payload is piped to stdin; we assert exit code and
|
|
5
5
|
// whether stdout carries {"decision":"block",...}. The hook resolves its own
|
|
6
6
|
// state directory; running from source that is a temp directory rather than the
|
|
7
|
-
// source tree,
|
|
7
|
+
// source tree, so each test seeds/clears it deliberately.
|
|
8
8
|
|
|
9
9
|
const { test, beforeEach, after } = require('node:test');
|
|
10
10
|
const assert = require('node:assert');
|
|
@@ -14,6 +14,11 @@ const fs = require('node:fs');
|
|
|
14
14
|
const os = require('node:os');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
|
|
17
|
+
// Hooks run from source keep state under os.tmpdir(); a private one per test process
|
|
18
|
+
// keeps a parallel run of this file from reading or clearing this run's state.
|
|
19
|
+
const PRIVATE_TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-tmp-'));
|
|
20
|
+
for (const key of ['TMPDIR', 'TMP', 'TEMP']) process.env[key] = PRIVATE_TMP;
|
|
21
|
+
|
|
17
22
|
const HOOK = path.join(__dirname, '..', 'spec-gate.cjs');
|
|
18
23
|
const { hookStateDir } = require(path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'));
|
|
19
24
|
const CACHE = path.join(hookStateDir(), 'spec-gate-last.json');
|
|
@@ -29,9 +34,10 @@ const FIXTURE_ARTIFACT_DIGEST = crypto.createHash('sha256').update(FIXTURE_ARTIF
|
|
|
29
34
|
function runHook(payload, cwd) {
|
|
30
35
|
const projectRoot = cwd || payload.cwd;
|
|
31
36
|
const specsRoot = path.join(projectRoot, 'specs');
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
// Source hooks use payload.cwd without inherited root overrides.
|
|
38
|
+
const env = { ...process.env };
|
|
39
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
40
|
+
delete env.PROJECT_ROOT;
|
|
35
41
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
36
42
|
input: JSON.stringify({
|
|
37
43
|
hook_event_name: 'Stop',
|
|
@@ -235,6 +241,8 @@ function installClaudeGate(root, policyMode = 'valid') {
|
|
|
235
241
|
fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
|
|
236
242
|
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
237
243
|
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
|
|
244
|
+
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'project-root.cjs'), path.join(hooks, 'lib', 'project-root.cjs'));
|
|
245
|
+
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'spec-touch.cjs'), path.join(hooks, 'lib', 'spec-touch.cjs'));
|
|
238
246
|
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
|
|
239
247
|
fs.copyFileSync(
|
|
240
248
|
path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'),
|
|
@@ -288,6 +296,7 @@ beforeEach(() => {
|
|
|
288
296
|
|
|
289
297
|
after(() => {
|
|
290
298
|
clearCache();
|
|
299
|
+
fs.rmSync(PRIVATE_TMP, { recursive: true, force: true });
|
|
291
300
|
});
|
|
292
301
|
|
|
293
302
|
test('1. newly-done task WITHOUT receipt → block, reason names task path', () => {
|
|
@@ -1264,11 +1273,14 @@ function boundReceipt(base = VALID_BASE, head = VALID_HEAD) {
|
|
|
1264
1273
|
}
|
|
1265
1274
|
|
|
1266
1275
|
function runHookFrom(dir, cwd) {
|
|
1276
|
+
const env = { ...process.env };
|
|
1277
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
1278
|
+
delete env.PROJECT_ROOT;
|
|
1267
1279
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
1268
1280
|
cwd,
|
|
1269
1281
|
input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'test', transcript_path: '/tmp/t', stop_hook_active: false, cwd: dir }),
|
|
1270
1282
|
encoding: 'utf8',
|
|
1271
|
-
env
|
|
1283
|
+
env,
|
|
1272
1284
|
});
|
|
1273
1285
|
return { code: res.status, stdout: (res.stdout || '').trim(), stderr: res.stderr || '' };
|
|
1274
1286
|
}
|
|
@@ -1371,17 +1383,142 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
|
|
|
1371
1383
|
clearCache();
|
|
1372
1384
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1373
1385
|
assert.ok(body && body.decision === 'block', 'an unborn HEAD must block');
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
|
|
1377
|
-
// not the binding-mode selector. The selector's own HEAD-unreadable branch
|
|
1378
|
-
// is guarded by cases 38 and 39.
|
|
1379
|
-
assert.match(body.reason, /controlled failure/);
|
|
1386
|
+
// A done task still blocks before receipt validation, with guidance to
|
|
1387
|
+
// create the first commit before recording its Base/Head.
|
|
1388
|
+
assert.match(body.reason, /first commit/);
|
|
1380
1389
|
} finally {
|
|
1381
1390
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1382
1391
|
}
|
|
1383
1392
|
});
|
|
1384
1393
|
|
|
1394
|
+
function noHistoryFixture() {
|
|
1395
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-no-history-'));
|
|
1396
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
|
|
1397
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
1398
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1399
|
+
}
|
|
1400
|
+
installClaudeGate(dir);
|
|
1401
|
+
fs.copyFileSync(path.join(__dirname, '..', 'spec-state.cjs'), path.join(dir, '.claude/hooks/spec-state.cjs'));
|
|
1402
|
+
return dir;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
test('Stop still blocks multiple active specs for an untouched session', () => {
|
|
1406
|
+
const dir = tmpDir();
|
|
1407
|
+
try {
|
|
1408
|
+
const gate = installClaudeGate(dir);
|
|
1409
|
+
for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'in_progress');
|
|
1410
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
1411
|
+
cwd: dir, encoding: 'utf8', env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir },
|
|
1412
|
+
input: JSON.stringify({ cwd: dir, session_id: 'untouched-session', hook_event_name: 'Stop', prompt: 'Continue' }),
|
|
1413
|
+
});
|
|
1414
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1415
|
+
const body = parseBlock(result.stdout);
|
|
1416
|
+
assert.strictEqual(body.decision, 'block');
|
|
1417
|
+
assert.match(body.reason, /multiple active specs/);
|
|
1418
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
function writeNoHistoryPacket(dir, feature, status) {
|
|
1422
|
+
const packet = path.join(dir, 'specs', feature);
|
|
1423
|
+
fs.mkdirSync(packet, { recursive: true });
|
|
1424
|
+
fs.writeFileSync(path.join(packet, 'plan.md'), `# ${feature}\nSpecs-Contract: process-first-ready-v1\n`);
|
|
1425
|
+
fs.writeFileSync(path.join(packet, 'task-01-work.md'), [
|
|
1426
|
+
'# Task 01: work', '', `Status: ${status}`, '', '## Dependencies', '', '- none', '',
|
|
1427
|
+
'## Verification Plan', '', '- Command: node --test', '',
|
|
1428
|
+
...(status === 'done' ? ['## Receipt', '', 'Verification: PASS', ''] : []),
|
|
1429
|
+
].join('\n'));
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function runInstalledHistoryHook(dir, hook = 'spec-gate', extraEnv = {}) {
|
|
1433
|
+
return spawnSync(process.execPath, [path.join(dir, '.claude/hooks', `${hook}.cjs`)], {
|
|
1434
|
+
cwd: dir, encoding: 'utf8',
|
|
1435
|
+
env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir, ...extraEnv },
|
|
1436
|
+
input: JSON.stringify({ cwd: dir, session_id: 'no-history',
|
|
1437
|
+
hook_event_name: hook === 'spec-state' ? 'UserPromptSubmit' : 'Stop', prompt: 'Continue specs/demo' }),
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
test('no commits allows Stop without done tasks and prints one reminder', () => {
|
|
1442
|
+
const dir = noHistoryFixture();
|
|
1443
|
+
try {
|
|
1444
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1445
|
+
const gate = runInstalledHistoryHook(dir);
|
|
1446
|
+
assert.strictEqual(gate.status, 0, gate.stderr);
|
|
1447
|
+
assert.strictEqual(gate.stdout.trim(), '');
|
|
1448
|
+
const state = runInstalledHistoryHook(dir, 'spec-state');
|
|
1449
|
+
assert.strictEqual(state.status, 0, state.stderr);
|
|
1450
|
+
assert.match(state.stdout, /no commits/);
|
|
1451
|
+
assert.strictEqual(state.stdout.trim().split('\n').length, 1);
|
|
1452
|
+
assert.doesNotMatch(state.stdout, /Spec tollgate unavailable/);
|
|
1453
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test('no commits blocks a done task with first commit guidance', () => {
|
|
1457
|
+
const dir = noHistoryFixture();
|
|
1458
|
+
try {
|
|
1459
|
+
writeNoHistoryPacket(dir, 'demo', 'done');
|
|
1460
|
+
const result = runInstalledHistoryHook(dir);
|
|
1461
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1462
|
+
const body = parseBlock(result.stdout);
|
|
1463
|
+
assert.strictEqual(body.decision, 'block');
|
|
1464
|
+
assert.match(body.reason, /first commit/);
|
|
1465
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
1466
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
test('no commits blocks two completed packets with first commit guidance', () => {
|
|
1470
|
+
const dir = noHistoryFixture();
|
|
1471
|
+
try {
|
|
1472
|
+
for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'done');
|
|
1473
|
+
const result = runInstalledHistoryHook(dir);
|
|
1474
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1475
|
+
const body = parseBlock(result.stdout);
|
|
1476
|
+
assert.strictEqual(body.decision, 'block');
|
|
1477
|
+
assert.match(body.reason, /first commit/);
|
|
1478
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
1479
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
test('a corrupt current branch ref remains a controlled failure', () => {
|
|
1483
|
+
const dir = tmpDir();
|
|
1484
|
+
try {
|
|
1485
|
+
installClaudeGate(dir);
|
|
1486
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1487
|
+
const ref = spawnSync('git', ['-C', dir, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
|
|
1488
|
+
assert.strictEqual(ref.status, 0, ref.stderr);
|
|
1489
|
+
fs.writeFileSync(path.join(dir, '.git', ref.stdout.trim()), 'corrupt-ref\n');
|
|
1490
|
+
const result = runInstalledHistoryHook(dir);
|
|
1491
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1492
|
+
const body = parseBlock(result.stdout);
|
|
1493
|
+
assert.strictEqual(body.decision, 'block');
|
|
1494
|
+
assert.match(body.reason, /controlled failure/);
|
|
1495
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
test('unavailable git remains a controlled failure in a repo without commits', () => {
|
|
1499
|
+
const dir = noHistoryFixture();
|
|
1500
|
+
try {
|
|
1501
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1502
|
+
const emptyPath = path.join(dir, 'empty-path');
|
|
1503
|
+
fs.mkdirSync(emptyPath);
|
|
1504
|
+
const result = runInstalledHistoryHook(dir, 'spec-gate', { PATH: emptyPath });
|
|
1505
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1506
|
+
const body = parseBlock(result.stdout);
|
|
1507
|
+
assert.strictEqual(body.decision, 'block');
|
|
1508
|
+
assert.match(body.reason, /controlled failure/);
|
|
1509
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
test('a committed repo still accepts a valid done task receipt', () => {
|
|
1513
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
1514
|
+
try {
|
|
1515
|
+
installClaudeGate(dir);
|
|
1516
|
+
const result = runInstalledHistoryHook(dir);
|
|
1517
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1518
|
+
assert.strictEqual(result.stdout.trim(), '');
|
|
1519
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1385
1522
|
test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
|
|
1386
1523
|
// Contract change. This used to require a block. `Base` is the last commit
|
|
1387
1524
|
// touching anything outside the specs root, so a receipt written before any
|
|
@@ -1866,3 +2003,82 @@ test('a packet holding both plan.md and spec.json is read as process-first and i
|
|
|
1866
2003
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1867
2004
|
}
|
|
1868
2005
|
});
|
|
2006
|
+
|
|
2007
|
+
// Head is sha256 over one JSON line per path with the file bytes as base64. It is now streamed instead of built, and
|
|
2008
|
+
// must stay byte-identical: every receipt already written carries a Head computed the old way.
|
|
2009
|
+
function referenceHead(dir) {
|
|
2010
|
+
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'buffer' }).stdout;
|
|
2011
|
+
const split = (buffer) => buffer.toString('utf8').split('\0').filter(Boolean);
|
|
2012
|
+
const raw = split(git('status', '--porcelain=v1', '-z', '--untracked-files=all'));
|
|
2013
|
+
const statuses = new Map();
|
|
2014
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
2015
|
+
const xy = raw[index].slice(0, 2);
|
|
2016
|
+
const first = raw[index].slice(3);
|
|
2017
|
+
if (xy.includes('R') || xy.includes('C')) {
|
|
2018
|
+
const next = raw[++index];
|
|
2019
|
+
statuses.set(first, { xy, role: 'rename-source', counterpart: next });
|
|
2020
|
+
statuses.set(next, { xy, role: 'rename-target', counterpart: first });
|
|
2021
|
+
} else statuses.set(first, { xy, role: xy === '??' || xy === '!!' ? 'untracked' : 'tracked' });
|
|
2022
|
+
}
|
|
2023
|
+
const paths = [...new Set([...split(git('ls-files', '-z', '--cached')), ...split(git('ls-tree', '-r', '-z', '--name-only', 'HEAD')), ...statuses.keys()])]
|
|
2024
|
+
.filter((relative) => !relative.startsWith('specs/') && !PROVENANCE.RUNTIME_STATE_ROOTS.some((root) => relative === root || relative.startsWith(`${root}/`)))
|
|
2025
|
+
.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
2026
|
+
const hash = crypto.createHash('sha256').update('cafekit-provenance\0');
|
|
2027
|
+
for (const relative of paths) {
|
|
2028
|
+
const absolute = path.join(dir, relative);
|
|
2029
|
+
const status = statuses.get(relative) || null;
|
|
2030
|
+
let stat;
|
|
2031
|
+
try { stat = fs.lstatSync(absolute); } catch {
|
|
2032
|
+
hash.update(`${JSON.stringify({ path: relative, type: 'deleted', mode: '0000', status, content: null })}\n`);
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
const type = stat.isSymbolicLink() ? 'symlink' : stat.isFile() ? 'file' : 'directory';
|
|
2036
|
+
const content = type === 'symlink' ? Buffer.from(fs.readlinkSync(absolute), 'utf8') : type === 'file' ? fs.readFileSync(absolute) : Buffer.alloc(0);
|
|
2037
|
+
const mode = (stat.mode & 0o7777).toString(8).padStart(4, '0');
|
|
2038
|
+
hash.update(`${JSON.stringify({ path: relative, type, mode, status, content: content.toString('base64') })}\n`);
|
|
2039
|
+
}
|
|
2040
|
+
return hash.digest('hex');
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function provenanceFixture() {
|
|
2044
|
+
const dir = fs.realpathSync(tmpDir());
|
|
2045
|
+
fs.mkdirSync(path.join(dir, 'specs', FEATURE), { recursive: true });
|
|
2046
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, 'plan.md'), '# Demo plan\n');
|
|
2047
|
+
return dir;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
test('Head is unchanged by streaming: files around the 3 MB chunk edge, symlink, rename, deletion, quoted names', () => {
|
|
2051
|
+
const dir = provenanceFixture();
|
|
2052
|
+
try {
|
|
2053
|
+
const chunk = 3 * 1024 * 1024;
|
|
2054
|
+
for (const size of [0, 1, 2, 4, chunk - 1, chunk, chunk + 1, 2 * chunk + 2]) {
|
|
2055
|
+
fs.writeFileSync(path.join(dir, `bin-${size}.dat`), crypto.randomBytes(size));
|
|
2056
|
+
}
|
|
2057
|
+
fs.writeFileSync(path.join(dir, 'tiếng "việt" \\ name.txt'), 'x');
|
|
2058
|
+
fs.writeFileSync(path.join(dir, 'run.sh'), '#!/bin/sh\n');
|
|
2059
|
+
fs.chmodSync(path.join(dir, 'run.sh'), 0o755);
|
|
2060
|
+
fs.writeFileSync(path.join(dir, 'gone.txt'), 'g');
|
|
2061
|
+
fs.writeFileSync(path.join(dir, 'old-name.txt'), 'r');
|
|
2062
|
+
for (const args of [['add', '-A'], ['commit', '-qm', 'files']]) assert.strictEqual(spawnSync('git', ['-C', dir, ...args]).status, 0);
|
|
2063
|
+
fs.unlinkSync(path.join(dir, 'gone.txt'));
|
|
2064
|
+
assert.strictEqual(spawnSync('git', ['-C', dir, 'mv', 'old-name.txt', 'new-name.txt']).status, 0);
|
|
2065
|
+
fs.symlinkSync('run.sh', path.join(dir, 'link'));
|
|
2066
|
+
fs.writeFileSync(path.join(dir, 'untracked.bin'), crypto.randomBytes(1000));
|
|
2067
|
+
assert.strictEqual(workflowRuntimeContext(dir).head, referenceHead(dir));
|
|
2068
|
+
} finally {
|
|
2069
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
test('a checkout whose files exceed the longest string V8 can hold still yields a Head', () => {
|
|
2074
|
+
const dir = provenanceFixture();
|
|
2075
|
+
try {
|
|
2076
|
+
// Sparse: 600 MB of zeros on disk costs nothing, but its base64 alone (800M characters) is past V8's string limit.
|
|
2077
|
+
const descriptor = fs.openSync(path.join(dir, 'large-asset.bin'), 'w');
|
|
2078
|
+
fs.ftruncateSync(descriptor, 600 * 1024 * 1024);
|
|
2079
|
+
fs.closeSync(descriptor);
|
|
2080
|
+
assert.match(workflowRuntimeContext(dir).head, /^[0-9a-f]{64}$/);
|
|
2081
|
+
} finally {
|
|
2082
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2083
|
+
}
|
|
2084
|
+
});
|