throughline 0.6.2 → 0.6.3

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,7 +10,21 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
- ## [0.6.2] — release candidate (pending)
13
+ ### Fixed
14
+
15
+ - `throughline factory-diagnostics --json` now reports the Codex hook summary
16
+ as `ready` when all three canonical managed hooks are ready. The Codex-only
17
+ overall aggregate no longer treats the separately exposed, uninspected
18
+ Claude connector as a blocking `unverified` state. The Claude connector
19
+ remains explicitly `unverified`; diagnostic output remains read-only and
20
+ privacy-safe.
21
+ - Windows runtime-error mutations no longer repeat identical PowerShell ACL
22
+ verification inside one bounded observation. Existing lock/store files are
23
+ still verified before use, new temporary files receive an exact
24
+ current-SID-only ACL before atomic replacement, and the five-second hook
25
+ observer deadline is unchanged.
26
+
27
+ ## [0.6.2] — 2026-07-13
14
28
 
15
29
  ### Added
16
30
 
@@ -22,9 +36,9 @@ shipped to npm but were not individually tagged on GitHub.
22
36
  owner-private atomic store with count, first/last seen, resolve/reopen,
23
37
  monotonic cursor/ack, retention that preserves unacknowledged records, and
24
38
  bounded `throughline runtime-errors ... --json` snapshot/diagnostics APIs.
25
- - Release candidate only: collection remains disabled by default and the local
26
- store sends no network traffic. Publication, tag, CI, and registry-install
27
- verification are pending.
39
+ - Collection remains disabled by default and the local store sends no network
40
+ traffic. Public commit `e6ce6e3`, CI `29238704750`, npm `latest`, tag / GitHub
41
+ Release, and a registry-derived isolated install were verified.
28
42
  - Raised the Node.js floor to 22.13, where `node:sqlite` is available without
29
43
  an experimental command-line flag; CI now exercises that exact minimum.
30
44
 
package/README.md CHANGED
@@ -716,10 +716,13 @@ entry to the `tasks` array yourself:
716
716
 
717
717
  ## Commands
718
718
 
719
- **v0.6.2 release candidate(公開待ち)です。** factory diagnostics local
720
- runtime error aggregate collection が既定OFFで、canonical dotagents config の
719
+ **v0.6.3 はリリース準備中です。** factory diagnostics Codex の managed
720
+ `UserPromptSubmit` / `PostToolUse` / `Stop` hooks がすべて canonical `ready` の場合に
721
+ Codex readiness を `ready` と返す。Claude connector は未検査のまま明示的に
722
+ `unverified` とし、Codex-only overall を妨げない。local runtime error
723
+ aggregate は collection が既定OFFで、canonical dotagents config の
721
724
  `collection.enabled: true` を明示した場合だけ収集します。いずれも network I/O は
722
- 行いません。npm公開、tag、CI、registry由来installの確認は未完了です。
725
+ 行いません。v0.6.3 の npm publish、tag、GitHub Release は未実施です。
723
726
 
724
727
  | Command | What it does |
725
728
  | ---------------------------------------------- | ------------------------------------------------------------ |
@@ -138,7 +138,7 @@ schema v4 で PostToolUse (`capture-tool`) は廃止、L2/L3 は Stop 内で一
138
138
  | **npm 公開 (v0.4.1): typed `/clear` も baton を書く + 2 経路の優先順位入れ替え** | 2026-05-09 `/clear` を UserPromptSubmit hook で検出した時点で当該セッションの `session_id` を `handoff_batons` に書き込み、次 SessionStart が確定的にそのセッションを引き継ぐ。これで multi-window で「最新更新セッション ≠ /clear したセッション」になるシナリオで `findLatestClaudePredecessor` heuristic が誤った前任を選ぶ問題を解消。2 経路の優先順位を **baton path = primary、auto path = fallback** に変更 (auto path は VSCode 拡張メニュー由来など UserPromptSubmit に届かない経路のフォールバック)。`THROUGHLINE_DISABLE_AUTO_HANDOFF=1` は fallback path のみに作用するようになった (typed `/clear` / `/tl` は env と無関係に発火する)。あわせて `.vscode/tasks.json` を git 追跡から外し (gitignore)、`ensureMonitorTaskFile` が hook 発火ごとに絶対パスを書き換える挙動による別環境での dirty diff を解消。`src/prompt-submit.test.mjs` を新設し、`isClearCommand` / `isBatonCommand` 判定 14 件と subprocess+DB 実体テスト 3 件を追加。詳細は [CHANGELOG.md](../CHANGELOG.md) |
