wicked-garden 12.42.0 → 12.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.42.0"}], "version": "12.42.0"}
1
+ {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.43.0"}], "version": "12.43.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.42.0",
3
+ "version": "12.43.0",
4
4
  "description": "Your coding agent's harness already plans and swarms; Wicked Garden is the curated toolkit for what it can't do alone. Proof, not claims \u2014 gates re-derive 'done' through the internal loom engine (fail-closed, independently attested via wicked-vault), so a self-asserted pass can't lie its way green. Relationships grep can't see \u2014 the wicked-estate code graph + injected bus/dispatch/capability/archetype edges power blast-radius & lineage. Plus deterministic multi-file refactor (wicked-patch), cross-session memory + knowledge (the mem domain over wicked-estate), real multi-model second opinions (jam:council), portable expertise as on-demand skill-refs, and evidence-gated testing (the in-catalog qe domain). It reads the shape of your work to apply the right rigor \u2014 steering, not a pipeline \u2014 then gets out of the way. The compiler stamps a self-contained evidence gate into any repo that runs with no wicked-garden installed. Don't fight the harness; fill its gaps. The evidence gate requires wicked-vault, a self-contained infra peer wicked-garden installs directly; wicked-bus is an opt-in layer \u2014 install what you'll use, the toolkit works without the rest.",
5
5
  "wicked_vault_version": "^0.5.0",
6
6
  "wicked_ledger_version": "^0.4.0",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.43.0] — 2026-10-05
