muse-crew 0.14.5 → 0.14.6

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.
@@ -237,14 +237,6 @@ var TOOL_CHECK_PREAMBLE =
237
237
  "2. Write exactly one line: artifact_tools: ok - or artifact_tools: missing if the call failed or the tool does not exist.\n" +
238
238
  "3. Run the shell command: echo tool-probe-ok - then write exactly one line: shell_transport: ok - or shell_transport: unavailable if you cannot run shell commands.\n" +
239
239
  "Then do the assignment below.\n\n";
240
- // ARTIFACT_LOAD_PREAMBLE - the one-line load instruction that precedes every
241
- // special-purpose prompt calling artifact tools (rebuild trigger, provenance
242
- // stamp, publish verification). The artifact tool namespace is deferred for
243
- // workflow children: present but invisible until the child loads it via
244
- // tool_search.load_tool_namespace (bug 3472bf36 root cause). Byte-identical
245
- // across standard/bugfix/chore - pinned by tests/artifact-tools.test.js.
246
- var ARTIFACT_LOAD_PREAMBLE =
247
- "First call tool_search.load_tool_namespace with paths [\"artifact\"] - the artifact tool namespace is deferred and invisible until loaded.\n";
248
240
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
249
241
  // reason: "discarded" (the runtime threw the output away - it could not be
250
242
  // machine-read), "empty" (agent() returned without throwing but produced
@@ -422,6 +414,11 @@ async function recordPublishLedger(entry, rework) {
422
414
  diff_path: entry.diff_path || null,
423
415
  diff_sha256: entry.diff_sha256 || null,
424
416
  base: entry.base || null,
417
+ // (0.14.6) Numeric publication attempt + the attempt's deterministic
418
+ // version. Attempt 1 is staged here by the workflow; attempts 2-3 are
419
+ // staged by scan-ack-pending's re-issue path with fresh versions.
420
+ publish_attempt: entry.publish_attempt || null,
421
+ version: entry.version || null,
425
422
  });
426
423
  var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
427
424
  var res = await agent(
@@ -433,9 +430,15 @@ async function recordPublishLedger(entry, rework) {
433
430
  label: "Recording publish attempt in ledger",
434
431
  schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
435
432
  );
436
- log("Noted publish outcome '" + entry.outcome + "' for task " + taskId + " in ledger");
433
+ // (2026-09-20 REVIEW) The intent entry is the machine's anchor — the
434
+ // caller gates on this return. LEDGER_OK in the agent's verbatim output
435
+ // is the only mechanical proof the line landed.
436
+ var ok = res && typeof res.result === "string" && res.result.indexOf("LEDGER_OK") !== -1;
437
+ log("Noted publish outcome '" + entry.outcome + "' for task " + taskId + " in ledger" + (ok ? "" : " (WRITE UNCONFIRMED)"));
438
+ return ok;
437
439
  } catch (e) {
438
440
  log("Publish ledger: write failed for task " + taskId + " (non-fatal, observability only): " + (e && e.message ? e.message : e));
441
+ return false;
439
442
  }
440
443
  }
441
444
 