139
139
  | **npm 公開 (v0.4.7): Codex monitor direct discovery + 80% auto-refresh** | 2026-05-09 Codex Stop hook auto-refresh の verified usage threshold を 90% から 80% に変更し、Codex native auto-compact より先に Throughline DB memory refresh を試行する。estimate usage / estimated context window では mutation しない。`throughline monitor` は `~/.throughline/state` に加えて `~/.codex/sessions/**/rollout-*.jsonl` を直接 discovery し、Throughline state が未生成の現在 Codex thread も表示する。既存 state がある場合は state の usage snapshot を保持しつつ discovered rollout path / mtime を合流する。Codex 表示 ID は `codex:01` ではなく raw thread id 先頭 8 桁 (`019e085c`) にした。Codex open turn の transient `output_tokens` は token count に overlay するが、モデル欄の `live+<tokens>` marker は表示しない |
140
140
  | **npm 公開 (v0.6.1): Spotter auditor context projection** | `throughline auditor-context`を追加。exact session/projectと最新完了L2 pairのorigin/turn/hash freshnessを検査し、fresh時だけbounded user/assistant本文をversioned JSONで返す。DBはread-onlyで開き、作成・migration・WAL書き込みをしない。Spotter側はproject opt-inでのみ利用する。580 tests、CI 6/6、pack scan、registry global install / doctorを通過し、npm / tag / GitHub Releaseを2026-07-13に公開した |
