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.
- package/.claude/commands/tl.md +6 -21
- package/.codex-sidecar.yml +62 -0
- package/CHANGELOG.md +632 -0
- package/README.ja.md +71 -46
- package/README.md +420 -76
- package/bin/throughline.mjs +169 -7
- package/codex/skills/throughline/SKILL.md +157 -0
- package/codex/skills/throughline/agents/openai.yaml +7 -0
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
- package/docs/L1_L2_L3_REDESIGN.md +415 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
- package/docs/archive/CONCEPT.md +476 -0
- package/docs/archive/EXPERIMENT.md +371 -0
- package/docs/archive/README.md +22 -0
- package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
- package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
- package/docs/throughline-handoff-context.example.json +57 -0
- package/docs/throughline-rollback-context-trim-insight.md +455 -0
- package/package.json +6 -2
- package/src/baton.mjs +17 -45
- package/src/baton.test.mjs +4 -41
- package/src/cli/codex-capture.mjs +95 -0
- package/src/cli/codex-handoff-model-smoke.mjs +292 -0
- package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
- package/src/cli/codex-handoff-smoke.mjs +163 -0
- package/src/cli/codex-handoff-smoke.test.mjs +149 -0
- package/src/cli/codex-handoff-start.mjs +291 -0
- package/src/cli/codex-handoff-start.test.mjs +194 -0
- package/src/cli/codex-hook.mjs +276 -0
- package/src/cli/codex-hook.test.mjs +293 -0
- package/src/cli/codex-host-primitive-audit.mjs +110 -0
- package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
- package/src/cli/codex-restore-smoke.mjs +357 -0
- package/src/cli/codex-restore-source-audit.mjs +304 -0
- package/src/cli/codex-resume.mjs +138 -0
- package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
- package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
- package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
- package/src/cli/codex-sidecar-dry-run.mjs +85 -0
- package/src/cli/codex-summarize.mjs +224 -0
- package/src/cli/codex-threads.mjs +89 -0
- package/src/cli/codex-visibility-smoke.mjs +196 -0
- package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
- package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
- package/src/cli/doctor.mjs +503 -1
- package/src/cli/doctor.test.mjs +542 -3
- package/src/cli/handoff-preview.mjs +78 -0
- package/src/cli/help.test.mjs +64 -0
- package/src/cli/install.mjs +226 -3
- package/src/cli/install.test.mjs +205 -4
- package/src/cli/trim.mjs +564 -0
- package/src/codex-app-server.mjs +1816 -0
- package/src/codex-app-server.test.mjs +512 -0
- package/src/codex-auto-refresh.mjs +194 -0
- package/src/codex-auto-refresh.test.mjs +182 -0
- package/src/codex-capture.mjs +235 -0
- package/src/codex-capture.test.mjs +393 -0
- package/src/codex-handoff-model-smoke.mjs +114 -0
- package/src/codex-handoff-model-smoke.test.mjs +89 -0
- package/src/codex-handoff-smoke.mjs +124 -0
- package/src/codex-handoff-smoke.test.mjs +103 -0
- package/src/codex-handoff.mjs +331 -0
- package/src/codex-handoff.test.mjs +220 -0
- package/src/codex-host-primitive-audit.mjs +374 -0
- package/src/codex-host-primitive-audit.test.mjs +208 -0
- package/src/codex-restore-smoke.test.mjs +639 -0
- package/src/codex-restore-source-audit.mjs +1348 -0
- package/src/codex-restore-source-audit.test.mjs +623 -0
- package/src/codex-resume.test.mjs +242 -0
- package/src/codex-rollout-memory.mjs +711 -0
- package/src/codex-rollout-memory.test.mjs +610 -0
- package/src/codex-sidecar-cli.test.mjs +75 -0
- package/src/codex-sidecar.mjs +246 -0
- package/src/codex-sidecar.test.mjs +172 -0
- package/src/codex-summarize.test.mjs +143 -0
- package/src/codex-thread-identity.mjs +23 -0
- package/src/codex-thread-index.mjs +173 -0
- package/src/codex-thread-index.test.mjs +164 -0
- package/src/codex-usage.mjs +110 -0
- package/src/codex-usage.test.mjs +140 -0
- package/src/codex-visibility-smoke.test.mjs +222 -0
- package/src/codex-vscode-restore-smoke.mjs +206 -0
- package/src/codex-vscode-restore-smoke.test.mjs +325 -0
- package/src/codex-vscode-rollback-smoke.mjs +90 -0
- package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
- package/src/db-schema.test.mjs +96 -0
- package/src/db.mjs +14 -1
- package/src/haiku-summarizer.mjs +267 -26
- package/src/haiku-summarizer.test.mjs +282 -0
- package/src/handoff-preview.test.mjs +108 -0
- package/src/handoff-record.mjs +294 -0
- package/src/handoff-record.test.mjs +226 -0
- package/src/hook-entrypoints.test.mjs +286 -0
- package/src/package-files.test.mjs +19 -0
- package/src/prompt-submit.mjs +9 -6
- package/src/resume-context.mjs +58 -171
- package/src/resume-context.test.mjs +177 -0
- package/src/session-start.mjs +85 -26
- package/src/state-file.mjs +50 -6
- package/src/state-file.test.mjs +50 -0
- package/src/token-monitor.mjs +14 -10
- package/src/token-monitor.test.mjs +27 -0
- package/src/trim-cli.test.mjs +1584 -0
- package/src/trim-model.mjs +584 -0
- package/src/trim-model.test.mjs +568 -0
- package/src/turn-processor.mjs +17 -10
- package/src/vscode-task.mjs +33 -10
- package/src/vscode-task.test.mjs +19 -9
- 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
|
+
});
|
package/src/cli/install.mjs
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* --project : .claude/settings.json(プロジェクトローカル)
|
|
7
7
|
* --uninstall: hook を削除
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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()) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -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
|
|
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: '.
|
|
385
|
+
PATHEXT: '.cmd',
|
|
185
386
|
});
|
|
186
387
|
assert.equal(result, binPath);
|
|
187
388
|
} else {
|