@rig-c/review 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +206 -12
  2. package/docs/DESIGN.md +26 -4
  3. package/docs/REVIEW_PACKAGE.md +113 -0
  4. package/package.json +1 -1
  5. package/src/applied-context.ts +285 -0
  6. package/src/baseline.ts +175 -0
  7. package/src/bone-anchors.ts +123 -0
  8. package/src/bones-check.ts +105 -0
  9. package/src/build-route.ts +109 -0
  10. package/src/chest-landmarks.ts +122 -0
  11. package/src/chest-regions.ts +10 -4
  12. package/src/cli.ts +19 -8
  13. package/src/config-patch.ts +213 -0
  14. package/src/cut-edits.ts +184 -0
  15. package/src/environment.ts +19 -6
  16. package/src/feedback-export.ts +17 -1
  17. package/src/front.ts +11 -0
  18. package/src/handoff.ts +208 -0
  19. package/src/idle-tracks.ts +401 -0
  20. package/src/manifest.ts +15 -6
  21. package/src/mesh-edit.ts +45 -15
  22. package/src/mesh-entry.ts +351 -0
  23. package/src/open.ts +12 -5
  24. package/src/physics-tuning.ts +236 -0
  25. package/src/producer-files.ts +11 -0
  26. package/src/producer.ts +158 -0
  27. package/src/readings-run.ts +129 -0
  28. package/src/readings.ts +235 -0
  29. package/src/rebuild.ts +938 -0
  30. package/src/reference.ts +4 -3
  31. package/src/requirements-run.ts +160 -0
  32. package/src/requirements.ts +230 -0
  33. package/src/retarget.ts +211 -0
  34. package/src/retune-route.ts +52 -0
  35. package/src/rig-fields.ts +295 -0
  36. package/src/server.ts +59 -5
  37. package/src/session.ts +9 -0
  38. package/src/setup-mesh.ts +144 -0
  39. package/src/weights.ts +74 -0
  40. package/web/bones-panel.ts +230 -0
  41. package/web/chest-editor.ts +128 -13
  42. package/web/chest-regions-tool.ts +2 -1
  43. package/web/cuts-panel.ts +266 -0
  44. package/web/idle-panel.ts +280 -0
  45. package/web/index.html +1 -1
  46. package/web/inspector-blocks.ts +77 -0
  47. package/web/main.ts +701 -49
  48. package/web/mesh-editor.ts +73 -38
  49. package/web/mesh-panel.ts +273 -0
  50. package/web/physics-panel.ts +151 -0
  51. package/web/readings-panel.ts +105 -0
  52. package/web/rebuild-client.ts +216 -0
  53. package/web/requirements-panel.ts +241 -0
  54. package/web/retarget-panel.ts +203 -0
  55. package/web/rig-fields-panel.ts +269 -0
  56. package/web/rig-overlays.ts +108 -14
  57. package/web/setup-pose.ts +30 -0
  58. package/web/styles.css +28 -5
  59. package/web/weights-view.ts +159 -0
@@ -30,6 +30,8 @@ The CLI also accepts an existing output directory. It reads a root `review.json`
30
30
  }
