muse-crew 0.7.9 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,85 @@
1
- # The Visual Verdict — parent protocol
1
+ # The Visual Verdict — Hazel owns it
2
+
3
+ > **UPDATE (2026-09-15):** the parent post-change verdict protocol is
4
+ > **retired**. There is no `visual_verdict:` note contract, no
5
+ > `visualVerdictStatus()` gate, no `visual: pending` marker, and no parent
6
+ > capture/composition step. Hazel (the QA work agent) drives the artifact
7
+ > herself with `lib/see-act.js`, logs every step to the OODA report, and
8
+ > records her verdict machine-readably — the workflow trusts her verdict
9
+ > and never parks for a parent one. The baseline-capture protocol below is
10
+ > unchanged: baselines are still captured before the fix exists, which the
11
+ > workflow cannot do synchronously.
12
+
13
+ > **UPDATE (2026-09-14):** the agent-owned see-act loop superseded the
14
+ > blocked platform path. `artifact_inspect` was removed by the platform and
15
+ > `artifact.inspect` is malfunction diagnosis, not a substitute — but the
16
+ > workflow no longer needs either: the QA/repro agent drives the artifact
17
+ > itself with `lib/see-act.js` (one browser action per invocation, reads
18
+ > its own screenshots) against `lib/serve-artifact.js` (local server for the
19
+ > built artifact). Experiential tasks get agent-run visual inspection in
20
+ > the QA phase (standard, bugfix) and experiential repro (bugfix).
2
21
 
3
22
  QA owns the visual verdict for experiential artifact tasks: does the change
4
- *look* right, judged by Hazel's artist-eye rubric, from rendered evidence.
5
- This document is the parent-side runbook. The workflow script handles the
6
- mechanical halves; the parent handles everything that needs eyes.
23
+ *look* right, judged by Hazel's artist-eye rubric, from rendered evidence
24
+ she captured herself.
7
25
 
8
- ## Why the parent does it
26
+ ## Why Hazel does it herself
9
27
 
10
- Async `artifact_inspect` results are delivered to the root agent the
11
- parent not to the workflow run. A workflow script cannot synchronously
12
- await them, so the workflow cannot issue the visual verdict itself. The
13
- contract is split on purpose:
28
+ The old model was async: rendered post-change evidence was delivered to
29
+ the root agent the parent which then recorded a `visual_verdict:` note
30
+ the workflow gated on. That split the verdict across two systems and left
31
+ tasks parked whenever the parent path stalled.
14
32
 
15
- - **Workflow-owned:** the experiential flag, the Capture phase, the Map
16
- baseline gate, the mechanical QA checks, the visual-verdict gate that
17
- parks for a verdict instead of silently passing.
18
- - **Parent-owned:** triggering inspections, saving baseline/post-change
19
- evidence, generating composites, launching Hazel only after rendered
20
- evidence arrives, recording `visual_verdict`, and closing, reworking, or
21
- parking the task.
33
+ The new model is depth-1: the QA work agent IS the inspector. She starts
34
+ the local artifact server, drives it one browser step at a time, reads
35
+ every frame, edits and composes evidence when a raw screenshot is not
36
+ enough, and records her verdict. The contract is:
22
37
 
23
- No experiential artifact task completes without a recorded visual PASS.
24
- Missing inspection results park visibly. Rendering impossibility never
25
- passes.
38
+ - **Hazel-owned:** the experiential loop, the OODA report
39
+ (`ooda-log.jsonl` + archived frames + derivatives), `verdict.json` and
40
+ the append-only `verdicts.jsonl` ledger, the visual verdict itself.
41
+ - **Workflow-owned:** the experiential flag, the Capture phase, the Map
42
+ baseline gate, the mechanical QA checks, trusting Hazel's verdict.
43
+ - **Parent-owned:** baseline capture only (see below) — baselines are
44
+ captured before the fix exists, which the workflow cannot do
45
+ synchronously.
46
+
47
+ No experiential artifact task completes without Hazel's recorded verdict.
48
+ A missing OODA log is a broken report, not a pass. Unknown is neither
49
+ PASS nor FAIL — un-runnable checks are named as explicit evidence gaps.
50
+
51
+ ## The OODA report
52
+
53
+ Task evidence lives under `$CREW_HOME/task-evidence/<task-id>/<phase>/`
54
+ (`repro/` for bugfix Reproduce, `postchange/` for QA):
55
+
56
+ - `ooda-log.jsonl` — ordered steps with observations. Every step carries
57
+ an `--attempt` identity; steps are strictly monotonic within an attempt;
58
+ a rerun is a new attempt at step 1 — attempts accumulate, never
59
+ overwrite. Actions: `aria|shot|click|scroll|type` (browser) and
60
+ `crop|zoom|label|nup|compose` (evidence derivatives).
61
+ - Archived frames — `001-shot-desktop.png`, `002-click-mobile.png`, … via
62
+ `SEE_ACT_ARCHIVE_DIR`. A frame Hazel did not read is not evidence.
63
+ - `verdict.json` — the latest machine-readable verdict
64
+ (`{verdict, summary, expected, actual, missing_evidence}`).
65
+ - `verdicts.jsonl` — the append-only ledger: every attempt's verdict is
66
+ preserved with a mechanical sequence number, never overwritten.
67
+
68
+ ## Evidence editing and composition
69
+
70
+ When a raw screenshot is not enough evidence, Hazel has two deterministic
71
+ layers:
72
+
73
+ - **Pixel operations** (`lib/edit-image.py`, Pillow): `crop` (pixel-exact,
74
+ rejects out-of-bounds), `zoom` (nearest-neighbor, back to frame size),
75
+ `label` (caption bar), `nup` (side-by-side grid with labels).
76
+ - **Composition** (`lib/render-html.js`, headless Chromium): renders a
77
+ local HTML layout to PNG for rich before/after presentations with real
78
+ typography. Hermetic — remote assets are blocked and fail loudly.
79
+
80
+ Derivatives supplement, never replace: the source frame stays archived
81
+ and is named in the step's args. Every derivative is logged and read —
82
+ an unread image is not evidence.
26
83
 
