@rig-c/review 0.8.1 → 0.10.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.
package/src/baseline.ts CHANGED
@@ -13,6 +13,7 @@ import { isProducerFile, PRODUCER_DOCUMENTS, PRODUCER_IMAGES_DIR } from './produ
13
13
  import { firstRefusal, runTool, type InstalledTool } from './producer.ts';
14
14
  import { checkSummary, MAX_DIFF_PATHS, type CheckSummary, type FilesDiff } from './applied-context.ts';
15
15
  import { jsonDiff } from './config-patch.ts';
16
+ import { baselineReach, reachOps, type BaselineReach } from './baseline-reach.ts';
16
17
 
17
18
  export const BASELINE_SCHEMA = 'rig-review-baseline/1';
18
19
  export type BaselineStatus = 'running' | 'reproduced' | 'not-reproduced' | 'refused' | 'failed';
@@ -23,6 +24,8 @@ export interface BaselineResult {
23
24
  firstDifference: string | null;
24
25
  /** Both sides: the rig.json SHA-256 and the SHA-256 of the sorted {path: sha256} table of every compared file. */
25
26
  hashes: { baseline: { rigJson: string | null; tree: string }; inspected: { rigJson: string | null; tree: string } } | null;
27
+ /** #148: what Apply can still do when not reproduced (`src/baseline-reach.ts`); null otherwise. */
28
+ reach?: BaselineReach | null;
26
29
  line: string; exitCode: number | null; wallMs: number | null;
27
30
  }
28
31
  const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
@@ -73,9 +76,9 @@ export async function producerDiff(inspectedDir: string, scratchDir: string): Pr
73
76
  return { documents, images: { changed: changed.length, unchanged: names.length - changed.length, names: changed.slice(0, MAX_DIFF_PATHS) } };
74
77
  }
75
78
  /** The editor bar's line. */
76
- export function baselineLine(r: Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes' | 'line'>): string {
79
+ export function baselineLine(r: Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes' | 'line'> & { reach?: BaselineReach | null }): string {
77
80
  if (r.status === 'reproduced' && r.hashes) return `baseline: reproduced — rig.json ${r.hashes.baseline.rigJson?.slice(0, 12)}… = inspected ${r.hashes.inspected.rigJson?.slice(0, 12)}…`;
78
- if (r.status === 'not-reproduced') return `baseline: not reproduced — ${r.firstDifference}`;
81
+ if (r.status === 'not-reproduced') return `baseline: not reproduced — ${r.firstDifference}${r.reach ? ` · ${r.reach.text}` : ''}`;
79
82
  return r.line;
80
83
  }
81
84
  /**
@@ -86,7 +89,7 @@ export class BaselineError extends Error { constructor(readonly code: string, de
86
89
  export function assertWholeRigBaseline(result: BaselineResult | null): void {
87
90
  if (!result) throw new BaselineError('EDIT_BASELINE_MISMATCH', 'no baseline was run: the handoff or the producer did not resolve');
88
91
  if (result.status === 'running') throw new BaselineError('EDIT_BASELINE_PENDING', 'the baseline rebuild is still running');
89
- if (result.status !== 'reproduced') throw new BaselineError('EDIT_BASELINE_MISMATCH', result.firstDifference ?? result.line);
92
+ if (result.status !== 'reproduced') throw new BaselineError('EDIT_BASELINE_MISMATCH', result.firstDifference ? `${result.firstDifference}${result.reach ? ` — ${result.reach.text}` : ''}` : result.line);
90
93
  }
91
94
 
92
95
  /** Deterministic: the package, the handoff record and the producer. No clock. */
@@ -94,9 +97,11 @@ export const baselineKey = (sourceHash: string, handoff: Handoff, producer: Inst
94
97
  sha256(JSON.stringify([BASELINE_SCHEMA, sourceHash, handoff.record, `${producer.package}@${producer.version}`, `${compiler.package}@${compiler.version}`]));
95
98
 
96
99
  /** Compare a finished `<dir>/baseline` with the inspected rig; `show` turns a rig-relative path into the package-relative one. */
97
- export async function compareBaseline(out: string, inspectedRig: string, show: (rel: string) => string): Promise<Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes'>> {
100
+ export async function compareBaseline(out: string, inspectedRig: string, show: (rel: string) => string, producerVersion: string | null = null): Promise<Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes' | 'reach'>> {
98
101
  const baseline = await rigTree(out), inspected = await rigTree(inspectedRig), first = firstDifference(baseline, inspected);
99
- return { status: first === null ? 'reproduced' : 'not-reproduced', firstDifference: first === null ? null : show(first), hashes: { baseline: { rigJson: baseline['rig.json'] ?? null, tree: treeHash(baseline) }, inspected: { rigJson: inspected['rig.json'] ?? null, tree: treeHash(inspected) } } };
102
+ // #148: a build the config does not reproduce says, per op, what the producer will still take.
103
+ const reach = first === null ? null : await baselineReach(out, inspectedRig, baseline, inspected, show, reachOps(producerVersion));
104
+ return { status: first === null ? 'reproduced' : 'not-reproduced', firstDifference: first === null ? null : show(first), hashes: { baseline: { rigJson: baseline['rig.json'] ?? null, tree: treeHash(baseline) }, inspected: { rigJson: inspected['rig.json'] ?? null, tree: treeHash(inspected) } }, reach };
100
105
  }
101
106
 
102
107
  /**
@@ -106,11 +111,11 @@ export async function compareBaseline(out: string, inspectedRig: string, show: (
106
111
  export async function runBaseline(dir: string, key: string, handoff: Handoff, producer: InstalledTool, show: (rel: string) => string): Promise<BaselineResult> {
107
112
  const base = { schema: BASELINE_SCHEMA, key, producer: { package: producer.package, version: producer.version } } as const;
108
113
  const out = join(dir, 'baseline'), resultFile = join(dir, 'result.json');
109
- if (!handoff.parts || !handoff.rig) return { ...base, status: 'refused', firstDifference: null, hashes: null, line: `baseline: unavailable — MODELING_INPUT_UNAVAILABLE: ${handoff.record.missing.join(', ')}`, exitCode: null, wallMs: null };
114
+ if (!handoff.parts || !handoff.rig) return { ...base, status: 'refused', firstDifference: null, hashes: null, reach: null, line: `baseline: unavailable — MODELING_INPUT_UNAVAILABLE: ${handoff.record.missing.join(', ')}`, exitCode: null, wallMs: null };
110
115
  try {
111
116
  const stored = JSON.parse(await readFile(resultFile, 'utf8')) as BaselineResult;
112
117
  if (stored.schema === BASELINE_SCHEMA && stored.key === key && (stored.status === 'reproduced' || stored.status === 'not-reproduced')) {
113
- const now = await compareBaseline(out, handoff.rig, show);
118
+ const now = await compareBaseline(out, handoff.rig, show, producer.version);
114
119
  if (now.hashes?.baseline.tree === stored.hashes?.baseline.tree) return { ...stored, ...now, line: baselineLine({ ...stored, ...now }) };
115
120
  }
116
121
  } catch { /* no reusable result */ }
