muse-crew 0.2.1 → 0.4.0

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` |
@@ -33,7 +33,7 @@ Update an existing task's fields. Only `id` is required; all other fields are op
33
33
  | `description` | string (≤ 3000) | no | |
34
34
  | `state` | `todo` · `in_progress` · `parked` · `done` | no | |
35
35
  | `priority` | `high` · `normal` · `low` | no | |
36
- | `project` | slug | no | |
36
+ | `project` | slug | no | **Project-move guard:** changing this to a different project throws while the task has an active run (an agent session with status `running` that started within the last hour), because the live run keeps the old project's repo context and moving it mid-phase would work on the wrong repo. Wait for the run to finish, or recover/park the task first, then move it. |
37
37
  | `workflow` | slug or null | no | |
38
38
  | `deps` | string[] | no | |
39
39
 
@@ -143,17 +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
 
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.
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
+
150
164
  ### `deleteproject`
151
165
 
152
166
  Remove a project registration.
153
167
 
154
168
  | Field | Type | Required | Notes |
155
169
  |-------|------|----------|-------|
156
- | `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. |
157
171
 
158
172
  ### `listprojects`
159
173
 
@@ -201,6 +215,14 @@ Read the full dashboard state: tasks, sessions, events, and projects. Used by wo
201
215
 
202
216
  Read global configuration. Takes no arguments.
203
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
+
204
226
  ### `updateconfig`
205
227
 
206
228
  Set a global configuration value.
package/docs/guide.md CHANGED
@@ -213,7 +213,7 @@ Each phase has an assigned identity — a character with a defined personality:
213
213
  | Map | **Mara** | Designer |
214
214
  | Build | **Wren** | Quietest one, trusts the plan |
215
215
  | Review | **Cass** | Fair but exacting — holds the spec as the contract |
216
- | Integrate | **Wren** | Merges the work, pushes `main` to the repo |
216
+ | Integrate | **Wren** | Merges the work, pushes `main` to the repo (succeeds vacuously when the task branch is empty — runtime-state deliverable) |
217
217
  | Publish | **Wren** | Ships the merged code to the publish target (skipped when none) |
218
218
  | QA | **Hazel** | Code-blind, persistent, wears persona costumes |
219
219
  | Reproduce | **Hazel** | Reproduces bugs before fixing |
@@ -6,10 +6,13 @@
6
6
  #
7
7
  # Layout:
8
8
  # ~/.jarvis/
9
- # releases/<full-commit-hash>/ # immutable snapshot (workflows/ + lib/)
9
+ # releases/<full-commit-hash>/ # immutable snapshot (workflows/ + lib/ + seed/workflows/ docs)
10
10
  # current -> releases/<hash> # active release
11
11
  # workflows -> current/workflows # convenience (cron/dispatcher reads this)
12
12
  # lib -> current/lib # convenience
13
+ # .orchestration/workflows/ # live workflow documents the dashboard
14
+ # # reads; re-synced from the activated
15
+ # # release on every deploy/rollback
13
16
  #
14
17
  # The Muse workflow runtime snapshots .js scripts at launch.
15
18
  # /tmp/crew-lib-* pins lifecycle scripts per run.
@@ -91,6 +94,37 @@ _validate_workflows() {
91
94
  echo "VALIDATED: workflow scripts parse"
92
95
  }
93
96
 