27
84
  ## Evidence layout
28
85
 
@@ -31,16 +88,10 @@ Task evidence lives under:
31
88
  ```
32
89
  $CREW_HOME/task-evidence/<task-id>/
33
90
  baseline/ baseline captures (pre-change)
34
- postchange/ post-change captures
35
- composites/ side-by-side and difference images
91
+ postchange/ post-change captures + OODA report
92
+ repro/ bugfix reproduction captures + OODA report
36
93
  ```
37
94
 
38
- Capture filenames follow the deterministic frame used by the workflow's
39
- `buildVisualCapturePlan` output: `<kind>-<n>-<viewport>-<state>.png`
40
- (e.g. `baseline-01-desktop1440x900-top.png`). The composites are named
41
- `<stem>-sidebyside.png` and `<stem>-overlay.png` by
42
- `lib/compose-evidence.py`.
43
-
44
95
  ## Baseline capture protocol
45
96
 
46
97
  Triggered by the Capture phase when the task is experiential and no
@@ -69,33 +120,6 @@ baseline evidence is recorded yet. The workflow logs
69
120
  The workflow requests at most two attempts; after two it records
70
121
  `baseline: none (capture unavailable after 2 attempts)` itself.
71
122
 
72
- ## Post-change capture, composition, Hazel, verdict
73
-
74
- After the QA step completes its mechanical checks, the QA session notes
75
- carry `visual: pending` and the deterministic `capture_plan:` — the parent
76
- then:
77
-
78
- 1. Trigger the post-change inspection with the `capture_plan:` frame from
79
- the QA session notes. Save captures under `postchange/`.
80
- 2. Run `python3 lib/compose-evidence.py <baseline-dir> <postchange-dir>
81
- <composites-dir>` and keep its `PAIR...` / `SKIP...` manifest output as
82
- evidence refs.
83
- 3. Launch Hazel with `identities/hazel.md` (artist-eye rubric + verdict
84
- discipline), the baseline refs, the post-change refs, and the composite
85
- manifest. She receives rendered evidence only — never a prose
86
- description of what the change "looks like".
87
- 4. Record her verdict as a task note event:
88
- `visual_verdict: PASS <composite refs>` or
89
- `visual_verdict: FAIL <reason> <refs>`.
90
- 5. Routing:
91
- - **PASS** — the task is done; close it.
92
- - **FAIL** — send the task back to Build as budgeted rework, with the
93
- FAIL reason and refs as the rejection notes.
94
- - **FAIL with reason beginning `rendering impossible:`** — the target
95
- cannot be rendered at all (never a rework loop). Park the task for
96
- human attention. A verdict can never be issued without rendered
97
- evidence; impossibility fails, it never passes.
98
-
99
123
  ## Exact note-event prefixes
100
124
 
101
125
  Case-sensitive, exact-prefix matches — the workflow matches on prefixes,
@@ -105,16 +129,6 @@ never on English meaning:
105
129
  - `baseline: captured <refs>` — parent, after saving baseline captures.
106
130
  - `baseline: none (<reason>)` — baseline not capturable; final QA judges
107
131
  on the rubric alone and states that explicitly.
