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.
@@ -26,6 +26,15 @@ const startStepIndex = inputs.start_step_index || 0;
26
26
  // resolution back via updatetask in the self-claim below.
27
27
  const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
28
28
  const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
29
+ // One-shot recovery routing: the dispatcher sets inputs.next_phase when it
30
+ // routes this run via an explicit recover-task redirect. The value is
31
+ // consumed (cleared) atomically by the successful self-claim below:
32
+ // claim-task takes expected_next_phase and clears the matching next_phase in
33
+ // the same transaction as the winning session insert, so no platform death
34
+ // can slip between claim and consumption and replay the routing. A stale or
35
+ // superseded routing survives — only an exact match clears.
36
+ // what the dispatcher routed on.
37
+ const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_phase.length > 0) ? inputs.next_phase : null;
29
38
 
30
39
  // Visual verdict protocol availability — the workflow parks for parent-run
31
40
  // baseline capture ONLY when the protocol is fully shipped. The protocol
@@ -638,6 +647,18 @@ function extractMarkerLines(workerText) {
638
647
  return markers.join("\n");
639
648
  }
640
649
 
650
+ // Already-merged idempotency (canary 2026-09-15, task 1d692d91): when the
651
+ // builder correctly makes no commit because the deliverable is already on
652
+ // main (a prior merge or hand-repair landed it), it declares
653
+ // `repo_diff: none (already-merged: <sha>)` naming the main commit that
654
+ // carries the work. The sha is hex-only (7-40 chars) so the workflow can
655
+ // interpolate it into the mechanical ancestor check without injection
656
+ // risk. Pure — pinned byte-identical across standard/bugfix/chore.
657
+ function extractAlreadyMerged(workerText) {
658
+ var m = /^repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})\)/im.exec(workerText || "");
659
+ return m ? { sha: m[1].toLowerCase() } : { sha: null };
660
+ }
661
+
641
662
  // Worktree confinement: the Build agent must declare the exact worktree
642
663
  // path it built in on a `worktree:` marker line. The workflow compares it
643
664
  // against WORKTREE_HINT mechanically (exact string match) — never by
@@ -815,6 +836,12 @@ let mapGateBounceCount = 0;
815
836
  // rationalized a skip against explicit instruction text — text alone did not
816
837
  // hold, so the decision now lives in workflow code, not agent judgment.
817
838
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
839
+ // Already-merged idempotency: the verified sha from the builder's
840
+ // `repo_diff: none (already-merged: <sha>)` declaration (null when the
841
+ // builder made commits or declared a runtime-state deliverable). The
842
+ // workflow verifies the sha is an ancestor of main at Build closeout;
843
+ // Review's no-diff branch reads this, never the builder's prose.
844
+ let alreadyMergedSha = null;
818
845
  // Deterministic publish target — computed by the workflow (registry base +
819
846
  // bumpVersion), never by the Publish agent.
820
847
  let publishTarget = null; // { base, scope, target }
