muse-crew 0.7.11 → 0.7.13

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.
@@ -28,6 +28,15 @@ const startStepIndex = inputs.start_step_index || 0;
28
28
  // resolution back via updatetask in the self-claim below.
29
29
  const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
30
30
  const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
31
+ // One-shot recovery routing: the dispatcher sets inputs.next_phase when it
32
+ // routes this run via an explicit recover-task redirect. The value is
33
+ // consumed (cleared) atomically by the successful self-claim below:
34
+ // claim-task takes expected_next_phase and clears the matching next_phase in
35
+ // the same transaction as the winning session insert, so no platform death
36
+ // can slip between claim and consumption and replay the routing. A stale or
37
+ // superseded routing survives — only an exact match clears.
38
+ // what the dispatcher routed on.
39
+ const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_phase.length > 0) ? inputs.next_phase : null;
31
40
  const CLAIM_WORKFLOW_PERSIST = (WORKFLOW_WAS_NULL && RESOLVED_WORKFLOW) ? ", \"workflow\": \"" + RESOLVED_WORKFLOW + "\"" : "";
32
41
 
33
42
  // Visual verdict protocol availability — the workflow parks for parent-run
@@ -581,6 +590,18 @@ function extractMarkerLines(workerText) {
581
590
  return markers.join("\n");
582
591
  }
583
592
 
593
+ // Already-merged idempotency (canary 2026-09-15, task 1d692d91): when the
594
+ // builder correctly makes no commit because the deliverable is already on
595
+ // main (a prior merge or hand-repair landed it), it declares
596
+ // `repo_diff: none (already-merged: <sha>)` naming the main commit that
597
+ // carries the work. The sha is hex-only (7-40 chars) so the workflow can
598
+ // interpolate it into the mechanical ancestor check without injection
599
+ // risk. Pure — pinned byte-identical across standard/bugfix/chore.
600
+ function extractAlreadyMerged(workerText) {
601
+ var m = /^repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})\)/im.exec(workerText || "");
602
+ return m ? { sha: m[1].toLowerCase() } : { sha: null };
603
+ }
604
+
584
605
  // Worktree confinement: the Build agent must declare the exact worktree
585
606
  // path it built in on a `worktree:` marker line. The workflow compares it
586
607
  // against WORKTREE_HINT mechanically (exact string match) — never by
@@ -769,6 +790,12 @@ let mapGateBounceCount = 0;
769
790
  // rationalized a skip against explicit instruction text — text alone did not
770
791
  // hold, so the decision now lives in workflow code, not agent judgment.
771
792
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
793
+ // Already-merged idempotency: the verified sha from the builder's
794
+ // `repo_diff: none (already-merged: <sha>)` declaration (null when the
795
+ // builder made commits or declared a runtime-state deliverable). The
796
+ // workflow verifies the sha is an ancestor of main at Build closeout;
797
+ // Review's no-diff branch reads this, never the builder's prose.
798
+ let alreadyMergedSha = null;
772
799
  // Deterministic publish target — computed by the workflow (registry base +
773
800
  // bumpVersion), never by the Publish agent.
774
801
  let publishTarget = null; // { base, scope, target }
@@ -1000,7 +1027,7 @@ while (i < STEPS.length) {
1000
1027
  const claimResult = await agent(
1001
1028
  "Claim this task for the " + step.name + " step.\n" +
1002
1029
  "Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
1003
- "Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" }) + "\n" +
1030
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started", ...(NEXT_PHASE_ROUTED ? { expected_next_phase: NEXT_PHASE_ROUTED } : {}) }) + "\n" +
1004
1031
  "If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
1005
1032
  "Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
1006
1033
  "Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
