dsh-llm-fallbacks 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,547 @@
1
+ /**
2
+ * catalog.ts — resolve the released session-format catalog (the refusal ORACLE)
3
+ * and classify one parsed log against it.
4
+ *
5
+ * Why a resolved host artifact and not a constant table: the migration truth of
6
+ * an old session log is the migration chain of the RELEASE that must read it.
7
+ * This repo may not duplicate those constants (Task 1's rules encode only the
8
+ * classes they can prove), and it may not declare `@deepseek-ai/*` as a
9
+ * dependency (AGENTS.md: peers only, and this is a host artifact, not a plugin
10
+ * peer). The catalog is therefore located at run time and imported
11
+ * DYNAMICALLY — no static import, so the repo builds and its tests run without
12
+ * a DSH install.
13
+ *
14
+ * Resolution order (first hit wins; every miss yields `null` — the caller fails
15
+ * closed and writes nothing):
16
+ * 1. explicit `catalogPath` (the CLI's `--catalog <path>`);
17
+ * 2. `$DSH_SESSION_FORMAT_CATALOG`;
18
+ * 3. the catalog the `dsh` binary on `PATH` itself resolves (its own
19
+ * `createRequire` resolution first, then the nearest ancestor
20
+ * `node_modules` that carries it);
21
+ * 4. `~/.npm/_npx/ *\/node_modules` (the npx cache `dsh` runs from).
22
+ * An explicitly configured catalog that does not resolve is NOT silently
23
+ * replaced by a different release: `resolveCatalog` returns `null` instead of
24
+ * falling through to step 3/4.
25
+ *
26
+ * TRUST BOUNDARY: every step *imports* the resolved module — a catalog is
27
+ * EXECUTED code, not a data file, and the same privilege the user already grants
28
+ * by running this tool as `tsx`. A directory candidate is accepted only when its
29
+ * `package.json` names the catalog package; a **file** candidate is accepted when
30
+ * no owning `package.json` is found (a bare module, e.g. a test fixture) or when
31
+ * the nearest owning one names the catalog package, and is rejected when it
32
+ * belongs to a different package. That check is a guard rail, not a sandbox:
33
+ * `--catalog` and the environment variable still execute what they name.
34
+ *
35
+ * RELEASE PIN: a resolved catalog is only trusted when its own
36
+ * `currentVersion` is at least {@link REQUIRED_CATALOG_VERSION} — the caller
37
+ * asserts it with {@link catalogVersionRefusal} — because this tool encodes a
38
+ * successor with that module and then reads it back with the SAME module, so
39
+ * "it accepts its own output" is not evidence about the release the GUI runs.
40
+ * The handle also carries the catalog's own package version so a report can name
41
+ * the build it trusted.
42
+ *
43
+ * A candidate path may be the catalog package directory, a directory that
44
+ * contains it (`node_modules/...`), or the module entry file itself.
45
+ *
46
+ * Row contract (`ParsedRow`): a row is one physical JSONL record. Event records
47
+ * are exactly `{ type, seq, time, data }`; the FIRST record of a log is the
48
+ * session header, which carries `version` / `id` / `createdAt` / ... at the top
49
+ * level and has no `seq`, so a reader synthesizes the `seq` member `ParsedRow`
50
+ * requires and {@link rowRecord} drops it again. A caller that prefers to keep
51
+ * the header payload under `data` (a wrapped row) is also accepted.
52
+ */
53
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
54
+ import { createRequire } from 'node:module'
55
+ import { homedir } from 'node:os'
56
+ import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
57
+ import { pathToFileURL } from 'node:url'
58
+ import type { ParsedRow, RefusalClass } from './rules.ts'
59
+
60
+ /** Released catalog package this tool resolves (never a `package.json` peer). */
61
+ const CATALOG_PACKAGE = '@deepseek-ai/dsh-session-format-catalog'
62
+
63
+ /** Short package name, used when a caller points at a directory holding it. */
64
+ const CATALOG_PACKAGE_DIR = 'dsh-session-format-catalog'
65
+
66
+ /** Environment override honoured as resolution step 2. */
67
+ export const CATALOG_ENV_VAR = 'DSH_SESSION_FORMAT_CATALOG'
68
+
69
+ /**
70
+ * The oldest released catalog this tool accepts as its oracle: the format
71
+ * generation it repairs TO (`CURRENT_VERSION_FLOOR`, the pre-V3 scope). A catalog
72
+ * below it cannot read a successor this tool would write, so `--apply` must
73
+ * refuse rather than verify its own output against a build the GUI does not run.
74
+ * One constant for both the selection scope and the oracle floor: they are the
75
+ * same number and must not drift.
76
+ */
77
+ export const REQUIRED_CATALOG_VERSION = 3
78
+
79
+ /** Recovery policy of one catalog restore (released `SessionFormatRecovery`). */
80
+ export type RestoreRecovery = 'strict' | 'recoverable'
81
+
82
+ /** Validation policy of one catalog restore (released `SessionFormatRestoreOptions`). */
83
+ export type RestoreValidation = 'transformed' | 'current'
84
+
85
+ /** One restore request, mirroring the released `SessionFormatRestoreOptions`. */
86
+ export interface RestoreOptions {
87
+ recovery: RestoreRecovery
88
+ validation: RestoreValidation
89
+ }
90
+
91
+ /** The released current artifact a restore settles on (structural view). */
92
+ export interface ReleasedArtifact {
93
+ readonly header: { readonly version: number; readonly [member: string]: unknown }
94
+ readonly inheritedEventCount: number
95
+ readonly events: readonly unknown[]
96
+ }
97
+
98
+ /** The released single-pass restore (structural view). */
99
+ export interface ReleasedRestore {
100
+ readonly header: ReleasedArtifact['header']
101
+ decodeRow(rowValue: unknown): void
102
+ finish(): ReleasedArtifact
103
+ }
104
+
105
+ /**
106
+ * The catalog members this tool uses, as a STRUCTURAL view of the released
107
+ * `SessionFormatCatalog`. Only these four members are relied upon; a module
108
+ * that does not expose them is not accepted as an oracle.
109
+ */
110
+ export interface ReleasedCatalog {
111
+ readonly currentVersion: number
112
+ createRestore(headerValue: unknown, options: RestoreOptions): ReleasedRestore
113
+ encodeCurrentHeader(header: unknown, inheritedEventCount: number): Record<string, unknown>
114
+ encodeCurrentEvent(event: unknown): Record<string, unknown>
115
+ }
116
+
117
+ /** Which resolution step produced a handle (diagnostics for the CLI report). */
118
+ export type CatalogResolvedBy = 'option' | 'env' | 'dsh-path' | 'npx-store'
119
+
120
+ /** A resolved, loadable released catalog. Opaque to callers but for diagnostics. */
121
+ export interface CatalogHandle {
122
+ /** Absolute path of the resolved catalog module entry file. */
123
+ readonly modulePath: string
124
+ /** Resolution step that produced this handle. */
125
+ readonly resolvedBy: CatalogResolvedBy
126
+ /**
127
+ * The catalog package's OWN `version` from its `package.json`, or `null` when
128
+ * no owning manifest could be read. Diagnostic: the report names the build it
129
+ * trusted, not just the path.
130
+ */
131
+ readonly packageVersion: string | null
132
+ /** The released catalog module's `sessionFormatCatalog` export. */
133
+ readonly catalog: ReleasedCatalog
134
+ }
135
+
136
+ /**
137
+ * Why a resolved catalog may not be used as this run's oracle, or `null` when it
138
+ * may. The caller turns a non-`null` reason into a fatal (exit 2) refusal: a
139
+ * 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.
141
+ */
142
+ export function catalogVersionRefusal(handle: CatalogHandle): string | null {
143
+ const version = handle.catalog.currentVersion
144
+ if (typeof version === 'number' && Number.isSafeInteger(version) && version >= REQUIRED_CATALOG_VERSION) {
145
+ return null
146
+ }
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.'
152
+ }
153
+
154
+ /** Inputs for {@link resolveCatalog}. */
155
+ export interface ResolveCatalogOptions {
156
+ /** Explicit catalog path (CLI `--catalog`); highest precedence when given. */
157
+ catalogPath?: string
158
+ /** Environment to read (`DSH_SESSION_FORMAT_CATALOG`, `PATH`, home); defaults to `process.env`. */
159
+ env?: NodeJS.ProcessEnv
160
+ }
161
+
162
+ /* ------------------------------------------------------------------ */
163
+ /* resolution */
164
+ /* ------------------------------------------------------------------ */
165
+
166
+ /**
167
+ * Resolve the released catalog, or `null` when no usable catalog exists.
168
+ *
169
+ * Never throws for a missing/broken oracle: the caller fails closed. A catalog
170
+ * that resolves but does not load (or does not expose the released members) is
171
+ * also reported as `null`.
172
+ *
173
+ * @param opts explicit path and/or environment to resolve against.
174
+ * @returns a handle wrapping the released catalog, or `null`.
175
+ */
176
+ export async function resolveCatalog(opts: ResolveCatalogOptions = {}): Promise<CatalogHandle | null> {
177
+ const env = opts.env ?? process.env
178
+ const explicit = opts.catalogPath
179
+ if (explicit !== undefined && explicit.length > 0) return loadCatalog(explicit, 'option', env)
180
+ const fromEnv = env[CATALOG_ENV_VAR]
181
+ if (fromEnv !== undefined && fromEnv.length > 0) return loadCatalog(fromEnv, 'env', env)
182
+ return (
183
+ (await loadCatalog(resolveFromDshBinary(env), 'dsh-path', env))
184
+ ?? (await loadCatalog(resolveFromNpxStore(env), 'npx-store', env))
185
+ )
186
+ }
187
+
188
+ /** Import the fixed member of a resolved candidate path, or reject the candidate. */
189
+ async function loadCatalog(
190
+ candidate: string | null,
191
+ resolvedBy: CatalogResolvedBy,
192
+ env: NodeJS.ProcessEnv,
193
+ ): Promise<CatalogHandle | null> {
194
+ if (candidate === null) return null
195
+ const modulePath = resolveCatalogEntry(candidate, env)
196
+ if (modulePath === null) return null
197
+ try {
198
+ const loaded = (await import(pathToFileURL(modulePath).href)) as { sessionFormatCatalog?: unknown }
199
+ if (!isReleasedCatalog(loaded.sessionFormatCatalog)) return null
200
+ return {
201
+ modulePath,
202
+ resolvedBy,
203
+ packageVersion: catalogPackageVersion(modulePath),
204
+ catalog: loaded.sessionFormatCatalog,
205
+ }
206
+ } catch {
207
+ return null
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The owning catalog package's own `version`, walking up from a module entry to
213
+ * the nearest `package.json` that names the catalog package; `null` when none is
214
+ * found or it carries no string `version`.
215
+ */
216
+ function catalogPackageVersion(modulePath: string): string | null {
217
+ const owner = owningPackageDirectory(modulePath)
218
+ if (owner === null) return null
219
+ const manifest = readManifest(owner)
220
+ const version = manifest === null ? undefined : manifest['version']
221
+ return typeof version === 'string' && version.length > 0 ? version : null
222
+ }
223
+
224
+ /**
225
+ * The nearest ancestor directory of `modulePath` whose `package.json` names the
226
+ * catalog package, or `null`. Bounded: a module entry sits at most a few levels
227
+ * below its package root.
228
+ */
229
+ function owningPackageDirectory(modulePath: string): string | null {
230
+ let dir = dirname(modulePath)
231
+ for (let depth = 0; depth < 8; depth += 1) {
232
+ if (isCatalogPackage(dir)) return dir
233
+ const parent = dirname(dir)
234
+ if (parent === dir) break
235
+ dir = parent
236
+ }
237
+ return null
238
+ }
239
+
240
+ /** Whether a candidate path exposes the released catalog members this tool relies on. */
241
+ function isReleasedCatalog(value: unknown): value is ReleasedCatalog {
242
+ if (typeof value !== 'object' || value === null) return false
243
+ const candidate = value as Partial<ReleasedCatalog>
244
+ return (
245
+ typeof candidate.currentVersion === 'number'
246
+ && typeof candidate.createRestore === 'function'
247
+ && typeof candidate.encodeCurrentHeader === 'function'
248
+ && typeof candidate.encodeCurrentEvent === 'function'
249
+ )
250
+ }
251
+
252
+ /** Expand `~` and make a caller-supplied path absolute. */
253
+ function resolveCandidate(candidate: string, env: NodeJS.ProcessEnv): string {
254
+ const home = homeDirectory(env)
255
+ if (candidate === '~') return home
256
+ if (candidate.startsWith('~/') || candidate.startsWith('~\\')) return join(home, candidate.slice(2))
257
+ return isAbsolute(candidate) ? candidate : resolve(candidate)
258
+ }
259
+
260
+ /** The home directory the given environment names, falling back to the OS. */
261
+ function homeDirectory(env: NodeJS.ProcessEnv): string {
262
+ return env['HOME'] ?? env['USERPROFILE'] ?? homedir()
263
+ }
264
+
265
+ /**
266
+ * Normalize a candidate (package dir, a dir containing it, or a module file)
267
+ * to the catalog module entry file that can be imported.
268
+ *
269
+ * A FILE candidate is accepted when no owning `package.json` names a different
270
+ * package: a bare module (a fixture, a hand-written shim) has no owner and is
271
+ * taken at face value, while a module that provably belongs to another package is
272
+ * refused — the documented trust boundary of an executed, not parsed, input.
273
+ */
274
+ function resolveCatalogEntry(candidate: string, env: NodeJS.ProcessEnv): string | null {
275
+ const path = resolveCandidate(candidate, env)
276
+ if (!existsSync(path)) return null
277
+ if (statSync(path).isFile()) return fileCandidate(path)
278
+ if (isCatalogPackage(path)) return packageEntry(path)
279
+ for (const nested of [
280
+ join(path, CATALOG_PACKAGE),
281
+ join(path, 'node_modules', CATALOG_PACKAGE),
282
+ join(path, CATALOG_PACKAGE_DIR),
283
+ ]) {
284
+ if (isCatalogPackage(nested)) return packageEntry(nested)
285
+ }
286
+ return null
287
+ }
288
+
289
+ /** One explicit FILE candidate: refuse it when it belongs to another package. */
290
+ function fileCandidate(path: string): string | null {
291
+ let dir = dirname(path)
292
+ for (let depth = 0; depth < 8; depth += 1) {
293
+ const manifest = readManifest(dir)
294
+ if (manifest !== null) {
295
+ // The nearest owning manifest decides: the catalog package, or nothing.
296
+ return manifest['name'] === CATALOG_PACKAGE ? path : null
297
+ }
298
+ const parent = dirname(dir)
299
+ if (parent === dir) break
300
+ dir = parent
301
+ }
302
+ return path
303
+ }
304
+
305
+ /** Whether a directory's own `package.json` names the released catalog package. */
306
+ function isCatalogPackage(dir: string): boolean {
307
+ const manifest = readManifest(dir)
308
+ return manifest !== null && manifest['name'] === CATALOG_PACKAGE
309
+ }
310
+
311
+ /** Read one package directory's `package.json` as a record, or `null`. */
312
+ function readManifest(dir: string): Record<string, unknown> | null {
313
+ const manifestPath = join(dir, 'package.json')
314
+ if (!existsSync(manifestPath)) return null
315
+ try {
316
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
317
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null
318
+ } catch {
319
+ return null
320
+ }
321
+ }
322
+
323
+ /** Resolve one package directory's ESM entry file (`exports['.']` then `main`). */
324
+ function packageEntry(packageDir: string): string | null {
325
+ const manifest = readManifest(packageDir)
326
+ if (manifest === null) return null
327
+ const exports = manifest['exports']
328
+ const root = typeof exports === 'object' && exports !== null
329
+ ? (exports as Record<string, unknown>)['.']
330
+ : undefined
331
+ const entry = typeof root === 'string'
332
+ ? root
333
+ : typeof root === 'object' && root !== null
334
+ ? (root as Record<string, unknown>)['default']
335
+ : manifest['main']
336
+ if (typeof entry !== 'string') return null
337
+ const entryPath = join(packageDir, entry)
338
+ return existsSync(entryPath) ? entryPath : null
339
+ }
340
+
341
+ /**
342
+ * Step 3: the catalog the `dsh` binary on `PATH` itself resolves.
343
+ *
344
+ * The binary's OWN `createRequire` resolution comes first, because that is the
345
+ * resolution the running dsh package performs (it follows the package's real
346
+ * dependency layout, including pnpm's global layout where the binary sits outside
347
+ * the tree that owns its dependencies) — a nearer ancestor `node_modules` that
348
+ * merely *contains* a catalog is not necessarily the one the user's dsh runs, so
349
+ * it is only a fallback.
350
+ */
351
+ function resolveFromDshBinary(env: NodeJS.ProcessEnv): string | null {
352
+ const found = findOnPath('dsh', env)
353
+ if (found === null) return null
354
+ const binary = realpath(found)
355
+ try {
356
+ return createRequire(binary).resolve(CATALOG_PACKAGE)
357
+ } catch {
358
+ // Fall through to the ancestor walk below.
359
+ }
360
+ let dir = dirname(binary)
361
+ for (;;) {
362
+ const nested = join(dir, 'node_modules', CATALOG_PACKAGE)
363
+ if (existsSync(nested)) return nested
364
+ const parent = dirname(dir)
365
+ if (parent === dir) break
366
+ dir = parent
367
+ }
368
+ return null
369
+ }
370
+
371
+ /** Absolute path of one executable name on the environment's `PATH`, or `null`. */
372
+ function findOnPath(name: string, env: NodeJS.ProcessEnv): string | null {
373
+ for (const entry of (env['PATH'] ?? '').split(delimiter)) {
374
+ if (entry.length === 0) continue
375
+ for (const candidate of [name, `${name}.cmd`, `${name}.exe`]) {
376
+ const full = join(entry, candidate)
377
+ try {
378
+ if (statSync(full).isFile()) return full
379
+ } catch {
380
+ continue
381
+ }
382
+ }
383
+ }
384
+ return null
385
+ }
386
+
387
+ /** Follow a symlink to its target, falling back to the path itself. */
388
+ function realpath(path: string): string {
389
+ try {
390
+ return realpathSync(path)
391
+ } catch {
392
+ return path
393
+ }
394
+ }
395
+
396
+ /** Step 4: the npx cache (`~/.npm/_npx/<hash>/node_modules`), newest install first. */
397
+ function resolveFromNpxStore(env: NodeJS.ProcessEnv): string | null {
398
+ const npxDir = join(homeDirectory(env), '.npm', '_npx')
399
+ if (!existsSync(npxDir)) return null
400
+ let entries: string[]
401
+ try {
402
+ entries = readdirSync(npxDir)
403
+ } catch {
404
+ return null
405
+ }
406
+ const candidates = entries
407
+ .map((entry) => join(npxDir, entry, 'node_modules', CATALOG_PACKAGE))
408
+ .filter((candidate) => existsSync(candidate))
409
+ .map((candidate) => ({ candidate, mtime: modificationTime(candidate) }))
410
+ .sort((left, right) => right.mtime - left.mtime)
411
+ return candidates[0]?.candidate ?? null
412
+ }
413
+
414
+ /** Modification time of one path in ms, or 0 when it cannot be read. */
415
+ function modificationTime(path: string): number {
416
+ try {
417
+ return statSync(path).mtimeMs
418
+ } catch {
419
+ return 0
420
+ }
421
+ }
422
+
423
+ /* ------------------------------------------------------------------ */
424
+ /* row / record conversion and restore */
425
+ /* ------------------------------------------------------------------ */
426
+
427
+ /** Plain-object guard for the physical records handed to the catalog. */
428
+ function isRecord(value: unknown): value is Record<string, unknown> {
429
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
430
+ }
431
+
432
+ /** Copy own enumerable members, dropping `undefined` (JSON has no `undefined`). */
433
+ function withoutUndefined(record: object): Record<string, unknown> {
434
+ const out: Record<string, unknown> = {}
435
+ for (const [key, value] of Object.entries(record)) {
436
+ if (value !== undefined) out[key] = value
437
+ }
438
+ return out
439
+ }
440
+
441
+ /**
442
+ * The physical JSON record of one parsed row (see the module docblock).
443
+ *
444
+ * Event rows are their own record. The header record carries no `seq` on the
445
+ * wire, so the reader-synthesized `seq` is dropped for `type: 'session'`; a row
446
+ * that keeps the header payload under `data` is resolved to that payload.
447
+ */
448
+ export function rowRecord(row: ParsedRow): Record<string, unknown> {
449
+ if (row.type !== 'session') return withoutUndefined(row)
450
+ const data = row.data
451
+ if (isRecord(data) && typeof data['version'] === 'number' && typeof data['id'] === 'string') {
452
+ return withoutUndefined(data)
453
+ }
454
+ const { seq: _seq, time: _time, data: _data, ...rest } = row
455
+ return withoutUndefined(rest)
456
+ }
457
+
458
+ /**
459
+ * Restore parsed rows through the released catalog and return the current
460
+ * artifact. Throws the FIRST refusal (released chain order) unchanged, so the
461
+ * caller can classify it; no partial artifact escapes.
462
+ *
463
+ * @param catalog released catalog to restore through.
464
+ * @param rows parsed log rows in log order (header first).
465
+ * @param options recovery and validation policy of the restore.
466
+ */
467
+ export function restoreRows(
468
+ catalog: ReleasedCatalog,
469
+ rows: readonly ParsedRow[],
470
+ options: RestoreOptions,
471
+ ): ReleasedArtifact {
472
+ const [header, ...events] = rows
473
+ if (header === undefined) throw new Error('cannot restore an empty session log: the header row is missing')
474
+ const restore = catalog.createRestore(rowRecord(header), options)
475
+ for (const row of events) restore.decodeRow(rowRecord(row))
476
+ return restore.finish()
477
+ }
478
+
479
+ /** Concatenated message text of an error and its `cause` chain. */
480
+ function errorChainText(error: unknown): string {
481
+ const parts: string[] = []
482
+ let current: unknown = error
483
+ for (let depth = 0; depth < 8 && current !== null && current !== undefined; depth += 1) {
484
+ if (!(current instanceof Error)) {
485
+ parts.push(String(current))
486
+ break
487
+ }
488
+ parts.push(current.message)
489
+ current = current.cause
490
+ }
491
+ return parts.join('\n')
492
+ }
493
+
494
+ /**
495
+ * The class of one refusal thrown by the released catalog restore.
496
+ *
497
+ * The mapping is over the released build's OWN prose, so every entry cites the
498
+ * raise site that produces it (pinned checkout; the installed `0.1.5-rc.1`
499
+ * build's line is given for the same statement). A released reword therefore
500
+ * degrades these logs to `other-refusal` (unrepairable, nothing written) until
501
+ * this table is updated — which is exactly what the two real-catalog fixtures in
502
+ * `tests/repair-session-logs.spec.ts` (`describe.skipIf(realCatalog === null)`,
503
+ * foreign-`source.kind`-first and legacy-first) exist to catch:
504
+ * - `cannot safely transform unclassified message source` —
505
+ * `session-format-v2-to-v3/src/payload.ts:113` (`assertSource`; installed
506
+ * `dsh-session-format-v2-to-v3/lib/index.js:125`);
507
+ * - `uses unsupported descriptor version` —
508
+ * `session-format-v0-to-v1/src/validation.ts:202`
509
+ * (`assertReleasedEventPayload`; installed `…-v0-to-v1/lib/index.js:1586`);
510
+ * - `unknown historical event type` —
511
+ * `session-format-v0-to-v1/src/validation.ts:120` and `:194` (installed
512
+ * `…-v0-to-v1/lib/index.js:1530`, `:1582`).
513
+ */
514
+ function refusalClass(error: unknown): RefusalClass {
515
+ const text = errorChainText(error)
516
+ if (text.includes('unclassified message source')) return 'source-kind'
517
+ if (text.includes('unsupported descriptor version')) return 'subagent-descriptor-version'
518
+ if (text.includes('unknown historical event type')) return 'unknown-event-type'
519
+ return 'other-refusal'
520
+ }
521
+
522
+ /**
523
+ * Classify parsed rows against the released catalog: the truthful first-refusal
524
+ * class, not the structural approximation `classifyRows` reports.
525
+ *
526
+ * The restore runs under the pinned policy
527
+ * `createRestore(header, { recovery: 'recoverable', validation: 'transformed' })`
528
+ * and every row is decoded before `finish()`, so the class is the one the
529
+ * released chain raises first for this log.
530
+ *
531
+ * Returned classes: `'ok'` when the restore completes, `'source-kind'`,
532
+ * `'subagent-descriptor-version'`, `'unknown-event-type'` for the three released
533
+ * refusals this tool repairs or reports, and `'other-refusal'` for everything
534
+ * else. `'decompress-failed'` is not produced here: the caller passes rows that
535
+ * already decoded.
536
+ *
537
+ * @param rows parsed log rows in log order (header first).
538
+ * @param catalog resolved released catalog oracle.
539
+ */
540
+ export function classifyWithCatalog(rows: readonly ParsedRow[], catalog: CatalogHandle): RefusalClass {
541
+ try {
542
+ restoreRows(catalog.catalog, rows, { recovery: 'recoverable', validation: 'transformed' })
543
+ return 'ok'
544
+ } catch (error) {
545
+ return refusalClass(error)
546
+ }
547
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * classify.ts — the refusal classifier over one parsed session log.
3
+ *
4
+ * The released chain validates a log row by row and throws at the FIRST bad
5
+ * row, so this classifier walks the rows in log order and reports the class of
6
+ * that first refusal, together with every finding it collected. Each rule's
7
+ * `detect` is called once per row (a single-row slice) for exactly that reason —
8
+ * see the row-local contract on `LogRule`.
9
+ *
10
+ * Scope: this is the STRUCTURAL classifier. It knows only the classes the rule
11
+ * registry models, so `'ok'` means "no structural refusal found", never "the
12
+ * released chain accepts this log" — the catalog read-back is the truth. The
13
+ * read/restore layers, not a row rule, produce `'other-refusal'` (a row shape
14
+ * failure no rule models) and `'decompress-failed'`.
15
+ */
16
+ import type { Finding, LogRule, ParsedRow, RefusalClass } from './rules.ts'
17
+
18
+ export interface Classification {
19
+ /** Class of the first refusal in log order; `'ok'` when no rule reports one. */
20
+ class: RefusalClass
21
+ /** Every finding, in log order (row order, then registry order). */
22
+ findings: Finding[]
23
+ }
24
+
25
+ /**
26
+ * Classify one parsed log: `{ class, findings }` for its first refusal class.
27
+ *
28
+ * @param rows parsed log rows in log order (header first).
29
+ * @param rules rules to apply, in check order.
30
+ */
31
+ export function classifyRows(rows: readonly ParsedRow[], rules: readonly LogRule[]): Classification {
32
+ const findings: Finding[] = []
33
+ let refusalClass: RefusalClass = 'ok'
34
+ let firstRefusal = true
35
+ for (const row of rows) {
36
+ for (const rule of rules) {
37
+ const detected = rule.detect([row])
38
+ if (detected.length === 0) continue
39
+ if (firstRefusal) {
40
+ refusalClass = rule.class
41
+ firstRefusal = false
42
+ }
43
+ findings.push(...detected)
44
+ }
45
+ }
46
+ return { class: refusalClass, findings }
47
+ }