full-reverse 0.0.0-stage → 1.0.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/THIRD_PARTY_NOTICES.md +63 -0
  4. package/VERIFICATION.md +156 -0
  5. package/bin/full-reverse.mjs +253 -0
  6. package/package.json +30 -4
  7. package/skills/full-reverse/LICENSE +21 -0
  8. package/skills/full-reverse/README.md +116 -0
  9. package/skills/full-reverse/SKILL.md +173 -0
  10. package/skills/full-reverse/THIRD_PARTY_NOTICES.md +63 -0
  11. package/skills/full-reverse/VERIFICATION.md +156 -0
  12. package/skills/full-reverse/agents/openai.yaml +3 -0
  13. package/skills/full-reverse/package-lock.json +75 -0
  14. package/skills/full-reverse/package.json +21 -0
  15. package/skills/full-reverse/references/investigation-contract.md +171 -0
  16. package/skills/full-reverse/references/workflow-example.md +148 -0
  17. package/skills/full-reverse/references/workspace-layout.md +83 -0
  18. package/skills/full-reverse/schemas/claims.schema.json +123 -0
  19. package/skills/full-reverse/schemas/common.schema.json +305 -0
  20. package/skills/full-reverse/schemas/evidence-index.schema.json +135 -0
  21. package/skills/full-reverse/schemas/examples/claims.example.json +355 -0
  22. package/skills/full-reverse/schemas/examples/evidence-index.example.json +254 -0
  23. package/skills/full-reverse/schemas/examples/external-evidence.example.json +33 -0
  24. package/skills/full-reverse/schemas/examples/inventory.example.json +106 -0
  25. package/skills/full-reverse/schemas/examples/investigation.example.json +159 -0
  26. package/skills/full-reverse/schemas/examples/review.example.json +75 -0
  27. package/skills/full-reverse/schemas/external-evidence.schema.json +68 -0
  28. package/skills/full-reverse/schemas/inventory.schema.json +91 -0
  29. package/skills/full-reverse/schemas/investigation.schema.json +142 -0
  30. package/skills/full-reverse/schemas/review.schema.json +78 -0
  31. package/skills/full-reverse/scripts/lib/cli.mjs +254 -0
  32. package/skills/full-reverse/scripts/lib/jsonscan.mjs +285 -0
  33. package/skills/full-reverse/scripts/lib/rea.mjs +496 -0
  34. package/skills/full-reverse/scripts/lib/schemas.mjs +79 -0
  35. package/skills/full-reverse/scripts/lib/scope.mjs +59 -0
  36. package/skills/full-reverse/scripts/lib/selectors.mjs +256 -0
  37. package/skills/full-reverse/scripts/lib/util.mjs +333 -0
  38. package/skills/full-reverse/scripts/lib/validate.mjs +1133 -0
  39. package/skills/full-reverse/scripts/lib/workspace.mjs +861 -0
  40. package/skills/full-reverse/scripts/validate-investigation.mjs +28 -0
  41. package/skills/full-reverse-inspect/SKILL.md +119 -0
  42. package/skills/full-reverse-inspect/agents/openai.yaml +5 -0
  43. package/skills/full-reverse-inspect/references/android.md +84 -0
  44. package/skills/full-reverse-inspect/references/evidence-workflows.md +170 -0
  45. package/skills/full-reverse-inspect/references/javascript-electron.md +146 -0
  46. package/skills/full-reverse-inspect/references/managed.md +59 -0
  47. package/skills/full-reverse-inspect/references/native.md +290 -0
  48. package/skills/full-reverse-inspect/references/packages-and-specialized-artifacts.md +93 -0
  49. package/skills/full-reverse-inspect/references/recorded-evidence.md +118 -0
  50. package/skills/full-reverse-inventory/SKILL.md +134 -0
  51. package/skills/full-reverse-inventory/agents/openai.yaml +5 -0
  52. package/skills/full-reverse-model/SKILL.md +148 -0
  53. package/skills/full-reverse-model/agents/openai.yaml +5 -0
  54. package/skills/full-reverse-model/references/claim-classification.md +85 -0
  55. package/skills/full-reverse-model/references/specification-template.md +199 -0
  56. package/skills/full-reverse-observe/SKILL.md +97 -0
  57. package/skills/full-reverse-observe/agents/openai.yaml +5 -0
  58. package/skills/full-reverse-observe/references/runtime-scope.md +301 -0
  59. package/skills/full-reverse-verify/SKILL.md +111 -0
  60. package/skills/full-reverse-verify/agents/openai.yaml +5 -0
  61. package/skills/full-reverse-verify/references/review-checklist.md +99 -0
