muse-crew 0.4.0 → 0.4.2

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/docs/guide.md CHANGED
@@ -18,7 +18,7 @@ This is the full setup and operations reference. If you're new, start with the [
18
18
  - An **`.orchestration/` directory** in the crew home with identities, personas, workflow docs, and feedback conventions.
19
19
  - A **project registration** — the task service registered as its own first project.
20
20
  - A **polling loop** — checks for work every 3 minutes via Muse's scheduling, even when nobody's in the conversation.
21
- - An **orphan sweep** — runs every 30 minutes to clean merged worktrees and break stale merge locks.
21
+ - An **orphan sweep** — runs every 30 minutes to clean merged worktrees and break stale merge locks. It never touches worktrees belonging to tasks with a running dashboard session.
22
22
 
23
23
  The agent running in the main chat receives the dispatcher's claims and launches each task workflow. Workflows can't launch workflows, so this handoff is structural.
24
24
 
package/lib/AGENTS.md CHANGED
@@ -6,3 +6,4 @@ 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
+ - `test-orphan-sweep.sh` — regression tests for orphan-sweep.sh (active-run guard, verified removal, fail-closed)
@@ -3,9 +3,24 @@
3
3
  #
4
4
  # Usage:
5
5
  # orphan-sweep.sh report — list orphans (read-only for worktrees);
6
- # force-releases stale locks with dead PIDs
6
+ # stale locks with dead PIDs are released
7
7
  # orphan-sweep.sh clean — remove safe-to-clean orphans (merged branches only)
8
- # and force-release stale merge locks (>30 min)
8
+ # and release stale merge locks
9
+ #
10
+ # Active-run knowledge is injected by the caller, never fetched here:
11
+ # CREW_ACTIVE_TASKS — space-separated task IDs with a running session
12
+ # CREW_ACTIVE_TASKS_FILE — path to a file with one task ID per line
13
+ # (blank lines and '#' comments are ignored)
14
+ # Both inputs feed one active set, matched exactly against worktree dir names.
15
+ # A task in the active set is never touched: its worktree and any merge lock
16
+ # it holds are skipped regardless of merge status, lock age, or PID liveness.
17
+ #
18
+ # Fail closed: clean mode without either input refuses to remove anything
19
+ # and exits 2. Report mode without either input still runs read-only but
20
+ # prints a banner noting the ACTIVE checks were skipped.
21
+ #
22
+ # Callers (the sweep cron body; worktree-lifecycle.sh cmd_sweep is a
23
+ # passthrough) must export CREW_ACTIVE_TASKS.
9
24
  #
10
25
  # "Safe to clean" means the task branch is fully merged into main.
11
26
  # Dirty or unmerged worktrees are always preserved and reported.
@@ -19,33 +34,74 @@ STALE_LOCK_MIN=30
19
34
 
20
35
  cmd="${1:-report}"
21
36
  found=0
37
+ any_failed=0
38
+
39
+ # --- Active-run set (union of both inputs) ---
40
+ have_active_data=0
41
+ ACTIVE_TASKS=""
42
+ if [ -n "${CREW_ACTIVE_TASKS+set}" ]; then
43
+ have_active_data=1
44
+ ACTIVE_TASKS="${ACTIVE_TASKS} ${CREW_ACTIVE_TASKS}"
45
+ fi
46
+ if [ -n "${CREW_ACTIVE_TASKS_FILE+set}" ]; then
47
+ if [ -r "${CREW_ACTIVE_TASKS_FILE}" ]; then
48
+ have_active_data=1
49
+ while IFS= read -r line || [ -n "$line" ]; do
50
+ case "$line" in ''|\#*) continue ;; esac
51
+ ACTIVE_TASKS="${ACTIVE_TASKS} ${line}"
52
+ done < "${CREW_ACTIVE_TASKS_FILE}"
53
+ fi
54
+ fi
55
+
56
+ task_is_active() {
57
+ [ -n "${1:-}" ] || return 1
58
+ case " ${ACTIVE_TASKS} " in
59
+ *" $1 "*) return 0 ;;
60
+ *) return 1 ;;
61
+ esac
62
+ }
63
+
64
+ if [ "$cmd" = "clean" ] && [ "$have_active_data" -eq 0 ]; then
65
+ echo "BLOCKED: clean mode requires the active-run list (set CREW_ACTIVE_TASKS or CREW_ACTIVE_TASKS_FILE) — refusing to remove anything"
66
+ exit 2
67
+ fi
68
+
69
+ if [ "$cmd" != "clean" ] && [ "$have_active_data" -eq 0 ]; then
70
+ echo "NOTE: no active-run data provided; ACTIVE checks skipped"
71
+ fi
22
72
 