31
31
  ```
32
32
 
33
+ An optional `producer` block declares the producer handoff: `"producer": {"config": "rig/config.json", "parts": "rig/out", "source": "inputs/painting.png", "full": "inputs/full", "head": "inputs/head"}` (only `config` is required; `source` and `full` go together, `head` only with them; no other key). See *Producer handoff* below.
34
+
33
35
  The illustrative `look` animation in `axes` must also be declared in `animations`. Omit axes when no real angle animation exists. All animation identifiers name actual compiled-model animations; the frontend reports runtime load/lookup errors rather than inventing them. Metadata duration is optional; the player supplies actual duration.
34
36
 
35
37
  ## Files and references
@@ -117,6 +119,117 @@ The active compiled model supplies rig identity. Without one, a known root or un
117
119
  `bun run feedback -- <review.json|output-directory>` reads the existing bound session JSON after the UI closes. With no saved state it emits pending and `updatedAt: null`. It never writes source or creates a review submission. JSON/Markdown exports include this identity, so another agent can tell exactly which rig, motion and viewer revision a person reviewed.
118
120
 
119
121
 
122
+ ## Producer handoff
123
+
124
+ An Apply rebuilds through the installed producer from the files the inspected rig was built from: the producer config, its parts directory (`parts.json`, `parts/`) and the inspected rig directory `<parts>/rig/`. Review finds them in this order, first match wins, never by searching:
125
+
126
+ 1. **Declared on the command line**: `rig-review open <dir> --config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]]`. `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
+ 2. **Declared in review.json**: the `producer` block, package-relative literal portable paths (no `..`, no absolute path, no encoding).
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).
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`.
132
+
133
+ **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.
134
+
135
+ **Record and identity.** `reviewedSource.handoff` (absent when there is no handoff, so earlier identities are unchanged):
136
+
137
+ ```json
138
+ {"schema":"rig-review-handoff/1","how":"discovered","via":"discovery","outside":false,
139
+ "config":{"path":"rig/config.json","sha256":"…"},"parts":"rig/out","rig":"rig/out/rig","source":null,"full":null,"head":null,
140
+ "files":[{"path":"rig/config.json","sha256":"…"},{"path":"rig/out/parts.json","sha256":"…"}],"missing":[]}
141
+ ```
142
+
143
+ `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.
144
+
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.
146
+
147
+ ## Config patch and Apply (`rig-review-config-patch/1`)
148
+
149
+ Every edit card states its edit as one config patch; review applies it to a scratch copy of the handoff config and rebuilds through the installed producer. The package's config, parts and rig are only read.
150
+
151
+ ```json
152
+ {"schema":"rig-review-config-patch/1",
153
+ "config_sha256":"<SHA-256 of the handoff config bytes the patch was made against>",
154
+ "ops":[{"op":"replace","path":"/constraints/0/damping","value":0.8}],
155
+ "producer":{"name":"@rig-c/parts","version":"1.42.0"},
156
+ "op":{"cmd":"rig","args":[]}}
157
+ ```
158
+
159
+ - **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
+ - **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.
162
+ - **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
+ - **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.
164
+ - **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}`.
165
+
166
+ **`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:
167
+
168
+ - `rig-review-applied/1` (#99 regions): `{schema, buildId, part, regions, configHash, entrySha256, producer, compiler, line, modelHash}`; a Landmarks Apply (#105) adds `bust` (the `contour.bust` entry as sent) and its `regions` are review's derivation of the two circles.
169
+ - `rig-review-applied/2` (patch): `{schema, buildId, patch, patchSha256, configHash, producer:{package,version}, compiler:{package,version}, line, check, diff, modelHash}` — the exact patch (whose `config_sha256` equals `configHash`), its hash, the producer's last line, check's summary and the rig.json diff. Since #136 the record adds `op`, the producer op that ran: `"rig"` or `"retune"` for a `rig` patch, `"remesh"` for a remesh patch (anything else is refused); the job carries the same `op` and its lines name the command. Records without `op` stay valid. Since #115 `op` may be `"build"` for a `rig` patch, and exactly then the record adds `assembled: {wallMs, recomposite:{before, after}, partsChanged[], seamPairs[≤64]}` — the build's wall clock for that Apply, the recomposite figures, the parts whose PNG changed against the handoff's and the `seam pair` lines; the issue export's `Applied:` line names the wall clock (`· build 118.0 s · parts changed: bottomwear, ribbon_r · recomposite error px 8194 → 8194`). A remesh patch from the Mesh panel (#106) adds `mesh: {part, before:{vertices, triangles, hull}, after:{vertices, triangles, hull}}` — the revision's `remesh.json` counts; `part` must be the part the patch's `remesh --part` names. Since #125 the record adds `files: {documents:[{file, count, paths}], images:{changed, unchanged, names}}` (or `null`) — the scratch rig against the inspected rig, every document the producer writes: `documents` in the order `rig.json`, `motion.json`, `sets.json`, `mesh_report.json` (each `file` at most once; a document neither side has is left out), `count` the number of RFC 6901 pointers at which it differs and `paths` the first ≤ 200 of them (`""` — the whole document — when only one side has it or a side is not JSON, unless the bytes are equal); `images` compares `images/*` by SHA-256 only: `changed` (including an image only one side has, its `images/<name>` in `names`, ≤ 200) and `unchanged`. `diff` stays rig.json's entry as before. Records without `files` (written before #125) stay valid. The list of documents is `PRODUCER_DOCUMENTS` in `src/producer-files.ts`, the same list the baseline compares. The Apply status line and the export state it per file: `motion.json differs at 2 path(s) · rig.json, sets.json, mesh_report.json unchanged · 31 images unchanged`.
170
+ - `rig-review-applied/3` (#135, Edit Mesh's delta): `{schema, buildId, part, delta, deltaSha256, configHash, producer, compiler, line, op, files, mesh?, modelHash}` — the `rig-review-mesh-edit/1` document as sent (basis `"setup"`, `slot` the part, at most 300 ops), the SHA-256 of its bytes, the producer's last line, `op` always `"remesh"`, the per-file diff as above (or `null`) and the part's `remesh.json` counts (`mesh.part` must be `part`). No `check`: `rig-parts remesh` holds check on the revision itself (`REMESH_CHECK_HELD`). See *Mesh edit export*.
171
+
172
+ **Readings (#108).** `POST /api/readings` (single review; same-origin, capability, 16 KiB) takes exactly `{animation, readings:[1–8], environment}`: each reading is `{kind:"folds"}`, `{kind:"overlaps"}`, `{kind:"pass-through", cloth, leg}`, `{kind:"seam", a, b}` or `{kind:"angle-range", bone, relative}`, every name a slot or bone name without `:` or `,` (whether the rig has it is rigc's to say), no reading twice; `environment` is `null` or `{wind:[x,y], gravity:[x,y]}` (x right, y down). Malformed: HTTP 400 `READINGS_REQUEST_INVALID`. Outcomes (HTTP 200 `{status:"refused", error}`): `READINGS_COMPILER_NOT_INSTALLED` (rig-c beside `@rig-c/parts`, else beside the package or review), `READINGS_COMPILER_TOO_OLD <found> 2.63.0`, `READINGS_NO_MODEL`, `READINGS_STATE_INSIDE_PACKAGE`, `READINGS_BUSY`. The job (`GET /api/readings/<id>`, the id hashed from the package, the request, the inspected model's bytes, the scratch build id, the compiler and whether the schedule was passed): `{schema:"rig-review-readings/1", id, status:"running"|"done"|"failed", error, request, environment:{requested, flag, passed}, compiler, runs:[{build:"inspected"|"scratch", id, modelHash, results[]}]}`. Each result is `{reading, label, status:"read"|"refused"|"failed", fps, frames[] (every frame the reading's JSON lists: folds' meshes' frames, pass-through's frames, seam's opens, overlaps' moving pairs' runs; angle-range's lowest and highest frame from its printed line), source:"json"|"line", worst:{frame, figure, line}|null, items[] (per mesh or pair), lines[] (rigc's printed rows for the reading), refusal (rigc's `rigc: …` line verbatim), exitCode, wallMs}`. The inspected build is `--candidate <the directory of the manifest's model>`; the scratch build is the session's latest built Apply. Each reading runs as `rigc render --candidate <build> --animation <animation> [--environment environment.json] <flag> --out render` in `<state>/readings/<package hash>/<inspected-<model sha256>|scratch-<build id>>/<key>/`, keeps `result.json`, the reading's JSON and `frames.json`, and removes the rendered frames. The schedule `{spec:"rigc-environment/1", steps:[{t:0, wind, gravity}]}` is passed only when `rigc render --help` lists `--environment`.
173
+
174
+ **`context.readings`.** Feedback written after a readings run carries the pairs of the last finished run: `{schema:"rig-review-readings/1", animation, requests[] (the readings as sent), environment, environmentPassed, builds:{inspected:<model sha256>, scratch:<build id>|null}, compiler:{package, version}, ticks:{inspected, scratch|null}}`; `null` when none ran. Absent and `null` stay valid; a present value must have this shape. The results themselves are not recorded: the AI receiving the review reruns the same `rigc render` readings on the named builds.
175
+
176
+ **Requirements (#113).** `GET /api/requirements` (single review) answers what the handoff's rig spec offers: `{status:"ready"|"unavailable", reason, bones[], slots[], meshes:{<slot>:[mesh attachment]}, slotBones:{<slot>:<bone>}, constraints:[{name, type:"ik"|"transform", bones[], drives[]}], animations:[{name, duration}], scratch:<build id>|null}`. `POST /api/requirements` (same-origin, capability, 64 KiB) takes exactly `{fps, build:"inspected"|"scratch", requirements:[1–64]}`, each requirement an object of at most 12 lower-case keys whose values are names, finite numbers or a `{bone, point}` / `{stage:[x, y]}` target; `may_fold` is refused. Malformed: HTTP 400 `REQUIREMENTS_REQUEST_INVALID`. Which kind, field, name or bar reads is the producer reader's to say. Outcomes (HTTP 200 `{status:"refused", error}`): `MODELING_HANDOFF_MISSING`, `MODELING_INPUT_UNAVAILABLE`, `REQUIREMENTS_NO_SCRATCH`, `MODELING_PRODUCER_NOT_INSTALLED`, `REQUIREMENTS_STATE_INSIDE_PACKAGE`, `REQUIREMENTS_BUSY`. Review writes `{spec:"spine-parts-requirements/1", fps, requirements}` (one-space JSON and a newline) as `requirements.json` in `<state>/requirements/<package hash>/<run id>/` and runs `rig-parts check --rig <the handoff rig, or the latest scratch build's rig spec> --out out --parts <handoff parts> --requirements requirements.json` there; the directory is removed after the run. The job (`GET /api/requirements/<id>`): `{schema:"rig-review-requirements/1", id, status:"running"|"checked"|"refused"|"failed", build, buildId, file:{sha256, size, text}, producer, exitCode, wallMs, rows:[{name, kind, animation, status, measured, least, greatest, worst:{frame, time, text}|null, reason, line}], summary, checkLine, refusals[] (`REQUIREMENTS_*` lines verbatim), problems[] (`CHECK_REQUIREMENT_MET` / `CHECK_REQUIREMENT_MEASURABLE` lines verbatim), error, lines[]}`; rows come from `check.json`'s `requirements` block, else from the printed `NAME: STATUS — …` lines.
177
+
178
+ **Self-targeted requirements (#142).** The panel refuses before Add, as `REQUIREMENTS_SELF_TARGET`, a `contact` whose target is its own bone and point and an `aim` whose target bone is the aimed bone: both measure 0 in every frame and the producer reports them as PASS (its own refusal is rig-c/parts#540). A bone's tip against its own origin is not refused; it measures the bone's length. A fresh contact or aim form proposes a target bone other than the subject (the next bone in rig order).
179
+
180
+ **Retarget (#114).** `GET /api/rebuild` adds `retarget: {available, reason, producer, compiler, rig, sequences:[{id, label, path, cleaned, frames, people[]}], bones[], animations[]}`: the installed `@rig-c/pose` (found as `@rig-c/parts` is: the nearest `node_modules` from the package, then from review's working directory; `RETARGET_PRODUCER_NOT_INSTALLED` when absent, never installed) and the `rig-c` beside it (`RETARGET_COMPILER_NOT_INSTALLED`), the package's declared `rig-pose-sequence/1` pose references, and the handoff rig's bone and animation names (`RETARGET_SEQUENCE_UNAVAILABLE` when the package declares no sequence). `POST /api/rebuild` with exactly `{retarget: {sequence, map, flags, configHash}}` (same-origin, capability, 64 KiB): `sequence` a declared pose reference id; `map` a `rig-pose-map/1` document of at most 32 KiB whose chains each hold a name and 1–32 bones and joints (everything else is the producer's to judge); `flags` exactly `{animation, person, clean, minScore, flip, short, bends, bendTolerance, legScale, plantFeet, stance, raisedByHead, carryIdle}`, `clean` null or exactly `{loop, legs, rest, hold, hipsInferred}` (windows 1, or odd and 5–61). Malformed: HTTP 400 `RETARGET_REQUEST_INVALID`. Refused before anything runs: `REBUILD_CONFIG_STALE`, `RETARGET_SEQUENCE_UNKNOWN`, `RETARGET_SEQUENCE_UNSUPPORTED` (a static pose), the handoff and state-directory refusals, `REBUILD_BUSY`. The server maps the flags to argv (`op.args` for retarget, `op.clean` for clean, only what the flags state) and runs, in `<state>/rebuild/<package hash>/<retarget id>/`, `rig-pose clean --pose pose.json --out pose.clean.json <op.clean>` (unless `clean` is null) and `rig-pose retarget --rig <handoff rig> --map map.json --sequence <pose.clean.json | pose.json> --out motion.json <op.args>`, copies the handoff rig's regular files (motion.json excepted) and `images/` to `rig/` with `rig/motion.json` the retarget's, then the shared tail: the per-file diff and `rigc build --rig rig/rig.json --motion rig/motion.json --out build --profile rig-play --pack --page-edges free --pack-shape polygon`. The retarget id hashes the package, the handoff config, the record's bytes and both tool versions (no clock); an identical retarget reuses its build. The job (`GET /api/rebuild/<id>`): `{schema:"rig-review-rebuild/1", kind:"retarget", op:"retarget", id, status, reused, request:{record, recordSha256, configHash}, producer, compiler, lines[], producerLine, refusals[] (every FAIL line of the step that refused, verbatim), exitCode, wallMs:{clean, retarget, compile}, files, motion:{animation, animations[], kept, changed[], animationSha256, motionSha256}, readings:[{code, line, frame, time}], build}`. `readings` are the retarget's FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines (at most 200), each with the first frame it names and that frame's key time from the run's report `frameTimes` (else the line's `t=`). The record, `rig-review-retarget/1`: `{schema, config_sha256, rig:{path, sha256 (rig.json)}, sequence:{reference, path, sha256}, map, map_sha256 (the bytes of map.json as written: one-space JSON and a newline), flags, producer:{name, version}, op:{cmd:"retarget", args, clean}}`; a reader checks that `op` is its flags' own argv. The applied context of a built retarget is `rig-review-applied/4`: `{schema, buildId, retarget, recordSha256, configHash, producer, compiler, line, motion, files, readings, modelHash}`.
181
+
182
+ **`context.requirements`.** Feedback written while requirements are declared carries the file by hash and content: `{schema:"rig-review-requirements-context/1", sha256, size, text, checked:{build, producer, summary, refusals[], rows:[{name, status, worst}]}|null}` — `text` is exactly the bytes the producer reads, `sha256` and `size` are recomputed over it on save, and `checked` is the last check of those same bytes. Absent and `null` stay valid.
183
+
184
+ A `rig-review-applied/2` record whose `check.pass` is `false` is refused at submission (`PUT /api/session`) as `EDIT_SUBMIT_UNCHECKED: applied build failed check — <check's "check: FAIL …" line, else the first bar check.json marks FAIL>`; the build may still be loaded and looked at. `GET /api/rebuild` also returns the handoff config's `constraints` list as written (or `null`), which the physics tuning panel maps against the inspected model.
185
+
186
+ **Mesh panel (#106).** `GET /api/rebuild` also returns `meshes`: every `meshes.<part>` entry of the handoff config as written (`{}` without a handoff). The panel's Apply is an ordinary patch, exactly one op, run by remesh:
187
+
188
+ ```json
189
+ {"schema":"rig-review-config-patch/1","config_sha256":"<…>",
190
+ "ops":[{"op":"replace","path":"/meshes/topwear","value":{"contour":{"tolerance":2,"margin":2,"spacing":20,"maxEdge":45,"regions":[{"name":"chest","shape":"circle","cx":414,"cy":342,"r":50,"bone":"chest","spacing":13,"band":30}]},"r":15,"segments":[["chest",[416,230],[416,600]]]}}],
191
+ "producer":{"name":"@rig-c/parts","version":"1.42.0"},"op":{"cmd":"remesh","args":["--part","topwear"]}}
192
+ ```
193
+
194
+ The page refuses before sending a patch that is not that one op (`MESH_PATCH_SCOPE`) and an entry the config loader would refuse, in the loader's names (`CONFIG_MESH_MODE`, `CONFIG_FIELD_PRESENT`, `CONFIG_FIELD_TYPE`, `CONFIG_REGION_NAME_UNIQUE`, `CONFIG_NAME_RESOLVES`, `CONFIG_RIBS_MODE`, `CONFIG_STRAY_MODE`, `CONFIG_KEY_KNOWN`); the server's own rule is the patch rule above (`EDIT_PATCH_OUT_OF_SCOPE` outside `/meshes/<part>`). A remesh patch's job adds `remesh: {part, before, after, modelTriangles}|null`: `before` and `after` are `remesh.json`'s `target` counts (`{vertices, triangles, hull}`, spec `rig-parts-remesh/1`), `modelTriangles` the compiled scratch model's triangles for that slot's mesh attachment (null when it draws none), and the job's lines carry `review: remesh.json <part>: v … t … → v … t …`. The panel edits the grid and contour modes; an `auto` entry is shown without controls and `influence` is not offered (the installed `remesh --help` names grid, contour and auto entries).
195
+
196
+ **Chest landmarks (#105).** `{part, bust, configHash}` names a contour part and a `contour.bust` entry, typed as the producer's loader types it: `bust` holds exactly `apex_r`, `apex_l`, `cleavage` (each `[x, y]` in rig px on the 1/256 grid) and optionally `alpha` (> 0), `clearance` (a number), `bone_r`, `bone_l` (bone names) and `spacing` (> 0); any other key, an off-grid point or a missing point is `REBUILD_REQUEST_INVALID`. The server writes `mesh.json` as the part's config entry with `contour.regions` removed and `contour.bust` set (keys in the producer's order: `apex_r, apex_l, cleavage, alpha, clearance, bone_r, bone_l, spacing`, only those given), every other field as written, and runs the same `rig-parts remesh --mesh mesh.json` as #99. Example entry:
197
+
198
+ ```json
199
+ {"contour": {"tolerance": 2, "margin": 2, "spacing": 28, "bust": {"apex_r": [384, 345], "apex_l": [446, 345], "cleavage": [415, 350], "alpha": 0.22, "clearance": 3.8}}, "r": 15}
200
+ ```
201
+
202
+ The producer derives `bust_r` / `bust_l` (`R = round(d − clearance)`, `r = round(alpha × R)`, `band = R − r`, centre on the apex) and refuses `CONFIG_BUST_CLEARANCE`, `CONFIG_NAME_RESOLVES` and `CONFIG_REGION_NAME_UNIQUE` by name; the job's `request` carries `bust` beside `regions` (review's derivation, recorded, never sent). Clearance and bones are not judged by the server: the page refuses `CONFIG_BUST_CLEARANCE` before Apply on the producer's own test, and anything else is the producer's line verbatim.
203
+
204
+ **Idle tracks (#104).** `GET /api/rebuild` also returns `idle` (or `null` without a handoff): `{source, keying}`. `source` is `{available:true, source:{duration, tracks[], blink|null, bones[], chains:{<chain>: <links>}}}` — `config.motion` read for its shapes only (a single track `{bone, prop, amp, period, phase, base?}`, `prop` one of rig-parts' `rotate translatex translatey scalex scaley shearx sheary`; a chain track `{chain, amps[], period, phase, lag}`; the blink as written), the bones `config.bones` declares with chain links expanded (`<chain>0…`) — or `{available:false, reason:"IDLE_TRACKS_UNAVAILABLE: …"}`. `keying` says where the inspected idle keys each track, read off the handoff rig's `rig.json` and `motion.json`: `{keys:"ctl"|"direct", source, keyed:{"<config bone>.<prop>": "<keyed bone>"}, controls[]}` (`direct` when `rig.json` declares `invariants.idleDrivesMeshes`, which `rig --idle-keys direct` writes; otherwise `ctl`, the producer's default, under which a keyed bone a mesh is weighted to is keyed on `<bone>_ctl`), or `{error:"IDLE_KEYS_UNKNOWN: …"}`. The panel's edits are ordinary config patch ops: field ops at `/motion/tracks/<i>/<field>` (and `/motion/tracks/<i>/amps/<k>` for one chain link) on the config's own indices, then removed tracks as `remove /motion/tracks/<i>` from the highest index down, then added tracks as `add /motion/tracks/-`, then `/motion/blink/<field>`; Apply sends them as one `rig` patch with `--idle-keys <keying.keys>` (no flag when the keying could not be read); track and blink-number edits run `retune` on parts 1.44.0+ (*Routing*). The local draft's `#idle-pending` record is `{schema:"rig-review-idle-draft/1", ops}` and is restored only when the ops are exactly the ones the panel writes for the idle they describe. The preview is page state only: it is in neither the draft, the patch nor `context.applied`.
205
+
206
+ The issue export adds an `Applied:` line for a valid record (`patch <hash>… (rig; replace /constraints/0/damping) with @rig-c/parts 1.42.0 · check PASS · rig.json differs at 1 path(s) · motion.json, sets.json, mesh_report.json unchanged · 31 images unchanged · model <hash>…`; a record without `files` says `rig.json differs at N path(s)` alone); the raw context follows it. The review's `reviewedSource` still names the inspected build.
207
+
208
+ **Bones (#109).** `GET /api/rebuild` also returns `bones`: the handoff config's `bones` list as written (or `null` without a handoff). The Bones block's edits are ordinary config patch ops, `{op:"replace", path, value:[x, y], name}` with `path` one of `/bones/<i>/at`, `/bones/<i>/points/<k>`, `/bones/<i>/tip` (rig px) and `name` the entry's `name` — a chain entry, which has none, is named by its `chain`, and `EDIT_PATCH_NAME_MISMATCH` checks it the same way. Apply sends them as one `rig` patch (no flag), so it needs the reproduced baseline. `POST /api/bones-check` (single review; same-origin, capability, 64 KiB) takes exactly `{ops, configHash}`; every op must be such a `replace` (anything else is HTTP 400 `BONES_OP_OUT_OF_SCOPE`), and the patch refusals apply (`EDIT_PATCH_STALE`, `EDIT_PATCH_NAME_MISMATCH`, `EDIT_PATCH_PATH_MISSING`, `EDIT_PATCH_PATH_HELD`; producer resolution refusals verbatim; `BONES_CHECK_BUSY`), as HTTP 200 `{status:"refused", error}`. It writes the patched config (`scratchConfigText`) to `<state>/bones-check/<package hash>/<ops hash>/config.json`, runs `rig-parts propose --parts <handoff parts> --source <handoff source> --out propose --from-config config.json` and `rig-parts compare --left <handoff config> --right config.json` there, removes the directory, and returns `{schema:"rig-review-bones-check/1", status:"checked", request:{configHash, opsSha256, ops}, producer, propose:{status:"ran"|"unavailable", exitCode, wallMs, lint[], summary, coverage[], line}, compare:{exitCode, wallMs, origins:[{bone, distance|null, tip|null, text}], summary[], line}}`. `propose.line` is the LINT count line, propose's named refusal when it exits non-zero without a LINT line, or — when the handoff declares no source painting — `MODELING_INPUT_UNAVAILABLE: source painting — propose --from-config reads the painting (--source) and the handoff declares none; declare it with rig-review open <dir> --config <path> --source <png> --full <dir> or the review.json "producer" block`. Nothing is written into the package.
209
+
210
+ **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.
211
+
212
+ ## Mesh edit export
213
+
214
+ 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.
215
+
216
+ `mesh-edit.json` is the delta, in the shape the producer's `rig-parts remesh --delta <mesh-edit.json>` reads (rig-c/parts 1.44.0) without conversion:
217
+
218
+ ```json
219
+ {"schema":"rig-review-mesh-edit/1","slot":"panel","skin":"default","attachment":"panel",
220
+ "source":{"vertexCount":5,"triangleCount":4,"hullLength":4,"hash":"<sha256>"},
221
+ "units":"setup-pose crop px of the rig canvas, x right, y down; …","basis":"setup",
222
+ "preview":"preview only — the producer retriangulates; …",
223
+ "ops":[{"op":"move","v":4,"from":[5,5],"to":[6.5,4]},{"op":"edge","a":0,"b":2},{"op":"add","at":[5,0],"onEdge":[0,1]},{"op":"remove","v":3}]}
224
+ ```
225
+
226
+ - **Space.** Every position is a setup-pose crop pixel of the rig canvas, x right, y down: the attachment's stored vertices in the compiled model document (`skeleton.model.json`), placed through the setup-pose bones and mapped by the model stage (`world = (x − width/2, height − y)`, root at the world origin). `basis` is always `"setup"`; the producer refuses any other basis (`REMESH_DELTA_BASIS`). A package without the model document has no setup mesh: Edit Mesh refuses `MESH_EDIT_MODEL_UNAVAILABLE` and exports nothing.
227
+ - **Source.** `vertexCount`, `triangleCount` and `hullLength` are the stored entry's (`uvs`/`vertices`, `triangles`, `hull`; the first `hull` vertices, in order, are the outline). `hash` is the SHA-256 of `JSON.stringify` of the attachment's entry (`skins[name].attachments.<slot>.<attachment>`) in the hash-verified model document. The producer holds both to the input part's own (`REMESH_DELTA_SOURCE`) and requires `skin` `"default"` with `slot` and `attachment` the part's name. A linked mesh is not editable (edit its source).
228
+ - **Ops**, in order, at most 300: `move {v, from, to}` — `from` is the vertex's exact current position, unrounded (the producer refuses a `from` more than 1e-3 px off), `to` rounded to 0.01 px; `add {at, onEdge?}` — the next id, splitting edge `onEdge` when given; `remove {v}` — the vertex and its triangulation edges, never a vertex that ends a constraint edge; `edge {a, b}` — a constraint edge; `unedge {a, b}` — only a constraint edge this delta added (hull and triangulation edges are the producer's; review refuses `MESH_EDIT_HULL_EDGE`, `MESH_EDIT_TRIANGULATION_EDGE` and `MESH_EDIT_CONSTRAINT_END` before recording). Vertex ids are the attachment's indices; an added vertex takes the next id; a removed id is never reused. Each op carries exactly these keys.
229
+ - `units` and `preview` are text for a reader; nothing reads them.
230
+
231
+ `mesh-edit.binding.json` (`rig-review-mesh-edit-binding/1`) binds the delta: `file {name, sha256}`, the package source hash, the model and motion SHA-256, `attachment {slot, skin, name, sha256}` (the same `hash`), `exportedAt`, the viewer and runtime versions, `mapping` (the crop mapping's basis) and an evidence note. Apply · Rebuild (#135) sends the same document as `{part, delta, configHash}` (bounded to 64 KiB, same-origin); the server writes it with the download's bytes and runs `rig-parts remesh --delta mesh-edit.json --part <part>` over the handoff rig (`@rig-c/parts` 1.43.0 or later, else `MODELING_PRODUCER_TOO_OLD <found> 1.43.0`). The producer computes `source.hash` from its own rigc build of the input and refuses a mismatch (`REMESH_DELTA_SOURCE`), a non-setup basis (`REMESH_DELTA_BASIS`) and an op that does not replay (`REMESH_DELTA_OP`); each line is shown verbatim. A built revision's `context.applied` is `rig-review-applied/3`: `{schema, buildId, part, delta, deltaSha256, configHash, producer, compiler, line, op: "remesh", files, mesh?, modelHash}`. `deltaSha256` is the SHA-256 of the bytes written, which equal the download's `mesh-edit.json`.
232
+
120
233
  ## Historical feedback retrieval
121
234
 
122
235
  `feedback` and `feedback compare` first return the exact current-tool stored session, including an explicitly saved pending decision. A corrupt current record is refused rather than replaced with an older approval. Current-file reads use an explicit present/missing result, and the CLI rechecks current state before returning history so a concurrently saved pending review wins without relying on its timestamp. If the current file is absent, these CLI commands search prior saved submissions for the same immutable content. HTTP session reads and all saves retain their existing strict current-tool binding; history is not approval migration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -0,0 +1,285 @@
1
+ /**
2
+ * What a feedback record's `context.applied` holds: the exact last applied state that was on the stage when the note was
3
+ * written, or null. `rig-review-applied/1` is #99's region designation; `rig-review-applied/2` (#102) is a config patch
4
+ * with its hash, the producer's line, the scratch `check.json` summary and the structural diff against the inspected
5
+ * rig.json. A remesh patch from the Mesh panel (#106) adds `mesh`: the part and its vertex / triangle / hull counts before
6
+ * and after, as the revision's `remesh.json` states them. #125 adds `files`: the diff of every document the producer
7
+ * writes (`src/producer-files.ts`), per file, and its images by hash; records without it (older ones) stay valid. #136
8
+ * adds `op`: the producer op that ran (`rig`, `retune` or `remesh`); records without it stay valid.
9
+ * `rig-review-applied/3` (#135) is Edit Mesh's delta, run by `rig-parts remesh --delta`: the `rig-review-mesh-edit/1`
10
+ * document as sent (by content) and the SHA-256 of its bytes (`deltaSha256`, the download's file hash), the part, the
11
+ * producer's line, `op` "remesh", the per-file diff and the part's counts.
12
+ * `rig-review-applied/4` (#114) is a retarget: the `rig-review-retarget/1` record (the sequence by reference, path and
13
+ * hash; the map by content and hash; the typed flags and their argv in `op`), the producer's line, what the scratch
14
+ * motion holds against the inspected rig's (`motion`), the per-file diff and the run's FEET / STANCE / FLIP / SHORT /
15
+ * END-ON / LEG-SCALE / REACH lines with their frames.
16
+ * Records without `applied`, or with null, stay valid. Pure.
17
+ */
18
+ import { CONFIG_PATCH_SCHEMA, parseConfigPatch, type ConfigPatch } from './config-patch.ts';
19
+ import { parseBustSpec } from './chest-landmarks.ts';
20
+ import { PRODUCER_DOCUMENTS, type ProducerDocument } from './producer-files.ts';
21
+ import { MESH_EDIT_BASIS, MESH_EDIT_MAX_OPS, MESH_EDIT_SCHEMA } from './mesh-edit.ts';
22
+ import { assertRetargetRecord, RETARGET_LINE_CODES, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
23
+
24
+ export const APPLIED_REGIONS_SCHEMA = 'rig-review-applied/1';
25
+ export const APPLIED_PATCH_SCHEMA = 'rig-review-applied/2';
26
+ export const APPLIED_DELTA_SCHEMA = 'rig-review-applied/3';
27
+ export const APPLIED_RETARGET_SCHEMA = 'rig-review-applied/4';
28
+ /** At most this many retarget lines are kept in a record. */
29
+ export const MAX_RETARGET_LINES = 200;
30
+ export const CHECK_SUMMARY_SCHEMA = 'rig-review-check-summary/1';
31
+ /** At most this many differing paths are listed; `count` always holds the full number. */
32
+ export const MAX_DIFF_PATHS = 200;
33
+
34
+ export interface CheckSummary {
35
+ schema: typeof CHECK_SUMMARY_SCHEMA;
36
+ /** check.json's PASS, or null when check wrote no check.json. */
37
+ pass: boolean | null;
38
+ exitCode: number;
39
+ /** check's last `check: …` line (or its first named refusal when it wrote nothing), verbatim. */
40
+ line: string | null;
41
+ /** Every bar check.json carries as `{status}`, by name. */
42
+ bars: Record<string, string>;
43
+ gate: boolean | null;
44
+ }
45
+ export interface Tool { package: string; version: string }
46
+ export interface AppliedPatch {
47
+ schema: typeof APPLIED_PATCH_SCHEMA;
48
+ buildId: string;
49
+ patch: ConfigPatch;
50
+ patchSha256: string;
51
+ configHash: string;
52
+ producer: Tool; compiler: Tool;
53
+ line: string | null;
54
+ check: CheckSummary | null;
55
+ /** rig.json's diff (#102), kept as it was for older readers; `files` carries it too. */
56
+ diff: { count: number; paths: string[] } | null;
57
+ modelHash: string;
58
+ /** #106, a remesh patch only: `remesh.json`'s part counts. */
59
+ mesh?: { part: string; before: MeshCounts; after: MeshCounts };
60
+ /** #125: every producer document's diff, per file, and the images by hash. Absent in older records. */
61
+ files?: FilesDiff | null;
62
+ /** #136: the producer op that ran — `retune`, `rig` or (#115) `build` for a `rig` patch, `remesh` for a remesh patch. Absent in older records. */
63
+ op?: 'rig' | 'retune' | 'remesh' | 'build';
64
+ /** #115, a `build` op only: the build's wall clock, the recomposite figures before and after, the parts it changed and its `seam pair` lines. */
65
+ assembled?: AppliedAssembled;
66
+ }
67
+ /** #115: what a `rig-parts build` Apply measured, recorded per Apply. `wallMs` is the build's own wall clock (assemble → rig → check). */
68
+ export interface AppliedAssembled { wallMs: number | null; recomposite: { before: Record<string, number> | null; after: Record<string, number> | null }; partsChanged: string[]; seamPairs: string[] }
69
+ export const MAX_SEAM_PAIR_LINES = 64;
70
+ const RECOMPOSITE_KEYS = 'error_px,hole_count,mean_abs,uncovered_error_px,within_share';
71
+ function parseAssembled(v: unknown, field: string): void {
72
+ const a = exact(v, 'partsChanged,recomposite,seamPairs,wallMs', field);
73
+ if (a.wallMs !== null && !count(a.wallMs)) throw new Error(`${field}.wallMs: whole ms or null`);
74
+ const r = exact(a.recomposite, 'after,before', `${field}.recomposite`);
75
+ for (const side of ['before', 'after'] as const) if (r[side] !== null) { const f = exact(r[side], RECOMPOSITE_KEYS, `${field}.recomposite.${side}`); if (!Object.values(f).every(n => typeof n === 'number' && Number.isFinite(n))) throw new Error(`${field}.recomposite.${side}: numbers`); }
76
+ if (!Array.isArray(a.partsChanged) || a.partsChanged.length > MAX_DIFF_PATHS || !a.partsChanged.every(n => typeof n === 'string')) throw new Error(`${field}.partsChanged: at most ${MAX_DIFF_PATHS} part names`);
77
+ if (!Array.isArray(a.seamPairs) || a.seamPairs.length > MAX_SEAM_PAIR_LINES || !a.seamPairs.every(l => typeof l === 'string' && l.length <= 4000)) throw new Error(`${field}.seamPairs: at most ${MAX_SEAM_PAIR_LINES} lines`);
78
+ }
79
+ /** #135: Edit Mesh's delta as applied by `rig-parts remesh --delta`. */
80
+ export interface AppliedDelta {
81
+ schema: typeof APPLIED_DELTA_SCHEMA;
82
+ buildId: string;
83
+ part: string;
84
+ /** The `rig-review-mesh-edit/1` document as sent to the producer. */
85
+ delta: Record<string, unknown>;
86
+ /** SHA-256 of the delta's bytes as written (`meshEditText`): the binding's `file.sha256` for the same document. */
87
+ deltaSha256: string;
88
+ configHash: string;
89
+ producer: Tool; compiler: Tool;
90
+ line: string | null;
91
+ op: 'remesh';
92
+ files: FilesDiff | null;
93
+ mesh?: { part: string; before: MeshCounts; after: MeshCounts };
94
+ modelHash: string;
95
+ }
96
+ /** #114: a retarget as applied by `rig-pose clean` + `rig-pose retarget`, compiled in scratch. */
97
+ export interface AppliedRetarget {
98
+ schema: typeof APPLIED_RETARGET_SCHEMA;
99
+ buildId: string;
100
+ retarget: RetargetRecord;
101
+ /** SHA-256 of the record's bytes as review hashes them (`retargetRecordText`). */
102
+ recordSha256: string;
103
+ configHash: string;
104
+ producer: Tool; compiler: Tool;
105
+ line: string | null;
106
+ motion: MotionSummary;
107
+ files: FilesDiff | null;
108
+ readings: RetargetLine[];
109
+ modelHash: string;
110
+ }
111
+ /** One producer document against the inspected one: `count` differing JSON Pointers, the first `MAX_DIFF_PATHS` listed. */
112
+ export interface DocumentDiff { file: ProducerDocument; count: number; paths: string[] }
113
+ /** #125: the documents in `PRODUCER_DOCUMENTS` order (a document neither side has is left out) and the images by hash. */
114
+ export interface FilesDiff { documents: DocumentDiff[]; images: { changed: number; unchanged: number; names: string[] } }
115
+ const count = (v: unknown) => Number.isSafeInteger(v) && (v as number) >= 0;
116
+ function parseFilesDiff(v: unknown, field: string): void {
117
+ if (v === null) return;
118
+ const f = exact(v, 'documents,images', field);
119
+ if (!Array.isArray(f.documents)) throw new Error(`${field}.documents: expected [{file, count, paths}]`);
120
+ const seen = new Set<string>();
121
+ for (const [i, entry] of f.documents.entries()) {
122
+ const d = exact(entry, 'count,file,paths', `${field}.documents[${i}]`);
123
+ if (typeof d.file !== 'string' || !(PRODUCER_DOCUMENTS as readonly string[]).includes(d.file) || seen.has(d.file)) throw new Error(`${field}.documents[${i}].file: one of ${PRODUCER_DOCUMENTS.join(', ')}, each once`);
124
+ seen.add(d.file);
125
+ if (!count(d.count) || !Array.isArray(d.paths) || d.paths.length > MAX_DIFF_PATHS || d.paths.length > (d.count as number) || !d.paths.every(p => typeof p === 'string')) throw new Error(`${field}.documents[${i}]: {count, paths ≤ ${MAX_DIFF_PATHS}}`);
126
+ }
127
+ const m = exact(f.images, 'changed,names,unchanged', `${field}.images`);
128
+ if (!count(m.changed) || !count(m.unchanged) || !Array.isArray(m.names) || m.names.length > MAX_DIFF_PATHS || m.names.length > (m.changed as number) || !m.names.every(n => typeof n === 'string')) throw new Error(`${field}.images: {changed, unchanged, names ≤ ${MAX_DIFF_PATHS}}`);
129
+ }
130
+ /**
131
+ * The diff in one line, e.g. `motion.json differs at 2 path(s) · rig.json, sets.json, mesh_report.json unchanged · 31
132
+ * images unchanged`: each differing document by name, then the unchanged ones, then the images.
133
+ */
134
+ export function filesDiffText(files: FilesDiff): string {
135
+ const differs = files.documents.filter(d => d.count > 0).map(d => `${d.file} differs at ${d.count} path(s)`);
136
+ const same = files.documents.filter(d => d.count === 0).map(d => d.file);
137
+ const { changed, unchanged } = files.images;
138
+ const images = changed ? `${changed} image(s) changed, ${unchanged} unchanged` : `${unchanged} images unchanged`;
139
+ return [...differs, ...(same.length ? [`${same.join(', ')} unchanged`] : []), images].join(' · ');
140
+ }
141
+ export interface MeshCounts { vertices: number; triangles: number; hull: number }
142
+ const meshCounts = (v: unknown, field: string) => {
143
+ const c = exact(v, 'hull,triangles,vertices', field);
144
+ if (![c.vertices, c.triangles, c.hull].every(n => Number.isSafeInteger(n) && (n as number) >= 0)) throw new Error(`${field}: vertices, triangles and hull are whole numbers`);
145
+ };
146
+
147
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
148
+ const hex = (v: unknown) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
149
+ const exact = (v: unknown, keys: string, field: string): Record<string, unknown> => {
150
+ if (!isObject(v) || Object.keys(v).sort().join(',') !== keys) throw new Error(`${field}: expected exactly {${keys.split(',').join(', ')}}`);
151
+ return v;
152
+ };
153
+ const tool = (v: unknown, field: string) => { const t = exact(v, 'package,version', field); if (typeof t.package !== 'string' || typeof t.version !== 'string') throw new Error(`${field}: package and version are strings`); };
154
+ const lineOrNull = (v: unknown, field: string) => { if (v !== null && (typeof v !== 'string' || v.length > 4000)) throw new Error(`${field}: expected the producer's line or null`); };
155
+
156
+ /** check's summary from what it wrote and printed. */
157
+ export function checkSummary(checkJson: unknown, exitCode: number, lines: readonly string[], refusal: string | null): CheckSummary {
158
+ const bars: Record<string, string> = {};
159
+ const doc = isObject(checkJson) ? checkJson : null;
160
+ if (doc) for (const [name, value] of Object.entries(doc)) if (/^[A-Z][A-Z0-9_]+$/.test(name) && isObject(value) && typeof value.status === 'string') bars[name] = value.status;
161
+ const last = [...lines].reverse().find(l => /^check:\s/.test(l.trim()))?.trim() ?? null;
162
+ return { schema: CHECK_SUMMARY_SCHEMA, pass: doc && typeof doc.PASS === 'boolean' ? doc.PASS : null, exitCode, line: last ?? refusal, bars, gate: doc && typeof doc.gate_spine_html_green === 'boolean' ? doc.gate_spine_html_green : null };
163
+ }
164
+
165
+ function parseCheck(v: unknown, field: string): void {
166
+ if (v === null) return;
167
+ const c = exact(v, 'bars,exitCode,gate,line,pass,schema', field);
168
+ if (c.schema !== CHECK_SUMMARY_SCHEMA) throw new Error(`${field}.schema: expected ${CHECK_SUMMARY_SCHEMA}`);
169
+ if (c.pass !== null && typeof c.pass !== 'boolean') throw new Error(`${field}.pass: boolean or null`);
170
+ if (c.gate !== null && typeof c.gate !== 'boolean') throw new Error(`${field}.gate: boolean or null`);
171
+ if (!Number.isSafeInteger(c.exitCode)) throw new Error(`${field}.exitCode: integer`);
172
+ lineOrNull(c.line, `${field}.line`);
173
+ if (!isObject(c.bars) || !Object.values(c.bars).every(s => typeof s === 'string')) throw new Error(`${field}.bars: {name: status}`);
174
+ }
175
+
176
+ /** Null, #99's region record or #102's patch record, each exactly its shape. */
177
+ export function parseAppliedContext(value: unknown): void {
178
+ if (value === null) return;
179
+ if (!isObject(value)) throw new Error('feedback.context.applied: expected an applied record or null');
180
+ const field = 'feedback.context.applied';
181
+ if (value.schema === APPLIED_REGIONS_SCHEMA) {
182
+ // #105: a landmarks Apply adds `bust` (the contour.bust entry as sent); its `regions` are review's derived circles.
183
+ const v = exact(value, Object.hasOwn(value, 'bust') ? 'buildId,bust,compiler,configHash,entrySha256,line,modelHash,part,producer,regions,schema' : 'buildId,compiler,configHash,entrySha256,line,modelHash,part,producer,regions,schema', field);
184
+ if (Object.hasOwn(v, 'bust')) { try { parseBustSpec(v.bust, `${field}.bust`); } catch (e) { throw new Error(e instanceof Error ? e.message : String(e)); } }
185
+ if (![v.buildId, v.configHash, v.entrySha256, v.modelHash].every(hex)) throw new Error(`${field}: buildId, configHash, entrySha256 and modelHash are SHA-256 hex`);
186
+ if (typeof v.part !== 'string' || !Array.isArray(v.regions)) throw new Error(`${field}: part and regions`);
187
+ tool(v.producer, `${field}.producer`); tool(v.compiler, `${field}.compiler`); lineOrNull(v.line, `${field}.line`);
188
+ return;
189
+ }
190
+ if (value.schema === APPLIED_PATCH_SCHEMA) {
191
+ const v = exact(value, `${Object.hasOwn(value, 'assembled') ? 'assembled,' : ''}buildId,check,compiler,configHash,diff,${Object.hasOwn(value, 'files') ? 'files,' : ''}line,${Object.hasOwn(value, 'mesh') ? 'mesh,' : ''}modelHash,${Object.hasOwn(value, 'op') ? 'op,' : ''}patch,patchSha256,producer,schema`, field);
192
+ if (![v.buildId, v.configHash, v.patchSha256, v.modelHash].every(hex)) throw new Error(`${field}: buildId, configHash, patchSha256 and modelHash are SHA-256 hex`);
193
+ let patch: ConfigPatch;
194
+ try { patch = parseConfigPatch(v.patch); } catch (e) { throw new Error(`${field}.patch: ${e instanceof Error ? e.message : String(e)}`); }
195
+ if (patch.config_sha256 !== v.configHash) throw new Error(`${field}: patch.config_sha256 is not configHash`);
196
+ tool(v.producer, `${field}.producer`); tool(v.compiler, `${field}.compiler`); lineOrNull(v.line, `${field}.line`);
197
+ parseCheck(v.check, `${field}.check`);
198
+ if (Object.hasOwn(v, 'mesh')) {
199
+ const m = exact(v.mesh, 'after,before,part', `${field}.mesh`);
200
+ if (patch.op.cmd !== 'remesh' || patch.op.args[patch.op.args.indexOf('--part') + 1] !== m.part) throw new Error(`${field}.mesh.part: the part the patch's remesh names`);
201
+ meshCounts(m.before, `${field}.mesh.before`); meshCounts(m.after, `${field}.mesh.after`);
202
+ }
203
+ if (Object.hasOwn(v, 'files')) parseFilesDiff(v.files, `${field}.files`);
204
+ // #136: a remesh patch ran remesh; a rig patch ran rig, retune or (#115) build.
205
+ if (Object.hasOwn(v, 'op') && !(patch.op.cmd === 'remesh' ? ['remesh'] : ['rig', 'retune', 'build']).includes(v.op as string)) throw new Error(`${field}.op: ${patch.op.cmd === 'remesh' ? '"remesh" for a remesh patch' : '"rig", "retune" or "build" for a rig patch'}`);
206
+ // #115: the build's measurements ride with a build op only, and a build op carries them.
207
+ if (Object.hasOwn(v, 'assembled') !== (v.op === 'build')) throw new Error(`${field}.assembled: present exactly when op is "build"`);
208
+ if (Object.hasOwn(v, 'assembled')) parseAssembled(v.assembled, `${field}.assembled`);
209
+ if (v.diff !== null) {
210
+ const d = exact(v.diff, 'count,paths', `${field}.diff`);
211
+ if (!Number.isSafeInteger(d.count) || (d.count as number) < 0 || !Array.isArray(d.paths) || d.paths.length > MAX_DIFF_PATHS || d.paths.length > (d.count as number) || !d.paths.every(p => typeof p === 'string')) throw new Error(`${field}.diff: {count, paths ≤ ${MAX_DIFF_PATHS}}`);
212
+ }
213
+ return;
214
+ }
215
+ if (value.schema === APPLIED_DELTA_SCHEMA) {
216
+ const v = exact(value, `buildId,compiler,configHash,delta,deltaSha256,files,line,${Object.hasOwn(value, 'mesh') ? 'mesh,' : ''}modelHash,op,part,producer,schema`, field);
217
+ if (![v.buildId, v.configHash, v.deltaSha256, v.modelHash].every(hex)) throw new Error(`${field}: buildId, configHash, deltaSha256 and modelHash are SHA-256 hex`);
218
+ if (typeof v.part !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(v.part)) throw new Error(`${field}.part: the part remesh --delta ran on`);
219
+ const d = v.delta;
220
+ if (!isObject(d) || d.schema !== MESH_EDIT_SCHEMA || d.basis !== MESH_EDIT_BASIS || d.slot !== v.part || !isObject(d.source) || !hex(d.source.hash) || !Array.isArray(d.ops) || d.ops.length > MESH_EDIT_MAX_OPS) throw new Error(`${field}.delta: a ${MESH_EDIT_SCHEMA} document (basis "${MESH_EDIT_BASIS}", slot the part, source.hash, at most ${MESH_EDIT_MAX_OPS} ops)`);
221
+ if (v.op !== 'remesh') throw new Error(`${field}.op: "remesh" for a mesh-edit delta`);
222
+ tool(v.producer, `${field}.producer`); tool(v.compiler, `${field}.compiler`); lineOrNull(v.line, `${field}.line`);
223
+ parseFilesDiff(v.files, `${field}.files`);
224
+ if (Object.hasOwn(v, 'mesh')) {
225
+ const m = exact(v.mesh, 'after,before,part', `${field}.mesh`);
226
+ if (m.part !== v.part) throw new Error(`${field}.mesh.part: the delta's part`);
227
+ meshCounts(m.before, `${field}.mesh.before`); meshCounts(m.after, `${field}.mesh.after`);
228
+ }
229
+ return;
230
+ }
231
+ if (value.schema === APPLIED_RETARGET_SCHEMA) {
232
+ const v = exact(value, 'buildId,compiler,configHash,files,line,modelHash,motion,producer,readings,recordSha256,retarget,schema', field);
233
+ if (![v.buildId, v.configHash, v.recordSha256, v.modelHash].every(hex)) throw new Error(`${field}: buildId, configHash, recordSha256 and modelHash are SHA-256 hex`);
234
+ const record = assertRetargetRecord(v.retarget, `${field}.retarget`);
235
+ if (record.config_sha256 !== v.configHash) throw new Error(`${field}: retarget.config_sha256 is not configHash`);
236
+ tool(v.producer, `${field}.producer`); tool(v.compiler, `${field}.compiler`); lineOrNull(v.line, `${field}.line`);
237
+ const m = exact(v.motion, 'animation,animationSha256,animations,changed,kept,motionSha256', `${field}.motion`);
238
+ if (m.animation !== record.flags.animation || typeof m.kept !== 'boolean' || !hex(m.motionSha256) || (m.animationSha256 !== null && !hex(m.animationSha256)) || !Array.isArray(m.animations) || !m.animations.every(a => typeof a === 'string') || !Array.isArray(m.changed) || !m.changed.every(a => typeof a === 'string')) throw new Error(`${field}.motion: {animation (the record's), animations, kept, changed, animationSha256, motionSha256}`);
239
+ parseFilesDiff(v.files, `${field}.files`);
240
+ if (!Array.isArray(v.readings) || v.readings.length > MAX_RETARGET_LINES) throw new Error(`${field}.readings: at most ${MAX_RETARGET_LINES} lines`);
241
+ for (const [i, r] of v.readings.entries()) {
242
+ const x = exact(r, 'code,frame,line,time', `${field}.readings[${i}]`);
243
+ if (!(RETARGET_LINE_CODES as readonly string[]).includes(x.code as string) || typeof x.line !== 'string' || x.line.length > 4000 || (x.frame !== null && !count(x.frame)) || (x.time !== null && (typeof x.time !== 'number' || !Number.isFinite(x.time)))) throw new Error(`${field}.readings[${i}]: {code, line, frame, time}`);
244
+ }
245
+ return;
246
+ }
247
+ throw new Error(`${field}.schema: expected ${APPLIED_REGIONS_SCHEMA}, ${APPLIED_PATCH_SCHEMA} (patch ${CONFIG_PATCH_SCHEMA}), ${APPLIED_DELTA_SCHEMA} (mesh-edit delta) or ${APPLIED_RETARGET_SCHEMA} (retarget)`);
248
+ }
249
+
250
+ /** The export's one readable line for a valid applied record; the raw context still follows it. */
251
+ export function appliedSummary(value: unknown): string | null {
252
+ try { parseAppliedContext(value); } catch { return null; }
253
+ if (value === null) return 'the inspected build (nothing applied)';
254
+ const v = value as Record<string, unknown>, producer = v.producer as Tool, model = String(v.modelHash).slice(0, 12);
255
+ if (v.schema === APPLIED_REGIONS_SCHEMA && v.bust) return `contour.bust landmarks on ${String(v.part)} with ${producer.package} ${producer.version} · model ${model}…`;
256
+ if (v.schema === APPLIED_REGIONS_SCHEMA) return `regions on ${String(v.part)} with ${producer.package} ${producer.version} · model ${model}…`;
257
+ if (v.schema === APPLIED_RETARGET_SCHEMA) {
258
+ const r = value as AppliedRetarget, seq = r.retarget.sequence;
259
+ return `retarget "${r.motion.animation}" from ${seq.reference} (${seq.sha256.slice(0, 12)}…) with map ${r.retarget.map_sha256.slice(0, 12)}… and ${producer.package} ${producer.version} (${r.retarget.op.clean ? 'clean, ' : ''}retarget ${r.retarget.op.args.join(' ')})${r.motion.kept ? '' : ` · changed ${r.motion.changed.join(', ')}`} · ${r.readings.length} line(s) · model ${model}…`;
260
+ }
261
+ if (v.schema === APPLIED_DELTA_SCHEMA) {
262
+ const d = value as AppliedDelta, ops = (d.delta.ops as unknown[]).length;
263
+ const mesh = d.mesh ? ` · ${d.mesh.part} v ${d.mesh.before.vertices} t ${d.mesh.before.triangles} → v ${d.mesh.after.vertices} t ${d.mesh.after.triangles}` : '';
264
+ return `mesh edit ${d.deltaSha256.slice(0, 12)}… (remesh --delta; ${ops} op(s) on ${d.part}) with ${producer.package} ${producer.version}${d.files ? ` · ${filesDiffText(d.files)}` : ''}${mesh} · model ${model}…`;
265
+ }
266
+ const a = value as AppliedPatch, paths = a.patch.ops.map(o => `${o.op} ${o.path}`);
267
+ const check = a.check ? `check ${a.check.pass === null ? 'not read' : a.check.pass ? 'PASS' : 'FAIL'}` : 'check not run';
268
+ const diff = a.files ? ` · ${filesDiffText(a.files)}` : a.diff ? ` · rig.json differs at ${a.diff.count} path(s)` : '';
269
+ const mesh = a.mesh ? ` · ${a.mesh.part} v ${a.mesh.before.vertices} t ${a.mesh.before.triangles} → v ${a.mesh.after.vertices} t ${a.mesh.after.triangles}` : '';
270
+ const assembled = a.assembled ? ` · build ${a.assembled.wallMs === null ? '—' : `${(a.assembled.wallMs / 1000).toFixed(1)} s`}${a.assembled.partsChanged.length ? ` · parts changed: ${a.assembled.partsChanged.slice(0, 6).join(', ')}${a.assembled.partsChanged.length > 6 ? ', …' : ''}` : ''}${a.assembled.recomposite.before && a.assembled.recomposite.after ? ` · recomposite error px ${a.assembled.recomposite.before.error_px} → ${a.assembled.recomposite.after.error_px}` : ''}` : '';
271
+ return `patch ${a.patchSha256.slice(0, 12)}… (${a.op ?? a.patch.op.cmd}; ${paths.slice(0, 3).join(', ')}${paths.length > 3 ? `, +${paths.length - 3}` : ''}) with ${producer.package} ${producer.version} · ${check}${diff}${mesh}${assembled} · model ${model}…`;
272
+ }
273
+
274
+ /**
275
+ * HQ ruling on #103: a scratch build whose `rig-parts check` FAILed may be loaded and looked at, but a review whose
276
+ * applied state failed check is not submitted. The refusal names check's FAIL line — the summary's `check: FAIL …` line,
277
+ * else the first bar check.json marks FAIL — verbatim. Null when the record is not a patch or its check did not FAIL.
278
+ */
279
+ export function appliedCheckRefusal(value: unknown): string | null {
280
+ if (!isObject(value) || value.schema !== APPLIED_PATCH_SCHEMA || !isObject(value.check) || value.check.pass !== false) return null;
281
+ const check = value.check as unknown as CheckSummary;
282
+ const bar = Object.entries(check.bars ?? {}).find(([, status]) => status === 'FAIL')?.[0];
283
+ const line = typeof check.line === 'string' && /\bFAIL\b/.test(check.line) ? check.line : bar ? `${bar}: FAIL` : `check exited ${check.exitCode}`;
284
+ return `EDIT_SUBMIT_UNCHECKED: applied build failed check — ${line}`;
285
+ }