throughline 0.3.24 → 0.4.0

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 (116) hide show
  1. package/.claude/commands/tl.md +6 -21
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +632 -0
  4. package/README.ja.md +71 -46
  5. package/README.md +420 -76
  6. package/bin/throughline.mjs +169 -7
  7. package/codex/skills/throughline/SKILL.md +157 -0
  8. package/codex/skills/throughline/agents/openai.yaml +7 -0
  9. package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
  12. package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
  13. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  14. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  15. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  17. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  18. package/docs/archive/CONCEPT.md +476 -0
  19. package/docs/archive/EXPERIMENT.md +371 -0
  20. package/docs/archive/README.md +22 -0
  21. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  22. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  23. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  24. package/docs/throughline-handoff-context.example.json +57 -0
  25. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  26. package/package.json +6 -2
  27. package/src/baton.mjs +17 -45
  28. package/src/baton.test.mjs +4 -41
  29. package/src/cli/codex-capture.mjs +95 -0
  30. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  31. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  32. package/src/cli/codex-handoff-smoke.mjs +163 -0
  33. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  34. package/src/cli/codex-handoff-start.mjs +291 -0
  35. package/src/cli/codex-handoff-start.test.mjs +194 -0
  36. package/src/cli/codex-hook.mjs +276 -0
  37. package/src/cli/codex-hook.test.mjs +293 -0
  38. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  39. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  40. package/src/cli/codex-restore-smoke.mjs +357 -0
  41. package/src/cli/codex-restore-source-audit.mjs +304 -0
  42. package/src/cli/codex-resume.mjs +138 -0
  43. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  44. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  45. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  46. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  47. package/src/cli/codex-summarize.mjs +224 -0
  48. package/src/cli/codex-threads.mjs +89 -0
  49. package/src/cli/codex-visibility-smoke.mjs +196 -0
  50. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  51. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  52. package/src/cli/doctor.mjs +503 -1
  53. package/src/cli/doctor.test.mjs +542 -3
  54. package/src/cli/handoff-preview.mjs +78 -0
  55. package/src/cli/help.test.mjs +64 -0
  56. package/src/cli/install.mjs +226 -3
  57. package/src/cli/install.test.mjs +205 -4
  58. package/src/cli/trim.mjs +564 -0
  59. package/src/codex-app-server.mjs +1816 -0
  60. package/src/codex-app-server.test.mjs +512 -0
  61. package/src/codex-auto-refresh.mjs +194 -0
  62. package/src/codex-auto-refresh.test.mjs +182 -0
  63. package/src/codex-capture.mjs +235 -0
  64. package/src/codex-capture.test.mjs +393 -0
  65. package/src/codex-handoff-model-smoke.mjs +114 -0
  66. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  67. package/src/codex-handoff-smoke.mjs +124 -0
  68. package/src/codex-handoff-smoke.test.mjs +103 -0
  69. package/src/codex-handoff.mjs +331 -0
  70. package/src/codex-handoff.test.mjs +220 -0
  71. package/src/codex-host-primitive-audit.mjs +374 -0
  72. package/src/codex-host-primitive-audit.test.mjs +208 -0
  73. package/src/codex-restore-smoke.test.mjs +639 -0
  74. package/src/codex-restore-source-audit.mjs +1348 -0
  75. package/src/codex-restore-source-audit.test.mjs +623 -0
  76. package/src/codex-resume.test.mjs +242 -0
  77. package/src/codex-rollout-memory.mjs +711 -0
  78. package/src/codex-rollout-memory.test.mjs +610 -0
  79. package/src/codex-sidecar-cli.test.mjs +75 -0
  80. package/src/codex-sidecar.mjs +246 -0
  81. package/src/codex-sidecar.test.mjs +172 -0
  82. package/src/codex-summarize.test.mjs +143 -0
  83. package/src/codex-thread-identity.mjs +23 -0
  84. package/src/codex-thread-index.mjs +173 -0
  85. package/src/codex-thread-index.test.mjs +164 -0
  86. package/src/codex-usage.mjs +110 -0
  87. package/src/codex-usage.test.mjs +140 -0
  88. package/src/codex-visibility-smoke.test.mjs +222 -0
  89. package/src/codex-vscode-restore-smoke.mjs +206 -0
  90. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  91. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  92. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  93. package/src/db-schema.test.mjs +96 -0
  94. package/src/db.mjs +14 -1
  95. package/src/haiku-summarizer.mjs +267 -26
  96. package/src/haiku-summarizer.test.mjs +282 -0
  97. package/src/handoff-preview.test.mjs +108 -0
  98. package/src/handoff-record.mjs +294 -0
  99. package/src/handoff-record.test.mjs +226 -0
  100. package/src/hook-entrypoints.test.mjs +286 -0
  101. package/src/package-files.test.mjs +19 -0
  102. package/src/prompt-submit.mjs +9 -6
  103. package/src/resume-context.mjs +58 -171
  104. package/src/resume-context.test.mjs +177 -0
  105. package/src/session-start.mjs +85 -26
  106. package/src/state-file.mjs +50 -6
  107. package/src/state-file.test.mjs +50 -0
  108. package/src/token-monitor.mjs +14 -10
  109. package/src/token-monitor.test.mjs +27 -0
  110. package/src/trim-cli.test.mjs +1584 -0
  111. package/src/trim-model.mjs +584 -0
  112. package/src/trim-model.test.mjs +568 -0
  113. package/src/turn-processor.mjs +17 -10
  114. package/src/vscode-task.mjs +33 -10
  115. package/src/vscode-task.test.mjs +19 -9
  116. package/src/cli/save-inflight.mjs +0 -81