@@ -119,9 +124,9 @@ export async function runBaseline(dir: string, key: string, handoff: Handoff, pr
119
124
  const run = await runTool(producer.bin, ['rig', '--config', handoff.config, '--parts', handoff.parts, '--out', out], { cwd: dir });
120
125
  if (run.exitCode !== 0) {
121
126
  const named = firstRefusal(run.lines) ?? `rig-parts rig exited ${run.exitCode} without a named refusal`;
122
- return { ...base, status: 'refused', firstDifference: null, hashes: null, line: `baseline: not run — ${named}`, exitCode: run.exitCode, wallMs: run.wallMs };
127
+ return { ...base, status: 'refused', firstDifference: null, hashes: null, reach: null, line: `baseline: not run — ${named}`, exitCode: run.exitCode, wallMs: run.wallMs };
123
128
  }
124
- const compared = await compareBaseline(out, handoff.rig, show);
129
+ const compared = await compareBaseline(out, handoff.rig, show, producer.version);
125
130
  const result: BaselineResult = { ...base, ...compared, line: '', exitCode: 0, wallMs: run.wallMs };
126
131
  result.line = baselineLine(result);
127
132
  await writeFile(resultFile, JSON.stringify(result, null, 1) + '\n', { mode: 0o600 });
package/src/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { formatFeedbackIssues } from './feedback-export.ts';
6
6
  import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
7
7
  import {animationDiagnostic,selectAnimation} from './animation-selection.ts';
8
8
  import {shellWord} from './open-shell.ts';
9
- import {openReview,controlReview,loadOpenPackage,defaultStateDir,handoffFlags,type OpenInputs} from './open.ts';
9
+ import {openReview,buildOpenDiagnostic,controlReview,loadOpenPackage,defaultStateDir,handoffFlags,type OpenInputs} from './open.ts';
10
10
  import {checkHandoffFlags,HANDOFF_FLAGS} from './handoff.ts';
11
11
  import { readFeedbackSession, readFeedbackCompareSession } from './session-history.ts';
12
12
  import { startServer } from './server.ts';
@@ -94,7 +94,7 @@ export async function main(args:string[],options:{stateDir?:string}={}){
94
94
  options={stateDir:options.stateDir??defaultStateDir()};if(args[0]==='--')args.shift();
95
95
  if(args[0]==='control')return controlCommand(args.slice(1),options.stateDir);
96
96
  if(args[0]==='open'){const openArgs=args.slice(1);if(commandHelpRequested(openArgs,'open')){console.log(usage);return;}const inputs=parseOpenInputs(openArgs);const result=await openReview(inputs,options);// Launcher contract (README): stdout is the URL line alone; stderr is diagnostic, control notice, retrieval, in that order.
97
- console.log(result.url);if(result.selection)console.error(animationDiagnostic(result.selection));if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[]),...handoffWords(result.inputs)].map(shellWord).join(' ')}`);return;}
97
+ console.log(result.url);if(result.build)console.error(buildOpenDiagnostic(result.build));if(result.selection)console.error(animationDiagnostic(result.selection));if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[]),...handoffWords(result.inputs)].map(shellWord).join(' ')}`);return;}
98
98
  if(args[0]==='strip'){args.shift();if(args.includes('--help')){console.log(usage+'\nStrip: strip <package> --reference <declared-video-id> --animation <name> (--frames FIRST:LAST | --all-feedback) [--json] [--out path]\n strip compare <a> <b> --reference <video> --animation <name> --basis a|b (--frames FIRST:LAST | --all-feedback) [--json] [--out path]');return;}return stripCommand(args,options);}let command:'review'|'check'|'feedback'='review';if(args[0]==='check'||args[0]==='feedback'){command=args.shift() as 'check'|'feedback';}
99
99
  if(args[0]==='--help'||args[0]==='-h'){console.log(usage);return;}
