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.
@@ -0,0 +1,323 @@
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 (with a mechanically computed collision
9
+ // exemption for removed lines that also occur in untouched code),
10
+ // checks supersession via git, and only then stamps provenance, logs
11
+ // the terminal event, and re-queues the task.
12
+ //
13
+ // The LLM is the sensor (it reads the artifact source); this code is the
14
+ // judge. Unparseable findings, content mismatches, supersession, and stamp
15
+ // failures all fail CLOSED with a terminal parent verdict — never a stamp.
16
+ //
17
+ // Usage:
18
+ // node verify-publish.js --crew-home <path> --task-id <uuid>
19
+ // --commit <sha> --base <sha> --repo-path <path> --slug <slug>
20
+ // --crew-release <release> --inspection-id <uuid> --result-file <path>
21
+ // [--build-agent-id <uuid>]
22
+ //
23
+ // --base is the previously-stamped provenance source_commit (or the
24
+ // empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
25
+ // publish). It MUST be the same base the read-back request was built
26
+ // with: the verifier checks the inspector's findings against the
27
+ // base..commit diff, and a mismatched base would compare the findings
28
+ // against the wrong expected change. See the --base contract in
29
+ // lib/build-readback-request.js (push-time reconcile merges break the
30
+ // old commit^1 assumption, 2026-09-14, task 0c53af4e).
31
+ //
32
+ // Exit codes: 0 verified · 1 verification failed (terminal verdict logged) ·
33
+ // 2 usage/validation.
34
+
35
+ import { execFileSync } from "node:child_process";
36
+ import { readFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+
39
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
40
+
41
+ function arg(name, required = true) {
42
+ const i = process.argv.indexOf(name);
43
+ if (i < 0 || i + 1 >= process.argv.length) {
44
+ if (!required) return null;
45
+ fail("usage", `${name} is required.`, 2);
46
+ }
47
+ return process.argv[i + 1];
48
+ }
49
+ function fail(code, message, exitCode) {
50
+ process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
51
+ process.exit(exitCode);
52
+ }
53
+
54
+ const crewHome = arg("--crew-home");
55
+ const taskId = arg("--task-id");
56
+ const commit = arg("--commit");
57
+ const base = arg("--base");
58
+ const repoPath = arg("--repo-path");
59
+ const slug = arg("--slug");
60
+ const inspectionId = arg("--inspection-id");
61
+ const resultFile = arg("--result-file");
62
+ const crewRelease = arg("--crew-release");
63
+ const buildAgentId = arg("--build-agent-id", false);
64
+
65
+ if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
66
+ if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
67
+
68
+ const crewApi = join(crewHome, "lib", "crew-api.js");
69
+ function api(command, json) {
70
+ const out = execFileSync("node", [crewApi, "--crew-home", crewHome, command, "--json", JSON.stringify(json)], {
71
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
72
+ });
73
+ return JSON.parse(out);
74
+ }
75
+ function terminal(reason, detail) {
76
+ // Always lands a terminal parent verdict, then exits 1 (failed) — the
77
+ // task stays parked for human attention. Only the verified path exits 0.
78
+ const msg = `publish: verification-failed ${commit} ${reason}${detail ? ` — ${detail}` : ""} (read-back ${inspectionId})`;
79
+ api("log-event", { task_id: taskId, type: "note", message: msg.slice(0, 1000) });
80
+ process.stderr.write(JSON.stringify({ ok: false, verdict: reason, message: msg }) + "\n");
81
+ process.exit(1);
82
+ }
83
+
84
+ // --- 1. Load the inspection result and extract the findings text ---------
85
+ // The tick worker saves the FULL async result — which may be the inspector's
86
+ // raw prose or a JSON envelope wrapping it (the platform's async handoff
87
+ // shape). The findings block is located mechanically, never assumed:
88
+ // - raw text (not JSON): the whole file is the one candidate.
89
+ // - JSON: every string leaf is a candidate (depth-first, encounter order),
90
+ // recursing into double-encoded JSON strings (bounded depth).
91
+ // The winning candidate is the one whose findings cover the most files from
92
+ // the expected diff — so an echoed request template or stray prose never
93
+ // outranks the real findings block. No candidate with real paths =>
94
+ // unreadable-result, fail closed.
95
+ let resultText;
96
+ try {
97
+ resultText = readFileSync(resultFile, "utf8");
98
+ } catch (e) {
99
+ fail("usage", `cannot read result file: ${e.message}`, 2);
100
+ }
101
+
102
+ function collectCandidates(value, depth, out) {
103
+ if (depth > 4 || out.length > 64) return;
104
+ if (typeof value === "string") {
105
+ out.push(value);
106
+ const t = value.trim();
107
+ if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
108
+ try { collectCandidates(JSON.parse(t), depth + 1, out); } catch { /* not JSON */ }
109
+ }
110
+ } else if (Array.isArray(value)) {
111
+ for (const v of value) collectCandidates(v, depth, out);
112
+ } else if (value && typeof value === "object") {
113
+ for (const v of Object.values(value)) collectCandidates(v, depth, out);
114
+ }
115
+ }
116
+
117
+ let candidates;
118
+ try {
119
+ const parsed = JSON.parse(resultText);
120
+ candidates = [];
121
+ collectCandidates(parsed, 0, candidates);
122
+ if (candidates.length === 0) candidates = [resultText];
123
+ } catch {
124
+ candidates = [resultText];
125
+ }
126
+
127
+ // The inspector was instructed to emit a machine-readable block:
128
+ // FILE: <path>
129
+ // ADDED: <line> :: PRESENT|ABSENT
130
+ // REMOVED: <line> :: PRESENT|ABSENT
131
+ // END_FILE
132
+ function parseFindings(text) {
133
+ const findings = new Map(); // path -> { added: Map(line->verdict), removed: Map(line->verdict) }
134
+ let cur = null;
135
+ let malformed = null;
136
+ for (const rawLine of text.split("\n")) {
137
+ const line = rawLine.trimEnd();
138
+ if (line.startsWith("FILE: ")) {
139
+ cur = { added: new Map(), removed: new Map() };
140
+ findings.set(line.slice(6).trim(), cur);
141
+ } else if (line === "END_FILE") {
142
+ cur = null;
143
+ } else if (cur && (line.startsWith("ADDED: ") || line.startsWith("REMOVED: "))) {
144
+ const kind = line.startsWith("ADDED: ") ? "added" : "removed";
145
+ const rest = line.slice(kind === "added" ? 7 : 9);
146
+ const sep = rest.lastIndexOf(" :: ");
147
+ if (sep < 0) { malformed = `malformed finding line: ${line.slice(0, 80)}`; break; }
148
+ const content = rest.slice(0, sep);
149
+ const verdict = rest.slice(sep + 4).trim();
150
+ if (verdict !== "PRESENT" && verdict !== "ABSENT") {
151
+ malformed = `bad verdict: ${verdict}`;
152
+ break;
153
+ }
154
+ cur[kind].set(content, verdict);
155
+ }
156
+ }
157
+ return { findings, malformed };
158
+ }
159
+
160
+ // --- 2. Expected diff from git (never from the builder's report) -----------
161
+ // The expected change is the publish delta base..commit, using the SAME
162
+ // base the read-back request was built with. A base that is not an
163
+ // ancestor of commit is a usage error (exit 2, no terminal verdict — this
164
+ // is a procedural input problem, not a content failure).
165
+ if (base !== EMPTY_TREE) {
166
+ const anc = (() => { try {
167
+ execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
168
+ return true;
169
+ } catch { return false; } })();
170
+ if (!anc) fail("usage", `base ${base} is not an ancestor of commit ${commit} — refusing to verify against an unrelated tree.`, 2);
171
+ }
172
+ let diff;
173
+ try {
174
+ diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
175
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
176
+ });
177
+ } catch (e) {
178
+ terminal("read-back-unavailable", `git diff failed: ${e.message}`);
179
+ }
180
+ const expected = new Map(); // path -> { added: [], removed: [] }
181
+ let curFile = null;
182
+ for (const line of diff.split("\n")) {
183
+ if (line.startsWith("diff --git")) {
184
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
185
+ curFile = m ? m[2] : "unknown";
186
+ expected.set(curFile, { added: [], removed: [] });
187
+ } else if (curFile && line.startsWith("+") && !line.startsWith("+++")) {
188
+ expected.get(curFile).added.push(line.slice(1));
189
+ } else if (curFile && line.startsWith("-") && !line.startsWith("---")) {
190
+ expected.get(curFile).removed.push(line.slice(1));
191
+ }
192
+ }
193
+
194
+ // --- 3. Pick the findings candidate that covers the expected diff -----------
195
+ let findings = null;
196
+ let bestScore = -1;
197
+ let malformedNote = null;
198
+ for (const cand of candidates) {
199
+ const { findings: f, malformed } = parseFindings(cand);
200
+ if (f.size === 0) continue;
201
+ let score = 0;
202
+ for (const p of f.keys()) if (expected.has(p)) score++;
203
+ if (malformed && score > 0) malformedNote = malformed;
204
+ if (score > bestScore) { bestScore = score; findings = f; }
205
+ }
206
+ if (malformedNote && findings) terminal("unreadable-result", malformedNote);
207
+ if (!findings || bestScore <= 0) {
208
+ terminal("unreadable-result", "no machine-readable FILE blocks covering the expected diff in inspection result");
209
+ }
210
+
211
+ // --- 4. Mechanical comparison ----------------------------------------------
212
+ // Collision exemption (2026-09-15, task 00bca4b8): a removed diff line that
213
+ // also occurs verbatim in untouched code has zero discriminating power —
214
+ // its presence in the new file cannot tell "old block removed" from "old
215
+ // block present". Task 1d692d91 parked a valid publish because two removed
216
+ // lines (` return (`, ` </div>`) occur identically in
217
+ // the untouched WorkflowSteps component; the naive every-removed-line-ABSENT
218
+ // rule failed closed on zero signal.
219
+ //
220
+ // The exemption is mechanically computed and signal-preserving: for file F,
221
+ // line L is exempt iff L occurs in F's old tree (at <base>) strictly more
222
+ // times than the expected diff removes it. An exempted line must survive in
223
+ // untouched code no matter what, so exempting it can never turn a missed
224
+ // removal into a pass. No change to the sensor: build-readback-request.js
225
+ // still reports whole-file PRESENT/ABSENT honestly; only this judge gets
226
+ // smarter.
227
+ function oldTreeLines(path) {
228
+ // Exact whole-line contents of <path> at <base>; [] when the base is the
229
+ // empty tree or the file did not exist there (a new file has no removed
230
+ // lines, so the exemption is vacuous for it).
231
+ if (base === EMPTY_TREE) return [];
232
+ let text;
233
+ try {
234
+ text = execFileSync("git", ["-C", repoPath, "show", `${base}:${path}`], {
235
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
236
+ });
237
+ } catch {
238
+ return [];
239
+ }
240
+ if (text === "") return [];
241
+ const lines = text.split("\n");
242
+ if (lines[lines.length - 1] === "") lines.pop(); // drop the trailing-newline artifact
243
+ return lines;
244
+ }
245
+ const oldTreeCounts = new Map(); // path -> Map(line -> occurrences in old tree)
246
+ function oldCount(path, line) {
247
+ let counts = oldTreeCounts.get(path);
248
+ if (counts === undefined) {
249
+ counts = new Map();
250
+ for (const l of oldTreeLines(path)) counts.set(l, (counts.get(l) || 0) + 1);
251
+ oldTreeCounts.set(path, counts);
252
+ }
253
+ return counts.get(line) || 0;
254
+ }
255
+
256
+ let exempted = 0;
257
+ for (const [path, exp] of expected) {
258
+ const found = findings.get(path);
259
+ if (!found) terminal("content-mismatch", `no findings for changed file ${path}`);
260
+ for (const line of exp.added) {
261
+ const v = found.added.get(line);
262
+ if (v === undefined) terminal("unreadable-result", `no ADDED finding for line in ${path}: ${line.slice(0, 60)}`);
263
+ if (v !== "PRESENT") terminal("content-mismatch", `added line ABSENT in ${path}: ${line.slice(0, 80)}`);
264
+ }
265
+ // The diff may remove the same line more than once; the old tree must
266
+ // account for every removal before a line counts as a collision.
267
+ const removedBudget = new Map();
268
+ for (const line of exp.removed) removedBudget.set(line, (removedBudget.get(line) || 0) + 1);
269
+ for (const line of exp.removed) {
270
+ const v = found.removed.get(line);
271
+ if (v === undefined) terminal("unreadable-result", `no REMOVED finding for line in ${path}: ${line.slice(0, 60)}`);
272
+ if (v === "ABSENT") continue;
273
+ if (oldCount(path, line) > removedBudget.get(line)) {
274
+ exempted += 1; // colliding pre-existing line: zero signal, cannot fail a good publish
275
+ continue;
276
+ }
277
+ terminal("content-mismatch", `removed line PRESENT in ${path}: ${line.slice(0, 80)}`);
278
+ }
279
+ }
280
+
281
+ // --- 5. Supersession ---------------------------------------------------------
282
+ let head;
283
+ try {
284
+ head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
285
+ } catch (e) {
286
+ terminal("read-back-unavailable", `git rev-parse HEAD failed: ${e.message}`);
287
+ }
288
+ if (head !== commit) {
289
+ terminal("superseded", `HEAD is ${head}, not ${commit} — a newer publish supersedes this one`);
290
+ }
291
+
292
+ // --- 6. Build-ID correlation (informational; the stamp certifies content) ---
293
+ let buildNote = "build-id-unobserved";
294
+ if (buildAgentId && resultText.includes(buildAgentId)) buildNote = `build-id-correlated ${buildAgentId}`;
295
+
296
+ // --- 7. Stamp (only after the machine-checked comparison succeeded) ---------
297
+ let stamp;
298
+ try {
299
+ stamp = api("set-provenance", { source_commit: commit, crew_release: crewRelease, task_id: taskId });
300
+ } catch (e) {
301
+ terminal("stamp-failed", `set-provenance threw: ${e.message}`);
302
+ }
303
+ if (!stamp || stamp.ok !== true) terminal("stamp-failed", "set-provenance did not return ok");
304
+ // Exact read-back: the stamp must match what we intended, or it is fiction.
305
+ let prov;
306
+ try {
307
+ prov = api("get-provenance", {});
308
+ } catch (e) {
309
+ terminal("stamp-failed", `get-provenance threw: ${e.message}`);
310
+ }
311
+ const p = prov && prov.provenance;
312
+ if (!p || p.source_commit !== commit || p.task_id !== taskId || p.crew_release !== crewRelease) {
313
+ terminal("stamp-failed", `provenance read-back mismatch: ${JSON.stringify(p)}`);
314
+ }
315
+
316
+ // --- 8. Terminal verified event + re-queue -----------------------------------
317
+ const verifiedMsg =
318
+ `publish: verified ${commit} (${inspectionId}) — read-back: all added lines PRESENT, all removed lines ABSENT` +
319
+ (exempted > 0 ? ` (${exempted} colliding removed line${exempted === 1 ? "" : "s"} exempted)` : "") +
320
+ `; ${buildNote}; no supersession (HEAD=${commit}).`;
321
+ api("log-event", { task_id: taskId, type: "note", message: verifiedMsg.slice(0, 1000) });
322
+ api("update-task", { id: taskId, state: "in_progress" });
323
+ process.stdout.write(JSON.stringify({ ok: true, verdict: "verified", commit, build_note: buildNote }) + "\n");
@@ -0,0 +1,147 @@
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
+ // --reason is REQUIRED and must be non-empty after trim when --verdict is
13
+ // FAIL or NOT_POSSIBLE; a missing or empty --reason fails with exit 2 and
14
+ // writes nothing (no verdict.json, no ledger line). A FAIL verdict must
15
+ // carry a machine-readable reason — an unreasoned FAIL can never be written.
16
+ //
17
+ // Writes two records:
18
+ // <phase-dir>/verdict.json — the LATEST verdict (what the workflow
19
+ // closeout reads). Overwritten on each call.
20
+ // <phase-dir>/verdicts.jsonl — the append-only ledger. One JSON line per
21
+ // verdict, NEVER overwritten: {seq, attempt, verdict, summary, expected,
22
+ // actual, missing_evidence[], reason?, ts?}. seq is assigned mechanically
23
+ // (existing lines + 1). A QA retry that fails after an earlier PASS keeps
24
+ // both: the ledger preserves every attempt's verdict, so a later attempt
25
+ // can never silently erase an earlier one.
26
+ //
27
+ // --attempt is the attempt/run identity and must match the --attempt used
28
+ // when logging that attempt's steps.
29
+ //
30
+ // missing_evidence names evidence that should exist but honestly does not
31
+ // (e.g. "no pixel-visual of the footer element: infinite scroll").
32
+ // reason carries the FAIL defect or the NOT POSSIBLE explanation — required
33
+ // for both verdicts, never optional.
34
+ //
35
+ // Exit 0 on success, 2 on bad input. Determinism: no wall-clock reads, no
36
+ // randomness; ts comes only from --ts and is omitted when not passed.
37
+ "use strict";
38
+
39
+ const { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync } = require("node:fs");
40
+ const { join, resolve } = require("node:path");
41
+
42
+ const VERDICTS = { PASS: 1, FAIL: 1, NOT_POSSIBLE: 1 };
43
+
44
+ function fail(msg) {
45
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + "\n");
46
+ process.exit(2);
47
+ }
48
+
49
+ function parseArgs(argv) {
50
+ const out = {};
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === "--dir") out.dir = argv[++i];
54
+ else if (a === "--attempt") out.attempt = argv[++i];
55
+ else if (a === "--verdict") out.verdict = argv[++i];
56
+ else if (a === "--summary") out.summary = argv[++i];
57
+ else if (a === "--expected") out.expected = argv[++i];
58
+ else if (a === "--actual") out.actual = argv[++i];
59
+ else if (a === "--missing") out.missing = argv[++i];
60
+ else if (a === "--reason") out.reason = argv[++i];
61
+ else if (a === "--ts") out.ts = argv[++i];
62
+ else fail("unknown flag: " + a);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function ledgerSeq(ledgerPath) {
68
+ if (!existsSync(ledgerPath)) return 1;
69
+ const raw = readFileSync(ledgerPath, "utf8");
70
+ let count = 0;
71
+ for (const line of raw.split("\n")) {
72
+ if (!line.trim()) continue;
73
+ let entry;
74
+ try {
75
+ entry = JSON.parse(line);
76
+ } catch (e) {
77
+ fail("existing verdict ledger is corrupt (unparseable line): " + ledgerPath);
78
+ }
79
+ if (!Number.isInteger(entry.seq) || entry.seq !== count + 1) {
80
+ fail("existing verdict ledger is corrupt (seq broken at line " + (count + 1) + "): " + ledgerPath);
81
+ }
82
+ count++;
83
+ }
84
+ return count + 1;
85
+ }
86
+
87
+ function main() {
88
+ const args = parseArgs(process.argv.slice(2));
89
+ if (!args.dir) fail("missing --dir <phase-dir>");
90
+ if (args.attempt === undefined || String(args.attempt).trim() === "") {
91
+ fail("missing --attempt <id> (the attempt/run identity, matching the OODA log)");
92
+ }
93
+ if (!args.verdict) fail("missing --verdict <PASS|FAIL|NOT_POSSIBLE>");
94
+ if (!VERDICTS[args.verdict]) fail("unknown --verdict: " + args.verdict + " (PASS|FAIL|NOT_POSSIBLE)");
95
+
96
+ // A FAIL or NOT_POSSIBLE verdict without a machine-readable reason cannot
97
+ // be written — the workflow closeout reads verdict.json for the reason,
98
+ // and an unreasoned negative verdict is a broken record.
99
+ var reason = "";
100
+ if (args.verdict === "FAIL" || args.verdict === "NOT_POSSIBLE") {
101
+ if (args.reason === undefined || String(args.reason).trim() === "") {
102
+ fail("--reason is required for a " + args.verdict + " verdict and must be non-empty");
103
+ }
104
+ reason = String(args.reason).trim();
105
+ }
106
+
107
+ const attempt = String(args.attempt).trim();
108
+
109
+ let missing = [];
110
+ if (args.missing !== undefined) {
111
+ try {
112
+ missing = JSON.parse(args.missing);
113
+ } catch (e) {
114
+ fail("--missing must be a JSON array: " + e.message);
115
+ }
116
+ if (!Array.isArray(missing) || !missing.every((m) => typeof m === "string")) {
117
+ fail("--missing must be a JSON array of strings");
118
+ }
119
+ }
120
+
121
+ const record = {
122
+ verdict: args.verdict,
123
+ attempt: attempt,
124
+ summary: args.summary || "",
125
+ expected: args.expected || "",
126
+ actual: args.actual || "",
127
+ missing_evidence: missing,
128
+ };
129
+ if (args.verdict === "FAIL" || args.verdict === "NOT_POSSIBLE") record.reason = reason;
130
+ if (args.ts) record.ts = args.ts;
131
+
132
+ const dir = resolve(args.dir);
133
+ mkdirSync(dir, { recursive: true });
134
+
135
+ // Append-only ledger first: the record is preserved even if the verdict.json
136
+ // write below were to fail.
137
+ const ledgerPath = join(dir, "verdicts.jsonl");
138
+ const seq = ledgerSeq(ledgerPath);
139
+ const ledgerEntry = Object.assign({ seq: seq }, record);
140
+ appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
141
+
142
+ const verdictPath = join(dir, "verdict.json");
143
+ writeFileSync(verdictPath, JSON.stringify(record, null, 2) + "\n");
144
+ process.stdout.write(JSON.stringify({ ok: true, verdict: verdictPath, ledger: ledgerPath, seq: seq, attempt: attempt }) + "\n");
145
+ }
146
+
147
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
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,17 @@ 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, reads back, and certifies; you are only the ferry between the deterministic steps (scan → sensor → verifier).
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 (deterministic sensor, 2026-09-15):** no platform inspection tool is needed — `lib/readback-disk.js` reads the platform's on-disk working copy of the artifact source (`~/workspace/ts-spaces/<slug>/`) and emits the machine-readable findings block the verifier parses. For each entry in `to_verify`, resolve the base via `get-provenance` (`source_commit`; the empty-tree sha `4b825dc642cb6eb9a060e54bf8d69288fbee4904` when nothing is stamped yet — a first publish), then run:
44
+ `node {crewHome}/lib/readback-disk.js --repo-path "<repo_path>" --commit <commit> --base <base> --slug "<deploy_slug>" --task-id <task_id> > /tmp/readback-<task_id>.txt 2> /tmp/readback-<task_id>.err`
45
+ Use the entry's `repo_path` and `deploy_slug` verbatim. If the sensor exits 0, the result file holds the findings block — hand it to the verify step below. If it exits non-zero, do NOT save or use stdout: log `publish: verification-procedural-error <commit> <first line of the .err file>` and leave the task parked — the next tick retries. A sensor failure is procedural (the read could not be performed), never a content verdict. Do NOT judge content yourself, and do NOT stamp provenance.
46
+ - **Verify (code):** `node {crewHome}/lib/verify-publish.js --crew-home {crewHome} --task-id <task_id> --commit <commit> --base <base> --repo-path "<repo_path>" --slug "<deploy_slug>" --crew-release <crew_release> --inspection-id <task_id>-disk --result-file /tmp/readback-<task_id>.txt`
47
+ Pass `--build-agent-id <id>` from the entry's `build_agent_id` when it is present. Use the SAME `<base>` the sensor ran with. The verifier parses the findings, compares mechanically against the base..commit diff, checks supersession, and stamps only on a match. Its terminal verdicts (`publish: verified` → task re-queued; `publish: verification-failed` → stays parked) are final — log them and continue.
48
+ - 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
49
 