23
73
  # --- Stale merge lock ---
24
74
  if [ -f "$LOCK_FILE" ]; then
25
75
  lock_holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
26
- lock_time=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "")
27
- lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "")
28
- if [ -n "$lock_time" ]; then
29
- lock_epoch=$(date -d "$lock_time" +%s 2>/dev/null || echo 0)
30
- now_epoch=$(date -u +%s)
31
- age_min=$(( (now_epoch - lock_epoch) / 60 ))
32
- if [ "$age_min" -gt "$STALE_LOCK_MIN" ]; then
33
- # Check if owner process is still alive
34
- pid_alive=0
35
- if [ -n "$lock_pid" ] && [ "$lock_pid" != "-" ] && kill -0 "$lock_pid" 2>/dev/null; then
36
- pid_alive=1
37
- fi
76
+ if task_is_active "$lock_holder"; then
77
+ echo "ACTIVE_LOCK: held by $lock_holder (active run on dashboard) — skipping"
78
+ found=1
79
+ else
80
+ lock_time=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "")
81
+ lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "")
82
+ if [ -n "$lock_time" ]; then
83
+ lock_epoch=$(date -d "$lock_time" +%s 2>/dev/null || echo 0)
84
+ now_epoch=$(date -u +%s)
85
+ age_min=$(( (now_epoch - lock_epoch) / 60 ))
86
+ if [ "$age_min" -gt "$STALE_LOCK_MIN" ]; then
87
+ # Check if owner process is still alive
88
+ pid_alive=0
89
+ if [ -n "$lock_pid" ] && [ "$lock_pid" != "-" ] && kill -0 "$lock_pid" 2>/dev/null; then
90
+ pid_alive=1
91
+ fi
38
92
 
39
- if [ "$pid_alive" -eq 1 ]; then
40
- echo "ACTIVE_LOCK: held ${age_min}m by $lock_holder (pid $lock_pid alive) — skipping"
41
- else
42
- echo "STALE_LOCK: held ${age_min}m by $lock_holder (pid ${lock_pid:-none} dead) — threshold ${STALE_LOCK_MIN}m"
43
- found=1
44
- rm -f "$LOCK_FILE"
45
- if [ ! -f "$LOCK_FILE" ]; then
46
- echo " → released (dead PID)"
93
+ if [ "$pid_alive" -eq 1 ]; then
94
+ echo "ACTIVE_LOCK: held ${age_min}m by $lock_holder (pid $lock_pid alive) — skipping"
47
95
  else
48
- echo " → ERROR: lock file still exists after rm at $LOCK_FILE"
96
+ echo "STALE_LOCK: held ${age_min}m by $lock_holder (pid ${lock_pid:-none} dead) — threshold ${STALE_LOCK_MIN}m"
97
+ found=1
98
+ rm -f "$LOCK_FILE"
99
+ if [ ! -f "$LOCK_FILE" ]; then
100
+ echo " → released (dead PID)"
101
+ else
102
+ echo " → FAILED: lock file still present after rm"
103
+ any_failed=1
104
+ fi
49
105
  fi
50
106
  fi
51
107
  fi
@@ -67,19 +123,31 @@ if [ -d "$WORKTREE_DIR" ]; then
67
123
 
68
124
  branch="task/$task_id"
69
125
 
126
+ found=1
127
+
128
+ # Active runs are never touched, regardless of merge status.
129
+ if task_is_active "$task_id"; then
130
+ echo "ACTIVE: $task_id — dashboard shows a running session, skipped"
131
+ continue
132
+ fi
133
+
70
134
  # Is the branch merged into main?
71
135
  merged=$(git branch --merged main 2>/dev/null | sed 's/^[* +]*//' | grep -Fx "$branch" || true)
72
136
 
73
137
  # Is the worktree dirty?
74
138
  dirty=$(cd "$wt" && git status --porcelain 2>/dev/null | wc -l)
75
139
 
76
- found=1
77
140
  if [ -n "$merged" ]; then
78
141
  echo "MERGED: $task_id — branch merged into main, safe to remove"
79
142
  if [ "$cmd" = "clean" ]; then
