muse-crew 0.4.2 → 0.4.3

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.
package/lib/AGENTS.md CHANGED
@@ -6,4 +6,5 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
6
6
  - `merge-lock.sh` — serialized merge lock for concurrent agents; records owner PID
7
7
  - `worktree-lifecycle.sh` — git worktree create/cleanup for isolated agent work
8
8
  - `orphan-sweep.sh` — find and clean stale worktrees and merge locks
9
+ - `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
9
10
  - `test-orphan-sweep.sh` — regression tests for orphan-sweep.sh (active-run guard, verified removal, fail-closed)
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bash
2
+ # publish-npm.sh — deterministic npm publish for Muse Crew.
3
+ #
4
+ # Takes the entire publish decision as input; the Publish agent makes none.
5
+ # TARGET_VERSION is authoritative — computed by the workflow, never here.
6
+ # Idempotent on retry/resume: if this task already published the target
7
+ # version (version-bump commit + registry both present), the mutation steps
8
+ # are skipped and the run goes straight to push (idempotent) + finalize.
9
+ #
10
+ # Env:
11
+ # TASK_ID task id (idempotent commit message + merge-lock ops)
12
+ # REPO_PATH repo checkout (Publish runs here, not in a worktree)
13
+ # PKG npm package name (default: muse-crew)
14
+ # TARGET_VERSION version to publish (workflow-computed, authoritative)
15
+ # CREW_HOME crew instance home (accepted for uniformity; unused here)
16
+ # LIFECYCLE pinned worktree-lifecycle.sh
17
+ # RELEASE_SCRIPT crew-release.sh (stable path)
18
+ # NPM_PUBLISH_PY publish helper (default: ~/workspace/skills/npm/bin/npm-publish.py)
19
+ # DRY_RUN=1 print markers and exit before any mutation
20
+ #
21
+ # Output markers (PUBLISH_* lines). On any failure: print
22
+ # PUBLISH_FAILED=<short-reason> plus one detail line, exit 1.
23
+ # On success: print the markers below, exit 0. The final marker is
24
+ # PUBLISH_COMPLETE=<version> — the Publish agent pastes it verbatim into its
25
+ # summary as the script's completion marker.
26
+
27
+ set -euo pipefail
28
+
29
+ PKG="${PKG:-muse-crew}"
30
+ NPM_PUBLISH_PY="${NPM_PUBLISH_PY:-$HOME/workspace/skills/npm/bin/npm-publish.py}"
31
+
32
+ fail() { # fail <reason> [detail]
33
+ echo "PUBLISH_FAILED=$1"
34
+ if [ $# -ge 2 ]; then
35
+ echo "$2"
36
+ fi
37
+ exit 1
38
+ }
39
+
40
+ # 1-2. Validate env, then print the target.
41
+ [ -n "${TASK_ID:-}" ] || fail "env" "TASK_ID is required"
42
+ [ -n "${REPO_PATH:-}" ] || fail "env" "REPO_PATH is required"
43
+ [ -n "${TARGET_VERSION:-}" ] || fail "env" "TARGET_VERSION is required"
44
+ echo "PUBLISH_TARGET=$TARGET_VERSION"
45
+ git -C "$REPO_PATH" rev-parse --git-dir >/dev/null 2>&1 \
46
+ || fail "env" "REPO_PATH is not a git checkout: $REPO_PATH"
47
+
48
+ # 3. Dry run: markers only, no lock, no deploy, no mutation.
49
+ if [ "${DRY_RUN:-0}" = "1" ]; then
50
+ echo "PUBLISH_DRY_RUN=1"
51
+ exit 0
52
+ fi
53
+
54
+ # 4. Idempotency: this task's version-bump commit is HEAD and the registry
55
+ # already serves the target version — a prior Publish finished. Skip the
56
+ # mutation steps; push (idempotent) and finalize still run.
57
+ ALREADY_PUBLISHED=0
58
+ HEAD_MSG="$(git -C "$REPO_PATH" log -1 --format=%s)"
59
+ REG="$(npm view "$PKG" version 2>/dev/null || true)"
60
+ if { [ "$HEAD_MSG" = "release: $PKG@$TARGET_VERSION ($TASK_ID)" ] \
61
+ || [ "$HEAD_MSG" = "release: $PKG@$TARGET_VERSION" ]; } \
62
+ && [ "$REG" = "$TARGET_VERSION" ]; then
63
+ ALREADY_PUBLISHED=1
64
+ echo "PUBLISHED_ALREADY=$TARGET_VERSION"
65
+ echo "PUBLISH_VERIFIED=$TARGET_VERSION"
66
+ fi
67
+
68
+ if [ "$ALREADY_PUBLISHED" = "0" ]; then
69
+ # 5. Refresh the merge lock (held from Integrate through post-deploy).
70
+ LOCK_OUT="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
71
+ || fail "lock-refresh" "$LOCK_OUT"
72
+
73
+ # 6. Install/activate the immutable release.
74
+ REL_OUT="$(bash "${RELEASE_SCRIPT:?RELEASE_SCRIPT is required}" deploy "$REPO_PATH" 2>&1)" \
75
+ || fail "release-deploy" "$REL_OUT"
76
+ case "$REL_OUT" in
77
+ *INSTALLED*ACTIVATED*|*EXISTS*ACTIVATED*) ;;
78
+ *) fail "release-deploy" "unexpected output: $REL_OUT" ;;
79
+ esac
80
+
81
+ # 7. Read the old version (audit only — TARGET_VERSION stays authoritative).
82
+ OLD="$(git -C "$REPO_PATH" show "HEAD:package.json" \
83
+ | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
84
+ || fail "read-base" "could not read version from HEAD:package.json"
85
+ echo "PUBLISH_BASE=$OLD"
86
+
87
+ # 8. Write TARGET_VERSION into package.json (`version` field only),
88
+ # preserving the file's existing formatting (2-space indent + trailing
89
+ # newline). The change must be exactly one line — anything else means the
90
+ # rewrite did not preserve the file.
91
+ PKG_JSON="$REPO_PATH/package.json"
92
+ PKG_JSON="$PKG_JSON" TARGET_VERSION="$TARGET_VERSION" node -e '
93
+ const fs = require("fs");
94
+ const f = process.env.PKG_JSON;
95
+ const j = JSON.parse(fs.readFileSync(f, "utf8"));
96
+ j.version = process.env.TARGET_VERSION;
97
+ fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
98
+ ' || fail "version-write" "node could not write $TARGET_VERSION into package.json"
99
+ echo "PUBLISH_DIFF:"
100
+ git -C "$REPO_PATH" diff -- package.json
101
+ ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
102
+ [ "$ADD_DEL" = "1 1 package.json" ] \
103
+ || fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
104
+
105
+ # 9. Commit only if changed (retried run leaves the commit in place).
106
+ if git -C "$REPO_PATH" diff --quiet -- package.json; then
107
+ echo "PUBLISH_COMMIT_EXISTS"
108
+ else
109
+ git -C "$REPO_PATH" add package.json \
110
+ || fail "commit" "git add package.json failed"
111
+ git -C "$REPO_PATH" commit -m "release: $PKG@$TARGET_VERSION ($TASK_ID)" >/dev/null \
112
+ || fail "commit" "git commit failed"
113
+ echo "PUBLISH_COMMIT=$(git -C "$REPO_PATH" rev-parse HEAD)"
114
+ fi
115
+
116
+ # 10. Pack and sanity-check the tarball's embedded version.
117
+ cd "$REPO_PATH" || fail "pack" "cannot cd to $REPO_PATH"
118
+ npm pack >/dev/null 2>&1 || fail "pack" "npm pack failed"
119
+ TGZ="$REPO_PATH/$PKG-$TARGET_VERSION.tgz"
120
+ [ -f "$TGZ" ] || fail "pack" "expected tarball $TGZ not produced"
121
+ TGZ_VER="$(tar -xzOf "$TGZ" package/package.json \
122
+ | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
123
+ || fail "pack-version-mismatch" "could not read version from tarball $TGZ"
124
+ [ "$TGZ_VER" = "$TARGET_VERSION" ] \
125
+ || fail "pack-version-mismatch" "tarball has $TGZ_VER, expected $TARGET_VERSION"
126
+
127
+ # 11. Publish. "previously published versions" means a retried Publish
128
+ # already landed this version (the merge lock guarantees no other task
129
+ # picked it) — continue. Any other failure is fatal.
130
+ if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
131
+ echo "PUBLISHED=$TARGET_VERSION"
132
+ else
133
+ case "$PUB_OUT" in
134
+ *"previously published versions"*)
135
+ echo "PUBLISHED_ALREADY=$TARGET_VERSION"
136
+ ;;
137
+ *)
138
+ rm -f "$TGZ"
139
+ fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
140
+ ;;
141
+ esac
142
+ fi
143
+ rm -f "$TGZ"
144
+
145
+ # 12. Verify: ground truth is the registry, not any agent's summary.
146
+ REG="$(npm view "$PKG" version 2>/dev/null)" \
147
+ || fail "verify" "npm view $PKG failed"
148
+ [ "$REG" = "$TARGET_VERSION" ] \
149
+ || fail "verify" "registry=$REG"
150
+ echo "PUBLISH_VERIFIED=$TARGET_VERSION"
151
+ fi
152
+
153
+ # 13. Push the version-bump commit (idempotent: no-op if already pushed).
154
+ PUSH_OUT="$(git -C "$REPO_PATH" push origin main 2>&1)" \
155
+ || fail "push" "$PUSH_OUT"
156
+ echo "PUBLISH_PUSHED=1"
157
+
158
+ # 14. Finalize: releases the merge lock and cleans up the worktree.
159
+ FIN_OUT="$(CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" post-deploy "$TASK_ID" 2>&1)" \
160
+ || fail "post-deploy" "$FIN_OUT"
161
+ case "$FIN_OUT" in
162
+ *DEPLOYED*) echo "PUBLISH_FINALIZED=1" ;;
163
+ *) fail "post-deploy" "missing DEPLOYED marker: $FIN_OUT" ;;
164
+ esac
165
+
166
+ # Final marker: the full publish path completed.
167
+ echo "PUBLISH_COMPLETE=$TARGET_VERSION"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -32,6 +32,8 @@ const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
32
32
  const RUN_LIB = "/tmp/crew-lib-" + taskId;
33
33
  const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
34
34
  const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
35
+ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
36
+ const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
35
37
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
36
38
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
37
39
 
@@ -84,6 +86,15 @@ let mapperSpec = "";
84
86
  // rationalized a skip against explicit instruction text — text alone did not
85
87
  // hold, so the decision now lives in workflow code, not agent judgment.
86
88
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
89
+ // Deterministic publish target — computed by the workflow (registry base +
90
+ // bumpVersion), never by the Publish agent.
91
+ let publishTarget = null; // { base, scope, target }
92
+ function bumpVersion(base, scope) {
93
+ var p = String(base).trim().split(".").map(function (x) { return parseInt(x, 10) || 0; });
94
+ if (scope === "major") return (p[0] + 1) + ".0.0";
95
+ if (scope === "minor") return p[0] + "." + (p[1] + 1) + ".0";
96
+ return p[0] + "." + p[1] + "." + (p[2] + 1); // patch (default)
97
+ }
87
98
  function extractReleaseDecision(text) {
88
99
  const t = text || "";
89
100
  const r = /^release:\s*(yes|no)\s*$/im.exec(t);
@@ -101,7 +112,7 @@ let i = startStepIndex;
101
112
  // Pin lifecycle scripts
102
113
  await agent(
103
114
  "Snapshot lifecycle scripts for version pinning.\n" +
104
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + ORPHAN_SWEEP,
115
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP,
105
116
  { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
106
117
  );
107
118
 
@@ -188,6 +199,32 @@ while (i < STEPS.length) {
188
199
  i++;
189
200
  continue;
190
201
  }
202
+
203
+ // Deterministic pre-publish: the workflow reads the registry base and computes
204
+ // the target version. The Publish agent never does version math. Guarded by
205
+ // the enclosing Publish+npm branch: releaseDecision is guaranteed non-null
206
+ // and release !== "no" at this point.
207
+ try {
208
+ var baseResult = await agent(
209
+ "Read the npm registry base version.\n" +
210
+ "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
211
+ "If the output is NOT_FOUND, run: node -p \"require('" + REPO_PATH + "/package.json').version\"\n" +
212
+ "Return JSON { \"base\": \"<the version string, trimmed>\" } and nothing else.",
213
+ { key: "publish-base-" + taskId, label: "Reading registry base version",
214
+ schema: { type: "object", properties: { base: { type: "string" } }, required: ["base"] } }
215
+ );
216
+ var pubScope = releaseDecision.version_bump || "patch";
217
+ var pubBase = (baseResult.base || "").trim();
218
+ if (!/^\d+\.\d+\.\d+$/.test(pubBase)) {
219
+ return { status: "blocked", task_id: taskId,
220
+ reason: "Publish base version unreadable: '" + pubBase + "'. Fail-closed." };
221
+ }
222
+ publishTarget = { base: pubBase, scope: pubScope, target: bumpVersion(pubBase, pubScope) };
223
+ log("Publish target for task " + taskId + ": " + pubBase + " + " + pubScope + " -> " + publishTarget.target);
224
+ } catch (e) {
225
+ return { status: "blocked", task_id: taskId,
226
+ reason: "Deterministic pre-publish failed: " + (e && e.message ? e.message : e) + ". Fail-closed." };
227
+ }
191
228
  }
192
229
 
193
230
  // Claim session
@@ -305,38 +342,24 @@ while (i < STEPS.length) {
305
342
 
306
343
  } else if (step.name === "Publish") {
307
344
  if (PUBLISH_TYPE === "npm") {
308
- // npm packages: immutable release + pack + publish to the registry (push is universal in Integrate)
309
- // The release decision arrived deterministically from the workflow (release: yes)
310
- // these steps are unconditional. There is no decision to make and no skip path.
311
- instructions = "Publish the npm package to the registry.\n\n" +
312
- "The release decision is already made and recorded — it is not yours to make: the accepted Build summary (validated by Review) declares " + releaseDecisionText() + ". Execute every step below in order.\n\n" +
313
- "The repo push already happened in Integrate do NOT push to git in this phase except STEP 7, and NEVER force-push.\n\n" +
314
- "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
315
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
316
- "STEP 1: Install and activate the immutable release.\n" +
317
- "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
318
- "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
319
- "STEP 2: Read the registry base version.\n" +
320
- "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
321
- "If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
322
- "STEP 3: Apply the version_bump scope (" + releaseDecision.version_bump + ") to the base version: patch increments the last segment; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0. Example: base 1.2.3 + minor → 1.3.0. Call the result <new-version>.\n" +
323
- "STEP 3B: Make the version math auditable. State the computed target version explicitly — your summary MUST include the line: TARGET_VERSION=<new-version> computed as <base> + <scope> <new-version> (example: TARGET_VERSION=0.3.1 computed as 0.3.0 + patch → 0.3.1). Every later step uses exactly this version. Do not improvise the arithmetic: 0.3.0 + patch is 0.3.1, never 0.4.0.\n" +
324
- "STEP 4: Write <new-version> into package.json (only the `version` field), then commit it under the still-held merge lock: cd " + REPO_PATH + " && git add package.json && git commit -m \"release: muse-crew@<new-version>\". The lock serializes Publish per repo, so two tasks can never pick the same version.\n" +
325
- "STEP 5: Pack and publish.\n" +
326
- "Run: cd " + REPO_PATH + " && npm pack\n" +
327
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
328
- "If publish fails with 'You cannot publish over the previously published versions', <new-version> is already on the registry (a retried Publish — the merge lock guarantees no other task picked this version): continue to STEP 6 verification. Any other publish failure: set passed to false with the failure details.\n\n" +
329
- "STEP 6: Verify.\n" +
330
- "Run: npm view muse-crew version\n" +
331
- "It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
332
- "STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
333
- "STEP 8: Finalize.\n" +
334
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
335
- "If the output contains DEPLOYED, finalization is complete.\n\n" +
336
- "Do NOT compare the local package.json version to the registry version: with versions assigned at publish time, local==registry is the normal steady state before assignment — not a signal to skip. Execute every step above.\n\n" +
337
- "End your summary with exactly these two lines, in this order — lowercase, no trailing period, do not rephrase (the QA backstop extracts them by pattern):\n" +
338
- "TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version>\n" +
339
- "published: muse-crew@<new-version>\n\n" +
345
+ // Deterministic publish: the workflow computed the target version and the
346
+ // agent runs exactly one command the pinned publish script. The script
347
+ // does lock refresh, release install, version write/commit, pack, registry
348
+ // publish, verify, push, and post-deploy. Text stays minimal by design;
349
+ // decisions live in code, not in agent judgment.
350
+ instructions = "Publish the npm package by running exactly ONE command the deterministic publish script.\n" +
351
+ "The release decision is already made and recorded: release: yes, version_bump: " + publishTarget.scope +
352
+ ", target version " + publishTarget.target + " (computed as " + publishTarget.base + " + " + publishTarget.scope +
353
+ " " + publishTarget.target + " by the workflow, not by you). There is no decision to make and no skip path.\n\n" +
354
+ "Run exactly this command and no other publish-related commands:\n" +
355
+ "TASK_ID=" + taskId + " REPO_PATH=" + REPO_PATH + " PKG=muse-crew TARGET_VERSION=" + publishTarget.target +
356
+ " CREW_HOME=" + crewHome + " LIFECYCLE=" + LIFECYCLE + " RELEASE_SCRIPT=" + RELEASE_SCRIPT +
357
+ " bash " + PUBLISH_NPM + "\n\n" +
358
+ "Do NOT run npm, npm pack, npm publish, or the python publish script yourself. Do NOT compare local and registry versions. Do NOT decide whether to publish.\n\n" +
359
+ "If the command exits nonzero, set passed to false and put the script's PUBLISH_FAILED line in your summary.\n" +
360
+ "If it exits zero, paste the script's COMPLETE marker block verbatim into your summary, then end your summary with exactly these two lines, in this order lowercase, no trailing period, do not rephrase (the QA backstop extracts them by pattern):\n" +
361
+ "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " " + publishTarget.target + "\n" +
362
+ "published: muse-crew@" + publishTarget.target + "\n\n" +
340
363
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
341
364
  "No prose, no markdown, just the JSON object.";
342
365
  } else if (PUBLISH_TYPE === "artifact") {
@@ -466,6 +489,27 @@ while (i < STEPS.length) {
466
489
  const passed = stepResult.passed !== false;
467
490
  const status = passed ? "completed" : "rejected";
468
491
 
492
+ // Deterministic publish verification: the agent cannot self-certify a publish.
493
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
494
+ try {
495
+ var verifyResult = await agent(
496
+ "Run: npm view muse-crew version 2>/dev/null. Return JSON { \"registry_version\": \"<output trimmed>\" } and nothing else.",
497
+ { key: "verify-publish-" + taskId, label: "Verifying published version",
498
+ schema: { type: "object", properties: { registry_version: { type: "string" } }, required: ["registry_version"] } }
499
+ );
500
+ var regVer = (verifyResult.registry_version || "").trim();
501
+ if (regVer !== publishTarget.target) {
502
+ return { status: "blocked", task_id: taskId,
503
+ reason: "Publish verification failed: registry shows " + regVer + " but the release decision required " +
504
+ publishTarget.target + " (" + publishTarget.base + " + " + publishTarget.scope + "). The publish did not land." };
505
+ }
506
+ log("Publish verified for task " + taskId + ": registry at " + regVer);
507
+ } catch (e) {
508
+ return { status: "blocked", task_id: taskId,
509
+ reason: "Publish verification failed: could not read the registry version (" + (e && e.message ? e.message : e) + "). Fail-closed." };
510
+ }
511
+ }
512
+
469
513
  // Capture mapper's spec for Build and Review
470
514
  if (step.name === "Map" && passed) {
471
515
  mapperSpec = summary;
@@ -30,6 +30,8 @@ const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
30
30
  const RUN_LIB = "/tmp/crew-lib-" + taskId;
31
31
  const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
32
32
  const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
33
+ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
34
+ const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
33
35
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
34
36
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
35
37
 
@@ -78,6 +80,15 @@ let mapperSpec = "";
78
80
  // rationalized a skip against explicit instruction text — text alone did not
79
81
  // hold, so the decision now lives in workflow code, not agent judgment.
80
82
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
83
+ // Deterministic publish target — computed by the workflow (registry base +
84
+ // bumpVersion), never by the Publish agent.
85
+ let publishTarget = null; // { base, scope, target }
86
+ function bumpVersion(base, scope) {
87
+ var p = String(base).trim().split(".").map(function (x) { return parseInt(x, 10) || 0; });
88
+ if (scope === "major") return (p[0] + 1) + ".0.0";
89
+ if (scope === "minor") return p[0] + "." + (p[1] + 1) + ".0";
90
+ return p[0] + "." + p[1] + "." + (p[2] + 1); // patch (default)
91
+ }
81
92
  function extractReleaseDecision(text) {
82
93
  const t = text || "";
83
94
  const r = /^release:\s*(yes|no)\s*$/im.exec(t);
@@ -95,7 +106,7 @@ let i = startStepIndex;
95
106
  // Pin lifecycle scripts
96
107
  await agent(
97
108
  "Snapshot lifecycle scripts for version pinning.\n" +
98
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + ORPHAN_SWEEP,
109
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP,
99
110
  { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
100
111
  );
101
112
 
@@ -182,6 +193,32 @@ while (i < STEPS.length) {
182
193
  i++;
183
194
  continue;
184
195
  }
196
+
197
+ // Deterministic pre-publish: the workflow reads the registry base and computes
198
+ // the target version. The Publish agent never does version math. Guarded by
199
+ // the enclosing Publish+npm branch: releaseDecision is guaranteed non-null
200
+ // and release !== "no" at this point.
201
+ try {
202
+ var baseResult = await agent(
203
+ "Read the npm registry base version.\n" +
204
+ "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
205
+ "If the output is NOT_FOUND, run: node -p \"require('" + REPO_PATH + "/package.json').version\"\n" +
206
+ "Return JSON { \"base\": \"<the version string, trimmed>\" } and nothing else.",
207
+ { key: "publish-base-" + taskId, label: "Reading registry base version",
208
+ schema: { type: "object", properties: { base: { type: "string" } }, required: ["base"] } }
209
+ );
210
+ var pubScope = releaseDecision.version_bump || "patch";
211
+ var pubBase = (baseResult.base || "").trim();
212
+ if (!/^\d+\.\d+\.\d+$/.test(pubBase)) {
213
+ return { status: "blocked", task_id: taskId,
214
+ reason: "Publish base version unreadable: '" + pubBase + "'. Fail-closed." };
215
+ }
216
+ publishTarget = { base: pubBase, scope: pubScope, target: bumpVersion(pubBase, pubScope) };
217
+ log("Publish target for task " + taskId + ": " + pubBase + " + " + pubScope + " -> " + publishTarget.target);
218
+ } catch (e) {
219
+ return { status: "blocked", task_id: taskId,
220
+ reason: "Deterministic pre-publish failed: " + (e && e.message ? e.message : e) + ". Fail-closed." };
221
+ }
185
222
  }
186
223
 
187
224
  let activeSessionId;
@@ -287,38 +324,24 @@ while (i < STEPS.length) {
287
324
 
288
325
  } else if (step.name === "Publish") {
289
326
  if (PUBLISH_TYPE === "npm") {
290
- // npm packages: immutable release + pack + publish to the registry (push is universal in Integrate)
291
- // The release decision arrived deterministically from the workflow (release: yes)
292
- // these steps are unconditional. There is no decision to make and no skip path.
293
- instructions = "Publish the npm package to the registry.\n\n" +
294
- "The release decision is already made and recorded — it is not yours to make: the accepted Build summary (validated by Review) declares " + releaseDecisionText() + ". Execute every step below in order.\n\n" +
295
- "The repo push already happened in Integrate do NOT push to git in this phase except STEP 7, and NEVER force-push.\n\n" +
296
- "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
297
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
298
- "STEP 1: Install and activate the immutable release.\n" +
299
- "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
300
- "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
301
- "STEP 2: Read the registry base version.\n" +
302
- "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
303
- "If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
304
- "STEP 3: Apply the version_bump scope (" + releaseDecision.version_bump + ") to the base version: patch increments the last segment; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0. Example: base 1.2.3 + minor → 1.3.0. Call the result <new-version>.\n" +
305
- "STEP 3B: Make the version math auditable. State the computed target version explicitly — your summary MUST include the line: TARGET_VERSION=<new-version> computed as <base> + <scope> <new-version> (example: TARGET_VERSION=0.3.1 computed as 0.3.0 + patch → 0.3.1). Every later step uses exactly this version. Do not improvise the arithmetic: 0.3.0 + patch is 0.3.1, never 0.4.0.\n" +
306
- "STEP 4: Write <new-version> into package.json (only the `version` field), then commit it under the still-held merge lock: cd " + REPO_PATH + " && git add package.json && git commit -m \"release: muse-crew@<new-version>\". The lock serializes Publish per repo, so two tasks can never pick the same version.\n" +
307
- "STEP 5: Pack and publish.\n" +
308
- "Run: cd " + REPO_PATH + " && npm pack\n" +
309
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
310
- "If publish fails with 'You cannot publish over the previously published versions', <new-version> is already on the registry (a retried Publish — the merge lock guarantees no other task picked this version): continue to STEP 6 verification. Any other publish failure: set passed to false with the failure details.\n\n" +
311
- "STEP 6: Verify.\n" +
312
- "Run: npm view muse-crew version\n" +
313
- "It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
314
- "STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
315
- "STEP 8: Finalize.\n" +
316
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
317
- "If the output contains DEPLOYED, finalization is complete.\n\n" +
318
- "Do NOT compare the local package.json version to the registry version: with versions assigned at publish time, local==registry is the normal steady state before assignment — not a signal to skip. Execute every step above.\n\n" +
319
- "End your summary with exactly these two lines, in this order — lowercase, no trailing period, do not rephrase (the QA backstop extracts them by pattern):\n" +
320
- "TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version>\n" +
321
- "published: muse-crew@<new-version>\n\n" +
327
+ // Deterministic publish: the workflow computed the target version and the
328
+ // agent runs exactly one command the pinned publish script. The script
329
+ // does lock refresh, release install, version write/commit, pack, registry
330
+ // publish, verify, push, and post-deploy. Text stays minimal by design;
331
+ // decisions live in code, not in agent judgment.
332
+ instructions = "Publish the npm package by running exactly ONE command the deterministic publish script.\n" +
333
+ "The release decision is already made and recorded: release: yes, version_bump: " + publishTarget.scope +
334
+ ", target version " + publishTarget.target + " (computed as " + publishTarget.base + " + " + publishTarget.scope +
335
+ " " + publishTarget.target + " by the workflow, not by you). There is no decision to make and no skip path.\n\n" +
336
+ "Run exactly this command and no other publish-related commands:\n" +
337
+ "TASK_ID=" + taskId + " REPO_PATH=" + REPO_PATH + " PKG=muse-crew TARGET_VERSION=" + publishTarget.target +
338
+ " CREW_HOME=" + crewHome + " LIFECYCLE=" + LIFECYCLE + " RELEASE_SCRIPT=" + RELEASE_SCRIPT +
339
+ " bash " + PUBLISH_NPM + "\n\n" +
340
+ "Do NOT run npm, npm pack, npm publish, or the python publish script yourself. Do NOT compare local and registry versions. Do NOT decide whether to publish.\n\n" +
341
+ "If the command exits nonzero, set passed to false and put the script's PUBLISH_FAILED line in your summary.\n" +
342
+ "If it exits zero, paste the script's COMPLETE marker block verbatim into your summary, then end your summary with exactly these two lines, in this order lowercase, no trailing period, do not rephrase (the QA backstop extracts them by pattern):\n" +
343
+ "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " " + publishTarget.target + "\n" +
344
+ "published: muse-crew@" + publishTarget.target + "\n\n" +
322
345
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
323
346
  "No prose, no markdown, just the JSON object.";
324
347
  } else if (PUBLISH_TYPE === "artifact") {
@@ -412,6 +435,27 @@ while (i < STEPS.length) {
412
435
  const passed = stepResult.passed !== false;
413
436
  const status = passed ? "completed" : "rejected";
414
437
 
438
+ // Deterministic publish verification: the agent cannot self-certify a publish.
439
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
440
+ try {
441
+ var verifyResult = await agent(
442
+ "Run: npm view muse-crew version 2>/dev/null. Return JSON { \"registry_version\": \"<output trimmed>\" } and nothing else.",
443
+ { key: "verify-publish-" + taskId, label: "Verifying published version",
444
+ schema: { type: "object", properties: { registry_version: { type: "string" } }, required: ["registry_version"] } }
445
+ );
446
+ var regVer = (verifyResult.registry_version || "").trim();
447
+ if (regVer !== publishTarget.target) {
448
+ return { status: "blocked", task_id: taskId,
449
+ reason: "Publish verification failed: registry shows " + regVer + " but the release decision required " +
450
+ publishTarget.target + " (" + publishTarget.base + " + " + publishTarget.scope + "). The publish did not land." };
451
+ }
452
+ log("Publish verified for task " + taskId + ": registry at " + regVer);
453
+ } catch (e) {
454
+ return { status: "blocked", task_id: taskId,
455
+ reason: "Publish verification failed: could not read the registry version (" + (e && e.message ? e.message : e) + "). Fail-closed." };
456
+ }
457
+ }
458
+
415
459
  // Capture mapper's spec for Build and Review
416
460
  if (step.name === "Map" && passed) {
417
461
  mapperSpec = summary;
@@ -32,6 +32,8 @@ const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
32
32
  const RUN_LIB = "/tmp/crew-lib-" + taskId;
33
33
  const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
34
34
  const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
35
+ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
36
+ const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
35
37
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
36
38
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
37
39
 
@@ -83,6 +85,15 @@ let mapperSpec = "";
83
85
  // rationalized a skip against explicit instruction text — text alone did not
84
86
  // hold, so the decision now lives in workflow code, not agent judgment.
85
87
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
88
+ // Deterministic publish target — computed by the workflow (registry base +
89
+ // bumpVersion), never by the Publish agent.
90
+ let publishTarget = null; // { base, scope, target }
91
+ function bumpVersion(base, scope) {
92
+ var p = String(base).trim().split(".").map(function (x) { return parseInt(x, 10) || 0; });
93
+ if (scope === "major") return (p[0] + 1) + ".0.0";
94
+ if (scope === "minor") return p[0] + "." + (p[1] + 1) + ".0";
95
+ return p[0] + "." + p[1] + "." + (p[2] + 1); // patch (default)
96
+ }
86
97
  function extractReleaseDecision(text) {
87
98
  const t = text || "";
88
99
  const r = /^release:\s*(yes|no)\s*$/im.exec(t);
@@ -106,8 +117,9 @@ await agent(
106
117
  " mkdir -p " + RUN_LIB + "\n" +
107
118
  " cp " + LIFECYCLE_SRC + " " + LIFECYCLE + "\n" +
108
119
  " cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + "\n" +
120
+ " cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + "\n" +
109
121
  " cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + "\n" +
110
- " chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + ORPHAN_SWEEP + "\n" +
122
+ " chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + "\n" +
111
123
  "Confirm the files exist by listing " + RUN_LIB + ".",
112
124
  { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
113
125
  );
@@ -196,6 +208,32 @@ while (i < STEPS.length) {
196
208
  i++;
197
209
  continue;
198
210
  }
211
+
212
+ // Deterministic pre-publish: the workflow reads the registry base and computes
213
+ // the target version. The Publish agent never does version math. Guarded by
214
+ // the enclosing Publish+npm branch: releaseDecision is guaranteed non-null
215
+ // and release !== "no" at this point.
216
+ try {
217
+ var baseResult = await agent(
218
+ "Read the npm registry base version.\n" +
219
+ "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
220
+ "If the output is NOT_FOUND, run: node -p \"require('" + REPO_PATH + "/package.json').version\"\n" +
221
+ "Return JSON { \"base\": \"<the version string, trimmed>\" } and nothing else.",
222
+ { key: "publish-base-" + taskId, label: "Reading registry base version",
223
+ schema: { type: "object", properties: { base: { type: "string" } }, required: ["base"] } }
224
+ );
225
+ var pubScope = releaseDecision.version_bump || "patch";
226
+ var pubBase = (baseResult.base || "").trim();
227
+ if (!/^\d+\.\d+\.\d+$/.test(pubBase)) {
228
+ return { status: "blocked", task_id: taskId,
229
+ reason: "Publish base version unreadable: '" + pubBase + "'. Fail-closed." };
230
+ }
231
+ publishTarget = { base: pubBase, scope: pubScope, target: bumpVersion(pubBase, pubScope) };
232
+ log("Publish target for task " + taskId + ": " + pubBase + " + " + pubScope + " -> " + publishTarget.target);
233
+ } catch (e) {
234
+ return { status: "blocked", task_id: taskId,
235
+ reason: "Deterministic pre-publish failed: " + (e && e.message ? e.message : e) + ". Fail-closed." };
236
+ }
199
237
  }
200
238
 
201
239
  // Claim session — reuse dispatcher's session for the very first step
@@ -303,38 +341,24 @@ while (i < STEPS.length) {
303
341
 
304
342
  } else if (step.name === "Publish") {
305
343
  if (PUBLISH_TYPE === "npm") {
306
- // npm packages: immutable release + pack + publish to the registry (push is universal in Integrate)
307
- // The release decision arrived deterministically from the workflow (release: yes)
308
- // these steps are unconditional. There is no decision to make and no skip path.
309
- instructions = "Publish the npm package to the registry.\n\n" +
310
- "The release decision is already made and recorded — it is not yours to make: the accepted Build summary (validated by Review) declares " + releaseDecisionText() + ". Execute every step below in order.\n\n" +
311
- "The repo push already happened in Integrate do NOT push to git in this phase except STEP 7, and NEVER force-push.\n\n" +
312
- "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
313
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
314
- "STEP 1: Install and activate the immutable release.\n" +
315
- "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
316
- "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
317
- "STEP 2: Read the registry base version.\n" +
318
- "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
319
- "If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
320
- "STEP 3: Apply the version_bump scope (" + releaseDecision.version_bump + ") to the base version: patch increments the last segment; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0. Example: base 1.2.3 + minor → 1.3.0. Call the result <new-version>.\n" +
321
- "STEP 3B: Make the version math auditable. State the computed target version explicitly — your summary MUST include the line: TARGET_VERSION=<new-version> computed as <base> + <scope> <new-version> (example: TARGET_VERSION=0.3.1 computed as 0.3.0 + patch → 0.3.1). Every later step uses exactly this version. Do not improvise the arithmetic: 0.3.0 + patch is 0.3.1, never 0.4.0.\n" +
322
- "STEP 4: Write <new-version> into package.json (only the `version` field), then commit it under the still-held merge lock: cd " + REPO_PATH + " && git add package.json && git commit -m \"release: muse-crew@<new-version>\". The lock serializes Publish per repo, so two tasks can never pick the same version.\n" +
323
- "STEP 5: Pack and publish.\n" +
324
- "Run: cd " + REPO_PATH + " && npm pack\n" +
325
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
326
- "If publish fails with 'You cannot publish over the previously published versions', <new-version> is already on the registry (a retried Publish — the merge lock guarantees no other task picked this version): continue to STEP 6 verification. Any other publish failure: set passed to false with the failure details.\n\n" +
327
- "STEP 6: Verify.\n" +
328
- "Run: npm view muse-crew version\n" +
329
- "It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
330
- "STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
331
- "STEP 8: Finalize.\n" +
332
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
333
- "If the output contains DEPLOYED, finalization is complete.\n\n" +
334
- "Do NOT compare the local package.json version to the registry version: with versions assigned at publish time, local==registry is the normal steady state before assignment — not a signal to skip. Execute every step above.\n\n" +
335
- "End your summary with exactly these two lines, in this order — lowercase, no trailing period, do not rephrase (the QA backstop extracts them by pattern):\n" +
336
- "TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version>\n" +
337
- "published: muse-crew@<new-version>\n\n" +
344
+ // Deterministic publish: the workflow computed the target version and the
345
+ // agent runs exactly one command the pinned publish script. The script
346
+ // does lock refresh, release install, version write/commit, pack, registry
347
+ // publish, verify, push, and post-deploy. Text stays minimal by design;
348
+ // decisions live in code, not in agent judgment.
349
+ instructions = "Publish the npm package by running exactly ONE command the deterministic publish script.\n" +
350
+ "The release decision is already made and recorded: release: yes, version_bump: " + publishTarget.scope +
351
+ ", target version " + publishTarget.target + " (computed as " + publishTarget.base + " + " + publishTarget.scope +
352
+ " " + publishTarget.target + " by the workflow, not by you). There is no decision to make and no skip path.\n\n" +
353
+ "Run exactly this command and no other publish-related commands:\n" +
354
+ "TASK_ID=" + taskId + " REPO_PATH=" + REPO_PATH + " PKG=muse-crew TARGET_VERSION=" + publishTarget.target +
355
+ " CREW_HOME=" + crewHome + " LIFECYCLE=" + LIFECYCLE + " RELEASE_SCRIPT=" + RELEASE_SCRIPT +
356
+ " bash " + PUBLISH_NPM + "\n\n" +
357
+ "Do NOT run npm, npm pack, npm publish, or the python publish script yourself. Do NOT compare local and registry versions. Do NOT decide whether to publish.\n\n" +
358
+ "If the command exits nonzero, set passed to false and put the script's PUBLISH_FAILED line in your summary.\n" +
359
+ "If it exits zero, paste the script's COMPLETE marker block verbatim into your summary, then end your summary with exactly these two lines, in this order lowercase, no trailing period, do not rephrase (the QA backstop extracts them by pattern):\n" +
360
+ "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " " + publishTarget.target + "\n" +
361
+ "published: muse-crew@" + publishTarget.target + "\n\n" +
338
362
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
339
363
  "No prose, no markdown, just the JSON object.";
340
364
  } else if (PUBLISH_TYPE === "artifact") {
@@ -477,6 +501,27 @@ while (i < STEPS.length) {
477
501
  const passed = stepResult.passed !== false;
478
502
  const status = passed ? "completed" : "rejected";
479
503
 
504
+ // Deterministic publish verification: the agent cannot self-certify a publish.
505
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
506
+ try {
507
+ var verifyResult = await agent(
508
+ "Run: npm view muse-crew version 2>/dev/null. Return JSON { \"registry_version\": \"<output trimmed>\" } and nothing else.",
509
+ { key: "verify-publish-" + taskId, label: "Verifying published version",
510
+ schema: { type: "object", properties: { registry_version: { type: "string" } }, required: ["registry_version"] } }
511
+ );
512
+ var regVer = (verifyResult.registry_version || "").trim();
513
+ if (regVer !== publishTarget.target) {
514
+ return { status: "blocked", task_id: taskId,
515
+ reason: "Publish verification failed: registry shows " + regVer + " but the release decision required " +
516
+ publishTarget.target + " (" + publishTarget.base + " + " + publishTarget.scope + "). The publish did not land." };
517
+ }
518
+ log("Publish verified for task " + taskId + ": registry at " + regVer);
519
+ } catch (e) {
520
+ return { status: "blocked", task_id: taskId,
521
+ reason: "Publish verification failed: could not read the registry version (" + (e && e.message ? e.message : e) + "). Fail-closed." };
522
+ }
523
+ }
524
+
480
525
  // Capture mapper's spec for Build and Review
481
526
  if (step.name === "Map" && passed) {
482
527
  mapperSpec = summary;