muse-crew 0.7.8 → 0.7.10

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.
@@ -72,7 +72,7 @@ The parked message is stored as `Parked: publish: verification-requested
72
72
  `publish: verification-requested`. The `<commit>` is the merged commit whose
73
73
  content must be verified. The `(build …)` suffix carries the
74
74
  `build.agent_id` the workflow observed for this publish attempt (the
75
- artifact system's durable build identifier — the parent uses it for the
75
+ artifact system's in-flight build correlation ID — the parent uses it for the
76
76
  build-ID correlation in step 4b); `agent_id unobserved` means the edit was
77
77
  accepted but the workflow never correlated it to a builder run. The merge
78
78
  lock is already released (post-deploy ran before the park), so the parked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -21,7 +21,7 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
21
21
 
22
22
  1. **Load tools:** Call tool_search_load_tool_namespace with paths ["workflow_launch"].
23
23
 
24
- 2. **Load the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and parse it as JSON. If the file does not exist (the live release predates the registry), proceed without it — omit the `registry` arg and the dispatcher will load the registry the slow way and log a warning.
24
+ 2. **Load the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and parse it as JSON. If the file does not exist (the live release predates the registry), proceed without it — omit the `registry` arg and the dispatcher will load the registry the slow way and log a warning. If the read FAILS on a file that exists (transient read error — observed 2026-09-13; the file itself was healthy and later ticks read it fine), retry the read once; if it still fails, write the error text into your run summary (observability — never silently swallow a failed read) and proceed without the registry the same way.
25
25
 
26
26
  3. **Run the dispatcher:** Call workflow_launch with scriptPath "{crewHome}/workflows/crew-dispatch.js" and args {"crewHome": "{crewHome}", "registry": <parsed registry JSON, or omit the key when the file was missing>}.
27
27
 
@@ -486,8 +486,8 @@ function verifyAppliedChanges(expected, applied) {
486
486
  // it must match the artifact's real content.
487
487
  function buildPublishReadbackRequest(taskId, commit, diff, buildAgentId) {
488
488
  // Build-ID correlation (2026-09-12): buildAgentId is the build.agent_id the
489
- // workflow observed for the publish attempt (the artifact system's durable
490
- // build identifier). The read-back request carries it so the parent can
489
+ // workflow observed for the publish attempt (the artifact system's in-flight
490
+ // build correlation ID — not a durable post-completion identifier). The read-back request carries it so the parent can
491
491
  // prove the read-back inspected the live build of THIS attempt — not a
492
492
  // different build's output. Null/empty means the edit was accepted but
493
493
  // never correlated to a builder run. Pure function of inputs — no I/O,
@@ -1469,11 +1469,15 @@ while (i < STEPS.length) {
1469
1469
  if (/^rename from /m.test(mergeDiff)) {
1470
1470
  return await parkTask("Publish diff contains a rename — the diff transport cannot carry renames. Human attention needed.");
1471
1471
  }
1472
- var mergeDiffLines = mergeDiff.split("\n").length;
1473
- if (mergeDiffLines > 200) {
1474
- return await parkTask("Publish diff is " + mergeDiffLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
1475
- }
1476
1472
  var expectedChanges = parseUnifiedDiff(mergeDiff);
1473
+ // Budget counts CHANGED lines (added + removed), not raw unified-diff
1474
+ // output lines: context lines and file headers inflated the old
1475
+ // split("\n").length count ~2x, parking a 95-line change against a
1476
+ // 200-line budget (Gate 1 Journey 3 attempt 3, 2026-09-13).
1477
+ var mergeDiffChangedLines = expectedChanges.reduce(function (n, f) { return n + f.added.length + f.removed.length; }, 0);
1478
+ if (mergeDiffChangedLines > 200) {
1479
+ return await parkTask("Publish diff changes " + mergeDiffChangedLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
1480
+ }
1477
1481
  if (expectedChanges.length === 0) {
1478
1482
  return await parkTask("Publish diff parsed to zero files for commit " + (mergeCommitForPublish || "unknown") + " — cannot verify application. Human attention needed.");
1479
1483
  }
@@ -1521,12 +1525,22 @@ while (i < STEPS.length) {
1521
1525
  "- Report, for each file you changed: its path, the exact lines you added, and the exact lines you removed.\n" +
1522
1526
  buildPreHashInstruction(expectedChanges) + "'\n" +
1523
1527
  ARTIFACT_LOAD_PREAMBLE +
1524
- "If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
1525
- "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
1528
+ "If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"build_agent_id\": null, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
1529
+ "RECEIPT CAPTURE (receipt-chained publish, 2026-09-13): the edit is only half the contract you must also capture the platform build's receipt, the in-flight correlation ID the follow-up poll chains to.\n" +
1530
+ "- The artifact namespace is already loaded (see below). BEFORE calling artifact_edit, call artifact_status with slug \"" + PUBLISH_SLUG + "\" and note the running build's agent_id (or null when no build is running). This is the pre-edit baseline.\n" +
1531
+ "- Call artifact_edit as instructed above.\n" +
1532
+ "- IMMEDIATELY after artifact_edit returns, call artifact_status again. If a build is running whose agent_id DIFFERS from the pre-edit baseline (or the baseline was null), that build is this edit's — its agent_id is the receipt.\n" +
1533
+ "- If the post-edit status shows the SAME agent_id as the baseline, or no build at all, wait about 15 seconds and check artifact_status again, up to 4 more times. If a build with a NEW agent_id appears, that is the receipt.\n" +
1534
+ "- If no new build appears, the receipt is null: the build may be pending_init-invisible, may have finished before the capture, or may be queued behind the earlier build. Return null — do NOT guess, and do NOT substitute the baseline build's agent_id.\n" +
1535
+ "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"build_agent_id\": <the receipt agent_id string, or null when no build could be attributed to this edit>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
1526
1536
  var rebuildSchema =
1527
1537
  { type: "object",
1528
1538
  properties: {
1529
1539
  edit_started: { type: "boolean" },
1540
+ build_agent_id: {
1541
+ type: ["string", "null"],
1542
+ description: "Receipt-chained publish (2026-09-13): the platform build's in-flight correlation ID (build.agent_id from artifact_status) captured immediately after the edit was accepted — the receipt the follow-up poll chains to. Null when no build could be attributed to this edit. Required: an accepted edit with a null receipt parks fail-closed as unknown."
1543
+ },
1530
1544
  error: { type: "string" },
1531
1545
  applied: {
1532
1546
  type: "array",
@@ -1545,7 +1559,7 @@ while (i < STEPS.length) {
1545
1559
  description: "Diagnostic (2026-09-12): sha256 of each touched file's content BEFORE the builder applied the diff, as reported by the builder. Compared against the workflow-computed expected base hashes (merge parent) — observation only, never gating."
1546
1560
  }
1547
1561
  },
1548
- required: ["edit_started", "applied"] };
1562
+ required: ["edit_started", "build_agent_id", "applied"] };
1549
1563
  var rebuildTrigger = null;
1550
1564
  var rebuildReportMissing = false; // true if the edit went through but the agent returned no applied report (structured-output failure) — the smoke-check is skipped; the parent's independent read-back is the verification
1551
1565
  // The trigger key of the attempt that last ran, for the publish ledger.
@@ -1579,8 +1593,9 @@ while (i < STEPS.length) {
1579
1593
  //
1580
1594
  // Build-ID research (2026-09-12) corrected the model: artifact.edit
1581
1595
  // returns pending_init with NO agent_id, but artifact_status exposes
1582
- // the build's agent_id (the artifact system's durable build
1583
- // identifier, stable across polls) immediately after acceptance.
1596
+ // the build's agent_id (the artifact system's in-flight build
1597
+ // correlation ID, stable across polls while the build runs)
1598
+ // immediately after acceptance.
1584
1599
  // So the recovery no longer asks the child to derive booleans —
1585
1600
  // the layer where the 2026-09-12 signal was lost. It reads the RAW
1586
1601
  // build object and extracts build.agent_id mechanically in the
@@ -1647,6 +1662,42 @@ while (i < STEPS.length) {
1647
1662
  { key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", totalReworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
1648
1663
  rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", totalReworkCount);
1649
1664
  }
1665
+ // Receipt chaining (2026-09-13): adopt the trigger's build receipt,
1666
+ // or park on an uncorrelated acceptance. The trigger's closeout schema
1667
+ // requires build_agent_id — the platform build's in-flight correlation
1668
+ // ID captured immediately after the edit was accepted.
1669
+ // An accepted edit (edit_started=true) with a null receipt is UNKNOWN,
1670
+ // not "did not go through": the build may be pending_init-invisible,
1671
+ // may have finished before the capture window, or may be queued behind
1672
+ // a still-running earlier build. No re-trigger is issued on unknown —
1673
+ // a blind re-trigger duplicated the edit on 2026-09-12, and the platform
1674
+ // offers no idempotency proof that would make re-issue safe.
1675
+ // (Retry-semantics reconciliation, 2026-09-13: the "no receipt → safe
1676
+ // re-trigger" sketch assumed the edit command idempotently publishes
1677
+ // what's on git; the duplicate-edit incident disproves the assumption,
1678
+ // and the standing rule retries only on explicit negative evidence.
1679
+ // Re-trigger stays exactly where it was: the edit_started=false
1680
+ // explicit-rejection path above.) Record the attempt and park
1681
+ // fail-closed; correlate via the ledger and the parent's content
1682
+ // read-back before re-driving Publish.
1683
+ if (rebuildTrigger && rebuildTrigger.edit_started && !rebuildReportMissing) {
1684
+ var triggerAgentId = (typeof rebuildTrigger.build_agent_id === "string" && rebuildTrigger.build_agent_id.length > 0) ? rebuildTrigger.build_agent_id : null;
1685
+ if (!triggerAgentId) {
1686
+ var uncorrelatedObservation = (function () { var c = verifyAppliedChanges(expectedChanges, rebuildTrigger.applied); return c.ok ? "match" : "mismatch: " + c.reason; })();
1687
+ log("Publish receipt missing for task " + taskId + ": the trigger reported edit_started=true but captured no build receipt (build_agent_id null) — no build attributable to this edit. Parking fail-closed without re-triggering.");
1688
+ await recordPublishLedger({
1689
+ commit: mergeCommitForPublish,
1690
+ attempt: rebuildAttemptKey,
1691
+ agent_id: null,
1692
+ applied_report: uncorrelatedObservation,
1693
+ outcome: "unknown",
1694
+ detail: "edit accepted (edit_started=true) but the trigger captured no build receipt in its capture window: no build attributable to this edit (pending_init-invisible, finished before capture, or queued behind an earlier build). No re-trigger issued — a blind re-trigger on an unknown outcome duplicated the edit on 2026-09-12 and the platform offers no idempotency proof."
1695
+ }, totalReworkCount);
1696
+ return await parkTask("Publish outcome unknown: the rebuild trigger reported the edit was accepted but captured no build receipt (build_agent_id null) — no build could be attributed to this edit in the capture window. The edit may be pending_init-invisible, already finished, or queued behind an earlier build, so no re-trigger was issued: a blind retry duplicated the edit on 2026-09-12. The attempt is recorded in the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl (commit " + String(mergeCommitForPublish || "unknown").slice(0, 12) + "). Correlate via the ledger and re-drive Publish only after the parent's content read-back resolves what actually landed. Fail-closed.");
1697
+ }
1698
+ rebuildAgentId = triggerAgentId;
1699
+ log("Publish receipt chained for task " + taskId + ": build " + triggerAgentId + " — the follow-up poll waits on this build only.");
1700
+ }
1650
1701
  // Durable publish-attempt ledger: record the trigger outcome while the
1651
1702
  // attempt key and commit are in scope. Every attempt lands here with
1652
1703
  // its outcome — submitted, rejected, or unknown (unknown is recorded
@@ -1750,10 +1801,13 @@ while (i < STEPS.length) {
1750
1801
  ? attemptKey("publish-artifact-poll-" + taskId, totalReworkCount)
1751
1802
  : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
1752
1803
  buildPoll = await agent(
1753
- "First call tool_search.load_tool_namespace with paths [\"artifact\"]. Then poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 7 checks (3.5 minutes max).\n" +
1754
- "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
1804
+ "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" +
1805
+ "- If no build is running (build is null): OUR build finished. Stop and report done.\n" +
1806
+ "- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
1807
+ "- 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" +
1808
+ "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.",
1755
1809
  { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1756
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
1810
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
1757
1811
  timeoutMs: 270000 }
1758
1812
  );
1759
1813
  if (buildPoll && buildPoll.build_done) { break; }
@@ -543,8 +543,8 @@ function verifyAppliedChanges(expected, applied) {
543
543
  // it must match the artifact's real content.
544
544
  function buildPublishReadbackRequest(taskId, commit, diff, buildAgentId) {
545
545
  // Build-ID correlation (2026-09-12): buildAgentId is the build.agent_id the
546
- // workflow observed for the publish attempt (the artifact system's durable
547
- // build identifier). The read-back request carries it so the parent can
546
+ // workflow observed for the publish attempt (the artifact system's in-flight
547
+ // build correlation ID — not a durable post-completion identifier). The read-back request carries it so the parent can
548
548
  // prove the read-back inspected the live build of THIS attempt — not a
549
549
  // different build's output. Null/empty means the edit was accepted but
550
550
  // never correlated to a builder run. Pure function of inputs — no I/O,
@@ -1458,11 +1458,15 @@ while (i < STEPS.length) {
1458
1458
  if (/^rename from /m.test(mergeDiff)) {
1459
1459
  return await parkTask("Publish diff contains a rename — the diff transport cannot carry renames. Human attention needed.");
1460
1460
  }
1461
- var mergeDiffLines = mergeDiff.split("\n").length;
1462
- if (mergeDiffLines > 200) {
1463
- return await parkTask("Publish diff is " + mergeDiffLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
1464
- }
1465
1461
  var expectedChanges = parseUnifiedDiff(mergeDiff);
1462
+ // Budget counts CHANGED lines (added + removed), not raw unified-diff
1463
+ // output lines: context lines and file headers inflated the old
1464
+ // split("\n").length count ~2x, parking a 95-line change against a
1465
+ // 200-line budget (Gate 1 Journey 3 attempt 3, 2026-09-13).
1466
+ var mergeDiffChangedLines = expectedChanges.reduce(function (n, f) { return n + f.added.length + f.removed.length; }, 0);
1467
+ if (mergeDiffChangedLines > 200) {
1468
+ return await parkTask("Publish diff changes " + mergeDiffChangedLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
1469
+ }
1466
1470
  if (expectedChanges.length === 0) {
1467
1471
  return await parkTask("Publish diff parsed to zero files for commit " + (mergeCommitForPublish || "unknown") + " — cannot verify application. Human attention needed.");
1468
1472
  }
@@ -1510,12 +1514,22 @@ while (i < STEPS.length) {
1510
1514
  "- Report, for each file you changed: its path, the exact lines you added, and the exact lines you removed.\n" +
1511
1515
  buildPreHashInstruction(expectedChanges) + "'\n" +
1512
1516
  ARTIFACT_LOAD_PREAMBLE +
1513
- "If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
1514
- "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
1517
+ "If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"build_agent_id\": null, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
1518
+ "RECEIPT CAPTURE (receipt-chained publish, 2026-09-13): the edit is only half the contract you must also capture the platform build's receipt, the in-flight correlation ID the follow-up poll chains to.\n" +
1519
+ "- The artifact namespace is already loaded (see below). BEFORE calling artifact_edit, call artifact_status with slug \"" + PUBLISH_SLUG + "\" and note the running build's agent_id (or null when no build is running). This is the pre-edit baseline.\n" +
1520
+ "- Call artifact_edit as instructed above.\n" +
1521
+ "- IMMEDIATELY after artifact_edit returns, call artifact_status again. If a build is running whose agent_id DIFFERS from the pre-edit baseline (or the baseline was null), that build is this edit's — its agent_id is the receipt.\n" +
1522
+ "- If the post-edit status shows the SAME agent_id as the baseline, or no build at all, wait about 15 seconds and check artifact_status again, up to 4 more times. If a build with a NEW agent_id appears, that is the receipt.\n" +
1523
+ "- If no new build appears, the receipt is null: the build may be pending_init-invisible, may have finished before the capture, or may be queued behind the earlier build. Return null — do NOT guess, and do NOT substitute the baseline build's agent_id.\n" +
1524
+ "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"build_agent_id\": <the receipt agent_id string, or null when no build could be attributed to this edit>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
1515
1525
  var rebuildSchema =
1516
1526
  { type: "object",
1517
1527
  properties: {
1518
1528
  edit_started: { type: "boolean" },
1529
+ build_agent_id: {
1530
+ type: ["string", "null"],
1531
+ description: "Receipt-chained publish (2026-09-13): the platform build's in-flight correlation ID (build.agent_id from artifact_status) captured immediately after the edit was accepted — the receipt the follow-up poll chains to. Null when no build could be attributed to this edit. Required: an accepted edit with a null receipt parks fail-closed as unknown."
1532
+ },
1519
1533
  error: { type: "string" },
1520
1534
  applied: {
1521
1535
  type: "array",
@@ -1534,7 +1548,7 @@ while (i < STEPS.length) {
1534
1548
  description: "Diagnostic (2026-09-12): sha256 of each touched file's content BEFORE the builder applied the diff, as reported by the builder. Compared against the workflow-computed expected base hashes (merge parent) — observation only, never gating."
1535
1549
  }
1536
1550
  },
1537
- required: ["edit_started", "applied"] };
1551
+ required: ["edit_started", "build_agent_id", "applied"] };
1538
1552
  var rebuildTrigger = null;
1539
1553
  var rebuildReportMissing = false; // true if the edit went through but the agent returned no applied report (structured-output failure) — the smoke-check is skipped; the parent's independent read-back is the verification
1540
1554
  // The trigger key of the attempt that last ran, for the publish ledger.
@@ -1568,8 +1582,9 @@ while (i < STEPS.length) {
1568
1582
  //
1569
1583
  // Build-ID research (2026-09-12) corrected the model: artifact.edit
1570
1584
  // returns pending_init with NO agent_id, but artifact_status exposes
1571
- // the build's agent_id (the artifact system's durable build
1572
- // identifier, stable across polls) immediately after acceptance.
1585
+ // the build's agent_id (the artifact system's in-flight build
1586
+ // correlation ID, stable across polls while the build runs)
1587
+ // immediately after acceptance.
1573
1588
  // So the recovery no longer asks the child to derive booleans —
1574
1589
  // the layer where the 2026-09-12 signal was lost. It reads the RAW
1575
1590
  // build object and extracts build.agent_id mechanically in the
@@ -1636,6 +1651,42 @@ while (i < STEPS.length) {
1636
1651
  { key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", reworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
1637
1652
  rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", reworkCount);
1638
1653
  }
1654
+ // Receipt chaining (2026-09-13): adopt the trigger's build receipt,
1655
+ // or park on an uncorrelated acceptance. The trigger's closeout schema
1656
+ // requires build_agent_id — the platform build's in-flight correlation
1657
+ // ID captured immediately after the edit was accepted.
1658
+ // An accepted edit (edit_started=true) with a null receipt is UNKNOWN,
1659
+ // not "did not go through": the build may be pending_init-invisible,
1660
+ // may have finished before the capture window, or may be queued behind
1661
+ // a still-running earlier build. No re-trigger is issued on unknown —
1662
+ // a blind re-trigger duplicated the edit on 2026-09-12, and the platform
1663
+ // offers no idempotency proof that would make re-issue safe.
1664
+ // (Retry-semantics reconciliation, 2026-09-13: the "no receipt → safe
1665
+ // re-trigger" sketch assumed the edit command idempotently publishes
1666
+ // what's on git; the duplicate-edit incident disproves the assumption,
1667
+ // and the standing rule retries only on explicit negative evidence.
1668
+ // Re-trigger stays exactly where it was: the edit_started=false
1669
+ // explicit-rejection path above.) Record the attempt and park
1670
+ // fail-closed; correlate via the ledger and the parent's content
1671
+ // read-back before re-driving Publish.
1672
+ if (rebuildTrigger && rebuildTrigger.edit_started && !rebuildReportMissing) {
1673
+ var triggerAgentId = (typeof rebuildTrigger.build_agent_id === "string" && rebuildTrigger.build_agent_id.length > 0) ? rebuildTrigger.build_agent_id : null;
1674
+ if (!triggerAgentId) {
1675
+ var uncorrelatedObservation = (function () { var c = verifyAppliedChanges(expectedChanges, rebuildTrigger.applied); return c.ok ? "match" : "mismatch: " + c.reason; })();
1676
+ log("Publish receipt missing for task " + taskId + ": the trigger reported edit_started=true but captured no build receipt (build_agent_id null) — no build attributable to this edit. Parking fail-closed without re-triggering.");
1677
+ await recordPublishLedger({
1678
+ commit: mergeCommitForPublish,
1679
+ attempt: rebuildAttemptKey,
1680
+ agent_id: null,
1681
+ applied_report: uncorrelatedObservation,
1682
+ outcome: "unknown",
1683
+ detail: "edit accepted (edit_started=true) but the trigger captured no build receipt in its capture window: no build attributable to this edit (pending_init-invisible, finished before capture, or queued behind an earlier build). No re-trigger issued — a blind re-trigger on an unknown outcome duplicated the edit on 2026-09-12 and the platform offers no idempotency proof."
1684
+ }, reworkCount);
1685
+ return await parkTask("Publish outcome unknown: the rebuild trigger reported the edit was accepted but captured no build receipt (build_agent_id null) — no build could be attributed to this edit in the capture window. The edit may be pending_init-invisible, already finished, or queued behind an earlier build, so no re-trigger was issued: a blind retry duplicated the edit on 2026-09-12. The attempt is recorded in the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl (commit " + String(mergeCommitForPublish || "unknown").slice(0, 12) + "). Correlate via the ledger and re-drive Publish only after the parent's content read-back resolves what actually landed. Fail-closed.");
1686
+ }
1687
+ rebuildAgentId = triggerAgentId;
1688
+ log("Publish receipt chained for task " + taskId + ": build " + triggerAgentId + " — the follow-up poll waits on this build only.");
1689
+ }
1639
1690
  // Durable publish-attempt ledger: record the trigger outcome while the
1640
1691
  // attempt key and commit are in scope. Every attempt lands here with
1641
1692
  // its outcome — submitted, rejected, or unknown (unknown is recorded
@@ -1739,10 +1790,13 @@ while (i < STEPS.length) {
1739
1790
  ? attemptKey("publish-artifact-poll-" + taskId, reworkCount)
1740
1791
  : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, reworkCount);
1741
1792
  buildPoll = await agent(
1742
- "First call tool_search.load_tool_namespace with paths [\"artifact\"]. Then poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 7 checks (3.5 minutes max).\n" +
1743
- "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
1793
+ "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" +
1794
+ "- If no build is running (build is null): OUR build finished. Stop and report done.\n" +
1795
+ "- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
1796
+ "- 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" +
1797
+ "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.",
1744
1798
  { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1745
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
1799
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
1746
1800
  timeoutMs: 270000 }
1747
1801
  );
1748
1802
  if (buildPoll && buildPoll.build_done) { break; }
@@ -248,6 +248,34 @@ function unwrapBoardResult(boardResult) {
248
248
  }
249
249
  }
250
250
  }
251
+ // The read-board agent may return the whole board stdout in a
252
+ // {"status":"ok","result":"<board JSON string>"} envelope — the
253
+ // get-dispatch-state stdout placed in .result as a string instead of a
254
+ // parsed object (17:00 PDT tick, 2026-09-13). Parse it deterministically
255
+ // in JS: a string that parses to an array is the ready_tasks array
256
+ // itself; a string that parses to an object is the whole board stdout,
257
+ // so re-run the unwrap on it. Unparseable or scalar strings fall through
258
+ // to the fail-closed throw below.
259
+ if (boardData && typeof boardData === 'object' && typeof boardData.result === 'string' && !Array.isArray(boardData.ready_tasks)) {
260
+ var parsedResult = null, resultParsed = false;
261
+ try {
262
+ parsedResult = JSON.parse(boardData.result);
263
+ resultParsed = true;
264
+ } catch (e) {
265
+ // Unparseable — fall through to fail-closed below.
266
+ }
267
+ if (resultParsed) {
268
+ if (Array.isArray(parsedResult)) {
269
+ boardData.ready_tasks = parsedResult;
270
+ } else if (parsedResult && typeof parsedResult === 'object') {
271
+ // Recursion happens outside the try: a fail-closed throw from the
272
+ // inner unwrap must propagate with its own precise message, not be
273
+ // swallowed into the generic throw below.
274
+ return unwrapBoardResult(parsedResult);
275
+ }
276
+ // Scalar — fall through to fail-closed below.
277
+ }
278
+ }
251
279
  // The read-board agent may hand back the envelope with ready_tasks as a
252
280
  // JSON string — {"ready_tasks": "<json string>"} — the get-dispatch-state
253
281
  // stdout placed in the envelope instead of parsed (14:24 PDT tick,
@@ -272,7 +300,7 @@ function unwrapBoardResult(boardResult) {
272
300
  if (!boardData || typeof boardData !== 'object' || !Array.isArray(boardData.ready_tasks)) {
273
301
  throw new Error(
274
302
  "unwrapBoardResult: unknown board envelope — expected ready_tasks at top level, " +
275
- "in .data, in .result, in .result.data, or as a JSON string of a ready_tasks array " +
303
+ "in .data, in .result (object or JSON string), in .result.data, or as a JSON string of a ready_tasks array " +
276
304
  "or of a whole board object. Got keys: " +
277
305
  (boardData && typeof boardData === 'object' ? Object.keys(boardData).join(",") : typeof boardData)
278
306
  );
@@ -486,8 +486,8 @@ function verifyAppliedChanges(expected, applied) {
486
486
  // it must match the artifact's real content.
487
487
  function buildPublishReadbackRequest(taskId, commit, diff, buildAgentId) {
488
488
  // Build-ID correlation (2026-09-12): buildAgentId is the build.agent_id the
489
- // workflow observed for the publish attempt (the artifact system's durable
490
- // build identifier). The read-back request carries it so the parent can
489
+ // workflow observed for the publish attempt (the artifact system's in-flight
490
+ // build correlation ID — not a durable post-completion identifier). The read-back request carries it so the parent can
491
491
  // prove the read-back inspected the live build of THIS attempt — not a
492
492
  // different build's output. Null/empty means the edit was accepted but
493
493
  // never correlated to a builder run. Pure function of inputs — no I/O,
@@ -1441,11 +1441,15 @@ while (i < STEPS.length) {
1441
1441
  if (/^rename from /m.test(mergeDiff)) {
1442
1442
  return await parkTask("Publish diff contains a rename — the diff transport cannot carry renames. Human attention needed.");
1443
1443
  }
1444
- var mergeDiffLines = mergeDiff.split("\n").length;
1445
- if (mergeDiffLines > 200) {
1446
- return await parkTask("Publish diff is " + mergeDiffLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
1447
- }
1448
1444
  var expectedChanges = parseUnifiedDiff(mergeDiff);
1445
+ // Budget counts CHANGED lines (added + removed), not raw unified-diff
1446
+ // output lines: context lines and file headers inflated the old
1447
+ // split("\n").length count ~2x, parking a 95-line change against a
1448
+ // 200-line budget (Gate 1 Journey 3 attempt 3, 2026-09-13).
1449
+ var mergeDiffChangedLines = expectedChanges.reduce(function (n, f) { return n + f.added.length + f.removed.length; }, 0);
1450
+ if (mergeDiffChangedLines > 200) {
1451
+ return await parkTask("Publish diff changes " + mergeDiffChangedLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
1452
+ }
1449
1453
  if (expectedChanges.length === 0) {
1450
1454
  return await parkTask("Publish diff parsed to zero files for commit " + (mergeCommitForPublish || "unknown") + " — cannot verify application. Human attention needed.");
1451
1455
  }
@@ -1493,12 +1497,22 @@ while (i < STEPS.length) {
1493
1497
  "- Report, for each file you changed: its path, the exact lines you added, and the exact lines you removed.\n" +
1494
1498
  buildPreHashInstruction(expectedChanges) + "'\n" +
1495
1499
  ARTIFACT_LOAD_PREAMBLE +
1496
- "If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
1497
- "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
1500
+ "If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"build_agent_id\": null, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
1501
+ "RECEIPT CAPTURE (receipt-chained publish, 2026-09-13): the edit is only half the contract you must also capture the platform build's receipt, the in-flight correlation ID the follow-up poll chains to.\n" +
1502
+ "- The artifact namespace is already loaded (see below). BEFORE calling artifact_edit, call artifact_status with slug \"" + PUBLISH_SLUG + "\" and note the running build's agent_id (or null when no build is running). This is the pre-edit baseline.\n" +
1503
+ "- Call artifact_edit as instructed above.\n" +
1504
+ "- IMMEDIATELY after artifact_edit returns, call artifact_status again. If a build is running whose agent_id DIFFERS from the pre-edit baseline (or the baseline was null), that build is this edit's — its agent_id is the receipt.\n" +
1505
+ "- If the post-edit status shows the SAME agent_id as the baseline, or no build at all, wait about 15 seconds and check artifact_status again, up to 4 more times. If a build with a NEW agent_id appears, that is the receipt.\n" +
1506
+ "- If no new build appears, the receipt is null: the build may be pending_init-invisible, may have finished before the capture, or may be queued behind the earlier build. Return null — do NOT guess, and do NOT substitute the baseline build's agent_id.\n" +
1507
+ "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"build_agent_id\": <the receipt agent_id string, or null when no build could be attributed to this edit>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
1498
1508
  var rebuildSchema =
1499
1509
  { type: "object",
1500
1510
  properties: {
1501
1511
  edit_started: { type: "boolean" },
1512
+ build_agent_id: {
1513
+ type: ["string", "null"],
1514
+ description: "Receipt-chained publish (2026-09-13): the platform build's in-flight correlation ID (build.agent_id from artifact_status) captured immediately after the edit was accepted — the receipt the follow-up poll chains to. Null when no build could be attributed to this edit. Required: an accepted edit with a null receipt parks fail-closed as unknown."
1515
+ },
1502
1516
  error: { type: "string" },
1503
1517
  applied: {
1504
1518
  type: "array",
@@ -1517,7 +1531,7 @@ while (i < STEPS.length) {
1517
1531
  description: "Diagnostic (2026-09-12): sha256 of each touched file's content BEFORE the builder applied the diff, as reported by the builder. Compared against the workflow-computed expected base hashes (merge parent) — observation only, never gating."
1518
1532
  }
1519
1533
  },
1520
- required: ["edit_started", "applied"] };
1534
+ required: ["edit_started", "build_agent_id", "applied"] };
1521
1535
  var rebuildTrigger = null;
1522
1536
  var rebuildReportMissing = false; // true if the edit went through but the agent returned no applied report (structured-output failure) — the smoke-check is skipped; the parent's independent read-back is the verification
1523
1537
  // The trigger key of the attempt that last ran, for the publish ledger.
@@ -1551,8 +1565,9 @@ while (i < STEPS.length) {
1551
1565
  //
1552
1566
  // Build-ID research (2026-09-12) corrected the model: artifact.edit
1553
1567
  // returns pending_init with NO agent_id, but artifact_status exposes
1554
- // the build's agent_id (the artifact system's durable build
1555
- // identifier, stable across polls) immediately after acceptance.
1568
+ // the build's agent_id (the artifact system's in-flight build
1569
+ // correlation ID, stable across polls while the build runs)
1570
+ // immediately after acceptance.
1556
1571
  // So the recovery no longer asks the child to derive booleans —
1557
1572
  // the layer where the 2026-09-12 signal was lost. It reads the RAW
1558
1573
  // build object and extracts build.agent_id mechanically in the
@@ -1619,6 +1634,42 @@ while (i < STEPS.length) {
1619
1634
  { key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", totalReworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
1620
1635
  rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", totalReworkCount);
1621
1636
  }
1637
+ // Receipt chaining (2026-09-13): adopt the trigger's build receipt,
1638
+ // or park on an uncorrelated acceptance. The trigger's closeout schema
1639
+ // requires build_agent_id — the platform build's in-flight correlation
1640
+ // ID captured immediately after the edit was accepted.
1641
+ // An accepted edit (edit_started=true) with a null receipt is UNKNOWN,
1642
+ // not "did not go through": the build may be pending_init-invisible,
1643
+ // may have finished before the capture window, or may be queued behind
1644
+ // a still-running earlier build. No re-trigger is issued on unknown —
1645
+ // a blind re-trigger duplicated the edit on 2026-09-12, and the platform
1646
+ // offers no idempotency proof that would make re-issue safe.
1647
+ // (Retry-semantics reconciliation, 2026-09-13: the "no receipt → safe
1648
+ // re-trigger" sketch assumed the edit command idempotently publishes
1649
+ // what's on git; the duplicate-edit incident disproves the assumption,
1650
+ // and the standing rule retries only on explicit negative evidence.
1651
+ // Re-trigger stays exactly where it was: the edit_started=false
1652
+ // explicit-rejection path above.) Record the attempt and park
1653
+ // fail-closed; correlate via the ledger and the parent's content
1654
+ // read-back before re-driving Publish.
1655
+ if (rebuildTrigger && rebuildTrigger.edit_started && !rebuildReportMissing) {
1656
+ var triggerAgentId = (typeof rebuildTrigger.build_agent_id === "string" && rebuildTrigger.build_agent_id.length > 0) ? rebuildTrigger.build_agent_id : null;
1657
+ if (!triggerAgentId) {
1658
+ var uncorrelatedObservation = (function () { var c = verifyAppliedChanges(expectedChanges, rebuildTrigger.applied); return c.ok ? "match" : "mismatch: " + c.reason; })();
1659
+ log("Publish receipt missing for task " + taskId + ": the trigger reported edit_started=true but captured no build receipt (build_agent_id null) — no build attributable to this edit. Parking fail-closed without re-triggering.");
1660
+ await recordPublishLedger({
1661
+ commit: mergeCommitForPublish,
1662
+ attempt: rebuildAttemptKey,
1663
+ agent_id: null,
1664
+ applied_report: uncorrelatedObservation,
1665
+ outcome: "unknown",
1666
+ detail: "edit accepted (edit_started=true) but the trigger captured no build receipt in its capture window: no build attributable to this edit (pending_init-invisible, finished before capture, or queued behind an earlier build). No re-trigger issued — a blind re-trigger on an unknown outcome duplicated the edit on 2026-09-12 and the platform offers no idempotency proof."
1667
+ }, totalReworkCount);
1668
+ return await parkTask("Publish outcome unknown: the rebuild trigger reported the edit was accepted but captured no build receipt (build_agent_id null) — no build could be attributed to this edit in the capture window. The edit may be pending_init-invisible, already finished, or queued behind an earlier build, so no re-trigger was issued: a blind retry duplicated the edit on 2026-09-12. The attempt is recorded in the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl (commit " + String(mergeCommitForPublish || "unknown").slice(0, 12) + "). Correlate via the ledger and re-drive Publish only after the parent's content read-back resolves what actually landed. Fail-closed.");
1669
+ }
1670
+ rebuildAgentId = triggerAgentId;
1671
+ log("Publish receipt chained for task " + taskId + ": build " + triggerAgentId + " — the follow-up poll waits on this build only.");
1672
+ }
1622
1673
  // Durable publish-attempt ledger: record the trigger outcome while the
1623
1674
  // attempt key and commit are in scope. Every attempt lands here with
1624
1675
  // its outcome — submitted, rejected, or unknown (unknown is recorded
@@ -1722,10 +1773,13 @@ while (i < STEPS.length) {
1722
1773
  ? attemptKey("publish-artifact-poll-" + taskId, totalReworkCount)
1723
1774
  : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
1724
1775
  buildPoll = await agent(
1725
- "First call tool_search.load_tool_namespace with paths [\"artifact\"]. Then poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 7 checks (3.5 minutes max).\n" +
1726
- "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
1776
+ "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" +
1777
+ "- If no build is running (build is null): OUR build finished. Stop and report done.\n" +
1778
+ "- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
1779
+ "- 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" +
1780
+ "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.",
1727
1781
  { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1728
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
1782
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
1729
1783
  timeoutMs: 270000 }
1730
1784
  );
1731
1785
  if (buildPoll && buildPoll.build_done) { break; }
@@ -125,6 +125,40 @@ ok("string and object returns converge identically (deep equal)", () => {
125
125
  assert.deepStrictEqual(fromString, fromObject);
126
126
  });
127
127
 
128
+ // ── result-as-string envelope (2026-09-13 17:00 PDT tick) ───────────
129
+ // The read-board agent returned {"status":"ok","result":"<board JSON>"} —
130
+ // the whole board stdout as a string in .result. unwrapBoardResult must
131
+ // converge it the same way it converges every other envelope.
132
+ ok("envelope string {status, result: board-JSON-string} converges", () => {
133
+ const boardData = converge(JSON.stringify({ status: "ok", result: JSON.stringify(BOARD) }));
134
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
135
+ assert.deepStrictEqual(boardData.projects, PROJECTS);
136
+ });
137
+
138
+ ok("envelope object {status, result: board-JSON-string} converges", () => {
139
+ const boardData = converge({ status: "ok", result: JSON.stringify(BOARD) });
140
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
141
+ assert.deepStrictEqual(boardData.projects, PROJECTS);
142
+ });
143
+
144
+ ok("envelope {status, result: tasks-array-JSON-string} converges", () => {
145
+ const boardData = converge({ status: "ok", result: JSON.stringify(TASKS) });
146
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
147
+ });
148
+
149
+ ok("doubly-nested envelope {status, result: \"{result:{ready_tasks}}\"} converges", () => {
150
+ const inner = JSON.stringify({ result: { ready_tasks: TASKS } });
151
+ const boardData = converge(JSON.stringify({ status: "ok", result: inner }));
152
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
153
+ });
154
+
155
+ throwsClosed("envelope {status, result: unparseable-string} throws", () =>
156
+ converge(JSON.stringify({ status: "ok", result: "{not json" })));
157
+ throwsClosed("envelope {status, result: scalar-string} throws", () =>
158
+ converge(JSON.stringify({ status: "ok", result: "42" })));
159
+ throwsClosed("envelope {status, result: JSON-without-ready_tasks} throws", () =>
160
+ converge(JSON.stringify({ status: "ok", result: JSON.stringify({ foo: 1 }) })));
161
+
128
162
  // ── Fail-closed: never a silent empty task set ──────────────────────
129
163
  throwsClosed("invalid JSON string throws", () => converge("{not json"));
130
164
  throwsClosed("truncated JSON throws", () => converge('{"ready_tasks": ['));