dsh-llm-fallbacks 0.6.5 → 0.6.7

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,13 +1,14 @@
1
1
  /**
2
2
  * Dispatch-seam role resolution + per-child record (plan
3
- * role-based-subagent-adoption Task 1).
3
+ * role-based-subagent-adoption Task 1; rc.1 `startActivation` migration, plan
4
+ * dsh-021-subagent-seam-repair Task 2).
4
5
  *
5
6
  * The seam is the documented cordis service-read waterfall
6
7
  * (`Events['internal/get']`, `@deepseek-ai/cordis`
7
8
  * `lib/types/events.d.ts`): a listener wraps the `subagents` service VALUE on
8
- * read and returns a prototype-delegating wrapper whose `start` /
9
- * `startContinuable` resolve the dispatch role from the incoming request
10
- * BEFORE delegating. The real service object is never mutated; readers of the
9
+ * read and returns a prototype-delegating wrapper whose `startActivation`
10
+ * resolves the dispatch role from the incoming activation spec BEFORE
11
+ * delegating. The real service object is never mutated; readers of the
11
12
  * `subagents` service transparently receive the wrapper.
12
13
  *
13
14
  * Role source (plan Global Constraints): the child request's prompt carries
@@ -29,34 +30,36 @@
29
30
  * ambiguity rule stands unchanged: the DISTINCT DECLARED ids must collapse to
30
31
  * exactly one, else the dispatch is a no-op — never a first-occurrence pick.
31
32
  *
32
- * Per-child record: the wrapped start's RESULT carries the child session id —
33
- * `start` resolves to a `SubagentRun` (`id` IS the published child session id
34
- * for a local run) and `startContinuable` to a `ContinuableStart` (`childId`).
35
- * The record `{ role, at, firstNoticePending }` is keyed by that id (Task 3
36
- * reads it to emit the one role notice row). A result carrying only a
37
- * `jobId` — a registry job id, NOT a session id — is never keyed: the child is
38
- * correlated instead through the parent-owned `subagent/catalog` session event
39
- * `{ childId, label }` joined on the delegating parent + request label.
33
+ * Per-child record + the timing rule (rc.1 `startActivation`): the record
34
+ * `{ role, at, firstNoticePending }` is keyed by the child id (Task 3 reads it
35
+ * to emit the one role notice row). The child is created INSIDE the native
36
+ * call — `manager.startLocal` mints/uses `spec.childId` and only then resolves
37
+ * the activation — so `agent/created` fires BEFORE any `.then` on this side
38
+ * could key a record off the resolved `SubagentActivation.childId`. The
39
+ * wrapper therefore reserves the child id itself for a LOCAL backend (the
40
+ * provider exposes `prepareContinuable`; a reserved id on any other backend is
41
+ * rejected with `UNSUPPORTED_CAPABILITY`) and writes the record SYNCHRONOUSLY
42
+ * before delegating, deleting it when the native call fails. An EXTERNAL
43
+ * backend allocates its own child id, receives the caller's OWN spec object
44
+ * untouched, and has its record keyed off the resolved activation's `childId`.
40
45
  *
41
- * Persona delivery (plan Task 2): the SAME single resolution point merges the
42
- * declared role's `roles.list[].persona` (trimmed, non-empty) into the
43
- * request's NATIVE `persona` slot — the slot the runtime composes as the
44
- * scoped `deployment:persona-prefix` system-prompt section on the child. The
45
- * source is the RESOLVED ROLE only, so delivery is chain-independent: a
46
- * `chain: []` role gets its persona exactly like a chained one (no routing
47
- * state is read). Both native start surfaces are covered, with the gate each
48
- * one's fail-loud contract needs (measured on the installed
49
- * `@deepseek-ai/dsh-subagent`): the one-shot `start` REJECTS a
50
- * request carrying `persona` for a provider whose
51
- * `getProvider(name).capabilities.persona` is not `true` (`lib/index.js:3284,3340-3363`,
52
- * `assertCapabilities`), so the merge pre-checks that flag; the continuable
53
- * surface is composed by the continuation manager, which applies
54
- * `request.persona` unconditionally (`lib/index.js:1691`) and is gated by
55
- * the provider's `prepareContinuable` presence instead (`lib/index.js:3316-3319`).
56
- * A gate miss skips the persona with ONE contained debug log — never a failed
57
- * start. An explicit caller `persona` WINS (the slot is filled only when it is
58
- * absent), and every skip path returns the caller's OWN request object, so the
59
- * native call stays byte-identical.
46
+ * A result carrying no child id — a `jobId` is a registry id, NOT a session id
47
+ * — is never keyed: the child is correlated instead through the parent-owned
48
+ * `subagent/catalog` session event `{ childId, label }` joined on the
49
+ * delegating parent + the activation `label` (the same string the cataloged
50
+ * child membership carries).
51
+ *
52
+ * Persona delivery is NO LONGER this module's job (plan Task 1): rc.1 removed
53
+ * `SubagentStartRequest.persona`, so the resolved role's persona is registered
54
+ * as a scoped `deployment:persona-prefix` section on the CHILD's own context
55
+ * by `./role-persona.ts`, over the SAME ROOT-shared record map this module
56
+ * writes. That listener owns the `personaNotApplied` verdict the notice row
57
+ * reads — EXCEPT when a reservation failure means it can never see the child
58
+ * (the runtime mints its own child id and `agent/created` has already run), in
59
+ * which case this module resolves the same question against the live roles
60
+ * source and carries the verdict onto the record keyed off the RESOLVED id,
61
+ * before the child's first pre-step. This module writes no request field,
62
+ * reads no provider persona capability, and injects no agent preset.
60
63
  *
61
64
  * In-session notice row (plan Task 3): the SAME install point registers the
62
65
  * `agent/pre-step` emitter (`./role-notice.ts`) over the record map it owns, so
@@ -103,14 +106,14 @@ export interface SubagentSeamRecord {
103
106
  * The DECLARED RAW role id resolved at the dispatch seam.
104
107
  *
105
108
  * Authoritative-role precedence (CF-10, documented rather than resolved by
106
- * re-branding): a `startContinuable` resume re-keys the child and may resolve
107
- * a DIFFERENT declared role, and this field is then rewritten while the
109
+ * re-branding): a resume that reuses a reserved `childId` may resolve a
110
+ * DIFFERENT declared role, and this field is then rewritten while the
108
111
  * durable notice row the child's session already carries keeps the FIRST role.
109
112
  * The projection deliberately reads the notice row, so the durable role is the
110
113
  * child's announced identity and this record is only the LIVE dispatch hint
111
- * (what the next start / the resume fallback would use). The two can therefore
112
- * disagree after a re-dispatch until the child is disposed; no consumer may
113
- * treat the record as the durable role.
114
+ * (what the next activation / the resume fallback would use). The two can
115
+ * therefore disagree after a re-dispatch until the child is disposed; no
116
+ * consumer may treat the record as the durable role.
114
117
  */
