throughline 0.6.0 → 0.6.1

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,19 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.6.1] — 2026-07-13
14
+
15
+ ### Added
16
+
17
+ - **Spotter auditor context v1.** `throughline auditor-context` adds an
18
+ opt-in, JSON-only, read-only projection for Spotter. It verifies the exact
19
+ session/project and the latest completed L2 user/assistant pair against an
20
+ origin/turn/SHA-256 freshness expectation, supplied explicitly or derived
21
+ from a Claude JSONL or Codex rollout. Only `fresh` returns bounded pair
22
+ bodies; all other states return no bodies. The command never creates,
23
+ migrates, or writes the Throughline DB. Opt-in and any onward transmission
24
+ remain Spotter responsibilities.
25
+
13
26
  ## [0.6.0] — 2026-07-12
14
27
 
15
28
  L2 capture is rebuilt from "save only the last pair each Stop" to a
package/README.md CHANGED
@@ -109,7 +109,7 @@ Anthropic API usage from the transcript JSONL (no `length / 4` heuristics).
109
109
 
110
110
  ---
111
111
 
112
- ## Three-layer memory model (schema v7)
112
+ ## Three-layer memory model (schema v8)
113
113
 
114
114
  ```mermaid
115
115
  flowchart LR
@@ -789,6 +789,35 @@ project history.
789
789
 
790
790
  ---
791
791
 
792
+ ## Spotter auditor context (read-only)
793
+
794
+ `throughline auditor-context` is a **Spotter-only, opt-in read-only
795
+ projection** for an auditor. It does not create, migrate, or write the
796
+ Throughline database. The caller must name an exact `--session` and `--project`;
797
+ the stored session must belong to that project root (or one of its descendants).
798
+
799
+ It returns only completed L2 user/assistant pairs—never L1 summaries, L3 tool
800
+ details, developer messages, or an in-flight Codex turn. Freshness is checked
801
+ against the latest completed pair by origin session, turn number, and normalized
802
+ SHA-256 hashes. Supply that expectation either explicitly, or derive it from a
803
+ Claude JSONL / Codex rollout with `--host claude|codex --transcript`; the two
804
+ sources are mutually exclusive. Returned context is bounded by `--recent-turns`
805
+ (default 2), `--max-body-chars` (1,200), and `--max-total-chars` (4,000).
806
+
807
+ The command is JSON-only. `fresh` is the only status that contains pair bodies;
808
+ `empty`, `stale`, `session_mismatch`, `unavailable`, and `schema_mismatch`
809
+ return no bodies and still exit successfully. Argument and internal errors are
810
+ fixed JSON errors on stderr with a non-zero exit. Spotter owns the
811
+ decision to opt in and any onward transmission of this local projection;
812
+ Throughline only reads and projects it.
813
+
814
+ ```bash
815
+ throughline auditor-context --session claude-session-id --project "$PWD" \
816
+ --host claude --transcript "$HOME/.claude/projects/.../session.jsonl" --json
817
+ ```
818
+
819
+ ---
820
+
792
821
  ## Requirements
793
822
 
794
823
  - **Node.js >= 22.5** (for the built-in `node:sqlite` module — no native build
@@ -812,7 +841,7 @@ plain `.mjs` files.
812
841
  └── <session_id>.json Per-session activity state for the monitor
813
842
  ```
814
843
 
815
- Schema v7:
844
+ Schema v8:
816
845
 
817
846
  - `sessions` — one row per `session_id`, with `project_path` and `merged_into`
818
847
  - `skeletons` — L1 one-liners, keyed by `(session_id, origin_session_id, turn, role)`
@@ -970,7 +999,7 @@ unchanged here.
970
999
 
971
1000
  **Database got corrupted / want a clean slate**
972
1001
  Delete `~/.throughline/throughline.db` (and the `-shm` / `-wal` companion files)
973
- and `~/.throughline/state/*.json`. A fresh database with schema v7 is created on
1002
+ and `~/.throughline/state/*.json`. A fresh database with schema v8 is created on
974
1003
  the next hook fire.
975
1004
 
976
1005
  **New session didn't inherit memory from the previous one**
@@ -10,6 +10,7 @@
10
10
  * throughline session-start # SessionStart hook (Claude Code から呼ばれる)
11
11
  * throughline detail <時刻> # L2+L3 詳細取得 (Claude が Bash 経由で呼ぶ想定)