@@ -0,0 +1,861 @@
1
+ // Workspace operations: init, atomic publication, evidence/external registration,
2
+ // digests, reconcile and build descriptions. Nothing here executes a target.
3
+ import { randomBytes } from "node:crypto";
4
+ import fsp from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { scanFile } from "./jsonscan.mjs";
7
+ import { DEFAULT_MAX_PARSE_BYTES, inspectFile } from "./rea.mjs";
8
+ import { workspaceScopeDigest } from "./scope.mjs";
9
+ import { validateSchema } from "./schemas.mjs";
10
+ import {
11
+ CliError,
12
+ EXIT,
13
+ atomicReplace,
14
+ canonicalDigest,
15
+ hashFile,
16
+ nowIso,
17
+ openWorkspace,
18
+ publishExclusive,
19
+ readJson,
20
+ resolveInWorkspace,
21
+ slugify,
22
+ stableStringify,
23
+ toInt,
24
+ usage,
25
+ withLock,
26
+ } from "./util.mjs";
27
+
28
+ export const INDEX_PATH = "evidence/index.json";
29
+ const LAYOUT_DIRS = ["evidence/records", "evidence/external", "evidence/diagnostics", "evidence/.staging", "specs"];
30
+ const KIND_DIR = {
31
+ "canonical-record": "evidence/records",
32
+ "canonical-bundle": "evidence/records",
33
+ "derived-view": "evidence/records",
34
+ external: "evidence/external",
35
+ diagnostic: "evidence/diagnostics",
36
+ "interrupted-output": "evidence/diagnostics",
37
+ };
38
+ export const kindDir = (kind) => KIND_DIR[kind];
39
+ export const EVIDENCE_DIRS = ["evidence/records", "evidence/external", "evidence/diagnostics"];
40
+ const MUTABLE_TARGETS = {
41
+ "investigation.json": "investigation",
42
+ "inventory.json": "inventory",
43
+ "claims.json": "claims",
44
+ "review.json": "review",
45
+ };
46
+
47
+ /** Directories that make up an empty workspace skeleton (and their ancestors). */
48
+ const SKELETON_DIRS = new Set(["evidence", ...LAYOUT_DIRS]);
49
+
50
+ async function readIndexFile(ws) {
51
+ const target = await resolveInWorkspace(ws.abs, INDEX_PATH);
52
+ if (!target.exists) throw new CliError(`evidence/index.json is missing; run init-workspace first`, { code: "index_missing" });
53
+ const index = await readJson(target.abs);
54
+ const result = await validateSchema("evidence-index", index);
55
+ if (!result.valid) throw new CliError("evidence/index.json does not match its schema", { code: "index_invalid", details: result.errors });
56
+ return index;
57
+ }
58
+
59
+ async function writeIndexFile(ws, index) {
60
+ index.updated_at = nowIso();
61
+ const result = await validateSchema("evidence-index", index);
62
+ if (!result.valid) throw new CliError("refusing to write an invalid evidence index", { code: "index_invalid", details: result.errors });
63
+ await atomicReplace((await resolveInWorkspace(ws.abs, INDEX_PATH)).abs, stableStringify(index));
64
+ }
65
+
66
+ async function readInvestigationMaybe(ws) {
67
+ const target = await resolveInWorkspace(ws.abs, "investigation.json");
68
+ if (!target.exists) return null;
69
+ try {
70
+ return await readJson(target.abs);
71
+ } catch {
72
+ throw new CliError("investigation.json is not valid JSON", { code: "investigation_invalid" });
73
+ }
74
+ }
75
+
76
+ /** Bind intrinsic digests to a build: every digest must equal the build's exact identity digest. */
77
+ export function matchBuild(builds, digests) {
78
+ if (!Array.isArray(builds) || digests.length === 0) return null;
79
+ return builds.find((build) => digests.every((digest) => digest === build.identity?.sha256)) ?? null;
80
+ }
81
+
82
+ function targetFor(investigation, binding, declaredBuildId) {
83
+ const base = { binding_source: binding.source, binding_digests: binding.digests, identity_state: binding.identity_state ?? null };
84
+ const builds = investigation?.builds ?? [];
85
+ if (binding.digests.length > 0) {
86
+ const match = matchBuild(builds, binding.digests);
87
+ if (declaredBuildId) {
88
+ if (!match || match.build_id !== declaredBuildId) {
89
+ throw new CliError(
90
+ `--build-id ${declaredBuildId} does not match the record's own target digest ${binding.digests.join(",")}; refusing cross-target publication`,
91
+ { code: "cross_target" },
92
+ );
93
+ }
94
+ }
95
+ return match
96
+ ? { association: "intrinsic", build_id: match.build_id, ...base }
97
+ : { association: "unbound", build_id: null, ...base };
98
+ }
99
+ if (declaredBuildId) {
100
+ if (!builds.some((build) => build.build_id === declaredBuildId)) {
101
+ throw new CliError(`--build-id ${declaredBuildId} is not a build in investigation.json`, { code: "unknown_build" });
102
+ }
103
+ return { association: "declared", build_id: declaredBuildId, ...base };
104
+ }
105
+ return { association: "unbound", build_id: null, ...base };
106
+ }
107
+
108
+ // --- init-workspace -----------------------------------------------------------
109
+
110
+ /**
111
+ * True when the directory holds nothing but (recursively empty) layout
112
+ * directories, i.e. an interrupted earlier init that never wrote the index.
113
+ */
114
+ async function isEmptySkeleton(abs, rel = "") {
115
+ for (const entry of await fsp.readdir(path.join(abs, rel), { withFileTypes: true })) {
116
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
117
+ if (!entry.isDirectory() || !SKELETON_DIRS.has(childRel) || !(await isEmptySkeleton(abs, childRel))) return false;
118
+ }
119
+ return true;
120
+ }
121
+
122
+ export async function initWorkspace(flags) {
123
+ if (!flags.workspace) throw usage("--workspace DIR is required");
124
+ const investigationId = flags["investigation-id"] ?? `inv_${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${randomBytes(3).toString("hex")}`;
125
+ if (!/^inv_[a-z0-9][a-z0-9_-]{2,62}$/.test(investigationId)) throw usage("--investigation-id must match ^inv_[a-z0-9][a-z0-9_-]{2,62}$");
126
+ const requested = path.resolve(flags.workspace);
127
+
128
+ // Phase 1: decide whether the directory may be initialized. Nothing is written here.
129
+ const rootStat = await fsp.stat(requested).catch((error) => {
130
+ if (error.code === "ENOENT") return null;
131
+ throw error;
132
+ });
133
+ let ws = null;
134
+ let hasIndex = false;
135
+ if (rootStat) {
136
+ if (!rootStat.isDirectory()) throw new CliError(`workspace is not a directory: ${requested}`, { code: "workspace_missing" });
137
+ ws = await openWorkspace(requested);
138
+ for (const dir of LAYOUT_DIRS) {
139
+ const layout = await resolveInWorkspace(ws.abs, dir);
140
+ if (layout.exists && !layout.stat.isDirectory()) throw new CliError(`${dir} exists and is not a directory`, { code: "workspace_exists" });
141
+ }
142
+ const indexTarget = await resolveInWorkspace(ws.abs, INDEX_PATH);
143
+ if (indexTarget.exists) {
144
+ hasIndex = true;
145
+ const index = await readIndexFile(ws);
146
+ if (index.investigation_id !== investigationId) {
147
+ throw new CliError(`workspace already belongs to ${index.investigation_id}`, { code: "workspace_exists" });
148
+ }
149
+ } else if (!(await isEmptySkeleton(ws.abs))) {
150
+ throw new CliError(
151
+ `${ws.abs} is not empty and is not an investigation workspace; init-workspace only initializes an empty directory or re-opens a workspace with the same --investigation-id. Nothing was written`,
152
+ { code: "workspace_not_empty" },
153
+ );
154
+ }
155
+ }
156
+ const existingInvestigation = ws ? await readInvestigationMaybe(ws) : null;
157
+ if (existingInvestigation && existingInvestigation.investigation_id !== investigationId) {
158
+ throw new CliError("investigation.json belongs to a different investigation", { code: "workspace_exists" });
159
+ }
160
+
161
+ // Phase 2: writes.
162
+ if (!ws) {
163
+ await fsp.mkdir(requested, { recursive: true });
164
+ ws = await openWorkspace(requested);
165
+ }
166
+ for (const dir of LAYOUT_DIRS) await fsp.mkdir(path.join(ws.abs, dir), { recursive: true });
167
+ const created = [];
168
+ if (!hasIndex) {
169
+ await writeIndexFile(ws, { schema_version: "full-reverse.evidence-index/1", investigation_id: investigationId, updated_at: nowIso(), entries: [] });
170
+ created.push(INDEX_PATH);
171
+ }
172
+ if (!existingInvestigation && flags.objective) {
173
+ const investigation = {
174
+ schema_version: "full-reverse.investigation/1",
175
+ investigation_id: investigationId,
176
+ created_at: nowIso(),
177
+ objective: flags.objective,
178
+ non_goals: [],
179
+ workspace: { path: ws.abs, allowed_output_paths: [ws.abs] },
180
+ builds: [],
181
+ questions: [],
182
+ authorizations: [],
183
+ tools: [],
184
+ phase: "scope",
185
+ steps: [],
186
+ lifecycle: { last_resumed_at: null, review_sha256: null, updated_at: nowIso() },
187
+ };
188
+ await atomicReplace(path.join(ws.abs, "investigation.json"), stableStringify(investigation));
189
+ created.push("investigation.json");
190
+ }
191
+ return { workspace: ws.abs, investigation_id: investigationId, created, directories: LAYOUT_DIRS, staging_dir: path.join(ws.abs, "evidence/.staging") };
192
+ }
193
+
194
+ // --- publish (mutable files) --------------------------------------------------
195
+
196
+ async function readStdin(limit) {
197
+ const chunks = [];
198
+ let size = 0;
199
+ for await (const chunk of process.stdin) {
200
+ size += chunk.length;
201
+ if (size > limit) throw new CliError("stdin exceeds the 64 MiB publish limit", { code: "too_large" });
202
+ chunks.push(chunk);
203
+ }
204
+ return Buffer.concat(chunks);
205
+ }
206
+
207
+ export async function publishFile(flags) {
208
+ const ws = await openWorkspace(flags.workspace);
209
+ const target = flags.target;
210
+ if (!target) throw usage("--target REL is required");
211
+ if (!flags.source) throw usage("--source FILE|- is required");
212
+ const isSpec = /^specs\/.+\.md$/.test(target);
213
+ const schemaName = MUTABLE_TARGETS[target] ?? null;
214
+ if (!schemaName && !isSpec) {
215
+ throw new CliError(
216
+ `publish only writes ${Object.keys(MUTABLE_TARGETS).join(", ")} and specs/**/*.md; evidence/** is written by publish-evidence/register-external and evidence/index.json by the helper`,
217
+ { exit: EXIT.USAGE, code: "target_not_allowed" },
218
+ );
219
+ }
220
+ if (flags.schema && flags.schema !== schemaName) throw usage(`--schema ${flags.schema} does not match target ${target}`);
221
+ const resolved = await resolveInWorkspace(ws.abs, target);
222
+ const data = flags.source === "-" ? await readStdin(64 * 1024 * 1024) : await fsp.readFile(flags.source);
223
+ let report = { schema: schemaName ?? "none", valid: true };
224
+ if (schemaName) {
225
+ let doc;
226
+ try {
227
+ doc = JSON.parse(data.toString("utf8"));
228
+ } catch (error) {
229
+ throw new CliError(`source is not valid JSON: ${error.message}`, { code: "invalid_json" });
230
+ }
231
+ const result = await validateSchema(schemaName, doc);
232
+ if (!result.valid) {
233
+ throw new CliError(`${target} does not match schema ${schemaName}; nothing was written`, { code: "schema_invalid", details: result.errors });
234
+ }
235
+ const index = await readIndexFile(ws);
236
+ if (doc.investigation_id !== index.investigation_id) {
237
+ throw new CliError(`investigation_id ${doc.investigation_id} does not match the workspace (${index.investigation_id})`, { code: "investigation_mismatch" });
238
+ }
239
+ } else if (data.includes(0)) {
240
+ throw new CliError("spec files must be text", { code: "invalid_text" });
241
+ }
242
+ await fsp.mkdir(path.dirname(resolved.abs), { recursive: true });
243
+ await resolveInWorkspace(ws.abs, target); // re-check after mkdir
244
+ await atomicReplace(resolved.abs, data);
245
+ const { sha256, bytes } = await hashFile(resolved.abs);
246
+ return { published: target, sha256, bytes, replaced: resolved.exists, ...report };
247
+ }
248
+
249
+ // --- publish-evidence -----------------------------------------------------------
250
+
251
+ function parseCommandJson(value) {
252
+ if (value === undefined) return null;
253
+ let text = value;
254
+ if (!value.trim().startsWith("[")) {
255
+ // treat as file path
256
+ throw usage("--command-json must be a JSON array of strings (inline) or use @FILE");
257
+ }
258
+ let parsed;
259
+ try {
260
+ parsed = JSON.parse(text);
261
+ } catch (error) {
262
+ throw usage(`--command-json is not valid JSON: ${error.message}`);
263
+ }
264
+ if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string")) throw usage("--command-json must be an array of strings");
265
+ return parsed;
266
+ }
267
+
268
+ async function commandFromFlag(value) {
269
+ if (value === undefined) return null;
270
+ if (value.startsWith("@")) return parseCommandJson(await fsp.readFile(value.slice(1), "utf8"));
271
+ return parseCommandJson(value);
272
+ }
273
+
274
+ function extensionFor(contentType) {
275
+ if (["rea-record", "rea-bundle", "rea-error", "json"].includes(contentType)) return "json";
276
+ if (contentType === "jsonl") return "jsonl";
277
+ if (contentType === "binary") return "bin";
278
+ return "txt";
279
+ }
280
+
281
+ /**
282
+ * Validate a caller-supplied source file and canonicalize its location. The
283
+ * final component must be a regular file (never a symlink/FIFO/device); the
284
+ * parent directory is realpath-ed so a workspace reached through a symlink
285
+ * compares equal to the canonical root that openWorkspace returns.
286
+ */
287
+ async function stagedSource(ws, stagedArg, name) {
288
+ if (!stagedArg) throw usage(`--${name} FILE is required`);
289
+ const lexical = path.resolve(stagedArg);
290
+ const stat = await fsp.lstat(lexical).catch(() => null);
291
+ if (!stat) throw new CliError(`staged file does not exist: ${lexical}`, { code: "missing" });
292
+ if (!stat.isFile()) throw new CliError(`staged path must be a regular file (no symlinks, FIFOs or devices): ${lexical}`, { code: "not_regular_file" });
293
+ const abs = path.join(await fsp.realpath(path.dirname(lexical)), path.basename(lexical));
294
+ const relToWs = path.relative(ws.abs, abs).split(path.sep).join("/");
295
+ const insideWorkspace = relToWs !== "" && relToWs !== ".." && !relToWs.startsWith("../") && !path.isAbsolute(relToWs);
296
+ if (insideWorkspace) {
297
+ for (const dir of EVIDENCE_DIRS) {
298
+ if (relToWs === dir || relToWs.startsWith(`${dir}/`)) throw new CliError("staged file is already inside published evidence", { code: "already_published" });
299
+ }
300
+ }
301
+ return { abs, inStaging: insideWorkspace && relToWs.startsWith("evidence/.staging/") };
302
+ }
303
+
304
+ // --- publication identity -------------------------------------------------------------
305
+ //
306
+ // One index entry == one file == one semantic publication. The identity of a
307
+ // publication is the digest of everything that gives the bytes meaning (kind,
308
+ // status, invocation outcome, command, step, stderr bytes, target, producer,
309
+ // note, external metadata); only where/when it is stored is excluded. The same
310
+ // bytes published with a different outcome or metadata therefore become a new
311
+ // entry with its own file, while an exact retry maps to the existing entry.
312
+ // Blob identity stays bytes-based: ext_id is ext_<sha256>; record evidence_ids
313
+ // are intrinsic; entries/files are what disambiguate provenance.
314
+
315
+ export function semanticDigest(entry) {
316
+ const { entry_id: _id, file: _file, published_at: _at, ...rest } = entry;
317
+ const body = { ...rest, invocation: { ...rest.invocation, stderr_file: null } };
318
+ if (body.external) body.external = { ...body.external, registered_at: null, file: { ...body.external.file, path: null } };
319
+ return canonicalDigest(body);
320
+ }
321
+
322
+ /** Attach the digest-derived entry_id and file name to a draft entry. */
323
+ function identify(draft, { dir, slug, ext }) {
324
+ const digest = semanticDigest(draft);
325
+ const short = digest.slice(0, 16);
326
+ const { kind, ...rest } = draft;
327
+ const file = `${dir}/${short}-${slug}.${ext}`;
328
+ const entry = { entry_id: `ei_${short}`, kind, file, ...rest };
329
+ if (entry.external) entry.external = { ...entry.external, file: { ...entry.external.file, path: file } };
330
+ return { digest, entry };
331
+ }
332
+
333
+ async function verifyIndexedFile(ws, entry) {
334
+ const resolved = await resolveInWorkspace(ws.abs, entry.file, { allowMissing: false });
335
+ const live = await hashFile(resolved.abs);
336
+ if (live.sha256 !== entry.sha256 || live.bytes !== entry.bytes) {
337
+ throw new CliError(`${entry.file} no longer matches its indexed sha256/bytes; the saved evidence was modified`, { code: "evidence_modified" });
338
+ }
339
+ }
340
+
341
+ /** Copy the staged snapshot to its exclusive destination; an existing destination must hold identical bytes. */
342
+ async function placeFile(ws, stagedAbs, file, sha256) {
343
+ const probe = await resolveInWorkspace(ws.abs, file);
344
+ await fsp.mkdir(path.dirname(probe.abs), { recursive: true });
345
+ const dest = await resolveInWorkspace(ws.abs, file); // re-check after mkdir
346
+ const outcome = await publishExclusive(stagedAbs, dest.abs, { expectedSha256: sha256 });
347
+ if (outcome === "exists") {
348
+ const live = await hashFile(dest.abs);
349
+ if (live.sha256 !== sha256) throw new CliError(`destination ${file} exists with different content`, { code: "destination_conflict", exit: EXIT.IO });
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Add an entry to the in-memory index, or recognize an exact retry. An existing
355
+ * entry with the same id must have the same semantics and a verified live file;
356
+ * an index that was edited or a truncated-id collision fails closed.
357
+ */
358
+ async function ensureEntry(ws, index, entry, digest, stagedAbs) {
359
+ const existing = index.entries.find((candidate) => candidate.entry_id === entry.entry_id);
360
+ if (existing) {
361
+ // The slug is a cosmetic part of the file name and not of the identity: an exact retry
362
+ // under another --name reuses the existing file, provided the file lives where this
363
+ // identity puts it (same directory, digest-derived prefix) and the entry is consistent.
364
+ const prefix = `${entry.entry_id.slice(3)}-`;
365
+ const sameHome = path.posix.dirname(existing.file) === path.posix.dirname(entry.file) && path.posix.basename(existing.file).startsWith(prefix);
366
+ const sameExternalPath = !existing.external || existing.external.file.path === existing.file;
367
+ if (!sameHome || !sameExternalPath || semanticDigest(existing) !== digest) {
368
+ throw new CliError(`entry ${entry.entry_id} is already indexed with different content or metadata; refusing to merge publications`, { code: "entry_conflict", exit: EXIT.IO });
369
+ }
370
+ await verifyIndexedFile(ws, existing);
371
+ return { entry: existing, created: false };
372
+ }
373
+ if (index.entries.some((candidate) => candidate.file === entry.file)) {
374
+ throw new CliError(`${entry.file} is already indexed by another entry`, { code: "entry_conflict", exit: EXIT.IO });
375
+ }
376
+ await placeFile(ws, stagedAbs, entry.file, entry.sha256);
377
+ const stored = { ...entry, published_at: nowIso() };
378
+ index.entries.push(stored);
379
+ return { entry: stored, created: true };
380
+ }
381
+
382
+ function companionDraft(inspected) {
383
+ return {
384
+ kind: "diagnostic",
385
+ sha256: inspected.sha256,
386
+ bytes: inspected.bytes,
387
+ status: "complete",
388
+ content_type: inspected.content_type === "empty" ? "empty" : inspected.content_type === "binary" ? "binary" : "text",
389
+ producer: { name: null, version: null, provider: null },
390
+ operation: null,
391
+ predicate_type: null,
392
+ records: [],
393
+ records_truncated: false,
394
+ id_authentication: { status: "not-applicable", reason: null },
395
+ invocation: { source: "unknown", command: null, exit_code: null, stderr_file: null, stderr_sha256: null, step_id: null },
396
+ target: { association: "unbound", build_id: null, binding_source: "none", binding_digests: [], identity_state: null },
397
+ external: null,
398
+ note: "stderr companion; shared by every publication whose stderr has these bytes",
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Producer metadata: what the saved bytes say about themselves wins. --producer-name and
404
+ * --producer-version only fill what the content does not declare; a flag that contradicts
405
+ * intrinsic metadata is refused instead of silently replacing it (it would enter the
406
+ * publication identity and nothing downstream re-checks it).
407
+ */
408
+ function producerFor(inspected, flags) {
409
+ const intrinsic = { name: inspected.content_type.startsWith("rea-") ? "rea" : null, version: inspected.producer_version ?? null };
410
+ const resolved = {};
411
+ for (const field of ["name", "version"]) {
412
+ const flag = flags[`producer-${field}`];
413
+ if (flag !== undefined && (typeof flag !== "string" || flag === "")) throw usage(`--producer-${field} needs a non-empty value`);
414
+ if (flag !== undefined && intrinsic[field] !== null && flag !== intrinsic[field]) {
415
+ throw usage(`--producer-${field} ${flag} contradicts the producer ${field} recorded in the content (${intrinsic[field]}); omit the flag`);
416
+ }
417
+ resolved[field] = intrinsic[field] ?? flag ?? null;
418
+ }
419
+ return { name: resolved.name, version: resolved.version, provider: inspected.provider };
420
+ }
421
+
422
+ export async function publishEvidence(flags) {
423
+ const ws = await openWorkspace(flags.workspace);
424
+ const maxParse = toInt(flags["max-parse-bytes"], "max-parse-bytes", { min: 1 }) ?? DEFAULT_MAX_PARSE_BYTES;
425
+ const staged = await stagedSource(ws, flags.staged, "staged");
426
+ const exitCode = flags["exit-code"] === undefined ? null : toInt(flags["exit-code"], "exit-code", { min: 0 });
427
+ const interrupted = flags.interrupted === true;
428
+ const command = await commandFromFlag(flags["command-json"]);
429
+ const source = flags.source ?? (command ? "cli" : "unknown");
430
+ if (!["cli", "session", "export", "unknown"].includes(source)) throw usage("--source must be cli|session|export|unknown");
431
+ const stepId = flags["step-id"] ?? null;
432
+ if (stepId !== null && !/^step_[a-z0-9][a-z0-9_-]{0,62}$/.test(stepId)) throw usage("--step-id must match ^step_[a-z0-9][a-z0-9_-]{0,62}$");
433
+ const declaredBuild = flags["build-id"] ?? null;
434
+ if (declaredBuild !== null && !/^bld_[a-z0-9][a-z0-9_-]{0,62}$/.test(declaredBuild)) throw usage("--build-id must match ^bld_[a-z0-9][a-z0-9_-]{0,62}$");
435
+ const inspected = await inspectFile(staged.abs, { maxParseBytes: maxParse });
436
+ const investigation = await readInvestigationMaybe(ws);
437
+
438
+ // Classify by content and caller-reported outcome.
439
+ let kind;
440
+ let status;
441
+ const notes = [];
442
+ if (interrupted) {
443
+ kind = "interrupted-output";
444
+ status = "interrupted";
445
+ } else if (inspected.kind_hint && (exitCode === null || exitCode === 0)) {
446
+ kind = inspected.kind_hint;
447
+ status = "complete";
448
+ } else {
449
+ kind = "diagnostic";
450
+ status =
451
+ (exitCode !== null && exitCode !== 0) ||
452
+ inspected.content_type === "rea-error" ||
453
+ inspected.id_authentication.status === "failed" ||
454
+ inspected.structure === "incomplete" ||
455
+ inspected.jsonl_ok === false ||
456
+ inspected.content_type === "empty"
457
+ ? "error"
458
+ : "complete";
459
+ if (inspected.kind_hint && exitCode) notes.push(`valid REA output saved as diagnostic because exit code was ${exitCode}`);
460
+ if (!inspected.kind_hint && (inspected.content_type === "rea-record" || inspected.content_type === "rea-bundle")) {
461
+ notes.push(`not publishable as canonical evidence: ${inspected.reasons.join(", ") || inspected.id_authentication.reason}`);
462
+ }
463
+ }
464
+ const canonical = kind === "canonical-record" || kind === "canonical-bundle" || kind === "derived-view";
465
+ const target = canonical
466
+ ? targetFor(investigation, inspected.binding, declaredBuild)
467
+ : { association: "unbound", build_id: null, binding_source: "none", binding_digests: [], identity_state: null };
468
+ if (!canonical && declaredBuild) notes.push("--build-id ignored for non-canonical output");
469
+ if (canonical && target.association === "unbound" && inspected.binding.digests.length > 0) {
470
+ notes.push("record target digest matches no build in investigation.json; it is unbound until the build exists");
471
+ }
472
+
473
+ // The stderr companion is its own entry whose identity is its bytes plus the
474
+ // companion role; it is shared by every publication with equal stderr bytes
475
+ // and never collides with a main output that happens to have the same bytes.
476
+ let companion = null;
477
+ if (flags["stderr-file"]) {
478
+ const support = await stagedSource(ws, flags["stderr-file"], "stderr-file");
479
+ const supportInspected = await inspectFile(support.abs, { shallow: true });
480
+ companion = { support, ...identify(companionDraft(supportInspected), { dir: kindDir("diagnostic"), slug: "stderr", ext: "txt" }) };
481
+ }
482
+
483
+ const slug = slugify(flags.name ?? inspected.operation ?? (kind === "diagnostic" ? "diagnostic" : "output"));
484
+ const main = identify(
485
+ {
486
+ kind,
487
+ sha256: inspected.sha256,
488
+ bytes: inspected.bytes,
489
+ status,
490
+ content_type: inspected.content_type,
491
+ producer: producerFor(inspected, flags),
492
+ operation: inspected.operation,
493
+ predicate_type: inspected.predicate_type,
494
+ records: canonical ? inspected.records : [],
495
+ records_truncated: canonical ? inspected.records_truncated : false,
496
+ id_authentication: canonical ? inspected.id_authentication : { status: "not-applicable", reason: null },
497
+ invocation: {
498
+ source,
499
+ command,
500
+ exit_code: exitCode,
501
+ stderr_file: companion?.entry.file ?? null,
502
+ stderr_sha256: companion?.entry.sha256 ?? null,
503
+ step_id: stepId,
504
+ },
505
+ target,
506
+ external: null,
507
+ note: [flags.note, ...notes].filter(Boolean).join("; "),
508
+ },
509
+ { dir: kindDir(kind), slug, ext: extensionFor(inspected.content_type) },
510
+ );
511
+
512
+ const result = await withLock(path.join(ws.abs, "evidence", ".index.lock"), async () => {
513
+ const index = await readIndexFile(ws);
514
+ // stderr is always preserved, even when the main output turns out to be an exact retry.
515
+ const stderr = companion ? await ensureEntry(ws, index, companion.entry, companion.digest, companion.support.abs) : null;
516
+ const published = await ensureEntry(ws, index, main.entry, main.digest, staged.abs);
517
+ if (stderr?.created || published.created) await writeIndexFile(ws, index);
518
+ if (staged.inStaging) await fsp.rm(staged.abs, { force: true });
519
+ if (companion?.support.inStaging) await fsp.rm(companion.support.abs, { force: true });
520
+ return { already_published: !published.created, entry: published.entry, stderr_entry: stderr?.entry ?? null, stderr_reused: stderr ? !stderr.created : null };
521
+ });
522
+ return {
523
+ ...result,
524
+ staged_retained: !staged.inStaging,
525
+ summary: {
526
+ entry_id: result.entry.entry_id,
527
+ kind: result.entry.kind,
528
+ status: result.entry.status,
529
+ file: result.entry.file,
530
+ sha256: result.entry.sha256,
531
+ evidence_ids: result.entry.records.map((record) => record.evidence_id),
532
+ binding_digests: result.entry.target.binding_digests,
533
+ association: result.entry.target.association,
534
+ build_id: result.entry.target.build_id,
535
+ id_authentication: result.entry.id_authentication,
536
+ stderr_entry_id: result.stderr_entry?.entry_id ?? null,
537
+ },
538
+ };
539
+ }
540
+
541
+ // --- register-external ------------------------------------------------------------
542
+
543
+ export async function registerExternal(flags) {
544
+ const ws = await openWorkspace(flags.workspace);
545
+ const staged = await stagedSource(ws, flags.staged, "staged");
546
+ if (!flags.metadata) throw usage("--metadata META.json is required");
547
+ let meta;
548
+ try {
549
+ meta = await readJson(flags.metadata);
550
+ } catch (error) {
551
+ if (error instanceof SyntaxError) throw usage(`--metadata is not valid JSON: ${error.message}`);
552
+ throw error;
553
+ }
554
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) throw usage("--metadata must be a JSON object");
555
+ const inspected = await inspectFile(staged.abs, { shallow: true });
556
+ const investigation = await readInvestigationMaybe(ws);
557
+ const stepId = flags["step-id"] ?? null;
558
+ if (stepId !== null && !/^step_[a-z0-9][a-z0-9_-]{0,62}$/.test(stepId)) throw usage("--step-id must match ^step_[a-z0-9][a-z0-9_-]{0,62}$");
559
+ const slug = slugify(flags.name ?? meta.observation_kind ?? "external");
560
+ const originalName = path.basename(staged.abs);
561
+ const ext = path.extname(originalName).replace(/[^.a-zA-Z0-9]/g, "").replace(/^\.+/, "").slice(0, 10).toLowerCase() || "bin";
562
+ const external = {
563
+ ext_id: `ext_${inspected.sha256}`,
564
+ file: { path: "evidence/external/pending", sha256: inspected.sha256, bytes: inspected.bytes },
565
+ media_type: meta.media_type ?? "application/octet-stream",
566
+ observation_kind: meta.observation_kind,
567
+ description: meta.description,
568
+ acquisition: meta.acquisition,
569
+ sensitivity: meta.sensitivity,
570
+ target_association: meta.target_association,
571
+ limitations: meta.limitations ?? [],
572
+ registered_at: nowIso(),
573
+ };
574
+ const check = await validateSchema("external-evidence", external);
575
+ if (!check.valid) throw new CliError("metadata does not form a valid external-evidence record", { code: "schema_invalid", details: check.errors });
576
+ const association = external.target_association;
577
+ if (association.status === "declared") {
578
+ if (!association.build_id || !(investigation?.builds ?? []).some((b) => b.build_id === association.build_id)) {
579
+ throw new CliError("target_association.build_id must name a build in investigation.json", { code: "unknown_build" });
580
+ }
581
+ } else if (association.build_id !== null) {
582
+ throw new CliError("target_association.build_id must be null unless status is declared", { code: "schema_invalid" });
583
+ }
584
+ // Same bytes with different provenance/metadata are different publications:
585
+ // ext_id stays bytes-based, the entry and file carry the provenance.
586
+ const registration = identify(
587
+ {
588
+ kind: "external",
589
+ sha256: inspected.sha256,
590
+ bytes: inspected.bytes,
591
+ status: "complete",
592
+ content_type: ["binary", "empty", "text", "json", "jsonl"].includes(inspected.content_type) ? inspected.content_type : "json",
593
+ producer: { name: null, version: null, provider: null },
594
+ operation: null,
595
+ predicate_type: null,
596
+ records: [],
597
+ records_truncated: false,
598
+ id_authentication: { status: "not-applicable", reason: null },
599
+ invocation: { source: "external", command: null, exit_code: null, stderr_file: null, stderr_sha256: null, step_id: stepId },
600
+ target: {
601
+ association: association.status === "declared" ? "declared" : "unbound",
602
+ build_id: association.build_id,
603
+ binding_source: "none",
604
+ binding_digests: [],
605
+ identity_state: null,
606
+ },
607
+ external,
608
+ note: flags.note ?? "",
609
+ },
610
+ { dir: kindDir("external"), slug, ext },
611
+ );
612
+ const result = await withLock(path.join(ws.abs, "evidence", ".index.lock"), async () => {
613
+ const index = await readIndexFile(ws);
614
+ const registered = await ensureEntry(ws, index, registration.entry, registration.digest, staged.abs);
615
+ if (registered.created) await writeIndexFile(ws, index);
616
+ if (staged.inStaging) await fsp.rm(staged.abs, { force: true });
617
+ return { already_registered: !registered.created, entry: registered.entry };
618
+ });
619
+ return { ...result, staged_retained: !staged.inStaging, summary: { ext_id: external.ext_id, entry_id: result.entry.entry_id, file: result.entry.file, sha256: inspected.sha256 } };
620
+ }
621
+
622
+ // --- digests ------------------------------------------------------------------------
623
+
624
+ export async function walkFiles(rootAbs, relBase, { skipDirs = [] } = {}) {
625
+ const out = [];
626
+ const visit = async (abs, rel) => {
627
+ let entries;
628
+ try {
629
+ entries = await fsp.readdir(abs, { withFileTypes: true });
630
+ } catch (error) {
631
+ if (error.code === "ENOENT") return;
632
+ throw error;
633
+ }
634
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
635
+ for (const entry of entries) {
636
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
637
+ if (skipDirs.includes(childRel)) continue;
638
+ if (entry.isSymbolicLink()) out.push({ rel: childRel, symlink: true });
639
+ else if (entry.isDirectory()) await visit(path.join(abs, entry.name), childRel);
640
+ else if (entry.isFile()) out.push({ rel: childRel, symlink: false });
641
+ }
642
+ };
643
+ await visit(path.join(rootAbs, relBase), relBase);
644
+ return out;
645
+ }
646
+
647
+ export async function digests(flags, { paths = [] } = {}) {
648
+ const ws = await openWorkspace(flags.workspace);
649
+ const wanted = paths.length
650
+ ? paths
651
+ : ["investigation.json", "inventory.json", INDEX_PATH, "claims.json", "review.json"];
652
+ const files = [];
653
+ const addFile = async (rel) => {
654
+ const resolved = await resolveInWorkspace(ws.abs, rel);
655
+ if (!resolved.exists) return files.push({ path: rel, exists: false, sha256: null, bytes: null });
656
+ if (!resolved.stat.isFile()) throw new CliError(`not a regular file: ${rel}`, { code: "not_regular_file" });
657
+ const { sha256, bytes } = await hashFile(resolved.abs);
658
+ files.push({ path: rel, exists: true, sha256, bytes });
659
+ };
660
+ for (const rel of wanted) await addFile(rel);
661
+ let specs = [];
662
+ if (!paths.length) {
663
+ const specFiles = (await walkFiles(ws.abs, "specs")).filter((f) => !f.symlink && f.rel.endsWith(".md"));
664
+ for (const file of specFiles) await addFile(file.rel);
665
+ specs = specFiles.map((f) => f.rel);
666
+ }
667
+ const byPath = Object.fromEntries(files.map((f) => [f.path, f]));
668
+ const digestOf = (rel) => (byPath[rel]?.exists ? { path: rel, sha256: byPath[rel].sha256 } : null);
669
+ return {
670
+ files,
671
+ reviewed_inputs_template: {
672
+ inventory: digestOf("inventory.json"),
673
+ evidence_index: digestOf(INDEX_PATH),
674
+ claims: digestOf("claims.json"),
675
+ specs: specs.map(digestOf),
676
+ scope: await workspaceScopeDigest(ws.abs),
677
+ },
678
+ claims_inputs_template: { inventory: digestOf("inventory.json"), evidence_index: digestOf(INDEX_PATH) },
679
+ };
680
+ }
681
+
682
+ // --- reconcile ------------------------------------------------------------------------
683
+
684
+ export async function reconcile(flags) {
685
+ const ws = await openWorkspace(flags.workspace);
686
+ const index = await readIndexFile(ws);
687
+ const findings = [];
688
+ const indexed = new Map(index.entries.map((entry) => [entry.file, entry]));
689
+ for (const entry of index.entries) {
690
+ const resolved = await resolveInWorkspace(ws.abs, entry.file).catch((error) => ({ error }));
691
+ if (resolved.error) {
692
+ findings.push({ severity: "error", code: resolved.error.code ?? "unsafe_path", file: entry.file, message: resolved.error.message });
693
+ continue;
694
+ }
695
+ if (!resolved.exists) {
696
+ findings.push({ severity: "error", code: "index_target_missing", file: entry.file, entry_id: entry.entry_id, message: "indexed file is missing" });
697
+ continue;
698
+ }
699
+ if (flags["no-hash"] !== true) {
700
+ const live = await hashFile(resolved.abs);
701
+ if (live.sha256 !== entry.sha256 || live.bytes !== entry.bytes) {
702
+ findings.push({ severity: "error", code: "index_hash_mismatch", file: entry.file, entry_id: entry.entry_id, message: "file content differs from the indexed digest" });
703
+ }
704
+ }
705
+ }
706
+ for (const dir of EVIDENCE_DIRS) {
707
+ for (const file of await walkFiles(ws.abs, dir)) {
708
+ if (file.symlink) findings.push({ severity: "error", code: "symlink_refused", file: file.rel, message: "symbolic link inside evidence" });
709
+ else if (!indexed.has(file.rel)) {
710
+ findings.push({ severity: "warning", code: "orphan_evidence_file", file: file.rel, message: "published file has no index entry; not adopted. Re-run publish-evidence with a staged copy only after review" });
711
+ }
712
+ }
713
+ }
714
+ for (const file of await walkFiles(ws.abs, "evidence/.staging")) {
715
+ findings.push({ severity: "warning", code: "staged_file", file: file.rel, message: "unpublished staged output (possible interrupted run); inspect before publishing or discarding" });
716
+ }
717
+ const lock = await resolveInWorkspace(ws.abs, "evidence/.index.lock");
718
+ if (lock.exists) findings.push({ severity: "warning", code: "stale_index_lock", file: "evidence/.index.lock", message: "index lock present; confirm no helper is running before removing" });
719
+ const everything = await walkFiles(ws.abs, "", { skipDirs: ["evidence/.staging"] });
720
+ for (const file of everything) {
721
+ if (/(^|\/)\.[^/]*\.tmp-\d+-[0-9a-f]{8}$/.test(file.rel)) {
722
+ findings.push({ severity: "warning", code: "interrupted_write_temp", file: file.rel, message: "leftover temporary file from an interrupted atomic write" });
723
+ }
724
+ }
725
+ const failed = findings.some((f) => f.severity === "error");
726
+ return { outcome: failed ? "fail" : "pass", findings, entries: index.entries.length };
727
+ }
728
+
729
+ // --- authenticated entries ------------------------------------------------------------
730
+
731
+ /**
732
+ * Re-derive everything an index entry claims about a canonical file from the bytes on disk:
733
+ * entry id and file prefix from the semantic digest, SHA-256/size, content classification,
734
+ * semantic evidence_id authentication of every record, record summaries and the intrinsic
735
+ * target digests. The index is a locator, never the authority.
736
+ */
737
+ async function authenticateCanonicalEntry(ws, entry, maxParseBytes) {
738
+ const fail = (code, message) => new CliError(`${entry.entry_id}: ${message}`, { code });
739
+ const short = semanticDigest(entry).slice(0, 16);
740
+ if (entry.entry_id !== `ei_${short}` || !path.posix.basename(entry.file).startsWith(`${short}-`)) {
741
+ throw fail("entry_identity_mismatch", "entry_id/file do not derive from the entry's semantic content; the index was edited");
742
+ }
743
+ const resolved = await resolveInWorkspace(ws.abs, entry.file, { allowMissing: false });
744
+ const live = await inspectFile(resolved.abs, { maxParseBytes });
745
+ if (live.sha256 !== entry.sha256 || live.bytes !== entry.bytes) {
746
+ throw fail("evidence_modified", `${entry.file} no longer matches its indexed sha256/bytes; the saved evidence was modified`);
747
+ }
748
+ if (live.kind_hint !== entry.kind) {
749
+ throw fail("entry_unauthenticated", `saved content classifies as ${live.kind_hint ?? live.content_type}, not ${entry.kind}${live.reasons.length ? ` (${live.reasons.join(", ")})` : ""}`);
750
+ }
751
+ if (live.id_authentication.status !== "verified") {
752
+ throw fail(
753
+ "entry_unauthenticated",
754
+ `evidence_id authentication is ${live.id_authentication.status}: ${live.id_authentication.reason}${live.parse === "header-only" ? `; raise --max-parse-bytes above ${live.bytes}` : ""}`,
755
+ );
756
+ }
757
+ const sameRecords =
758
+ live.records_truncated ||
759
+ (live.records.length === entry.records.length &&
760
+ live.records.every((record, i) => {
761
+ const other = entry.records[i];
762
+ return (
763
+ other &&
764
+ record.evidence_id === other.evidence_id &&
765
+ record.pointer === other.pointer &&
766
+ record.operation === other.operation &&
767
+ record.confidence === other.confidence &&
768
+ record.authority === other.authority &&
769
+ JSON.stringify(record.binding_digests) === JSON.stringify(other.binding_digests) &&
770
+ record.parent_evidence_id === other.parent_evidence_id
771
+ );
772
+ }));
773
+ if (!sameRecords) throw fail("entry_binding_mismatch", "index record summaries differ from the saved records");
774
+ const liveDigests = [...live.binding.digests].sort();
775
+ const indexDigests = [...entry.target.binding_digests].sort();
776
+ if (JSON.stringify(liveDigests) !== JSON.stringify(indexDigests) || live.binding.source !== entry.target.binding_source) {
777
+ throw fail("entry_binding_mismatch", "index target digests differ from the saved record's intrinsic target digests");
778
+ }
779
+ return { abs: resolved.abs, live };
780
+ }
781
+
782
+ // --- describe-build ---------------------------------------------------------------------
783
+
784
+ export async function describeBuild(flags) {
785
+ const buildId = flags["build-id"];
786
+ if (!buildId || !/^bld_[a-z0-9][a-z0-9_-]{0,62}$/.test(buildId)) throw usage("--build-id bld_<slug> is required");
787
+ if (flags.file) {
788
+ const abs = path.resolve(flags.file);
789
+ const stat = await fsp.stat(abs).catch(() => null);
790
+ if (!stat?.isFile()) throw new CliError("--file must be a regular file; for directories/apps use --entry from a published REA record", { code: "not_regular_file" });
791
+ const { sha256, bytes } = await hashFile(abs);
792
+ return {
793
+ build: {
794
+ build_id: buildId,
795
+ kind: flags.kind ?? "file",
796
+ name: flags.name ?? path.basename(abs),
797
+ identity: { method: "file-sha256", sha256, bytes, coverage: "complete", rea_format: null, note: "Full streamed SHA-256 of the file." },
798
+ local_path: abs,
799
+ parent_build_id: flags.parent ?? null,
800
+ },
801
+ };
802
+ }
803
+ if (flags.entry) {
804
+ const ws = await openWorkspace(flags.workspace);
805
+ const index = await readIndexFile(ws);
806
+ const entry = index.entries.find((e) => e.entry_id === flags.entry);
807
+ if (!entry) throw new CliError(`no such entry: ${flags.entry}`, { code: "unknown_entry" });
808
+ if (!["canonical-record", "canonical-bundle", "derived-view"].includes(entry.kind)) throw usage("--entry must be a canonical record");
809
+ const maxParse = toInt(flags["max-parse-bytes"], "max-parse-bytes", { min: 1 }) ?? DEFAULT_MAX_PARSE_BYTES;
810
+ const { abs, live } = await authenticateCanonicalEntry(ws, entry, maxParse);
811
+ const digests = [...new Set(live.binding.digests)];
812
+ if (digests.length !== 1) throw new CliError(`entry binds ${digests.length} distinct target digests; select a single-target record`, { code: "ambiguous_target" });
813
+ const scan = await scanFile(abs, ["/subject", "/records/*/subject"]);
814
+ const subjectText = [...scan.captures.values()].map((c) => c.text).find((t) => t && t !== "null");
815
+ const subject = subjectText ? JSON.parse(subjectText) : null;
816
+ if (!subject) {
817
+ throw new CliError("record has no subject (for example a process capture); describe the executable with --file instead", { code: "no_subject" });
818
+ }
819
+ const isDirectoryLike = subject.format === "directory";
820
+ let bytes = null;
821
+ let verifiedLocal = false;
822
+ if (!isDirectoryLike && typeof subject.local_path === "string") {
823
+ const stat = await fsp.stat(subject.local_path).catch(() => null);
824
+ if (stat?.isFile()) {
825
+ const live = await hashFile(subject.local_path);
826
+ if (live.sha256 !== digests[0]) {
827
+ throw new CliError("the file now at the recorded local_path no longer matches the record's subject digest", { code: "identity_changed" });
828
+ }
829
+ bytes = live.bytes;
830
+ verifiedLocal = true;
831
+ }
832
+ }
833
+ return {
834
+ build: {
835
+ build_id: buildId,
836
+ kind: flags.kind ?? (isDirectoryLike ? "directory" : "file"),
837
+ name: flags.name ?? subject.name ?? buildId,
838
+ identity: isDirectoryLike
839
+ ? {
840
+ method: "rea-subject-digest",
841
+ sha256: digests[0],
842
+ bytes: null,
843
+ coverage: "unknown",
844
+ rea_format: subject.format,
845
+ note: "Digest REA reported as the subject of the directory/bundle analysis. Manifest coverage is not established by this helper; set coverage from the record's own coverage fields and do not call it a whole-directory fingerprint unless complete.",
846
+ }
847
+ : {
848
+ method: "file-sha256",
849
+ sha256: digests[0],
850
+ bytes,
851
+ coverage: "complete",
852
+ rea_format: subject.format ?? null,
853
+ note: verifiedLocal ? "File SHA-256 reported by REA and re-verified against the local file." : "File SHA-256 reported by REA as the subject digest; local file was not re-hashed.",
854
+ },
855
+ local_path: typeof subject.local_path === "string" ? subject.local_path : null,
856
+ parent_build_id: flags.parent ?? null,
857
+ },
858
+ };
859
+ }
860
+ throw usage("describe-build needs --file PATH or --workspace D --entry ei_...");
861
+ }