80
143
  git worktree remove "$wt" 2>/dev/null || true
81
144
  git branch -d "$branch" 2>/dev/null || true
82
- echo " removed"
145
+ if [ ! -d "$wt" ] && ! git show-ref --verify --quiet "refs/heads/$branch"; then
146
+ echo " → removed"
147
+ else
148
+ echo " → FAILED: removal attempted but worktree/branch still present, left in place"
149
+ any_failed=1
150
+ fi
83
151
  fi
84
152
  elif [ "$dirty" -gt 0 ]; then
85
153
  echo "DIRTY: $task_id — $dirty uncommitted changes, preserved"
@@ -92,3 +160,8 @@ fi
92
160
  if [ "$found" -eq 0 ]; then
93
161
  echo "CLEAN: no orphans, no stale locks"
94
162
  fi
163
+
164
+ if [ "$any_failed" -eq 1 ]; then
165
+ exit 1
166
+ fi
167
+ exit 0
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bash
2
+ # test-orphan-sweep.sh — regression tests for orphan-sweep.sh
3
+ #
4
+ # Self-contained: builds throwaway git fixtures in a temp dir and drives
5
+ # lib/orphan-sweep.sh through the CREW_ACTIVE_TASKS / CREW_ACTIVE_TASKS_FILE
6
+ # seam (no network, no dashboard). Exits 0 only if every case passes.
7
+
8
+ set -uo pipefail
9
+
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ SWEEP="$SCRIPT_DIR/orphan-sweep.sh"
12
+
13
+ pass=0
14
+ fail=0
15
+ ok() { echo "PASS: $1"; pass=$((pass + 1)); }
16
+ no() { echo "FAIL: $1"; fail=$((fail + 1)); }
17
+
18
+ TMPBASE="$(mktemp -d)"
19
+ trap 'rm -rf "$TMPBASE"' EXIT
20
+
21
+ # new_fixture <name> — fresh git repo, one commit on main; exports CREW_REPO.
22
+ new_fixture() {
23
+ local dir="$TMPBASE/$1"
24
+ mkdir -p "$dir"
25
+ git init -q -b main "$dir" >/dev/null
26
+ git -C "$dir" config user.email "test@example.com"
27
+ git -C "$dir" config user.name "test"
28
+ echo base > "$dir/seed.txt"
29
+ git -C "$dir" add seed.txt
30
+ git -C "$dir" commit -qm "initial"
31
+ export CREW_REPO="$dir"
32
+ cd "$dir" || exit 1
33
+ }
34
+
35
+ # merged_worktree <task_id> — worktree whose branch gets a real merge commit.
36
+ merged_worktree() {
37
+ local t="$1"
38
+ git worktree add -q ".worktrees/$t" -b "task/$t" >/dev/null
39
+ echo "$t" > ".worktrees/$t/work.txt"
40
+ git -C ".worktrees/$t" add work.txt
41
+ git -C ".worktrees/$t" commit -qm "work for $t"
42
+ git merge -q --no-ff "task/$t" -m "merge $t" >/dev/null
43
+ }
44
+
45
+ # --- Case 1: live run protected (the incident's exact case) ---
46
+ # T1's branch tip equals main (no commits — "merged" by equality), clean tree.
47
+ new_fixture case1
48
+ git worktree add -q .worktrees/T1 -b task/T1 >/dev/null
49
+ out=$(CREW_ACTIVE_TASKS="T1" "$SWEEP" clean 2>&1); code=$?
50
+ [ "$code" -eq 0 ] && ok "1: clean exits 0 with only an active run present" || no "1: exit $code, want 0"
51
+ [ -d .worktrees/T1 ] && ok "1: active worktree dir preserved" || no "1: active worktree dir removed"
52
+ git show-ref --verify --quiet refs/heads/task/T1 && ok "1: active branch preserved" || no "1: active branch deleted"
53
+ echo "$out" | grep -q "ACTIVE: T1" && ok "1: ACTIVE line printed" || no "1: no ACTIVE line in output"
54
+
55
+ # --- Case 2: dead run cleaned ---
56
+ new_fixture case2
57
+ merged_worktree T2
58
+ out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
59
+ [ "$code" -eq 0 ] && ok "2: clean exits 0" || no "2: exit $code, want 0"
60
+ [ ! -d .worktrees/T2 ] && ok "2: merged worktree dir removed" || no "2: merged worktree dir still present"
61
+ git show-ref --verify --quiet refs/heads/task/T2 && no "2: merged branch still present" || ok "2: merged branch deleted"
62
+ echo "$out" | grep -q "→ removed" && ok "2: removal reported" || no "2: no removal line in output"
63
+
64
+ # --- Case 3: fail closed ---
65
+ new_fixture case3
66
+ merged_worktree T3
67
+ out=$(env -u CREW_ACTIVE_TASKS -u CREW_ACTIVE_TASKS_FILE "$SWEEP" clean 2>&1); code=$?
68
+ [ "$code" -eq 2 ] && ok "3: clean without active data exits 2" || no "3: exit $code, want 2"
69
+ echo "$out" | grep -q "BLOCKED" && ok "3: BLOCKED line printed" || no "3: no BLOCKED line in output"
70
+ [ -d .worktrees/T3 ] && ok "3: worktree left alone (dir)" || no "3: worktree removed despite BLOCKED"
71
+ git show-ref --verify --quiet refs/heads/task/T3 && ok "3: worktree left alone (branch)" || no "3: branch deleted despite BLOCKED"
72
+
73
+ # --- Case 4: false report impossible (forced removal failure via worktree lock) ---
74
+ new_fixture case4
75
+ merged_worktree T4
76
+ git worktree lock .worktrees/T4
77
+ out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
78
+ [ "$code" -eq 1 ] && ok "4: clean with failed removal exits 1" || no "4: exit $code, want 1"
79
+ echo "$out" | grep -q "→ FAILED" && ok "4: FAILED line printed (no false 'removed')" || no "4: no FAILED line — false report!"
80
+ [ -d .worktrees/T4 ] && ok "4: failed worktree left in place" || no "4: worktree gone despite failed removal"
81
+ git worktree unlock .worktrees/T4 2>/dev/null || true
82
+
83
+ # --- Case 5: lock path ---
84
+ new_fixture case5
85
+ mkdir -p .worktrees
86
+ printf 'T5 2020-01-01T00:00:00Z 999999\n' > .worktrees/.merge-lock
87
+ out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
88
+ [ ! -f .worktrees/.merge-lock ] && ok "5a: stale lock released" || no "5a: stale lock file still present"
89
+ echo "$out" | grep -q "released (dead PID)" && ok "5a: release reported" || no "5a: no release line in output"
90
+ [ "$code" -eq 0 ] && ok "5a: exit 0" || no "5a: exit $code, want 0"
91
+
92
+ printf 'T6 2020-01-01T00:00:00Z 999999\n' > .worktrees/.merge-lock
93
+ out=$(CREW_ACTIVE_TASKS="T6" "$SWEEP" clean 2>&1); code=$?
94
+ [ -f .worktrees/.merge-lock ] && ok "5b: active holder's lock not released" || no "5b: active holder's lock was released"
95
+ echo "$out" | grep -q "ACTIVE_LOCK" && ok "5b: ACTIVE_LOCK line printed" || no "5b: no ACTIVE_LOCK line in output"
96
+ [ "$code" -eq 0 ] && ok "5b: exit 0" || no "5b: exit $code, want 0"
97
+
98
+ # --- Case 6: report mode without active data ---
99
+ new_fixture case6
100
+ merged_worktree T7
101
+ out=$(env -u CREW_ACTIVE_TASKS -u CREW_ACTIVE_TASKS_FILE "$SWEEP" report 2>&1); code=$?
102
+ [ "$code" -eq 0 ] && ok "6: report exits 0" || no "6: exit $code, want 0"
103
+ echo "$out" | grep -q "NOTE: no active-run data provided; ACTIVE checks skipped" && ok "6: NOTE banner printed" || no "6: no NOTE banner in output"
104
+ [ -d .worktrees/T7 ] && ok "6: report mode removes nothing" || no "6: report mode removed a worktree"
105
+ echo "$out" | grep -q "MERGED: T7" && ok "6: MERGED line still listed" || no "6: MERGED line missing from report"
106
+
107
+ echo ""
108
+ echo "== $pass passed, $fail failed =="
109
+ [ "$fail" -eq 0 ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.4.0",
4
- "description": "Opinionated orchestration for Muse workflows, identities, and tooling for autonomous software development.",
3
+ "version": "0.4.2",
4
+ "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "repository": {
@@ -320,6 +320,7 @@ while (i < STEPS.length) {
320
320
  "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
321
321
  "If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
322
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" +
323
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" +
324
325
  "STEP 5: Pack and publish.\n" +
325
326
  "Run: cd " + REPO_PATH + " && npm pack\n" +
@@ -333,7 +334,9 @@ while (i < STEPS.length) {
333
334
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
334
335
  "If the output contains DEPLOYED, finalization is complete.\n\n" +
335
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" +
336
- "End your summary with exactly this line: published: muse-crew@<new-version>\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" +
337
340
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
338
341
  "No prose, no markdown, just the JSON object.";
339
342
  } else if (PUBLISH_TYPE === "artifact") {
@@ -374,7 +377,10 @@ while (i < STEPS.length) {
374
377
  // declared release: yes, QA verifies the registry actually moved. A silent
375
378
  // publish skip becomes a loud QA failure with evidence, not a pass.
376
379
  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"
380
+ ? "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" +
381
+ "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" +
382
+ "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" +
383
+ "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
384
  : "";
379
385
  instructions = "Final QA testing. You are CODE-BLIND — do NOT read source code.\n" +
380
386
  "Public docs (API.md, README, published action schemas) are NOT source code — read them freely, exactly as a user would.\n" +
@@ -302,6 +302,7 @@ while (i < STEPS.length) {
302
302
  "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
303
303
  "If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
304
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" +
305
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" +
306
307
  "STEP 5: Pack and publish.\n" +
307
308
  "Run: cd " + REPO_PATH + " && npm pack\n" +
@@ -315,7 +316,9 @@ while (i < STEPS.length) {
315
316
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
316
317
  "If the output contains DEPLOYED, finalization is complete.\n\n" +
317
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" +
318
- "End your summary with exactly this line: published: muse-crew@<new-version>\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" +
319
322
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
320
323
  "No prose, no markdown, just the JSON object.";
321
324
  } else if (PUBLISH_TYPE === "artifact") {
@@ -251,10 +251,12 @@ try {
251
251
  "Body text for the cron:\n" +
252
252
  "---\n" +
253
253
  "## Muse Crew Orphan Sweep\n\n" +
254
- "Run the orphan sweep to clean merged worktrees and break stale merge locks.\n\n" +
255
- "Run this command:\n" +
256
- crewHome + "/lib/orphan-sweep.sh clean\n\n" +
257
- "Report the output. If it says CLEAN, no action was needed.\n" +
254
+ "1. Call artifact_invoke_action on slug \"" + dashboardSlug + "\", action \"getdispatchstate\", args {}.\n" +
255
+ "2. Collect the task IDs of every entry in ready_tasks whose latest_session.status is \"running\".\n" +
256
+ " (Sessions older than 1h are already reclassified as timed_out by the dashboard — no extra math.)\n" +
257
+ "3. Run: CREW_ACTIVE_TASKS=\"<space-separated ids>\" " + crewHome + "/lib/orphan-sweep.sh clean\n" +
258
+ " If the list is empty, pass an empty string — do NOT omit the variable (the sweep fails closed without it).\n" +
259
+ "4. Report the output. If it says CLEAN, no action was needed.\n" +
258
260
  "---\n\n" +
259
261
  "Return JSON with existed (boolean).",
260
262
  {
@@ -318,6 +318,7 @@ while (i < STEPS.length) {
318
318
  "Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
319
319
  "If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
320
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" +
321
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" +
322
323
  "STEP 5: Pack and publish.\n" +
323
324
  "Run: cd " + REPO_PATH + " && npm pack\n" +
@@ -331,7 +332,9 @@ while (i < STEPS.length) {
331
332
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
332
333
  "If the output contains DEPLOYED, finalization is complete.\n\n" +
333
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" +
334
- "End your summary with exactly this line: published: muse-crew@<new-version>\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" +
335
338
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
336
339
  "No prose, no markdown, just the JSON object.";
337
340
  } else if (PUBLISH_TYPE === "artifact") {
@@ -372,7 +375,10 @@ while (i < STEPS.length) {
372
375
  // declared release: yes, QA verifies the registry actually moved. A silent
373
376
  // publish skip becomes a loud QA failure with evidence, not a pass.
374
377
  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"
378
+ ? "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" +
379
+ "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" +
380
+ "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" +
381
+ "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
382
  : "";
377
383
  if (PUBLISH_TYPE === "artifact") {
378
384
  var safeDesc = taskDescription.replace(/"/g, "'").replace(/\\/g, "\\\\").slice(0, 500);