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.
@@ -27,6 +27,15 @@ const startStepIndex = inputs.start_step_index || 0;
27
27
  // resolution back via updatetask in the self-claim below.
28
28
  const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
29
29
  const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
30
+ // One-shot recovery routing: the dispatcher sets inputs.next_phase when it
31
+ // routes this run via an explicit recover-task redirect. The value is
32
+ // consumed (cleared) atomically by the successful self-claim below:
33
+ // claim-task takes expected_next_phase and clears the matching next_phase in
34
+ // the same transaction as the winning session insert, so no platform death
35
+ // can slip between claim and consumption and replay the routing. A stale or
36
+ // superseded routing survives — only an exact match clears.
37
+ // what the dispatcher routed on.
38
+ const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_phase.length > 0) ? inputs.next_phase : null;
30
39
  const CLAIM_WORKFLOW_PERSIST = (WORKFLOW_WAS_NULL && RESOLVED_WORKFLOW) ? ", \"workflow\": \"" + RESOLVED_WORKFLOW + "\"" : "";
31
40
 
32
41
  // Visual verdict protocol availability — the workflow parks for parent-run
@@ -582,6 +591,18 @@ function extractMarkerLines(workerText) {
582
591
  return markers.join("\n");
583
592
  }
584
593
 
594
+ // Already-merged idempotency (canary 2026-09-15, task 1d692d91): when the
595
+ // builder correctly makes no commit because the deliverable is already on
596
+ // main (a prior merge or hand-repair landed it), it declares
597
+ // `repo_diff: none (already-merged: <sha>)` naming the main commit that
598
+ // carries the work. The sha is hex-only (7-40 chars) so the workflow can
599
+ // interpolate it into the mechanical ancestor check without injection
600
+ // risk. Pure — pinned byte-identical across standard/bugfix/chore.
601
+ function extractAlreadyMerged(workerText) {
602
+ var m = /^repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})\)/im.exec(workerText || "");
603
+ return m ? { sha: m[1].toLowerCase() } : { sha: null };
604
+ }
605
+
585
606
  // Worktree confinement: the Build agent must declare the exact worktree
586
607
  // path it built in on a `worktree:` marker line. The workflow compares it
587
608
  // against WORKTREE_HINT mechanically (exact string match) — never by
@@ -760,6 +781,12 @@ let mapGateBounceCount = 0;
760
781
  // rationalized a skip against explicit instruction text — text alone did not
761
782
  // hold, so the decision now lives in workflow code, not agent judgment.
762
783
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
784
+ // Already-merged idempotency: the verified sha from the builder's
785
+ // `repo_diff: none (already-merged: <sha>)` declaration (null when the
786
+ // builder made commits or declared a runtime-state deliverable). The
787
+ // workflow verifies the sha is an ancestor of main at Build closeout;
788
+ // Review's no-diff branch reads this, never the builder's prose.
789
+ let alreadyMergedSha = null;
763
790
  // Deterministic publish target — computed by the workflow (registry base +
764
791
  // bumpVersion), never by the Publish agent.
765
792
  let publishTarget = null; // { base, scope, target }
