muse-crew 0.4.2 → 0.4.4

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,9 +1,10 @@
1
1
  export const meta = {
2
2
  name: "crew-standard",
3
- description: "Standard workflow: Triage → Map → Build → Review → Integrate → Publish → QA",
4
- phases: ["Triage", "Map", "Build", "Review", "Integrate", "Publish", "QA"],
3
+ description: "Standard workflow: Triage → Capture → Map → Build → Review → Integrate → Publish → QA",
4
+ phases: ["Triage", "Capture", "Map", "Build", "Review", "Integrate", "Publish", "QA"],
5
5
  steps: [
6
6
  { name: "Triage", identity: "sage" },
7
+ { name: "Capture", identity: "hazel" },
7
8
  { name: "Map", identity: "mara" },
8
9
  { name: "Build", identity: "wren" },
9
10
  { name: "Review", identity: "cass" },
@@ -18,7 +19,6 @@ const inputs = args ?? {};
18
19
  const taskId = inputs.task_id;
19
20
  const taskTitle = inputs.task_title || "";
20
21
  const taskDescription = inputs.task_description || "";
21
- const firstSessionId = inputs.session_id || null;
22
22
  const startStepIndex = inputs.start_step_index || 0;
23
23
 
24
24
  // Config from args — backward-compatible fallbacks for manual launches
@@ -29,9 +29,11 @@ const ORCH_PATH = crewHome + "/.orchestration";
29
29
  // Pin lifecycle scripts to this run — snapshot them so mid-run upgrades can't break us
30
30
  const LIFECYCLE_SRC = crewHome + "/lib/worktree-lifecycle.sh";
31
31
  const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
32
- const RUN_LIB = "/tmp/crew-lib-" + taskId;
32
+ const RUN_LIB = crewHome + "/.pins/" + taskId; // persistent disk, NOT /tmp (tmpfs wiped by cell reboots — canary b5efd1b1); stale pins reaped by orphan-sweep
33
33
  const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
34
34
  const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
35
+ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
36
+ const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
35
37
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
36
38
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
37
39
 
@@ -41,6 +43,21 @@ const projectConfig = inputs.project_config || {};
41
43
  // compares the task's live project against this on every phase boundary.
42
44
  const LAUNCH_PROJECT_ID = inputs.project_id || "";
43
45
  const REPO_PATH = projectConfig.repo_path || "~/workspace/ts-spaces/orchestra-dashboard";
46
+
47
+ // Worktree layout: the task's worktree lives at
48
+ // <repo>/.worktrees/<task_id> on branch task/<task_id>. The lifecycle
49
+ // manager (lib/worktree-lifecycle.sh) owns creation and removal; the
50
+ // registry at <repo>/.worktrees/.registry/<task_id> is the source of
51
+ // truth for task→branch/path.
52
+ // Env prefix baked into every lifecycle invocation the agents run.
53
+ const LIFECYCLE_ENV = "CREW_REPO=" + REPO_PATH + " ";
54
+ // The task branch is always task/<task_id>.
55
+ const TASK_BRANCH = "task/" + taskId;
56
+ // Where the agent works.
57
+ const WORKTREE_HINT = REPO_PATH + "/.worktrees/" + taskId;
58
+ // Where preserved work lives when the rework budget is exhausted.
59
+ const WORKTREE_PRESERVED_HINT = ".worktrees/" + taskId;
60
+
44
61
  const PUBLISH_TYPE = projectConfig.deploy_type || "";
45
62
  const PUBLISH_SLUG = projectConfig.deploy_slug || DASHBOARD_SLUG;
46
63
  const PROJECT_DESC = projectConfig.description || "React + TypeScript web dashboard (client/src/, server/src/, drizzle/)";
@@ -50,17 +67,339 @@ if (!taskId) {
50
67
  throw new Error("task_id is required in args");
51
68
  }
52
69
 
53
- // Work-agent result schema. Runtime retries on non-JSON (structured outputs).
54
- // Workflow wraps in try/catch so exhausted retries block instead of crashing.
55
- const WORK_SCHEMA = {
56
- type: "object",
57
- properties: { passed: { type: "boolean" }, summary: { type: "string" } },
58
- required: ["passed", "summary"]
59
- };
70
+ // Closeout is deterministic: the work agent returns the runtime's native
71
+ // transport envelope {"status": "ok", "result": "<prose report>"} with no
72
+ // schema, so the workflow receives the report as a plain string. There is no
73
+ // {"report"} wrapper: that invented shape invited agents to improvise sibling
74
+ // keys (notably "status"), which the runtime duck-types as its own envelope
75
+ // and fatally misparses. The envelope is the runtime's own documented shape
76
+ // — not a demand for machine-structured reasoning.
77
+ // The verdict is extracted mechanically by extractVerdict below — never by an
78
+ // agent. The summary is the worker's report truncated. The release decision
79
+ // comes from extractReleaseDecision. No formatter agent: it added a failure
80
+ // mode while contributing nothing the workflow doesn't compute itself.
81
+ // Steps whose passed=false drives a control-flow branch (rework bounce,
82
+ // block) declare their verdict explicitly on a VERDICT: line. The verdict is
83
+ // extracted DETERMINISTICALLY by workflow code (extractVerdict) — never by
84
+ // an agent. Missing, malformed, or contradictory lines fail the phase (never silently pass).
85
+ const VERDICT_STEPS = ["Build", "Review", "QA", "Reproduce", "Integrate", "Publish"];
86
+ function extractVerdict(workerText) {
87
+ // The verdict is the LAST VERDICT: PASS/FAIL in the report (contract: end
88
+ // your report with the verdict). This ignores literal VERDICT strings echoed
89
+ // from the worker's instructions (which contain quoted examples). Fail
90
+ // closed if: no verdict found, the last verdict is not in the trailing 100
91
+ // chars (verdict must be at the end), or conflicting verdicts appear in the
92
+ // trailing 200 chars. Word boundary prevents "PASSING" matching as PASS.
93
+ var text = workerText || "";
94
+ var regex = /VERDICT:\s*(PASS|FAIL)\b/gi;
95
+ var matches = [];
96
+ var m;
97
+ while ((m = regex.exec(text)) !== null) {
98
+ matches.push({ value: /FAIL/i.test(m[0]) ? "FAIL" : "PASS", index: m.index });
99
+ }
100
+ if (matches.length === 0) return { ok: false, count: 0 };
101
+ var last = matches[matches.length - 1];
102
+ if (last.index < text.length - 100) return { ok: false, count: matches.length };
103
+ var trailing = matches.filter(function (x) { return x.index >= text.length - 200; });
104
+ var uniq = trailing.map(function (x) { return x.value; }).filter(function (v, i, a) { return a.indexOf(v) === i; });
105
+ if (uniq.length !== 1) return { ok: false, count: matches.length };
106
+ return { ok: true, passed: last.value === "PASS" };
107
+ }
108
+ // Verdict re-ask (bug cd18ccc2): a verdict-step report that fails
109
+ // extractVerdict is not failed immediately. Stochastic verdict-line
110
+ // non-compliance (the agent did the work but omitted or garbled the VERDICT
111
+ // line) gets up to two bounded follow-up agent() calls whose only job is to
112
+ // read the preserved report and emit exactly one VERDICT line. The verdict
113
+ // is still extracted mechanically by extractVerdict — the re-ask agent
114
+ // transcribes, never decides the phase outcome. Each attempt uses a fresh
115
+ // stable-key suffix so a cached failure can never replay deterministically.
116
+ // Exhaustion keeps the existing fail-closed behavior. This is structure, not
117
+ // prompt hardening: no instruction text was stern-ified to get here.
118
+ function verdictReaskKey(stepName, reworkSuffix, attempt) {
119
+ return "verdict-reask-" + stepName + reworkSuffix + "-a" + attempt;
120
+ }
121
+ function buildVerdictReaskPrompt(stepName, workerText) {
122
+ return "Mechanical transcription task. Read the work report below and emit its verdict.\n\n" +
123
+ "WORK REPORT (verbatim):\n" + workerText + "\n\n" +
124
+ "Decide from the report's own content whether the " + stepName + " step clearly describes successful completion: " +
125
+ "if it does, the verdict is PASS; otherwise — failure, error, unfinished work, or unclear — the verdict is FAIL. " +
126
+ "Do NOT copy any VERDICT line from the report — decide from the content.\n\n" +
127
+ "Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your verdict line here\"}. " +
128
+ "The result must be exactly one line and nothing else: VERDICT: PASS or VERDICT: FAIL.";
129
+ }
130
+ async function reaskVerdict(stepName, reworkSuffix, workerText) {
131
+ var verdict = { ok: false, count: 0 };
132
+ for (var attempt = 1; attempt <= 2; attempt++) {
133
+ var reaskResult = null;
134
+ try {
135
+ reaskResult = await agent(buildVerdictReaskPrompt(stepName, workerText), {
136
+ key: verdictReaskKey(stepName, reworkSuffix, attempt),
137
+ label: "Verdict re-ask: " + stepName + " (attempt " + attempt + " of 2)",
138
+ timeoutMs: 180000
139
+ });
140
+ } catch (e) {
141
+ log(stepName + " verdict re-ask attempt " + attempt + " errored: " + ((e && e.message ? e.message : String(e)) || "").slice(0, 200));
142
+ continue;
143
+ }
144
+ var reaskText = (typeof reaskResult === "string") ? reaskResult : "";
145
+ verdict = extractVerdict(reaskText);
146
+ if (verdict.ok) {
147
+ log(stepName + " verdict re-ask attempt " + attempt + " recovered verdict: " + (verdict.passed ? "PASS" : "FAIL"));
148
+ return verdict;
149
+ }
150
+ log(stepName + " verdict re-ask attempt " + attempt + " produced no readable verdict (" + verdict.count + " trailing-window matches)");
151
+ }
152
+ return verdict;
153
+ }
154
+ // Transport retry: the work-agent agent() call can throw even when the agent
155
+ // did the work. Stochastic envelope non-compliance (bare prose instead of
156
+ // the native {"status":"ok","result":"..."} envelope) trips the runtime's
157
+ // JSON-candidate heuristic when the prose contains a {...}-looking
158
+ // substring — canary 39457ee9's QA report quoted the change's own
159
+ // {/* ... */} JSX comment, the runtime tried to parse it as JSON, threw,
160
+ // and the workflow discarded a complete VERDICT: PASS report as "no output".
161
+ // The verdict re-ask covers an unreadable verdict inside a RECEIVED report;
162
+ // this covers the report never arriving. The assignment is retried boundedly
163
+ // with fresh keys (never a cached replay) before failing closed. Re-entry is
164
+ // safe: lifecycle scripts answer REUSED for existing worktrees/branches, the
165
+ // retry trailer tells the agent to check existing state first and report
166
+ // rather than duplicate completed side effects, and the rework path already
167
+ // re-runs Build after rejection — Build re-entry is an established pattern.
168
+ function workRetryKey(stepName, reworkSuffix, attempt) {
169
+ return "work-" + stepName + reworkSuffix + "-t" + attempt;
170
+ }
171
+ // replay-key scoping for bug 1b8bb875 — runtime keys agent() calls by explicit key per workflow process; on in-process rework a re-executed phase must mint fresh keys; uses the same -r<N> suffix as the phase loop; pure function of inputs, no clock, no randomness (determinism contract). The count is a parameter because chore.js names the counter reworkCount while standard.js/bugfix.js name it totalReworkCount.
172
+ function attemptKey(base, reworkCount) {
173
+ return base + (reworkCount > 0 ? "-r" + reworkCount : "");
174
+ }
175
+ function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
176
+ // reason: "discarded" (the runtime threw the output away — it could not be
177
+ // machine-read) or "empty" (agent() returned without throwing but produced
178
+ // nothing usable). The trailer tells the retry what to expect, not just to
179
+ // try again.
180
+ var why = reason === "empty"
181
+ ? "your previous attempt returned no usable output"
182
+ : "your previous attempt's output could not be machine-read as JSON and was discarded";
183
+ return "\n\nTRANSPORT RETRY (attempt " + attempt + " of 2): " + why + ". " +
184
+ "First check existing state (worktree/branch at " + repoPath + "/.worktrees/" + taskId + ", the task branch, dashboard sessions for this task) — " +
185
+ "if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
186
+ "Then return your report as JSON in exactly the shape specified above.";
187
+ }
188
+ function describeWorkAgentFailure(stepName, identity, attempts) {
189
+ // Honest classification of a work-agent call that yielded no usable
190
+ // report, with the per-attempt evidence preserved in the session notes.
191
+ // Two distinct cases:
192
+ // - agent() THREW: the runtime discarded output it could not machine-read
193
+ // (e.g. prose tripping the JSON-candidate heuristic). The raw output is
194
+ // gone — the workflow never received it — so the surviving error text is
195
+ // recorded here instead. (An earlier comment claimed the raw output was
196
+ // "preserved in the run record"; that was false — nothing preserved it —
197
+ // and the claim is removed.)
198
+ // - agent() RETURNED EMPTY without throwing: the child produced nothing
199
+ // usable. Each attempt's outcome is the evidence.
200
+ // attempts: [{threw, error, outcome}, ...], in order.
201
+ var parts = [];
202
+ for (var i = 0; i < attempts.length; i++) {
203
+ var a = attempts[i];
204
+ parts.push("attempt " + (i + 1) + "/" + attempts.length + ": " +
205
+ (a.threw ? "threw '" + a.error + "'" : "returned " + a.outcome));
206
+ }
207
+ var detail = parts.join("; ").replace(/"/g, "'").slice(0, 400);
208
+ var anyThrow = false;
209
+ for (var j = 0; j < attempts.length; j++) {
210
+ if (attempts[j].threw) { anyThrow = true; break; }
211
+ }
212
+ if (anyThrow) {
213
+ return {
214
+ notes: "Work agent produced no machine-readable report after " + attempts.length + " attempts; runtime discarded the output (" + detail + ")",
215
+ eventMessage: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts — phase failed, dispatcher will retry",
216
+ blockedReason: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts",
217
+ message: "The " + identity + " agent's output could not be machine-read (" + attempts.length + " attempts exhausted); the runtime discarded the raw output before the workflow could see it. Surviving evidence: " + detail
218
+ };
219
+ }
220
+ return {
221
+ notes: "Work agent returned no usable output after " + attempts.length + " attempts (" + detail + ")",
222
+ eventMessage: stepName + " work agent returned no usable output after " + attempts.length + " attempts — phase failed, dispatcher will retry",
223
+ blockedReason: stepName + " work agent returned no usable output",
224
+ message: "The " + identity + " agent returned no usable output after " + attempts.length + " attempts (" + detail + ")."
225
+ };
226
+ }
227
+
228
+ // Release declaration extraction: deterministic. The Build worker declares
229
+ // release:/version_bump: in its report; workflow code reads them directly
230
+ // from the full worker report. A missing or malformed declaration fails
231
+ // closed to null (Review then rejects).
232
+ function extractReleaseDecision(workerText) {
233
+ var t = workerText || "";
234
+ var r = /release:\s*(yes|no)\b/i.exec(t);
235
+ if (!r) return null;
236
+ var b = /version_bump:\s*(patch|minor|major)\b/i.exec(t);
237
+ var rel = r[1].toLowerCase();
238
+ if (rel === "yes" && !b) return null;
239
+ return { release: rel, version_bump: b ? b[1].toLowerCase() : null };
240
+ }
241
+ // Marker line preservation: machine-readable lines (repo_diff:, release:,
242
+ // version_bump:, VERDICT:, TARGET_VERSION=, published:) are extracted from
243
+ // the full worker report and appended after the summary slice, so a long
244
+ // report can never amputate them. Later phases read these from session notes.
245
+ function extractMarkerLines(workerText) {
246
+ var lines = (workerText || "").split("\n");
247
+ var markers = [];
248
+ for (var i = 0; i < lines.length; i++) {
249
+ var line = lines[i].trim();
250
+ if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:)/i.test(line)) {
251
+ markers.push(line);
252
+ }
253
+ }
254
+ return markers.join("\n");
255
+ }
256
+
257
+ // Visual verdict shared functions: byte-identical across standard.js,
258
+ // bugfix.js, and chore.js (pinned by tests/visual-verdict.test.js — same
259
+ // contract as buildTransportRetryTrailer).
260
+ function extractExperiential(workerText) {
261
+ // Reads Sage's experiential: yes|no marker line. Missing or malformed
262
+ // fails closed to null — the flag is opt-in; null is treated as
263
+ // non-experiential (never silently parks a task on a garbled line).
264
+ var t = workerText || "";
265
+ var r = /experiential:\s*(yes|no)\b/i.exec(t);
266
+ if (!r) return null;
267
+ return r[1].toLowerCase() === "yes";
268
+ }
269
+ function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets) {
270
+ // Deterministic visual-capture frame. kind: "baseline" | "postchange".
271
+ // This string IS the capture script: fixed viewport matrix, scroll
272
+ // positions, and interaction states — the inspection agent executes it
273
+ // verbatim, nothing is improvised. Task-specific targets fill the slots.
274
+ var title = String(taskTitle || "").replace(/"/g, "'").slice(0, 120);
275
+ var targets = String(captureTargets || "").trim() ||
276
+ String(taskDescription || "").replace(/"/g, "'").slice(0, 300);
277
+ return "VISUAL CAPTURE — " + kind.toUpperCase() + " — task: " + title + ". " +
278
+ "Target views/controls: " + targets + ". " +
279
+ "For EACH target, capture exactly: " +
280
+ "(1) desktop 1440x900, full view, scrolled to top; " +
281
+ "(2) desktop 1440x900, scrolled so the target is vertically centered; " +
282
+ "(3) mobile 390x844, scrolled so the target is vertically centered; " +
283
+ "(4) desktop 1440x900, hover state on the target control; " +
284
+ "(5) desktop 1440x900, keyboard-focus state on the target control; " +
285
+ "(6) desktop 1440x900, active/pressed state if the target is a button or control. " +
286
+ "Also record: console error count, the ARIA tree of the target region, any horizontal overflow. " +
287
+ "Name captures " + kind + "-<n>-<viewport>-<state>. Return the captures, not a summary.";
288
+ }
289
+ // Experiential flag resolution: the task is experiential when Sage's Triage
290
+ // report ends with the machine-read marker "experiential: yes". The flag is
291
+ // opt-in — a missing or garbled line degrades to "unknown", which callers
292
+ // treat as non-experiential. The task is never parked on a garbled line.
293
+ async function resolveExperiential() {
294
+ if (isExperiential === true) return "yes";
295
+ if (isExperiential === false) return "no";
296
+ var expCheck = null;
297
+ try {
298
+ expCheck = await agent(
299
+ "Find this task's Triage step session notes from the dashboard.\n" +
300
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }.\n" +
301
+ "Find the session with task_id \"" + taskId + "\" and step \"Triage\" (status completed) in the returned sessions array and read its notes field.\n" +
302
+ "Return JSON { \"experiential_line\": \"<the exact text of the experiential: marker line from the notes, or empty string if absent>\" } and nothing else.",
303
+ {
304
+ key: "resolve-experiential-" + taskId,
305
+ label: "Resolving experiential flag from Triage notes",
306
+ schema: { type: "object", properties: { experiential_line: { type: "string" } }, required: ["experiential_line"] }
307
+ }
308
+ );
309
+ } catch (e) {
310
+ log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
311
+ return "unknown";
312
+ }
313
+ var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
314
+ if (marker === true) return "yes";
315
+ if (marker === false) return "no";
316
+ return "unknown";
317
+ }
318
+ // Baseline evidence status: reads the task's note events for the exact
319
+ // protocol prefixes (explicit state, never English matching). Returns
320
+ // { baseline_found, baseline_kind, baseline_refs, requested_count }.
321
+ // found = any note starting exactly "baseline: captured" or "baseline:
322
+ // none" (kind/refs come from the LATEST such message); requested_count =
323
+ // the number of notes starting exactly "baseline: requested". Each call
324
+ // uses a fresh key: the evidence changes between calls (the parent logs
325
+ // the capture while this run is parked), so a cached replay would lie.
326
+ let baselineStatusCallCount = 0;
327
+ async function baselineStatus() {
328
+ baselineStatusCallCount++;
329
+ try {
330
+ var ev = await agent(
331
+ "Read this task's note events from the dashboard.\n" +
332
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
333
+ "Consider only events with type \"note\". For each message, check whether it starts exactly with \"baseline: captured\", \"baseline: none\", or \"baseline: requested\" (exact prefix, case-sensitive).\n" +
334
+ "Return JSON { \"baseline_found\": <true if any message starts exactly with \"baseline: captured\" or \"baseline: none\">, \"baseline_kind\": \"<\"captured\" or \"none\", from the LATEST such message, or empty string if none>\", \"baseline_refs\": \"<the text after the prefix in that latest message, or empty string>\", \"requested_count\": <count of messages starting exactly with \"baseline: requested\"> } and nothing else.",
335
+ {
336
+ key: "baseline-status-" + taskId + "-" + baselineStatusCallCount,
337
+ label: "Reading baseline evidence status",
338
+ schema: {
339
+ type: "object",
340
+ properties: {
341
+ baseline_found: { type: "boolean" },
342
+ baseline_kind: { type: "string" },
343
+ baseline_refs: { type: "string" },
344
+ requested_count: { type: "number" }
345
+ },
346
+ required: ["baseline_found", "baseline_kind", "baseline_refs", "requested_count"]
347
+ }
348
+ }
349
+ );
350
+ return {
351
+ baseline_found: !!(ev && ev.baseline_found),
352
+ baseline_kind: (ev && ev.baseline_kind) || "",
353
+ baseline_refs: (ev && ev.baseline_refs) || "",
354
+ requested_count: (ev && ev.requested_count) || 0
355
+ };
356
+ } catch (e) {
357
+ log("baselineStatus: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as no evidence");
358
+ return { baseline_found: false, baseline_kind: "", baseline_refs: "", requested_count: 0 };
359
+ }
360
+ }
361
+ // Visual verdict status: the parent records the visual verdict as a note
362
+ // event after the rendered post-change inspection results arrive. Finds the
363
+ // latest step "QA" session's started_at, then note events NEWER than it
364
+ // whose message starts exactly "visual_verdict: PASS" / "visual_verdict:
365
+ // FAIL". Returns { found, verdict, detail }. A fresh key per call: the
366
+ // verdict lands while this run is parked.
367
+ let visualVerdictCallCount = 0;
368
+ async function visualVerdictStatus() {
369
+ visualVerdictCallCount++;
370
+ try {
371
+ var vv = await agent(
372
+ "Read this task's QA session and note events from the dashboard.\n" +
373
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }.\n" +
374
+ "Find the LATEST session with task_id \"" + taskId + "\" and step \"QA\" in the returned sessions array and note its started_at timestamp (call it QA_START; use empty string if there is no QA session).\n" +
375
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
376
+ "Consider only events with type \"note\" whose timestamp is newer than QA_START. Among them, find messages starting exactly with \"visual_verdict: PASS\" or \"visual_verdict: FAIL\" (exact prefix, case-sensitive); use the latest such message.\n" +
377
+ "Return JSON { \"found\": <true if such a message exists>, \"verdict\": \"<\"PASS\" or \"FAIL\" from that message, or empty string>\", \"detail\": \"<the text after the prefix in that message, or empty string>\" } and nothing else.",
378
+ {
379
+ key: "visual-verdict-status-" + taskId + "-" + visualVerdictCallCount,
380
+ label: "Reading visual verdict status",
381
+ schema: {
382
+ type: "object",
383
+ properties: {
384
+ found: { type: "boolean" },
385
+ verdict: { type: "string" },
386
+ detail: { type: "string" }
387
+ },
388
+ required: ["found", "verdict", "detail"]
389
+ }
390
+ }
391
+ );
392
+ return { found: !!(vv && vv.found), verdict: (vv && vv.verdict) || "", detail: (vv && vv.detail) || "" };
393
+ } catch (e) {
394
+ log("visualVerdictStatus: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as not found");
395
+ return { found: false, verdict: "", detail: "" };
396
+ }
397
+ }
60
398
 
