dsh-llm-fallbacks 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.5.0",
8
+ "version": "0.5.2",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -61,28 +61,29 @@
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@deepseek-ai/cordis": "^4.0.2",
64
- "@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
65
- "@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.1",
66
- "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
67
- "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
68
- "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
69
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
70
- "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
71
- "@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.1",
72
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
73
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.1",
74
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
75
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
76
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.1",
77
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
78
- "@deepseek-ai/dsh-commands": "^0.1.5-rc.1",
79
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
80
- "@deepseek-ai/dsh-llm-retry": "^0.1.5-rc.1",
81
- "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
82
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
83
- "@deepseek-ai/dsh-tool-subagent": "^0.1.5-rc.1",
84
- "@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.1",
85
- "@deepseek-ai/dsh-typert-registry": "^0.1.5-rc.1",
64
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
65
+ "@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.2",
66
+ "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.2",
67
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.2",
68
+ "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.2",
69
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
70
+ "@deepseek-ai/dsh-client-store": "^0.1.5-rc.2",
71
+ "@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.2",
72
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
73
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
74
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
75
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
76
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.2",
77
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
78
+ "@deepseek-ai/dsh-commands": "^0.1.5-rc.2",
79
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
80
+ "@deepseek-ai/dsh-llm-retry": "^0.1.5-rc.2",
81
+ "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
82
+ "@deepseek-ai/dsh-session-projection": "^0.1.5-rc.2",
83
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
84
+ "@deepseek-ai/dsh-tool-subagent": "^0.1.5-rc.2",
85
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.2",
86
+ "@deepseek-ai/dsh-typert-registry": "^0.1.5-rc.2",
86
87
  "@deepseek-ai/schemastery": "^3.18.2",
87
88
  "react": "^18.2.0"
88
89
  },
@@ -48,7 +48,7 @@ const OUT_FILE = 'index.js'
48
48
 
49
49
  /**
50
50
  * Frozen loader module table (dsh mechanism-guide): the plan-documented
51
- * composition = PLATFORM_MODULES (9 entries in 0.1.5-rc.1,
51
+ * composition = PLATFORM_MODULES (9 entries in 0.1.5-rc.2,
52
52
  * `web/src/platform.ts:8-16` — the old runtime `./client` exemption became
53
53
  * the `@deepseek-ai/dsh-client-store` platform module, so the store VALUE
54
54
  * import stays external and resolves from the host loader table) + the
@@ -76,6 +76,19 @@
76
76
  * stay inside `--root`. A stale `session.repair.*.jsonl.zstd.tmp` (the residue of
77
77
  * an interrupted run) is reported too, and never deleted by this tool.
78
78
  *
79
+ * THE TEXT REPORT IS LIVE: the header and the input diagnostics print as soon as
80
+ * discovery is done, every per-log line prints the moment that log has been
81
+ * triaged (and, under `--apply`, published), and only the by-class table and the
82
+ * summary line wait for the end of the walk — so a run that dies mid-walk (a
83
+ * crash, an OOM, a Ctrl-C, a closed stdout) keeps the lines it has already written
84
+ * out (stream bytes not yet flushed can still be lost with the process). `--json`
85
+ * is unchanged: ONE document built from the finished run.
86
+ * `--quiet` is unchanged too (see its own contract below). A consumer that stops
87
+ * reading — a closed stdout/stderr, the ordinary `| head -n 5` — is not a store
88
+ * event: the first EPIPE stops writes to that stream, the run still finishes (an
89
+ * `--apply` pass publishes every remaining successor) and the exit code keeps
90
+ * describing the store (see `pipeSafeWriter`).
91
+ *
79
92
  * `--class NAME` restricts RULE APPLICATION to one class (default: all rules),
80
93
  * which is what `--apply` will repair. It never narrows the listing, the class
81
94
  * table or the exit code: the report's `repairable` verdict is always computed
@@ -528,12 +541,20 @@ async function readableDirectoryProblem(dir: string): Promise<string | null> {
528
541
  * row fails loudly (the log is then reported as `decompress-failed`, never
529
542
  * silently truncated).
530
543
  *
544
+ * Frames are consumed one at a time from ANY iterable (the publisher's decoder is
545
+ * a generator), so the frames are never joined into one string and no array of every
546
+ * line is built. Each frame's own plaintext is materialized whole, and the event
547
+ * batch is ONE frame — so that frame is the session's whole event plaintext (see the
548
+ * frame ceiling in `session-logs/publish.ts`). A line split across two frames is
549
+ * reassembled exactly like concatenating the frames first would, so the framing
550
+ * stays irrelevant to the row stream.
551
+ *
531
552
  * @param frames decoded frame plaintexts, in file order.
532
553
  */
