@catheadowl/ukp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,1040 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve, win32 } from "node:path";
4
+ import { loadManifest } from "../config/manifest.ts";
5
+ import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
6
+ import { resolveScope } from "../scope.ts";
7
+ import { buildQmdInvocation, defaultQmdCommand, isBareDocidReference, providerTimeoutMs, stripDocidHash, stripQmdHeader, toQmdGetArgument } from "./qmd.ts";
8
+ import { isValidPin, pinFromSourceDocument, recoverRenamedResource, type RecoveryCandidate } from "./rename-recovery.ts";
9
+ import { isInsideRealRoot, splitEndpointRelativeSegments } from "../path-safety.ts";
10
+
11
+ export interface ReadRequest {
12
+ /** Undefined only for an absolute filesystem reference, which the
13
+ * capability maps against the Registry before any read (ADR-URI-001
14
+ * tolerant tier); every other tier requires it. */
15
+ endpoint?: string;
16
+ path: string;
17
+ lines?: LineRange;
18
+ /** "uri" = exact slot addressing (ukp:// input, ADR 0014): the path must
19
+ * resolve exactly; no fuzzy fallback and no provider delegation on miss.
20
+ * "absolute" = Registry-mapped absolute path (ADR-URI-001): same exact
21
+ * intent — a miss fails fast with resource-missing and never enters the
22
+ * filesystem candidate scan (which on large endpoints is pathologically
23
+ * slow; BB-006 Run 2 evidence: 118s no-output on a miss). */
24
+ addressing?: "uri" | "absolute";
25
+ /** Document-relative context (ADR-URI-001 tolerant tier): the
26
+ * endpoint-relative route of the source document the reference was copied
27
+ * from. `path` is then resolved against that document's directory. */
28
+ fromRef?: string;
29
+ /** Consumer-pinned content hash (`sha256-<64 hex>`, ADR 0020 / spec
30
+ * read-rename-recovery): the ukp-pin verification key for the miss-path
31
+ * recovery descent. Absent = stale-unknown verification. */
32
+ pin?: string;
33
+ /** The reference exactly as the caller wrote it before tolerant-tier
34
+ * rewriting — used to locate the same-line ukp-pin annotation in the
35
+ * --from source document (Q1 spelling). */
36
+ sourceReference?: string;
37
+ }
38
+
39
+ export interface LineRange {
40
+ start: number;
41
+ count?: number;
42
+ }
43
+
44
+ export interface ReadContext {
45
+ currentDirectory: string;
46
+ registryPath: string;
47
+ qmdCommand?: readonly string[];
48
+ }
49
+
50
+ /** Tolerant-addressing resolution provenance (ADR-URI-001): the factual echo
51
+ * of the request rewrite. Structured so a non-CLI surface can hand the
52
+ * consumer back the canonical endpoint+route instead of parsing stderr. */
53
+ export interface ReadResolution {
54
+ kind: "absolute-mapped" | "doc-relative";
55
+ /** Factual echo body without the `ukp read:` prefix. */
56
+ note: string;
57
+ }
58
+
59
+ /** Rename-recovery metadata (ADR 0020 / spec read-rename-recovery). `from`
60
+ * carries the original route so the success echo is renderable from data. */
61
+ export interface ReadRecoveryMeta {
62
+ from: string;
63
+ attempted: string[];
64
+ outcome: "recovered" | "exhausted";
65
+ recoveredTo?: string;
66
+ layer?: string;
67
+ verification?: string;
68
+ candidates?: RecoveryCandidate[];
69
+ }
70
+
71
+ /** ADR 0021 error classification — lifted from the word-level taxonomy that
72
+ * already lived in the stderr wording (`provider-unavailable:`,
73
+ * `resource-missing:`, `provider-timeout`, ...). Exit codes are adapter
74
+ * renderings of these classes. */
75
+ export type ReadErrorClass =
76
+ | "no-endpoint"
77
+ | "endpoint-name-mismatch"
78
+ | "provider-unavailable"
79
+ | "provider-timeout"
80
+ | "provider-cancelled"
81
+ | "provider-incompatible"
82
+ | "provider-no-content"
83
+ | "resource-missing"
84
+ | "qmd-route-unavailable"
85
+ | "resource-disappeared"
86
+ | "resource-is-directory"
87
+ | "start-beyond-eof"
88
+ | "ambiguous-match"
89
+ | "no-exact-match"
90
+ | "resource-not-found"
91
+ /** Inline tier-validation failures (containment escape, lexical path
92
+ * violations re-checked at execution): exit 2 with a bare message, unlike
93
+ * parse-layer usage errors which render the usage block. */
94
+ | "usage-error";
95
+
96
+ export interface ReadFailure {
97
+ errorClass: ReadErrorClass;
98
+ /** Factual message body — no `ukp read:` prefix (the adapter adds it once,
99
+ * on the first line). May be multi-line; embedded recovery-hint wording is
100
+ * known debt of the same class as search warnings (envelope/byte
101
+ * compatibility first). */
102
+ message: string;
103
+ recovery?: ReadRecoveryMeta;
104
+ resolution?: ReadResolution;
105
+ }
106
+
107
+ /** ADR 0021 payload-type success: the resource body IS the contract (stdout
108
+ * purity); echoes, recovery metadata, and warnings are sideband fields. */
109
+ export interface ReadSuccess {
110
+ content: string;
111
+ recovery?: ReadRecoveryMeta;
112
+ /** Recovery-descent warnings (factual lines; adapter prefixes each). */
113
+ recoveryWarnings: string[];
114
+ resolution?: ReadResolution;
115
+ }
116
+
117
+ export type ReadOutcome =
118
+ | { ok: true; result: ReadSuccess }
119
+ | { ok: false; failure: ReadFailure };
120
+
121
+ export class ReadUsageError extends Error {
122
+ constructor(message: string) {
123
+ super(message);
124
+ this.name = "ReadUsageError";
125
+ }
126
+ }
127
+
128
+ function readFailure(
129
+ errorClass: ReadErrorClass,
130
+ message: string,
131
+ recovery?: ReadRecoveryMeta,
132
+ ): ReadOutcome {
133
+ return { ok: false, failure: recovery ? { errorClass, message, recovery } : { errorClass, message } };
134
+ }
135
+
136
+ /** provider-unavailable classification for "no qmd executable at all" — the
137
+ * read channel is absent, which is the same recovery class as a spawn
138
+ * failure (install/verify qmd), never resource-missing. */
139
+ function providerUnavailableNoExecutable(endpointName: string): ReadOutcome {
140
+ return readFailure(
141
+ "provider-unavailable",
142
+ `provider-unavailable: the QMD read channel for endpoint '${endpointName}' has no usable qmd executable.\n`
143
+ + `Browse the endpoint with 'ukp nav --endpoint ${endpointName}' or install qmd, then retry.`,
144
+ );
145
+ }
146
+
147
+ function validateEndpointRelativePath(reference: string): string[] {
148
+ if (reference.length === 0) throw new ReadUsageError("reference must be a non-empty endpoint-scoped reference");
149
+ // Path-safety primitive (ADR 0023): verdicts shared, error wording local.
150
+ const result = splitEndpointRelativeSegments(reference);
151
+ if (!result.ok) {
152
+ if (result.reason === "absolute") {
153
+ throw new ReadUsageError("reference must be endpoint-scoped, not absolute");
154
+ }
155
+ if (result.reason === "empty-segment") {
156
+ throw new ReadUsageError("reference must not contain empty path segments");
157
+ }
158
+ throw new ReadUsageError("reference must not contain '.' or '..' path segments");
159
+ }
160
+ return result.segments;
161
+ }
162
+
163
+ function resolveEndpointPath(serviceFolder: string, reference: string): string {
164
+ const segments = validateEndpointRelativePath(reference);
165
+ const targetPath = resolve(join(serviceFolder, ...segments));
166
+ // Single containment check via realpath: catches both lexical and symlink escapes
167
+ const serviceReal = realpathSync(serviceFolder);
168
+ const targetReal = realpathSync(targetPath);
169
+ if (!isInsideRealRoot(serviceReal, targetReal)) {
170
+ throw new ReadUsageError("reference must stay inside the selected Service folder when resolved as a file");
171
+ }
172
+ return targetReal;
173
+ }
174
+
175
+ /** Tolerant-tier absolute-shape detection (ADR-URI-001): an input that is an
176
+ * absolute filesystem path (any of the shapes the plain tier rejects) is
177
+ * mapped against the Registry instead of being a usage error. */
178
+ export function isAbsoluteFilesystemReference(reference: string): boolean {
179
+ // `C:foo` (no separator) is drive-relative, not absolute — it stays on the
180
+ // plain tier, where validateEndpointRelativePath rejects the `X:` shape.
181
+ return (
182
+ isAbsolute(reference)
183
+ || win32.isAbsolute(reference)
184
+ || /^[A-Za-z]:[\\/]/.test(reference)
185
+ || reference.startsWith("//")
186
+ || reference.startsWith("\\\\")
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Resolve a document-relative reference (ADR-URI-001 tolerant tier): the
192
+ * reference as it appears inside a source document (`../x.md`, `./x.md`,
193
+ * bare `x.md`), resolved against `fromRef`'s directory with `.`/`..`
194
+ * segment normalization. Pure string work — no filesystem access, no fuzzy
195
+ * search; escaping the endpoint root is a usage error, not a containment
196
+ * miss (the caller learns the boundary before any file is touched).
197
+ */
198
+ function resolveDocRelativeReference(fromRef: string, reference: string): string {
199
+ const base = validateEndpointRelativePath(fromRef).slice(0, -1);
200
+ const segments = [...base];
201
+ for (const segment of reference.split(/[\\/]/)) {
202
+ if (segment.length === 0 || segment === ".") continue;
203
+ if (segment === "..") {
204
+ if (segments.length === 0) {
205
+ throw new ReadUsageError(
206
+ `'${reference}' (from '${fromRef}') resolves outside the endpoint`,
207
+ );
208
+ }
209
+ segments.pop();
210
+ continue;
211
+ }
212
+ segments.push(segment);
213
+ }
214
+ if (segments.length === 0) {
215
+ throw new ReadUsageError(
216
+ `'${reference}' (from '${fromRef}') resolves to the endpoint root, not a resource`,
217
+ );
218
+ }
219
+ return segments.join("/");
220
+ }
221
+
222
+ /**
223
+ * Map an absolute filesystem reference to { endpoint, route } by matching it
224
+ * against every registered endpoint's Service folder (ADR-URI-001 tolerant
225
+ * tier; the agent-facing equivalent of `gh pr view` accepting a pasted URL).
226
+ * Containment uses the same resolved-realpath stance as reads; a target that
227
+ * does not exist yet falls back to a lexical check against the real Service
228
+ * root. Ambiguity (zero or multiple endpoints) is a usage error, never a
229
+ * silent first-match: the mapping must be reproducible.
230
+ */
231
+ function resolveAbsoluteReference(
232
+ reference: string,
233
+ registry: readonly RegistryBinding[],
234
+ ): { endpoint: string; route: string } {
235
+ const matches: { endpoint: string; route: string }[] = [];
236
+ for (const binding of registry) {
237
+ if (isRemoteBinding(binding) || binding.path === undefined) continue;
238
+ let service: ReturnType<typeof loadManifest>;
239
+ try {
240
+ service = loadManifest(binding.path);
241
+ } catch {
242
+ continue;
243
+ }
244
+ if (service.effectiveName !== binding.name) continue;
245
+ let serviceReal: string;
246
+ try {
247
+ serviceReal = realpathSync(service.folder);
248
+ } catch {
249
+ continue;
250
+ }
251
+ let target: string;
252
+ try {
253
+ target = realpathSync(reference);
254
+ } catch {
255
+ target = resolve(reference);
256
+ }
257
+ if (!isInsideRealRoot(serviceReal, target)) continue;
258
+ matches.push({
259
+ endpoint: binding.name,
260
+ route: relative(serviceReal, target).replace(/\\/g, "/"),
261
+ });
262
+ }
263
+ if (matches.length === 0) {
264
+ const names = registry.map((binding) => binding.name).join(", ");
265
+ throw new ReadUsageError(
266
+ `absolute reference '${reference}' matches no registered endpoint`
267
+ + (names.length > 0 ? ` (registered: ${names})` : " (no endpoints registered)"),
268
+ );
269
+ }
270
+ if (matches.length > 1) {
271
+ const list = matches.map((match) => `${match.endpoint} (${match.route})`).join(", ");
272
+ throw new ReadUsageError(
273
+ `absolute reference '${reference}' matches multiple endpoints: ${list}; use 'ukp read --endpoint <name> <route>' instead`,
274
+ );
275
+ }
276
+ return matches[0];
277
+ }
278
+
279
+ function normalizeFilename(name: string): string {
280
+ // Strip extension (only if there's a leading character before the dot) and normalize hyphens/underscores
281
+ // Dotfiles like .env keep their name (dotIndex === 0 means no leading char)
282
+ const dotIndex = name.lastIndexOf(".");
283
+ const withoutExt = dotIndex > 0 ? name.slice(0, dotIndex) : name;
284
+ return withoutExt.toLowerCase().replace(/[-_]/g, "");
285
+ }
286
+
287
+ /** Budget for the human-shorthand candidate scan. The scan is advisory only
288
+ * (Did-you-mean), so it is bounded: beyond this many files scanned it stops
289
+ * with whatever it has collected. The walk itself is cheap — the per-file
290
+ * name filter below rejects non-matching entries before ANY filesystem
291
+ * syscall (realpathSync per file on a large endpoint was the BB-006 Run 2
292
+ * pathology: 118s on a miss), so this budget is a safety net, not the fix.
293
+ * `UKP_CANDIDATE_SCAN_FILE_BUDGET` overrides (min 1) so the trigger branch
294
+ * is testable without materializing 20k files. */
295
+ function candidateScanFileBudget(): number {
296
+ const raw = process.env.UKP_CANDIDATE_SCAN_FILE_BUDGET;
297
+ if (raw !== undefined && /^\d+$/.test(raw) && Number(raw) >= 1) return Number(raw);
298
+ return 20_000;
299
+ }
300
+
301
+ function findFilesBySuffix(serviceFolder: string, suffix: string): {
302
+ serviceReal: string;
303
+ suffixMatches: string[];
304
+ nameFuzzyMatches: string[];
305
+ } {
306
+ const serviceReal = realpathSync(serviceFolder);
307
+ const suffixMatches = new Set<string>();
308
+ const nameFuzzyMatches = new Set<string>();
309
+ const visitedDirs = new Set<string>();
310
+ const normalizedSuffix = suffix.replace(/\\/g, "/");
311
+ const suffixSegments = normalizedSuffix.split("/");
312
+ const targetFileName = suffixSegments[suffixSegments.length - 1];
313
+ const normalizedTargetName = normalizeFilename(targetFileName);
314
+ // For fuzzy matching, also normalize the path prefix (all segments except the last)
315
+ const pathPrefix = suffixSegments.slice(0, -1).join("/");
316
+ const normalizedPathPrefix = pathPrefix.replace(/\\/g, "/").toLowerCase().replace(/[-_]/g, "");
317
+ let filesScanned = 0;
318
+
319
+ function scan(currentPath: string): void {
320
+ if (filesScanned > candidateScanFileBudget()) return;
321
+ let entries;
322
+ try {
323
+ entries = readdirSync(currentPath, { withFileTypes: true });
324
+ } catch {
325
+ return;
326
+ }
327
+
328
+ for (const entry of entries) {
329
+ const fullPath = join(currentPath, entry.name);
330
+ let canonicalPath: string;
331
+ let isSymlink = false;
332
+
333
+ if (entry.isSymbolicLink()) {
334
+ isSymlink = true;
335
+ try {
336
+ canonicalPath = realpathSync(fullPath);
337
+ if (!isInsideRealRoot(serviceReal, canonicalPath)) {
338
+ continue;
339
+ }
340
+ } catch {
341
+ continue; // Broken symlink
342
+ }
343
+ } else {
344
+ canonicalPath = fullPath;
345
+ }
346
+
347
+ // Determine actual type (symlinks need statSync since Dirent methods return false for symlinks)
348
+ let isDir: boolean;
349
+ let isFile: boolean;
350
+ if (isSymlink) {
351
+ try {
352
+ const stat = statSync(canonicalPath);
353
+ isDir = stat.isDirectory();
354
+ isFile = stat.isFile();
355
+ } catch {
356
+ continue;
357
+ }
358
+ } else {
359
+ isDir = entry.isDirectory();
360
+ isFile = entry.isFile();
361
+ }
362
+
363
+ if (isDir) {
364
+ const dirReal = isSymlink ? canonicalPath : realpathSync(canonicalPath);
365
+ if (visitedDirs.has(dirReal)) continue;
366
+ visitedDirs.add(dirReal);
367
+ scan(canonicalPath);
368
+ } else if (isFile) {
369
+ filesScanned += 1;
370
+ // Cheap name filter BEFORE any per-file syscall: both match checks
371
+ // below are gated on the entry name (exact or normalized), so a
372
+ // non-matching name can skip realpathSync/relative entirely. On a
373
+ // large endpoint this is the difference between a cheap readdir walk
374
+ // and a per-file realpath storm (BB-006 Run 2: 118s miss).
375
+ const nameExact = entry.name === targetFileName;
376
+ if (!nameExact && normalizeFilename(entry.name) !== normalizedTargetName) {
377
+ continue;
378
+ }
379
+ const fileReal = isSymlink ? canonicalPath : realpathSync(canonicalPath);
380
+ const relPath = relative(serviceReal, fileReal).replace(/\\/g, "/");
381
+
382
+ // Check 1: Exact suffix match (filename exact, path fuzzy)
383
+ if (nameExact) {
384
+ if (relPath === normalizedSuffix || relPath.endsWith("/" + normalizedSuffix)) {
385
+ if (isInsideRealRoot(serviceReal, fileReal)) {
386
+ suffixMatches.add(fileReal);
387
+ continue;
388
+ }
389
+ }
390
+ }
391
+
392
+ // Check 2: Name fuzzy match (filename fuzzy with -/_ normalization, ignoring extension)
393
+ // Also check path prefix if the user specified one
394
+ if (normalizeFilename(entry.name) === normalizedTargetName) {
395
+ // If user specified a path prefix (e.g., "subdir/file.md"), verify it matches
396
+ if (normalizedPathPrefix) {
397
+ const fileDir = relative(serviceReal, isSymlink ? canonicalPath : realpathSync(join(canonicalPath, "..")))
398
+ .replace(/\\/g, "/")
399
+ .toLowerCase()
400
+ .replace(/[-_]/g, "");
401
+ // Check if the file's directory path ends with the normalized prefix
402
+ if (fileDir !== normalizedPathPrefix && !fileDir.endsWith("/" + normalizedPathPrefix)) {
403
+ continue; // Path prefix doesn't match, skip
404
+ }
405
+ }
406
+ if (isInsideRealRoot(serviceReal, fileReal)) {
407
+ nameFuzzyMatches.add(fileReal);
408
+ }
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ visitedDirs.add(serviceReal);
415
+ scan(serviceReal);
416
+ return {
417
+ serviceReal,
418
+ suffixMatches: Array.from(suffixMatches),
419
+ nameFuzzyMatches: Array.from(nameFuzzyMatches),
420
+ };
421
+ }
422
+
423
+ type LineRangeResult =
424
+ | { kind: "ok"; content: string }
425
+ | { kind: "start-beyond-eof"; start: number; lineCount: number };
426
+
427
+ /**
428
+ * Slice `content` to the requested 1-based `--lines` window.
429
+ *
430
+ * Two out-of-range shapes are distinguished so the caller can enforce the
431
+ * read/file exit contract, which must match read/qmd (where an empty provider
432
+ * body already fails with exit 1):
433
+ * - `start` past the last line → `start-beyond-eof`; the caller errors (exit 1)
434
+ * and never surfaces a silently-empty success read.
435
+ * - `start` valid but the window runs past the end → content is truncated to
436
+ * the available lines and remains `ok` (exit 0). `--lines` is a best-effort
437
+ * reading hint and never selects another resource.
438
+ */
439
+ function applyLineRange(content: string, range: LineRange | undefined): LineRangeResult {
440
+ if (!range) return { kind: "ok", content };
441
+ // An empty file has zero lines, so any start is beyond the end.
442
+ if (content.length === 0) return { kind: "start-beyond-eof", start: range.start, lineCount: 0 };
443
+ const lines = content.split(/\r?\n/);
444
+ if (content.endsWith("\n") || content.endsWith("\r\n")) lines.pop();
445
+ const lineCount = lines.length;
446
+ const startIndex = range.start - 1;
447
+ if (startIndex >= lineCount) {
448
+ return { kind: "start-beyond-eof", start: range.start, lineCount };
449
+ }
450
+ const selected = lines.slice(startIndex, range.count === undefined ? undefined : startIndex + range.count);
451
+ return { kind: "ok", content: `${selected.join("\n")}\n` };
452
+ }
453
+
454
+ /**
455
+ * Read an explicit provider-tier reference (bare docid handoff key, ADR 0011,
456
+ * or a `qmd://` provider reference, ADR 0008) through the QMD-backed get
457
+ * adapter. Never entered from a plain-path miss (ADR 0017: shape-based
458
+ * dispatch). The adapter re-adds the `#` to a bare docid so QMD resolves it
459
+ * by content fingerprint, strips the provider header so stdout starts at the
460
+ * body, and keeps UKP's exit/error discipline — failures classify as
461
+ * provider-unavailable (spawn) or resource-missing (provider ran, no
462
+ * resolution) without leaking QMD internals as traces.
463
+ */
464
+ function readViaQmd(
465
+ qmdCommand: readonly string[],
466
+ serviceFolder: string,
467
+ request: ReadRequest,
468
+ endpointName: string,
469
+ ): ReadOutcome {
470
+ const providerArgs = [
471
+ "get",
472
+ toQmdGetArgument(request.path, request.lines),
473
+ "--no-line-numbers",
474
+ ];
475
+ const invocation = buildQmdInvocation(qmdCommand, providerArgs);
476
+ const result = spawnSync(invocation.file, invocation.args, {
477
+ cwd: serviceFolder,
478
+ encoding: "utf8",
479
+ windowsHide: true,
480
+ windowsVerbatimArguments: invocation.verbatim,
481
+ maxBuffer: 64 * 1024 * 1024,
482
+ timeout: providerTimeoutMs(),
483
+ });
484
+
485
+ if (result.signal === "SIGTERM") {
486
+ // Zero-output hang audit: a provider that never responds must land in a
487
+ // classified failure, not silence. spawnSync kills with SIGTERM on timeout.
488
+ return readFailure(
489
+ "provider-timeout",
490
+ `provider-timeout: the QMD read channel for endpoint '${endpointName}' did not respond within ${providerTimeoutMs() / 1000}s.\n`
491
+ + `Verify the qmd installation or set UKP_PROVIDER_TIMEOUT_MS, then retry.`,
492
+ );
493
+ }
494
+
495
+ if (result.error) {
496
+ // provider-unavailable, not resource-missing: the read channel itself
497
+ // could not start (spawn failure — qmd missing, or an unstartable shim
498
+ // form). The two classes drive completely different recovery actions, so
499
+ // the wording must classify and point at recovery paths.
500
+ const detail = result.error instanceof Error ? result.error.message : String(result.error);
501
+ return readFailure(
502
+ "provider-unavailable",
503
+ `provider-unavailable: the QMD read channel for endpoint '${endpointName}' could not start (${detail}).\n`
504
+ + `Browse the endpoint with 'ukp nav --endpoint ${endpointName}' or verify the qmd installation, then retry.`,
505
+ );
506
+ }
507
+ if (result.signal === "SIGINT" || result.status === 130) {
508
+ return readFailure("provider-cancelled", "provider cancelled");
509
+ }
510
+ if (result.status !== 0) {
511
+ // Provider stderr detail keeps at most the first non-empty line — a
512
+ // multi-line crash dump must not flood the surface (a provider
513
+ // malfunction still lands in this branch; the classification stays
514
+ // resource-missing-shaped, which is a declared limitation of a one-line
515
+ // provider detail).
516
+ const rawProviderError = (result.stderr ?? "").trim();
517
+ if (
518
+ rawProviderError.includes("no-line-numbers")
519
+ || rawProviderError.includes("unknown option")
520
+ || rawProviderError.includes("unknown flag")
521
+ ) {
522
+ return readFailure(
523
+ "provider-incompatible",
524
+ "qmd build does not support '--no-line-numbers'; provider incompatible",
525
+ );
526
+ }
527
+ const providerError = rawProviderError
528
+ .split(/\r?\n/)
529
+ .map((line) => line.trim())
530
+ .find((line) => line.length > 0);
531
+ return readFailure(
532
+ "resource-missing",
533
+ `resource-missing: '${request.path}' could not be resolved by the provider in endpoint '${endpointName}'`
534
+ + (providerError ? `\n(provider: ${providerError})` : ""),
535
+ );
536
+ }
537
+
538
+ const body = stripQmdHeader(result.stdout ?? "");
539
+ if (body.length === 0) {
540
+ return readFailure(
541
+ "provider-no-content",
542
+ `provider returned no content for '${request.path}' in endpoint '${endpointName}'`,
543
+ );
544
+ }
545
+ return { ok: true, result: { content: body, recoveryWarnings: [] } };
546
+ }
547
+
548
+ /**
549
+ * Miss-path rename recovery (ADR 0020 / spec read-rename-recovery): run the
550
+ * layered descent for a slot miss and, on recovery, read the proposed route
551
+ * with the same line-window contract. Returns either the recovered read
552
+ * (echo + warnings prepended on stderr, `recovery` metadata attached) or a
553
+ * marker the miss site merges into its existing resource-missing wording
554
+ * (plus advisory candidates). Provider shapes never enter here (spec:
555
+ * recovery is a file-slot concern); the L2 search layer runs only when the
556
+ * endpoint declares a qmd search capability, and its absence is silent.
557
+ */
558
+ function attemptRenameRecovery(
559
+ request: ReadRequest,
560
+ endpointName: string,
561
+ serviceFolder: string,
562
+ qmdCommand: readonly string[] | undefined,
563
+ pin: string | undefined,
564
+ ): { kind: "recovered"; outcome: ReadOutcome } | { kind: "exhausted"; recovery: ReadRecoveryMeta } {
565
+ const outcome = recoverRenamedResource({
566
+ route: request.path,
567
+ endpointName,
568
+ serviceFolder,
569
+ ...(pin !== undefined ? { pin } : {}),
570
+ ...(qmdCommand !== undefined ? { qmdCommand } : {}),
571
+ });
572
+ const recovery: ReadRecoveryMeta = {
573
+ from: request.path,
574
+ attempted: outcome.attempted,
575
+ outcome: outcome.status,
576
+ ...(outcome.recoveredRoute !== undefined ? { recoveredTo: outcome.recoveredRoute } : {}),
577
+ ...(outcome.layer !== undefined ? { layer: outcome.layer } : {}),
578
+ ...(outcome.verification !== undefined ? { verification: outcome.verification } : {}),
579
+ ...(outcome.candidates.length > 0 ? { candidates: outcome.candidates } : {}),
580
+ };
581
+ if (outcome.status === "recovered" && outcome.recoveredRoute !== undefined) {
582
+ try {
583
+ const targetPath = resolveEndpointPath(serviceFolder, outcome.recoveredRoute);
584
+ const read = readTargetWithLines(targetPath, { ...request, path: outcome.recoveredRoute });
585
+ if (read.ok) {
586
+ // Success sideband: echo + warnings render from structured fields.
587
+ return {
588
+ kind: "recovered",
589
+ outcome: { ok: true, result: { ...read.result, recovery, recoveryWarnings: outcome.warnings } },
590
+ };
591
+ }
592
+ // The proposed route failed its own read (e.g. start-beyond-eof): the
593
+ // recovery found the resource but the read contract still governs —
594
+ // surface that failure rather than hiding behind resource-missing.
595
+ return { kind: "recovered", outcome: { ...read, failure: { ...read.failure, recovery } } };
596
+ } catch {
597
+ // Proposed route vanished or escaped containment mid-recovery: fall
598
+ // through to exhaustion (recorded below with the attempted layers).
599
+ }
600
+ }
601
+ return { kind: "exhausted", recovery };
602
+ }
603
+
604
+ /** Advisory recovery candidates appended to a resource-missing miss (spec
605
+ * read-rename-recovery): pin-rejected and search-recalled routes the consumer
606
+ * may confirm manually. Empty input renders nothing. */
607
+ function formatRecoveryCandidates(candidates: readonly RecoveryCandidate[] | undefined): string {
608
+ if (!candidates || candidates.length === 0) return "";
609
+ const lines = candidates.map((candidate) => ` - ${candidate.path} (recovery candidate, ${candidate.verified})`);
610
+ return `Rename recovery candidates:\n${lines.join("\n")}\n`;
611
+ }
612
+
613
+ function readTargetWithLines(targetPath: string, request: ReadRequest): ReadOutcome {
614
+ let content: string;
615
+ try {
616
+ content = readFileSync(targetPath, "utf8");
617
+ } catch (error) {
618
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
619
+ return readFailure("resource-disappeared", "resource disappeared during lookup");
620
+ }
621
+ // A directory tail resolves as a path but is not a readable resource; the
622
+ // failure must state that in operator-facing terms instead of leaking the Node
623
+ // errno (`EISDIR: illegal operation on a directory, read`) as the surface.
624
+ if (error instanceof Error && "code" in error && error.code === "EISDIR") {
625
+ return readFailure(
626
+ "resource-is-directory",
627
+ `'${request.path}' is a directory, not a readable resource`,
628
+ );
629
+ }
630
+ throw error;
631
+ }
632
+ const rangeResult = applyLineRange(content, request.lines);
633
+ if (rangeResult.kind === "start-beyond-eof") {
634
+ // Word by input origin: a URI #L<line> fragment never mentions --lines.
635
+ const origin = request.addressing === "uri" ? "line window start" : "--lines start";
636
+ return readFailure(
637
+ "start-beyond-eof",
638
+ `${origin} ${rangeResult.start} is beyond the end of '${request.path}' (${rangeResult.lineCount} lines)`,
639
+ );
640
+ }
641
+ return { ok: true, result: { content: rangeResult.content, recoveryWarnings: [] } };
642
+ }
643
+
644
+ /** ADR 0021 core entry: runs the read capability and returns the structured
645
+ * outcome. Parse-layer usage errors and scope/manifest failures still throw
646
+ * typed errors for the surface adapter to map; execution-tier usage
647
+ * violations classify as `usage-error` failures (exit-2 class). */
648
+ export function runRead(request: ReadRequest, context: ReadContext): ReadOutcome {
649
+ // A docid[:line] handoff key already carries an embedded line; a separate
650
+ // --lines range would double-specify (ADR 0011 / read-qmd-adapter). Strip any
651
+ // leading `#` first so a hash-prefixed `#docid:line` is caught too, even
652
+ // though `#` never appears on the UKP surface.
653
+ const barePath = stripDocidHash(request.path);
654
+ if (request.lines && isBareDocidReference(barePath) && barePath.includes(":")) {
655
+ throw new ReadUsageError(
656
+ "a docid[:line] reference already carries a line; do not also pass --lines",
657
+ );
658
+ }
659
+
660
+ // Tolerant-addressing pre-pass (ADR-URI-001): the caller's address encoding
661
+ // is UKP's job, never a shape the agent must pre-normalize. Two
662
+ // explicit tiers — an absolute filesystem path mapped against the Registry
663
+ // (endpoint inferred), and a document-relative reference resolved against
664
+ // --from. Both rewrite the request to the canonical endpoint+route and
665
+ // record the mapping as structured resolution provenance (echoed on stderr
666
+ // by the adapter; stdout stays body-only); both fail loud as usage
667
+ // errors, never silently delegating (the weak-reference lesson, ADR 0017).
668
+ const registry = readRegistry(context.registryPath);
669
+ let resolution: ReadResolution | undefined;
670
+ if (request.addressing !== "uri" && isAbsoluteFilesystemReference(request.path)) {
671
+ if (request.fromRef !== undefined) {
672
+ throw new ReadUsageError("an absolute filesystem path cannot be combined with --from");
673
+ }
674
+ if (request.endpoint !== undefined) {
675
+ throw new ReadUsageError(
676
+ "an absolute filesystem path carries its own endpoint (matched against registered endpoints); do not also pass --endpoint",
677
+ );
678
+ }
679
+ const mapped = resolveAbsoluteReference(request.path, registry);
680
+ request = { ...request, endpoint: mapped.endpoint, path: mapped.route, addressing: "absolute" };
681
+ resolution = {
682
+ kind: "absolute-mapped",
683
+ note: `absolute path matched endpoint '${mapped.endpoint}', route '${mapped.route}'`,
684
+ };
685
+ } else if (request.fromRef !== undefined) {
686
+ if (request.endpoint === undefined) {
687
+ throw new ReadUsageError("--from requires --endpoint <name>");
688
+ }
689
+ if (request.path.startsWith("qmd://") || isBareDocidReference(barePath)) {
690
+ throw new ReadUsageError("--from applies to document-relative path references, not provider references");
691
+ }
692
+ const resolved = resolveDocRelativeReference(request.fromRef, request.path);
693
+ resolution = {
694
+ kind: "doc-relative",
695
+ note: `resolved '${request.path}' from '${request.fromRef}' -> '${resolved}'`,
696
+ };
697
+ // Keep the verbatim reference for same-line ukp-pin extraction (Q1).
698
+ request = { ...request, path: resolved, sourceReference: request.path };
699
+ }
700
+
701
+ const outcome = executeResolvedRead(request, context, registry);
702
+ if (resolution === undefined) return outcome;
703
+ return outcome.ok
704
+ ? { ok: true, result: { ...outcome.result, resolution } }
705
+ : { ok: false, failure: { ...outcome.failure, resolution } };
706
+ }
707
+
708
+ function executeResolvedRead(
709
+ request: ReadRequest,
710
+ context: ReadContext,
711
+ registry: ReturnType<typeof readRegistry>,
712
+ ): ReadOutcome {
713
+ if (request.endpoint === undefined) {
714
+ return readFailure("no-endpoint", "no endpoint selected");
715
+ }
716
+ const scope = resolveScope({
717
+ currentDirectory: context.currentDirectory,
718
+ registry,
719
+ explicitEndpoints: [request.endpoint],
720
+ global: false,
721
+ });
722
+ const [binding] = scope.bindings;
723
+ if (!binding) {
724
+ return readFailure("no-endpoint", "no endpoint selected");
725
+ }
726
+ if (isRemoteBinding(binding)) {
727
+ // Defensive: the read adapter routes remote endpoints through the
728
+ // remote transport before runRead is reached.
729
+ return readFailure(
730
+ "provider-incompatible",
731
+ `endpoint '${binding.name}' is remote (${binding.url}); remote reads route through the remote transport`,
732
+ );
733
+ }
734
+
735
+ const service = loadManifest(localPathOf(binding));
736
+ if (service.effectiveName !== binding.name) {
737
+ return readFailure(
738
+ "endpoint-name-mismatch",
739
+ `endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
740
+ );
741
+ }
742
+
743
+ // ukp-pin from the --from source document (Q1 same-line spelling): the
744
+ // consumer's verification key travels with the reference, not the command.
745
+ let pin = request.pin;
746
+ if (pin === undefined && request.fromRef !== undefined && request.sourceReference !== undefined) {
747
+ pin = pinFromSourceDocument(request.fromRef, request.sourceReference, service.folder);
748
+ }
749
+
750
+ // QMD-backed route is derived from the declared search provider; there is no
751
+ // explicit get capability in the current Manifest. ADR 0017: QMD visibility
752
+ // governs only the explicit provider tiers (bare docid, qmd://), never the
753
+ // file-native plain-path tier.
754
+ const qmdBacked = service.manifest.capabilities.search?.provider === "qmd";
755
+ const qmdCommand = qmdBacked
756
+ ? context.qmdCommand ?? defaultQmdCommand()
757
+ : undefined;
758
+
759
+ // ukp:// URI input (ADR 0014): exact slot addressing. The rel-path must
760
+ // resolve exactly — no filesystem fuzzy fallback and no QMD weak-reference
761
+ // delegation on a miss, and this tier routes BEFORE the qmd:// provider
762
+ // reference check: a rel-path that happens to start with `qmd://` (e.g.
763
+ // `ukp://ep/qmd://abc`) is a literal URI path segment sequence, never a
764
+ // provider-owned reference — the URI's determinism promise outranks
765
+ // provider delegation everywhere. Failures classify per the three-valued
766
+ // taxonomy (dangling-endpoint is raised earlier by scope resolution; here:
767
+ // resource-missing). Containment is the same resolved-realpath check as
768
+ // the explicit file baseline (G5: resolved containment stance).
769
+ if (request.addressing === "uri") {
770
+ let targetPath: string;
771
+ try {
772
+ targetPath = resolveEndpointPath(service.folder, request.path);
773
+ } catch (error) {
774
+ if (error instanceof ReadUsageError) {
775
+ return readFailure("usage-error", error.message);
776
+ }
777
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
778
+ // ADR 0020 miss-path recovery: the URI slot missed, so descend the
779
+ // layered stack (git-derived -> search re-anchor) before classifying.
780
+ const recovered = attemptRenameRecovery(request, binding.name, service.folder, qmdCommand, pin);
781
+ if (recovered.kind === "recovered") return recovered.outcome;
782
+ return readFailure(
783
+ "resource-missing",
784
+ `resource-missing '${request.path}' in endpoint '${binding.name}' (ukp:// addresses a slot exactly; no fuzzy resolution)\n`
785
+ + formatRecoveryCandidates(recovered.recovery.candidates).trimEnd(),
786
+ recovered.recovery,
787
+ );
788
+ }
789
+ throw error;
790
+ }
791
+ return readTargetWithLines(targetPath, request);
792
+ }
793
+
794
+ // ukp:// provider reference (non-URI input only; the URI tier above already
795
+ // returned): route before endpoint-local path validation, so the `://`
796
+ // empty segment is never misread as a file-path usage error.
797
+ if (request.path.startsWith("qmd://")) {
798
+ if (!qmdBacked) {
799
+ return readFailure(
800
+ "qmd-route-unavailable",
801
+ `qmd:// references require a QMD-backed endpoint; endpoint '${binding.name}' has no QMD get route`,
802
+ );
803
+ }
804
+ if (!qmdCommand) {
805
+ return providerUnavailableNoExecutable(binding.name);
806
+ }
807
+ return readViaQmd(qmdCommand, service.folder, request, binding.name);
808
+ }
809
+
810
+ // Bare docid[:line] handoff key (ADR 0011) — an explicit provider shape
811
+ // (ADR 0017: shape-based dispatch). It routes to the provider before any
812
+ // filesystem resolution: the fingerprint carries search-handoff intent, and
813
+ // a Service-folder file that happens to be named like a docid must not
814
+ // shadow it. (Recomputed here: the tolerant pre-pass may have rewritten
815
+ // request.path, but a rewritten path is always a plain route, never a docid.)
816
+ const barePath = stripDocidHash(request.path);
817
+ if (isBareDocidReference(barePath)) {
818
+ if (!qmdBacked) {
819
+ return readFailure(
820
+ "qmd-route-unavailable",
821
+ `a docid[:line] reference requires a QMD-backed endpoint; endpoint '${binding.name}' has no QMD get route`,
822
+ );
823
+ }
824
+ if (!qmdCommand) {
825
+ return providerUnavailableNoExecutable(binding.name);
826
+ }
827
+ return readViaQmd(qmdCommand, service.folder, request, binding.name);
828
+ }
829
+
830
+ let targetPath: string;
831
+ try {
832
+ targetPath = resolveEndpointPath(service.folder, request.path);
833
+ } catch (error) {
834
+ if (error instanceof ReadUsageError) {
835
+ return readFailure("usage-error", error.message);
836
+ }
837
+ // realpathSync throws ENOENT if path doesn't exist
838
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
839
+ // Absolute-tier miss (ADR-URI-001): the input carried exact intent (a
840
+ // Registry-mapped absolute path), so there is no human-shorthand to
841
+ // recover from — fail fast with resource-missing and never enter the
842
+ // candidate scan (BB-006 Run 2 evidence: scanning a large external
843
+ // endpoint on such a miss took 118s with zero output).
844
+ if (request.addressing === "absolute") {
845
+ const recovered = attemptRenameRecovery(request, binding.name, service.folder, qmdCommand, pin);
846
+ if (recovered.kind === "recovered") return recovered.outcome;
847
+ return readFailure(
848
+ "resource-missing",
849
+ `resource-missing '${request.path}' in endpoint '${binding.name}'`
850
+ + ` (absolute paths address files exactly; the mapped route does not exist — the target may live one level deeper, e.g. under src/)\n`
851
+ + formatRecoveryCandidates(recovered.recovery.candidates).trimEnd(),
852
+ recovered.recovery,
853
+ );
854
+ }
855
+ // ADR 0017: read is file-native — a plain-path miss is resource-missing
856
+ // on every endpoint; the provider is never entered from a miss. On a
857
+ // QMD-backed endpoint the filesystem scan is advisory only (candidate
858
+ // list for human shorthand recovery); it never resolves the read, so
859
+ // provider collection/ignore visibility is never bypassed by a silent
860
+ // hit. Pure file-backed endpoints keep the file layer's
861
+ // own suffix/fuzzy resolution, whose visibility root is the Service
862
+ // folder itself.
863
+ if (qmdBacked) {
864
+ const recovered = attemptRenameRecovery(request, binding.name, service.folder, qmdCommand, pin);
865
+ if (recovered.kind === "recovered") return recovered.outcome;
866
+ const { serviceReal, suffixMatches, nameFuzzyMatches } = findFilesBySuffix(service.folder, request.path);
867
+ const candidates = [...suffixMatches, ...nameFuzzyMatches]
868
+ .map((m) => ` - ${relative(serviceReal, m).replace(/\\/g, "/")}`);
869
+ // Shape-tailored miss wording (BB-006 evidence): never mention docid/
870
+ // qmd:// here — a file-surface consumer misread that parenthetical as
871
+ // a hint and detoured into a docid miss. Discovery pointers only.
872
+ const shapeHint = request.path.includes("/")
873
+ ? `Browse the endpoint with 'ukp nav --endpoint ${binding.name}'`
874
+ : `For a document-relative reference use '--from <route>', or browse with 'ukp nav --endpoint ${binding.name}'`;
875
+ return readFailure(
876
+ "resource-missing",
877
+ `resource-missing '${request.path}' in endpoint '${binding.name}' (plain paths and ukp:// URIs address files exactly; no fuzzy resolution)\n`
878
+ + `${shapeHint}.`
879
+ + (candidates.length > 0
880
+ ? `\nDid you mean:\n${candidates.join("\n")}`
881
+ : "")
882
+ + (recovered.recovery.candidates && recovered.recovery.candidates.length > 0
883
+ ? `\n${formatRecoveryCandidates(recovered.recovery.candidates).trimEnd()}`
884
+ : ""),
885
+ recovered.recovery,
886
+ );
887
+ }
888
+ // Pure file-backed: filesystem fuzzy fallback (visibility root is the
889
+ // Service folder itself).
890
+ const { serviceReal, suffixMatches, nameFuzzyMatches } = findFilesBySuffix(service.folder, request.path);
891
+
892
+ // Suffix match (filename exact): single match → return directly, multiple → list candidates
893
+ if (suffixMatches.length === 1) {
894
+ targetPath = suffixMatches[0];
895
+ } else if (suffixMatches.length > 1) {
896
+ const matchList = suffixMatches
897
+ .map((m) => ` - ${relative(serviceReal, m).replace(/\\/g, "/")}`)
898
+ .join("\n");
899
+ return readFailure(
900
+ "ambiguous-match",
901
+ `multiple resources match '${request.path}' in endpoint '${binding.name}':\n${matchList}\nUse a more specific path.`,
902
+ );
903
+ } else if (nameFuzzyMatches.length > 0) {
904
+ // Name fuzzy match (filename fuzzy): always show candidates
905
+ const matchList = nameFuzzyMatches
906
+ .map((m) => ` - ${relative(serviceReal, m).replace(/\\/g, "/")}`)
907
+ .join("\n");
908
+ return readFailure(
909
+ "no-exact-match",
910
+ `no exact match for '${request.path}' in endpoint '${binding.name}'.\nDid you mean:\n${matchList}`,
911
+ );
912
+ } else {
913
+ const recovered = attemptRenameRecovery(request, binding.name, service.folder, undefined, pin);
914
+ if (recovered.kind === "recovered") return recovered.outcome;
915
+ return readFailure(
916
+ "resource-not-found",
917
+ `resource '${request.path}' was not found in endpoint '${binding.name}'`
918
+ + (recovered.recovery.candidates && recovered.recovery.candidates.length > 0
919
+ ? `\n${formatRecoveryCandidates(recovered.recovery.candidates).trimEnd()}`
920
+ : ""),
921
+ recovered.recovery,
922
+ );
923
+ }
924
+ } else {
925
+ throw error;
926
+ }
927
+ }
928
+
929
+ return readTargetWithLines(targetPath, request);
930
+ }
931
+
932
+ // ---------------------------------------------------------------------------
933
+ // Presentation (ADR 0021 two-stage form). read is a payload-type capability:
934
+ // stdout is the resource body itself; every echo/warning/diagnostic is a
935
+ // sideband rendered onto stderr by the shared render below. Adapters must
936
+ // consume these — never re-parse rendered text (the old --format json path
937
+ // regex-extracted the error class out of stderr; the class is data now).
938
+ // ---------------------------------------------------------------------------
939
+
940
+ /** Success sideband lines: resolution echo, recovery echo, then recovery
941
+ * warnings — composed from structured fields, `ukp read:` prefixes added
942
+ * here (CLI wording, not capability data). */
943
+ function renderReadSideband(result: ReadSuccess): string {
944
+ const parts: string[] = [];
945
+ if (result.resolution !== undefined) {
946
+ parts.push(`ukp read: ${result.resolution.note}`);
947
+ }
948
+ if (result.recovery?.outcome === "recovered" && result.recovery.recoveredTo !== undefined) {
949
+ const layerLabel = result.recovery.layer === "search-reanchor" ? "search re-anchor" : "git history";
950
+ parts.push(`ukp read: recovered: '${result.recovery.from}' moved to '${result.recovery.recoveredTo}' (${layerLabel})`);
951
+ }
952
+ for (const warning of result.recoveryWarnings) {
953
+ parts.push(`ukp read: warning: ${warning}`);
954
+ }
955
+ return parts.map((part) => `${part}\n`).join("");
956
+ }
957
+
958
+ /** Human rendering of an outcome, in surface-neutral terms: `body` is the
959
+ * payload (the CLI prints it on stdout), `diagnostics` is the sideband
960
+ * (echoes/warnings/failure message — the CLI prints it on stderr). Channel
961
+ * assignment belongs to adapters, so the presentation view stays
962
+ * surface-neutral. */
963
+ export interface ReadHumanView {
964
+ body: string;
965
+ diagnostics: string;
966
+ }
967
+
968
+ export function renderReadHuman(outcome: ReadOutcome): ReadHumanView {
969
+ if (outcome.ok) {
970
+ return { body: outcome.result.content, diagnostics: renderReadSideband(outcome.result) };
971
+ }
972
+ const failure = outcome.failure;
973
+ const prefix = failure.resolution !== undefined ? `ukp read: ${failure.resolution.note}\n` : "";
974
+ const message = failure.message.endsWith("\n") ? failure.message : `${failure.message}\n`;
975
+ return { body: "", diagnostics: `${prefix}ukp read: ${message}` };
976
+ }
977
+
978
+ /** `--format json` envelope (spec read-rename-recovery). Failure class is
979
+ * data (`failure.errorClass`); only the three word-level classes that the
980
+ * published envelope vocabulary defines surface as-is, `usage-error` keeps
981
+ * its name, everything else maps to `error` — identical to the envelope the
982
+ * text-regex extraction produced. */
983
+ export interface ReadEnvelope {
984
+ ok: boolean;
985
+ endpoint?: string;
986
+ reference: string;
987
+ recovered_to?: string;
988
+ error?: {
989
+ class: string;
990
+ message: string;
991
+ recovery?: {
992
+ attempted: string[];
993
+ outcome: string;
994
+ recovered_to?: string;
995
+ candidates?: RecoveryCandidate[];
996
+ };
997
+ };
998
+ }
999
+
1000
+ export function projectReadEnvelope(request: ReadRequest, outcome: ReadOutcome): ReadEnvelope {
1001
+ if (outcome.ok) {
1002
+ return {
1003
+ ok: true,
1004
+ ...(request.endpoint !== undefined ? { endpoint: request.endpoint } : {}),
1005
+ reference: request.path,
1006
+ ...(outcome.result.recovery?.recoveredTo !== undefined
1007
+ ? { recovered_to: outcome.result.recovery.recoveredTo }
1008
+ : {}),
1009
+ };
1010
+ }
1011
+ const failure = outcome.failure;
1012
+ const wordLevel = failure.errorClass === "resource-missing"
1013
+ || failure.errorClass === "provider-unavailable"
1014
+ || failure.errorClass === "provider-timeout";
1015
+ const errorClass = failure.errorClass === "usage-error"
1016
+ ? "usage-error"
1017
+ : wordLevel
1018
+ ? failure.errorClass
1019
+ : "error";
1020
+ const firstLine = failure.message.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
1021
+ return {
1022
+ ok: false,
1023
+ ...(request.endpoint !== undefined ? { endpoint: request.endpoint } : {}),
1024
+ reference: request.path,
1025
+ error: {
1026
+ class: errorClass,
1027
+ message: firstLine,
1028
+ ...(failure.recovery !== undefined
1029
+ ? {
1030
+ recovery: {
1031
+ attempted: failure.recovery.attempted,
1032
+ outcome: failure.recovery.outcome,
1033
+ ...(failure.recovery.recoveredTo !== undefined ? { recovered_to: failure.recovery.recoveredTo } : {}),
1034
+ ...(failure.recovery.candidates !== undefined ? { candidates: failure.recovery.candidates } : {}),
1035
+ },
1036
+ }
1037
+ : {}),
1038
+ },
1039
+ };
1040
+ }