@@ -1054,7 +1081,7 @@ while (i < STEPS.length) {
1054
1081
  const claimResult = await agent(
1055
1082
  "Claim this task for the " + step.name + " step.\n" +
1056
1083
  "Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
1057
- "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" +
1084
+ "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" +
1058
1085
  "If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
1059
1086
  "Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
1060
1087
  "Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
@@ -1266,12 +1293,33 @@ while (i < STEPS.length) {
1266
1293
  "cd " + WORKTREE_HINT + "\n" +
1267
1294
  "git add -A\n" +
1268
1295
  "git commit -m \"chore: " + safeTitle + "\"\n\n" +
1269
- "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" +
1296
+ "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" +
1270
1297
  (rejectionNotes ? "REWORK after rejection. Address:\n" + rejectionNotes + "\n\n" : "") +
1271
1298
  "Report back in plain prose: what you built and the outcome." +
1272
1299
  (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.");
1273
1300
 
1274
1301
  } else if (step.name === "Review") {
1302
+ // Already-merged hydration: when this run did not execute Build itself
1303
+ // (dispatcher resume at Review after a platform death between phases),
1304
+ // recover the workflow-attested verification from the latest completed
1305
+ // Build session notes. The `already_merged_verified:` line was written
1306
+ // by the workflow after a mechanical ancestor check — it is trusted;
1307
+ // the builder's bare declaration never is. Absent the line, the
1308
+ // mechanical fact below reads "none declared" and Cass fails closed.
1309
+ if (!alreadyMergedSha) {
1310
+ var hydNotes = await agent(
1311
+ "Read the latest completed Build session notes for task " + taskId + ".\n" +
1312
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1313
+ "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.",
1314
+ { key: "hydrate-already-merged" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Hydrating already-merged verification" }
1315
+ );
1316
+ var hydStr = (typeof hydNotes === "string") ? hydNotes : JSON.stringify(hydNotes);
1317
+ var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
1318
+ if (hvm) {
1319
+ alreadyMergedSha = hvm[1].toLowerCase();
1320
+ log("Hydrated already-merged verification from Build session notes: " + alreadyMergedSha);
1321
+ }
1322
+ }
1275
1323
  instructions = "Review independently and cold. No prior context 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" +
1276
1324
  (mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + mapperSpec + "\n\n" : "Read the spec from the task description.\n\n") +
1277
1325
  "Examine the code changes by running:\n" +
@@ -1281,7 +1329,7 @@ while (i < STEPS.length) {
1281
1329
  WORKTREE_HINT + "/\n\n" +
1282
1330
  "Check quality, correctness, spec compliance.\n" +
1283
1331
  "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" +
1284
- "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" +
1332
+ "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" +
1285
1333
  (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" +
1286
1334
  "(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" +
1287
1335
  "(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
@@ -1295,7 +1343,7 @@ while (i < STEPS.length) {
1295
1343
  "Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: chore: " + safeTitle + "\"\n\n" +
1296
1344
  "Read the output:\n" +
1297
1345
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
1298
- "- 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" +
1346
+ "- 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" +
1299
1347
  "- 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" +
1300
1348
  "- 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" +
1301
1349
  "RESOLUTION:\n" +
@@ -1842,6 +1890,15 @@ while (i < STEPS.length) {
1842
1890
  // lock was lost: stop the run and park the task — never continue to
1843
1891
  // a provenance stamp or version assignment without holding the lock.
1844
1892
  var buildPoll = null;
1893
+ // STEP 1b poll-signal accumulators (2026-09-15, task aadeccc3):
1894
+ // the durable audit-dir fallback below needs the poll's own
1895
+ // observations, not just its final verdict — whether our build was
1896
+ // ever seen, whether a stranger's build was ever in flight, and
1897
+ // what the last check observed. OR-ed across all three chunks so
1898
+ // a signal seen in any chunk survives the chunk boundary.
1899
+ var pollSawOurBuild = false;
1900
+ var pollSawStranger = false;
1901
+ var lastObservedAgentId = null;
1845
1902
  for (var chunk = 1; chunk <= 3; chunk++) {
1846
1903
  if (chunk > 1) {
1847
1904
  var refreshPoll = await agent(
@@ -1864,20 +1921,25 @@ while (i < STEPS.length) {
1864
1921
  : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, reworkCount);
1865
1922
  buildPoll = await agent(
1866
1923
  "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" +
1867
- "- If no build is running (build is null): OUR build finished. Stop and report done.\n" +
1924
+ "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" +
1925
+ "- 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" +
1926
+ "- If no build is running (build is null) and you previously observed our build running: our build finished. Stop and report done.\n" +
1868
1927
  "- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
1869
- "- 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" +
1870
- "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.",
1928
+ "- 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" +
1929
+ "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.",
1871
1930
  { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1872
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
1931
+ 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"] },
1873
1932
  timeoutMs: 270000 }
1874
1933
  );
1934
+ pollSawOurBuild = pollSawOurBuild || (buildPoll && buildPoll.saw_our_build === true);
1935
+ pollSawStranger = pollSawStranger || (buildPoll && buildPoll.saw_stranger === true);
1936
+ lastObservedAgentId = (buildPoll && buildPoll.observed_agent_id) || null;
1875
1937
  if (buildPoll && buildPoll.build_done) { break; }
1876
1938
  }
1877
1939
  if (!buildPoll || !buildPoll.build_done) {
1878
1940
  buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
1879
1941
  }
1880
- if (buildPoll.build_done) {
1942
+ if (buildPoll.build_done && pollSawOurBuild) {
1881
1943
  // STEP 1c (mechanical): NO provenance stamp here. Canary run 8
1882
1944
  // (2026-09-11) proved the stamp cannot certify content: the
1883
1945
  // builder's applied-report is derived from the carried diff, so
@@ -1893,7 +1955,116 @@ while (i < STEPS.length) {
1893
1955
  artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
1894
1956
  log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
1895
1957
  } else {
1896
- publishFailure = "Artifact build did not complete within budget: " + (buildPoll.status || "timeout") + ". The publish may or may not have landed — provenance was not stamped.";
1958
+ // STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
1959
+ // the poll above only observes IN-FLIGHT builds. A build that
1960
+ // finished between the receipt capture and the poll's first check
1961
+ // leaves no in-flight trace — but the platform's audit harness
1962
+ // leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
1963
+ // <timestamp>-<id>/ per completed build). Diff the audit-dir
1964
+ // listing against the pre-trigger snapshot: a timestamped dir
1965
+ // that appeared during the attempt window is evidence a build
1966
+ // completed. Attribution is by window, not by build identity:
1967
+ // the poll's saw_stranger signal only catches stranger builds in
1968
+ // flight AT a check — a stranger that finished entirely inside
1969
+ // the window is indistinguishable, so any observed stranger
1970
+ // blocks attribution and the outcome stays unknown. This never
1971
+ // re-issues the edit and never stamps provenance — ok=true only
1972
+ // routes to the parent's independent content read-back, which
1973
+ // remains the real verification.
1974
+ //
1975
+ // The poll end-state is read from the poll's own observations,
1976
+ // not from build_done alone: a build in flight at the last check
1977
+ // means the budget was shorter than the latency (or the build is
1978
+ // stuck) — NOT that no build ever started; nothing observed at
1979
+ // any check is the never-started signal.
1980
+ var pollEndState = lastObservedAgentId ? "build-still-running-at-poll-end"
1981
+ : (pollSawOurBuild ? "our-build-observed-then-unconfirmed" : "no-build-observed-in-window");
1982
+ var strangerObserved = pollSawStranger;
1983
+ var newAuditDirsAfterPoll = [];
1984
+ try {
1985
+ var auditAfterPoll = await agent(
1986
+ "List the artifact audit directories for slug \"" + PUBLISH_SLUG + "\" (best-effort, never a gate).\n" +
1987
+ "Run: ls -1 ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/ 2>/dev/null\n" +
1988
+ "Return JSON { \"dirs\": \"<newline-separated names, empty string when the audits directory does not exist or is empty>\" } and nothing else.",
1989
+ { key: attemptKey("publish-audit-after-poll-" + taskId, reworkCount), label: "Re-listing audit dirs after build poll",
1990
+ schema: { type: "object", properties: { dirs: { type: "string" } }, required: ["dirs"] } }
1991
+ );
1992
+ var auditDirsAfterPollList = String((auditAfterPoll && auditAfterPoll.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1993
+ newAuditDirsAfterPoll = auditDirsAfterPollList.filter(function (d) {
1994
+ return auditDirsBeforeTrigger.indexOf(d) === -1 && /^20\d\d-\d\d-\d\dT\d\d-\d\d-\d\dZ-/.test(d);
1995
+ });
1996
+ log("Publish audit-dir re-list after build poll for task " + taskId + ": " + newAuditDirsAfterPoll.length + " new timestamped dir(s)");
1997
+ } catch (auditAfterPollErr) {
1998
+ 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));
1999
+ }
2000
+ // auditReportOk: pure tri-state read of a report.json body —
2001
+ // true (build ok), false (build failed), null (missing or
2002
+ // unreadable — not evidence either way). The child returns the
2003
+ // raw body verbatim; interpretation lives here, never in prose.
2004
+ var auditReportOk = function (raw) {
2005
+ if (typeof raw !== "string") return null;
2006
+ var trimmed = raw.trim();
2007
+ if (trimmed === "" || trimmed === "MISSING") return null;
2008
+ var parsed;
2009
+ try { parsed = JSON.parse(trimmed); } catch (e) { return null; }
2010
+ if (parsed && typeof parsed.ok === "boolean") return parsed.ok;
2011
+ return null;
2012
+ };
2013
+ var auditOkAfterPoll = null;
2014
+ var newestAuditDirAfterPoll = null;
2015
+ if (newAuditDirsAfterPoll.length > 0 && !strangerObserved) {
2016
+ newAuditDirsAfterPoll.sort();
2017
+ newestAuditDirAfterPoll = newAuditDirsAfterPoll[newAuditDirsAfterPoll.length - 1];
2018
+ try {
2019
+ var auditOkRead = await agent(
2020
+ "Read the build report for artifact slug \"" + PUBLISH_SLUG + "\", audit dir \"" + newestAuditDirAfterPoll + "\" (verbatim read, never interpreted, never a gate).\n" +
2021
+ "Run: cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/" + newestAuditDirAfterPoll + "/report.json 2>/dev/null || echo MISSING\n" +
2022
+ "Return JSON { \"raw\": \"<verbatim file contents, or the literal string MISSING when the file does not exist>\" } and nothing else.",
2023
+ { key: attemptKey("publish-audit-ok-after-poll-" + taskId, reworkCount), label: "Reading build report after build poll",
2024
+ schema: { type: "object", properties: { raw: { type: "string" } }, required: ["raw"] } }
2025
+ );
2026
+ auditOkAfterPoll = auditReportOk(auditOkRead && auditOkRead.raw);
2027
+ } catch (auditOkReadErr) {
2028
+ log("Publish build-report read after build poll failed for task " + taskId + " (non-fatal, treated as unknown): " + (auditOkReadErr && auditOkReadErr.message ? auditOkReadErr.message : auditOkReadErr));
2029
+ auditOkAfterPoll = null;
2030
+ }
2031
+ }
2032
+ if (auditOkAfterPoll === true) {
2033
+ publishBuildLanded = true;
2034
+ artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
2035
+ log("Publish build landed for task " + taskId + " via durable audit evidence — provenance stamp deferred to parent content verification");
2036
+ await recordPublishLedger({
2037
+ commit: mergeCommitForPublish,
2038
+ attempt: rebuildAttemptKey,
2039
+ agent_id: rebuildAgentId,
2040
+ applied_report: publishAppliedObservation,
2041
+ outcome: "submitted",
2042
+ detail: "durable audit evidence shows a build completed during the attempt window (audit dir " + newestAuditDirAfterPoll + ", report ok=true); routed to parent verification"
2043
+ }, reworkCount);
2044
+ } else if (auditOkAfterPoll === false) {
2045
+ 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.";
2046
+ await recordPublishLedger({
2047
+ commit: mergeCommitForPublish,
2048
+ attempt: rebuildAttemptKey,
2049
+ agent_id: rebuildAgentId,
2050
+ applied_report: publishAppliedObservation,
2051
+ outcome: "failed",
2052
+ 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"
2053
+ }, reworkCount);
2054
+ } else {
2055
+ var unattributableReason = strangerObserved ? "stranger-build-observed-during-poll"
2056
+ : (pollEndState === "build-still-running-at-poll-end" ? "build-still-running-at-poll-end"
2057
+ : (newAuditDirsAfterPoll.length === 0 ? "no-new-audit-dir-in-window" : "audit-report-unreadable-or-missing"));
2058
+ 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.";
2059
+ await recordPublishLedger({
2060
+ commit: mergeCommitForPublish,
2061
+ attempt: rebuildAttemptKey,
2062
+ agent_id: rebuildAgentId,
2063
+ applied_report: publishAppliedObservation,
2064
+ outcome: "unknown",
2065
+ detail: "durable audit-dir fallback could not attribute a completed build to this attempt (unattributable_reason=" + unattributableReason + ", poll_end_state=" + pollEndState + ")"
2066
+ }, reworkCount);
2067
+ }
1897
2068
  }
1898
2069
  } else {
1899
2070
  publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
@@ -2144,6 +2315,52 @@ while (i < STEPS.length) {
2144
2315
  };
2145
2316
  }
2146
2317
  log("Build worktree confinement passed: " + wt.path);
2318
+
2319
+ // Already-merged idempotency: a `repo_diff: none (already-merged:
2320
+ // <sha>)` declaration is verified mechanically — <sha> must resolve
2321
+ // and be an ancestor of main in the configured repo. A fabricated or
2322
+ // mistaken declaration fails the phase here (the dispatcher retries
2323
+ // Build under its consecutive-failure cap); a verified declaration is
2324
+ // recorded in alreadyMergedSha for Review's no-diff branch. Without
2325
+ // this guard, Build correctly doing nothing left Review with no
2326
+ // mechanical way to accept an empty diff, and Cass rejected for "no
2327
+ // commits ahead of main — the builder likely forgot to commit" while
2328
+ // the deliverable sat on main (canary 2026-09-15, task 1d692d91).
2329
+ // The sha is hex-only by construction (extractAlreadyMerged), so
2330
+ // interpolating it into the shell command cannot inject.
2331
+ var am = extractAlreadyMerged(workerText);
2332
+ if (am.sha) {
2333
+ var amCheck = await agent(
2334
+ "Verify the builder's already-merged declaration.\n" +
2335
+ "Run in shell and return the stdout verbatim:\n" +
2336
+ "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",
2337
+ { key: "verify-already-merged" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Verifying already-merged declaration" }
2338
+ );
2339
+ var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
2340
+ if (!/ALREADY_MERGED_YES/.test(amOut)) {
2341
+ log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of main — marking failed for retry");
2342
+ await agent(
2343
+ "Record already-merged verification failure.\n" +
2344
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2345
+ task_id: taskId,
2346
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
2347
+ 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" },
2348
+ 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" }
2349
+ }),
2350
+ { key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
2351
+ );
2352
+ return {
2353
+ __hatchWorkflowControl: "blocked",
2354
+ result: {
2355
+ blocked_reason: "Build already-merged declaration failed verification",
2356
+ 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.",
2357
+ task_id: taskId
2358
+ }
2359
+ };
2360
+ }
2361
+ alreadyMergedSha = am.sha;
2362
+ log("Build already-merged declaration verified: " + am.sha + " is an ancestor of main");
2363
+ }
2147
2364
  }
2148
2365
 
2149
2366
  // Deterministic closeout: no formatter agent. The verdict is mechanical
@@ -2304,6 +2521,15 @@ while (i < STEPS.length) {
2304
2521
  } else {
2305
2522
  summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
2306
2523
  }
2524
+ // Already-merged attestation: when the Build gate verified the builder's
2525
+ // already-merged declaration, the workflow records its own marker line in
2526
+ // the session notes (like the builder markers above, it is appended after
2527
+ // the slice so it can never be amputated). A later run resumed at Review
2528
+ // hydrates alreadyMergedSha from this workflow-attested line — never from
2529
+ // the builder's declaration alone.
2530
+ if (step.name === "Build" && alreadyMergedSha) {
2531
+ summary += "\nalready_merged_verified: " + alreadyMergedSha;
2532
+ }
2307
2533
 
2308
2534
  // Capture mapper's spec for Build and Review
2309
2535
  if (step.name === "Map" && passed) {
@@ -103,18 +103,44 @@ phase("dispatch");
103
103
  // the schema demanded a top-level object with ready_tasks. The LLM resolved
104
104
  // the contradiction non-deterministically: some ticks validated, some
105
105
  // burned retries and blocked the whole dispatcher.)
106
- const boardReturn = await agent(
107
- "Read the dispatch state.\n" +
108
- "Run in shell and return the stdout as a raw string:\n" + crewCmd("get-dispatch-state", {}) + "\n" +
109
- "Return the command's stdout JSON as a plain string, byte-for-byte, unmodified. " +
110
- "Do NOT parse the JSON your return value must be the raw stdout string, never an object. " +
111
- "Do not select fields, do not rewrite, summarize, paraphrase, or reformat anything. " +
112
- "The result has keys ready_tasks, projects, config, counts — ferry the string exactly as received.",
113
- {
114
- key: "read-board",
115
- label: "Reading board state"
106
+ // Bounded in-tick retry (canary 2026-09-15): the read-board agent
107
+ // occasionally ferries malformed JSON (observed twice: a literal
108
+ // `.replace()` code fragment appended to the dispatch-state string).
109
+ // A malformed ferry used to abort the whole dispatcher tick; retrying
110
+ // the identical dispatcher call recovered both times. Retry the read
111
+ // in-tick instead of burning a tick two attempts with distinct
112
+ // replay keys, fail closed after that.
113
+ var boardReturn = null;
114
+ var boardReadError = null;
115
+ for (var readAttempt = 1; readAttempt <= 2; readAttempt++) {
116
+ var readBoardKey = readAttempt === 1 ? "read-board" : "read-board-r" + readAttempt;
117
+ try {
118
+ boardReturn = await agent(
119
+ "Read the dispatch state.\n" +
120
+ "Run in shell and return the stdout as a raw string:\n" + crewCmd("get-dispatch-state", {}) + "\n" +
121
+ "Return the command's stdout JSON as a plain string, byte-for-byte, unmodified. " +
122
+ "Do NOT parse the JSON — your return value must be the raw stdout string, never an object. " +
123
+ "Do not select fields, do not rewrite, summarize, paraphrase, or reformat anything. " +
124
+ "The result has keys ready_tasks, projects, config, counts — ferry the string exactly as received.",
125
+ {
126
+ key: readBoardKey,
127
+ label: "Reading board state" + (readAttempt > 1 ? " (attempt " + readAttempt + ")" : "")
128
+ }
129
+ );
130
+ // Parse eagerly inside the attempt so a malformed ferry retries
131
+ // instead of aborting the tick. (function declarations hoist.)
132
+ parseBoardJson(boardReturn);
133
+ boardReadError = null;
134
+ break;
135
+ } catch (readErr) {
136
+ boardReadError = readErr;
137
+ log("WARNING: read-board attempt " + readAttempt + " failed (" + (readErr && readErr.message ? readErr.message : readErr) + ")" + (readAttempt < 2 ? " — retrying" : " — attempts exhausted"));
138
+ boardReturn = null;
116
139
  }
117
- );
140
+ }
141
+ if (boardReturn === null) {
142
+ throw new Error("read-board: all 2 attempts failed to ferry parseable board JSON: " + (boardReadError && boardReadError.message ? boardReadError.message : boardReadError));
143
+ }
118
144
 
119
145
  // Deterministic board parse — the read-board agent is told to return the
120
146
  // CLI stdout as a raw string; the workflow parses it here. Fails closed:
@@ -210,6 +236,10 @@ function projectTaskRecord(t) {
210
236
  blocked: t.blocked,
211
237
  deps: Array.isArray(t.deps) ? t.deps.slice() : [],
212
238
  priority: t.priority,
239
+ // next_phase is the one-shot recovery routing set by recover-task. It is
240
+ // ferried verbatim here and consumed (validated, routed, cleared) by the
241
+ // deterministic eligibility logic below — never interpreted by the LLM.
242
+ next_phase: (typeof t.next_phase === "string" && t.next_phase.length > 0) ? t.next_phase : null,
213
243
  latest_session: ls ? { id: ls.id, status: ls.status, step: ls.step, notes: ls.notes } : null,
214
244
  retry: {
215
245
  consecutive_failures: (typeof cf === "number" && cf >= 0) ? cf : 0,
@@ -497,6 +527,30 @@ for (var t = 0; t < allTasks.length; t++) {
497
527
  var workflow = task.workflow || "standard";
498
528
  var steps = WORKFLOWS[workflow] || WORKFLOWS.standard;
499
529
 
530
+ // One-shot recovery routing: recover-task sets next_phase to an explicit
531
+ // human-chosen phase. It takes precedence over every session-derived path
532
+ // below — the human's redirect wins over the automatic resume — but never
533
+ // over work in flight, and never on a task in a terminal state.
534
+ // Invalid values fail closed: logged, skipped, and NOT cleared, so a
535
+ // human can fix it with a corrected recover-task. Valid values bypass
536
+ // the retry cap by design: an explicit redirect is not an automatic
537
+ // retry and must not consume retry budget. The launched workflow consumes
538
+ // (clears) next_phase on its successful self-claim, exactly once.
539
+ if (task.next_phase) {
540
+ if (task.state !== "todo" && task.state !== "in_progress") {
541
+ log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" set but state is " + task.state + "; left set for inspection");
542
+ continue;
543
+ }
544
+ if (latest && latest.status === "running") continue; // work in flight
545
+ var npIdx = steps.indexOf(task.next_phase);
546
+ if (npIdx < 0) {
547
+ log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" not in " + workflow + " step registry; left set for a corrected recover-task");
548
+ continue;
549
+ }
550
+ eligible.push({ task: task, startStep: npIdx, reason: "next_phase", workflow: workflow, nextPhase: task.next_phase });
551
+ continue;
552
+ }
553
+
500
554
  if (task.state === "todo") {
501
555
  // Idle playtests always enter at QA — the filing carries the full
502
556
  // assignment, so Triage/Map have nothing to add.
@@ -844,7 +898,12 @@ for (var p = 0; p < toProcess.length; p++) {
844
898
  // whether the task record had no workflow (playtest filings carry explicit
845
899
  // workflow:'standard', so they correctly get false).
846
900
  resolved_workflow: iworkflow,
847
- workflow_was_null: (itask.workflow == null)
901
+ workflow_was_null: (itask.workflow == null),
902
+ // One-shot recovery routing: the value the dispatcher routed on. The
903
+ // workflow's successful self-claim consumes (clears) it atomically via
904
+ // claim-task's expected_next_phase — only an exact match clears, so a
905
+ // newer recover-task written in the race window survives.
906
+ next_phase: item.nextPhase || null
848
907
  };
849
908
 
850
909
  log("Recommended " + iworkflow + " for \"" + itask.title + "\" [" + taskProject + "] at step " + nextStepName);
@@ -29,21 +29,28 @@ if (!!dashboardSlug !== !!dashboardRepoPath) throw new Error("dashboardSlug and
29
29
 
30
30
  const orchDir = crewHome + "/.orchestration";
31
31
 
32
- // ── Gate 0: Launchable-path + repo validation ───────────────────────
32
+ // ── Gate 0: Launchable-path validation ─────────────────────────────
33
33
  // workflow_launch only accepts workspace-contained scripts. A crewHome
34
34
  // outside the workspace would produce an instance whose workflows can
35
35
  // never launch (the Gate 1 canary's /home/hatch/.crew-canary-gate1
36
36
  // defect, 2026-09-11: workers resorted to changing, manually staged
37
37
  // copies). Fail closed here so no such instance can ever be created.
38
+ // The crew home is state, not a repository: it is NEVER git-initialized.
39
+ // (2026-09-15: removed the old git-init — its unborn repo let a clean
40
+ // room register the crew home itself as a project's repo_path, and the
41
+ // worktree lifecycle then failed closed on the home's untracked state
42
+ // files. A home with no .git can never pass create-project's
43
+ // isGitRepoPath check, so the trap is structurally impossible. The
44
+ // crew's own source repo remains fully registerable — that is how a
45
+ // crew works on itself.)
38
46
  // The dashboard repo is validated too: init registers the dashboard
39
47
  // project against it, and a non-git path would only fail later at the
40
- // first Build (the canary's npm-package repo_path defect, 2026-09-13:
41
- // init registered the crew home itself as the dashboard repo_path).
42
- // Unlike the crew home, the dashboard repo is NEVER auto-created — it is
43
- // the user's own project repository; a non-git path is a user error.
48
+ // first Build. Unlike the crew home, the dashboard repo is NEVER
49
+ // auto-created it is the user's own project repository; a non-git
50
+ // path is a user error.
44
51
  // Gate 0 decision logic is pure JS — the agent is a sensor, not a judge.
45
- // It reports raw facts (HOME, expanded paths, the git rev-parse exit
46
- // codes); the launchable/repo_valid verdicts are computed here,
52
+ // It reports raw facts (HOME, expanded paths, the dashboard repo's git
53
+ // rev-parse exit code); the launchable verdicts are computed here,
47
54
  // deterministically. An agent asked for a verdict can mis-apply the
48
55
  // prefix rule (e.g. the "$ws-evil" trailing-slash trick); code cannot.
49
56
  // (Prompt hardening is a smell — this is the mechanical version.)
@@ -53,15 +60,15 @@ function expandTilde(p, home) {
53
60
  return p;
54
61
  }
55
62
  function gate0Decide(facts) {
56
- // facts: { home, crewHomeExpanded, gitExitCode, dashboardRepoExpanded, dashboardRepoGitExitCode }
63
+ // facts: { home, crewHomeExpanded, dashboardRepoExpanded, dashboardRepoGitExitCode }
57
64
  // The dashboard fields are null in CLI-only mode (no dashboard) — the
58
65
  // dashboard verdicts are then vacuously true (nothing to validate).
66
+ // The crew home carries no repo verdict: homes are state, never repos.
59
67
  var workspace = facts.home + "/workspace";
60
68
  var launchable = facts.crewHomeExpanded.indexOf(workspace + "/") === 0;
61
- var repoValid = facts.gitExitCode === 0;
62
69
  var dashboardRepoLaunchable = facts.dashboardRepoExpanded == null || facts.dashboardRepoExpanded.indexOf(workspace + "/") === 0;
63
70
  var dashboardRepoValid = facts.dashboardRepoGitExitCode == null || facts.dashboardRepoGitExitCode === 0;
64
- return { workspace: workspace, launchable: launchable, repoValid: repoValid, dashboardRepoLaunchable: dashboardRepoLaunchable, dashboardRepoValid: dashboardRepoValid };
71
+ return { workspace: workspace, launchable: launchable, dashboardRepoLaunchable: dashboardRepoLaunchable, dashboardRepoValid: dashboardRepoValid };
65
72
  }
66
73
  var gateFacts;
67
74
  try {
@@ -73,19 +80,16 @@ try {
73
80
  "1. home=$(echo $HOME)\\n" +
74
81
  "2. Expand a leading ~/ in the requested crewHome against $HOME (leave other paths untouched).\\n" +
75
82
  "3. If the expanded crewHome does not exist: mkdir -p <expanded crewHome>\\n" +
76
- "4. Run: git -C <expanded crewHome> rev-parse --git-dir; echo EXIT:$?\\n" +
77
- "5. If EXIT is not 0: run git -C <expanded crewHome> init -b main (or git init if -b not supported)\\n" +
78
- "6. Re-run: git -C <expanded crewHome> rev-parse --git-dir; echo EXIT:$?\\n" +
83
+ "4. If <expanded crewHome>/.git exists, remove it (rm -rf <expanded crewHome>/.git). The crew home is state, never a git repository — a vestigial .git would let the home be mistaken for a project repo.\\n" +
79
84
  (dashboardRepoPath ?
80
- "7. Expand a leading ~/ in the requested dashboardRepoPath against $HOME (leave other paths untouched).\\n" +
81
- "8. Run: git -C <expanded dashboardRepoPath> rev-parse --git-dir; echo EXIT:$?\\n" +
85
+ "5. Expand a leading ~/ in the requested dashboardRepoPath against $HOME (leave other paths untouched).\\n" +
86
+ "6. Run: git -C <expanded dashboardRepoPath> rev-parse --git-dir; echo EXIT:$?\\n" +
82
87
  " Do NOT create or git-init the dashboard repo — it must already be a git repository.\\n" +
83
- "9. Return JSON { home, crewHomeExpanded, gitExitCode, dashboardRepoExpanded, dashboardRepoGitExitCode }\\n" +
84
- " where gitExitCode is the FINAL crew-home exit code after init, and dashboardRepoGitExitCode\\n" +
85
- " is the dashboard repo's exit code (no init attempted).\\n" :
86
- "7. Return JSON { home, crewHomeExpanded, gitExitCode, dashboardRepoExpanded: null, dashboardRepoGitExitCode: null }.\\n") +
88
+ "7. Return JSON { home, crewHomeExpanded, dashboardRepoExpanded, dashboardRepoGitExitCode }\\n" +
89
+ " where dashboardRepoGitExitCode is the dashboard repo's exit code (no init attempted).\\n" :
90
+ "5. Return JSON { home, crewHomeExpanded, dashboardRepoExpanded: null, dashboardRepoGitExitCode: null }.\\n") +
87
91
  "\\n" +
88
- "This ensures the crew home is a valid git repository" + (dashboardRepoPath ? " and the dashboard repo exists as one" : "") + ". Do not judge — just ensure and report.",
92
+ "This ensures the crew home exists inside the workspace and is not a git repository" + (dashboardRepoPath ? ", and that the dashboard repo exists as one" : "") + ". Do not judge — just ensure and report.",
89
93
  {
90
94
  key: "gate-0",
91
95
  label: "Validate crew home",
@@ -94,11 +98,10 @@ try {
94
98
  properties: {
95
99
  home: { type: "string" },
96
100
  crewHomeExpanded: { type: "string" },
97
- gitExitCode: { type: "number" },
98
101
  dashboardRepoExpanded: { type: ["string", "null"] },
99
102
  dashboardRepoGitExitCode: { type: ["number", "null"] }
100
103
  },
101
- required: ["home", "crewHomeExpanded", "gitExitCode", "dashboardRepoExpanded", "dashboardRepoGitExitCode"]
104
+ required: ["home", "crewHomeExpanded", "dashboardRepoExpanded", "dashboardRepoGitExitCode"]
102
105
  }
103
106
  }
104
107
  );
@@ -115,15 +118,6 @@ if (!gateResult.launchable) {
115
118
  }
116
119
  };
117
120
  }
118
- if (!gateResult.repoValid) {
119
- return {
120
- __hatchWorkflowControl: "blocked",
121
- result: {
122
- blocked_reason: "crewHome git init failed",
123
- message: "crewHome (" + gateFacts.crewHomeExpanded + ") could not be initialized as a git repository (git exit code " + gateFacts.gitExitCode + "). Check permissions and try again."
124
- }
125
- };
126
- }
127
121
  if (!gateResult.dashboardRepoLaunchable) {
128
122
  return {
129
123
  __hatchWorkflowControl: "blocked",
@@ -142,7 +136,7 @@ if (!gateResult.dashboardRepoValid) {
142
136
  }
143
137
  };
144
138
  }
145
- log("Gate 0: crewHome " + gateFacts.crewHomeExpanded + " is workspace-contained and a valid git repo" + (gateFacts.dashboardRepoExpanded ? "; dashboard repo " + gateFacts.dashboardRepoExpanded + " is workspace-contained and a valid git repo" : " (CLI-only mode: no dashboard)"));
139
+ log("Gate 0: crewHome " + gateFacts.crewHomeExpanded + " is workspace-contained (a state dir, never a git repo)" + (gateFacts.dashboardRepoExpanded ? "; dashboard repo " + gateFacts.dashboardRepoExpanded + " is workspace-contained and a valid git repo" : " (CLI-only mode: no dashboard)"));
146
140
 
147
141
  // ── Crew name ─────────────────────────────────────────────────────────
148
142
  // The human names their crew during setup (SETUP-EXPERIENCE.md). Stored as
package/workflows/docs.js CHANGED
@@ -22,6 +22,15 @@ const startStepIndex = inputs.start_step_index || 0;
22
22
  // resolution back via updatetask in the self-claim below.
23
23
  const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
24
24
  const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
25
+ // One-shot recovery routing: the dispatcher sets inputs.next_phase when it
26
+ // routes this run via an explicit recover-task redirect. The value is
27
+ // consumed (cleared) atomically by the successful self-claim below:
28
+ // claim-task takes expected_next_phase and clears the matching next_phase in
29
+ // the same transaction as the winning session insert, so no platform death
30
+ // can slip between claim and consumption and replay the routing. A stale or
31
+ // superseded routing survives — only an exact match clears.
32
+ // what the dispatcher routed on.
33
+ const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_phase.length > 0) ? inputs.next_phase : null;
25
34
 
26
35
  // Config from args — backward-compatible fallbacks for manual launches
27
36
  const crewHome = inputs.crewHome || "~/workspace/.jarvis";
@@ -148,7 +157,7 @@ while (i < STEPS.length) {
148
157
  const claimResult = await agent(
149
158
  "Claim this task for the " + step.name + " step.\n" +
150
159
  "Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
151
- "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" +
160
+ "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" +
152
161
  "Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
153
162
  "Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
154
163
  {