61
399
  // STEPS inline — export const meta is parsed as metadata, not a runtime binding
62
400
  const STEPS = [
63
401
  { name: "Triage", identity: "sage" },
402
+ { name: "Capture", identity: "hazel" },
64
403
  { name: "Map", identity: "mara" },
65
404
  { name: "Build", identity: "wren" },
66
405
  { name: "Review", identity: "cass" },
@@ -70,6 +409,8 @@ const STEPS = [
70
409
  ];
71
410
  const BUILD_INDEX = STEPS.findIndex(s => s.name === 'Build');
72
411
  if (BUILD_INDEX < 0) throw new Error("STEPS missing 'Build' step");
412
+ const CAPTURE_INDEX = STEPS.findIndex(s => s.name === 'Capture');
413
+ if (CAPTURE_INDEX < 0) throw new Error("STEPS missing 'Capture' step");
73
414
  const REWORK_STEP = STEPS[BUILD_INDEX].name;
74
415
  // Shared rework budget: Review and QA rejections draw from the SAME pool of 2.
75
416
  // E.g. 2 Review bounces + 1 QA bounce = 3 total > budget -> task blocks.
@@ -77,24 +418,59 @@ const MAX_TOTAL_REWORK = 2;
77
418
  let totalReworkCount = 0;
78
419
  let rejectionNotes = inputs.rejection_notes || "";
79
420
  let mapperSpec = "";
421
+ // Visual verdict state: Sage's experiential flag (true/false/null until the
422
+ // Triage report is read), Mara's capture targets for the post-change capture
423
+ // plan, and the Map-gate bounce counter (keeps agent stable-keys unique when
424
+ // a Map-gate bounce re-runs Capture/Map in the same run).
425
+ let isExperiential = null;
426
+ let captureTargets = "";
427
+ let mapGateBounceCount = 0;
80
428
  // Merge-time versioning: the release decision is extracted deterministically
81
- // from the accepted Build summary (extractReleaseDecision) so the Publish
429
+ // from the accepted Build worker report (extractReleaseDecision) so the Publish
82
430
  // agent never decides whether to publish. Two consecutive Publish runs
83
431
  // rationalized a skip against explicit instruction text — text alone did not
84
432
  // hold, so the decision now lives in workflow code, not agent judgment.
85
433
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
86
- function extractReleaseDecision(text) {
87
- const t = text || "";
88
- const r = /^release:\s*(yes|no)\s*$/im.exec(t);
89
- if (!r) return null;
90
- const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
91
- if (r[1].toLowerCase() === "yes" && !b) return null;
92
- return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
434
+ // Deterministic publish target — computed by the workflow (registry base +
435
+ // bumpVersion), never by the Publish agent.
436
+ let publishTarget = null; // { base, scope, target }
437
+ function bumpVersion(base, scope) {
438
+ var p = String(base).trim().split(".").map(function (x) { return parseInt(x, 10) || 0; });
439
+ if (scope === "major") return (p[0] + 1) + ".0.0";
440
+ if (scope === "minor") return p[0] + "." + (p[1] + 1) + ".0";
441
+ return p[0] + "." + p[1] + "." + (p[2] + 1); // patch (default)
93
442
  }
94
443
  function releaseDecisionText() {
95
- if (!releaseDecision) return "no parseable release:/version_bump: decision";
444
+ if (!releaseDecision) return "no machine-readable release decision from the Build report";
96
445
  return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
97
446
  }
447
+ // Park the task for human attention and end the run. "blocked" is never
448
+ // manually authored — the dashboard derives it mechanically from unmet
449
+ // dependencies — so a workflow outcome that needs a human parks the task
450
+ // instead. Parking is one atomic dashboard action (parktask): the parked
451
+ // state and the explanatory note land in one transaction, never half.
452
+ // The dispatcher skips parked tasks; a human moving parked→todo
453
+ // mechanically resets the retry counters. Returns the workflow result
454
+ // envelope the launcher sees. If the park call itself fails, the run
455
+ // reports "failed" (retryable) so the next tick re-attempts the park —
456
+ // a lost park is never reported as parked.
457
+ async function parkTask(reason) {
458
+ log("Parking task " + taskId + " for human attention: " + reason);
459
+ var parkMessage = ("Parked: " + reason).slice(0, 1000);
460
+ try {
461
+ await agent(
462
+ "Park this task for human attention.\n" +
463
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"parktask\", args: " +
464
+ JSON.stringify({ task_id: taskId, message: parkMessage }) + ".\n" +
465
+ "The parked state is the human-attention signal — the dispatcher skips parked tasks.",
466
+ { key: "park-task", label: "Parking task for human attention", schema: { type: "object" } }
467
+ );
468
+ } catch (parkErr) {
469
+ log("PARK FAILED for task " + taskId + ": " + (parkErr && parkErr.message ? parkErr.message : parkErr) + " — park did not land, reporting failed so the next tick retries");
470
+ return { status: "failed", task_id: taskId, reason: "park failed: " + reason, park_failed: true };
471
+ }
472
+ return { status: "parked", task_id: taskId, reason: reason };
473
+ }
98
474
  let i = startStepIndex;
99
475
 
100
476
  // ── Pin lifecycle scripts ────────────────────────────────────────────
@@ -106,8 +482,9 @@ await agent(
106
482
  " mkdir -p " + RUN_LIB + "\n" +
107
483
  " cp " + LIFECYCLE_SRC + " " + LIFECYCLE + "\n" +
108
484
  " cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + "\n" +
485
+ " cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + "\n" +
109
486
  " cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + "\n" +
110
- " chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + ORPHAN_SWEEP + "\n" +
487
+ " chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + "\n" +
111
488
  "Confirm the files exist by listing " + RUN_LIB + ".",
112
489
  { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
113
490
  );
@@ -142,15 +519,14 @@ while (i < STEPS.length) {
142
519
  if (currentProject !== LAUNCH_PROJECT_ID) {
143
520
  const abortMessage = "Task project changed mid-run from '" + LAUNCH_PROJECT_ID + "' to '" + currentProject + "' — aborting stale run. The dispatcher will re-launch from " + REWORK_STEP + " with the new project context.";
144
521
  log(abortMessage);
145
- const abortSessionPatch = (isFirstClaim && firstSessionId) ? "\"id\": \"" + firstSessionId + "\", " : "";
146
522
  await agent(
147
523
  "Abort the stale run and remove its worktree from the old project's repo.\n" +
148
524
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
149
- "{ \"task_id\": \"" + taskId + "\", " + abortSessionPatch + "\"identity\": \"" + step.identity + "\", \"step\": \"" + REWORK_STEP + "\", \"status\": \"failed\", " +
525
+ "{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + REWORK_STEP + "\", \"status\": \"failed\", " +
150
526
  "\"notes\": " + JSON.stringify(abortMessage + " Rebuild from the Map session notes in the task's event history.") + " }.\n" +
151
527
  "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
152
528
  "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"identity\": \"" + step.identity + "\", \"message\": " + JSON.stringify(abortMessage) + " }.\n" +
153
- "Then run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " cleanup " + taskId + "\n" +
529
+ "Then run: "+ LIFECYCLE_ENV + " cleanup " + taskId + "\n" +
154
530
  "The cleanup output should contain CLEANUP.",
155
531
  { key: "abort-project-change", label: "Aborting stale run (project changed)", schema: { type: "object" } }
156
532
  );
@@ -164,16 +540,16 @@ while (i < STEPS.length) {
164
540
  log("Publish skipped for task " + taskId + " — no publish target configured (deploy_type empty)");
165
541
  await agent(
166
542
  "Release the merge lock and clean up without publishing.\n" +
167
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
543
+ "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
168
544
  "If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
169
- { key: "publish-skip-cleanup", label: "Skipping Publish (no target)", schema: { type: "object" } }
545
+ { key: attemptKey("publish-skip-cleanup", totalReworkCount), label: "Skipping Publish (no target)", schema: { type: "object" } }
170
546
  );
171
547
  i++;
172
548
  continue;
173
549
  }
174
550
  if (step.name === "Publish" && PUBLISH_TYPE !== "npm" && PUBLISH_TYPE !== "artifact" && PUBLISH_TYPE !== "vercel") {
175
551
  log("Unknown publish target for task " + taskId + ": " + PUBLISH_TYPE);
176
- return { status: "blocked", task_id: taskId, reason: "Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish)." };
552
+ return await parkTask("Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish).");
177
553
  }
178
554
 
179
555
  // Merge-time versioning, decided deterministically: the release decision was
@@ -183,25 +559,74 @@ while (i < STEPS.length) {
183
559
  // therefore has no decision point to rationalize into a skip.
184
560
  if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
185
561
  if (!releaseDecision) {
186
- return { status: "blocked", task_id: taskId, reason: "Build summary has no parseable release:/version_bump: lines — cannot assign version at publish time." };
562
+ return await parkTask("Build report has no machine-readable release:/version_bump: declaration — cannot assign version at publish time.");
187
563
  }
188
564
  if (releaseDecision.release === "no") {
189
- log("Publish skipped for task " + taskId + " — accepted Build summary declared release: no");
565
+ log("Publish skipped for task " + taskId + " — accepted Build report declared release: no");
190
566
  await agent(
191
567
  "Release the merge lock and clean up without publishing.\n" +
192
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
568
+ "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
193
569
  "If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
194
- { key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
570
+ { key: attemptKey("publish-skip-release-no", totalReworkCount), label: "Skipping Publish (release: no)", schema: { type: "object" } }
195
571
  );
196
572
  i++;
197
573
  continue;
198
574
  }
575
+
576
+ // Deterministic pre-publish: the workflow reads the registry base and computes
577
+ // the target version. The Publish agent never does version math. Guarded by
578
+ // the enclosing Publish+npm branch: releaseDecision is guaranteed non-null
579
+ // and release !== "no" at this point.
580
+ try {
581
+ var baseResult = await agent(
582
+ "Read the npm registry base version.\n" +
583
+ "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
584
+ "If the output is NOT_FOUND, run: node -p \"require('" + REPO_PATH + "/package.json').version\"\n" +
585
+ "Return JSON { \"base\": \"<the version string, trimmed>\" } and nothing else.",
586
+ { key: attemptKey("publish-base-" + taskId, totalReworkCount), label: "Reading registry base version",
587
+ schema: { type: "object", properties: { base: { type: "string" } }, required: ["base"] } }
588
+ );
589
+ var pubScope = releaseDecision.version_bump || "patch";
590
+ var pubBase = (baseResult.base || "").trim();
591
+ if (!/^\d+\.\d+\.\d+$/.test(pubBase)) {
592
+ return await parkTask("Publish base version unreadable: '" + pubBase + "'. Fail-closed.");
593
+ }
594
+ publishTarget = { base: pubBase, scope: pubScope, target: bumpVersion(pubBase, pubScope) };
595
+ log("Publish target for task " + taskId + ": " + pubBase + " + " + pubScope + " -> " + publishTarget.target);
596
+ } catch (e) {
597
+ return await parkTask("Deterministic pre-publish failed: " + (e && e.message ? e.message : e) + ". Fail-closed.");
598
+ }
199
599
  }
200
600
 
201
- // Claim session reuse dispatcher's session for the very first step
601
+ // Self-claimthe dispatcher only recommends; the launched workflow claims
602
+ // the task as its first action, so a claim can never exist without a launched
603
+ // agent behind it. If another run claimed the task first (two poll ticks
604
+ // raced in the window before this run's claim), claimtask returns
605
+ // claimed:false and this run stands down as a duplicate.
202
606
  let activeSessionId;
203
- if (isFirstClaim && firstSessionId) {
204
- activeSessionId = firstSessionId;
607
+ if (isFirstClaim) {
608
+ const claimResult = await agent(
609
+ "Claim this task for the " + step.name + " step.\n" +
610
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + taskId + "\", \"state\": \"in_progress\" }.\n" +
611
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
612
+ "{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started\" }.\n" +
613
+ "Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
614
+ "Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
615
+ {
616
+ key: "claim-" + step.name + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
617
+ label: "Claiming " + step.name,
618
+ schema: {
619
+ type: "object",
620
+ properties: { claimed: { type: "boolean" }, session_id: { type: "string" } },
621
+ required: ["claimed", "session_id"]
622
+ }
623
+ }
624
+ );
625
+ if (!claimResult.claimed) {
626
+ log("Standing down — task " + taskId + " was already claimed by another run");
627
+ return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
628
+ }
629
+ activeSessionId = claimResult.session_id;
205
630
  } else {
206
631
  const claimResult = await agent(
207
632
  "Claim a session for this task step.\n" +
@@ -209,7 +634,7 @@ while (i < STEPS.length) {
209
634
  "{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started" + (totalReworkCount > 0 ? " (rework #" + totalReworkCount + ")" : "") + "\" }.\n" +
210
635
  "Return the session_id from the response.",
211
636
  {
212
- key: "claim-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
637
+ key: "claim-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
213
638
  label: "Claiming " + step.name,
214
639
  schema: {
215
640
  type: "object",
@@ -221,164 +646,363 @@ while (i < STEPS.length) {
221
646
  activeSessionId = claimResult.session_id;
222
647
  }
223
648
 
649
+ // ── Capture: baseline evidence for experiential tasks ─────────────
650
+ // Hazel's QA capture pass runs right after Triage, before Map, for tasks
651
+ // Sage flagged experiential. The capture itself is parent-driven (the
652
+ // inspection handoff arrives at the root agent, outside this script), so
653
+ // when no baseline evidence is recorded yet the script logs a note event
654
+ // and parks with the exact parent protocol + resume path. Never fails the
655
+ // task over missing evidence: after two requests, baseline:none is
656
+ // recorded and final QA judges on the rubric alone.
657
+ if (step.name === "Capture") {
658
+ var capExp = await resolveExperiential();
659
+ var bounceSuffix = (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : "");
660
+ if (capExp !== "yes" || PUBLISH_TYPE !== "artifact") {
661
+ log("Capture skipped for task " + taskId + " — " + (capExp !== "yes" ? "not experiential" : "publish target is not artifact"));
662
+ await agent(
663
+ "Update the session and log the event.\n" +
664
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
665
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": \"Capture skipped — not an experiential artifact task\" }.\n" +
666
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
667
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
668
+ { key: "record-Capture" + bounceSuffix, label: "Recording Capture result", schema: { type: "object" } }
669
+ );
670
+ i++;
671
+ continue;
672
+ }
673
+ var capStatus = await baselineStatus();
674
+ if (capStatus.baseline_found) {
675
+ log("Capture: baseline evidence already recorded for task " + taskId + " (" + capStatus.baseline_kind + ")");
676
+ await agent(
677
+ "Update the session and log the event.\n" +
678
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
679
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": " + JSON.stringify("Baseline evidence already recorded: " + capStatus.baseline_kind + " " + capStatus.baseline_refs) + " }.\n" +
680
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
681
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
682
+ { key: "record-Capture" + bounceSuffix, label: "Recording Capture result", schema: { type: "object" } }
683
+ );
684
+ i++;
685
+ continue;
686
+ }
687
+ var attemptN = capStatus.requested_count + 1;
688
+ if (capStatus.requested_count >= 2) {
689
+ log("Capture: baseline capture unavailable after 2 requests for task " + taskId + " — recording baseline:none");
690
+ await agent(
691
+ "Record that no baseline was capturable.\n" +
692
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
693
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"note\", \"identity\": \"" + step.identity + "\", \"message\": \"baseline: none (capture unavailable after 2 attempts)\" }.\n" +
694
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
695
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": \"baseline: none — final QA judges on rubric alone\" }.\n" +
696
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
697
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
698
+ { key: "record-Capture-none" + bounceSuffix, label: "Recording baseline: none", schema: { type: "object" } }
699
+ );
700
+ i++;
701
+ continue;
702
+ }
703
+ log("Capture: requesting baseline capture (attempt " + attemptN + ") for task " + taskId);
704
+ await agent(
705
+ "Request the baseline capture and record the request.\n" +
706
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
707
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"note\", \"identity\": \"" + step.identity + "\", \"message\": \"baseline: requested (attempt " + attemptN + ")\" }.\n" +
708
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
709
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": " + JSON.stringify("Baseline capture requested (attempt " + attemptN + ") — parent protocol: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map. The Capture step re-checks baseline evidence when re-run.") + " }.",
710
+ { key: "record-Capture-request" + bounceSuffix, label: "Recording baseline capture request", schema: { type: "object" } }
711
+ );
712
+ return await parkTask("Baseline capture requested (attempt " + attemptN + ") — parent: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map");
713
+ }
714
+
715
+ // ── Map gate: experiential tasks need baseline evidence ────────────
716
+ // Mara must verify baseline evidence exists before writing the spec. If
717
+ // missing, bounce back to Capture — never to Build, never a task failure.
718
+ var mapBaselineRefs = "";
719
+ var mapBaselineNone = false;
720
+ if (step.name === "Map") {
721
+ if ((await resolveExperiential()) === "yes") {
722
+ var gateStatus = await baselineStatus();
723
+ if (!gateStatus.baseline_found) {
724
+ log("Map gate: no baseline evidence for experiential task " + taskId + " — bouncing to Capture");
725
+ await agent(
726
+ "Record the Map gate bounce.\n" +
727
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
728
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"Map gate bounce: no baseline evidence recorded for this experiential task — no spec was written. Baseline evidence must be captured before the spec.\" }.\n" +
729
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
730
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"identity\": \"" + step.identity + "\", \"message\": \"Map gate bounce — baseline evidence missing, returning to Capture\" }.",
731
+ { key: "record-Map-bounce" + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""), label: "Recording Map gate bounce", schema: { type: "object" } }
732
+ );
733
+ mapGateBounceCount++;
734
+ i = CAPTURE_INDEX;
735
+ continue;
736
+ }
737
+ mapBaselineRefs = gateStatus.baseline_refs;
738
+ mapBaselineNone = (gateStatus.baseline_kind === "none");
739
+ }
740
+ }
741
+
742
+ // Visual verdict routing: experiential artifact tasks get their visual
743
+ // verdict from the parent AFTER the rendered post-change inspection
744
+ // results arrive (this script cannot receive the async handoff). The QA
745
+ // work agent covers mechanical checks only; the visual-verdict gate below
746
+ // parks for the parent protocol when no visual_verdict: note event exists
747
+ // yet.
748
+ var qaVisual = false;
749
+ if (step.name === "QA") {
750
+ qaVisual = (await resolveExperiential()) === "yes" && PUBLISH_TYPE === "artifact";
751
+ }
752
+
224
753
  // Step-specific instructions
225
754
  var safeTitle = taskTitle.replace(/"/g, "'").replace(/\\/g, "\\\\").replace(/`/g, "'");
226
755
  var instructions = "";
227
756
 
228
757
  if (step.name === "Triage") {
229
- instructions = "Validate the task, check clarity, note dependencies, confirm the standard workflow assignment.\nWrite a brief triage assessment as notes for the next step.\nYour final response MUST be valid JSON and nothing else: { \"summary\": \"your assessment\", \"passed\": true }. No prose, no markdown, just the JSON object.";
758
+ instructions = "Validate the task, check clarity, note dependencies, confirm the standard workflow assignment.\nWrite a brief triage assessment as notes for the next step.\nReport back in plain prose what you found.\nEXPERIENTIAL FLAG: does this task change anything rendered and visible in the project's user-facing artifact (pages, components, styles, layout, copy, visual states)? If yes it is experiential and gets baseline captures (plus a visual verdict where the workflow has a QA phase). End your report with exactly one line on its own, lowercase, unrephrased: experiential: yes — or experiential: no. This line is machine-read.";
230
759
 
231
760
  } else if (step.name === "Map") {
232
- instructions = "Research the problem space, evaluate options, pick the shortest path.\nWrite a clear spec that a builder can execute without asking questions.\nThe builder will edit source files in a git worktree.\nProject: " + PROJECT_DESC + "\nSave the spec to a file under " + crewHome + "/ if needed.\nYour final response MUST be valid JSON and nothing else: { \"summary\": \"what you specified\", \"passed\": true }. No prose, no markdown, just the JSON object.";
761
+ var mapGatePara = "";
762
+ if (mapBaselineRefs || mapBaselineNone) {
763
+ mapGatePara = "\nBASELINE GATE (experiential task): " +
764
+ (mapBaselineNone
765
+ ? "no baseline was capturable (baseline: none recorded) — write the spec without baseline comparison and note it."
766
+ : "pre-change baseline captures: " + mapBaselineRefs + " — consult the affected views when writing the spec.") +
767
+ " If the baseline evidence is missing with no baseline:none recorded, do not write the spec — report 'baseline evidence missing — Map gate bounce required' and stop.\n" +
768
+ "Declare capture targets for the post-change visual capture: end your report with a line `capture_targets: <comma-separated views/controls this change affects>` (optional; falls back to the task description).";
769
+ }
770
+ instructions = "Research the problem space, evaluate options, pick the shortest path.\nWrite a clear spec that a builder can execute without asking questions.\nThe builder will edit source files in a git worktree.\nProject: " + PROJECT_DESC + "\nSave the spec to a file under " + crewHome + "/ if needed.\nReport back in plain prose — what you specified." + mapGatePara;
233
771
 
234
772
  } else if (step.name === "Build") {
235
773
  instructions = "STEP 1: Prepare your worktree.\n" +
236
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " prepare " + taskId + "\n" +
237
- "If the output says CREATED or REUSED, proceed. If it says ERROR, stop and set passed to false.\n\n" +
774
+ "Run: "+ LIFECYCLE_ENV + " prepare " + taskId + "\n" +
775
+ "If the output says CREATED or REUSED, proceed. If it says ERROR, stop and report the failure clearly.\n\n" +
238
776
  "STEP 2: Edit source files to implement the mapper's spec below.\n" +
239
777
  (mapperSpec ? "MAPPER'S SPEC (implement exactly this):\n" + mapperSpec + "\n\n" : "") +
240
- "Your working directory: " + REPO_PATH + "/.worktrees/" + taskId + "/\n" +
778
+ "Your working directory: " + WORKTREE_HINT + "/\n" +
241
779
  "This is the project source: " + PROJECT_DESC + "\n" +
242
780
  "Edit source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
243
781
  "Do not add unrequested features. Build exactly what the spec calls for.\n" +
244
782
  "PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
245
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time — never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your summary with exactly these two lines:\n" +
783
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time — never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your report with exactly these two lines:\n" +
246
784
  "release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
247
785
  "version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
248
786
  "Example: release: yes\\nversion_bump: minor\n\n" : "") +
249
787
  "STEP 3: Commit your changes.\n" +
250
- "cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
788
+ "cd " + WORKTREE_HINT + "\n" +
251
789
  "git add -A\n" +
252
790
  "git commit -m \"" + safeTitle + "\"\n\n" +
253
- "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of main and declare `repo_diff: none` in your summary, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
791
+ "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of main and declare `repo_diff: none` in your report, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
254
792
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
255
- "Your final response MUST be valid JSON and nothing else: { \"summary\": \"what you built\", \"passed\": true }. No prose, no markdown, just the JSON object.";
793
+ "Report back in plain prose: what you built and the outcome." +
794
+ (PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
256
795
 
257
796
  } else if (step.name === "Review") {
258
797
  instructions = "Review independently and cold. You have NOT seen any reasoning from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
259
798
  (mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + mapperSpec + "\n\n" : "Read the spec (from the task description or spec files under " + crewHome + "/).\n\n") +
260
799
  "Examine the code changes by running:\n" +
261
- "CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " inspect " + taskId + "\n\n" +
800
+ LIFECYCLE_ENV + LIFECYCLE + " inspect " + taskId + "\n\n" +
262
801
  "The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of main. Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n" +
263
802
  "You can also read specific files in the worktree at:\n" +
264
- REPO_PATH + "/.worktrees/" + taskId + "/\n\n" +
803
+ WORKTREE_HINT + "/\n\n" +
265
804
  "Check quality, correctness, and spec compliance.\n" +
266
- "Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, reject with notes on what is stale.\n" +
267
- "If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise reject: 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit'.\n" +
805
+ "Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, report what is stale, then end your report with exactly this line: VERDICT: FAIL.\n" +
806
+ "If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise report 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n" +
268
807
  (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches. Two checks:\n" +
269
- "(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...task/" + taskId + " -- package.json. If the branch touched `version` in any way, REJECT with notes: 'versions are assigned at publish time, never in branches — remove the version change'.\n" +
270
- "(b) The accepted Build summary declares: " + releaseDecisionText() + ". " +
808
+ "(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main..." + TASK_BRANCH + " -- package.json. If the branch touched `version` in any way, report 'versions are assigned at publish time, never in branches — remove the version change' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n" +
809
+ "(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
271
810
  (releaseDecision
272
- ? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, reject with notes."
273
- : "The decision is missing or malformed — REJECT with notes: 'Build summary must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines'.") + "\n" : "") +
274
- "If the work passes review, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
275
- "If the work fails review, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes explaining what needs to change\" }.\n" +
276
- "No prose, no markdown, just the JSON object.";
811
+ ? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, report your notes, then end with exactly this line: VERDICT: FAIL."
812
+ : "The decision is missing or malformed — report 'Build report must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines', then end with exactly this line: VERDICT: FAIL.") + "\n" : "") +
813
+ "Write your review as plain prose — findings, then decision. End your report with exactly one line: VERDICT: PASS if the work passes, VERDICT: FAIL if it fails.";
277
814
 
278
815
  } else if (step.name === "Integrate") {
279
816
  instructions = "Merge the approved task branch into main.\n\n" +
280
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " integrate " + taskId + " \"merge: " + safeTitle + "\"\n\n" +
817
+ "Run: "+ LIFECYCLE_ENV + " integrate " + taskId + " \"merge: " + safeTitle + "\"\n\n" +
281
818
  "Read the output:\n" +
282
819
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
283
- "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Set passed to true with summary 'merged empty: no repo changes — deliverable was runtime state'. SKIP STEP 2 (push): there is no new commit to push.\n" +
284
- "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false with summary 'merge lock held'.\n" +
820
+ "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Report 'merged empty: no repo changes — deliverable was runtime state', then end your report with exactly this line: VERDICT: PASS. SKIP STEP 2 (push): there is no new commit to push.\n" +
821
+ "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Report 'merge lock held', then end your report with exactly this line: VERDICT: FAIL.\n" +
285
822
  "- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
286
823
  "RESOLUTION:\n" +
287
- "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge task/" + taskId + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch task/" + taskId + " is never modified.\n" +
824
+ "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): "+ LIFECYCLE_ENV + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge " + TASK_BRANCH + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch " + TASK_BRANCH + " is never modified.\n" +
288
825
  "R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
289
- "R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
826
+ "R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock ("+ LIFECYCLE_ENV + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
290
827
  "R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
291
828
  "R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
292
829
  "R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
293
- "ESCALATE — return {\"passed\": false, \"summary\": \"conflict needs human resolution\"} — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
294
- "- If it contains ERROR, something else failed. Set passed to false with the error.\n\n" +
830
+ "ESCALATE — report 'conflict needs human resolution', then end your report with exactly this line: VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
831
+ "- If it contains ERROR, something else failed. Report the error, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
295
832
  "\n" +
296
833
  "STEP 2: Push the merged main to the remote repository.\n" +
297
834
  "Run: cd " + REPO_PATH + " && git push origin main\n" +
298
835
  "- If the push succeeds, report the merged commit hash.\n" +
299
836
  "- If the push is rejected as non-fast-forward (the remote has commits not present locally),\n" +
300
- " NEVER force-push. Do not run any --force variant. Set passed to false with summary:\n" +
301
- " 'git push origin main rejected as non-fast-forwardremote main has diverged; manual resolution required'.\n\n" +
302
- "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true/false }. No prose, no markdown, just the JSON object.";
837
+ " NEVER force-push. Do not run any --force variant. Report 'git push origin main rejected as non-fast-forward — remote main has diverged; manual resolution required' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
838
+ "Report back in plain prose what happened at each step and end your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
303
839
 
304
840
  } else if (step.name === "Publish") {
305
841
  if (PUBLISH_TYPE === "npm") {
306
- // npm packages: immutable release + pack + publish to the registry (push is universal in Integrate)
307
- // The release decision arrived deterministically from the workflow (release: yes)
308
- // these steps are unconditional. There is no decision to make and no skip path.
309
- instructions = "Publish the npm package to the registry.\n\n" +
310
- "The release decision is already made and recorded — it is not yours to make: the accepted Build summary (validated by Review) declares " + releaseDecisionText() + ". Execute every step below in order.\n\n" +
311
- "The repo push already happened in Integrate do NOT push to git in this phase except STEP 7, and NEVER force-push.\n\n" +
312
- "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
313
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
314
- "STEP 1: Install and activate the immutable release.\n" +
315
- "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
316
- "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
317
- "STEP 2: Read the registry base version.\n" +
318
- "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
319
- "If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
320
- "STEP 3: Apply the version_bump scope (" + releaseDecision.version_bump + ") to the base version: patch increments the last segment; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0. Example: base 1.2.3 + minor → 1.3.0. Call the result <new-version>.\n" +
321
- "STEP 3B: Make the version math auditable. State the computed target version explicitly — your summary MUST include the line: TARGET_VERSION=<new-version> computed as <base> + <scope> <new-version> (example: TARGET_VERSION=0.3.1 computed as 0.3.0 + patch → 0.3.1). Every later step uses exactly this version. Do not improvise the arithmetic: 0.3.0 + patch is 0.3.1, never 0.4.0.\n" +
322
- "STEP 4: Write <new-version> into package.json (only the `version` field), then commit it under the still-held merge lock: cd " + REPO_PATH + " && git add package.json && git commit -m \"release: muse-crew@<new-version>\". The lock serializes Publish per repo, so two tasks can never pick the same version.\n" +
323
- "STEP 5: Pack and publish.\n" +
324
- "Run: cd " + REPO_PATH + " && npm pack\n" +
325
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
326
- "If publish fails with 'You cannot publish over the previously published versions', <new-version> is already on the registry (a retried Publish — the merge lock guarantees no other task picked this version): continue to STEP 6 verification. Any other publish failure: set passed to false with the failure details.\n\n" +
327
- "STEP 6: Verify.\n" +
328
- "Run: npm view muse-crew version\n" +
329
- "It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
330
- "STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
331
- "STEP 8: Finalize.\n" +
332
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
333
- "If the output contains DEPLOYED, finalization is complete.\n\n" +
334
- "Do NOT compare the local package.json version to the registry version: with versions assigned at publish time, local==registry is the normal steady state before assignment — not a signal to skip. Execute every step above.\n\n" +
335
- "End your summary with exactly these two lines, in this order — lowercase, no trailing period, do not rephrase (the QA backstop extracts them by pattern):\n" +
336
- "TARGET_VERSION=<new-version> computed as <base> + <scope> <new-version>\n" +
337
- "published: muse-crew@<new-version>\n\n" +
338
- "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
339
- "No prose, no markdown, just the JSON object.";
340
- } else if (PUBLISH_TYPE === "artifact") {
341
- // Artifact projects: rebuild the live artifact via artifact_edit, then finalize
842
+ // Deterministic publish: the workflow computed the target version and the
843
+ // agent runs exactly one command the pinned publish script. The script
844
+ // does lock refresh, release install, version write/commit, pack, registry
845
+ // publish, verify, push, and post-deploy. Text stays minimal by design;
846
+ // decisions live in code, not in agent judgment.
847
+ instructions = "Publish the npm package by running exactly ONE command the deterministic publish script.\n" +
848
+ "The release decision is already made and recorded: release: yes, version_bump: " + publishTarget.scope +
849
+ ", target version " + publishTarget.target + " (computed as " + publishTarget.base + " + " + publishTarget.scope +
850
+ " " + publishTarget.target + " by the workflow, not by you). There is no decision to make and no skip path.\n\n" +
851
+ "Run exactly this command and no other publish-related commands:\n" +
852
+ "TASK_ID=" + taskId + " REPO_PATH=" + REPO_PATH + " PKG=muse-crew TARGET_VERSION=" + publishTarget.target +
853
+ " CREW_HOME=" + crewHome + " LIFECYCLE=" + LIFECYCLE + " RELEASE_SCRIPT=" + RELEASE_SCRIPT +
854
+ " bash " + PUBLISH_NPM + "\n\n" +
855
+ "Do NOT run npm, npm pack, npm publish, or the python publish script yourself. Do NOT compare local and registry versions. Do NOT decide whether to publish.\n\n" +
856
+ "If the command exits nonzero, put the script's PUBLISH_FAILED line in your report, then end your report with exactly this line: VERDICT: FAIL.\n" +
857
+ "If it exits zero, paste the script's COMPLETE marker block verbatim into your report, then end your report with exactly these three lines, in this order lowercase, no trailing period, do not rephrase:\n" +
858
+ "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " " + publishTarget.target + "\n" +
859
+ "published: muse-crew@" + publishTarget.target + "\n" +
860
+ "VERDICT: PASS\n\n";
861
+ } else if (PUBLISH_TYPE === "artifact") {
862
+ // Deterministic artifact publish (canary b5efd1b1, 2026-09-10): the work
863
+ // agent claimed "Rebuilt and deployed" while no build ran and no
864
+ // provenance was stamped — prose-trusted side effects, the same failure
865
+ // class as the npm double-skip (bb739316). The npm path already runs one
866
+ // deterministic script; the artifact path now has the same shape. Lock
867
+ // refresh, rebuild trigger, build-completion poll, provenance stamp, and
868
+ // post-deploy are narrow schema'd bookkeeping calls owned by the
869
+ // workflow — the work agent reports on the mechanical outcome and
870
+ // cannot skip what it never owned. Any step failing parks with an
871
+ // honest, step-specific reason (fail-closed). The post-hoc
872
+ // getprovenance-vs-HEAD verification below stays as the final gate.
873
+ var artifactPublish = null;
874
+ var publishLockRefreshed = false;
875
+ try {
876
+ // STEP 0 (mechanical): refresh the merge lock so a long build cannot
877
+ // go stale mid-publish.
878
+ var lockRefresh = await agent(
879
+ "Run: "+ LIFECYCLE_ENV + " refresh-lock " + taskId + "\n" +
880
+ "Return JSON { \"refreshed\": <true if the command exited zero, false otherwise>, \"output\": \"<trimmed stdout>\" } and nothing else.",
881
+ { key: attemptKey("publish-lock-refresh-" + taskId, totalReworkCount), label: "Refreshing merge lock for publish",
882
+ schema: { type: "object", properties: { refreshed: { type: "boolean" }, output: { type: "string" } }, required: ["refreshed"] } }
883
+ );
884
+ if (!lockRefresh.refreshed) {
885
+ return await parkTask("Publish lock refresh failed: " + (lockRefresh.output || "no output") + ". Fail-closed — lock state unknown.");
886
+ }
887
+ publishLockRefreshed = true;
888
+ // STEP 1 (mechanical): trigger the rebuild with one narrow call. The
889
+ // agent only makes the artifact_edit call and reports whether it was
890
+ // accepted — no prose claim to trust. If the artifact tool namespace
891
+ // is missing from this child it reports honestly and the workflow
892
+ // retries once with a fresh key (bounded); anything else parks.
893
+ var rebuildPrompt =
894
+ "Call artifact_edit with slug \"" + PUBLISH_SLUG + "\" and verbatim_request:\n" +
895
+ "'Rebuild the application from current source. Do not modify any source files — just rebuild and deploy what is on disk.'\n" +
896
+ "If the artifact_edit tool is not available in this session, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_edit unavailable\" }.\n" +
897
+ "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\" } and nothing else.";
898
+ var rebuildSchema =
899
+ { type: "object", properties: { edit_started: { type: "boolean" }, error: { type: "string" } }, required: ["edit_started"] };
900
+ var rebuildTrigger = await agent(rebuildPrompt,
901
+ { key: attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount), label: "Triggering artifact rebuild", schema: rebuildSchema });
902
+ if (!rebuildTrigger.edit_started && rebuildTrigger.error === "artifact_edit unavailable") {
903
+ log("Publish rebuild trigger: artifact_edit unavailable — one bounded retry with a fresh key");
904
+ rebuildTrigger = await agent(rebuildPrompt,
905
+ { key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry1", totalReworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
906
+ }
907
+ var publishFailure = null;
908
+ if (rebuildTrigger.edit_started) {
909
+ // STEP 1b (mechanical): bounded poll for build completion.
910
+ var buildPoll = await agent(
911
+ "Poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 20 checks (10 minutes max).\n" +
912
+ "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
913
+ { key: attemptKey("publish-artifact-poll-" + taskId, totalReworkCount), label: "Waiting for artifact build to complete",
914
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
915
+ timeoutMs: 660000 }
916
+ );
917
+ if (buildPoll.build_done) {
918
+ // STEP 1c (mechanical): stamp provenance from workflow-computed values.
919
+ var provStamp = await agent(
920
+ "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this SRC.\n" +
921
+ "Run: basename $(readlink " + crewHome + "/current) — call this REL.\n" +
922
+ "Run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS.\n" +
923
+ "Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"setprovenance\", args:\n" +
924
+ "{ \"source_commit\": \"<SRC trimmed>\", \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\", \"task_id\": \"" + taskId + "\" }.\n" +
925
+ "Return JSON { \"stamped\": <true if the setprovenance response contains ok: true, false otherwise>, \"source_commit\": \"<SRC trimmed>\", \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
926
+ { key: attemptKey("publish-artifact-stamp-" + taskId, totalReworkCount), label: "Stamping artifact provenance",
927
+ schema: { type: "object", properties: { stamped: { type: "boolean" }, source_commit: { type: "string" }, crew_release: { type: "string" }, published_at: { type: "string" } }, required: ["stamped", "source_commit", "crew_release", "published_at"] } }
928
+ );
929
+ if (provStamp.stamped) {
930
+ artifactPublish = { source_commit: provStamp.source_commit, crew_release: provStamp.crew_release, published_at: provStamp.published_at };
931
+ } else {
932
+ publishFailure = "Provenance stamp failed after a completed build (source_commit " + (provStamp.source_commit || "unknown") + "). The build landed but is unstamped — fail-closed.";
933
+ }
934
+ } else {
935
+ publishFailure = "Artifact build did not complete within budget: " + (buildPoll.status || "timeout") + ". The publish may or may not have landed — provenance was not stamped.";
936
+ }
937
+ } else {
938
+ publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
939
+ }
940
+ // STEP 2 (mechanical, always once the lock was refreshed): post-deploy
941
+ // commits builder leftovers if any, removes the worktree, and releases
942
+ // the merge lock — even when the publish itself failed, so a skipped
943
+ // or failed publish can never leave the lock held (canary b5efd1b1).
944
+ var postDeploy = await agent(
945
+ "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
946
+ "Return JSON { \"deployed\": <true if the output contains DEPLOYED, false otherwise>, \"output\": \"<trimmed output>\" } and nothing else.",
947
+ { key: attemptKey("publish-postdeploy-" + taskId, totalReworkCount), label: "Finalizing publish (post-deploy)",
948
+ schema: { type: "object", properties: { deployed: { type: "boolean" }, output: { type: "string" } }, required: ["deployed"] } }
949
+ );
950
+ if (publishFailure) {
951
+ return await parkTask(publishFailure + (postDeploy.deployed
952
+ ? " Post-deploy finalized cleanup."
953
+ : " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
954
+ }
955
+ if (!postDeploy.deployed) {
956
+ return await parkTask("Post-deploy failed after a stamped publish: " + (postDeploy.output || "no output") + ". The publish landed but worktree cleanup and lock release are unknown — human attention needed.");
957
+ }
958
+ log("Deterministic artifact publish completed for task " + taskId + ": provenance at " + artifactPublish.source_commit);
959
+ } catch (pubErr) {
960
+ // Best-effort cleanup: if the lock was refreshed, try to release it
961
+ // before parking so the failure cannot wedge the next run.
962
+ if (publishLockRefreshed) {
963
+ try {
964
+ await agent(
965
+ "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
966
+ "Return JSON { \"deployed\": <true if the output contains DEPLOYED, false otherwise> } and nothing else.",
967
+ { key: attemptKey("publish-postdeploy-cleanup-" + taskId, totalReworkCount), label: "Releasing lock after publish failure",
968
+ schema: { type: "object", properties: { deployed: { type: "boolean" } }, required: ["deployed"] } }
969
+ );
970
+ } catch (cleanupErr) {
971
+ log("Publish cleanup post-deploy also failed: " + (cleanupErr && cleanupErr.message ? cleanupErr.message : cleanupErr));
972
+ }
973
+ }
974
+ return await parkTask("Deterministic artifact publish failed: " + (pubErr && pubErr.message ? pubErr.message : pubErr) + ". Fail-closed.");
975
+ }
976
+ // The work agent no longer performs the publish — it reports on the
977
+ // mechanical outcome above. It must not rebuild or re-stamp: a second
978
+ // artifact_edit would trigger a duplicate build.
342
979
  instructions = "Publish the merged code to the live artifact.\n\n" +
980
+ "The publish was performed deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would trigger a duplicate build or disturb the finalized state. You perform no publish actions.\n\n" +
981
+ "For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
982
+ "Mechanical outcome (every step succeeded and was verified by the workflow):\n" +
983
+ "- merge lock refreshed: yes\n" +
984
+ "- artifact rebuild triggered and completed: yes\n" +
985
+ "- provenance stamped: yes — source_commit " + artifactPublish.source_commit + ", crew_release " + artifactPublish.crew_release + ", published_at " + artifactPublish.published_at + "\n" +
986
+ "- post-deploy finalized: yes (worktree removed, merge lock released)\n\n" +
343
987
  "POLICY: The live artifact is rebuilt only in this phase, from the repo. Never use artifact_edit to change the artifact directly — fixes go through the repo and the loop. A source fix is not done until the artifact is rebuilt from it here.\n\n" +
344
988
  "The repo push already happened in Integrate — do NOT push to git in this phase.\n\n" +
345
- "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
346
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
347
- "STEP 1: Publish to the live artifact.\n" +
348
- "Get the change summary: cd " + REPO_PATH + " && git log -1 --stat\n" +
349
- "Then call artifact_edit with slug \"" + PUBLISH_SLUG + "\" and verbatim_request:\n" +
350
- "'Rebuild the application from current source. Do not modify any source files — just rebuild and deploy what is on disk.'\n" +
351
- "Wait for the build to complete by polling artifact_status until it is no longer running.\n\n" +
352
- "STEP 1B: Stamp publication provenance.\n" +
353
- "Run: cd " + REPO_PATH + " && git rev-parse HEAD\n" +
354
- "Run: basename $(readlink " + crewHome + "/current)\n" +
355
- "Run: date -u +%Y-%m-%dT%H:%M:%SZ\n" +
356
- "Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"setprovenance\", args:\n" +
357
- "{ \"source_commit\": \"<rev-parse output>\", \"crew_release\": \"<basename output>\", \"published_at\": \"<date output>\", \"task_id\": \"" + taskId + "\" }.\n" +
358
- "Confirm the response contains ok: true. If setprovenance fails, report it in your summary but still run post-deploy to release the lock.\n\n" +
359
- "STEP 2: Finalize.\n" +
360
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
361
- "If the output contains DEPLOYED, publishing is complete.\n\n" +
362
- "If artifact_edit failed, still run post-deploy to release the merge lock and clean up.\n" +
363
- "Report the failure: { \"passed\": false, \"summary\": \"artifact publish failed: [details]\" }.\n\n" +
364
- "Your final response MUST be valid JSON and nothing else: { \"summary\": \"published changes\", \"passed\": true }.\n" +
365
- "No prose, no markdown, just the JSON object.";
366
- } else if (PUBLISH_TYPE === "vercel") {
989
+ "Write plain prose describing what was published, then on its own line: VERDICT: PASS\n" +
990
+ "The VERDICT line must be the last line of your report.";
991
+ } else if (PUBLISH_TYPE === "vercel") {
367
992
  // vercel publish is not yet implemented — block without inventing behavior
368
993
  instructions = "The project's publish target is \"vercel\", which is not yet implemented.\n" +
369
994
  "Do NOT invent publish behavior — do not guess CLI commands, APIs, or deployment steps.\n" +
370
- "Your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"vercel publish not yet implemented\" }.\n" +
371
- "No prose, no markdown, just the JSON object.";
995
+ "Report the situation in prose, then end your report with exactly this line: VERDICT: FAIL.";
372
996
  }
373
997
  } else if (step.name === "QA") {
374
998
  // Backstop for merge-time versioning: when the accepted Build summary
375
999
  // declared release: yes, QA verifies the registry actually moved. A silent
376
1000
  // publish skip becomes a loud QA failure with evidence, not a pass.
377
1001
  var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
378
- ? "NPM PUBLISH CHECK: the accepted Build summary declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
379
- "Extract the line matching TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version>. If the line is missing, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: Publish summary did not echo its computed target version (STEP 3B)\" }.\n" +
380
- "Verify the bump SCOPE: the <scope> in that line MUST equal the accepted version_bump scope \"" + releaseDecision.version_bump + "\" — if the Publish agent applied a different scope, FAIL. Verify the ARITHMETIC: <base> + <scope> must equal <new-version> (patch increments the last segment only, e.g. 0.3.0 + patch → 0.3.1; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0) — if the math is wrong, FAIL.\n" +
381
- "Extract the published version from the notes line matching published: muse-crew@<version> (match case-insensitively and ignore any trailing period — agents sometimes rephrase it). It MUST equal <new-version> from the TARGET_VERSION line. Then run: npm view muse-crew version. The registry version MUST equal <new-version>. If any of these checks fails, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: [details]\" }.\n"
1002
+ ? "NPM PUBLISH CHECK: the accepted Build report declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
1003
+ "Extract the line matching TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version> (the workflow appends it to the Publish notes, so it is always present). If the line is missing, report 'npm publish verification failed: Publish notes did not carry the computed target version', then end your report with exactly this line: VERDICT: FAIL.\n" +
1004
+ "Verify the bump SCOPE: the <scope> in that line MUST equal the accepted version_bump scope \"" + releaseDecision.version_bump + "\" — if it differs, report the mismatch, then end your report with exactly this line: VERDICT: FAIL. Verify the ARITHMETIC: <base> + <scope> must equal <new-version> (patch increments the last segment only, e.g. 0.3.0 + patch → 0.3.1; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0) — if the math is wrong, report it, then end your report with exactly this line: VERDICT: FAIL.\n" +
1005
+ "Extract the published version from the notes line matching published: muse-crew@<version>. It MUST equal <new-version> from the TARGET_VERSION line. Then run: npm view muse-crew version. The registry version MUST equal <new-version>. If any of these checks fails, report 'npm publish verification failed: [details]', then end your report with exactly this line: VERDICT: FAIL.\n"
382
1006
  : "";
383
1007
  if (PUBLISH_TYPE === "artifact") {
384
1008
  var safeDesc = taskDescription.replace(/"/g, "'").replace(/\\/g, "\\\\").slice(0, 500);
@@ -392,26 +1016,57 @@ while (i < STEPS.length) {
392
1016
  "This call is asynchronous — it fires the inspection but results arrive outside this workflow. That is expected and correct.\n\n" +
393
1017
  "STEP 2: Verify data integrity via the dashboard API.\n" +
394
1018
  "Use artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\" with read-only actions (e.g. gettasks, getagentsessions) to check the task's data-level effects.\n" +
395
- "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, FAIL with { \"passed\": false, \"summary\": \"public docs missing/stale for [the change]\" }. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
1019
+ "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
396
1020
  "PROVENANCE CHECK: Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}.\n" +
397
- "If provenance is null, FAIL: { \"passed\": false, \"summary\": \"provenance missing — publish did not stamp source/crew release\" }.\n" +
398
- "Run: cd " + REPO_PATH + " && git rev-parse HEAD\n" +
399
- "Run: basename $(readlink " + crewHome + "/current)\n" +
400
- "If provenance.source_commit does not equal the rev-parse output or provenance.crew_release does not equal the basename output, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: [details]\" }.\n\n" +
1021
+ "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
1022
+ "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
1023
+ "Run: basename $(readlink " + crewHome + "/current) — call this LIVE_CREW.\n" +
1024
+ "If provenance.crew_release does not equal LIVE_CREW, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] != [LIVE_CREW]\" }.\n" +
1025
+ "If provenance.source_commit equals LIVE_HEAD, the source check passes — continue to STEP 3.\n" +
1026
+ "Otherwise the check is NOT failed yet: post-deploy commits an artifact-builder staging commit (\"rebuild: <task_id>\") AFTER the stamp, so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1027
+ "1. Run: cd " + REPO_PATH + " && git merge-base --is-ancestor <provenance.source_commit> LIVE_HEAD && echo ANCESTOR_OK (substitute the real stamped hash and LIVE_HEAD; do not run the literal placeholders). If this command fails, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: stamped source_commit is not an ancestor of live HEAD\" }.\n" +
1028
+ "2. Run: cd " + REPO_PATH + " && git log --format=%s <provenance.source_commit>..LIVE_HEAD (substitute real values). Every subject line MUST start with \"rebuild: \". If any line does not, report 'provenance mismatch: live HEAD moved past the stamped commit with non-rebuild source commits: [paste the offending subject lines]', then end your report with exactly this line: VERDICT: FAIL.\n" +
1029
+ "If both pass, the source check passes — the only drift since the stamp is builder staging output committed by post-deploy. Continue to STEP 3.\n\n" +
401
1030
  "STEP 3: File follow-up tasks for any related issues you discover.\n" +
402
1031
  "Use artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for each issue.\n\n" +
403
- "Your final response MUST be valid JSON and nothing else:\n" +
404
- "{ \"passed\": true, \"summary\": \"what data checks you ran and that visual inspection was requested\" }.\n" +
405
- "No prose, no markdown, just the JSON object.";
1032
+ "Report back in plain prose what checks you ran and their results. End your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
406
1033
  } else {
407
1034
  instructions = "Test from a user's perspective. You are CODE-BLIND — do NOT read source code.\n" +
408
1035
  "Public docs (API.md, README) are NOT source code — read them freely, exactly as a user would.\n" +
409
1036
  "Verify the change is working as described in the task.\n" +
410
- "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale, FAIL with { \"passed\": false, \"summary\": \"public docs missing/stale for [the change]\" }. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n" +
1037
+ "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n" +
411
1038
  "File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues found.\n\n" +
412
1039
  npmPublishCheck +
413
- "Your final response MUST be valid JSON and nothing else: { \"passed\": true/false, \"summary\": \"what you tested and found\" }.\n" +
414
- "No prose, no markdown, just the JSON object.";
1040
+ "Report back in plain prose what you tested and found. End your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1041
+ }
1042
+ // Visual verdict ownership (experiential artifact tasks only): the QA
1043
+ // work agent covers the MECHANICAL CHECKS below — the artifact_inspect
1044
+ // trigger prose is replaced, because the visual verdict is produced by
1045
+ // the parent after the rendered post-change inspection results arrive.
1046
+ // Mechanical checks are kept verbatim from the artifact path above.
1047
+ if (qaVisual) {
1048
+ instructions = "You are code-blind QA. You NEVER read source files. Public docs are not source — read them as a user would.\n" +
1049
+ "VISUAL VERDICT OWNERSHIP: this task is experiential. The visual verdict is NOT yours to issue — it is produced after the rendered post-change inspection results arrive, by Hazel with the baseline and post-change evidence in hand.\n" +
1050
+ "Your VERDICT below covers the MECHANICAL CHECKS only. Do NOT call artifact_inspect (async; the parent triggers the post-change capture after your step).\n\n" +
1051
+ "MECHANICAL CHECKS:\n" +
1052
+ "STEP 2: Verify data integrity via the dashboard API.\n" +
1053
+ "Use artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\" with read-only actions (e.g. gettasks, getagentsessions) to check the task's data-level effects.\n" +
1054
+ "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
1055
+ "PROVENANCE CHECK: Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}.\n" +
1056
+ "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
1057
+ "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
1058
+ "Run: basename $(readlink " + crewHome + "/current) — call this LIVE_CREW.\n" +
1059
+ "If provenance.crew_release does not equal LIVE_CREW, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] != [LIVE_CREW]\" }.\n" +
1060
+ "If provenance.source_commit equals LIVE_HEAD, the source check passes — continue to STEP 3.\n" +
1061
+ "Otherwise the check is NOT failed yet: post-deploy commits an artifact-builder staging commit (\"rebuild: <task_id>\") AFTER the stamp, so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1062
+ "1. Run: cd " + REPO_PATH + " && git merge-base --is-ancestor <provenance.source_commit> LIVE_HEAD && echo ANCESTOR_OK (substitute the real stamped hash and LIVE_HEAD; do not run the literal placeholders). If this command fails, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: stamped source_commit is not an ancestor of live HEAD\" }.\n" +
1063
+ "2. Run: cd " + REPO_PATH + " && git log --format=%s <provenance.source_commit>..LIVE_HEAD (substitute real values). Every subject line MUST start with \"rebuild: \". If any line does not, report 'provenance mismatch: live HEAD moved past the stamped commit with non-rebuild source commits: [paste the offending subject lines]', then end your report with exactly this line: VERDICT: FAIL.\n" +
1064
+ "If both pass, the source check passes — the only drift since the stamp is builder staging output committed by post-deploy. Continue to STEP 3.\n\n" +
1065
+ "STEP 3: File follow-up tasks for any related issues you discover.\n" +
1066
+ "Use artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for each issue.\n\n" +
1067
+ "BASELINE SANITY: in the event history you fetched, the task's note events must contain a message starting with `baseline: captured` or `baseline: none`. If no message starts with either prefix, report 'baseline evidence missing at QA — the Map gate was bypassed', then end your report with exactly this line: VERDICT: FAIL.\n\n" +
1068
+ "File follow-up tasks as today.\n" +
1069
+ "Report back in plain prose — what checks you ran and their results. End your report with exactly one line: VERDICT: PASS or VERDICT: FAIL on the mechanical checks.";
415
1070
  }
416
1071
  }
417
1072
 
@@ -426,10 +1081,13 @@ while (i < STEPS.length) {
426
1081
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
427
1082
  }
428
1083
 
429
- // Run work agent WITH schema runtime retries on non-JSON via structured outputs
430
- var stepResult;
431
- try {
432
- stepResult = await agent(
1084
+ // Work agent returns the runtime's native envelope {"status": "ok",
1085
+ // "result": "<prose>"} with no schema. The runtime requires JSON output;
1086
+ // the envelope is its own documented shape, so there is nothing for the
1087
+ // agent to improvise. The workflow receives the prose report as a plain
1088
+ // string. The verdict is still extracted deterministically from the report
1089
+ // text by extractVerdict below — never by an agent.
1090
+ var workPromptBase =
433
1091
  "Read the identity file at " + ORCH_PATH + "/identities/" + step.identity + ".md using the read tool, and embody that character fully.\n\n" +
434
1092
  "## Your Assignment\n\n" +
435
1093
  "Task: " + taskTitle + "\n" +
@@ -439,54 +1097,213 @@ while (i < STEPS.length) {
439
1097
  (step.name !== "Review" ? "Dashboard slug: " + DASHBOARD_SLUG + "\n" : "") +
440
1098
  "\n## Instructions\n\n" + eventPreamble + instructions + "\n\n" +
441
1099
  "CONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\n" +
442
- "Stay in character. Do the work thoroughly.",
443
- {
444
- key: "work-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
445
- label: step.identity + ": " + step.name + " on \"" + taskTitle + "\"",
446
- timeoutMs: 3600000,
447
- schema: WORK_SCHEMA
1100
+ "Stay in character. Do the work thoroughly.\n\n" +
1101
+ "Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your report here\"}. " +
1102
+ "The result is plain prose describing what you did and found. For verdict steps, end the report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1103
+ var workKeyBase = "work-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "");
1104
+ var workerResult = null;
1105
+ var workAttempts = [];
1106
+ for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
1107
+ var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), workAttempt);
1108
+ var retryReason = workAttempt === 0 ? null : (workAttempts[workAttempt - 1].threw ? "discarded" : "empty");
1109
+ try {
1110
+ workerResult = await agent(
1111
+ workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),
1112
+ {
1113
+ key: workKey,
1114
+ label: step.identity + ": " + step.name + " on \"" + taskTitle + "\"" + (workAttempt === 0 ? "" : " (transport retry " + workAttempt + " of 2)"),
1115
+ timeoutMs: 3600000
1116
+ }
1117
+ );
1118
+ // Success path: a non-blank string report is usable — keep it and stop
1119
+ // retrying. Without this branch every attempt is discarded and the phase
1120
+ // always fails (regression shipped in ecef136 when Date.now() was
1121
+ // removed from this loop).
1122
+ if (typeof workerResult === "string" && workerResult.trim()) {
1123
+ if (workAttempt > 0) log(step.name + " work agent transport retry " + workAttempt + " returned a machine-readable report");
1124
+ break;
448
1125
  }
449
- );
450
- } catch (e) {
451
- log(step.name + " agent failed: " + (e.message || String(e)).slice(0, 500));
452
- stepResult = null;
1126
+ var emptyOutcome = workerResult === null ? "null" : (typeof workerResult === "string" ? "blank string" : typeof workerResult);
1127
+ workAttempts.push({ threw: false, error: "", outcome: emptyOutcome });
1128
+ log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 returned no usable output (" + emptyOutcome + ") — retrying with a fresh key");
1129
+ workerResult = null;
1130
+ } catch (e) {
1131
+ var attemptErr = (e && e.message ? e.message : String(e)).replace(/"/g, "'").slice(0, 160);
1132
+ workAttempts.push({ threw: true, error: attemptErr, outcome: "" });
1133
+ log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 threw: " + attemptErr);
1134
+ workerResult = null;
1135
+ }
453
1136
  }
1137
+ // The report arrives as a plain string — the runtime unwraps the envelope.
1138
+ var workerText = (typeof workerResult === "string") ? workerResult : null;
454
1139
 
455
- // If schema retries were exhausted, block gracefully
456
- if (!stepResult || typeof stepResult.summary !== "string") {
457
- log(step.name + " closeout unrecoverableblocking");
1140
+ if (typeof workerText !== "string" || !workerText.trim()) {
1141
+ var workFailure = describeWorkAgentFailure(step.name, step.identity, workAttempts);
1142
+ log(step.name + " " + workFailure.notes + " marking failed for retry");
458
1143
  await agent(
459
- "Record closeout failure.\n" +
1144
+ "Record work failure.\n" +
460
1145
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
461
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"blocked\", \"notes\": \"Agent failed to return structured result after retries\" }.\n" +
1146
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"" + workFailure.notes + "\" }.\n" +
462
1147
  "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
463
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"blocked\", \"message\": \"" + step.name + " agent failed after retries — workflow blocked\" }.",
464
- { key: "record-block-" + step.name, label: "Recording closeout failure", schema: { type: "object" } }
1148
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + workFailure.eventMessage + "\" }.",
1149
+ { key: "record-block-" + step.name, label: "Recording work failure", schema: { type: "object" } }
465
1150
  );
466
1151
  return {
467
1152
  __hatchWorkflowControl: "blocked",
468
1153
  result: {
469
- blocked_reason: step.name + " agent failed after retries",
470
- message: "The " + step.identity + " agent's work may be valid — structured output failed.",
1154
+ blocked_reason: workFailure.blockedReason,
1155
+ message: workFailure.message,
471
1156
  task_id: taskId
472
1157
  }
473
1158
  };
474
1159
  }
1160
+ // Verdict derivation (deterministic): for VERDICT_STEPS the workflow owns
1161
+ // the verdict — extracted by regex from the trailing window from the worker's own report.
1162
+ // A missing, malformed, or contradictory VERDICT line first gets a bounded
1163
+ // mechanical re-ask (reaskVerdict); only if that is exhausted does the phase
1164
+ // fail closed (blocked):
1165
+ // On verdict failure the session is marked "failed", NOT "blocked":
1166
+ // the dispatcher retries failed sessions at the same step, while a
1167
+ // "blocked" session is never picked up again (blocked is reserved for
1168
+ // unmet dependencies). The __hatchWorkflowControl: "blocked" return below
1169
+ // stays — that is the runtime's halt-the-run signal, a separate vocabulary.
1170
+ var verdictPassed = null;
1171
+ if (VERDICT_STEPS.indexOf(step.name) >= 0) {
1172
+ var verdict = extractVerdict(workerText);
1173
+ if (!verdict.ok) {
1174
+ // Bounded mechanical re-ask before failing the phase: the report may
1175
+ // be valid with a stochastically omitted or garbled verdict line.
1176
+ log(step.name + " verdict line missing or ambiguous (" + verdict.count + " trailing-window matches) — attempting bounded re-ask");
1177
+ verdict = await reaskVerdict(step.name, (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), workerText);
1178
+ }
1179
+ if (!verdict.ok) {
1180
+ log(step.name + " verdict re-ask exhausted — marking failed for retry");
1181
+ await agent(
1182
+ "Record verdict failure.\n" +
1183
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
1184
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)\" }.\n" +
1185
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
1186
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry\" }.",
1187
+ { key: "record-block-" + step.name, label: "Recording verdict failure", schema: { type: "object" } }
1188
+ );
1189
+ return {
1190
+ __hatchWorkflowControl: "blocked",
1191
+ result: {
1192
+ blocked_reason: step.name + " worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)",
1193
+ message: "The " + step.identity + " agent's work may be valid — its report did not declare a verdict the workflow could read, and two bounded re-ask attempts could not transcribe one. The report is preserved in the workflow log.",
1194
+ task_id: taskId
1195
+ }
1196
+ };
1197
+ }
1198
+ verdictPassed = verdict.passed;
1199
+ }
1200
+
1201
+ // Deterministic closeout: no formatter agent. The verdict is mechanical
1202
+ // (extractVerdict above); the summary is the worker's report truncated.
1203
+ // For verdict steps passed comes from the verdict; for non-verdict steps
1204
+ // (Triage, Map) the worker producing output means the step passed.
1205
+ var stepResult = {
1206
+ passed: verdictPassed !== null ? verdictPassed : true,
1207
+ summary: workerText
1208
+ };
1209
+ const passed = stepResult.passed === true;
1210
+ // "rejected" is an explicit phase verdict routed through rework (Review/QA,
1211
+ // and the Build/Reproduce reports that feed them). Integrate/Publish work
1212
+ // that did not finish is operational — "failed", retryable under the
1213
+ // dispatcher's consecutive-failure cap.
1214
+ const status = passed ? "completed" : (step.name === "Integrate" || step.name === "Publish" ? "failed" : "rejected");
1215
+
1216
+ // Deterministic publish verification: the agent cannot self-certify a publish.
1217
+ var publishVerified = false;
1218
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
1219
+ try {
1220
+ var verifyResult = await agent(
1221
+ "Run: npm view muse-crew version 2>/dev/null. Return JSON { \"registry_version\": \"<output trimmed>\" } and nothing else.",
1222
+ { key: attemptKey("verify-publish-" + taskId, totalReworkCount), label: "Verifying published version",
1223
+ schema: { type: "object", properties: { registry_version: { type: "string" } }, required: ["registry_version"] } }
1224
+ );
1225
+ var regVer = (verifyResult.registry_version || "").trim();
1226
+ if (regVer !== publishTarget.target) {
1227
+ return await parkTask("Publish verification failed: registry shows " + regVer + " but the release decision required " +
1228
+ publishTarget.target + " (" + publishTarget.base + " + " + publishTarget.scope + "). The publish did not land.");
1229
+ }
1230
+ log("Publish verified for task " + taskId + ": registry at " + regVer);
1231
+ publishVerified = true;
1232
+ } catch (e) {
1233
+ return await parkTask("Publish verification failed: could not read the registry version (" + (e && e.message ? e.message : e) + "). Fail-closed.");
1234
+ }
1235
+ }
475
1236
 
476
- const summary = (stepResult.summary || "Step completed").slice(0, 2000);
477
- const passed = stepResult.passed !== false;
478
- const status = passed ? "completed" : "rejected";
1237
+ // Artifact publish verification: the worker cannot self-certify a deploy.
1238
+ // The workflow reads the artifact's provenance and confirms it points at the
1239
+ // integrated commit. A stale or missing provenance means the publish did not
1240
+ // land — fail closed, do not trust the worker's prose.
1241
+ if (step.name === "Publish" && PUBLISH_TYPE === "artifact" && PUBLISH_SLUG) {
1242
+ try {
1243
+ var headResult = await agent(
1244
+ "Run: cd " + REPO_PATH + " && git rev-parse HEAD. Return JSON { \"head\": \"<output trimmed>\" } and nothing else.",
1245
+ { key: attemptKey("verify-publish-head-" + taskId, totalReworkCount), label: "Reading integrated commit",
1246
+ schema: { type: "object", properties: { head: { type: "string" } }, required: ["head"] } }
1247
+ );
1248
+ var expectedCommit = (headResult.head || "").trim();
1249
+ var provResult = await agent(
1250
+ "Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}. " +
1251
+ "Return JSON { \"source_commit\": \"<provenance.source_commit>\", \"published_at\": \"<provenance.published_at>\" } and nothing else.",
1252
+ { key: attemptKey("verify-publish-prov-" + taskId, totalReworkCount), label: "Verifying artifact provenance",
1253
+ schema: { type: "object", properties: { source_commit: { type: "string" }, published_at: { type: "string" } }, required: ["source_commit"] } }
1254
+ );
1255
+ var provCommit = (provResult.source_commit || "").trim();
1256
+ if (!provCommit || provCommit !== expectedCommit) {
1257
+ return await parkTask("Publish verification failed: artifact provenance shows source_commit '" + provCommit +
1258
+ "' but the integrated HEAD is '" + expectedCommit + "'. The publish did not land or provenance was not stamped.");
1259
+ }
1260
+ log("Publish verified for task " + taskId + ": artifact provenance at " + provCommit);
1261
+ publishVerified = true;
1262
+ } catch (e) {
1263
+ return await parkTask("Publish verification failed: could not read artifact provenance (" + (e && e.message ? e.message : e) + "). Fail-closed.");
1264
+ }
1265
+ }
1266
+
1267
+ // Session notes. Machine-readable marker lines are extracted from the full
1268
+ // worker report and appended AFTER the slice so a long report can never
1269
+ // amputate them; later phases (Review reading repo_diff:, QA backstop
1270
+ // reading TARGET_VERSION=) depend on them.
1271
+ let summary;
1272
+ var workerMarkers = extractMarkerLines(workerText);
1273
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget && publishVerified) {
1274
+ const markerLines = "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1275
+ "published: muse-crew@" + publishTarget.target;
1276
+ summary = (stepResult.summary || "Step completed").slice(0, 2000 - markerLines.length - workerMarkers.length - 2) + "\n" + markerLines + (workerMarkers ? "\n" + workerMarkers : "");
1277
+ } else {
1278
+ summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
1279
+ }
1280
+
1281
+ // Visual verdict evidence: for experiential artifact tasks, append the
1282
+ // deterministic post-change capture plan to the QA session notes. The
1283
+ // parent protocol (docs/visual-verdict.md) triggers the inspection with
1284
+ // this plan; "visual: pending" marks the owed verdict.
1285
+ if (step.name === "QA" && passed && qaVisual) {
1286
+ summary = (summary + "\nvisual: pending\ncapture_plan: " +
1287
+ buildVisualCapturePlan(taskTitle, taskDescription, "postchange", captureTargets).replace(/\s+/g, " ")).slice(0, 2900);
1288
+ }
479
1289
 
480
1290
  // Capture mapper's spec for Build and Review
481
1291
  if (step.name === "Map" && passed) {
482
1292
  mapperSpec = summary;
1293
+ var ctm = /capture_targets:\s*(.+)/i.exec(workerText);
1294
+ captureTargets = ctm ? ctm[1].trim().slice(0, 300) : "";
1295
+ }
1296
+
1297
+ // Capture Sage's experiential flag (machine-read marker line).
1298
+ if (step.name === "Triage" && passed) {
1299
+ isExperiential = extractExperiential(workerText);
483
1300
  }
484
1301
 
485
- // Capture the accepted Build summary's machine-readable release decision.
486
- // Parsed from the RAW summary (before the 2000-char slice above) — the
487
- // release:/version_bump: lines sit at the very end and must survive truncation.
1302
+ // Capture the accepted Build report's machine-readable release decision.
1303
+ // Deterministic extraction from the full worker report. A missing or
1304
+ // malformed declaration fails closed to null and Review rejects.
488
1305
  if (step.name === "Build" && passed) {
489
- releaseDecision = extractReleaseDecision(stepResult.summary || "");
1306
+ releaseDecision = extractReleaseDecision(workerText);
490
1307
  }
491
1308
 
492
1309
  // Record session result
@@ -497,18 +1314,49 @@ while (i < STEPS.length) {
497
1314
  "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
498
1315
  "{ \"task_id\": \"" + taskId + "\", \"type\": \"" + status + "\", \"identity\": \"" + step.identity + "\", \"message\": \"" + step.name + " " + status + " by " + step.identity + "\" }.",
499
1316
  {
500
- key: "record-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
1317
+ key: "record-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
501
1318
  label: "Recording " + step.name + " result",
502
1319
  schema: { type: "object" }
503
1320
  }
504
1321
  );
505
1322
 
1323
+ // Visual-verdict gate: for experiential artifact tasks the task is done
1324
+ // only when the parent has recorded a visual_verdict: note event. The QA
1325
+ // work agent above covered the mechanical checks only. No recorded
1326
+ // verdict → park for the parent protocol (never mark done on a pending
1327
+ // visual verdict). A recorded FAIL with budget left → rework at Build.
1328
+ if (step.name === "QA" && passed && qaVisual) {
1329
+ var vvStatus = await visualVerdictStatus();
1330
+ if (vvStatus.found && vvStatus.verdict === "PASS") {
1331
+ log("Visual verdict PASS recorded for task " + taskId + (vvStatus.detail ? " — " + vvStatus.detail : ""));
1332
+ } else if (vvStatus.found && vvStatus.verdict === "FAIL") {
1333
+ // A FAIL whose reason begins exactly "rendering impossible:" is not
1334
+ // reworkable — there is no rendered evidence to fix against. Park for
1335
+ // human attention instead of bouncing to Build.
1336
+ if (vvStatus.detail.indexOf("rendering impossible:") === 0) {
1337
+ log("Visual verdict FAIL (rendering impossible) for task " + taskId + " — parking for human attention");
1338
+ return await parkTask("Visual verdict FAIL — rendering impossible, human attention required: " + vvStatus.detail);
1339
+ }
1340
+ totalReworkCount++;
1341
+ if (totalReworkCount > MAX_TOTAL_REWORK) {
1342
+ log("Shared rework budget exhausted for task " + taskId + " — parking after visual verdict FAIL");
1343
+ return await parkTask("Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after visual verdict FAIL: " + vvStatus.detail);
1344
+ }
1345
+ rejectionNotes = "Visual verdict FAIL: " + vvStatus.detail;
1346
+ i = BUILD_INDEX;
1347
+ log("Visual verdict FAIL — bouncing to Build (rework #" + totalReworkCount + " of " + MAX_TOTAL_REWORK + ")");
1348
+ continue;
1349
+ } else {
1350
+ return await parkTask("Visual verdict pending — parent: run the post-change capture + visual verdict protocol in docs/visual-verdict.md (capture plan is in the QA session notes)");
1351
+ }
1352
+ }
1353
+
506
1354
  // Handle rejection — bounce back to Build
507
1355
  if (!passed && (step.name === "Review" || step.name === "QA")) {
508
1356
  totalReworkCount++;
509
1357
  if (totalReworkCount > MAX_TOTAL_REWORK) {
510
- log("Shared rework budget exhausted for task " + taskId + " — worktree preserved at .worktrees/" + taskId + " for manual inspection");
511
- return { status: "blocked", task_id: taskId, reason: "Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after " + step.name + " rejection. Worktree preserved." };
1358
+ log("Shared rework budget exhausted for task " + taskId + " — worktree preserved at " + WORKTREE_PRESERVED_HINT + " for manual inspection");
1359
+ return await parkTask("Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after " + step.name + " rejection. Worktree preserved.");
512
1360
  }
513
1361
  rejectionNotes = summary;
514
1362
  i = BUILD_INDEX;
@@ -516,16 +1364,19 @@ while (i < STEPS.length) {
516
1364
  continue;
517
1365
  }
518
1366
 
519
- // Integrate failure blocks the task
1367
+ // Integrate failure is operational (the merge did not finish), not a verdict:
1368
+ // the session above is recorded "failed" and the dispatcher retries the
1369
+ // phase under its consecutive-failure cap, parking after the cap.
520
1370
  if (!passed && step.name === "Integrate") {
521
1371
  log("Integration failed for task " + taskId + ": " + summary);
522
- return { status: "blocked", task_id: taskId, reason: "Integration failed: " + summary };
1372
+ return { status: "failed", task_id: taskId, reason: "Integration failed: " + summary };
523
1373
  }
524
1374
 
525
- // Publish failure blocks the task
1375
+ // Publish failure is operational (the publish did not finish), not a
1376
+ // verdict: recorded "failed" above, retried under the dispatcher's cap.
526
1377
  if (!passed && step.name === "Publish") {
527
1378
  log("Publish failed for task " + taskId + ": " + summary);
528
- return { status: "blocked", task_id: taskId, reason: "Publish failed: " + summary };
1379
+ return { status: "failed", task_id: taskId, reason: "Publish failed: " + summary };
529
1380
  }
530
1381
 
531
1382
  i++;