100
100
  let format:'json'|'issue'='json';if(command==='feedback'){const parsed=feedbackFormat(args);args=parsed.args;format=parsed.format;}
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Density strip (#173), pure: N ≤ 5 values of one part's density field — `grid` on a lattice entry, `contour.spacing`
3
+ * on a contour entry — each sent as the one existing Mesh-panel patch (`replace /meshes/<part>`, `remesh --part
4
+ * <part>`, #106) and built side by side. This module holds what needs no IO: the request's shape (the patches may
5
+ * differ only at the density field), the values of a strip and of a refined strip, and the row a settled candidate job
6
+ * reads as — `remesh.json`'s `target.after` counts, the producer's own check lines for the part (folds, stretch, every
7
+ * bar the part is measured on as a ratio against its floor or ceiling) and the verdict (PASS, or the refusal verbatim).
8
+ * The two labels ("fewest triangles passing", "widest margin among ties") name rows; nothing here applies one.
9
+ */
10
+ import { applyConfigPatch, type ConfigPatch, type JsonValue } from './config-patch.ts';
11
+ import { assertMeshPatch, MeshEntryError, meshMode, type MeshCounts } from './mesh-entry.ts';
12
+
13
+ export const DENSITY_STRIP_SCHEMA = 'rig-review-density-strip/1';
14
+ /** At most this many values per strip (the card's N ≤ 5); at least two (one value is an Apply). */
15
+ export const MAX_STRIP_VALUES = 5, MIN_STRIP_VALUES = 2;
16
+ /** `POST /api/density-strip`: five patches of at most 64 KiB each (the Apply body bound). */
17
+ export const DENSITY_STRIP_LIMIT = MAX_STRIP_VALUES * 64 * 1024;
18
+ /** The density field a strip runs over, by the entry's mode. */
19
+ export type DensityField = 'grid' | 'contour.spacing';
20
+ export const densityPath = (field: DensityField): readonly string[] => (field === 'grid' ? ['grid'] : ['contour', 'spacing']);
21
+
22
+ export class DensityStripError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
23
+ const invalid = (detail: string): never => { throw new DensityStripError('DENSITY_STRIP_REQUEST_INVALID', detail); };
24
+ const isObject = (v: unknown): v is Record<string, JsonValue> => !!v && typeof v === 'object' && !Array.isArray(v);
25
+
26
+ /** The density field of an entry: `grid` for a lattice, `contour.spacing` for a contour; any other mode has none here. */
27
+ export function densityField(entry: unknown, part: string): DensityField {
28
+ const mode = meshMode(entry, part);
29
+ if ('error' in mode) throw new DensityStripError('DENSITY_STRIP_FIELD', mode.error);
30
+ if (mode.mode === 'grid') return 'grid';
31
+ if (mode.mode === 'contour') return 'contour.spacing';
32
+ return invalid(`meshes.${part} is a ${mode.mode} entry; a strip runs over grid (lattice) or contour.spacing (contour) only`);
33
+ }
34
+ const valueAt = (entry: Record<string, JsonValue>, path: readonly string[]): JsonValue | undefined => path.reduce<JsonValue | undefined>((v, k) => (isObject(v) ? v[k] : undefined), entry);
35
+ const without = (entry: Record<string, JsonValue>, path: readonly string[]): JsonValue => {
36
+ const out = structuredClone(entry); let holder: Record<string, JsonValue> = out;
37
+ for (const k of path.slice(0, -1)) holder = holder[k] as Record<string, JsonValue>;
38
+ delete holder[path.at(-1)!];
39
+ return out;
40
+ };
41
+
42
+ /** `{patches}` exactly, 2–5 of them (each still to be parsed as a config patch). */
43
+ export function stripPatches(input: unknown): unknown[] {
44
+ if (!isObject(input) || Object.keys(input).join(',') !== 'patches') invalid('expected exactly {patches}');
45
+ const patches = (input as { patches: unknown }).patches;
46
+ if (!Array.isArray(patches) || patches.length < MIN_STRIP_VALUES || patches.length > MAX_STRIP_VALUES) invalid(`patches must hold ${MIN_STRIP_VALUES}–${MAX_STRIP_VALUES} config patches`);
47
+ return patches as unknown[];
48
+ }
49
+
50
+ export interface StripShape { part: string; field: DensityField; values: number[]; entries: JsonValue[] }
51
+ /**
52
+ * The strip a list of parsed patches states: every one the Mesh panel's patch for one part (`MESH_PATCH_SCOPE`
53
+ * otherwise), the same config bytes, producer and flags, and entries that differ only at the part's density field,
54
+ * each at another value. Order is the request's.
55
+ */
56
+ export function stripShape(patches: readonly ConfigPatch[]): StripShape {
57
+ const first = patches[0]!;
58
+ const part = first.op.args[first.op.args.indexOf('--part') + 1];
59
+ if (first.op.cmd !== 'remesh' || typeof part !== 'string') invalid('a strip is made of remesh patches (remesh --part <part>)');
60
+ const field = densityField(first.ops[0]?.value, part!);
61
+ const path = densityPath(field), entries: JsonValue[] = [], values: number[] = [];
62
+ let rest: string | null = null;
63
+ for (const [i, patch] of patches.entries()) {
64
+ try { assertMeshPatch(patch, part!); } catch (e) { if (e instanceof MeshEntryError) throw new DensityStripError(e.code, `patches[${i}]: ${e.message.slice(e.code.length + 2)}`); throw e; }
65
+ if (patch.config_sha256 !== first.config_sha256) invalid(`patches[${i}] was made against other config bytes than patches[0]`);
66
+ if (JSON.stringify(patch.producer) !== JSON.stringify(first.producer) || JSON.stringify(patch.op) !== JSON.stringify(first.op)) invalid(`patches[${i}] names another producer or other flags than patches[0]`);
67
+ const entry = patch.ops[0]!.value;
68
+ if (!isObject(entry) || densityField(entry, part!) !== field) invalid(`patches[${i}]: the entry is not a ${field === 'grid' ? 'grid' : 'contour'} entry like patches[0]`);
69
+ const value = valueAt(entry as Record<string, JsonValue>, path);
70
+ if (typeof value !== 'number' || !Number.isFinite(value)) invalid(`patches[${i}]: meshes.${part}.${field} is not a number`);
71
+ const others = JSON.stringify(without(entry as Record<string, JsonValue>, path));
72
+ if (rest === null) rest = others;
73
+ else if (others !== rest) throw new DensityStripError('DENSITY_STRIP_FIELD', `patches[${i}] differs from patches[0] outside meshes.${part}.${field}; a strip varies the density field only`);
74
+ if (values.includes(value as number)) invalid(`patches[${i}]: ${field} ${value} is already in the strip`);
75
+ values.push(value as number); entries.push(entry as JsonValue);
76
+ }
77
+ return { part: part!, field, values, entries };
78
+ }
79
+
80
+ /** n values from a to b inclusive, evenly spaced; a lattice's grid is a whole number (rounded, duplicates dropped). */
81
+ export function stripValues(a: number, b: number, n: number, integer: boolean): number[] {
82
+ if (![a, b, n].every(Number.isFinite) || n < MIN_STRIP_VALUES || n > MAX_STRIP_VALUES || !Number.isInteger(n)) throw new DensityStripError('DENSITY_STRIP_VALUES', `N must be a whole number ${MIN_STRIP_VALUES}–${MAX_STRIP_VALUES}, and A and B numbers`);
83
+ const lo = Math.min(a, b), hi = Math.max(a, b);
84
+ const raw = Array.from({ length: n }, (_, k) => lo + ((hi - lo) * k) / (n - 1));
85
+ const out = [...new Set(raw.map(v => (integer ? Math.round(v) : Math.round(v * 1000) / 1000)))].sort((x, y) => x - y);
86
+ if (out.length < MIN_STRIP_VALUES) throw new DensityStripError('DENSITY_STRIP_VALUES', `${lo}…${hi} holds fewer than ${MIN_STRIP_VALUES} distinct ${integer ? 'whole ' : ''}values`);
87
+ return out;
88
+ }
89
+ /** "Refine between" two adjacent rows: up to n values strictly inside (a, b), evenly spaced. */
90
+ export function refineValues(a: number, b: number, n: number, integer: boolean): number[] {
91
+ const lo = Math.min(a, b), hi = Math.max(a, b);
92
+ const raw = Array.from({ length: n }, (_, k) => lo + ((hi - lo) * (k + 1)) / (n + 1));
93
+ const out = [...new Set(raw.map(v => (integer ? Math.round(v) : Math.round(v * 1000) / 1000)))].filter(v => v > lo && v < hi).sort((x, y) => x - y);
94
+ if (out.length < MIN_STRIP_VALUES) throw new DensityStripError('DENSITY_STRIP_VALUES', `between ${lo} and ${hi} there are fewer than ${MIN_STRIP_VALUES} ${integer ? 'whole ' : ''}values to try`);
95
+ return out;
96
+ }
97
+ /** The entry with its density field at `value` (every other field as the base entry holds it, in its order). */
98
+ export const entryAt = (entry: Record<string, JsonValue>, field: DensityField, value: number): JsonValue =>
99
+ applyConfigPatch(entry, [{ op: 'replace', path: densityPath(field).map(k => '/' + k).join(''), value }]);
100
+
101
+ /** One bar the part is measured on, as the producer's check printed it: its figure against the bar's floor or ceiling. */
102
+ export interface StripBar { bar: string; status: string; key: string; value: number; bound: number; kind: 'floor' | 'ceiling'; ratio: number; margin: number }
103
+ export interface StripFigures {
104
+ /** `ANIMATIONS_DO_NOT_FOLD`'s folding mesh count, as printed. */
105
+ folds: number | null;
106
+ /** The part's `TEXTURE_STRETCH` severity and the frame of its worst triangle (the max or the min side, whichever is worse). */
107
+ stretch: { severity: number; frame: string | null } | null;
108
+ bars: StripBar[];
109
+ }
110
+ const NUM = '(-?\\d+(?:\\.\\d+)?(?:e[-+]?\\d+)?)';
111
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
+ /**
113
+ * The check lines a candidate's figures are read from: the producer op's own check of its revision (remesh prints it
114
+ * as `[check-revision] …`, #174) when the job carries it, else the lines of the check review ran (no bracket prefix).
115
+ */
116
+ export function revisionCheckLines(lines: readonly string[]): string[] {
117
+ const tagged = lines.filter(l => l.startsWith('[check-revision]')).map(l => l.slice('[check-revision]'.length));
118
+ return tagged.length ? tagged : lines.filter(l => !/^\s*\[/.test(l) && !l.startsWith('review:'));
119
+ }
120
+ /** The part's figures from check's bar lines (` BAR: STATUS — detail`), the producer's numbers as printed. */
121
+ export function stripFigures(lines: readonly string[], part: string): StripFigures {
122
+ const out: StripFigures = { folds: null, stretch: null, bars: [] };
123
+ const marker = new RegExp(`\\b(?:part|slot) ${escape(part)}, `, 'g');
124
+ for (const raw of revisionCheckLines(lines)) {
125
+ const m = /^\s*([A-Z][A-Z0-9_]+): (PASS|FAIL|SKIP|WARN|CLEAR|REPORTED) — (.*)$/.exec(raw);
126
+ if (!m) continue;
127
+ const [, bar, status, detail] = m as unknown as [string, string, string, string];
128
+ if (bar === 'ANIMATIONS_DO_NOT_FOLD') { const f = /(\d+) folding mesh\(es\)/.exec(detail); if (f) out.folds = Number(f[1]); }
129
+ // The part's own segment: the last time the line names it (TEXTURE_STRETCH names its worst slot first, then per mesh).
130
+ let at = -1; for (const hit of detail.matchAll(marker)) at = hit.index!;
131
+ if (at < 0) continue;
132
+ const end = detail.indexOf(' | ', at), segment = detail.slice(at, end < 0 ? undefined : end);
133
+ if (/^\S+ \S+, unmeasured\b/.test(segment)) continue;
134
+ if (bar === 'TEXTURE_STRETCH') {
135
+ const sev = new RegExp(`\\bseverity ${NUM}`).exec(segment), max = new RegExp(`\\bmax_ratio ${NUM}`).exec(segment), min = new RegExp(`\\bmin_ratio ${NUM}`).exec(segment);
136
+ const frames = [...segment.matchAll(/\b([A-Za-z0-9_-]+) frame (\d+)/g)].map(f => `${f[1]} frame ${f[2]}`);
137
+ if (sev) {
138
+ // The worst frame is the side the severity was taken from: the max ratio, or the inverse of the min ratio.
139
+ const s = Number(sev[1]), worstMin = !!(max && min && Number(min[1]) > 0 && Math.abs(1 / Number(min[1]) - s) < Math.abs(Number(max[1]) - s));
140
+ out.stretch = { severity: Number(sev[1]), frame: (worstMin ? frames[1] : frames[0]) ?? null };
141
+ }
142
+ }
143
+ for (const b of detail.matchAll(new RegExp(`\\b([a-z][a-z0-9_]*?)_(floor|ceiling) ${NUM}`, 'g'))) {
144
+ const key = b[1]!, kind = b[2] as 'floor' | 'ceiling', bound = Number(b[3]);
145
+ const v = new RegExp(`\\b${escape(key)} ${NUM}`).exec(segment);
146
+ if (!v || !(bound > 0)) continue;
147
+ const value = Number(v[1]), ratio = value / bound;
148
+ out.bars.push({ bar, status, key, value, bound, kind, ratio, margin: kind === 'floor' ? ratio : value > 0 ? bound / value : Infinity });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** A settled candidate job, as much of it as a row reads. */
155
+ export interface CandidateJobLike {
156
+ status: string; producerLine: string | null; lines: readonly string[];
157
+ remesh: { part: string; before: MeshCounts; after: MeshCounts; modelTriangles: number | null } | null;
158
+ check: { pass: boolean | null; line: string | null } | null;
159
+ build: { modelHash: string } | null;
160
+ }
161
+ export interface StripRow extends StripFigures {
162
+ value: number; id: string; status: string;
163
+ /** `remesh.json`'s `target.after` (null when the candidate wrote none: refused before it). */
164
+ counts: { vertices: number; triangles: number } | null;
165
+ modelTriangles: number | null;
166
+ /** PASS, or the producer's refusal (or check's line) verbatim. */
167
+ verdict: string;
168
+ /** Served for viewing while the strip holds it; null for a refused or running row. */
169
+ modelUrl: string | null; modelHash: string | null;
170
+ /** The candidate's build moved into the Apply scratch root when the reader Applied this row's patch. */
171
+ applied: boolean;
172
+ entry: JsonValue;
173
+ }
174
+ export function candidateRow(input: { value: number; id: string; entry: JsonValue; job: CandidateJobLike; part: string; modelUrl: string | null; applied: boolean }): StripRow {
175
+ const { job } = input, built = job.status === 'built';
176
+ const verdict = job.status === 'running' ? 'running' : built ? (job.check?.pass ? 'PASS' : job.check?.line ?? 'check: no summary') : job.producerLine ?? `REBUILD_${job.status.toUpperCase()}`;
177
+ return {
178
+ value: input.value, id: input.id, status: job.status, ...stripFigures(job.lines, input.part),
179
+ counts: job.remesh ? { vertices: job.remesh.after.vertices, triangles: job.remesh.after.triangles } : null,
180
+ modelTriangles: job.remesh?.modelTriangles ?? null, verdict,
181
+ modelUrl: built && !input.applied ? input.modelUrl : null, modelHash: built ? job.build?.modelHash ?? null : null,
182
+ applied: input.applied, entry: input.entry,
183
+ };
184
+ }
185
+ /** The narrowest margin of a row over every bar it is measured on (> 1: every bar met with room), or null with none. */
186
+ export const rowMargin = (row: Pick<StripRow, 'bars'>): number | null => (row.bars.length ? Math.min(...row.bars.map(b => b.margin)) : null);
187
+ /**
188
+ * The two labels, by row index: the passing row with the fewest triangles, and — when several passing rows tie on that
189
+ * count — the one among them with the widest narrowest margin. Labels only: nothing here applies a row.
190
+ */
191
+ export function stripLabels(rows: readonly StripRow[]): { fewest: number | null; widest: number | null } {
192
+ const passing = rows.map((r, i) => ({ r, i })).filter(x => x.r.verdict === 'PASS' && x.r.counts);
193
+ if (!passing.length) return { fewest: null, widest: null };
194
+ const least = Math.min(...passing.map(x => x.r.counts!.triangles)), tied = passing.filter(x => x.r.counts!.triangles === least);
195
+ const fewest = tied[0]!.i;
196
+ if (tied.length < 2) return { fewest, widest: null };
197
+ const best = tied.reduce((a, b) => ((rowMargin(b.r) ?? -Infinity) > (rowMargin(a.r) ?? -Infinity) ? b : a));
198
+ return { fewest, widest: best.i };
199
+ }
200
+ /** A bar as the table shows it: `TIP_OVER_ROOT ratio 1.995 / floor 1.4725 = 1.355×`. */
201
+ export const barText = (b: StripBar) => `${b.bar} ${b.key} ${b.value} / ${b.kind} ${b.bound} = ${b.ratio.toFixed(3)}×`;
package/src/front.ts CHANGED
@@ -57,7 +57,14 @@ export function frontRoute(mode:'single'|'compare',method:string,path:string):Ro
57
57
  if((mode==='single'?/^\/assets\/[^/]/:/^\/compare-assets\/(a|b)\/[^/]/).test(path))return read?get:null;
58
58
  // Apply · Rebuild (single only): status, one typed request, a job's status and its served scratch build.
59
59
  if(mode==='single'&&path==='/api/rebuild')return read?get:method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
60
+ // #166 (single only): the applied records, and undo of the last one ({from}).
61
+ if(mode==='single'&&path==='/api/rebuild/history')return read?get:null;
62
+ if(mode==='single'&&path==='/api/rebuild/undo')return method==='POST'?{mutation:true,limit:1024,timeoutMs:30000,control:false}:null;
60
63
  if(mode==='single'&&(/^\/api\/rebuild\/[a-f0-9]{64}$/.test(path)||/^\/rebuild\/[a-f0-9]{64}\/[^/]/.test(path)))return read?get:null;
64
+ // #173 (single only): a density strip (2–5 patches, 320 KiB), its status, a candidate's job, its served build, and closing it.
65
+ if(mode==='single'&&path==='/api/density-strip')return read?get:method==='POST'?{mutation:true,limit:320*1024,timeoutMs:30000,control:false}:null;
66
+ if(mode==='single'&&path==='/api/density-strip/close')return method==='POST'?{mutation:true,limit:1024,timeoutMs:30000,control:false}:null;
67
+ if(mode==='single'&&(/^\/api\/density-strip\/[a-f0-9]{64}(?:\/[a-f0-9]{64})?$/.test(path)||/^\/density-strip\/[a-f0-9]{64}\/[a-f0-9]{64}\/[^/]/.test(path)))return read?get:null;
61
68
  // Bones check (#109, single only): one typed request {ops, configHash}, answered in the response.
62
69
  if(mode==='single'&&path==='/api/bones-check')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:60000,control:false}:null;
63
70
  // Readings (#108, single only): one typed request, then its job's status.
package/src/handoff.ts CHANGED
@@ -9,6 +9,13 @@
9
9
  * package root; for the `../config.json` step, the folder that holds `out/`); a symlink that leaves it is refused by name.
10
10
  * The record (paths relative to the package root, hashes, how it was found) is part of `reviewedSource`, so changing a
11
11
  * handoff file's bytes starts a new pending session.
12
+ *
13
+ * A rig-parts build root (#149, {@link isBuildRoot}) opens as its `check/` package. For that package the producer's own
14
+ * config for the build is the regular file `config.json` in the build root, else in the folder that holds it — the two
15
+ * places `rig-parts build --config <dir>/config.json --out <dir>/out` leaves it. The build writes no record of its
16
+ * config, so this is a location rule bounded by the build's own files, not a proof: the session baseline (the producer
17
+ * rebuilding the inspected rig from that config) is the check. Discovered there, or declared with `--config` at exactly
18
+ * one of those two files, the handoff's root is that file's folder and the parts directory is the build root.
12
19
  */
13
20
  import { createHash } from 'node:crypto';
14
21
  import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
@@ -19,6 +26,9 @@ export const HANDOFF_SCHEMA = 'rig-review-handoff/1';
19
26
  export const DISCOVERY_PLACES = ['config.json', 'rig/config.json', '../config.json (beside an opened out/)'] as const;
20
27
  export const HANDOFF_DECLARATION = 'rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block';
21
28
  export const MODELING_INPUT_UNAVAILABLE = `MODELING_INPUT_UNAVAILABLE: config.json — looked at ${DISCOVERY_PLACES.join(', ')}; declare it with ${HANDOFF_DECLARATION}`;
29
+ /** #149: the places looked at for the check package of a rig-parts build root, after the in-folder ones. */
30
+ export const BUILD_DISCOVERY_PLACES = ['../config.json (in the build root)', '../../config.json (beside the build root)'] as const;
31
+ export const BUILD_MODELING_INPUT_UNAVAILABLE = `MODELING_INPUT_UNAVAILABLE: config.json — looked at ${[...DISCOVERY_PLACES.slice(0, 2), ...BUILD_DISCOVERY_PLACES].join(', ')}; declare it with ${HANDOFF_DECLARATION}`;
22
32
  /** Files the producer reads by the config's own naming, resolved against the config's directory (rig-parts `relativeConfigPaths`). */
23
33
  export const CONFIG_NAMED_FILES = [['motion', 'animations_from'], ['generation', 'control', 'skeleton']] as const;
24
34
  /** What `rig-parts rig` writes into its --out and remesh reads from --rig: the rig spec, its motion, the mesh report and the part images. */
@@ -56,6 +66,33 @@ const inside = (base: string, path: string, allowSelf = false) => { const rel =
56
66
  const code = (e: unknown) => (e as NodeJS.ErrnoException).code ?? '';
57
67
  async function exists(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return false; throw e; } }
58
68
 
69
+ /** A path's own entry (never followed) is of this kind; absent or another kind is false. */
70
+ async function entryIs(path: string, kind: 'file' | 'dir'): Promise<boolean> {
71
+ try { const info = await lstat(path); return kind === 'file' ? info.isFile() : info.isDirectory(); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return false; throw e; }
72
+ }
73
+ /**
74
+ * #149: a rig-parts build root — the folder `rig-parts build --out` wrote — recognised by fixed names the build owns, never
75
+ * by a search: regular `parts.json` and `rig/rig.json`, a real (not symlinked) `check/` directory, and an existing
76
+ * `check/build/skeleton.model.json` entry (its validity is the package adapter's to refuse). A folder lacking any of
77
+ * these is not a build root.
78
+ */
79
+ export async function isBuildRoot(dir: string): Promise<boolean> {
80
+ if (!(await entryIs(join(dir, 'parts.json'), 'file')) || !(await entryIs(join(dir, 'rig'), 'dir')) || !(await entryIs(join(dir, 'rig', 'rig.json'), 'file')) || !(await entryIs(join(dir, 'check'), 'dir'))) return false;
81
+ return exists(join(dir, 'check', 'build', 'skeleton.model.json'));
82
+ }
83
+ /** The producer's own config places for the build whose check package is `root` (canonical), or null when `root` is not one. */
84
+ interface BuildPlace { config: string; base: string; field: string }
85
+ async function buildPlaces(root: string): Promise<{ build: string; places: BuildPlace[] } | null> {
86
+ if (basename(root) !== 'check' || !(await isBuildRoot(dirname(root)))) return null;
87
+ const build = dirname(root);
88
+ return { build, places: [{ config: join(build, 'config.json'), base: build, field: '../config.json' }, { config: join(dirname(build), 'config.json'), base: dirname(build), field: '../../config.json' }] };
89
+ }
90
+ /** The path as written with its parent chain canonicalized (so /tmp → /private/tmp is not an escape); null when the parent is missing. */
91
+ async function writtenPath(value: string): Promise<string | null> {
92
+ const lexical = resolve(value);
93
+ try { return join(await realpath(dirname(lexical)), basename(lexical)); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return null; throw e; }
94
+ }
95
+
59
96
  /**
60
97
  * One handoff path: it must exist, its canonical location must stay inside `base`, and it must be the expected kind.
61
98
  * `lexicalInside` says whether the path as written was inside the root, so a canonical escape is named a symlink escape.
@@ -175,13 +212,17 @@ export async function resolveHandoff(root: string, declared: HandoffDeclaration
175
212
  if (flags) {
176
213
  const problem = checkHandoffFlags(flags);
177
214
  if (problem) throw new HandoffError('HANDOFF_FLAGS_INVALID', problem.split(':')[0]!, problem);
178
- const config = await flagPath(root, flags.config, '--config', 'file');
179
- const parts = flags.parts !== undefined ? await flagPath(root, flags.parts, '--parts', 'dir') : join(dirname(config), 'out');
180
- const source = flags.source !== undefined ? await flagPath(root, flags.source, '--source', 'file') : undefined;
181
- const full = flags.full !== undefined ? await flagPath(root, flags.full, '--full', 'dir') : undefined;
182
- const head = flags.head !== undefined ? await flagPath(root, flags.head, '--head', 'dir') : undefined;
183
- const keypoints = flags.keypoints !== undefined ? await flagPath(root, flags.keypoints, '--keypoints', 'file') : undefined;
184
- const handoff = await record(root, root, 'declared', 'open --config', config, parts, { source, full, head, keypoints }, flags.parts !== undefined);
215
+ // #149: a --config written as exactly one of the build's own config places takes that place's folder as its root.
216
+ const build = await buildPlaces(root), written = build ? await writtenPath(flags.config) : null;
217
+ const place = build?.places.find(p => p.config === written) ?? null, base = place?.base ?? root;
218
+ const config = await flagPath(base, flags.config, '--config', 'file');
219
+ if (place && config !== place.config) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', '--config', `${flags.config} is a symlink; only the build's own ${place.field} is taken`);
220
+ const parts = flags.parts !== undefined ? await flagPath(base, flags.parts, '--parts', 'dir') : place ? build!.build : join(dirname(config), 'out');
221
+ const source = flags.source !== undefined ? await flagPath(base, flags.source, '--source', 'file') : undefined;
222
+ const full = flags.full !== undefined ? await flagPath(base, flags.full, '--full', 'dir') : undefined;
223
+ const head = flags.head !== undefined ? await flagPath(base, flags.head, '--head', 'dir') : undefined;
224
+ const keypoints = flags.keypoints !== undefined ? await flagPath(base, flags.keypoints, '--keypoints', 'file') : undefined;
225
+ const handoff = await record(root, base, 'declared', 'open --config', config, parts, { source, full, head, keypoints }, flags.parts !== undefined);
185
226
  // Canonical absolute flags: the background worker and later reloads read exactly these.
186
227
  handoff.flags = { config, ...(flags.parts !== undefined ? { parts } : {}), ...(source ? { source } : {}), ...(full ? { full } : {}), ...(head ? { head } : {}), ...(keypoints ? { keypoints } : {}) };
187
228
  return { handoff, reason: null };
@@ -202,6 +243,17 @@ export async function resolveHandoff(root: string, declared: HandoffDeclaration
202
243
  const config = await contained(root, path, name, 'file', true);
203
244
  return { handoff: await record(root, root, 'discovered', 'discovery', config, join(dirname(path), 'out'), {}, false), reason: null };
204
245
  }
246
+ // #149: the check package of a rig-parts build root — the build's own config, in the build root, else beside it.
247
+ const build = await buildPlaces(root);
248
+ if (build) {
249
+ for (const place of build.places) {
250
+ if (!(await exists(place.config))) continue;
251
+ const config = await contained(place.base, place.config, place.field, 'file', true);
252
+ if (config !== place.config) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', place.field, `${place.config} is a symlink; only the build's own config file is discovered`);
253
+ return { handoff: await record(root, place.base, 'discovered', 'discovery', config, build.build, {}, false), reason: null };
254
+ }
255
+ return { handoff: null, reason: BUILD_MODELING_INPUT_UNAVAILABLE };
256
+ }
205
257
  // The last step: an opened `out/` whose config sits beside it, one level up. Outside the folder, so named.
206
258
  if (basename(root) === 'out') {
207
259
  const parent = dirname(root), path = join(parent, 'config.json');
package/src/manifest.ts CHANGED
@@ -6,7 +6,7 @@ import { parseReport, type ReportSeeds } from './report.ts';
6
6
  import { readFile, realpath, stat, lstat, readdir } from 'node:fs/promises';
7
7
  import { createReadStream } from 'node:fs';
8
8
  import { basename, dirname, isAbsolute, relative, resolve, posix } from 'node:path';
9
- import { parseHandoffDeclaration, resolveHandoff, type Handoff, type HandoffDeclaration, type HandoffFlags, type HandoffRecord } from './handoff.ts';
9
+ import { isBuildRoot, parseHandoffDeclaration, resolveHandoff, type Handoff, type HandoffDeclaration, type HandoffFlags, type HandoffRecord } from './handoff.ts';
10
10
 
11
11
  export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
12
12
  export interface Reference { id: string; label: string; type: 'image' | 'video' | 'pose'; path: string; width?: number; height?: number }
@@ -74,7 +74,9 @@ export function parseManifest(input: unknown): ReviewManifest {
74
74
  /** `options.handoff` are the explicit `--config …` flags; they win over the manifest block and discovery. */
75
75
  export async function loadReview(input: string, options: { handoff?: HandoffFlags } = {}): Promise<ReviewPackage> {
76
76
  const inputPath=await realpath(resolve(input));const directory=(await stat(inputPath)).isDirectory();
77
- if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'),options);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}}
77
+ if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'),options);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
78
+ // #149: a rig-parts build root without a model of its own opens as its check package, exactly as opening check/ does.
79
+ const check=await buildCheckPackage(inputPath);if(check)return loadReview(check,options);}
78
80
  const manifestPath=inputPath;const root=directory?inputPath:await realpath(dirname(inputPath));
79
81
  const manifest=directory?await folderManifest(root):parseManifest(JSON.parse(await readFile(manifestPath,'utf8')));
80
82
  const files = new Set([...(directory?[]:[relative(root,manifestPath)]),...manifest.references.map(v=>v.path),...manifest.authoring.map(v=>v.path),...manifest.audio.clips.map(v=>v.path)]);
@@ -112,9 +114,17 @@ export async function loadReview(input: string, options: { handoff?: HandoffFlag
112
114
  return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{})};
113
115
  }
114
116
 
117
+ /**
118
+ * #149: the `check/` package of a rig-parts build root ({@link isBuildRoot}), when the folder has neither of the two
119
+ * adapter model paths itself (a folder that does keeps opening as before). Fixed names, no search.
120
+ */
121
+ export async function buildCheckPackage(dir:string):Promise<string|null>{
122
+ for(const candidate of ['skeleton.model.json','build/skeleton.model.json']){try{await lstat(resolve(dir,candidate));return null;}catch(e){if(!['ENOENT','ENOTDIR'].includes((e as NodeJS.ErrnoException).code??''))throw e;}}
123
+ return await isBuildRoot(dir)?resolve(dir,'check'):null;
124
+ }
115
125
  /** Same text for every command; `root` lets the open launcher name the refused input. */
116
126
  export class UnrecognizedOutputError extends Error {
117
- constructor(readonly root:string){super('Output folder: no recognized skeleton.*, build/skeleton.model.json, motion.json, report.json, source video, pose sequence or contact strip');}
127
+ constructor(readonly root:string){super('Output folder: no recognized skeleton.*, build/skeleton.model.json, rig-parts build root (check/build/skeleton.model.json), motion.json, report.json, source video, pose sequence or contact strip');}
118
128
  }
119
129
  /** An adapter over an existing output directory; never writes a manifest there. */
120
130
  async function folderManifest(root:string):Promise<ReviewManifest>{
package/src/open.ts CHANGED
@@ -16,7 +16,14 @@ import {selectAnimation,AnimationSelectionError,type AnimationSelection} from '.
16
16
  import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-protocol.ts';
17
17
  import {ensureFront,frontPort} from './front.ts';
18
18
  import {playPackagePath} from './versions.ts';
19
- import {HandoffError,HANDOFF_FLAGS,type HandoffFlags} from './handoff.ts';
19
+ import {HandoffError,HANDOFF_FLAGS,isBuildRoot,type HandoffFlags,type HandoffRecord} from './handoff.ts';
20
+ /** #149: an opened rig-parts build check package — its canonical path and the handoff record (or the refusal line). */
21
+ export interface BuildOpen {package:string;handoff:HandoffRecord|null;reason:string|null}
22
+ /** The stderr diagnostic for {@link BuildOpen}: the package opened, then how the config was found, its package-relative path and full SHA-256. */
23
+ export function buildOpenDiagnostic(build:BuildOpen):string{
24
+ const h=build.handoff,how=!h?'':h.how==='declared'?`declared (${h.via})`:h.outside?'discovered outside the opened folder':'discovered';
25
+ return `Package: rig-parts build check package ${build.package}\n${h?`Handoff ${how}: ${h.config.path} sha256 ${h.config.sha256}${h.parts?` · parts ${h.parts}`:''}`:`Handoff: ${build.reason??'none'}`}`;
26
+ }
20
27
  /** `direct` chooses how the page is reached (own port instead of the managed front); it is not part of the instance identity. */
21
28
  export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean;config?:string;parts?:string;source?:string;full?:string;head?:string;keypoints?:string}
22
29
  /** The producer handoff flags (#101) among the inputs, or undefined when none was given. */
@@ -97,7 +104,7 @@ async function openRefusal(error:unknown,inputs:OpenInputs):Promise<unknown>{
97
104
  }
98
105
  /** Managed is the default: the printed URL goes through the one loopback front (127.0.0.1:4318, or
99
106
  * RIG_REVIEW_FRONT_PORT) so browser storage keeps one origin across reopens. `direct` keeps the worker's own port. */
100
- export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number;direct?:boolean;frontPort?:number;frontIdleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null;direct:boolean}>{
107
+ export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number;direct?:boolean;frontPort?:number;frontIdleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null;direct:boolean;build:BuildOpen|null}>{
101
108
  const direct=options.direct??inputs.direct??false,port=direct?0:frontPort(options.frontPort);
102
109
  for(const path of [inputs.input,...(inputs.compare!==undefined?[inputs.compare]:[])])await assertOpenInput(path);
103
110
  if(inputs.compare!==undefined&&!inputs.reference)throw new Error('OPEN_REFERENCE_REQUIRED: compare: explicit --reference required');
@@ -108,7 +115,9 @@ export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idl
108
115
  const control=join(stateDir,'open');await mkdir(control,{recursive:true,mode:0o700});const canonicalControl=await realpath(control);if((await lstat(control)).isSymbolicLink())throw new Error('open.state: control directory cannot be a symlink');
109
116
  const worker=await ensureWorker(p,key,fingerprint,stateDir,canonicalControl,options.idleMs??30*60*1000);
110
117
  const url=direct?openUrl(worker.row):openUrl(worker.row,await ensureFront({control:canonicalControl,port,fingerprint,idleMs:options.frontIdleMs}));
111
- return {url,reused:worker.reused,nonce:worker.row.nonce,inputs:p.inputs,selection:p.selection,direct};
118
+ // #149: the check package of a rig-parts build root says which package it opened and which config the handoff took.
119
+ const build=p.mode==='single'&&basename(p.p.root)==='check'&&await isBuildRoot(dirname(p.p.root))?{package:p.p.root,handoff:p.p.reviewedSource.handoff??null,reason:p.p.handoffReason}:null;
120
+ return {url,reused:worker.reused,nonce:worker.row.nonce,inputs:p.inputs,selection:p.selection,direct,build};
112
121
  }
113
122
  async function ensureWorker(p:OpenPackage,key:string,fingerprint:string,stateDir:string,canonicalControl:string,idleMs:number):Promise<{row:RecordFile;reused:boolean}>{
114
123
  const registry=join(canonicalControl,key+'.json'),claim=join(canonicalControl,key+'.claim'),nonce=randomUUID(),deadline=performance.now()+20000;