dsh-llm-fallbacks 0.6.6 → 0.6.8

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.
@@ -1,17 +1,23 @@
1
1
  /**
2
- * Dispatch-time three-stage role resolution (plan fallbacks-role-automatch
3
- * Task 2).
2
+ * Dispatch-time role resolution (plan fallbacks-role-automatch Task 2; the
3
+ * dispatch-role stage added by plan dsh-148-agent-team-role-delivery Task 1).
4
4
  *
5
- * Precedence (first hit wins):
5
+ * Precedence (first hit wins) across four ordered stages:
6
6
  * 1. explicit — `session.header.agentPreset` trimmed matches a declared role
7
7
  * id → that role's DECLARED RAW id (via the `roleIds` trimmed-id map, the
8
8
  * same canonicalization the rules path uses); the reserved `'inherit'`
9
9
  * and undeclared presets are NEVER dispatchable explicit roles and fall
10
- * through to rules;
11
- * 2. rules — the existing `resolveRole` passthrough, unchanged (a declared
10
+ * through to the next stage;
11
+ * 2. dispatch — `opts.dispatchRole`: the role the dispatch seam resolved from
12
+ * the Assignment/teammate prompt (`**Execute as**: <id>`, the single
13
+ * parser in `src/subagents-seam.ts`), canonicalized by the same trim +
14
+ * `roleIds` lookup. An absent, empty, `'inherit'` or no-longer-declared
15
+ * reference falls through silently — a role removed after the dispatch is
16
+ * not an error;
17
+ * 3. rules — the existing `resolveRole` passthrough, unchanged (a declared
12
18
  * role wins; no-rule-match / an explicit rule targeting `'inherit'`
13
19
  * resolve to `'inherit'`);
14
- * 3. auto-match — only when stage 2 resolved to `'inherit'` AND
20
+ * 4. auto-match — only when stage 3 resolved to `'inherit'` AND
15
21
  * `opts.automatchEnabled`: call `opts.automatch` with the agent. The hook
16
22
  * (Task 3 supplies the bounded LLM caller) MUST return a declared raw id
17
23
  * or `null`; the resolver defensively validates the return against
@@ -35,10 +41,16 @@ import type { Selector } from './selectors.ts';
35
41
  export type { FallbacksRoleRule } from './config.ts';
36
42
  /** Options for {@link resolveRoleAtDispatch}. */
