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.
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ // verify-publish.js — deterministic parent publish verifier.
3
+ //
4
+ // The parent never eyeballs an inspection report. This script takes the
5
+ // async read-back inspection's result JSON and MECHANICALLY decides the
6
+ // verdict: it parses the inspector's machine-readable findings block,
7
+ // compares every added/removed diff line against the reported
8
+ // present/absent verdicts, checks supersession via git, and only then
9
+ // stamps provenance, logs the terminal event, and re-queues the task.
10
+ //
11
+ // The LLM is the sensor (it reads the artifact source); this code is the
12
+ // judge. Unparseable findings, content mismatches, supersession, and stamp
13
+ // failures all fail CLOSED with a terminal parent verdict — never a stamp.
14
+ //
15
+ // Usage:
16
+ // node verify-publish.js --crew-home <path> --task-id <uuid>
17
+ // --commit <sha> --base <sha> --repo-path <path> --slug <slug>
18
+ // --crew-release <release> --inspection-id <uuid> --result-file <path>
19
+ // [--build-agent-id <uuid>]
20
+ //
21
+ // --base is the previously-stamped provenance source_commit (or the
22
+ // empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
23
+ // publish). It MUST be the same base the read-back request was built
24
+ // with: the verifier checks the inspector's findings against the
25
+ // base..commit diff, and a mismatched base would compare the findings
26
+ // against the wrong expected change. See the --base contract in
27
+ // lib/build-readback-request.js (push-time reconcile merges break the
28
+ // old commit^1 assumption, 2026-09-14, task 0c53af4e).
29
+ //
30
+ // Exit codes: 0 verified · 1 verification failed (terminal verdict logged) ·
31
+ // 2 usage/validation.
32
+
33
+ import { execFileSync } from "node:child_process";
34
+ import { readFileSync } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
38
+
39
+ function arg(name, required = true) {
40
+ const i = process.argv.indexOf(name);
41
+ if (i < 0 || i + 1 >= process.argv.length) {
42
+ if (!required) return null;
43
+ fail("usage", `${name} is required.`, 2);
44
+ }
45
+ return process.argv[i + 1];
46
+ }
47
+ function fail(code, message, exitCode) {
48
+ process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
49
+ process.exit(exitCode);
50
+ }
51
+
52
+ const crewHome = arg("--crew-home");
53
+ const taskId = arg("--task-id");
54
+ const commit = arg("--commit");
55
+ const base = arg("--base");
56
+ const repoPath = arg("--repo-path");
57
+ const slug = arg("--slug");
58
+ const inspectionId = arg("--inspection-id");
59
+ const resultFile = arg("--result-file");
60
+ const crewRelease = arg("--crew-release");
61
+ const buildAgentId = arg("--build-agent-id", false);
62
+
63
+ if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
64
+ if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
65
+
66
+ const crewApi = join(crewHome, "lib", "crew-api.js");
67
+ function api(command, json) {
68
+ const out = execFileSync("node", [crewApi, "--crew-home", crewHome, command, "--json", JSON.stringify(json)], {
69
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
70
+ });
71
+ return JSON.parse(out);
72
+ }
73
+ function terminal(reason, detail) {
74
+ // Always lands a terminal parent verdict, then exits 1 (failed) — the
75
+ // task stays parked for human attention. Only the verified path exits 0.
76
+ const msg = `publish: verification-failed ${commit} ${reason}${detail ? ` — ${detail}` : ""} (read-back ${inspectionId})`;
77
+ api("log-event", { task_id: taskId, type: "note", message: msg.slice(0, 1000) });
78
+ process.stderr.write(JSON.stringify({ ok: false, verdict: reason, message: msg }) + "\n");
79
+ process.exit(1);
80
+ }
81
+
82
+ // --- 1. Load the inspection result and extract the findings text ---------
83
+ // The tick worker saves the FULL async result — which may be the inspector's
84
+ // raw prose or a JSON envelope wrapping it (the platform's async handoff
85
+ // shape). The findings block is located mechanically, never assumed:
86
+ // - raw text (not JSON): the whole file is the one candidate.
87
+ // - JSON: every string leaf is a candidate (depth-first, encounter order),
88
+ // recursing into double-encoded JSON strings (bounded depth).
89
+ // The winning candidate is the one whose findings cover the most files from
90
+ // the expected diff — so an echoed request template or stray prose never
91
+ // outranks the real findings block. No candidate with real paths =>
92
+ // unreadable-result, fail closed.
93
+ let resultText;
94
+ try {
95
+ resultText = readFileSync(resultFile, "utf8");
96
+ } catch (e) {
97
+ fail("usage", `cannot read result file: ${e.message}`, 2);
98
+ }
99
+
100
+ function collectCandidates(value, depth, out) {
101
+ if (depth > 4 || out.length > 64) return;
102
+ if (typeof value === "string") {
103
+ out.push(value);
104
+ const t = value.trim();
105
+ if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
106
+ try { collectCandidates(JSON.parse(t), depth + 1, out); } catch { /* not JSON */ }
107
+ }
108
+ } else if (Array.isArray(value)) {
109
+ for (const v of value) collectCandidates(v, depth, out);
110
+ } else if (value && typeof value === "object") {
111
+ for (const v of Object.values(value)) collectCandidates(v, depth, out);
112
+ }
113
+ }
114
+
115
+ let candidates;
116
+ try {
117
+ const parsed = JSON.parse(resultText);
118
+ candidates = [];
119
+ collectCandidates(parsed, 0, candidates);
120
+ if (candidates.length === 0) candidates = [resultText];
121
+ } catch {
122
+ candidates = [resultText];
123
+ }
124
+
125
+ // The inspector was instructed to emit a machine-readable block:
126
+ // FILE: <path>
127
+ // ADDED: <line> :: PRESENT|ABSENT
128
+ // REMOVED: <line> :: PRESENT|ABSENT
129
+ // END_FILE
130
+ function parseFindings(text) {
131
+ const findings = new Map(); // path -> { added: Map(line->verdict), removed: Map(line->verdict) }
132
+ let cur = null;
133
+ let malformed = null;
134
+ for (const rawLine of text.split("\n")) {
135
+ const line = rawLine.trimEnd();
136
+ if (line.startsWith("FILE: ")) {
137
+ cur = { added: new Map(), removed: new Map() };
138
+ findings.set(line.slice(6).trim(), cur);
139
+ } else if (line === "END_FILE") {
140
+ cur = null;
141
+ } else if (cur && (line.startsWith("ADDED: ") || line.startsWith("REMOVED: "))) {
142
+ const kind = line.startsWith("ADDED: ") ? "added" : "removed";
143
+ const rest = line.slice(kind === "added" ? 7 : 9);
144
+ const sep = rest.lastIndexOf(" :: ");
145
+ if (sep < 0) { malformed = `malformed finding line: ${line.slice(0, 80)}`; break; }
146
+ const content = rest.slice(0, sep);
147
+ const verdict = rest.slice(sep + 4).trim();
148
+ if (verdict !== "PRESENT" && verdict !== "ABSENT") {
149
+ malformed = `bad verdict: ${verdict}`;
150
+ break;
151
+ }
152
+ cur[kind].set(content, verdict);
153
+ }
154
+ }
155
+ return { findings, malformed };
156
+ }
157
+
158
+ // --- 2. Expected diff from git (never from the builder's report) -----------
159
+ // The expected change is the publish delta base..commit, using the SAME
160
+ // base the read-back request was built with. A base that is not an
161
+ // ancestor of commit is a usage error (exit 2, no terminal verdict — this
162
+ // is a procedural input problem, not a content failure).
163
+ if (base !== EMPTY_TREE) {
164
+ const anc = (() => { try {
165
+ execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
166
+ return true;
167
+ } catch { return false; } })();
168
+ if (!anc) fail("usage", `base ${base} is not an ancestor of commit ${commit} — refusing to verify against an unrelated tree.`, 2);
169
+ }
170
+ let diff;
171
+ try {
172
+ diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
173
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
174
+ });
175
+ } catch (e) {
176
+ terminal("read-back-unavailable", `git diff failed: ${e.message}`);
177
+ }
178
+ const expected = new Map(); // path -> { added: [], removed: [] }
179
+ let curFile = null;
180
+ for (const line of diff.split("\n")) {
181
+ if (line.startsWith("diff --git")) {
182
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
183
+ curFile = m ? m[2] : "unknown";
184
+ expected.set(curFile, { added: [], removed: [] });
185
+ } else if (curFile && line.startsWith("+") && !line.startsWith("+++")) {
186
+ expected.get(curFile).added.push(line.slice(1));
187
+ } else if (curFile && line.startsWith("-") && !line.startsWith("---")) {
188
+ expected.get(curFile).removed.push(line.slice(1));
189
+ }
190
+ }
191
+
192
+ // --- 3. Pick the findings candidate that covers the expected diff -----------
193
+ let findings = null;
194
+ let bestScore = -1;
195
+ let malformedNote = null;
196
+ for (const cand of candidates) {
197
+ const { findings: f, malformed } = parseFindings(cand);
198
+ if (f.size === 0) continue;
199
+ let score = 0;
200
+ for (const p of f.keys()) if (expected.has(p)) score++;
201
+ if (malformed && score > 0) malformedNote = malformed;
202
+ if (score > bestScore) { bestScore = score; findings = f; }
203
+ }
204
+ if (malformedNote && findings) terminal("unreadable-result", malformedNote);
205
+ if (!findings || bestScore <= 0) {
206
+ terminal("unreadable-result", "no machine-readable FILE blocks covering the expected diff in inspection result");
207
+ }
208
+
209
+ // --- 4. Mechanical comparison ----------------------------------------------
210
+ for (const [path, exp] of expected) {
211
+ const found = findings.get(path);
212
+ if (!found) terminal("content-mismatch", `no findings for changed file ${path}`);
213
+ for (const line of exp.added) {
214
+ const v = found.added.get(line);
215
+ if (v === undefined) terminal("unreadable-result", `no ADDED finding for line in ${path}: ${line.slice(0, 60)}`);
216
+ if (v !== "PRESENT") terminal("content-mismatch", `added line ABSENT in ${path}: ${line.slice(0, 80)}`);
217
+ }
218
+ for (const line of exp.removed) {
219
+ const v = found.removed.get(line);
220
+ if (v === undefined) terminal("unreadable-result", `no REMOVED finding for line in ${path}: ${line.slice(0, 60)}`);
221
+ if (v !== "ABSENT") terminal("content-mismatch", `removed line PRESENT in ${path}: ${line.slice(0, 80)}`);
222
+ }
223
+ }
224
+
225
+ // --- 5. Supersession ---------------------------------------------------------
226
+ let head;
227
+ try {
228
+ head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
229
+ } catch (e) {
230
+ terminal("read-back-unavailable", `git rev-parse HEAD failed: ${e.message}`);
231
+ }
232
+ if (head !== commit) {
233
+ terminal("superseded", `HEAD is ${head}, not ${commit} — a newer publish supersedes this one`);
234
+ }
235
+
236
+ // --- 6. Build-ID correlation (informational; the stamp certifies content) ---
237
+ let buildNote = "build-id-unobserved";
238
+ if (buildAgentId && resultText.includes(buildAgentId)) buildNote = `build-id-correlated ${buildAgentId}`;
239
+
240
+ // --- 7. Stamp (only after the machine-checked comparison succeeded) ---------
241
+ let stamp;
242
+ try {
243
+ stamp = api("set-provenance", { source_commit: commit, crew_release: crewRelease, task_id: taskId });
244
+ } catch (e) {
245
+ terminal("stamp-failed", `set-provenance threw: ${e.message}`);
246
+ }
247
+ if (!stamp || stamp.ok !== true) terminal("stamp-failed", "set-provenance did not return ok");
248
+ // Exact read-back: the stamp must match what we intended, or it is fiction.
249
+ let prov;
250
+ try {
251
+ prov = api("get-provenance", {});
252
+ } catch (e) {
253
+ terminal("stamp-failed", `get-provenance threw: ${e.message}`);
254
+ }
255
+ const p = prov && prov.provenance;
256
+ if (!p || p.source_commit !== commit || p.task_id !== taskId || p.crew_release !== crewRelease) {
257
+ terminal("stamp-failed", `provenance read-back mismatch: ${JSON.stringify(p)}`);
258
+ }
259
+
260
+ // --- 8. Terminal verified event + re-queue -----------------------------------
261
+ const verifiedMsg =
262
+ `publish: verified ${commit} (${inspectionId}) — read-back: all added lines PRESENT, all removed lines ABSENT; ${buildNote}; no supersession (HEAD=${commit}).`;
263
+ api("log-event", { task_id: taskId, type: "note", message: verifiedMsg.slice(0, 1000) });
264
+ api("update-task", { id: taskId, state: "in_progress" });
265
+ process.stdout.write(JSON.stringify({ ok: true, verdict: "verified", commit, build_note: buildNote }) + "\n");
@@ -0,0 +1,130 @@
1
+ // write-ooda-verdict.js — deterministic writer for the OODA report's terminal state.
2
+ //
3
+ // After the see-act loop, the QA/repro agent records its verdict as
4
+ // machine-readable state instead of prose alone. This script owns the schema.
5
+ //
6
+ // Usage:
7
+ // node write-ooda-verdict.js --dir <phase-dir> --attempt <id>
8
+ // --verdict <PASS|FAIL|NOT_POSSIBLE>
9
+ // [--summary <text>] [--expected <text>] [--actual <text>]
10
+ // [--missing <json-array>] [--reason <text>] [--ts <iso>]
11
+ //
12
+ // Writes two records:
13
+ // <phase-dir>/verdict.json — the LATEST verdict (what the workflow
14
+ // closeout reads). Overwritten on each call.
15
+ // <phase-dir>/verdicts.jsonl — the append-only ledger. One JSON line per
16
+ // verdict, NEVER overwritten: {seq, attempt, verdict, summary, expected,
17
+ // actual, missing_evidence[], reason?, ts?}. seq is assigned mechanically
18
+ // (existing lines + 1). A QA retry that fails after an earlier PASS keeps
19
+ // both: the ledger preserves every attempt's verdict, so a later attempt
20
+ // can never silently erase an earlier one.
21
+ //
22
+ // --attempt is the attempt/run identity and must match the --attempt used
23
+ // when logging that attempt's steps.
24
+ //
25
+ // missing_evidence names evidence that should exist but honestly does not
26
+ // (e.g. "no pixel-visual of the footer element: infinite scroll").
27
+ // reason carries the NOT POSSIBLE explanation when verdict is NOT_POSSIBLE.
28
+ //
29
+ // Exit 0 on success, 2 on bad input. Determinism: no wall-clock reads, no
30
+ // randomness; ts comes only from --ts and is omitted when not passed.
31
+ "use strict";
32
+
33
+ const { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync } = require("node:fs");
34
+ const { join, resolve } = require("node:path");
35
+
36
+ const VERDICTS = { PASS: 1, FAIL: 1, NOT_POSSIBLE: 1 };
37
+
38
+ function fail(msg) {
39
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + "\n");
40
+ process.exit(2);
41
+ }
42
+
43
+ function parseArgs(argv) {
44
+ const out = {};
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i];
47
+ if (a === "--dir") out.dir = argv[++i];
48
+ else if (a === "--attempt") out.attempt = argv[++i];
49
+ else if (a === "--verdict") out.verdict = argv[++i];
50
+ else if (a === "--summary") out.summary = argv[++i];
51
+ else if (a === "--expected") out.expected = argv[++i];
52
+ else if (a === "--actual") out.actual = argv[++i];
53
+ else if (a === "--missing") out.missing = argv[++i];
54
+ else if (a === "--reason") out.reason = argv[++i];
55
+ else if (a === "--ts") out.ts = argv[++i];
56
+ else fail("unknown flag: " + a);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function ledgerSeq(ledgerPath) {
62
+ if (!existsSync(ledgerPath)) return 1;
63
+ const raw = readFileSync(ledgerPath, "utf8");
64
+ let count = 0;
65
+ for (const line of raw.split("\n")) {
66
+ if (!line.trim()) continue;
67
+ let entry;
68
+ try {
69
+ entry = JSON.parse(line);
70
+ } catch (e) {
71
+ fail("existing verdict ledger is corrupt (unparseable line): " + ledgerPath);
72
+ }
73
+ if (!Number.isInteger(entry.seq) || entry.seq !== count + 1) {
74
+ fail("existing verdict ledger is corrupt (seq broken at line " + (count + 1) + "): " + ledgerPath);
75
+ }
76
+ count++;
77
+ }
78
+ return count + 1;
79
+ }
80
+
81
+ function main() {
82
+ const args = parseArgs(process.argv.slice(2));
83
+ if (!args.dir) fail("missing --dir <phase-dir>");
84
+ if (args.attempt === undefined || String(args.attempt).trim() === "") {
85
+ fail("missing --attempt <id> (the attempt/run identity, matching the OODA log)");
86
+ }
87
+ if (!args.verdict) fail("missing --verdict <PASS|FAIL|NOT_POSSIBLE>");
88
+ if (!VERDICTS[args.verdict]) fail("unknown --verdict: " + args.verdict + " (PASS|FAIL|NOT_POSSIBLE)");
89
+
90
+ const attempt = String(args.attempt).trim();
91
+
92
+ let missing = [];
93
+ if (args.missing !== undefined) {
94
+ try {
95
+ missing = JSON.parse(args.missing);
96
+ } catch (e) {
97
+ fail("--missing must be a JSON array: " + e.message);
98
+ }
99
+ if (!Array.isArray(missing) || !missing.every((m) => typeof m === "string")) {
100
+ fail("--missing must be a JSON array of strings");
101
+ }
102
+ }
103
+
104
+ const record = {
105
+ verdict: args.verdict,
106
+ attempt: attempt,
107
+ summary: args.summary || "",
108
+ expected: args.expected || "",
109
+ actual: args.actual || "",
110
+ missing_evidence: missing,
111
+ };
112
+ if (args.verdict === "NOT_POSSIBLE") record.reason = args.reason || "";
113
+ if (args.ts) record.ts = args.ts;
114
+
115
+ const dir = resolve(args.dir);
116
+ mkdirSync(dir, { recursive: true });
117
+
118
+ // Append-only ledger first: the record is preserved even if the verdict.json
119
+ // write below were to fail.
120
+ const ledgerPath = join(dir, "verdicts.jsonl");
121
+ const seq = ledgerSeq(ledgerPath);
122
+ const ledgerEntry = Object.assign({ seq: seq }, record);
123
+ appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
124
+
125
+ const verdictPath = join(dir, "verdict.json");
126
+ writeFileSync(verdictPath, JSON.stringify(record, null, 2) + "\n");
127
+ process.stdout.write(JSON.stringify({ ok: true, verdict: verdictPath, ledger: ledgerPath, seq: seq, attempt: attempt }) + "\n");
128
+ }
129
+
130
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -5,6 +5,7 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
5
5
  ### Steps