@@ -1485,7 +1488,7 @@ while (i < STEPS.length) {
1485
1488
  var publishDiffFile = crewHome + "/.publish-diffs/" + taskId + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + ".diff";
1486
1489
  var diffResult = await agent(
1487
1490
  "Run exactly one command and nothing else:\n" +
1488
- "node " + COMPUTE_DIFF + " --repo-path \"" + REPO_PATH + "\" --base " + publishBase + " --out \"" + publishDiffFile + "\"\n" +
1491
+ "node " + COMPUTE_DIFF + " --repo-path \"" + REPO_PATH + "\" --base " + publishBase + " --task-id \"" + taskId + "\" --attempt 1 --out \"" + publishDiffFile + "\"\n" +
1489
1492
  "The script prints one small JSON object to stdout. Return the stdout verbatim as { \"output\": \"<verbatim stdout>\" } and nothing else. Do not interpret it.",
1490
1493
  { key: attemptKey("publish-artifact-diff-" + taskId, totalReworkCount), label: "Computing publish diff from stamped base",
1491
1494
  schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
@@ -1508,6 +1511,18 @@ while (i < STEPS.length) {
1508
1511
  var mergeCommitForPublish = String(diffSummary.commit || "").trim();
1509
1512
  var mergeCommitShortForPublish = String(mergeCommitForPublish).substring(0, 7) || "unknown";
1510
1513
  var publishDiffSha256 = String(diffSummary.sha256 || "");
1514
+ // (0.14.6) The per-attempt publish version, synthesized into the
1515
+ // staged diff as .crew-publish-version-<task_id> (per-task receipt —
1516
+ // two same-project tasks can publish concurrently) by the
1517
+ // deterministic script.
1518
+ // The workflow never invents it: the script derives it from
1519
+ // task+commit+attempt, and the intent scan re-derives it before
1520
+ // claiming. A missing/malformed version parks — never publish blind.
1521
+ var publishVersion = String(diffSummary.version || "");
1522
+ // Mirrors isPublishVersion() in lib/crew-api.js: <40-hex commit>:<attempt>.
1523
+ if (!/^[0-9a-f]{40}:[1-9][0-9]*$/.test(publishVersion)) {
1524
+ return await parkTask("Publish diff for commit " + (mergeCommitForPublish || "unknown") + " carries no usable publish version — cannot stage a verifiable intent. Human attention needed.");
1525
+ }
1511
1526
  if (!diffSummary.bytes) {
1512
1527
  return await parkTask("Publish diff is empty for commit " + (mergeCommitForPublish || "unknown") + " — a merge lock was held but there is no change to carry. Human attention needed.");
1513
1528
  }
@@ -1535,131 +1550,53 @@ while (i < STEPS.length) {
1535
1550
  // worker verifies the sha256 from the intent entry before issuing;
1536
1551
  // a mismatch stops issuance. (2026-09-20, one-party worker-owned
1537
1552
  // publish: the workflow no longer issues the edit itself.)
1538
- // See docs/decisions/publish-path.md#durable-evidence-snapshot: snapshot the audit-dir listing BEFORE the trigger; fallback diffs before/after.
1539
- var auditDirsBeforeTrigger = [];
1540
- var auditBeforeOk = false;
1541
- // Pre-trigger baselines (design §1.9): the audit-dir listing and the
1542
- // manifest snapshot. The verified-path freshness check compares the
1543
- // post-trigger manifest against the baseline (built_at advance +
1544
- // content_sha256 change). Best-effort, never gates — a missing
1545
- // manifest baseline fails the verified path closed.
1546
- var preTriggerManifest = null;
1547
- try {
1548
- var auditBefore = await agent(
1549
- "Capture pre-trigger baselines for slug \"" + PUBLISH_SLUG + "\" (best-effort snapshots, never gates).\n" +
1550
- "Run: ls -1 ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/ 2>/dev/null; echo ---MANIFEST---; cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/.space-build/manifest.json 2>/dev/null\n" +
1551
- "Return JSON { \"dirs\": \"<newline-separated names, empty string when missing>\", \"manifest\": \"<the manifest's full text, or empty string when missing/unreadable>\" } and nothing else.",
1552
- { key: attemptKey("publish-baseline-before-" + taskId, totalReworkCount), label: "Snapshotting baselines before rebuild trigger",
1553
- schema: { type: "object", properties: { dirs: { type: "string" }, manifest: { type: "string" } }, required: ["dirs", "manifest"] } }
1554
- );
1555
- auditDirsBeforeTrigger = String((auditBefore && auditBefore.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1556
- var manifestText = String((auditBefore && auditBefore.manifest) || "").trim();
1557
- if (manifestText) {
1558
- var manifestJson = JSON.parse(manifestText);
1559
- preTriggerManifest = {
1560
- built_at: typeof manifestJson.built_at === "string" ? manifestJson.built_at : null,
1561
- content_sha256: typeof manifestJson.content_sha256 === "string" ? manifestJson.content_sha256 : null,
1562
- };
1563
- }
1564
- // Arm only after both baselines parse.
1565
- auditBeforeOk = true;
1566
- log("Publish pre-trigger baselines for task " + taskId + ": " + auditDirsBeforeTrigger.length + " audit dirs, manifest " + (preTriggerManifest ? "built_at=" + preTriggerManifest.built_at : "none"));
1567
- } catch (auditBeforeErr) {
1568
- log("Publish pre-trigger baselines failed for task " + taskId + " (non-fatal): audit fallback DISABLED for this attempt — without a baseline, historical dirs would look new: " + (auditBeforeErr && auditBeforeErr.message ? auditBeforeErr.message : auditBeforeErr));
1569
- }
1570
-
1571
- // See docs/decisions/publish-path.md#fire-and-forget-trigger: the trigger child returns immediately; the workflow owns observation and verdict.
1572
- var publishToolsOk = false;
1573
- var publishToolsMissing = false;
1574
- for (var toolcheckAttempt = 1; toolcheckAttempt <= 2 && !publishToolsOk; toolcheckAttempt++) {
1575
- try {
1576
- var toolcheckResult = await agent(
1577
- "Check whether the artifact tool namespace is available.\n" +
1578
- "Call tool_search.load_tool_namespace with paths [\"artifact\"].\n" +
1579
- "Then emit exactly one line and nothing else: ARTIFACT_TOOLS: <ok if the load succeeded and artifact_edit and artifact_status are now functions, missing otherwise>.\n" +
1580
- "Return JSON { \"signal\": \"<the exact ARTIFACT_TOOLS line>\" } and nothing else.",
1581
- { key: attemptKey("publish-artifact-toolcheck-" + taskId + (toolcheckAttempt === 1 ? "" : "-retry2"), totalReworkCount),
1582
- label: "Checking artifact tool availability" + (toolcheckAttempt === 1 ? "" : " (retry)"),
1583
- schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] } }
1584
- );
1585
- var toolSignal = String((toolcheckResult && toolcheckResult.signal) || "");
1586
- if (/ARTIFACT_TOOLS:\s*ok/.test(toolSignal)) {
1587
- publishToolsOk = true;
1588
- } else if (/ARTIFACT_TOOLS:\s*missing/.test(toolSignal)) {
1589
- publishToolsMissing = true;
1590
- }
1591
- log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2): " +
1592
- (publishToolsOk ? "tools ok" : publishToolsMissing ? "tools missing (explicit parsed signal)" : "inconclusive (no ARTIFACT_TOOLS signal parsed)"));
1593
- } catch (toolcheckErr) {
1594
- log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2) threw (" + (toolcheckErr && toolcheckErr.message ? toolcheckErr.message : toolcheckErr) + ") — inconclusive: a throw proves nothing about tool availability, never counted as missing");
1595
- }
1596
- }
1597
- if (!publishToolsOk && !publishToolsMissing) {
1598
- await recordPublishLedger({
1599
- commit: mergeCommitForPublish,
1600
- attempt: rebuildAttemptKey,
1601
- agent_id: null,
1602
- applied_report: null,
1603
- outcome: "unknown",
1604
- detail: "artifact toolcheck inconclusive after two attempts (throws or unparseable signals — never an explicit ARTIFACT_TOOLS: missing): tool availability unproven, so the trigger was NOT issued; unknown parks fail closed with no blind retry"
1605
- }, totalReworkCount);
1606
- return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact toolcheck was inconclusive after two attempts (no explicit ARTIFACT_TOOLS signal parsed — a throw is transport noise, not evidence). Tool availability is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.");
1607
- }
1608
- if (!publishToolsOk) {
1609
- await recordPublishLedger({
1610
- commit: mergeCommitForPublish,
1611
- attempt: rebuildAttemptKey,
1612
- agent_id: null,
1613
- applied_report: null,
1614
- outcome: "rejected",
1615
- detail: "artifact tool namespace explicitly missing (parsed ARTIFACT_TOOLS: missing signal, one bounded retry spent): the edit provably did not go through — no trigger issued, no blind retry"
1616
- }, totalReworkCount);
1617
- return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact tool namespace was explicitly missing (parsed signal — the edit provably did not go through, so no trigger was issued and nothing was retried blindly). Human attention needed.");
1618
- }
1619
- // See docs/decisions/publish-path.md#pretrigger-baseline: a tiny schema'd baseline is captured before the trigger.
1620
- var baselineAgentId = null;
1621
- var baselineFailed = false;
1622
- try {
1623
- var publishBaseline = await agent(
1624
- ARTIFACT_LOAD_PREAMBLE +
1625
- "Call artifact_status with slug \"" + PUBLISH_SLUG + "\" once.\n" +
1626
- "Return JSON { \"build\": <the raw \"build\" value exactly as returned, or null when there is none> } and nothing else.",
1627
- { key: attemptKey("publish-artifact-baseline-" + taskId, totalReworkCount), label: "Reading pre-trigger build state",
1628
- schema: { type: "object", properties: { build: { type: ["object", "null"] } }, required: ["build"] } }
1629
- );
1630
- baselineAgentId = (publishBaseline && publishBaseline.build && typeof publishBaseline.build.agent_id === "string" && publishBaseline.build.agent_id) || null;
1631
- log("Publish pre-trigger baseline for task " + taskId + ": " + (baselineAgentId ? "build " + baselineAgentId + " already in flight (predates the trigger — never attributed to this edit)" : "no build in flight"));
1632
- } catch (baselineErr) {
1633
- baselineFailed = true;
1634
- log("Publish pre-trigger baseline read failed for task " + taskId + " (" + (baselineErr && baselineErr.message ? baselineErr.message : baselineErr) + ") — receipt attribution skipped; durable audit-dir evidence is the only positive signal");
1635
- }
1636
1553
  // (2026-09-20, one-party worker-owned publish) The workflow exits
1637
1554
  // Publish at intent. Everything above — preflight, provenance base,
1555
+ // checksummed diff — is read-only. (2026-09-20 REVIEW: the
1556
+ // pre-trigger baselines, artifact toolcheck, and pre-trigger build
1557
+ // state agent steps were deleted — their outputs fed only the
1558
+ // retired content-verdict/receipt-attribution machinery, and the
1559
+ // toolcheck ran in a workflow child that cannot reach artifact_edit.
1560
+ // Issuance belongs to the session-carrying tick worker, which
1561
+ // handles tool-unavailability as inconclusive per the tick protocol.)
1562
+ // Publish at intent. Everything above — preflight, provenance base,
1638
1563
  // checksummed diff, toolcheck, pre-trigger baselines — is read-only.
1639
1564
  // Issuance belongs to the session-carrying tick worker
1640
- // (scan-publish-unknown's intent bucket), the only caller class that
1565
+ // (scan-publish-intent's intent bucket), the only caller class that
1641
1566
  // can reach artifact_edit. This entry is issuer-written BY THE
1642
1567
  // WORKFLOW and names the intent: outcome "publish-intent", never
1643
1568
  // "submitted" — the workflow issued nothing. The worker claims it
1644
1569
  // via the park note below.
1645
- await recordPublishLedger({
1570
+ // (2026-09-20 REVIEW) The intent ledger entry is the anchor the
1571
+ // whole machine depends on. If the ferry drops it, the task would
1572
+ // park with publish-requested and no anchor — unclaimable forever.
1573
+ // Fail loudly instead of parking an orphan intent.
1574
+ var intentAnchored = await recordPublishLedger({
1646
1575
  commit: mergeCommitForPublish,
1647
1576
  attempt: rebuildAttemptKey,
1577
+ // (0.14.6) Attempt 1, staged here. The version is the attempt's
1578
+ // deterministic receipt string, synthesized into the staged diff;
1579
+ // the ack scan re-derives it from task+commit+attempt before the
1580
+ // tick worker may issue the edit.
1581
+ publish_attempt: 1,
1582
+ version: publishVersion,
1648
1583
  agent_id: null,
1649
1584
  applied_report: null,
1650
- manifest_before: preTriggerManifest,
1651
1585
  outcome: "publish-intent",
1652
1586
  issuer: "workflow",
1653
1587
  diff_path: publishDiffFile,
1654
1588
  diff_sha256: publishDiffSha256,
1655
1589
  // (2026-09-20, critic-0145 F-A4) The base the diff was computed
1656
- // against: scan-publish-unknown's intent branch compares it with
1590
+ // against: scan-publish-intent's intent branch compares it with
1657
1591
  // current stamped provenance and re-queues the task when it went
1658
1592
  // stale — never issues the stale diff.
1659
1593
  base: publishBase,
1660
- detail: "publish intent: preflight, checksummed diff, toolcheck, and pre-trigger baselines are complete; issuance is owned by the session-carrying tick worker. The workflow performed no artifact_edit and wrote no issuance."
1594
+ detail: "publish intent: preflight and checksummed diff are complete; issuance is owned by the session-carrying tick worker. The workflow performed no artifact_edit and wrote no issuance."
1661
1595
  }, totalReworkCount);
1662
- return await parkTask("publish: publish-requested " + mergeCommitForPublish + " " + rebuildAttemptKey + " — Publish intent recorded for task " + taskId + ": the merged change is staged as a checksummed diff (" + publishDiffFile + ", sha256 " + publishDiffSha256 + ") for the crew's publish worker to issue. The worker verifies the build and stamps provenance before this task resumes; no action needed.");
1596
+ if (!intentAnchored) {
1597
+ throw new Error("publish-intent ledger anchor write unconfirmed for task " + taskId + " — refusing to park an unclaimable intent; the Publish phase fails loudly instead");
1598
+ }
1599
+ return await parkTask("publish: publish-requested " + mergeCommitForPublish + " attempt=1 — Publish intent recorded for task " + taskId + ": the merged change is staged as a checksummed diff (" + publishDiffFile + ", sha256 " + publishDiffSha256 + ") carrying the per-attempt publish version for the crew's publish worker to issue. The worker issues the edit directly and the acknowledgement scan owns the task from there; no action needed.");
1663
1600
  } // end: publishSkippedNoLock — no rebuild, no stamp, nothing to ship
1664
1601
  // STEP 2 (mechanical, always — skip path included): post-deploy
1665
1602
  // commits builder leftovers if any, removes the worktree, and releases
@@ -2320,10 +2257,10 @@ while (i < STEPS.length) {
2320
2257
  } // end: !npmPublishSkipped — a skipped publish has nothing to verify
2321
2258
  }
2322
2259
 
2323
- // Parent-owned verification (docs/publish-verification.md): the parent's
2324
- // scan builds the request via lib/build-readback-request.js and ferries
2325
- // the inspection; a skipped or failed publish has nothing to verify.
2326
- // See docs/decisions/publish-path.md#parent-owned-verification for history.
2260
+ // Version acknowledgement (docs/publish-verification.md): the tick worker
2261
+ // issues the edit directly and the ack scan owns the task from there —
2262
+ // the exact per-attempt version on the artifact's disk is the sole positive
2263
+ // completion criterion. A skipped or failed publish has nothing to ack.
2327
2264
  let summary;
2328
2265
  var workerMarkers = extractMarkerLines(workerText);
2329
2266
  if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget && (publishVerified || npmPublishSkipped)) {
@@ -296,14 +296,6 @@ var TOOL_CHECK_PREAMBLE =
296
296
  "2. Write exactly one line: artifact_tools: ok - or artifact_tools: missing if the call failed or the tool does not exist.\n" +
297
297
  "3. Run the shell command: echo tool-probe-ok - then write exactly one line: shell_transport: ok - or shell_transport: unavailable if you cannot run shell commands.\n" +
298
298
  "Then do the assignment below.\n\n";
299
- // ARTIFACT_LOAD_PREAMBLE - the one-line load instruction that precedes every
300
- // special-purpose prompt calling artifact tools (rebuild trigger, provenance
301
- // stamp, publish verification). The artifact tool namespace is deferred for
302
- // workflow children: present but invisible until the child loads it via
303
- // tool_search.load_tool_namespace (bug 3472bf36 root cause). Byte-identical
304
- // across standard/bugfix/chore - pinned by tests/artifact-tools.test.js.
305
- var ARTIFACT_LOAD_PREAMBLE =
306
- "First call tool_search.load_tool_namespace with paths [\"artifact\"] - the artifact tool namespace is deferred and invisible until loaded.\n";
307
299
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
308
300
  // reason: "discarded" (the runtime threw the output away - it could not be
309
301
  // machine-read), "empty" (agent() returned without throwing but produced
@@ -481,6 +473,11 @@ async function recordPublishLedger(entry, rework) {
481
473
  diff_path: entry.diff_path || null,
482
474
  diff_sha256: entry.diff_sha256 || null,
483
475
  base: entry.base || null,
476
+ // (0.14.6) Numeric publication attempt + the attempt's deterministic
477
+ // version. Attempt 1 is staged here by the workflow; attempts 2-3 are
478
+ // staged by scan-ack-pending's re-issue path with fresh versions.
479
+ publish_attempt: entry.publish_attempt || null,
480
+ version: entry.version || null,
484
481
  });
485
482
  var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
486
483
  var res = await agent(
@@ -492,9 +489,15 @@ async function recordPublishLedger(entry, rework) {
492
489
  label: "Recording publish attempt in ledger",
493
490
  schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
494
491
  );
495
- log("Noted publish outcome '" + entry.outcome + "' for task " + taskId + " in ledger");
492
+ // (2026-09-20 REVIEW) The intent entry is the machine's anchor — the
493
+ // caller gates on this return. LEDGER_OK in the agent's verbatim output
494
+ // is the only mechanical proof the line landed.
495
+ var ok = res && typeof res.result === "string" && res.result.indexOf("LEDGER_OK") !== -1;
496
+ log("Noted publish outcome '" + entry.outcome + "' for task " + taskId + " in ledger" + (ok ? "" : " (WRITE UNCONFIRMED)"));
497
+ return ok;
496
498
  } catch (e) {
497
499
  log("Publish ledger: write failed for task " + taskId + " (non-fatal, observability only): " + (e && e.message ? e.message : e));
500
+ return false;
498
501
  }
499
502
  }