141
- | **v0.6.2 release candidate(公開待ち): factory diagnostics + local runtime error store** | `throughline factory-diagnostics --json` と `throughline runtime-errors ... --json` を公開候補へ同期する。collection は canonical dotagents config の `collection.enabled: true` が明示された時だけ有効で既定OFF、store は network I/O を行わない。npm公開、tag、CI、registry由来installの確認はこの時点で未実施であり、公開済みとは扱わない |
141
+ | **npm 公開 (v0.6.2): factory diagnostics + local runtime error store** | `throughline factory-diagnostics --json` と `throughline runtime-errors ... --json` を追加。collection は canonical dotagents config の `collection.enabled: true` が明示された時だけ有効で既定OFF、store は network I/O を行わない。公開commit `e6ce6e3`、CI `29238704750`、npm `latest`、tag / GitHub Release、registry由来隔離installと診断snapshotを2026-07-13に確認した |
142
142
  | **未リリース: 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` も有効化する |
143
143
  | **グローバル E2E 検証** | 2026-04-17 別ディレクトリから `throughline doctor` 全緑を確認 |
144
144
 
@@ -23,8 +23,10 @@ session の本文、秘密、絶対パス、例外詳細を出力しない。
23
23
  検証する。handoff memoryは一致確認済みproject/threadだけを数える。
24
24
  - restore capabilityとlive smoke evidenceを分離する。未実行smokeは`unverified`だが、
25
25
  capability readinessを恒常的に失敗扱いにはしない。
26
- - Codex hookはcanonical shapeを検査するが、trust実火は機械検証不能なので`unverified`に
27
- 留める。Claude connectorはこの単位では未検査として明示`unverified`にする。
26
+ - Codex hook canonical shape を検査し、3 managed event がすべて `ready` なら hook
27
+ summary も `ready` とする。trust 実火はこの read-only projection の判定対象外であり、
28
+ Claude connector はこの単位では未検査として明示 `unverified` にする。ただし後者は
29
+ Codex-only overall を阻害しない。
28
30
 
29
31
  ## 実施 TODO
30
32
 
@@ -1,6 +1,6 @@
1
1
  # BugHub runtime error store plan
2
2
 
3
- Status: implementation complete; v0.6.2 release pending
3
+ Status: v0.6.2 complete; v0.6.3 Windows bounded-observer hardening in progress
4
4
 
5
5
  This plan is the implementation TODO for Throughline's product-owned, local
6
6
  runtime error projection. It implements the cross-repository contract in
@@ -43,10 +43,31 @@ existing transcript, handoff, or SQLite memory contracts.
43
43
  - [x] Run the complete test suite and update product documentation.
44
44
  - [x] Commit and push this repository independently.
45
45
 
46
+ ### v0.6.3 Windows bounded-observer hardening
47
+
48
+ - [x] Reproduce the public-CI failure where a cold Windows Node 24 observer
49
+ exceeded the five-second product deadline while starting several
50
+ PowerShell ACL apply/verify processes.
51
+ - [x] Characterize the current-SID-only directory, SQLite lock, existing
52
+ store, unique temporary file, atomic replacement, and five-second
53
+ no-fallback contract before changing the implementation.
54
+ - [x] Bound one mutation to the minimum distinct ACL transitions: apply plus
55
+ read-back for a private directory and each newly created file, and one
56
+ read-before-use verification for an existing lock/store. Carry a private
57
+ in-process directory capability so unchanged paths are not rechecked by
58
+ another PowerShell process in the same mutation.
59
+ - [x] Keep malformed/symlink/ACL-drift state fail-loud, leave no late child or
60
+ partial final store after failure, and do not extend the product deadline.
61
+ - [x] Keep the production 3-second ACL and 5-second observer bounds unchanged,
62
+ but serialize Windows CI test files so parallel PowerShell fixture load
63
+ cannot consume those bounds before the contract under test runs.
64
+ - [ ] Run focused Windows matrix tests, the complete suite, pack inspection,
65
+ registry-derived smoke, and the public Node 22/24 CI matrix before publish.
66
+
46
67
  ## Release wave
47
68
 
48
69
  - [x] Bump the package and release-facing documentation to `0.6.2` without moving an existing tag.
49
70
  - [x] Run the full test suite, pack inspection, secret/path scan, and temporary-prefix install smoke.
50
- - [ ] Push the release commit and require the public CI gate to pass.
51
- - [ ] After the owner H gate, publish `throughline@0.6.2`, create tag/release, and verify npm `latest` plus a registry-derived global install.
52
- - [ ] Confirm `throughline factory-diagnostics --json` and the runtime-error snapshot/ack commands from the published package, then record the public SHA and results in the changelog and canonical docs.
71
+ - [x] Push the release commit and require the public CI gate to pass (`e6ce6e3`, CI `29238704750`).
72
+ - [x] After the owner H gate, publish `throughline@0.6.2`, create tag/release, and verify npm `latest` plus a registry-derived isolated install. Global installation is deferred to the dotagents Mac rollout wave.
73
+ - [x] Confirm `throughline factory-diagnostics --json` and the runtime-error snapshot from the published package, then record the public SHA and results in the changelog and canonical docs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -163,9 +163,11 @@ export function inspectFactoryHooks({ codexHome = defaultCodexHome(), readHooks
163
163
  return {
164
164
  status: values.includes('not_ready')
165
165
  ? 'not_ready'
166
- : values.includes('ready')
166
+ : values.includes('unverified')
167
167
  ? 'unverified'
168
- : 'not_applicable',
168
+ : values.includes('ready')
169
+ ? 'ready'
170
+ : 'not_applicable',
169
171
  reason: 'hooks_inspected',
170
172
  events,
171
173
  };
@@ -168,6 +168,31 @@ test('factory-diagnostics hook inspection rejects corrupt and false-ready shapes
168
168
  assert.equal(malformed.status, 'not_ready');
169
169
  });
170
170
 
171
+ test('factory-diagnostics hook inspection summarizes every canonical ready event as ready', () => {
172
+ const result = inspectFactoryHooks({
173
+ readHooks: () => ({
174
+ configExists: true,
175
+ configReadable: true,
176
+ hooksExists: true,
177
+ hooksReadable: true,
178
+ featureEnabled: true,
179
+ expectedPromptCommand: 'prompt',
180
+ expectedPostToolUseCommand: 'post',
181
+ expectedStopCommand: 'stop',
182
+ managedPromptHooks: [{ type: 'command', command: 'prompt', timeoutSec: 30, async: false }],
183
+ legacyManagedPromptHooks: [],
184
+ managedPostToolUseHooks: [{ type: 'command', command: 'post', timeoutSec: 30, async: false }],
185
+ legacyManagedPostToolUseHooks: [],
186
+ managedStopHooks: [{ type: 'command', command: 'stop', timeoutSec: 300, async: false }],
187
+ legacyManagedStopHooks: [],
188
+ }),
189
+ });
190
+
191
+ assert.deepEqual(result.events, { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' });
192
+ assert.equal(result.status, 'ready');
193
+ assert.equal(result.reason, 'hooks_inspected');
194
+ });
195
+
171
196
  function createFactorySchema(db) {
172
197
  db.exec(`