6
6
 
7
7
  0. **Check for platform workflow failures (opacity killer):** The platform records workflow run failures in `runtime.workflow_runs` — these are invisible in the crew DB unless you check. A workflow that dies on a fatal `agent()` error (e.g. "subagent bootstrap is no longer authorized") leaves its task stranded with no explanation.
8
+ - **Guard (2026-09-13):** query `runtime.workflow_runs` ONLY through `muse.db` — never via sqlite3 against the crew's `crew-state.db` (the platform table does not exist there; a worker did exactly this on 2026-09-13 and aborted the tick). If this Step 0 check fails for any reason, log the error and continue with dispatch anyway; never abort the tick over a failed Step 0.
8
9
  - Use `muse.db` to find failed platform runs in the last 15 minutes:
9
10
  ```sql
10
11
  SELECT w.run_id, w.created_at, c.error
@@ -34,7 +35,13 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
34
35
  - If the acknowledge fails (no reservation exists), DO NOT LAUNCH — the dispatcher did not acquire this task. This is a safety invariant.
35
36
  - The launched workflow self-claims the task and clears the reservation as its first actions. If the task was already claimed or is done, the claim fails closed and the run stands down quietly — this is the mechanical duplicate protection, not an error.
36
37
 
37
- If the dispatcher returned no claims or the claims array is empty, report: NO_DISPATCH and exit.
38
+ If the dispatcher returned no claims or the claims array is empty, log NO_DISPATCH and CONTINUE to Step 4.5 — do NOT exit. Verification (4.5) and evidence (6) run independently of dispatch claims. A no-claims tick must still verify parked publishes and deliver evidence. (Fixed 2026-09-14: the old "exit on NO_DISPATCH" skipped verification permanently.)
39
+
40
+ 4.5. **Parent publish verification (docs/publish-verification.md):** The publisher parks instead of stamping provenance; the parent — this tick, the live root agent — verifies content and stamps. Deterministic code detects, claims, and certifies; you are only the async ferry for the inspection.
41
+ - Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-verification-pending`
42
+ This atomically claims each verification-pending task (1-hour lease, so a second tick cannot double-verify) and reconciles verified-but-still-parked tasks to `in_progress`. It returns `{ to_verify: [...], reconciled: [...] }`. Log both lists. If the scan exits 2 (e.g. the active release cannot be resolved), log the error loudly and continue — do NOT work around it.
43
+ - **READ-BACK BLOCKED (2026-09-14):** `artifact_inspect` was removed by the platform. No agent-callable replacement exists (`artifact.inspect` is malfunction diagnosis, not a read-back tool), so the inspection ferry step cannot run. For each entry in `to_verify`, log `verification-blocked: no agent-callable read-back tool <task_id> <commit>` and leave the task parked — do NOT attempt an inspection call, do NOT judge content yourself, and do NOT stamp provenance. The deterministic machinery (`build-readback-request.js`, `verify-publish.js`) is preserved in `lib/` for when a read-back path exists.
44
+ - Never stamp provenance from prose. Never infer a verdict from an inspector's summary text. The verify script's machine-checked comparison is the only certification.
38
45
 