37
43
  export interface ResolveRoleAtDispatchOptions {
44
+ /**
45
+ * Dispatch-declared role: the DECLARED RAW id the seam resolved from the
46
+ * Assignment/teammate prompt (`**Execute as**: <id>`). Validated against the
47
+ * live `roleIds` map; absent, `inherit`, or no-longer-declared → skipped.
48
+ */
49
+ dispatchRole?: string;
38
50
  /**
39
51
  * Whether the auto-match stage may run (the `roleAutoMatch` config switch).
40
- * When `false`, stage 3 never runs and the outcome is identical to the
41
- * rules stage alone.
52
+ * When `false`, stage 4 never runs and the outcome is identical to the
53
+ * explicit/dispatch/rules stages alone.
42
54
  */
43
55
  automatchEnabled: boolean;
44
56
  /**
@@ -64,10 +76,11 @@ export interface ResolveRoleAtDispatchOptions {
64
76
  };
65
77
  }
66
78
  /**
67
- * Resolve the dispatch-time role for an agent across three ordered stages
68
- * (explicit → rules → auto-match hook). Never throws on its own inputs; the
69
- * hook's own rejection is deliberately NOT caught here (the Task-3/4 wiring
70
- * bounds the LLM call so it never throws out of the request path).
79
+ * Resolve the dispatch-time role for an agent across four ordered stages
80
+ * (explicit → dispatch → rules → auto-match hook). Never throws on its own
81
+ * inputs; the hook's own rejection is deliberately NOT caught here (the
82
+ * Task-3/4 wiring bounds the LLM call so it never throws out of the request
83
+ * path).
71
84
  *
72
85
  * `roleIds` is the canonical trimmed-id map (`trimmed id → declared raw id`,
73
86
  * built in `src/index.ts` apply()): every stage canonicalizes its reference
package/dist/roles.d.ts CHANGED
@@ -3,10 +3,12 @@
3
3
  * Task 1).
4
4
  *
5
5
  * This module is the RULES stage. Dispatch-time role resolution
6
- * (plan fallbacks-role-automatch) is the three-stage resolver in
7
- * `role-resolution.ts` — explicit (`session.header.agentPreset`) → this
8
- * rules stage → an optional auto-match hook; `AgentLike` carries the additive
9
- * `session.header.agentPreset?` carrier for that explicit stage.
6
+ * (plan fallbacks-role-automatch; plan dsh-148 added the dispatch stage) is
7
+ * the four-stage resolver in `role-resolution.ts` — explicit
8
+ * (`session.header.agentPreset`) → dispatch-declared role (the seam record
9
+ * resolved from `**Execute as**`) → this rules stage → an optional auto-match
10
+ * hook; `AgentLike` carries the additive `session.header.agentPreset?`
11
+ * carrier for that explicit stage.
10
12
  *
11
13
  * Precedence (first hit wins):
12
14
  * 1. the first `rules` entry whose specified provider/model patterns match
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.6.6",
8
+ "version": "0.6.8",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -9,8 +9,12 @@
9
9
  * and — only for the classes whose normalization is provably shape-preserving
10
10
  * (the rule registry in `./session-logs/rules.ts`) and only with a resolved
11
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.
12
+ * REPAIR-FLOOR (v3) successor beside the original (`./session-logs/publish.ts`).
13
+ * The original generation is never modified. A newer installed catalog supplies
14
+ * that floor through its own `historicalSessionFormatCatalog`; the host migrates
15
+ * the published v3 generation onward when it opens the session, with its own
16
+ * historical child evidence (the V3→V4 edge needs evidence no single-log repair
17
+ * can produce — see `CatalogHandle.floor`).
14
18
  *
15
19
  * This module CONSUMES the frozen Task 1/2 modules; it re-implements none of
16
20
  * them. The refusal vocabulary, the rule registry, the oracle resolution and
@@ -134,8 +138,8 @@
134
138
  * at all (`skipped`), or a stale `session.repair.*.jsonl.zstd.tmp` was found under
135
139
  * the root; 2 = fatal (bad arguments, missing/unreadable
136
140
  * `--root`, `--apply` without a resolved catalog, a catalog below the required
137
- * format version, `--apply --drop-legacy-events` without `--backup`, or a runtime
138
- * without zstd).
141
+ * format version or a newer catalog without a usable repair floor,
142
+ * `--apply --drop-legacy-events` without `--backup`, or a runtime without zstd).
139
143
  */
140
144
  import { createHash } from 'node:crypto'
141
145
  import { constants as fsConstants } from 'node:fs'
@@ -149,6 +153,7 @@ import {
149
153
  REQUIRED_CATALOG_VERSION,
150
154
  catalogVersionRefusal,
151
155
  classifyWithCatalog,
156
+ repairCatalog,
152
157
  resolveCatalog,
153
158
  restoreRows,
154
159
  } from './session-logs/catalog.ts'
@@ -733,6 +738,12 @@ export interface RunResult {
733
738
  packageVersion: string | null
734
739
  /** The format version that catalog declares as current. */
735
740
  currentVersion: number
741
+ /**
742
+ * The format version this tool REPAIRS TO (the floor): the declared
743
+ * `currentVersion` on a build at the floor, otherwise that build's
744
+ * historical V0–V3 catalog. Successors are published at this version.
745
+ */
746
+ repairVersion: number
736
747
  }
737
748
  | { resolved: false }
738
749
  logs: LogOutcome[]
@@ -801,8 +812,11 @@ function strictRefusalOf(rows: readonly ParsedRow[], catalog: CatalogHandle | nu
801
812
  // differs from this one exactly by `recovery`, so a refusal found here can only
802
813
  // mean the rows after it are dropped — not merely that the session is not
803
814
  // current-shaped. Using the publisher's `validation: 'current'` axis here would
804
- // over-claim truncation for a log that loses no rows at all.
805
- restoreRows(catalog.catalog, structuredClone(rows), { recovery: 'strict', validation: 'transformed' })
815
+ // over-claim truncation for a log that loses no rows at all. The restore goes
816
+ // through the REPAIR FLOOR (the pre-V3 chain this tool repairs along and the
817
+ // generation it publishes); the host's floor→current migration needs
818
+ // corpus-level child evidence and is outside a single log's verdict.
819
+ restoreRows(repairCatalog(catalog), structuredClone(rows), { recovery: 'strict', validation: 'transformed' })
806
820
  return null
807
821
  } catch (error) {
808
822
  const cause = error instanceof Error ? error.cause : undefined
@@ -1004,7 +1018,10 @@ function lossyDropRefusal(
1004
1018
  try {
1005
1019
  // A defensive copy: the released restore normalizes a packed Assistant run's
1006
1020
  // stream IN PLACE, and `repaired` is what the publisher is about to write.
1007
- restoreRows(catalog.catalog, structuredClone(repaired), { recovery: 'strict', validation: 'transformed' })
1021
+ restoreRows(repairCatalog(catalog), structuredClone(repaired), {
1022
+ recovery: 'strict',
1023
+ validation: 'transformed',
1024
+ })
1008
1025
  } catch (error) {
1009
1026
  return {
1010
1027
  ok: false,
@@ -1228,23 +1245,25 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
1228
1245
  }
1229
1246
  const renumbered = verdict === null ? 0 : verdict.renumberedEventCount
1230
1247
 
1231
- // The successor NAME needs the catalog's current version, so it is only known
1232
- // with a resolved oracle: in report mode without one nothing is probed (the log
1233
- // is still reported `repairable` from the rule registry alone), and `--apply`
1234
- // was already refused as fatal by the caller — which is why the null case is
1235
- // checked here rather than assumed away.
1248
+ // The successor NAME needs the repair floor's version, so it is only known with
1249
+ // a resolved oracle: in report mode without one nothing is probed (the log is
1250
+ // still reported `repairable` from the rule registry alone), and `--apply` was
1251
+ // already refused as fatal by the caller — which is why the null case is checked
1252
+ // here rather than assumed away.
1236
1253
  //
1237
1254
  // 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.
1255
+ // the two INPUTS differ: this probe uses the handle's REPAIR FLOOR version,
1256
+ // while `publishSuccessor` derives its target from the version the RESTORED
1257
+ // header settles on. They agree for every released catalog (the floor catalog's
1258
+ // restore settles at the floor it declares, and the publisher pins the
1259
+ // successor to it before publication); a catalog whose restore settled at a
1260
+ // different version would leave this probe looking for a name the publication
1261
+ // never used, so report mode alone could call such a log unpublished while
1262
+ // `--apply` accepts the existing successor as byte-identical.
1246
1263
  const catalog = context.catalog
1247
- const expected = catalog === null ? null : context.successorFilename(catalog.catalog.currentVersion)
1264
+ const expected = catalog === null
1265
+ ? null
1266
+ : context.successorFilename(repairCatalog(catalog).currentVersion)
1248
1267
  const successorPath = expected === null ? null : join(candidate.sessionDir, expected)
1249
1268
  const existingSuccessor =
1250
1269
  successorPath !== null && (await fileExists(successorPath)) ? successorPath : null
@@ -1493,10 +1512,11 @@ export async function runRepair(
1493
1512
  throw new FatalError(messageOf(error))
1494
1513
  }
1495
1514
  }
1496
- // The oracle is pinned to a RELEASE, in every mode: the same module encodes the
1497
- // successor and reads it back, so "it accepts its own output" is only evidence
1498
- // when the module is the build that will read the session. A catalog below the
1499
- // floor is a fatal (exit 2), not a quieter classification.
1515
+ // The oracle is pinned to a RELEASE, in every mode: the floor catalog encodes
1516
+ // the successor and reads it back, so "it accepts its own output" is only
1517
+ // evidence when the floor comes from the build that will read the session. A
1518
+ // catalog below the floor, or a newer build with no usable floor, is a fatal
1519
+ // (exit 2), not a quieter classification.
1500
1520
  const catalogProblem = catalog === null ? null : catalogVersionRefusal(catalog)
1501
1521
  if (catalogProblem !== null) throw new FatalError(catalogProblem)
1502
1522
 
@@ -1510,6 +1530,7 @@ export async function runRepair(
1510
1530
  resolvedBy: catalog.resolvedBy,
1511
1531
  packageVersion: catalog.packageVersion,
1512
1532
  currentVersion: catalog.catalog.currentVersion,
1533
+ repairVersion: repairCatalog(catalog).currentVersion,
1513
1534
  }
1514
1535
 
1515
1536
  const reportRules = policyRules(options.dropLegacyEvents)
@@ -1741,6 +1762,9 @@ function reportHead(head: ReportHead, io: CliIO): void {
1741
1762
  io.out(
1742
1763
  head.catalog.resolved
1743
1764
  ? `catalog: ${head.catalog.modulePath} (v${head.catalog.currentVersion},`
1765
+ + `${head.catalog.repairVersion === head.catalog.currentVersion
1766
+ ? ''
1767
+ : ` repair target v${head.catalog.repairVersion},`}`
1744
1768
  + ` package ${head.catalog.packageVersion ?? 'version unknown'}, resolved by ${head.catalog.resolvedBy})`
1745
1769
  : `catalog: none resolved — classification is STRUCTURAL ONLY and --apply would refuse `
1746
1770
  + `(no --catalog, no ${CATALOG_ENV_VAR}, no dsh on PATH, no npx install)`,
@@ -1884,15 +1908,19 @@ export function usage(): string {
1884
1908
  Triage (default, read-only) and repair (--apply) pre-V3 dsh session logs. A log is
1885
1909
  refused when the FROZEN released migration chain cannot classify it; this tool names
1886
1910
  the refusal class and, for the classes whose normalization is provably
1887
- shape-preserving, publishes a current-generation successor beside the original. The
1888
- original generation is never modified and never truncated.
1911
+ shape-preserving, publishes a REPAIR-FLOOR (v${CURRENT_VERSION_FLOOR}) successor generation
1912
+ beside the original. The original generation is never modified and never truncated.
1913
+ The floor stays v${CURRENT_VERSION_FLOOR} even when the installed catalog is newer: the next
1914
+ released edge (v${CURRENT_VERSION_FLOOR}->v${CURRENT_VERSION_FLOOR + 1}) needs historical child evidence a
1915
+ single-log repair cannot produce, so the host migrates the published v${CURRENT_VERSION_FLOOR}
1916
+ generation onward when it opens the session.
1889
1917
 
1890
1918
  --root DIR session root to walk (default: $DSH_HOME or ~/.dsh, then /sessions).
1891
1919
  Every <namespace>/<session>/ directory's newest canonical generation
1892
1920
  below format v${CURRENT_VERSION_FLOOR} is considered; noncanonical names (staged
1893
- .tmp files, .bak copies) are ignored, and a current generation already
1894
- present beside it is reported as an already published successor (proven
1895
- by a read-back) instead of being a repair target.
1921
+ .tmp files, .bak copies) are ignored, and a repair-floor generation
1922
+ already present beside it is reported as an already published
1923
+ successor (proven by a read-back) instead of being a repair target.
1896
1924
  A namespace/session directory that cannot be read, a canonical
1897
1925
  generation BELOW the format floor that is a symlink or not a regular
1898
1926
  file, and a stale session.repair.*.jsonl.zstd.tmp are REPORTED (a
@@ -1903,7 +1931,8 @@ original generation is never modified and never truncated.
1903
1931
  Symlinks are reported, never followed: a repair writes beside the
1904
1932
  generation it repairs, which must stay inside --root.
1905
1933
  --apply run the rules' proofs and publish a successor generation per repaired
1906
- log. Requires a resolved catalog. Default: read-only report.
1934
+ log, at the repair floor (v${CURRENT_VERSION_FLOOR}). Requires a resolved catalog.
1935
+ Default: read-only report.
1907
1936
  The revision this run DECODED is digest-checked against the file before
1908
1937
  anything is staged, so a concurrent append is refused before the first
1909
1938
  write; if the source moves after a publication this run CREATED, that
@@ -1924,9 +1953,14 @@ original generation is never modified and never truncated.
1924
1953
  resolves, then the newest ~/.npm/_npx install. The resolved module is
1925
1954
  EXECUTED, not parsed (the same privilege as running this tool), and it
1926
1955
  must declare currentVersion >= ${CURRENT_VERSION_FLOOR}: a below-floor catalog is
1927
- refused with exit 2 rather than trusted to verify its own output. A file
1928
- candidate whose owning package.json names a different package is
1929
- refused too. The report names the module path, its format version and
1956
+ refused with exit 2 rather than trusted to verify its own output. When
1957
+ currentVersion is ABOVE the floor, the module must also export
1958
+ historicalSessionFormatCatalog (that build's V0-V${CURRENT_VERSION_FLOOR} reader/encoder):
1959
+ the floor catalog restores and verifies the published v${CURRENT_VERSION_FLOOR} successor
1960
+ and the host migrates it onward with its own child evidence; a build
1961
+ without a usable floor is refused with exit 2. A file candidate whose
1962
+ owning package.json names a different package is refused too. The
1963
+ report names the module path, its format version, the repair target and
1930
1964
  its package version.
1931
1965
  --backup copy the original generation to <name>.bak before publishing. A run
1932
1966
  that ends up publishing nothing removes the copy IT created again,
@@ -67,12 +67,20 @@ const CATALOG_PACKAGE_DIR = 'dsh-session-format-catalog'
67
67
  export const CATALOG_ENV_VAR = 'DSH_SESSION_FORMAT_CATALOG'
68
68
 
69
69
  /**
70
- * The oldest released catalog this tool accepts as its oracle: the format
70
+ * The oldest released catalog this tool accepts as its oracle AND the format
71
71
  * generation it repairs TO (`CURRENT_VERSION_FLOOR`, the pre-V3 scope). A catalog
72
72
  * below it cannot read a successor this tool would write, so `--apply` must
73
73
  * refuse rather than verify its own output against a build the GUI does not run.
74
74
  * One constant for both the selection scope and the oracle floor: they are the
75
75
  * same number and must not drift.
76
+ *
77
+ * The repair target stays at this floor even when the installed build is newer:
78
+ * the tool only repairs logs BELOW the floor, and the next released edge
79
+ * (V3→V4) requires historical child evidence (one parent's complete child
80
+ * descriptors) that a single-log repair cannot produce. The successor is
81
+ * therefore encoded through the newer build's own `historicalSessionFormatCatalog`
82
+ * (see {@link CatalogHandle.floor}) and the host migrates it onward, with its own
83
+ * child facts, exactly like any other readable pre-V3 session it opens.
76
84
  */
77
85
  export const REQUIRED_CATALOG_VERSION = 3
78
86
 
@@ -131,24 +139,77 @@ export interface CatalogHandle {
131
139
  readonly packageVersion: string | null
132
140
  /** The released catalog module's `sessionFormatCatalog` export. */
133
141
  readonly catalog: ReleasedCatalog
142
+ /**
143
+ * The catalog generation this tool REPAIRS TO — the format floor
144
+ * ({@link REQUIRED_CATALOG_VERSION}): the declared catalog itself when the
145
+ * resolved build is AT the floor, otherwise that build's
146
+ * `historicalSessionFormatCatalog` export (the V0–V3 reader/encoder).
147
+ *
148
+ * Every restore, classification and published successor goes through this
149
+ * catalog, so the successor's header version (and the `N` in its canonical
150
+ * filename) is the floor, never a generation whose migration needs evidence a
151
+ * single-log repair cannot supply. `null` when the resolved build is newer than
152
+ * the floor but exposes no usable historical catalog: the caller refuses the
153
+ * whole run through {@link catalogVersionRefusal} (fatal, exit 2) instead of
154
+ * publishing a generation it cannot restore, verify or hand to the host.
155
+ */
156
+ readonly floor: ReleasedCatalog | null
134
157
  }
135
158
 
136
159
  /**
137
160
  * Why a resolved catalog may not be used as this run's oracle, or `null` when it
138
161
  * may. The caller turns a non-`null` reason into a fatal (exit 2) refusal: a
139
162
  * below-floor catalog would encode a successor this tool's own read-back cannot
140
- * vouch for, and the GUI would not run it either.
163
+ * vouch for, and the GUI would not run it either; a newer build without a usable
164
+ * repair floor could not be restored or encoded at all.
141
165
  */
142
166
  export function catalogVersionRefusal(handle: CatalogHandle): string | null {
143
167
  const version = handle.catalog.currentVersion
144
- if (typeof version === 'number' && Number.isSafeInteger(version) && version >= REQUIRED_CATALOG_VERSION) {
145
- return null
168
+ if (!(typeof version === 'number' && Number.isSafeInteger(version) && version >= REQUIRED_CATALOG_VERSION)) {
169
+ return `the resolved released catalog at ${handle.modulePath}`
170
+ + `${handle.packageVersion === null ? '' : ` (package version ${handle.packageVersion})`}`
171
+ + ` declares currentVersion ${JSON.stringify(version)}, but this tool requires at least `
172
+ + `${REQUIRED_CATALOG_VERSION}: it would publish a successor that build cannot read. `
173
+ + 'Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at the catalog of the release the GUI runs.'
174
+ }
175
+ if (handle.floor === null) {
176
+ return `the resolved released catalog at ${handle.modulePath}`
177
+ + `${handle.packageVersion === null ? '' : ` (package version ${handle.packageVersion})`}`
178
+ + ` declares currentVersion ${version}, but it exposes no usable historicalSessionFormatCatalog at the `
179
+ + `repair floor (v${REQUIRED_CATALOG_VERSION}). This tool repairs pre-V3 logs by publishing a v`
180
+ + `${REQUIRED_CATALOG_VERSION} successor: the V3→V4 edge needs historical child evidence that a single-log `
181
+ + 'repair cannot supply, so the floor reader/encoder is what restores and verifies the successor and the '
182
+ + 'host migrates it onward when it opens the session. Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at '
183
+ + `a build that declares currentVersion >= ${REQUIRED_CATALOG_VERSION} and still exports `
184
+ + 'historicalSessionFormatCatalog.'
185
+ }
186
+ const floorVersion = handle.floor.currentVersion
187
+ if (!(typeof floorVersion === 'number' && Number.isSafeInteger(floorVersion)
188
+ && floorVersion >= REQUIRED_CATALOG_VERSION && floorVersion <= version)) {
189
+ return `the resolved released catalog at ${handle.modulePath} exposes a repair-floor catalog that declares `
190
+ + `currentVersion ${JSON.stringify(floorVersion)}, but this tool requires a floor of at least `
191
+ + `${REQUIRED_CATALOG_VERSION} no greater than the declared currentVersion ${version}. `
192
+ + 'Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at the catalog of the release the GUI runs.'
146
193
  }
147
- return `the resolved released catalog at ${handle.modulePath}`
148
- + `${handle.packageVersion === null ? '' : ` (package version ${handle.packageVersion})`}`
149
- + ` declares currentVersion ${JSON.stringify(version)}, but this tool requires at least `
150
- + `${REQUIRED_CATALOG_VERSION}: it would publish a successor that build cannot read. `
151
- + 'Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at the catalog of the release the GUI runs.'
194
+ return null
195
+ }
196
+
197
+ /**
198
+ * The catalog generation this tool repairs TO (see {@link CatalogHandle.floor}).
199
+ *
200
+ * Callers reach this only after {@link catalogVersionRefusal} accepted the handle;
201
+ * the throw is the fail-closed backstop for a caller that skipped that gate (a
202
+ * restore or encode through a missing floor must never degrade to "nothing was
203
+ * published").
204
+ */
205
+ export function repairCatalog(handle: CatalogHandle): ReleasedCatalog {
206
+ if (handle.floor === null) {
207
+ throw new Error(
208
+ `the resolved session-format catalog exposes no usable repair-floor catalog (v${REQUIRED_CATALOG_VERSION}): `
209
+ + 'refusing to restore or encode a successor with it',
210
+ )
211
+ }
212
+ return handle.floor
152
213
  }
153
214
 
154
215
  /** Inputs for {@link resolveCatalog}. */
@@ -195,19 +256,44 @@ async function loadCatalog(
195
256
  const modulePath = resolveCatalogEntry(candidate, env)
196
257
  if (modulePath === null) return null
197
258
  try {
198
- const loaded = (await import(pathToFileURL(modulePath).href)) as { sessionFormatCatalog?: unknown }
259
+ const loaded = (await import(pathToFileURL(modulePath).href)) as {
260
+ sessionFormatCatalog?: unknown
261
+ historicalSessionFormatCatalog?: unknown
262
+ }
199
263
  if (!isReleasedCatalog(loaded.sessionFormatCatalog)) return null
200
264
  return {
201
265
  modulePath,
202
266
  resolvedBy,
203
267
  packageVersion: catalogPackageVersion(modulePath),
204
268
  catalog: loaded.sessionFormatCatalog,
269
+ floor: floorCatalogOf(loaded.sessionFormatCatalog, loaded.historicalSessionFormatCatalog),
205
270
  }
206
271
  } catch {
207
272
  return null
208
273
  }
209
274
  }
210
275
 
276
+ /**
277
+ * The catalog generation this tool repairs TO: the declared catalog itself when
278
+ * the resolved build is at or below the floor, otherwise that build's
279
+ * `historicalSessionFormatCatalog` export.
280
+ *
281
+ * A build newer than the floor publishes nothing but its own current generation,
282
+ * and the released V3→V4 edge needs historical child evidence no single-log
283
+ * repair can produce; the historical export is that build's own V0–V3
284
+ * reader/encoder, so restoring through it and encoding the floor generation is
285
+ * the only successor the tool can both verify and hand to the host.
286
+ *
287
+ * @param catalog the module's declared `sessionFormatCatalog`.
288
+ * @param historical the module's `historicalSessionFormatCatalog`, unknown.
289
+ * @returns the floor catalog, or `null` when a newer build exposes none.
290
+ */
291
+ function floorCatalogOf(catalog: ReleasedCatalog, historical: unknown): ReleasedCatalog | null {
292
+ if (catalog.currentVersion <= REQUIRED_CATALOG_VERSION) return catalog
293
+ if (isReleasedCatalog(historical) && historical.currentVersion === REQUIRED_CATALOG_VERSION) return historical
294
+ return null
295
+ }
296
+
211
297
  /**
212
298
  * The owning catalog package's own `version`, walking up from a module entry to
213
299
  * the nearest `package.json` that names the catalog package; `null` when none is
@@ -526,7 +612,12 @@ function refusalClass(error: unknown): RefusalClass {
526
612
  * The restore runs under the pinned policy
527
613
  * `createRestore(header, { recovery: 'recoverable', validation: 'transformed' })`
528
614
  * and every row is decoded before `finish()`, so the class is the one the
529
- * released chain raises first for this log.
615
+ * released chain raises first for this log on its way to the REPAIR FLOOR
616
+ * ({@link CatalogHandle.floor}) — the pre-V3 chain this tool repairs along and
617
+ * the generation it publishes. With a newer installed build the floor is that
618
+ * build's `historicalSessionFormatCatalog`; the host's forward migration from
619
+ * the floor to current (which needs corpus-level child evidence) is outside a
620
+ * single log's verdict.
530
621
  *
531
622
  * Returned classes: `'ok'` when the restore completes, `'source-kind'`,
532
623
  * `'subagent-descriptor-version'`, `'unknown-event-type'` for the three released
@@ -539,7 +630,7 @@ function refusalClass(error: unknown): RefusalClass {
539
630
  */
540
631
  export function classifyWithCatalog(rows: readonly ParsedRow[], catalog: CatalogHandle): RefusalClass {
541
632
  try {
542
- restoreRows(catalog.catalog, rows, { recovery: 'recoverable', validation: 'transformed' })
633
+ restoreRows(repairCatalog(catalog), rows, { recovery: 'recoverable', validation: 'transformed' })
543
634
  return 'ok'
544
635
  } catch (error) {
545
636
  return refusalClass(error)
@@ -14,7 +14,11 @@
14
14
  * (`ZSTD_c_checksumFlag`), exactly like the released writer.
15
15
  * The successor's records come from the RELEASED encoder
16
16
  * (`encodeCurrentHeader` / `encodeCurrentEvent`), not from the input rows, so
17
- * the written generation is whatever the catalog considers current.
17
+ * the written generation is whatever the catalog's REPAIR FLOOR considers
18
+ * current — v3 when the installed build is newer (see `CatalogHandle.floor`).
19
+ * The host then migrates that v3 generation onward to the installed current
20
+ * format with its own historical child evidence; this module never paints a
21
+ * generation whose migration needs corpus-level facts a single log lacks.
18
22
  *
19
23
  * COMPRESSION — Node's `node:zlib` zstd API, no `zstd` CLI (measured on
20
24
  * v24.18.0: `zstdCompress`/`zstdDecompress` exist and are used here):
@@ -66,7 +70,7 @@ import { link, readFile, rm, writeFile } from 'node:fs/promises'
66
70
  import { dirname, join, resolve } from 'node:path'
67
71
  import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
68
72
  import type { CatalogHandle, ReleasedArtifact, ReleasedCatalog } from './catalog.ts'
69
- import { restoreRows } from './catalog.ts'
73
+ import { repairCatalog, restoreRows } from './catalog.ts'
70
74
  import type { ParsedRow } from './rules.ts'
71
75
 
72
76
  /** Zstandard frame magic (little-endian `0xFD2FB528`). */
@@ -333,8 +337,9 @@ function sizeToNumber(buffer: Buffer, offset: number, width: number): number | n
333
337
  * function, which it receives from `runRepair`'s dynamic import of this module
334
338
  * (a static import would defeat the `node:zlib` zstd runtime probe that must run
335
339
  * first). `publishSuccessor` derives its target from the RESTORED header's
336
- * version, the CLI's probe from the resolved catalog's declared
337
- * `currentVersion` — see the probe's comment in `repair-session-logs.ts`.
340
+ * version (the repair floor), and the CLI's probe resolves the floor's
341
+ * `currentVersion` through the catalog handle — the two agree for every released
342
+ * catalog, whose restore settles at the floor it was chosen for.
338
343
  */
339
344
  export function successorFilename(version: number): string {
340
345
  return version === 0 ? 'session.jsonl.zstd' : `session.v${version}.jsonl.zstd`
@@ -382,6 +387,13 @@ export async function publishSuccessor(
382
387
  sourceDigest: string,
383
388
  ): Promise<PublishedSuccessor> {
384
389
  const handle = assertCatalog(catalog)
390
+ // The generation this tool repairs TO: the declared catalog itself on a build
391
+ // at the floor, the build's own historical V0–V3 catalog on a newer one. A
392
+ // restore through the newer build's current catalog would demand historical
393
+ // child evidence this single-log tool cannot produce; the floor restore has no
394
+ // such requirement, and the host migrates the published floor generation
395
+ // onward with its own child facts.
396
+ const floor = repairCatalog(handle)
385
397
  const originalBytes = await readFile(logPath)
386
398
  const originalDigest = sha256(originalBytes)
387
399
  if (originalDigest !== sourceDigest) {
@@ -394,7 +406,7 @@ export async function publishSuccessor(
394
406
  // Defensive copy: the caller's rows alias `form` / `summary` / `sections`
395
407
  // objects, and the released restore may normalize the artifact in place.
396
408
  const rows = structuredClone(normalizedRows)
397
- const artifact = restoreRows(handle.catalog, rows, { recovery: 'strict', validation: 'transformed' })
409
+ const artifact = restoreRows(floor, rows, { recovery: 'strict', validation: 'transformed' })
398
410
  const generation = artifact.header.version
399
411
  const targetPath = join(dirname(logPath), successorFilename(generation))
400
412
  if (resolve(targetPath) === resolve(logPath)) {
@@ -405,10 +417,10 @@ export async function publishSuccessor(
405
417
  // (lazily): materializing them all first would hold a second copy of the session
406
418
  // alongside the restored artifact it was built from.
407
419
  const encodedEvents = function* (): Generator<Record<string, unknown>> {
408
- for (const event of artifact.events) yield handle.catalog.encodeCurrentEvent(event)
420
+ for (const event of artifact.events) yield floor.encodeCurrentEvent(event)
409
421
  }
410
422
  const bytes = encodeZstdFrames(
411
- handle.catalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount),
423
+ floor.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount),
412
424
  encodedEvents(),
413
425
  )
414
426
 
@@ -422,8 +434,10 @@ export async function publishSuccessor(
422
434
  // names instead of ignoring them.
423
435
  await writeFile(temporaryPath, bytes, { flag: 'wx', mode: 0o600 })
424
436
  // Read the staged generation back with the host's current-generation policy
425
- // BEFORE it becomes visible under its canonical name.
426
- verifyGeneration(handle.catalog, await readFile(temporaryPath), generation)
437
+ // BEFORE it becomes visible under its canonical name. The floor catalog is
438
+ // the one that must read it: it is the build whose encoder wrote it and the
439
+ // same historical reader the host uses for a pre-current generation.
440
+ verifyGeneration(floor, await readFile(temporaryPath), generation)
427
441
  outcome = await publishExclusive(temporaryPath, targetPath, sha256(bytes))
428
442
  } catch (error) {
429
443
  try {