diff --git a/docs/reference/project-workspace.md b/docs/reference/project-workspace.md index 3cac73b3c..4221b3f4b 100644 --- a/docs/reference/project-workspace.md +++ b/docs/reference/project-workspace.md @@ -7,6 +7,10 @@ without daemon-internal knowledge. This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md). The instance initializer delegates these exact workspace bytes to this owner. +To inspect or read an exact reconstruction under a selected work home, use +[`rig context reconstruction`](reconstructions.md). It shares catalog and work-home +resolution with `context work-install` and performs no installation or projection. + ## Default Shape `rig config init-workspace` creates the default workspace at diff --git a/docs/reference/reconstructions.md b/docs/reference/reconstructions.md new file mode 100644 index 000000000..2df19f2c1 --- /dev/null +++ b/docs/reference/reconstructions.md @@ -0,0 +1,90 @@ +# Read a reconstruction of selected work + +A reconstruction records an author's working understanding, its evidence routes, +constraints and gaps. `rig context reconstruction` reads an exact immutable +`openrig.reconstruction/v0` revision locally. It does not start a daemon, fetch +remote context, write records or transfer the author's authority. + +Inspect the purpose, depth, author-declared coverage, observation dates, complete +source manifest and approximate size before reading a view: + +```bash +rig context reconstruction inspect --project example --mission task --slice 01-example +rig context reconstruction view --project example --mission task --slice 01-example --item decision --json +``` + +Without `--store`, the store is `reconstructions/` under the selected project, +mission or slice home. Project selection follows `context work-install`: an +explicit catalog project, the only entry, the calling rig's association, the +deepest project containing `--cwd`, or the only unclaimed entry. Ambiguity is an +error. The project's configured `missions.root`, declared mission membership, +slice directory names and slice frontmatter IDs are honored. A workspace +without a catalog can use its own project home. No skill installation or context +projection happens during this lookup. + +Use `--store /absolute/local/store` for an imported store without resolving a +project. Supplying work selectors as well still validates that work home. +Revisions are full lowercase SHA-256 IDs; there is no implicit latest revision. + +## Faithful views and gaps + +Repeat `--item`, `--category` or `--tag` to select their union. A filtered view +always retains authority, constraint and gap items, plus transitive item +dependencies. It names omitted item IDs and uncited evidence routes, and keeps +declared reconstruction coverage separate from actual view coverage. Inspect +always provides the complete manifest; a filtered view provides cited routes. +A full view also includes uncited registered routes. + +Base records, deltas and pinned compositions retain inherited observation dates. +A revision's creation date does not refresh those observations. Composition +members do not imply an atomic capture. Authors are claimed attribution, and +coverage is authored description rather than proof of runtime state or acceptance. + +Unavailable records, missing selections, dependencies or citations make the +result `complete: false` and set exit status 1. Available composition members +remain visible with the missing member's exact store and revision. Unknown +schemas, invalid content hashes and unsupported numeric representations are +reported explicitly. The initial reader supports safe integer JSON numbers; +floating-point/exponent and unsafe integer representations are rejected rather +than assigned a different ID. Unicode keys follow v0's codepoint sorting. + +## Relocate a store without changing its records + +Parent and member pointers include absolute source-store paths in the hash. +Do not edit those paths in immutable records. Copy their closure into local +custody, then supply a separate JSON mapping: + +```json +[ + { + "sourceStore": "/original/work/reconstructions", + "localStore": "/local/import/reconstructions", + "sourceHost": "original-host" + } +] +``` + +```bash +rig context reconstruction inspect --store /local/import/reconstructions --store-map /local/map.json +``` + +The mapping is read-time location information, not an identity registry or a +remote fetch permission. It preserves source-host provenance. Since v0 pointers +do not contain a host discriminator, duplicate mappings for the same source +store are ambiguous and rejected. A store path that exists on this host can be +read directly; a path that has moved needs a mapping to its local location. + +## Bounds and byte checks + +Reads are limited to 8 MiB per regular revision file, 32 MiB per closure, 64 +record references and 128 JSON nesting levels. Symlink revision files are not +followed. Output defaults to 256 KiB; `--max-bytes` accepts 1 KiB through 8 MiB. +Oversized output fails explicitly rather than silently truncating a purportedly +complete view. Size estimates use UTF-8 bytes divided by four, not a model tokenizer. + +`--check-sources` hashes declared local regular files within 128 files and 64 MiB +total. It reports changed, unchanged, unavailable or unchecked bytes without +executing source commands, fetching opaque routes or updating observations. +Discovery declarations are shown but not scanned. Unchanged known files do not +establish semantic freshness or detect new queue decisions. There is no queue +discovery cursor, native writer or continuity-consumer replacement in this reader. diff --git a/packages/cli/src/commands/context-reconstruction.ts b/packages/cli/src/commands/context-reconstruction.ts new file mode 100644 index 000000000..7c27edbb0 --- /dev/null +++ b/packages/cli/src/commands/context-reconstruction.ts @@ -0,0 +1,69 @@ +import { Command } from "commander"; +import { join, resolve } from "node:path"; +import { ConfigStore } from "../config-store.js"; +import { readOpenRigEnv } from "../openrig-compat.js"; +import { resolveWorkContextHome } from "../lib/work-install.js"; +import { ReconstructionError, readReconstruction, readStoreMappings, reconstructionOutput } from "../lib/reconstruction-reader.js"; + +interface Options { + store?: string; storeMap?: string; project?: string; mission?: string; slice?: string; cwd?: string; + item: string[]; category: string[]; tag: string[]; checkSources?: boolean; maxBytes: string; json?: boolean; +} +const collect = (value: string, previous: string[]) => [...previous, value]; + +/** A local config/filesystem consumer. This command never starts or contacts a + * daemon, invokes a helper, writes revisions, or takes over the selected work. */ +export function reconstructionCommand(): Command { + const cmd = new Command("reconstruction") + .description("Inspect or read an exact immutable reconstruction locally (read-only v0 consumer)"); + for (const mode of ["inspect", "view"] as const) { + cmd.command(`${mode} `) + .description(mode === "inspect" ? "Inspect provenance, dates, size and the complete source manifest" : "Read a faithful selected view with dependencies and explicit omissions") + .option("--store ", "Explicit local v0/import store; default: selected work home/reconstructions") + .option("--store-map ", "JSON array of {sourceStore, localStore, sourceHost?}; read-time mapping only") + .option("--project ", "Catalog project, with the same inference as context work-install") + .option("--mission ", "Mission in the selected project's configured missions root") + .option("--slice ", "Slice under the selected mission") + .option("--cwd ", "Working directory used for project inference") + .option("--item ", "Include item (repeatable; selectors form a union)", collect, []) + .option("--category ", "Include category (repeatable)", collect, []) + .option("--tag ", "Include tag (repeatable)", collect, []) + .option("--check-sources", "Hash declared local files within 128 files / 64 MiB; never refresh observation dates") + .option("--max-bytes ", "Maximum complete output, 1024..8388608 UTF-8 bytes", "262144") + .option("--json", "Structured output; inspect always uses JSON") + .addHelpText("after", "\nSupports openrig.reconstruction/v0 with safe integer JSON numbers; other numeric representations are explicitly unsupported. No implicit remote reads. Context does not transfer authority.\n") + .action((revision: string, opts: Options) => { + try { + let store = opts.store; + let resolution: object = { mode: "explicit-store", project: null, home: null }; + if (store === undefined || opts.project !== undefined || opts.mission !== undefined || opts.slice !== undefined) { + const config = new ConfigStore(); + const home = resolveWorkContextHome({ + workspaceRoot: String(config.resolveWithSource("workspace.root").value), + catalogPath: String(config.resolveWithSource("workspace.catalog_path").value), + cwd: resolve(opts.cwd ?? process.cwd()), + ...(readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME") ? { sessionName: readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")! } : {}), + ...(opts.project === undefined ? {} : { project: opts.project }), + ...(opts.mission === undefined ? {} : { mission: opts.mission }), + ...(opts.slice === undefined ? {} : { slice: opts.slice }), + }); + if ("error" in home) throw new ReconstructionError(home.error.code, home.error.message); + resolution = { mode: store === undefined ? "work-home" : "explicit-store", ...home }; + store ??= join(home.home, "reconstructions"); + } + const result = readReconstruction({ store, revision, + ...(opts.storeMap ? { mappings: readStoreMappings(resolve(opts.storeMap)) } : {}), + items: opts.item, categories: opts.category, tags: opts.tag, checkSources: opts.checkSources }); + const response = { ...result, resolution }; + console.log(reconstructionOutput(response, mode, Boolean(opts.json), Number(opts.maxBytes))); + if (!result.complete) process.exitCode = 1; + } catch (error) { + const code = error instanceof ReconstructionError ? error.code : "reconstruction_unavailable"; + if (opts.json || mode === "inspect") console.log(JSON.stringify({ complete: false, error: { code, message: (error as Error).message } }, null, 2)); + else console.error(`${code}: ${(error as Error).message}`); + process.exitCode = 1; + } + }); + } + return cmd; +} diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index be922877a..4c19c948f 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -43,6 +43,7 @@ import { realDeps } from "./daemon.js"; import { prepareDaemonAutoStart } from "../daemon-auto-start.js"; import { readOpenRigEnv } from "../openrig-compat.js"; import type { StatusDeps } from "./status.js"; +import { reconstructionCommand } from "./context-reconstruction.js"; const contextRuntimeArg = enumArg(["claude-code", "claude", "codex"]); @@ -252,6 +253,8 @@ Examples: rig context trace --rig factory --pod dev --seat dev-qa --name LEARNED.md `); + cmd.addCommand(reconstructionCommand()); + const getDeps = (): StatusDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url), diff --git a/packages/cli/src/lib/reconstruction-reader.ts b/packages/cli/src/lib/reconstruction-reader.ts new file mode 100644 index 000000000..99195d501 --- /dev/null +++ b/packages/cli/src/lib/reconstruction-reader.ts @@ -0,0 +1,382 @@ +import { createHash } from "node:crypto"; +import { constants, openSync, closeSync, fstatSync, lstatSync, readSync, existsSync } from "node:fs"; +import { isAbsolute, join, normalize } from "node:path"; + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +type RecordValue = { [key: string]: Json }; +export const RECONSTRUCTION_SCHEMA = "openrig.reconstruction/v0"; +const MAX_DOCUMENT = 8 * 1024 * 1024; +const MAX_CLOSURE = 32 * 1024 * 1024; +const PRESERVED = ["authority", "constraint", "gap"]; + +export class ReconstructionError extends Error { + readonly code: string; + constructor(code: string, message: string) { super(message); this.code = code; } +} +function requireValue(value: unknown, message: string): asserts value { + if (!value) throw new ReconstructionError("invalid_reconstruction", message); +} +function object(value: Json | undefined, label: string): RecordValue { + requireValue(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); + return value; +} +function list(value: Json | undefined, label: string): Json[] { + requireValue(Array.isArray(value), `${label} must be an array`); return value; +} +function text(value: Json | undefined, label: string): string { + requireValue(typeof value === "string" && value.trim(), `${label} must be a nonempty string`); return value; +} +function strings(value: Json | undefined, label: string): string[] { + const values = list(value === undefined ? [] : value, label); + requireValue(values.every(v => typeof v === "string"), `${label} must contain strings`); return values as string[]; +} +function timestamp(value: Json | undefined, label: string, nullable = false): void { + if (value === null && nullable) return; + requireValue(typeof value === "string" && /(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value)), `${label} must have an ISO timestamp with timezone${nullable ? " or null" : ""}`); +} +function absolute(value: Json | undefined, label: string): string { + const name = text(value, label); requireValue(isAbsolute(name), `${label} must be an absolute local path`); return normalize(name); +} +function revision(value: Json | undefined): string { + const id = text(value, "revision"); requireValue(/^[a-f0-9]{64}$/.test(id), "revision must be a full lowercase SHA256 ID"); return id; +} +function validUnicode(value: string): void { + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++i); + requireValue(next >= 0xdc00 && next <= 0xdfff, "unpaired Unicode surrogate is unsupported by v0 UTF-8 hashing"); + } else requireValue(unit < 0xdc00 || unit > 0xdfff, "unpaired Unicode surrogate is unsupported by v0 UTF-8 hashing"); + } +} + +/** v0 is Python sorted compact JSON, not JS property order. Reject numeric + * representations we cannot reproduce, rather than silently changing an ID. */ +export function parseReconstructionJson(input: string): Json { + let at = 0; + const space = () => { while (/[ \n\r\t]/.test(input[at] ?? "x")) at++; }; + const quoted = (): string => { + const start = at++; + while (at < input.length) { + const char = input[at++]; + if (char === "\\") at++; + else if (char === '"') { + const result: unknown = JSON.parse(input.slice(start, at)); + requireValue(typeof result === "string", "invalid JSON string"); validUnicode(result); return result; + } + } + throw new ReconstructionError("invalid_reconstruction", "unterminated JSON string"); + }; + const value = (depth: number): Json => { + requireValue(depth <= 128, "JSON nesting exceeds 128"); space(); + if (input[at] === '"') return quoted(); + if (input[at] === "{") { + at++; space(); const result: RecordValue = Object.create(null) as RecordValue; + if (input[at] !== "}") while (true) { + requireValue(input[at] === '"', "expected JSON object key"); const key = quoted(); space(); + requireValue(!Object.hasOwn(result, key), `duplicate JSON key: ${key}`); + requireValue(input[at++] === ":", "expected JSON colon"); result[key] = value(depth + 1); space(); + if (input[at] !== ",") break; at++; space(); + } + requireValue(input[at++] === "}", "expected JSON object end"); return result; + } + if (input[at] === "[") { + at++; space(); const result: Json[] = []; + if (input[at] !== "]") while (true) { + result.push(value(depth + 1)); space(); if (input[at] !== ",") break; at++; + } + requireValue(input[at++] === "]", "expected JSON array end"); return result; + } + for (const [token, result] of [["true", true], ["false", false], ["null", null]] as const) { + if (input.startsWith(token, at)) { at += token.length; return result; } + } + const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(input.slice(at))?.[0]; + requireValue(number !== undefined, "expected JSON value"); + if (/[.eE]/.test(number) || !Number.isSafeInteger(Number(number))) { + throw new ReconstructionError("unsupported_v0_number", "v0 reader supports safe integer JSON numbers only; floating/exponent and unsafe integers require an explicit compatible reader, never an ID rewrite"); + } + at += number.length; return Number(number); + }; + try { + const parsed = value(0); space(); requireValue(at === input.length, "trailing JSON input"); return parsed; + } catch (error) { + if (error instanceof ReconstructionError) throw error; + throw new ReconstructionError("invalid_reconstruction", `invalid JSON: ${(error as Error).message}`); + } +} + +function codepointOrder(a: string, b: string): number { + const aa = Array.from(a), bb = Array.from(b); + for (let i = 0; i < Math.min(aa.length, bb.length); i++) { + const difference = aa[i]!.codePointAt(0)! - bb[i]!.codePointAt(0)!; if (difference) return difference; + } + return aa.length - bb.length; +} +export function canonicalV0(value: Json): string { + if (value === null || typeof value !== "object") { + if (typeof value === "number") requireValue(Number.isSafeInteger(value), "unsupported v0 number"); + if (typeof value === "string") validUnicode(value); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalV0).join(",")}]`; + return `{${Object.keys(value).sort(codepointOrder).map(key => `${canonicalV0(key)}:${canonicalV0(value[key]!)}`).join(",")}}`; +} +const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex"); + +/** Regular-file, no-follow, bounded read; no source commands are executed. */ +function readRegular(file: string, limit: number, onRead?: (bytes: number) => void): Buffer { + const initial = lstatSync(file); + requireValue(initial.isFile() && !initial.isSymbolicLink(), `not a regular file: ${file}`); + const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = fstatSync(fd); + requireValue(before.isFile() && before.dev === initial.dev && before.ino === initial.ino && before.size <= limit, `file identity or byte bound failed: ${file}`); + const chunks: Buffer[] = []; let bytes = 0; + while (bytes < limit) { + const buffer = Buffer.alloc(Math.min(65536, limit - bytes)); + const count = readSync(fd, buffer); if (!count) break; + bytes += count; onRead?.(count); chunks.push(buffer.subarray(0, count)); + } + const after = fstatSync(fd), current = lstatSync(file); + requireValue(before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs + && after.dev === current.dev && after.ino === current.ino, `file changed during read: ${file}`); + return Buffer.concat(chunks); + } finally { closeSync(fd); } +} +function jsonFile(file: string, remainingBytes = MAX_DOCUMENT, onRead?: (bytes: number) => void): { value: Json; bytes: number } { + const bytes = readRegular(file, Math.min(MAX_DOCUMENT, remainingBytes), onRead); + return { value: parseReconstructionJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), bytes: bytes.length }; +} + +export interface StoreMapping { sourceStore: string; localStore: string; sourceHost?: string } +export function readStoreMappings(file: string): StoreMapping[] { + return list(jsonFile(file).value, "store mappings").map(value => { + const entry = object(value, "mapping"); + return { sourceStore: absolute(entry.sourceStore, "sourceStore"), localStore: absolute(entry.localStore, "localStore"), + ...(entry.sourceHost === undefined ? {} : { sourceHost: text(entry.sourceHost, "sourceHost") }) }; + }); +} +interface Pointer { store: string; revision: string } +function pointer(value: Json | undefined): Pointer { + const v = object(value, "pointer"); return { store: absolute(v.store, "pointer.store"), revision: revision(v.revision) }; +} +function records(value: Json | undefined, kind: "items" | "sources"): RecordValue[] { + const seen = new Set(); + return list(value === undefined ? [] : value, kind).map(v => { + const r = object(v, kind), id = text(r.id, `${kind}.id`); requireValue(!seen.has(id), `duplicate ${kind} ID: ${id}`); seen.add(id); + timestamp(r.observed_at, `${kind}.observed_at`, true); + if (kind === "items") { + text(r.category, "item.category"); text(r.text, "item.text"); + if (r.status !== undefined) text(r.status, "item.status"); + for (const name of ["sources", "depends_on", "tags"]) strings(r[name], name); + requireValue(["source-belief", "assessment", "observed", "reported"].includes(String(r.perspective ?? "assessment")), "invalid item perspective"); + } else { + requireValue(r.path !== undefined || r.address !== undefined, "source requires path or address"); + if (r.path !== undefined) absolute(r.path, "source.path"); + if (r.address !== undefined) text(r.address, "source.address"); + if (r.sha256 !== undefined && r.sha256 !== null) revision(r.sha256); + } + return r; + }); +} +function discovery(value: Json | undefined): Json[] { + const roots = list(value === undefined ? [] : value, "discovery"), ids = new Set(); + for (const value of roots) { + const root = object(value, "discovery entry"), id = text(root.id, "discovery.id"); + requireValue(!ids.has(id), "duplicate discovery ID"); ids.add(id); + absolute(root.path, "discovery.path"); + for (const name of strings(root.known_paths, "discovery.known_paths")) { + requireValue(!isAbsolute(name) && !name.split("/").includes(".."), "discovery known_paths must stay under root"); + } + const entries = root.max_entries ?? 1000, depth = root.max_depth ?? 8; + requireValue(typeof entries === "number" && Number.isInteger(entries) && entries >= 1 && entries <= 10000, "discovery.max_entries must be 1..10000"); + requireValue(typeof depth === "number" && Number.isInteger(depth) && depth >= 0 && depth <= 32, "discovery.max_depth must be 0..32"); + timestamp(root.observed_at ?? null, "discovery.observed_at", true); + } + return roots; +} +function validate(doc: RecordValue): void { + if (doc.schema !== RECONSTRUCTION_SCHEMA) throw new ReconstructionError("unsupported_schema", `unsupported reconstruction schema: ${String(doc.schema)}`); + requireValue(["base", "delta", "composition"].includes(String(doc.kind)), "invalid reconstruction kind"); + for (const key of ["subject", "purpose", "author"]) text(doc[key], key); + timestamp(doc.created_at, "created_at"); + requireValue(["orientation", "working", "continuity", "full-domain"].includes(String(doc.depth)), "invalid reconstruction depth"); + for (const key of ["items", "upsert_items"]) if (doc[key] !== undefined) records(doc[key], "items"); + for (const key of ["sources", "upsert_sources"]) if (doc[key] !== undefined) records(doc[key], "sources"); + if (doc.coverage !== undefined) { + const coverage = object(doc.coverage, "coverage"); strings(coverage.included, "coverage.included"); strings(coverage.excluded, "coverage.excluded"); + } + strings(doc.unknowns, "unknowns"); discovery(doc.discovery); + for (const key of ["remove_items", "remove_sources"]) { + const ids = strings(doc[key], key); requireValue(new Set(ids).size === ids.length, `duplicate ${key}`); + } + if (doc.kind === "delta") { + pointer(doc.parent); + requireValue(!["items", "sources", "members"].some(key => key in doc), "delta uses upsert/remove fields; members remain pinned"); + } else requireValue(!Object.keys(doc).some(key => key.startsWith("upsert_") || key.startsWith("remove_")), "base/composition uses items and sources, not delta fields"); + if (doc.kind === "composition") { + const members = list(doc.members, "members"); requireValue(members.length > 0 && members.length <= 64, "composition requires 1..64 members"); members.forEach(pointer); + } +} +interface Node { + document: RecordValue; sourceStore: string; localStore: string; items: RecordValue[]; sources: RecordValue[]; + coverage: Json; unknowns: string[]; discovery: Json[]; members: Node[]; lineage: Pointer[]; +} +interface Issue { code: string; message: string; store?: string; revision?: string; subject?: string } +export interface ReconstructionOptions { + store: string; revision: string; mappings?: StoreMapping[]; + items?: string[]; categories?: string[]; tags?: string[]; checkSources?: boolean; +} +const flatten = (node: Node): Node[] => [node, ...node.members.flatMap(flatten)]; + +export function readReconstruction(options: ReconstructionOptions) { + const rootStore = absolute(options.store, "store"), rootRevision = revision(options.revision); + const mappings = new Map(); + for (const entry of options.mappings ?? []) { + const source = absolute(entry.sourceStore, "sourceStore"); + requireValue(!mappings.has(source), "ambiguous duplicate source-store mapping; v0 pointers have no host discriminator"); + mappings.set(source, { ...entry, sourceStore: source, localStore: absolute(entry.localStore, "localStore") }); + } + const issues: Issue[] = [], loaded: Array<{ revision: string; sourceStore: string; localStore: string; fileBytes: number }> = []; + let totalBytes = 0, visited = 0; + let exhausted = false; + const materialize = (store: string, id: string, stack: string[]): Node | undefined => { + if (exhausted) return undefined; + const sourceStore = absolute(store, "pointer.store"), key = `${sourceStore}\0${id}`; + try { + if (stack.length >= 64 || ++visited > 64 || totalBytes >= MAX_CLOSURE) { + exhausted = true; + throw new ReconstructionError("closure_limit", "reconstruction closure exceeds 64 references or 32 MiB"); + } + requireValue(!stack.includes(key), "reconstruction reference cycle"); + const mapping = mappings.get(sourceStore); + if (!mapping && sourceStore !== rootStore && !existsSync(sourceStore)) throw new ReconstructionError("unmapped_external_store", `store ${sourceStore} is not available here; supply an explicit store mapping`); + const localStore = mapping?.localStore ?? sourceStore; + // Charge positive reads even when decoding, parsing or a late identity check fails. + const file = jsonFile(join(localStore, `${revision(id)}.json`), MAX_CLOSURE - totalBytes, count => { totalBytes += count; }); + const doc = object(file.value, "revision"); requireValue(doc.id === id, "stored ID does not match requested revision"); + const { id: ignored, ...body } = doc; void ignored; + requireValue(hash(canonicalV0(body)) === id, "revision content hash mismatch"); validate(doc); + loaded.push({ revision: id, sourceStore, localStore, fileBytes: file.bytes }); + const trail = [...stack, key]; let result: Node; + if (doc.kind === "delta") { + const parent = pointer(doc.parent), inherited = materialize(parent.store, parent.revision, trail); + if (!inherited) return undefined; + requireValue(inherited.document.subject === doc.subject, "delta cannot change subject identity"); + result = { ...inherited, members: [...inherited.members], lineage: [...inherited.lineage, { store: sourceStore, revision: id }] }; + for (const collection of ["items", "sources"] as const) { + const values = new Map(inherited[collection].map(v => [String(v.id), v])); + const removed = strings(doc[`remove_${collection}`], `remove_${collection}`); + const updates = records(doc[`upsert_${collection}`], collection); + for (const remove of removed) { + requireValue(values.has(remove) && !updates.some(v => v.id === remove), `invalid removal/upsert: ${remove}`); values.delete(remove); + } + for (const v of updates) values.set(String(v.id), v); + result[collection] = [...values.values()]; + } + if (doc.coverage !== undefined) result.coverage = doc.coverage; + if (doc.unknowns !== undefined) result.unknowns = strings(doc.unknowns, "unknowns"); + if (doc.discovery !== undefined) result.discovery = list(doc.discovery, "discovery"); + } else { + result = { document: doc, sourceStore, localStore, items: records(doc.items, "items"), sources: records(doc.sources, "sources"), + coverage: doc.coverage ?? { included: [], excluded: [] }, unknowns: strings(doc.unknowns, "unknowns"), discovery: discovery(doc.discovery), + members: [], lineage: [{ store: sourceStore, revision: id }] }; + for (const member of list(doc.members === undefined ? [] : doc.members, "members")) { + const p = pointer(member), node = materialize(p.store, p.revision, trail); if (node) result.members.push(node); + } + } + return { ...result, document: doc, sourceStore, localStore }; + } catch (error) { + issues.push({ code: error instanceof ReconstructionError ? error.code : "revision_unavailable", message: (error as Error).message, store: sourceStore, revision: id }); + return undefined; + } + }; + let root = materialize(rootStore, rootRevision, []); + const seenSubjects = new Set(); + if (root) for (const node of flatten(root)) { + const subject = String(node.document.subject); + if (seenSubjects.has(subject)) { issues.push({ code: "duplicate_subject", message: `composition repeats subject ${subject}` }); root = undefined; break; } + seenSubjects.add(subject); + } + const nodes = root ? flatten(root) : [], allIds = new Set(nodes.flatMap(node => node.items.map(v => String(v.id)))); + const filtered = Boolean(options.items?.length || options.categories?.length || options.tags?.length); + for (const id of options.items ?? []) if (!allIds.has(id)) issues.push({ code: "missing_item", message: `selected item is missing: ${id}` }); + const selected = nodes.map(node => { + const byId = new Map(node.items.map(v => [String(v.id), v])); + const ids = new Set(node.items.filter(v => !filtered || options.items?.includes(String(v.id)) || options.categories?.includes(String(v.category)) + || strings(v.tags, "tags").some(tag => options.tags?.includes(tag)) || PRESERVED.includes(String(v.category))).map(v => String(v.id))); + const pending = [...ids]; + while (pending.length) for (const dependency of strings(byId.get(pending.pop()!)!.depends_on, "depends_on")) { + if (!byId.has(dependency)) issues.push({ code: "missing_dependency", message: `missing dependency: ${dependency}`, subject: String(node.document.subject) }); + else if (!ids.has(dependency)) { ids.add(dependency); pending.push(dependency); } + } + const items = node.items.filter(v => ids.has(String(v.id))), cited = new Set(items.flatMap(v => strings(v.sources, "sources"))); + const sources = new Map(node.sources.map(v => [String(v.id), v])); + for (const id of cited) if (!sources.has(id)) issues.push({ code: "missing_citation", message: `missing source: ${id}`, subject: String(node.document.subject) }); + return { node, items, cited, ids }; + }); + let sourceBytes = 0, sourceCount = 0; + const sourceChecks = options.checkSources ? nodes.flatMap(node => node.sources.map(source => { + const base = { subject: node.document.subject, id: source.id, observedAt: source.observed_at }; + if (source.path === undefined) return { ...base, status: "unchecked", reason: "opaque/nonlocal route; not fetched" }; + try { + requireValue(++sourceCount <= 128 && sourceBytes < 64 * 1024 * 1024, "source checking budget exhausted (128 files / 64 MiB total)"); + const bytes = readRegular(String(source.path), 64 * 1024 * 1024 - sourceBytes, count => { sourceBytes += count; }); + const actual = hash(bytes); + return { ...base, sha256: actual, status: source.sha256 ? (actual === source.sha256 ? "unchanged" : "changed") : "unchecked", reason: "byte comparison only; observation dates and semantic freshness are not refreshed" }; + } catch (error) { return { ...base, status: "unavailable", reason: (error as Error).message }; } + })) : undefined; + const materializedBytes = Buffer.byteLength(JSON.stringify(root ?? null)); + return { + presentationSchema: "openrig.reconstruction-reader/v1", storedSchema: RECONSTRUCTION_SCHEMA, + requestedStore: rootStore, requestedRevision: rootRevision, complete: issues.length === 0, issues, + completenessScope: "record closure and selected item/citation references; not source freshness", + caveat: "Context is evidence for understanding, not authority, competence, acceptance or current runtime state. Authors are claimed attribution. No discovery cursor or implicit remote reads.", + selectors: { mode: filtered ? "filtered" : "full", items: options.items ?? [], categories: options.categories ?? [], tags: options.tags ?? [], combination: "union", alwaysIncludeCategories: PRESERVED, includeTransitiveDependencies: true }, + mappings: [...mappings.values()], records: loaded, closureBytesRead: totalBytes, atomicGroupCapture: false, + size: { materializedJsonUtf8Bytes: materializedBytes, approximateTokens: Math.ceil(materializedBytes / 4), + estimateMethod: "materialized reader JSON UTF-8 bytes / 4; rough, not a model tokenizer" }, + nodes: selected.map(({ node, items, cited, ids }) => ({ + subject: node.document.subject, revision: node.document.id, kind: node.document.kind, author: node.document.author, + purpose: node.document.purpose, depth: node.document.depth, createdAt: node.document.created_at, + sourceStore: node.sourceStore, localStore: node.localStore, lineage: node.lineage, + declaredCoverage: node.coverage, unknowns: node.unknowns, discovery: node.discovery, + sourceManifest: node.sources, itemObservations: node.items.map(v => ({ id: v.id, observedAt: v.observed_at })), + items, viewSources: node.sources.filter(v => !filtered || cited.has(String(v.id))), + viewCoverage: { totalItems: node.items.length, includedIds: items.map(v => v.id), omittedIds: node.items.filter(v => !ids.has(String(v.id))).map(v => v.id), + registeredSources: node.sources.length, citedSourceIds: [...cited], omittedRouteIds: filtered ? node.sources.filter(v => !cited.has(String(v.id))).map(v => v.id) : [] }, + })), ...(sourceChecks ? { sourceChecks } : {}), + }; +} + +export type ReconstructionRead = ReturnType; +export function reconstructionOutput(result: ReconstructionRead, mode: "inspect" | "view", json: boolean, maxBytes = 256 * 1024): string { + requireValue(Number.isInteger(maxBytes) && maxBytes >= 1024 && maxBytes <= MAX_DOCUMENT, "max-bytes must be 1024..8388608"); + // Inspect never delivers item bodies; view carries its actual omissions and + // complete-manifest route rather than presenting full coverage as selected. + const data = { ...result, nodes: result.nodes.map(node => mode === "inspect" ? { ...node, items: undefined, viewSources: undefined } : { ...node, sourceManifest: undefined }) }; + let output: string; + if (json || mode === "inspect") output = JSON.stringify(data, null, 2); + else { + const lines = ["# Reconstruction view", "", result.caveat, `Complete: ${result.complete} (${result.completenessScope})`, `Applied selectors: ${JSON.stringify(result.selectors)}`]; + for (const issue of result.issues) lines.push(`UNAVAILABLE: ${issue.code}: ${issue.message}`); + for (const node of result.nodes) { + lines.push("", `## ${String(node.subject)}`, `Revision: ${String(node.revision)}`, `Author (claimed): ${String(node.author)}`, `Purpose: ${String(node.purpose)}`, `Depth: ${String(node.depth)}`, + `Source store: ${node.sourceStore}; local read store: ${node.localStore}`, + `Created: ${String(node.createdAt)} (not inherited-fact verification time)`, `Author-declared coverage: ${JSON.stringify(node.declaredCoverage)}`, `Selected view coverage: ${JSON.stringify(node.viewCoverage)}`, + "Composition members retain independent observation dates; no atomic capture is claimed."); + for (const unknown of node.unknowns) lines.push(`Unknown: ${unknown}`); + for (const item of node.items) lines.push("", `### ${String(item.id)} [${String(item.category)}]`, String(item.text), + `Perspective: ${String(item.perspective ?? "assessment")}; observed: ${String(item.observed_at)}; status: ${String(item.status ?? "unspecified")}`, + `Sources: ${JSON.stringify(item.sources ?? [])}; dependencies: ${JSON.stringify(item.depends_on ?? [])}`); + lines.push("", "Evidence routes:"); + for (const source of node.viewSources) lines.push(JSON.stringify(source)); + if (node.viewCoverage.omittedRouteIds.length) lines.push("Uncited routes omitted; inspect this exact revision for its complete source manifest."); + } + if (result.sourceChecks) lines.push("Source byte checks (not semantic reverification):", JSON.stringify(result.sourceChecks)); + output = lines.join("\n"); + } + const bytes = Buffer.byteLength(output); + if (bytes > maxBytes) throw new ReconstructionError("output_limit", `output is ${bytes} UTF-8 bytes; max-bytes is ${maxBytes}. Select a narrower view or explicitly raise the bound; no complete output was delivered.`); + return output; +} diff --git a/packages/cli/src/lib/work-install.ts b/packages/cli/src/lib/work-install.ts index 6bceee2d9..63424b2b8 100644 --- a/packages/cli/src/lib/work-install.ts +++ b/packages/cli/src/lib/work-install.ts @@ -215,45 +215,22 @@ function resolveExplicitSlice( return matches[0]!; } -export function resolveWorkPosition(opts: { +export interface WorkProjectOptions { workspaceRoot: string; catalogPath?: string; - contextRoot: string; - systemWorldSelection: string; - systemWorldSource: SystemWorldSource; project?: string; - mission?: string; - slice?: string; - /** Working directory for selection step 4 (the CLI passes --cwd or the process cwd). */ cwd?: string; - /** Calling seat's canonical session, for selection step 3; absent in a plain shell. */ sessionName?: string; -}): WorkInstallResult { +} + +/** Catalog/home resolution shared by read-only work consumers and work-install. + * No System World, skill selection or projection is performed here. */ +export function resolveWorkProject(opts: WorkProjectOptions, warnings: string[]) { if (opts.project !== undefined && !SEGMENT.test(opts.project)) { return failure("invalid_project", "project must be a single bounded segment"); } - if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) { - return failure("invalid_mission", "mission must be a single bounded segment"); - } - if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) { - return failure("invalid_slice", "slice must be a single bounded segment"); - } - if (opts.slice !== undefined && opts.mission === undefined) { - return failure("mission_required", "slice requires an exact mission selection"); - } - const workspaceRoot = canonicalExisting(opts.workspaceRoot); - if (!workspaceRoot) { - return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`); - } - - const warnings: string[] = []; - const systemWorld = resolveSystemWorld({ - contextRoot: opts.contextRoot, - selection: opts.systemWorldSelection, - source: opts.systemWorldSource, - }); - if (!systemWorld.ok) return failure(systemWorld.error.code, systemWorld.error.message); + if (!workspaceRoot) return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`); const catalogPath = resolve(opts.catalogPath ?? join(workspaceRoot, "workspace.yaml")); let projectId: string | null = null; let projectRoot = workspaceRoot; @@ -303,6 +280,85 @@ export function resolveWorkPosition(opts: { return failure("missions_root_escape", "project.yaml missions.root resolves outside the selected project root"); } + return { workspaceRoot, projectId, projectRoot, selectedBy, projectManifest, missionsRoot }; +} + +/** A context home is a location, not task ownership or runtime authority. */ +export function resolveWorkContextHome(opts: WorkProjectOptions & { mission?: string; slice?: string }) { + const warnings: string[] = []; + if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) return failure("invalid_mission", "mission must be a single bounded segment"); + if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) return failure("invalid_slice", "slice must be a single bounded segment"); + if (opts.slice !== undefined && opts.mission === undefined) return failure("mission_required", "slice requires an exact mission selection"); + const project = resolveWorkProject(opts, warnings); + if ("error" in project) return project; + let home = project.projectRoot; + if (opts.mission !== undefined) { + const nominal = join(project.missionsRoot, opts.mission); + const mission = canonicalExisting(nominal); + if (!mission) return failure("mission_not_found", `mission '${opts.mission}' does not exist in the selected project`); + if (!inside(project.projectRoot, mission)) return failure("mission_root_escape", "mission resolves outside the selected project root"); + home = mission; + if (opts.slice !== undefined) { + let declared: Set | undefined; + const manifestPath = join(mission, "mission.yaml"); + if (existsSync(manifestPath)) { + const parsed = readYaml(manifestPath), composition = parsed.value?.["composition"]; + if (parsed.value && isRecord(composition) && "slices" in composition) { + try { declared = new Set(validateMissionComposition(parsed.value, manifestPath).map(member => dirname(member.path))); } + catch (error) { return failure("mission_composition_invalid", (error as Error).message); } + } else if (!parsed.value) warnings.push(`${parsed.error}; kept conventional slice addresses`); + } + const slice = resolveExplicitSlice(mission, opts.slice, declared); + if ("error" in slice) return slice; + home = slice.root; + } + } + return { projectId: project.projectId, projectRoot: project.projectRoot, selectedBy: project.selectedBy, home, warnings }; +} + +export function resolveWorkPosition(opts: { + workspaceRoot: string; + catalogPath?: string; + contextRoot: string; + systemWorldSelection: string; + systemWorldSource: SystemWorldSource; + project?: string; + mission?: string; + slice?: string; + /** Working directory for selection step 4 (the CLI passes --cwd or the process cwd). */ + cwd?: string; + /** Calling seat's canonical session, for selection step 3; absent in a plain shell. */ + sessionName?: string; +}): WorkInstallResult { + if (opts.project !== undefined && !SEGMENT.test(opts.project)) { + return failure("invalid_project", "project must be a single bounded segment"); + } + if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) { + return failure("invalid_mission", "mission must be a single bounded segment"); + } + if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) { + return failure("invalid_slice", "slice must be a single bounded segment"); + } + if (opts.slice !== undefined && opts.mission === undefined) { + return failure("mission_required", "slice requires an exact mission selection"); + } + + const workspaceRoot = canonicalExisting(opts.workspaceRoot); + if (!workspaceRoot) { + return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`); + } + + const warnings: string[] = []; + const systemWorld = resolveSystemWorld({ + contextRoot: opts.contextRoot, + selection: opts.systemWorldSelection, + source: opts.systemWorldSource, + }); + if (!systemWorld.ok) return failure(systemWorld.error.code, systemWorld.error.message); + const project = resolveWorkProject({ ...opts, workspaceRoot }, warnings); + if ("error" in project) return project; + const { projectId, projectRoot, selectedBy, projectManifest, missionsRoot } = project; + let projectIntent = "SPEC.md"; let projectIntentSource: WorkInstallSource = "default"; let projectContext: string[] = []; diff --git a/packages/cli/test/context-grammar.test.ts b/packages/cli/test/context-grammar.test.ts index 2c8fbf082..13b7f9c40 100644 --- a/packages/cli/test/context-grammar.test.ts +++ b/packages/cli/test/context-grammar.test.ts @@ -24,7 +24,9 @@ const VIEWER_DESCRIPTION_FRAGMENT = "context-usage across running agents"; // situation-composed delivery verb. Both are delivery-FREE library verbs // (get/profile SERVE bytes to the caller's stdout; nothing sends to a seat), // so the delivery-seam exclusion below still holds. -const LIBRARY_SUBCOMMANDS = ["work-install", "trace", "compose", "list", "show", "preview", "get", "profile", "recap-write", "sync", "add", "source", "rm"]; +// Reconstruction inspect/view reads local immutable records; it neither +// delivers to a seat nor starts or contacts a daemon. +const LIBRARY_SUBCOMMANDS = ["reconstruction", "work-install", "trace", "compose", "list", "show", "preview", "get", "profile", "recap-write", "sync", "add", "source", "rm"]; function topLevelNames(program: Command): string[] { return program.commands.map((c) => c.name()); diff --git a/packages/cli/test/context-reconstruction.test.ts b/packages/cli/test/context-reconstruction.test.ts new file mode 100644 index 000000000..7ec04e03e --- /dev/null +++ b/packages/cli/test/context-reconstruction.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { Command } from "commander"; +import { contextCommand } from "../src/commands/context.js"; +import { resolveWorkContextHome } from "../src/lib/work-install.js"; + +const fixture = JSON.parse(readFileSync(new URL("./fixtures/reconstruction-v0.json", import.meta.url), "utf8")) as { + base: { id: string }; sourceStore: string; +}; + +describe("rig context reconstruction", () => { + let root: string, catalog: string, alpha: string, beta: string, home: string; + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "context-reconstruction-"))); + catalog = join(root, "catalog"); alpha = join(root, "alpha"); beta = join(root, "beta"); home = join(root, "instance"); + for (const path of [catalog, alpha, beta, home]) mkdirSync(path); + for (const [id, path] of [["alpha", alpha], ["beta", beta]]) { + const mission = join(path!, "workplans", "same-task"); + mkdirSync(join(mission, "slices", "01-example", "reconstructions"), { recursive: true }); + writeFileSync(join(path!, "project.yaml"), `schema: openrig.project/v0alpha1\nkind: project\nid: ${id}\nmissions: {root: workplans}\n`); + writeFileSync(join(mission, "slices", "01-example", "SPEC.md"), "---\nid: OPR.99.0.1\n---\nExample slice\n"); + writeFileSync(join(mission, "slices", "01-example", "reconstructions", `${fixture.base.id}.json`), JSON.stringify(fixture.base)); + } + writeFileSync(join(catalog, "workspace.yaml"), `schema: openrig.workspace/v0alpha1\nprojects:\n - id: alpha\n root: ${relative(catalog, alpha)}\n rigs: [example-rig]\n - id: beta\n root: ${relative(catalog, beta)}\n rigs: [other-rig]\n`); + vi.stubEnv("OPENRIG_HOME", home); + vi.stubEnv("OPENRIG_WORKSPACE_ROOT", catalog); + vi.stubEnv("OPENRIG_WORKSPACE_CATALOG_PATH", join(catalog, "workspace.yaml")); + vi.stubEnv("OPENRIG_SESSION_NAME", ""); vi.stubEnv("RIGGED_SESSION_NAME", ""); + // If this local command accidentally contacts the daemon, fail in the test. + vi.spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("local reconstruction must not fetch"); }); + }); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(root, { recursive: true, force: true }); }); + + async function run(args: string[]) { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const saved = process.exitCode; process.exitCode = undefined; + try { + const command = new Command().exitOverride(); command.addCommand(contextCommand()); + await command.parseAsync(["node", "rig", "context", "reconstruction", ...args]); + return { output: log.mock.calls.map(call => call.join(" ")).join("\n"), errors: error.mock.calls, exitCode: process.exitCode }; + } finally { process.exitCode = saved; log.mockRestore(); error.mockRestore(); } + } + + it("resolves a catalog project, custom missions root and slice ID without writing or installing context", async () => { + const result = await run(["inspect", fixture.base.id, "--project", "beta", "--mission", "same-task", "--slice", "OPR.99.0.1"]); + const data = JSON.parse(result.output); + expect(result.exitCode).toBeUndefined(); + expect(data.complete).toBe(true); + expect(data.resolution.projectId).toBe("beta"); + expect(data.resolution.home).toBe(join(beta, "workplans", "same-task", "slices", "01-example")); + expect(data.requestedStore).toBe(join(data.resolution.home, "reconstructions")); + expect(data.nodes[0].sourceManifest).toHaveLength(2); + expect(data.nodes[0].items).toBeUndefined(); + expect(readdirSync(home)).toEqual([]); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("uses shared cwd/rig inference rather than choosing another project's same mission", () => { + const common = { workspaceRoot: catalog, catalogPath: join(catalog, "workspace.yaml"), mission: "same-task" }; + expect(resolveWorkContextHome({ ...common, cwd: join(beta, "workplans") })).toMatchObject({ projectId: "beta", selectedBy: "cwd" }); + expect(resolveWorkContextHome({ ...common, sessionName: "agent@example-rig", cwd: beta })).toMatchObject({ projectId: "alpha", selectedBy: "rig" }); + expect(resolveWorkContextHome({ ...common, cwd: root })).toMatchObject({ error: { code: "project_required" } }); + }); + + it("reads an explicit imported store even when the workspace configuration is unavailable", async () => { + vi.stubEnv("OPENRIG_WORKSPACE_ROOT", join(root, "missing")); + const store = join(alpha, "workplans", "same-task", "slices", "01-example", "reconstructions"); + const result = await run(["view", fixture.base.id, "--store", store, "--item", "decision", "--json"]); + const data = JSON.parse(result.output); + expect(data.complete).toBe(true); + expect(data.resolution.mode).toBe("explicit-store"); + expect(data.nodes[0].items.map((v: { id: string }) => v.id)).toEqual(["decision", "dependency", "constraint", "gap", "authority"]); + expect(data.nodes[0].viewCoverage.omittedIds).toEqual(["remove", "tagged", "omitted"]); + expect(readdirSync(home)).toEqual([]); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("returns structured failures and nonzero status for missing context, invalid bounds or ambiguous projects", async () => { + const ambiguous = await run(["inspect", fixture.base.id, "--cwd", root]); + expect(ambiguous.exitCode).toBe(1); expect(JSON.parse(ambiguous.output).error.code).toBe("project_required"); + const unavailable = await run(["inspect", "b".repeat(64), "--store", root]); + expect(unavailable.exitCode).toBe(1); expect(JSON.parse(unavailable.output).complete).toBe(false); + const bound = await run(["view", fixture.base.id, "--store", root, "--max-bytes", "NaN", "--json"]); + expect(bound.exitCode).toBe(1); expect(JSON.parse(bound.output).error.message).toContain("max-bytes"); + }); + + it("rejects paths escaping the selected mission and invalid mission composition", () => { + const opts = { workspaceRoot: catalog, project: "alpha", mission: "same-task" }; + expect(resolveWorkContextHome({ ...opts, mission: "../beta" })).toMatchObject({ error: { code: "invalid_mission" } }); + expect(resolveWorkContextHome({ ...opts, slice: "../other" })).toMatchObject({ error: { code: "invalid_slice" } }); + symlinkSync(beta, join(alpha, "workplans", "same-task", "slices", "outside")); + expect(resolveWorkContextHome({ ...opts, slice: "outside" })).toMatchObject({ error: { code: "slice_root_escape" } }); + writeFileSync(join(alpha, "workplans", "same-task", "mission.yaml"), "composition: {slices: not-a-list}\n"); + expect(resolveWorkContextHome({ ...opts, slice: "01-example" })).toMatchObject({ error: { code: "mission_composition_invalid" } }); + }); +}); diff --git a/packages/cli/test/fixtures/reconstruction-v0.json b/packages/cli/test/fixtures/reconstruction-v0.json new file mode 100644 index 000000000..c208559f4 --- /dev/null +++ b/packages/cli/test/fixtures/reconstruction-v0.json @@ -0,0 +1,193 @@ +{ + "provenance": "Sanitized fixtures using the v0 helper test shape; IDs computed with Python json.dumps(sort_keys=True,separators=(comma,colon),ensure_ascii=False). No real work records.", + "sourceStore": "/example/original/reconstructions", + "base": { + "schema": "openrig.reconstruction/v0", + "kind": "base", + "subject": "work:example", + "author": "example:author", + "purpose": "Continue the selected example task", + "depth": "working", + "created_at": "2026-10-01T10:00:00+00:00", + "coverage": { + "included": [ + "selected task" + ], + "excluded": [ + "credentials" + ] + }, + "unknowns": [ + "No current runtime check" + ], + "items": [ + { + "id": "decision", + "category": "decision", + "text": "Working understanding decision", + "observed_at": "2026-10-01T10:00:00+00:00", + "sources": [ + "route" + ], + "depends_on": [ + "dependency" + ] + }, + { + "id": "dependency", + "category": "evidence", + "text": "Working understanding dependency", + "observed_at": "2026-10-01T10:00:00+00:00" + }, + { + "id": "remove", + "category": "decision", + "text": "Working understanding remove", + "observed_at": "2026-10-01T10:00:00+00:00" + }, + { + "id": "constraint", + "category": "constraint", + "text": "Working understanding constraint", + "observed_at": "2026-10-01T10:00:00+00:00" + }, + { + "id": "gap", + "category": "gap", + "text": "Working understanding gap", + "observed_at": "2026-10-01T10:00:00+00:00" + }, + { + "id": "authority", + "category": "authority", + "text": "Working understanding authority", + "observed_at": "2026-10-01T10:00:00+00:00" + }, + { + "id": "tagged", + "category": "detail", + "text": "Working understanding tagged", + "observed_at": "2026-10-01T10:00:00+00:00", + "tags": [ + "next" + ] + }, + { + "id": "omitted", + "category": "detail", + "text": "Working understanding omitted", + "observed_at": "2026-10-01T10:00:00+00:00" + } + ], + "sources": [ + { + "id": "route", + "address": "transcript:example:lines-1-5", + "observed_at": "2026-10-01T10:00:00+00:00" + }, + { + "id": "uncited", + "address": "https://example.invalid/context", + "observed_at": null + } + ], + "discovery": [], + "id": "da27fa1f5276d29d1da0b3877cbc9344b25212b9e331a44e970963ae6a7af3f6" + }, + "delta": { + "schema": "openrig.reconstruction/v0", + "kind": "delta", + "subject": "work:example", + "author": "example:author", + "purpose": "Continue the selected example task", + "depth": "working", + "created_at": "2026-10-01T10:00:00+00:00", + "parent": { + "store": "/example/original/reconstructions", + "revision": "da27fa1f5276d29d1da0b3877cbc9344b25212b9e331a44e970963ae6a7af3f6" + }, + "upsert_items": [ + { + "id": "decision", + "category": "decision", + "text": "A changed assessment based on new evidence", + "observed_at": "2026-10-27T23:00:00+00:00", + "sources": [ + "route" + ], + "depends_on": [ + "dependency" + ] + } + ], + "remove_items": [ + "remove" + ], + "id": "36623c590a22ce12401132ae90f9aae29fa88c07f6ae76ebf182fa5ed701a7a1" + }, + "other": { + "schema": "openrig.reconstruction/v0", + "kind": "base", + "subject": "seat:helper", + "author": "example:author", + "purpose": "Continue the selected example task", + "depth": "working", + "created_at": "2026-10-01T10:00:00+00:00", + "items": [ + { + "id": "activity", + "category": "constraint", + "text": "Working understanding activity", + "observed_at": "2026-10-01T10:00:00+00:00" + } + ], + "sources": [], + "created_note": "Different observation dates are not an atomic capture", + "id": "d3dbba4b6dffe6aec89c7fc71be5f5ee07e231241ae3167b3a7e3d9bf1bc0fe3" + }, + "composition": { + "schema": "openrig.reconstruction/v0", + "kind": "composition", + "subject": "group:example", + "author": "example:author", + "purpose": "Continue the selected example task", + "depth": "working", + "created_at": "2026-10-01T10:00:00+00:00", + "members": [ + { + "store": "/example/original/reconstructions", + "revision": "36623c590a22ce12401132ae90f9aae29fa88c07f6ae76ebf182fa5ed701a7a1" + }, + { + "store": "/example/original/reconstructions", + "revision": "d3dbba4b6dffe6aec89c7fc71be5f5ee07e231241ae3167b3a7e3d9bf1bc0fe3" + } + ], + "id": "abdca73469bd2e7c75443d2e7648757616f04c62e449d623a97a6ede49f28b28" + }, + "canonicalVector": { + "value": { + "10": "ten", + "2": "two", + "é": "été", + "😀": "astral", + "": "bmp", + "__proto__": { + "safe": true + }, + "control": "\b\n\t", + "ints": [ + 0, + -4, + 9007199254740991 + ], + "values": [ + null, + false, + true + ] + }, + "canonical": "{\"10\":\"ten\",\"2\":\"two\",\"__proto__\":{\"safe\":true},\"control\":\"\\b\\n\\t\",\"ints\":[0,-4,9007199254740991],\"values\":[null,false,true],\"é\":\"été\",\"\":\"bmp\",\"😀\":\"astral\"}", + "sha256": "9c3b2f8426bd1ed10bf2740a1a759e6f199431f9f862af0bce968c49be627b7d" + } +} diff --git a/packages/cli/test/reconstruction-reader.test.ts b/packages/cli/test/reconstruction-reader.test.ts new file mode 100644 index 000000000..c23dd28a8 --- /dev/null +++ b/packages/cli/test/reconstruction-reader.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { canonicalV0, parseReconstructionJson, readReconstruction, readStoreMappings, reconstructionOutput } from "../src/lib/reconstruction-reader.js"; + +type Doc = Record & { id: string }; +const fixture = JSON.parse(readFileSync(new URL("./fixtures/reconstruction-v0.json", import.meta.url), "utf8")) as { + sourceStore: string; base: Doc; delta: Doc; other: Doc; composition: Doc; + canonicalVector: { value: unknown; canonical: string; sha256: string }; +}; +const sha = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +// Dynamic malformed/limit examples use native hashing; compatibility assertions +// below use frozen Python-computed IDs, not an expected value from this reader. +function seal(value: Record): Doc { + const { id: ignored, ...body } = value; void ignored; + return { ...body, id: sha(canonicalV0(parseReconstructionJson(JSON.stringify(body)))) }; +} + +describe("local v0 reconstruction reader", () => { + let root: string, store: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "reconstruction-reader-")); store = join(root, "import"); mkdirSync(store); + for (const doc of [fixture.base, fixture.delta, fixture.other, fixture.composition]) save(doc); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + function save(doc: Doc) { writeFileSync(join(store, `${doc.id}.json`), JSON.stringify(doc)); return doc.id; } + const mappings = () => [{ sourceStore: fixture.sourceStore, localStore: store, sourceHost: "example-source" }]; + const read = (id = fixture.composition.id, extra: Partial[0]> = {}) => + readReconstruction({ store, revision: id, mappings: mappings(), ...extra }); + + it("reproduces Python's canonical Unicode/key/integer preimage and four frozen record IDs", () => { + const vector = fixture.canonicalVector; + expect(canonicalV0(parseReconstructionJson(JSON.stringify(vector.value)))).toBe(vector.canonical); + expect(sha(vector.canonical)).toBe(vector.sha256); + for (const doc of [fixture.base, fixture.delta, fixture.other, fixture.composition]) { + const { id, ...body } = doc; + expect(sha(canonicalV0(parseReconstructionJson(JSON.stringify(body))))).toBe(id); + } + }); + + it("reads a relocated base/delta/composition without rewriting IDs, bytes or observation dates", () => { + const before = readFileSync(join(store, `${fixture.base.id}.json`)); + const result = read(); + expect(result.complete).toBe(true); + expect(result.nodes.map(n => n.subject)).toEqual(["group:example", "work:example", "seat:helper"]); + const work = result.nodes[1]!; + expect(work.lineage.map(v => v.revision)).toEqual([fixture.base.id, fixture.delta.id]); + expect(work.lineage[0]!.store).toBe(fixture.sourceStore); + expect(work.items.find(i => i.id === "decision")!.observed_at).toBe("2026-10-27T23:00:00+00:00"); + expect(work.items.find(i => i.id === "dependency")!.observed_at).toBe("2026-10-01T10:00:00+00:00"); + expect(work.items.some(i => i.id === "remove")).toBe(false); + expect(result.atomicGroupCapture).toBe(false); + expect(readFileSync(join(store, `${fixture.base.id}.json`))).toEqual(before); + expect(result.caveat).toContain("not authority"); + }); + + it("uses selector union, includes dependencies/constraints/gaps/authority and names omissions", () => { + const result = read(undefined, { items: ["decision"], tags: ["next"] }); + const work = result.nodes[1]!; + expect(work.items.map(i => i.id)).toEqual(["decision", "dependency", "constraint", "gap", "authority", "tagged"]); + expect(work.viewCoverage.omittedIds).toEqual(["omitted"]); + expect(work.viewCoverage.omittedRouteIds).toEqual(["uncited"]); + expect(work.sourceManifest.map(s => s.id)).toEqual(["route", "uncited"]); + expect(work.viewSources.map(s => s.id)).toEqual(["route"]); + expect(result.nodes[2]!.items.map(i => i.id)).toEqual(["activity"]); + const inspect = JSON.parse(reconstructionOutput(result, "inspect", true)); + expect(inspect.nodes[1].items).toBeUndefined(); + expect(inspect.nodes[1].sourceManifest).toHaveLength(2); + expect(JSON.parse(reconstructionOutput(result, "view", true)).nodes[1].sourceManifest).toBeUndefined(); + expect(reconstructionOutput(read(), "view", false)).toContain("https://example.invalid/context"); + expect(reconstructionOutput(result, "view", false)).toContain("inspect this exact revision"); + }); + + it("does not infer missing selections, citations, dependencies or a newer revision", () => { + expect(read(undefined, { items: ["absent"] }).issues.map(i => i.code)).toContain("missing_item"); + const doc = seal({ ...fixture.base, items: [{ id: "x", category: "decision", text: "Needs evidence", observed_at: null, sources: ["missing"], depends_on: ["missing"] }] }); + const result = read(save(doc)); + expect(result.complete).toBe(false); + expect(result.issues.map(i => i.code)).toEqual(["missing_dependency", "missing_citation"]); + expect(result.nodes[0]!.itemObservations[0]!.observedAt).toBeNull(); + expect(() => readReconstruction({ store, revision: "latest" })).toThrow(/full lowercase SHA256/); + }); + + it("retains available composition members but reports an unmapped/missing member as incomplete", () => { + const unmapped = read(undefined, { mappings: [] }); + expect(unmapped.complete).toBe(false); + expect(unmapped.issues.every(i => i.code === "unmapped_external_store")).toBe(true); + rmSync(join(store, `${fixture.base.id}.json`)); + const missing = read(); + expect(missing.complete).toBe(false); + expect(missing.nodes.map(n => n.subject)).toEqual(["group:example", "seat:helper"]); + expect(missing.issues[0]!.revision).toBe(fixture.base.id); + }); + + it("reads a same-host cross-store composition without a relocation mapping", () => { + const otherStore = join(root, "slice-store"); mkdirSync(otherStore); + writeFileSync(join(otherStore, `${fixture.other.id}.json`), JSON.stringify(fixture.other)); + const doc = seal({ ...fixture.composition, members: [{ store: otherStore, revision: fixture.other.id }] }); + const result = read(save(doc), { mappings: [] }); + expect(result.complete).toBe(true); + expect(result.nodes.map(node => node.subject)).toEqual(["group:example", "seat:helper"]); + expect(result.records[1]).toMatchObject({ sourceStore: otherStore, localStore: otherStore, revision: fixture.other.id }); + }); + + it.each(["{", "1e2"])("charges malformed member reads before parsing %s and stops at the closure budget", input => { + const budget = 32 * 1024 * 1024, perFile = 8 * 1024 * 1024; + const ids = Array.from({ length: 6 }, (_, n) => sha(`invalid-member-${n}`)); + const doc = seal({ ...fixture.composition, members: ids.map(revision => ({ store, revision })) }); + const rootBytes = Buffer.byteLength(JSON.stringify(doc)); save(doc); + // Three near-limit members, then exactly the remaining budget; later members + // must not be opened. Neither corrupt JSON nor unsupported numbers are free IO. + const nearLimit = perFile - 1; + for (let n = 0; n < ids.length; n++) { + const size = n === 3 ? budget - rootBytes - 3 * nearLimit : nearLimit; + expect(size).toBeLessThanOrEqual(perFile); + const bytes = Buffer.alloc(size, 32); bytes.write(input); + writeFileSync(join(store, `${ids[n]}.json`), bytes); + } + const result = read(doc.id); + expect(result.complete).toBe(false); + expect(result.closureBytesRead).toBe(budget); + expect(result.records.map(record => record.revision)).toEqual([doc.id]); + expect(result.issues.slice(0, 4).map(issue => issue.revision)).toEqual(ids.slice(0, 4)); + expect(result.issues[4]).toMatchObject({ code: "closure_limit", revision: ids[4] }); + expect(result.issues).toHaveLength(5); + }); + + it("requires unambiguous explicit local mapping and preserves its provenance", () => { + const file = join(root, "map.json"); writeFileSync(file, JSON.stringify(mappings())); + expect(readStoreMappings(file)).toEqual(mappings()); + expect(read(undefined, { mappings: readStoreMappings(file) }).mappings[0]!.sourceHost).toBe("example-source"); + expect(() => read(undefined, { mappings: [...mappings(), { ...mappings()[0]!, sourceHost: "other-host" }] })).toThrow(/ambiguous/); + writeFileSync(file, '[{"sourceStore":"relative","localStore":"/tmp"}]'); + expect(() => readStoreMappings(file)).toThrow(/absolute/); + }); + + it("rejects tampering, unsupported schemas, duplicate subjects and semantic invalidity", () => { + writeFileSync(join(store, `${fixture.base.id}.json`), JSON.stringify({ ...fixture.base, purpose: "Changed bytes" })); + expect(read(fixture.base.id).issues[0]!.message).toContain("hash mismatch"); + expect(read(save(seal({ ...fixture.base, schema: "openrig.reconstruction/v999" }))).issues[0]!.code).toBe("unsupported_schema"); + const duplicate = seal({ ...fixture.composition, members: [{ store, revision: fixture.other.id }, { store, revision: fixture.other.id }] }); + expect(read(save(duplicate)).issues[0]!.code).toBe("duplicate_subject"); + for (const invalid of [ + { ...fixture.delta, remove_items: ["not-present"] }, + { ...fixture.delta, subject: "different" }, + { ...fixture.delta, items: [] }, + { ...fixture.other, remove_items: [] }, + { ...fixture.other, discovery: [{ id: "d", path: "/tmp", observed_at: null, known_paths: ["../outside"] }] }, + ]) expect(read(save(seal(invalid))).complete).toBe(false); + }); + + it.each(['1.0', '1e2', '9007199254740992', '{"a":1,"a":2}', '"\\ud800"', '[1,]', '{"a":1}garbage']) + ("explicitly rejects incompatible/invalid JSON %s", input => expect(() => parseReconstructionJson(input)).toThrow()); + + it("bounds closure reads, individual files, nesting and output without silently truncating", () => { + let id = fixture.other.id; + for (let n = 0; n < 65; n++) id = save(seal({ ...fixture.other, kind: "delta", items: undefined, sources: undefined, parent: { store, revision: id } })); + const result = read(id); + expect(result.complete).toBe(false); + expect(result.records.length).toBeLessThanOrEqual(64); + expect(result.issues.some(i => i.code === "closure_limit")).toBe(true); + const fake = "a".repeat(64); writeFileSync(join(store, `${fake}.json`), Buffer.alloc(8 * 1024 * 1024 + 1, 32)); + expect(read(fake).issues[0]!.message).toContain("byte bound"); + expect(() => parseReconstructionJson("[".repeat(130) + "0" + "]".repeat(130))).toThrow(/nesting/); + expect(() => reconstructionOutput(read(), "view", true, 1024)).toThrow(/no complete output/); + }); + + it("checks local source bytes without refreshing facts, fetching opaque routes or following symlinks", () => { + const source = join(root, "evidence.txt"); writeFileSync(source, "before"); + const link = join(root, "link"); symlinkSync(source, link); + const doc = seal({ ...fixture.other, sources: [ + { id: "local", path: source, sha256: sha("before"), observed_at: "2026-10-01T10:00:00Z" }, + { id: "link", path: link, observed_at: null }, + { id: "opaque", address: "transcript:example", observed_at: null }, + ] }); + save(doc); + expect(read(doc.id).sourceChecks).toBeUndefined(); + expect(read(doc.id, { checkSources: true }).sourceChecks![0]!.status).toBe("unchanged"); + writeFileSync(source, "after"); + const checked = read(doc.id, { checkSources: true }); + expect(checked.sourceChecks!.map(s => s.status)).toEqual(["changed", "unavailable", "unchecked"]); + expect(checked.sourceChecks![0]!.observedAt).toBe("2026-10-01T10:00:00Z"); + expect(checked.nodes[0]!.sourceManifest[0]!.sha256).toBe(sha("before")); + const destination = join(store, `${doc.id}.json`); rmSync(destination); symlinkSync(source, destination); + expect(read(doc.id).complete).toBe(false); + }); +});