wicked-garden 12.42.0 → 12.44.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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +26 -1
  4. package/docs/cross-cli-skill-format.md +6 -8
  5. package/package.json +1 -1
  6. package/scripts/_estate_client.py +57 -15
  7. package/scripts/demo/record.mjs +47 -25
  8. package/scripts/demo/walkthrough-lint.mjs +248 -0
  9. package/scripts/demo/walkthrough.mjs +256 -68
  10. package/scripts/mcp/install.py +443 -0
  11. package/scripts/mcp/openapi.py +200 -0
  12. package/scripts/mcp/probe.py +39 -10
  13. package/scripts/mcp/scaffold.py +71 -3
  14. package/skills/core/SKILL.md +1 -1
  15. package/skills/demo/SKILL.md +21 -1
  16. package/skills/demo/refs/walkthrough-author.md +83 -0
  17. package/skills/domain/vendor/README.md +4 -2
  18. package/skills/domain/vendor/VERSION +1 -1
  19. package/skills/governed-worker/SKILL.md +20 -4
  20. package/skills/mcp-scaffold/SKILL.md +190 -38
  21. package/skills/mcp-scaffold/assets/typescript/LICENSE-HEADER.txt +2 -0
  22. package/skills/mcp-scaffold/assets/typescript/README.md +71 -0
  23. package/skills/mcp-scaffold/assets/typescript/env.example +18 -0
  24. package/skills/mcp-scaffold/assets/typescript/eslint.config.js +11 -0
  25. package/skills/mcp-scaffold/assets/typescript/gitignore +3 -0
  26. package/skills/mcp-scaffold/assets/typescript/mcp-server.config.json +14 -0
  27. package/skills/mcp-scaffold/assets/typescript/package.json +46 -0
  28. package/skills/mcp-scaffold/assets/typescript/src/auth.ts +98 -0
  29. package/skills/mcp-scaffold/assets/typescript/src/config.ts +90 -0
  30. package/skills/mcp-scaffold/assets/typescript/src/logging.ts +57 -0
  31. package/skills/mcp-scaffold/assets/typescript/src/rest.ts +160 -0
  32. package/skills/mcp-scaffold/assets/typescript/src/server.ts +52 -0
  33. package/skills/mcp-scaffold/assets/typescript/src/telemetry.ts +65 -0
  34. package/skills/mcp-scaffold/assets/typescript/src/tools/example.ts +22 -0
  35. package/skills/mcp-scaffold/assets/typescript/src/tools/generated.ts +64 -0
  36. package/skills/mcp-scaffold/assets/typescript/src/tools/registry.ts +76 -0
  37. package/skills/mcp-scaffold/assets/typescript/test/conformance.test.ts +112 -0
  38. package/skills/mcp-scaffold/assets/typescript/test/contract/example.test.ts +75 -0
  39. package/skills/mcp-scaffold/assets/typescript/test/global-setup.ts +10 -0
  40. package/skills/mcp-scaffold/assets/typescript/tools.json +3 -0
  41. package/skills/mcp-scaffold/assets/typescript/tsconfig.json +17 -0
  42. package/skills/mcp-scaffold/assets/typescript/vitest.config.ts +11 -0
  43. package/skills/mem/SKILL.md +7 -7
  44. package/skills/repo-learn/SKILL.md +19 -18
  45. package/skills/search/SKILL.md +49 -8
  46. package/skills/search/codebase-narrator/SKILL.md +4 -2
  47. package/skills/search/refs/hotspots.md +2 -1
  48. package/skills/wickedizer/SKILL.md +4 -5
