@rig-c/review 0.10.1 → 0.10.3

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
@@ -119,7 +119,7 @@ The server bundles `web/main.ts` at startup using the pinned installed runtime,
119
119
 
120
120
  ### Releasing
121
121
 
122
- Releases are cut by `.github/workflows/release.yml`. On every push to main, release-please keeps a `release: vX.Y.Z` pull request whose version comes from the conventional-commit subjects since the last release. Merging it tags the version, creates the GitHub release, runs typecheck and the install smoke on the tagged tree, and publishes to npm through trusted publishing (OIDC) with provenance. No npm token is stored. Nothing is published until a release PR is merged.
122
+ Releases are cut by `.github/workflows/release.yml`. On every push to main, release-please keeps a `release: vX.Y.Z` pull request whose version comes from the conventional-commit subjects since the last release. Merging it tags the version, creates the GitHub release, runs typecheck and the install smoke on the tagged tree, and publishes to npm through trusted publishing (OIDC) with provenance. No npm token is stored. Nothing is published until a release PR is merged. A manual dispatch only re-publishes an existing tag whose GitHub release release-please created, whose commit is on main, and whose version the registry does not serve yet. It must be dispatched on the tag itself (`gh workflow run release.yml --ref vX.Y.Z -f tag=vX.Y.Z`): npm provenance names the run's commit, so a run whose commit is not the tag's is refused `PROVENANCE_SHA_MISMATCH`. Only a tag cut after that check carries it.
123
123
 
124
124
  ## Frame strip export
125
125
 
@@ -218,7 +218,8 @@ package. Input hashes are freshly computed, not trusted from mtime caches. Idle
218
218
  open-managed workers stop after 30 minutes. Their private registry, requests and
219
219
  logs live beside feedback under `~/.local/state/rig-c-review/open/`; nothing is
220
220
  written into supplied folders. `RIG_REVIEW_STATE_DIR` selects another state root
221
- outside all input packages. Existing foreground review commands still work.
221
+ outside all input packages, judged by its canonical location (symlinks in the path
222
+ and its existing ancestors resolved). Existing foreground review commands still work.
222
223
 
223
224
  No browser is opened, no LAN listener is added, and no misleading phone/QR URL is
224
225
  printed: every current instance binds only `127.0.0.1`.
@@ -247,6 +248,7 @@ stderr and exit code alone:
247
248
  - `OPEN_ANIMATION_AMBIGUOUS`: no flag or report selects one clip, or the A/B reports conflict.
248
249
  - `OPEN_ANIMATION_UNVALIDATABLE`: a clip was requested but no compiled model can validate it.
249
250
  - `OPEN_OPTION_INVALID <flag>`: an unknown, duplicate, empty or value-less option, a handoff flag without `--config`, `--source` without `--full` (or the reverse), or a handoff flag with `--compare`.
251
+ - `OPEN_STATE_INSIDE_PACKAGE <package | build root | handoff root>`: the state directory (`RIG_REVIEW_STATE_DIR` or the default), resolved through symlinks, lies inside an input package, the `rig-parts build` root a check package belongs to, or the producer handoff's root.
250
252
  - `OPEN_HANDOFF_INVALID <flag or path>`: a declared or discovered producer handoff path is refused — `HANDOFF_PATH_OUTSIDE_ROOT`, `HANDOFF_SYMLINK_ESCAPE`, `HANDOFF_INPUT_MISSING`, `HANDOFF_INPUT_INVALID` or `HANDOFF_PATH_INVALID`, named after the colon.
251
253
  4. Exit codes: 0 applied, 1 refused. (`control` keeps its own exit 2 for an
252
254
  unapplied command.)
@@ -606,7 +608,7 @@ Rig pixels are the whole figure's frame, which is the crop frame the model's sta
606
608
  - **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.
607
609
  - **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.
608
610
  - **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.
611
+ - **Scratch.** `<state>/rebuild/<package source hash>/<designation hash>/` under `RIG_REVIEW_STATE_DIR` (refused, by its canonical location, inside the package, its build root or the handoff root), 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
612
  - **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.