115
118
  role: string;
116
119
  /** When the record was written (epoch ms). */
@@ -126,20 +129,24 @@ export interface SubagentSeamRecord {
126
129
  firstNoticePending: boolean;
127
130
  /**
128
131
  * `true` when the role DECLARES a persona and that persona was NOT delivered
129
- * (Task 2's capability gate declined it, or no provider was registered to
130
- * carry it). Absent when there was nothing to report: the persona was
131
- * delivered, the caller set one (explicit intent wins), the role declares
132
- * none, or no persona source is wired. Task 3 appends
133
- * ` (persona not applied)` on `true` only.
132
+ * onto the child. Written by the `agent/created` role-persona listener
133
+ * (`./role-persona.ts`) over this same map — except when a reservation
134
+ * failure means that listener can never see the child (the runtime mints its
135
+ * own child id), where the seam itself carries the verdict onto the record
136
+ * keyed off the RESOLVED id. The seam never delivers or reads a persona
137
+ * other than for that one question. Absent when there was nothing to report:
138
+ * the section was registered, a same-name section was already on the child's
139
+ * scope (whoever registered it), the listener never saw the child (an
140
+ * EXTERNAL backend creates no local agent), the role declares none, or no
141
+ * persona source is wired. Task 3 appends ` (persona not applied)` on `true`
142
+ * only.
134
143
  *
135
- * STICKY (Task 3 review Minor 4): a record rewrite (a `startContinuable`
136
- * resume re-keys the child) can only ADD this verdict, never clear it. A
137
- * resume recomputes the merge outcome on its own surface — and that surface
138
- * legitimately returns `false` when it delivers the CALLER's persona or when
139
- * its own merge degrades — but neither retroactively installs the role's
140
- * DECLARED persona on the dispatch that already reported it missing. The
141
- * child's one notice row is a claim about that dispatch, so the first `true`
142
- * survives; a later `false` is simply not recorded.
144
+ * STICKY (Task 3 review Minor 4): a record rewrite — a resume reusing the
145
+ * reserved `childId`, or the resolved activation re-keying the same child
146
+ * after `agent/created` already ran — can only ADD this verdict, never clear
147
+ * it. A rewrite carries no verdict of its own, and the child's one notice row
148
+ * is a claim about the dispatch that already reported it missing, so the first
149
+ * `true` survives.
143
150
  */
144
151
  personaNotApplied?: boolean;
145
152
  }
