muse-crew 0.4.1 → 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.1",
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,35 +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 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" +
324
- "STEP 5: Pack and publish.\n" +
325
- "Run: cd " + REPO_PATH + " && npm pack\n" +
326
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
327
- "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" +
328
- "STEP 6: Verify.\n" +
329
- "Run: npm view muse-crew version\n" +
330
- "It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
331
- "STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
332
- "STEP 8: Finalize.\n" +
333
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
334
- "If the output contains DEPLOYED, finalization is complete.\n\n" +
335
- "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" +
336
- "End your summary with exactly this line: 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" +
337
363
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
338
364
  "No prose, no markdown, just the JSON object.";
339
365
  } else if (PUBLISH_TYPE === "artifact") {
@@ -374,7 +400,10 @@ while (i < STEPS.length) {
374
400
  // declared release: yes, QA verifies the registry actually moved. A silent
375
401
  // publish skip becomes a loud QA failure with evidence, not a pass.
376
402
  var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
377
- ? "NPM PUBLISH CHECK: the accepted Build summary declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and extract its `published: muse-crew@<version>` line from the session notes (the Publish agent ends its summary with exactly that line — event history does NOT carry it). Then run: npm view muse-crew version. The registry version MUST equal the published version. If there is no completed Publish session with a published: line, or the registry version does not match, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: [details]\" }.\n"
403
+ ? "NPM PUBLISH CHECK: the accepted Build summary declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
404
+ "Extract the line matching TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version>. If the line is missing, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: Publish summary did not echo its computed target version (STEP 3B)\" }.\n" +
405
+ "Verify the bump SCOPE: the <scope> in that line MUST equal the accepted version_bump scope \"" + releaseDecision.version_bump + "\" — if the Publish agent applied a different scope, FAIL. Verify the ARITHMETIC: <base> + <scope> must equal <new-version> (patch increments the last segment only, e.g. 0.3.0 + patch → 0.3.1; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0) — if the math is wrong, FAIL.\n" +
406
+ "Extract the published version from the notes line matching published: muse-crew@<version> (match case-insensitively and ignore any trailing period — agents sometimes rephrase it). It MUST equal <new-version> from the TARGET_VERSION line. Then run: npm view muse-crew version. The registry version MUST equal <new-version>. If any of these checks fails, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: [details]\" }.\n"
378
407
  : "";
379
408
  instructions = "Final QA testing. You are CODE-BLIND — do NOT read source code.\n" +
380
409
  "Public docs (API.md, README, published action schemas) are NOT source code — read them freely, exactly as a user would.\n" +
@@ -460,6 +489,27 @@ while (i < STEPS.length) {
460
489
  const passed = stepResult.passed !== false;
461
490
  const status = passed ? "completed" : "rejected";
462
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
+
463
513
  // Capture mapper's spec for Build and Review
464
514
  if (step.name === "Map" && passed) {
465
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,35 +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 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" +
306
- "STEP 5: Pack and publish.\n" +
307
- "Run: cd " + REPO_PATH + " && npm pack\n" +
308
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
309
- "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" +
310
- "STEP 6: Verify.\n" +
311
- "Run: npm view muse-crew version\n" +
312
- "It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
313
- "STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
314
- "STEP 8: Finalize.\n" +
315
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
316
- "If the output contains DEPLOYED, finalization is complete.\n\n" +
317
- "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" +
318
- "End your summary with exactly this line: 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" +
319
345
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
320
346
  "No prose, no markdown, just the JSON object.";
321
347
  } else if (PUBLISH_TYPE === "artifact") {
@@ -409,6 +435,27 @@ while (i < STEPS.length) {
409
435
  const passed = stepResult.passed !== false;
410
436
  const status = passed ? "completed" : "rejected";
411
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
+
412
459
  // Capture mapper's spec for Build and Review
413
460
  if (step.name === "Map" && passed) {
414
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,35 +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 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" +
322
- "STEP 5: Pack and publish.\n" +
323
- "Run: cd " + REPO_PATH + " && npm pack\n" +
324
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
325
- "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" +
326
- "STEP 6: Verify.\n" +
327
- "Run: npm view muse-crew version\n" +
328
- "It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
329
- "STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
330
- "STEP 8: Finalize.\n" +
331
- "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
332
- "If the output contains DEPLOYED, finalization is complete.\n\n" +
333
- "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" +
334
- "End your summary with exactly this line: 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" +
335
362
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
336
363
  "No prose, no markdown, just the JSON object.";
337
364
  } else if (PUBLISH_TYPE === "artifact") {
@@ -372,7 +399,10 @@ while (i < STEPS.length) {
372
399
  // declared release: yes, QA verifies the registry actually moved. A silent
373
400
  // publish skip becomes a loud QA failure with evidence, not a pass.
374
401
  var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
375
- ? "NPM PUBLISH CHECK: the accepted Build summary declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and extract its `published: muse-crew@<version>` line from the session notes (the Publish agent ends its summary with exactly that line — event history does NOT carry it). Then run: npm view muse-crew version. The registry version MUST equal the published version. If there is no completed Publish session with a published: line, or the registry version does not match, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: [details]\" }.\n"
402
+ ? "NPM PUBLISH CHECK: the accepted Build summary declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
403
+ "Extract the line matching TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version>. If the line is missing, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: Publish summary did not echo its computed target version (STEP 3B)\" }.\n" +
404
+ "Verify the bump SCOPE: the <scope> in that line MUST equal the accepted version_bump scope \"" + releaseDecision.version_bump + "\" — if the Publish agent applied a different scope, FAIL. Verify the ARITHMETIC: <base> + <scope> must equal <new-version> (patch increments the last segment only, e.g. 0.3.0 + patch → 0.3.1; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0) — if the math is wrong, FAIL.\n" +
405
+ "Extract the published version from the notes line matching published: muse-crew@<version> (match case-insensitively and ignore any trailing period — agents sometimes rephrase it). It MUST equal <new-version> from the TARGET_VERSION line. Then run: npm view muse-crew version. The registry version MUST equal <new-version>. If any of these checks fails, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: [details]\" }.\n"
376
406
  : "";
377
407
  if (PUBLISH_TYPE === "artifact") {
378
408
  var safeDesc = taskDescription.replace(/"/g, "'").replace(/\\/g, "\\\\").slice(0, 500);
@@ -471,6 +501,27 @@ while (i < STEPS.length) {
471
501
  const passed = stepResult.passed !== false;
472
502
  const status = passed ? "completed" : "rejected";
473
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
+
474
525
  // Capture mapper's spec for Build and Review
475
526
  if (step.name === "Map" && passed) {
476
527
  mapperSpec = summary;