@haposoft/cafekit 0.18.1 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- 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 +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- 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-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 +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- 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 +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -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 +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- 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/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- 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/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -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/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -5,7 +5,8 @@ const path = require('path');
|
|
|
5
5
|
const { PLATFORMS } = require('../lib/context');
|
|
6
6
|
const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
|
|
7
7
|
const { report, treeAction } = require('./report');
|
|
8
|
-
const {
|
|
8
|
+
const { isGeneratedArtifact } = require('../lib/copy-utils');
|
|
9
|
+
const { copyRulesDirectory, copyOutputStylesDirectory, removeObsoleteRuntimeFiles } = require('./claude-runtime');
|
|
9
10
|
const {
|
|
10
11
|
managedRange,
|
|
11
12
|
upsertManagedCodexBlock
|
|
@@ -23,30 +24,9 @@ const CODEX_OWN_RUNTIME = [
|
|
|
23
24
|
['runtime.json', 'runtime.json']
|
|
24
25
|
];
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
* Delete hooks, scripts and skill files the manifest marks obsolete. Codex tracker keys are project-relative
|
|
28
|
-
* (`.codex/hooks/…`), so the record is pruned through keyFor, and always: a record whose
|
|
29
|
-
* file is already gone must still leave the manifest.
|
|
30
|
-
*/
|
|
27
|
+
/** Use the shared ownership and symlink checks for Codex runtime cleanup. */
|
|
31
28
|
function removeObsoleteCodexHooks(ctx, platformKey) {
|
|
32
|
-
|
|
33
|
-
const tracker = ctx.trackers && ctx.trackers[platformKey];
|
|
34
|
-
const obsoleteHooks = (ctx.manifest?.obsolete?.runtimeFiles || []).filter((rel) => rel.startsWith('hooks/') || rel.startsWith('scripts/') || rel.startsWith('skills/'));
|
|
35
|
-
for (const rel of obsoleteHooks) {
|
|
36
|
-
// Skill files live in the shared skills directory, not under the runtime folder.
|
|
37
|
-
const target = rel.startsWith('skills/')
|
|
38
|
-
? path.join(platform.skillsDir, rel.slice('skills/'.length))
|
|
39
|
-
: path.join(platform.folder, rel);
|
|
40
|
-
const exists = fs.existsSync(target);
|
|
41
|
-
if (!ctx.dryRun) {
|
|
42
|
-
if (exists) fs.rmSync(target, { force: true, recursive: fs.statSync(target).isDirectory() });
|
|
43
|
-
if (tracker) tracker.prune(tracker.keyFor(target));
|
|
44
|
-
}
|
|
45
|
-
if (exists) {
|
|
46
|
-
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete Codex runtime: ${rel}`);
|
|
47
|
-
ctx.results.updated++;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
29
|
+
removeObsoleteRuntimeFiles(ctx, platformKey);
|
|
50
30
|
}
|
|
51
31
|
|
|
52
32
|
function writeSourceFile(ctx, platformKey, src, dest, label) {
|
|
@@ -82,13 +62,32 @@ function installRuntimeFiles(ctx, platformKey) {
|
|
|
82
62
|
|
|
83
63
|
mergeCodexHooks(ctx, platformKey);
|
|
84
64
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
65
|
+
const hooksSource = path.join(CODEX_SRC, 'hooks');
|
|
66
|
+
const libsSource = path.join(hooksSource, 'lib');
|
|
67
|
+
const shimPattern = /^(?:['"]use strict['"];?\s*)?module\.exports\s*=\s*require\(['"]\.\.\/\.\.\/\.\.\/claude\/hooks\/lib\/([^'"/]+)['"]\);?\s*$/;
|
|
68
|
+
const sharedLibraries = fs.readdirSync(libsSource).filter(name => {
|
|
69
|
+
const file = path.join(libsSource, name);
|
|
70
|
+
if (!fs.lstatSync(file).isFile()) return false;
|
|
71
|
+
const match = fs.readFileSync(file, 'utf8').match(shimPattern);
|
|
72
|
+
return match && match[1] === name;
|
|
91
73
|
});
|
|
74
|
+
const agg = { created: 0, updated: 0, unchanged: 0, preserved: 0, missing: 0 };
|
|
75
|
+
const copyHookEntry = relative => {
|
|
76
|
+
if (isGeneratedArtifact(path.basename(relative))) return;
|
|
77
|
+
const child = copyManagedTree({
|
|
78
|
+
src: path.join(hooksSource, relative),
|
|
79
|
+
dest: path.join(platform.folder, 'hooks', relative),
|
|
80
|
+
sourceRoot: hooksSource,
|
|
81
|
+
platformFolder: platform.folder, ctx, tracker: ctx.trackers[platformKey]
|
|
82
|
+
});
|
|
83
|
+
for (const key of Object.keys(agg)) agg[key] += child[key];
|
|
84
|
+
};
|
|
85
|
+
for (const name of fs.readdirSync(hooksSource)) {
|
|
86
|
+
if (name !== 'lib') copyHookEntry(name);
|
|
87
|
+
else for (const lib of fs.readdirSync(libsSource)) {
|
|
88
|
+
if (!sharedLibraries.includes(lib)) copyHookEntry(path.join('lib', lib));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
92
91
|
report(ctx, treeAction(agg), 'Codex native hooks');
|
|
93
92
|
removeObsoleteCodexHooks(ctx, platformKey);
|
|
94
93
|
// Keep one canonical command analyzer in the Claude source tree, then
|
|
@@ -102,27 +101,21 @@ function installRuntimeFiles(ctx, platformKey) {
|
|
|
102
101
|
path.join(platform.folder, 'runtime.schema.json'),
|
|
103
102
|
'Codex runtime schema'
|
|
104
103
|
);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
path.
|
|
110
|
-
'Codex
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
ctx,
|
|
121
|
-
platformKey,
|
|
122
|
-
path.join(SRC, 'claude', 'hooks', 'lib', 'runtime-path-safety.cjs'),
|
|
123
|
-
path.join(platform.folder, 'hooks', 'lib', 'runtime-path-safety.cjs'),
|
|
124
|
-
'Codex runtime path safety'
|
|
125
|
-
);
|
|
104
|
+
const helperLabels = {
|
|
105
|
+
'privacy-command-analysis.cjs': 'Codex privacy command analyzer',
|
|
106
|
+
'privacy-paths.cjs': 'Codex privacy path analyzer',
|
|
107
|
+
'secret-keywords.cjs': 'Codex secret keyword matcher',
|
|
108
|
+
'runtime-path-safety.cjs': 'Codex runtime path safety',
|
|
109
|
+
'inspect-paths.cjs': 'Codex inspection path analyzer',
|
|
110
|
+
'spec-touch.cjs': 'Codex spec touch helpers',
|
|
111
|
+
'update-check.cjs': 'Codex update check'
|
|
112
|
+
};
|
|
113
|
+
for (const name of sharedLibraries) {
|
|
114
|
+
writeSourceFile(ctx, platformKey,
|
|
115
|
+
path.join(SRC, 'claude', 'hooks', 'lib', name),
|
|
116
|
+
path.join(platform.folder, 'hooks', 'lib', name),
|
|
117
|
+
helperLabels[name] || `Codex hook helper: ${name}`);
|
|
118
|
+
}
|
|
126
119
|
}
|
|
127
120
|
|
|
128
121
|
function installNativeRuleOverrides(ctx, platformKey) {
|
|
@@ -170,7 +163,7 @@ function installManagedAgentsMd(ctx) {
|
|
|
170
163
|
function installCodexRuntime(ctx, platformKey) {
|
|
171
164
|
if (platformKey !== 'codex') return ctx;
|
|
172
165
|
installRuntimeFiles(ctx, platformKey);
|
|
173
|
-
copyRulesDirectory(ctx, platformKey);
|
|
166
|
+
copyRulesDirectory(ctx, platformKey, new Set(fs.readdirSync(path.join(CODEX_SRC, 'rules'))));
|
|
174
167
|
copyOutputStylesDirectory(ctx, platformKey);
|
|
175
168
|
installNativeRuleOverrides(ctx, platformKey);
|
|
176
169
|
installManagedAgentsMd(ctx);
|
|
@@ -97,9 +97,7 @@ function copyPlatformFiles(ctx, platformKey) {
|
|
|
97
97
|
// The specs skill tree above carries its templates; remove the ones retired
|
|
98
98
|
// with the spec.json planning flow from earlier installs.
|
|
99
99
|
if (platformKey === 'claude' || platformKey === 'codex') {
|
|
100
|
-
const retiredTemplates =
|
|
101
|
-
? ['init.json', 'spec-state.json', 'requirements-init.md', 'requirements.md', 'design.md', 'task.md']
|
|
102
|
-
: ['spec-state.json', 'requirements-init.md', 'requirements.md', 'design.md', 'task.md'];
|
|
100
|
+
const retiredTemplates = ['init.json', 'spec-state.json', 'requirements-init.md', 'requirements.md', 'design.md', 'task.md'];
|
|
103
101
|
for (const fileName of retiredTemplates) {
|
|
104
102
|
const retired = path.join(platform.skillsDir, 'specs', 'templates', fileName);
|
|
105
103
|
if (!fs.existsSync(retired)) continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haposoft/cafekit",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
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",
|
package/src/claude/CLAUDE.md
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
## Claude Code runtime
|
|
9
9
|
|
|
10
|
+
Project notes (commands, files to leave unchanged, slow or expensive operations)
|
|
11
|
+
belong in the root AGENTS.md after the CAFEKIT CORE END marker; Claude Code reads
|
|
12
|
+
them through `@AGENTS.md`.
|
|
13
|
+
|
|
10
14
|
- Edit project skills under `.claude/skills/`, not `~/.claude/skills`.
|
|
11
15
|
- Run Python skill scripts with the skill venv:
|
|
12
16
|
- macOS/Linux: `.claude/skills/.venv/bin/python3 scripts/<script>.py`
|
package/src/claude/gitignore
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
|
|
11
|
+
const CLAUDE = path.resolve(__dirname, '../..');
|
|
12
|
+
const MANIFEST = require('../../migration-manifest.json');
|
|
13
|
+
const RUNTIME = { paths: { specs: 'work-specs', plans: 'work-plans', docs: 'work-docs' }, inspect: { enabled: false } };
|
|
14
|
+
|
|
15
|
+
function fixture(run, withRuntime = true) {
|
|
16
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-installed-root-'));
|
|
17
|
+
const sessions = new Set();
|
|
18
|
+
try {
|
|
19
|
+
for (const relative of MANIFEST.runtime.files) {
|
|
20
|
+
const target = path.join(root, '.claude', relative);
|
|
21
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
22
|
+
fs.copyFileSync(path.join(CLAUDE, relative), target);
|
|
23
|
+
}
|
|
24
|
+
for (const relative of MANIFEST.scripts.required) {
|
|
25
|
+
const target = path.join(root, '.claude/scripts', relative);
|
|
26
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
27
|
+
fs.copyFileSync(path.join(CLAUDE, 'scripts', relative), target);
|
|
28
|
+
}
|
|
29
|
+
const runtimeFile = path.join(root, '.claude/runtime.json');
|
|
30
|
+
const writeRuntime = () => fs.writeFileSync(runtimeFile, JSON.stringify(RUNTIME));
|
|
31
|
+
if (withRuntime) writeRuntime();
|
|
32
|
+
else fs.unlinkSync(runtimeFile);
|
|
33
|
+
const cwd = path.join(root, 'sub/dir');
|
|
34
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
35
|
+
const env = { ...process.env };
|
|
36
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
37
|
+
delete env.PROJECT_ROOT;
|
|
38
|
+
const invoke = (name, payload) => {
|
|
39
|
+
if (payload.session_id) sessions.add(payload.session_id);
|
|
40
|
+
return spawnSync(process.execPath, [path.join(root, '.claude/hooks', `${name}.cjs`)], {
|
|
41
|
+
cwd, env, encoding: 'utf8', input: JSON.stringify({ cwd, ...payload }),
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
run({ root: fs.realpathSync(root), cwd, invoke, writeRuntime });
|
|
45
|
+
} finally {
|
|
46
|
+
for (const session of sessions) {
|
|
47
|
+
const key = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
|
|
48
|
+
fs.rmSync(path.join(os.tmpdir(), `cafekit-rules-${key}.json`), { force: true });
|
|
49
|
+
}
|
|
50
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('installed rules read the project runtime from a nested session cwd', () => {
|
|
55
|
+
fixture(({ root, invoke }) => {
|
|
56
|
+
const result = invoke('rules', { hook_event_name: 'UserPromptSubmit', session_id: crypto.randomUUID() });
|
|
57
|
+
assert.equal(result.status, 0, result.stderr);
|
|
58
|
+
assert.match(result.stdout, /## Rules/);
|
|
59
|
+
assert.ok(result.stdout.includes(`${root}/work-specs/`));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('rules wait for runtime before reserving the session injection', () => {
|
|
64
|
+
fixture(({ invoke, writeRuntime }) => {
|
|
65
|
+
const payload = { hook_event_name: 'UserPromptSubmit', session_id: crypto.randomUUID() };
|
|
66
|
+
const absent = invoke('rules', payload);
|
|
67
|
+
assert.equal(absent.status, 0, absent.stderr);
|
|
68
|
+
assert.equal(absent.stdout, '');
|
|
69
|
+
writeRuntime();
|
|
70
|
+
const present = invoke('rules', payload);
|
|
71
|
+
assert.equal(present.status, 0, present.stderr);
|
|
72
|
+
assert.match(present.stdout, /## Rules/);
|
|
73
|
+
}, false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('installed inspect respects a disabled project runtime from a nested cwd', () => {
|
|
77
|
+
fixture(({ invoke }) => {
|
|
78
|
+
const result = invoke('inspect-block', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: 'node_modules/pkg/index.js' } });
|
|
79
|
+
assert.equal(result.status, 0, result.stderr);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('installed agent context uses project paths from a nested session cwd', () => {
|
|
84
|
+
fixture(({ root, invoke }) => {
|
|
85
|
+
const result = invoke('agent', { hook_event_name: 'SubagentStart', agent_id: 'nested-agent' });
|
|
86
|
+
assert.equal(result.status, 0, result.stderr);
|
|
87
|
+
assert.match(result.stdout, /additionalContext/);
|
|
88
|
+
const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
|
|
89
|
+
for (const relative of ['work-specs', 'work-plans', 'work-docs']) assert.ok(context.includes(`${root}/${relative}/`));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('installed spec gate checks root packet receipts from a nested session cwd', () => {
|
|
94
|
+
fixture(({ root, invoke }) => {
|
|
95
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
96
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
97
|
+
assert.equal(result.status, 0, result.stderr);
|
|
98
|
+
}
|
|
99
|
+
const packet = path.join(root, 'work-specs/demo');
|
|
100
|
+
fs.mkdirSync(packet, { recursive: true });
|
|
101
|
+
fs.writeFileSync(path.join(packet, 'plan.md'), '# Demo\nSpecs-Contract: process-first-ready-v1\n');
|
|
102
|
+
fs.writeFileSync(path.join(packet, 'task-01-work.md'), '# Work\n\nStatus: done\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- Command: node --test\n');
|
|
103
|
+
const result = invoke('spec-gate', { hook_event_name: 'Stop', session_id: crypto.randomUUID() });
|
|
104
|
+
assert.equal(result.status, 0, result.stderr);
|
|
105
|
+
const block = JSON.parse(result.stdout);
|
|
106
|
+
assert.equal(block.decision, 'block');
|
|
107
|
+
assert.match(block.reason, /receipt/i);
|
|
108
|
+
assert.match(block.reason, /task-01-work\.md/);
|
|
109
|
+
assert.doesNotMatch(block.reason, /unavailable|controlled failure/i);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -11,11 +11,21 @@ const { spawnSync } = require('node:child_process');
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const os = require('node:os');
|
|
13
13
|
const path = require('node:path');
|
|
14
|
+
const crypto = require('node:crypto');
|
|
14
15
|
|
|
15
16
|
const PRECOMPACT = path.join(__dirname, '..', 'precompact.cjs');
|
|
16
17
|
const SESSION = path.join(__dirname, '..', 'session.cjs');
|
|
17
18
|
const { hookStateDir } = require(path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'));
|
|
18
|
-
const
|
|
19
|
+
const CAPTURES = new Set([path.join(hookStateDir(), 'compact-recovery.json')]);
|
|
20
|
+
function recoveryPath(session = 'compact-session') {
|
|
21
|
+
const key = crypto.createHash('sha256').update(session).digest('hex');
|
|
22
|
+
const file = path.join(hookStateDir(), `compact-recovery-${key}.json`);
|
|
23
|
+
CAPTURES.add(file);
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
const HOOK_ENV = { ...process.env };
|
|
27
|
+
delete HOOK_ENV.CLAUDE_PROJECT_DIR;
|
|
28
|
+
delete HOOK_ENV.PROJECT_ROOT;
|
|
19
29
|
|
|
20
30
|
function fixture() {
|
|
21
31
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-'));
|
|
@@ -36,21 +46,24 @@ function fixture() {
|
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
function run(hook, dir, payload) {
|
|
49
|
+
if (typeof payload?.session_id === 'string' && payload.session_id.trim()) recoveryPath(payload.session_id);
|
|
39
50
|
const result = spawnSync(process.execPath, [hook], {
|
|
40
51
|
cwd: dir,
|
|
41
52
|
input: JSON.stringify(payload),
|
|
42
53
|
encoding: 'utf8',
|
|
43
|
-
env:
|
|
54
|
+
env: HOOK_ENV,
|
|
44
55
|
});
|
|
45
56
|
return { code: result.status, stdout: result.stdout || '', stderr: result.stderr || '' };
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
function clearCapture() {
|
|
49
|
-
|
|
60
|
+
for (const file of CAPTURES) {
|
|
61
|
+
try { fs.unlinkSync(file); } catch { /* absent */ }
|
|
62
|
+
}
|
|
50
63
|
}
|
|
51
64
|
|
|
52
|
-
function readCapture() {
|
|
53
|
-
return JSON.parse(fs.readFileSync(
|
|
65
|
+
function readCapture(session) {
|
|
66
|
+
return JSON.parse(fs.readFileSync(recoveryPath(session), 'utf8'));
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
test('PreCompact records branch, HEAD, dirty count and the in-flight task', () => {
|
|
@@ -93,8 +106,8 @@ test('a compact restart prints the captured anchors alongside the authorization
|
|
|
93
106
|
const dir = fixture();
|
|
94
107
|
try {
|
|
95
108
|
clearCapture();
|
|
96
|
-
assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', trigger: 'manual', cwd: dir }).code, 0);
|
|
97
|
-
const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', cwd: dir });
|
|
109
|
+
assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', trigger: 'manual', session_id: 'compact-session', cwd: dir }).code, 0);
|
|
110
|
+
const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session', cwd: dir });
|
|
98
111
|
assert.strictEqual(restart.code, 0, restart.stderr);
|
|
99
112
|
assert.match(restart.stdout, /VERIFY PENDING AUTHORIZATIONS/, 'the existing warning must survive');
|
|
100
113
|
assert.match(restart.stdout, /State before compaction/);
|
|
@@ -119,6 +132,53 @@ test('a compact restart without a capture still prints the warning and nothing i
|
|
|
119
132
|
}
|
|
120
133
|
});
|
|
121
134
|
|
|
135
|
+
test('a compact restart in another session prints the warning without foreign anchors', () => {
|
|
136
|
+
const dir = fixture();
|
|
137
|
+
try {
|
|
138
|
+
clearCapture();
|
|
139
|
+
assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
|
|
140
|
+
const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-b', cwd: dir });
|
|
141
|
+
assert.strictEqual(restart.code, 0, restart.stderr);
|
|
142
|
+
assert.match(restart.stdout, /SESSION COMPRESSED/);
|
|
143
|
+
assert.doesNotMatch(restart.stdout, /State before compaction/);
|
|
144
|
+
} finally {
|
|
145
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
clearCapture();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a compact restart in the same session restores its captured anchors', () => {
|
|
151
|
+
const dir = fixture();
|
|
152
|
+
try {
|
|
153
|
+
clearCapture();
|
|
154
|
+
assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
|
|
155
|
+
const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-a', cwd: dir });
|
|
156
|
+
assert.strictEqual(restart.code, 0, restart.stderr);
|
|
157
|
+
assert.match(restart.stdout, /SESSION COMPRESSED/);
|
|
158
|
+
assert.match(restart.stdout, /State before compaction[\s\S]*branch work/);
|
|
159
|
+
} finally {
|
|
160
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
161
|
+
clearCapture();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
for (const [captureId, restartId] of [[undefined, 'session-a'], ['session-a', undefined], [undefined, undefined]]) {
|
|
166
|
+
test(`a compact restart without ${captureId ? 'restart' : restartId ? 'capture' : 'either'} session identity keeps only the warning`, () => {
|
|
167
|
+
const dir = fixture();
|
|
168
|
+
try {
|
|
169
|
+
clearCapture();
|
|
170
|
+
assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: captureId, cwd: dir }).code, 0);
|
|
171
|
+
const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: restartId, cwd: dir });
|
|
172
|
+
assert.strictEqual(restart.code, 0, restart.stderr);
|
|
173
|
+
assert.match(restart.stdout, /SESSION COMPRESSED/);
|
|
174
|
+
assert.doesNotMatch(restart.stdout, /State before compaction/);
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
clearCapture();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
122
182
|
test('a normal session start prints no compaction anchors', () => {
|
|
123
183
|
const dir = fixture();
|
|
124
184
|
try {
|
|
@@ -138,7 +198,7 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
|
|
|
138
198
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-bare-'));
|
|
139
199
|
try {
|
|
140
200
|
clearCapture();
|
|
141
|
-
const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', cwd: dir });
|
|
201
|
+
const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'compact-session', cwd: dir });
|
|
142
202
|
assert.strictEqual(noGit.code, 0, 'a directory outside git must not fail the hook');
|
|
143
203
|
const record = readCapture();
|
|
144
204
|
assert.strictEqual(record.branch, null, 'unavailable git facts are recorded as null, never guessed');
|
|
@@ -147,12 +207,148 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
|
|
|
147
207
|
|
|
148
208
|
clearCapture();
|
|
149
209
|
const malformed = spawnSync(process.execPath, [PRECOMPACT], {
|
|
150
|
-
cwd: dir, input: 'not json', encoding: 'utf8', env:
|
|
210
|
+
cwd: dir, input: 'not json', encoding: 'utf8', env: HOOK_ENV,
|
|
151
211
|
});
|
|
152
212
|
assert.strictEqual(malformed.status, 0, 'malformed payload must fail open');
|
|
153
|
-
assert.strictEqual(fs.existsSync(
|
|
213
|
+
assert.strictEqual(fs.existsSync(recoveryPath()), false, 'a malformed payload writes no capture');
|
|
154
214
|
} finally {
|
|
155
215
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
216
|
clearCapture();
|
|
157
217
|
}
|
|
158
218
|
});
|
|
219
|
+
|
|
220
|
+
function installedFixture(callback) {
|
|
221
|
+
const root = fixture();
|
|
222
|
+
const source = path.resolve(__dirname, '../..');
|
|
223
|
+
const manifest = require('../../migration-manifest.json');
|
|
224
|
+
const sessions = new Set();
|
|
225
|
+
try {
|
|
226
|
+
for (const relative of manifest.runtime.files) {
|
|
227
|
+
const target = path.join(root, '.claude', relative);
|
|
228
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
229
|
+
fs.copyFileSync(path.join(source, relative), target);
|
|
230
|
+
}
|
|
231
|
+
for (const relative of manifest.scripts.required) {
|
|
232
|
+
const target = path.join(root, '.claude/scripts', relative);
|
|
233
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
234
|
+
fs.copyFileSync(path.join(source, 'scripts', relative), target);
|
|
235
|
+
}
|
|
236
|
+
const hooks = path.join(root, '.claude/hooks');
|
|
237
|
+
const invoke = (name, payload, overrides = {}) => {
|
|
238
|
+
if (typeof payload?.session_id === 'string' && payload.session_id.trim()) sessions.add(payload.session_id);
|
|
239
|
+
return spawnSync(process.execPath, [path.join(hooks, `${name}.cjs`)], {
|
|
240
|
+
cwd: root, env: HOOK_ENV, encoding: 'utf8', timeout: 10000,
|
|
241
|
+
input: typeof payload === 'string' ? payload : JSON.stringify(payload), ...overrides,
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
callback({ root: fs.realpathSync(root), hooks, invoke });
|
|
245
|
+
} finally {
|
|
246
|
+
for (const session of sessions) {
|
|
247
|
+
const key = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
|
|
248
|
+
fs.rmSync(path.join(os.tmpdir(), `cafekit-rules-${key}.json`), { force: true });
|
|
249
|
+
}
|
|
250
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
test('two compacting sessions keep separate anchors and restore only their own branch', () => {
|
|
255
|
+
installedFixture(({ root, hooks, invoke }) => {
|
|
256
|
+
const sessions = [crypto.randomUUID(), crypto.randomUUID()];
|
|
257
|
+
const files = [];
|
|
258
|
+
for (const [index, session_id] of sessions.entries()) {
|
|
259
|
+
const branch = `anchor-${index === 0 ? 'first' : 'second'}`;
|
|
260
|
+
const checkout = spawnSync('git', ['-C', root, 'checkout', '-qb', branch], { encoding: 'utf8' });
|
|
261
|
+
assert.strictEqual(checkout.status, 0, checkout.stderr);
|
|
262
|
+
const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
|
|
263
|
+
assert.strictEqual(capture.status, 0, capture.stderr);
|
|
264
|
+
const hash = crypto.createHash('sha256').update(session_id).digest('hex');
|
|
265
|
+
files.push(path.join(hooks, '.logs', `compact-recovery-${hash}.json`));
|
|
266
|
+
}
|
|
267
|
+
for (const [index, file] of files.entries()) {
|
|
268
|
+
assert.ok(fs.existsSync(file), 'both session captures must survive');
|
|
269
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
270
|
+
assert.strictEqual(record.session_id, sessions[index]);
|
|
271
|
+
assert.strictEqual(record.branch, `anchor-${index === 0 ? 'first' : 'second'}`);
|
|
272
|
+
}
|
|
273
|
+
for (const [index, session_id] of sessions.entries()) {
|
|
274
|
+
const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id, cwd: root });
|
|
275
|
+
assert.strictEqual(compact.status, 0, compact.stderr);
|
|
276
|
+
assert.match(compact.stdout, /SESSION COMPRESSED/);
|
|
277
|
+
assert.match(compact.stdout, /State before compaction/);
|
|
278
|
+
assert.ok(compact.stdout.includes(`branch anchor-${index === 0 ? 'first' : 'second'}`));
|
|
279
|
+
assert.ok(!compact.stdout.includes(`branch anchor-${index === 0 ? 'second' : 'first'}`));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('PreCompact without a usable session identity creates no anchor or state directory', () => {
|
|
285
|
+
installedFixture(({ root, hooks, invoke }) => {
|
|
286
|
+
for (const session_id of [undefined, null, '', 42, ' ']) {
|
|
287
|
+
const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
|
|
288
|
+
assert.strictEqual(capture.status, 0, capture.stderr);
|
|
289
|
+
assert.strictEqual(fs.existsSync(path.join(hooks, '.logs')), false);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('compact reinjects rules only for its own session even without matching anchors', () => {
|
|
295
|
+
installedFixture(({ root, hooks, invoke }) => {
|
|
296
|
+
const session = crypto.randomUUID();
|
|
297
|
+
const other = crypto.randomUUID();
|
|
298
|
+
const prompt = id => invoke('rules', { hook_event_name: 'UserPromptSubmit', session_id: id, cwd: root });
|
|
299
|
+
for (const id of [session, other]) {
|
|
300
|
+
const first = prompt(id);
|
|
301
|
+
assert.strictEqual(first.status, 0, first.stderr);
|
|
302
|
+
assert.match(first.stdout, /## Rules/);
|
|
303
|
+
assert.strictEqual(prompt(id).stdout, '');
|
|
304
|
+
}
|
|
305
|
+
fs.mkdirSync(path.join(hooks, '.logs'), { recursive: true });
|
|
306
|
+
const otherHash = crypto.createHash('sha256').update(other).digest('hex');
|
|
307
|
+
fs.writeFileSync(path.join(hooks, '.logs', `compact-recovery-${otherHash}.json`), JSON.stringify({ session_id: other }));
|
|
308
|
+
const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id: session, cwd: root });
|
|
309
|
+
assert.strictEqual(compact.status, 0, compact.stderr);
|
|
310
|
+
assert.match(compact.stdout, /SESSION COMPRESSED/);
|
|
311
|
+
assert.doesNotMatch(compact.stdout, /State before compaction/);
|
|
312
|
+
const again = prompt(session);
|
|
313
|
+
assert.strictEqual(again.status, 0, again.stderr);
|
|
314
|
+
assert.match(again.stdout, /## Rules/);
|
|
315
|
+
assert.strictEqual(prompt(other).stdout, '');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('malformed session input fails open without a stack and logs its crash', () => {
|
|
320
|
+
installedFixture(({ hooks, invoke }) => {
|
|
321
|
+
const result = invoke('session', '{');
|
|
322
|
+
assert.strictEqual(result.status, 0);
|
|
323
|
+
assert.strictEqual(result.stderr, '');
|
|
324
|
+
const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
325
|
+
assert.strictEqual(rows.length, 1);
|
|
326
|
+
assert.strictEqual(rows[0].hook, 'session');
|
|
327
|
+
assert.strictEqual(rows[0].status, 'crash');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('installed session explains a nested cwd but omits the root line without a cwd', () => {
|
|
332
|
+
installedFixture(({ root, invoke }) => {
|
|
333
|
+
const cwd = path.join(root, 'sub/dir');
|
|
334
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
335
|
+
const nested = invoke('session', { source: 'startup', cwd }, { cwd });
|
|
336
|
+
assert.strictEqual(nested.status, 0, nested.stderr);
|
|
337
|
+
assert.ok(nested.stdout.includes(`CafeKit project root: ${root} (session working directory: ${cwd}).`));
|
|
338
|
+
const missing = invoke('session', { source: 'startup' }, { cwd });
|
|
339
|
+
assert.strictEqual(missing.status, 0, missing.stderr);
|
|
340
|
+
assert.doesNotMatch(missing.stdout, /CafeKit project root:/);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('PreCompact bounds slow git calls and finishes within ten seconds', { timeout: 15000 }, () => {
|
|
345
|
+
installedFixture(({ root, invoke }) => {
|
|
346
|
+
const bin = path.join(root, 'fake-bin');
|
|
347
|
+
fs.mkdirSync(bin);
|
|
348
|
+
fs.writeFileSync(path.join(bin, 'git'), '#!/bin/sh\nexec /bin/sleep 30\n', { mode: 0o755 });
|
|
349
|
+
const start = Date.now();
|
|
350
|
+
const result = invoke('precompact', { hook_event_name: 'PreCompact', session_id: crypto.randomUUID(), cwd: root }, { env: { ...HOOK_ENV, PATH: bin } });
|
|
351
|
+
assert.strictEqual(result.status, 0, 'slow git must not exhaust the hook timeout');
|
|
352
|
+
assert.ok(Date.now() - start < 10000);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -11,12 +11,16 @@ const os = require('node:os');
|
|
|
11
11
|
const path = require('node:path');
|
|
12
12
|
|
|
13
13
|
const HOOK = path.join(__dirname, '..', 'privacy-block.cjs');
|
|
14
|
+
const HOOK_ENV = { ...process.env };
|
|
15
|
+
delete HOOK_ENV.CLAUDE_PROJECT_DIR;
|
|
16
|
+
delete HOOK_ENV.PROJECT_ROOT;
|
|
14
17
|
|
|
15
18
|
/** Run the hook with a tool payload; return process output. */
|
|
16
19
|
function runHook(payload, cwd) {
|
|
17
20
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
18
21
|
input: JSON.stringify({ ...payload, cwd: cwd || payload.cwd }),
|
|
19
22
|
encoding: 'utf8',
|
|
23
|
+
env: HOOK_ENV,
|
|
20
24
|
});
|
|
21
25
|
return { code: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
|
|
22
26
|
}
|
|
@@ -30,6 +34,29 @@ function permissionDecision(stdout) {
|
|
|
30
34
|
return JSON.parse(stdout).hookSpecificOutput?.permissionDecision || null;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
test('sensitive reads list sorted distinct basenames and the remaining count', () => {
|
|
38
|
+
const dir = tmpDir();
|
|
39
|
+
try {
|
|
40
|
+
const command = 'cat token.json nested/.env secrets.json credentials.json .env';
|
|
41
|
+
const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
42
|
+
assert.strictEqual(code, 0);
|
|
43
|
+
const output = JSON.parse(stdout).hookSpecificOutput;
|
|
44
|
+
assert.strictEqual(output.permissionDecision, 'ask');
|
|
45
|
+
assert.strictEqual(output.permissionDecisionReason,
|
|
46
|
+
'Sensitive file access requires approval: .env, credentials.json, secrets.json (+1 more)');
|
|
47
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('empty stdin asks because sensitive access cannot be evaluated safely', () => {
|
|
51
|
+
const result = spawnSync(process.execPath, [HOOK], { input: '', encoding: 'utf8', env: HOOK_ENV });
|
|
52
|
+
assert.strictEqual(result.status, 0);
|
|
53
|
+
assert.strictEqual(result.stderr, '');
|
|
54
|
+
assert.deepStrictEqual(JSON.parse(result.stdout).hookSpecificOutput, {
|
|
55
|
+
hookEventName: 'PreToolUse', permissionDecision: 'ask',
|
|
56
|
+
permissionDecisionReason: 'Sensitive access could not be evaluated safely.',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
33
60
|
test('asks natively for a direct Read of .env', () => {
|
|
34
61
|
const dir = tmpDir();
|
|
35
62
|
try {
|
|
@@ -17,6 +17,31 @@ const path = require('node:path');
|
|
|
17
17
|
|
|
18
18
|
const HOOK = path.join(__dirname, '..', 'session.cjs');
|
|
19
19
|
|
|
20
|
+
test('session.cjs does not export GIT_URL or remote userinfo', () => {
|
|
21
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-session-remote-'));
|
|
22
|
+
const envFile = path.join(dir, 'env.sh');
|
|
23
|
+
try {
|
|
24
|
+
git(['init', '-q'], dir);
|
|
25
|
+
const userinfo = 'fixture-user:fixture-placeholder';
|
|
26
|
+
const remote = `https://${userinfo}@example.invalid/r.git`;
|
|
27
|
+
const configured = spawnSync('git', ['remote', 'add', 'origin', remote], { cwd: dir, encoding: 'utf8' });
|
|
28
|
+
assert.strictEqual(configured.status, 0, 'fake remote setup must succeed');
|
|
29
|
+
const result = spawnSync(process.execPath, [HOOK], {
|
|
30
|
+
cwd: dir, input: JSON.stringify({ source: 'startup', cwd: dir }), encoding: 'utf8',
|
|
31
|
+
env: { ...process.env, CLAUDE_ENV_FILE: envFile },
|
|
32
|
+
});
|
|
33
|
+
assert.strictEqual(result.status, 0, 'session hook must exit successfully');
|
|
34
|
+
assert.ok(fs.existsSync(envFile), 'session env file must exist');
|
|
35
|
+
const content = fs.readFileSync(envFile, 'utf8');
|
|
36
|
+
assert.deepStrictEqual({
|
|
37
|
+
exportsGitUrl: /^\s*(?:export\s+)?GIT_URL=/m.test(content),
|
|
38
|
+
containsUserInfo: content.includes(userinfo),
|
|
39
|
+
}, { exportsGitUrl: false, containsUserInfo: false });
|
|
40
|
+
} finally {
|
|
41
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
20
45
|
function git(args, cwd) {
|
|
21
46
|
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
22
47
|
if (res.status !== 0) {
|