dsh-llm-fallbacks 0.4.4 → 0.5.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.
@@ -0,0 +1,1858 @@
1
+ /**
2
+ * repair-session-logs.ts — triage and repair pre-V3 dsh session logs.
3
+ *
4
+ * WHY: a session log is only readable through the FROZEN released migration
5
+ * chain. A log written by an older release (or by a plugin that merged a custom
6
+ * `MessageSource.kind`) makes that chain throw at the FIRST refused row, so the
7
+ * GUI reports a failed session load and the session can no longer be opened.
8
+ * This CLI names the refusal class of every pre-V3 log under a session root
9
+ * and — only for the classes whose normalization is provably shape-preserving
10
+ * (the rule registry in `./session-logs/rules.ts`) and only with a resolved
11
+ * released catalog as the oracle (`./session-logs/catalog.ts`) — publishes a
12
+ * current-generation successor beside the original
13
+ * (`./session-logs/publish.ts`). The original generation is never modified.
14
+ *
15
+ * This module CONSUMES the frozen Task 1/2 modules; it re-implements none of
16
+ * them. The refusal vocabulary, the rule registry, the oracle resolution and
17
+ * the publication order live there.
18
+ *
19
+ * USAGE (via the `repair:session-logs` npm script):
20
+ * pnpm repair:session-logs -- [--root DIR] [--apply] [--class NAME]
21
+ * [--catalog PATH] [--backup] [--drop-legacy-events]
22
+ * [--json] [--quiet]
23
+ *
24
+ * LOSSY MODE (`--drop-legacy-events`, off by default): the legacy
25
+ * `fallbacks/switch` event type this repo's own pre-`#52` plugin wrote is refused
26
+ * by the frozen V0→V1 edge even with `ignorable: true`, so no rewrite can make
27
+ * such a row load — the row can only be removed, which loses the provider/model
28
+ * switch audit entry it carries. That removal is the ONLY lossy operation this
29
+ * tool can perform, it needs the explicit opt-in, and `--apply` with it REQUIRES
30
+ * `--backup` (the original generation is the only copy of the dropped rows once
31
+ * a successor is published). A row of any other unknown event type is never
32
+ * dropped: such a log stays unrepairable. The run reports the legacy-row
33
+ * population of the source log, the dropped-event and renumbered-event counts per
34
+ * log (`legacyEventCount` / `droppedEventCount` / `renumberedEventCount` in
35
+ * `--json`) and warns loudly on stderr; the post-drop restore and the published
36
+ * read-back are still the proof of success.
37
+ *
38
+ * WHY THE SURVIVORS ARE RENUMBERED: the same V0→V1 edge requires every event's
39
+ * `seq` to equal its running event count (packed Assistant runs are checked by
40
+ * `seq0` and advance the count by their payload length), so removing a row is
41
+ * loadable only when the removed rows are the LAST events of the generation.
42
+ * A legacy row in the middle leaves the survivors with a gap, so the opt-in mode
43
+ * renumbers every surviving event to its position in the surviving event stream
44
+ * and shifts each surviving Session-seq reference (`seq`, packed `seq0`,
45
+ * `sourceEventSeqs`, `surfaceOp`, and the payload references the released
46
+ * remappers audit) by the same monotone shift. Content is otherwise byte-identical.
47
+ *
48
+ * REFERENCE INTEGRITY IS A HARD GATE: when a surviving row's reference names a
49
+ * dropped seq (a delivery-independent reference to one of the removed events),
50
+ * renumbering it would silently point it at a different event. The gate therefore
51
+ * refuses the whole file, writes nothing, and reports the offending row + field —
52
+ * a correct fail-closed outcome, never a renumbering failure. The same gate
53
+ * refuses a file whose source rows are not densely numbered, and one whose drop
54
+ * would renumber across the header's seed cut.
55
+ *
56
+ * DISCOVERY: `<root>/<namespace>/<session>/` — exactly two directory levels —
57
+ * and inside each session directory the newest CANONICAL generation whose
58
+ * version is below the current-format floor, i.e. the newest of
59
+ * `session.jsonl.zstd` (v0) and `session.v<N>.jsonl.zstd` (N >= 1). Canonical
60
+ * means what the released `parseGenerationLogFilename` accepts: lowercase `v`,
61
+ * no leading zeros, and no version-zero-tagged name, so a staged
62
+ * `session.repair.<hex>.jsonl.zstd.tmp` or a `--backup` copy is ignored. A
63
+ * session directory that already carries a v3+ successor is still triaged when
64
+ * a pre-V3 generation is present, so a re-run reports `already published`
65
+ * instead of silently skipping: report mode reads that successor back through
66
+ * the oracle before calling it loadable, and `--apply` never replaces one
67
+ * holding different bytes (the publisher refuses).
68
+ *
69
+ * DISCOVERY IS NOT ALLOWED TO FAIL OPEN: a namespace/session directory the walk
70
+ * cannot read, and a canonical generation that is a symlink or not a regular
71
+ * file, are collected with their errno into `skipped` (text report AND `--json`),
72
+ * folded into the non-zero exit code, and they suppress the "no session log …"
73
+ * line, which otherwise describes an empty root. An unreadable ROOT is fatal
74
+ * (exit 2) rather than an empty report — it is the one input whose failure hides
75
+ * every log at once. Symlinked entries are reported, never followed: writes must
76
+ * stay inside `--root`. A stale `session.repair.*.jsonl.zstd.tmp` (the residue of
77
+ * an interrupted run) is reported too, and never deleted by this tool.
78
+ *
79
+ * `--class NAME` restricts RULE APPLICATION to one class (default: all rules),
80
+ * which is what `--apply` will repair. It never narrows the listing, the class
81
+ * table or the exit code: the report's `repairable` verdict is always computed
82
+ * over the FULL policy, and a log the filtered chain cannot repair on its own is
83
+ * reported as such — so the flag can never hide a refusal, and the report never
84
+ * promises a repair the same invocation would fail to perform.
85
+ *
86
+ * TWO POLICIES, AND THE REPORT SAYS WHICH ONE SAID `ok`: a log's class and its
87
+ * `ok` come from the host loader's policy (`recovery: 'recoverable'`), which is
88
+ * what decides whether the GUI opens the session — but that policy can swallow a
89
+ * refusal and drop the rows after it. Every `ok` log is therefore cross-checked
90
+ * with the SAME loader policy under STRICT recovery (one axis apart, so a refusal
91
+ * there means rows were dropped rather than "not current-shaped"): when it refuses,
92
+ * the log is reported
93
+ * as `ok-truncated` (a distinct token, an `okTruncated` summary count and a
94
+ * `strictRefusal` reason in `--json`) because the session opens WITHOUT the rows
95
+ * the strict policy rejects. Its exit code stays `0`: those sessions do load.
96
+ *
97
+ * PRECONDITION for `--apply` (printed loudly, never enforced): run it only while
98
+ * NO dsh instance is writing the sessions under `--root`. The publisher links
99
+ * the successor into the session directory without observing the host's flock
100
+ * lease (`lease.ts:40` in `@deepseek-ai/dsh-session-persistence-jsonl`; the
101
+ * flock addon is host-internal, so this repo cannot take it), which means a
102
+ * still-running older dsh would keep appending to a generation the host stops
103
+ * preferring. The publisher compares the digest of the revision this run
104
+ * DECODED with the file before staging anything, so a concurrent append is
105
+ * refused before the first write instead of being published as a verified
106
+ * successor; if the source moves after a publication this run CREATED, that
107
+ * successor is unlinked again, and if it moves after accepting a pre-existing
108
+ * identical one the failure names that file and says to delete it. Rollback is
109
+ * `rm` of the successor generation — the original is authoritative and
110
+ * byte-identical.
111
+ *
112
+ * RUNTIME FLOOR: reading and writing need `node:zlib` zstd, added in Node 22.15
113
+ * (`engines.node` says `>= 22`). Availability is PROBED before the zstd-
114
+ * dependent module is imported, so an older runtime gets an actionable message
115
+ * and exit 2 instead of a module-link stack trace (see `zstdRuntimeProblem`).
116
+ *
117
+ * EXIT CODES: 0 = every log loads (a session that opens with rows dropped under
118
+ * the strict policy is reported `ok-truncated` and still exits 0); 1 = at least
119
+ * one log is still refused/unrepairable, a repair failed, a log was left
120
+ * unpublished (including one `--class` excluded), an input could not be inspected
121
+ * at all (`skipped`), or a stale `session.repair.*.jsonl.zstd.tmp` was found under
122
+ * the root; 2 = fatal (bad arguments, missing/unreadable
123
+ * `--root`, `--apply` without a resolved catalog, a catalog below the required
124
+ * format version, `--apply --drop-legacy-events` without `--backup`, or a runtime
125
+ * without zstd).
126
+ */
127
+ import { createHash } from 'node:crypto'
128
+ import { constants as fsConstants } from 'node:fs'
129
+ import { copyFile, readFile, readdir, rm, stat } from 'node:fs/promises'
130
+ import { homedir } from 'node:os'
131
+ import { basename, dirname, join } from 'node:path'
132
+ import { pathToFileURL } from 'node:url'
133
+ import { classifyRows } from './session-logs/classify.ts'
134
+ import {
135
+ CATALOG_ENV_VAR,
136
+ REQUIRED_CATALOG_VERSION,
137
+ catalogVersionRefusal,
138
+ classifyWithCatalog,
139
+ resolveCatalog,
140
+ restoreRows,
141
+ } from './session-logs/catalog.ts'
142
+ import type { CatalogHandle, CatalogResolvedBy } from './session-logs/catalog.ts'
143
+ import {
144
+ BUILT_IN_RULES,
145
+ REFUSAL_CLASSES,
146
+ dropLegacyEventsRule,
147
+ droppedEventCount,
148
+ fallbacksSwitchRule,
149
+ legacyDropSplit,
150
+ lossyRefusalReason,
151
+ renumberSurvivingEvents,
152
+ } from './session-logs/rules.ts'
153
+ import type { Finding, LogRule, LossyRefusalReason, ParsedRow, RefusalClass } from './session-logs/rules.ts'
154
+
155
+ /** Program name used in every diagnostic line. */
156
+ const PROGRAM = 'repair-session-logs'
157
+
158
+ /**
159
+ * Format version at which the released chain is current: generations BELOW it are
160
+ * triaged (the "pre-V3" scope of the plan) and it is also the floor the resolved
161
+ * catalog must satisfy to be trusted as this run's oracle — one constant, so the
162
+ * scope and the oracle pin cannot drift (`REQUIRED_CATALOG_VERSION`).
163
+ */
164
+ const CURRENT_VERSION_FLOOR = REQUIRED_CATALOG_VERSION
165
+
166
+ /**
167
+ * What "repairable" means for one log, derived from the RUN'S OWN RULE SET rather
168
+ * than from a literal: the classes of the rules that can make a log of their class
169
+ * load at all (a detector that only reports answers `false`, and the lossy rule
170
+ * answers per log, which is what this tool used to special-case by hand). Adding a
171
+ * rule is therefore enough to make its class repairable.
172
+ */
173
+ function repairableClasses(rules: readonly LogRule[], rows: readonly ParsedRow[]): ReadonlySet<RefusalClass> {
174
+ return new Set(rules.filter((rule) => rule.repairable(rows)).map((rule) => rule.class))
175
+ }
176
+
177
+ /** Exit codes (see the module docblock). */
178
+ const EXIT_CLEAN = 0
179
+ const EXIT_REFUSED = 1
180
+ const EXIT_FATAL = 2
181
+
182
+ /** Suffix of the `--backup` copy (noncanonical, so discovery ignores it). */
183
+ const BACKUP_SUFFIX = '.bak'
184
+
185
+ /** Column width of the per-log status token. */
186
+ const TOKEN_WIDTH = 28
187
+
188
+ /* ------------------------------------------------------------------ */
189
+ /* arguments */
190
+ /* ------------------------------------------------------------------ */
191
+
192
+ /** Parsed command line. */
193
+ export interface CliOptions {
194
+ /** `--help`: print the usage text and exit 0 without touching anything. */
195
+ help: boolean
196
+ /** Session root to walk (`--root`, default `$DSH_HOME|~/.dsh` + `/sessions`). */
197
+ root: string
198
+ /** `--apply`: run the proofs and publish successors (default: read-only report). */
199
+ apply: boolean
200
+ /** `--class`: restrict rule application to one class (default: all rules). */
201
+ classFilter: RefusalClass | null
202
+ /** `--catalog`: explicit released-catalog path, forwarded to `resolveCatalog`. */
203
+ catalogPath: string | undefined
204
+ /** `--backup`: copy the original generation to `<name>.bak` before publishing. */
205
+ backup: boolean
206
+ /**
207
+ * `--drop-legacy-events`: LOSSY recovery — remove the legacy
208
+ * `fallbacks/switch` rows instead of refusing the log (default: off). Report
209
+ * mode only counts what it would drop; `--apply` additionally requires
210
+ * `--backup`.
211
+ */
212
+ dropLegacyEvents: boolean
213
+ /** `--json`: emit one machine-readable JSON document instead of the text report. */
214
+ json: boolean
215
+ /** `--quiet`: suppress the per-log lines and the by-class table. */
216
+ quiet: boolean
217
+ }
218
+
219
+ /** The home directory one environment names, falling back to the OS. */
220
+ function homeDirectory(env: NodeJS.ProcessEnv): string {
221
+ return env['HOME'] ?? env['USERPROFILE'] ?? homedir()
222
+ }
223
+
224
+ /** Expand a leading `~` against one environment's home directory. */
225
+ function expandHome(path: string, env: NodeJS.ProcessEnv): string {
226
+ if (path === '~') return homeDirectory(env)
227
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homeDirectory(env), path.slice(2))
228
+ return path
229
+ }
230
+
231
+ /** Default session root: `$DSH_HOME/sessions`, else `~/.dsh/sessions`. */
232
+ function defaultRoot(env: NodeJS.ProcessEnv): string {
233
+ const dshHome = env['DSH_HOME']
234
+ const base = dshHome !== undefined && dshHome.length > 0 ? expandHome(dshHome, env) : join(homeDirectory(env), '.dsh')
235
+ return join(base, 'sessions')
236
+ }
237
+
238
+ /** One flag's value, or a clear "missing value" failure. */
239
+ function argument(argv: readonly string[], index: number, flag: string): string {
240
+ const value = argv[index]
241
+ if (value === undefined) throw new Error(`${flag} requires a value`)
242
+ return value
243
+ }
244
+
245
+ /** One `--class` value, validated against the refusal vocabulary. */
246
+ function parseClass(value: string): RefusalClass {
247
+ const match = REFUSAL_CLASSES.find((name) => name === value)
248
+ if (match === undefined) throw new Error(`unknown --class ${value}; expected one of ${REFUSAL_CLASSES.join(', ')}`)
249
+ return match
250
+ }
251
+
252
+ /**
253
+ * Parse the CLI arguments.
254
+ *
255
+ * @param argv arguments after the script name (`pnpm run <script> -- <args>`'s
256
+ * separator is skipped).
257
+ * @param env environment the default root and `~` expansion read.
258
+ * @throws Error on a malformed command line (the caller prints usage, exit 2).
259
+ */
260
+ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): CliOptions {
261
+ const options: CliOptions = {
262
+ help: false,
263
+ root: defaultRoot(env),
264
+ apply: false,
265
+ classFilter: null,
266
+ catalogPath: undefined,
267
+ backup: false,
268
+ dropLegacyEvents: false,
269
+ json: false,
270
+ quiet: false,
271
+ }
272
+ for (let index = 0; index < argv.length; index += 1) {
273
+ const arg = argv[index]
274
+ // `pnpm run <script> -- <args>` forwards the separator; skip it.
275
+ if (arg === '--') continue
276
+ switch (arg) {
277
+ case '--help':
278
+ case '-h':
279
+ options.help = true
280
+ break
281
+ case '--apply':
282
+ options.apply = true
283
+ break
284
+ case '--backup':
285
+ options.backup = true
286
+ break
287
+ case '--drop-legacy-events':
288
+ options.dropLegacyEvents = true
289
+ break
290
+ case '--json':
291
+ options.json = true
292
+ break
293
+ case '--quiet':
294
+ options.quiet = true
295
+ break
296
+ case '--root':
297
+ options.root = expandHome(argument(argv, (index += 1), '--root'), env)
298
+ break
299
+ case '--catalog':
300
+ options.catalogPath = expandHome(argument(argv, (index += 1), '--catalog'), env)
301
+ break
302
+ case '--class':
303
+ options.classFilter = parseClass(argument(argv, (index += 1), '--class'))
304
+ break
305
+ default:
306
+ throw new Error(`unknown argument: ${String(arg)}`)
307
+ }
308
+ }
309
+ return options
310
+ }
311
+
312
+ /* ------------------------------------------------------------------ */
313
+ /* runtime floor */
314
+ /* ------------------------------------------------------------------ */
315
+
316
+ /**
317
+ * Why this runtime cannot run the tool, or `null` when it can.
318
+ *
319
+ * Kept catalog- and host-independent on purpose: it only looks at the two
320
+ * `node:zlib` zstd functions the publisher needs (`zstdCompressSync`,
321
+ * `zstdDecompressSync`, added in Node 22.15), so an old runtime fails closed
322
+ * with an actionable message before any module that imports them is loaded.
323
+ *
324
+ * @param zlib the `node:zlib` namespace (or any object, for tests).
325
+ */
326
+ export function zstdRuntimeProblem(zlib: object): string | null {
327
+ const missing = ['zstdCompressSync', 'zstdDecompressSync'].filter(
328
+ (name) => typeof (zlib as Record<string, unknown>)[name] !== 'function',
329
+ )
330
+ if (missing.length === 0) return null
331
+ return (
332
+ `${PROGRAM}: this Node runtime (${process.version}) has no node:zlib zstd support `
333
+ + `(${missing.join(', ')} missing). Session logs are concatenated Zstandard frames, so the tool can `
334
+ + 'neither read nor publish them. Upgrade to Node >= 22.15 (the release that added node:zlib zstd) and retry.'
335
+ )
336
+ }
337
+
338
+ /* ------------------------------------------------------------------ */
339
+ /* discovery */
340
+ /* ------------------------------------------------------------------ */
341
+
342
+ /** One candidate: the newest pre-current canonical generation of one session. */
343
+ export interface LogGeneration {
344
+ /** Absolute or root-relative path of the generation file. */
345
+ path: string
346
+ /** Version the generation's filename pins (0 for `session.jsonl.zstd`). */
347
+ generation: number
348
+ /** The `<namespace>/<session>/` directory the generation sits in. */
349
+ sessionDir: string
350
+ }
351
+
352
+ /**
353
+ * The version one canonical generation filename pins, or `null` when the name is
354
+ * not canonical.
355
+ *
356
+ * Mirrors the released `parseGenerationLogFilename`: version zero keeps the
357
+ * suffix-only name, later generations carry a lowercase numeric `v<N>` with no
358
+ * leading zeros. Everything else (uppercase, `v0`, `v01`, `.tmp`, `.bak`) is
359
+ * noncanonical and therefore invisible to both the host and this tool.
360
+ */
361
+ export function canonicalGeneration(name: string): number | null {
362
+ if (name === 'session.jsonl.zstd') return 0
363
+ const match = /^session\.v([1-9][0-9]*)\.jsonl\.zstd$/.exec(name)
364
+ if (match === null) return null
365
+ const version = Number(match[1])
366
+ return Number.isSafeInteger(version) ? version : null
367
+ }
368
+
369
+ /** One input the walk could not inspect, with the reason it could not. */
370
+ export interface SkippedEntry {
371
+ /** Path of the unreadable/irregular entry (or of the directory that failed). */
372
+ path: string
373
+ /** The errno (or the shape) that stopped the walk, e.g. `EACCES: …` or `symlink`. */
374
+ reason: string
375
+ }
376
+
377
+ /** Everything one walk of a session root found. */
378
+ export interface DiscoveryResult {
379
+ /** The triage candidates, sorted by path so a report is reproducible. */
380
+ generations: LogGeneration[]
381
+ /** Entries that could not be inspected: reported, counted, and non-zero. */
382
+ skipped: SkippedEntry[]
383
+ /** Residue of an interrupted run (`session.repair.*.jsonl.zstd.tmp`); never deleted here. */
384
+ staleStagingFiles: string[]
385
+ /**
386
+ * Why the ROOT itself could not be read, or `null`. The caller turns this into a
387
+ * fatal (exit 2): an unreadable root hides every log at once, which must not read
388
+ * as an empty store.
389
+ */
390
+ rootFailure: string | null
391
+ }
392
+
393
+ /** One error's short reason: its errno when it has one, else its message. */
394
+ function reasonOf(error: unknown): string {
395
+ const code = (error as NodeJS.ErrnoException).code
396
+ return typeof code === 'string' && code.length > 0 ? `${code}: ${messageOf(error)}` : messageOf(error)
397
+ }
398
+
399
+ /** Whether one name is the residue of an interrupted publication. */
400
+ function isStaleStagingName(name: string): boolean {
401
+ return name.startsWith('session.repair.') && name.endsWith('.jsonl.zstd.tmp')
402
+ }
403
+
404
+ /**
405
+ * Every immediate subdirectory of one directory, sorted.
406
+ *
407
+ * A `readdir` failure is REPORTED (the directory's own path + errno), never
408
+ * swallowed into an empty list, and a symlinked directory is reported rather than
409
+ * followed: this tool writes beside the generation it repairs, so following a link
410
+ * could place a successor or a `.bak` outside `--root`.
411
+ */
412
+ async function subdirectories(dir: string, skipped: SkippedEntry[]): Promise<string[]> {
413
+ let entries
414
+ try {
415
+ entries = await readdir(dir, { withFileTypes: true })
416
+ } catch (error) {
417
+ skipped.push({ path: dir, reason: reasonOf(error) })
418
+ return []
419
+ }
420
+ const directories: string[] = []
421
+ for (const entry of entries) {
422
+ const path = join(dir, entry.name)
423
+ if (entry.isDirectory()) {
424
+ directories.push(path)
425
+ continue
426
+ }
427
+ if (entry.isSymbolicLink()) {
428
+ skipped.push({ path, reason: 'symlink (not followed: a repair would write beside its target, outside --root)' })
429
+ }
430
+ }
431
+ return directories.sort()
432
+ }
433
+
434
+ /**
435
+ * The newest canonical generation of one session directory below the
436
+ * current-format floor, or `null` when the directory holds none.
437
+ *
438
+ * A generation at or above the floor is deliberately ignored *when selecting*
439
+ * (the host already prefers it), but its presence does not hide a pre-V3
440
+ * generation: the caller reports such a log as already published instead of
441
+ * silently skipping the session. A canonical NAME that is a symlink or not a
442
+ * regular file is reported as skipped — never silently dropped — while
443
+ * noncanonical names (`.tmp`, `.bak`, uppercase) are not candidates at all; the
444
+ * staged-publication residue is collected separately so an interrupted run is
445
+ * visible instead of forgotten.
446
+ */
447
+ async function newestPreCurrentGeneration(
448
+ sessionDir: string,
449
+ skipped: SkippedEntry[],
450
+ staleStagingFiles: string[],
451
+ ): Promise<LogGeneration | null> {
452
+ let entries
453
+ try {
454
+ entries = await readdir(sessionDir, { withFileTypes: true })
455
+ } catch (error) {
456
+ skipped.push({ path: sessionDir, reason: reasonOf(error) })
457
+ return null
458
+ }
459
+ let best: LogGeneration | null = null
460
+ for (const entry of entries) {
461
+ if (isStaleStagingName(entry.name)) staleStagingFiles.push(join(sessionDir, entry.name))
462
+ const generation = canonicalGeneration(entry.name)
463
+ if (generation === null || generation >= CURRENT_VERSION_FLOOR) continue
464
+ const path = join(sessionDir, entry.name)
465
+ if (!entry.isFile()) {
466
+ skipped.push({
467
+ path,
468
+ reason: entry.isSymbolicLink()
469
+ ? 'symlink (not followed: a repair would write beside its target, outside --root)'
470
+ : 'not a regular file',
471
+ })
472
+ continue
473
+ }
474
+ if (best === null || generation > best.generation) {
475
+ best = { path, generation, sessionDir }
476
+ }
477
+ }
478
+ return best
479
+ }
480
+
481
+ /**
482
+ * Every triage candidate under one session root (see the module docblock), plus
483
+ * everything the walk could not inspect and the reason it could not.
484
+ *
485
+ * @param root session root (`<root>/<namespace>/<session>/<generation>`).
486
+ */
487
+ export async function findGenerations(root: string): Promise<DiscoveryResult> {
488
+ const skipped: SkippedEntry[] = []
489
+ const staleStagingFiles: string[] = []
490
+ const found: LogGeneration[] = []
491
+ const rootFailure = await readableDirectoryProblem(root)
492
+ if (rootFailure !== null) return { generations: [], skipped, staleStagingFiles, rootFailure }
493
+ for (const namespace of await subdirectories(root, skipped)) {
494
+ for (const session of await subdirectories(namespace, skipped)) {
495
+ const generation = await newestPreCurrentGeneration(session, skipped, staleStagingFiles)
496
+ if (generation !== null) found.push(generation)
497
+ }
498
+ }
499
+ return {
500
+ generations: found.sort((left, right) => left.path.localeCompare(right.path)),
501
+ skipped,
502
+ staleStagingFiles: staleStagingFiles.sort(),
503
+ rootFailure: null,
504
+ }
505
+ }
506
+
507
+ /** Why `dir` cannot be walked, or `null` when it can be read. */
508
+ async function readableDirectoryProblem(dir: string): Promise<string | null> {
509
+ try {
510
+ await readdir(dir)
511
+ return null
512
+ } catch (error) {
513
+ return `--root cannot be read: ${dir} (${reasonOf(error)}). Refusing to report an empty store for a root this `
514
+ + 'process cannot inspect.'
515
+ }
516
+ }
517
+
518
+ /* ------------------------------------------------------------------ */
519
+ /* reading */
520
+ /* ------------------------------------------------------------------ */
521
+
522
+ /**
523
+ * Turn one decoded container into parsed rows.
524
+ *
525
+ * The first physical record is the session header, which carries no `seq` on the
526
+ * wire, so a synthesized `seq: 0` is added — the frozen row contract
527
+ * (`rowRecord`) drops it again. Blank lines are skipped; any other malformed
528
+ * row fails loudly (the log is then reported as `decompress-failed`, never
529
+ * silently truncated).
530
+ *
531
+ * @param frames decoded frame plaintexts, in file order.
532
+ */
533
+ export function decodeRows(frames: readonly string[]): ParsedRow[] {
534
+ const rows: ParsedRow[] = []
535
+ for (const line of frames.join('').split('\n')) {
536
+ if (line.length === 0) continue
537
+ let record: unknown
538
+ try {
539
+ record = JSON.parse(line)
540
+ } catch (error) {
541
+ throw new Error(`row ${rows.length + 1} is not JSON`, { cause: error })
542
+ }
543
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) {
544
+ throw new Error(`row ${rows.length + 1} is not a JSON object`)
545
+ }
546
+ if (rows.length === 0) {
547
+ rows.push({ seq: 0, ...(record as Record<string, unknown>) } as unknown as ParsedRow)
548
+ continue
549
+ }
550
+ if (typeof (record as Record<string, unknown>)['type'] !== 'string') {
551
+ throw new Error(`row ${rows.length + 1} has no string "type"`)
552
+ }
553
+ rows.push(record as unknown as ParsedRow)
554
+ }
555
+ if (rows.length === 0) throw new Error('the log carries no JSONL record')
556
+ return rows
557
+ }
558
+
559
+ /**
560
+ * Canonical successor filename for one generation.
561
+ *
562
+ * Mirrors the released `generationLogFilename` (and `publish.ts`'s private
563
+ * `successorFilename`, which is not exported): version 0 keeps the suffix-only
564
+ * name, later generations carry `v<N>`.
565
+ */
566
+ export function successorFilename(generation: number): string {
567
+ return generation === 0 ? 'session.jsonl.zstd' : `session.v${generation}.jsonl.zstd`
568
+ }
569
+
570
+ /* ------------------------------------------------------------------ */
571
+ /* per-log work */
572
+ /* ------------------------------------------------------------------ */
573
+
574
+ /** What this run can say about one log. */
575
+ export type LogStatus = 'ok' | 'repairable' | 'unrepairable'
576
+
577
+ /** The outcome of one log. */
578
+ export interface LogOutcome {
579
+ path: string
580
+ /** Version of the generation this run inspected. */
581
+ generation: number
582
+ /** First-refusal class (the catalog's truth when an oracle resolved). */
583
+ class: RefusalClass
584
+ status: LogStatus
585
+ /**
586
+ * Why the released chain with STRICT recovery (the host loader's own validation
587
+ * axis) refuses this log while the loader's lenient policy called it `ok`, or
588
+ * `null` when that stricter attempt accepts the log — and also `null` when NO
589
+ * oracle resolved, because then there is no strict verdict to report at all (the
590
+ * report says STRUCTURAL ONLY). The two policies differ exactly by `recovery`, so
591
+ * a refusal here means the session opens with the rows after it dropped; that is
592
+ * why `token()` prints `ok-truncated` for it instead of a clean `ok`.
593
+ */
594
+ strictRefusal: string | null
595
+ /** Human-facing one-line explanation. */
596
+ detail: string
597
+ /** Structural findings collected over the rows (may be empty). */
598
+ findings: Finding[]
599
+ /**
600
+ * The parsed legacy `fallbacks/switch` rows of the SOURCE log — the drop's
601
+ * candidate population, reported whether or not the flag is on and whether or
602
+ * not the drop was accepted (`dropLegacyEventsRule.detect(rows).length`, never
603
+ * derived from {@link droppedEventCount}). `0` therefore means "this log
604
+ * carries no legacy row at all"; a non-zero population with
605
+ * `droppedEventCount === 0` means the drop was refused or not requested.
606
+ *
607
+ * It stays `0` for the two states where the population is NOT measured, and both
608
+ * are reported as such in `detail` (never presented as a measured zero): a log
609
+ * that could not be decoded, and a log whose analysis threw before the count was
610
+ * taken (`analysisFailureOutcome`).
611
+ */
612
+ legacyEventCount: number
613
+ /**
614
+ * Legacy rows removed from the successor this run published (in apply mode) or
615
+ * would publish (in report mode). Always `0` when nothing is published for this
616
+ * log — the default path is strictly non-lossy and a refused drop publishes
617
+ * nothing — so it is NOT the log's legacy-row population.
618
+ */
619
+ droppedEventCount: number
620
+ /**
621
+ * Surviving events whose `seq` the renumber of the same successor changed (a
622
+ * packed Assistant run counts its payload length). `0` unless a drop was
623
+ * accepted.
624
+ */
625
+ renumberedEventCount: number
626
+ /**
627
+ * Why the opt-in lossy recovery did not apply to this log, when it did not:
628
+ * `reference-integrity` (a surviving row names a dropped seq — the fail-closed
629
+ * gate), `seed-cut` (the renumber would cross the header's seed cut), `other`
630
+ * (the drop or the renumber could not be proven). `null` when the gate did not
631
+ * stop this log.
632
+ */
633
+ lossyRefusal: LossyRefusalReason | null
634
+ /** Whether `--class` let this run repair this log. */
635
+ selected: boolean
636
+ /** Basename of the successor generation this run published, else `null`. */
637
+ published: string | null
638
+ /**
639
+ * Absolute path of a successor that IS on disk and was published from a stale
640
+ * snapshot this run did not create, else `null`. Structured on purpose: with
641
+ * `published: null` and `alreadyPublished: false` the free-text `detail` would be
642
+ * the only thing distinguishing "nothing was published" from "a file is there and
643
+ * must be deleted", which is exactly the reading C-2 forbids.
644
+ */
645
+ stalePublicationPath: string | null
646
+ /**
647
+ * A successor generation was ALREADY published and is proven to load: in
648
+ * report mode by reading it back through the oracle, in apply mode by the
649
+ * publisher accepting it as byte-identical to this repair's own output.
650
+ */
651
+ alreadyPublished: boolean
652
+ /** A repair was attempted and threw (never a silent failure). */
653
+ failed: boolean
654
+ }
655
+
656
+ /**
657
+ * Aggregate counts of one run: the exit code is derived from `refused` — which
658
+ * counts every log still not loadable after this run, including a repairable one
659
+ * this invocation left unpublished — and from `skipped` (an input the walk could
660
+ * not inspect at all).
661
+ */
662
+ export interface RunSummary {
663
+ total: number
664
+ /** Logs the host loader's policy reads without refusal. */
665
+ ok: number
666
+ /**
667
+ * Subset of `ok` that the STRICT current policy still refuses: those sessions
668
+ * open with the rows after the swallowed refusal silently dropped. Counted
669
+ * apart so `ok` is never read as "loads intact"; their exit code stays 0 because
670
+ * they do load.
671
+ */
672
+ okTruncated: number
673
+ /** Inputs the walk could not inspect (unreadable dir, symlink, irregular file). */
674
+ skipped: number
675
+ /** Logs whose proof passes (whether or not this run applied it). */
676
+ repairable: number
677
+ /** Successors NEWLY published by this run (a re-run counts them as already published). */
678
+ repaired: number
679
+ /** Logs whose successor was already published and proven loadable. */
680
+ alreadyPublished: number
681
+ /** Logs no registered normalize step can repair. */
682
+ unrepairable: number
683
+ /** Repairs attempted and failed. */
684
+ failed: number
685
+ /** Repairable logs `--class` excluded from this run. */
686
+ notSelected: number
687
+ /** Logs still not loadable after this run — drives the exit code. */
688
+ refused: number
689
+ /** Log counts by first-refusal class. */
690
+ byClass: Record<RefusalClass, number>
691
+ }
692
+
693
+ /** Whether one run writes (`apply`) or only reports (`report`). */
694
+ export type RunMode = 'report' | 'apply'
695
+
696
+ /** Everything one run produced. */
697
+ export interface RunResult {
698
+ root: string
699
+ mode: RunMode
700
+ classFilter: RefusalClass | null
701
+ catalog:
702
+ | {
703
+ resolved: true
704
+ modulePath: string
705
+ resolvedBy: CatalogResolvedBy
706
+ /** The catalog package's own version, when its manifest was readable. */
707
+ packageVersion: string | null
708
+ /** The format version that catalog declares as current. */
709
+ currentVersion: number
710
+ }
711
+ | { resolved: false }
712
+ logs: LogOutcome[]
713
+ summary: RunSummary
714
+ /** Every input the walk could not inspect, with its errno — never silent. */
715
+ skipped: SkippedEntry[]
716
+ /**
717
+ * `session.repair.*.jsonl.zstd.tmp` residue found under the root (an interrupted
718
+ * publication). Reported so it can be deleted; this tool never removes it.
719
+ */
720
+ staleStagingFiles: string[]
721
+ exitCode: number
722
+ }
723
+
724
+ /** A fatal, non-per-log failure (bad input or unusable runtime/oracle). */
725
+ export class FatalError extends Error {}
726
+
727
+ /** One error's message. */
728
+ function messageOf(error: unknown): string {
729
+ return error instanceof Error ? error.message : String(error)
730
+ }
731
+
732
+ /**
733
+ * Run every selected rule's `normalize` over one log, chained in registry order.
734
+ *
735
+ * This is the PROOF gate: the chain is all-or-nothing, so a single unprovable
736
+ * finding refuses the whole log and nothing is ever published for it. The input
737
+ * rows are never mutated (each rule returns a new array).
738
+ *
739
+ * The chain runs in report mode too (it writes nothing), which is what lets a
740
+ * dry run report the lossy drop count without touching a file.
741
+ */
742
+ function runProof(
743
+ rows: readonly ParsedRow[],
744
+ rules: readonly LogRule[],
745
+ ): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
746
+ let current: readonly ParsedRow[] = rows
747
+ const findings: Finding[] = []
748
+ for (const rule of rules) {
749
+ const outcome = rule.normalize(current)
750
+ if ('refused' in outcome) return { refused: `${rule.id}: ${outcome.refused}` }
751
+ current = outcome.rows
752
+ findings.push(...outcome.findings)
753
+ }
754
+ return { rows: [...current], findings }
755
+ }
756
+
757
+ /** Hex sha256 of one byte buffer. */
758
+ function sha256(bytes: Buffer): string {
759
+ return createHash('sha256').update(bytes).digest('hex')
760
+ }
761
+
762
+ /**
763
+ * Why the released chain with STRICT recovery — the loader's own validation axis —
764
+ * refuses these rows, or `null` when it accepts them (or when no oracle resolved:
765
+ * without one there is no strict verdict to report, and the structural class
766
+ * already says what this run knows).
767
+ *
768
+ * The defensive copy matters: the released restore normalizes some rows in place
769
+ * (`publish.ts` step 3), and these rows are still the ones the run may publish.
770
+ */
771
+ function strictRefusalOf(rows: readonly ParsedRow[], catalog: CatalogHandle | null): string | null {
772
+ if (catalog === null) return null
773
+ try {
774
+ // STRICT RECOVERY, the host loader's own validation axis: the lenient policy
775
+ // differs from this one exactly by `recovery`, so a refusal found here can only
776
+ // mean the rows after it are dropped — not merely that the session is not
777
+ // current-shaped. Using the publisher's `validation: 'current'` axis here would
778
+ // over-claim truncation for a log that loses no rows at all.
779
+ restoreRows(catalog.catalog, structuredClone(rows), { recovery: 'strict', validation: 'transformed' })
780
+ return null
781
+ } catch (error) {
782
+ const cause = error instanceof Error ? error.cause : undefined
783
+ return cause === undefined ? messageOf(error) : `${messageOf(error)} (cause: ${messageOf(cause)})`
784
+ }
785
+ }
786
+
787
+ /** What one log inspection needs from the surrounding run. */
788
+ interface InspectContext {
789
+ apply: boolean
790
+ backup: boolean
791
+ classFilter: RefusalClass | null
792
+ /**
793
+ * Every rule this run's POLICY allows, unfiltered: the default registry, plus
794
+ * the opt-in lossy rule when `--drop-legacy-events` is on. The structural
795
+ * report pass uses this set (the report always covers every log, whatever
796
+ * `--class` selected).
797
+ */
798
+ reportRules: readonly LogRule[]
799
+ /** The rules the WRITE decision applies: `reportRules` narrowed by `--class`. */
800
+ rules: readonly LogRule[]
801
+ dropLegacyEvents: boolean
802
+ catalog: CatalogHandle | null
803
+ decodeZstdFrames(bytes: Buffer): string[]
804
+ publishSuccessor: (
805
+ logPath: string,
806
+ rows: readonly ParsedRow[],
807
+ catalog: CatalogHandle | null,
808
+ sourceDigest: string,
809
+ ) => Promise<{ generation: number; verified: true; targetPath: string; outcome: 'created' | 'accepted' }>
810
+ /**
811
+ * The successor path of a publication that happened from a stale snapshot, or
812
+ * `null` for any other error. Lets this layer avoid reporting "nothing was
813
+ * published" after a publication (the publisher classifies it).
814
+ */
815
+ stalePublicationPath(error: unknown): string | null
816
+ }
817
+
818
+ /**
819
+ * Copy the original generation aside, idempotently.
820
+ *
821
+ * @returns the backup path and whether THIS call created it (a byte-identical
822
+ * copy that already existed is accepted but is not this run's to delete).
823
+ * @throws when a pre-existing copy holds different bytes: that copy is the only
824
+ * pre-publication snapshot of some other revision, so this run cannot claim the
825
+ * guarantee `--backup` exists for — and the refusal names the file and the way
826
+ * out, because nothing else will.
827
+ */
828
+ async function writeBackup(logPath: string): Promise<{ path: string; created: boolean }> {
829
+ const backupPath = `${logPath}${BACKUP_SUFFIX}`
830
+ try {
831
+ await copyFile(logPath, backupPath, fsConstants.COPYFILE_EXCL)
832
+ return { path: backupPath, created: true }
833
+ } catch (error) {
834
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
835
+ const [original, existing] = await Promise.all([readFile(logPath), readFile(backupPath)])
836
+ if (!original.equals(existing)) {
837
+ throw new Error(
838
+ `a backup already exists at ${backupPath} and holds DIFFERENT bytes than the current original, so it is not `
839
+ + 'a restorable copy of the revision this run would publish. Inspect it and delete it (or move it aside) to '
840
+ + 'unblock the repair — until then every --apply --backup run for this session fails here and publishes '
841
+ + 'nothing.',
842
+ )
843
+ }
844
+ return { path: backupPath, created: false }
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Undo the `.bak` copy THIS run created after its publication failed.
850
+ *
851
+ * A backup that already existed before the run is NOT this run's to delete and is
852
+ * left alone.
853
+ *
854
+ * @param backup the copy this run made (or `null` when `--backup` was off).
855
+ * @param successorNamedForDeletion whether the failure is ALSO naming a successor
856
+ * that is still on disk (the accepted-then-stale outcome, and a created successor
857
+ * whose own unlink failed). The directory is not "as it was" in that case — a file
858
+ * is there and the detail says to delete it — so the clause is only claimed when
859
+ * no successor survives (QA's fifth-arm finding).
860
+ * @returns a clause for the failure detail, or `''` when there is nothing to say.
861
+ */
862
+ async function discardCreatedBackup(
863
+ backup: { path: string; created: boolean } | null,
864
+ successorNamedForDeletion: boolean,
865
+ ): Promise<string> {
866
+ if (backup === null || !backup.created) return ''
867
+ try {
868
+ await rm(backup.path, { force: true })
869
+ return successorNamedForDeletion
870
+ ? '; the .bak copy this run created was removed again'
871
+ : '; the .bak copy this run created was removed again, so the session directory is as it was'
872
+ } catch (error) {
873
+ return `; WARNING the .bak copy this run created at ${backup.path} could NOT be removed (${messageOf(error)})`
874
+ }
875
+ }
876
+
877
+ /** The lossy evidence one log's detail line carries, or `''` in the non-lossy path. */
878
+ function lossyNote(dropped: number, renumbered: number, applied: boolean): string {
879
+ if (dropped === 0) return ''
880
+ const events = `${dropped} legacy fallbacks/switch event(s)`
881
+ const renumber = renumbered === 0 ? '' : ` and renumbered ${renumbered} surviving event(s)`
882
+ return applied
883
+ ? `; LOSSY: removed ${events}${renumber} from the published successor`
884
+ : `; LOSSY: ${events} would be removed${renumber ? ` and ${renumbered} surviving event(s) renumbered` : ''} by --apply`
885
+ }
886
+
887
+ /** The lossy mode's own pre-write verdict for one log. */
888
+ type LossyVerdict =
889
+ | { ok: true; renumberedEventCount: number }
890
+ | { ok: false; refused: string; reason: LossyRefusalReason }
891
+
892
+ /**
893
+ * The lossy mode's own PRE-WRITE proof: nothing is trusted, least of all the
894
+ * count the chain reported.
895
+ *
896
+ * (a) the reported count must be the number of PARSED legacy rows in the source
897
+ * log AND the real row-count delta of the repair, and no parsed legacy row
898
+ * may survive it. Byte-identity of every survivor is proven by the drop
899
+ * rule itself, over the same unmodified input (the drop rule runs first in
900
+ * a lossy chain) — see `legacyDropRefusal`;
901
+ * (b) the renumber must be reproducible over the drop's OWN survivors (`legacyDropSplit`,
902
+ * the one place the drop policy lives), re-deriving the gate (`reference integrity`,
903
+ * seed cut, dense numbering) and the renumbered count reported for this log;
904
+ * (c) the repaired rows must restore through the released catalog under the
905
+ * publisher's own STRICT policy, so a differently-unknown or unparseable
906
+ * row cannot hide behind the drop.
907
+ *
908
+ * @returns the verdict: the renumbered-event count, or the reason plus its
909
+ * category (which is what lets a refusal be counted by reason).
910
+ */
911
+ function lossyDropRefusal(
912
+ rows: readonly ParsedRow[],
913
+ repaired: readonly ParsedRow[],
914
+ reported: number,
915
+ catalog: CatalogHandle | null,
916
+ ): LossyVerdict {
917
+ const removable = dropLegacyEventsRule.detect(rows).length
918
+ if (reported !== removable) {
919
+ return {
920
+ ok: false,
921
+ reason: 'other',
922
+ refused: `the reported dropped count ${reported} is not the ${removable} parsed legacy fallbacks/switch row(s) of the source log`,
923
+ }
924
+ }
925
+ const delta = rows.length - repaired.length
926
+ if (delta !== removable) {
927
+ return {
928
+ ok: false,
929
+ reason: 'other',
930
+ refused: `the repair removed ${delta} row(s), while the source log carries ${removable} parsed legacy fallbacks/switch row(s)`,
931
+ }
932
+ }
933
+ if (dropLegacyEventsRule.detect(repaired).length !== 0) {
934
+ return { ok: false, reason: 'other', refused: 'a parsed legacy fallbacks/switch row survived the drop' }
935
+ }
936
+ // Re-derivation, NOT an independent oracle: the renumber is recomputed over the
937
+ // drop's own survivors through the SAME gate (`legacyDropSplit` +
938
+ // `renumberSurvivingEvents`), so a bug shared by the two would agree with itself
939
+ // here — the rule's output-side dense-`seq` check and this re-derivation live in
940
+ // the same function and therefore add input-walk-vs-output-rows evidence, not
941
+ // independent evidence. What this buys is that the reported count and refusal
942
+ // reason are properties of the ROWS rather than of the report; the genuinely
943
+ // independent gate is the released strict restore below. The later rules
944
+ // legitimately rewrite rows, so only the drop's own row count is compared.
945
+ const { survivors } = legacyDropSplit(rows)
946
+ if (survivors.length !== repaired.length) {
947
+ return {
948
+ ok: false,
949
+ reason: 'other',
950
+ refused: `the drop keeps ${survivors.length} row(s), while the repair chain normalized ${repaired.length}`,
951
+ }
952
+ }
953
+ const renumber = renumberSurvivingEvents(rows, survivors)
954
+ if ('refused' in renumber) {
955
+ return { ok: false, reason: lossyRefusalReason(renumber.refused), refused: renumber.refused }
956
+ }
957
+ if (catalog === null) {
958
+ return {
959
+ ok: false,
960
+ reason: 'other',
961
+ refused: 'no released catalog resolved, so the post-drop restore cannot be proven — pass --catalog '
962
+ + `(or set ${CATALOG_ENV_VAR}); nothing was written`,
963
+ }
964
+ }
965
+ try {
966
+ // A defensive copy: the released restore normalizes a packed Assistant run's
967
+ // stream IN PLACE, and `repaired` is what the publisher is about to write.
968
+ restoreRows(catalog.catalog, structuredClone(repaired), { recovery: 'strict', validation: 'transformed' })
969
+ } catch (error) {
970
+ return {
971
+ ok: false,
972
+ reason: 'other',
973
+ refused: 'the repaired rows do not restore through the released catalog '
974
+ + `(${messageOf(error)}), so a differently-unknown or unparseable row would remain; nothing was written`,
975
+ }
976
+ }
977
+ return { ok: true, renumberedEventCount: renumber.renumberedEventCount }
978
+ }
979
+
980
+ /**
981
+ * Classify one log, prove its repair, and publish it when `--apply` asks.
982
+ *
983
+ * One unexpected throw anywhere in here must not void the whole corpus run, so the
984
+ * analysis is delegated and a throw becomes THIS log's failure (`failed: true`,
985
+ * which the exit code counts as refused) with the walk continuing.
986
+ */
987
+ async function inspectLog(candidate: LogGeneration, context: InspectContext): Promise<LogOutcome> {
988
+ try {
989
+ return await analyzeLog(candidate, context)
990
+ } catch (error) {
991
+ return analysisFailureOutcome(candidate, error)
992
+ }
993
+ }
994
+
995
+ /**
996
+ * What one log reports when its analysis throws unexpectedly: `failed: true`
997
+ * (which the exit code counts as refused) with the reason, so one bad log cannot
998
+ * void the report for every other log in the run. Exported for the pin, since the
999
+ * guard's reachability is by definition an unexpected condition.
1000
+ */
1001
+ export function analysisFailureOutcome(candidate: LogGeneration, error: unknown): LogOutcome {
1002
+ return {
1003
+ path: candidate.path,
1004
+ generation: candidate.generation,
1005
+ // `other-refusal` is the vocabulary's own "no class could be established"
1006
+ // bucket: the failure happened before the refusal was known, so no measured
1007
+ // class is being claimed (and `failed: true` is what counts it).
1008
+ class: 'other-refusal',
1009
+ status: 'unrepairable',
1010
+ strictRefusal: null,
1011
+ detail: `analysis failed before this log's counts were known, nothing was written for this log: ${messageOf(error)}`,
1012
+ findings: [],
1013
+ legacyEventCount: 0,
1014
+ droppedEventCount: 0,
1015
+ renumberedEventCount: 0,
1016
+ lossyRefusal: null,
1017
+ selected: true,
1018
+ published: null,
1019
+ alreadyPublished: false,
1020
+ stalePublicationPath: null,
1021
+ failed: true,
1022
+ }
1023
+ }
1024
+
1025
+ /** The body of {@link inspectLog}: classify, prove, and (with `--apply`) publish. */
1026
+ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Promise<LogOutcome> {
1027
+ const base = {
1028
+ path: candidate.path,
1029
+ generation: candidate.generation,
1030
+ selected: true,
1031
+ published: null,
1032
+ alreadyPublished: false,
1033
+ failed: false,
1034
+ findings: [] as Finding[],
1035
+ legacyEventCount: 0,
1036
+ droppedEventCount: 0,
1037
+ renumberedEventCount: 0,
1038
+ lossyRefusal: null as LossyRefusalReason | null,
1039
+ strictRefusal: null as string | null,
1040
+ stalePublicationPath: null as string | null,
1041
+ }
1042
+
1043
+ let rows: ParsedRow[]
1044
+ let sourceDigest: string
1045
+ try {
1046
+ const sourceBytes = await readFile(candidate.path)
1047
+ // The digest of THIS read travels to the publisher, which refuses to write a
1048
+ // successor from any other revision (C-2): decoding and publishing must be
1049
+ // about the same bytes.
1050
+ sourceDigest = sha256(sourceBytes)
1051
+ rows = decodeRows(context.decodeZstdFrames(sourceBytes))
1052
+ } catch (error) {
1053
+ return {
1054
+ ...base,
1055
+ class: 'decompress-failed',
1056
+ status: 'unrepairable',
1057
+ detail: `cannot decode the generation: ${messageOf(error)}`,
1058
+ }
1059
+ }
1060
+
1061
+ // The drop's candidate population, independent of the flag, of the verdict and
1062
+ // of the chain: `0` here means the log carries no legacy row at all (I1).
1063
+ const legacy = dropLegacyEventsRule.detect(rows).length
1064
+
1065
+ // Structural pass over the FULL policy rule set is the report's evidence even
1066
+ // when the oracle (the released chain) is the authority on the class.
1067
+ const structural = classifyRows(rows, context.reportRules)
1068
+ // The oracle restore MUTATES the rows it validates (a packed Assistant run's
1069
+ // stream is normalized in place, `publish.ts` step 3), and these are the rows
1070
+ // the repair chain then renumbers and the publisher writes. It therefore gets a
1071
+ // defensive copy — the same one the publisher takes of its own input.
1072
+ const refusal =
1073
+ context.catalog === null ? structural.class : classifyWithCatalog(structuredClone(rows), context.catalog)
1074
+
1075
+ if (refusal === 'ok') {
1076
+ // The lenient policy is the host loader's, so it decides "the GUI opens it" —
1077
+ // but it can swallow a refusal and silently drop the rows after it. Cross-check
1078
+ // the same policy under STRICT recovery and report the difference: a log that
1079
+ // opens with rows missing must never be printed as a clean `ok`. The legacy-row
1080
+ // population is measured here too, because such a log can carry one.
1081
+ const strictRefusal = strictRefusalOf(rows, context.catalog)
1082
+ return {
1083
+ ...base,
1084
+ class: 'ok',
1085
+ status: 'ok',
1086
+ strictRefusal,
1087
+ legacyEventCount: legacy,
1088
+ detail: strictRefusal === null
1089
+ ? 'no refusal'
1090
+ : "the host loader's policy reads no refusal, but the same loader policy with STRICT recovery refuses this "
1091
+ + `session, so it opens with the rows after that refusal silently dropped: ${strictRefusal}`,
1092
+ findings: structural.findings,
1093
+ }
1094
+ }
1095
+
1096
+ // The report's repairable verdict is a property of the log under the run's FULL
1097
+ // policy — `--class` narrows what this invocation REPAIRS, never what the report
1098
+ // claims — and the repairable classes come from the REGISTRY (each rule answers
1099
+ // for its own class, and the lossy rule only for a log it can actually drop
1100
+ // from), so adding a rule is enough to make its class repairable.
1101
+ if (!repairableClasses(context.reportRules, rows).has(refusal)) {
1102
+ return {
1103
+ ...base,
1104
+ class: refusal,
1105
+ status: 'unrepairable',
1106
+ legacyEventCount: legacy,
1107
+ detail: `${refusal}: no registered normalize step can make this log load`,
1108
+ findings: structural.findings,
1109
+ }
1110
+ }
1111
+
1112
+ const fullProof = runProof(rows, context.reportRules)
1113
+ if ('refused' in fullProof) {
1114
+ const reason = lossyRefusalReason(fullProof.refused)
1115
+ return {
1116
+ ...base,
1117
+ class: refusal,
1118
+ status: 'unrepairable',
1119
+ legacyEventCount: legacy,
1120
+ // Only the lossy rule's own refusal is a lossy verdict; another rule's
1121
+ // refusal is not this mode's doing (`runProof` prefixes the rule id).
1122
+ lossyRefusal: fullProof.refused.startsWith(`${dropLegacyEventsRule.id}: `) ? reason : null,
1123
+ detail: `${refusal}: the repair proof refused — ${fullProof.refused}`,
1124
+ findings: structural.findings,
1125
+ }
1126
+ }
1127
+
1128
+ // What THIS invocation would actually write: the same chain narrowed by `--class`.
1129
+ // When the full policy repairs the log but the selected class's rules alone do
1130
+ // not, the run says so and writes nothing for it — the report must not promise a
1131
+ // repair the same invocation cannot perform.
1132
+ const proof = context.classFilter === null ? fullProof : runProof(rows, context.rules)
1133
+ // A filtered chain can also succeed while performing LESS than the full policy:
1134
+ // with `--drop-legacy-events --class source-kind` the drop rule is out of the
1135
+ // selected chain, so the policy's only repair for the legacy rows is not applied
1136
+ // and `--apply` would hand rows the publisher then refuses. The promise must
1137
+ // account for the enabled policy, not only for the selected class.
1138
+ if (context.classFilter !== null
1139
+ && droppedEventCount(fullProof.findings) > 0
1140
+ && droppedEventCount('findings' in proof ? proof.findings : []) === 0) {
1141
+ return {
1142
+ ...base,
1143
+ class: refusal,
1144
+ status: 'repairable',
1145
+ selected: false,
1146
+ legacyEventCount: legacy,
1147
+ detail: `repairable by the full policy, but --class ${String(context.classFilter)} excludes the rule that `
1148
+ + 'removes this log\'s legacy fallbacks/switch row(s), so --apply with this filter cannot publish it',
1149
+ findings: structural.findings,
1150
+ }
1151
+ }
1152
+ if ('refused' in proof) {
1153
+ return {
1154
+ ...base,
1155
+ class: refusal,
1156
+ status: 'repairable',
1157
+ selected: false,
1158
+ legacyEventCount: legacy,
1159
+ detail: `repairable by the full rule chain; --class ${String(context.classFilter)} alone cannot repair this `
1160
+ + `log, so this run writes nothing for it (${proof.refused})`,
1161
+ findings: structural.findings,
1162
+ }
1163
+ }
1164
+
1165
+ // Reported only when the run actually removes those rows from a published
1166
+ // successor (apply) or would remove them from one (report): a repair that
1167
+ // published nothing has dropped nothing.
1168
+ const dropped = droppedEventCount(proof.findings)
1169
+
1170
+ // The lossy path's own PRE-WRITE proof. It gates BOTH modes, so a log whose drop
1171
+ // would leave another unknown event type (or an unparseable row) behind is
1172
+ // reported unrepairable instead of being repaired on hope; in apply mode nothing
1173
+ // is written — not even the --backup copy — before it passes.
1174
+ const verdict = dropped === 0 ? null : lossyDropRefusal(rows, proof.rows, dropped, context.catalog)
1175
+ if (verdict !== null && !verdict.ok) {
1176
+ return {
1177
+ ...base,
1178
+ class: refusal,
1179
+ status: 'unrepairable',
1180
+ legacyEventCount: legacy,
1181
+ lossyRefusal: verdict.reason,
1182
+ // `droppedEventCount` stays 0: nothing was published, so nothing readable
1183
+ // lost an event. The candidate population and the refusal reason above are
1184
+ // what make that 0 unambiguous.
1185
+ detail: `${refusal}: the lossy drop was refused before any write — ${verdict.refused} `
1186
+ + `(${legacy} legacy fallbacks/switch row(s) in the source log, nothing written)`,
1187
+ findings: structural.findings,
1188
+ }
1189
+ }
1190
+ const renumbered = verdict === null ? 0 : verdict.renumberedEventCount
1191
+
1192
+ // The successor NAME needs the catalog's current version, so it is only known
1193
+ // with a resolved oracle: in report mode without one nothing is probed (the log
1194
+ // is still reported `repairable` from the rule registry alone), and `--apply`
1195
+ // was already refused as fatal by the caller — which is why the null case is
1196
+ // checked here rather than assumed away.
1197
+ const catalog = context.catalog
1198
+ const expected = catalog === null ? null : successorFilename(catalog.catalog.currentVersion)
1199
+ const successorPath = expected === null ? null : join(candidate.sessionDir, expected)
1200
+ const existingSuccessor =
1201
+ successorPath !== null && (await fileExists(successorPath)) ? successorPath : null
1202
+ // "Already published" is a POSITIVE, proven claim, never an existence claim:
1203
+ // report mode proves it by reading the existing generation back through the
1204
+ // oracle, apply mode by letting the publisher succeed (it only accepts an
1205
+ // existing successor holding exactly the bytes this repair would write).
1206
+ let alreadyPublished = false
1207
+ let successorNote = ''
1208
+ if (existingSuccessor !== null && !context.apply) {
1209
+ const successorClass = await classifyFile(existingSuccessor, context)
1210
+ alreadyPublished = successorClass === 'ok'
1211
+ successorNote = alreadyPublished
1212
+ ? `; the existing ${String(expected)} generation reads back with no refusal`
1213
+ : `; WARNING the existing ${String(expected)} generation is itself refused (${successorClass})`
1214
+ }
1215
+ const selected = context.classFilter === null || context.classFilter === refusal
1216
+
1217
+ if (!context.apply) {
1218
+ return {
1219
+ ...base,
1220
+ class: refusal,
1221
+ status: 'repairable',
1222
+ selected,
1223
+ alreadyPublished,
1224
+ legacyEventCount: legacy,
1225
+ droppedEventCount: dropped,
1226
+ renumberedEventCount: renumbered,
1227
+ detail: `${
1228
+ alreadyPublished
1229
+ ? `repairable; the successor ${String(expected)} is already published and loadable${successorNote}`
1230
+ : `repairable; run with --apply to publish the successor generation${successorNote}`
1231
+ }${lossyNote(dropped, renumbered, false)}`,
1232
+ findings: structural.findings,
1233
+ }
1234
+ }
1235
+
1236
+ if (!selected) {
1237
+ return {
1238
+ ...base,
1239
+ class: refusal,
1240
+ status: 'repairable',
1241
+ selected: false,
1242
+ alreadyPublished: false,
1243
+ legacyEventCount: legacy,
1244
+ detail: `repairable, but --class ${String(context.classFilter)} excludes this class from this run`,
1245
+ findings: structural.findings,
1246
+ }
1247
+ }
1248
+
1249
+ let backup: { path: string; created: boolean } | null = null
1250
+ if (context.backup) {
1251
+ try {
1252
+ backup = await writeBackup(candidate.path)
1253
+ } catch (error) {
1254
+ return {
1255
+ ...base,
1256
+ class: refusal,
1257
+ status: 'unrepairable',
1258
+ failed: true,
1259
+ legacyEventCount: legacy,
1260
+ detail: `backup failed, nothing was published: ${messageOf(error)}`,
1261
+ findings: structural.findings,
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ try {
1267
+ const result = await context.publishSuccessor(candidate.path, proof.rows, context.catalog, sourceDigest)
1268
+ const published = basename(result.targetPath)
1269
+ const created = result.outcome === 'created'
1270
+ return {
1271
+ ...base,
1272
+ class: refusal,
1273
+ status: 'repairable',
1274
+ selected: true,
1275
+ published,
1276
+ alreadyPublished: !created,
1277
+ legacyEventCount: legacy,
1278
+ droppedEventCount: dropped,
1279
+ renumberedEventCount: renumbered,
1280
+ detail: `${
1281
+ created
1282
+ ? `published ${published} (created); read back through the catalog with validation: 'current'`
1283
+ : `already published ${published} (verified byte-identical; accepted, not written by this run)`
1284
+ }${lossyNote(dropped, renumbered, true)}`,
1285
+ findings: structural.findings,
1286
+ }
1287
+ } catch (error) {
1288
+ // A publication that HAPPENED and then found its snapshot stale must never be
1289
+ // reported as "nothing was published": name the file and how to roll it back.
1290
+ const stalePath = context.stalePublicationPath(error)
1291
+ // Whatever the failure was, the copy THIS run made must not outlive it. The
1292
+ // "as it was" clause is gated on the stale path: when a successor is being named
1293
+ // for deletion, a file this run is pointing at is still there.
1294
+ const backupNote = await discardCreatedBackup(backup, stalePath !== null)
1295
+ return {
1296
+ ...base,
1297
+ class: refusal,
1298
+ status: 'unrepairable',
1299
+ failed: true,
1300
+ legacyEventCount: legacy,
1301
+ stalePublicationPath: stalePath,
1302
+ detail: `${
1303
+ stalePath === null
1304
+ ? `repair failed, nothing was published: ${messageOf(error)}`
1305
+ : `a successor WAS published from a snapshot that is now stale — DELETE ${stalePath} to roll the `
1306
+ + `publication back: ${messageOf(error)}`
1307
+ }${backupNote}`,
1308
+ findings: structural.findings,
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * The refusal class of one EXISTING generation, read through the same oracle as
1315
+ * a candidate (report mode's proof that an already-published successor loads).
1316
+ */
1317
+ async function classifyFile(path: string, context: InspectContext): Promise<RefusalClass> {
1318
+ try {
1319
+ const rows = decodeRows(context.decodeZstdFrames(await readFile(path)))
1320
+ // The report's structural pass uses the RUN'S policy rules (not the default
1321
+ // registry): an existing successor is judged by the same rule set this run was
1322
+ // invoked with. The catalog-null arm is DEFENSIVE — this function's only call
1323
+ // site sits behind `existingSuccessor !== null`, which already requires a
1324
+ // resolved oracle for the successor name — but it must not silently classify
1325
+ // with a different policy if a future caller reaches it.
1326
+ return context.catalog === null
1327
+ ? classifyRows(rows, context.reportRules).class
1328
+ : classifyWithCatalog(rows, context.catalog)
1329
+ } catch {
1330
+ return 'decompress-failed'
1331
+ }
1332
+ }
1333
+
1334
+ /** Whether one path is a file (never throws). */
1335
+ async function fileExists(path: string): Promise<boolean> {
1336
+ try {
1337
+ return (await stat(path)).isFile()
1338
+ } catch {
1339
+ return false
1340
+ }
1341
+ }
1342
+
1343
+ /** Whether one path is a directory (never throws). */
1344
+ async function isDirectory(path: string): Promise<boolean> {
1345
+ try {
1346
+ return (await stat(path)).isDirectory()
1347
+ } catch {
1348
+ return false
1349
+ }
1350
+ }
1351
+
1352
+ /* ------------------------------------------------------------------ */
1353
+ /* run */
1354
+ /* ------------------------------------------------------------------ */
1355
+
1356
+ /**
1357
+ * The rules one run's POLICY allows, in check order.
1358
+ *
1359
+ * The default is the strictly non-lossy registry. With `--drop-legacy-events` the
1360
+ * never-repairable `fallbacks-switch` detector is REPLACED by the lossy
1361
+ * `drop-legacy-events` rule: both cover exactly the legacy rows, one refuses what
1362
+ * the other removes, and only one of them may sit in a chain.
1363
+ *
1364
+ * The drop rule runs FIRST so its input is the UNMODIFIED log: that is what lets
1365
+ * the rule prove every survivor byte-identical to its source row (any later
1366
+ * position would see the other rules' legitimate rewrites).
1367
+ */
1368
+ function policyRules(dropLegacyEvents: boolean): readonly LogRule[] {
1369
+ if (!dropLegacyEvents) return BUILT_IN_RULES
1370
+ return [
1371
+ dropLegacyEventsRule,
1372
+ ...BUILT_IN_RULES.filter((rule) => rule.id !== fallbacksSwitchRule.id),
1373
+ ]
1374
+ }
1375
+
1376
+ /**
1377
+ * Do the work: resolve the oracle, triage every candidate, and publish when
1378
+ * `--apply` asks for it.
1379
+ *
1380
+ * @throws FatalError on a missing or unreadable `--root`, a runtime without
1381
+ * `node:zlib` zstd, `--apply` without a resolved catalog, a resolved catalog
1382
+ * below {@link REQUIRED_CATALOG_VERSION}, or `--apply --drop-legacy-events`
1383
+ * without `--backup` (no write happens in those cases).
1384
+ */
1385
+ export async function runRepair(
1386
+ options: CliOptions,
1387
+ env: NodeJS.ProcessEnv = process.env,
1388
+ ): Promise<RunResult> {
1389
+ // The lossy opt-in is the one write that removes data from the readable
1390
+ // session, so an `--apply` run must not proceed without a byte copy of the only
1391
+ // generation that still holds those bytes. Checked before anything is read.
1392
+ if (options.apply && options.dropLegacyEvents && !options.backup) {
1393
+ throw new FatalError(
1394
+ '--drop-legacy-events with --apply requires --backup: the dropped rows are the provider/model '
1395
+ + 'switch audit trail of the session, and the original generation is the only place those bytes '
1396
+ + 'survive. Re-run with --backup (the original is copied to <name>.bak before publication), or '
1397
+ + 'drop --apply for a read-only report of what would be dropped.',
1398
+ )
1399
+ }
1400
+
1401
+ if (!(await isDirectory(options.root))) {
1402
+ throw new FatalError(`--root directory not found: ${options.root}`)
1403
+ }
1404
+
1405
+ // Probe BEFORE importing the publisher: its `node:zlib` zstd named imports do
1406
+ // not exist on Node < 22.15, so an old runtime would die with a module-link
1407
+ // stack trace instead of an actionable message.
1408
+ const problem = zstdRuntimeProblem(await import('node:zlib'))
1409
+ if (problem !== null) throw new FatalError(problem)
1410
+
1411
+ const publishing = await import('./session-logs/publish.ts')
1412
+ const catalog = await resolveCatalog({ catalogPath: options.catalogPath, env })
1413
+ if (options.apply) {
1414
+ // Reuse the publisher's own guard: the same refusal and the same message.
1415
+ try {
1416
+ publishing.assertCatalog(catalog)
1417
+ } catch (error) {
1418
+ throw new FatalError(messageOf(error))
1419
+ }
1420
+ }
1421
+ // The oracle is pinned to a RELEASE, in every mode: the same module encodes the
1422
+ // successor and reads it back, so "it accepts its own output" is only evidence
1423
+ // when the module is the build that will read the session. A catalog below the
1424
+ // floor is a fatal (exit 2), not a quieter classification.
1425
+ const catalogProblem = catalog === null ? null : catalogVersionRefusal(catalog)
1426
+ if (catalogProblem !== null) throw new FatalError(catalogProblem)
1427
+
1428
+ const reportRules = policyRules(options.dropLegacyEvents)
1429
+ const rules =
1430
+ options.classFilter === null
1431
+ ? reportRules
1432
+ : reportRules.filter((rule) => rule.class === options.classFilter)
1433
+ const discovery = await findGenerations(options.root)
1434
+ if (discovery.rootFailure !== null) throw new FatalError(discovery.rootFailure)
1435
+ const context: InspectContext = {
1436
+ apply: options.apply,
1437
+ backup: options.backup,
1438
+ classFilter: options.classFilter,
1439
+ reportRules,
1440
+ rules,
1441
+ dropLegacyEvents: options.dropLegacyEvents,
1442
+ catalog,
1443
+ decodeZstdFrames: publishing.decodeZstdFrames,
1444
+ publishSuccessor: publishing.publishSuccessor,
1445
+ stalePublicationPath: (error) =>
1446
+ error instanceof publishing.PublishedFromStaleSourceError ? error.successorPath : null,
1447
+ }
1448
+
1449
+ const logs: LogOutcome[] = []
1450
+ for (const candidate of discovery.generations) logs.push(await inspectLog(candidate, context))
1451
+
1452
+ const summary = summarize(logs, discovery.skipped.length)
1453
+ return {
1454
+ root: options.root,
1455
+ mode: options.apply ? 'apply' : 'report',
1456
+ classFilter: options.classFilter,
1457
+ catalog:
1458
+ catalog === null
1459
+ ? { resolved: false }
1460
+ : {
1461
+ resolved: true,
1462
+ modulePath: catalog.modulePath,
1463
+ resolvedBy: catalog.resolvedBy,
1464
+ packageVersion: catalog.packageVersion,
1465
+ currentVersion: catalog.catalog.currentVersion,
1466
+ },
1467
+ logs,
1468
+ summary,
1469
+ skipped: discovery.skipped,
1470
+ staleStagingFiles: discovery.staleStagingFiles,
1471
+ // An input this run could not inspect is not a clean run, and neither is a log
1472
+ // still not loadable; a stranded staging file is the residue of an interrupted
1473
+ // publication this run cannot vouch for either (it never deletes it). All three
1474
+ // exit 1 (a fatal input problem exits 2 above).
1475
+ exitCode: summary.refused > 0 || discovery.skipped.length > 0 || discovery.staleStagingFiles.length > 0
1476
+ ? EXIT_REFUSED
1477
+ : EXIT_CLEAN,
1478
+ }
1479
+ }
1480
+
1481
+ /** Count one run's outcomes (plus the inputs the walk could not inspect). */
1482
+ function summarize(logs: readonly LogOutcome[], skipped: number): RunSummary {
1483
+ const byClass = Object.fromEntries(REFUSAL_CLASSES.map((name) => [name, 0])) as Record<RefusalClass, number>
1484
+ const summary: RunSummary = {
1485
+ total: logs.length,
1486
+ ok: 0,
1487
+ okTruncated: 0,
1488
+ skipped,
1489
+ repairable: 0,
1490
+ repaired: 0,
1491
+ alreadyPublished: 0,
1492
+ unrepairable: 0,
1493
+ failed: 0,
1494
+ notSelected: 0,
1495
+ refused: 0,
1496
+ byClass,
1497
+ }
1498
+ for (const log of logs) {
1499
+ summary.byClass[log.class] += 1
1500
+ if (log.status === 'ok') summary.ok += 1
1501
+ if (log.strictRefusal !== null) summary.okTruncated += 1
1502
+ if (log.status === 'repairable') summary.repairable += 1
1503
+ if (log.status === 'unrepairable') summary.unrepairable += 1
1504
+ if (log.published !== null && !log.alreadyPublished) summary.repaired += 1
1505
+ if (log.alreadyPublished) summary.alreadyPublished += 1
1506
+ if (log.failed) summary.failed += 1
1507
+ if (log.status === 'repairable' && !log.selected) summary.notSelected += 1
1508
+ // A proven already-published successor means the session loads again, so the
1509
+ // refusal is not open any more; anything else without a published successor
1510
+ // still counts against the run.
1511
+ if (log.status !== 'ok' && log.published === null && !log.alreadyPublished) summary.refused += 1
1512
+ }
1513
+ return summary
1514
+ }
1515
+
1516
+ /* ------------------------------------------------------------------ */
1517
+ /* reporting */
1518
+ /* ------------------------------------------------------------------ */
1519
+
1520
+ /** Output sinks (injectable so tests read the report without a child process). */
1521
+ export interface CliIO {
1522
+ out(text: string): void
1523
+ err(text: string): void
1524
+ }
1525
+
1526
+ /** The default sinks: one line per call on stdout / stderr. */
1527
+ export function consoleIO(): CliIO {
1528
+ return {
1529
+ out: (text) => {
1530
+ process.stdout.write(`${text}\n`)
1531
+ },
1532
+ err: (text) => {
1533
+ process.stderr.write(`${text}\n`)
1534
+ },
1535
+ }
1536
+ }
1537
+
1538
+ /**
1539
+ * The per-log status token: `ok`, the class, `unrepairable`, or — when the lossy
1540
+ * opt-in removes (or would remove) legacy events — a distinct `lossy` token.
1541
+ */
1542
+ function token(log: LogOutcome, mode: RunMode): string {
1543
+ if (log.status === 'ok') return log.strictRefusal === null ? 'ok' : 'ok-truncated'
1544
+ if (log.status === 'unrepairable') return 'unrepairable'
1545
+ if (log.droppedEventCount === 0) return log.class
1546
+ const renumber = log.renumberedEventCount === 0 ? '' : `, ${log.renumberedEventCount} renumbered`
1547
+ return mode === 'apply'
1548
+ ? `repaired-lossy (${log.droppedEventCount} events dropped${renumber})`
1549
+ : `lossy-repairable (${log.droppedEventCount} events to drop${renumber})`
1550
+ }
1551
+
1552
+ /** The loud `--apply` precondition line (never suppressed, not even by --quiet). */
1553
+ export function preconditionNotice(): string {
1554
+ return [
1555
+ '!! --apply PRECONDITION: run this only while NO dsh instance is writing the sessions under --root.',
1556
+ '!! The successor generation is linked beside the original WITHOUT observing the host flock lease,',
1557
+ "!! so a dsh that is still appending to the old generation would be orphaned once the host prefers",
1558
+ '!! the successor. Stop dsh first. Rollback = delete the successor generation (the original stays).',
1559
+ ].join('\n')
1560
+ }
1561
+
1562
+ /**
1563
+ * The loud static data-loss warning for a lossy `--apply` run, printed BEFORE the
1564
+ * run (never suppressed, not even by --quiet): the counts are only known once the
1565
+ * logs are decoded, so they follow in {@link lossyResultNotice}.
1566
+ */
1567
+ export function lossyApplyNotice(): string {
1568
+ return [
1569
+ '!! --drop-legacy-events is LOSSY: every published successor leaves out its legacy',
1570
+ '!! fallbacks/switch events. Those rows are the provider/model switch audit trail of the',
1571
+ '!! session. The original generation is never modified and --backup keeps a byte copy of it,',
1572
+ '!! so the successor is the only readable generation that lacks them. An unknown event type of',
1573
+ '!! any OTHER name is never dropped: such a log stays unrepairable and nothing is written for it.',
1574
+ '!! The surviving events of a successor are RENUMBERED: each one receives the seq of its position',
1575
+ '!! in the surviving event stream (and every Session-seq reference it carries is shifted with it).',
1576
+ '!! Their content is otherwise unchanged, byte for byte.',
1577
+ ].join('\n')
1578
+ }
1579
+
1580
+ /**
1581
+ * The count-bearing lossy report of one finished run, or `null` when this run
1582
+ * drops nothing (every non-lossy run, and any lossy run that published nothing).
1583
+ */
1584
+ export function lossyResultNotice(result: RunResult): string | null {
1585
+ const logs = result.logs.filter((log) => log.droppedEventCount > 0)
1586
+ if (logs.length === 0) return null
1587
+ const dropped = logs.reduce((total, log) => total + log.droppedEventCount, 0)
1588
+ const renumbered = logs.reduce((total, log) => total + log.renumberedEventCount, 0)
1589
+ const effect = result.mode === 'apply'
1590
+ ? 'those audit rows are gone from the published successor(s); the original generations and their '
1591
+ + '--backup copies keep the bytes'
1592
+ : 'nothing was written: an --apply run with --backup is what removes them'
1593
+ return [
1594
+ `!! LOSSY: ${result.mode === 'apply' ? 'dropped' : 'would drop'} ${dropped} legacy fallbacks/switch `
1595
+ + `event(s) in ${logs.length} log(s) —`,
1596
+ `!! ${effect}. ${renumbered} surviving event(s) ${result.mode === 'apply' ? 'received' : 'would receive'} `
1597
+ + 'a new seq (content otherwise unchanged).',
1598
+ ].join('\n')
1599
+ }
1600
+
1601
+ /** The text report (per-log lines + per-class table + summary line). */
1602
+ function reportText(result: RunResult, io: CliIO, quiet: boolean): void {
1603
+ io.out(
1604
+ `${PROGRAM}: ${
1605
+ result.mode === 'apply' ? 'apply (publishes successor generations; originals are never modified)' : 'report only (no write)'
1606
+ }`,
1607
+ )
1608
+ io.out(`root: ${result.root}`)
1609
+ io.out(
1610
+ result.catalog.resolved
1611
+ ? `catalog: ${result.catalog.modulePath} (v${result.catalog.currentVersion},`
1612
+ + ` package ${result.catalog.packageVersion ?? 'version unknown'}, resolved by ${result.catalog.resolvedBy})`
1613
+ : `catalog: none resolved — classification is STRUCTURAL ONLY and --apply would refuse `
1614
+ + `(no --catalog, no ${CATALOG_ENV_VAR}, no dsh on PATH, no npx install)`,
1615
+ )
1616
+ if (result.classFilter !== null) {
1617
+ io.out(
1618
+ `class filter: ${result.classFilter} (rule application only; the listing, the class table and the exit code `
1619
+ + 'still cover every log, and the repairable verdict is always computed over the full policy)',
1620
+ )
1621
+ }
1622
+
1623
+ // Never silent, and never hidden by --quiet: an input the walk could not inspect
1624
+ // (named with its errno) and the residue of an interrupted publication. These are
1625
+ // diagnostics about the ROOT, not per-log report rows, and each of them makes the
1626
+ // run exit 1 — which is what the README promises.
1627
+ if (result.logs.length === 0 && result.skipped.length === 0 && result.staleStagingFiles.length === 0) {
1628
+ io.out(` no session log with a canonical generation below v${CURRENT_VERSION_FLOOR} under this root`)
1629
+ }
1630
+ for (const entry of result.skipped) {
1631
+ io.out(` ${'skipped'.padEnd(TOKEN_WIDTH)} ${entry.path}: ${entry.reason}`)
1632
+ }
1633
+ for (const path of result.staleStagingFiles) {
1634
+ io.out(
1635
+ ` ${'stale staging'.padEnd(TOKEN_WIDTH)} ${path}: residue of an interrupted publication; `
1636
+ + 'this tool never removes it, delete it once no run is active',
1637
+ )
1638
+ }
1639
+
1640
+ if (!quiet) {
1641
+ for (const log of result.logs) {
1642
+ io.out(` ${token(log, result.mode).padEnd(TOKEN_WIDTH)} ${log.path} (v${log.generation}): ${log.detail}`)
1643
+ }
1644
+ io.out('')
1645
+ io.out('class logs')
1646
+ for (const name of REFUSAL_CLASSES) {
1647
+ io.out(`${name.padEnd(TOKEN_WIDTH)} ${String(result.summary.byClass[name]).padStart(4)}`)
1648
+ }
1649
+ }
1650
+
1651
+ const summary = result.summary
1652
+ io.out(
1653
+ `summary: ${summary.total} log(s) | ok ${summary.ok} (ok-truncated ${summary.okTruncated}) `
1654
+ + `| repairable ${summary.repairable} `
1655
+ + `| unrepairable ${summary.unrepairable} | repaired ${summary.repaired} `
1656
+ + `| already published ${summary.alreadyPublished} | failed ${summary.failed} `
1657
+ + `| not selected ${summary.notSelected} | refused ${summary.refused} `
1658
+ + `| skipped ${summary.skipped} | stale staging ${result.staleStagingFiles.length}`,
1659
+ )
1660
+ }
1661
+
1662
+ /** The machine-readable report: the same data the text report prints. */
1663
+ function reportJson(result: RunResult, io: CliIO): void {
1664
+ io.out(JSON.stringify(result, null, 2))
1665
+ }
1666
+
1667
+ /**
1668
+ * Print one run's report and return its exit code.
1669
+ *
1670
+ * @param options parsed command line.
1671
+ * @param io output sinks.
1672
+ * @param env environment the oracle resolution and the default root read.
1673
+ */
1674
+ export async function execute(
1675
+ options: CliOptions,
1676
+ io: CliIO = consoleIO(),
1677
+ env: NodeJS.ProcessEnv = process.env,
1678
+ ): Promise<number> {
1679
+ if (options.apply) io.err(preconditionNotice())
1680
+ if (options.apply && options.dropLegacyEvents) io.err(lossyApplyNotice())
1681
+ let result: RunResult
1682
+ try {
1683
+ result = await runRepair(options, env)
1684
+ } catch (error) {
1685
+ if (error instanceof FatalError) {
1686
+ io.err(`${PROGRAM}: ${error.message}`)
1687
+ return EXIT_FATAL
1688
+ }
1689
+ throw error
1690
+ }
1691
+ if (options.json) reportJson(result, io)
1692
+ else reportText(result, io, options.quiet)
1693
+ const lossy = lossyResultNotice(result)
1694
+ if (lossy !== null) io.err(lossy)
1695
+ return result.exitCode
1696
+ }
1697
+
1698
+ /* ------------------------------------------------------------------ */
1699
+ /* usage */
1700
+ /* ------------------------------------------------------------------ */
1701
+
1702
+ /** The full usage text (`--help`), including the `--apply` precondition. */
1703
+ export function usage(): string {
1704
+ return `usage: pnpm repair:session-logs -- [--root DIR] [--apply] [--class NAME]
1705
+ [--catalog PATH] [--backup] [--drop-legacy-events]
1706
+ [--json] [--quiet]
1707
+
1708
+ Triage (default, read-only) and repair (--apply) pre-V3 dsh session logs. A log is
1709
+ refused when the FROZEN released migration chain cannot classify it; this tool names
1710
+ the refusal class and, for the classes whose normalization is provably
1711
+ shape-preserving, publishes a current-generation successor beside the original. The
1712
+ original generation is never modified and never truncated.
1713
+
1714
+ --root DIR session root to walk (default: $DSH_HOME or ~/.dsh, then /sessions).
1715
+ Every <namespace>/<session>/ directory's newest canonical generation
1716
+ below format v${CURRENT_VERSION_FLOOR} is considered; noncanonical names (staged
1717
+ .tmp files, .bak copies) are ignored, and a current generation already
1718
+ present beside it is reported as an already published successor (proven
1719
+ by a read-back) instead of being a repair target.
1720
+ A namespace/session directory that cannot be read, a canonical
1721
+ generation BELOW the format floor that is a symlink or not a regular
1722
+ file, and a stale session.repair.*.jsonl.zstd.tmp are REPORTED (a
1723
+ skipped/stale entry, always printed, not hidden by --quiet) and make the
1724
+ run exit 1; they suppress the "no session log ..." line. An unreadable
1725
+ ROOT is fatal (exit 2). Above the floor a name is not a candidate at
1726
+ all, so its shape is not inspected.
1727
+ Symlinks are reported, never followed: a repair writes beside the
1728
+ generation it repairs, which must stay inside --root.
1729
+ --apply run the rules' proofs and publish a successor generation per repaired
1730
+ log. Requires a resolved catalog. Default: read-only report.
1731
+ The revision this run DECODED is digest-checked against the file before
1732
+ anything is staged, so a concurrent append is refused before the first
1733
+ write; if the source moves after a publication this run CREATED, that
1734
+ successor is removed again, and if it moves after accepting a
1735
+ pre-existing identical successor the failure names that file and says
1736
+ to delete it (never "nothing was published").
1737
+ --class NAME restrict RULE APPLICATION to one class (default: all rules). Names:
1738
+ ${REFUSAL_CLASSES.join(' | ')}.
1739
+ The listing, the class table and the exit code still cover every log
1740
+ under --root, and the repairable verdict is always computed over the
1741
+ FULL policy, so --class never hides a refusal and never promises a
1742
+ repair this invocation cannot perform; it only decides what --apply
1743
+ repairs (a log the filtered chain alone cannot repair is reported as
1744
+ such and left unpublished).
1745
+ --catalog PATH explicit released catalog path (package directory, a directory holding
1746
+ it, or its module entry file). Default resolution order:
1747
+ $${CATALOG_ENV_VAR}, the catalog the dsh binary on PATH itself
1748
+ resolves, then the newest ~/.npm/_npx install. The resolved module is
1749
+ EXECUTED, not parsed (the same privilege as running this tool), and it
1750
+ must declare currentVersion >= ${CURRENT_VERSION_FLOOR}: a below-floor catalog is
1751
+ refused with exit 2 rather than trusted to verify its own output. A file
1752
+ candidate whose owning package.json names a different package is
1753
+ refused too. The report names the module path, its format version and
1754
+ its package version.
1755
+ --backup copy the original generation to <name>.bak before publishing. A run
1756
+ that ends up publishing nothing removes the copy IT created again,
1757
+ so a failed run leaves the directory as it found it; a copy that
1758
+ already existed is never touched, and one holding different bytes
1759
+ than the current original blocks the repair with a message naming
1760
+ it until you inspect and delete it.
1761
+ --drop-legacy-events
1762
+ LOSSY, off by default: remove the legacy fallbacks/switch rows (the
1763
+ provider/model switch audit trail this repo's own pre-#52 plugin wrote)
1764
+ instead of refusing the log. The frozen V0->V1 edge refuses that event
1765
+ type even with "ignorable: true", so no rewrite can keep it: removal is
1766
+ the only in-repo recovery, and the rows survive only in the original
1767
+ .zstd (hence the --backup requirement below). Report mode writes
1768
+ nothing and only counts what would be dropped; with --apply this flag
1769
+ REQUIRES --backup (exit 2 without it). An unknown event type of any
1770
+ other name is never dropped.
1771
+ RENUMBERING: the same edge requires each event's seq to equal its
1772
+ running event count, so the surviving events are renumbered to their
1773
+ position in the surviving event stream and every surviving Session-seq
1774
+ reference (sourceEventSeqs, surfaceOp, and the payload references the
1775
+ released remappers audit) is shifted with them. Content is otherwise
1776
+ unchanged, byte for byte. A log whose surviving rows reference a
1777
+ DROPPED seq, whose rows are not densely numbered, or whose drop would
1778
+ renumber across the header's seed cut is refused with nothing written.
1779
+ COUNTS (--json, per log): legacyEventCount is the parsed legacy row
1780
+ population of the SOURCE log (0 means the log carries no legacy row);
1781
+ droppedEventCount counts rows removed from the successor this run
1782
+ published or would publish (0 when nothing is published, including a
1783
+ refused drop); renumberedEventCount counts surviving events given a new
1784
+ seq in that successor (a packed Assistant run counts its payload length).
1785
+ --json emit one machine-readable JSON document instead of the text report.
1786
+ --quiet suppress the per-log lines and the by-class table (the header and the
1787
+ summary line still print; warnings and errors are never suppressed).
1788
+ --help, -h show this help and exit 0.
1789
+
1790
+ POLICIES: a log's class and its "ok" come from the host loader's policy (what decides
1791
+ whether the GUI opens the session), but that policy can swallow a refusal and drop the
1792
+ rows after it. Every ok log is therefore cross-checked with the SAME loader policy under
1793
+ STRICT recovery — one axis apart, so a refusal there means rows were dropped, not merely
1794
+ that the session is not current-shaped. When it refuses, the log is reported
1795
+ "ok-truncated" (with a strictRefusal reason in --json and an ok-truncated count in the
1796
+ summary) because the session opens WITHOUT the rows that refusal swallowed. Such logs
1797
+ still exit 0 — they do load.
1798
+
1799
+ PRECONDITION for --apply: run it only while NO dsh instance is writing the sessions
1800
+ under --root. The successor is linked into the session directory without observing the
1801
+ host's flock lease (that lease is host-internal and cannot be taken from this repo), so
1802
+ a dsh that is still appending to the old generation would be orphaned once the host
1803
+ prefers the successor. Stop dsh first. Rollback: delete the successor generation.
1804
+
1805
+ RUNTIME: reading and writing session logs needs node:zlib zstd (Node >= 22.15, while
1806
+ engines.node allows >= 22); a runtime without it fails closed with exit 2. A frame whose
1807
+ declared plaintext exceeds the tool's frame ceiling is refused as decompress-failed.
1808
+
1809
+ EXIT CODES: 0 = every log loads (a session that opens with rows dropped under the strict
1810
+ policy is reported ok-truncated and still exits 0); 1 = at least one log is still
1811
+ refused/unrepairable, a repair failed, a log was left unpublished (including one --class
1812
+ excluded), an input could not be inspected (a skipped path), or a stale
1813
+ session.repair.*.jsonl.zstd.tmp was found under the root; 2 = fatal (bad arguments,
1814
+ a missing or unreadable --root, --apply without a resolved catalog, a catalog below
1815
+ format v${CURRENT_VERSION_FLOOR}, --apply --drop-legacy-events without --backup, or a runtime
1816
+ without node:zlib zstd).`
1817
+ }
1818
+
1819
+ /**
1820
+ * The CLI entry point: parse, print usage on a malformed command line, else run.
1821
+ *
1822
+ * @returns the process exit code (see the module docblock).
1823
+ */
1824
+ export async function main(
1825
+ argv: readonly string[] = process.argv.slice(2),
1826
+ env: NodeJS.ProcessEnv = process.env,
1827
+ io: CliIO = consoleIO(),
1828
+ ): Promise<number> {
1829
+ let options: CliOptions
1830
+ try {
1831
+ options = parseArgs(argv, env)
1832
+ } catch (error) {
1833
+ io.err(`${PROGRAM}: ${messageOf(error)}`)
1834
+ io.err('')
1835
+ io.err(usage())
1836
+ return EXIT_FATAL
1837
+ }
1838
+ if (options.help) {
1839
+ io.out(usage())
1840
+ return EXIT_CLEAN
1841
+ }
1842
+ return execute(options, io, env)
1843
+ }
1844
+
1845
+ // Run only when executed directly (tsx scripts/repair-session-logs.ts) —
1846
+ // importing the module (unit tests) must not start the CLI.
1847
+ const entry = process.argv[1] === undefined ? '' : pathToFileURL(process.argv[1]).href
1848
+ if (import.meta.url === entry) {
1849
+ main().then(
1850
+ (code) => {
1851
+ process.exitCode = code
1852
+ },
1853
+ (error: unknown) => {
1854
+ process.stderr.write(`${PROGRAM} failed: ${messageOf(error)}\n`)
1855
+ process.exitCode = EXIT_FATAL
1856
+ },
1857
+ )
1858
+ }