@@ -991,7 +1018,7 @@ while (i < STEPS.length) {
991
1018
  const claimResult = await agent(
992
1019
  "Claim this task for the " + step.name + " step.\n" +
993
1020
  "Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
994
- "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" +
1021
+ "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" +
995
1022
  "If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
996
1023
  "Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
997
1024
  "Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
@@ -1210,12 +1237,33 @@ while (i < STEPS.length) {
1210
1237
  "cd " + WORKTREE_HINT + "\n" +
1211
1238
  "git add -A\n" +
1212
1239
  "git commit -m \"" + safeTitle + "\"\n\n" +
1213
- "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" +
1240
+ "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" +
1214
1241
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
1215
1242
  "Report back in plain prose: what you built and the outcome." +
1216
1243
  (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.");
1217
1244
 
1218
1245
  } else if (step.name === "Review") {
1246
+ // Already-merged hydration: when this run did not execute Build itself
1247
+ // (dispatcher resume at Review after a platform death between phases),
1248
+ // recover the workflow-attested verification from the latest completed
1249
+ // Build session notes. The `already_merged_verified:` line was written
1250
+ // by the workflow after a mechanical ancestor check — it is trusted;
1251
+ // the builder's bare declaration never is. Absent the line, the
1252
+ // mechanical fact below reads "none declared" and Cass fails closed.
1253
+ if (!alreadyMergedSha) {
1254
+ var hydNotes = await agent(
1255
+ "Read the latest completed Build session notes for task " + taskId + ".\n" +
1256
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1257
+ "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.",
1258
+ { key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
1259
+ );
1260
+ var hydStr = (typeof hydNotes === "string") ? hydNotes : JSON.stringify(hydNotes);
1261
+ var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
1262
+ if (hvm) {
1263
+ alreadyMergedSha = hvm[1].toLowerCase();
1264
+ log("Hydrated already-merged verification from Build session notes: " + alreadyMergedSha);
1265
+ }
1266
+ }
1219
1267
  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" +
1220
1268
  (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") +
1221
1269
  "Examine the code changes by running:\n" +
@@ -1225,7 +1273,7 @@ while (i < STEPS.length) {
1225
1273
  WORKTREE_HINT + "/\n\n" +
1226
1274
  "Check quality, correctness, and spec compliance.\n" +
1227
1275
  "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" +
1228
- "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" +
1276
+ "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" +
1229
1277
  (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" +
1230
1278
  "(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" +
1231
1279
  "(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
@@ -1239,7 +1287,7 @@ while (i < STEPS.length) {
1239
1287
  "Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: " + safeTitle + "\"\n\n" +
1240
1288
  "Read the output:\n" +
1241
1289
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
1242
- "- 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" +
1290
+ "- 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" +
1243
1291
  "- 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" +
1244
1292
  "- 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" +
1245
1293
  "RESOLUTION:\n" +
@@ -1786,6 +1834,15 @@ while (i < STEPS.length) {
1786
1834
  // lock was lost: stop the run and park the task — never continue to
1787
1835
  // a provenance stamp or version assignment without holding the lock.
1788
1836
  var buildPoll = null;
1837
+ // STEP 1b poll-signal accumulators (2026-09-15, task aadeccc3):
1838
+ // the durable audit-dir fallback below needs the poll's own
1839
+ // observations, not just its final verdict — whether our build was
1840
+ // ever seen, whether a stranger's build was ever in flight, and
1841
+ // what the last check observed. OR-ed across all three chunks so
1842
+ // a signal seen in any chunk survives the chunk boundary.
1843
+ var pollSawOurBuild = false;
1844
+ var pollSawStranger = false;
1845
+ var lastObservedAgentId = null;
1789
1846
  for (var chunk = 1; chunk <= 3; chunk++) {
1790
1847
  if (chunk > 1) {
1791
1848
  var refreshPoll = await agent(
@@ -1808,20 +1865,25 @@ while (i < STEPS.length) {
1808
1865
  : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
1809
1866
  buildPoll = await agent(
1810
1867
  "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" +
1811
- "- If no build is running (build is null): OUR build finished. Stop and report done.\n" +
1868
+ "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" +
1869
+ "- 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" +
1870
+ "- If no build is running (build is null) and you previously observed our build running: our build finished. Stop and report done.\n" +
1812
1871
  "- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
1813
- "- 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" +
1814
- "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.",
1872
+ "- 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" +
1873
+ "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.",
1815
1874
  { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1816
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
1875
+ 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"] },
1817
1876
  timeoutMs: 270000 }
1818
1877
  );
1878
+ pollSawOurBuild = pollSawOurBuild || (buildPoll && buildPoll.saw_our_build === true);
1879
+ pollSawStranger = pollSawStranger || (buildPoll && buildPoll.saw_stranger === true);
1880
+ lastObservedAgentId = (buildPoll && buildPoll.observed_agent_id) || null;
1819
1881
  if (buildPoll && buildPoll.build_done) { break; }
1820
1882
  }
1821
1883
  if (!buildPoll || !buildPoll.build_done) {
1822
1884
  buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
1823
1885
  }
1824
- if (buildPoll.build_done) {
1886
+ if (buildPoll.build_done && pollSawOurBuild) {
1825
1887
  // STEP 1c (mechanical): NO provenance stamp here. Canary run 8
1826
1888
  // (2026-09-11) proved the stamp cannot certify content: the
1827
1889
  // builder's applied-report is derived from the carried diff, so
@@ -1839,7 +1901,116 @@ while (i < STEPS.length) {
1839
1901
  artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
1840
1902
  log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
1841
1903
  } else {
1842
- publishFailure = "Artifact build did not complete within budget: " + (buildPoll.status || "timeout") + ". The publish may or may not have landed — provenance was not stamped.";
1904
+ // STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
1905
+ // the poll above only observes IN-FLIGHT builds. A build that
1906
+ // finished between the receipt capture and the poll's first check
1907
+ // leaves no in-flight trace — but the platform's audit harness
1908
+ // leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
1909
+ // <timestamp>-<id>/ per completed build). Diff the audit-dir
1910
+ // listing against the pre-trigger snapshot: a timestamped dir
1911
+ // that appeared during the attempt window is evidence a build
1912
+ // completed. Attribution is by window, not by build identity:
1913
+ // the poll's saw_stranger signal only catches stranger builds in
1914
+ // flight AT a check — a stranger that finished entirely inside
1915
+ // the window is indistinguishable, so any observed stranger
1916
+ // blocks attribution and the outcome stays unknown. This never
1917
+ // re-issues the edit and never stamps provenance — ok=true only
1918
+ // routes to the parent's independent content read-back, which
1919
+ // remains the real verification.
1920
+ //
1921
+ // The poll end-state is read from the poll's own observations,
1922
+ // not from build_done alone: a build in flight at the last check
1923
+ // means the budget was shorter than the latency (or the build is
1924
+ // stuck) — NOT that no build ever started; nothing observed at
1925
+ // any check is the never-started signal.
1926
+ var pollEndState = lastObservedAgentId ? "build-still-running-at-poll-end"
1927
+ : (pollSawOurBuild ? "our-build-observed-then-unconfirmed" : "no-build-observed-in-window");
1928
+ var strangerObserved = pollSawStranger;
1929
+ var newAuditDirsAfterPoll = [];
1930
+ try {
1931
+ var auditAfterPoll = await agent(
1932
+ "List the artifact audit directories for slug \"" + PUBLISH_SLUG + "\" (best-effort, never a gate).\n" +
1933
+ "Run: ls -1 ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/ 2>/dev/null\n" +
1934
+ "Return JSON { \"dirs\": \"<newline-separated names, empty string when the audits directory does not exist or is empty>\" } and nothing else.",
1935
+ { key: attemptKey("publish-audit-after-poll-" + taskId, totalReworkCount), label: "Re-listing audit dirs after build poll",
1936
+ schema: { type: "object", properties: { dirs: { type: "string" } }, required: ["dirs"] } }
1937
+ );
1938
+ var auditDirsAfterPollList = String((auditAfterPoll && auditAfterPoll.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1939
+ newAuditDirsAfterPoll = auditDirsAfterPollList.filter(function (d) {
1940
+ return auditDirsBeforeTrigger.indexOf(d) === -1 && /^20\d\d-\d\d-\d\dT\d\d-\d\d-\d\dZ-/.test(d);
1941
+ });
1942
+ log("Publish audit-dir re-list after build poll for task " + taskId + ": " + newAuditDirsAfterPoll.length + " new timestamped dir(s)");
1943
+ } catch (auditAfterPollErr) {
1944
+ 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));
1945
+ }
1946
+ // auditReportOk: pure tri-state read of a report.json body —
1947
+ // true (build ok), false (build failed), null (missing or
1948
+ // unreadable — not evidence either way). The child returns the
1949
+ // raw body verbatim; interpretation lives here, never in prose.
1950
+ var auditReportOk = function (raw) {
1951
+ if (typeof raw !== "string") return null;
1952
+ var trimmed = raw.trim();
1953
+ if (trimmed === "" || trimmed === "MISSING") return null;
1954
+ var parsed;
1955
+ try { parsed = JSON.parse(trimmed); } catch (e) { return null; }
1956
+ if (parsed && typeof parsed.ok === "boolean") return parsed.ok;
1957
+ return null;
1958
+ };
1959
+ var auditOkAfterPoll = null;
1960
+ var newestAuditDirAfterPoll = null;
1961
+ if (newAuditDirsAfterPoll.length > 0 && !strangerObserved) {
1962
+ newAuditDirsAfterPoll.sort();
1963
+ newestAuditDirAfterPoll = newAuditDirsAfterPoll[newAuditDirsAfterPoll.length - 1];
1964
+ try {
1965
+ var auditOkRead = await agent(
1966
+ "Read the build report for artifact slug \"" + PUBLISH_SLUG + "\", audit dir \"" + newestAuditDirAfterPoll + "\" (verbatim read, never interpreted, never a gate).\n" +
1967
+ "Run: cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/" + newestAuditDirAfterPoll + "/report.json 2>/dev/null || echo MISSING\n" +
1968
+ "Return JSON { \"raw\": \"<verbatim file contents, or the literal string MISSING when the file does not exist>\" } and nothing else.",
1969
+ { key: attemptKey("publish-audit-ok-after-poll-" + taskId, totalReworkCount), label: "Reading build report after build poll",
1970
+ schema: { type: "object", properties: { raw: { type: "string" } }, required: ["raw"] } }
1971
+ );
1972
+ auditOkAfterPoll = auditReportOk(auditOkRead && auditOkRead.raw);
1973
+ } catch (auditOkReadErr) {
1974
+ log("Publish build-report read after build poll failed for task " + taskId + " (non-fatal, treated as unknown): " + (auditOkReadErr && auditOkReadErr.message ? auditOkReadErr.message : auditOkReadErr));
1975
+ auditOkAfterPoll = null;
1976
+ }
1977
+ }
1978
+ if (auditOkAfterPoll === true) {
1979
+ publishBuildLanded = true;
1980
+ artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
1981
+ log("Publish build landed for task " + taskId + " via durable audit evidence — provenance stamp deferred to parent content verification");
1982
+ await recordPublishLedger({
1983
+ commit: mergeCommitForPublish,
1984
+ attempt: rebuildAttemptKey,
1985
+ agent_id: rebuildAgentId,
1986
+ applied_report: publishAppliedObservation,
1987
+ outcome: "submitted",
1988
+ detail: "durable audit evidence shows a build completed during the attempt window (audit dir " + newestAuditDirAfterPoll + ", report ok=true); routed to parent verification"
1989
+ }, totalReworkCount);
1990
+ } else if (auditOkAfterPoll === false) {
1991
+ 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.";
1992
+ await recordPublishLedger({
1993
+ commit: mergeCommitForPublish,
1994
+ attempt: rebuildAttemptKey,
1995
+ agent_id: rebuildAgentId,
1996
+ applied_report: publishAppliedObservation,
1997
+ outcome: "failed",
1998
+ 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"
1999
+ }, totalReworkCount);
2000
+ } else {
2001
+ var unattributableReason = strangerObserved ? "stranger-build-observed-during-poll"
2002
+ : (pollEndState === "build-still-running-at-poll-end" ? "build-still-running-at-poll-end"
2003
+ : (newAuditDirsAfterPoll.length === 0 ? "no-new-audit-dir-in-window" : "audit-report-unreadable-or-missing"));
2004
+ 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.";
2005
+ await recordPublishLedger({
2006
+ commit: mergeCommitForPublish,
2007
+ attempt: rebuildAttemptKey,
2008
+ agent_id: rebuildAgentId,
2009
+ applied_report: publishAppliedObservation,
2010
+ outcome: "unknown",
2011
+ detail: "durable audit-dir fallback could not attribute a completed build to this attempt (unattributable_reason=" + unattributableReason + ", poll_end_state=" + pollEndState + ")"
2012
+ }, totalReworkCount);
2013
+ }
1843
2014
  }
1844
2015
  } else {
1845
2016
  publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
@@ -1963,7 +2134,7 @@ while (i < STEPS.length) {
1963
2134
  "node " + CREW_API + " --crew-home " + crewHome + " create-task --json '{\"title\": \"<issue title>\", \"description\": \"<issue details>\", \"project\": \"" + LAUNCH_PROJECT_ID + "\", \"workflow\": \"bugfix\", \"filed_by\": \"hazel\"}'\n" +
1964
2135
  "(replace <issue title> and <issue details> with the real values).\n\n" +
1965
2136
  "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" +
1966
- "Report back in plain prose — what checks you ran and their results. Checks you could not run are evidence gaps, not silent drops: name every one in --missing — unknown is neither PASS nor FAIL. End your report with exactly one line: VERDICT: PASS or VERDICT: 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 \"<what the task required>\" --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).";
2137
+ "Report back in plain prose — what checks you ran and their results. Checks you could not run are evidence gaps, not silent drops: name every one in --missing — unknown is neither PASS nor FAIL. End your report with exactly one line: VERDICT: PASS or VERDICT: 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 \"<what the task required>\" --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).";
1967
2138
  } else {
1968
2139
  instructions = "Test from a user's perspective. You are CODE-BLIND — do NOT read source code.\n" +
1969
2140
  "Public docs (API.md, README) are NOT source code — read them freely, exactly as a user would.\n" +
@@ -2159,6 +2330,52 @@ while (i < STEPS.length) {
2159
2330
  };
2160
2331
  }
2161
2332
  log("Build worktree confinement passed: " + wt.path);
2333
+
2334
+ // Already-merged idempotency: a `repo_diff: none (already-merged:
2335
+ // <sha>)` declaration is verified mechanically — <sha> must resolve
2336
+ // and be an ancestor of main in the configured repo. A fabricated or
2337
+ // mistaken declaration fails the phase here (the dispatcher retries
2338
+ // Build under its consecutive-failure cap); a verified declaration is
2339
+ // recorded in alreadyMergedSha for Review's no-diff branch. Without
2340
+ // this guard, Build correctly doing nothing left Review with no
2341
+ // mechanical way to accept an empty diff, and Cass rejected for "no
2342
+ // commits ahead of main — the builder likely forgot to commit" while
2343
+ // the deliverable sat on main (canary 2026-09-15, task 1d692d91).
2344
+ // The sha is hex-only by construction (extractAlreadyMerged), so
2345
+ // interpolating it into the shell command cannot inject.
2346
+ var am = extractAlreadyMerged(workerText);
2347
+ if (am.sha) {
2348
+ var amCheck = await agent(
2349
+ "Verify the builder's already-merged declaration.\n" +
2350
+ "Run in shell and return the stdout verbatim:\n" +
2351
+ "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",
2352
+ { key: "verify-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Verifying already-merged declaration" }
2353
+ );
2354
+ var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
2355
+ if (!/ALREADY_MERGED_YES/.test(amOut)) {
2356
+ log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of main — marking failed for retry");
2357
+ await agent(
2358
+ "Record already-merged verification failure.\n" +
2359
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2360
+ task_id: taskId,
2361
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
2362
+ 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" },
2363
+ 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" }
2364
+ }),
2365
+ { key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
2366
+ );
2367
+ return {
2368
+ __hatchWorkflowControl: "blocked",
2369
+ result: {
2370
+ blocked_reason: "Build already-merged declaration failed verification",
2371
+ 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.",
2372
+ task_id: taskId
2373
+ }
2374
+ };
2375
+ }
2376
+ alreadyMergedSha = am.sha;
2377
+ log("Build already-merged declaration verified: " + am.sha + " is an ancestor of main");
2378
+ }
2162
2379
  }
2163
2380
 
2164
2381
  // Deterministic closeout: no formatter agent. The verdict is mechanical
@@ -2323,6 +2540,15 @@ while (i < STEPS.length) {
2323
2540
  } else {
2324
2541
  summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
2325
2542
  }
2543
+ // Already-merged attestation: when the Build gate verified the builder's
2544
+ // already-merged declaration, the workflow records its own marker line in
2545
+ // the session notes (like the builder markers above, it is appended after
2546
+ // the slice so it can never be amputated). A later run resumed at Review
2547
+ // hydrates alreadyMergedSha from this workflow-attested line — never from
2548
+ // the builder's declaration alone.
2549
+ if (step.name === "Build" && alreadyMergedSha) {
2550
+ summary += "\nalready_merged_verified: " + alreadyMergedSha;
2551
+ }
2326
2552
 
2327
2553
  // Visual verdict evidence: for experiential artifact tasks, append the
2328
2554
  // deterministic post-change capture plan to the QA session notes. The