533
- export function decodeRows(frames: readonly string[]): ParsedRow[] {
554
+ export function decodeRows(frames: Iterable<string>): ParsedRow[] {
534
555
  const rows: ParsedRow[] = []
535
- for (const line of frames.join('').split('\n')) {
536
- if (line.length === 0) continue
556
+ const push = (line: string): void => {
557
+ if (line.length === 0) return
537
558
  let record: unknown
538
559
  try {
539
560
  record = JSON.parse(line)
@@ -545,28 +566,33 @@ export function decodeRows(frames: readonly string[]): ParsedRow[] {
545
566
  }
546
567
  if (rows.length === 0) {
547
568
  rows.push({ seq: 0, ...(record as Record<string, unknown>) } as unknown as ParsedRow)
548
- continue
569
+ return
549
570
  }
550
571
  if (typeof (record as Record<string, unknown>)['type'] !== 'string') {
551
572
  throw new Error(`row ${rows.length + 1} has no string "type"`)
552
573
  }
553
574
  rows.push(record as unknown as ParsedRow)
554
575
  }
576
+ // One frame's plaintext at a time: only the current line and the current frame
577
+ // are alive, and the frame's last (possibly partial) line is carried into the
578
+ // next frame so a line split across a frame boundary still parses.
579
+ let pending = ''
580
+ for (const frame of frames) {
581
+ const text = pending === '' ? frame : pending + frame
582
+ let start = 0
583
+ for (;;) {
584
+ const end = text.indexOf('\n', start)
585
+ if (end < 0) break
586
+ push(text.slice(start, end))
587
+ start = end + 1
588
+ }
589
+ pending = text.slice(start)
590
+ }
591
+ push(pending)
555
592
  if (rows.length === 0) throw new Error('the log carries no JSONL record')
556
593
  return rows
557
594
  }
558
595
 
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
596
  /* ------------------------------------------------------------------ */
571
597
  /* per-log work */
572
598
  /* ------------------------------------------------------------------ */
@@ -800,7 +826,20 @@ interface InspectContext {
800
826
  rules: readonly LogRule[]
801
827
  dropLegacyEvents: boolean
802
828
  catalog: CatalogHandle | null
803
- decodeZstdFrames(bytes: Buffer): string[]
829
+ /**
830
+ * Canonical successor filename for one generation: the publisher's exported
831
+ * rule, taken from the same module instance that publishes (this module is
832
+ * imported dynamically, AFTER the `node:zlib` zstd runtime probe, so the CLI
833
+ * cannot import this statically). One implementation — no local copy here.
834
+ */
835
+ successorFilename(version: number): string
836
+ /**
837
+ * The publisher's frame decoder, one frame's plaintext at a time (`decodeRows`
838
+ * consumes it lazily, so no two frames are materialized at once). Typed as an
839
+ * iterable — not `string[]` — on purpose: the array form would hold every frame,
840
+ * the whole event batch included, simultaneously.
841
+ */
842
+ decodeFrameTexts(bytes: Buffer): Iterable<string>
804
843
  publishSuccessor: (
805
844
  logPath: string,
806
845
  rows: readonly ParsedRow[],
@@ -1048,7 +1087,7 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
1048
1087
  // successor from any other revision (C-2): decoding and publishing must be
1049
1088
  // about the same bytes.
1050
1089
  sourceDigest = sha256(sourceBytes)
1051
- rows = decodeRows(context.decodeZstdFrames(sourceBytes))
1090
+ rows = decodeRows(context.decodeFrameTexts(sourceBytes))
1052
1091
  } catch (error) {
1053
1092
  return {
1054
1093
  ...base,
@@ -1194,8 +1233,18 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
1194
1233
  // is still reported `repairable` from the rule registry alone), and `--apply`
1195
1234
  // was already refused as fatal by the caller — which is why the null case is
1196
1235
  // checked here rather than assumed away.
1236
+ //
1237
+ // The name comes from the publisher's own exported rule (no local copy). Note
1238
+ // the two INPUTS differ: this probe uses the resolved catalog's declared
1239
+ // `currentVersion`, while `publishSuccessor` derives its target from the version
1240
+ // the RESTORED header settles on. They agree for every catalog whose restore
1241
+ // settles at the version it declares (the released one does — its successor is
1242
+ // read back and pinned to that version before publication); a catalog that
1243
+ // declared one version and settled at another would leave this probe looking for
1244
+ // a name the publication never used, so report mode alone could call such a log
1245
+ // unpublished while `--apply` accepts the existing successor as byte-identical.
1197
1246
  const catalog = context.catalog
1198
- const expected = catalog === null ? null : successorFilename(catalog.catalog.currentVersion)
1247
+ const expected = catalog === null ? null : context.successorFilename(catalog.catalog.currentVersion)
1199
1248
  const successorPath = expected === null ? null : join(candidate.sessionDir, expected)
1200
1249
  const existingSuccessor =
1201
1250
  successorPath !== null && (await fileExists(successorPath)) ? successorPath : null
@@ -1316,7 +1365,7 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
1316
1365
  */
1317
1366
  async function classifyFile(path: string, context: InspectContext): Promise<RefusalClass> {
1318
1367
  try {
1319
- const rows = decodeRows(context.decodeZstdFrames(await readFile(path)))
1368
+ const rows = decodeRows(context.decodeFrameTexts(await readFile(path)))
1320
1369
  // The report's structural pass uses the RUN'S policy rules (not the default
1321
1370
  // registry): an existing successor is judged by the same rule set this run was
1322
1371
  // invoked with. The catalog-null arm is DEFENSIVE — this function's only call
@@ -1373,10 +1422,35 @@ function policyRules(dropLegacyEvents: boolean): readonly LogRule[] {
1373
1422
  ]
1374
1423
  }
1375
1424
 
1425
+ /**
1426
+ * The live progress seam of one walk: the text report uses it to print each log's
1427
+ * line while the run is still working, instead of buffering the whole report until
1428
+ * the walk ends. A run that dies mid-walk (a crash, an OOM, a Ctrl-C, a closed
1429
+ * stdout) therefore loses at most the output its stream had not written out yet.
1430
+ * `--json` — and every library caller — passes no sink and sees the buffered
1431
+ * behaviour.
1432
+ */
1433
+ export interface RunProgress {
1434
+ /**
1435
+ * Discovery is done and the catalog is resolved: everything the text report
1436
+ * prints BEFORE the first per-log line is now known — the header, the inputs the
1437
+ * walk could not inspect (`skipped`) and the stale staging residue.
1438
+ */
1439
+ onDiscovery(discovery: DiscoveryResult, catalog: RunResult['catalog']): void
1440
+ /**
1441
+ * One finished log, in walk order. Called after all of that log's own work
1442
+ * (classification, proof and any publication) is done.
1443
+ */
1444
+ onLog(log: LogOutcome): void
1445
+ }
1446
+
1376
1447
  /**
1377
1448
  * Do the work: resolve the oracle, triage every candidate, and publish when
1378
1449
  * `--apply` asks for it.
1379
1450
  *
1451
+ * @param progress optional live sink; see {@link RunProgress}. Every callback
1452
+ * fires after the corresponding fact is known, so no fatal path (all of which
1453
+ * precede discovery) can report a partial line.
1380
1454
  * @throws FatalError on a missing or unreadable `--root`, a runtime without
1381
1455
  * `node:zlib` zstd, `--apply` without a resolved catalog, a resolved catalog
1382
1456
  * below {@link REQUIRED_CATALOG_VERSION}, or `--apply --drop-legacy-events`
@@ -1385,6 +1459,7 @@ function policyRules(dropLegacyEvents: boolean): readonly LogRule[] {
1385
1459
  export async function runRepair(
1386
1460
  options: CliOptions,
1387
1461
  env: NodeJS.ProcessEnv = process.env,
1462
+ progress?: RunProgress,
1388
1463
  ): Promise<RunResult> {
1389
1464
  // The lossy opt-in is the one write that removes data from the readable
1390
1465
  // session, so an `--apply` run must not proceed without a byte copy of the only
@@ -1425,6 +1500,18 @@ export async function runRepair(
1425
1500
  const catalogProblem = catalog === null ? null : catalogVersionRefusal(catalog)
1426
1501
  if (catalogProblem !== null) throw new FatalError(catalogProblem)
1427
1502
 
1503
+ // Built once and used by BOTH the progress callback and the returned result, so
1504
+ // a streaming run reports exactly the catalog the finished document carries.
1505
+ const catalogReport: RunResult['catalog'] = catalog === null
1506
+ ? { resolved: false }
1507
+ : {
1508
+ resolved: true,
1509
+ modulePath: catalog.modulePath,
1510
+ resolvedBy: catalog.resolvedBy,
1511
+ packageVersion: catalog.packageVersion,
1512
+ currentVersion: catalog.catalog.currentVersion,
1513
+ }
1514
+
1428
1515
  const reportRules = policyRules(options.dropLegacyEvents)
1429
1516
  const rules =
1430
1517
  options.classFilter === null
@@ -1432,6 +1519,9 @@ export async function runRepair(
1432
1519
  : reportRules.filter((rule) => rule.class === options.classFilter)
1433
1520
  const discovery = await findGenerations(options.root)
1434
1521
  if (discovery.rootFailure !== null) throw new FatalError(discovery.rootFailure)
1522
+ // Every fatal check is behind us, so a text run may report its header now: the
1523
+ // resolved catalog and the walk's inputs are known, the logs are not.
1524
+ progress?.onDiscovery(discovery, catalogReport)
1435
1525
  const context: InspectContext = {
1436
1526
  apply: options.apply,
1437
1527
  backup: options.backup,
@@ -1440,30 +1530,28 @@ export async function runRepair(
1440
1530
  rules,
1441
1531
  dropLegacyEvents: options.dropLegacyEvents,
1442
1532
  catalog,
1443
- decodeZstdFrames: publishing.decodeZstdFrames,
1533
+ successorFilename: publishing.successorFilename,
1534
+ decodeFrameTexts: publishing.decodeZstdFrameTexts,
1444
1535
  publishSuccessor: publishing.publishSuccessor,
1445
1536
  stalePublicationPath: (error) =>
1446
1537
  error instanceof publishing.PublishedFromStaleSourceError ? error.successorPath : null,
1447
1538
  }
1448
1539
 
1449
1540
  const logs: LogOutcome[] = []
1450
- for (const candidate of discovery.generations) logs.push(await inspectLog(candidate, context))
1541
+ for (const candidate of discovery.generations) {
1542
+ const log = await inspectLog(candidate, context)
1543
+ logs.push(log)
1544
+ // Report THIS log now (its publication, if any, is complete): a later crash,
1545
+ // OOM or Ctrl-C can only take down output this run had not written out yet.
1546
+ progress?.onLog(log)
1547
+ }
1451
1548
 
1452
1549
  const summary = summarize(logs, discovery.skipped.length)
1453
1550
  return {
1454
1551
  root: options.root,
1455
1552
  mode: options.apply ? 'apply' : 'report',
1456
1553
  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
- },
1554
+ catalog: catalogReport,
1467
1555
  logs,
1468
1556
  summary,
1469
1557
  skipped: discovery.skipped,
@@ -1523,15 +1611,52 @@ export interface CliIO {
1523
1611
  err(text: string): void
1524
1612
  }
1525
1613
 
1526
- /** The default sinks: one line per call on stdout / stderr. */
1614
+ /**
1615
+ * A stream's guarded writer, once: the closed-pipe state belongs to the STREAM, so a
1616
+ * second `consoleIO()` in the same process reuses the writer instead of stacking a
1617
+ * second 'error' listener on a process stream.
1618
+ */
1619
+ const guardedWriters = new WeakMap<NodeJS.WriteStream, (text: string) => void>()
1620
+
1621
+ /**
1622
+ * A writer for ONE process stream that survives its reader going away.
1623
+ *
1624
+ * Piping the report into a consumer that stops reading (`… | head -n 5`) closes the
1625
+ * read end, and the next write then raises `EPIPE` as an UNHANDLED `'error'` event:
1626
+ * a raw `node:events … write EPIPE` stack trace and an exit code that says nothing
1627
+ * about the store — the exact opposite of what this tool's exit codes mean. A reader
1628
+ * is not a store event, so the FIRST `EPIPE` marks this stream dead, every later
1629
+ * write to it is skipped, and the run itself finishes normally (an `--apply` pass
1630
+ * still publishes every remaining successor) with the exit code it computed.
1631
+ *
1632
+ * A stream error that is NOT `EPIPE` keeps the previous behaviour (an uncaught
1633
+ * error): a report that genuinely could not be delivered (a full disk, an I/O
1634
+ * failure) must never be swallowed silently.
1635
+ */
1636
+ function pipeSafeWriter(stream: NodeJS.WriteStream): (text: string) => void {
1637
+ const existing = guardedWriters.get(stream)
1638
+ if (existing !== undefined) return existing
1639
+ let closed = false
1640
+ stream.on('error', (error: NodeJS.ErrnoException) => {
1641
+ if (error.code === 'EPIPE') {
1642
+ closed = true
1643
+ return
1644
+ }
1645
+ throw error
1646
+ })
1647
+ const write = (text: string): void => {
1648
+ if (closed) return
1649
+ stream.write(`${text}\n`)
1650
+ }
1651
+ guardedWriters.set(stream, write)
1652
+ return write
1653
+ }
1654
+
1655
+ /** The default sinks: one line per call on stdout / stderr, closed-pipe safe. */
1527
1656
  export function consoleIO(): CliIO {
1528
1657
  return {
1529
- out: (text) => {
1530
- process.stdout.write(`${text}\n`)
1531
- },
1532
- err: (text) => {
1533
- process.stderr.write(`${text}\n`)
1534
- },
1658
+ out: pipeSafeWriter(process.stdout),
1659
+ err: pipeSafeWriter(process.stderr),
1535
1660
  }
1536
1661
  }
1537
1662
 
@@ -1598,24 +1723,31 @@ export function lossyResultNotice(result: RunResult): string | null {
1598
1723
  ].join('\n')
1599
1724
  }
1600
1725
 
1601
- /** The text report (per-log lines + per-class table + summary line). */
1602
- function reportText(result: RunResult, io: CliIO, quiet: boolean): void {
1726
+ /** The run's report minus the parts that only exist once the walk is over. */
1727
+ type ReportHead = Omit<RunResult, 'logs' | 'summary' | 'exitCode'>
1728
+
1729
+ /**
1730
+ * The text report's head: the header lines plus the input diagnostics, i.e.
1731
+ * everything that precedes the first per-log line. A streaming text run prints it
1732
+ * the moment discovery is done (see {@link textProgress}).
1733
+ */
1734
+ function reportHead(head: ReportHead, io: CliIO): void {
1603
1735
  io.out(
1604
1736
  `${PROGRAM}: ${
1605
- result.mode === 'apply' ? 'apply (publishes successor generations; originals are never modified)' : 'report only (no write)'
1737
+ head.mode === 'apply' ? 'apply (publishes successor generations; originals are never modified)' : 'report only (no write)'
1606
1738
  }`,
1607
1739
  )
1608
- io.out(`root: ${result.root}`)
1740
+ io.out(`root: ${head.root}`)
1609
1741
  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})`
1742
+ head.catalog.resolved
1743
+ ? `catalog: ${head.catalog.modulePath} (v${head.catalog.currentVersion},`
1744
+ + ` package ${head.catalog.packageVersion ?? 'version unknown'}, resolved by ${head.catalog.resolvedBy})`
1613
1745
  : `catalog: none resolved — classification is STRUCTURAL ONLY and --apply would refuse `
1614
1746
  + `(no --catalog, no ${CATALOG_ENV_VAR}, no dsh on PATH, no npx install)`,
1615
1747
  )
1616
- if (result.classFilter !== null) {
1748
+ if (head.classFilter !== null) {
1617
1749
  io.out(
1618
- `class filter: ${result.classFilter} (rule application only; the listing, the class table and the exit code `
1750
+ `class filter: ${head.classFilter} (rule application only; the listing, the class table and the exit code `
1619
1751
  + 'still cover every log, and the repairable verdict is always computed over the full policy)',
1620
1752
  )
1621
1753
  }
@@ -1624,23 +1756,39 @@ function reportText(result: RunResult, io: CliIO, quiet: boolean): void {
1624
1756
  // (named with its errno) and the residue of an interrupted publication. These are
1625
1757
  // diagnostics about the ROOT, not per-log report rows, and each of them makes the
1626
1758
  // 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) {
1759
+ for (const entry of head.skipped) {
1631
1760
  io.out(` ${'skipped'.padEnd(TOKEN_WIDTH)} ${entry.path}: ${entry.reason}`)
1632
1761
  }
1633
- for (const path of result.staleStagingFiles) {
1762
+ for (const path of head.staleStagingFiles) {
1634
1763
  io.out(
1635
1764
  ` ${'stale staging'.padEnd(TOKEN_WIDTH)} ${path}: residue of an interrupted publication; `
1636
1765
  + 'this tool never removes it, delete it once no run is active',
1637
1766
  )
1638
1767
  }
1768
+ }
1769
+
1770
+ /** One per-log line — the single place the per-log report format lives. */
1771
+ function logLine(log: LogOutcome, mode: RunMode): string {
1772
+ return ` ${token(log, mode).padEnd(TOKEN_WIDTH)} ${log.path} (v${log.generation}): ${log.detail}`
1773
+ }
1774
+
1775
+ /**
1776
+ * The text report's tail, printed once the walk is over: the empty-root line, the
1777
+ * by-class table (unless `--quiet`) and the summary line. The per-log lines are
1778
+ * NOT printed here — a text run emitted each of them during the walk (see
1779
+ * {@link textProgress}), which is what lets an interrupted run keep them.
1780
+ *
1781
+ * The empty-root line only prints when there is no per-log line at all (no
1782
+ * candidate, no skipped entry, no stale staging file), so keeping it in the tail
1783
+ * leaves the printed order identical to the buffered report — a streaming run
1784
+ * simply learns `logs.length` one walk later than it learns the header.
1785
+ */
1786
+ function reportTail(result: RunResult, io: CliIO, quiet: boolean): void {
1787
+ if (result.logs.length === 0 && result.skipped.length === 0 && result.staleStagingFiles.length === 0) {
1788
+ io.out(` no session log with a canonical generation below v${CURRENT_VERSION_FLOOR} under this root`)
1789
+ }
1639
1790
 
1640
1791
  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
1792
  io.out('')
1645
1793
  io.out('class logs')
1646
1794
  for (const name of REFUSAL_CLASSES) {
@@ -1659,6 +1807,31 @@ function reportText(result: RunResult, io: CliIO, quiet: boolean): void {
1659
1807
  )
1660
1808
  }
1661
1809
 
1810
+ /**
1811
+ * The text report's live sink: the head prints at discovery and each per-log line
1812
+ * prints as that log finishes, so an interrupted run keeps what it has already
1813
+ * written out (R-004). `--quiet` still prints no per-log line (its documented
1814
+ * contract: only the per-log lines and the by-class table are suppressed), and it
1815
+ * still prints the head and the summary.
1816
+ */
1817
+ function textProgress(options: CliOptions, io: CliIO): RunProgress {
1818
+ const mode: RunMode = options.apply ? 'apply' : 'report'
1819
+ return {
1820
+ onDiscovery: (discovery, catalog) =>
1821
+ reportHead({
1822
+ root: options.root,
1823
+ mode,
1824
+ classFilter: options.classFilter,
1825
+ catalog,
1826
+ skipped: discovery.skipped,
1827
+ staleStagingFiles: discovery.staleStagingFiles,
1828
+ }, io),
1829
+ onLog: (log) => {
1830
+ if (!options.quiet) io.out(logLine(log, mode))
1831
+ },
1832
+ }
1833
+ }
1834
+
1662
1835
  /** The machine-readable report: the same data the text report prints. */
1663
1836
  function reportJson(result: RunResult, io: CliIO): void {
1664
1837
  io.out(JSON.stringify(result, null, 2))
@@ -1678,9 +1851,12 @@ export async function execute(
1678
1851
  ): Promise<number> {
1679
1852
  if (options.apply) io.err(preconditionNotice())
1680
1853
  if (options.apply && options.dropLegacyEvents) io.err(lossyApplyNotice())
1854
+ // Text mode streams the report through the walk; `--json` passes no sink and
1855
+ // stays ONE document built from the finished result.
1856
+ const progress = options.json ? undefined : textProgress(options, io)
1681
1857
  let result: RunResult
1682
1858
  try {
1683
- result = await runRepair(options, env)
1859
+ result = await runRepair(options, env, progress)
1684
1860
  } catch (error) {
1685
1861
  if (error instanceof FatalError) {
1686
1862
  io.err(`${PROGRAM}: ${error.message}`)
@@ -1689,7 +1865,7 @@ export async function execute(
1689
1865
  throw error
1690
1866
  }
1691
1867
  if (options.json) reportJson(result, io)
1692
- else reportText(result, io, options.quiet)
1868
+ else reportTail(result, io, options.quiet)
1693
1869
  const lossy = lossyResultNotice(result)
1694
1870
  if (lossy !== null) io.err(lossy)
1695
1871
  return result.exitCode
@@ -1802,6 +1978,12 @@ host's flock lease (that lease is host-internal and cannot be taken from this re
1802
1978
  a dsh that is still appending to the old generation would be orphaned once the host
1803
1979
  prefers the successor. Stop dsh first. Rollback: delete the successor generation.
1804
1980
 
1981
+ CLOSED OUTPUT: a consumer that stops reading the report — a closed stdout or stderr,
1982
+ the ordinary "| head -n 5" — is NOT a store event. The first EPIPE stops further
1983
+ writes to that stream (no stack trace), while the run itself still finishes: an
1984
+ --apply pass publishes every remaining successor, and the exit code keeps describing
1985
+ the STORE (see EXIT CODES). Any other stream error is not swallowed.
1986
+
1805
1987
  RUNTIME: reading and writing session logs needs node:zlib zstd (Node >= 22.15, while
1806
1988
  engines.node allows >= 22); a runtime without it fails closed with exit 2. A frame whose
1807
1989
  declared plaintext exceeds the tool's frame ceiling is refused as decompress-failed.