12
12
  * throughline handoff-preview # Codex-facing throughline_handoff JSON preview
13
+ * throughline auditor-context --json # Read-only bounded auditor context JSON
13
14
  * throughline codex-capture # Capture active Codex rollout turns into Throughline DB
14
15
  * throughline codex-hook user-prompt-submit # Codex current-session auto-refresh prompt hook
15
16
  * throughline codex-hook post-tool-use # Codex current-session auto-refresh tool-loop hook
@@ -65,6 +66,11 @@ switch (cmd) {
65
66
  case 'handoff-preview':
66
67
  await (await import('../src/cli/handoff-preview.mjs')).run(rest);
67
68
  break;
69
+ case 'auditor-context': {
70
+ const exitCode = (await import('../src/cli/auditor-context.mjs')).run(rest);
71
+ if (exitCode !== 0) process.exitCode = exitCode;
72
+ break;
73
+ }
68
74
  case 'codex-capture':
69
75
  await (await import('../src/cli/codex-capture.mjs')).run(rest);
70
76
  break;
@@ -149,6 +155,10 @@ Usage:
149
155
  throughline monitor Multi-session token monitor (use --all, --session <id>)
150
156
  throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
151
157
  throughline handoff-preview Print Codex-facing throughline_handoff JSON
158
+ throughline auditor-context --session <id> --project <root>
159
+ Read only bounded completed user/assistant context
160
+ for an auditor; requires either --host plus --transcript,
161
+ or explicit pair identity/hashes; always requires --json
152
162
  throughline codex-capture Capture active Codex rollout turns into DB
153
163
  (requires --codex-thread-id or env thread id)
154
164
  throughline codex-hook user-prompt-submit
@@ -50,7 +50,7 @@ docs 整合: ビルトインコマンドは UserPromptSubmit(prompt 送信時
50
50
 
51
51
  ### 3. 案A(startup 時間窓フォールバック)不採用の根拠
52
52
 
53
- - 幽霊セッション: 2026-07-11 だけで project_path=/Users/kite startup が **182 件**(03:02〜12:56、最短間隔 0.001 秒、全て bodies=0)。haiku-workdir に 207 件=headless `claude -p` も SessionStart hook を発火する。
53
+ - 幽霊セッション: 2026-07-11 だけで user home直下のproject_pathのstartupが **182 件**(03:02〜12:56、最短間隔 0.001 秒、全て bodies=0)。haiku-workdir に 207 件=headless `claude -p` も SessionStart hook を発火する。
54
54
  - 幽霊がチェーンに入ると MAX_CHAIN_DEPTH=10([src/session-merger.mjs](../src/session-merger.mjs):14)へ数時間で到達し resolveMergeTarget throw → ターン捕捉が恒久停止=ここで記憶が本当に失われる。
55
55
  - source='startup' は「/clear の後継」と「並行して開いた別窓」を原理的に区別できず、稼働中セッションのレコードを relabel して記憶を split する。「bodies>0 の前任だけ選ぶ」は前任側フィルタなので無効(refuter 検証済み)。
56
56
 
@@ -90,7 +90,7 @@ docs 整合: ビルトインコマンドは UserPromptSubmit(prompt 送信時
90
90
  - [x] 本プランを docs/12 として正本化(本文書)
91
91
  - [x] rag/01-hooks に SessionEnd reason enum を還流([session-end-reasons.md](../rag/01-hooks/raw/session-end-reasons.md))、rag/INDEX.md に Finding 8 追記
92
92
  - [x] 今日の調査を caveat に記録: public `claude-code-clear-userpromptsubmit-hook`(confirmed)/ private `claude-code-desktop-assistant-transcript-jsonl`(tentative・B-2 で更新)
93
- - [x] 実稼働デプロイ(2026-07-12): `npm i -g /Users/kite/Developer/Throughline`(symlink 化=リポ変更が即時反映。リリース時は registry 版へ戻す)
93
+ - [x] 実稼働デプロイ(2026-07-12): ローカルcheckoutをglobal install(symlink化=リポ変更が即時反映。リリース時はregistry版へ戻す)
94
94
 
95
95
  ## Workstream B-1 — 捕捉のバックフィル化(先行。A の E2E 品質の前提。挙動修正レーン=挙動差を明文化して個別承認)
96
96
 
@@ -117,7 +117,7 @@ docs 整合: ビルトインコマンドは UserPromptSubmit(prompt 送信時
117
117
  | Desktop セッション削除(675493fb) | 発火(12 秒後) | **`other`** | payload 構造は /clear と完全同一 |
118
118
  | VSCode `/clear`(fa43271f) | 即時 | **`clear`** | 42ms 後に後継 SessionStart(source=clear)→auto merge。仕組み自体は健全 |
119
119
 
120
- 副次発見: Desktop の幽霊セッション(/Users/kite)も SessionEnd(other) を高頻度で発火する。
120
+ 副次発見: Desktop のuser home直下の幽霊セッションも SessionEnd(other) を高頻度で発火する。
121
121
  - [x] 判定: **NO-GO**。Desktop は /clear で SessionEnd を即時発火するが reason を `other` にラベルし、**セッション削除(明示的破棄)と区別不能**。reason=other でバトンを書くと削除セッションの記憶が次セッションに蘇る誤注入 + 幽霊バトン汚染。reason 不問の退行案は不採用(計画どおり)。→ A Phase 2 は実装せず停止、fallback 裁定へ
122
122
  - [x] spike 撤去: settings.json から SessionEnd 登録を削除(JSON 検証済み)、spike ファイル削除(git 履歴に残存)。実測ログ `~/.throughline/logs/session-end-spike.log` は証拠として保全
123
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -0,0 +1,330 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join, resolve, sep } from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { buildBodyRowsFromActiveTurns } from './codex-capture.mjs';
7
+ import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
8
+ import { getLogicalTurnGroups } from './transcript-reader.mjs';
9
+
10
+ export const AUDITOR_CONTEXT_SCHEMA = 'throughline.auditor_context.v1';
11
+ export const AUDITOR_CONTEXT_DB_SCHEMA_VERSION = 8;
12
+ export const DEFAULT_AUDITOR_RECENT_TURNS = 2;
13
+ export const DEFAULT_AUDITOR_MAX_BODY_CHARS = 1200;
14
+ export const DEFAULT_AUDITOR_MAX_TOTAL_CHARS = 4000;
15
+
16
+ export function defaultAuditorContextDbPath() {
17
+ return join(homedir(), '.throughline', 'throughline.db');
18
+ }
19
+
20
+ export function normalizeAuditorBody(value) {
21
+ return String(value ?? '').normalize('NFC').replace(/\r\n?/g, '\n').trim();
22
+ }
23
+
24
+ export function hashAuditorBody(value) {
25
+ return createHash('sha256').update(normalizeAuditorBody(value), 'utf8').digest('hex');
26
+ }
27
+
28
+ export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessionId } = {}) {
29
+ assertNonEmptyString(transcriptPath, 'transcriptPath');
30
+ assertNonEmptyString(sessionId, 'sessionId');
31
+ if (host === 'claude') {
32
+ const latest = getLogicalTurnGroups(transcriptPath).at(-1);
33
+ if (!latest) return null;
34
+ return {
35
+ expectedOriginSessionId: sessionId,
36
+ expectedTurnNumber: latest.representative.index,
37
+ expectedUserSha256: hashAuditorBody(latest.user.content),
38
+ expectedAssistantSha256: hashAuditorBody(latest.representative.content),
39
+ };
40
+ }
41
+ if (host === 'codex') {
42
+ const parsed = parseCodexRolloutFile(transcriptPath, { includeInFlightTurn: false });
43
+ const rows = buildBodyRowsFromActiveTurns(parsed.activeTurns, { sessionId, now: 0 });
44
+ const latestTurnNumber = rows.reduce((max, row) => Math.max(max, row.turnNumber), 0);
45
+ if (latestTurnNumber < 1) return null;
46
+ const user = rows.find((row) => row.turnNumber === latestTurnNumber && row.role === 'user');
47
+ const assistant = rows.find((row) => row.turnNumber === latestTurnNumber && row.role === 'assistant');
48
+ if (!user || !assistant) return null;
49
+ return {
50
+ expectedOriginSessionId: sessionId,
51
+ // Codex active turns are rebuilt after rollback/in-flight filtering. Their ordinal can
52
+ // shift between Stop capture and the next UserPromptSubmit, so it is not a stable identity.
53
+ // Exact session/origin plus both normalized pair hashes remain mandatory below.
54
+ expectedTurnNumber: null,
55
+ expectedUserSha256: hashAuditorBody(user.text),
56
+ expectedAssistantSha256: hashAuditorBody(assistant.text),
57
+ };
58
+ }
59
+ throw new TypeError('host must be claude or codex');
60
+ }
61
+
62
+ export function readAuditorContext({
63
+ dbPath = defaultAuditorContextDbPath(),
64
+ sessionId,
65
+ projectRoot,
66
+ expectedOriginSessionId,
67
+ expectedTurnNumber,
68
+ expectedUserSha256,
69
+ expectedAssistantSha256,
70
+ recentTurns = DEFAULT_AUDITOR_RECENT_TURNS,
71
+ maxBodyChars = DEFAULT_AUDITOR_MAX_BODY_CHARS,
72
+ maxTotalChars = DEFAULT_AUDITOR_MAX_TOTAL_CHARS,
73
+ } = {}) {
74
+ assertNonEmptyString(sessionId, 'sessionId');
75
+ assertNonEmptyString(projectRoot, 'projectRoot');
76
+ assertPositiveInteger(recentTurns, 'recentTurns');
77
+ assertPositiveInteger(maxBodyChars, 'maxBodyChars');
78
+ assertPositiveInteger(maxTotalChars, 'maxTotalChars');
79
+
80
+ if (!existsSync(dbPath)) {
81
+ return emptyResult('unavailable', 'db_not_found', { sessionId, projectRoot, recentTurns });
82
+ }
83
+
84
+ let db;
85
+ try {
86
+ db = new DatabaseSync(dbPath, { readOnly: true });
87
+ } catch (cause) {
88
+ throw new AuditorContextError('E_AUDITOR_CONTEXT_DB_OPEN', 'auditor context DB could not be opened', { cause });
89
+ }
90
+
91
+ try {
92
+ const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
93
+ if (version !== AUDITOR_CONTEXT_DB_SCHEMA_VERSION) {
94
+ return emptyResult('schema_mismatch', 'unsupported_db_schema', {
95
+ sessionId,
96
+ projectRoot,
97
+ recentTurns,
98
+ dbSchemaVersion: version,
99
+ });
100
+ }
101
+
102
+ const session = db.prepare(
103
+ `SELECT session_id, project_path
104
+ FROM sessions
105
+ WHERE session_id = ?`,
106
+ ).get(sessionId);
107
+ if (!session) {
108
+ return emptyResult('empty', 'session_not_found', { sessionId, projectRoot, recentTurns });
109
+ }
110
+ if (!isSameProjectOrDescendant(session.project_path, projectRoot)) {
111
+ return emptyResult('session_mismatch', 'project_mismatch', { sessionId, projectRoot, recentTurns });
112
+ }
113
+
114
+ const rows = db.prepare(
115
+ `SELECT id, origin_session_id, turn_number, role, text, created_at
116
+ FROM bodies
117
+ WHERE session_id = ? AND role IN ('user', 'assistant')
118
+ ORDER BY created_at ASC, id ASC`,
119
+ ).all(sessionId);
120
+ const completed = buildCompletedPairs(rows);
121
+ if (completed.length === 0) {
122
+ return emptyResult('empty', 'completed_pair_not_found', { sessionId, projectRoot, recentTurns });
123
+ }
124
+
125
+ const latest = completed.at(-1);
126
+ const stableTurnIdentityAvailable = Number.isInteger(expectedTurnNumber) && expectedTurnNumber >= 0;
127
+ const codexPairIdentity = sessionId.startsWith('codex:') && expectedOriginSessionId === sessionId;
128
+ const expectationComplete =
129
+ typeof expectedOriginSessionId === 'string' && expectedOriginSessionId.length > 0 &&
130
+ (stableTurnIdentityAvailable || codexPairIdentity) &&
131
+ isSha256(expectedUserSha256) && isSha256(expectedAssistantSha256);
132
+ if (!expectationComplete) {
133
+ return emptyResult('stale', 'freshness_expectation_incomplete', { sessionId, projectRoot, recentTurns });
134
+ }
135
+
136
+ const expectedUserHash = expectedUserSha256.toLowerCase();
137
+ const expectedAssistantHash = expectedAssistantSha256.toLowerCase();
138
+ const matchedIndex = stableTurnIdentityAvailable
139
+ ? completed.length - 1
140
+ : completed.findLastIndex((pair) =>
141
+ pair.originSessionId === expectedOriginSessionId &&
142
+ hashAuditorBody(pair.user) === expectedUserHash &&
143
+ hashAuditorBody(pair.assistant) === expectedAssistantHash);
144
+ const matched = matchedIndex >= 0 ? completed[matchedIndex] : latest;
145
+ const identityMatched = matchedIndex >= 0 && matched.originSessionId === expectedOriginSessionId &&
146
+ (!stableTurnIdentityAvailable || matched.turnNumber === expectedTurnNumber);
147
+ const userMatched = matchedIndex >= 0 && hashAuditorBody(matched.user) === expectedUserHash;
148
+ const assistantMatched = matchedIndex >= 0 && hashAuditorBody(matched.assistant) === expectedAssistantHash;
149
+ if (!identityMatched || !userMatched || !assistantMatched) {
150
+ return emptyResult('stale', 'latest_pair_mismatch', { sessionId, projectRoot, recentTurns });
151
+ }
152
+
153
+ const stableCompleted = completed.slice(0, matchedIndex + 1);
154
+ const bounded = boundCompletedPairs(stableCompleted.slice(-recentTurns), { maxBodyChars, maxTotalChars });
155
+ return {
156
+ schema: AUDITOR_CONTEXT_SCHEMA,
157
+ status: 'fresh',
158
+ reason: 'latest_pair_matched',
159
+ sessionId,
160
+ projectPath: canonicalProjectPath(projectRoot),
161
+ source: 'throughline-db-l2',
162
+ freshness: {
163
+ originSessionId: matched.originSessionId,
164
+ turnNumber: matched.turnNumber,
165
+ identityMatched: true,
166
+ userMatched: true,
167
+ assistantMatched: true,
168
+ },
169
+ turns: bounded.turns,
170
+ stats: {
171
+ requestedTurns: recentTurns,
172
+ returnedTurns: bounded.turns.length,
173
+ chars: bounded.chars,
174
+ truncated: bounded.truncated,
175
+ },
176
+ };
177
+ } catch (cause) {
178
+ if (cause instanceof AuditorContextError) throw cause;
179
+ throw new AuditorContextError('E_AUDITOR_CONTEXT_QUERY', 'auditor context query failed', { cause });
180
+ } finally {
181
+ db.close();
182
+ }
183
+ }
184
+
185
+ export class AuditorContextError extends Error {
186
+ constructor(code, message, { cause } = {}) {
187
+ super(message, { cause });
188
+ this.name = 'AuditorContextError';
189
+ this.code = code;
190
+ }
191
+ }
192
+
193
+ function buildCompletedPairs(rows) {
194
+ const grouped = new Map();
195
+ for (const row of rows) {
196
+ if (!row?.origin_session_id || !Number.isInteger(row.turn_number)) continue;
197
+ const key = `${row.origin_session_id}\u0000${row.turn_number}`;
198
+ const pair = grouped.get(key) ?? {
199
+ originSessionId: row.origin_session_id,
200
+ turnNumber: row.turn_number,
201
+ user: null,
202
+ assistant: null,
203
+ createdAt: Number(row.created_at) || 0,
204
+ lastId: Number(row.id) || 0,
205
+ };
206
+ if (row.role === 'user' && pair.user === null) pair.user = normalizeAuditorBody(row.text);
207
+ if (row.role === 'assistant' && pair.assistant === null) pair.assistant = normalizeAuditorBody(row.text);
208
+ pair.createdAt = Math.max(pair.createdAt, Number(row.created_at) || 0);
209
+ pair.lastId = Math.max(pair.lastId, Number(row.id) || 0);
210
+ grouped.set(key, pair);
211
+ }
212
+ return [...grouped.values()]
213
+ .filter((pair) => pair.user !== null && pair.assistant !== null)
214
+ .sort((a, b) => a.createdAt - b.createdAt || a.lastId - b.lastId)
215
+ .map(({ lastId: _lastId, ...pair }) => pair);
216
+ }
217
+
218
+ function boundCompletedPairs(pairs, { maxBodyChars, maxTotalChars }) {
219
+ const prepared = pairs.map((pair) => {
220
+ const user = tail(pair.user, maxBodyChars);
221
+ const assistant = tail(pair.assistant, maxBodyChars);
222
+ return {
223
+ ...pair,
224
+ user,
225
+ assistant,
226
+ truncated: user.length < pair.user.length || assistant.length < pair.assistant.length,
227
+ };
228
+ });
229
+
230
+ const selected = [];
231
+ let chars = 0;
232
+ let truncated = prepared.some((pair) => pair.truncated);
233
+ for (let i = prepared.length - 1; i >= 0; i--) {
234
+ const pair = prepared[i];
235
+ const remaining = maxTotalChars - chars;
236
+ if (remaining <= 0) {
237
+ truncated = true;
238
+ continue;
239
+ }
240
+ let user = pair.user;
241
+ let assistant = pair.assistant;
242
+ if (user.length + assistant.length > remaining) {
243
+ const assistantBudget = Math.min(assistant.length, Math.ceil(remaining / 2));
244
+ const userBudget = Math.max(0, remaining - assistantBudget);
245
+ user = tail(user, userBudget);
246
+ assistant = tail(assistant, assistantBudget);
247
+ truncated = true;
248
+ }
249
+ if (user.length === 0 || assistant.length === 0) {
250
+ truncated = true;
251
+ continue;
252
+ }
253
+ chars += user.length + assistant.length;
254
+ selected.unshift({
255
+ originSessionId: pair.originSessionId,
256
+ turnNumber: pair.turnNumber,
257
+ user,
258
+ assistant,
259
+ createdAt: pair.createdAt,
260
+ });
261
+ }
262
+ return { turns: selected, chars, truncated };
263
+ }
264
+
265
+ function emptyResult(status, reason, { sessionId, projectRoot, recentTurns, dbSchemaVersion } = {}) {
266
+ return {
267
+ schema: AUDITOR_CONTEXT_SCHEMA,
268
+ status,
269
+ reason,
270
+ sessionId,
271
+ projectPath: canonicalProjectPath(projectRoot),
272
+ source: 'throughline-db-l2',
273
+ freshness: {
274
+ identityMatched: false,
275
+ userMatched: false,
276
+ assistantMatched: false,
277
+ },
278
+ turns: [],
279
+ stats: {
280
+ requestedTurns: recentTurns,
281
+ returnedTurns: 0,
282
+ chars: 0,
283
+ truncated: false,
284
+ ...(Number.isInteger(dbSchemaVersion) ? { dbSchemaVersion } : {}),
285
+ },
286
+ };
287
+ }
288
+
289
+ function canonicalProjectPath(value) {
290
+ const raw = String(value ?? '');
291
+ if (/^[A-Za-z]:[\\/]/.test(raw)) {
292
+ return raw.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
293
+ }
294
+ let normalized = isAbsolute(raw) ? raw : resolve(raw);
295
+ try {
296
+ if (existsSync(normalized)) normalized = realpathSync.native(normalized);
297
+ } catch {
298
+ // Keep the lexical path when the filesystem cannot resolve it.
299
+ }
300
+ return normalized.split(sep).join('/').replace(/\/+$/, '');
301
+ }
302
+
303
+ function isSameProjectOrDescendant(candidate, root) {
304
+ const normalizedCandidate = canonicalProjectPath(candidate);
305
+ const normalizedRoot = canonicalProjectPath(root);
306
+ const left = /^[A-Za-z]:\//.test(normalizedCandidate) ? normalizedCandidate.toLowerCase() : normalizedCandidate;
307
+ const right = /^[A-Za-z]:\//.test(normalizedRoot) ? normalizedRoot.toLowerCase() : normalizedRoot;
308
+ return left === right || left.startsWith(`${right}/`);
309
+ }
310
+
311
+ function tail(value, maxChars) {
312
+ if (maxChars <= 0) return '';
313
+ return value.length <= maxChars ? value : value.slice(value.length - maxChars);
314
+ }
315
+
316
+ function isSha256(value) {
317
+ return typeof value === 'string' && /^[a-fA-F0-9]{64}$/.test(value);
318
+ }
319
+
320
+ function assertNonEmptyString(value, name) {
321
+ if (typeof value !== 'string' || value.length === 0) {
322
+ throw new TypeError(`${name} must be a non-empty string`);
323
+ }
324
+ }
325
+
326
+ function assertPositiveInteger(value, name) {
327
+ if (!Number.isInteger(value) || value < 1) {
328
+ throw new TypeError(`${name} must be an integer >= 1`);
329
+ }
330
+ }