97
+ # ── workflow doc sync ───────────────────────────────────────────────
98
+ # Decision (2026-09-09): the dashboard reads the workflow documents live
99
+ # from $CREW_HOME/.orchestration/workflows/, and `recovertask` validates
100
+ # target_phase against them — so those documents must always reflect the
101
+ # phases the dispatcher actually runs. Both the workflow scripts and the
102
+ # .md docs ship in the same repo commit, so we re-sync the docs from the
103
+ # activated release on every release activation (deploy AND rollback),
104
+ # automatically. A manual re-sync step is exactly what drifted here: the
105
+ # copy was seeded once by crew-init's `cp -n` and never refreshed, leaving
106
+ # the dashboard on pre-publish-architecture phases (no Integrate, no
107
+ # Publish) while the dispatcher ran the current phase lists.
108
+ _sync_workflow_docs() {
109
+ local home="${1:?usage: _sync_workflow_docs <jarvis-home>}"
110
+ local src="$home/current/seed/workflows"
111
+ local dest="$home/.orchestration/workflows"
112
+ if [ ! -d "$src" ]; then
113
+ echo "WORKFLOW-DOCS: no seed/workflows in activated release; skipping sync" >&2
114
+ return 0
115
+ fi
116
+ mkdir -p "$dest"
117
+ local f base copied=0
118
+ for f in "$src"/*.md; do
119
+ [ -f "$f" ] || continue
120
+ base="$(basename "$f")"
121
+ [ "$base" = "AGENTS.md" ] && continue # folder docs, not a workflow
122
+ cp -f "$f" "$dest/$base"
123
+ copied=$((copied + 1))
124
+ done
125
+ echo "WORKFLOW-DOCS: synced $copied workflow docs to $dest"
126
+ }
127
+
94
128
  # ── deploy ────────────────────────────────────────────────────────────
95
129
  # Build, validate, and atomically activate a release from repo HEAD.
96
130
  # Single command — no cross-step lock needed.
@@ -124,11 +158,18 @@ cmd_deploy() {
124
158
  mkdir -p "$staging_dir"
125
159
  if [ -d .git ]; then
126
160
  # Extract from committed tree (not working directory)
127
- git archive HEAD -- workflows lib | tar -xC "$staging_dir"
161
+ _seed=""
162
+ git cat-file -e "HEAD:seed/workflows" 2>/dev/null && _seed="seed/workflows" || true
163
+ # shellcheck disable=SC2086
164
+ git archive HEAD -- workflows lib $_seed | tar -xC "$staging_dir"
128
165
  else
129
166
  # Plain directory (npm install): copy directly
130
167
  [ -d workflows ] && cp -r workflows "$staging_dir/"
131
168
  [ -d lib ] && cp -r lib "$staging_dir/"
169
+ if [ -d seed/workflows ]; then
170
+ mkdir -p "$staging_dir/seed"
171
+ cp -r seed/workflows "$staging_dir/seed/"
172
+ fi
132
173
  fi
133
174
  # Gate: refuse to install a release whose workflow scripts don't parse.
134
175
  if ! _validate_workflows "$staging_dir"; then
@@ -147,6 +188,9 @@ cmd_deploy() {
147
188
 
148
189
  echo "ACTIVATED: $hash"
149
190
 
191
+ # Keep the dashboard's workflow documents in sync with the release.
192
+ _sync_workflow_docs "$CREW_HOME"
193
+
150
194
  # Prune old releases — keep the 5 most recent
151
195
  _prune_releases "$home" 5
152
196
  }
@@ -172,6 +216,9 @@ cmd_rollback() {
172
216
  mv -T "$tmp_link" "$CREW_HOME/current"
173
217
 
174
218
  echo "ROLLED_BACK: $current_hash -> $prev"
219
+
220
+ # Keep the dashboard's workflow documents in sync with the release.
221
+ _sync_workflow_docs "$CREW_HOME"
175
222
  }
176
223
 
177
224
  # ── current ───────────────────────────────────────────────────────────
@@ -158,8 +158,11 @@ cmd_integrate() {
158
158
  local ahead
159
159
  ahead=$(git rev-list --count "main..task/$task_id")
160
160
  if [ "$ahead" -eq 0 ]; then
161
- echo "ERROR: task/$task_id has no commits ahead of main — nothing to merge"
162
- return 1
161
+ # Approved empty diff: the deliverable was runtime state (cron,
162
+ # scheduler, dashboard config), not a repo change. Nothing to merge,
163
+ # nothing to serialize — the merge lock is intentionally not taken.
164
+ echo "MERGED_EMPTY: task/$task_id has no commits ahead of main — runtime-state deliverable, nothing to merge"
165
+ return 0
163
166
  fi
164
167
 
165
168
  # Acquire merge lock
@@ -174,7 +177,6 @@ cmd_integrate() {
174
177
  if ! git merge --no-ff "task/$task_id" -m "$commit_msg" 2>&1; then
175
178
  echo "CONFLICT: merge failed — aborting"
176
179
  git merge --abort 2>/dev/null || true
177
- "$MERGE_LOCK" release "$task_id" >/dev/null 2>&1
178
180
  exit 3
179
181
  fi
180
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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,11 +244,15 @@ 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" +
211
254
  "git commit -m \"fix: " + safeTitle + "\"\n\n" +
255
+ "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of main and declare `repo_diff: none` in your summary, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
212
256
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
213
257
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"what you built\", \"passed\": true }. No prose, no markdown, just the JSON object.";
214
258
 
@@ -222,7 +266,13 @@ while (i < STEPS.length) {
222
266
  REPO_PATH + "/.worktrees/" + taskId + "/\n\n" +
223
267
  "Check quality, correctness, and spec compliance.\n" +
224
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" +
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" : "") +
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" +
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" : "") +
226
276
  "If the work passes review, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
227
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" +
228
278
  "No prose, no markdown, just the JSON object.";
@@ -232,8 +282,17 @@ while (i < STEPS.length) {
232
282
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " integrate " + taskId + " \"merge: fix: " + safeTitle + "\"\n\n" +
233
283
  "Read the output:\n" +
234
284
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
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" +
235
286
  "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false.\n" +
236
- "- 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" +
237
296
  "- If it contains ERROR, something else failed. Set passed to false.\n\n" +
238
297
  "\n" +
239
298
  "STEP 2: Push the merged main to the remote repository.\n" +
@@ -247,28 +306,34 @@ while (i < STEPS.length) {
247
306
  } else if (step.name === "Publish") {
248
307
  if (PUBLISH_TYPE === "npm") {
249
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.
250
311
  instructions = "Publish the npm package to the registry.\n\n" +
251
- "The repo push already happened in Integratedo NOT push to git in this phase, and NEVER force-push.\n" +
252
- "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" +
253
314
  "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
254
315
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
255
316
  "STEP 1: Install and activate the immutable release.\n" +
256
317
  "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
257
318
  "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
258
- "STEP 2: Check whether the package version needs publishing.\n" +
259
- "Read the version from: cd " + REPO_PATH + " && node -p \"require('./package.json').version\"\n" +
260
- "Check the registry: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
261
- "If the local version matches the registry version, the version is already live skip to STEP 5.\n\n" +
262
- "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" +
263
325
  "Run: cd " + REPO_PATH + " && npm pack\n" +
264
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-$(node -p \"require('" + REPO_PATH + "/package.json').version\").tgz\n" +
265
- "If publish fails with 'You cannot publish over the previously published versions', the version is already live — continue to STEP 4.\n\n" +
266
- "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" +
267
329
  "Run: npm view muse-crew version\n" +
268
- "Confirm it matches the local package.json version.\n\n" +
269
- "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" +
270
333
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
271
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" +
272
337
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
273
338
  "No prose, no markdown, just the JSON object.";
274
339
  } else if (PUBLISH_TYPE === "artifact") {
@@ -305,6 +370,12 @@ while (i < STEPS.length) {
305
370
  "No prose, no markdown, just the JSON object.";
306
371
  }
307
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
+ : "";
308
379
  instructions = "Final QA testing. You are CODE-BLIND — do NOT read source code.\n" +
309
380
  "Public docs (API.md, README, published action schemas) are NOT source code — read them freely, exactly as a user would.\n" +
310
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" +
@@ -313,6 +384,7 @@ while (i < STEPS.length) {
313
384
  "You can also check specific data with the getevents action.\n" +
314
385
  "Do NOT use artifact_inspect — it is async and will not return results inline.\n" +
315
386
  "File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues.\n" +
387
+ npmPublishCheck +
316
388
  "If testing passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"test results\" }.\n" +
317
389
  "If testing fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"failure details\" }.\n" +
318
390
  "No prose, no markdown, just the JSON object.";
@@ -327,9 +399,12 @@ while (i < STEPS.length) {
327
399
  }
328
400
  }
329
401
 
330
- // 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.
331
406
  var eventPreamble = "";
332
- if (step.name !== "Review") {
407
+ if (step.name !== "Review" && step.name !== "Publish") {
333
408
  eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
334
409
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
335
410
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
@@ -390,6 +465,13 @@ while (i < STEPS.length) {
390
465
  mapperSpec = summary;
391
466
  }
392
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
+
393
475
  await agent(
394
476
  "Update the session and log the event.\n" +
395
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,11 +226,15 @@ 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" +
193
236
  "git commit -m \"chore: " + safeTitle + "\"\n\n" +
237
+ "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of main and declare `repo_diff: none` in your summary, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
194
238
  (rejectionNotes ? "REWORK after rejection. Address:\n" + rejectionNotes + "\n\n" : "") +
195
239
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"what you built\", \"passed\": true }. No prose, no markdown, just the JSON object.";
196
240
 
@@ -204,7 +248,13 @@ while (i < STEPS.length) {
204
248
  REPO_PATH + "/.worktrees/" + taskId + "/\n\n" +
205
249
  "Check quality, correctness, spec compliance.\n" +
206
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" +
207
- (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" : "") +
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" +
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" : "") +
208
258
  "If it passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
209
259
  "If it fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes\" }.\n" +
210
260
  "No prose, no markdown, just the JSON object.";
@@ -214,8 +264,17 @@ while (i < STEPS.length) {
214
264
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " integrate " + taskId + " \"merge: chore: " + safeTitle + "\"\n\n" +
215
265
  "Read the output:\n" +
216
266
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
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" +
217
268
  "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false.\n" +
218
- "- 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" +
219
278
  "- If it contains ERROR, something else failed. Set passed to false.\n\n" +
220
279
  "\n" +
221
280
  "STEP 2: Push the merged main to the remote repository.\n" +
@@ -229,28 +288,34 @@ while (i < STEPS.length) {
229
288
  } else if (step.name === "Publish") {
230
289
  if (PUBLISH_TYPE === "npm") {
231
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.
232
293
  instructions = "Publish the npm package to the registry.\n\n" +
233
- "The repo push already happened in Integratedo NOT push to git in this phase, and NEVER force-push.\n" +
234
- "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" +
235
296
  "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
236
297
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
237
298
  "STEP 1: Install and activate the immutable release.\n" +
238
299
  "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
239
300
  "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
240
- "STEP 2: Check whether the package version needs publishing.\n" +
241
- "Read the version from: cd " + REPO_PATH + " && node -p \"require('./package.json').version\"\n" +
242
- "Check the registry: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
243
- "If the local version matches the registry version, the version is already live skip to STEP 5.\n\n" +
244
- "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" +
245
307
  "Run: cd " + REPO_PATH + " && npm pack\n" +
246
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-$(node -p \"require('" + REPO_PATH + "/package.json').version\").tgz\n" +
247
- "If publish fails with 'You cannot publish over the previously published versions', the version is already live — continue to STEP 4.\n\n" +
248
- "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" +
249
311
  "Run: npm view muse-crew version\n" +
250
- "Confirm it matches the local package.json version.\n\n" +
251
- "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" +
252
315
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
253
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" +
254
319
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
255
320
  "No prose, no markdown, just the JSON object.";
256
321
  } else if (PUBLISH_TYPE === "artifact") {
@@ -288,9 +353,12 @@ while (i < STEPS.length) {
288
353
  }
289
354
  }
290
355
 
291
- // 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.
292
360
  var eventPreamble = "";
293
- if (step.name !== "Review") {
361
+ if (step.name !== "Review" && step.name !== "Publish") {
294
362
  eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
295
363
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
296
364
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
@@ -346,6 +414,13 @@ while (i < STEPS.length) {
346
414
  mapperSpec = summary;
347
415
  }
348
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
+
349
424
  await agent(
350
425
  "Update session and log event.\n" +
351
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.
@@ -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,11 +242,15 @@ 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" +
209
252
  "git commit -m \"" + safeTitle + "\"\n\n" +
253
+ "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of main and declare `repo_diff: none` in your summary, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
210
254
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
211
255
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"what you built\", \"passed\": true }. No prose, no markdown, just the JSON object.";
212
256
 
@@ -220,7 +264,13 @@ while (i < STEPS.length) {
220
264
  REPO_PATH + "/.worktrees/" + taskId + "/\n\n" +
221
265
  "Check quality, correctness, and spec compliance.\n" +
222
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" +
223
- (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" : "") +
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" +
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" : "") +
224
274
  "If the work passes review, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
225
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" +
226
276
  "No prose, no markdown, just the JSON object.";
@@ -230,8 +280,17 @@ while (i < STEPS.length) {
230
280
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " integrate " + taskId + " \"merge: " + safeTitle + "\"\n\n" +
231
281
  "Read the output:\n" +
232
282
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
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" +
233
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" +
234
- "- 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" +
235
294
  "- If it contains ERROR, something else failed. Set passed to false with the error.\n\n" +
236
295
  "\n" +
237
296
  "STEP 2: Push the merged main to the remote repository.\n" +
@@ -245,28 +304,34 @@ while (i < STEPS.length) {
245
304
  } else if (step.name === "Publish") {
246
305
  if (PUBLISH_TYPE === "npm") {
247
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.
248
309
  instructions = "Publish the npm package to the registry.\n\n" +
249
- "The repo push already happened in Integratedo NOT push to git in this phase, and NEVER force-push.\n" +
250
- "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" +
251
312
  "STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
252
313
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
253
314
  "STEP 1: Install and activate the immutable release.\n" +
254
315
  "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
255
316
  "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
256
- "STEP 2: Check whether the package version needs publishing.\n" +
257
- "Read the version from: cd " + REPO_PATH + " && node -p \"require('./package.json').version\"\n" +
258
- "Check the registry: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
259
- "If the local version matches the registry version, the version is already live skip to STEP 5.\n\n" +
260
- "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" +
261
323
  "Run: cd " + REPO_PATH + " && npm pack\n" +
262
- "Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-$(node -p \"require('" + REPO_PATH + "/package.json').version\").tgz\n" +
263
- "If publish fails with 'You cannot publish over the previously published versions', the version is already live — continue to STEP 4.\n\n" +
264
- "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" +
265
327
  "Run: npm view muse-crew version\n" +
266
- "Confirm it matches the local package.json version.\n\n" +
267
- "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" +
268
331
  "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
269
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" +
270
335
  "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
271
336
  "No prose, no markdown, just the JSON object.";
272
337
  } else if (PUBLISH_TYPE === "artifact") {
@@ -303,6 +368,12 @@ while (i < STEPS.length) {
303
368
  "No prose, no markdown, just the JSON object.";
304
369
  }
305
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
+ : "";
306
377
  if (PUBLISH_TYPE === "artifact") {
307
378
  var safeDesc = taskDescription.replace(/"/g, "'").replace(/\\/g, "\\\\").slice(0, 500);
308
379
  instructions = "You are code-blind QA. You NEVER read source files.\n" +
@@ -332,14 +403,18 @@ while (i < STEPS.length) {
332
403
  "Verify the change is working as described in the task.\n" +
333
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" +
334
405
  "File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues found.\n\n" +
406
+ npmPublishCheck +
335
407
  "Your final response MUST be valid JSON and nothing else: { \"passed\": true/false, \"summary\": \"what you tested and found\" }.\n" +
336
408
  "No prose, no markdown, just the JSON object.";
337
409
  }
338
410
  }
339
411
 
340
- // 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.
341
416
  var eventPreamble = "";
342
- if (step.name !== "Review") {
417
+ if (step.name !== "Review" && step.name !== "Publish") {
343
418
  eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
344
419
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
345
420
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
@@ -401,6 +476,13 @@ while (i < STEPS.length) {
401
476
  mapperSpec = summary;
402
477
  }
403
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
+
404
486
  // Record session result
405
487
  await agent(
406
488
  "Update the session and log the event.\n" +