@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.
Files changed (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. 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', 'tollgate-last.txt'),
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
- input: JSON.stringify({ hook_event_name: 'SessionStart', session_id: 'start-1', cwd: dir }),
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 keeps printing placeholder-only context', () => {
385
- assert.match(sessionStartWith(PLACEHOLDER_STATE, '.omp'), /Prior Execution Context/);
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
- // Use payload.cwd for monorepo support — subagent may run in a different dir
47
- const payloadCwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
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 = agentCwd;
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(agentCwd);
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 path = require('path');
19
- const { execSync } = require('child_process');
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
- const cwd = data.cwd || process.cwd();
101
- const runtime = readRuntime(cwd);
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
- // Allow all-permitted bash commands immediately
106
- if (toolInput.command) {
107
- const cmds = toolInput.command.split(/\s*(?:&&|\|\||;)\s*/).filter(Boolean);
108
- if (cmds.every(c => isAllowedCommand(c))) process.exit(0);
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: ${toolInput.pattern}\n\n` +
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
- // Blocked directory check
121
- const paths = extractPaths(toolName, toolInput);
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: ${p}\n` +
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);