@@ -0,0 +1,64 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
9
+ const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
10
+ const CODEX_HELP_COMMANDS = [
11
+ 'throughline codex-capture',
12
+ 'throughline codex-hook stop',
13
+ 'throughline codex-summarize',
14
+ 'throughline codex-resume',
15
+ 'throughline codex-handoff-smoke',
16
+ 'throughline codex-handoff-model-smoke',
17
+ 'throughline codex-handoff-start',
18
+ 'throughline codex-visibility-smoke',
19
+ 'throughline codex-rollback-model-visible-smoke',
20
+ 'throughline codex-restore-smoke',
21
+ 'throughline codex-restore-source-audit',
22
+ 'throughline codex-host-primitive-audit',
23
+ 'throughline codex-vscode-restore-smoke',
24
+ 'throughline codex-vscode-rollback-smoke',
25
+ 'throughline codex-threads',
26
+ 'throughline codex-sidecar-diagnostics',
27
+ 'throughline codex-sidecar-dry-run',
28
+ ];
29
+
30
+ function runThroughline(args = []) {
31
+ return spawnSync(process.execPath, [BIN_PATH, ...args], {
32
+ cwd: REPO_ROOT,
33
+ encoding: 'utf8',
34
+ });
35
+ }
36
+
37
+ function dispatchCommand(command) {
38
+ return command.replace(/^throughline\s+/, '').split(/\s+/)[0];
39
+ }
40
+
41
+ test('CLI help exposes guided Codex handoff and guarded execute semantics', () => {
42
+ const result = runThroughline(['--help']);
43
+
44
+ assert.equal(result.status, 0, result.stderr);
45
+ for (const command of CODEX_HELP_COMMANDS) {
46
+ assert.match(result.stdout, new RegExp(command.replaceAll('-', '\\-')));
47
+ }
48
+ assert.match(result.stdout, /Guided read-only fresh-thread handoff start plan/);
49
+ assert.match(result.stdout, /throughline trim --execute/);
50
+ assert.match(result.stdout, /injectable DB memory/);
51
+ assert.match(result.stdout, /matching/);
52
+ assert.match(result.stdout, /rollout\/app-server turns/);
53
+ assert.match(result.stdout, /--inspect-risky-rollout/);
54
+ assert.match(result.stdout, /risk-evidence inspection/);
55
+ });
56
+
57
+ test('CLI help Codex commands are dispatchable', () => {
58
+ const bin = readFileSync(BIN_PATH, 'utf8');
59
+
60
+ for (const command of CODEX_HELP_COMMANDS) {
61
+ const subcommand = dispatchCommand(command);
62
+ assert.match(bin, new RegExp(`case '${subcommand}':`), `${command} is missing dispatch`);
63
+ }
64
+ });
@@ -6,11 +6,12 @@
6
6
  * --project : .claude/settings.json(プロジェクトローカル)
7
7
  * --uninstall: hook を削除
8
8
  *
