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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -1
- package/package.json +1 -1
- package/scripts/_estate_client.py +57 -15
- package/scripts/demo/record.mjs +47 -25
- package/scripts/demo/walkthrough.mjs +219 -51
- package/skills/domain/vendor/README.md +4 -2
- package/skills/domain/vendor/VERSION +1 -1
- package/skills/search/SKILL.md +2 -1
- package/skills/search/refs/hotspots.md +2 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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}}.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
package/scripts/demo/record.mjs
CHANGED
|
@@ -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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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].
|
|
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(`${
|
|
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 {
|
|
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(
|
|
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()) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
1126
|
-
|
|
1127
|
-
const
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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.
|
|
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
|
|
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.
|
|
1
|
+
1.2.1
|
package/skills/search/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|