611
613
  - **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.
612
614
  - **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*.
@@ -0,0 +1,93 @@
1
+ # Live fixture
2
+
3
+ Unit tests use fake producers. A card that changes what an Apply runs also needs one **live** run against the installed producers. This page is the package and the setup that run uses, so a session does not have to find them again.
4
+
5
+ ## The package: parts `examples/wind`
6
+
7
+ The public [`examples/wind`](https://github.com/rig-c/parts/tree/v1.49.1/examples/wind) in rig-c/parts fits every editor block:
8
+
9
+ - cloth recipes for three tags (bottomwear, back hair, front hair), for the Cloth block;
10
+ - limbs on arms and legs, for the Cuts block and the leg lines of Retarget (FEET, STANCE, LEG-SCALE, REACH);
11
+ - `keypoints.json`, for the `--keypoints` handoff input;
12
+ - a painting and the `full` and `head` See-through layer sets, for `--source`, `--full` and `--head`;
13
+ - `expected/`, the files a build must reproduce.
14
+
15
+ It is public, so a PR may name it. Pin it to a parts tag; `examples/wind` is unchanged from v1.49.1 to v1.49.2.
16
+
17
+ ## 1. The producer bench
18
+
19
+ The blocks below run in order in one shell. Start in an empty folder; every later path hangs off `BASE`, so a block works wherever the previous one left the shell.
20
+
21
+ ```sh
22
+ BASE="$(pwd)"
23
+ ```
24
+
25
+ Review runs the installed producers and never installs them. Keep one folder that holds the versions a card needs, and start review from it (`rig-review open` resolves the producers from its working directory):
26
+
27
+ ```sh
28
+ mkdir "$BASE/review-producer" && cd "$BASE/review-producer"
29
+ npm init -y
30
+ npm i -D @rig-c/review@0.8.0 @rig-c/parts@1.49.1 rig-c@2.64.1 @rig-c/pose@0.37.0
31
+ cd "$BASE"
32
+ ```
33
+
34
+ Bump a version here when a card needs a newer producer. Review refuses an older one by name where a flag needs it, for example `MODELING_PRODUCER_TOO_OLD` for `check --environment`.
35
+
36
+ ## 2. The package, built in scratch
37
+
38
+ The example's heavy inputs live in rig-c/parts-examples. A parts clone copies them in with `bun run fetch-examples`:
39
+
40
+ ```sh
41
+ git clone --branch v1.49.1 https://github.com/rig-c/parts.git "$BASE/parts"
42
+ cd "$BASE/parts" && bun install && bun run fetch-examples
43
+ cd "$BASE"
44
+ ```
45
+
46
+ Copy the example into a scratch folder, because review refuses handoff paths outside the folder it opens. `pose.json` is needed: the config names it as `generation.control.skeleton`, and `assemble` refuses `CONFIG_FILE_PRESENT` without it.
47
+
48
+ ```sh
49
+ W="$BASE/parts/examples/wind"
50
+ P="$BASE/scratch/wind"
51
+ mkdir -p "$P"
52
+ cp "$W/config.json" "$W/keypoints.json" "$W/pose.json" "$P/"
53
+ cp -R "$W/inputs" "$P/inputs"
54
+ ```
55
+
56
+ Build it from the bench, then put the checked compiled model where the folder adapter looks (`build/skeleton.model.json`):
57
+
58
+ ```sh
59
+ cd "$BASE/review-producer"
60
+ npx rig-parts build --config "$P/config.json" --source "$P/inputs/painting.png" \
61
+ --full "$P/inputs/layers/full" --head "$P/inputs/layers/head" --out "$P/out"
62
+ cp -R "$P/out/check/build" "$P/build"
63
+ ```
64
+
65
+ The build takes about two minutes and ends with `build: PASS`. `out/parts.json`, `out/rig/motion.json` and `out/rig/mesh_report.json` are byte-equal to `expected/`.
66
+
67
+ ## 3. Open it with the handoff
68
+
69
+ From the bench folder:
70
+
71
+ ```sh
72
+ cd "$BASE/review-producer"
73
+ npx rig-review open "$P" --config "$P/config.json" --parts "$P/out" \
74
+ --source "$P/inputs/painting.png" --full "$P/inputs/layers/full" --head "$P/inputs/layers/head" \
75
+ --keypoints "$P/keypoints.json" --animation idle
76
+ ```
77
+
78
+ Inside a review clone, `bun src/cli.ts open …` is the same command. Once the session baseline settles (about two minutes), the editor bar reads:
79
+
80
+ ```
81
+ baseline: reproduced — rig.json d19892a32cec… = inspected d19892a32cec…
82
+ ```
83
+
84
+ `--parts` is the build's output root (`out/`), not `out/parts`.
85
+
86
+ **Shorter form (#149).** `rig-review open "$P/out"` opens the build root directly, without copying `out/check/build` to `build/`. Review opens `out/check` and discovers the build's own `config.json` beside `out/`, and the open line prints its path and sha256. `--source`, `--full`, `--head` and `--keypoints` are not recorded by the build, so declare them when a card needs them, as in step 3 (`--source "$P/inputs/painting.png"`, and so on).
87
+
88
+ ## Routes worth knowing
89
+
90
+ - **Whole rig.** On a reproduced baseline, a physics patch runs `rig --config` (#136).
91
+ - **Retune.** To exercise retune (#136, #139), change one number in `$P/out/rig/rig.json` (a +0.001 nudge is enough), so the baseline is not reproduced. Then a patch inside retune's scope routes to `rig-parts retune`. Keep the file the writer's text (edit the number in place, or re-serialise with `JSON.stringify(value, null, 1)` and a closing newline): any other text refuses `RETUNE_INPUT_WRITTEN` (#148). A nudge in an image, not in rig.json, refuses every retune `RETUNE_REQUEST_HELD` instead.
92
+ - **Density strip (#173).** Select `bottomwear` (a `grid` 31 entry) in the Mesh panel and build grid 40 → 120 with N 5: grid 120 refuses `REMESH_CHECK_HELD` (TIP_OVER_ROOT), the other four build. Each candidate is a whole remesh (its own baseline rig, input check and revision check), so a strip of five costs about five Applies of CPU; on a shared machine its wall clock follows the free cores.
93
+ - **Build.** A patch under `/assemble`, `/limbs`, or `/bones` on a config with `limbs` runs `rig-parts build` (#115). So does a cloth Apply (#111).
@@ -163,7 +163,7 @@ Every edit card states its edit as one config patch; review applies it to a scra
163
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
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
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`.
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).
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 named directory outside the declared runs by its contents (every regular file under it, at most 8 levels deep and 4096 files, each held inside the package root, in path order; over the bound refuses `BUILD_INPUT_TOO_LARGE`), a declared run through the handoff record (#185); 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).
167
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}`.
168
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.
169
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.
@@ -458,8 +458,13 @@ Registry/request/log/claim files are private tool-state files; a per-binding cla
458
458
  serializes startup, stale records are not reused, and no unrelated process is
459
459
  terminated. Workers expire after 30 minutes without requests and clean only their
460
460
  own nonce's registration. Startup waiting is bounded. `RIG_REVIEW_STATE_DIR`
461
- changes the tool state root but cannot place it inside a source package, including
462
- through a symlink alias. No package manifest, source video, motion or compiled
461
+ changes the tool state root but cannot place it inside a source package, the
462
+ `rig-parts build` root a check package belongs to, or the producer handoff's root,
463
+ including through a symlink alias of the directory or an ancestor
464
+ (`OPEN_STATE_INSIDE_PACKAGE <package | build root | handoff root>`). Every runner that
465
+ writes state (rebuild, strip, bones check, readings, requirements, environment check)
466
+ applies the same canonical check and refuses with its own `*_STATE_INSIDE_PACKAGE`
467
+ code naming the folder. No package manifest, source video, motion or compiled
463
468
  model is written by open. There is no browser launch or new LAN exposure.
464
469
 
465
470
  The printed feedback retrieval command uses POSIX shell quoting for literal path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "examples/demo",
36
36
  "docs/REVIEW_PACKAGE.md",
37
37
  "docs/DESIGN.md",
38
+ "docs/LIVE_FIXTURE.md",
38
39
  "README.md",
39
40
  "LICENSE"
40
41
  ],
@@ -31,3 +31,5 @@ export function initialPlayerAnimation(names:readonly string[],selected:string|u
31
31
  const name=checkpoint?.animation??selected;if(name!==undefined){if(!names.includes(name))throw new Error(`initial.animation: model has no animation ${JSON.stringify(name)}`);return name;}
32
32
  return names[0]??null;
33
33
  }
34
+ /** The animation a model swap keeps (#186): the staged one when the new model has it, else none — never a removed clip's name. */
35
+ export function keptAnimation(staged:string,names:readonly string[]):string {return staged&&names.includes(staged)?staged:'';}
@@ -46,3 +46,17 @@ export function itemApplied(itemOps: readonly PatchOp[], applied: readonly Patch
46
46
  const keys = new Set(applied.map(opKey));
47
47
  return itemOps.every(o => keys.has(opKey(o)));
48
48
  }
49
+
50
+ /** The stage's identity for a readout: the applied record's build id, or null on the inspected build. */
51
+ const stageId = (record: unknown): string | null => isObject(record) && typeof record.buildId === 'string' ? record.buildId : null;
52
+
53
+ /**
54
+ * #186: the blocks whose result readout goes stale when the stage changes from `previous` to `next` — every block the
55
+ * new stage record does not belong to (a Cuts readout after a Cloth Apply, both after a Reset or another kind of Apply).
56
+ * Nothing while the stage is unchanged, so a refused Apply's readout stays; the owning block shows its own new job.
57
+ */
58
+ export function staleReadouts(previous: unknown, next: unknown): AppliedBlock[] {
59
+ if (stageId(previous) === stageId(next)) return [];
60
+ const owner = recordBlock(next);
61
+ return APPLIED_BLOCKS.filter(b => b !== owner);
62
+ }
@@ -79,6 +79,15 @@ function meshAttachments(rig: Json): Map<string, Json> {
79
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
80
  return out;
81
81
  }
82
+ /**
83
+ * The rig-relative image a part's mesh attachment draws: the rig's `images` directory and the attachment's `image`
84
+ * (`images/shirt.png` for `topwear` drawing `shirt.png`); `images/<part>.png` only when the attachment names none.
85
+ */
86
+ function attachmentImage(rig: Json, att: Json, part: string): string {
87
+ const dir = isObject(rig) && typeof rig.images === 'string' && rig.images ? rig.images.replace(/\/+$/, '') : PRODUCER_IMAGES_DIR;
88
+ const image = isObject(att) && typeof att.image === 'string' && att.image ? att.image : `${part}.png`;
89
+ return `${dir}/${image}`;
90
+ }
82
91
  const reportRow = (report: Json, part: string): Json => Array.isArray(report) ? report.find(r => isObject(r) && r.part === part) : undefined;
83
92
 
84
93
  export interface RigDocuments { rig: Json; motion: Json; report: Json; texts: Record<string, string | null> }
@@ -114,7 +123,9 @@ export function reachOf(baseline: RigDocuments, inspected: RigDocuments, baselin
114
123
  const bonesDiffer = !jsonEqual(rigI.bones, rigB.bones), motionDiffers = !jsonEqual(inspected.motion, baseline.motion);
115
124
  const remeshRefused = meshParts.filter(part => {
116
125
  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);
126
+ // The image each side's attachment draws (#185), compared in the trees, so a renamed image is not missed.
127
+ const imageDiffers = [attachmentImage(inspected.rig, attsI.get(part), part), attachmentImage(baseline.rig, attsB.get(part), part)].some(k => baselineTree[k] !== inspectedTree[k]);
128
+ return !jsonEqual(attsI.get(part), attsB.get(part)) || imageDiffers || !jsonEqual(row, reportRow(baseline.report, part)) || (isObject(row) && row.mode === 'auto' && motionDiffers);
118
129
  });
119
130
  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
131
  reach.text = reachText(reach, show);
@@ -9,13 +9,14 @@
9
9
  * scratch directory is removed after the run.
10
10
  */
11
11
  import { createHash } from 'node:crypto';
12
- import { isAbsolute, join, relative } from 'node:path';
12
+ import { join } from 'node:path';
13
13
  import { mkdir, rm, writeFile } from 'node:fs/promises';
14
14
  import type { ReviewPackage } from './manifest.ts';
15
15
  import { firstRefusal, resolveProducer, runTool, type InstalledTool } from './producer.ts';
16
16
  import { applyConfigPatch, assertOpNames, assertPatchFresh, ConfigPatchError, CONFIG_PATCH_SCHEMA, parseConfigPatch, type JsonValue, type PatchOp } from './config-patch.ts';
17
17
  import { assertNamedFilesHeld, producerInputs, RebuildError, scratchConfigText } from './rebuild.ts';
18
18
  import { assertBoneOps, BoneAnchorError, compareOrigins, proposeLines, type OriginRow } from './bone-anchors.ts';
19
+ import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
19
20
 
20
21
  export const BONES_CHECK_SCHEMA = 'rig-review-bones-check/1';
21
22
  export const BONES_CHECK_LIMIT = 64 * 1024;
@@ -60,10 +61,7 @@ export function createBonesCheck(p: ReviewPackage, options: { stateDir: string;
60
61
  let running = false;
61
62
  async function run(input: unknown): Promise<BonesCheckResult> {
62
63
  const { ops, configHash } = parseBonesCheck(input);
63
- for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
64
- const inside = relative(root, options.stateDir);
65
- if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
66
- }
64
+ assertStateDirOutside(options.stateDir, p, message => new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', message));
67
65
  await options.unchanged();
68
66
  const inputs = await producerInputs(p.handoff, p.handoffReason);
69
67
  const original = JSON.parse(inputs.configText) as JsonValue;
@@ -129,19 +129,23 @@ export function recipeOps(config: unknown, tag: string, next: Record<string, Jso
129
129
  if (!known.has(key)) continue;
130
130
  const was = current[key], now = next[key];
131
131
  if (same(was, now)) continue;
132
- if (now === undefined) { ops.push({ op: 'remove', path: at(key) }); continue; }
133
- if (was === undefined) { ops.push({ op: 'add', path: at(key), value: now }); continue; }
134
- // An object field (split, strength, swing, influence) is edited key by key, so its other keys keep their place.
135
- if (isObject(was) && isObject(now)) {
136
- for (const sub of [...new Set([...Object.keys(was), ...Object.keys(now)])]) {
137
- if (same(was[sub], now[sub])) continue;
132
+ // An object field (split, strength, swing, influence) is edited key by key, so its other keys keep their place;
133
+ // cleared to nothing (no field left, no unknown key to keep), the object itself is removed rather than left `{}`.
134
+ if (isObject(was) && (now === undefined || isObject(now))) {
135
+ const to: Record<string, JsonValue> = now ?? {}, left: Record<string, unknown> = { ...was }, subOps: PatchOp[] = [];
136
+ for (const sub of [...new Set([...Object.keys(was), ...Object.keys(to)])]) {
137
+ if (same(was[sub], to[sub])) continue;
138
138
  const known2 = RECIPE_FIELDS.some(f => f.path[0] === key && f.path[1] === sub);
139
139
  if (!known2) continue;
140
- if (now[sub] === undefined) ops.push({ op: 'remove', path: at(key, sub) });
141
- else ops.push({ op: was[sub] === undefined ? 'add' : 'replace', path: at(key, sub), value: now[sub] as JsonValue });
140
+ if (to[sub] === undefined) { subOps.push({ op: 'remove', path: at(key, sub) }); delete left[sub]; }
141
+ else { subOps.push({ op: was[sub] === undefined ? 'add' : 'replace', path: at(key, sub), value: to[sub] as JsonValue }); left[sub] = to[sub] as JsonValue; }
142
142
  }
143
+ if (Object.keys(left).length === 0) ops.push({ op: 'remove', path: at(key) });
144
+ else ops.push(...subOps);
143
145
  continue;
144
146
  }
147
+ if (now === undefined) { ops.push({ op: 'remove', path: at(key) }); continue; }
148
+ if (was === undefined) { ops.push({ op: 'add', path: at(key), value: now }); continue; }
145
149
  ops.push({ op: 'replace', path: at(key), value: now });
146
150
  }
147
151
  return ops;
@@ -11,7 +11,7 @@
11
11
  * back. The client sends `{build, schedule}` only — never a path or a flag. Nothing is written into the package.
12
12
  */
13
13
  import { createHash } from 'node:crypto';
14
- import { isAbsolute, join, relative } from 'node:path';
14
+ import { join } from 'node:path';
15
15
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
16
16
  import type { ReviewPackage } from './manifest.ts';
17
17
  import { firstRefusal, modelingProducerTooOld, resolveProducer, runTool, versionAtLeast } from './producer.ts';
@@ -19,6 +19,7 @@ import { producerInputs, RebuildError } from './rebuild.ts';
19
19
  import { checkSummary, type CheckSummary } from './applied-context.ts';
20
20
  import { barLine, READOUT_BARS } from './physics-tuning.ts';
21
21
  import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
22
+ import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
22
23
 
23
24
  export const ENVIRONMENT_CHECK_SCHEMA = 'rig-review-environment-check/1';
24
25
  export const ENVIRONMENT_CHECK_LIMIT = 64 * 1024;
@@ -78,10 +79,7 @@ export function createEnvironmentCheck(p: ReviewPackage, options: EnvironmentChe
78
79
  let running: string | null = null;
79
80
  const snapshot = (job: EnvironmentCheckJob): EnvironmentCheckJob => JSON.parse(JSON.stringify(job)) as EnvironmentCheckJob;
80
81
  function assertStateOutside(): void {
81
- for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
82
- const inside = relative(root, options.stateDir);
83
- if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
84
- }
82
+ assertStateDirOutside(options.stateDir, p, message => new EnvironmentCheckError('ENVIRONMENT_CHECK_STATE_INSIDE_PACKAGE', message));
85
83
  }
86
84
 
87
85
  async function execute(job: EnvironmentCheckJob, dir: string, bin: string, rig: string, parts: string, text: string): Promise<void> {
@@ -65,7 +65,9 @@ export function windSchedule(s: WindSnapshot, gravity: number, duration: number,
65
65
  const g: [number, number] = [0, gravity];
66
66
  if (!windVaries(s)) return { schedule: standingSchedule({ wind: [s.base, 0], gravity: g }), form: 'step', tolerance: 0, fps, duration };
67
67
  if (!(duration > 0) || !Number.isFinite(duration)) throw new ScheduleError('ENVIRONMENT_SCHEDULE_NO_CLIP', 'a breeze or a repeating gust is sampled over a clip; no clip with a duration is drawn');
68
- const last = Math.round(duration * fps);
68
+ // The last frame inside the clip: floor, so an off-grid clip (0.8 s = 9.6 frames) ends on frame 9, not a step after it
69
+ // (rigc refuses that); the epsilon keeps an on-grid duration whose product lands a hair under the integer on its frame.
70
+ const last = Math.floor(duration * fps + 1e-9);
69
71
  if (last + 1 > MAX_SCHEDULE_STEPS) throw new ScheduleError('ENVIRONMENT_SCHEDULE_TOO_LONG', `${duration} s at ${fps} fps is ${last + 1} steps; at most ${MAX_SCHEDULE_STEPS}`);
70
72
  const steps: EnvironmentStep[] = [];
71
73
  let previous = NaN;
package/src/manifest.ts CHANGED
@@ -23,7 +23,9 @@ export interface ReviewManifest {
23
23
  export interface ReviewedSource { readonly packageRevision:string; readonly viewerVersion:string; readonly runtimeVersion:string; readonly rig:{readonly path:string;readonly sha256:string}|null; readonly motion:{readonly path:string;readonly sha256:string}|null; readonly handoff?:HandoffRecord }
24
24
  export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog;
25
25
  /** The resolved producer handoff (#101) and, when there is none, the refusal line naming where it was looked for. */
26
- handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags }
26
+ handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags;
27
+ /** #187: the rig-parts build root ({@link isBuildRoot}) whose `check/` this package is, however it was opened; review state stays outside it. */
28
+ buildRoot?: string }
27
29
  function fail(field: string, expected: string): never { throw new Error(`${field}: expected ${expected}`); }
28
30
  export function object(value: unknown, field: string): Record<string, unknown> { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(field, 'object'); return value as Record<string, unknown>; }
29
31
  export function string(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim() || value.length > 10000) fail(field, 'nonempty string (at most 10000 characters)'); return value; }
@@ -109,9 +111,11 @@ export async function loadReview(input: string, options: { handoff?: HandoffFlag
109
111
  }
110
112
  // The handoff files are hashed beside the package: their record joins the reviewed identity (and so the session file).
111
113
  const {handoff,reason:handoffReason}=await resolveHandoff(root,manifest.producer,options.handoff);
114
+ // #192: the root is `check/` whether the folder or its review.json was opened, so the build root is kept either way.
115
+ const buildRoot=basename(root)==='check'&&await isBuildRoot(dirname(root))?dirname(root):undefined;
112
116
  const reviewedSource:ReviewedSource={packageRevision:manifest.revision,...await toolVersions(),rig:rigPath?{path:rigPath,sha256:fileHashes.get(rigPath)!}:null,motion:motionPath?{path:motionPath,sha256:fileHashes.get(motionPath)!}:null,...(handoff?{handoff:handoff.record}:{})};
113
117
  const feedbackCatalog:FeedbackCatalog={rig:manifest.id,bones:modelNames(modelMetadata,'bones'),slots:modelNames(modelMetadata,'slots'),animations:modelAnimations(modelMetadata),clock:report?.clock??null,clockAnimation:report?.animation??null};
114
- return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{})};
118
+ return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{}),...(buildRoot?{buildRoot}:{})};
115
119
  }
116
120
 
117
121
  /**
package/src/open.ts CHANGED
@@ -5,7 +5,7 @@ import {spawn} from 'node:child_process';
5
5
  import {homedir} from 'node:os';
6
6
  import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,stat,open as openFile} from 'node:fs/promises';
7
7
  import {constants} from 'node:fs';
8
- import {basename,dirname,join,relative,resolve,isAbsolute} from 'node:path';
8
+ import {basename,dirname,join,resolve,isAbsolute} from 'node:path';
9
9
  import {fileURLToPath} from 'node:url';
10
10
  import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
11
11
  import {loadReview,UnrecognizedOutputError,type ReviewPackage} from './manifest.ts';
@@ -17,6 +17,7 @@ import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-proto
17
17
  import {ensureFront,frontPort} from './front.ts';
18
18
  import {playPackagePath} from './versions.ts';
19
19
  import {HandoffError,HANDOFF_FLAGS,isBuildRoot,type HandoffFlags,type HandoffRecord} from './handoff.ts';
20
+ import {stateDirInside,stateRoots} from './state-dir.ts';
20
21
  /** #149: an opened rig-parts build check package — its canonical path and the handoff record (or the refusal line). */
21
22
  export interface BuildOpen {package:string;handoff:HandoffRecord|null;reason:string|null}
22
23
  /** The stderr diagnostic for {@link BuildOpen}: the package opened, then how the config was found, its package-relative path and full SHA-256. */
@@ -110,8 +111,9 @@ export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idl
110
111
  if(inputs.compare!==undefined&&!inputs.reference)throw new Error('OPEN_REFERENCE_REQUIRED: compare: explicit --reference required');
111
112
  let p:OpenPackage;try{p=await loadOpenPackage(inputs);}catch(error){throw await openRefusal(error,inputs);}
112
113
  const fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
113
- const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?[p.p.root]:[p.p.a.root,p.p.b.root];
114
- for(const root of roots){const rel=relative(root,stateDir);if(!rel||rel!=='..'&&!rel.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&!isAbsolute(rel))throw new Error('open.state: must be outside input packages');}
114
+ // #187: canonical (symlinks and ancestors resolved) and outside every supplied folder: the package, the build root a check package belongs to, the handoff root.
115
+ const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?stateRoots(p.p):[...stateRoots(p.p.a),...stateRoots(p.p.b)];
116
+ const inside=stateDirInside(stateDir,roots);if(inside)throw new Error(`OPEN_STATE_INSIDE_PACKAGE ${inside.kind}: the review state directory must be outside input packages; it is inside the ${inside.kind}; set RIG_REVIEW_STATE_DIR outside it`);
115
117
  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');
116
118
  const worker=await ensureWorker(p,key,fingerprint,stateDir,canonicalControl,options.idleMs??30*60*1000);
117
119
  const url=direct?openUrl(worker.row):openUrl(worker.row,await ensureFront({control:canonicalControl,port,fingerprint,idleMs:options.frontIdleMs}));
@@ -7,11 +7,12 @@
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
9
  import { copyFile, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
10
- import { dirname, isAbsolute, join, relative } from 'node:path';
10
+ import { dirname, join } from 'node:path';
11
11
  import { safeFile, type ReviewPackage } from './manifest.ts';
12
12
  import { resolveCompiler, runTool, type InstalledTool } from './producer.ts';
13
13
  import { scheduleText, type EnvironmentSchedule } from './environment-schedule.ts';
14
14
  import { environmentSchedule, helpHasEnvironment, parseReadingsRequest, READING_FILE, readingLabel, readingResult, READINGS_SCHEMA, READINGS_SINCE, ReadingsError, renderArgs, type ReadingBuild, type ReadingRequest, type ReadingResult, type ReadingsRequest } from './readings.ts';
15
+ import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
15
16
 
16
17
  const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
17
18
  async function exists(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; throw e; } }
@@ -67,10 +68,7 @@ export function createReadings(p: ReviewPackage, options: ReadingsOptions) {
67
68
  let running: string | null = null;
68
69
  const snapshot = (job: ReadingsJob): ReadingsJob => JSON.parse(JSON.stringify(job)) as ReadingsJob;
69
70
  function assertStateOutside(): void {
70
- for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
71
- const inside = relative(root, options.stateDir);
72
- if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new ReadingsError('READINGS_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
73
- }
71
+ assertStateDirOutside(options.stateDir, p, message => new ReadingsError('READINGS_STATE_INSIDE_PACKAGE', message));
74
72
  }
75
73
  /** Whether this rigc's `render --help` lists `--environment`, asked once per compiler. */
76
74
  async function environmentFlag(compiler: InstalledTool): Promise<boolean> {
package/src/readings.ts CHANGED
@@ -214,6 +214,14 @@ export function readingTicks(runs: readonly { build: ReadingBuild; results: read
214
214
 
215
215
  /** rigc samples the last frame at frame / fps; the runtime's clip duration can round a hair below it. Within this, the tick is the clip's end. */
216
216
  export const TICK_EDGE = 1e-4;
217
+ /**
218
+ * A reading's seek time on its clip (#186): within TICK_EDGE past the clip's end, the clip's end. `clipDuration` is the
219
+ * duration of the clip the reading names (the one the seek switches to), never the clip drawn before the switch; a time
220
+ * further past is returned unchanged for the seek to refuse by name.
221
+ */
222
+ export function readingSeekTime(exact: number, clipDuration: number): number {
223
+ return clipDuration > 0 && exact > clipDuration && exact - clipDuration <= TICK_EDGE ? clipDuration : exact;
224
+ }
217
225
  /** The ticks the scrub draws: the job's animation is the one drawn, each with a time inside the clip (the last sample kept). */
218
226
  export function ticksOnClip(ticks: readonly ReadingTick[], jobAnimation: string, animation: string, duration: number): ReadingTick[] {
219
227
  if (jobAnimation !== animation || !(duration > 0)) return [];