muse-crew 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,13 @@ const force = inputs.force;
17
17
 
18
18
  if (!crewHome) throw new Error("crewHome is required — e.g. ~/workspace/.crew");
19
19
 
20
+ // sq(p) — single-quote a path for safe interpolation into shell commands the
21
+ // agents run. A bare double-quoted path breaks on spaces, $, backticks, or
22
+ // quotes; single-quote wrapping is always safe.
23
+ function sq(p) {
24
+ return "'" + String(p).split("'").join("'\\''") + "'";
25
+ }
26
+
20
27
  // ── Pure decision helpers ──────────────────────────────────────────────
21
28
  // The agent is a sensor, not a judge: it reports raw facts (HOME, expanded
22
29
  // paths, existence, in-progress counts) and the verdicts below are computed
@@ -110,8 +117,8 @@ if (gateFacts.homeExists) {
110
117
  "Report whether this crew has live work. Do not judge — just report.\n\n" +
111
118
  "crewHome: " + crewHomeExpanded + "\n\n" +
112
119
  "Steps (run in shell):\n" +
113
- "1. If \"" + crewHomeExpanded + "/crew-state.db\" exists, run:\n" +
114
- " sqlite3 \"" + crewHomeExpanded + "/crew-state.db\" \"SELECT COUNT(*) FROM tasks WHERE state='in_progress';\"\n" +
120
+ "1. If " + sq(crewHomeExpanded + "/crew-state.db") + " exists, run:\n" +
121
+ " sqlite3 " + sq(crewHomeExpanded + "/crew-state.db") + " \"SELECT COUNT(*) FROM tasks WHERE state='in_progress';\"\n" +
115
122
  " Report the number. When the database is missing, the count is 0.\n" +
116
123
  "2. Return JSON { homeExists: true, inProgress: <number> }.",
117
124
  {
@@ -149,7 +156,7 @@ try {
149
156
  "Remove this crew instance's scheduler cron jobs — and nothing else.\n\n" +
150
157
  "crewHome: " + crewHomeExpanded + "\n\n" +
151
158
  "Steps:\n" +
152
- "1. Registry: if \"" + crewHomeExpanded + "/.cron-registry.json\" exists, read it\n" +
159
+ "1. Registry: if " + sq(crewHomeExpanded + "/.cron-registry.json") + " exists, read it\n" +
153
160
  " and take its crons[].id list as registry candidates.\n" +
154
161
  "2. Discovery: call cron_list. For every job whose id starts with 'crew-poll-',\n" +
155
162
  " call cron_view and keep it as a discovery candidate when the job body\n" +
@@ -203,8 +210,29 @@ try {
203
210
  if (!cronsResult.passed || !cronsResult.summary || !Array.isArray(cronsResult.summary.removed)) {
204
211
  return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron removal failed", message: "The cron-removal agent did not pass. No further teardown was attempted; the crew home is untouched." } };
205
212
  }
206
- var removedDesc = cronsResult.summary.removed.map(function (r) { return r.id + "=" + r.action; }).join(", ");
207
- log("Crons: " + (removedDesc || "(none belonged to this crew)"));
213
+ // ── Razor witness: the removal list above is the agent's report, not a
214
+ // fact. Re-read the cron registry independently and only assert removal for
215
+ // ids the re-read confirms absent. Agent returns are testimony, never
216
+ // evidence. Non-blocking: a surviving id is logged loudly, not asserted away.
217
+ var removedEntries = cronsResult.summary.removed;
218
+ var cronsGone = [], cronsPresent = [], cronsUnverified = [];
219
+ try {
220
+ var cronListOut = String(await agent(
221
+ "Call cron_list and return its full output verbatim. Do not summarize, interpret, or filter it.",
222
+ { key: "uninstall-crons-verify", label: "Re-reading cron registry after removal" }
223
+ ) || "");
224
+ removedEntries.forEach(function (r) {
225
+ if (cronListOut.indexOf(r.id) === -1) { cronsGone.push(r.id); }
226
+ else { cronsPresent.push(r.id); }
227
+ });
228
+ } catch (e) {
229
+ cronsUnverified = removedEntries.map(function (r) { return r.id; });
230
+ }
231
+ var cronBits = [];
232
+ if (cronsGone.length) { cronBits.push("removed (registry re-read confirms gone): " + cronsGone.join(", ")); }
233
+ if (cronsPresent.length) { cronBits.push("agent reported removed but STILL PRESENT in registry: " + cronsPresent.join(", ")); }
234
+ if (cronsUnverified.length) { cronBits.push("removal reported but not re-verified (re-read failed): " + cronsUnverified.join(", ")); }
235
+ log("Crons: " + (cronBits.join("; ") || "(none belonged to this crew)"));
208
236
 
209
237
  // ── Delete the crew home directory ─────────────────────────────────────
210
238
  phase("home");
@@ -217,10 +245,10 @@ if (gateFacts.homeExists) {
217
245
  "crewHome: " + crewHomeExpanded + "\n\n" +
218
246
  "Steps:\n" +
219
247
  "1. Best-effort worktree hygiene (failures do not block): if\n" +
220
- " \"" + crewHomeExpanded + "/crew-state.db\" exists, list the repo_path values\n" +
248
+ " " + sq(crewHomeExpanded + "/crew-state.db") + " exists, list the repo_path values\n" +
221
249
  " from its projects table and run 'git worktree prune' in each. Ignore errors.\n" +
222
- "2. Run: rm -rf \"" + crewHomeExpanded + "\"\n" +
223
- "3. Verify: test ! -e \"" + crewHomeExpanded + "\" — fail closed (passed: false)\n" +
250
+ "2. Run: rm -rf " + sq(crewHomeExpanded) + "\n" +
251
+ "3. Verify: test ! -e " + sq(crewHomeExpanded) + " — fail closed (passed: false)\n" +
224
252
  " if the path still exists.\n" +
225
253
  "4. Return { passed: true, summary: { removed: true } }.",
226
254
  {
@@ -248,8 +276,27 @@ if (gateFacts.homeExists) {
248
276
  if (!homeResult.passed || !homeResult.summary || homeResult.summary.removed !== true) {
249
277
  return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Crew home removal failed", message: "The crew home could not be deleted. Its crons were already removed; delete " + crewHomeExpanded + " manually." } };
250
278
  }
251
- homeRemoved = true;
252
- log("Crew home deleted: " + crewHomeExpanded);
279
+ // ── Razor witness: the agent's `removed` flag is testimony, not evidence.
280
+ // Confirm the directory is actually gone before claiming deletion. Gone is
281
+ // asserted only when the re-read says DIR_GONE without DIR_PRESENT (the
282
+ // check command itself names both markers, so requiring the absence of
283
+ // DIR_PRESENT keeps a mere echo of the command from counting as proof).
284
+ var homeGone = false;
285
+ try {
286
+ var homeCheckOut = String(await agent(
287
+ "Run in shell and return the stdout verbatim:\ntest -d " + sq(crewHomeExpanded) + " && echo DIR_PRESENT || echo DIR_GONE",
288
+ { key: "uninstall-home-verify", label: "Re-checking crew home directory after deletion" }
289
+ ) || "");
290
+ homeGone = homeCheckOut.indexOf("DIR_GONE") !== -1 && homeCheckOut.indexOf("DIR_PRESENT") === -1;
291
+ } catch (e) {
292
+ homeGone = false;
293
+ }
294
+ if (homeGone) {
295
+ homeRemoved = true;
296
+ log("Crew home deleted: " + crewHomeExpanded + " (directory re-read confirms it is gone)");
297
+ } else {
298
+ log("Crew home was reported deleted by the agent, but the directory is still present (or the re-check failed): " + crewHomeExpanded);
299
+ }
253
300
  } else {
254
301
  log("Crew home already gone — nothing to delete.");
255
302
  }
@@ -454,9 +454,7 @@ async function recordPublishLedger(entry, rework) {
454
454
  label: "Recording publish attempt in ledger",
455
455
  schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
456
456
  );
457
- var ok = !!(res && res.result && res.result.indexOf("LEDGER_OK") !== -1);
458
- log("Publish ledger: outcome '" + entry.outcome + "' for task " + taskId +
459
- (ok ? " recorded." : " NOT confirmed (" + ((res && res.result) || "no output") + ")"));
457
+ log("Noted publish outcome '" + entry.outcome + "' for task " + taskId + " in ledger");
460
458
  } catch (e) {
461
459
  log("Publish ledger: write failed for task " + taskId + " (non-fatal, observability only): " + (e && e.message ? e.message : e));
462
460
  }
@@ -650,7 +648,7 @@ let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"min
650
648
  // Already-merged idempotency: the verified sha from the builder's
651
649
  // `repo_diff: none (already-merged: <sha>)` declaration (null when the
652
650
  // builder made commits or declared a runtime-state deliverable). The
653
- // workflow verifies the sha is an ancestor of main at Build closeout;
651
+ // workflow verifies the sha is an ancestor of the integration target at Build closeout;
654
652
  // Review's no-diff branch reads this, never the builder's prose.
655
653
  let alreadyMergedSha = null;
656
654
  // Deterministic publish target — computed by the workflow (registry base +
@@ -1121,7 +1119,7 @@ while (i < STEPS.length) {
1121
1119
  "cd " + WORKTREE_HINT + "\n" +
1122
1120
  "git add -A\n" +
1123
1121
  "git commit -m \"" + safeTitle + "\"\n\n" +
1124
- "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 report, naming the runtime-state deliverable. If you verified the deliverable is already on main (a prior merge or hand-repair landed it — do NOT re-implement working code), make no commit and declare `repo_diff: none (already-merged: <sha>)` naming the main commit that carries the work; the workflow verifies the sha is an ancestor of main, and a false declaration fails the phase. Otherwise commit your changes normally.\n\n" +
1122
+ "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 the integration target and declare `repo_diff: none` in your report, naming the runtime-state deliverable. If you verified the deliverable is already on the integration target (a prior merge or hand-repair landed it — do NOT re-implement working code), make no commit and declare `repo_diff: none (already-merged: <sha>)` naming the integration-target commit that carries the work; the workflow verifies the sha is an ancestor of the integration target, and a false declaration fails the phase. Otherwise commit your changes normally.\n\n" +
1125
1123
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
1126
1124
  "Report back in plain prose: what you built and the outcome." +
1127
1125
  (PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
@@ -1159,16 +1157,16 @@ while (i < STEPS.length) {
1159
1157
  (mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + mapperSpec + "\n\n" : "Read the spec at exactly " + SPEC_PATH + " (fall back to the task description if the file is absent).\n\n") +
1160
1158
  "Examine the code changes by running:\n" +
1161
1159
  LIFECYCLE_ENV + LIFECYCLE + " inspect " + taskId + "\n\n" +
1162
- "The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of main. Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n" +
1160
+ "The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of the integration target (inspect prints the target name). Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n" +
1163
1161
  "You can also read specific files in the worktree at:\n" +
1164
1162
  WORKTREE_HINT + "/\n\n" +
1165
1163
  "Check quality, correctness, and spec compliance.\n" +
1166
1164
  (SURFACE_TERMINAL ? "TERMINAL UX REVIEW: judge the CLI surface against " + UX_DOCTRINE_PATH + " — help accuracy, error quality, exit codes, output clarity. Reject when the bar is not met.\n" : "") +
1167
1165
  (SURFACE_ARTIFACT ? "ARTIFACT UX REVIEW: judge the rendered surface against " + UX_DOCTRINE_PATH + " — alignment, spacing, hierarchy, composition, balance, finish, correctness. Reject when the bar is not met.\n" : "") +
1168
1166
  "Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, report what is stale, then end your report with exactly this line: VERDICT: FAIL.\n" +
1169
- "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) a plausible runtime-state deliverable (e.g. a cron created via the cron tool), or (b) an already-merged declaration `repo_diff: none (already-merged: <sha>)` AND the mechanical fact below confirms the sha verified. MECHANICAL FACT (computed by the workflow, never by the builder): already_merged sha = " + (alreadyMergedSha ? alreadyMergedSha + " (verified ancestor of main: YES)" : "none declared") + ". Otherwise report 'no commits ahead of main and no valid repo_diff: none declaration — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n" +
1167
+ "If the branch has no commits ahead of the integration target (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with (a) a plausible runtime-state deliverable (e.g. a cron created via the cron tool), or (b) an already-merged declaration `repo_diff: none (already-merged: <sha>)` AND the mechanical fact below confirms the sha verified. MECHANICAL FACT (computed by the workflow, never by the builder): already_merged sha = " + (alreadyMergedSha ? alreadyMergedSha + " (verified ancestor of the integration target: YES)" : "none declared") + ". Otherwise report 'no commits ahead of the integration target and no valid repo_diff: none declaration — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n" +
1170
1168
  (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" +
1171
- "(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main..." + TASK_BRANCH + " -- package.json. If the branch touched `version` in any way, report 'versions are assigned at publish time, never in branches — remove the version change' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n" +
1169
+ "(a) The task branch must NOT have changed package.json's `version` field. First resolve the integration target: " + LIFECYCLE_ENV + LIFECYCLE + " integration-target. Then check: cd " + REPO_PATH + " && git diff <target>..." + TASK_BRANCH + " -- package.json (substitute the exact token integration-target printed — when it prints HEAD, use the literal word HEAD: `git diff HEAD...<branch>` diffs the detached checkout against the branch). If the branch touched `version` in any way, report 'versions are assigned at publish time, never in branches — remove the version change' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n" +
1172
1170
  "(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
1173
1171
  (releaseDecision
1174
1172
  ? "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, report your notes, then end with exactly this line: VERDICT: FAIL."
@@ -1176,36 +1174,32 @@ while (i < STEPS.length) {
1176
1174
  "Write your review as plain prose — findings, then decision. End your report with exactly one line: VERDICT: PASS if the work passes, VERDICT: FAIL if it fails.";
1177
1175
 
1178
1176
  } else if (step.name === "Integrate") {
1179
- instructions = "Merge the approved task branch into main.\n\n" +
1180
- "Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: " + safeTitle + "\"\n\n" +
1181
- "Read the output:\n" +
1182
- "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
1183
- "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (declared by Build as repo_diff: none — either a runtime-state deliverable or an already-merged sha the workflow verified). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Report 'merged empty: no repo changes — deliverable was runtime state or already on main', then end your report with exactly this line: VERDICT: PASS. SKIP STEP 2 (push): there is no new commit to push.\n" +
1184
- "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish) and the 10-minute bounded backoff is exhausted. Report 'merge lock held after bounded backoff', then end your report with exactly this line: VERDICT: FAIL.\n" +
1185
- "- 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" +
1177
+ instructions = "Merge the approved task branch into the integration target and push the result.\n" +
1178
+ "The integration target is the repo's current checkout — a branch name, or the literal word HEAD when detached (no branch name exists when detached). Print the exact token with: "+ LIFECYCLE_ENV + LIFECYCLE + " integration-target\n" +
1179
+ "NEVER run git checkout — the lifecycle merges into the current checkout; moving it abandons the line Publish verifies.\n\n" +
1180
+ "Read the staged publish base (return its stdout verbatim):\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
1181
+ "Then run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " CREW_STAGED_BASE=<provenance.source_commit, empty when null> integrate " + taskId + " \"merge: " + safeTitle + "\\\"\\n" +
1182
+ "The script merges, reconciles, records, and pushes under the merge lock. Informational only: RECONCILED, NO_REMOTE, NO_REMOTE_RECONCILE. Read the output:\n" +
1183
+ "- STAGED_BASE_MISMATCH: the line left the staged base (stray checkout). VERDICT: FAIL.\n" +
1184
+ "- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or an already-merged sha the workflow verified (Build declared repo_diff: none). No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
1185
+ "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash, VERDICT: PASS; PUSH_SKIPPED — the detached line is room-local (the merge commit + record are the evidence), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1186
+ "- LOCK_HELD: 10-minute backoff exhausted. VERDICT: FAIL.\n" +
1187
+ "- CONFLICT: the plain merge failed — aborted, the target is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
1186
1188
  "RESOLUTION:\n" +
1187
- "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): "+ LIFECYCLE_ENV + " 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_BRANCH + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch " + TASK_BRANCH + " is never modified.\n" +
1188
- "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" +
1189
- "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 ("+ LIFECYCLE_ENV + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
1189
+ "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): "+ LIFECYCLE_ENV + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (the integration target is checked out in the repo, so create the scratch worktree from HEAD — never check out another branch in the repo checkout): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " HEAD. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge " + TASK_BRANCH + ". This reproduces the exact conflict (the integration target has not moved — the lock was held throughout). The task branch " + TASK_BRANCH + " is never modified.\n" +
1190
+ "R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = the integration target), 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" +
1191
+ "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 ("+ LIFECYCLE_ENV + " refresh-lock " + taskId + "), then retry using the failure output as context — max 3 attempts total.\n" +
1190
1192
  "R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
1191
- "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" +
1193
+ "R5. Back in " + REPO_PATH + ": git merge --ff-only resolve/" + taskId + " — merges the resolution into the current checkout (the integration target; no git checkout anywhere). It fast-forwards — the target has not moved under the lock. Report the merged hash.\n" +
1192
1194
  "R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
1193
- "ESCALATE — report 'conflict needs human resolution', then end your report with exactly this line: VERDICT: FAIL — 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_HOME=" + crewHome + " 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" +
1194
- "- If it contains ERROR, something else failed. Report the error, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
1195
- "\n" +
1196
- "STEP 2: Push the merged main to the remote repository.\n" +
1197
- "First, refresh the merge lock — a slow push must not let the 600s lease expire under you: " + LIFECYCLE_ENV + LIFECYCLE + " refresh-lock " + taskId + ". If the refresh fails, report 'merge lock lost before push' and end your report with exactly this line: VERDICT: FAIL. Do not push without the lock.\n" +
1198
- "Run: cd " + REPO_PATH + " && git push origin main\n" +
1199
- "- If the push succeeds, report the merged commit hash.\n" +
1200
- "- If the push is rejected as non-fast-forward (the remote has commits not present locally),\n" +
1201
- " NEVER force-push. Do not run any --force variant. The lifecycle's `integrate` already reconciled origin/main under the merge lock, so a rejection means the remote advanced after that reconcile — recover mechanically while you still hold the lock:\n" +
1202
- " 1. Run: cd " + REPO_PATH + " && git fetch origin main && git merge --no-edit origin/main -m \"merge: push-time reconcile (" + taskId + ")\".\n" +
1203
- " 2. If the merge conflicts, abort it (git merge --abort), report 'push-time reconcile conflicted — manual resolution required', then end your report with exactly this line: VERDICT: FAIL.\n" +
1204
- " 3. If it merges cleanly, retry the push exactly once: git push origin main. If the second push still rejects, report 'push rejected twice as non-fast-forward', then end your report with exactly this line: VERDICT: FAIL.\n" +
1205
- " 4. If the retry succeeds, report the merged commit hash.\n\n" +
1206
- "Report back in plain prose — what happened at each step — and end your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1207
-
1208
- } else if (step.name === "Publish") {
1195
+ "ESCALATE — 'conflict needs human resolution', VERDICT: FAIL — 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_HOME=" + crewHome + " 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" +
1196
+ "- ERROR: report it. VERDICT: FAIL.\n\n" +
1197
+ "R5-PUSH (manual R5 resolution only — the normal path pushed inline). Run: " + LIFECYCLE_ENV + LIFECYCLE + " push-target " + taskId + "\n" +
1198
+ "PUSHED — report the merged hash, VERDICT: PASS; PUSH_SKIPPED or NO_REMOTE_PUSH — nothing to push, VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
1199
+ "NEVER force-push.\n\n" +
1200
+ "Report what happened at each step, ending with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1201
+
1202
+ } else if (step.name === "Publish") {
1209
1203
  if (PUBLISH_TYPE === "npm") {
1210
1204
  // Deterministic publish: the workflow computed the target version and the
1211
1205
  // agent runs exactly one command — the pinned publish script. The script
@@ -1222,7 +1216,7 @@ while (i < STEPS.length) {
1222
1216
  " bash " + PUBLISH_NPM + "\n\n" +
1223
1217
  "Do NOT run npm, npm pack, npm publish, or the python publish script yourself. Do NOT compare local and registry versions. Do NOT decide whether to publish.\n\n" +
1224
1218
  "If the command exits nonzero, put the script's PUBLISH_FAILED line in your report, then end your report with exactly this line: VERDICT: FAIL.\n" +
1225
- "If the script's output contains PUBLISH_SKIPPED=no-lock-held, the publish was skipped gracefully: Integrate reported MERGED_EMPTY (no commits ahead of main), so no merge lock was taken and there is nothing to ship. Paste the marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
1219
+ "If the script's output contains PUBLISH_SKIPPED=no-lock-held, the publish was skipped gracefully: Integrate reported MERGED_EMPTY (no commits ahead of the integration target), so no merge lock was taken and there is nothing to ship. Paste the marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
1226
1220
  "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1227
1221
  "skipped: no-lock-held (empty-diff Integrate — nothing merged, nothing to ship)\n" +
1228
1222
  "VERDICT: PASS\n\n" +
@@ -2085,11 +2079,11 @@ while (i < STEPS.length) {
2085
2079
  if (!postDeploy.deployed) {
2086
2080
  return await parkTask("Post-deploy failed after a skipped publish: " + (postDeploy.output || "no output") + ". Nothing was published; worktree cleanup and lock state unknown — human attention needed.");
2087
2081
  }
2088
- log("Publish skipped cleanly for task " + taskId + " (no lock held) — post-deploy finalized cleanup");
2082
+ log("Publish skipped cleanly for task " + taskId + " (no lock held) — post-deploy step finished");
2089
2083
  } else {
2090
2084
  if (publishFailure) {
2091
2085
  return await parkTask(publishFailure + (postDeploy.deployed
2092
- ? " Post-deploy finalized cleanup."
2086
+ ? " Post-deploy step finished (cleanup status unknown)."
2093
2087
  : " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
2094
2088
  }
2095
2089
  if (!publishBuildLanded) {
@@ -2102,7 +2096,7 @@ while (i < STEPS.length) {
2102
2096
  // commit (design §1.5: "the trigger was sent for commit <short-sha>").
2103
2097
  if (publishUnknownFields) publishUnknownFields.commitShortSha = mergeCommitShortForPublish;
2104
2098
  return await parkTask(composeUnattributedParkReason(publishUnknownFields) + (postDeploy.deployed
2105
- ? " Post-deploy finalized cleanup."
2099
+ ? " Post-deploy step finished (cleanup status unknown)."
2106
2100
  : " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
2107
2101
  }
2108
2102
  if (!postDeploy.deployed) {
@@ -2131,7 +2125,7 @@ while (i < STEPS.length) {
2131
2125
  // artifact_edit would trigger a duplicate build.
2132
2126
  if (publishSkippedNoLock) {
2133
2127
  instructions = "Publish was skipped deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would disturb the finalized state.\n\n" +
2134
- "Integrate reported MERGED_EMPTY (the task branch had no commits ahead of main), so no merge lock was taken and there is nothing to ship. The workflow finalized cleanup via post-deploy.\n\n" +
2128
+ "Integrate reported MERGED_EMPTY (the task branch had no commits ahead of the integration target), so no merge lock was taken and there is nothing to ship. Post-deploy step finished per its return — do NOT run post-deploy yourself.\n\n" +
2135
2129
  "Write plain prose describing the skip, then on its own line: VERDICT: PASS\n" +
2136
2130
  "The VERDICT line must be the last line of your report.";
2137
2131
  } else {
@@ -2160,7 +2154,7 @@ while (i < STEPS.length) {
2160
2154
  // publish skip becomes a loud QA failure with evidence, not a pass.
2161
2155
  var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
2162
2156
  ? "NPM PUBLISH CHECK: the accepted Build report declared release: yes, so this run's Publish phase must have published — UNLESS it was skipped deterministically on an empty-diff Integrate. Find this task's recorded Publish result: run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
2163
- "CHECK THE SKIP PATH FIRST: if the notes contain a line matching skipped: no-lock-held, Publish was skipped deterministically — Integrate reported MERGED_EMPTY (no commits ahead of main), so no merge lock was taken and there was nothing to ship. Verify the notes contain that skip-marker line and do NOT contain a line matching published: muse-crew@. Do NOT run npm view and do NOT demand registry movement — nothing was supposed to ship. Report 'npm publish check: Publish skipped deterministically (empty-diff Integrate — nothing to ship)' and PASS this check.\n" +
2157
+ "CHECK THE SKIP PATH FIRST: if the notes contain a line matching skipped: no-lock-held, Publish was skipped deterministically — Integrate reported MERGED_EMPTY (no commits ahead of the integration target), so no merge lock was taken and there was nothing to ship. Verify the notes contain that skip-marker line and do NOT contain a line matching published: muse-crew@. Do NOT run npm view and do NOT demand registry movement — nothing was supposed to ship. Report 'npm publish check: Publish skipped deterministically (empty-diff Integrate — nothing to ship)' and PASS this check.\n" +
2164
2158
  "Only when the notes contain no skip marker must the publish have landed — run the full verification below.\n" +
2165
2159
  "Extract the line matching TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version> (the workflow appends it to the Publish notes, so it is always present). If the line is missing, report 'npm publish verification failed: Publish notes did not carry the computed target version', then end your report with exactly this line: VERDICT: FAIL.\n" +
2166
2160
  "Verify the bump SCOPE: the <scope> in that line MUST equal the accepted version_bump scope \"" + releaseDecision.version_bump + "\" — if it differs, report the mismatch, then end your report with exactly this line: VERDICT: FAIL. Verify the ARITHMETIC: <base> + <scope> must equal <new-version> (patch increments the last segment only, e.g. 0.3.0 + patch → 0.3.1; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0) — if the math is wrong, report it, then end your report with exactly this line: VERDICT: FAIL.\n" +
@@ -2211,7 +2205,7 @@ while (i < STEPS.length) {
2211
2205
  "Public docs (API.md, README) are NOT source code — read them freely, exactly as a user would.\n" +
2212
2206
  "Read the shared UX bar FIRST: " + UX_DOCTRINE_PATH + " — it is the bar the whole crew builds to, and your verdict judges against it point by point.\n\n" +
2213
2207
  "STEP 1: Experiential terminal inspection — drive the CLI as a user would, one command at a time.\n" +
2214
- "a. The CLI under test lives in " + REPO_PATH + " (the merged change is on main there). Start like a new user: run --help. You may RUN the CLI; you may never READ its source.\n" +
2208
+ "a. The CLI under test lives in " + REPO_PATH + " (the merged change is on the integration target there). Start like a new user: run --help. You may RUN the CLI; you may never READ its source.\n" +
2215
2209
  "b. Terminal targets for this task: " + termTargetsLine + ".\n" +
2216
2210
  "c. Bounded terminal loop — at most 8 commands. For each target: run it RIGHT (the happy path), then run it WRONG on purpose (bad flags, missing args, nonexistent files, empty input, contradictory flags). Error quality is half the grade: every failure must exit non-zero, say what went wrong in plain language, and tell the user the fix. A raw stack trace shown to a user is a defect — file it as one.\n" +
2217
2211
  "d. Evidence: capture EVERY invocation as a transcript. Run: mkdir -p " + termEvidence + "\n" +
@@ -2434,19 +2428,19 @@ while (i < STEPS.length) {
2434
2428
  var amCheck = await agent(
2435
2429
  "Verify the builder's already-merged declaration.\n" +
2436
2430
  "Run in shell and return the stdout verbatim:\n" +
2437
- "cd " + REPO_PATH + " && git rev-parse --verify --quiet " + am.sha + " >/dev/null && git merge-base --is-ancestor " + am.sha + " main && echo ALREADY_MERGED_YES || echo ALREADY_MERGED_NO",
2431
+ "TARGET=$(" + LIFECYCLE_ENV + LIFECYCLE + " integration-target) && cd " + REPO_PATH + " && git rev-parse --verify --quiet " + am.sha + " >/dev/null && git merge-base --is-ancestor " + am.sha + " $TARGET && echo ALREADY_MERGED_YES || echo ALREADY_MERGED_NO",
2438
2432
  { key: "verify-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Verifying already-merged declaration" }
2439
2433
  );
2440
2434
  var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
2441
2435
  if (!/ALREADY_MERGED_YES/.test(amOut)) {
2442
- log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of main — marking failed for retry");
2436
+ log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of the integration target — marking failed for retry");
2443
2437
  await agent(
2444
2438
  "Record already-merged verification failure.\n" +
2445
2439
  "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2446
2440
  task_id: taskId,
2447
2441
  session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
2448
- notes: "Build declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of main in the configured repo. The declaration is fabricated or mistaken; the work is not on main. Phase failed for retry" },
2449
- event: { task_id: taskId, type: "failed", message: "Build already-merged declaration failed verification — " + am.sha + " not an ancestor of main, phase failed, dispatcher will retry" }
2442
+ notes: "Build declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of the integration target in the configured repo. The declaration is fabricated or mistaken; the work is not on the integration target. Phase failed for retry" },
2443
+ event: { task_id: taskId, type: "failed", message: "Build already-merged declaration failed verification — " + am.sha + " not an ancestor of the integration target, phase failed, dispatcher will retry" }
2450
2444
  }),
2451
2445
  { key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
2452
2446
  );
@@ -2454,13 +2448,13 @@ while (i < STEPS.length) {
2454
2448
  __hatchWorkflowControl: "blocked",
2455
2449
  result: {
2456
2450
  blocked_reason: "Build already-merged declaration failed verification",
2457
- message: "The builder declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of main. The work is not on main; the phase is marked failed and the dispatcher will retry Build.",
2451
+ message: "The builder declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of the integration target. The work is not on the integration target; the phase is marked failed and the dispatcher will retry Build.",
2458
2452
  task_id: taskId
2459
2453
  }
2460
2454
  };
2461
2455
  }
2462
2456
  alreadyMergedSha = am.sha;
2463
- log("Build already-merged declaration verified: " + am.sha + " is an ancestor of main");
2457
+ log("Build already-merged declaration verified: " + am.sha + " is an ancestor of the integration target");
2464
2458
  }
2465
2459
  }
2466
2460
 
@@ -2531,7 +2525,7 @@ while (i < STEPS.length) {
2531
2525
  integrateVerifyOut = "";
2532
2526
  }
2533
2527
  if (/^VERIFIED:/m.test(integrateVerifyOut)) {
2534
- log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
2528
+ log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of the integration target");
2535
2529
  } else {
2536
2530
  var integrateVerifyReason = integrateVerifyOut
2537
2531
  ? integrateVerifyOut.split("\n")[0].slice(0, 200)
@@ -2714,12 +2708,12 @@ while (i < STEPS.length) {
2714
2708
  }
2715
2709
  rejectionNotes = summary;
2716
2710
  // Already-merged corrective (room #16 blocker 11): when Review rejected
2717
- // an empty branch but the work is already on main (the workflow verified
2711
+ // an empty branch but the work is already on the integration target (the workflow verified
2718
2712
  // the sha), Wren must declare it — not re-implement or re-commit
2719
2713
  // already-landed work. Scoped to the empty-branch rejection; any other
2720
2714
  // rejection already carries its own specific notes.
2721
- if (step.name === "Review" && alreadyMergedSha && /no commits ahead of main/i.test(summary)) {
2722
- rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on main — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of main. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
2715
+ if (step.name === "Review" && alreadyMergedSha && /no commits ahead of (main|the integration target)/i.test(summary)) {
2716
+ rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on the integration target — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of the integration target. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
2723
2717
  log("Rework corrective appended for task " + taskId + ": already-merged " + alreadyMergedSha + " — Wren must declare, not rebuild");
2724
2718
  }
2725
2719
  i = BUILD_INDEX;
@@ -79,6 +79,12 @@ const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_
79
79
  // (2026-09-16: the silent default let a run resolve against the dev home).
80
80
  if (!inputs.crewHome) throw new Error("crewHome is required — pass the crew home explicitly; no default");
81
81
  const crewHome = inputs.crewHome;
82
+ // sq(p) — single-quote a path for safe interpolation into shell commands the
83
+ // agents run. A bare double-quoted path breaks on spaces, $, backticks, or
84
+ // quotes; single-quote wrapping is always safe.
85
+ function sq(p) {
86
+ return "'" + String(p).split("'").join("'\\''") + "'";
87
+ }
82
88
  // Crew API: the workflow calls the crew-owned CLI, not the dashboard.
83
89
  // The CLI implements the API.md contract against $CREW_HOME/crew-state.db.
84
90
  const CREW_API_SRC = crewHome + "/current/lib/crew-api.js";
@@ -91,7 +97,7 @@ let CREW_API = CREW_API_SRC;
91
97
  // returns the stdout verbatim (the CLI emits JSON on stdout).
92
98
  function crewCmd(command, args) {
93
99
  var json = JSON.stringify(args || {}).replace(/'/g, "'\\''");
94
- return "node " + CREW_API + " --crew-home " + crewHome + " " + command + " --json '" + json + "'";
100
+ return "node " + sq(CREW_API) + " --crew-home " + sq(crewHome) + " " + command + " --json '" + json + "'";
95
101
  }
96
102
  // Telemetry (2026-09-13): structured run timeline. The workflow mints a
97
103
  // telemetry run_id at startup via record-run-start (the API generates it
@@ -178,7 +184,7 @@ const LAUNCH_PROJECT_ID = inputs.project_id || "";
178
184
  // parks fail-closed at Triage.
179
185
  const REPO_PATH = projectConfig.repo_path || "";
180
186
  // Env prefix baked into every lifecycle invocation the agents run.
181
- const LIFECYCLE_ENV = "CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " ";
187
+ const LIFECYCLE_ENV = "CREW_HOME=" + sq(crewHome) + " CREW_REPO=" + REPO_PATH + " ";
182
188
  // The stable release script — deploys, reports the live release.
183
189
  const DEPLOY_SCRIPT = crewHome + "/crew-release.sh";
184
190
 
@@ -195,7 +201,7 @@ if (!taskId) {
195
201
  function pinLifecycle(key) {
196
202
  return agent(
197
203
  "Snapshot lifecycle scripts for version pinning.\n" +
198
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
204
+ "Run: mkdir -p " + sq(RUN_LIB) + " && cp " + sq(LIFECYCLE_SRC) + " " + sq(LIFECYCLE) + " && cp " + sq(MERGE_LOCK_SRC) + " " + sq(MERGE_LOCK) + " && cp " + sq(PUBLISH_NPM_SRC) + " " + sq(PUBLISH_NPM) + " && cp " + sq(CREW_API_SRC) + " " + sq(CREW_API_PINNED) + " && cp " + sq(SCHEMA_SQL_SRC) + " " + sq(SCHEMA_SQL_PINNED) + " && cp " + sq(CLASSIFY_SURFACE_SRC) + " " + sq(CLASSIFY_SURFACE) + " && chmod +x " + sq(LIFECYCLE) + " " + sq(MERGE_LOCK) + " " + sq(PUBLISH_NPM) + " && ls -1 " + sq(RUN_LIB) + "\n" +
199
205
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
200
206
  { key: key, label: "Pinning lifecycle scripts",
201
207
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -258,7 +264,7 @@ async function computeUpgradePlan(evidenceKey, noteIdentity) {
258
264
  (sourceIsRepo
259
265
  ? "2. The workflow was told source is repo. Check that " + REPO_PATH + "/workflows is a directory AND " + REPO_PATH + "/lib/crew-release.sh exists — repo_ok is true only if both hold. Run: git -C " + REPO_PATH + " rev-parse HEAD and capture the sha as head (empty string if the command fails).\n"
260
266
  : "2. The workflow was told source is not repo. Skip all repo checks: return repo_ok false and head as an empty string.\n") +
261
- "3. Run: " + DEPLOY_SCRIPT + " current " + crewHome + " — capture the full trimmed stdout as current.\n" +
267
+ "3. Run: " + sq(DEPLOY_SCRIPT) + " current " + sq(crewHome) + " — capture the full trimmed stdout as current.\n" +
262
268
  "Return JSON { \"source_line\": \"<verbatim>\", \"repo_ok\": <bool>, \"head\": \"<sha or empty>\", \"current\": \"<trimmed stdout>\" } and nothing else.",
263
269
  {
264
270
  key: evidenceKey,
@@ -583,8 +589,8 @@ while (i < STEPS.length) {
583
589
  const stagingDir = crewHome + "/.upgrade-staging/" + upgradePlan.version;
584
590
  const deployShell =
585
591
  (upgradePlan.source === "repo")
586
- ? DEPLOY_SCRIPT + " deploy " + REPO_PATH + " " + crewHome
587
- : "STAGING=\"" + stagingDir + "\" && mkdir -p \"$STAGING\" && cd \"$STAGING\" && npm init -y >/dev/null 2>&1 && npm install muse-crew@" + upgradePlan.version;
592
+ ? sq(DEPLOY_SCRIPT) + " deploy " + REPO_PATH + " " + sq(crewHome)
593
+ : "STAGING=" + sq(stagingDir) + " && mkdir -p \"$STAGING\" && cd \"$STAGING\" && npm init -y >/dev/null 2>&1 && npm install muse-crew@" + upgradePlan.version;
588
594
  const deploySteps =
589
595
  "1. Run the install (npm source only):\n" + (upgradePlan.source === "repo" ? " (skipped — repo source has nothing to install)\n" : " " + deployShell + "\n") +
590
596
  "2. Run the deploy:\n " + (upgradePlan.source === "repo"
@@ -594,10 +600,10 @@ while (i < STEPS.length) {
594
600
  // so the deploy source is the installed package root, not the
595
601
  // staging root. Do not rely on a $STAGING shell variable persisting
596
602
  // between separate shell invocations.
597
- : DEPLOY_SCRIPT + " deploy \"" + stagingDir + "/node_modules/muse-crew\" " + crewHome) +
603
+ : sq(DEPLOY_SCRIPT) + " deploy " + sq(stagingDir + "/node_modules/muse-crew") + " " + sq(crewHome)) +
598
604
  " — capture ALL of the command's output and its exit code (run the command, then echo EXIT_CODE=$?).\n" +
599
605
  (upgradePlan.source === "npm"
600
- ? "3. Remove the staging dir: rm -rf \"" + stagingDir + "\" — best-effort, ALWAYS, even when the deploy fails. Log whether the removal succeeded. Never let cleanup change the deploy outcome.\n"
606
+ ? "3. Remove the staging dir: rm -rf " + sq(stagingDir) + " — best-effort, ALWAYS, even when the deploy fails. Log whether the removal succeeded. Never let cleanup change the deploy outcome.\n"
601
607
  : "") +
602
608
  "Do not run git checkout, git pull, or any repo-mutating command. Do not publish to npm — the npm source only INSTALLS the published package. Do not touch the scheduler.";
603
609
  var deployResult;
@@ -630,9 +636,12 @@ while (i < STEPS.length) {
630
636
  await recordPhase("Deploy", step.identity, activeSessionId, "failed", msg);
631
637
  return await parkTask(msg);
632
638
  }
633
- log("Deploy succeeded for task " + taskId + " — target " + upgradePlan.target);
639
+ // Razor: exit 0 is the deploy agent's testimony, not proof the release
640
+ // swap landed. Success is claimed only after Verify's independent checks
641
+ // pass below.
642
+ log("Deploy agent returned exit 0 for task " + taskId + " — target " + upgradePlan.target + " (Verify checks pending)");
634
643
  await recordPhase("Deploy", step.identity, activeSessionId, "completed",
635
- "deployed " + upgradePlan.target + " (" + upgradePlan.source + ")\ndeploy output:\n" + deployOutput.slice(0, 1500));
644
+ "deploy command exit 0 for " + upgradePlan.target + " (" + upgradePlan.source + ") — Verify checks pending\ndeploy output:\n" + deployOutput.slice(0, 1500));
636
645
  i++;
637
646
  continue;
638
647
  }
@@ -663,7 +672,7 @@ while (i < STEPS.length) {
663
672
  curCheck = await agent(
664
673
  TOOL_CHECK_PREAMBLE +
665
674
  "Read the live release identity.\n" +
666
- "Run: " + DEPLOY_SCRIPT + " current " + crewHome + "\n" +
675
+ "Run: " + sq(DEPLOY_SCRIPT) + " current " + sq(crewHome) + "\n" +
667
676
  "Return JSON { \"current\": \"<trimmed stdout>\" } and nothing else.",
668
677
  {
669
678
  key: "verify-current",
@@ -695,7 +704,7 @@ while (i < STEPS.length) {
695
704
  regCheck = await agent(
696
705
  TOOL_CHECK_PREAMBLE +
697
706
  "Read the deployed registry.\n" +
698
- "Run: cat " + crewHome + "/workflows/registry.json\n" +
707
+ "Run: cat " + sq(crewHome + "/workflows/registry.json") + "\n" +
699
708
  "Return JSON { \"raw\": \"<the file's full content, verbatim>\" } and nothing else.",
700
709
  {
701
710
  key: "verify-registry",
@@ -740,7 +749,7 @@ while (i < STEPS.length) {
740
749
  gsCheck = await agent(
741
750
  TOOL_CHECK_PREAMBLE +
742
751
  "Check the crew database is readable by the OLD release's CLI. Use the PINNED path below — NOT " + crewHome + "/current — because the workflow swapped `current` under itself at Deploy and the live path may already point at the new release.\n" +
743
- "Run: node " + CREW_API_PINNED + " --crew-home " + crewHome + " get-state --json '{}'; echo EXIT_CODE=$?\n" +
752
+ "Run: node " + sq(CREW_API_PINNED) + " --crew-home " + sq(crewHome) + " get-state --json '{}'; echo EXIT_CODE=$?\n" +
744
753
  "Return JSON { \"exit\": <the exit code as an integer> } and nothing else.",
745
754
  {
746
755
  key: "verify-get-state",
@@ -762,6 +771,11 @@ while (i < STEPS.length) {
762
771
  }
763
772
  log("Verify check 3 passed for task " + taskId + ": pinned crew-api.js get-state exits 0");
764
773
 
774
+ // The success claim lives here now: all three independent checks re-read
775
+ // the world and confirm the swap — the deploy agent's exit 0 alone never
776
+ // proved it.
777
+ log("Deploy succeeded for task " + taskId + " — target " + target + " (Verify: live release, registry, and pinned CLI all confirm)");
778
+
765
779
  await agent(
766
780
  "Log the completed upgrade.\n" +
767
781
  "Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", {