173
198
  PRAGMA user_version = 8;
@@ -77,7 +77,6 @@ export function buildFactoryDiagnostics({ version, database = {}, hooks = {}, th
77
77
  status: aggregate([
78
78
  stateSchemaStatus,
79
79
  hooksStatus,
80
- claudeConnectorStatus,
81
80
  captureStatus,
82
81
  restoreStatus,
83
82
  handoffStatus,
@@ -45,6 +45,24 @@ test('factory diagnostics: restore は実行していない smoke を ready に
45
45
  assert.equal(result.overall.status, 'ready');
46
46
  });
47
47
 
48
+ test('factory diagnostics: Claude connector 未検査は Codex-only ready snapshot を unverified にしない', () => {
49
+ const result = buildFactoryDiagnostics({
50
+ version: '0.6.2',
51
+ database: { status: 'ready', schemaVersion: 8, supportedSchemaVersion: 8, handoffMemory: true },
52
+ hooks: {
53
+ status: 'ready',
54
+ events: { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' },
55
+ },
56
+ thread: { status: 'ready', rolloutAvailable: true },
57
+ });
58
+
59
+ assert.equal(result.hooks.status, 'ready');
60
+ assert.equal(result.connectors.codex.status, 'ready');
61
+ assert.equal(result.connectors.claude.status, 'unverified');
62
+ assert.equal(result.connectors.claude.reason, 'diagnostic_unverified');
63
+ assert.equal(result.overall.status, 'ready');
64
+ });
65
+
48
66
  test('factory diagnostics: JSON に本文、秘密、絶対 path、生 state を含めない', () => {
49
67
  const secret = 'sk-test-very-secret';
50
68
  const body = 'ユーザーの prompt 本文';
@@ -9,7 +9,7 @@ import {
9
9
  rmSync,
10
10
  writeFileSync,
11
11
  } from 'node:fs';
12
- import { spawnSync } from 'node:child_process';
12
+ import childProcess from 'node:child_process';
13
13
  import { arch as hostArch, homedir, platform as hostPlatform } from 'node:os';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { createRequire } from 'node:module';
@@ -27,6 +27,7 @@ const BEST_EFFORT_TIMEOUT_MS = 750;
27
27
  const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
28
28
  const WINDOWS_ACL_TIMEOUT_MS = 3_000;
29
29
  const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
30
+ const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
30
31
 
31
32
  const DEFINITIONS = Object.freeze({
32
33
  HOOK_SESSION_START_FAILED: Object.freeze({
@@ -86,13 +87,13 @@ export function observeRuntimeError(input, options = {}) {
86
87
  if (!definition) throw new TypeError('未登録の runtime error code です');
87
88
  if (!collectionEnabled(options)) return { status: 'disabled' };
88
89
 
89
- return withStoreLock(options, () => {
90
+ return withStoreLock(options, (privateDirectory) => {
90
91
  const now = normalizeTimestamp(input.now);
91
92
  const version = normalizeVersion(options.version ?? PACKAGE_VERSION);
92
93
  const fingerprint = createHash('sha256')
93
94
  .update(['throughline', definition.component, input.code, definition.template].join('\0'))
94
95
  .digest('hex');
95
- const store = readStore(options);
96
+ const store = readStore(options, { privateDirectory });
96
97
  const existing = store.records.find((record) => record.fingerprint === fingerprint);
97
98
  const sequence = nextSequence(store);
98
99
  if (existing) {
@@ -115,7 +116,7 @@ export function observeRuntimeError(input, options = {}) {
115
116
  status: 'open', resolved_at: null, reason_code: null, sequence,
116
117
  });
117
118
  }
118
- writeStore(store, options);
119
+ writeStore(store, options, privateDirectory);
119
120
  return { status: 'recorded', fingerprint, sequence };
120
121
  });
121
122
  }
@@ -124,8 +125,8 @@ export function resolveRuntimeError(fingerprint, options = {}) {
124
125
  assertExactOptions(options, ['env', 'configPath', 'storePath', 'now', 'reasonCode']);
125
126
  assertFingerprint(fingerprint);
126
127
  if (!collectionEnabled(options)) return { status: 'disabled' };
127
- return withStoreLock(options, () => {
128
- const store = readStore(options);
128
+ return withStoreLock(options, (privateDirectory) => {
129
+ const store = readStore(options, { privateDirectory });
129
130
  const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
130
131
  if (!record) return { status: 'not_found' };
131
132
  if (record.status === 'resolved') return { status: 'resolved', sequence: record.sequence };
@@ -135,7 +136,7 @@ export function resolveRuntimeError(fingerprint, options = {}) {
135
136
  record.resolved_at = normalizeTimestamp(options.now);
136
137
  record.reason_code = reasonCode;
137
138
  record.sequence = nextSequence(store);
138
- writeStore(store, options);
139
+ writeStore(store, options, privateDirectory);
139
140
  return { status: 'resolved', sequence: record.sequence };
140
141
  });
141
142
  }
@@ -144,8 +145,8 @@ export function reopenRuntimeError(fingerprint, options = {}) {
144
145
  assertExactOptions(options, ['env', 'configPath', 'storePath']);
145
146
  assertFingerprint(fingerprint);
146
147
  if (!collectionEnabled(options)) return { status: 'disabled' };
147
- return withStoreLock(options, () => {
148
- const store = readStore(options);
148
+ return withStoreLock(options, (privateDirectory) => {
149
+ const store = readStore(options, { privateDirectory });
149
150
  const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
150
151
  if (!record) return { status: 'not_found' };
151
152
  if (record.status === 'open') return { status: 'open', sequence: record.sequence };
@@ -153,7 +154,7 @@ export function reopenRuntimeError(fingerprint, options = {}) {
153
154
  record.resolved_at = null;
154
155
  record.reason_code = null;
155
156
  record.sequence = nextSequence(store);
156
- writeStore(store, options);
157
+ writeStore(store, options, privateDirectory);
157
158
  return { status: 'open', sequence: record.sequence };
158
159
  });
159
160
  }
@@ -161,12 +162,12 @@ export function reopenRuntimeError(fingerprint, options = {}) {
161
162
  export function acknowledgeRuntimeErrors(cursor, options = {}) {
162
163
  if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('cursor は非負の整数が必要です');
163
164
  if (!collectionEnabled(options)) return { status: 'disabled', acknowledgedThrough: 0 };
164
- return withStoreLock(options, () => {
165
- const store = readStore(options);
165
+ return withStoreLock(options, (privateDirectory) => {
166
+ const store = readStore(options, { privateDirectory });
166
167
  const highWatermark = store.next_sequence - 1;
167
168
  if (cursor > highWatermark) throw new RangeError('cursor がstore high watermarkを超えています');
168
169
  store.acknowledged_through = Math.max(store.acknowledged_through, cursor);
169
- writeStore(store, options);
170
+ writeStore(store, options, privateDirectory);
170
171
  return { status: 'acknowledged', acknowledgedThrough: store.acknowledged_through };
171
172
  });
172
173
  }
@@ -183,8 +184,8 @@ export function compactRuntimeErrors({
183
184
  }
184
185
  const options = { env, configPath, storePath };
185
186
  if (!collectionEnabled(options)) return { status: 'disabled', removed: 0 };
186
- return withStoreLock(options, () => {
187
- const store = readStore(options);
187
+ return withStoreLock(options, (privateDirectory) => {
188
+ const store = readStore(options, { privateDirectory });
188
189
  const cutoff = Date.parse(normalizeTimestamp(now)) - retentionMs;
189
190
  const before = store.records.length;
190
191
  store.records = store.records.filter((record) => {
@@ -192,7 +193,7 @@ export function compactRuntimeErrors({
192
193
  const expired = Date.parse(record.last_seen) <= cutoff;
193
194
  return !(acknowledged && record.status === 'resolved' && expired);
194
195
  });
195
- writeStore(store, options);
196
+ writeStore(store, options, privateDirectory);
196
197
  return { status: 'compacted', removed: before - store.records.length };
197
198
  });
198
199
  }
@@ -256,7 +257,7 @@ export function getRuntimeErrorDiagnostics(options = {}) {
256
257
  export function recordRuntimeErrorBestEffort(code, options = {}) {
257
258
  const { stderr = process.stderr, ...storeOptions } = options;
258
259
  try {
259
- const child = spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
260
+ const child = childProcess.spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
260
261
  env: storeOptions.env ?? process.env,
261
262
  encoding: 'utf8',
262
263
  stdio: 'ignore',
@@ -288,13 +289,13 @@ function emptyStore() {
288
289
  };
289
290
  }
290
291
 
291
- function readStore(options, { missingIsEmpty = true } = {}) {
292
+ function readStore(options, { missingIsEmpty = true, privateDirectory } = {}) {
292
293
  const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
293
294
  let parsed;
294
295
  try {
295
296
  const info = lstatSync(path);
296
297
  if (!info.isFile() || info.isSymbolicLink()) throw new Error('runtime error store path unsafe');
297
- assertPrivateStoreDirectory(dirname(path), options.env);
298
+ assertPrivateDirectoryCapability(privateDirectory, dirname(path), options.env);
298
299
  assertPrivateStoreFile(info, options.env, path);
299
300
  parsed = JSON.parse(readFileSync(path, 'utf8'));
300
301
  } catch (error) {
@@ -305,19 +306,28 @@ function readStore(options, { missingIsEmpty = true } = {}) {
305
306
  return parsed;
306
307
  }
307
308
 
308
- function writeStore(store, options) {
309
+ function writeStore(store, options, privateDirectory) {
309
310
  validateStore(store);
310
311
  const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
311
312
  const directory = dirname(path);
312
- ensurePrivateStoreDirectory(directory, options.env);
313
+ assertPrivateDirectoryCapability(privateDirectory, directory, options.env);
313
314
  const temporary = join(directory, `.runtime-errors.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
314
315
  try {
315
316
  writeFileSync(temporary, `${JSON.stringify(store)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
316
- if (!isWindows(options.env)) chmodSync(temporary, 0o600);
317
+ if (!isWindows(options.env)) {
318
+ chmodSync(temporary, 0o600);
319
+ } else {
320
+ // The apply script performs an exact read-back verification. Repeating
321
+ // the same PowerShell verification here only consumes the bounded hook
322
+ // observer deadline without protecting a new state transition.
323
+ applyAndVerifyWindowsAcl(temporary, false);
324
+ assertPrivateStoreFileShape(lstatSync(temporary));
325
+ }
326
+ // ACL/mode is complete before replacement, so an ACL failure leaves the
327
+ // previous final store intact. Rename preserves the prepared file ACL.
317
328
  renameSync(temporary, path);
318
- if (!isWindows(options.env)) chmodSync(path, 0o600);
319
- else applyAndVerifyWindowsAcl(path, false);
320
- assertPrivateStoreFile(lstatSync(path), options.env, path);
329
+ if (!isWindows(options.env)) assertPrivateStoreFile(lstatSync(path), options.env, path);
330
+ else assertPrivateStoreFileShape(lstatSync(path));
321
331
  } finally {
322
332
  rmSync(temporary, { force: true });
323
333
  }
@@ -339,15 +349,15 @@ function withStoreLock(options, operation) {
339
349
  if (isWindows(options.env)) applyAndVerifyWindowsAcl(lockPath, false);
340
350
  else chmodSync(lockPath, 0o600);
341
351
  }
342
- assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
352
+ if (created && isWindows(options.env)) assertPrivateStoreFileShape(lstatSync(lockPath));
353
+ else assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
343
354
  const database = new DatabaseSync(lockPath);
344
355
  let active = false;
345
356
  try {
346
- assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
347
357
  database.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL');
348
358
  database.exec('BEGIN IMMEDIATE');
349
359
  active = true;
350
- const result = operation();
360
+ const result = operation(privateDirectoryCapability(directory, options.env));
351
361
  database.exec('COMMIT');
352
362
  active = false;
353
363
  return result;
@@ -363,10 +373,42 @@ function ensurePrivateStoreDirectory(directory, env = process.env) {
363
373
  if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('runtime error store directory unsafe');
364
374
  if (isWindows(env)) {
365
375
  applyAndVerifyWindowsAcl(directory, true);
376
+ assertPrivateStoreDirectoryShape(lstatSync(directory));
366
377
  } else {
367
378
  chmodSync(directory, 0o700);
379
+ assertPrivateStoreDirectory(directory, env);
380
+ }
381
+ }
382
+
383
+ function privateDirectoryCapability(directory, env) {
384
+ return Object.freeze({
385
+ directory,
386
+ windows: isWindows(env),
387
+ [PRIVATE_DIRECTORY_CAPABILITY]: true,
388
+ });
389
+ }
390
+
391
+ function assertPrivateDirectoryCapability(capability, directory, env) {
392
+ if (!capability || capability[PRIVATE_DIRECTORY_CAPABILITY] !== true ||
393
+ capability.directory !== directory || capability.windows !== isWindows(env)) {
394
+ // Read-only callers do not hold a mutation capability and still perform
395
+ // the complete ACL/mode verification immediately before reading.
396
+ assertPrivateStoreDirectory(directory, env);
397
+ return;
398
+ }
399
+ assertPrivateStoreDirectoryShape(lstatSync(directory));
400
+ }
401
+
402
+ function assertPrivateStoreDirectoryShape(info) {
403
+ if (!info.isDirectory() || info.isSymbolicLink()) {
404
+ throw new Error('runtime error store directory unsafe');
405
+ }
406
+ }
407
+
408
+ function assertPrivateStoreFileShape(info) {
409
+ if (!info.isFile() || info.isSymbolicLink()) {
410
+ throw new Error('runtime error store path unsafe');
368
411
  }
369
- assertPrivateStoreDirectory(directory, env);
370
412
  }
371
413
 
372
414
  function isWindows(env = process.env) {
@@ -496,13 +538,13 @@ function isCanonicalTimestamp(value) {
496
538
 
497
539
  function assertPrivateStoreDirectory(directory, env = process.env) {
498
540
  const info = lstatSync(directory);
499
- if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('runtime error store directory unsafe');
541
+ assertPrivateStoreDirectoryShape(info);
500
542
  if (isWindows(env)) verifyWindowsAcl(directory, true);
501
543
  else assertPosixOwnerMode(info, 0o700);
502
544
  }
503
545
 
504
546
  function assertPrivateStoreFile(info, env = process.env, path) {
505
- if (!info.isFile() || info.isSymbolicLink()) throw new Error('runtime error store path unsafe');
547
+ assertPrivateStoreFileShape(info);
506
548
  if (isWindows(env)) verifyWindowsAcl(path, false);
507
549
  else assertPosixOwnerMode(info, 0o600);
508
550
  }
@@ -521,7 +563,7 @@ function verifyWindowsAcl(path, directory) {
521
563
  }
522
564
 
523
565
  function runWindowsAclScript(path, directory, script) {
524
- const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
566
+ const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
525
567
  env: { ...process.env, FACTORY_ACL_PATH: path, FACTORY_ACL_DIRECTORY: directory ? '1' : '0' },
526
568
  stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
527
569
  });
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { spawn } from 'node:child_process';
4
- import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import childProcess, { spawn } from 'node:child_process';
4
+ import { chmodSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
 
@@ -16,7 +16,7 @@ import {
16
16
  reopenRuntimeError,
17
17
  resolveRuntimeError,
18
18
  } from './runtime-error-store.mjs';
19
- import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
19
+ import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
20
20
 
21
21
  const TEST_PLATFORM = process.platform === 'win32' ? 'win32' : 'darwin';
22
22
 
@@ -83,6 +83,50 @@ test('runtime error store: Windows native uses the canonical LocalAppData paths'
83
83
  );
84
84
  });
85
85
 
86
+ test('runtime error store: one Windows mutation spends ACL processes only on distinct state transitions', (t) => {
87
+ const box = sandbox();
88
+ box.env.OS = 'Windows_NT';
89
+ enableCollection(box);
90
+ const calls = [];
91
+ t.mock.method(childProcess, 'spawnSync', (command, args, options) => {
92
+ calls.push({ command, args, options });
93
+ return { status: 0, signal: null, error: undefined };
94
+ });
95
+ const options = { env: box.env, configPath: box.configPath, storePath: box.storePath };
96
+
97
+ assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
98
+ assert.equal(calls.length, 3, 'new directory, lock, and store each require one apply+verify process');
99
+ assert.ok(calls.every((call) => call.command === 'powershell.exe'));
100
+ calls.length = 0;
101
+
102
+ assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
103
+ assert.equal(calls.length, 4, 'directory apply, existing lock/store verify, and replacement store apply are distinct');
104
+ assert.ok(calls.every((call) => call.options.timeout === 3_000));
105
+ });
106
+
107
+ test('runtime error store: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
108
+ const box = sandbox();
109
+ box.env.OS = 'Windows_NT';
110
+ enableCollection(box);
111
+ let calls = 0;
112
+ let failAt = Number.POSITIVE_INFINITY;
113
+ t.mock.method(childProcess, 'spawnSync', () => {
114
+ calls += 1;
115
+ return { status: calls === failAt ? 1 : 0, signal: null, error: undefined };
116
+ });
117
+ const options = { env: box.env, configPath: box.configPath, storePath: box.storePath };
118
+
119
+ observeRuntimeError({ code: 'HOOK_CODEX_FAILED', now: '2026-07-13T00:00:00.000Z' }, options);
120
+ const before = readFileSync(box.storePath, 'utf8');
121
+ failAt = calls + 4;
122
+ assert.throws(
123
+ () => observeRuntimeError({ code: 'HOOK_CODEX_FAILED', now: '2026-07-13T00:01:00.000Z' }, options),
124
+ /Windows owner-only ACL verification failed/,
125
+ );
126
+ assert.equal(readFileSync(box.storePath, 'utf8'), before);
127
+ assert.deepEqual(readdirSync(dirname(box.storePath)).filter((name) => name.endsWith('.tmp')), []);
128
+ });
129
+
86
130
  test('runtime error store: reporting config and credentials are ignored and no network API is accepted', () => {
87
131
  const box = sandbox();
88
132
  enableCollection(box, {
@@ -235,6 +279,10 @@ test('runtime error store: atomic private store has owner-only modes and bounded
235
279
  if (process.platform !== 'win32') {
236
280
  assert.equal(statSync(dirname(box.storePath)).mode & 0o777, 0o700);
237
281
  assert.equal(statSync(box.storePath).mode & 0o777, 0o600);
282
+ } else {
283
+ verifyWindowsPrivateAcl(dirname(box.storePath), true);
284
+ verifyWindowsPrivateAcl(`${box.storePath}.lock.sqlite`);
285
+ verifyWindowsPrivateAcl(box.storePath);
238
286
  }
239
287
  assert.doesNotThrow(() => JSON.parse(readFileSync(box.storePath, 'utf8')));
240
288
 
@@ -27,3 +27,32 @@ if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO
27
27
  });
28
28
  if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture setup failed');
29
29
  }
30
+
31
+ export function verifyWindowsPrivateAcl(path, directory = false) {
32
+ if (process.platform !== 'win32') return;
33
+ const script = String.raw`
34
+ $ErrorActionPreference='Stop'
35
+ $target=$env:THROUGHLINE_TEST_ACL_PATH; $isDir=$env:THROUGHLINE_TEST_ACL_DIRECTORY -eq '1'
36
+ $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
37
+ $acl=if($isDir){[System.IO.Directory]::GetAccessControl($target)}else{[System.IO.File]::GetAccessControl($target)}
38
+ $owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
39
+ if($owner -ne $sid){exit 41}
40
+ $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))
41
+ if($rules.Count -ne 1){exit 42}
42
+ $rule=$rules[0]
43
+ if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow' -or $rule.IsInherited -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
44
+ `;
45
+ const result = spawnSync('powershell.exe', [
46
+ '-NoProfile', '-NonInteractive', '-Command', script,
47
+ ], {
48
+ encoding: 'utf8',
49
+ timeout: 3_000,
50
+ windowsHide: true,
51
+ env: {
52
+ ...process.env,
53
+ THROUGHLINE_TEST_ACL_PATH: path,
54
+ THROUGHLINE_TEST_ACL_DIRECTORY: directory ? '1' : '0',
55
+ },
56
+ });
57
+ if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture verification failed');
58
+ }