9
- * 登録コマンドは PATH 解決型 (throughline <subcommand>) を使う。
10
- * node のインストール先や OS が変わっても PATH さえ通れば動く。
9
+ * Claude-facing hook は従来通り PATH 解決型 (throughline <subcommand>) を使う。
10
+ * Codex-facing hook VSCode App Server の PATH 差分を避けるため、絶対 node + CLI
11
+ * script path で登録する。
11
12
  */
12
13
 
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
14
15
  import { join, dirname, resolve, delimiter } from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
16
17
  import { homedir } from 'node:os';
@@ -18,6 +19,10 @@ import { homedir } from 'node:os';
18
19
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
19
20
  const SLASH_COMMANDS_SRC = join(PACKAGE_ROOT, '.claude', 'commands');
20
21
  const SC_SLASH_COMMAND_FILES = ['tl.md', 'sc-detail.md'];
22
+ const CODEX_SKILLS_SRC = join(PACKAGE_ROOT, 'codex', 'skills');
23
+ const CODEX_SKILL_NAMES = ['throughline'];
24
+ const CODEX_HOOKS_RELATIVE_PATH = ['.codex', 'hooks.json'];
25
+ const CODEX_CONFIG_RELATIVE_PATH = ['.codex', 'config.toml'];
21
26
 
22
27
  // Throughline が管理する hook コマンド一覧
23
28
  // schema v4 以降: PostToolUse (capture-tool) は廃止。Stop 内で L2/L3 を一括処理する。
@@ -46,6 +51,47 @@ const SC_HOOKS = {
46
51
  },
47
52
  };
48
53
 