39
46
  5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` — your death is what kills it.
40
47
  - For each launched run_id, poll its status every ~2 minutes via muse.db:
@@ -52,6 +59,21 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
52
59
  - **If status is `running` or `paused`:** Continue polling.
53
60
  - **Monitor ceiling:** 90 minutes from tick start. If a run is still not terminal then (pathological — the work phase caps each `agent()` call at 60 minutes), exit; the next tick's Step 0 continues recovery through the durable mapping.
54
61
  - **Backstop (not the plan, the insurance):** If THIS launcher dies early for any reason (platform kill, cell recycle), the next tick's Step 0 detects the dead run through the durable platform run -> task mapping and retries it — at most ~3 minutes later. The mapping exists so a dead launcher never strands a task for an hour; the monitor exists so the launcher rarely dies mid-run in the first place.
55
- - When all launched workflows are terminal or retry-exhausted, exit silently.
62
+ - When all launched workflows are terminal or retry-exhausted, continue to step 6 (evidence) — do not exit before delivering evidence for newly-done tasks.
63
+
64
+ 6. **Deliver QA evidence to chat (Eric's screenshots):** You are the crew's voice to Eric. Whenever a task completes QA, its evidence screenshots must land in this report automatically — Eric explicitly asked for them. This step is mechanical discovery, not judgment.
65
+ - Find newly-done tasks: `node {crewHome}/lib/crew-api.js --crew-home {crewHome} get-state --json '{}'` and take tasks with `state == "done"` and `updated_at` within the last 20 minutes.
66
+ - Dedupe: for each candidate, check `get-events --json '{"task_id": "<id>", "limit": 50}'` for an event whose message starts with `evidence: delivered`. Skip tasks already delivered.
67
+ - For each remaining task:
68
+ - Read its QA verdict: from get-state's `sessions`, the completed session for this task whose notes mention Hazel/QA. Quote one verdict line (pass/fail and the one-line reason).
69
+ - Find the screenshots (platform audit harness): `AUDIT=$(readlink ~/workspace/ts-spaces/<project>/audits/latest)` where `<project>` is the task's project field. Confirm `$AUDIT/screenshot.png` and `$AUDIT/screenshot-mobile.png` exist AND are newer than the task's `created_at` (compare with `stat -c %Y`). If the audit dir predates the task, the capture is stale from an earlier run — say so and do NOT attach it. Never attach screenshots from a different run.
70
+ - Write the transition BEFORE the attachments (2026-09-14 — Eric: screenshots arriving out of context need a problem→solution handoff). Do not hand-write it: run the deterministic composer and paste its stdout verbatim, then the two attachment lines:
71
+ `node {crewHome}/lib/compose-evidence-caption.js --crew-home {crewHome} --task-id "<id>" --audit-dir "<resolved_dir>"`
72
+ The composer emits: the out-of-context acknowledgment, the task title, Problem: (from the task description), Shipped: (the merge commit's subject + short-sha, or an honest "provenance not yet stamped"), Evidence: (the audit dir + QA verdict, or "No QA verdict recorded"), and the visual-protocol-unavailable honest label when the run recorded one. If the composer exits non-zero, say so in one line and attach nothing for that task.
73
+ - Attach both to your report, each on its own plain line, using the RESOLVED dir name (not the `latest` symlink) so the evidence is pinned to this run:
74
+ `![desktop](sandbox://workspace/ts-spaces/<project>/audits/<resolved_dir>/screenshot.png)`
75
+ `![mobile](sandbox://workspace/ts-spaces/<project>/audits/<resolved_dir>/screenshot-mobile.png)`
76
+ - Log the delivery so the next tick skips it: `node {crewHome}/lib/crew-api.js --crew-home {crewHome} log-event --json '{"task_id": "<id>", "type": "note", "message": "evidence: delivered <id> — screenshots attached to tick report"}'`.
77
+ - If a done task has no fresh screenshots, say so in one line — never claim evidence you don't have.
56
78
 
57
- 6. Exit silently.
79
+ 7. Exit silently.