@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
|
@@ -6,9 +6,13 @@ const { spawnSync } = require('node:child_process');
|
|
|
6
6
|
const fs = require('node:fs');
|
|
7
7
|
const os = require('node:os');
|
|
8
8
|
const path = require('node:path');
|
|
9
|
+
const crypto = require('node:crypto');
|
|
9
10
|
|
|
10
11
|
const CLAUDE_ROOT = path.join(__dirname, '..', '..');
|
|
11
12
|
const STATE_HOOK = path.join(CLAUDE_ROOT, 'hooks', 'state.cjs');
|
|
13
|
+
const HOOK_ENV = { ...process.env };
|
|
14
|
+
delete HOOK_ENV.CLAUDE_PROJECT_DIR;
|
|
15
|
+
delete HOOK_ENV.PROJECT_ROOT;
|
|
12
16
|
const POLICY = require(path.join(CLAUDE_ROOT, 'scripts', 'workflow-policy.cjs'));
|
|
13
17
|
|
|
14
18
|
function processTaskContent(title, status, dependency = 'none') {
|
|
@@ -44,6 +48,9 @@ function inInstalledProcessSpecStateFixture(run) {
|
|
|
44
48
|
fs.mkdirSync(scripts, { recursive: true });
|
|
45
49
|
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
|
|
46
50
|
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
51
|
+
for (const lib of ['project-root.cjs', 'spec-touch.cjs']) {
|
|
52
|
+
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', lib), path.join(hooks, 'lib', lib));
|
|
53
|
+
}
|
|
47
54
|
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
|
|
48
55
|
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
|
|
49
56
|
fs.copyFileSync(
|
|
@@ -92,7 +99,7 @@ function inInstalledProcessSpecStateFixture(run) {
|
|
|
92
99
|
encoding: 'utf8',
|
|
93
100
|
});
|
|
94
101
|
return run({
|
|
95
|
-
cacheFile: path.join(hooks, '.logs',
|
|
102
|
+
cacheFile: path.join(hooks, '.logs', `tollgate-last-${crypto.createHash('sha256').update('session-auth-state').digest('hex').slice(0, 16)}.txt`),
|
|
96
103
|
dir,
|
|
97
104
|
feature,
|
|
98
105
|
hooks,
|
|
@@ -277,6 +284,7 @@ test('Stop snapshot includes tracked modifications and untracked files', () => {
|
|
|
277
284
|
fs.writeFileSync(path.join(dir, 'new-untracked.json'), '{}\n');
|
|
278
285
|
const result = spawnSync(process.execPath, [STATE_HOOK], {
|
|
279
286
|
cwd: dir,
|
|
287
|
+
env: HOOK_ENV,
|
|
280
288
|
input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
|
|
281
289
|
encoding: 'utf8',
|
|
282
290
|
});
|
|
@@ -302,6 +310,7 @@ test('Stop snapshot includes untracked files before the first commit', () => {
|
|
|
302
310
|
|
|
303
311
|
const result = spawnSync(process.execPath, [STATE_HOOK], {
|
|
304
312
|
cwd: dir,
|
|
313
|
+
env: HOOK_ENV,
|
|
305
314
|
input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
|
|
306
315
|
encoding: 'utf8',
|
|
307
316
|
});
|
|
@@ -344,7 +353,7 @@ const PLACEHOLDER_STATE = [
|
|
|
344
353
|
'',
|
|
345
354
|
].join('\n');
|
|
346
355
|
|
|
347
|
-
function sessionStartWith(content, runtimeFolder = '.claude') {
|
|
356
|
+
function sessionStartWith(content, runtimeFolder = '.claude', readingSession = 'start-1') {
|
|
348
357
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-start-'));
|
|
349
358
|
try {
|
|
350
359
|
let hook = STATE_HOOK;
|
|
@@ -352,17 +361,19 @@ function sessionStartWith(content, runtimeFolder = '.claude') {
|
|
|
352
361
|
const hooks = path.join(dir, runtimeFolder, 'hooks');
|
|
353
362
|
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
354
363
|
fs.copyFileSync(STATE_HOOK, path.join(hooks, 'state.cjs'));
|
|
355
|
-
for (const lib of ['parser.cjs', 'runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs']) {
|
|
364
|
+
for (const lib of ['parser.cjs', 'runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs', 'project-root.cjs', 'secret-keywords.cjs']) {
|
|
356
365
|
fs.copyFileSync(path.join(path.dirname(STATE_HOOK), 'lib', lib), path.join(hooks, 'lib', lib));
|
|
357
366
|
}
|
|
358
367
|
hook = path.join(hooks, 'state.cjs');
|
|
359
368
|
}
|
|
360
|
-
const stateFolder = path.join(dir, runtimeFolder, 'session-state'
|
|
369
|
+
const stateFolder = path.join(dir, runtimeFolder, 'session-state',
|
|
370
|
+
...(['.claude', '.omp'].includes(runtimeFolder) ? [crypto.createHash('sha256').update('start-1').digest('hex').slice(0, 16)] : []));
|
|
361
371
|
fs.mkdirSync(stateFolder, { recursive: true });
|
|
362
372
|
fs.writeFileSync(path.join(stateFolder, 'latest.md'), content.replace('GENERATED', new Date().toISOString()));
|
|
363
373
|
const result = spawnSync(process.execPath, [hook], {
|
|
364
374
|
cwd: dir,
|
|
365
|
-
|
|
375
|
+
env: HOOK_ENV,
|
|
376
|
+
input: JSON.stringify({ hook_event_name: 'SessionStart', session_id: readingSession, cwd: dir }),
|
|
366
377
|
encoding: 'utf8',
|
|
367
378
|
});
|
|
368
379
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
@@ -381,6 +392,195 @@ test('SessionStart prints prior context that has a completed todo', () => {
|
|
|
381
392
|
assert.match(sessionStartWith(withTodo), /Prior Execution Context[\s\S]*Ship the statusline/);
|
|
382
393
|
});
|
|
383
394
|
|
|
384
|
-
test('SessionStart under .omp
|
|
385
|
-
assert.
|
|
395
|
+
test('SessionStart under .omp drops placeholder-only context like Claude', () => {
|
|
396
|
+
assert.strictEqual(sessionStartWith(PLACEHOLDER_STATE, '.omp'), '');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('two omp sessions keep separate state', () => {
|
|
400
|
+
const withTodo = PLACEHOLDER_STATE.replace('- (No completed tasks recorded)', '- Ship the statusline');
|
|
401
|
+
assert.match(sessionStartWith(withTodo, '.omp'), /Ship the statusline/, 'the same session reads its own state');
|
|
402
|
+
assert.strictEqual(sessionStartWith(withTodo, '.omp', 'start-2'), '', 'another omp session does not');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
function inSessionStateFixture(run) {
|
|
406
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-sessions-'));
|
|
407
|
+
try {
|
|
408
|
+
const hooks = path.join(dir, '.claude/hooks');
|
|
409
|
+
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
410
|
+
fs.copyFileSync(STATE_HOOK, path.join(hooks, 'state.cjs'));
|
|
411
|
+
for (const lib of ['parser.cjs', 'runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs', 'project-root.cjs', 'secret-keywords.cjs']) {
|
|
412
|
+
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks/lib', lib), path.join(hooks, 'lib', lib));
|
|
413
|
+
}
|
|
414
|
+
const invoke = (payload) => {
|
|
415
|
+
const result = spawnSync(process.execPath, [path.join(hooks, 'state.cjs')], {
|
|
416
|
+
cwd: dir, env: HOOK_ENV, encoding: 'utf8', input: JSON.stringify({ cwd: dir, ...payload }),
|
|
417
|
+
});
|
|
418
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
419
|
+
return result.stdout;
|
|
420
|
+
};
|
|
421
|
+
const write = (session, todo) => {
|
|
422
|
+
const transcript = path.join(dir, `${session}.jsonl`);
|
|
423
|
+
fs.writeFileSync(transcript, JSON.stringify({ message: { content: [{
|
|
424
|
+
type: 'tool_use', id: 'todo-write', name: 'TodoWrite',
|
|
425
|
+
input: { todos: [{ content: todo, status: 'completed' }] },
|
|
426
|
+
}] } }) + '\n');
|
|
427
|
+
invoke({ hook_event_name: 'PostToolUse', session_id: session, tool_name: 'TodoWrite', transcript_path: transcript });
|
|
428
|
+
};
|
|
429
|
+
run({ dir, invoke, write, stateRoot: path.join(dir, '.claude/session-state') });
|
|
430
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function savedSession(stateRoot, session = 'robust-session') {
|
|
434
|
+
return path.join(stateRoot, crypto.createHash('sha256').update(session).digest('hex').slice(0, 16));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
test('Agent refreshes the installed state from transcript todos and is parsed as an agent', () => {
|
|
438
|
+
inSessionStateFixture(({ dir, invoke, stateRoot }) => {
|
|
439
|
+
const transcript = path.join(dir, 'agent.jsonl');
|
|
440
|
+
const agent = { type: 'tool_use', id: 'agent-call', name: 'Agent', input: { subagent_type: 'tester' } };
|
|
441
|
+
fs.writeFileSync(transcript, JSON.stringify({ message: { content: [agent, {
|
|
442
|
+
type: 'tool_use', id: 'todos', name: 'TodoWrite', input: { todos: [{ content: 'Verify agent snapshot', status: 'pending' }] },
|
|
443
|
+
}] } }) + '\n');
|
|
444
|
+
invoke({ hook_event_name: 'PostToolUse', tool_name: 'Agent', session_id: 'robust-session', transcript_path: transcript });
|
|
445
|
+
const file = path.join(savedSession(stateRoot), 'latest.md');
|
|
446
|
+
assert.ok(fs.existsSync(file), 'Agent must create the state snapshot');
|
|
447
|
+
assert.match(fs.readFileSync(file, 'utf8'), /Verify agent snapshot/);
|
|
448
|
+
const agents = new Map();
|
|
449
|
+
const tools = new Map();
|
|
450
|
+
require('../lib/parser.cjs').processEntry({ message: { content: [agent] } }, tools, agents, [], {});
|
|
451
|
+
assert.ok(agents.has('agent-call'));
|
|
452
|
+
assert.ok(!tools.has('agent-call'));
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('Stop persists a bounded summary of the last assistant message', () => {
|
|
457
|
+
inSessionStateFixture(({ invoke, stateRoot }) => {
|
|
458
|
+
invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: 'Verified the change. ' + 'x'.repeat(700) });
|
|
459
|
+
const state = fs.readFileSync(path.join(savedSession(stateRoot), 'latest.md'), 'utf8');
|
|
460
|
+
assert.match(state, /## Latest Assistant Message\nVerified the change\./);
|
|
461
|
+
assert.ok(!state.includes('x'.repeat(601)), 'the summary must be bounded');
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('Stop omits secret-related assistant messages before saving state', () => {
|
|
466
|
+
inSessionStateFixture(({ invoke, stateRoot }) => {
|
|
467
|
+
const message = 'API key handling was discussed in this synthetic test.';
|
|
468
|
+
invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: message });
|
|
469
|
+
const state = fs.readFileSync(path.join(savedSession(stateRoot), 'latest.md'), 'utf8');
|
|
470
|
+
assert.ok(state.includes('[omitted: possible secret]'));
|
|
471
|
+
assert.ok(!state.includes(message));
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('SubagentStop saves the assistant summary and omits secret-related content', () => {
|
|
476
|
+
inSessionStateFixture(({ invoke, stateRoot }) => {
|
|
477
|
+
invoke({ hook_event_name: 'SubagentStop', session_id: 'robust-session', agent_type: 'tester', last_assistant_message: 'Checks passed.' });
|
|
478
|
+
const file = path.join(savedSession(stateRoot), 'latest.md');
|
|
479
|
+
assert.match(fs.readFileSync(file, 'utf8'), /Checks passed\./);
|
|
480
|
+
const message = 'Credentials handling was discussed in this synthetic test.';
|
|
481
|
+
invoke({ hook_event_name: 'SubagentStop', session_id: 'robust-session', agent_type: 'reviewer', last_assistant_message: message });
|
|
482
|
+
const state = fs.readFileSync(file, 'utf8');
|
|
483
|
+
assert.ok(state.includes('[omitted: possible secret]'));
|
|
484
|
+
assert.ok(!state.includes(message));
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('a fresh state write lock skips the snapshot without changing existing bytes', () => {
|
|
489
|
+
inSessionStateFixture(({ invoke, stateRoot }) => {
|
|
490
|
+
const dir = savedSession(stateRoot);
|
|
491
|
+
fs.mkdirSync(path.join(dir, '.write-lock'), { recursive: true });
|
|
492
|
+
const file = path.join(dir, 'latest.md');
|
|
493
|
+
fs.writeFileSync(file, 'Prior state must survive\n');
|
|
494
|
+
const before = fs.readFileSync(file);
|
|
495
|
+
invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: 'New snapshot' });
|
|
496
|
+
assert.deepStrictEqual(fs.readFileSync(file), before);
|
|
497
|
+
assert.ok(fs.existsSync(path.join(dir, '.write-lock')));
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('a stale state write lock is removed and the snapshot is saved', () => {
|
|
502
|
+
inSessionStateFixture(({ invoke, stateRoot }) => {
|
|
503
|
+
const dir = savedSession(stateRoot);
|
|
504
|
+
const lock = path.join(dir, '.write-lock');
|
|
505
|
+
fs.mkdirSync(lock, { recursive: true });
|
|
506
|
+
const old = new Date(Date.now() - 20000);
|
|
507
|
+
fs.utimesSync(lock, old, old);
|
|
508
|
+
invoke({ hook_event_name: 'Stop', session_id: 'robust-session' });
|
|
509
|
+
assert.ok(fs.existsSync(path.join(dir, 'latest.md')));
|
|
510
|
+
assert.ok(!fs.existsSync(lock), 'the stale lock must be released');
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('consecutive PostToolUse snapshots both save their todos and release the write lock', () => {
|
|
515
|
+
inSessionStateFixture(({ write, stateRoot }) => {
|
|
516
|
+
const dir = savedSession(stateRoot);
|
|
517
|
+
for (const todo of ['First saved todo', 'Second saved todo']) {
|
|
518
|
+
write('robust-session', todo);
|
|
519
|
+
assert.ok(fs.readFileSync(path.join(dir, 'latest.md'), 'utf8').includes(todo));
|
|
520
|
+
assert.ok(!fs.existsSync(path.join(dir, '.write-lock')));
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('malformed state input exits successfully and records a crash', () => {
|
|
526
|
+
inSessionStateFixture(({ dir }) => {
|
|
527
|
+
const hooks = path.join(dir, '.claude/hooks');
|
|
528
|
+
const result = spawnSync(process.execPath, [path.join(hooks, 'state.cjs')], { cwd: dir, input: '{', encoding: 'utf8' });
|
|
529
|
+
assert.strictEqual(result.status, 0);
|
|
530
|
+
const file = path.join(hooks, '.logs/hook-log.jsonl');
|
|
531
|
+
assert.ok(fs.existsSync(file), 'the asynchronous failure must be logged');
|
|
532
|
+
const rows = fs.readFileSync(file, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
533
|
+
assert.strictEqual(rows.length, 1);
|
|
534
|
+
assert.strictEqual(rows[0].hook, 'state');
|
|
535
|
+
assert.strictEqual(rows[0].status, 'crash');
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('a new Claude session startup does not inherit another session context', () => {
|
|
540
|
+
inSessionStateFixture(({ write, invoke }) => {
|
|
541
|
+
write('session-a', 'Completed work for session A');
|
|
542
|
+
const output = invoke({ hook_event_name: 'SessionStart', session_id: 'session-b', source: 'startup' });
|
|
543
|
+
assert.doesNotMatch(output, /Prior Execution Context/);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('a Claude session resumes its own saved todo', () => {
|
|
548
|
+
inSessionStateFixture(({ write, invoke, stateRoot }) => {
|
|
549
|
+
write('session-a', 'Completed work for session A');
|
|
550
|
+
const key = crypto.createHash('sha256').update('session-a').digest('hex').slice(0, 16);
|
|
551
|
+
assert.match(fs.readFileSync(path.join(stateRoot, key, 'latest.md'), 'utf8'), /Completed work for session A/);
|
|
552
|
+
const output = invoke({ hook_event_name: 'SessionStart', session_id: 'session-a', source: 'resume' });
|
|
553
|
+
assert.match(output, /Prior Execution Context[\s\S]*Completed work for session A/);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('two Claude sessions resume only their own completed todos', () => {
|
|
558
|
+
inSessionStateFixture(({ write, invoke }) => {
|
|
559
|
+
write('session-a', 'Completed work for session A');
|
|
560
|
+
write('session-b', 'Completed work for session B');
|
|
561
|
+
for (const [session, own, other] of [['session-a', 'session A', 'session B'], ['session-b', 'session B', 'session A']]) {
|
|
562
|
+
const output = invoke({ hook_event_name: 'SessionStart', session_id: session, source: 'resume' });
|
|
563
|
+
const verified = output.split('## What Worked (Verified)')[1]?.split("## What's Left")[0] || '';
|
|
564
|
+
assert.match(output, /Prior Execution Context/);
|
|
565
|
+
assert.ok(verified.includes(own));
|
|
566
|
+
assert.ok(!verified.includes(other));
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('a session-less payload still reads project-level context', () => {
|
|
572
|
+
inSessionStateFixture(({ stateRoot, invoke }) => {
|
|
573
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
574
|
+
fs.writeFileSync(path.join(stateRoot, 'latest.md'), PLACEHOLDER_STATE.replace('GENERATED', new Date().toISOString())
|
|
575
|
+
.replace('- (No completed tasks recorded)', '- Legacy project todo'));
|
|
576
|
+
assert.match(invoke({ hook_event_name: 'SessionStart' }), /Prior Execution Context[\s\S]*Legacy project todo/);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test('Claude SessionStart does not create state directories', () => {
|
|
581
|
+
inSessionStateFixture(({ stateRoot, invoke }) => {
|
|
582
|
+
assert.strictEqual(fs.existsSync(stateRoot), false);
|
|
583
|
+
assert.strictEqual(invoke({ hook_event_name: 'SessionStart', session_id: 'new-session', source: 'startup' }).trim(), '');
|
|
584
|
+
assert.strictEqual(fs.existsSync(stateRoot), false);
|
|
585
|
+
});
|
|
386
586
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
async function fixture(run) {
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-update-check-'));
|
|
12
|
+
try {
|
|
13
|
+
fs.writeFileSync(path.join(dir, 'cafekit.json'), JSON.stringify({ version: '1.2.3' }));
|
|
14
|
+
const cache = path.join(dir, '.cafekit-update-cache.json');
|
|
15
|
+
await run({ dir, cache, check: (...args) => require('../lib/update-check.cjs').checkCafeKitUpdate(...args) });
|
|
16
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('a newer version produces the existing notice and caches only version data', async () => {
|
|
20
|
+
await fixture(async ({ dir, cache, check }) => {
|
|
21
|
+
const notice = await check(dir, async () => '1.3.0');
|
|
22
|
+
assert.equal(notice, '\n⚡ CafeKit update available: 1.2.3 → 1.3.0\n Run: npx @haposoft/cafekit\n');
|
|
23
|
+
const saved = JSON.parse(fs.readFileSync(cache, 'utf8'));
|
|
24
|
+
assert.deepEqual(Object.keys(saved).sort(), ['latest', 'ts']);
|
|
25
|
+
assert.equal(saved.latest, '1.3.0');
|
|
26
|
+
assert.equal(await check(dir, async () => { throw Error('fresh cache must avoid fetch'); }), notice);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the installed version produces no update notice', async () => {
|
|
31
|
+
await fixture(async ({ dir, check }) => {
|
|
32
|
+
assert.equal(await check(dir, async () => '1.2.3'), '');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('an invalid cached latest version is refetched instead of printed', async () => {
|
|
37
|
+
await fixture(async ({ dir, cache, check }) => {
|
|
38
|
+
fs.writeFileSync(cache, JSON.stringify({ ts: Date.now(), latest: '9.0.0\nInjected cache text' }));
|
|
39
|
+
let calls = 0;
|
|
40
|
+
assert.equal(await check(dir, async () => { calls++; return '1.2.3'; }), '');
|
|
41
|
+
assert.equal(calls, 1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a cache timestamp in the future requires a fresh version fetch', async () => {
|
|
46
|
+
await fixture(async ({ dir, cache, check }) => {
|
|
47
|
+
fs.writeFileSync(cache, JSON.stringify({ ts: Date.now() + 60000, latest: '9.0.0' }));
|
|
48
|
+
let calls = 0;
|
|
49
|
+
assert.equal(await check(dir, async () => { calls++; return '1.2.3'; }), '');
|
|
50
|
+
assert.equal(calls, 1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a legacy notice cache cannot inject its stored text into the session', async () => {
|
|
55
|
+
await fixture(async ({ dir, cache, check }) => {
|
|
56
|
+
const injected = 'Untrusted cached instruction';
|
|
57
|
+
fs.writeFileSync(cache, JSON.stringify({ ts: Date.now(), notice: injected }));
|
|
58
|
+
let calls = 0;
|
|
59
|
+
const notice = await check(dir, async () => { calls++; return '1.3.0'; });
|
|
60
|
+
assert.equal(calls, 1);
|
|
61
|
+
assert.ok(!notice.includes(injected));
|
|
62
|
+
assert.match(notice, /1\.2\.3 → 1\.3\.0/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an interrupted version response fails open without an unhandled error', () => {
|
|
67
|
+
for (const event of ['error', 'aborted']) {
|
|
68
|
+
const script = `
|
|
69
|
+
const { EventEmitter } = require('events');
|
|
70
|
+
require('https').get = (url, options, callback) => {
|
|
71
|
+
const request = new EventEmitter();
|
|
72
|
+
request.destroy = () => {};
|
|
73
|
+
process.nextTick(() => {
|
|
74
|
+
const response = new EventEmitter();
|
|
75
|
+
callback(response);
|
|
76
|
+
response.emit(${JSON.stringify(event)}, new Error('synthetic response reset'));
|
|
77
|
+
});
|
|
78
|
+
return request;
|
|
79
|
+
};
|
|
80
|
+
require(${JSON.stringify(path.resolve(__dirname, '../lib/update-check.cjs'))}).fetchLatestVersion()
|
|
81
|
+
.then(value => { if (value !== null) process.exit(3); process.stdout.write('null'); });
|
|
82
|
+
`;
|
|
83
|
+
const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 5000 });
|
|
84
|
+
assert.equal(result.status, 0, result.stderr);
|
|
85
|
+
assert.equal(result.stderr, '');
|
|
86
|
+
assert.equal(result.stdout, 'null');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
@@ -17,6 +17,7 @@ try {
|
|
|
17
17
|
|
|
18
18
|
/** Read <runtime>/runtime.json */
|
|
19
19
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
20
|
+
const { projectRoot } = require('./lib/project-root.cjs');
|
|
20
21
|
function readRuntime(cwd) {
|
|
21
22
|
try {
|
|
22
23
|
const p = runtimePath(cwd, 'runtime.json');
|
|
@@ -43,10 +44,8 @@ try {
|
|
|
43
44
|
|
|
44
45
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
45
46
|
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const agentCwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
|
|
49
|
-
const runtime = readRuntime(agentCwd);
|
|
47
|
+
const root = projectRoot(payload);
|
|
48
|
+
const runtime = readRuntime(root);
|
|
50
49
|
if (runtime === null) process.exit(0);
|
|
51
50
|
|
|
52
51
|
// Language config from runtime.json
|
|
@@ -56,7 +55,7 @@ try {
|
|
|
56
55
|
const effectThink = thinkLang || (respondLang ? 'en' : '');
|
|
57
56
|
|
|
58
57
|
// Resolve paths from env (set by session.cjs) or runtime defaults
|
|
59
|
-
const baseDir =
|
|
58
|
+
const baseDir = root;
|
|
60
59
|
const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
|
|
61
60
|
const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
|
|
62
61
|
const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
|
|
@@ -74,10 +73,12 @@ try {
|
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
// Python venv (optional — if <skills>/.venv exists)
|
|
77
|
-
const venv = resolveVenv(
|
|
76
|
+
const venv = resolveVenv(root);
|
|
78
77
|
|
|
79
78
|
lines.push('## Rules');
|
|
80
79
|
lines.push(`- Specs → ${specsPath}/ | Plans → ${plansPath}/ | Docs → ${docsPath}/`);
|
|
80
|
+
lines.push('- YAGNI · KISS · DRY');
|
|
81
|
+
lines.push('- Be concise. List unresolved questions at end.');
|
|
81
82
|
if (venv) {
|
|
82
83
|
lines.push(`- Python in ${skillsDir()}/: use \`${venv}\``);
|
|
83
84
|
lines.push('- Never use global pip install');
|
|
@@ -15,50 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
try {
|
|
17
17
|
const fs = require('fs');
|
|
18
|
-
const
|
|
19
|
-
const {
|
|
20
|
-
|
|
21
|
-
// Directories that should never be read (too large / irrelevant to LLM)
|
|
22
|
-
const BLOCKED_DIRS = [
|
|
23
|
-
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
24
|
-
'__pycache__', '.venv', 'venv',
|
|
25
|
-
'vendor', 'target',
|
|
26
|
-
'.git', 'coverage', '.nyc_output',
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
// Glob patterns too broad to be useful
|
|
30
|
-
const BROAD_GLOB = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
|
|
31
|
-
|
|
32
|
-
// Commands that are always allowed (build tools, package managers, venv)
|
|
33
|
-
const ALLOWED_CMD = /^(npm|pnpm|yarn|bun|npx|pnpx|bunx|tsc|vite|esbuild|webpack|rollup|turbo|nx|jest|vitest|eslint|prettier|go|cargo|make|mvn|gradle|dotnet|docker|kubectl|helm|python3?|pip|uv|deno|bundle|rake|php|composer|ruby|mix)\b/;
|
|
34
|
-
const VENV_EXEC = /(^|[\/\\])\.?venv[\/\\](bin|Scripts)[\/\\]/;
|
|
35
|
-
const VENV_CREATE = /^(python3?|py)\s+.*-m\s+venv\s+|^uv\s+venv(\s|$)/;
|
|
36
|
-
|
|
37
|
-
function isAllowedCommand(cmd) {
|
|
38
|
-
const s = cmd.trim().replace(/^(\w+=\S+\s+)+/, '').replace(/^(sudo|env|time)\s+/, '').trim();
|
|
39
|
-
return ALLOWED_CMD.test(s) || VENV_EXEC.test(s) || VENV_CREATE.test(s);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isBlockedPath(p) {
|
|
43
|
-
return p.replace(/\\/g, '/').split('/').some(seg => BLOCKED_DIRS.includes(seg));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isBroadGlob(p) {
|
|
47
|
-
return BROAD_GLOB.some(r => r.test(p.trim()));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function extractPaths(toolName, input) {
|
|
51
|
-
const out = [];
|
|
52
|
-
if (!input) return out;
|
|
53
|
-
if (input.file_path) out.push(input.file_path);
|
|
54
|
-
if (input.path) out.push(input.path);
|
|
55
|
-
if (input.pattern) out.push(input.pattern);
|
|
56
|
-
if (typeof input.command === 'string') {
|
|
57
|
-
const m = input.command.match(/(?:cat|ls|find|head|tail)\s+(\S+)/g);
|
|
58
|
-
if (m) m.forEach(s => out.push(s.trim().split(/\s+/).pop()));
|
|
59
|
-
}
|
|
60
|
-
return out.filter(Boolean);
|
|
61
|
-
}
|
|
18
|
+
const { BLOCKED_DIRS, inspectInput } = require('./lib/inspect-paths.cjs');
|
|
19
|
+
const { projectRoot } = require('./lib/project-root.cjs');
|
|
62
20
|
|
|
63
21
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
64
22
|
function readRuntime(cwd) {
|
|
@@ -97,37 +55,31 @@ function denyWithReason(reason) {
|
|
|
97
55
|
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
98
56
|
const toolName = data.tool_name || '';
|
|
99
57
|
const toolInput = data.tool_input || {};
|
|
100
|
-
|
|
101
|
-
|
|
58
|
+
let cwd = data.cwd || process.cwd();
|
|
59
|
+
try { cwd = fs.realpathSync(cwd); } catch { /* retain the payload cwd when unavailable */ }
|
|
60
|
+
const root = projectRoot(data);
|
|
61
|
+
const runtime = readRuntime(root);
|
|
102
62
|
|
|
103
63
|
if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) process.exit(0);
|
|
104
64
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Broad glob check
|
|
112
|
-
if (toolInput.pattern && isBroadGlob(toolInput.pattern)) {
|
|
65
|
+
const result = inspectInput(toolName, toolInput, {
|
|
66
|
+
cwd, projectRoot: root
|
|
67
|
+
});
|
|
68
|
+
if (result.broadGlob) {
|
|
113
69
|
denyWithReason(
|
|
114
70
|
`SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n` +
|
|
115
|
-
`Requested Pattern: ${
|
|
71
|
+
`Requested Pattern: ${result.broadGlob}\n\n` +
|
|
116
72
|
`Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).`
|
|
117
73
|
);
|
|
118
74
|
}
|
|
119
75
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for (const p of paths) {
|
|
123
|
-
if (isBlockedPath(p)) {
|
|
124
|
-
const blocked = p.replace(/\\/g, '/').split('/').find(s => BLOCKED_DIRS.includes(s));
|
|
76
|
+
if (result.blockedPath) {
|
|
77
|
+
const { requested, blocked } = result.blockedPath;
|
|
125
78
|
denyWithReason(
|
|
126
79
|
`SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n` +
|
|
127
|
-
`Requested Path: ${
|
|
128
|
-
`Restricted zones: ${BLOCKED_DIRS.join(', ')}`
|
|
80
|
+
`Requested Path: ${requested}\n` +
|
|
81
|
+
`Restricted zones: ${[...BLOCKED_DIRS].join(', ')}`
|
|
129
82
|
);
|
|
130
|
-
}
|
|
131
83
|
}
|
|
132
84
|
|
|
133
85
|
process.exit(0);
|