muse-crew 0.7.10 → 0.7.12

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/lib/AGENTS.md CHANGED
@@ -14,3 +14,11 @@ 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> [--reason <text>]` 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. `--reason` is REQUIRED and must be non-empty for `FAIL` and `NOT_POSSIBLE` — a reason-less negative verdict fails with exit 2 before anything is written (2026-09-15). Exit 2 on bad input. See `docs/ooda-report.md`.
23
+ - `read-ooda-verdict.js` — deterministic cross-checker for the OODA terminal verdict (2026-09-15): `node read-ooda-verdict.js --dir <phase-dir> --expect <PASS|FAIL>` reads `<dir>/verdict.json`, prints one JSON line to stdout, exits 0 with `{ok:true, verdict, reason, summary, expected, actual, attempt}` when the record agrees with the prose expectation and a FAIL carries a non-empty reason, or exits 2 with `{ok:false, code}` — `missing|corrupt|contradiction|no_reason`. No wall-clock reads, no randomness. The bugfix QA closeout runs it against the prose `VERDICT:` line before any rework routing: a failed cross-check records the phase as failed for retry, never routes to rework.
24
+ - `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");