muse-crew 0.3.0 → 0.4.1

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/API.md CHANGED
@@ -16,7 +16,7 @@ Create a new task on the board.
16
16
  |-------|------|----------|-------|
17
17
  | `title` | string (1–160 chars) | yes | |
18
18
  | `description` | string (≤ 5000) | no | Defaults to `""` |
19
- | `project` | slug | no | Defaults to the service's default project |
19
+ | `project` | slug | no | Implicit project resolution: an explicit `project` wins; otherwise the service resolves `config.default_project` (set by `setdefaultproject`, `crew-init`, or automatically for the first registered project); as a last resort it falls back to the legacy `"orchestra-dashboard"` project id. Agents filing tasks normally omit `project` and inherit the dashboard's default. |
20
20
  | `workflow` | slug or null | no | Routing label (e.g. `standard`, `bugfix`, `chore`, `docs`) |
21
21
  | `priority` | `high` · `normal` · `low` | no | Defaults to `normal` |
22
22
  | `state` | `todo` · `in_progress` · `parked` · `done` | no | Defaults to `todo` |
@@ -143,19 +143,31 @@ Register a new project.
143
143
  | `simultaneity` | integer (1–100) | no | Max concurrent tasks; defaults to 2 |
144
144
  | `quiesced` | boolean | no | Start paused; defaults to false |
145
145
 
146
+ The first registered project automatically becomes the `default_project` (see State) when no default is set yet.
147
+
146
148
  ### `updateproject`
147
149
 
148
150
  Update project fields. Only `id` is required; all others are optional patch fields. Same fields as `createproject`, except `simultaneity` minimum is 0 (for kill switch state).
149
151
 
150
152
  **Context-change guard:** changing `repo_path`, `deploy_type`, or `deploy_slug` is blocked while any task in the project has an active run (an agent session with status `running` that started within the last hour): the call throws, because live runs keep the old project config and the change would split the project context mid-run. Wait for the runs to finish, or recover/park those tasks first, then retry.
151
153
 
154
+ ### `setdefaultproject`
155
+
156
+ Set the dashboard's default project — the project the CLI and agents use implicitly when no explicit project is given. The dispatcher resolves a task's project as its explicit label first, then this default, then the first registered project as a last resort.
157
+
158
+ | Field | Type | Required | Notes |
159
+ |-------|------|----------|-------|
160
+ | `project_id` | slug | yes | Must be a registered project id, else the call throws `"Project not found."` |
161
+
162
+ Returns `{ "ok": true, "project_id": "<slug>" }`. Idempotent: sets the `default_project` config key via upsert.
163
+
152
164
  ### `deleteproject`
153
165
 
154
166
  Remove a project registration.
155
167
 
156
168
  | Field | Type | Required | Notes |
157
169
  |-------|------|----------|-------|
158
- | `id` | slug | yes | |
170
+ | `id` | slug | yes | If this project was the `default_project`, the default pointer is cleared so it never aims at a missing project. |
159
171
 
160
172
  ### `listprojects`
161
173
 
@@ -203,6 +215,14 @@ Read the full dashboard state: tasks, sessions, events, and projects. Used by wo
203
215
 
204
216
  Read global configuration. Takes no arguments.
205
217
 