108
- - `visual_verdict: PASS <composite refs>` — parent, after Hazel's verdict.
109
- - `visual_verdict: FAIL <reason> <refs>`reason beginning exactly
110
- `rendering impossible:` parks for a human; any other reason reworks at
111
- Build within the shared budget.
112
-
113
- ## Recovery: rebuilding the capture plan
114
-
115
- If the QA session notes are truncated (summary cap) and `capture_plan:`
116
- is lost, the frame is deterministic — rebuild it from the workflow
117
- source: `buildVisualCapturePlan(taskTitle, taskDescription,
118
- "postchange", captureTargets)` in `workflows/standard.js` (identical in
119
- `bugfix.js` and `chore.js`). Capture targets come from the Map step's
120
- `capture_targets:` marker line; fall back to the task description.
132
+
133
+ The `visual_verdict:` prefixes are retired (2026-09-15) Hazel's verdict
134
+ lives in `verdict.json` / `verdicts.jsonl`, not in note events.
package/lib/AGENTS.md CHANGED
@@ -14,3 +14,10 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
14
14
  - `test-publish-skip.sh` — regression tests for the no-lock graceful publish skip (park 2026-09-11): `lock-status` returns exact `UNLOCKED`/`LOCKED by <holder> since <ts> (pid <pid>)`; a fake empty-diff integrate takes the MERGED_EMPTY branch and never takes the lock; the shipped step-5 block from publish-npm.sh (extracted by anchor) skips gracefully on UNLOCKED (no park, no `PUBLISH_COMPLETE`), follows the refresh path when a lock is held, and fails closed on a `lock-status` query failure; the gate sits inside the ALREADY_PUBLISHED=0 branch and mutation steps are gated on NO_LOCK_HELD=0