500
503
 
@@ -1391,7 +1394,7 @@ while (i < STEPS.length) {
1391
1394
  var publishDiffFile = crewHome + "/.publish-diffs/" + taskId + (reworkCount > 0 ? "-r" + reworkCount : "") + ".diff";
1392
1395
  var diffResult = await agent(
1393
1396
  "Run exactly one command and nothing else:\n" +
1394
- "node " + COMPUTE_DIFF + " --repo-path \"" + REPO_PATH + "\" --base " + publishBase + " --out \"" + publishDiffFile + "\"\n" +
1397
+ "node " + COMPUTE_DIFF + " --repo-path \"" + REPO_PATH + "\" --base " + publishBase + " --task-id \"" + taskId + "\" --attempt 1 --out \"" + publishDiffFile + "\"\n" +
1395
1398
  "The script prints one small JSON object to stdout. Return the stdout verbatim as { \"output\": \"<verbatim stdout>\" } and nothing else. Do not interpret it.",
1396
1399
  { key: attemptKey("publish-artifact-diff-" + taskId, reworkCount), label: "Computing publish diff from stamped base",
1397
1400
  schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
@@ -1414,6 +1417,18 @@ while (i < STEPS.length) {
1414
1417
  var mergeCommitForPublish = String(diffSummary.commit || "").trim();
1415
1418
  var mergeCommitShortForPublish = String(mergeCommitForPublish).substring(0, 7) || "unknown";
1416
1419
  var publishDiffSha256 = String(diffSummary.sha256 || "");
1420
+ // (0.14.6) The per-attempt publish version, synthesized into the
1421
+ // staged diff as .crew-publish-version-<task_id> (per-task receipt —
1422
+ // two same-project tasks can publish concurrently) by the
1423
+ // deterministic script.
1424
+ // The workflow never invents it: the script derives it from
1425
+ // task+commit+attempt, and the intent scan re-derives it before
1426
+ // claiming. A missing/malformed version parks — never publish blind.
1427
+ var publishVersion = String(diffSummary.version || "");
1428
+ // Mirrors isPublishVersion() in lib/crew-api.js: <40-hex commit>:<attempt>.
1429
+ if (!/^[0-9a-f]{40}:[1-9][0-9]*$/.test(publishVersion)) {
1430
+ return await parkTask("Publish diff for commit " + (mergeCommitForPublish || "unknown") + " carries no usable publish version — cannot stage a verifiable intent. Human attention needed.");
1431
+ }
1417
1432
  if (!diffSummary.bytes) {
1418
1433
  return await parkTask("Publish diff is empty for commit " + (mergeCommitForPublish || "unknown") + " — a merge lock was held but there is no change to carry. Human attention needed.");
1419
1434
  }
@@ -1441,131 +1456,53 @@ while (i < STEPS.length) {
1441
1456
  // worker verifies the sha256 from the intent entry before issuing;
1442
1457
  // a mismatch stops issuance. (2026-09-20, one-party worker-owned
1443
1458
  // publish: the workflow no longer issues the edit itself.)
1444
- // See docs/decisions/publish-path.md#durable-evidence-snapshot: snapshot the audit-dir listing BEFORE the trigger; fallback diffs before/after.
1445
- var auditDirsBeforeTrigger = [];
1446
- var auditBeforeOk = false;
1447
- // Pre-trigger baselines (design §1.9): the audit-dir listing and the
1448
- // manifest snapshot. The verified-path freshness check compares the
1449
- // post-trigger manifest against the baseline (built_at advance +
1450
- // content_sha256 change). Best-effort, never gates — a missing
1451
- // manifest baseline fails the verified path closed.
1452
- var preTriggerManifest = null;
1453
- try {
1454
- var auditBefore = await agent(
1455
- "Capture pre-trigger baselines for slug \"" + PUBLISH_SLUG + "\" (best-effort snapshots, never gates).\n" +
1456
- "Run: ls -1 ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/ 2>/dev/null; echo ---MANIFEST---; cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/.space-build/manifest.json 2>/dev/null\n" +
1457
- "Return JSON { \"dirs\": \"<newline-separated names, empty string when missing>\", \"manifest\": \"<the manifest's full text, or empty string when missing/unreadable>\" } and nothing else.",
1458
- { key: attemptKey("publish-baseline-before-" + taskId, reworkCount), label: "Snapshotting baselines before rebuild trigger",
1459
- schema: { type: "object", properties: { dirs: { type: "string" }, manifest: { type: "string" } }, required: ["dirs", "manifest"] } }
1460
- );
1461
- auditDirsBeforeTrigger = String((auditBefore && auditBefore.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1462
- var manifestText = String((auditBefore && auditBefore.manifest) || "").trim();
1463
- if (manifestText) {
1464
- var manifestJson = JSON.parse(manifestText);
1465
- preTriggerManifest = {
1466
- built_at: typeof manifestJson.built_at === "string" ? manifestJson.built_at : null,
1467
- content_sha256: typeof manifestJson.content_sha256 === "string" ? manifestJson.content_sha256 : null,
1468
- };
1469
- }
1470
- // Arm only after both baselines parse.
1471
- auditBeforeOk = true;
1472
- log("Publish pre-trigger baselines for task " + taskId + ": " + auditDirsBeforeTrigger.length + " audit dirs, manifest " + (preTriggerManifest ? "built_at=" + preTriggerManifest.built_at : "none"));
1473
- } catch (auditBeforeErr) {
1474
- log("Publish pre-trigger baselines failed for task " + taskId + " (non-fatal): audit fallback DISABLED for this attempt — without a baseline, historical dirs would look new: " + (auditBeforeErr && auditBeforeErr.message ? auditBeforeErr.message : auditBeforeErr));
1475
- }
1476
-
1477
- // See docs/decisions/publish-path.md#fire-and-forget-trigger: the trigger child returns immediately; the workflow owns observation and verdict.
1478
- var publishToolsOk = false;
1479
- var publishToolsMissing = false;
1480
- for (var toolcheckAttempt = 1; toolcheckAttempt <= 2 && !publishToolsOk; toolcheckAttempt++) {
1481
- try {
1482
- var toolcheckResult = await agent(
1483
- "Check whether the artifact tool namespace is available.\n" +
1484
- "Call tool_search.load_tool_namespace with paths [\"artifact\"].\n" +
1485
- "Then emit exactly one line and nothing else: ARTIFACT_TOOLS: <ok if the load succeeded and artifact_edit and artifact_status are now functions, missing otherwise>.\n" +
1486
- "Return JSON { \"signal\": \"<the exact ARTIFACT_TOOLS line>\" } and nothing else.",
1487
- { key: attemptKey("publish-artifact-toolcheck-" + taskId + (toolcheckAttempt === 1 ? "" : "-retry2"), reworkCount),
1488
- label: "Checking artifact tool availability" + (toolcheckAttempt === 1 ? "" : " (retry)"),
1489
- schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] } }
1490
- );
1491
- var toolSignal = String((toolcheckResult && toolcheckResult.signal) || "");
1492
- if (/ARTIFACT_TOOLS:\s*ok/.test(toolSignal)) {
1493
- publishToolsOk = true;
1494
- } else if (/ARTIFACT_TOOLS:\s*missing/.test(toolSignal)) {
1495
- publishToolsMissing = true;
1496
- }
1497
- log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2): " +
1498
- (publishToolsOk ? "tools ok" : publishToolsMissing ? "tools missing (explicit parsed signal)" : "inconclusive (no ARTIFACT_TOOLS signal parsed)"));
1499
- } catch (toolcheckErr) {
1500
- log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2) threw (" + (toolcheckErr && toolcheckErr.message ? toolcheckErr.message : toolcheckErr) + ") — inconclusive: a throw proves nothing about tool availability, never counted as missing");
1501
- }
1502
- }
1503
- if (!publishToolsOk && !publishToolsMissing) {
1504
- await recordPublishLedger({
1505
- commit: mergeCommitForPublish,
1506
- attempt: rebuildAttemptKey,
1507
- agent_id: null,
1508
- applied_report: null,
1509
- outcome: "unknown",
1510
- detail: "artifact toolcheck inconclusive after two attempts (throws or unparseable signals — never an explicit ARTIFACT_TOOLS: missing): tool availability unproven, so the trigger was NOT issued; unknown parks fail closed with no blind retry"
1511
- }, reworkCount);
1512
- return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact toolcheck was inconclusive after two attempts (no explicit ARTIFACT_TOOLS signal parsed — a throw is transport noise, not evidence). Tool availability is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.");
1513
- }
1514
- if (!publishToolsOk) {
1515
- await recordPublishLedger({
1516
- commit: mergeCommitForPublish,
1517
- attempt: rebuildAttemptKey,
1518
- agent_id: null,
1519
- applied_report: null,
1520
- outcome: "rejected",
1521
- detail: "artifact tool namespace explicitly missing (parsed ARTIFACT_TOOLS: missing signal, one bounded retry spent): the edit provably did not go through — no trigger issued, no blind retry"
1522
- }, reworkCount);
1523
- return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact tool namespace was explicitly missing (parsed signal — the edit provably did not go through, so no trigger was issued and nothing was retried blindly). Human attention needed.");
1524
- }
1525
- // See docs/decisions/publish-path.md#pretrigger-baseline: a tiny schema'd baseline is captured before the trigger.
1526
- var baselineAgentId = null;
1527
- var baselineFailed = false;
1528
- try {
1529
- var publishBaseline = await agent(
1530
- ARTIFACT_LOAD_PREAMBLE +
1531
- "Call artifact_status with slug \"" + PUBLISH_SLUG + "\" once.\n" +
1532
- "Return JSON { \"build\": <the raw \"build\" value exactly as returned, or null when there is none> } and nothing else.",
1533
- { key: attemptKey("publish-artifact-baseline-" + taskId, reworkCount), label: "Reading pre-trigger build state",
1534
- schema: { type: "object", properties: { build: { type: ["object", "null"] } }, required: ["build"] } }
1535
- );
1536
- baselineAgentId = (publishBaseline && publishBaseline.build && typeof publishBaseline.build.agent_id === "string" && publishBaseline.build.agent_id) || null;
1537
- log("Publish pre-trigger baseline for task " + taskId + ": " + (baselineAgentId ? "build " + baselineAgentId + " already in flight (predates the trigger — never attributed to this edit)" : "no build in flight"));
1538
- } catch (baselineErr) {
1539
- baselineFailed = true;
1540
- log("Publish pre-trigger baseline read failed for task " + taskId + " (" + (baselineErr && baselineErr.message ? baselineErr.message : baselineErr) + ") — receipt attribution skipped; durable audit-dir evidence is the only positive signal");
1541
- }
1542
1459
  // (2026-09-20, one-party worker-owned publish) The workflow exits
1543
1460
  // Publish at intent. Everything above — preflight, provenance base,
1461
+ // checksummed diff — is read-only. (2026-09-20 REVIEW: the
1462
+ // pre-trigger baselines, artifact toolcheck, and pre-trigger build
1463
+ // state agent steps were deleted — their outputs fed only the
1464
+ // retired content-verdict/receipt-attribution machinery, and the
1465
+ // toolcheck ran in a workflow child that cannot reach artifact_edit.
1466
+ // Issuance belongs to the session-carrying tick worker, which
1467
+ // handles tool-unavailability as inconclusive per the tick protocol.)
1468
+ // Publish at intent. Everything above — preflight, provenance base,
1544
1469
  // checksummed diff, toolcheck, pre-trigger baselines — is read-only.
1545
1470
  // Issuance belongs to the session-carrying tick worker
1546
- // (scan-publish-unknown's intent bucket), the only caller class that
1471
+ // (scan-publish-intent's intent bucket), the only caller class that
1547
1472
  // can reach artifact_edit. This entry is issuer-written BY THE
1548
1473
  // WORKFLOW and names the intent: outcome "publish-intent", never
1549
1474
  // "submitted" — the workflow issued nothing. The worker claims it
1550
1475
  // via the park note below.
1551
- await recordPublishLedger({
1476
+ // (2026-09-20 REVIEW) The intent ledger entry is the anchor the
1477
+ // whole machine depends on. If the ferry drops it, the task would
1478
+ // park with publish-requested and no anchor — unclaimable forever.
1479
+ // Fail loudly instead of parking an orphan intent.
1480
+ var intentAnchored = await recordPublishLedger({
1552
1481
  commit: mergeCommitForPublish,
1553
1482
  attempt: rebuildAttemptKey,
1483
+ // (0.14.6) Attempt 1, staged here. The version is the attempt's
1484
+ // deterministic receipt string, synthesized into the staged diff;
1485
+ // the ack scan re-derives it from task+commit+attempt before the
1486
+ // tick worker may issue the edit.
1487
+ publish_attempt: 1,
1488
+ version: publishVersion,
1554
1489
  agent_id: null,
1555
1490
  applied_report: null,
1556
- manifest_before: preTriggerManifest,
1557
1491
  outcome: "publish-intent",
1558
1492
  issuer: "workflow",
1559
1493
  diff_path: publishDiffFile,
1560
1494
  diff_sha256: publishDiffSha256,
1561
1495
  // (2026-09-20, critic-0145 F-A4) The base the diff was computed
1562
- // against: scan-publish-unknown's intent branch compares it with
1496
+ // against: scan-publish-intent's intent branch compares it with
1563
1497
  // current stamped provenance and re-queues the task when it went
1564
1498
  // stale — never issues the stale diff.
1565
1499
  base: publishBase,
1566
- detail: "publish intent: preflight, checksummed diff, toolcheck, and pre-trigger baselines are complete; issuance is owned by the session-carrying tick worker. The workflow performed no artifact_edit and wrote no issuance."
1500
+ detail: "publish intent: preflight and checksummed diff are complete; issuance is owned by the session-carrying tick worker. The workflow performed no artifact_edit and wrote no issuance."
1567
1501
  }, reworkCount);
1568
- return await parkTask("publish: publish-requested " + mergeCommitForPublish + " " + rebuildAttemptKey + " — Publish intent recorded for task " + taskId + ": the merged change is staged as a checksummed diff (" + publishDiffFile + ", sha256 " + publishDiffSha256 + ") for the crew's publish worker to issue. The worker verifies the build and stamps provenance before this task resumes; no action needed.");
1502
+ if (!intentAnchored) {
1503
+ throw new Error("publish-intent ledger anchor write unconfirmed for task " + taskId + " — refusing to park an unclaimable intent; the Publish phase fails loudly instead");
1504
+ }
1505
+ return await parkTask("publish: publish-requested " + mergeCommitForPublish + " attempt=1 — Publish intent recorded for task " + taskId + ": the merged change is staged as a checksummed diff (" + publishDiffFile + ", sha256 " + publishDiffSha256 + ") carrying the per-attempt publish version for the crew's publish worker to issue. The worker issues the edit directly and the acknowledgement scan owns the task from there; no action needed.");
1569
1506
  } // end: publishSkippedNoLock — no rebuild, no stamp, nothing to ship
1570
1507
  // STEP 2 (mechanical, always — skip path included): post-deploy
1571
1508
  // commits builder leftovers if any, removes the worktree, and releases
@@ -1947,10 +1884,10 @@ while (i < STEPS.length) {
1947
1884
  } // end: !npmPublishSkipped — a skipped publish has nothing to verify
1948
1885
  }
1949
1886
 
1950
- // Parent-owned verification (docs/publish-verification.md): the parent's
1951
- // scan builds the request via lib/build-readback-request.js and ferries
1952
- // the inspection; a skipped or failed publish has nothing to verify.
1953
- // See docs/decisions/publish-path.md#parent-owned-verification for history.
1887
+ // Version acknowledgement (docs/publish-verification.md): the tick worker
1888
+ // issues the edit directly and the ack scan owns the task from there —
1889
+ // the exact per-attempt version on the artifact's disk is the sole positive
1890
+ // completion criterion. A skipped or failed publish has nothing to ack.
1954
1891
  let summary;
1955
1892
  var workerMarkers = extractMarkerLines(workerText);
1956
1893
  if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget && (publishVerified || npmPublishSkipped)) {
@@ -58,6 +58,29 @@ if (!crewHome) throw new Error("crewHome is required — e.g. ~/workspace/.crew
58
58
  // create projects later via crew-api.js). One without the other is an error.
59
59
  if (!!dashboardSlug !== !!dashboardRepoPath) throw new Error("dashboardSlug and dashboardRepoPath must be provided together, or neither (omit both for a CLI-only install without a dashboard)");
60
60
 
61
+ // ── Cron id override validation (2026-09-20, 0.14.6 REVIEW) ──
62
+ // Uninstall's discovery fallback only recognizes live ids that start with
63
+ // a manifest entry id + "-" (crew-poll-, crew-ack-scan-,
64
+ // crew-update-watch-). An override that breaks that convention would
65
+ // survive uninstall if .cron-registry.json were ever lost — so overrides
66
+ // must keep the manifest-id prefix. Fail closed at init, before any cron
67
+ // is created.
68
+ for (const manifestId of Object.keys(cronIds)) {
69
+ const liveId = cronIds[manifestId];
70
+ if (typeof liveId !== "string" || !liveId.startsWith(manifestId + "-")) {
71
+ return {
72
+ __hatchWorkflowControl: "blocked",
73
+ result: {
74
+ blocked_reason: "Invalid cronIds override",
75
+ message: "cronIds[\"" + manifestId + "\"] must start with \"" + manifestId + "-\" — got " +
76
+ JSON.stringify(liveId) + ". Uninstall's discovery fallback only recognizes live ids under " +
77
+ "the manifest-id prefix; an arbitrary id could survive uninstall if the cron registry " +
78
+ "were lost. Re-run init with a prefixed id."
79
+ }
80
+ };
81
+ }
82
+ }
83
+
61
84
  const orchDir = crewHome + "/.orchestration";
62
85
 
63
86
  // ── Dependency node_modules resolution (clean-room defect 2026-09-16) ──
@@ -158,11 +158,14 @@ try {
158
158
  "Steps:\n" +
159
159
  "1. Registry: if " + sq(crewHomeExpanded + "/.cron-registry.json") + " exists, read it\n" +
160
160
  " and take its crons[].id list as registry candidates.\n" +
161
- "2. Discovery: call cron_list. For every job whose id starts with 'crew-poll-',\n" +
161
+ "2. Discovery: call cron_list. For every job whose id starts with one of\n" +
162
+ " 'crew-poll-', 'crew-ack-scan-', or 'crew-update-watch-' (the three\n" +
163
+ " seed/crons.json manifest entry ids, each suffixed with the instance id),\n" +
162
164
  " call cron_view and keep it as a discovery candidate when the job body\n" +
163
- " contains the crewHome string '" + crewHomeExpanded + "' (the poll body template\n" +
164
- " embeds the crew home path). The bare id 'crew-poll' with no instance suffix\n" +
165
- " is never a live crew job — never remove it.\n" +
165
+ " contains the crewHome string '" + crewHomeExpanded + "' (the cron body templates\n" +
166
+ " embed the crew home path). A bare manifest id ('crew-poll', 'crew-ack-scan',\n" +
167
+ " 'crew-update-watch') with no instance suffix is never a live crew job —\n" +
168
+ " never remove it.\n" +
166
169
  "3. Removal list = registry candidates ∪ discovery candidates, deduped.\n" +
167
170
  " SAFETY: remove only jobs on this list. When in doubt about any job, fail\n" +
168
171
  " closed (passed: false) instead of guessing.\n" +