54
+ const CODEX_COMMANDS = [
55
+ 'throughline codex-hook stop',
56
+ ];
57
+
58
+ function quoteCommandPath(p) {
59
+ return /\s/.test(p) ? `"${p.replace(/"/g, '\\"')}"` : p;
60
+ }
61
+
62
+ export function buildCodexStopHookCommand({
63
+ nodePath = process.execPath,
64
+ cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
65
+ } = {}) {
66
+ return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook stop`;
67
+ }
68
+
69
+ export function isThroughlineCodexStopCommand(command) {
70
+ if (typeof command !== 'string') return false;
71
+ const normalized = command.replace(/["']/g, '');
72
+ return (
73
+ normalized === 'throughline codex-hook stop' ||
74
+ normalized.includes('throughline codex-hook stop') ||
75
+ normalized.includes('throughline.mjs codex-hook stop')
76
+ );
77
+ }
78
+
79
+ function createCodexHooks() {
80
+ return {
81
+ Stop: {
82
+ hooks: [
83
+ {
84
+ type: 'command',
85
+ command: buildCodexStopHookCommand(),
86
+ timeoutSec: 300,
87
+ async: false,
88
+ statusMessage: null,
89
+ },
90
+ ],
91
+ },
92
+ };
93
+ }
94
+
49
95
  function resolveSettingsPath(args) {
50
96
  if (args.includes('--project')) {
51
97
  return join(process.cwd(), '.claude', 'settings.json');
@@ -60,6 +106,18 @@ function resolveCommandsDir(args) {
60
106
  return join(homedir(), '.claude', 'commands');
61
107
  }
62
108
 
109
+ function resolveCodexHooksPath() {
110
+ return join(homedir(), ...CODEX_HOOKS_RELATIVE_PATH);
111
+ }
112
+
113
+ function resolveCodexConfigPath() {
114
+ return join(homedir(), ...CODEX_CONFIG_RELATIVE_PATH);
115
+ }
116
+
117
+ function resolveCodexSkillsDir() {
118
+ return join(homedir(), '.codex', 'skills');
119
+ }
120
+
63
121
  function installSlashCommands(commandsDir) {
64
122
  if (!existsSync(SLASH_COMMANDS_SRC)) {
65
123
  return { installed: [], skipped: 'source-missing' };
@@ -89,6 +147,48 @@ function uninstallSlashCommands(commandsDir) {
89
147
  return removed;
90
148
  }
91
149
 
150
+ function copyDirectory(srcDir, destDir) {
151
+ mkdirSync(destDir, { recursive: true });
152
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
153
+ const src = join(srcDir, entry.name);
154
+ const dest = join(destDir, entry.name);
155
+ if (entry.isDirectory()) {
156
+ copyDirectory(src, dest);
157
+ } else if (entry.isFile()) {
158
+ copyFileSync(src, dest);
159
+ }
160
+ }
161
+ }
162
+
163
+ function installCodexSkills(skillsDir) {
164
+ if (!existsSync(CODEX_SKILLS_SRC)) {
165
+ return { installed: [], skipped: 'source-missing' };
166
+ }
167
+ mkdirSync(skillsDir, { recursive: true });
168
+ const installed = [];
169
+ for (const name of CODEX_SKILL_NAMES) {
170
+ const src = join(CODEX_SKILLS_SRC, name);
171
+ if (!existsSync(src)) continue;
172
+ const dest = join(skillsDir, name);
173
+ rmSync(dest, { recursive: true, force: true });
174
+ copyDirectory(src, dest);
175
+ installed.push(name);
176
+ }
177
+ return { installed, skipped: null };
178
+ }
179
+
180
+ function uninstallCodexSkills(skillsDir) {
181
+ const removed = [];
182
+ for (const name of CODEX_SKILL_NAMES) {
183
+ const dest = join(skillsDir, name);
184
+ if (existsSync(dest)) {
185
+ rmSync(dest, { recursive: true, force: true });
186
+ removed.push(name);
187
+ }
188
+ }
189
+ return removed;
190
+ }
191
+
92
192
  /**
93
193
  * PATH 上で 'throughline' (Windows なら .cmd / .ps1 / .exe) が解決できるかを確認する。
94
194
  *
@@ -148,10 +248,105 @@ function writeSettings(settingsPath, obj) {
148
248
  writeFileSync(settingsPath, JSON.stringify(obj, null, 2) + '\n');
149
249
  }
150
250
 
251
+ function ensureCodexHooksFeature(configPath) {
252
+ const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
253
+ const lines = existing.split(/\r?\n/);
254
+ const sectionStart = lines.findIndex((line) => line.trim() === '[features]');
255
+ let updated;
256
+
257
+ if (sectionStart === -1) {
258
+ const prefix = existing.trimEnd();
259
+ updated = `${prefix}${prefix ? '\n\n' : ''}[features]\ncodex_hooks = true\n`;
260
+ } else {
261
+ let sectionEnd = lines.length;
262
+ for (let i = sectionStart + 1; i < lines.length; i++) {
263
+ if (/^\s*\[[^\]]+\]\s*$/.test(lines[i])) {
264
+ sectionEnd = i;
265
+ break;
266
+ }
267
+ }
268
+
269
+ const codexHooksLine = lines
270
+ .slice(sectionStart + 1, sectionEnd)
271
+ .findIndex((line) => /^\s*codex_hooks\s*=/.test(line));
272
+ if (codexHooksLine === -1) {
273
+ lines.splice(sectionStart + 1, 0, 'codex_hooks = true');
274
+ } else {
275
+ lines[sectionStart + 1 + codexHooksLine] = 'codex_hooks = true';
276
+ }
277
+ updated = lines.join('\n').replace(/\n*$/, '\n');
278
+ }
279
+
280
+ const dir = dirname(configPath);
281
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
282
+ writeFileSync(configPath, updated);
283
+ }
284
+
285
+ function installCodexHooks() {
286
+ const hooksPath = resolveCodexHooksPath();
287
+ const configPath = resolveCodexConfigPath();
288
+ const current = readSettings(hooksPath);
289
+ const existingHooks = current.hooks ?? {};
290
+ const codexHooks = createCodexHooks();
291
+
292
+ for (const [key, entry] of Object.entries(codexHooks)) {
293
+ const list = existingHooks[key] ?? [];
294
+ const preserved = [];
295
+ for (const group of list) {
296
+ const hooks = (group.hooks ?? []).filter(h => !isThroughlineCodexStopCommand(h.command));
297
+ if (hooks.length > 0) preserved.push({ ...group, hooks });
298
+ }
299
+ existingHooks[key] = [entry, ...preserved];
300
+ }
301
+
302
+ current.hooks = existingHooks;
303
+ writeSettings(hooksPath, current);
304
+ ensureCodexHooksFeature(configPath);
305
+
306
+ return { hooksPath, configPath };
307
+ }
308
+
309
+ function uninstallCodexHooks() {
310
+ const hooksPath = resolveCodexHooksPath();
311
+ if (!existsSync(hooksPath)) {
312
+ return { hooksPath, removed: 0 };
313
+ }
314
+
315
+ const current = readSettings(hooksPath);
316
+ const existingHooks = current.hooks ?? {};
317
+ let removed = 0;
318
+
319
+ for (const [key, groups] of Object.entries(existingHooks)) {
320
+ existingHooks[key] = groups
321
+ .map((group) => {
322
+ const hooks = (group.hooks ?? []).filter((hook) => {
323
+ const shouldRemove =
324
+ CODEX_COMMANDS.includes(hook.command) ||
325
+ isThroughlineCodexStopCommand(hook.command);
326
+ if (shouldRemove) removed++;
327
+ return !shouldRemove;
328
+ });
329
+ return { ...group, hooks };
330
+ })
331
+ .filter((group) => group.hooks.length > 0);
332
+ if (existingHooks[key].length === 0) delete existingHooks[key];
333
+ }
334
+
335
+ if (Object.keys(existingHooks).length === 0) {
336
+ delete current.hooks;
337
+ } else {
338
+ current.hooks = existingHooks;
339
+ }
340
+
341
+ writeSettings(hooksPath, current);
342
+ return { hooksPath, removed };
343
+ }
344
+
151
345
  export async function run(args = []) {
152
346
  const uninstall = args.includes('--uninstall');
153
347
  const settingsPath = resolveSettingsPath(args);
154
348
  const commandsDir = resolveCommandsDir(args);
349
+ const codexSkillsDir = resolveCodexSkillsDir();
155
350
  const current = readSettings(settingsPath);
156
351
  const existingHooks = current.hooks ?? {};
157
352
  const scSet = new Set(SC_COMMANDS);
@@ -172,11 +367,19 @@ export async function run(args = []) {
172
367
 
173
368
  writeSettings(settingsPath, current);
174
369
  const removedCommands = uninstallSlashCommands(commandsDir);
370
+ const codex = args.includes('--project') ? null : uninstallCodexHooks();
371
+ const removedCodexSkills = args.includes('--project') ? [] : uninstallCodexSkills(codexSkillsDir);
175
372
  console.log('Throughline hooks を削除しました。');
176
373
  console.log(` ${settingsPath}`);
177
374
  if (removedCommands.length > 0) {
178
375
  console.log(` slash commands 削除: ${removedCommands.join(', ')} (${commandsDir})`);
179
376
  }
377
+ if (codex?.removed > 0) {
378
+ console.log(` Codex hooks 削除: ${codex.removed} (${codex.hooksPath})`);
379
+ }
380
+ if (removedCodexSkills.length > 0) {
381
+ console.log(` Codex skills 削除: ${removedCodexSkills.join(', ')} (${codexSkillsDir})`);
382
+ }
180
383
  return;
181
384
  }
182
385
 
@@ -195,15 +398,27 @@ export async function run(args = []) {
195
398
  current.hooks = existingHooks;
196
399
  writeSettings(settingsPath, current);
197
400
  const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
401
+ const codex = args.includes('--project') ? null : installCodexHooks();
402
+ const codexSkills = args.includes('--project') ? { installed: [], skipped: null } : installCodexSkills(codexSkillsDir);
198
403
 
199
404
  const scope = args.includes('--project') ? 'プロジェクトローカル' : 'グローバル(全プロジェクト)';
200
405
  console.log(`Throughline hooks をインストールしました [${scope}]`);
201
406
  console.log(` ${settingsPath}`);
407
+ if (codex) {
408
+ console.log(` ${codex.hooksPath}`);
409
+ console.log(` ${codex.configPath}`);
410
+ if (codexSkills.installed.length > 0) {
411
+ console.log(` ${codexSkillsDir}`);
412
+ }
413
+ }
202
414
  console.log('');
203
415
  console.log('有効な hooks:');
204
416
  console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
205
417
  console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
206
418
  console.log(' UserPromptSubmit → throughline prompt-submit (/tl バトン書き込み)');
419
+ if (codex) {
420
+ console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
421
+ }
207
422
  console.log('');
208
423
  if (installedCommands.length > 0) {
209
424
  console.log(`slash commands を配置しました: ${installedCommands.map(n => '/' + n.replace(/\.md$/, '')).join(', ')}`);
@@ -213,6 +428,14 @@ export async function run(args = []) {
213
428
  console.log('注意: パッケージ内に slash commands のソースが見つからないためスキップしました。');
214
429
  console.log('');
215
430
  }
431
+ if (codexSkills.installed.length > 0) {
432
+ console.log(`Codex skills を配置しました: ${codexSkills.installed.map(n => '$' + n).join(', ')}`);
433
+ console.log(` ${codexSkillsDir}`);
434
+ console.log('');
435
+ } else if (codexSkills.skipped === 'source-missing') {
436
+ console.log('注意: パッケージ内に Codex skills のソースが見つからないためスキップしました。');
437
+ console.log('');
438
+ }
216
439
  console.log(' アンインストール: throughline uninstall');
217
440
 
218
441
  if (!resolveThroughlineOnPath()) {
@@ -4,7 +4,7 @@ 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 { run, resolveThroughlineOnPath } from './install.mjs';
7
+ import { buildCodexStopHookCommand, run, resolveThroughlineOnPath } from './install.mjs';
8
8
 
9
9
  function makeTempHome() {
10
10
  const dir = mkdtempSync(join(tmpdir(), 'tl-install-test-'));
@@ -40,7 +40,7 @@ function silence() {
40
40
  };
41
41
  }
42
42
 
43
- test('global install copies /tl and /sc-detail to ~/.claude/commands/', async () => {
43
+ test('global install copies Throughline slash commands to ~/.claude/commands/', async () => {
44
44
  const home = makeTempHome();
45
45
  if (home.resolved !== home.dir) {
46
46
  home.restore();
@@ -51,8 +51,10 @@ test('global install copies /tl and /sc-detail to ~/.claude/commands/', async ()
51
51
  await run([]);
52
52
  const tl = join(home.dir, '.claude', 'commands', 'tl.md');
53
53
  const sc = join(home.dir, '.claude', 'commands', 'sc-detail.md');
54
+ const trim = join(home.dir, '.claude', 'commands', 'tl-trim.md');
54
55
  assert.ok(existsSync(tl), 'tl.md should be installed globally');
55
56
  assert.ok(existsSync(sc), 'sc-detail.md should be installed globally');
57
+ assert.ok(!existsSync(trim), 'tl-trim.md should NOT be installed (deprecated in v0.4.0)');
56
58
  const tlBody = readFileSync(tl, 'utf8');
57
59
  assert.match(tlBody, /Throughline/, 'tl.md content should be real');
58
60
  const settings = JSON.parse(readFileSync(join(home.dir, '.claude', 'settings.json'), 'utf8'));
@@ -79,6 +81,8 @@ test('project install copies commands to cwd/.claude/commands/', async () => {
79
81
  assert.ok(existsSync(tl), 'tl.md should be installed in project');
80
82
  const globalTl = join(home.dir, '.claude', 'commands', 'tl.md');
81
83
  assert.ok(!existsSync(globalTl), '--project should NOT touch global dir');
84
+ assert.ok(!existsSync(join(home.dir, '.codex', 'hooks.json')), '--project should NOT touch global Codex hooks');
85
+ assert.ok(!existsSync(join(home.dir, '.codex', 'skills', 'throughline')), '--project should NOT touch global Codex skills');
82
86
  } finally {
83
87
  unsilence();
84
88
  process.chdir(origCwd);
@@ -87,6 +91,161 @@ test('project install copies commands to cwd/.claude/commands/', async () => {
87
91
  }
88
92
  });
89
93
 
94
+ test('global install registers Codex Stop hook and enables codex_hooks feature', async () => {
95
+ const home = makeTempHome();
96
+ if (home.resolved !== home.dir) {
97
+ home.restore();
98
+ return;
99
+ }
100
+ const unsilence = silence();
101
+ try {
102
+ await run([]);
103
+ const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
104
+ const expectedCommand = buildCodexStopHookCommand();
105
+ const codexHook = hooks.hooks.Stop
106
+ .flatMap(g => g.hooks ?? [])
107
+ .find(h => h.command === expectedCommand);
108
+ assert.ok(codexHook, 'Codex Stop should have absolute throughline.mjs codex-hook stop');
109
+ assert.equal(codexHook.async, false, 'Codex Stop hook should be synchronous for Codex');
110
+ assert.equal(codexHook.timeoutSec, 300, 'Codex Stop hook should allow summarizer time');
111
+ const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
112
+ assert.match(config, /^\[features\]\ncodex_hooks = true/m);
113
+ } finally {
114
+ unsilence();
115
+ home.restore();
116
+ }
117
+ });
118
+
119
+ test('global install copies Throughline Codex skill to ~/.codex/skills/', async () => {
120
+ const home = makeTempHome();
121
+ if (home.resolved !== home.dir) {
122
+ home.restore();
123
+ return;
124
+ }
125
+ const unsilence = silence();
126
+ try {
127
+ await run([]);
128
+ const skill = join(home.dir, '.codex', 'skills', 'throughline', 'SKILL.md');
129
+ const metadata = join(home.dir, '.codex', 'skills', 'throughline', 'agents', 'openai.yaml');
130
+ assert.ok(existsSync(skill), 'Throughline Codex skill should be installed globally');
131
+ assert.ok(existsSync(metadata), 'Throughline Codex skill UI metadata should be installed globally');
132
+ const skillBody = readFileSync(skill, 'utf8');
133
+ const metadataBody = readFileSync(metadata, 'utf8');
134
+ assert.match(skillBody, /name: throughline/);
135
+ assert.match(skillBody, /Bare "\$throughline"/);
136
+ assert.match(skillBody, /throughline codex-handoff-start --session codex:<current-thread-id> --json/);
137
+ assert.match(skillBody, /throughline trim --execute --host codex --all/);
138
+ assert.match(metadataBody, /inspect guarded Codex trim/);
139
+ assert.doesNotMatch(metadataBody, /preview blocked Codex trim/);
140
+ } finally {
141
+ unsilence();
142
+ home.restore();
143
+ }
144
+ });
145
+
146
+ test('global install preserves existing Codex hooks and is idempotent', async () => {
147
+ const home = makeTempHome();
148
+ if (home.resolved !== home.dir) {
149
+ home.restore();
150
+ return;
151
+ }
152
+ mkdirSync(join(home.dir, '.codex'), { recursive: true });
153
+ writeFileSync(
154
+ join(home.dir, '.codex', 'hooks.json'),
155
+ JSON.stringify(
156
+ {
157
+ hooks: {
158
+ Stop: [
159
+ {
160
+ hooks: [
161
+ {
162
+ type: 'command',
163
+ command: '/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop',
164
+ timeoutSec: 5,
165
+ async: false,
166
+ statusMessage: null,
167
+ },
168
+ ],
169
+ },
170
+ ],
171
+ },
172
+ },
173
+ null,
174
+ 2,
175
+ ) + '\n',
176
+ );
177
+ writeFileSync(join(home.dir, '.codex', 'config.toml'), '[features]\nother = true\n');
178
+ const unsilence = silence();
179
+ try {
180
+ await run([]);
181
+ await run([]);
182
+ const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
183
+ const commands = hooks.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
184
+ const expectedCommand = buildCodexStopHookCommand();
185
+ assert.ok(commands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
186
+ assert.equal(commands.filter(c => c === expectedCommand).length, 1);
187
+ assert.ok(!commands.includes('throughline codex-hook stop'));
188
+ const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
189
+ assert.match(config, /other = true/);
190
+ assert.match(config, /codex_hooks = true/);
191
+ } finally {
192
+ unsilence();
193
+ home.restore();
194
+ }
195
+ });
196
+
197
+ test('global install updates existing Throughline Codex Stop hook shape', async () => {
198
+ const home = makeTempHome();
199
+ if (home.resolved !== home.dir) {
200
+ home.restore();
201
+ return;
202
+ }
203
+ mkdirSync(join(home.dir, '.codex'), { recursive: true });
204
+ writeFileSync(
205
+ join(home.dir, '.codex', 'hooks.json'),
206
+ JSON.stringify(
207
+ {
208
+ hooks: {
209
+ Stop: [
210
+ {
211
+ hooks: [
212
+ {
213
+ type: 'command',
214
+ command: 'throughline codex-hook stop',
215
+ timeoutSec: 300,
216
+ async: true,
217
+ statusMessage: null,
218
+ },
219
+ ],
220
+ },
221
+ ],
222
+ },
223
+ },
224
+ null,
225
+ 2,
226
+ ) + '\n',
227
+ );
228
+ const unsilence = silence();
229
+ try {
230
+ await run([]);
231
+ const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
232
+ const expectedCommand = buildCodexStopHookCommand();
233
+ const codexHooks = hooks.hooks.Stop
234
+ .flatMap(g => g.hooks ?? [])
235
+ .filter(h => h.command === expectedCommand);
236
+ assert.equal(codexHooks.length, 1);
237
+ assert.equal(codexHooks[0].async, false);
238
+ assert.equal(codexHooks[0].timeoutSec, 300);
239
+ assert.equal(
240
+ hooks.hooks.Stop.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook stop').length,
241
+ 0,
242
+ );
243
+ } finally {
244
+ unsilence();
245
+ home.restore();
246
+ }
247
+ });
248
+
90
249
  test('uninstall removes slash command files', async () => {
91
250
  const home = makeTempHome();
92
251
  if (home.resolved !== home.dir) {
@@ -102,6 +261,44 @@ test('uninstall removes slash command files', async () => {
102
261
  assert.ok(!existsSync(tl), 'uninstall should remove tl.md');
103
262
  const sc = join(home.dir, '.claude', 'commands', 'sc-detail.md');
104
263
  assert.ok(!existsSync(sc), 'uninstall should remove sc-detail.md');
264
+ const codexSkill = join(home.dir, '.codex', 'skills', 'throughline', 'SKILL.md');
265
+ assert.ok(!existsSync(codexSkill), 'uninstall should remove Throughline Codex skill');
266
+ } finally {
267
+ unsilence();
268
+ home.restore();
269
+ }
270
+ });
271
+
272
+ test('global uninstall removes only Throughline-managed Codex hook', async () => {
273
+ const home = makeTempHome();
274
+ if (home.resolved !== home.dir) {
275
+ home.restore();
276
+ return;
277
+ }
278
+ const unsilence = silence();
279
+ try {
280
+ await run([]);
281
+ const hooksPath = join(home.dir, '.codex', 'hooks.json');
282
+ const hooks = JSON.parse(readFileSync(hooksPath, 'utf8'));
283
+ hooks.hooks.Stop.push({
284
+ hooks: [
285
+ {
286
+ type: 'command',
287
+ command: '/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop',
288
+ timeoutSec: 5,
289
+ async: false,
290
+ statusMessage: null,
291
+ },
292
+ ],
293
+ });
294
+ writeFileSync(hooksPath, JSON.stringify(hooks, null, 2) + '\n');
295
+
296
+ await run(['--uninstall']);
297
+ const after = JSON.parse(readFileSync(hooksPath, 'utf8'));
298
+ const commands = after.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
299
+ assert.ok(commands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
300
+ assert.ok(!commands.includes('throughline codex-hook stop'));
301
+ assert.ok(!commands.some(c => c.includes('throughline.mjs codex-hook stop')));
105
302
  } finally {
106
303
  unsilence();
107
304
  home.restore();
@@ -127,7 +324,7 @@ test('uninstall preserves unrelated slash commands in the same dir', async () =>
127
324
  }
128
325
  });
129
326
 
130
- test('Stop hook is registered with async:true so it does not block ターン完了 UX', async () => {
327
+ test('Claude Stop hook is registered with async:true so it does not block ターン完了 UX', async () => {
131
328
  const home = makeTempHome();
132
329
  if (home.resolved !== home.dir) {
133
330
  home.restore();
@@ -177,11 +374,15 @@ test('resolveThroughlineOnPath: finds throughline binary in PATH directory', ()
177
374
  const dir = mkdtempSync(join(tmpdir(), 'tl-path-found-'));
178
375
  try {
179
376
  if (process.platform === 'win32') {
377
+ // PATHEXT は通常大文字 (.EXE;.CMD;.BAT) だが、resolveThroughlineOnPath が
378
+ // 返すパスはその ext をそのまま join するため、書き込んだ実ファイル名 (小文字)
379
+ // と厳密一致するよう小文字を渡す。Windows FS は case-insensitive で
380
+ // existsSync は通る。
180
381
  const binPath = join(dir, 'throughline.cmd');
181
382
  writeFileSync(binPath, '@echo off\n');
182
383
  const result = resolveThroughlineOnPath({
183
384
  PATH: dir,
184
- PATHEXT: '.CMD',
385
+ PATHEXT: '.cmd',
185
386
  });
186
387
  assert.equal(result, binPath);
187
388
  } else {