39
50
  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
51
  - For each launched run_id, poll its status every ~2 minutes via muse.db:
@@ -51,7 +62,22 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
51
62
  - **Task-level error (not a platform error):** The workflow's own error handling applies. Stop monitoring this run.
52
63
  - **If status is `running` or `paused`:** Continue polling.
53
64
  - **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
- - **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.
65
+ - **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 ~15 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.
66
+ - When all launched workflows are terminal or retry-exhausted, continue to step 6 (evidence) — do not exit before delivering evidence for newly-done tasks.
67
+
68
+ 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.
69
+ - 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.
70
+ - 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.
71
+ - For each remaining task:
72
+ - 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).
73
+ - 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.
74
+ - 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:
75
+ `node {crewHome}/lib/compose-evidence-caption.js --crew-home {crewHome} --task-id "<id>" --audit-dir "<resolved_dir>"`
76
+ 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.
77
+ - 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:
78
+ `![desktop](sandbox://workspace/ts-spaces/<project>/audits/<resolved_dir>/screenshot.png)`
79
+ `![mobile](sandbox://workspace/ts-spaces/<project>/audits/<resolved_dir>/screenshot-mobile.png)`
80
+ - 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"}'`.
81
+ - If a done task has no fresh screenshots, say so in one line — never claim evidence you don't have.
56
82
 
57
- 6. Exit silently.
83
+ 7. Exit silently.
package/seed/crons.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "mode": "task",
8
8
  "owner": "space:{dashboardSlug}",
9
9
  "schedule": {
10
- "every": "3m",
10
+ "every": "15m",
11
11
  "kind": "interval"
12
12
  },
13
13
  "timeout_secs": 5400,