6
+
7
+ ### Fixed
8
+ - **Walkthrough tool: the proof root is what crew's walkthrough view and acceptance read (wicked-garden#1208).** `scripts/demo/walkthrough.mjs record` wrote only `{key, verdict, failed_at_sec}` per chapter, `progress.json` only at the end, its segments under `<root>/segments/`, no planned chapter list and no stitched take — so crew's view (`GET /runs/:id/walkthrough`, WT-W1) showed "Starting the app" for the whole recording, then a failed chapter by its key with no title, no check, no frame and nothing to watch. The tool now writes crew's contract: `chapters.json` (`{key, title, blurb, tags, resets}`) before the app starts; `progress.json` `{state: "starting_app"}` → `{state: "recording", chapter, index, total}` per chapter → `{state: "judging"}`; `result.json` with a top-level `tree` and per chapter `title`, `takes`, `failed_at_sec` (the earliest failing check's `at_sec`, else the take's `failure.json` — DES §4.5), `failed_frame` (the earliest failing on-screen check's frame, else one cut from the take at `failed_at_sec`), `proves`, `legs` and `checks[] {id, kind, sentence, passed, at_sec, evidence[] (root-relative paths), vault_entry, detail}`; the segments under `demo-video/segments/<key>/`, and the take stitched from each chapter's kept video (the good take, else the failed take the recorder kept) into `demo-video/demo.mp4` with `demo-video/chapters.md` markers and `demo-video/poster.jpg`. **The seal is now the form crew recomputes** (`walkthrough-acceptance.ts`, WT-W2): sha256 over `"<sha256 hex> <posix relative path>\n"` lines in UTF-8 byte order — the tool hashed `"<path>:<sha>\n"` in locale order, so every live take would have been denied as "walkthrough evidence changed after it was sealed". Like crew, the seal refuses a root holding a link or a non-regular entry outside `app/`/`data/`, or a file it cannot read: `record` then writes `INCONCLUSIVE` with `cause: unsealable_root` and prints the seal line with `bundle_sha: null` (crew reads it as not sealed and denies); `seal --root` exits 1 and says why. A chapter whose take failed has its kept video published at `demo-video/segments/<key>/segment.mp4` too (a copy; `guard.json` and `failed-<take>/failure.json` still say it failed), since that is the one path crew reads a chapter as recorded from. A segment key outside `^[a-z0-9][a-z0-9-]*$` (what `record.mjs` and crew accept) is refused as `storyline_refused`, naming the key, instead of being dropped by the view silently. `record.mjs` exports `stitchParts` (the one stitch, also used by its own `--stitch`) and `extractFrame`. Tests: `tests/demo/test_walkthrough.py` (the seal against a Python recompute of crew's form, `chapters.json`, the running progress read mid-take, the key rule, and a PASS+FAIL recording checked field by field).
9
+ - **`_estate_client.py` keeps estate's diagnostics block (wicked-garden#1211).** The shim read `content[0]` of an MCP result only; estate's RetrievalTools put `STALENESS:` (and since estate#242 `CLAMPED:`, plus `LEXICAL-FALLBACK:`) in the second text block, so every skill going through the shim could not tell a stale or clamped answer from a fresh, complete one. `_unwrap_blocks(envelope)` returns `(payload, diagnostics_lines)`, `call_blocks(tool, arguments)` returns `{"result", "diagnostics"}`, and the CLI's `call` action — the command every skill names — prints `{"result": …, "diagnostics": [...]}` so the seat reading stdout sees the marker (additive: `result` is where it always was; a consumer that never reads `diagnostics` sees no change). `call`/`_unwrap` keep their one-value contract. The search skill and its hotspots ref say where the marker now appears. The governed read-verb allowlist the skill lint mirrors (`tests/test_skill_governed_grounding.py`) is unchanged: wicked-core's `ESTATE_READ_VERBS` still lacks `lineage`/`traverse`/`hotspots` (wicked-core#729 open), and garden must not coach a verb the fence denies.
10
+
11
+ ### Changed
12
+ - **Domain vendor: the schema bundle `VERSION` follows the owner to 1.2.1.** wicked-core#699 bumped its governance schema bundle to 1.2.1 (conformance-rules 1.2.0 declares the DC-S1 project-rule fields it already documented). `skills/domain/vendor/VERSION` now matches, so `tests/domain/test_schema_vendor_pin.py` passes again against a current wicked-core checkout. `domain-model.schema.json` is byte-unchanged, and documents still carry `schema_version: "1.0.0"`.
13
+
5
14
  ## [12.42.0] — 2026-10-03
6
15
 
7
16
  ### Changed
@@ -971,7 +980,8 @@ through `v9.2.18`. Major themes across that span:
971
980
 
972
981
  For each of those releases, see the corresponding tag in git history.
973
982
 
974
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.42.0...HEAD
983
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.43.0...HEAD
984
+ [12.43.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.42.0...v12.43.0
975
985
  [12.42.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.41.0...v12.42.0
976
986
  [12.41.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.40.0...v12.41.0
977
987
  [12.40.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.39.0...v12.40.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.42.0",
3
+ "version": "12.43.0",
4
4
  "type": "module",
5
5
  "description": "Curated toolkit for what AI coding agents can't do alone: evidence-gated work, codegraph relationships, deterministic multi-file refactor, multi-model second opinions, and cross-session memory. Installs as a Claude Code plugin.",
6
6
  "keywords": [
@@ -727,31 +727,51 @@ def _rpc(method: str, params: dict, *, timeout: float) -> Optional[dict]:
727
727
  return responses.get(2)
728
728
 
729
729
 
730
- def _unwrap(envelope: Optional[dict]) -> Optional[Any]:
731
- """Extract a tool's payload from an MCP tools/call envelope.
732
-
733
- Estate wraps results as {"result": {"content": [{"type":"text","text": …}],
734
- "isError": bool}}. The text is usually the tool's own JSON string, which is
735
- parsed and returned; a tool that emits plain (non-JSON) text gets its text
736
- handed back verbatim as a str. Returns None on error / isError=true /
737
- a malformed envelope shape.
730
+ def _unwrap_blocks(envelope: Optional[dict]) -> "tuple[Optional[Any], list[str]]":
731
+ """Extract a tool's payload AND its diagnostics from an MCP tools/call envelope.
732
+
733
+ Estate wraps results as {"result": {"content": [{"type":"text","text": …}, …],
734
+ "isError": bool}}. `content[0]` is the tool's own JSON string, which is parsed
735
+ and returned (a tool that emits plain, non-JSON text gets its text handed back
736
+ verbatim as a str). `content[1]`, when present, is the diagnostics block the
737
+ estate MCP server appends (`wicked-estate-mcp/src/lib.rs`, "Append diagnostics
738
+ as a second text block"): one line each — `STALENESS: …` (W7.4 freshness),
739
+ `CLAMPED: …` (estate#242, a bound the server lowered), `LEXICAL-FALLBACK: …`.
740
+ They are returned as a list of lines so a caller can tell a stale or clamped
741
+ answer from a fresh, complete one (wicked-garden#1211).
742
+
743
+ Returns (None, []) on error / isError=true / a malformed envelope shape.
738
744
  """
739
745
  if not isinstance(envelope, dict):
740
- return None
746
+ return None, []
741
747
  result = envelope.get("result")
742
748
  if not isinstance(result, dict) or result.get("isError"):
743
- return None
749
+ return None, []
744
750
  content = result.get("content")
745
751
  if not isinstance(content, list) or not content:
746
- return None
752
+ return None, []
747
753
  text = content[0].get("text") if isinstance(content[0], dict) else None
748
754
  if text is None:
749
- return None
755
+ return None, []
750
756
  try:
751
- return json.loads(text)
757
+ payload: Any = json.loads(text)
752
758
  except (json.JSONDecodeError, TypeError):
753
759
  # Some tools may return plain text; hand it back verbatim.
754
- return text
760
+ payload = text
761
+ diagnostics: list[str] = []
762
+ for block in content[1:]:
763
+ more = block.get("text") if isinstance(block, dict) else None
764
+ if isinstance(more, str):
765
+ diagnostics.extend(line for line in more.split("\n") if line.strip())
766
+ return payload, diagnostics
767
+
768
+
769
+ def _unwrap(envelope: Optional[dict]) -> Optional[Any]:
770
+ """The payload alone (`content[0]` parsed) — see `_unwrap_blocks` for both blocks.
771
+
772
+ Returns None on error / isError=true / a malformed envelope shape.
773
+ """
774
+ return _unwrap_blocks(envelope)[0]
755
775
 
756
776
 
757
777
  # ─────────────────────────────────────────────────────────────────────────────
@@ -791,6 +811,24 @@ def call(tool: str, arguments: Optional[dict] = None, timeout: float = 8.0) -> O
791
811
  return None
792
812
 
793
813
 
814
+ def call_blocks(tool: str, arguments: Optional[dict] = None, timeout: float = 8.0) -> Optional[dict]:
815
+ """Like `call`, but keep the diagnostics block (wicked-garden#1211).
816
+
817
+ Returns {"result": <parsed payload>, "diagnostics": [<line>, …]} — the
818
+ `STALENESS:` / `CLAMPED:` / `LEXICAL-FALLBACK:` lines estate rides in the
819
+ second content block — or None when estate is unreachable or the tool
820
+ errored (the same fail-open shape as `call`).
821
+ """
822
+ try:
823
+ env = _rpc("tools/call", {"name": tool, "arguments": arguments or {}}, timeout=timeout)
824
+ except Exception:
825
+ return None
826
+ payload, diagnostics = _unwrap_blocks(env)
827
+ if payload is None and not diagnostics:
828
+ return None
829
+ return {"result": payload, "diagnostics": diagnostics}
830
+
831
+
794
832
  def call_raw(tool: str, arguments: Optional[dict] = None, timeout: float = 8.0) -> Optional[dict]:
795
833
  """Like `call`, but return the full MCP envelope (for callers that need
796
834
  `isError` / staleness diagnostics). Fail-open (None)."""
@@ -1053,7 +1091,11 @@ def main(argv: list) -> int:
1053
1091
  if not tool:
1054
1092
  _emit({"error": "call requires {\"tool\": \"<ToolName>\", \"arguments\": {...}}"})
1055
1093
  return 1
1056
- _emit({"result": call(tool, args.get("arguments", {}))})
1094
+ # Both blocks, in-band (wicked-garden#1211): the seat reading stdout sees the
1095
+ # `STALENESS:` / `CLAMPED:` lines beside the payload, as the MCP caller does.
1096
+ blocks = call_blocks(tool, args.get("arguments", {}))
1097
+ _emit({"result": blocks["result"] if blocks else None,
1098
+ "diagnostics": blocks["diagnostics"] if blocks else []})
1057
1099
  elif action == "propose":
1058
1100
  kind_type = args.get("kind_type")
1059
1101
  payload = args.get("payload")
@@ -79,6 +79,49 @@ export function failedAtInVideo(dir, failedAt, trimStart = SEGMENT_TRIM_START) {
79
79
  }
80
80
  }
81
81
 
82
+ // ---- exported stitch ---------------------------------------------------------------------------
83
+ /**
84
+ * Join segment videos (same codec, size and frame rate — every take is cut by buildVideo) into one MP4 with
85
+ * chapter metadata, without re-encoding. `parts` = [{ title, file }] in take order; `workDir` holds the two
86
+ * ffmpeg list files. Returns the `| m:ss | Title |` marker rows (chapters.md) and the total seconds.
87
+ */
88
+ export function stitchParts(parts, outFile, { ffmpeg = "ffmpeg", title = "Demo", workDir }) {
89
+ const ffprobe = ffprobeOf(ffmpeg);
90
+ const probeSeconds = (file) => {
91
+ const r = spawnSync(ffprobe, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file], { encoding: "utf8" });
92
+ if (r.status !== 0) throw new Error(`ffprobe failed on ${file}: ${r.stderr}`);
93
+ return Number(r.stdout.trim());
94
+ };
95
+ if (!parts.length) throw new Error("no segments recorded yet");
96
+ fs.mkdirSync(workDir, { recursive: true });
97
+ const list = path.join(workDir, "concat.txt");
98
+ fs.writeFileSync(list, parts.map((p) => `file '${p.file.replace(/\\/g, "/").replace(/'/g, "'\\''")}'`).join("\n") + "\n");
99
+ let at = 0;
100
+ const meta = [";FFMETADATA1", `title=${String(title).replace(/[=;#\\\n]/g, " ")}`];
101
+ const rows = ["| Time | Chapter |", "|---|---|"];
102
+ for (const p of parts) {
103
+ const d = probeSeconds(p.file);
104
+ meta.push("[CHAPTER]", "TIMEBASE=1/1000", `START=${Math.round(at * 1000)}`, `END=${Math.round((at + d) * 1000)}`, `title=${String(p.title).replace(/[=;#\\\n]/g, " ")}`);
105
+ rows.push(`| ${Math.floor(at / 60)}:${String(Math.floor(at % 60)).padStart(2, "0")} | ${p.title} |`);
106
+ at += d;
107
+ }
108
+ const metaFile = path.join(workDir, "chapters.ffmeta");
109
+ fs.writeFileSync(metaFile, meta.join("\n") + "\n");
110
+ const r = spawnSync(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-i", metaFile, "-map", "0", "-map_metadata", "1",
111
+ "-map_chapters", "1", "-map", "-0:d", "-c", "copy", "-movflags", "+faststart", outFile], { stdio: ["ignore", "ignore", "pipe"] });
112
+ if (r.status !== 0) throw new Error(`stitch failed: ${r.stderr?.toString().slice(-1500)}`);
113
+ return { seconds: at, rows };
114
+ }
115
+
116
+ /** One frame of a video as a JPEG (`atSec` into it); false when ffmpeg could not decode one there. */
117
+ export function extractFrame(video, atSec, outFile, { ffmpeg = "ffmpeg" } = {}) {
118
+ const r = spawnSync(ffmpeg, ["-y", "-ss", String(Math.max(0, atSec)), "-i", video, "-frames:v", "1", "-q:v", "3", outFile],
119
+ { stdio: ["ignore", "ignore", "pipe"] });
120
+ const ok = r.status === 0 && fs.existsSync(outFile) && fs.statSync(outFile).size > 0;
121
+ if (!ok) fs.rmSync(outFile, { force: true });
122
+ return ok;
123
+ }
124
+
82
125
  // ---- exported recorder factory -----------------------------------------------------------------
83
126
  /**
84
127
  * Build a reusable per-segment recorder bound to a story context.
@@ -241,7 +284,6 @@ export function makeSegmentRecorder({
241
284
  // ---- CLI entry point (only runs when invoked directly) -----------------------------------------
242
285
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
243
286
  const FFMPEG = process.env.FFMPEG || "ffmpeg";
244
- const FFPROBE = ffprobeOf(FFMPEG);
245
287
 
246
288
  function usage(msg) {
247
289
  if (msg) console.error(`error: ${msg}\n`);
@@ -302,12 +344,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
302
344
  const readTimings = (key) => { try { return JSON.parse(fs.readFileSync(path.join(segDir(key), "timings.json"), "utf8")); } catch { return {}; } };
303
345
 
304
346
  // ---- stitch ----
305
- function probeSeconds(file) {
306
- const r = spawnSync(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file], { encoding: "utf8" });
307
- if (r.status !== 0) throw new Error(`ffprobe failed on ${file}: ${r.stderr}`);
308
- return Number(r.stdout.trim());
309
- }
310
-
311
347
  /** A segment's guard.json (how its last take was recorded), or null when it has none. */
312
348
  function readGuard(key) {
313
349
  try { return JSON.parse(fs.readFileSync(path.join(segDir(key), "guard.json"), "utf8")); } catch { return null; }
@@ -333,24 +369,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
333
369
  refuseFailedTakes();
334
370
  const segs = SEGMENTS.filter((s) => fs.existsSync(segVideo(s.key)));
335
371
  const missing = SEGMENTS.filter((s) => !fs.existsSync(segVideo(s.key))).map((s) => s.key);
336
- if (!segs.length) throw new Error("no segments recorded yet");
337
- const list = path.join(SEG_DIR, "concat.txt");
338
- fs.writeFileSync(list, segs.map((s) => `file '${segVideo(s.key).replace(/\\/g, "/").replace(/'/g, "'\\''")}'`).join("\n") + "\n");
339
- let at = 0;
340
- const meta = [";FFMETADATA1", `title=${(story.title || "Demo").replace(/[=;#\\\n]/g, " ")}`];
341
- const rows = ["| Time | Chapter |", "|---|---|"];
342
- for (const s of segs) {
343
- const d = probeSeconds(segVideo(s.key));
344
- const title = s.intro ? "Introduction" : s.title;
345
- meta.push("[CHAPTER]", "TIMEBASE=1/1000", `START=${Math.round(at * 1000)}`, `END=${Math.round((at + d) * 1000)}`, `title=${title.replace(/[=;#\\\n]/g, " ")}`);
346
- rows.push(`| ${Math.floor(at / 60)}:${String(Math.floor(at % 60)).padStart(2, "0")} | ${title} |`);
347
- at += d;
348
- }
349
- fs.writeFileSync(path.join(SEG_DIR, "chapters.ffmeta"), meta.join("\n") + "\n");
350
- // Segments share codec, size and frame rate, so they join without re-encoding.
351
- const r = spawnSync(FFMPEG, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-i", path.join(SEG_DIR, "chapters.ffmeta"), "-map", "0", "-map_metadata", "1",
352
- "-map_chapters", "1", "-map", "-0:d", "-c", "copy", "-movflags", "+faststart", FINAL], { stdio: ["ignore", "ignore", "pipe"] });
353
- if (r.status !== 0) throw new Error(`stitch failed: ${r.stderr?.toString().slice(-1500)}`);
372
+ const { seconds: at, rows } = stitchParts(
373
+ segs.map((s) => ({ title: s.intro ? "Introduction" : s.title, file: segVideo(s.key) })),
374
+ FINAL, { ffmpeg: FFMPEG, title: story.title || "Demo", workDir: SEG_DIR },
375
+ );
354
376
  fs.writeFileSync(path.join(OUT, "chapters.md"), rows.join("\n") + "\n");
355
377
  fs.writeFileSync(path.join(OUT, "timings.json"), JSON.stringify(Object.fromEntries(SEGMENTS.map((s) => [s.key, readTimings(s.key)])), null, 2));
356
378
  // How the stitched segments were recorded: read-only only when every one of them was (a segment recorded
@@ -7,6 +7,23 @@
7
7
  // Required env for record: WICKED_EVIDENCE_ROOT, WICKED_RUN_ID, WICKED_RUN_UNIT, WICKED_TREE
8
8
  // Safety: WICKED_WALKTHROUGH_JAIL must be set; without it writes INCONCLUSIVE(unjailed_host) and exits 0.
9
9
  //
10
+ // What the proof root holds is ONE contract with crew's walkthrough view and acceptance
11
+ // (wicked-crew `packages/crew/src/api/recording.ts` WT-W1, `qe/walkthrough-acceptance.ts` WT-W2;
12
+ // wicked-garden#1208):
13
+ // chapters.json the planned list, written before the app starts: [{key, title, blurb, tags, resets}]
14
+ // progress.json while it runs: {state: "starting_app"} → {state: "recording", chapter, index, total}
15
+ // → {state: "judging"}; at the end {state: "judging", overall, chapters}
16
+ // result.json {overall, tree, cause?, reason?, chapters: [{key, title, verdict, takes, failed_at_sec,
17
+ // failed_frame, proves, legs, checks: [{id, kind, sentence, passed, at_sec, evidence[],
18
+ // vault_entry, detail}]}]} — evidence[] and failed_frame are root-relative paths
19
+ // demo-video/ segments/<key>/segment.mp4 (a kept failed take: segments/<key>/failed-<take>/), the
20
+ // stitched take demo.mp4, poster.jpg, chapters.md (`| m:ss | Title |` markers)
21
+ // capture/, vault/, .wicked-qe/, storyline.mjs — the checks' evidence, contracts and ledger
22
+ // The seal's bundle_sha is the form crew recomputes: sha256 over the lines "<sha256 hex> <posix relative
23
+ // path>\n" of every regular file under the root except top-level app/ and data/, sorted in UTF-8 byte order.
24
+ // A link or a non-regular entry anywhere else, or an unreadable file, makes the root UNSEALABLE (crew refuses
25
+ // it too): result.json becomes INCONCLUSIVE(unsealable_root) and the seal line carries bundle_sha: null.
26
+ //
10
27
  // Runtime: wicked-garden run scripts/demo/walkthrough.mjs <action> [args]
11
28
  // Manual alternative: node scripts/demo/walkthrough.mjs <action> [args]
12
29
 
@@ -19,7 +36,7 @@ import { pathToFileURL } from "node:url";
19
36
  import { createRequire } from "node:module";
20
37
  import { tmpdir } from "node:os";
21
38
 
22
- import { makeSegmentRecorder, failedAtInVideo, SEGMENT_TRIM_START } from "./record.mjs";
39
+ import { makeSegmentRecorder, failedAtInVideo, stitchParts, extractFrame, SEGMENT_TRIM_START } from "./record.mjs";
23
40
  import { fixtureOrigin } from "./readonly.mjs";
24
41
  import { crewRunStamp } from "../qe/lib/crew-run.mjs";
25
42
 
@@ -40,7 +57,11 @@ if (action !== "record" && action !== "seal") {
40
57
  if (action === "seal") {
41
58
  const sealRoot = argVal("--root") ?? process.env.WICKED_EVIDENCE_ROOT;
42
59
  if (!sealRoot) { process.stderr.write("seal: --root <dir> required\n"); process.exit(2); }
43
- const sha = computeBundleSha(path.resolve(sealRoot));
60
+ let sha;
61
+ try { sha = computeBundleSha(path.resolve(sealRoot)); } catch (e) {
62
+ process.stderr.write(`seal: ${e.message}\n`);
63
+ process.exit(1);
64
+ }
44
65
  process.stdout.write(JSON.stringify({ bundle_sha: sha }) + "\n");
45
66
  process.exit(0);
46
67
  }
@@ -83,11 +104,7 @@ if (!process.env.WICKED_WALKTHROUGH_JAIL) {
83
104
  const unjailedChapters = tryStaticSegmentKeys(_earlyStoryPath);
84
105
  writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "unjailed_host", chapters: unjailedChapters });
85
106
  writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "unjailed_host" });
86
- const sha = computeBundleSha(ROOT);
87
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({
88
- tree: TREE, storyline_sha: null, contract_shas: {}, bundle_sha: sha,
89
- overall: "INCONCLUSIVE", cause: "unjailed_host", chapters: unjailedChapters,
90
- }) + "\n");
107
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: null, contract_shas: {}, overall: "INCONCLUSIVE", cause: "unjailed_host", chapters: unjailedChapters });
91
108
  process.exit(0);
92
109
  }
93
110
 
@@ -117,27 +134,88 @@ function writeProgress(root, obj) {
117
134
  fs.writeFileSync(path.join(root, "progress.json"), JSON.stringify(obj, null, 2) + "\n");
118
135
  }
119
136
 
120
- /** sha256 over sorted (rel:sha\n) of every file under root except app/ and data/. */
137
+ /** The storyline's chapters (non-intro segments), in order. */
138
+ function storyChapters(story) {
139
+ return (Array.isArray(story?.segments) ? story.segments : []).filter((s) => !s.intro);
140
+ }
141
+
142
+ /** Every chapter as INCONCLUSIVE, with its title — what a refusal after the storyline loaded lists. */
143
+ function chapterStubs(story) {
144
+ return storyChapters(story).map((s) => ({ key: s.key, title: s.title ?? s.key, verdict: "INCONCLUSIVE" }));
145
+ }
146
+
147
+ /** The planned list crew's view shows while the tool runs (the demo chapter shape). */
148
+ function writeChaptersJson(root, story) {
149
+ const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
150
+ const chapters = storyChapters(story).map((s) => ({
151
+ key: s.key,
152
+ title: typeof s.title === "string" ? s.title : s.key,
153
+ blurb: typeof s.blurb === "string" ? s.blurb : "",
154
+ tags: strs(s.tags),
155
+ resets: strs(s.resets),
156
+ }));
157
+ fs.writeFileSync(path.join(root, "chapters.json"), JSON.stringify(chapters, null, 2) + "\n");
158
+ }
159
+
160
+ /**
161
+ * The seal crew recomputes at every read (`walkthrough-acceptance.ts` computeBundleSha): sha256 over one line
162
+ * `<sha256 hex of the file> <posix relative path>\n` (two spaces, as `sha256sum` prints) per regular file under
163
+ * the root except the top-level `app/` and `data/` subtrees, sorted in UTF-8 byte order (`LC_ALL=C sort`).
164
+ * Any other form reads as "walkthrough evidence changed after it was sealed" on crew's side. Like crew, it
165
+ * refuses (throws, `code: "unsealable_root"`) a link or a non-regular entry outside app/ and data/, and an
166
+ * entry it cannot read — crew would compute no seal for such a root, so none is claimed here either.
167
+ */
121
168
  function computeBundleSha(root) {
122
169
  const pairs = [];
123
170
  walkForSeal(root, root, pairs);
124
- pairs.sort((a, b) => a[0].localeCompare(b[0]));
171
+ pairs.sort((a, b) => Buffer.compare(Buffer.from(a[0], "utf8"), Buffer.from(b[0], "utf8")));
125
172
  const h = crypto.createHash("sha256");
126
- for (const [rel, sha] of pairs) h.update(`${rel}:${sha}\n`);
173
+ for (const [rel, sha] of pairs) h.update(`${sha} ${rel}\n`, "utf8");
127
174
  return h.digest("hex");
128
175
  }
129
176
 
177
+ function unsealable(why) {
178
+ const err = new Error(`unsealable proof root: ${why}`);
179
+ err.code = "unsealable_root";
180
+ return err;
181
+ }
182
+
130
183
  function walkForSeal(root, dir, out) {
131
184
  let entries;
132
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
185
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) {
186
+ throw unsealable(`${path.relative(root, dir).split(path.sep).join("/") || "."} could not be read: ${e.message}`);
187
+ }
133
188
  for (const e of entries) {
134
189
  const full = path.join(dir, e.name);
135
- const rel = path.relative(root, full);
136
- const top = rel.split(path.sep)[0];
190
+ const rel = path.relative(root, full).split(path.sep).join("/");
191
+ const top = rel.split("/")[0];
137
192
  if (top === "app" || top === "data") continue;
193
+ if (e.isSymbolicLink()) throw unsealable(`the proof root holds a link at ${rel}`);
138
194
  if (e.isDirectory()) { walkForSeal(root, full, out); }
139
- else if (e.isFile()) { try { out.push([rel, sha256File(full)]); } catch { /* skip unreadable */ } }
195
+ else if (e.isFile()) {
196
+ try { out.push([rel, sha256File(full)]); } catch (err) { throw unsealable(`${rel} could not be read: ${err.message}`); }
197
+ } else throw unsealable(`the proof root holds a non-regular entry at ${rel}`);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The one way out of `record`: seal the root and print the WALKTHROUGH-SEAL line. When the root cannot be
203
+ * sealed, result.json and progress.json are rewritten as INCONCLUSIVE(unsealable_root) with every chapter
204
+ * INCONCLUSIVE and the line carries `bundle_sha: null` — crew reads that as "not sealed" and denies.
205
+ */
206
+ function finishWithSeal(root, { tree, storyline_sha, contract_shas, overall, cause, reason, chapters }) {
207
+ let bundle_sha;
208
+ try { bundle_sha = computeBundleSha(root); } catch (e) {
209
+ process.stderr.write(`walkthrough: ${e.message}\n`);
210
+ overall = "INCONCLUSIVE"; cause = "unsealable_root"; reason = e.message; bundle_sha = null;
211
+ chapters = chapters.map((c) => ({ ...c, verdict: "INCONCLUSIVE" }));
212
+ writeResult(root, { overall, tree, cause, reason, chapters });
213
+ writeProgress(root, { overall, cause, chapters: chapters.map(({ key, verdict }) => ({ key, verdict })) });
140
214
  }
215
+ const seal = { tree, storyline_sha, contract_shas, bundle_sha, overall };
216
+ if (cause) seal.cause = cause;
217
+ seal.chapters = chapters.map(({ key, verdict }) => ({ key, verdict }));
218
+ process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify(seal) + "\n");
141
219
  }
142
220
 
143
221
  async function freePort() {
@@ -502,10 +580,14 @@ function judgeChapter(seg, captures, failedAtSec, { crossCheck = null, vaultEntr
502
580
  claims.push({
503
581
  id: def.id,
504
582
  kind: def.kind,
583
+ sentence: typeof def.sentence === "string" ? def.sentence : "",
505
584
  verdict: claimVerdict,
585
+ passed: claimVerdict === "PASS" ? true : claimVerdict === "FAIL" ? false : null,
506
586
  at_sec: cap?.at_sec ?? null,
507
587
  evidence: cap ? evidenceListForCapture(cap, def) : [],
588
+ evidence_paths: cap ? evidencePathsForCapture(cap, def) : [],
508
589
  vault_entry: vaultEntries[def.id] ?? null,
590
+ detail: claimDetail(claimVerdict, cap, vaultAvailable && !vaultError ? vaultClaimsMap[def.id] : undefined, vaultAvailable, vaultError),
509
591
  });
510
592
  }
511
593
 
@@ -514,7 +596,34 @@ function judgeChapter(seg, captures, failedAtSec, { crossCheck = null, vaultEntr
514
596
  if (!vaultAvailable || vaultError) verdict = "INCONCLUSIVE";
515
597
  else if (verdict === "PASS" && (crossCheck.overall !== "PASS" || !declared)) verdict = "INCONCLUSIVE";
516
598
 
517
- return { verdict, claims, failed_at_sec: failedAtSec ?? null, proves: seg.proves ?? [] };
599
+ // DES §4.5: failed_at_sec is the earliest at_sec among the failing checks, otherwise the take's failure.json.
600
+ const failingAt = claims.filter((c) => c.verdict === "FAIL" && typeof c.at_sec === "number").map((c) => c.at_sec);
601
+ const failed_at_sec = failingAt.length ? Math.min(...failingAt) : failedAtSec ?? null;
602
+
603
+ return { verdict, claims, failed_at_sec, proves: seg.proves ?? [], legs: Array.isArray(seg.legs) ? seg.legs : [] };
604
+ }
605
+
606
+ /** One sentence on why a check is not a plain PASS (crew's `checks[].detail`); null for a PASS without a note. */
607
+ function claimDetail(claimVerdict, cap, vaultClaim, vaultAvailable, vaultError) {
608
+ if (!vaultAvailable) return "vault unavailable: no cross-check could run";
609
+ if (vaultError) return "vault cross-check returned ERROR";
610
+ if (cap?._verifier_error) return `collector error: ${cap._verifier_error}`;
611
+ if (!cap) return "never reached: ctx.check was not called for this id";
612
+ if (!vaultClaim) return "not recorded in the vault";
613
+ if (claimVerdict === "PASS") return null;
614
+ const status = vaultClaim.result ?? vaultClaim.verifier_status ?? claimVerdict;
615
+ const why = [vaultClaim.reason, vaultClaim.message, vaultClaim.detail].find((v) => typeof v === "string" && v);
616
+ return why ? `vault ${status}: ${why}` : `vault ${status}`;
617
+ }
618
+
619
+ /** The root-relative files behind a capture, as crew serves them (`/walkthrough/file?path=`). */
620
+ function evidencePathsForCapture(capture, def) {
621
+ switch (def.kind) {
622
+ case "locator": return capture.frame ? [capture.frame] : [];
623
+ case "probe": return capture.parsed_path ? [capture.parsed_path] : [];
624
+ case "artifact": return capture.path && capture.sha256 ? [path.posix.join("data", capture.path.split(path.sep).join("/"))] : [];
625
+ default: return [];
626
+ }
518
627
  }
519
628
 
520
629
  // ---- manifest writing ----------------------------------------------------------
@@ -749,8 +858,7 @@ async function main() {
749
858
  if (!fs.existsSync(storyPath)) {
750
859
  writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused", reason: `storyline not found: ${storyPath}`, chapters: [] });
751
860
  writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused" });
752
- const sha = computeBundleSha(ROOT);
753
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({ tree: TREE, storyline_sha: null, contract_shas: {}, bundle_sha: sha, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters: [] }) + "\n");
861
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: null, contract_shas: {}, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters: [] });
754
862
  process.exit(0);
755
863
  }
756
864
 
@@ -767,48 +875,49 @@ async function main() {
767
875
  const refusedChapters = tryStaticSegmentKeys(storyPath);
768
876
  writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused", reason: e.message, chapters: refusedChapters });
769
877
  writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused" });
770
- const sha = computeBundleSha(ROOT);
771
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({ tree: TREE, storyline_sha: storySha, contract_shas: {}, bundle_sha: sha, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters: refusedChapters }) + "\n");
878
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: storySha, contract_shas: {}, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters: refusedChapters });
772
879
  process.exit(0);
773
880
  }
774
881
 
775
882
  if (!story?.fixture?.start || !Array.isArray(story?.segments)) {
776
- const chapters = Array.isArray(story?.segments)
777
- ? story.segments.filter((s) => !s.intro).map((s) => ({ key: s.key, verdict: "INCONCLUSIVE" }))
778
- : [];
883
+ const chapters = chapterStubs(story);
779
884
  writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused", reason: "storyline missing fixture.start or segments[]", chapters });
780
885
  writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused" });
781
- const sha = computeBundleSha(ROOT);
782
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({ tree: TREE, storyline_sha: storySha, contract_shas: {}, bundle_sha: sha, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters }) + "\n");
886
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: storySha, contract_shas: {}, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters: chapters });
783
887
  process.exit(0);
784
888
  }
785
889
 
786
- // Chapter keys and check ids become path segments under <root>; refuse anything that could escape.
890
+ // Chapter keys and check ids become path segments under <root>; refuse anything that could escape. A
891
+ // segment key is also what record.mjs and crew's view accept (`^[a-z0-9][a-z0-9-]*$`): a key outside it
892
+ // would be dropped by the view silently, so it is refused here, by name.
893
+ const SEGMENT_KEY = /^[a-z0-9][a-z0-9-]*$/;
787
894
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
788
895
  const badIds = [];
789
896
  for (const s of story.segments) {
790
- if (!s.intro && !SAFE_ID.test(String(s.key ?? ""))) badIds.push(`segment key ${JSON.stringify(s.key)}`);
897
+ if (!SEGMENT_KEY.test(String(s.key ?? ""))) badIds.push(`segment key ${JSON.stringify(s.key)} (lowercase letters, digits and hyphens only)`);
791
898
  for (const c of s.checks ?? []) if (!SAFE_ID.test(String(c.id ?? ""))) badIds.push(`check id ${JSON.stringify(c.id)}`);
792
899
  }
793
900
  if (badIds.length) {
794
- const chapters = story.segments.filter((s) => !s.intro).map((s) => ({ key: s.key, verdict: "INCONCLUSIVE" }));
901
+ const chapters = chapterStubs(story);
795
902
  writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused", reason: `unsafe identifiers: ${badIds.join(", ")}`, chapters });
796
903
  writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused" });
797
- const sha = computeBundleSha(ROOT);
798
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({ tree: TREE, storyline_sha: storySha, contract_shas: {}, bundle_sha: sha, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters }) + "\n");
904
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: storySha, contract_shas: {}, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters: chapters });
799
905
  process.exit(0);
800
906
  }
801
907
 
908
+ // The planned list for the view, then the first running state — before anything slow.
909
+ writeChaptersJson(ROOT, story);
910
+ writeProgress(ROOT, { state: "starting_app" });
911
+
802
912
  // 2. Git archive WICKED_TREE into <root>/app/
803
913
  const appDir = path.join(ROOT, "app");
804
- const allChapters = story.segments.filter((s) => !s.intro).map((s) => ({ key: s.key, verdict: "INCONCLUSIVE" }));
914
+ const allChapters = chapterStubs(story);
805
915
  try {
806
916
  await gitArchive(TREE, appDir);
807
917
  } catch (e) {
808
918
  writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "fixture_unavailable", reason: `git archive failed: ${e.message}`, chapters: allChapters });
809
919
  writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "fixture_unavailable" });
810
- const sha = computeBundleSha(ROOT);
811
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({ tree: TREE, storyline_sha: storySha, contract_shas: {}, bundle_sha: sha, overall: "INCONCLUSIVE", cause: "fixture_unavailable", chapters: allChapters }) + "\n");
920
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: storySha, contract_shas: {}, overall: "INCONCLUSIVE", cause: "fixture_unavailable", chapters: allChapters });
812
921
  process.exit(0);
813
922
  }
814
923
 
@@ -864,16 +973,16 @@ async function main() {
864
973
  await Promise.race([pollFixtureReady(story.fixture, fixtureOriginStr), _spawnErrPromise]);
865
974
  } catch (e) {
866
975
  killFixture();
867
- const chapters = story.segments.filter((s) => !s.intro).map((s) => ({ key: s.key, verdict: "INCONCLUSIVE" }));
976
+ const chapters = chapterStubs(story);
868
977
  writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "fixture_unavailable", reason: e.message, chapters });
869
978
  writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "fixture_unavailable" });
870
- const sha = computeBundleSha(ROOT);
871
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({ tree: TREE, storyline_sha: storySha, contract_shas: {}, bundle_sha: sha, overall: "INCONCLUSIVE", cause: "fixture_unavailable", chapters }) + "\n");
979
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: storySha, contract_shas: {}, overall: "INCONCLUSIVE", cause: "fixture_unavailable", chapters: chapters });
872
980
  process.exit(0);
873
981
  }
874
982
 
875
983
  const writableOrigin = fixtureOrigin(fixtureOriginStr);
876
- const segmentsDir = path.join(ROOT, "segments");
984
+ const demoVideoDir = path.join(ROOT, "demo-video");
985
+ const segmentsDir = path.join(demoVideoDir, "segments");
877
986
  const brand = { name: story.title || "Walkthrough", accent: story.brand?.accent ?? "#ee0000", logoSvg: story.brand?.logoSvg ?? "" };
878
987
 
879
988
  // 4-5. Record each segment with check capture
@@ -968,6 +1077,7 @@ async function main() {
968
1077
  claims: chapterResults[s.key]?.claims ?? [],
969
1078
  failed_at_sec: chapterResults[s.key]?.failed_at_sec ?? null,
970
1079
  proves: s.proves ?? [],
1080
+ legs: Array.isArray(s.legs) ? s.legs : [],
971
1081
  _cause: cause,
972
1082
  };
973
1083
  if (!(s.key in contractShas)) {
@@ -976,10 +1086,12 @@ async function main() {
976
1086
  }
977
1087
  }
978
1088
 
1089
+ const chapterOrder = storyChapters(story).map((s) => s.key);
979
1090
  for (const seg of story.segments) {
980
1091
  if (seg.intro) continue;
981
1092
  consoleErrBuf.current = [];
982
1093
  if (!chapterCaptures[seg.key]) chapterCaptures[seg.key] = {};
1094
+ writeProgress(ROOT, { state: "recording", chapter: seg.key, index: chapterOrder.indexOf(seg.key) + 1, total: chapterOrder.length });
983
1095
 
984
1096
  // Check disk cap before each chapter — a global cause that makes EVERY chapter INCONCLUSIVE
985
1097
  if (DISK_CAP > 0 && dirSizeBytes(ROOT) > DISK_CAP) {
@@ -1044,6 +1156,18 @@ async function main() {
1044
1156
  );
1045
1157
  }
1046
1158
 
1159
+ writeProgress(ROOT, { state: "judging" });
1160
+
1161
+ // A chapter whose take failed has its video only under failed-<take>/; crew's view reads a chapter as
1162
+ // recorded from demo-video/segments/<key>/segment.mp4 alone, so the kept take is published there too (a
1163
+ // regular-file copy — a link would make the root unsealable). guard.json (failed: true) and
1164
+ // failed-<take>/failure.json keep saying what it is.
1165
+ for (const seg of storyChapters(story)) {
1166
+ const canonical = path.join(segmentsDir, seg.key, "segment.mp4");
1167
+ const kept = path.join(segmentsDir, seg.key, `failed-${takeMap[seg.key] ?? 1}`, "segment.mp4");
1168
+ if (!fs.existsSync(canonical) && fs.existsSync(kept)) fs.copyFileSync(kept, canonical);
1169
+ }
1170
+
1047
1171
  // 6. Validate per-chapter manifests BEFORE writing ledger rows.
1048
1172
  // Any manifest violation makes the chapter INCONCLUSIVE — including FAIL chapters.
1049
1173
  for (const seg of story.segments.filter((s) => !s.intro)) {
@@ -1122,23 +1246,67 @@ async function main() {
1122
1246
  : "INCONCLUSIVE";
1123
1247
  }
1124
1248
 
1125
- // 7. Write result.json + progress.json
1126
- const resultChapters = Object.entries(chapterResults).map(([key, r]) => ({ key, verdict: r.verdict, failed_at_sec: r.failed_at_sec }));
1127
- const resultObj = { overall: finalOverall, chapters: resultChapters };
1249
+ // 7. The take as crew's view plays it: each chapter's video (the good take, or the failed take published
1250
+ // above), stitched in order with chapter markers and a poster; a FAIL chapter's frame.
1251
+ const FFMPEG_BIN = process.env.FFMPEG || "ffmpeg";
1252
+ const segVideoOf = (seg) => {
1253
+ const video = path.join(segmentsDir, seg.key, "segment.mp4");
1254
+ return fs.existsSync(video) ? video : null;
1255
+ };
1256
+ const failedFrames = {};
1257
+ for (const seg of storyChapters(story)) {
1258
+ const r = chapterResults[seg.key];
1259
+ if (!r || r.verdict !== "FAIL") continue;
1260
+ // The earliest failing on-screen check already has its frame; otherwise cut one from the take.
1261
+ const failing = r.claims.filter((c) => c.verdict === "FAIL" && typeof c.at_sec === "number").sort((a, b) => a.at_sec - b.at_sec);
1262
+ const withFrame = failing.find((c) => c.evidence_paths.some((e) => e.endsWith(".png")));
1263
+ if (withFrame) { failedFrames[seg.key] = withFrame.evidence_paths.find((e) => e.endsWith(".png")); continue; }
1264
+ const video = segVideoOf(seg);
1265
+ if (video && typeof r.failed_at_sec === "number") {
1266
+ const out = path.join(segmentsDir, seg.key, "failed-frame.jpg");
1267
+ if (extractFrame(video, r.failed_at_sec, out, { ffmpeg: FFMPEG_BIN })) failedFrames[seg.key] = path.relative(ROOT, out).split(path.sep).join("/");
1268
+ }
1269
+ }
1270
+ const parts = story.segments.map((seg) => ({ seg, file: segVideoOf(seg) })).filter((p) => p.file !== null)
1271
+ .map((p) => ({ title: p.seg.intro ? "Introduction" : p.seg.title, file: p.file }));
1272
+ if (parts.length) {
1273
+ try {
1274
+ const { rows } = stitchParts(parts, path.join(demoVideoDir, "demo.mp4"), { ffmpeg: FFMPEG_BIN, title: story.title || "Walkthrough", workDir: segmentsDir });
1275
+ fs.writeFileSync(path.join(demoVideoDir, "chapters.md"), rows.join("\n") + "\n");
1276
+ extractFrame(path.join(demoVideoDir, "demo.mp4"), 1.0, path.join(demoVideoDir, "poster.jpg"), { ffmpeg: FFMPEG_BIN })
1277
+ || extractFrame(path.join(demoVideoDir, "demo.mp4"), 0, path.join(demoVideoDir, "poster.jpg"), { ffmpeg: FFMPEG_BIN });
1278
+ } catch (e) {
1279
+ // The verdicts stand without a stitched take; crew shows no video rather than a wrong one.
1280
+ process.stderr.write(`walkthrough: stitch skipped: ${e.message}\n`);
1281
+ fs.rmSync(path.join(demoVideoDir, "demo.mp4"), { force: true });
1282
+ }
1283
+ }
1284
+
1285
+ // 8. Write result.json + progress.json — the chapters as crew's view reads them.
1286
+ const titleOf = Object.fromEntries(storyChapters(story).map((s) => [s.key, s.title ?? s.key]));
1287
+ const resultChapters = Object.entries(chapterResults).map(([key, r]) => ({
1288
+ key,
1289
+ title: titleOf[key] ?? key,
1290
+ verdict: r.verdict,
1291
+ takes: takeMap[key] ?? 1,
1292
+ failed_at_sec: r.failed_at_sec ?? null,
1293
+ failed_frame: failedFrames[key] ?? null,
1294
+ proves: r.proves ?? [],
1295
+ legs: r.legs ?? [],
1296
+ checks: (r.claims ?? []).map((c) => ({
1297
+ id: c.id, kind: c.kind, sentence: c.sentence, passed: c.passed, at_sec: c.at_sec,
1298
+ evidence: c.evidence_paths, vault_entry: c.vault_entry, detail: c.detail,
1299
+ })),
1300
+ }));
1301
+ const resultObj = { overall: finalOverall, tree: TREE, chapters: resultChapters };
1128
1302
  if (diskCapHit) resultObj.cause = "disk_cap";
1129
1303
  writeResult(ROOT, resultObj);
1130
- writeProgress(ROOT, { overall: finalOverall, chapters: resultChapters });
1131
-
1132
- // 8. Print WALKTHROUGH-SEAL
1133
- const bundleSha = computeBundleSha(ROOT);
1134
- process.stdout.write("WALKTHROUGH-SEAL " + JSON.stringify({
1135
- tree: TREE,
1136
- storyline_sha: storySha,
1137
- contract_shas: contractShas,
1138
- bundle_sha: bundleSha,
1139
- overall: finalOverall,
1140
- chapters: resultChapters.map(({ key, verdict }) => ({ key, verdict })),
1141
- }) + "\n");
1304
+ // `state` stays "judging" so crew's view (which reads progress only while the unit is distributed) does not
1305
+ // snap back to "Starting the app" between this write and the unit completing.
1306
+ writeProgress(ROOT, { state: "judging", overall: finalOverall, chapters: resultChapters.map(({ key, verdict }) => ({ key, verdict })) });
1307
+
1308
+ // 9. Print WALKTHROUGH-SEAL
1309
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: storySha, contract_shas: contractShas, overall: finalOverall, chapters: resultChapters });
1142
1310
 
1143
1311
  killFixture();
1144
1312
  process.exit(0);
@@ -15,7 +15,7 @@ is history, not the contract.
15
15
 
16
16
  | | |
17
17
  |---|---|
18
- | Bundle version (`VERSION`) | `1.2.0` — mirrors the owner's `schemas/VERSION` (re-vendored 2026-09-11 from wicked-core `main` @ `e421e87`) |
18
+ | Bundle version (`VERSION`) | `1.2.1` — mirrors the owner's `schemas/VERSION` (synced 2026-10-04 to wicked-core `main` @ `0f419c3`; `domain-model.schema.json` byte-unchanged since the 2026-09-11 re-vendor) |
19
19
  | Schema contract version (`$id` / `metadata.schema_version`) | `1.0.0` |
20
20
  | Schema `$id` | `https://wickedagile.com/schemas/domain-model/1.0.0` |
21
21
  | Canonical source | `wicked-core/crates/wicked-governance/schemas/domain-model.schema.json` |
@@ -26,7 +26,9 @@ is history, not the contract.
26
26
  The **bundle** `VERSION` bumps when ANY schema in the owner's 4-file bundle
27
27
  changes (the 1.0.0→1.1.0 bump added `conformance-rules.schema.json` to the
28
28
  bundle; the 1.1.0→1.2.0 bump was the STEERING unification's optional fields on
29
- `conformance-rules.schema.json` — this file was byte-untouched both times). The
29
+ `conformance-rules.schema.json`; the 1.2.0→1.2.1 bump was conformance-rules 1.2.0's
30
+ optional DC-S1 project-rule fields, wicked-core#699 — this file was byte-untouched
31
+ every time). The
30
32
  **contract version** a document carries (`metadata.schema_version`,
31
33
  const-pinned by the schema, matching the `$id` segment) is independent — the
32
34
  schemas document that independence themselves. Documents emitted here still carry
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.2.1
@@ -71,7 +71,8 @@ external engine, no Node version floor.
71
71
  where you are the agent): the graph is handed to you already indexed and the write CLI is **never**
72
72
  yours: never `wicked-estate index`, never `wicked-estate scip|tfstate|import-telemetry|compact|watch`,
73
73
  never `wicked-estate clusters --annotate`. A stale graph is *reported* through its
74
- `STALENESS` marker, not rebuilt. Binary resolution: `WICKED_ESTATE_BIN` env → `PATH` → `~/.local/bin`.
74
+ `STALENESS` marker, not rebuilt — the shim's `call` prints it under `diagnostics`, beside any
75
+ `CLAMPED:` line (a bound the server lowered), so read that list before trusting a result. Binary resolution: `WICKED_ESTATE_BIN` env → `PATH` → `~/.local/bin`.
75
76
 
76
77
  ## Resolving symbols + the one way to the graph (shared by every action)
77
78
 
@@ -7,7 +7,8 @@ god-objects, coupling hotspots, and high-impact refactor targets.
7
7
 
8
8
  ## Instructions
9
9
 
10
- 1. **Freshness** — read the `STALENESS` marker the result carries and report it. The graph is
10
+ 1. **Freshness** — read the `STALENESS` marker the result carries (the shim's `call` output lists
11
+ it under `diagnostics`, with any `CLAMPED:` line) and report it. The graph is
11
12
  rebuilt by an operator at a terminal (`wicked-estate index <path>` — the search skill's
12
13
  `index` action), never from a seat.
13
14
  2. **The estate `RankHotspots` tool through the read-only shim** — the one way, on every seat