throughline 0.4.7 → 0.4.9

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.
@@ -28,7 +28,14 @@ import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
28
28
  import { defaultCodexHome, listCodexThreadCandidates } from '../codex-thread-index.mjs';
29
29
  import { getDb } from '../db.mjs';
30
30
  import { detectJsoncFeatures, findMonitorTaskIndex, isMonitorTaskBroken } from '../vscode-task.mjs';
31
- import { buildCodexStopHookCommand, isThroughlineCodexStopCommand } from './install.mjs';
31
+ import {
32
+ buildCodexPostToolUseHookCommand,
33
+ buildCodexStopHookCommand,
34
+ buildCodexUserPromptSubmitHookCommand,
35
+ isThroughlineCodexHookCommand,
36
+ isThroughlineCodexPostToolUseCommand,
37
+ isThroughlineCodexStopCommand,
38
+ } from './install.mjs';
32
39
 
33
40
  const GREEN = '\x1b[32m✓\x1b[0m';
34
41
  const RED = '\x1b[31m✗\x1b[0m';
@@ -390,23 +397,126 @@ function countCapturedCodexSessions(db, projectPath) {
390
397
  }
391
398
  }
392
399
 
400
+ function parseCodexTrustedHookState(configText) {
401
+ const trustedKeys = new Set();
402
+ let currentKey = null;
403
+ let currentTrusted = false;
404
+
405
+ function flush() {
406
+ if (currentKey && currentTrusted) trustedKeys.add(currentKey);
407
+ }
408
+
409
+ for (const line of configText.split(/\r?\n/)) {
410
+ const section = line.match(/^\s*\[hooks\.state\."([^"]+)"\]\s*$/);
411
+ if (section) {
412
+ flush();
413
+ currentKey = section[1];
414
+ currentTrusted = false;
415
+ continue;
416
+ }
417
+ if (!currentKey) continue;
418
+ if (/^\s*\[/.test(line)) {
419
+ flush();
420
+ currentKey = null;
421
+ currentTrusted = false;
422
+ continue;
423
+ }
424
+ if (/^\s*trusted_hash\s*=\s*"sha256:[^"]+"\s*$/.test(line)) {
425
+ currentTrusted = true;
426
+ }
427
+ }
428
+ flush();
429
+ return trustedKeys;
430
+ }
431
+
432
+ function listHooksWithTrust(parsed, eventName, eventKey, hooksPath, trustedStateKeys) {
433
+ const groups = parsed.hooks?.[eventName] ?? [];
434
+ return groups.flatMap((group, groupIndex) =>
435
+ (group.hooks ?? []).map((hook, hookIndex) => {
436
+ const trustKey = `${hooksPath}:${eventKey}:${groupIndex}:${hookIndex}`;
437
+ return {
438
+ ...hook,
439
+ throughlineDoctorTrustKey: trustKey,
440
+ throughlineDoctorTrusted: trustedStateKeys.has(trustKey),
441
+ };
442
+ }),
443
+ );
444
+ }
445
+
446
+ function summarizeHookTrust(hooks, { hooksFeatureEnabled, codexHooksFeatureEnabled } = {}) {
447
+ if (hooks.length === 0) {
448
+ return {
449
+ status: 'no managed hooks',
450
+ trustedCount: 0,
451
+ totalCount: 0,
452
+ };
453
+ }
454
+ const trustedCount = hooks.filter(h => h.throughlineDoctorTrusted).length;
455
+ if (trustedCount === hooks.length) {
456
+ return {
457
+ status: 'trusted',
458
+ trustedCount,
459
+ totalCount: hooks.length,
460
+ };
461
+ }
462
+ if (hooksFeatureEnabled) {
463
+ return {
464
+ status: `${trustedCount}/${hooks.length} trusted - accept hooks in Codex menu`,
465
+ trustedCount,
466
+ totalCount: hooks.length,
467
+ };
468
+ }
469
+ if (codexHooksFeatureEnabled) {
470
+ return {
471
+ status: 'not recorded (legacy codex_hooks)',
472
+ trustedCount,
473
+ totalCount: hooks.length,
474
+ };
475
+ }
476
+ return {
477
+ status: 'not trusted',
478
+ trustedCount,
479
+ totalCount: hooks.length,
480
+ };
481
+ }
482
+
393
483
  function readCodexHookDiagnosis(codexHome) {
394
484
  const hooksPath = join(codexHome, 'hooks.json');
395
485
  const configPath = join(codexHome, 'config.toml');
396
- const expectedCommand = buildCodexStopHookCommand();
486
+ const expectedStopCommand = buildCodexStopHookCommand();
487
+ const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
488
+ const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
397
489
  const out = {
398
490
  hooksPath,
399
491
  configPath,
400
- expectedCommand,
492
+ expectedStopCommand,
493
+ expectedPromptCommand,
494
+ expectedPostToolUseCommand,
401
495
  hooksReadable: false,
402
496
  featureEnabled: false,
497
+ codexHooksFeatureEnabled: false,
498
+ hooksFeatureEnabled: false,
499
+ trustedStateKeys: new Set(),
500
+ managedHookTrust: {
501
+ status: 'no managed hooks',
502
+ trustedCount: 0,
503
+ totalCount: 0,
504
+ },
505
+ managedPromptHooks: [],
506
+ legacyManagedPromptHooks: [],
507
+ managedPostToolUseHooks: [],
508
+ legacyManagedPostToolUseHooks: [],
403
509
  managedStopHooks: [],
404
510
  legacyManagedStopHooks: [],
405
511
  };
406
512
 
407
513
  if (existsSync(configPath)) {
408
514
  try {
409
- out.featureEnabled = /^\s*codex_hooks\s*=\s*true\s*$/m.test(readFileSync(configPath, 'utf8'));
515
+ const config = readFileSync(configPath, 'utf8');
516
+ out.codexHooksFeatureEnabled = /^\s*codex_hooks\s*=\s*true\s*$/m.test(config);
517
+ out.hooksFeatureEnabled = /^\s*hooks\s*=\s*true\s*$/m.test(config);
518
+ out.featureEnabled = out.codexHooksFeatureEnabled || out.hooksFeatureEnabled;
519
+ out.trustedStateKeys = parseCodexTrustedHookState(config);
410
520
  } catch {
411
521
  out.featureEnabled = false;
412
522
  }
@@ -421,9 +531,37 @@ function readCodexHookDiagnosis(codexHome) {
421
531
  }
422
532
 
423
533
  out.hooksReadable = true;
424
- const stopHooks = (parsed.hooks?.Stop ?? []).flatMap(group => group.hooks ?? []);
534
+ const promptHooks = listHooksWithTrust(
535
+ parsed,
536
+ 'UserPromptSubmit',
537
+ 'user_prompt_submit',
538
+ hooksPath,
539
+ out.trustedStateKeys,
540
+ );
541
+ const postToolUseHooks = listHooksWithTrust(
542
+ parsed,
543
+ 'PostToolUse',
544
+ 'post_tool_use',
545
+ hooksPath,
546
+ out.trustedStateKeys,
547
+ );
548
+ const stopHooks = listHooksWithTrust(parsed, 'Stop', 'stop', hooksPath, out.trustedStateKeys);
549
+ out.managedPromptHooks = promptHooks.filter(h => isThroughlineCodexHookCommand(h.command));
550
+ out.legacyManagedPromptHooks = out.managedPromptHooks.filter(h => h.command !== expectedPromptCommand);
551
+ out.managedPostToolUseHooks = postToolUseHooks.filter(h => isThroughlineCodexPostToolUseCommand(h.command));
552
+ out.legacyManagedPostToolUseHooks = out.managedPostToolUseHooks.filter(
553
+ h => h.command !== expectedPostToolUseCommand,
554
+ );
425
555
  out.managedStopHooks = stopHooks.filter(h => isThroughlineCodexStopCommand(h.command));
426
- out.legacyManagedStopHooks = out.managedStopHooks.filter(h => h.command !== expectedCommand);
556
+ out.legacyManagedStopHooks = out.managedStopHooks.filter(h => h.command !== expectedStopCommand);
557
+ out.managedHookTrust = summarizeHookTrust(
558
+ [
559
+ ...out.managedPromptHooks,
560
+ ...out.managedPostToolUseHooks,
561
+ ...out.managedStopHooks,
562
+ ],
563
+ out,
564
+ );
427
565
  return out;
428
566
  }
429
567
 
@@ -456,6 +594,35 @@ function runCodexDiagnosis({
456
594
  console.log(` project: ${cwd}`);
457
595
  console.log(` CODEX_HOME: ${codexHome}`);
458
596
  console.log(` Codex hooks feature: ${hookDiagnosis.featureEnabled ? 'enabled' : 'not enabled'}`);
597
+ console.log(` Codex hook trust: ${hookDiagnosis.managedHookTrust.status}`);
598
+ console.log(` Codex UserPrompt hook: ${
599
+ hookDiagnosis.managedPromptHooks.length === 0
600
+ ? 'not registered'
601
+ : hookDiagnosis.legacyManagedPromptHooks.length > 0
602
+ ? 'legacy command needs reinstall'
603
+ : 'registered'
604
+ }`);
605
+ if (hookDiagnosis.managedPromptHooks.length > 0) {
606
+ const h = hookDiagnosis.managedPromptHooks[0];
607
+ console.log(` command: ${h.command}`);
608
+ console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
609
+ console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
610
+ console.log(` trusted: ${h.throughlineDoctorTrusted ? 'yes' : 'no'}`);
611
+ }
612
+ console.log(` Codex PostTool hook: ${
613
+ hookDiagnosis.managedPostToolUseHooks.length === 0
614
+ ? 'not registered'
615
+ : hookDiagnosis.legacyManagedPostToolUseHooks.length > 0
616
+ ? 'legacy command needs reinstall'
617
+ : 'registered'
618
+ }`);
619
+ if (hookDiagnosis.managedPostToolUseHooks.length > 0) {
620
+ const h = hookDiagnosis.managedPostToolUseHooks[0];
621
+ console.log(` command: ${h.command}`);
622
+ console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
623
+ console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
624
+ console.log(` trusted: ${h.throughlineDoctorTrusted ? 'yes' : 'no'}`);
625
+ }
459
626
  console.log(` Codex Stop hook: ${
460
627
  hookDiagnosis.managedStopHooks.length === 0
461
628
  ? 'not registered'
@@ -468,6 +635,7 @@ function runCodexDiagnosis({
468
635
  console.log(` command: ${h.command}`);
469
636
  console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
470
637
  console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
638
+ console.log(` trusted: ${h.throughlineDoctorTrusted ? 'yes' : 'no'}`);
471
639
  }
472
640
  console.log(` VSCode monitor task: ${monitorTaskDiagnosis.status}`);
473
641
  if (monitorTaskDiagnosis.path) {
@@ -155,6 +155,8 @@ test('runCodexDiagnosis: reports env thread and captured DB session', () => {
155
155
 
156
156
  assert.match(output, /\[Codex primary\]/);
157
157
  assert.match(output, /Codex hooks feature:\s+not enabled/);
158
+ assert.match(output, /Codex UserPrompt hook:\s+not registered/);
159
+ assert.match(output, /Codex PostTool hook:\s+not registered/);
158
160
  assert.match(output, /Codex Stop hook:\s+not registered/);
159
161
  assert.match(output, /VSCode monitor task:\s+not registered/);
160
162
  assert.match(output, /created by the next VSCode hook event/);
@@ -328,7 +330,7 @@ test('buildCodexContextRefreshDiagnosis keeps ready label when restore safety is
328
330
  }
329
331
  });
330
332
 
331
- test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', () => {
333
+ test('readCodexHookDiagnosis detects Codex prompt and Stop hooks', () => {
332
334
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
333
335
  try {
334
336
  mkdirSync(join(codexHome), { recursive: true });
@@ -337,6 +339,30 @@ test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', (
337
339
  JSON.stringify(
338
340
  {
339
341
  hooks: {
342
+ UserPromptSubmit: [
343
+ {
344
+ hooks: [
345
+ {
346
+ type: 'command',
347
+ command: '/usr/bin/node /pkg/bin/throughline.mjs codex-hook user-prompt-submit',
348
+ timeoutSec: 30,
349
+ async: false,
350
+ },
351
+ ],
352
+ },
353
+ ],
354
+ PostToolUse: [
355
+ {
356
+ hooks: [
357
+ {
358
+ type: 'command',
359
+ command: '/usr/bin/node /pkg/bin/throughline.mjs codex-hook post-tool-use',
360
+ timeoutSec: 30,
361
+ async: false,
362
+ },
363
+ ],
364
+ },
365
+ ],
340
366
  Stop: [
341
367
  {
342
368
  hooks: [
@@ -355,11 +381,41 @@ test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', (
355
381
  2,
356
382
  ) + '\n',
357
383
  );
358
- writeFileSync(join(codexHome, 'config.toml'), '[features]\ncodex_hooks = true\n');
384
+ const hooksPath = join(codexHome, 'hooks.json');
385
+ writeFileSync(
386
+ join(codexHome, 'config.toml'),
387
+ [
388
+ '[features]',
389
+ 'codex_hooks = true',
390
+ 'hooks = true',
391
+ '',
392
+ `[hooks.state."${hooksPath}:user_prompt_submit:0:0"]`,
393
+ 'trusted_hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"',
394
+ '',
395
+ `[hooks.state."${hooksPath}:post_tool_use:0:0"]`,
396
+ 'trusted_hash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"',
397
+ '',
398
+ `[hooks.state."${hooksPath}:stop:0:0"]`,
399
+ 'trusted_hash = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"',
400
+ '',
401
+ ].join('\n'),
402
+ );
359
403
 
360
404
  const diagnosis = readCodexHookDiagnosis(codexHome);
361
405
  assert.equal(diagnosis.featureEnabled, true);
406
+ assert.equal(diagnosis.codexHooksFeatureEnabled, true);
407
+ assert.equal(diagnosis.hooksFeatureEnabled, true);
408
+ assert.equal(diagnosis.managedHookTrust.status, 'trusted');
409
+ assert.equal(diagnosis.managedHookTrust.trustedCount, 3);
410
+ assert.equal(diagnosis.managedHookTrust.totalCount, 3);
411
+ assert.equal(diagnosis.managedPromptHooks.length, 1);
412
+ assert.equal(diagnosis.managedPromptHooks[0].throughlineDoctorTrusted, true);
413
+ assert.equal(diagnosis.legacyManagedPromptHooks.length, 1);
414
+ assert.equal(diagnosis.managedPostToolUseHooks.length, 1);
415
+ assert.equal(diagnosis.managedPostToolUseHooks[0].throughlineDoctorTrusted, true);
416
+ assert.equal(diagnosis.legacyManagedPostToolUseHooks.length, 1);
362
417
  assert.equal(diagnosis.managedStopHooks.length, 1);
418
+ assert.equal(diagnosis.managedStopHooks[0].throughlineDoctorTrusted, true);
363
419
  assert.equal(diagnosis.legacyManagedStopHooks.length, 1);
364
420
  } finally {
365
421
  rmSync(codexHome, { recursive: true, force: true });
@@ -9,6 +9,8 @@ const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
9
9
  const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
10
10
  const CODEX_HELP_COMMANDS = [
11
11
  'throughline codex-capture',
12
+ 'throughline codex-hook user-prompt-submit',
13
+ 'throughline codex-hook post-tool-use',
12
14
  'throughline codex-hook stop',
13
15
  'throughline codex-summarize',
14
16
  'throughline codex-resume',
@@ -54,6 +54,8 @@ const SC_HOOKS = {
54
54
 
55
55
  const CODEX_COMMANDS = [
56
56
  'throughline codex-hook stop',
57
+ 'throughline codex-hook user-prompt-submit',
58
+ 'throughline codex-hook post-tool-use',
57
59
  ];
58
60
 
59
61
  function quoteCommandPath(p) {
@@ -67,6 +69,36 @@ export function buildCodexStopHookCommand({
67
69
  return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook stop`;
68
70
  }
69
71
 
72
+ export function buildCodexUserPromptSubmitHookCommand({
73
+ nodePath = process.execPath,
74
+ cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
75
+ } = {}) {
76
+ return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook user-prompt-submit`;
77
+ }
78
+
79
+ export function buildCodexPostToolUseHookCommand({
80
+ nodePath = process.execPath,
81
+ cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
82
+ } = {}) {
83
+ return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook post-tool-use`;
84
+ }
85
+
86
+ export function isThroughlineCodexHookCommand(command) {
87
+ if (typeof command !== 'string') return false;
88
+ const normalized = command.replace(/["']/g, '');
89
+ return (
90
+ normalized === 'throughline codex-hook stop' ||
91
+ normalized === 'throughline codex-hook user-prompt-submit' ||
92
+ normalized === 'throughline codex-hook post-tool-use' ||
93
+ normalized.includes('throughline codex-hook stop') ||
94
+ normalized.includes('throughline codex-hook user-prompt-submit') ||
95
+ normalized.includes('throughline codex-hook post-tool-use') ||
96
+ normalized.includes('throughline.mjs codex-hook stop') ||
97
+ normalized.includes('throughline.mjs codex-hook user-prompt-submit') ||
98
+ normalized.includes('throughline.mjs codex-hook post-tool-use')
99
+ );
100
+ }
101
+
70
102
  export function isThroughlineCodexStopCommand(command) {
71
103
  if (typeof command !== 'string') return false;
72
104
  const normalized = command.replace(/["']/g, '');
@@ -77,8 +109,40 @@ export function isThroughlineCodexStopCommand(command) {
77
109
  );
78
110
  }
79
111
 
112
+ export function isThroughlineCodexPostToolUseCommand(command) {
113
+ if (typeof command !== 'string') return false;
114
+ const normalized = command.replace(/["']/g, '');
115
+ return (
116
+ normalized === 'throughline codex-hook post-tool-use' ||
117
+ normalized.includes('throughline codex-hook post-tool-use') ||
118
+ normalized.includes('throughline.mjs codex-hook post-tool-use')
119
+ );
120
+ }
121
+
80
122
  function createCodexHooks() {
81
123
  return {
124
+ UserPromptSubmit: {
125
+ hooks: [
126
+ {
127
+ type: 'command',
128
+ command: buildCodexUserPromptSubmitHookCommand(),
129
+ timeoutSec: 30,
130
+ async: false,
131
+ statusMessage: null,
132
+ },
133
+ ],
134
+ },
135
+ PostToolUse: {
136
+ hooks: [
137
+ {
138
+ type: 'command',
139
+ command: buildCodexPostToolUseHookCommand(),
140
+ timeoutSec: 30,
141
+ async: false,
142
+ statusMessage: null,
143
+ },
144
+ ],
145
+ },
82
146
  Stop: {
83
147
  hooks: [
84
148
  {
@@ -253,11 +317,19 @@ function ensureCodexHooksFeature(configPath) {
253
317
  const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
254
318
  const lines = existing.split(/\r?\n/);
255
319
  const sectionStart = lines.findIndex((line) => line.trim() === '[features]');
320
+ const ensureFeatureLine = (featureLines, name) => {
321
+ const idx = featureLines.findIndex((line) => new RegExp(`^\\s*${name}\\s*=`).test(line));
322
+ if (idx === -1) {
323
+ featureLines.push(`${name} = true`);
324
+ } else {
325
+ featureLines[idx] = `${name} = true`;
326
+ }
327
+ };
256
328
  let updated;
257
329
 
258
330
  if (sectionStart === -1) {
259
331
  const prefix = existing.trimEnd();
260
- updated = `${prefix}${prefix ? '\n\n' : ''}[features]\ncodex_hooks = true\n`;
332
+ updated = `${prefix}${prefix ? '\n\n' : ''}[features]\ncodex_hooks = true\nhooks = true\n`;
261
333
  } else {
262
334
  let sectionEnd = lines.length;
263
335
  for (let i = sectionStart + 1; i < lines.length; i++) {
@@ -267,14 +339,10 @@ function ensureCodexHooksFeature(configPath) {
267
339
  }
268
340
  }
269
341
 
270
- const codexHooksLine = lines
271
- .slice(sectionStart + 1, sectionEnd)
272
- .findIndex((line) => /^\s*codex_hooks\s*=/.test(line));
273
- if (codexHooksLine === -1) {
274
- lines.splice(sectionStart + 1, 0, 'codex_hooks = true');
275
- } else {
276
- lines[sectionStart + 1 + codexHooksLine] = 'codex_hooks = true';
277
- }
342
+ const featureLines = lines.slice(sectionStart + 1, sectionEnd);
343
+ ensureFeatureLine(featureLines, 'codex_hooks');
344
+ ensureFeatureLine(featureLines, 'hooks');
345
+ lines.splice(sectionStart + 1, sectionEnd - sectionStart - 1, ...featureLines);
278
346
  updated = lines.join('\n').replace(/\n*$/, '\n');
279
347
  }
280
348
 
@@ -294,7 +362,7 @@ function installCodexHooks() {
294
362
  const list = existingHooks[key] ?? [];
295
363
  const preserved = [];
296
364
  for (const group of list) {
297
- const hooks = (group.hooks ?? []).filter(h => !isThroughlineCodexStopCommand(h.command));
365
+ const hooks = (group.hooks ?? []).filter(h => !isThroughlineCodexHookCommand(h.command));
298
366
  if (hooks.length > 0) preserved.push({ ...group, hooks });
299
367
  }
300
368
  existingHooks[key] = [entry, ...preserved];
@@ -323,7 +391,7 @@ function uninstallCodexHooks() {
323
391
  const hooks = (group.hooks ?? []).filter((hook) => {
324
392
  const shouldRemove =
325
393
  CODEX_COMMANDS.includes(hook.command) ||
326
- isThroughlineCodexStopCommand(hook.command);
394
+ isThroughlineCodexHookCommand(hook.command);
327
395
  if (shouldRemove) removed++;
328
396
  return !shouldRemove;
329
397
  });
@@ -422,7 +490,9 @@ export async function run(args = []) {
422
490
  console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
423
491
  console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
424
492
  if (codex) {
425
- console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
493
+ console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (75% 到達時に current session $throughline 指示を注入)`);
494
+ console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (tool loop 中も 75% 到達時に $throughline 指示を注入)`);
495
+ console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
426
496
  }
427
497
  console.log('');
428
498
  if (installedCommands.length > 0) {
@@ -4,7 +4,13 @@ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync
4
4
  import { tmpdir, homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
 
7
- import { buildCodexStopHookCommand, run, resolveThroughlineOnPath } from './install.mjs';
7
+ import {
8
+ buildCodexPostToolUseHookCommand,
9
+ buildCodexStopHookCommand,
10
+ buildCodexUserPromptSubmitHookCommand,
11
+ run,
12
+ resolveThroughlineOnPath,
13
+ } from './install.mjs';
8
14
 
9
15
  function makeTempHome() {
10
16
  const dir = mkdtempSync(join(tmpdir(), 'tl-install-test-'));
@@ -94,7 +100,7 @@ test('project install copies commands to cwd/.claude/commands/', async () => {
94
100
  }
95
101
  });
96
102
 
97
- test('global install registers Codex Stop hook and enables codex_hooks feature', async () => {
103
+ test('global install registers Codex session hooks and enables hooks features', async () => {
98
104
  const home = makeTempHome();
99
105
  if (home.resolved !== home.dir) {
100
106
  home.restore();
@@ -104,15 +110,30 @@ test('global install registers Codex Stop hook and enables codex_hooks feature',
104
110
  try {
105
111
  await run([]);
106
112
  const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
107
- const expectedCommand = buildCodexStopHookCommand();
113
+ const expectedStopCommand = buildCodexStopHookCommand();
114
+ const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
115
+ const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
108
116
  const codexHook = hooks.hooks.Stop
109
117
  .flatMap(g => g.hooks ?? [])
110
- .find(h => h.command === expectedCommand);
118
+ .find(h => h.command === expectedStopCommand);
111
119
  assert.ok(codexHook, 'Codex Stop should have absolute throughline.mjs codex-hook stop');
112
120
  assert.equal(codexHook.async, false, 'Codex Stop hook should be synchronous for Codex');
113
121
  assert.equal(codexHook.timeoutSec, 300, 'Codex Stop hook should allow summarizer time');
122
+ const promptHook = hooks.hooks.UserPromptSubmit
123
+ .flatMap(g => g.hooks ?? [])
124
+ .find(h => h.command === expectedPromptCommand);
125
+ assert.ok(promptHook, 'Codex UserPromptSubmit should have absolute throughline.mjs codex-hook user-prompt-submit');
126
+ assert.equal(promptHook.async, false, 'Codex UserPromptSubmit hook should be synchronous for context injection');
127
+ assert.equal(promptHook.timeoutSec, 30, 'Codex UserPromptSubmit hook should be short');
128
+ const postToolUseHook = hooks.hooks.PostToolUse
129
+ .flatMap(g => g.hooks ?? [])
130
+ .find(h => h.command === expectedPostToolUseCommand);
131
+ assert.ok(postToolUseHook, 'Codex PostToolUse should have absolute throughline.mjs codex-hook post-tool-use');
132
+ assert.equal(postToolUseHook.async, false, 'Codex PostToolUse hook should be synchronous for context injection');
133
+ assert.equal(postToolUseHook.timeoutSec, 30, 'Codex PostToolUse hook should be short');
114
134
  const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
115
135
  assert.match(config, /^\[features\]\ncodex_hooks = true/m);
136
+ assert.match(config, /^hooks = true/m);
116
137
  } finally {
117
138
  unsilence();
118
139
  home.restore();
@@ -215,21 +236,30 @@ test('global install preserves existing Codex hooks and is idempotent', async ()
215
236
  await run([]);
216
237
  await run([]);
217
238
  const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
218
- const commands = hooks.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
219
- const expectedCommand = buildCodexStopHookCommand();
220
- assert.ok(commands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
221
- assert.equal(commands.filter(c => c === expectedCommand).length, 1);
222
- assert.ok(!commands.includes('throughline codex-hook stop'));
239
+ const stopCommands = hooks.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
240
+ const promptCommands = hooks.hooks.UserPromptSubmit.flatMap(g => g.hooks ?? []).map(h => h.command);
241
+ const postToolUseCommands = hooks.hooks.PostToolUse.flatMap(g => g.hooks ?? []).map(h => h.command);
242
+ const expectedStopCommand = buildCodexStopHookCommand();
243
+ const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
244
+ const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
245
+ assert.ok(stopCommands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
246
+ assert.equal(stopCommands.filter(c => c === expectedStopCommand).length, 1);
247
+ assert.equal(promptCommands.filter(c => c === expectedPromptCommand).length, 1);
248
+ assert.equal(postToolUseCommands.filter(c => c === expectedPostToolUseCommand).length, 1);
249
+ assert.ok(!stopCommands.includes('throughline codex-hook stop'));
250
+ assert.ok(!promptCommands.includes('throughline codex-hook user-prompt-submit'));
251
+ assert.ok(!postToolUseCommands.includes('throughline codex-hook post-tool-use'));
223
252
  const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
224
253
  assert.match(config, /other = true/);
225
254
  assert.match(config, /codex_hooks = true/);
255
+ assert.match(config, /hooks = true/);
226
256
  } finally {
227
257
  unsilence();
228
258
  home.restore();
229
259
  }
230
260
  });
231
261
 
232
- test('global install updates existing Throughline Codex Stop hook shape', async () => {
262
+ test('global install updates existing Throughline Codex hook shapes', async () => {
233
263
  const home = makeTempHome();
234
264
  if (home.resolved !== home.dir) {
235
265
  home.restore();
@@ -241,6 +271,32 @@ test('global install updates existing Throughline Codex Stop hook shape', async
241
271
  JSON.stringify(
242
272
  {
243
273
  hooks: {
274
+ UserPromptSubmit: [
275
+ {
276
+ hooks: [
277
+ {
278
+ type: 'command',
279
+ command: 'throughline codex-hook user-prompt-submit',
280
+ timeoutSec: 300,
281
+ async: true,
282
+ statusMessage: null,
283
+ },
284
+ ],
285
+ },
286
+ ],
287
+ PostToolUse: [
288
+ {
289
+ hooks: [
290
+ {
291
+ type: 'command',
292
+ command: 'throughline codex-hook post-tool-use',
293
+ timeoutSec: 300,
294
+ async: true,
295
+ statusMessage: null,
296
+ },
297
+ ],
298
+ },
299
+ ],
244
300
  Stop: [
245
301
  {
246
302
  hooks: [
@@ -264,17 +320,39 @@ test('global install updates existing Throughline Codex Stop hook shape', async
264
320
  try {
265
321
  await run([]);
266
322
  const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
267
- const expectedCommand = buildCodexStopHookCommand();
323
+ const expectedStopCommand = buildCodexStopHookCommand();
324
+ const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
325
+ const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
268
326
  const codexHooks = hooks.hooks.Stop
269
327
  .flatMap(g => g.hooks ?? [])
270
- .filter(h => h.command === expectedCommand);
328
+ .filter(h => h.command === expectedStopCommand);
271
329
  assert.equal(codexHooks.length, 1);
272
330
  assert.equal(codexHooks[0].async, false);
273
331
  assert.equal(codexHooks[0].timeoutSec, 300);
332
+ const promptHooks = hooks.hooks.UserPromptSubmit
333
+ .flatMap(g => g.hooks ?? [])
334
+ .filter(h => h.command === expectedPromptCommand);
335
+ assert.equal(promptHooks.length, 1);
336
+ assert.equal(promptHooks[0].async, false);
337
+ assert.equal(promptHooks[0].timeoutSec, 30);
338
+ const postToolUseHooks = hooks.hooks.PostToolUse
339
+ .flatMap(g => g.hooks ?? [])
340
+ .filter(h => h.command === expectedPostToolUseCommand);
341
+ assert.equal(postToolUseHooks.length, 1);
342
+ assert.equal(postToolUseHooks[0].async, false);
343
+ assert.equal(postToolUseHooks[0].timeoutSec, 30);
274
344
  assert.equal(
275
345
  hooks.hooks.Stop.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook stop').length,
276
346
  0,
277
347
  );
348
+ assert.equal(
349
+ hooks.hooks.UserPromptSubmit.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook user-prompt-submit').length,
350
+ 0,
351
+ );
352
+ assert.equal(
353
+ hooks.hooks.PostToolUse.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook post-tool-use').length,
354
+ 0,
355
+ );
278
356
  } finally {
279
357
  unsilence();
280
358
  home.restore();
@@ -330,10 +408,16 @@ test('global uninstall removes only Throughline-managed Codex hook', async () =>
330
408
 
331
409
  await run(['--uninstall']);
332
410
  const after = JSON.parse(readFileSync(hooksPath, 'utf8'));
333
- const commands = after.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
334
- assert.ok(commands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
335
- assert.ok(!commands.includes('throughline codex-hook stop'));
336
- assert.ok(!commands.some(c => c.includes('throughline.mjs codex-hook stop')));
411
+ const stopCommands = after.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
412
+ const promptCommands = after.hooks.UserPromptSubmit?.flatMap(g => g.hooks ?? []).map(h => h.command) ?? [];
413
+ const postToolUseCommands = after.hooks.PostToolUse?.flatMap(g => g.hooks ?? []).map(h => h.command) ?? [];
414
+ assert.ok(stopCommands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
415
+ assert.ok(!stopCommands.includes('throughline codex-hook stop'));
416
+ assert.ok(!stopCommands.some(c => c.includes('throughline.mjs codex-hook stop')));
417
+ assert.ok(!promptCommands.includes('throughline codex-hook user-prompt-submit'));
418
+ assert.ok(!promptCommands.some(c => c.includes('throughline.mjs codex-hook user-prompt-submit')));
419
+ assert.ok(!postToolUseCommands.includes('throughline codex-hook post-tool-use'));
420
+ assert.ok(!postToolUseCommands.some(c => c.includes('throughline.mjs codex-hook post-tool-use')));
337
421
  } finally {
338
422
  unsilence();
339
423
  home.restore();