@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/README.md CHANGED
@@ -39,7 +39,7 @@ bun run check -- /absolute/path/to/output
39
39
  bun run review -- /absolute/path/to/output
40
40
  ```
41
41
 
42
- The adapter recognizes root `skeleton.model.json` or the fixed rigc build layout `build/skeleton.model.json` (root model takes precedence), root `motion.json`, `report.json`, `skeleton.json`/`.skel`/`.atlas`, source videos (`.mp4`, `.webm`, `.mov`), image filenames containing `strip`, `contact`, `sheet`, `filmstrip` or `띠`, and canonical `*pose*.json` sequences. Model discovery checks only those two named paths; it never recursively searches directories. Model page images resolve relative to the selected model, while motion and references remain relative to the opened folder. It builds the normalized review manifest in memory and reads source files without changing them. Motion/report/skeleton evidence links are read only. A recognized `rig-pose-report/1` also supplies read-only event markers: frame-only events retain their producer interval and explicitly lack seek until a declared clock is present; explicit event seconds can seek. Empty reported categories show none, while missing fields remain missing. Report markers prefill known feedback coordinates and actual bone/slot names without creating a review decision or motion keys. It exposes each matching video/strip separately, without guessing synchronization or source-to-rig bindings.
42
+ The adapter recognizes root `skeleton.model.json` or the fixed rigc build layout `build/skeleton.model.json` (root model takes precedence), root `motion.json`, `report.json`, `skeleton.json`/`.skel`/`.atlas`, source videos (`.mp4`, `.webm`, `.mov`), image filenames containing `strip`, `contact`, `sheet`, `filmstrip` or `띠`, and canonical `*pose*.json` sequences. Model discovery checks only those two named paths; it never recursively searches directories. A `rig-parts build` output root (`out/`: regular `parts.json` and `rig/rig.json`, a real `check/` directory and `check/build/skeleton.model.json`) that has neither model path itself opens as its `check/` package — the same package, identity and session as opening `out/check` — so `rig-review open <out>` works on a fresh build without copying `check/build` anywhere. Model page images resolve relative to the selected model, while motion and references remain relative to the opened folder. It builds the normalized review manifest in memory and reads source files without changing them. Motion/report/skeleton evidence links are read only. A recognized `rig-pose-report/1` also supplies read-only event markers: frame-only events retain their producer interval and explicitly lack seek until a declared clock is present; explicit event seconds can seek. Empty reported categories show none, while missing fields remain missing. Report markers prefill known feedback coordinates and actual bone/slot names without creating a review decision or motion keys. It exposes each matching video/strip separately, without guessing synchronization or source-to-rig bindings.
43
43
 
44
44
  When the report declares `fps` plus `frame0` (seconds offset) and a frame count, or exact `frameTimes[]`, the matching animation also shows a producer-frame readout and frame seek controls. Without a bounded declared clock it shows `rate unknown`; current unchanged v19 has no clock. Sample times come only from the report, and feedback context records the recalculated frame or null.
45
45
 
@@ -164,7 +164,12 @@ rig-review feedback /absolute/output --config /absolute/output/rig/config.json
164
164
  handoff (see `docs/REVIEW_PACKAGE.md`, *Producer handoff*). They win over the
165
165
  review.json `producer` block, which wins over discovery (`config.json`, then
166
166
  `rig/config.json` inside the opened folder, then `../config.json` beside an opened
167
- `out/`, named as discovered outside the folder). Every path must sit inside the
167
+ `out/`, named as discovered outside the folder). For the `check/` package of a
168
+ `rig-parts build` output the next places are the build's own config: `config.json`
169
+ in the build root, else beside it (`<out>/../config.json`, where `rig-parts build
170
+ --config <dir>/config.json --out <dir>/out` leaves it); the parts directory is the
171
+ build root. Declaring `--config` at exactly one of those two files is taken the same
172
+ way, and that file's folder is then the root for the other flags. Every other path must sit inside the
168
173
  package root; a path outside it or a symlink that leaves it is refused by name.
169
174
  The handoff files are hashed into the reviewed identity, so `feedback` and
170
175
  `control` take the same flags to retrieve the same session, and changing the
@@ -228,13 +233,15 @@ stderr and exit code alone:
228
233
 
229
234
  1. On success stdout is exactly one line, the URL. `open` never writes anything
230
235
  else to stdout.
231
- 2. On success stderr carries the human lines in this fixed order: the animation
232
- diagnostic (when a clip is selected), the control notice (with `--control`),
236
+ 2. On success stderr carries the human lines in this fixed order: for the `check/`
237
+ package of a `rig-parts build` output, `Package: rig-parts build check package <path>`
238
+ and the handoff line (`Handoff <how>: <config path> sha256 <hash> · parts <dir>`, or
239
+ `Handoff: <refusal>`); the animation diagnostic (when a clip is selected), the control notice (with `--control`),
233
240
  then the copyable retrieval line `Review started|reused; feedback: rig-review feedback …`.
234
241
  3. A refusal exits with code 1, writes nothing to stdout, and its first stderr line
235
242
  starts with a name, followed by a colon and the explanation:
236
243
  - `OPEN_INPUT_MISSING <path>`: the input or `--compare` path does not exist, or is neither a directory nor a regular file.
237
- - `OPEN_INPUT_UNRECOGNIZED <path>`: the folder holds no recognized model, skeleton, motion, report, reference or pose sequence, or the file is not a JSON manifest.
244
+ - `OPEN_INPUT_UNRECOGNIZED <path>`: the folder holds no recognized model, skeleton, motion, report, reference or pose sequence and is not a `rig-parts build` output root, or the file is not a JSON manifest.
238
245
  - `OPEN_REFERENCE_REQUIRED`: `--compare` was given without an explicit `--reference`.
239
246
  - `OPEN_ANIMATION_UNKNOWN <name>`: the explicit or report-named clip (JSON-quoted) is not in the compiled model.
240
247
  - `OPEN_ANIMATION_AMBIGUOUS`: no flag or report selects one clip, or the A/B reports conflict.
@@ -598,9 +605,9 @@ Rig pixels are the whole figure's frame, which is the crop frame the model's sta
598
605
 
599
606
  - **Producer.** `@rig-c/parts`' `rig-parts`, resolved the way `rigc review` resolves `rig-review`: `node_modules/@rig-c/parts/package.json` in the nearest directory walking up from the package, then from the directory `rig-review open` was run in; its `bin` entry is read from that manifest, never from `PATH`. The compiler is the `rig-c` that `rig-parts` itself finds walking up from its own package. Absent: `MODELING_PRODUCER_NOT_INSTALLED: … — bun add -d @rig-c/parts`; older than the first release with `remesh` (1.40.0): `MODELING_PRODUCER_TOO_OLD <found> 1.40.0`. Review never installs anything.
600
607
  - **Inputs.** The producer handoff (`docs/REVIEW_PACKAGE.md`, *Producer handoff*): `open --config` (and `--parts`), else the review.json `producer` block, else `config.json`, then `rig/config.json` inside the opened folder, then `../config.json` beside an opened `out/`. The parts directory defaults to `out/` beside the config and holds `parts.json`, `parts/` and the inspected `rig/`. The editor bar shows, before any Apply, how the config was found (declared, discovered, or discovered outside the opened folder), its path and SHA-256, and the baseline. Without a handoff the bar shows `MODELING_INPUT_UNAVAILABLE: config.json — looked at …` and Apply refuses `MODELING_HANDOFF_MISSING`, naming `--config`. The request carries only `{part, regions, configHash}`; the server reads the config, replaces `meshes.<part>.contour.regions` with the designated regions (every other field as written) and runs `rig-parts remesh --rig <out>/rig --config <config> --parts <out> --part <part> --mesh mesh.json --out revision`, then `rigc build --profile rig-play --pack` with the revision's own `--page-edges` / `--pack-shape`, both with the scratch directory as working directory. A region row with equal semi-axes and no clip is sent as the config's circle, any other as the 32-sample polygon. An untouched editor starts from the config's own circle regions and contour numbers.
601
- - **Baseline.** Once per session, when the handoff and the producer both resolve, review runs `rig-parts rig --config <config> --parts <parts> --out <state>/baseline/<key>/baseline` and compares every file it writes (rig.json, motion.json, mesh_report.json, sets.json, images/*) with the inspected rig directory: `baseline: reproduced — rig.json <hash> = inspected <hash>`, or `baseline: not reproduced — <first differing path>`. The key is the package hash, the handoff record and the producer versions (no clock), so a reopen reuses the result. Ops that rebuild the whole rig refuse `EDIT_BASELINE_MISMATCH <path>` unless the baseline reproduced; `remesh` keeps its own `REMESH_BASELINE_MISMATCH`. Once it reproduces, `rig-parts check` runs on the baseline output in the background (into `<state>/baseline/<key>/inspected-check/`, with Apply's default `--page-edges free --pack-shape polygon`) and its summary is stored beside the baseline record as `inspected-check.json`; that is the physics readout's BEFORE for the inspected build, and a reopen reads it instead of re-running.
602
- - **Scratch.** `<state>/rebuild/<package source hash>/<designation hash>/` under `RIG_REVIEW_STATE_DIR` (refused inside the package), named from the package, the config bytes, the part, the entry and both tool versions — no clock. The same designation again reuses its build and says so; a new one replaces the previous scratch build. The inspected package's files are only read.
603
- - **Watching.** The producer's lines stream into the editor bar (the latest line and a short log); a refusal shows the producer's first named line verbatim (for example `FAIL REMESH_REQUEST_HELD: …`) and the stage is unchanged. On success the compiled scratch build (served read-only under `/rebuild/<id>/`, its model and pages hashed) replaces the drawn rig in place with the same camera, animation, time and part set; playback resumes with physics when it was running. **Reset** returns the stage to the inspected build.
608
+ - **Baseline.** Once per session, when the handoff and the producer both resolve, review runs `rig-parts rig --config <config> --parts <parts> --out <state>/baseline/<key>/baseline` and compares every file it writes (rig.json, motion.json, mesh_report.json, sets.json, images/*) with the inspected rig directory: `baseline: reproduced — rig.json <hash> = inspected <hash>`, or `baseline: not reproduced — <first differing path> · <what Apply can still do here>`. The key is the package hash, the handoff record and the producer versions (no clock), so a reopen reuses the result. Ops that rebuild the whole rig refuse `EDIT_BASELINE_MISMATCH <path>` unless the baseline reproduced; `remesh` keeps its own `REMESH_BASELINE_MISMATCH`. A build the config does not reproduce — a hand repair — is not open to every Apply (#148): retune carries a **mesh** repair over but holds every image, so a repair that redrew an image refuses every physics and idle edit (`RETUNE_REQUEST_HELD: images/<part>.png`); remesh from a config entry refuses the repaired parts and re-fits the others; a mesh edit is held to the inspected mesh, not the config; and remesh, its mesh edit and retune all refuse an inspected `rig.json` (or `mesh_report.json`, `motion.json`) that is not the writer's text (`…_INPUT_WRITTEN` — re-serialise it as rig writes it, JSON with one space of indent and a closing newline, in a copy). The baseline line and `EDIT_BASELINE_MISMATCH` name which of these hold here, and a producer refusal review can explain carries `review: …` after the producer's own line (docs/REVIEW_PACKAGE.md, *Baseline reach*). Once it reproduces, `rig-parts check` runs on the baseline output in the background (into `<state>/baseline/<key>/inspected-check/`, with Apply's default `--page-edges free --pack-shape polygon`) and its summary is stored beside the baseline record as `inspected-check.json`; that is the physics readout's BEFORE for the inspected build, and a reopen reads it instead of re-running.
609
+ - **Scratch.** `<state>/rebuild/<package source hash>/<designation hash>/` under `RIG_REVIEW_STATE_DIR` (refused inside the package), named from the package, the config bytes, the part, the entry and both tool versions — no clock. The same designation again reuses its build and says so; a new one replaces the previous scratch build on the stage (the earlier applied records' builds are kept beside the scratch root for undo, at most eight per package). The inspected package's files are only read.
610
+ - **Watching.** The producer's lines stream into the editor bar (the latest line and a short log); a refusal shows the producer's first named line verbatim (for example `FAIL REMESH_REQUEST_HELD: …`) and the stage is unchanged. On success the compiled scratch build (served read-only under `/rebuild/<id>/`, its model and pages hashed) replaces the drawn rig in place with the same camera, animation, time and part set; playback resumes with physics when it was running. **Reset** returns the stage to the inspected build. **Undo last Apply** (the *Applied records* list at the top of the Inspector, its depth in the label) returns the stage to the previous applied build, or to the inspected build when none is left; a predecessor whose build was evicted re-runs its recorded step and must come back byte for byte, else `UNDO_PREDECESSOR_CHANGED: <what>` (docs/REVIEW_PACKAGE.md, *Undo across Applies*). A change to the handoff config ends the session's undo stack (reopen): Undo is then refused `FRONT_INSTANCE_EXPIRED`. The Cuts and Cloth blocks mark a pending op applied only while the record on the stage holds it, so after an undo they show the predecessor's ops as applied, and none on the inspected build. The mark shows what the build on the stage carries, not every op applied so far: while a cloth build is on the stage, the Cuts block marks nothing.
604
611
  - **Record.** The last applied state (`rig-review-applied/1`: part, regions, for a Landmarks Apply also `bust` — the entry as sent, with `regions` its two derived circles — config hash, entry SHA-256, producer and compiler versions, the producer's line and the scratch model's SHA-256) stays in the inspection draft, and feedback added while that build is on the stage carries it as `context.applied`. The review's `reviewedSource` still names the inspected build.
605
612
  - **Config patch.** The same loop takes any config edit as data: `POST /api/rebuild {patch}` with a `rig-review-config-patch/1` document (JSON Pointer ops over the handoff config, refused `EDIT_PATCH_STALE` when made against other config bytes) rebuilds the whole rig (`rig`) or one part (`remesh`) into scratch, compiles it and runs `rig-parts check` on it; the page's `window.rigReviewEditor.applyPatch(patch)` streams, reloads and resets like Apply, and the draft and `context.applied` carry `rig-review-applied/2` (the patch, its hash, the producer's line, check's summary and the diff of every document the producer writes, per file, with the images by hash). Schema and refusals: `docs/REVIEW_PACKAGE.md`, *Config patch and Apply*.
606
613
 
package/docs/DESIGN.md CHANGED
@@ -24,7 +24,7 @@ Animate's Retarget block edits what a pose retarget is made from — the `rig-po
24
24
 