218
+ **Known keys:**
219
+
220
+ | Key | Value | Set by |
221
+ |-----|-------|--------|
222
+ | `default_project` | project slug | `setdefaultproject` (explicit upsert), `createproject` (first project only), `crew-init` (the dashboard's own project) |
223
+
224
+ `default_project` is the dashboard's configured default project: agents and the CLI inherit it when no explicit project is given. Deleting the project it points at clears it. No migration is needed — the key simply appears once set, and the dispatcher reads it from `getdispatchstate`'s `config` object.
225
+
206
226
  ### `updateconfig`
207
227
 
208
228
  Set a global configuration value.
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 ]
@@ -177,7 +177,6 @@ cmd_integrate() {
177
177
  if ! git merge --no-ff "task/$task_id" -m "$commit_msg" 2>&1; then
178
178
  echo "CONFLICT: merge failed — aborting"
179
179
  git merge --abort 2>/dev/null || true
180
- "$MERGE_LOCK" release "$task_id" >/dev/null 2>&1
181
180
  exit 3
182
181
  fi
183
182
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.3.0",
4
- "description": "Opinionated orchestration for Muse workflows, identities, and tooling for autonomous software development.",
3
+ "version": "0.4.1",
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": {
@@ -78,6 +78,24 @@ const MAX_TOTAL_REWORK = 2;
78
78
  let totalReworkCount = 0;
79
79
  let rejectionNotes = inputs.rejection_notes || "";
80
80
  let mapperSpec = "";
81
+ // Merge-time versioning: the release decision is extracted deterministically
82
+ // from the accepted Build summary (extractReleaseDecision) so the Publish
83
+ // agent never decides whether to publish. Two consecutive Publish runs
84
+ // rationalized a skip against explicit instruction text — text alone did not
85
+ // hold, so the decision now lives in workflow code, not agent judgment.
86
+ let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
87
+ function extractReleaseDecision(text) {
88
+ const t = text || "";
89
+ const r = /^release:\s*(yes|no)\s*$/im.exec(t);
90
+ if (!r) return null;
91
+ const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
92
+ if (r[1].toLowerCase() === "yes" && !b) return null;
93
+ return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
94
+ }
95
+ function releaseDecisionText() {
96
+ if (!releaseDecision) return "no parseable release:/version_bump: decision";
97
+ return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
98
+ }
81
99
  let i = startStepIndex;
82
100
 
83
101
  // Pin lifecycle scripts
@@ -150,6 +168,28 @@ while (i < STEPS.length) {
150
168
  return { status: "blocked", task_id: taskId, reason: "Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish)." };
151
169
  }
152
170
 
171
+ // Merge-time versioning, decided deterministically: the release decision was
172
+ // extracted from the accepted Build summary above. release: no never reaches
173
+ // the Publish agent (the workflow skips it here, exactly like the no-target
174
+ // case); an unparseable decision blocks fail-closed. The Publish agent below
175
+ // therefore has no decision point to rationalize into a skip.
176
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
177
+ if (!releaseDecision) {
178
+ return { status: "blocked", task_id: taskId, reason: "Build summary has no parseable release:/version_bump: lines — cannot assign version at publish time." };
179
+ }
180
+ if (releaseDecision.release === "no") {
181
+ log("Publish skipped for task " + taskId + " — accepted Build summary declared release: no");
182
+ await agent(
183
+ "Release the merge lock and clean up without publishing.\n" +
184
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
185
+ "If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
186
+ { key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
187
+ );
188
+ i++;
189
+ continue;
190
+ }
191
+ }
192
+
153
193
  // Claim session
154
194
  let activeSessionId;
155
195
  if (isFirstClaim && firstSessionId) {
@@ -204,7 +244,10 @@ while (i < STEPS.length) {
204
244
  "Edit the TypeScript source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
205
245
  "Do not add unrequested features. Build exactly what the spec calls for.\n" +
206
246
  "PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
207
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, so you choose the package version. If this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API), bump the version in package.json with semver (patch for fixes, minor for new behavior, major for breaking changes) and state the chosen version and why in your summary. If the change is internal-only, leave the version unchanged and say so. Check the registry first — npm view muse-crew version — and never re-publish an existing version.\n\n" : "") +
247
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your summary with exactly these two lines:\n" +
248
+ "release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
249
+ "version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
250
+ "Example: release: yes\\nversion_bump: minor\n\n" : "") +
208
251
  "STEP 3: Commit your changes.\n" +
209
252
  "cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
210
253
  "git add -A\n" +
@@ -224,7 +267,12 @@ while (i < STEPS.length) {
224
267
  "Check quality, correctness, and spec compliance.\n" +
225
268
  "Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, reject with notes on what is stale.\n" +
226
269
  "If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise reject: 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit'.\n" +
227
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Validate the builder's version choice: package.json must hold valid semver; if the version was bumped it must be greater than the registry version (npm view muse-crew version), the bump scope (patch/minor/major) must fit the change, and exactly one version field may change. If the version is invalid, already published, or mis-scoped, reject with notes.\n" : "") +
270
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time never in branches. Two checks:\n" +
271
+ "(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...task/" + taskId + " -- package.json. If the branch touched `version` in any way, REJECT with notes: 'versions are assigned at publish time, never in branches — remove the version change'.\n" +
272
+ "(b) The accepted Build summary declares: " + releaseDecisionText() + ". " +
273
+ (releaseDecision
274
+ ? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, reject with notes."
275
+ : "The decision is missing or malformed — REJECT with notes: 'Build summary must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines'.") + "\n" : "") +
228
276
  "If the work passes review, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
229
277
  "If the work fails review, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes explaining what needs to change\" }.\n" +
230
278
  "No prose, no markdown, just the JSON object.";
@@ -236,7 +284,15 @@ while (i < STEPS.length) {
236
284
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
237
285
  "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Set passed to true with summary 'merged empty: no repo changes — deliverable was runtime state'. SKIP STEP 2 (push): there is no new commit to push.\n" +
238
286
  "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false.\n" +
239
- "- If it contains CONFLICT, a merge conflict occurred. Set passed to false with details.\n" +
287
+ "- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
288
+ "RESOLUTION:\n" +
289
+ "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge task/" + taskId + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch task/" + taskId + " is never modified.\n" +
290
+ "R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
291
+ "R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
292
+ "R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
293
+ "R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
294
+ "R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
295
+ "ESCALATE — return {\"passed\": false, \"summary\": \"conflict needs human resolution\"} — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
240
296
  "- If it contains ERROR, something else failed. Set passed to false.\n\n" +
241
297
  "\n" +
242
298
  "STEP 2: Push the merged main to the remote repository.\n" +
@@ -250,28 +306,34 @@ while (i < STEPS.length) {
250
306
  } else if (step.name === "Publish") {
251
307
  if (PUBLISH_TYPE === "npm") {
252
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.
253
311
  instructions = "Publish the npm package to the registry.\n\n" +
254
- "The repo push already happened in Integratedo NOT push to git in this phase, and NEVER force-push.\n" +
255
- "Version discipline: publish ships the exact version merged in Integrate (Build applied it, Review validated it). Do not bump the version here.\n\n" +
312
+ "The release decision is already made and recordedit 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" +
256
314
  "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
257
315
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
258
316
  "STEP 1: Install and activate the immutable release.\n" +
259
317
  "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
260
318
  "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
261
- "STEP 2: Check whether the package version needs publishing.\n" +
262
- "Read the version from: cd " + REPO_PATH + " && node -p \"require('./package.json').version\"\n" +
263
- "Check the registry: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
264
- "If the local version matches the registry version, the version is already live skip to STEP 5.\n\n" +
265
- "STEP 3: Pack and publish.\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" +
266
325
  "Run: cd " + REPO_PATH + " && npm pack\n" +
267
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-$(node -p \"require('" + REPO_PATH + "/package.json').version\").tgz\n" +
268
- "If publish fails with 'You cannot publish over the previously published versions', the version is already live — continue to STEP 4.\n\n" +
269
- "STEP 4: Verify.\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" +
270
329
  "Run: npm view muse-crew version\n" +
271
- "Confirm it matches the local package.json version.\n\n" +
272
- "STEP 5: Finalize.\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" +
273
333
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
274
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" +
275
337
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
276
338
  "No prose, no markdown, just the JSON object.";
277
339
  } else if (PUBLISH_TYPE === "artifact") {
@@ -308,6 +370,12 @@ while (i < STEPS.length) {
308
370
  "No prose, no markdown, just the JSON object.";
309
371
  }
310
372
  } else if (step.name === "QA") {
373
+ // Backstop for merge-time versioning: when the accepted Build summary
374
+ // declared release: yes, QA verifies the registry actually moved. A silent
375
+ // publish skip becomes a loud QA failure with evidence, not a pass.
376
+ 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"
378
+ : "";
311
379
  instructions = "Final QA testing. You are CODE-BLIND — do NOT read source code.\n" +
312
380
  "Public docs (API.md, README, published action schemas) are NOT source code — read them freely, exactly as a user would.\n" +
313
381
  "DOCS GATE: If the fix is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale, FAIL with { \"passed\": false, \"summary\": \"public docs missing/stale for [the change]\" }. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n" +
@@ -316,6 +384,7 @@ while (i < STEPS.length) {
316
384
  "You can also check specific data with the getevents action.\n" +
317
385
  "Do NOT use artifact_inspect — it is async and will not return results inline.\n" +
318
386
  "File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues.\n" +
387
+ npmPublishCheck +
319
388
  "If testing passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"test results\" }.\n" +
320
389
  "If testing fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"failure details\" }.\n" +
321
390
  "No prose, no markdown, just the JSON object.";
@@ -330,9 +399,12 @@ while (i < STEPS.length) {
330
399
  }
331
400
  }
332
401
 
333
- // Task event history — all phases except Review see the comment log
402
+ // Task event history — Review and Publish are excluded. Review is cold by
403
+ // design; Publish receives its release decision deterministically from the
404
+ // workflow, and prior rounds' history contains the exact skip deviation being
405
+ // eliminated — agents imitate recorded precedent.
334
406
  var eventPreamble = "";
335
- if (step.name !== "Review") {
407
+ if (step.name !== "Review" && step.name !== "Publish") {
336
408
  eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
337
409
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
338
410
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
@@ -393,6 +465,13 @@ while (i < STEPS.length) {
393
465
  mapperSpec = summary;
394
466
  }
395
467
 
468
+ // Capture the accepted Build summary's machine-readable release decision.
469
+ // Parsed from the RAW summary (before the 2000-char slice above) — the
470
+ // release:/version_bump: lines sit at the very end and must survive truncation.
471
+ if (step.name === "Build" && passed) {
472
+ releaseDecision = extractReleaseDecision(stepResult.summary || "");
473
+ }
474
+
396
475
  await agent(
397
476
  "Update the session and log the event.\n" +
398
477
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
@@ -72,6 +72,24 @@ const MAX_REWORK = 2;
72
72
  let reworkCount = 0;
73
73
  let rejectionNotes = inputs.rejection_notes || "";
74
74
  let mapperSpec = "";
75
+ // Merge-time versioning: the release decision is extracted deterministically
76
+ // from the accepted Build summary (extractReleaseDecision) so the Publish
77
+ // agent never decides whether to publish. Two consecutive Publish runs
78
+ // rationalized a skip against explicit instruction text — text alone did not
79
+ // hold, so the decision now lives in workflow code, not agent judgment.
80
+ let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
81
+ function extractReleaseDecision(text) {
82
+ const t = text || "";
83
+ const r = /^release:\s*(yes|no)\s*$/im.exec(t);
84
+ if (!r) return null;
85
+ const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
86
+ if (r[1].toLowerCase() === "yes" && !b) return null;
87
+ return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
88
+ }
89
+ function releaseDecisionText() {
90
+ if (!releaseDecision) return "no parseable release:/version_bump: decision";
91
+ return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
92
+ }
75
93
  let i = startStepIndex;
76
94
 
77
95
  // Pin lifecycle scripts
@@ -144,6 +162,28 @@ while (i < STEPS.length) {
144
162
  return { status: "blocked", task_id: taskId, reason: "Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish)." };
145
163
  }
146
164
 
165
+ // Merge-time versioning, decided deterministically: the release decision was
166
+ // extracted from the accepted Build summary above. release: no never reaches
167
+ // the Publish agent (the workflow skips it here, exactly like the no-target
168
+ // case); an unparseable decision blocks fail-closed. The Publish agent below
169
+ // therefore has no decision point to rationalize into a skip.
170
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
171
+ if (!releaseDecision) {
172
+ return { status: "blocked", task_id: taskId, reason: "Build summary has no parseable release:/version_bump: lines — cannot assign version at publish time." };
173
+ }
174
+ if (releaseDecision.release === "no") {
175
+ log("Publish skipped for task " + taskId + " — accepted Build summary declared release: no");
176
+ await agent(
177
+ "Release the merge lock and clean up without publishing.\n" +
178
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
179
+ "If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
180
+ { key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
181
+ );
182
+ i++;
183
+ continue;
184
+ }
185
+ }
186
+
147
187
  let activeSessionId;
148
188
  if (isFirstClaim && firstSessionId) {
149
189
  activeSessionId = firstSessionId;
@@ -186,7 +226,10 @@ while (i < STEPS.length) {
186
226
  "Edit the TypeScript source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
187
227
  "Do not add unrequested features.\n" +
188
228
  "PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
189
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, so you choose the package version. If this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API), bump the version in package.json with semver (patch for fixes, minor for new behavior, major for breaking changes) and state the chosen version and why in your summary. If the change is internal-only, leave the version unchanged and say so. Check the registry first — npm view muse-crew version — and never re-publish an existing version.\n\n" : "") +
229
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your summary with exactly these two lines:\n" +
230
+ "release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
231
+ "version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
232
+ "Example: release: yes\\nversion_bump: minor\n\n" : "") +
190
233
  "STEP 3: Commit your changes.\n" +
191
234
  "cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
192
235
  "git add -A\n" +
@@ -206,7 +249,12 @@ while (i < STEPS.length) {
206
249
  "Check quality, correctness, spec compliance.\n" +
207
250
  "Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, reject with notes on what is stale.\n" +
208
251
  "If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise reject: 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit'.\n" +
209
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Validate the builder's version choice: package.json must hold valid semver; if the version was bumped it must be greater than the registry version (npm view muse-crew version), the bump scope (patch/minor/major) must fit the change, and exactly one version field may change. If the version is invalid, already published, or mis-scoped, reject with notes.\n" : "") +
252
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time never in branches. Two checks:\n" +
253
+ "(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...task/" + taskId + " -- package.json. If the branch touched `version` in any way, REJECT with notes: 'versions are assigned at publish time, never in branches — remove the version change'.\n" +
254
+ "(b) The accepted Build summary declares: " + releaseDecisionText() + ". " +
255
+ (releaseDecision
256
+ ? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, reject with notes."
257
+ : "The decision is missing or malformed — REJECT with notes: 'Build summary must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines'.") + "\n" : "") +
210
258
  "If it passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
211
259
  "If it fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes\" }.\n" +
212
260
  "No prose, no markdown, just the JSON object.";
@@ -218,7 +266,15 @@ while (i < STEPS.length) {
218
266
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
219
267
  "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Set passed to true with summary 'merged empty: no repo changes — deliverable was runtime state'. SKIP STEP 2 (push): there is no new commit to push.\n" +
220
268
  "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false.\n" +
221
- "- If it contains CONFLICT, a merge conflict occurred. Set passed to false with details.\n" +
269
+ "- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
270
+ "RESOLUTION:\n" +
271
+ "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge task/" + taskId + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch task/" + taskId + " is never modified.\n" +
272
+ "R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
273
+ "R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
274
+ "R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
275
+ "R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
276
+ "R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
277
+ "ESCALATE — return {\"passed\": false, \"summary\": \"conflict needs human resolution\"} — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
222
278
  "- If it contains ERROR, something else failed. Set passed to false.\n\n" +
223
279
  "\n" +
224
280
  "STEP 2: Push the merged main to the remote repository.\n" +
@@ -232,28 +288,34 @@ while (i < STEPS.length) {
232
288
  } else if (step.name === "Publish") {
233
289
  if (PUBLISH_TYPE === "npm") {
234
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.
235
293
  instructions = "Publish the npm package to the registry.\n\n" +
236
- "The repo push already happened in Integratedo NOT push to git in this phase, and NEVER force-push.\n" +
237
- "Version discipline: publish ships the exact version merged in Integrate (Build applied it, Review validated it). Do not bump the version here.\n\n" +
294
+ "The release decision is already made and recordedit 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" +
238
296
  "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
239
297
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
240
298
  "STEP 1: Install and activate the immutable release.\n" +
241
299
  "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
242
300
  "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
243
- "STEP 2: Check whether the package version needs publishing.\n" +
244
- "Read the version from: cd " + REPO_PATH + " && node -p \"require('./package.json').version\"\n" +
245
- "Check the registry: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
246
- "If the local version matches the registry version, the version is already live skip to STEP 5.\n\n" +
247
- "STEP 3: Pack and publish.\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" +
248
307
  "Run: cd " + REPO_PATH + " && npm pack\n" +
249
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-$(node -p \"require('" + REPO_PATH + "/package.json').version\").tgz\n" +
250
- "If publish fails with 'You cannot publish over the previously published versions', the version is already live — continue to STEP 4.\n\n" +
251
- "STEP 4: Verify.\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" +
252
311
  "Run: npm view muse-crew version\n" +
253
- "Confirm it matches the local package.json version.\n\n" +
254
- "STEP 5: Finalize.\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" +
255
315
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
256
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" +
257
319
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
258
320
  "No prose, no markdown, just the JSON object.";
259
321
  } else if (PUBLISH_TYPE === "artifact") {
@@ -291,9 +353,12 @@ while (i < STEPS.length) {
291
353
  }
292
354
  }
293
355
 
294
- // Task event history — all phases except Review see the comment log
356
+ // Task event history — Review and Publish are excluded. Review is cold by
357
+ // design; Publish receives its release decision deterministically from the
358
+ // workflow, and prior rounds' history contains the exact skip deviation being
359
+ // eliminated — agents imitate recorded precedent.
295
360
  var eventPreamble = "";
296
- if (step.name !== "Review") {
361
+ if (step.name !== "Review" && step.name !== "Publish") {
297
362
  eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
298
363
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
299
364
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
@@ -349,6 +414,13 @@ while (i < STEPS.length) {
349
414
  mapperSpec = summary;
350
415
  }
351
416
 
417
+ // Capture the accepted Build summary's machine-readable release decision.
418
+ // Parsed from the RAW summary (before the 2000-char slice above) — the
419
+ // release:/version_bump: lines sit at the very end and must survive truncation.
420
+ if (step.name === "Build" && passed) {
421
+ releaseDecision = extractReleaseDecision(stepResult.summary || "");
422
+ }
423
+
352
424
  await agent(
353
425
  "Update session and log event.\n" +
354
426
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
@@ -83,8 +83,10 @@ const allTasks = boardResult.ready_tasks || [];
83
83
  const config = boardResult.config || {};
84
84
  const projects = boardResult.projects || [];
85
85
 
86
- // Default project: explicit arg, or first registered project
87
- const DEFAULT_PROJECT = inputs.defaultProject || (projects.length > 0 ? projects[0].id : "");
86
+ // Default project: explicit arg wins, then the dashboard's configured default
87
+ // project (config.default_project set by crew-init or the dashboard UI),
88
+ // then the first registered project as a last resort.
89
+ const DEFAULT_PROJECT = inputs.defaultProject || config.default_project || (projects.length > 0 ? projects[0].id : "");
88
90
 
89
91
  // Per-project quiesce: build a set of quiesced project IDs
90
92
  const quiescedProjects = {};
@@ -290,7 +292,7 @@ for (var p = 0; p < toProcess.length; p++) {
290
292
  crewHome: crewHome
291
293
  };
292
294
 
293
- log("Claimed " + iworkflow + " for \"" + itask.title + "\" at step " + nextStepName);
295
+ log("Claimed " + iworkflow + " for \"" + itask.title + "\" [" + taskProject + "] at step " + nextStepName);
294
296
  results.push({ task_id: itask.id, workflow: iworkflow, step: nextStepName, action: "claimed", scriptPath: scriptPath, args: launchArgs });
295
297
  }
296
298
 
@@ -145,6 +145,39 @@ try {
145
145
  }
146
146
  log("Project: " + (projectResult.registered ? "registered" : "failed"));
147
147
 
148
+ // ── Phase 3b: Set the dashboard's project as the default ─────────────
149
+ // The CLI takes the project implicitly from the dashboard's configured
150
+ // default (config.default_project). Idempotent: setdefaultproject upserts.
151
+ phase("default-project");
152
+ var defaultProjectResult;
153
+ try {
154
+ defaultProjectResult = await agent(
155
+ "Set the dashboard's default project to the dashboard itself.\\n\\n" +
156
+ "Dashboard slug: " + dashboardSlug + "\\n\\n" +
157
+ "Steps:\\n" +
158
+ "1. Call artifact_invoke_action with:\\n" +
159
+ " slug: '" + dashboardSlug + "'\\n" +
160
+ " action_name: 'setdefaultproject'\\n" +
161
+ " args: { project_id: '" + dashboardSlug + "' }\\n" +
162
+ "2. Return { defaulted: true } on success.\\n\\n" +
163
+ "Return JSON with defaulted (boolean).",
164
+ {
165
+ key: "project-2",
166
+ label: "Set default project",
167
+ schema: {
168
+ type: "object",
169
+ properties: {
170
+ defaulted: { type: "boolean" }
171
+ },
172
+ required: ["defaulted"]
173
+ }
174
+ }
175
+ );
176
+ } catch (e) {
177
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Default project could not be set", message: String(e.message || e) } };
178
+ }
179
+ log("Default project: " + (defaultProjectResult.defaulted ? "set" : "failed"));
180
+
148
181
  // ── Phase 4: Polling cron ─────────────────────────────────────────────
149
182
  // The cron body template lives in the repo at seed/cron-body-template.md.
150
183
  // Placeholders: {crewHome}, {dashboardSlug} are replaced with actual values.
@@ -218,10 +251,12 @@ try {
218
251
  "Body text for the cron:\n" +
219
252
  "---\n" +
220
253
  "## Muse Crew Orphan Sweep\n\n" +
221
- "Run the orphan sweep to clean merged worktrees and break stale merge locks.\n\n" +
222
- "Run this command:\n" +
223
- crewHome + "/lib/orphan-sweep.sh clean\n\n" +
224
- "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" +
225
260
  "---\n\n" +
226
261
  "Return JSON with existed (boolean).",
227
262
  {
@@ -77,6 +77,24 @@ const MAX_TOTAL_REWORK = 2;
77
77
  let totalReworkCount = 0;
78
78
  let rejectionNotes = inputs.rejection_notes || "";
79
79
  let mapperSpec = "";
80
+ // Merge-time versioning: the release decision is extracted deterministically
81
+ // from the accepted Build summary (extractReleaseDecision) so the Publish
82
+ // agent never decides whether to publish. Two consecutive Publish runs
83
+ // rationalized a skip against explicit instruction text — text alone did not
84
+ // hold, so the decision now lives in workflow code, not agent judgment.
85
+ let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
86
+ function extractReleaseDecision(text) {
87
+ const t = text || "";
88
+ const r = /^release:\s*(yes|no)\s*$/im.exec(t);
89
+ if (!r) return null;
90
+ const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
91
+ if (r[1].toLowerCase() === "yes" && !b) return null;
92
+ return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
93
+ }
94
+ function releaseDecisionText() {
95
+ if (!releaseDecision) return "no parseable release:/version_bump: decision";
96
+ return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
97
+ }
80
98
  let i = startStepIndex;
81
99
 
82
100
  // ── Pin lifecycle scripts ────────────────────────────────────────────
@@ -158,6 +176,28 @@ while (i < STEPS.length) {
158
176
  return { status: "blocked", task_id: taskId, reason: "Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish)." };
159
177
  }
160
178
 
179
+ // Merge-time versioning, decided deterministically: the release decision was
180
+ // extracted from the accepted Build summary above. release: no never reaches
181
+ // the Publish agent (the workflow skips it here, exactly like the no-target
182
+ // case); an unparseable decision blocks fail-closed. The Publish agent below
183
+ // therefore has no decision point to rationalize into a skip.
184
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
185
+ if (!releaseDecision) {
186
+ return { status: "blocked", task_id: taskId, reason: "Build summary has no parseable release:/version_bump: lines — cannot assign version at publish time." };
187
+ }
188
+ if (releaseDecision.release === "no") {
189
+ log("Publish skipped for task " + taskId + " — accepted Build summary declared release: no");
190
+ await agent(
191
+ "Release the merge lock and clean up without publishing.\n" +
192
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
193
+ "If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
194
+ { key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
195
+ );
196
+ i++;
197
+ continue;
198
+ }
199
+ }
200
+
161
201
  // Claim session — reuse dispatcher's session for the very first step
162
202
  let activeSessionId;
163
203
  if (isFirstClaim && firstSessionId) {
@@ -202,7 +242,10 @@ while (i < STEPS.length) {
202
242
  "Edit source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
203
243
  "Do not add unrequested features. Build exactly what the spec calls for.\n" +
204
244
  "PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
205
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, so you choose the package version. If this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API), bump the version in package.json with semver (patch for fixes, minor for new behavior, major for breaking changes) and state the chosen version and why in your summary. If the change is internal-only, leave the version unchanged and say so. Check the registry first — npm view muse-crew version — and never re-publish an existing version.\n\n" : "") +
245
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your summary with exactly these two lines:\n" +
246
+ "release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
247
+ "version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
248
+ "Example: release: yes\\nversion_bump: minor\n\n" : "") +
206
249
  "STEP 3: Commit your changes.\n" +
207
250
  "cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
208
251
  "git add -A\n" +
@@ -222,7 +265,12 @@ while (i < STEPS.length) {
222
265
  "Check quality, correctness, and spec compliance.\n" +
223
266
  "Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, reject with notes on what is stale.\n" +
224
267
  "If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise reject: 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit'.\n" +
225
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Validate the builder's version choice: package.json must hold valid semver; if the version was bumped it must be greater than the registry version (npm view muse-crew version), the bump scope (patch/minor/major) must fit the change, and exactly one version field may change. If the version is invalid, already published, or mis-scoped, reject with notes.\n" : "") +
268
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time never in branches. Two checks:\n" +
269
+ "(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...task/" + taskId + " -- package.json. If the branch touched `version` in any way, REJECT with notes: 'versions are assigned at publish time, never in branches — remove the version change'.\n" +
270
+ "(b) The accepted Build summary declares: " + releaseDecisionText() + ". " +
271
+ (releaseDecision
272
+ ? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, reject with notes."
273
+ : "The decision is missing or malformed — REJECT with notes: 'Build summary must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines'.") + "\n" : "") +
226
274
  "If the work passes review, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
227
275
  "If the work fails review, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes explaining what needs to change\" }.\n" +
228
276
  "No prose, no markdown, just the JSON object.";
@@ -234,7 +282,15 @@ while (i < STEPS.length) {
234
282
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
235
283
  "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Set passed to true with summary 'merged empty: no repo changes — deliverable was runtime state'. SKIP STEP 2 (push): there is no new commit to push.\n" +
236
284
  "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false with summary 'merge lock held'.\n" +
237
- "- If it contains CONFLICT, a merge conflict occurred. Set passed to false with the conflict details.\n" +
285
+ "- If it contains CONFLICT, the plain merge failed the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
286
+ "RESOLUTION:\n" +
287
+ "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge task/" + taskId + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch task/" + taskId + " is never modified.\n" +
288
+ "R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
289
+ "R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
290
+ "R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
291
+ "R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
292
+ "R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
293
+ "ESCALATE — return {\"passed\": false, \"summary\": \"conflict needs human resolution\"} — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
238
294
  "- If it contains ERROR, something else failed. Set passed to false with the error.\n\n" +
239
295
  "\n" +
240
296
  "STEP 2: Push the merged main to the remote repository.\n" +
@@ -248,28 +304,34 @@ while (i < STEPS.length) {
248
304
  } else if (step.name === "Publish") {
249
305
  if (PUBLISH_TYPE === "npm") {
250
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.
251
309
  instructions = "Publish the npm package to the registry.\n\n" +
252
- "The repo push already happened in Integratedo NOT push to git in this phase, and NEVER force-push.\n" +
253
- "Version discipline: publish ships the exact version merged in Integrate (Build applied it, Review validated it). Do not bump the version here.\n\n" +
310
+ "The release decision is already made and recordedit 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" +
254
312
  "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
255
313
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
256
314
  "STEP 1: Install and activate the immutable release.\n" +
257
315
  "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
258
316
  "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
259
- "STEP 2: Check whether the package version needs publishing.\n" +
260
- "Read the version from: cd " + REPO_PATH + " && node -p \"require('./package.json').version\"\n" +
261
- "Check the registry: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
262
- "If the local version matches the registry version, the version is already live skip to STEP 5.\n\n" +
263
- "STEP 3: Pack and publish.\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" +
264
323
  "Run: cd " + REPO_PATH + " && npm pack\n" +
265
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-$(node -p \"require('" + REPO_PATH + "/package.json').version\").tgz\n" +
266
- "If publish fails with 'You cannot publish over the previously published versions', the version is already live — continue to STEP 4.\n\n" +
267
- "STEP 4: Verify.\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" +
268
327
  "Run: npm view muse-crew version\n" +
269
- "Confirm it matches the local package.json version.\n\n" +
270
- "STEP 5: Finalize.\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" +
271
331
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
272
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" +
273
335
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
274
336
  "No prose, no markdown, just the JSON object.";
275
337
  } else if (PUBLISH_TYPE === "artifact") {
@@ -306,6 +368,12 @@ while (i < STEPS.length) {
306
368
  "No prose, no markdown, just the JSON object.";
307
369
  }
308
370
  } else if (step.name === "QA") {
371
+ // Backstop for merge-time versioning: when the accepted Build summary
372
+ // declared release: yes, QA verifies the registry actually moved. A silent
373
+ // publish skip becomes a loud QA failure with evidence, not a pass.
374
+ 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"
376
+ : "";
309
377
  if (PUBLISH_TYPE === "artifact") {
310
378
  var safeDesc = taskDescription.replace(/"/g, "'").replace(/\\/g, "\\\\").slice(0, 500);
311
379
  instructions = "You are code-blind QA. You NEVER read source files.\n" +
@@ -335,14 +403,18 @@ while (i < STEPS.length) {
335
403
  "Verify the change is working as described in the task.\n" +
336
404
  "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale, FAIL with { \"passed\": false, \"summary\": \"public docs missing/stale for [the change]\" }. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n" +
337
405
  "File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues found.\n\n" +
406
+ npmPublishCheck +
338
407
  "Your final response MUST be valid JSON and nothing else: { \"passed\": true/false, \"summary\": \"what you tested and found\" }.\n" +
339
408
  "No prose, no markdown, just the JSON object.";
340
409
  }
341
410
  }
342
411
 
343
- // Task event history — all phases except Review see the comment log
412
+ // Task event history — Review and Publish are excluded. Review is cold by
413
+ // design; Publish receives its release decision deterministically from the
414
+ // workflow, and prior rounds' history contains the exact skip deviation being
415
+ // eliminated — agents imitate recorded precedent.
344
416
  var eventPreamble = "";
345
- if (step.name !== "Review") {
417
+ if (step.name !== "Review" && step.name !== "Publish") {
346
418
  eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
347
419
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
348
420
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
@@ -404,6 +476,13 @@ while (i < STEPS.length) {
404
476
  mapperSpec = summary;
405
477
  }
406
478
 
479
+ // Capture the accepted Build summary's machine-readable release decision.
480
+ // Parsed from the RAW summary (before the 2000-char slice above) — the
481
+ // release:/version_bump: lines sit at the very end and must survive truncation.
482
+ if (step.name === "Build" && passed) {
483
+ releaseDecision = extractReleaseDecision(stepResult.summary || "");
484
+ }
485
+
407
486
  // Record session result
408
487
  await agent(
409
488
  "Update the session and log the event.\n" +