@@ -149,29 +156,29 @@ export interface SubagentPromptBlockView {
149
156
  readonly text?: string;
150
157
  }
151
158
  /**
152
- * Structural view of the one-shot start request (consumed fields only): the
153
- * `prompt` is the role-extraction source, `label` + `parent` are the
154
- * catalog-correlation join key, and `persona` is the NATIVE per-child persona
155
- * slot the role persona is merged into (plan Global Constraints: an explicit
156
- * caller value wins). The wrapper forwards the request object itself, so every
157
- * other field reaches the service unchanged.
159
+ * Structural view of the activation request (consumed fields only): `prompt` is
160
+ * the role-extraction source and `parent` is the catalog-correlation join half.
161
+ * The wrapper forwards the request OBJECT itself, so every other field (cwd,
162
+ * agentOptions, outputSchema, maxDepth, the signal) reaches the service
163
+ * unchanged.
158
164
  */
159
165
  export interface SubagentStartRequestView {
160
166
  readonly prompt?: readonly SubagentPromptBlockView[];
161
- readonly label?: string;
162
167
  readonly parent?: {
163
168
  readonly session?: {
164
169
  readonly id?: string;
165
170
  };
166
171
  };
167
- readonly persona?: string;
168
172
  }
169
173
  /**
170
- * Structural view of the continuable start spec (consumed fields only):
171
- * `childId` is the caller-reserved identity a resume already carries, used as
172
- * the record fallback when the resume request has no Assignment header.
174
+ * Structural view of the activation spec (rc.1 `SubagentActivationSpec`,
175
+ * consumed fields only): `childId` is the caller-reserved identity a resume
176
+ * already carries — also the record fallback when the request carries no
177
+ * Assignment header — and `label` is the catalog join's other half (the
178
+ * cataloged child membership carries this exact string). `signal`, `delivery`
179
+ * and every future field travel with the caller's spec object.
173
180
  */
