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.
- package/CHANGELOG.md +63 -0
- package/README.ja.md +6 -1
- package/README.md +29 -20
- package/bin/throughline.mjs +15 -2
- package/docs/PUBLIC_RELEASE_PLAN.md +4 -3
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +1 -1
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +3 -2
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +2 -2
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +7 -2
- package/docs/throughline-rollback-context-trim-insight.md +1 -1
- package/package.json +1 -1
- package/src/cli/codex-handoff-smoke.mjs +1 -1
- package/src/cli/codex-hook.mjs +258 -29
- package/src/cli/codex-hook.test.mjs +169 -2
- package/src/cli/doctor.mjs +174 -6
- package/src/cli/doctor.test.mjs +58 -2
- package/src/cli/help.test.mjs +2 -0
- package/src/cli/install.mjs +82 -12
- package/src/cli/install.test.mjs +100 -16
- package/src/codex-auto-refresh.mjs +1 -1
- package/src/codex-auto-refresh.test.mjs +4 -4
- package/src/codex-handoff-smoke.mjs +15 -12
- package/src/codex-handoff-smoke.test.mjs +25 -6
- package/src/codex-handoff.mjs +82 -58
- package/src/codex-handoff.test.mjs +69 -28
- package/src/codex-resume.test.mjs +11 -2
- package/src/handoff-record.mjs +51 -8
- package/src/l3-summary.mjs +72 -0
- package/src/resume-context.mjs +58 -56
- package/src/resume-context.test.mjs +332 -36
package/src/cli/doctor.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
486
|
+
const expectedStopCommand = buildCodexStopHookCommand();
|
|
487
|
+
const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
|
|
488
|
+
const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
|
|
397
489
|
const out = {
|
|
398
490
|
hooksPath,
|
|
399
491
|
configPath,
|
|
400
|
-
|
|
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
|
-
|
|
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
|
|
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 !==
|
|
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) {
|
package/src/cli/doctor.test.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 });
|
package/src/cli/help.test.mjs
CHANGED
|
@@ -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',
|
package/src/cli/install.mjs
CHANGED
|
@@ -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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 => !
|
|
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
|
-
|
|
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
|
|
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) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
assert.ok(
|
|
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();
|