throughline 0.8.8 → 0.8.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 CHANGED
@@ -10,6 +10,25 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.8.9] — 2026-08-02
14
+
15
+ ### Fixed
16
+
17
+ - Codex hook diagnosis no longer depends on the caller's `PATH`. The expected
18
+ hook command is rebuilt per invocation, and `resolveCodexHookNodePath` returns
19
+ the PATH form of Node when one is on `PATH` and `process.execPath` otherwise.
20
+ Comparing that string against the registered command classified a correctly
21
+ installed hook as a legacy command whenever the two representations differed,
22
+ so `doctor --codex` reported "legacy command needs reinstall" and
23
+ `factory-diagnostics` reported `codex_hooks` as `not_ready` for every scheduled
24
+ run started from a minimal environment. Hook commands are now compared by
25
+ parsed identity — same Node executable by realpath, same CLI script by
26
+ realpath, same event — so a hook registered as `/opt/homebrew/bin/node` still
27
+ matches an expectation resolved to `/opt/homebrew/Cellar/node/<ver>/bin/node`.
28
+ Hooks pointing at a different Throughline installation, a different event, or
29
+ the legacy PATH-resolved form remain flagged for reinstall, and paths whose
30
+ realpath cannot be resolved are never treated as equivalent.
31
+
13
32
  ## [0.8.8] — 2026-08-02
14
33
 
15
34
  ### Fixed
@@ -1191,7 +1210,8 @@ two attempts, instrument first instead of patching again.
1191
1210
 
1192
1211
  ---
1193
1212
 