174
- export interface ContinuableStartSpecView {
181
+ export interface SubagentActivationSpecView {
175
182
  readonly provider: string;
176
183
  readonly label?: string;
177
184
  readonly childId?: string;
@@ -180,14 +187,16 @@ export interface ContinuableStartSpecView {
180
187
  /** Options for {@link installSubagentSeam}. */
181
188
  export interface SubagentSeamOptions {
182
189
  /**
183
- * Live declared-role id map (trimmed id → DECLARED RAW id), read per start
184
- * so a settings change is observed without a re-install.
190
+ * Live declared-role id map (trimmed id → DECLARED RAW id), read per
191
+ * activation so a settings change is observed without a re-install.
185
192
  */
186
193
  roleIds: () => ReadonlyMap<string, string>;
187
194
  /**
188
- * Live declared roles (`roles.list`) — the persona source, read per start so
189
- * a settings/persona edit is observed without a re-install. Absent ⇒ the
190
- * seam only records roles (no persona delivery).
195
+ * Live declared roles (`roles.list`) — the SAME source `./role-persona.ts`
196
+ * reads. Consulted ONLY when a reservation failure means that listener can
197
+ * never see the child: the seam is then the only place left that can carry
198
+ * the persona verdict to the child's notice row. Absent (no persona source is
199
+ * wired) ⇒ no verdict, exactly like a listener that never saw the child.
191
200
  */
192
201
  roles?: () => readonly FallbacksRole[];
193
202
  /** Contained debug sink (at most one line per contained degrade). */
@@ -196,10 +205,12 @@ export interface SubagentSeamOptions {
196
205
  /** The installed seam (Task 3 surface + explicit teardown). */
197
206
  export interface SubagentSeam {
198
207
  /**
199
- * Per-child records keyed by the child SESSION id (the wrapped start's
200
- * result id). ROOT-shared (CF-5): every fiber applied over the same root reads
208
+ * Per-child records keyed by the child id — the reserved id this wrapper
209
+ * wrote before a local activation, or the `childId` a resolved activation
210
+ * reported. ROOT-shared (CF-5): every fiber applied over the same root reads
201
211
  * the same map. Mutable by design: Task 3 clears `firstNoticePending` after
202
- * emitting the notice.
212
+ * emitting the notice, and the role-persona listener writes
213
+ * `personaNotApplied`.
203
214
  */
204
215
  readonly records: Map<string, SubagentSeamRecord>;
205
216
  /**
@@ -253,7 +264,9 @@ export declare const PENDING_CORRELATION_LIMIT = 64;
253
264
  */
254
265
  export declare function resolveDeclaredRoleFromAssignment(prompt: string, roleIds: ReadonlyMap<string, string>): string | undefined;
255
266
  /**
256
- * PURE, chain-independent persona lookup (plan Task 2 / Global Constraints):
267
+ * PURE, chain-independent persona lookup for the `agent/created` role-persona
268
+ * listener (`./role-persona.ts`; rc.1 delivers the persona as a scoped prompt
269
+ * section on the child, never as a request field):
257
270
  * the DECLARED `persona` of `roleId`, trimmed, and only when it is still
258
271
  * non-empty after the trim — a blank persona is NO persona. `roleId` is the
259
272
  * DECLARED RAW id the seam already resolved, so this is an exact id match, not
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.6.5",
8
+ "version": "0.6.7",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -64,30 +64,31 @@
64
64
  "use-sync-external-store": "^1.6.0"
65
65
  },
66
66
  "peerDependencies": {
67
- "@deepseek-ai/cordis": "^4.0.2",
68
- "@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
69
- "@deepseek-ai/dsh-api-gateway": "^0.2.0-rc.2",
70
- "@deepseek-ai/dsh-api-remotes": "^0.2.0-rc.2",
71
- "@deepseek-ai/dsh-api-session-controller": "^0.2.0-rc.2",
72
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
73
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
74
- "@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
75
- "@deepseek-ai/dsh-client-ui-chat": "^0.2.0-rc.2",
76
- "@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.2",
77
- "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.2",
78
- "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
79
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
80
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
81
- "@deepseek-ai/dsh-commands": "^0.2.0-rc.2",
82
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
83
- "@deepseek-ai/dsh-llm-retry": "^0.2.0-rc.2",
84
- "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
85
- "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.2",
86
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
87
- "@deepseek-ai/dsh-tool-subagent": "^0.2.0-rc.2",
88
- "@deepseek-ai/dsh-typert-protocol": "^0.2.0-rc.2",
89
- "@deepseek-ai/dsh-typert-registry": "^0.2.0-rc.2",
90
- "@deepseek-ai/schemastery": "^3.18.2",
67
+ "@deepseek-ai/cordis": "^4.0.5",
68
+ "@deepseek-ai/dsh-agent": "^0.2.1-rc.2",
69
+ "@deepseek-ai/dsh-api-gateway": "^0.2.1-rc.2",
70
+ "@deepseek-ai/dsh-api-remotes": "^0.2.1-rc.2",
71
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.1-rc.2",
72
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-rc.2",
73
+ "@deepseek-ai/dsh-client-locale": "^0.2.1-rc.2",
74
+ "@deepseek-ai/dsh-client-store": "^0.2.1-rc.2",
75
+ "@deepseek-ai/dsh-client-ui-chat": "^0.2.1-rc.2",
76
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.2.1-rc.2",
77
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.1-rc.2",
78
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-rc.2",
79
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-rc.2",
80
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-rc.2",
81
+ "@deepseek-ai/dsh-commands": "^0.2.1-rc.2",
82
+ "@deepseek-ai/dsh-llm": "^0.2.1-rc.2",
83
+ "@deepseek-ai/dsh-llm-retry": "^0.2.1-rc.2",
84
+ "@deepseek-ai/dsh-session": "^0.2.1-rc.2",
85
+ "@deepseek-ai/dsh-session-projection": "^0.2.1-rc.2",
86
+ "@deepseek-ai/dsh-settings": "^0.2.1-rc.2",
87
+ "@deepseek-ai/dsh-system-prompt": "^0.2.1-rc.2",
88
+ "@deepseek-ai/dsh-tool-subagent": "^0.2.1-rc.2",
89
+ "@deepseek-ai/dsh-typert-protocol": "^0.2.1-rc.2",
90
+ "@deepseek-ai/dsh-typert-registry": "^0.2.1-rc.2",
91
+ "@deepseek-ai/schemastery": "^3.18.5",
91
92
  "react": "^18.2.0"
92
93
  },
93
94
  "devDependencies": {
@@ -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,