@@ -1217,7 +1244,7 @@ while (i < STEPS.length) {
1217
1244
  "Data-level fallback (only if the browser loop above is NOT POSSIBLE): run in shell and read the stdout JSON:\n" + crewCmd("get-state", {}) + "\n" +
1218
1245
  "This returns current sessions, events, and tasks — capture concrete evidence from the data you retrieve.\n" +
1219
1246
  "Report your reproduction steps and evidence as plain prose.\n" +
1220
- "End your report with exactly one line: VERDICT: PASS if you reproduced the reported bug (your frames show the reported misbehavior), VERDICT: FAIL if you could not. --expected names the bug as reported (its visible manifestation); --actual names what your frames actually showed. Checks you could not run are evidence gaps, not silent drops: name every one in --missing. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/repro/ooda-log.jsonl — an empty log is honest, an absent one is a broken report). Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/repro/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the bug as reported — its visible manifestation>\" --actual \"<what your frames actually showed>\" --missing '[\"honest evidence gap, if any\"]' — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten).";
1247
+ "End your report with exactly one line: VERDICT: PASS if you reproduced the reported bug (your frames show the reported misbehavior), VERDICT: FAIL if you could not. --expected names the bug as reported (its visible manifestation); --actual names what your frames actually showed. Checks you could not run are evidence gaps, not silent drops: name every one in --missing. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/repro/ooda-log.jsonl — an empty log is honest, an absent one is a broken report). Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/repro/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the bug as reported — its visible manifestation>\" --actual \"<what your frames actually showed>\" --missing '[\"honest evidence gap, if any\"]' [--reason \"<why it failed REQUIRED and non-empty when verdict is FAIL or NOT_POSSIBLE; the script rejects a reason-less negative verdict with exit 2>\"] — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten). A FAIL or NOT_POSSIBLE verdict without a machine-readable --reason cannot be written — state the reason.";
1221
1248
 
1222
1249
  } else if (step.name === "Map") {
1223
1250
  var mapGatePara = "";
@@ -1253,12 +1280,33 @@ while (i < STEPS.length) {
1253
1280
  "cd " + WORKTREE_HINT + "\n" +
1254
1281
  "git add -A\n" +
1255
1282
  "git commit -m \"fix: " + safeTitle + "\"\n\n" +
1256
- "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" +
1283
+ "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. If you verified the deliverable is already on main (a prior merge or hand-repair landed it — do NOT re-implement working code), make no commit and declare `repo_diff: none (already-merged: <sha>)` naming the main commit that carries the work; the workflow verifies the sha is an ancestor of main, and a false declaration fails the phase. Otherwise commit your changes normally.\n\n" +
1257
1284
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
1258
1285
  "Report back in plain prose: what you built and the outcome." +
1259
1286
  (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 line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
1260
1287
 
1261
1288
  } else if (step.name === "Review") {
1289
+ // Already-merged hydration: when this run did not execute Build itself
1290
+ // (dispatcher resume at Review after a platform death between phases),
1291
+ // recover the workflow-attested verification from the latest completed
1292
+ // Build session notes. The `already_merged_verified:` line was written
1293
+ // by the workflow after a mechanical ancestor check — it is trusted;
1294
+ // the builder's bare declaration never is. Absent the line, the
1295
+ // mechanical fact below reads "none declared" and Cass fails closed.
1296
+ if (!alreadyMergedSha) {
1297
+ var hydNotes = await agent(
1298
+ "Read the latest completed Build session notes for task " + taskId + ".\n" +
1299
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1300
+ "In the returned sessions array, find the most recent session (by started_at) with task_id \"" + taskId + "\", step \"Build\", and status \"completed\". Return ONLY its notes field, verbatim, with no commentary.",
1301
+ { key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
1302
+ );
1303
+ var hydStr = (typeof hydNotes === "string") ? hydNotes : JSON.stringify(hydNotes);
1304
+ var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
1305
+ if (hvm) {
1306
+ alreadyMergedSha = hvm[1].toLowerCase();
1307
+ log("Hydrated already-merged verification from Build session notes: " + alreadyMergedSha);
1308
+ }
1309
+ }
1262
1310
  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" +
1263
1311
  (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") +
1264
1312
  "Examine the code changes by running:\n" +
@@ -1268,7 +1316,7 @@ while (i < STEPS.length) {
1268
1316
  WORKTREE_HINT + "/\n\n" +
1269
1317
  "Check quality, correctness, and spec compliance.\n" +
1270
1318
  "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" +
1271
- "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" +
1319
+ "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) a plausible runtime-state deliverable (e.g. a cron created via the cron tool), or (b) an already-merged declaration `repo_diff: none (already-merged: <sha>)` AND the mechanical fact below confirms the sha verified. MECHANICAL FACT (computed by the workflow, never by the builder): already_merged sha = " + (alreadyMergedSha ? alreadyMergedSha + " (verified ancestor of main: YES)" : "none declared") + ". Otherwise report 'no commits ahead of main and no valid repo_diff: none declaration — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n" +
1272
1320
  (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" +
1273
1321
  "(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" +
1274
1322
  "(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
@@ -1282,7 +1330,7 @@ while (i < STEPS.length) {
1282
1330
  "Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: fix: " + safeTitle + "\"\n\n" +
1283
1331
  "Read the output:\n" +
1284
1332
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
1285
- "- 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" +
1333
+ "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (declared by Build as repo_diff: none — either a runtime-state deliverable or an already-merged sha the workflow verified). 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 or already on main', then end your report with exactly this line: VERDICT: PASS. SKIP STEP 2 (push): there is no new commit to push.\n" +
1286
1334
  "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish) and the 10-minute bounded backoff is exhausted. Report 'merge lock held after bounded backoff', then end your report with exactly this line: VERDICT: FAIL.\n" +
1287
1335
  "- 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" +
1288
1336
  "RESOLUTION:\n" +
@@ -1829,6 +1877,15 @@ while (i < STEPS.length) {
1829
1877
  // lock was lost: stop the run and park the task — never continue to
1830
1878
  // a provenance stamp or version assignment without holding the lock.
1831
1879
  var buildPoll = null;
1880
+ // STEP 1b poll-signal accumulators (2026-09-15, task aadeccc3):
1881
+ // the durable audit-dir fallback below needs the poll's own
1882
+ // observations, not just its final verdict — whether our build was
1883
+ // ever seen, whether a stranger's build was ever in flight, and
1884
+ // what the last check observed. OR-ed across all three chunks so
1885
+ // a signal seen in any chunk survives the chunk boundary.
1886
+ var pollSawOurBuild = false;
1887
+ var pollSawStranger = false;
1888
+ var lastObservedAgentId = null;
1832
1889
  for (var chunk = 1; chunk <= 3; chunk++) {
1833
1890
  if (chunk > 1) {
1834
1891
  var refreshPoll = await agent(
@@ -1851,20 +1908,25 @@ while (i < STEPS.length) {
1851
1908
  : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
1852
1909
  buildPoll = await agent(
1853
1910
  "First call tool_search.load_tool_namespace with paths [\"artifact\"]. Then poll artifact_status for slug \"" + PUBLISH_SLUG + "\" \u2014 for OUR build only, the one whose agent_id is \"" + rebuildAgentId + "\" (the receipt captured when the edit was accepted; the agent_id is the artifact system's in-flight build correlation ID, stable across polls while the build runs). Check every 30 seconds, up to 7 checks (3.5 minutes max). On each check, read the raw build object:\n" +
1854
- "- If no build is running (build is null): OUR build finished. Stop and report done.\n" +
1911
+ "On every check, record whether you have positively OBSERVED our build: a running build whose agent_id equals \"" + rebuildAgentId + "\", or a completed-build record whose agent_id equals \"" + rebuildAgentId + "\" (if the tool surfaces one \u2014 match it mechanically, never assume).\n" +
1912
+ "- If no build is running (build is null) and you have NOT observed our build: our build's completion is UNPROVEN. Absence of a running build is not evidence our build ran. Do NOT report done.\n" +
1913
+ "- If no build is running (build is null) and you previously observed our build running: our build finished. Stop and report done.\n" +
1855
1914
  "- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
1856
- "- If the running build's agent_id is present but DIFFERENT: our build is gone (it finished before this one started). Do NOT wait on the stranger's build and do NOT attribute its completion to our attempt \u2014 stop and report done.\n" +
1857
- "Return JSON { \"build_done\": <true if our build is no longer running within budget, false on timeout>, \"status\": \"<final status or timeout note>\", \"observed_agent_id\": \"<the agent_id seen on the last check, or null when no build was running>\" } and nothing else.",
1915
+ "- If the running build's agent_id is present but DIFFERENT: that is a stranger's build. Do NOT attribute its completion to our attempt and do NOT wait on it \u2014 keep checking within budget; if the budget expires without observing our build, report done=false. Record it in saw_stranger regardless of what else you observe.\n" +
1916
+ "Return JSON { \"build_done\": <true ONLY when you positively observed our build and it is no longer running, false otherwise>, \"saw_our_build\": <true if you observed our build at any check, false if never>, \"saw_stranger\": true if at ANY check a running build had an agent_id different from ours (\"" + rebuildAgentId + "\"), false otherwise, \"status\": \"<final status or timeout note>\", \"observed_agent_id\": \"<the agent_id seen on the last check, or null when no build was running>\" } and nothing else.",
1858
1917
  { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1859
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
1918
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, saw_our_build: { type: "boolean" }, saw_stranger: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
1860
1919
  timeoutMs: 270000 }
1861
1920
  );
1921
+ pollSawOurBuild = pollSawOurBuild || (buildPoll && buildPoll.saw_our_build === true);
1922
+ pollSawStranger = pollSawStranger || (buildPoll && buildPoll.saw_stranger === true);
1923
+ lastObservedAgentId = (buildPoll && buildPoll.observed_agent_id) || null;
1862
1924
  if (buildPoll && buildPoll.build_done) { break; }
1863
1925
  }
1864
1926
  if (!buildPoll || !buildPoll.build_done) {
1865
1927
  buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
1866
1928
  }
1867
- if (buildPoll.build_done) {
1929
+ if (buildPoll.build_done && pollSawOurBuild) {
1868
1930
  // STEP 1c (mechanical): NO provenance stamp here. Canary run 8
1869
1931
  // (2026-09-11) proved the stamp cannot certify content: the
1870
1932
  // builder's applied-report is derived from the carried diff, so
@@ -1882,7 +1944,116 @@ while (i < STEPS.length) {
1882
1944
  artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
1883
1945
  log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
1884
1946
  } else {
1885
- publishFailure = "Artifact build did not complete within budget: " + (buildPoll.status || "timeout") + ". The publish may or may not have landed — provenance was not stamped.";
1947
+ // STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
1948
+ // the poll above only observes IN-FLIGHT builds. A build that
1949
+ // finished between the receipt capture and the poll's first check
1950
+ // leaves no in-flight trace — but the platform's audit harness
1951
+ // leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
1952
+ // <timestamp>-<id>/ per completed build). Diff the audit-dir
1953
+ // listing against the pre-trigger snapshot: a timestamped dir
1954
+ // that appeared during the attempt window is evidence a build
1955
+ // completed. Attribution is by window, not by build identity:
1956
+ // the poll's saw_stranger signal only catches stranger builds in
1957
+ // flight AT a check — a stranger that finished entirely inside
1958
+ // the window is indistinguishable, so any observed stranger
1959
+ // blocks attribution and the outcome stays unknown. This never
1960
+ // re-issues the edit and never stamps provenance — ok=true only
1961
+ // routes to the parent's independent content read-back, which
1962
+ // remains the real verification.
1963
+ //
1964
+ // The poll end-state is read from the poll's own observations,
1965
+ // not from build_done alone: a build in flight at the last check
1966
+ // means the budget was shorter than the latency (or the build is
1967
+ // stuck) — NOT that no build ever started; nothing observed at
1968
+ // any check is the never-started signal.
1969
+ var pollEndState = lastObservedAgentId ? "build-still-running-at-poll-end"
1970
+ : (pollSawOurBuild ? "our-build-observed-then-unconfirmed" : "no-build-observed-in-window");
1971
+ var strangerObserved = pollSawStranger;
1972
+ var newAuditDirsAfterPoll = [];
1973
+ try {
1974
+ var auditAfterPoll = await agent(
1975
+ "List the artifact audit directories for slug \"" + PUBLISH_SLUG + "\" (best-effort, never a gate).\n" +
1976
+ "Run: ls -1 ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/ 2>/dev/null\n" +
1977
+ "Return JSON { \"dirs\": \"<newline-separated names, empty string when the audits directory does not exist or is empty>\" } and nothing else.",
1978
+ { key: attemptKey("publish-audit-after-poll-" + taskId, totalReworkCount), label: "Re-listing audit dirs after build poll",
1979
+ schema: { type: "object", properties: { dirs: { type: "string" } }, required: ["dirs"] } }
1980
+ );
1981
+ var auditDirsAfterPollList = String((auditAfterPoll && auditAfterPoll.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1982
+ newAuditDirsAfterPoll = auditDirsAfterPollList.filter(function (d) {
1983
+ return auditDirsBeforeTrigger.indexOf(d) === -1 && /^20\d\d-\d\d-\d\dT\d\d-\d\d-\d\dZ-/.test(d);
1984
+ });
1985
+ log("Publish audit-dir re-list after build poll for task " + taskId + ": " + newAuditDirsAfterPoll.length + " new timestamped dir(s)");
1986
+ } catch (auditAfterPollErr) {
1987
+ log("Publish audit-dir re-list after build poll failed for task " + taskId + " (non-fatal, durable-evidence check degraded): " + (auditAfterPollErr && auditAfterPollErr.message ? auditAfterPollErr.message : auditAfterPollErr));
1988
+ }
1989
+ // auditReportOk: pure tri-state read of a report.json body —
1990
+ // true (build ok), false (build failed), null (missing or
1991
+ // unreadable — not evidence either way). The child returns the
1992
+ // raw body verbatim; interpretation lives here, never in prose.
1993
+ var auditReportOk = function (raw) {
1994
+ if (typeof raw !== "string") return null;
1995
+ var trimmed = raw.trim();
1996
+ if (trimmed === "" || trimmed === "MISSING") return null;
1997
+ var parsed;
1998
+ try { parsed = JSON.parse(trimmed); } catch (e) { return null; }
1999
+ if (parsed && typeof parsed.ok === "boolean") return parsed.ok;
2000
+ return null;
2001
+ };
2002
+ var auditOkAfterPoll = null;
2003
+ var newestAuditDirAfterPoll = null;
2004
+ if (newAuditDirsAfterPoll.length > 0 && !strangerObserved) {
2005
+ newAuditDirsAfterPoll.sort();
2006
+ newestAuditDirAfterPoll = newAuditDirsAfterPoll[newAuditDirsAfterPoll.length - 1];
2007
+ try {
2008
+ var auditOkRead = await agent(
2009
+ "Read the build report for artifact slug \"" + PUBLISH_SLUG + "\", audit dir \"" + newestAuditDirAfterPoll + "\" (verbatim read, never interpreted, never a gate).\n" +
2010
+ "Run: cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/" + newestAuditDirAfterPoll + "/report.json 2>/dev/null || echo MISSING\n" +
2011
+ "Return JSON { \"raw\": \"<verbatim file contents, or the literal string MISSING when the file does not exist>\" } and nothing else.",
2012
+ { key: attemptKey("publish-audit-ok-after-poll-" + taskId, totalReworkCount), label: "Reading build report after build poll",
2013
+ schema: { type: "object", properties: { raw: { type: "string" } }, required: ["raw"] } }
2014
+ );
2015
+ auditOkAfterPoll = auditReportOk(auditOkRead && auditOkRead.raw);
2016
+ } catch (auditOkReadErr) {
2017
+ log("Publish build-report read after build poll failed for task " + taskId + " (non-fatal, treated as unknown): " + (auditOkReadErr && auditOkReadErr.message ? auditOkReadErr.message : auditOkReadErr));
2018
+ auditOkAfterPoll = null;
2019
+ }
2020
+ }
2021
+ if (auditOkAfterPoll === true) {
2022
+ publishBuildLanded = true;
2023
+ artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
2024
+ log("Publish build landed for task " + taskId + " via durable audit evidence — provenance stamp deferred to parent content verification");
2025
+ await recordPublishLedger({
2026
+ commit: mergeCommitForPublish,
2027
+ attempt: rebuildAttemptKey,
2028
+ agent_id: rebuildAgentId,
2029
+ applied_report: publishAppliedObservation,
2030
+ outcome: "submitted",
2031
+ detail: "durable audit evidence shows a build completed during the attempt window (audit dir " + newestAuditDirAfterPoll + ", report ok=true); routed to parent verification"
2032
+ }, totalReworkCount);
2033
+ } else if (auditOkAfterPoll === false) {
2034
+ publishFailure = "Artifact build FAILED for slug " + PUBLISH_SLUG + " (audit dir " + newestAuditDirAfterPoll + ", report ok=false). Explicit negative evidence: a build ran and failed (attribution by window, not by build identity — no stranger build was observed in flight during the poll). The publish did not land — provenance was not stamped. Fail-closed.";
2035
+ await recordPublishLedger({
2036
+ commit: mergeCommitForPublish,
2037
+ attempt: rebuildAttemptKey,
2038
+ agent_id: rebuildAgentId,
2039
+ applied_report: publishAppliedObservation,
2040
+ outcome: "failed",
2041
+ detail: "a build ran and failed (attribution by window, not by build identity): audit dir " + newestAuditDirAfterPoll + " report ok=false; no stranger build observed in flight during the poll"
2042
+ }, totalReworkCount);
2043
+ } else {
2044
+ var unattributableReason = strangerObserved ? "stranger-build-observed-during-poll"
2045
+ : (pollEndState === "build-still-running-at-poll-end" ? "build-still-running-at-poll-end"
2046
+ : (newAuditDirsAfterPoll.length === 0 ? "no-new-audit-dir-in-window" : "audit-report-unreadable-or-missing"));
2047
+ publishFailure = "Artifact build completion unproven (fail-closed, no provenance stamped): unattributable_reason=" + unattributableReason + "; poll_end_state=" + pollEndState + "; " + "saw_our_build=" + pollSawOurBuild + "; new_audit_dirs=" + newAuditDirsAfterPoll.length + ". Attribution is by window, not by build identity. The publish may or may not have landed. Fail-closed.";
2048
+ await recordPublishLedger({
2049
+ commit: mergeCommitForPublish,
2050
+ attempt: rebuildAttemptKey,
2051
+ agent_id: rebuildAgentId,
2052
+ applied_report: publishAppliedObservation,
2053
+ outcome: "unknown",
2054
+ detail: "durable audit-dir fallback could not attribute a completed build to this attempt (unattributable_reason=" + unattributableReason + ", poll_end_state=" + pollEndState + ")"
2055
+ }, totalReworkCount);
2056
+ }
1886
2057
  }
1887
2058
  } else {
1888
2059
  publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
@@ -2008,7 +2179,7 @@ while (i < STEPS.length) {
2008
2179
  "File follow-up tasks by running in shell:\n" + crewCmd("create-task", { title: "<short title>", description: "<details>", project: "<project id>", workflow: "bugfix", filed_by: "hazel" }) + "\n(substitute the real values for the placeholders).\n\n" +
2009
2180
  "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" +
2010
2181
  "Report your test results as plain prose.\n" +
2011
- "End your report with exactly one line: VERDICT: PASS if testing passes, VERDICT: FAIL if it fails on the visual or the mechanical checks. Checks you could not run are evidence gaps, not silent drops: name every one in --missing — unknown is neither PASS nor FAIL. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl — an empty log is honest, an absent one is a broken report). Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/postchange/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the reported bug, fixed>\" --actual \"<what you observed>\" --missing '[\"honest evidence gap, if any\"]' — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten).";
2182
+ "End your report with exactly one line: VERDICT: PASS if testing passes, VERDICT: FAIL if it fails on the visual or the mechanical checks. Checks you could not run are evidence gaps, not silent drops: name every one in --missing — unknown is neither PASS nor FAIL. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl — an empty log is honest, an absent one is a broken report). Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/postchange/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the reported bug, fixed>\" --actual \"<what you observed>\" --missing '[\"honest evidence gap, if any\"]' [--reason \"<why it failed REQUIRED and non-empty when verdict is FAIL or NOT_POSSIBLE; the script rejects a reason-less negative verdict with exit 2>\"] — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten). A FAIL verdict must carry a machine-readable reason: the workflow closeout cross-checks verdict.json against your prose VERDICT line, and an unreasoned or contradictory verdict fails the phase (never routes to rework).";
2012
2183
  }
2013
2184
  if (PUBLISH_TYPE === "artifact") {
2014
2185
  instructions = "PROVENANCE CHECK (this project publishes to a dashboard artifact).\n" +
@@ -2176,6 +2347,70 @@ while (i < STEPS.length) {
2176
2347
  verdictPassed = verdict.passed;
2177
2348
  }
2178
2349
 
2350
+ // QA verdict.json closeout gate (task 30dceb78): the prose VERDICT: line is
2351
+ // the routing signal, but verdict.json is the reason-carrying record the
2352
+ // workflow actually reads. A FAIL verdict must carry a machine-readable
2353
+ // reason; the workflow refuses to route to rework on an unreasoned or
2354
+ // contradictory verdict. The deterministic cross-checker
2355
+ // (lib/read-ooda-verdict.js) runs against the prose verdict: a missing,
2356
+ // corrupt, contradictory, or reason-less record fails the phase for retry
2357
+ // — the dispatcher re-runs QA at the same step under its
2358
+ // consecutive-failure cap — instead of routing to rework. Without this
2359
+ // gate, a bare VERDICT: FAIL with an all-positive report (canary
2360
+ // 2026-09-15, task 1d692d91) rebuilt nothing and parked at Publish on an
2361
+ // unobserved artifact build. The gate applies only to the experiential QA
2362
+ // path (qaVisual), the only path that writes verdict.json.
2363
+ if (step.name === "QA" && qaVisual && verdictPassed !== null) {
2364
+ var qaVerdictDir = crewHome + "/task-evidence/" + taskId + "/postchange";
2365
+ var qaVerdictExpect = verdictPassed ? "PASS" : "FAIL";
2366
+ var qaVerdictOut = "";
2367
+ try {
2368
+ var qaVerdictCheck = await agent(
2369
+ "Run: node " + crewHome + "/current/lib/read-ooda-verdict.js --dir " + qaVerdictDir + " --expect " + qaVerdictExpect + "\n" +
2370
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
2371
+ { key: attemptKey("qa-verdict-check-" + taskId, totalReworkCount), label: "Cross-checking QA verdict.json",
2372
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
2373
+ );
2374
+ qaVerdictOut = (qaVerdictCheck && qaVerdictCheck.output ? qaVerdictCheck.output : "").trim();
2375
+ } catch (e) {
2376
+ qaVerdictOut = "";
2377
+ }
2378
+ // The script prints exactly one JSON line; exit info does not survive
2379
+ // the schema'd return, so ok:false on that line is the gate signal.
2380
+ var qaVerdictGate = null;
2381
+ try {
2382
+ var qaVerdictLines = qaVerdictOut.split("\n");
2383
+ qaVerdictGate = JSON.parse(qaVerdictLines[qaVerdictLines.length - 1]);
2384
+ } catch (e) {
2385
+ qaVerdictGate = null;
2386
+ }
2387
+ if (!qaVerdictGate || qaVerdictGate.ok !== true) {
2388
+ var qaGateCode = (qaVerdictGate && qaVerdictGate.code) ? qaVerdictGate.code : "unreadable";
2389
+ var qaGateDetail = (qaVerdictGate && qaVerdictGate.error) ? qaVerdictGate.error : (qaVerdictOut ? qaVerdictOut.slice(0, 200) : "cross-checker produced no usable output");
2390
+ log("QA verdict.json closeout gate failed (" + qaGateCode + "): " + qaGateDetail + " — marking QA failed for retry, NOT routing to rework");
2391
+ await agent(
2392
+ "Record QA verdict gate failure.\n" +
2393
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2394
+ task_id: taskId,
2395
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name,
2396
+ status: "failed", notes: "QA verdict.json closeout gate failed (" + qaGateCode + "): " + qaGateDetail + ". The prose VERDICT line said " + qaVerdictExpect + " but verdict.json is missing, corrupt, contradictory, or (for FAIL) carries no machine-readable reason. Unreasoned or contradictory verdicts never route to rework; phase failed for retry" },
2397
+ event: { task_id: taskId, type: "failed",
2398
+ message: "QA verdict.json closeout gate failed (" + qaGateCode + ") — verdict unreasoned or contradictory, phase failed, dispatcher will retry QA" }
2399
+ }),
2400
+ { key: "record-qa-verdict-gate-fail-" + step.name, label: "Recording QA verdict gate failure" }
2401
+ );
2402
+ return {
2403
+ __hatchWorkflowControl: "blocked",
2404
+ result: {
2405
+ blocked_reason: "QA verdict.json closeout gate failed (" + qaGateCode + ")",
2406
+ message: "QA's prose VERDICT line said " + qaVerdictExpect + " but the machine-readable verdict.json is " + qaGateCode + " (" + qaGateDetail + "). A FAIL verdict must carry a machine-readable reason and the record must agree with the prose line. The phase is marked failed and the dispatcher will retry QA; it is not routed to rework.",
2407
+ task_id: taskId
2408
+ }
2409
+ };
2410
+ }
2411
+ log("QA verdict.json closeout gate passed: verdict.json agrees with prose VERDICT: " + qaVerdictExpect);
2412
+ }
2413
+
2179
2414
 
2180
2415
  // Worktree confinement (Build only): the declared worktree path must
2181
2416
  // match WORKTREE_HINT exactly. A builder that worked in any other
@@ -2207,6 +2442,52 @@ while (i < STEPS.length) {
2207
2442
  };
2208
2443
  }
2209
2444
  log("Build worktree confinement passed: " + wt.path);
2445
+
2446
+ // Already-merged idempotency: a `repo_diff: none (already-merged:
2447
+ // <sha>)` declaration is verified mechanically — <sha> must resolve
2448
+ // and be an ancestor of main in the configured repo. A fabricated or
2449
+ // mistaken declaration fails the phase here (the dispatcher retries
2450
+ // Build under its consecutive-failure cap); a verified declaration is
2451
+ // recorded in alreadyMergedSha for Review's no-diff branch. Without
2452
+ // this guard, Build correctly doing nothing left Review with no
2453
+ // mechanical way to accept an empty diff, and Cass rejected for "no
2454
+ // commits ahead of main — the builder likely forgot to commit" while
2455
+ // the deliverable sat on main (canary 2026-09-15, task 1d692d91).
2456
+ // The sha is hex-only by construction (extractAlreadyMerged), so
2457
+ // interpolating it into the shell command cannot inject.
2458
+ var am = extractAlreadyMerged(workerText);
2459
+ if (am.sha) {
2460
+ var amCheck = await agent(
2461
+ "Verify the builder's already-merged declaration.\n" +
2462
+ "Run in shell and return the stdout verbatim:\n" +
2463
+ "cd " + REPO_PATH + " && git rev-parse --verify --quiet " + am.sha + " >/dev/null && git merge-base --is-ancestor " + am.sha + " main && echo ALREADY_MERGED_YES || echo ALREADY_MERGED_NO",
2464
+ { key: "verify-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Verifying already-merged declaration" }
2465
+ );
2466
+ var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
2467
+ if (!/ALREADY_MERGED_YES/.test(amOut)) {
2468
+ log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of main — marking failed for retry");
2469
+ await agent(
2470
+ "Record already-merged verification failure.\n" +
2471
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2472
+ task_id: taskId,
2473
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
2474
+ notes: "Build declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of main in the configured repo. The declaration is fabricated or mistaken; the work is not on main. Phase failed for retry" },
2475
+ event: { task_id: taskId, type: "failed", message: "Build already-merged declaration failed verification — " + am.sha + " not an ancestor of main, phase failed, dispatcher will retry" }
2476
+ }),
2477
+ { key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
2478
+ );
2479
+ return {
2480
+ __hatchWorkflowControl: "blocked",
2481
+ result: {
2482
+ blocked_reason: "Build already-merged declaration failed verification",
2483
+ message: "The builder declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of main. The work is not on main; the phase is marked failed and the dispatcher will retry Build.",
2484
+ task_id: taskId
2485
+ }
2486
+ };
2487
+ }
2488
+ alreadyMergedSha = am.sha;
2489
+ log("Build already-merged declaration verified: " + am.sha + " is an ancestor of main");
2490
+ }
2210
2491
  }
2211
2492
 
2212
2493
  // Deterministic closeout: no formatter agent. The verdict is mechanical
@@ -2370,6 +2651,15 @@ while (i < STEPS.length) {
2370
2651
  } else {
2371
2652
  summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
2372
2653
  }
2654
+ // Already-merged attestation: when the Build gate verified the builder's
2655
+ // already-merged declaration, the workflow records its own marker line in
2656
+ // the session notes (like the builder markers above, it is appended after
2657
+ // the slice so it can never be amputated). A later run resumed at Review
2658
+ // hydrates alreadyMergedSha from this workflow-attested line — never from
2659
+ // the builder's declaration alone.
2660
+ if (step.name === "Build" && alreadyMergedSha) {
2661
+ summary += "\nalready_merged_verified: " + alreadyMergedSha;
2662
+ }
2373
2663
 
2374
2664
  // Visual verdict evidence (2026-09-15): no parent marker is appended. Hazel
2375
2665
  // records her own verdict.json + the append-only verdicts.jsonl in the