15
15
  - `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
16
16
  - `compose-evidence.py` — deterministic visual-evidence compositor (Pillow): pairs identical PNG stems from baseline/ and postchange/ dirs, emits `<stem>-sidebyside.png` and amplified-difference `<stem>-overlay.png` into composites/, prints `PAIR`/`SKIP` manifests. Byte-deterministic; nonzero exit on errors.
17
+ - `compose-evidence-caption.js` — deterministic QA-evidence caption composer (2026-09-14): given `--crew-home`, `--task-id`, and the resolved `--audit-dir` name, extracts the task title/description, the latest `visual_verdict:` note, the `publish: verified` commit (or the global provenance record when it belongs to this task), and the merge commit's subject line from the project repo — and prints the out-of-context transition Eric asked for (problem → solution → evidence handoff) to stdout. The caller appends the desktop/mobile attachment lines after this caption. Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note when the task's own run recorded `baseline: none`. Exits non-zero on missing input.
18
+ - `edit-image.py` — deterministic pixel-level evidence editing (2026-09-15, Pillow): `crop --region x,y,w,h` (pixel-exact, rejects out-of-bounds), `zoom --factor <f> [--center x,y]` (nearest-neighbor, returns to source frame size), `label --text <caption>` (caption bar), `nup --cols <n> --in <a.png> --in <b.png> [--labels "a|b"]` (side-by-side grid). Byte-deterministic PNG output; no network, no time, no randomness. Derivatives supplement raw frames — the source frame stays archived.
19
+ - `render-html.js` — deterministic HTML evidence composition (2026-09-15, headless Chromium): renders a local HTML layout to PNG (fixed viewport width, device scale 1, full-page screenshot). Hermetic: remote HTTP(S) assets are blocked and fail the render loudly; relative image paths resolve against the HTML file; local/system fonts only. Exit 3 reports NOT POSSIBLE when Chromium is unavailable. The composition layer above edit-image.py's pixel layer.
20
+ - `see-act.js` — single-step browser driver for experiential QA (2026-09-14): one browser action per invocation (`aria`, `shot`, `click`, `scroll`, `type`), one JSON line on stdout, exit 0/2/3 (3 = NOT POSSIBLE: missing playwright-core or Chromium). Spawns its own loopback forward proxy on an ephemeral port (Chromium blocks direct loopback). The QA work agent closes the OODA loop: run a step, read the screenshot/aria, decide the next. Determinism: no wall-clock reads, no randomness. `SEE_ACT_ARCHIVE_DIR=<phase-dir>` (2026-09-14): every screenshot is archived automatically as `001-shot-desktop.png`, `002-click-mobile.png`, ... (counter in `<dir>/.seq`); `--out` becomes optional; JSON carries `screenshot` + `archived`; explicit `--out` + archive copies into the archive; unusable dir is NOT POSSIBLE exit 3.
21
+ - `append-ooda-step.js` — deterministic writer for the OODA report log (2026-09-14, attempt identity 2026-09-15): `node append-ooda-step.js --log <path> --attempt <id> --step <n> --action <a> --exit <code> [--args <json>] [--screenshot <path>] [--observation <text>]` appends one JSON line to `<phase-dir>/ooda-log.jsonl` (`{step, attempt, action, args, exit, screenshot|null, observation}`). `--attempt` is required; steps must be strictly monotonic within an attempt (a rerun is a new attempt at step 1 — attempts accumulate, never overwrite). Actions: browser (`aria|shot|click|scroll|type`) and image (`crop|zoom|label|nup|compose` — the last five log evidence derivatives made with `lib/edit-image.py` / `lib/render-html.js`; frame-producing actions require `--screenshot`). Corrupt logs or sequence gaps fail loudly (exit 2). No wall-clock reads, no randomness.
22
+ - `write-ooda-verdict.js` — deterministic writer for the OODA terminal verdict (2026-09-14, append-only ledger 2026-09-15): `node write-ooda-verdict.js --dir <phase-dir> --attempt <id> --verdict <PASS|FAIL|NOT_POSSIBLE> --summary <text> --expected <text> --actual <text> --missing <json-array>` writes `<phase-dir>/verdict.json` (the latest verdict) and appends one JSON line to `<phase-dir>/verdicts.jsonl` — the append-only ledger: every attempt's verdict is preserved with a mechanical `seq`, never overwritten; corrupt or non-contiguous ledgers fail loudly. Exit 2 on bad input. See `docs/ooda-report.md`.
23
+ - `serve-artifact.js` — local server for a built TS space for experiential QA (2026-09-14): serves `<space-dir>/client/dist` statically and dispatches POST `*/actions` to the compiled server actions with a locally-built Ctx. Prints `READY port=<n>` then serves until killed. Read-only w.r.t. the space directory. Fidelity: the served client and action handlers are the artifact's own built code; the Ctx is locally built (privileged handlers run from the space's own `server/dist/privileged.js` when present; blobs are stored in a per-run temp dir and served back at `/__blobs/<key>`); environment is inherited from the caller. It is not the hosted runtime — tasks that cannot be judged under it must report `NOT POSSIBLE: <reason>`.
@@ -0,0 +1,167 @@
1
+ // append-ooda-step.js — deterministic writer for the OODA report log.
2
+ //
3
+ // The QA/repro agent runs the see-act loop itself; after each step it logs
4
+ // what it did and what it observed. This script owns the schema so the agent
5
+ // never hand-writes JSON: it supplies the fields as flags, the script
6
+ // validates and appends one JSON line.
7
+ //
8
+ // Usage:
9
+ // node append-ooda-step.js --log <path> --attempt <id> --step <n>
10
+ // --action <a> --exit <code>
11
+ // [--args <json>] [--screenshot <path>] [--observation <text>] [--ts <iso>]
12
+ //
13
+ // --attempt is the attempt/run identity (e.g. "1", "2" for successive QA
14
+ // loops; "repro-1" for a reproduce run). Steps are strictly monotonic PER
15
+ // ATTEMPT: the first step of an attempt is 1 and each following step is
16
+ // exactly previous+1. Retries never overwrite — they start a new attempt,
17
+ // so the log preserves every attempt's full sequence. A step that is not
18
+ // exactly previous+1 fails loudly (exit 2) and is not appended.
19
+ //
20
+ // Actions: aria, shot, click, scroll, type (see-act driver) plus the image
21
+ // tools: crop, zoom, label, nup (edit-image.py) and compose (render-html.js).
22
+ // Every action that produces a frame must carry --screenshot; a captured
23
+ // frame that is not logged is invisible to everyone after the agent.
24
+ //
25
+ // The log is <phase-dir>/ooda-log.jsonl, one object per line:
26
+ // {step, attempt, action, args, exit, screenshot|null, observation, ts?}
27
+ // Together with the archived screenshots and verdicts.jsonl, the log is the
28
+ // task's OODA report — the complete experiential record of the session.
29
+ //
30
+ // Exit 0 on success, 2 on bad input. Determinism: no wall-clock reads, no
31
+ // randomness; ts comes only from --ts (the agent's own clock) and is omitted
32
+ // when not passed.
33
+ "use strict";
34
+
35
+ const { appendFileSync, mkdirSync, readFileSync, existsSync } = require("node:fs");
36
+ const { dirname, resolve } = require("node:path");
37
+
38
+ const ACTIONS = {
39
+ aria: 1, shot: 1, click: 1, scroll: 1, type: 1,
40
+ crop: 1, zoom: 1, label: 1, nup: 1, compose: 1,
41
+ };
42
+
43
+ // Actions that produce a frame the agent must have looked at.
44
+ const FRAME_ACTIONS = { shot: 1, crop: 1, zoom: 1, label: 1, nup: 1, compose: 1 };
45
+
46
+ function fail(msg) {
47
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + "\n");
48
+ process.exit(2);
49
+ }
50
+
51
+ function parseArgs(argv) {
52
+ const out = {};
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (a === "--log") out.log = argv[++i];
56
+ else if (a === "--attempt") out.attempt = argv[++i];
57
+ else if (a === "--step") out.step = argv[++i];
58
+ else if (a === "--action") out.action = argv[++i];
59
+ else if (a === "--exit") out.exit = argv[++i];
60
+ else if (a === "--args") out.args = argv[++i];
61
+ else if (a === "--screenshot") out.screenshot = argv[++i];
62
+ else if (a === "--observation") out.observation = argv[++i];
63
+ else if (a === "--ts") out.ts = argv[++i];
64
+ else fail("unknown flag: " + a);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ // Read the existing log's max step for this attempt. Malformed lines fail
70
+ // loudly — a corrupt log is evidence of a broken run, not something to skip.
71
+ function maxStepForAttempt(logPath, attempt) {
72
+ if (!existsSync(logPath)) return 0;
73
+ const raw = readFileSync(logPath, "utf8");
74
+ let max = 0;
75
+ let seen = false;
76
+ for (const line of raw.split("\n")) {
77
+ if (!line.trim()) continue;
78
+ let entry;
79
+ try {
80
+ entry = JSON.parse(line);
81
+ } catch (e) {
82
+ fail("existing log is corrupt (unparseable line): " + logPath);
83
+ }
84
+ if (entry && entry.attempt === attempt) {
85
+ seen = true;
86
+ if (!Number.isInteger(entry.step) || entry.step < 1) {
87
+ fail("existing log is corrupt (bad step for attempt " + attempt + "): " + logPath);
88
+ }
89
+ if (entry.step > max) max = entry.step;
90
+ }
91
+ }
92
+ // An attempt with entries must have a contiguous 1..max sequence; a gap
93
+ // means the log was hand-edited or a step was lost — fail loudly.
94
+ if (seen) {
95
+ const steps = new Set();
96
+ for (const line of raw.split("\n")) {
97
+ if (!line.trim()) continue;
98
+ const entry = JSON.parse(line);
99
+ if (entry && entry.attempt === attempt) steps.add(entry.step);
100
+ }
101
+ for (let n = 1; n <= max; n++) {
102
+ if (!steps.has(n)) fail("existing log has a gap at step " + n + " for attempt " + attempt + ": " + logPath);
103
+ }
104
+ }
105
+ return max;
106
+ }
107
+
108
+ function main() {
109
+ const args = parseArgs(process.argv.slice(2));
110
+ if (!args.log) fail("missing --log <path>");
111
+ if (args.attempt === undefined || String(args.attempt).trim() === "") {
112
+ fail("missing --attempt <id> (the attempt/run identity, e.g. \"1\", \"2\", \"repro-1\")");
113
+ }
114
+ if (args.step === undefined) fail("missing --step <n>");
115
+ if (!args.action) fail("missing --action <aria|shot|click|scroll|type|crop|zoom|label|nup|compose>");
116
+ if (args.exit === undefined) fail("missing --exit <code>");
117
+
118
+ const attempt = String(args.attempt).trim();
119
+ const step = parseInt(args.step, 10);
120
+ if (!Number.isFinite(step) || step < 1 || String(step) !== String(args.step).trim()) {
121
+ fail("--step must be a positive integer, got: " + args.step);
122
+ }
123
+ if (!ACTIONS[args.action]) fail("unknown --action: " + args.action);
124
+ const exit = parseInt(args.exit, 10);
125
+ if (!Number.isFinite(exit)) fail("--exit must be an integer, got: " + args.exit);
126
+
127
+ let parsedArgs = {};
128
+ if (args.args !== undefined) {
129
+ try {
130
+ parsedArgs = JSON.parse(args.args);
131
+ } catch (e) {
132
+ fail("--args must be valid JSON: " + e.message);
133
+ }
134
+ if (parsedArgs === null || typeof parsedArgs !== "object" || Array.isArray(parsedArgs)) {
135
+ fail("--args must be a JSON object");
136
+ }
137
+ }
138
+
139
+ if (FRAME_ACTIONS[args.action] && !args.screenshot) {
140
+ fail("--action " + args.action + " produces a frame: --screenshot is required (log the frame you looked at)");
141
+ }
142
+
143
+ const logPath = resolve(args.log);
144
+ mkdirSync(dirname(logPath), { recursive: true });
145
+
146
+ const expected = maxStepForAttempt(logPath, attempt) + 1;
147
+ if (step !== expected) {
148
+ fail("--step must be exactly previous+1 for attempt \"" + attempt + "\": expected " + expected + ", got " + step +
149
+ " (retries start a NEW --attempt; they never renumber or overwrite)");
150
+ }
151
+
152
+ const entry = {
153
+ step: step,
154
+ attempt: attempt,
155
+ action: args.action,
156
+ args: parsedArgs,
157
+ exit: exit,
158
+ screenshot: args.screenshot ? resolve(args.screenshot) : null,
159
+ observation: args.observation || "",
160
+ };
161
+ if (args.ts) entry.ts = args.ts;
162
+
163
+ appendFileSync(logPath, JSON.stringify(entry) + "\n");
164
+ process.stdout.write(JSON.stringify({ ok: true, log: logPath, attempt: attempt, step: step }) + "\n");
165
+ }
166
+
167
+ main();
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // build-readback-request.js — deterministic builder for the parent publish
3
+ // content read-back inspection request.
4
+ //
5
+ // The parent (tick worker) never hand-writes the inspection prompt: this
6
+ // script builds the EXACT request from the merge commit's diff, so the
7
+ // read-back asks about the real expected change, not an LLM's paraphrase.
8
+ // Output is JSON on stdout: { verbatim_request }.
9
+ //
10
+ // Usage:
11
+ // node build-readback-request.js --repo-path <path> --commit <sha>
12
+ // --base <sha> --task-id <uuid> --slug <artifact-slug>
13
+ // [--build-agent-id <uuid>]
14
+ //
15
+ // --base is the previously-stamped provenance source_commit (the tree the
16
+ // last verification certified), or the empty-tree sha
17
+ // 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first publish with no
18
+ // prior stamp. It is REQUIRED because the merge commit's first parent is
19
+ // NOT necessarily the previously-published tree: push-time reconcile
20
+ // merges (2026-09-14, task 0c53af4e) put the task's own changes behind an
21
+ // intermediate merge, so `commit^1..commit` covers only the reconcile
22
+ // delta and the request would certify content it never checked.
23
+ //
24
+ // Exit codes: 0 ok · 2 usage/validation · 1 git/diff failure.
25
+
26
+ import { execFileSync } from "node:child_process";
27
+
28
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
29
+
30
+ function arg(name) {
31
+ const i = process.argv.indexOf(name);
32
+ if (i < 0 || i + 1 >= process.argv.length) {
33
+ process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: `${name} is required.` }) + "\n");
34
+ process.exit(2);
35
+ }
36
+ return process.argv[i + 1];
37
+ }
38
+
39
+ const repoPath = arg("--repo-path");
40
+ const commit = arg("--commit");
41
+ const base = arg("--base");
42
+ const taskId = arg("--task-id");
43
+ const slug = arg("--slug");
44
+ const buildAgentId = process.argv.includes("--build-agent-id")
45
+ ? arg("--build-agent-id") : null;
46
+
47
+ if (!/^[0-9a-f]{40}$/.test(commit)) {
48
+ process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: "commit must be a 40-char hex sha." }) + "\n");
49
+ process.exit(2);
50
+ }
51
+ if (!/^[0-9a-f]{40}$/.test(base)) {
52
+ process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: "base must be a 40-char hex sha." }) + "\n");
53
+ process.exit(2);
54
+ }
55
+ // The publish delta is base..commit. Refuse a base that is not an ancestor:
56
+ // certifying a diff against an unrelated tree is worse than no request.
57
+ if (base !== EMPTY_TREE) {
58
+ try {
59
+ execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
60
+ } catch (e) {
61
+ process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: `base ${base} is not an ancestor of commit ${commit} — refusing to build a request that could certify an unrelated tree.` }) + "\n");
62
+ process.exit(2);
63
+ }
64
+ }
65
+
66
+ let diff;
67
+ try {
68
+ diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
69
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
70
+ });
71
+ } catch (e) {
72
+ process.stderr.write(JSON.stringify({ ok: false, error: "git", message: `git diff failed: ${e.message}` }) + "\n");
73
+ process.exit(1);
74
+ }
75
+ if (!diff.trim()) {
76
+ process.stderr.write(JSON.stringify({ ok: false, error: "git", message: "empty diff for commit." }) + "\n");
77
+ process.exit(1);
78
+ }
79
+
80
+ // Parse the diff into per-file added/removed lines (the contract the
81
+ // verifier will check the inspector's findings against).
82
+ const files = [];
83
+ let current = null;
84
+ for (const line of diff.split("\n")) {
85
+ if (line.startsWith("diff --git")) {
86
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
87
+ current = { path: m ? m[2] : "unknown", added: [], removed: [] };
88
+ files.push(current);
89
+ } else if (current && line.startsWith("+") && !line.startsWith("+++")) {
90
+ current.added.push(line.slice(1));
91
+ } else if (current && line.startsWith("-") && !line.startsWith("---")) {
92
+ current.removed.push(line.slice(1));
93
+ }
94
+ }
95
+
96
+ const buildIdLine = buildAgentId
97
+ ? `Expected builder build agent_id: ${buildAgentId} (the artifact system's in-flight correlation ID for this publish attempt — not a durable post-completion identifier).\nReport the live build's agent_id as seen in artifact_status (or state explicitly that no build/agent_id is visible). If an expected agent_id is given above and the live one differs, say so exactly — the read-back may be inspecting a different build's output.\n\n`
98
+ : "";
99
+
100
+ const verbatim_request =
101
+ `Publish content read-back for task ${taskId}, merge commit ${commit}.
102
+ Expected change: the publish delta ${base}..${commit} (base = previously-stamped source commit, or the empty tree for a first publish).
103
+ The unified diff below was supposed to be applied to this artifact's source tree and deployed. Do NOT modify anything.
104
+ Do NOT rely on the builder's applied-changes report — it is derived from this same diff, so it cannot confirm the content. Read the artifact's CURRENT source directly.
105
+
106
+ ${buildIdLine}UNIFIED DIFF (expected change):
107
+ \`\`\`diff
108
+ ${diff}\`\`\`
109
+
110
+ For each file in the diff:
111
+ 1. Read the file's CURRENT content in the artifact source tree.
112
+ 2. Quote the exact current text of the regions around the changed lines.
113
+ 3. For every added (+) line in the diff, state whether that exact line is PRESENT in the current source.
114
+ 4. For every removed (-) line in the diff, state whether that exact line is ABSENT from the current source.
115
+ 5. Report build/deploy health and the console error count.
116
+
117
+ MACHINE-READABLE FINDINGS (REQUIRED — the parent parses this block mechanically; if it is missing or malformed the verification FAILS CLOSED as blocked):
118
+ For each file, emit exactly these lines, one per changed line, with the verdict at the end of the line:
119
+ FILE: <path>
120
+ ADDED: <exact added line from the diff> :: PRESENT
121
+ ADDED: <exact added line from the diff> :: ABSENT
122
+ REMOVED: <exact removed line from the diff> :: PRESENT
123
+ REMOVED: <exact removed line from the diff> :: ABSENT
124
+ END_FILE
125
+ Emit one ADDED line per (+) diff line and one REMOVED line per (-) diff line — no more, no fewer. The verdict is PRESENT if that exact line occurs in the file's current source, ABSENT otherwise.
126
+
127
+ Return the per-file present/absent findings with the quoted observed lines. Do not modify anything.
128
+ This read-back feeds the parent content-verification protocol (docs/publish-verification.md): the parent stamps provenance only when every added line is present and every removed line is absent.`;
129
+
130
+ process.stdout.write(JSON.stringify({ verbatim_request, files_changed: files.map((f) => f.path) }, null, 2) + "\n");
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // lib/compose-evidence-caption.js — deterministic QA-evidence caption composer.
3
+ //
4
+ // Screenshots delivered to chat can arrive out of context: the tick worker
5
+ // delivers them on its own cadence, detached from the run that produced them.
6
+ // This script builds the transition Eric asked for (2026-09-14): acknowledge
7
+ // the out-of-context arrival, state the problem the task addressed and the
8
+ // solution shipped, then hand off to the desktop/mobile attachments the
9
+ // caller appends AFTER this caption. Deterministic: every line is extracted
10
+ // mechanically from the Crew API, the task's events, and the merge commit —
11
+ // no LLM prose. The caller (seed/cron-body-template.md Step 6) prints this
12
+ // caption verbatim, then the two attachment lines, then logs delivery.
13
+ //
14
+ // Usage:
15
+ // node lib/compose-evidence-caption.js --crew-home <home> \
16
+ // --task-id <id> --audit-dir <resolved-dir-name>
17
+ //
18
+ // Prints the caption to stdout. Exits non-zero on any missing input.
19
+
20
+ const { execFileSync } = require("node:child_process");
21
+ const path = require("node:path");
22
+
23
+ function usage(msg) {
24
+ process.stderr.write("compose-evidence-caption: " + msg + "\n");
25
+ process.exit(1);
26
+ }
27
+
28
+ const argv = process.argv.slice(2);
29
+ const args = {};
30
+ for (let i = 0; i < argv.length; i++) {
31
+ if (argv[i].startsWith("--")) {
32
+ const k = argv[i].slice(2).replace(/-/g, "_");
33
+ args[k] = (argv[i + 1] && !argv[i + 1].startsWith("--")) ? argv[++i] : true;
34
+ }
35
+ }
36
+ const crewHome = args.crew_home;
37
+ const taskId = args.task_id;
38
+ const auditDir = args.audit_dir;
39
+ if (!crewHome) usage("--crew-home is required");
40
+ if (!taskId) usage("--task-id is required");
41
+ if (!auditDir) usage("--audit-dir is required");
42
+
43
+ const CLI = path.join(__dirname, "crew-api.js");
44
+
45
+ function api(command, payload) {
46
+ const out = execFileSync("node", [CLI, "--crew-home", crewHome, command, "--json", JSON.stringify(payload)], {
47
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
48
+ });
49
+ return JSON.parse(out);
50
+ }
51
+
52
+ function gitSubject(repoPath, commit) {
53
+ try {
54
+ return execFileSync("git", ["-C", repoPath, "log", "-1", "--format=%s", commit], {
55
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000,
56
+ }).trim();
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
61
+
62
+ function trimWords(s, max) {
63
+ s = (s || "").replace(/\s+/g, " ").trim();
64
+ if (s.length <= max) return s;
65
+ const cut = s.slice(0, max - 1);
66
+ const at = cut.lastIndexOf(" ");
67
+ return (at > 0 ? cut.slice(0, at) : cut) + "…";
68
+ }
69
+
70
+ let state;
71
+ try {
72
+ state = api("get-state", {});
73
+ } catch (e) {
74
+ usage("cannot read crew state: " + (e.message || e));
75
+ }
76
+ const task = (state.tasks || []).find((t) => t.id === taskId);
77
+ if (!task) usage("task " + taskId + " not found");
78
+
79
+ let events = [];
80
+ try {
81
+ events = api("get-events", { task_id: taskId, limit: 100 }).events || [];
82
+ } catch (e) {
83
+ usage("cannot read task events: " + (e.message || e));
84
+ }
85
+ const notes = events.filter((e) => e.type === "note");
86
+
87
+ // QA verdict: the workflow's own exact-prefix contract — the latest note
88
+ // whose message starts exactly "visual_verdict: PASS" / "visual_verdict: FAIL".
89
+ const verdictNote = [...notes].reverse()
90
+ .find((e) => /^visual_verdict:\s*(PASS|FAIL)/.test(e.message || ""));
91
+ const verdict = verdictNote
92
+ ? verdictNote.message.replace(/^visual_verdict:\s*/, "").trim()
93
+ : null;
94
+
95
+ // Provenance: this task's own `publish: verified` event first; the global
96
+ // provenance record only when it belongs to this task.
97
+ let commit = null;
98
+ const verifiedNote = [...notes].reverse()
99
+ .find((e) => (e.message || "").includes("publish: verified"));
100
+ if (verifiedNote) {
101
+ const m = /\b([0-9a-f]{40})\b/.exec(verifiedNote.message);
102
+ if (m) commit = m[1];
103
+ }
104
+ if (!commit) {
105
+ try {
106
+ const prov = api("get-provenance", {}).provenance;
107
+ if (prov && prov.task_id === taskId && /^[0-9a-f]{40}$/.test(prov.source_commit || "")) {
108
+ commit = prov.source_commit;
109
+ }
110
+ } catch {
111
+ // best-effort; absence is reported honestly below
112
+ }
113
+ }
114
+
115
+ // The solution shipped: the merge commit's subject line; fall back to the
116
+ // task title when the commit is unknown or unreadable.
117
+ let solution = "";
118
+ if (commit) {
119
+ const proj = (state.projects || []).find((p) => p.id === task.project);
120
+ if (proj && proj.repo_path) solution = gitSubject(proj.repo_path, commit);
121
+ }
122
+ if (!solution) solution = task.title;
123
+
124
+ const problem = trimWords(task.description || task.title, 280);
125
+ // The task's own run record is authoritative for the honest label: a
126
+ // `baseline: none` note means QA ran without the visual protocol.
127
+ const visualUnavailable = notes.some((e) => /baseline:\s*none/i.test(e.message || ""));
128
+
129
+ const lines = [];
130
+ lines.push("These screenshots are dropping in out of context — quick transition.");
131
+ lines.push("");
132
+ lines.push("**" + task.title + "**");
133
+ lines.push("Problem: " + problem);
134
+ lines.push("Shipped: " + trimWords(solution, 160) +
135
+ (commit ? " (commit `" + commit.slice(0, 12) + "`, parent-verified)" : " (provenance not yet stamped)"));
136
+ lines.push("Evidence: desktop + mobile captures below, taken by the platform audit harness after deploy (`" + auditDir + "`)." +
137
+ (verdict ? " QA verdict: " + trimWords(verdict, 200) + "." : " No QA verdict recorded."));
138
+ if (visualUnavailable) {
139
+ lines.push("Note: the visual protocol is unavailable — these are the post-deploy capture plus QA's mechanical checks, not a QA visual inspection.");
140
+ }
141
+ process.stdout.write(lines.join("\n") + "\n");