25
25
  The chest-region editor and Edit Mesh exist for detail that cannot be said in words. Issue #99 states the loop: **point → rebuild → watch → repeat → submit**. Review builds no mesh itself; **Apply · Rebuild runs the installed producer as a child process** on the user's own designation, into a scratch build beside the inspected one, and the viewer reloads that build in place. Every applied state goes into the draft and the submitted feedback (`context.applied`), so the AI receiving the review gets the exact last applied state, not a description of it. Edit Mesh's delta has no producer operation yet and refuses by name.
26
26
 
27
- Every Apply needs a binding to what the inspected rig was built from (issue #101): the **producer handoff**, declared (`open --config`, or the review.json `producer` block) or found at fixed places (`config.json`, `rig/config.json`, and last `../config.json` beside an opened `out/`, which the bar names as outside the folder). Its files are hashed into the reviewed identity beside the package, and once per session a **baseline** rebuilds the whole rig from them and says whether it reproduces the inspected one. An op that rebuilds the whole rig runs only on a reproduced baseline (`EDIT_BASELINE_MISMATCH` names the first differing path); `remesh` checks its own part (`REMESH_BASELINE_MISMATCH`). A patch that touches only what `rig-parts retune` splices — constraints, the idle's tracks and duration, the blink's numbers — runs retune instead (parts 1.44.0+, #136), which holds every other byte of the inspected rig itself, so a repaired build can still take a physics or idle edit; bones, stops, regions, turn and the blink's groups and still keep the whole-rig path and its guard.
27
+ Every Apply needs a binding to what the inspected rig was built from (issue #101): the **producer handoff**, declared (`open --config`, or the review.json `producer` block) or found at fixed places (`config.json`, `rig/config.json`, and last `../config.json` beside an opened `out/`, which the bar names as outside the folder). Its files are hashed into the reviewed identity beside the package, and once per session a **baseline** rebuilds the whole rig from them and says whether it reproduces the inspected one. An op that rebuilds the whole rig runs only on a reproduced baseline (`EDIT_BASELINE_MISMATCH` names the first differing path); `remesh` checks its own part (`REMESH_BASELINE_MISMATCH`). A patch that touches only what `rig-parts retune` splices — constraints, the idle's tracks and duration, the blink's numbers — runs retune instead (parts 1.44.0+, #136), which holds every other byte of the inspected rig itself, so a build whose repair is a mesh the config does not reproduce can still take a physics or idle edit — but not one whose repair redrew an image, which retune holds too, nor one whose rig.json is not the writer's text (#148; the baseline line says which); bones, stops, regions, turn and the blink's groups and still keep the whole-rig path and its guard.
28
28
 
29
29
  Edits reach the producer as data (issue #102): every edit card states its edit as one **config patch** (`rig-review-config-patch/1`: the config hash it was made against, JSON Pointer ops, the producer and the op), and one generic runner applies it to a scratch copy of the handoff config, runs the op (`rig` for the whole rig, `remesh` for one part's entry), compiles and runs `check` on the result. A stale patch is refused (`EDIT_PATCH_STALE`) rather than rebased. The page's client exposes `applyPatch(patch)` with #99's stream, in-place reload and Reset; the physics tuning panel (#103) is the first editing UI that produces them. What was applied — the patch, its hash, the producer's line, check's summary and the rig.json diff — rides in `context.applied`, so the AI receiving the review can rebuild exactly that state.
30
30
 
@@ -9,7 +9,7 @@ order. Adapter means downstream consumer integration, not a newly introduced or
9
9
  unreleased adapter contract. Existing producer formats and playback dependencies
10
10
  remain authoritative.
11
11
 
12
- The CLI also accepts an existing output directory. It reads a root `review.json` when present. Otherwise it constructs this contract in memory: optional root `skeleton.model.json`, otherwise the fixed rigc build path `build/skeleton.model.json`; read-only `skeleton.*`, `motion.json` and `report.json` links; root videos (`mp4`, `webm`, `mov`); image filenames containing strip/contact/sheet/filmstrip/띠; and canonical `*pose*.json` references. The root compiled model takes precedence over the build path. These are the only two automatically recognized model paths; discovery never searches recursively. The opened directory remains the package root, so root motion/references accompany the build model and its model-relative pages. An existing selected model with an invalid type, dangling symlink, escaping symlink or invalid model envelope is refused rather than skipped. Each reference is shown separately; no source relationship, FPS or video synchronization is inferred. It never inserts or rewrites a manifest or motion file in the source folder. Raw Spine JSON/skel remains read-only evidence with playback unavailable. The folder revision is `folder-adapter/1`; actual changed file bytes are distinguished by the source hash.
12
+ The CLI also accepts an existing output directory. It reads a root `review.json` when present. Otherwise it constructs this contract in memory: optional root `skeleton.model.json`, otherwise the fixed rigc build path `build/skeleton.model.json`; read-only `skeleton.*`, `motion.json` and `report.json` links; root videos (`mp4`, `webm`, `mov`); image filenames containing strip/contact/sheet/filmstrip/띠; and canonical `*pose*.json` references. The root compiled model takes precedence over the build path. These are the only two automatically recognized model paths; discovery never searches recursively. One fixed layout redirects instead (#149): a folder with neither model path that is a `rig-parts build` output root — regular `parts.json` and `rig/rig.json`, a real (not symlinked) `check/` directory, and an existing `check/build/skeleton.model.json` entry — opens as its `check/` package. The package root is then `check/`, exactly as if `check/` had been opened: the same manifest, source hash, `reviewedSource`, session and open key. A folder missing any of those files is not a build root and is refused or adapted as before; a build root that also holds a model of its own opens as itself. The opened directory remains the package root, so root motion/references accompany the build model and its model-relative pages. An existing selected model with an invalid type, dangling symlink, escaping symlink or invalid model envelope is refused rather than skipped. Each reference is shown separately; no source relationship, FPS or video synchronization is inferred. It never inserts or rewrites a manifest or motion file in the source folder. Raw Spine JSON/skel remains read-only evidence with playback unavailable. The folder revision is `folder-adapter/1`; actual changed file bytes are distinguished by the source hash.
13
13
 
14
14
  `review.json` is UTF-8 JSON. Its directory is the package root. Required keys are `schema: "rig-review/1"`, `id`, `title`, and `revision`. `id` and every entry ID match `[A-Za-z0-9][A-Za-z0-9._-]{0,127}`. Titles and revision strings are nonempty. Optional collections normalize to empty arrays.
15
15
 
@@ -126,9 +126,11 @@ An Apply rebuilds through the installed producer from the files the inspected ri
126
126
  1. **Declared on the command line**: `rig-review open <dir> --config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]] [--keypoints <file>]`. `feedback` and `control` take the same flags so retrieval binds the same identity. Paths are resolved from the working directory and canonicalized; the worker and every reload read exactly those files.
127
127
  2. **Declared in review.json**: the `producer` block, package-relative literal portable paths (no `..`, no absolute path, no encoding).
128
128
  3. **Discovered inside the opened folder**: `config.json`, then `rig/config.json`.
129
- 4. **Discovered beside an opened `out/`** (the last step): `../config.json`. It is outside the opened folder, so the record says `"how": "discovered", "outside": true` and the editor bar says *discovered outside the opened folder*, with its path and SHA-256. This step runs only when the opened folder is named `out`, and only for a regular file there (a symlink is refused).
129
+ 4. **Discovered beside an opened `out/`**: `../config.json`. It is outside the opened folder, so the record says `"how": "discovered", "outside": true` and the editor bar says *discovered outside the opened folder*, with its path and SHA-256. This step runs only when the opened folder is named `out`, and only for a regular file there (a symlink is refused).
130
130
 
131
- Without `--parts` the parts directory is `out/` beside the config (for step 4, the opened `out/` itself). A declared or discovered path that is missing, outside its root (the package root; for step 4 the folder holding `out/`), or a symlink that leaves it is refused by name: `HANDOFF_PATH_OUTSIDE_ROOT`, `HANDOFF_SYMLINK_ESCAPE`, `HANDOFF_INPUT_MISSING`, `HANDOFF_INPUT_INVALID`, `HANDOFF_PATH_INVALID` (`open` prefixes `OPEN_HANDOFF_INVALID <flag or path>`). Nothing found is not a load error: `/api/rebuild` reports `MODELING_INPUT_UNAVAILABLE: config.json — looked at config.json, rig/config.json, ../config.json (beside an opened out/); declare it with rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block`, and Apply refuses `MODELING_HANDOFF_MISSING`.
131
+ 5. **The build's own config, for the `check/` package of a `rig-parts build` output root** (#149): `../config.json` (in the build root), then `../../config.json` (beside it). A `rig-parts build` records no config path or hash, so "the producer's own config for that build" is defined by place, bounded by the build's own files: the opened package root is named `check` and its parent is a build root as recognized above, and the config is the regular file named `config.json` directly in that build root or directly in the folder holding it — the two places `rig-parts build --config <dir>/config.json --out <dir>/out` leaves it. No other parent and no other file name is looked at; a symlink there is refused (`HANDOFF_SYMLINK_ESCAPE`), never followed. The root for this step is the config's folder, the parts directory is the build root, and the record says `"how": "discovered", "outside": true` with the path (`../../config.json`) and SHA-256. Place is not proof that the config built the rig: the session baseline below is that check (`reproduced` or `not-reproduced`, by name). Declaring `--config` at exactly one of those two files (as written, its parent chain canonicalized) is taken the same way with `"how": "declared"`; that file's folder is then the root for `--parts`, `--source`, `--full`, `--head` and `--keypoints`. The build records none of those, so they stay declared-only. When nothing is at either place the refusal lists them: `MODELING_INPUT_UNAVAILABLE: config.json — looked at config.json, rig/config.json, ../config.json (in the build root), ../../config.json (beside the build root); declare it with …`.
132
+
133
+ Without `--parts` the parts directory is `out/` beside the config (for step 4, the opened `out/` itself; for step 5, the build root). A declared or discovered path that is missing, outside its root (the package root; for step 4 the folder holding `out/`; for step 5 the config's folder), or a symlink that leaves it is refused by name: `HANDOFF_PATH_OUTSIDE_ROOT`, `HANDOFF_SYMLINK_ESCAPE`, `HANDOFF_INPUT_MISSING`, `HANDOFF_INPUT_INVALID`, `HANDOFF_PATH_INVALID` (`open` prefixes `OPEN_HANDOFF_INVALID <flag or path>`). Nothing found is not a load error: `/api/rebuild` reports `MODELING_INPUT_UNAVAILABLE: config.json — looked at config.json, rig/config.json, ../config.json (beside an opened out/); declare it with rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block`, and Apply refuses `MODELING_HANDOFF_MISSING`.
132
134
 
133
135
  **Hashed files.** The config; the files the config names that the producer resolves against the config's directory (`motion.animations_from`, `generation.control.skeleton`; each must stay inside the root, a missing one is listed in `missing`); `parts.json` and every regular file directly in `parts/`; every regular file directly in the inspected `rig/` (`rig.json`, `motion.json`, `mesh_report.json`, `sets.json`, …) and `rig/images/`; with `--source`/`--full`/`--head`, that PNG and the regular files directly in those directories; with `--keypoints` (#111; the review.json block's `keypoints`), that file — the same path rules as `--source` (literal, inside the root, no symlink escape).
134
136
 
@@ -140,9 +142,9 @@ Without `--parts` the parts directory is `out/` beside the config (for step 4, t
140
142
  "files":[{"path":"rig/config.json","sha256":"…"},{"path":"rig/out/parts.json","sha256":"…"}],"missing":[]}
141
143
  ```
142
144
 
143
- `keypoints` (#111) is `{path, sha256}` and is present only when the keypoint file is declared: a handoff without it has no `keypoints` key and hashes no keypoint file, so its record and identity are what they were before. `how` is `declared` or `discovered`, `via` is `open --config`, `review.json producer` or `discovery`, and paths are relative to the package root (`../` only for step 4). The record is part of `reviewedSource`, so it reaches `/api/review`, the session, the feedback export and the session file name: changing any handoff file's bytes starts a new pending session while `sourceHash` (the package bytes) is unchanged, and the open-managed key changes with it. The same inputs give the same record, key and URL.
145
+ `keypoints` (#111) is `{path, sha256}` and is present only when the keypoint file is declared: a handoff without it has no `keypoints` key and hashes no keypoint file, so its record and identity are what they were before. `how` is `declared` or `discovered`, `via` is `open --config`, `review.json producer` or `discovery`, and paths are relative to the package root (`../` only for steps 4 and 5). The record is part of `reviewedSource`, so it reaches `/api/review`, the session, the feedback export and the session file name: changing any handoff file's bytes starts a new pending session while `sourceHash` (the package bytes) is unchanged, and the open-managed key changes with it. The same inputs give the same record, key and URL.
144
146
 
145
- **Baseline.** See README, *Chest regions*: once per session `rig-parts rig --config --parts --out <state>/baseline/<key>/baseline`, compared file by file with the inspected rig; `/api/rebuild` returns `baseline` (`status` `running`, `reproduced`, `not-reproduced`, `refused` or `failed`, `firstDifference`, both rig.json and tree hashes, `wallMs`). Nothing is written into the package. Once the baseline reproduces, `rig-parts check --rig baseline --out inspected-check --parts <parts> --page-edges free --pack-shape polygon` runs in the background in the same directory, and `/api/rebuild` returns `inspectedCheck` (`{schema:"rig-review-inspected-check/1", key, status:"running"|"checked"|"refused"|"failed", lines[], check (the check summary, or null), line ("check running…", check's `check: …` line, or its named refusal verbatim), exitCode, wallMs}`, else `null`). The finished record is stored as `<state>/baseline/<key>/inspected-check.json`, so a reopen reads it instead of re-running; it is the physics readout's BEFORE for the inspected build.
147
+ **Baseline.** See README, *Chest regions*: once per session `rig-parts rig --config --parts --out <state>/baseline/<key>/baseline`, compared file by file with the inspected rig; `/api/rebuild` returns `baseline` (`status` `running`, `reproduced`, `not-reproduced`, `refused` or `failed`, `firstDifference`, both rig.json and tree hashes, `wallMs`, and since #148 `reach` when not reproduced — *Baseline reach*, below). Nothing is written into the package. Once the baseline reproduces, `rig-parts check --rig baseline --out inspected-check --parts <parts> --page-edges free --pack-shape polygon` runs in the background in the same directory, and `/api/rebuild` returns `inspectedCheck` (`{schema:"rig-review-inspected-check/1", key, status:"running"|"checked"|"refused"|"failed", lines[], check (the check summary, or null), line ("check running…", check's `check: …` line, or its named refusal verbatim), exitCode, wallMs}`, else `null`). The finished record is stored as `<state>/baseline/<key>/inspected-check.json`, so a reopen reads it instead of re-running; it is the physics readout's BEFORE for the inspected build.
146
148
 
147
149
  ## Config patch and Apply (`rig-review-config-patch/1`)
148
150
 
@@ -158,12 +160,15 @@ Every edit card states its edit as one config patch; review applies it to a scra
158
160
 
159
161
  - **Document.** Exactly these five keys. `ops` holds 1–256 operations, each `replace` or `add` (`{op, path, value}`, value any JSON) or `remove` (`{op, path}`), each with an optional `name` (#103): the `name` the list entry `/<list>/<index>` holds in the config the patch was made against (a physics edit records `{"op":"replace","path":"/constraints/1/damping","value":0.3,"name":"fringe_r1_physics"}`), applied in order with RFC 6902 semantics; `path` is an RFC 6901 JSON Pointer into the config (`~1` is `/`, `~0` is `~`; an array index is a decimal without leading zeros, and `add` may use `-` to append). `""` (the whole config) is refused. `producer` is the producer the patch was made for. `op.cmd` is `rig` (rebuild the whole rig from the patched config) or `remesh` (re-fit one part from its patched `meshes.<part>` entry; `args` must hold `--part <name>`). `op.args` may also hold `--idle-keys ctl|direct`, `--page-edges pot|free` and `--pack-shape rect|polygon`, each once; nothing else, never a path.
160
162
  - **Refusals before anything runs.** Malformed (HTTP 400): `EDIT_PATCH_INVALID`, `EDIT_PATCH_OP_UNKNOWN`, `EDIT_PATCH_POINTER_INVALID`, `EDIT_PATCH_CMD_UNKNOWN`, `EDIT_PATCH_ARG_UNKNOWN`. Outcomes (HTTP 200, `{status:"refused", error, producerLine:null}`): `EDIT_PATCH_STALE` when `config_sha256` is not the handoff config's hash; `EDIT_PATCH_PATH_MISSING` when a pointer does not resolve in that config; `EDIT_PATCH_NAME_MISMATCH` when an op's `name` is not the name its index holds there; `EDIT_PATCH_PATH_HELD` when the patch changes a file name the config resolves against its directory (`motion.animations_from`, `generation.control.skeleton` — handoff files); `EDIT_PATCH_OUT_OF_SCOPE` when a `remesh` patch edits outside `/meshes/<part>`; `EDIT_PRODUCER_MISMATCH` when the installed producer is not the patch's; for a `rig` patch the runner runs as the whole-rig `rig` (*Routing*, below), `EDIT_BASELINE_MISMATCH: <first differing path>` unless the session's baseline reproduced the inspected rig (*Producer handoff*, Baseline); and the producer-resolution refusals of an Apply.
161
- - **Routing (#136).** A `rig` patch runs `rig-parts retune` instead of the whole-rig `rig` when the installed producer is `@rig-c/parts` 1.44.0 or later (`RETUNE_SINCE`, the CHANGELOG's first release with retune) and **every** op's pointer lies at or under one of `/constraints`, `/motion/duration`, `/motion/tracks`, `/motion/blink/t`, `/motion/blink/squash`, `/motion/blink/brow_drop` (`src/retune-route.ts`). The scope is retune's own help: it splices the config's constraints (rig.json `constraints` and `invariants.detached`) and idle (motion.json `animations.idle`) onto the gated inspected rig and holds stops, bones, slots, skeleton, images, every other invariant and every other byte of motion.json (`RETUNE_REQUEST_HELD`). So `/stops`, `/bones`, `/regions`, `/turn`, `/meshes`, `/motion/animations_from`, the blink's `eyes` and `brows` (its motion.json groups) and `still` (a `<part>_still` slot), and a patch mixing any of them with retune's pointers, keep the whole-rig `rig` behind the baseline guard; so does every patch on an older producer. Retune needs no baseline: it holds the rest of the inspected rig itself, repairs included, so it Applies on a build whose baseline did not reproduce. What it judges per value (a track that newly keys a mesh-bound bone, which gains a `<bone>_ctl` under `--idle-keys ctl`) comes back as its own `RETUNE_REQUEST_HELD` line verbatim, as do `RETUNE_CONFIG_REFUSED` and its other refusals.
163
+ - **Routing (#136).** A `rig` patch runs `rig-parts retune` instead of the whole-rig `rig` when the installed producer is `@rig-c/parts` 1.44.0 or later (`RETUNE_SINCE`, the CHANGELOG's first release with retune) and **every** op's pointer lies at or under one of `/constraints`, `/motion/duration`, `/motion/tracks`, `/motion/blink/t`, `/motion/blink/squash`, `/motion/blink/brow_drop` (`src/retune-route.ts`). The scope is retune's own help: it splices the config's constraints (rig.json `constraints` and `invariants.detached`) and idle (motion.json `animations.idle`) onto the gated inspected rig and holds stops, bones, slots, skeleton, images, every other invariant and every other byte of motion.json (`RETUNE_REQUEST_HELD`). So `/stops`, `/bones`, `/regions`, `/turn`, `/meshes`, `/motion/animations_from`, the blink's `eyes` and `brows` (its motion.json groups) and `still` (a `<part>_still` slot), and a patch mixing any of them with retune's pointers, keep the whole-rig `rig` behind the baseline guard; so does every patch on an older producer. Retune needs no baseline, but it does not carry every repair (#148, `@rig-c/parts` 1.49.1 AUTHORING §retune and `src/retune.ts`): it carries every attachment and every `mesh_report.json` row over as bytes, so a **mesh** repair the config does not reproduce stays; it holds every image file (with stops, bones, slots, skeleton, every invariant but `detached` and motion.json outside the idle) to the config's rebuild, so on a build whose repair redrew an image every retune refuses `RETUNE_REQUEST_HELD: images/<part>.png — the config changes it`; and it splices `rig.json` and `motion.json` as text, so an input that is not the writer's text refuses `RETUNE_INPUT_WRITTEN`. Measured on parts-examples `family/roa` (a hand shoulder repair that redrew `topwear.png`, parts 1.49.1): as committed, retune refuses `RETUNE_INPUT_WRITTEN` (its `rig.json` is not the writer's text); re-serialised in the writer's format, it refuses `RETUNE_REQUEST_HELD: images/topwear.png`. The baseline line says which applies (*Baseline reach*, below). What it judges per value (a track that newly keys a mesh-bound bone, which gains a `<bone>_ctl` under `--idle-keys ctl`) comes back as its own `RETUNE_REQUEST_HELD` line verbatim, as do `RETUNE_CONFIG_REFUSED` and its other refusals.
164
+ - **Baseline reach (#148).** When the baseline does not reproduce, `baseline.reach` (`src/baseline-reach.ts`) reads both rig directories and states what each producer op will still take, from the producer's own holds: `{schema:"rig-review-baseline-reach/1", notWriterText[], images[], retuneHeld, remeshRefused[], meshParts[], bonesDiffer, ops:{retune, delta, version}, text}`. `notWriterText`: the inspected `rig.json`, `motion.json` and `mesh_report.json` that are not `JSON.stringify(value, null, 1)` and a closing newline (the writer, parts `rigJsonText`) — remesh and its mesh edit refuse `rig.json` / `mesh_report.json` (`REMESH_INPUT_WRITTEN`), retune `rig.json` / `motion.json` (`RETUNE_INPUT_WRITTEN`). `images`: the images the config draws differently. `retuneHeld`: retune's first held difference in its own order (stops, bones, slots, skeleton, images, invariants but `detached`, each image, motion.json outside `animations.idle`), or null when a physics or idle edit Applies. `remeshRefused`: the meshed parts (`skins.default.<part>.<part>` of type `mesh`) whose attachment, image or report row (or an `auto` row's motion) the config does not reproduce — remesh from a config entry refuses them `REMESH_BASELINE_MISMATCH` and re-fits the rest; `bonesDiffer` refuses every part. A mesh edit (`remesh --delta`) builds no config entry, so the baseline does not hold it. `ops` is the installed producer's: below 1.44.0 a physics or idle edit is whole-rig. `text` is appended to the baseline line (`baseline: not reproduced — <first differing path> · <text>`) and to `EDIT_BASELINE_MISMATCH: <path> — <text>`. It is review's reading of the producer's rules; the producer's line on Apply stays the judgment. For family/roa as committed: `out/rig/rig.json, out/rig/mesh_report.json are not the text rig writes for their values (reformatted, or written by another tool), and an op that splices a revision as text refuses such a file — use the files as rig wrote them, or re-serialise them in a copy of the package exactly as rig writes them (JSON with one space of indent and a closing newline) and open the copy; whole-rig edits refuse (EDIT_BASELINE_MISMATCH); physics and idle edits (retune) refuse out/rig/rig.json (RETUNE_INPUT_WRITTEN), and past that the config draws out/rig/images/topwear.png differently and retune holds every image (RETUNE_REQUEST_HELD); remesh and a mesh edit refuse out/rig/rig.json, out/rig/mesh_report.json (REMESH_INPUT_WRITTEN); past that, remesh: bottomwear, topwear, hair_back, hair_front are refused (REMESH_BASELINE_MISMATCH: …); handwear_r, neck, earring can be re-fit`.
165
+ - **Producer refusal hint (#148).** When the producer refuses with a line review can explain — `REMESH_INPUT_WRITTEN` / `RETUNE_INPUT_WRITTEN` (a file that is not the writer's text) or `RETUNE_REQUEST_HELD: images/<name>` (an image the config draws differently) — the job keeps `producerLine` as printed and adds `hint` (absent otherwise), also pushed to `lines` as `review: <hint>`; the status line reads `<producer line> · review: <hint>`. For `RETUNE_INPUT_WRITTEN` on `rig.json`: `rig.json in the inspected rig is not the text rig writes for its value (reformatted, or written by another tool); retune splices a revision as text, so it refuses the file — use the files as rig wrote them, or re-serialise them in a copy of the package exactly as rig writes them (JSON with one space of indent and a closing newline) and open the copy`.
162
166
  - **Build (#115).** A `rig` patch with any op at or under `/assemble` or `/limbs` — or under `/bones` on a config that declares `limbs`, whose joints are their bones' origins — runs `rig-parts build` (op `build`): those sections are read by the producer's assemble stage, which `rig --config --parts` never runs (rig-parts AUTHORING §3, the `assemble.cuts`, `assemble.patches` and `limbs` rows). It needs the handoff's painting and full run (`--source <png> --full <dir> [--head <dir>]`, or the review.json block); without them it refuses before anything runs: `MODELING_INPUT_UNAVAILABLE: source painting and See-through runs — rig-parts build reassembles the parts from the painting (--source) and the runs (--full, --head) before the rig, and the handoff declares none; declare them with …`. It is a whole-rig op behind the reproduced baseline (`EDIT_BASELINE_MISMATCH`). The files assemble reads by the config's own naming (each cut's `under`, each limb segment's `cap`, each alternate's `source`, `full`, `head` and `skeleton`) are resolved against the handoff config's directory, must stay inside the package root (`BUILD_INPUT_OUTSIDE_ROOT`, `BUILD_INPUT_MISSING`) and are hashed into the build id; a patch may not name a new one (`EDIT_PATCH_PATH_HELD` — it may keep, move or drop one the config names). `config.json` is the patched config with those paths and the two above made absolute, then `rig-parts build --config config.json --source <handoff source> --full <handoff full> [--head <handoff head>] --out out --seam-pairs [args]`; the rig spec is `out/rig`, and the shared tail diffs and compiles it (no second check: build's own check ran on the parts it assembled). A stage that refuses leaves no scratch, and its FAIL lines come back verbatim (the stage prefix `[assemble]`, `[rig]`, `[check]` stripped); a build that stopped at check with bars not met is kept, check's FAIL lines beside it. The job adds `assembled: {inputs:{source, full, head, named:[{field, path, sha256|null}]}, refusals[], checkFails[], lines[] (the cut:, limb cut:, limb cap: and uncovered-hole lines), seam[], seamPairs[] (the base set's `seam pair` lines), recomposite:{line, before, after, errorMap:{url, sha256}|null}, parts:{changed[], unchanged, partsJson:{count, paths}}|null}` (`before` is the handoff's `parts.json` recomposite block, `after` the scratch one; `errorMap` serves `out/recomposite_error_rig.png` read-only at `/rebuild/<id>/recomposite/recomposite_error_rig.png`), and `wallMs.op` is the build's own wall clock (assemble → rig → check).
163
167
  - **Cloth (#111).** A `rig` patch whose ops all lie under `/cloth` (the Setup · Cloth block: one recipe per tag the installed reader accepts — read from its own `CLOTH_TARGETS` — with the reader's fields) runs op `cloth`; a patch mixing `/cloth` ops with others is refused before anything runs (`CLOTH_PATCH_MIXED`). Refused before anything runs: `CLOTH_KEYPOINTS_UNDECLARED` when the handoff declares no `--keypoints` (without them the draft moves the body bones the config states — Decision 1), `MODELING_PRODUCER_TOO_OLD <found> 1.49.1` (the first `@rig-c/parts` whose `--write-config` splices `bones`, `constraints` and `motion.tracks` entry by entry, parts#552), `MODELING_INPUT_UNAVAILABLE: source painting …` without `--source`, and, as a whole-rig op, `EDIT_BASELINE_MISMATCH`. `cloth.json` is the patched config written as `rig` writes its scratch config, with the assemble-read paths made absolute as for `build`; then `rig-parts propose --parts <handoff parts> --source <handoff source> --keypoints <handoff keypoints> --out propose --cloth cloth.json --write-config config.json` (no `--wrist-snap` or `--grid-rule`: the handoff states neither). Its refusals come back verbatim, every FAIL line in `cloth.refusals` — the reader's `CONFIG_CLOTH_*`, `PROPOSE_CLOTH_DRAFTED` (a recipe that drafts nothing), `PROPOSE_CLOTH_PART`, and the write's `PROPOSE_WRITE_CONFIG_FRESH`, `PROPOSE_WRITE_CONFIG_CONFLICT` (an entry the recipe re-drafts or removes that equals neither the draft with the recipe nor the draft without it, rig-parts AUTHORING *The config written*) and `PROPOSE_WRITE_CONFIG_SPLICE`. Review reads only the written `config.json`, never the producer's draft file: it lists every JSON Pointer at which the written config differs from `cloth.json` and refuses to build one that differs outside the sections propose splices (`bones`, `constraints`, `meshes`, `regions`, `limbs`, `motion/tracks`, `turn`, `proposed_rig_scale`): `CLOTH_WRITTEN_OUTSIDE_DRAFTED`. The written config is then built (Decision 2) as `rig-parts build --config config.json …` exactly as a `build` patch when the config declares `limbs` (the job adds `assembled`), else `rig-parts rig --config config.json --parts <handoff parts> --out rig`, and goes through the shared tail. The id adds `cloth`, the keypoint file's hash and (for the build route) the build inputs' hash. The job adds `cloth: {route:"build"|"rig", inputs:{source, keypoints:{path, sha256}}, refusals[], line (propose's `wrote config.json: …` line), written:{count, paths[≤200], outside[≤200], sections[]}|null, links[] (per tag: its parts and the written config's chains, the link preview), wallMs:{propose, build}}`. `GET /api/rebuild` adds `cloth: {targets, targetsRead, recipes, tags[] (per tag: its parts by See-through tag or by the recipe's `part`/`parts`, and the chains their `meshes.<part>.segments` name with `points` and `tip`), keypoints, route, reason}`.
164
168
  - **Run.** Scratch is `<state>/rebuild/<package source hash>/<id>/`, `id = sha256([rig-review-rebuild/1, "patch", source hash, config SHA-256, the patch's canonical bytes, producer@version, compiler@version])` — no clock; the same patch again reuses its build. `rig`: `config.json` there is the patched config written as the producer's own scratch configs are (2-space JSON, `motion.animations_from` and `generation.control.skeleton` made absolute when they name a file beside the handoff config), then `rig-parts rig --config config.json --parts <handoff parts> --out rig [args]`, or for a retune-routed patch `rig-parts retune --rig <handoff rig> --config config.json --parts <handoff parts> --out rig [args]` (the same config; retune reads the inspected rig and writes only `rig/`, with `retune.json`). `remesh`: `mesh.json` is the patched `meshes.<part>` entry and `rig-parts remesh --rig <handoff rig> --config <handoff config> --parts <handoff parts> --part <part> --mesh mesh.json --out rig [args]` (remesh reads the config as written). Then `rigc build --rig rig/rig.json --motion rig/motion.json --out build --profile rig-play --pack --page-edges … --pack-shape …` (the args' values, else `free` / `polygon`; for remesh, its `remesh.json` request; for retune, its `retune.json` request) and `rig-parts check --rig rig --out check --parts <handoff parts>` with the same pack flags. A producer or compiler refusal is the first named line verbatim and leaves no scratch; a check `FAIL` is check's judgment, kept beside the build.
165
- - **A retune's check (#139).** A retune-routed Apply does not run `rig-parts check` a second time: retune already gated and checked the revision it wrote (`checkStage` over that revision, the same `--parts` and pack flags review passes), and from `@rig-c/parts` 1.49.0 (`RETUNE_CHECK_SINCE`, parts#541) `retune.json`'s `check.revision` carries `line` — check's own last line, byte for byte — and `status` — every block's status as that tree's `check.json` records it, in the file's order. Review first binds it to the files: every `revision` hash row's file under `rig/` must hash to its row, none missing, and `rig/` holds no file but those rows and `retune.json`; otherwise the Apply is refused `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>; retune's check is not this revision's, so no build is recorded` (`absent` on the side without the file) and leaves no scratch. Bound, the side becomes the same check summary a separate check gives (below): `pass` ← `pass`; `exitCode` ← 0 when it passes, else 1 (check's own exit); `line` ← `line`; `bars` ← every upper-case `status` entry (`gate`, `loop` and `seam` are read off figures and are not check.json bars); `gate` ← `status.gate` is `PASS`; plus `source:"retune"`. The job's `wallMs.check` is `null` and its lines say `review: check not run again — retune.json carries retune's check of this revision, bound to it by N revision hash row(s): <line>`. Check still runs on the revision, as before, when `retune.json` carries no `line` / `status` (an older producer; the line says so — not a refusal) or when the Apply checks under an exported world (#112; retune's check read the default one). Whole-rig `rig`, `remesh`, `build` and cloth keep running check.
166
- - **Request and job.** `POST /api/rebuild` takes exactly `{patch}` (or #99's `{part, regions, configHash}`, or #105's `{part, bust, configHash}` — see *Chest landmarks* below, or #135's `{part, delta, configHash}` — see *Mesh edit export*), same-origin, 64 KiB. The job (`GET /api/rebuild/<id>`): `{schema:"rig-review-rebuild/1", kind:"patch", id, status:"running"|"built"|"refused"|"failed", reused, request:{patch, patchSha256, configHash}, producer, compiler, lines[], producerLine, exitCode, wallMs:{op, compile, check}, check, diff:{count, paths[≤200]}, build:{model, modelUrl, modelHash, files}|null}`. `diff` lists every JSON Pointer at which the scratch `rig.json` differs from the inspected one (a changed leaf or type, a key or index only one side has). `check` is `{schema:"rig-review-check-summary/1", pass (check.json's PASS, or null without one), exitCode, line (check's last `check: …` line, or its first named refusal), bars:{NAME: status}, gate}`, plus `source:"retune"` when the summary is retune's carried check (#139; absent: check's own output, `check.json` and its stdout).
169
+ - **A retune's check (#139).** A retune-routed Apply does not run `rig-parts check` a second time: retune already gated and checked the revision it wrote (`checkStage` over that revision, the same `--parts` and pack flags review passes), and from `@rig-c/parts` 1.49.0 (`RETUNE_CHECK_SINCE`, parts#541) `retune.json`'s `check.revision` carries `line` — check's own last line, byte for byte — and `status` — every block's status as that tree's `check.json` records it, in the file's order. Review first binds it to the files: every `revision` hash row's file under `rig/` must hash to its row, none missing, and `rig/` holds no file but those rows and `retune.json`; otherwise the Apply is refused `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>; retune's check is not this revision's, so no build is recorded` (`absent` on the side without the file) and leaves no scratch. Bound, the side becomes the same check summary a separate check gives (below): `pass` ← `pass`; `exitCode` ← 0 when it passes, else 1 (check's own exit); `line` ← `line`; `bars` ← every upper-case `status` entry (`gate`, `loop` and `seam` are read off figures and are not check.json bars); `gate` ← `status.gate` is `PASS`; plus `source:"retune"`. The job's `wallMs.check` is `null` and its lines say `review: check not run again — retune.json carries retune's check of this revision, bound to it by N revision hash row(s): <line>`. Check still runs on the revision, as before, when `retune.json` carries no `line` / `status` (an older producer; the line says so — not a refusal) or when the Apply checks under an exported world (#112; retune's check read the default one). Whole-rig `rig`, `build` and cloth keep running check.
170
+ - **A remesh's check (#174).** A `remesh` patch Apply (#106) reads `rig-parts remesh`'s own check of its revision the same way, with one binding shared with retune: `remesh.json`'s `check.revision` (`line` and `status` since `@rig-c/parts` 1.49.0, `REMESH_CHECK_SINCE`; the same parts#541 change) is bound by its `revision` hash rows to the files under `rig/` (none missing, none but those rows and `remesh.json`), else the Apply is refused `REMESH_CHECK_STALE: <file> sha256 <in remesh.json> … <on disk>; remesh's check is not this revision's, so no build is recorded` and leaves no scratch. remesh checked the revision with its request's `page_edges` / `pack_shape` — the flags review's own check reads from `remesh.json` — and the handoff `--parts`; a patch passes no `--requirements`. Bound, the summary is the same mapping as retune's with `source:"remesh"`; `wallMs.check` is `null` and the line says `review: check not run again — remesh.json carries remesh's check of this revision, bound to it by N revision hash row(s): <line>`. An older `remesh.json` or an Apply under an exported world runs check, and the line says why. The designation remesh (#99) and Edit Mesh's `remesh --delta` (#135) run no review check at all (remesh's own gate holds check there), so they are unchanged.
171
+ - **Request and job.** `POST /api/rebuild` takes exactly `{patch}` (or #99's `{part, regions, configHash}`, or #105's `{part, bust, configHash}` — see *Chest landmarks* below, or #135's `{part, delta, configHash}` — see *Mesh edit export*), same-origin, 64 KiB. The job (`GET /api/rebuild/<id>`): `{schema:"rig-review-rebuild/1", kind:"patch", id, status:"running"|"built"|"refused"|"failed", reused, request:{patch, patchSha256, configHash}, producer, compiler, lines[], producerLine, exitCode, wallMs:{op, compile, check}, check, diff:{count, paths[≤200]}, build:{model, modelUrl, modelHash, files}|null}`. `diff` lists every JSON Pointer at which the scratch `rig.json` differs from the inspected one (a changed leaf or type, a key or index only one side has). `check` is `{schema:"rig-review-check-summary/1", pass (check.json's PASS, or null without one), exitCode, line (check's last `check: …` line, or its first named refusal), bars:{NAME: status}, gate}`, plus `source:"retune"` or `source:"remesh"` when the summary is that op's carried check (#139, #174; absent: check's own output, `check.json` and its stdout).
167
172
 
168
173
  **`context.applied`.** Feedback written while a scratch build is on the stage carries the applied state; otherwise `null`. Absent and `null` stay valid, and a present value must be exactly one of:
169
174
 
@@ -219,6 +224,15 @@ The issue export adds an `Applied:` line for a valid record (`patch <hash>… (r
219
224
 
220
225
  **Rig fields (#110).** `GET /api/rebuild` also returns `fields`: the handoff config's `regions`, `motion.blink` (as `{motion:{blink}}`), `stops`, `bones` and `turn` as written, an absent section left out (or `null` without a handoff). The Rig fields block's edits are ordinary config patch ops, each at its field's pointer (rig-parts AUTHORING §3): `{op:"replace", path:"/regions/<part>", value:<bone>}`; `{op:"add", path:"/motion/blink/still/<part>", value:{row, bone}}` (preceded by `{op:"add", path:"/motion/blink/still", value:{}}` when the config states no `still`) or `{op:"remove", path:"/motion/blink/still/<part>"}`; `{op:"replace", path:"/stops/<i>", value:<the entry with inward / outward changed>, name:<its chain>}`; `{op:"add"|"remove", path:"/bones/<i>/inherit", value:<mode>, name}` or `path:"/bones/<i>/link_inherit", value:{<link>: mode}`; `{op:"replace", path:"/turn/pivot"|"/turn/depths/<part>", value:<number>}`. A `stops` entry is named by its `chain` and `EDIT_PATCH_NAME_MISMATCH` checks it as it checks a `bones` entry. The page refuses anything else before sending (`RIG_FIELDS_OP_OUT_OF_SCOPE`; `turn.deform` is out of scope) and sends the ops as one `rig` patch (no flag), so it needs the reproduced baseline: none of its pointers is in retune's scope (*Routing*). The producer judges every value and its refusal comes back verbatim as the job's `producerLine`. The turn dial is play's `adjust` on the drawn build's `turn` bone; it is not a patch op and nothing records it. Nothing is written into the package.
221
226
 
227
+ **Undo across Applies (#166).** The session's applied records are a stack, in order, each with its build id; the last is on the stage. `GET /api/rebuild/history` (single review) answers `{schema:"rig-review-applied-history/1", depth, records:[{id, kind:"regions"|"patch"|"delta"|"retarget", op, what, modelHash, kept}]}` (`kept`: the record's build is still on disk). `POST /api/rebuild/undo` (same-origin, capability, 1 KiB) takes exactly `{from}`, the build id the page shows on the stage; it removes the last record and returns the stage to its predecessor's build, or to the inspected build when none is left: `{status:"built", undone, job, history}` (the predecessor's job, its model served at `/rebuild/<id>/` again), `{status:"running", undone, job}` (an evicted predecessor re-running; poll `/api/rebuild/<id>`), or `{status:"inspected", undone, history}`. Refusals (HTTP 200 `{status:"refused", error}`): `UNDO_NOTHING_APPLIED`, `UNDO_STALE` (`from` is not the last record), `REBUILD_BUSY`, and `UNDO_PREDECESSOR_CHANGED: <what>`; malformed: HTTP 400 `REBUILD_REQUEST_INVALID`. The scratch root `<state>/rebuild/<package hash>/` still holds only the build on the stage; the other records' builds wait in `<state>/rebuild-kept/<package hash>/<id>/`, at most `MAX_KEPT_BUILDS` (8) builds per package on disk counting the stage's, the oldest record's evicted first, and a build no record names is removed. A kept predecessor is moved back and re-hashed (every file the model names) against both its result.json and its record; an evicted one re-runs its recorded request through the same runner, refused before anything runs when the handoff config's sha256 or the producer or compiler version is not the record's (`UNDO_PREDECESSOR_CHANGED: <config path> sha256 a… → b…`, `UNDO_PREDECESSOR_CHANGED: @rig-c/parts 1.49.1 → @rig-c/parts 1.50.0; …`), or when its build id hashes differently (`UNDO_PREDECESSOR_CHANGED: build id a… → b… — …`), and refused after the run when the build is not byte-identical, by the first differing file (`UNDO_PREDECESSOR_CHANGED: skeleton.model.json — the record's build has sha256 a…, the re-run wrote b…; …`) — never guessed; a refusal leaves the stage and the stack on the last Apply. The undone record's build is removed (no redo). Undo writes nothing into the package. Feedback written after an undo carries the post-undo record as `context.applied` (or `null` on the inspected build), never the reverted one. The Cuts and Cloth blocks read the same record (#172): a `rig-review-applied/2` record with op `build` is the Cuts block's, with op `cloth` the Cloth block's, and a pending op is marked applied only while that record holds it (same op, path, value and name); their readouts show the restored build's job when it is theirs, else nothing. The stack lives in the review process; a restarted review starts with none.
228
+
229
+ **Density strip (#173).** Next to the selected part's density field — `grid` on a lattice entry, `contour.spacing` on a contour entry — the Mesh panel takes "N values from A to B" (2 ≤ N ≤ 5; a lattice's grid is rounded to whole numbers) and **Build strip** sends one Mesh-panel patch per value (the pending entry with that field at that value, `replace /meshes/<part>`, `remesh --part <part>`) to `POST /api/density-strip` (single review; same-origin, capability, 320 KiB = five 64 KiB patches) as exactly `{patches}`. Malformed (HTTP 400): `DENSITY_STRIP_REQUEST_INVALID` (not 2–5 patches, a repeated value, another config hash, producer or flag set), `MESH_PATCH_SCOPE` (a patch that is not the panel's one op) and `DENSITY_STRIP_FIELD` (entries that differ anywhere but the density field, or a mode without one); each patch is then refused as an Apply refuses it (`EDIT_PATCH_STALE`, `EDIT_PATCH_NAME_MISMATCH`, `EDIT_PATCH_PATH_HELD`, `EDIT_PRODUCER_MISMATCH`, the producer-resolution lines), before anything runs. The strip id hashes the package, the config bytes, every patch's bytes in order and both tool versions; each candidate's build id is the patch's own Apply id (*Run*, above).
230
+ - **Runner.** Every candidate runs the patch runner a remesh Apply runs — remesh into `rig/`, the compile with remesh.json's pack flags, #174's carried check (no third check; an older producer runs check, as an Apply does), the build hashed and `result.json` written — into its own folder `<state>/density-strip/<package hash>/<strip id>/<build id>/`, at most `max(1, floor(cores / 2))` at once (the strip's `concurrency`). A candidate does not land: no applied record, no `latest` build, nothing in the Apply scratch root or `rebuild-kept`, no undo entry (#166's stack is untouched). One producer workload runs at a time per review: a strip is refused while an Apply (or undo's re-run) runs, and an Apply, an undo and another strip are refused `REBUILD_BUSY: density strip <id> is still running` while a strip runs; a single Apply is otherwise unchanged (its records, ids, scratch and lines).
231
+ - **Rows.** `GET /api/density-strip/<id>` (or `GET /api/density-strip` → `{strip}`, the one on screen or null) answers `{schema:"rig-review-density-strip/1", id, status:"running"|"done", part, field, concurrency, configHash, producer, compiler, wallMs, rows[], labels:{fewest, widest}}`, one row per patch in the request's order: `{value, id, status, counts:{vertices, triangles}|null (remesh.json target.after), modelTriangles, folds (ANIMATIONS_DO_NOT_FOLD's folding mesh count), stretch:{severity, frame}|null (the part's TEXTURE_STRETCH per-mesh severity and the frame of the side it came from), bars:[{bar, status, key, value, bound, kind:"floor"|"ceiling", ratio, margin}] (every bar line that measures the part — "part <name>," or "slot <name>," not unmeasured — its figure against the line's <key>_floor or <key>_ceiling), verdict ("PASS", check's line when it did not pass, or the refusal verbatim, e.g. "FAIL REMESH_CHECK_HELD: …"), modelUrl, modelHash, applied, entry}`. The figures are the producer's own printed check of the revision (`[check-revision] …` lines; the lines of review's check when it ran one). `GET /api/density-strip/<id>/<build id>` is the candidate's job in full. `labels.fewest` is the passing row with the fewest triangles; `labels.widest`, when passing rows tie on that count, the one whose narrowest margin over its bars is widest (margin = value/floor, or ceiling/value). Labels only: the table has no Apply.
232
+ - **Load is not Apply.** A built row's model is served read-only at `/density-strip/<id>/<build id>/<file>` (its hashed files only; a refused row has none and cannot be loaded). Load puts it on the stage as **pending** through the same in-place reload an Apply uses (camera, animation, time, world kept) and sets the Mesh panel's pending entry to the row's entry; the counts line shows `MESH_COUNTS_AGREE` / `MESH_COUNTS_DIFFER` for the row. Nothing is recorded: while a pending row is on the stage the feedback context's applied state is null (nothing applied is on the stage), Undo is unavailable (the stage is not the last record), and **Back** returns the stage to what it showed. The Mesh panel's Apply then sends the same patch; its build id is the candidate's, so the server moves the candidate's folder into the Apply scratch root and the ordinary reuse re-hashes and lands it — one applied record, exactly as a re-Applied patch, with no producer run. The row then reads `applied` and is no longer served from the strip. Opening, building or loading a strip approves nothing.
233
+ - **Refine between** two adjacent rows builds the next strip with N values strictly inside their interval.
234
+ - **Scratch.** A package keeps at most one strip on disk: a new strip removes its predecessor's folder before it starts, `POST /api/density-strip/close` (same-origin, capability, 1 KiB, exactly `{id}`; `DENSITY_STRIP_BUSY` while it runs, `DENSITY_STRIP_NOT_FOUND` for another id) removes it, and a refused candidate leaves no folder. A restarted review starts with no strip; the next strip removes what a previous process left. Nothing is written into the package.
235
+
222
236
  ## Mesh edit export
223
237
 
224
238
  Edit Mesh's **Download JSON** (#80, setup space since #100) saves two files; neither is written into the package and neither enters the review session.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -0,0 +1,48 @@
1
+ /**
2
+ * #172: what the Cuts and Cloth blocks mark as applied, derived from the stage's current applied record — the same
3
+ * record the draft keeps and feedback carries as `context.applied`. After an undo that record is the predecessor's (or
4
+ * none on the inspected build), so a reverted Apply's ops are never marked.
5
+ *
6
+ * The rule: a patch record (`rig-review-applied/2`) belongs to the Cloth block when its op is `cloth` (the server runs
7
+ * a patch whose every op is under `/cloth` as `propose --cloth --write-config`) and to the Cuts block when its op is
8
+ * `build` (a cut, patch, limb or joint edit is read by assemble, so it runs `rig-parts build`). Any other record — a
9
+ * physics or bones `rig` patch, a remesh, a regions, delta or retarget record, or none — marks nothing in either block.
10
+ * A pending op is applied when the block's applied ops hold an op with the same op, path, value and name.
11
+ */
12
+ import {APPLIED_PATCH_SCHEMA} from './applied-context.ts';
13
+ import type {PatchOp} from './config-patch.ts';
14
+
15
+ export type AppliedBlock = 'cuts' | 'cloth';
16
+ export const APPLIED_BLOCKS: readonly AppliedBlock[] = ['cuts', 'cloth'];
17
+
18
+ /** The record op each block owns. */
19
+ const BLOCK_OP: Record<AppliedBlock, string> = { cuts: 'build', cloth: 'cloth' };
20
+
21
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
22
+
23
+ /** One op's identity for the applied mark: op, path, value and name. */
24
+ export const opKey = (o: PatchOp): string => JSON.stringify([o.op, o.path, o.value ?? null, o.name ?? null]);
25
+
26
+ /** The block a record belongs to, or null when it marks nothing in the Cuts or Cloth block. */
27
+ export function recordBlock(record: unknown): AppliedBlock | null {
28
+ if (!isObject(record) || record.schema !== APPLIED_PATCH_SCHEMA) return null;
29
+ return APPLIED_BLOCKS.find(b => record.op === BLOCK_OP[b]) ?? null;
30
+ }
31
+
32
+ /** The stage record's applied ops per block: the record's patch ops in the block it belongs to, none in the other. */
33
+ export function appliedBlockOps(record: unknown): Record<AppliedBlock, PatchOp[]> {
34
+ const out: Record<AppliedBlock, PatchOp[]> = { cuts: [], cloth: [] };
35
+ const block = recordBlock(record);
36
+ if (!block) return out;
37
+ const patch = (record as Record<string, unknown>).patch;
38
+ const ops = isObject(patch) && Array.isArray(patch.ops) ? patch.ops as PatchOp[] : [];
39
+ out[block] = ops.map(o => ({ ...o }));
40
+ return out;
41
+ }
42
+
43
+ /** Whether a pending item (the ops it sends) is applied: it sends at least one op and every one is in `applied`. */
44
+ export function itemApplied(itemOps: readonly PatchOp[], applied: readonly PatchOp[]): boolean {
45
+ if (!itemOps.length || !applied.length) return false;
46
+ const keys = new Set(applied.map(opKey));
47
+ return itemOps.every(o => keys.has(opKey(o)));
48
+ }
@@ -44,9 +44,10 @@ export interface CheckSummary {
44
44
  /**
45
45
  * #139: where the summary came from. Absent: review read check's own output (`check.json` and its stdout). `"retune"`:
46
46
  * the check `rig-parts retune` ran on the revision it wrote, carried in `retune.json`'s `check.revision` (parts 1.49.0+)
47
- * and bound to the revision's files by its hash rows; no second check ran.
47
+ * and bound to the revision's files by its hash rows; no second check ran. `"remesh"` (#174): the same, from the check
48
+ * `rig-parts remesh` ran, carried in `remesh.json`.
48
49
  */
49
- source?: 'retune' | 'check';
50
+ source?: 'retune' | 'remesh' | 'check';
50
51
  }
51
52
  export interface Tool { package: string; version: string }
52
53
  export interface AppliedPatch {
@@ -186,7 +187,7 @@ export function checkSummary(checkJson: unknown, exitCode: number, lines: readon
186
187
  function parseCheck(v: unknown, field: string): void {
187
188
  if (v === null) return;
188
189
  const c = exact(v, isObject(v) && Object.hasOwn(v, 'source') ? 'bars,exitCode,gate,line,pass,schema,source' : 'bars,exitCode,gate,line,pass,schema', field);
189
- if (Object.hasOwn(c, 'source') && c.source !== 'retune' && c.source !== 'check') throw new Error(`${field}.source: "retune" or "check"`);
190
+ if (Object.hasOwn(c, 'source') && c.source !== 'retune' && c.source !== 'remesh' && c.source !== 'check') throw new Error(`${field}.source: "retune", "remesh" or "check"`);
190
191
  if (c.schema !== CHECK_SUMMARY_SCHEMA) throw new Error(`${field}.schema: expected ${CHECK_SUMMARY_SCHEMA}`);
191
192
  if (c.pass !== null && typeof c.pass !== 'boolean') throw new Error(`${field}.pass: boolean or null`);
192
193
  if (c.gate !== null && typeof c.gate !== 'boolean') throw new Error(`${field}.gate: boolean or null`);
@@ -0,0 +1,177 @@
1
+ /**
2
+ * What Apply can still do on a build whose baseline did not reproduce (#148). The baseline (`src/baseline.ts`) rebuilds
3
+ * the whole rig from the handoff config; when that differs from the inspected rig, the whole-rig ops refuse
4
+ * (`EDIT_BASELINE_MISMATCH`), and the producer's own revising ops hold their own parts of it. This module reads both
5
+ * rig directories and states, per op, what the installed producer will hold — read off `@rig-c/parts`' AUTHORING
6
+ * (§remesh and §retune, the `REMESH_*` and `RETUNE_*` rows) and `src/remesh.ts` / `src/retune.ts`:
7
+ *
8
+ * - **The writer's text.** `remesh` (and its `--delta` mesh edit) splices `rig.json` and `mesh_report.json` as text,
9
+ * `retune` splices `rig.json` and `motion.json`; each refuses an input file that is not the text the writer prints
10
+ * for its value (`REMESH_INPUT_WRITTEN`, `RETUNE_INPUT_WRITTEN`: "reformatted, or written by something else"). The
11
+ * writer is `rigJsonText`: `JSON.stringify(value, null, 1)` and a closing newline.
12
+ * - **retune** carries every attachment and every `mesh_report.json` row over as bytes, so a mesh repair stays; it
13
+ * holds `stops`, `bones`, `slots`, `skeleton`, `images`, every invariant but `detached`, every image file and
14
+ * `motion.json` outside `animations.idle` to the config's rebuild (`RETUNE_REQUEST_HELD`). An image the config draws
15
+ * differently — a repaired texture — is therefore held, and no constraint or idle edit can Apply over it.
16
+ * - **remesh** holds its target part (its attachment, its image, its `mesh_report.json` row, and for an `auto` row the
17
+ * motion) and the bones to the config's rebuild (`REMESH_BASELINE_MISMATCH`); every other part is carried over. The
18
+ * mesh edit (`--delta`) builds no config entry: its baseline is the inspected mesh itself.
19
+ *
20
+ * The baseline is the config as written, so this is what an op whose patch the producer judges no further meets. It is
21
+ * a reading, not the producer's verdict: the producer's own line on Apply stays the judgment, shown verbatim.
22
+ */
23
+ import { readFile } from 'node:fs/promises';
24
+ import { join } from 'node:path';
25
+ import { PRODUCER_IMAGES_DIR } from './producer-files.ts';
26
+ import { REMESH_DELTA_SINCE, versionAtLeast } from './producer.ts';
27
+ import { RETUNE_SINCE } from './retune-route.ts';
28
+
29
+ /** Which revising ops the installed producer has: `retune` since 1.44.0, `remesh --delta` since 1.43.0. Unknown version: both. */
30
+ export interface ReachOps { retune: boolean; delta: boolean; version: string | null }
31
+ export const reachOps = (version: string | null | undefined): ReachOps => ({ retune: !version || versionAtLeast(version, RETUNE_SINCE), delta: !version || versionAtLeast(version, REMESH_DELTA_SINCE), version: version || null });
32
+
33
+ /** `@rig-c/parts`' writer for its rig documents (`src/rig.ts` `rigJsonText`): one space of indent and a closing newline. */
34
+ export const writerText = (value: unknown): string => `${JSON.stringify(value, null, 1)}\n`;
35
+ /** True when the text is exactly what the writer prints for the value it parses to. */
36
+ export function isWriterText(text: string): boolean {
37
+ try { return writerText(JSON.parse(text)) === text; } catch { return false; }
38
+ }
39
+
40
+ export const BASELINE_REACH_SCHEMA = 'rig-review-baseline-reach/1';
41
+ export interface BaselineReach {
42
+ schema: typeof BASELINE_REACH_SCHEMA;
43
+ /** Inspected documents (package-relative) that are not the writer's text, in `rig.json`, `motion.json`, `mesh_report.json` order. */
44
+ notWriterText: string[];
45
+ /** Images (package-relative) the config draws differently from the inspected rig, or that one side lacks. */
46
+ images: string[];
47
+ /** retune (physics and idle edits): null when it holds nothing the config changes, else the first held difference. */
48
+ retuneHeld: string | null;
49
+ /** remesh from a config entry: the meshed parts whose attachment, image or report row the config does not reproduce. */
50
+ remeshRefused: string[];
51
+ /** remesh: every meshed part of the inspected rig, in the order the rig lists its skins. */
52
+ meshParts: string[];
53
+ /** remesh: true when the config moves a bone, which refuses every part. */
54
+ bonesDiffer: boolean;
55
+ /** The producer's revising ops this reading assumed (`reachOps`). */
56
+ ops: ReachOps;
57
+ /** The sentence the baseline line and `EDIT_BASELINE_MISMATCH` carry. */
58
+ text: string;
59
+ }
60
+
61
+ type Json = unknown;
62
+ const isObject = (v: Json): v is Record<string, Json> => !!v && typeof v === 'object' && !Array.isArray(v);
63
+ /** Structural equality of two parsed JSON values (object key order ignored). */
64
+ export function jsonEqual(a: Json, b: Json): boolean {
65
+ if (a === b) return true;
66
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => jsonEqual(x, b[i]));
67
+ if (!isObject(a) || !isObject(b)) return false;
68
+ const ka = Object.keys(a), kb = Object.keys(b);
69
+ return ka.length === kb.length && ka.every(k => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]));
70
+ }
71
+ const withoutKey = (v: Json, key: string): Json => isObject(v) ? Object.fromEntries(Object.entries(v).filter(([k]) => k !== key)) : v;
72
+ /** motion.json outside `animations.idle`, as retune holds it. */
73
+ const outsideIdle = (m: Json): Json => isObject(m) ? { ...m, animations: withoutKey(m.animations, 'idle') } : m;
74
+ /** The rig's mesh attachments named as `remesh --part` names them: `skins.default.<part>.<part>` of type `mesh`. */
75
+ function meshAttachments(rig: Json): Map<string, Json> {
76
+ const out = new Map<string, Json>();
77
+ const skins = isObject(rig) ? rig.skins : undefined, skin = isObject(skins) ? skins.default : undefined;
78
+ if (!isObject(skin)) return out;
79
+ for (const [slot, atts] of Object.entries(skin)) if (isObject(atts) && isObject(atts[slot]) && (atts[slot] as Record<string, Json>).type === 'mesh') out.set(slot, atts[slot]);
80
+ return out;
81
+ }
82
+ const reportRow = (report: Json, part: string): Json => Array.isArray(report) ? report.find(r => isObject(r) && r.part === part) : undefined;
83
+
84
+ export interface RigDocuments { rig: Json; motion: Json; report: Json; texts: Record<string, string | null> }
85
+ const DOCUMENTS = ['rig.json', 'motion.json', 'mesh_report.json'] as const;
86
+ async function documents(dir: string): Promise<RigDocuments> {
87
+ const texts: Record<string, string | null> = {}, parsed: Record<string, Json> = {};
88
+ for (const name of DOCUMENTS) {
89
+ let text: string | null = null;
90
+ try { text = await readFile(join(dir, name), 'utf8'); } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
91
+ texts[name] = text;
92
+ try { parsed[name] = text === null ? undefined : JSON.parse(text); } catch { parsed[name] = undefined; }
93
+ }
94
+ return { rig: parsed['rig.json'], motion: parsed['motion.json'], report: parsed['mesh_report.json'], texts };
95
+ }
96
+
97
+ /**
98
+ * The reach, from both rig directories' documents and their `{rig-relative path: sha256}` trees (`rigTree`).
99
+ * `show` turns a rig-relative path into the package-relative one the line names.
100
+ */
101
+ export function reachOf(baseline: RigDocuments, inspected: RigDocuments, baselineTree: Record<string, string>, inspectedTree: Record<string, string>, show: (rel: string) => string, ops: ReachOps = reachOps(null)): BaselineReach {
102
+ const notWritten = DOCUMENTS.filter(name => inspected.texts[name] !== null && !isWriterText(inspected.texts[name]!));
103
+ const imageKeys = [...new Set([...Object.keys(baselineTree), ...Object.keys(inspectedTree)])].filter(k => k.startsWith(`${PRODUCER_IMAGES_DIR}/`)).sort();
104
+ const images = imageKeys.filter(k => baselineTree[k] !== inspectedTree[k]);
105
+ const rigB = isObject(baseline.rig) ? baseline.rig : {}, rigI = isObject(inspected.rig) ? inspected.rig : {};
106
+ // retune's hold, in its own order: stops, then bones, slots, skeleton, images, the invariants but detached, each image, motion outside idle.
107
+ let retuneHeld: string | null = null;
108
+ for (const key of ['stops', 'bones', 'slots', 'skeleton', 'images']) if (!retuneHeld && !jsonEqual(rigI[key], rigB[key])) retuneHeld = `rig.json ${key}`;
109
+ if (!retuneHeld && !jsonEqual(withoutKey(rigI.invariants, 'detached'), withoutKey(rigB.invariants, 'detached'))) retuneHeld = 'rig.json invariants';
110
+ if (!retuneHeld && images.length) retuneHeld = images[0]!;
111
+ if (!retuneHeld && !jsonEqual(outsideIdle(inspected.motion), outsideIdle(baseline.motion))) retuneHeld = 'motion.json outside animations.idle';
112
+ // remesh's per-part baseline: the attachment, the image, the report row (and an auto row's motion), and the bones.
113
+ const attsI = meshAttachments(inspected.rig), attsB = meshAttachments(baseline.rig), meshParts = [...attsI.keys()];
114
+ const bonesDiffer = !jsonEqual(rigI.bones, rigB.bones), motionDiffers = !jsonEqual(inspected.motion, baseline.motion);
115
+ const remeshRefused = meshParts.filter(part => {
116
+ const row = reportRow(inspected.report, part);
117
+ return !jsonEqual(attsI.get(part), attsB.get(part)) || images.includes(`${PRODUCER_IMAGES_DIR}/${part}.png`) || !jsonEqual(row, reportRow(baseline.report, part)) || (isObject(row) && row.mode === 'auto' && motionDiffers);
118
+ });
119
+ const reach: BaselineReach = { schema: BASELINE_REACH_SCHEMA, notWriterText: notWritten.map(show), images: images.map(show), retuneHeld: retuneHeld === null ? null : retuneHeld.startsWith(`${PRODUCER_IMAGES_DIR}/`) ? show(retuneHeld) : retuneHeld, remeshRefused, meshParts, bonesDiffer, ops, text: '' };
120
+ reach.text = reachText(reach, show);
121
+ return reach;
122
+ }
123
+
124
+ /** Reads both directories and returns the reach (`reachOf`). */
125
+ export async function baselineReach(baselineDir: string, inspectedDir: string, baselineTree: Record<string, string>, inspectedTree: Record<string, string>, show: (rel: string) => string, ops: ReachOps = reachOps(null)): Promise<BaselineReach> {
126
+ return reachOf(await documents(baselineDir), await documents(inspectedDir), baselineTree, inspectedTree, show, ops);
127
+ }
128
+
129
+ const list = (items: readonly string[], max = 4) => items.length <= max ? items.join(', ') : `${items.slice(0, max).join(', ')} and ${items.length - max} more`;
130
+ /** The re-serialisation advice for a document that is not the writer's text (the producer's own row says: use the files as rig wrote them). */
131
+ export const WRITER_TEXT_ADVICE = 'use the files as rig wrote them, or re-serialise them in a copy of the package exactly as rig writes them (JSON with one space of indent and a closing newline) and open the copy';
132
+
133
+ /**
134
+ * One sentence per op, the cause first:
135
+ * whole-rig edits refuse (EDIT_BASELINE_MISMATCH); physics and idle edits (retune) …; remesh …; a mesh edit ….
136
+ */
137
+ export function reachText(r: Omit<BaselineReach, 'text'>, show: (rel: string) => string = rel => rel): string {
138
+ const parts: string[] = [];
139
+ const written = (names: readonly string[]) => r.notWriterText.filter(p => names.some(n => p === show(n)));
140
+ const remeshWritten = written(['rig.json', 'mesh_report.json']), retuneWritten = written(['rig.json', 'motion.json']);
141
+ if (r.notWriterText.length) parts.push(`${list(r.notWriterText)} ${r.notWriterText.length === 1 ? 'is' : 'are'} not the text rig writes for ${r.notWriterText.length === 1 ? 'its value' : 'their values'} (reformatted, or written by another tool), and an op that splices a revision as text refuses such a file — ${WRITER_TEXT_ADVICE}`);
142
+ parts.push('whole-rig edits refuse (EDIT_BASELINE_MISMATCH)');
143
+ // retune: physics and the idle.
144
+ const retune = !r.ops.retune ? `physics and idle edits rebuild the whole rig on ${r.ops.version} (retune is ${RETUNE_SINCE}+), so they refuse too`
145
+ : retuneWritten.length ? `physics and idle edits (retune) refuse ${list(retuneWritten)} (RETUNE_INPUT_WRITTEN)${r.retuneHeld ? `, and past that ${retuneHeldText(r.retuneHeld)}` : ''}`
146
+ : r.retuneHeld ? `physics and idle edits (retune) refuse: ${retuneHeldText(r.retuneHeld)}`
147
+ : 'physics and idle edits Apply through retune, which carries every inspected mesh over';
148
+ parts.push(retune);
149
+ // remesh from the config's entry (Mesh panel, chest regions).
150
+ const fits = r.meshParts.filter(p => !r.remeshRefused.includes(p));
151
+ const remeshHeld = r.bonesDiffer ? 'the config moves a bone, so every part is refused (REMESH_BASELINE_MISMATCH)'
152
+ : r.remeshRefused.length ? `${list(r.remeshRefused)} ${r.remeshRefused.length === 1 ? 'is' : 'are'} refused (REMESH_BASELINE_MISMATCH: the config does not reproduce ${r.remeshRefused.length === 1 ? 'its' : 'their'} attachment, image or report row)${fits.length ? `; ${list(fits, 8)} can be re-fit` : ''}`
153
+ : r.meshParts.length ? 'every meshed part can be re-fit' : 'the rig has no meshed part';
154
+ parts.push(remeshWritten.length ? `remesh${r.ops.delta ? ' and a mesh edit refuse' : ' refuses'} ${list(remeshWritten)} (REMESH_INPUT_WRITTEN); past that, remesh: ${remeshHeld}` : `remesh: ${remeshHeld}`);
155
+ if (r.ops.delta && !remeshWritten.length && r.meshParts.length) parts.push('a mesh edit (Edit Mesh) is held to the inspected mesh itself, not to the config, so the baseline does not refuse it');
156
+ return parts.join('; ');
157
+ }
158
+ function retuneHeldText(held: string): string {
159
+ if (held.includes(`${PRODUCER_IMAGES_DIR}/`) && !held.startsWith('rig.json')) return `the config draws ${held} differently and retune holds every image (RETUNE_REQUEST_HELD)`;
160
+ return `the config changes ${held}, which retune holds (RETUNE_REQUEST_HELD)`;
161
+ }
162
+
163
+ /**
164
+ * A review line beside a producer refusal it can explain (#148). The producer's line stays as printed; this names the
165
+ * cause and the way out in review's terms. Null for any other line.
166
+ */
167
+ export function producerHint(line: string | null): string | null {
168
+ if (!line) return null;
169
+ const written = /\b(REMESH|RETUNE)_INPUT_WRITTEN:\s+(.+?)\s+—\s/.exec(line);
170
+ if (written) {
171
+ const file = written[2]!.split(/[\\/]/).pop()!, op = written[1] === 'REMESH' ? 'remesh' : 'retune';
172
+ return `${file} in the inspected rig is not the text rig writes for its value (reformatted, or written by another tool); ${op} splices a revision as text, so it refuses the file — ${WRITER_TEXT_ADVICE}`;
173
+ }
174
+ const image = /\bRETUNE_REQUEST_HELD:\s+(images\/\S+)\s+—/.exec(line);
175
+ if (image) return `the inspected ${image[1]} is not the image the config draws (a repaired or redrawn image); retune holds every image, so no physics or idle edit Applies on this build — the baseline line says what still does`;
176
+ return null;
177
+ }