1194
- [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.8...HEAD
1213
+ [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.9...HEAD
1214
+ [0.8.9]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.8...v0.8.9
1195
1215
  [0.8.8]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.7...v0.8.8
1196
1216
  [0.8.7]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.6...v0.8.7
1197
1217
  [0.8.6]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.5...v0.8.6
@@ -143,7 +143,8 @@ schema v4 で PostToolUse (`capture-tool`) は廃止、L2/L3 は Stop 内で一
143
143
  | **npm 公開 (v0.8.4): Codex handoff host境界の明示化** | `$throughline` skillは現在のCodex UI surfaceからDesktop/VS Code/CLIを選び、`--open-host`を明示する。shell/永続PTYの継承環境はsurface判定に使わない。`codex-handoff-start`は互換の`openHost`を残し、requested / resolved hostをJSONとtextで報告する。古いVS Code由来PTY事故形と配布skillをfocused testで固定し、Claude-facing hook/`/tl`/baton/resume契約は変更しない。公開commit `5b840b6`、CI run `29721583754` 9/9 green、npm `latest` 0.8.4、shasum `1f2c39a22e45f3e02e8739ee5fd6ceefc6a71034`、tag / GitHub Release、registry由来global install、配置skill一致、`doctor --codex` exit 0を2026-07-20に確認した。判断証拠は [ADR 0017](adr/0017-codex-handoff-host-boundary.md) |
144
144
  | **npm公開 (v0.8.6): 製品所有DB migration入口** | `throughline migrate --json` は既存DBだけをproduction migrationで現行schemaへ移行し、versioned bounded JSONを返す。DB不在は作らず`not_applicable`、現行は`already_current`、future schemaと失敗は非0。`factory-diagnostics`のread-only契約は維持する。0.8.5はdry-run後に別作業の未コミット文書が混入したためdeprecateし、同一runtimeをclean worktreeから0.8.6として再公開した。設計判断は [ADR 0018](adr/0018-product-owned-database-migration.md) |
145
145
  | **公開済み (v0.8.7): Windows CI release latency** | completed-turn receiptの256件境界fixtureと実ACL検証を分離し、production mutationのPowerShellはdistinct state transitionだけに限定する。temporary ACL失敗時の旧store保持とdirectory/lock/final storeのnative ACL testを維持する。CIは9 matrixのままevent/ref単位concurrency、unit test 5分SLO/8分timeoutとする。設計判断は [ADR 0020](adr/0020-windows-ci-release-latency.md) |
146
- | **release準備 (v0.8.8): Codex hook timeout key修理** | Codex `hooks.json` の3 managed hookは秒単位の正規key `timeout`を30/30/300で書く。無視される旧`timeoutSec` entryは次回global installでcommand identityにより置換し、doctorは旧形式を要再install、factory diagnosticsはnot readyとする。Claude hooksとObserver wait deadline APIは変更しない |
146
+ | **npm公開 (v0.8.8): Codex hook timeout key修理** | Codex `hooks.json` の3 managed hookは秒単位の正規key `timeout`を30/30/300で書く。無視される旧`timeoutSec` entryは次回global installでcommand identityにより置換し、doctorは旧形式を要再install、factory diagnosticsはnot readyとする。Claude hooksとObserver wait deadline APIは変更しない。npm `latest` として2026-08-02T00:43Zに公開済み。tag / GitHub Releaseは未作成(要作成) |
147
+ | **release準備 (v0.8.9): Codex hook診断のPATH非依存化** | 期待hook commandのnode表記は呼び出し元の`PATH`で変わるため、登録済みcommandとの文字列比較では最小PATH(launchd factory reporter等)から正規登録が「legacy command」と誤判定され、`codex_hooks`が恒常的に`not_ready`になっていた。比較を解析済みidentity(node実体realpath+CLI script realpath+event)へ変更する。別install/別event/旧PATH解決型/realpath未解決は従来どおり要再install扱い。hooks.json の書式と Claude-facing hook は変更しない |
147
148
  | **未リリース: Codex current-session 75% trigger** | Codex 自動発火を token-monitor に依存させず、global install が Codex `UserPromptSubmit` / `PostToolUse` hooks も登録する。hook は当該 Codex session の rollout `token_count` を直接読み、verified 75% 以上なら同じ user turn または tool loop 継続前に `$throughline` workflow 実行指示を `additionalContext` で注入する。Stop hook の guarded auto-refresh は残す。`~/.codex/config.toml` は旧 `codex_hooks = true` に加えて現行 `hooks = true` も有効化する |
148
149
  | **グローバル E2E 検証** | 2026-04-17 別ディレクトリから `throughline doctor` 全緑を確認 |
149
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -32,6 +32,7 @@ import {
32
32
  buildCodexPostToolUseHookCommand,
33
33
  buildCodexStopHookCommand,
34
34
  buildCodexUserPromptSubmitHookCommand,
35
+ isEquivalentCodexHookCommand,
35
36
  isThroughlineCodexHookCommand,
36
37
  isThroughlineCodexPostToolUseCommand,
37
38
  isThroughlineCodexStopCommand,
@@ -576,13 +577,17 @@ function readCodexHookDiagnosis(codexHome) {
576
577
  );
577
578
  const stopHooks = listHooksWithTrust(parsed, 'Stop', 'stop', hooksPath, out.trustedStateKeys);
578
579
  out.managedPromptHooks = promptHooks.filter(h => isThroughlineCodexHookCommand(h.command));
579
- out.legacyManagedPromptHooks = out.managedPromptHooks.filter(h => h.command !== expectedPromptCommand);
580
+ out.legacyManagedPromptHooks = out.managedPromptHooks.filter(
581
+ h => !isEquivalentCodexHookCommand(h.command, expectedPromptCommand),
582
+ );
580
583
  out.managedPostToolUseHooks = postToolUseHooks.filter(h => isThroughlineCodexPostToolUseCommand(h.command));
581
584
  out.legacyManagedPostToolUseHooks = out.managedPostToolUseHooks.filter(
582
- h => h.command !== expectedPostToolUseCommand,
585
+ h => !isEquivalentCodexHookCommand(h.command, expectedPostToolUseCommand),
583
586
  );
584
587
  out.managedStopHooks = stopHooks.filter(h => isThroughlineCodexStopCommand(h.command));
585
- out.legacyManagedStopHooks = out.managedStopHooks.filter(h => h.command !== expectedStopCommand);
588
+ out.legacyManagedStopHooks = out.managedStopHooks.filter(
589
+ h => !isEquivalentCodexHookCommand(h.command, expectedStopCommand),
590
+ );
586
591
  out.managedHookTrust = summarizeHookTrust(
587
592
  [
588
593
  ...out.managedPromptHooks,
@@ -7,6 +7,7 @@ import { buildFactoryDiagnostics } from '../factory-diagnostics.mjs';
7
7
  import { findCodexThreadCandidate, defaultCodexHome } from '../codex-thread-index.mjs';
8
8
  import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
9
9
  import { CURRENT_VERSION } from '../db.mjs';
10
+ import { isEquivalentCodexHookCommand } from './install.mjs';
10
11
  import { _internal as doctorInternal } from './doctor.mjs';
11
12
 
12
13
  const require = createRequire(import.meta.url);
@@ -126,8 +127,8 @@ function eventStatus({ hooks, legacyHooks, featureEnabled, expectedCommand, time
126
127
  if (hooks.length === 0) return featureEnabled ? 'not_ready' : 'not_applicable';
127
128
  if (!featureEnabled || legacyHooks.length > 0 || hooks.length !== 1) return 'not_ready';
128
129
  const hook = hooks[0];
129
- return hook.type === 'command' && hook.command === expectedCommand && hook.timeout === timeout &&
130
- hook.async === false ? 'ready' : 'not_ready';
130
+ return hook.type === 'command' && isEquivalentCodexHookCommand(hook.command, expectedCommand) &&
131
+ hook.timeout === timeout && hook.async === false ? 'ready' : 'not_ready';
131
132
  }
132
133
 
133
134
  export function inspectFactoryHooks({ codexHome = defaultCodexHome(), readHooks = doctorInternal.readCodexHookDiagnosis } = {}) {
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
- import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
 
@@ -202,6 +202,48 @@ test('factory-diagnostics hook inspection summarizes every canonical ready event
202
202
  assert.equal(result.reason, 'hooks_inspected');
203
203
  });
204
204
 
205
+ test('factory-diagnostics hook inspection: 最小 PATH 由来の node 別表記を not_ready にしない', () => {
206
+ // launchd の factory reporter は PATH に /opt/homebrew/bin を持たないため、期待値の
207
+ // node が PATH symlink ではなく実体表記になる。同一 node を指す限り ready を維持する。
208
+ const dir = mkdtempSync(join(tmpdir(), 'tl-factory-hook-'));
209
+ try {
210
+ mkdirSync(join(dir, 'bin'));
211
+ mkdirSync(join(dir, 'cellar'));
212
+ const realNode = join(dir, 'cellar', 'node');
213
+ const pathNode = join(dir, 'bin', 'node');
214
+ const script = join(dir, 'throughline.mjs');
215
+ writeFileSync(realNode, '');
216
+ writeFileSync(script, '');
217
+ symlinkSync(realNode, pathNode);
218
+ const registered = (event) => `${pathNode} ${script} codex-hook ${event}`;
219
+ const expected = (event) => `${realNode} ${script} codex-hook ${event}`;
220
+
221
+ const result = inspectFactoryHooks({
222
+ readHooks: () => ({
223
+ configExists: true,
224
+ configReadable: true,
225
+ hooksExists: true,
226
+ hooksReadable: true,
227
+ featureEnabled: true,
228
+ expectedPromptCommand: expected('user-prompt-submit'),
229
+ expectedPostToolUseCommand: expected('post-tool-use'),
230
+ expectedStopCommand: expected('stop'),
231
+ managedPromptHooks: [{ type: 'command', command: registered('user-prompt-submit'), timeout: 30, async: false }],
232
+ legacyManagedPromptHooks: [],
233
+ managedPostToolUseHooks: [{ type: 'command', command: registered('post-tool-use'), timeout: 30, async: false }],
234
+ legacyManagedPostToolUseHooks: [],
235
+ managedStopHooks: [{ type: 'command', command: registered('stop'), timeout: 300, async: false }],
236
+ legacyManagedStopHooks: [],
237
+ }),
238
+ });
239
+
240
+ assert.deepEqual(result.events, { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' });
241
+ assert.equal(result.status, 'ready');
242
+ } finally {
243
+ rmSync(dir, { recursive: true, force: true });
244
+ }
245
+ });
246
+
205
247
  test('factory-diagnostics hook inspection rejects legacy timeoutSec keys', () => {
206
248
  const result = inspectFactoryHooks({
207
249
  readHooks: () => ({
@@ -163,6 +163,62 @@ export function isThroughlineCodexPostToolUseCommand(command) {
163
163
  );
164
164
  }
165
165
 
166
+ const CODEX_HOOK_EVENTS = new Set(['stop', 'user-prompt-submit', 'post-tool-use']);
167
+
168
+ function tokenizeCommand(command) {
169
+ const tokens = [];
170
+ const pattern = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
171
+ let match;
172
+ while ((match = pattern.exec(command)) !== null) {
173
+ // quoteCommandPath は `"` だけを escape する。Windows path の `\` は温存する。
174
+ if (match[1] !== undefined) tokens.push(match[1].replace(/\\"/g, '"'));
175
+ else if (match[2] !== undefined) tokens.push(match[2]);
176
+ else tokens.push(match[3]);
177
+ }
178
+ return tokens;
179
+ }
180
+
181
+ /**
182
+ * 絶対パス型の Codex hook command を {nodePath, scriptPath, event} へ分解する。
183
+ * 旧 PATH 解決型 (`throughline codex-hook stop`) など、この形でないものは null。
184
+ */
185
+ export function parseCodexHookCommand(command) {
186
+ if (typeof command !== 'string') return null;
187
+ const tokens = tokenizeCommand(command.replace(/^&\s+/, ''));
188
+ if (tokens.length !== 4) return null;
189
+ const [nodePath, scriptPath, subcommand, event] = tokens;
190
+ if (subcommand !== 'codex-hook' || !CODEX_HOOK_EVENTS.has(event)) return null;
191
+ if (!scriptPath.endsWith('throughline.mjs')) return null;
192
+ return { nodePath, scriptPath, event };
193
+ }
194
+
195
+ /**
196
+ * 登録済み hook command が期待値と同じ実行体を指すかを判定する。
197
+ *
198
+ * node の表記は呼び出し元の PATH で変わる (`resolveCodexHookNodePath` は PATH 上に
199
+ * 同一 node があればその表記を、無ければ `process.execPath` を返す)。launchd の
200
+ * ような最小 PATH から診断すると `/opt/homebrew/bin/node` で登録された正規 hook が
201
+ * `/opt/homebrew/Cellar/node/<ver>/bin/node` と文字列比較され、正しい登録が legacy
202
+ * と誤判定される。実体 (realpath) の同一性で比較してこれを防ぐ。realpath を解決
203
+ * できない場合は同一とみなさない。
204
+ */
205
+ export function isEquivalentCodexHookCommand(actual, expected, { realpath = realpathSync.native } = {}) {
206
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false;
207
+ if (actual === expected) return true;
208
+ const left = parseCodexHookCommand(actual);
209
+ const right = parseCodexHookCommand(expected);
210
+ if (!left || !right || left.event !== right.event) return false;
211
+ return isSameExecutablePath(left.scriptPath, right.scriptPath, realpath) &&
212
+ isSameExecutablePath(left.nodePath, right.nodePath, realpath);
213
+ }
214
+
215
+ function isSameExecutablePath(a, b, realpath) {
216
+ if (a === b) return true;
217
+ const left = safeRealpath(a, realpath);
218
+ const right = safeRealpath(b, realpath);
219
+ return left !== null && right !== null && left === right;
220
+ }
221
+
166
222
  function createCodexHooks() {
167
223
  return {
168
224
  UserPromptSubmit: {
@@ -8,7 +8,9 @@ import {
8
8
  buildCodexPostToolUseHookCommand,
9
9
  buildCodexStopHookCommand,
10
10
  buildCodexUserPromptSubmitHookCommand,
11
+ isEquivalentCodexHookCommand,
11
12
  isThroughlineCodexHookCommand,
13
+ parseCodexHookCommand,
12
14
  resolveCodexHookNodePath,
13
15
  run,
14
16
  resolveThroughlineOnPath,
@@ -580,6 +582,65 @@ test('resolveCodexHookNodePath: falls back to process execPath when PATH node is
580
582
  assert.equal(result, execPath);
581
583
  });
582
584
 
585
+ test('parseCodexHookCommand: 絶対パス型を分解し、旧 PATH 解決型と別コマンドは null', () => {
586
+ assert.deepEqual(
587
+ parseCodexHookCommand('/opt/homebrew/bin/node /pkg/bin/throughline.mjs codex-hook stop'),
588
+ { nodePath: '/opt/homebrew/bin/node', scriptPath: '/pkg/bin/throughline.mjs', event: 'stop' },
589
+ );
590
+ assert.deepEqual(
591
+ parseCodexHookCommand('& "C:\\node.exe" "C:\\pkg\\bin\\throughline.mjs" codex-hook post-tool-use'),
592
+ { nodePath: 'C:\\node.exe', scriptPath: 'C:\\pkg\\bin\\throughline.mjs', event: 'post-tool-use' },
593
+ );
594
+ assert.equal(parseCodexHookCommand('throughline codex-hook stop'), null);
595
+ assert.equal(parseCodexHookCommand('/bin/node /pkg/bin/throughline.mjs codex-hook rewind'), null);
596
+ assert.equal(parseCodexHookCommand('/bin/node /pkg/bin/other.mjs codex-hook stop'), null);
597
+ });
598
+
599
+ test('isEquivalentCodexHookCommand: 同一 node 実体を指す別表記を legacy と誤判定しない', () => {
600
+ // launchd の最小 PATH から診断すると expected 側だけ Cellar 実体表記になる。
601
+ const registered = '/opt/homebrew/bin/node /pkg/bin/throughline.mjs codex-hook user-prompt-submit';
602
+ const expected = '/opt/homebrew/Cellar/node/26.5.0/bin/node /pkg/bin/throughline.mjs codex-hook user-prompt-submit';
603
+ const realpath = (p) => (p.endsWith('node') ? '/real/node' : p);
604
+ assert.equal(isEquivalentCodexHookCommand(registered, expected, { realpath }), true);
605
+ });
606
+
607
+ test('isEquivalentCodexHookCommand: 別 install / 別 event / 旧形式は同一とみなさない', () => {
608
+ const realpath = (p) => p;
609
+ assert.equal(
610
+ isEquivalentCodexHookCommand(
611
+ '/bin/node /old/bin/throughline.mjs codex-hook stop',
612
+ '/bin/node /new/bin/throughline.mjs codex-hook stop',
613
+ { realpath },
614
+ ),
615
+ false,
616
+ );
617
+ assert.equal(
618
+ isEquivalentCodexHookCommand(
619
+ '/bin/node /pkg/bin/throughline.mjs codex-hook stop',
620
+ '/bin/node /pkg/bin/throughline.mjs codex-hook post-tool-use',
621
+ { realpath },
622
+ ),
623
+ false,
624
+ );
625
+ assert.equal(
626
+ isEquivalentCodexHookCommand(
627
+ 'throughline codex-hook stop',
628
+ '/bin/node /pkg/bin/throughline.mjs codex-hook stop',
629
+ { realpath },
630
+ ),
631
+ false,
632
+ );
633
+ // realpath を解決できない時は同一とみなさない(暗黙の緩和をしない)
634
+ assert.equal(
635
+ isEquivalentCodexHookCommand(
636
+ '/a/node /pkg/bin/throughline.mjs codex-hook stop',
637
+ '/b/node /pkg/bin/throughline.mjs codex-hook stop',
638
+ { realpath: (p) => { throw new Error(`missing: ${p}`); } },
639
+ ),
640
+ false,
641
+ );
642
+ });
643
+
583
644
  test('install is idempotent: second run keeps exactly one tl.md and one hook entry', async () => {
584
645
  const home = makeTempHome();
585
646
  if (home.resolved !== home.dir) {