@@ -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.44.0"}], "version": "12.44.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.42.0",
3
+ "version": "12.44.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,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.44.0] — 2026-10-08
6
+
7
+ ### Added
8
+ - **`mcp-scaffold`: the `openapi` and `install` actions (DES-mcp-server-workflow rev 2, slice 2 of 2).** `scripts/mcp/openapi.py` turns an OpenAPI 3 document into the server's `tools.json` through wicked-crew's conversion service — `POST /api/v1/mcp/servers/preview` with `{name, kind: "rest", url, openapiUrl | openapi, operations?}` at `--crew-url`, else `$WICKED_CREW_URL`, else `http://127.0.0.1:7701` — writing `{"source": {specUrl|specFile, baseUrl, convertedAt}, "tools": [{name, description, inputSchema, annotations, class, rest}], "skipped": [...]}` and setting `baseUrl` in `mcp-server.config.json`; it refuses to overwrite generated tools, never sends a `secret`/`auth` key, exits 1 carrying the daemon's refusal and exits 2 when no daemon answers ("write the tools by hand (branch B)") — it never invents tools. `scripts/mcp/install.py` installs or updates the built server for running, idempotent by the config's `key`: it locates the one `mcp-server.config.json` (`--from-run` under `$WICKED_TREE`, or `--dir`), builds and stages `dist/` + manifests into `<root>/<key>/next/` (`~/.wicked/mcp-servers` unless `--install-root`/`$WICKED_MCP_INSTALL_ROOT`; `--from-npm <pkg>@<ver>` installs a published package instead), smokes it with `probe.py --env <SERVER>_TOKEN` (a failure naming that variable is `pending_credential`, disclosed; any other failure exits 1 with `current/` untouched), swaps `current/` → `previous/` and `next/` → `current/` so the command `node <root>/<key>/current/dist/server.js` never changes, registers it in crew's MCP registry with `auth: {ref: "env:<SERVER>_TOKEN"}` — the one-secret contract: a reference the daemon resolves, never a value (`secret_missing` → `registered: false` with both remedies; no daemon → the reason; an existing key → `registered: "updated"`), writes the operator's CLI MCP configs only through `wicked-installer mcp upsert` (an installer without the verb is reported as skipped), and records `<root>/<key>/installed.json` `{key, version, source, command, args, envNames, installedAt, registered, smoke}`. `--uninstall <key>` deletes the registration, the CLI entries and the root. Exit 0 staged, 1 staging/smoke/registry failure, 2 bad arguments. The skill gains both actions, the `mcp-server` workflow's per-phase guide (scope, source-discovery, design, build, install) and the "Use when" phrases; the core router row is now `scaffold · openapi · probe · install`. Tests: `tests/mcp/test_mcp_openapi.py`, `tests/mcp/test_mcp_install.py` (stub `npm` and `wicked-installer` on PATH, a local http.server as the daemon, a real smoke probe).
9
+ - **`mcp-scaffold`: a TypeScript server template, now the default (`scaffold.py --lang typescript`; DES-mcp-server-workflow rev 2, slice 1 of 2).** `skills/mcp-scaffold/assets/typescript/` is a whole project: fastmcp 4 on Node >= 22, loglayer (OpenTelemetry plugin, redaction by name, a console transport bound to stderr — stdout carries protocol frames only — and a request-scoped child logger through `AsyncLocalStorage`), OpenTelemetry through `@opentelemetry/sdk-node` (spans `mcp.tool.call` and `http.client.request`, instruments `mcp.tool.calls`/`mcp.tool.duration`/`mcp.tool.errors`; the OTLP trace, metric and log exporters are armed only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set, otherwise nothing leaves the process), and authentication on every tool (`canAccess`; httpStream refuses a request without `MCP_SERVER_BEARER` with 401). The server reads ONE secret, `<SERVER>_TOKEN` (bearer token, API key, basic password or OAuth2 client secret, as the scheme says) — the one variable crew's broker and registry inject — and fails at startup naming it; every non-secret parameter lives in the committed `mcp-server.config.json` (`key`, `version`, `transport`, `baseUrl`, `auth`, `rateLimitPerMin`). `defineTool` wraps each tool in the span, the child logger, the instruments and a per-tool token bucket; `src/tools/generated.ts` registers every `tools.json` entry (the conversion service's output) with annotations derived from its class, calling `src/rest.ts`, which pins scheme/host/port/base path, sends allowlisted arguments only, never follows a redirect, refuses auth and hop-by-hop headers from arguments and retries 429/5xx for read tools only. `npm test` builds and runs a stdio conformance smoke (initialize, tools/list, tools/call; a missing secret exits non-zero naming the variable; httpStream 401) and a per-tool contract-test shape. Every `.ts` file starts with an SPDX line. `scaffold.py` copies the directory stamping the name, its env prefix and the year; `--out` may be an existing directory, but every file it would write must be absent — it refuses naming the first collision and writes nothing. `node` and `python` stay as the dependency-free one-file templates. `probe.py --env NAME` passes exactly that variable through to the server (never printed) and, on a failure, reports only which of those names the server's stderr mentioned (`stderrNames`). The skill gains the skeleton's contract and a "Steering this server follows" checklist (MCPS-1001…1007, sourced from wicked-core's `governance/packs/mcp-server`). Tests: `tests/mcp/test_mcp_scaffold.py` (default lang, stamping, SPDX, no secret values, scaffold into a populated dir, collision refusal, `--env` passthrough; the npm install/build/lint/test/probe end-to-end behind `WICKED_GARDEN_E2E_NPM=1`).
10
+
11
+ ### Changed
12
+ - **Governed-worker floor: foreground evidence, tool debris under the notes root, verdict scope (wicked-garden#1229, #1223).** New **C6**: no turn ends with tasks pending — the done-when recipe runs in order in the foreground with each command and exit code pasted; "running in the background" is never a closing line (three creator attempts in run ad5a4ca7 ended that way, F35). New **C7**: reporter output, coverage, screenshots, logs and scratch files go under the unit's notes root (`$WICKED_NOTES_ROOT` or the path the brief names), never in the repository — pass the tool's output flag or delete the file before the turn ends (S17b left a 2.3 MB `.vitest/json/output.json` in the worktree; crew's deliver skip-list, wicked-crew#861, is the backstop). **C2** now reads: a failure called pre-existing cites its run on the base tree (command + exit code) or it is a failure. New **E3**: every verdict item is in scope unless a human ruling strikes it by name (companion to wicked-core#760). Provenance rows added; `tests/test_governed_worker_rules.py` pins the text.
13
+
14
+ ### Fixed
15
+ - **Walkthrough: the `lint` action the `walkthrough_plan` pin calls now exists, and `record` reads the storyline the author wrote (wicked-garden#1231, the garden half of wicked-core#753).** The engine pins `walkthrough_plan` to `"${WICKED_GARDEN_ROOT}/scripts/wicked-garden" run scripts/demo/walkthrough.mjs lint --root "${WICKED_EVIDENCE_ROOT}"`, but `walkthrough.mjs` accepted only `record|seal` and exited 2 with usage — so every walkthrough was denied, storyline or not. `lint --root <author dir>` (new `scripts/demo/walkthrough-lint.mjs`, DES-walkthrough-proof §4.5) imports `<author dir>/storyline.mjs`, prints `{ok, storyline, chapters, checks, findings: [{rule, where, detail}]}` and exits 0 only with no finding (1 with any, 2 on a usage error; `--root` defaults to `$WICKED_EVIDENCE_ROOT`). It refuses: a missing or unloadable storyline; an empty plan; `baseUrl` other than `"fixture"`; a `fixture.start` that is not a plain argv, runs inline code (`-e`, `-c`, `--eval`, ...), names an absolute path or `..`, or (with `--tree <dir>`) a script the tree does not hold; a secret-shaped `fixture.env` name; a chapter without `proves` step ids (with `--steps a,b`, ids outside the plan), without a `locator` (on_screen) check, without a `probe`/`artifact` (state) check, or without a `join` (cross_check) of the two; a walkthrough without a `guard` (must_not_happen) check; unknown check kinds, a locator without a selector, a probe not in `fixture.probes`, an artifact path escaping `DATA_DIR`, a join not naming two earlier checks; a `verify` that is not `jq_pred` or that `jq` rejects; and a check with no `negative` sample or whose verifier PASSES one of its samples (a tautology; run through `jq -e` exactly as the vault does, failing closed without `jq`). `record` took its storyline from `<dirname(WICKED_EVIDENCE_ROOT)>/author/<WICKED_RUN_UNIT>/` — the unit **ordinal** — so a storyline written where the engine said was reported "storyline not found"; it now reads `--storyline`, else `$WICKED_WALKTHROUGH_AUTHOR/storyline.mjs` (the author dir wicked-core's `record_launch` hands it), and with neither refuses `storyline_refused` naming both. The demo skill gains a walkthrough-author section and `refs/walkthrough-author.md` (where the file goes, every rule id, a passing example the tests lint). Tests: `tests/demo/test_walkthrough_lint.py` (27: a clean pass, the exact pinned launcher command, one per rule, the documented example, a storyline that throws while read still gets a report), `tests/demo/test_walkthrough.py` (4: the author dir is read with an ordinal `WICKED_RUN_UNIT`, a missing author storyline names its path, no source refuses by name, the unjailed result lists the author storyline's chapters). The record tool does not yet re-run the lint before recording (§4.4 step 1); that follows separately.
16
+ - **`mcp-scaffold` TypeScript template: the conformance smoke no longer times out on a loaded host.** Loading fastmcp and the OpenTelemetry SDK can take well over 10 s when the machine is busy; the stdio exchange now waits up to 60 s, the httpStream readiness poll up to 60 s, `testTimeout` is 90 s, and the npm end-to-end test (`WICKED_GARDEN_E2E_NPM=1`) probes with `--timeout 30`.
17
+ - **CI no longer installs the retired `wicked-loom` package; the trust-spine tests need only node + wicked-vault (closes the wicked-garden#1132 residual).** Loom runs in-process (`scripts/loom/`, absorbed from wicked-loom), but `.github/workflows/test.yml` still ran `npm i -g wicked-vault wicked-loom` and `tests/qe/test_prove.py` / `tests/e2e/test_e2e_gate_cli.py` skipped (or, under `WICKED_REQUIRE_E2E=1`, failed) unless a `wicked-loom` binary was on PATH — and then pinned `WICKED_LOOM_BIN` to it, so CI exercised the deprecated external binary instead of the in-process gate garden ships. The tests now require node + a resolvable vault only, pass `WICKED_LOOM_BIN` through only when the caller set it, and CI installs `wicked-vault` alone. `docs/cross-cli-skill-format.md` no longer describes the deleted cross-CLI baseline or its `--write-baseline` flag, and the repo's agent guide (`.claude/CLAUDE.md`) no longer calls wicked-loom a required peer. Proof: `WICKED_REQUIRE_E2E=1 python3 -m pytest tests/qe/test_prove.py tests/e2e/test_e2e_gate_cli.py` → 17 passed with only `wicked-vault` on PATH.
18
+
19
+ ## [12.43.0] — 2026-10-05
20
+
21
+ ### Fixed
22
+ - **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).
23
+ - **`_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.
24
+
25
+ ### Changed
26
+ - **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"`.
27
+
5
28
  ## [12.42.0] — 2026-10-03
6
29
 
7
30
  ### Changed
@@ -971,7 +994,9 @@ through `v9.2.18`. Major themes across that span:
971
994
 
972
995
  For each of those releases, see the corresponding tag in git history.
973
996
 
974
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.42.0...HEAD
997
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.44.0...HEAD
998
+ [12.44.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.43.0...v12.44.0
999
+ [12.43.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.42.0...v12.43.0
975
1000
  [12.42.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.41.0...v12.42.0
976
1001
  [12.41.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.40.0...v12.41.0
977
1002
  [12.40.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.39.0...v12.40.0
@@ -4,8 +4,8 @@ wicked-garden is installed into Claude Code, Codex, OpenCode, Pi and Antigravity
4
4
  parties and snapshotted by wicked-crew for every seat. Only Claude Code reads Claude Code's
5
5
  plugin frontmatter — so a skill's identity, dispatch shape and prose must not depend on it.
6
6
  This page is the format; `scripts/_skill_meta.py` is its one reader; the lint
7
- (`tests/test_skill_portability.py`) enforces it; `tests/cross_cli_baseline.json` lists what
8
- still has to be translated (12.37.x batches; the strict release deletes the baseline).
7
+ (`tests/test_skill_portability.py`) enforces it, strictly: the 12.37.x translation batches are
8
+ done and the baseline they burned down was deleted (B18), so every hit fails.
9
9
 
10
10
  ## Frontmatter: the closed key set
11
11
 
@@ -75,8 +75,8 @@ generically instead.
75
75
 
76
76
  ## Products and size
77
77
 
78
- `wicked-testing`, `wicked-brain`, `wicked-signals` are retired and `wicked-loom` is being replaced — the skill TEXT retires the `wicked-loom` reference ahead of the peer itself (`wicked-loom` stays the prove gate's CI peer in `test.yml` / `tests/qe/test_prove.py` until the prove/loom-cutover seam removes it; DES-L6 B6 / review F3) —
79
- by the in-process gate — name their successors (the `qe` domain, wicked-estate through the `mem` /
78
+ `wicked-testing`, `wicked-brain`, `wicked-signals` are retired and `wicked-loom` was replaced (CI no longer installs it; DES-L6 B6 / review F3)
79
+ by the in-process gate (`scripts/loom/`) — name their successors (the `qe` domain, wicked-estate through the `mem` /
80
80
  `search` skills, the built-in gate) or mark the line `<!-- historical -->` (`retired-product-ref`).
81
81
  A SKILL.md stays under 500 lines and 24 KiB (`skill-too-large`; `scripts/ci/validate.py` applies
82
82
  the same bound to routers and modules); detail goes in `refs/`. Skill-relative paths, the
@@ -84,9 +84,7 @@ the same bound to routers and modules); detail goes in `refs/`. Skill-relative p
84
84
 
85
85
  ## Tooling
86
86
 
87
- - `python3 tests/test_skill_portability.py` — the report (0 violations, 0 stale baseline entries).
88
- - `python3 tests/test_skill_portability.py --write-baseline` — regenerate the baseline **at B0
89
- only**; afterwards the file only shrinks (a batch deletes the entries it translated; a stale entry
90
- fails CI; B18 deletes the file).
87
+ - `python3 tests/test_skill_portability.py` — the report (0 violations). There is no baseline: it
88
+ was deleted at B18 and `test_cross_cli_baseline_is_gone` fails if one comes back.
91
89
  - `python3 scripts/ci/validate.py && python3 scripts/_validate_registry.py` — the release gate,
92
90
  role-aware.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.42.0",
3
+ "version": "12.44.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
@@ -0,0 +1,248 @@
1
+ // Walkthrough storyline lint (DES-walkthrough-proof §4.5; wicked-garden#1231).
2
+ //
3
+ // wicked-garden run scripts/demo/walkthrough.mjs lint --root <author dir> [--storyline <path>]
4
+ // [--tree <dir>] [--steps <id,id,...>]
5
+ //
6
+ // The `walkthrough_plan` step's pinned validator runs this against the author dir the engine hands it
7
+ // (wicked-core `WALKTHROUGH_LINT_SCRIPT`: `--root "${WICKED_EVIDENCE_ROOT}"`, where that root is
8
+ // `<evidence root>/author/<plan step>`). It reads `<root>/storyline.mjs`, prints one JSON object
9
+ // `{ok, storyline, chapters, checks, findings: [{rule, where, detail}]}` on stdout and exits 0 only when
10
+ // there is no finding; 1 when there is at least one; 2 on a usage error.
11
+ //
12
+ // A check's `kind` is the record tool's collector (`runCollector` in walkthrough.mjs). The DES's check
13
+ // classes map onto them: locator = on_screen, probe/artifact = saved_state/events/side_effects/output,
14
+ // guard = must_not_happen, join = cross_check.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { spawnSync } from "node:child_process";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ export const CHECK_CLASS = Object.freeze({
22
+ __proto__: null,
23
+ locator: "on_screen",
24
+ probe: "state",
25
+ artifact: "state",
26
+ guard: "must_not_happen",
27
+ join: "cross_check",
28
+ });
29
+
30
+ /** The verifier the record tool hands the vault for a check (kept in step with walkthrough.mjs verifierForCheck). */
31
+ export function defaultVerifier(kind) {
32
+ switch (kind) {
33
+ case "locator": return { kind: "jq_pred", params: { expr: ".inner_text != null" } };
34
+ case "probe": return { kind: "jq_pred", params: { expr: ".raw.exit_code == 0" } };
35
+ case "artifact": return { kind: "jq_pred", params: { expr: ".sha256 != null" } };
36
+ case "guard": return { kind: "jq_pred", params: { expr: "(.foreign_writes | length) == 0 and ((.console_errors // []) | length) == 0" } };
37
+ case "join": return { kind: "jq_pred", params: { expr: ".joined | to_entries | all(.value != null)" } };
38
+ default: return null;
39
+ }
40
+ }
41
+
42
+ const SEGMENT_KEY = /^[a-z0-9][a-z0-9-]*$/;
43
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
44
+ const PLAIN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
45
+ // Env names a storyline may not hand the fixture: credentials never reach the jailed app (§7).
46
+ const SECRET_ENV = /(SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL|AUTH)/i;
47
+ // Inline-code flags: `node -e`, `python -c`, `sh -c`, ... — the fixture must be a script the tree declares.
48
+ const INLINE_FLAGS = new Set(["-e", "-c", "--eval", "-p", "--print", "--command"]);
49
+ const SCRIPT_LIKE = /\.(?:m?js|cjs|ts|mts|py|sh|rb|pl)$/i;
50
+
51
+ /** The first line of a thrown value's message; never throws itself (a storyline can throw anything). */
52
+ function why(e) {
53
+ try { return String(e?.message ?? e).split("\n")[0]; } catch { return "the storyline threw a value that cannot be read"; }
54
+ }
55
+
56
+ function hasDotDot(p) { return /(?:^|[\\/])\.\.(?:[\\/]|$)/.test(p); }
57
+
58
+ /**
59
+ * Lint a loaded storyline object. Pure apart from running `jq` for the negative samples.
60
+ * @param {object} story the storyline's default export
61
+ * @param {{tree?: string|null, steps?: string[]|null, jq?: string}} opts
62
+ * @returns {{findings: {rule: string, where: string, detail: string}[], chapters: number, checks: number}}
63
+ */
64
+ export function lintStoryline(story, { tree = null, steps = null, jq = "jq" } = {}) {
65
+ const findings = [];
66
+ const add = (rule, where, detail) => findings.push({ rule, where, detail });
67
+
68
+ if (!story || typeof story !== "object") {
69
+ add("storyline_shape", "default export", "the storyline's default export must be an object");
70
+ return { findings, chapters: 0, checks: 0 };
71
+ }
72
+
73
+ // ---- baseUrl + fixture ------------------------------------------------------------------------------
74
+ if (story.baseUrl !== "fixture") {
75
+ add("base_url", "baseUrl", `baseUrl must be "fixture" (the record tool films the fixture it starts), got ${JSON.stringify(story.baseUrl)}`);
76
+ }
77
+ const fixture = story.fixture;
78
+ if (!fixture || typeof fixture !== "object") {
79
+ add("fixture_start", "fixture", "fixture is required: { start: [argv], ready, probes?, env? }");
80
+ } else {
81
+ const start = fixture.start;
82
+ if (!Array.isArray(start) || start.length === 0 || !start.every((a) => typeof a === "string" && a.length > 0)) {
83
+ add("fixture_start", "fixture.start", "fixture.start must be a non-empty argv array of strings (no shell)");
84
+ } else {
85
+ for (const arg of start.slice(1)) {
86
+ if (INLINE_FLAGS.has(arg)) add("fixture_start", "fixture.start", `inline code (${arg}) is refused: start a script the tree declares`);
87
+ }
88
+ for (const arg of start) {
89
+ if (path.isAbsolute(arg) || /^[A-Za-z]:[\\/]/.test(arg)) add("fixture_start", "fixture.start", `${JSON.stringify(arg)} is an absolute path; the fixture runs from app/ (the tree)`);
90
+ else if (hasDotDot(arg)) add("fixture_start", "fixture.start", `${JSON.stringify(arg)} leaves app/ (the tree)`);
91
+ }
92
+ if (tree) {
93
+ for (const arg of start.slice(1)) {
94
+ if (arg.startsWith("-") || path.isAbsolute(arg) || hasDotDot(arg)) continue;
95
+ if (!(arg.includes("/") || SCRIPT_LIKE.test(arg))) continue;
96
+ if (!fs.existsSync(path.join(tree, arg))) add("fixture_start", "fixture.start", `${JSON.stringify(arg)} is not in the tree (${tree})`);
97
+ }
98
+ }
99
+ }
100
+ if (fixture.env !== undefined) {
101
+ if (!fixture.env || typeof fixture.env !== "object" || Array.isArray(fixture.env)) {
102
+ add("fixture_env", "fixture.env", "fixture.env must be an object of name: value");
103
+ } else {
104
+ for (const name of Object.keys(fixture.env)) {
105
+ if (SECRET_ENV.test(name)) add("secret_env", `fixture.env.${name}`, "a secret-shaped env name is refused: no credential reaches the jailed app");
106
+ }
107
+ }
108
+ }
109
+ if (fixture.probes !== undefined && (!fixture.probes || typeof fixture.probes !== "object" || Array.isArray(fixture.probes))) {
110
+ add("fixture_probes", "fixture.probes", "fixture.probes must be an object of name: argv");
111
+ }
112
+ }
113
+ const probes = fixture && typeof fixture.probes === "object" && fixture.probes ? fixture.probes : {};
114
+
115
+ // ---- segments ---------------------------------------------------------------------------------------
116
+ const segments = Array.isArray(story.segments) ? story.segments : null;
117
+ if (!segments) {
118
+ add("segments", "segments", "segments must be an array");
119
+ return { findings, chapters: 0, checks: 0 };
120
+ }
121
+ const chapters = segments.filter((s) => s && !s.intro);
122
+ if (chapters.length === 0) add("segments", "segments", "the storyline has no chapter (an empty plan proves nothing)");
123
+
124
+ const seenKeys = new Set();
125
+ let checkCount = 0;
126
+ let mustNotHappen = 0;
127
+
128
+ segments.forEach((seg, i) => {
129
+ const where = `segments[${i}]${seg && typeof seg.key === "string" ? ` (${seg.key})` : ""}`;
130
+ if (!seg || typeof seg !== "object") { add("segment_shape", where, "a segment must be an object"); return; }
131
+ if (!SEGMENT_KEY.test(String(seg.key ?? ""))) add("segment_key", where, `key ${JSON.stringify(seg.key)}: lowercase letters, digits and hyphens only`);
132
+ else if (seenKeys.has(seg.key)) add("segment_key", where, `key ${JSON.stringify(seg.key)} is used twice`);
133
+ seenKeys.add(seg.key);
134
+ if (typeof seg.run !== "function") add("segment_run", where, "run(ctx) must be a function");
135
+ if (seg.intro) return;
136
+
137
+ // proves: plan step ids
138
+ if (!Array.isArray(seg.proves) || seg.proves.length === 0) {
139
+ add("proves", where, "proves[] must name at least one plan step id");
140
+ } else {
141
+ for (const id of seg.proves) {
142
+ if (typeof id !== "string" || !PLAIN_ID.test(id)) add("proves", where, `proves id ${JSON.stringify(id)} is not a plain step id`);
143
+ else if (steps && !steps.includes(id)) add("proves", where, `proves id ${JSON.stringify(id)} is not a step of the plan (${steps.join(", ")})`);
144
+ }
145
+ }
146
+
147
+ const checks = Array.isArray(seg.checks) ? seg.checks : null;
148
+ if (!checks || checks.length === 0) { add("chapter_checks", where, "a chapter needs checks[]"); return; }
149
+ const byId = new Map();
150
+ const classes = { on_screen: [], state: [], must_not_happen: [], cross_check: [] };
151
+
152
+ checks.forEach((c, j) => {
153
+ const cw = `${where}.checks[${j}]${c && typeof c.id === "string" ? ` (${c.id})` : ""}`;
154
+ if (!c || typeof c !== "object") { add("check_shape", cw, "a check must be an object"); return; }
155
+ checkCount += 1;
156
+ if (!SAFE_ID.test(String(c.id ?? ""))) add("check_id", cw, `id ${JSON.stringify(c.id)}: letters, digits, '.', '_' and '-' only`);
157
+ else if (byId.has(c.id)) add("check_id", cw, `id ${JSON.stringify(c.id)} is used twice in this chapter`);
158
+ const cls = typeof c.kind === "string" && Object.hasOwn(CHECK_CLASS, c.kind) ? CHECK_CLASS[c.kind] : null;
159
+ if (!cls) { add("check_kind", cw, `kind ${JSON.stringify(c.kind)} is not one of ${Object.keys(CHECK_CLASS).join(", ")}`); return; }
160
+ classes[cls].push(c.id);
161
+ if (cls === "must_not_happen") mustNotHappen += 1;
162
+
163
+ // Collector inputs
164
+ if (c.kind === "locator" && (typeof c.selector !== "string" || !c.selector.trim())) add("check_selector", cw, "a locator check needs a selector");
165
+ if (c.kind === "probe") {
166
+ if (typeof c.name !== "string" || !c.name) add("check_probe", cw, "a probe check needs name (a key of fixture.probes)");
167
+ else if (!Object.prototype.hasOwnProperty.call(probes, c.name)) add("check_probe", cw, `probe ${JSON.stringify(c.name)} is not in fixture.probes`);
168
+ if (c.parse !== undefined && !["json", "jsonl", "sqlite-json", "text"].includes(c.parse)) add("check_probe", cw, `parse ${JSON.stringify(c.parse)} is not json | jsonl | sqlite-json | text`);
169
+ }
170
+ if (c.kind === "artifact") {
171
+ const p = c.path;
172
+ if (typeof p !== "string" || !p) add("check_artifact", cw, "an artifact check needs path (relative to DATA_DIR)");
173
+ else if (path.isAbsolute(p) || hasDotDot(p)) add("check_artifact", cw, `artifact path ${JSON.stringify(p)} escapes DATA_DIR`);
174
+ }
175
+ if (c.kind === "join") {
176
+ const src = Array.isArray(c.sources) ? c.sources : [];
177
+ if (src.length < 2) add("check_join", cw, "a join needs sources[] naming at least two earlier checks");
178
+ for (const s of src) if (!byId.has(s)) add("check_join", cw, `source ${JSON.stringify(s)} is not an earlier check of this chapter`);
179
+ }
180
+
181
+ // Verifier + negative samples (S3): the verifier must FAIL on every sample.
182
+ const verifier = c.verify === undefined ? defaultVerifier(c.kind) : c.verify;
183
+ const expr = verifier?.params?.expr;
184
+ if (!verifier || verifier.kind !== "jq_pred" || typeof expr !== "string" || !expr.trim()) {
185
+ add("check_verify", cw, "verify must be { kind: \"jq_pred\", params: { expr } } — the one verifier the record tool hands the vault");
186
+ } else {
187
+ const negs = c.negative;
188
+ if (!Array.isArray(negs) || negs.length === 0) {
189
+ add("negative_missing", cw, "every check needs at least one negative sample (a capture its verifier must FAIL)");
190
+ } else {
191
+ negs.forEach((sample, k) => {
192
+ const r = spawnSync(jq, ["-e", expr], { input: JSON.stringify(sample), encoding: "utf8", timeout: 10_000 });
193
+ if (r.error) add("negative_unverified", `${cw}.negative[${k}]`, `jq could not run (${r.error.code ?? r.error.message}); the lint fails closed`);
194
+ else if (r.status === 0) add("negative_passes", `${cw}.negative[${k}]`, `the verifier \`${expr}\` PASSES this sample, so the check cannot fail (a tautology)`);
195
+ else if (r.status !== 1 && r.status !== 4) add("check_verify", cw, `jq rejected \`${expr}\` (exit ${r.status}): ${(r.stderr || "").trim().slice(0, 200)}`);
196
+ });
197
+ }
198
+ }
199
+ byId.set(c.id, c);
200
+ });
201
+
202
+ if (classes.on_screen.length === 0) add("chapter_on_screen", where, "a chapter needs at least one on_screen check (kind: locator)");
203
+ if (classes.state.length === 0) add("chapter_state", where, "a chapter needs at least one saved_state/events/side_effects/output check (kind: probe or artifact)");
204
+ if (classes.on_screen.length && classes.state.length) {
205
+ const joined = checks.some((c) => c?.kind === "join" && Array.isArray(c.sources)
206
+ && c.sources.some((s) => classes.on_screen.includes(s)) && c.sources.some((s) => classes.state.includes(s)));
207
+ if (!joined) add("chapter_cross_check", where, "a chapter with on_screen and state checks needs a join (cross_check) whose sources include one of each");
208
+ }
209
+ });
210
+
211
+ if (chapters.length && mustNotHappen === 0) add("must_not_happen", "segments", "the walkthrough needs at least one must_not_happen check (kind: guard)");
212
+
213
+ return { findings, chapters: chapters.length, checks: checkCount };
214
+ }
215
+
216
+ /** The `lint` action: resolve, import and lint the storyline; print the report; return the exit code. */
217
+ export async function lintAction({ root, storyline, tree, steps }) {
218
+ const storyPath = storyline ? path.resolve(storyline) : root ? path.join(path.resolve(root), "storyline.mjs") : null;
219
+ const report = (findings, extra = {}) => {
220
+ const out = { ok: findings.length === 0, storyline: storyPath, chapters: 0, checks: 0, ...extra, findings };
221
+ process.stdout.write(JSON.stringify(out) + "\n");
222
+ for (const f of findings) process.stderr.write(`lint: ${f.rule} at ${f.where}: ${f.detail}\n`);
223
+ return findings.length === 0 ? 0 : 1;
224
+ };
225
+ if (!storyPath) {
226
+ process.stderr.write("usage: walkthrough.mjs lint --root <author dir> [--storyline <path>] [--tree <dir>] [--steps <id,...>]\n");
227
+ return 2;
228
+ }
229
+ let st;
230
+ try { st = fs.statSync(storyPath); } catch {
231
+ return report([{ rule: "storyline_missing", where: storyPath, detail: "no storyline here: the walkthrough_plan author writes <author dir>/storyline.mjs" }]);
232
+ }
233
+ if (!st.isFile()) return report([{ rule: "storyline_missing", where: storyPath, detail: "not a regular file" }]);
234
+ let story;
235
+ try {
236
+ story = (await import(pathToFileURL(storyPath).href)).default;
237
+ } catch (e) {
238
+ return report([{ rule: "storyline_unloadable", where: storyPath, detail: why(e) }]);
239
+ }
240
+ let result;
241
+ try {
242
+ result = lintStoryline(story, { tree: tree ? path.resolve(tree) : null, steps });
243
+ } catch (e) {
244
+ // A storyline that throws while being read (a getter, a proxy) is a finding, never a crash without a report.
245
+ return report([{ rule: "storyline_unreadable", where: storyPath, detail: why(e) }]);
246
+ }
247
+ return report(result.findings, { chapters: result.chapters, checks: result.checks });
248
+ }