muse-crew 0.6.2 → 0.6.4

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
@@ -294,6 +294,15 @@ Artifact Publish passes only when both are true:
294
294
 
295
295
  Verification fails closed. If the provenance is missing, unreadable, or points at a different commit than the integrated HEAD, the workflow parks the task — it does not trust the worker's prose. Narrative remains useful but cannot independently establish deployment truth.
296
296
 
297
+ ### Integrate merge verification
298
+
299
+ Integrate passes only when both are true:
300
+
301
+ 1. The worker declares success.
302
+ 2. Mechanical verification confirms the task branch tip is an ancestor of `main`: the workflow runs the lifecycle script's `verify-merge` command, which resolves the branch through the crew registry (never by reconstructing `task/<id>`), and matches the `VERIFIED:` marker on the script's own stdout with a regex — never by reading agent prose.
303
+
304
+ A genuine empty-diff Integrate (no commits ahead of `main` — a runtime-state deliverable) verifies vacuously: the branch tip is then an ancestor of `main`. Verification failure is an operational step failure, not a park: the dispatcher retries Integrate under its consecutive-failure cap, and the retry finds the commits still on the branch and performs the real merge.
305
+
297
306
  ---
298
307
 
299
308
  ## Not part of this API
package/lib/AGENTS.md CHANGED
@@ -5,7 +5,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
5
5
  - `build-registry.js` — deterministic extractor that generates `workflows/registry.json` (workflow step registry) from the workflow files' `meta` blocks at release time; invoked by `crew-release.sh` deploy
6
6
  - `crew-release.sh` — immutable release manager: deploy, rollback, prune
7
7
  - `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
8
- - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/status/post-deploy/refresh-lock/lock-status over git worktrees (`.worktrees/<id>`, branch `task/<id>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/path — never reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving. `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or `LOCKED by <holder> since <ts> (pid <pid>)`, always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
8
+ - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/refresh-lock/lock-status over git worktrees (`.worktrees/<id>`, branch `task/<id>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/path — never reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving. `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or `LOCKED by <holder> since <ts> (pid <pid>)`, always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
9
9
  - `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight) on a scratch repo
10
10
  - `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
11
11
  - `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
@@ -0,0 +1,75 @@
1
+ #!/bin/bash
2
+ # test-verify-merge.sh — regression tests for the verify-merge lifecycle
3
+ # command (bug b1b1f919: Integrate completed on empty merge while approved
4
+ # commits were still stranded on the task branch).
5
+ #
6
+ # Fixture: a scratch repo with main + prepared task branches.
7
+ # 1. missing branch → ERROR, non-zero
8
+ # 2. empty-diff branch (no commits ahead of main) → VERIFIED, exit 0
9
+ # 3. branch with commits ahead, not merged → NOT_MERGED, non-zero
10
+ # 4. after merging the branch → VERIFIED, exit 0
11
+ # 5. registry-divergent branch name → resolves via registry, not via
12
+ # reconstructed "task/<id>" (verifying the wrong branch is the same
13
+ # bug in different clothes)
14
+ # 6. invalid task id → rejected
15
+ set -u
16
+
17
+ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
18
+ LIFECYCLE="$REPO_DIR/lib/worktree-lifecycle.sh"
19
+
20
+ fail() { echo "FAIL: $1"; exit 1; }
21
+
22
+ T=/tmp/crew-verify-merge-test
23
+ rm -rf "$T"
24
+ mkdir -p "$T"
25
+ cd "$T"
26
+ git init -q -b main .
27
+ git config user.email test@test.t
28
+ git config user.name test
29
+ echo x > f.txt
30
+ echo ".worktrees/" > .gitignore
31
+ git add -A
32
+ git commit -qm init
33
+
34
+ export CREW_REPO="$T"
35
+ tid="verifytest1"
36
+
37
+ # 1. missing branch → ERROR, non-zero
38
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "verify-merge on missing branch exited 0"
39
+ echo "$out" | grep -q '^ERROR: branch task/verifytest1 does not exist' || fail "missing branch: expected ERROR, got: $out"
40
+
41
+ # 2. prepare (empty diff) → VERIFIED, exit 0
42
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "prepare exited non-zero: $out"
43
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge on empty-diff branch exited non-zero: $out"
44
+ echo "$out" | grep -q '^VERIFIED:' || fail "empty-diff: expected VERIFIED, got: $out"
45
+
46
+ # 3. commit on the branch, not merged → NOT_MERGED, non-zero
47
+ echo change >> "$T/.worktrees/$tid/f.txt"
48
+ (cd "$T/.worktrees/$tid" && git commit -qam work)
49
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "verify-merge on unmerged branch exited 0"
50
+ echo "$out" | grep -q '^NOT_MERGED:' || fail "unmerged: expected NOT_MERGED, got: $out"
51
+ echo "$out" | grep -q '1 commit(s) still ahead of main' || fail "unmerged: expected ahead count, got: $out"
52
+
53
+ # 4. merge the branch → VERIFIED, exit 0
54
+ (cd "$T" && git merge -q --no-ff "task/$tid" -m "merge: $tid")
55
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge after merge exited non-zero: $out"
56
+ echo "$out" | grep -q '^VERIFIED:' || fail "merged: expected VERIFIED, got: $out"
57
+
58
+ # 5. registry-divergent branch name → resolve_branch wins over reconstruction
59
+ tid2="verifytest2"
60
+ out=$(bash "$LIFECYCLE" prepare "$tid2") || fail "prepare 2 exited non-zero: $out"
61
+ (cd "$T" && git branch -m "task/$tid2" "custom/$tid2")
62
+ sed -i "s|^branch=task/$tid2\$|branch=custom/$tid2|" "$T/.worktrees/.registry/$tid2"
63
+ echo change2 >> "$T/.worktrees/$tid2/f.txt"
64
+ (cd "$T/.worktrees/$tid2" && git commit -qam work2)
65
+ out=$(bash "$LIFECYCLE" verify-merge "$tid2" 2>&1) && fail "verify-merge on divergent unmerged branch exited 0"
66
+ echo "$out" | grep -q '^NOT_MERGED: custom/verifytest2 ' || fail "divergent: expected NOT_MERGED on custom/verifytest2, got: $out"
67
+
68
+ # 6. invalid task id rejected
69
+ bash "$LIFECYCLE" verify-merge "bad id!" >/dev/null 2>&1 && fail "verify-merge accepted a bad id"
70
+
71
+ bash "$LIFECYCLE" cleanup "$tid" >/dev/null 2>&1 || true
72
+ (cd "$T" && git branch -D "custom/$tid2" >/dev/null 2>&1) || true
73
+ rm -rf "$T/.worktrees/$tid2" "$T/.worktrees/.registry/$tid2"
74
+ rm -rf "$T"
75
+ echo "verify-merge: OK"
@@ -15,6 +15,7 @@
15
15
  # prepare <task_id> — create worktree + branch, register ownership
16
16
  # inspect <task_id> — diff against main for review
17
17
  # integrate <task_id> <commit_msg> — merge lock → merge into main → report commit
18
+ # verify-merge <task_id> — confirm the task branch tip is an ancestor of main (post-step merge check)
18
19
  # post-deploy <task_id> — commit builder artifacts, cleanup, release lock
19
20
  # cleanup <task_id> — remove worktree + branch (no lock)
20
21
  # status <task_id> — report worktree state
@@ -250,6 +251,41 @@ cmd_integrate() {
250
251
  echo "LOCK: held by $task_id — release after deploy with post-deploy"
251
252
  }
252
253
 
254
+ cmd_verify_merge() {
255
+ local task_id="$1"
256
+ validate_task_id "$task_id"
257
+
258
+ # Resolve through the registry — never reconstruct "task/<id>" here. The
259
+ # workflow's TASK_BRANCH constant and the registry can diverge, and
260
+ # verifying the wrong branch is the same bug in different clothes
261
+ # (b1b1f919).
262
+ local branch
263
+ branch=$(resolve_branch "$task_id")
264
+
265
+ cd "$REPO"
266
+
267
+ if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
268
+ echo "ERROR: branch $branch does not exist"
269
+ return 1
270
+ fi
271
+
272
+ local tip
273
+ tip=$(git rev-parse "$branch")
274
+
275
+ # The merge landed iff the task branch tip is an ancestor of main. A
276
+ # genuine empty-diff Integrate (MERGED_EMPTY: no commits ahead of main)
277
+ # verifies vacuously — the tip is then main itself or behind it.
278
+ if git merge-base --is-ancestor "$branch" main 2>/dev/null; then
279
+ echo "VERIFIED: $branch tip $tip is an ancestor of main — merge landed"
280
+ return 0
281
+ fi
282
+
283
+ local ahead
284
+ ahead=$(git rev-list --count "main..$branch")
285
+ echo "NOT_MERGED: $branch tip $tip is not an ancestor of main — $ahead commit(s) still ahead of main"
286
+ return 1
287
+ }
288
+
253
289
  cmd_post_deploy() {
254
290
  local task_id="$1"
255
291
  validate_task_id "$task_id"
@@ -388,6 +424,7 @@ case "$cmd" in
388
424
  prepare) cmd_prepare "${1:?task_id required}" ;;
389
425
  inspect) cmd_inspect "${1:?task_id required}" ;;
390
426
  integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
427
+ verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
391
428
  post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
392
429
  cleanup) cmd_cleanup "${1:?task_id required}" ;;
393
430
  status) cmd_status "${1:?task_id required}" ;;
@@ -396,7 +433,7 @@ case "$cmd" in
396
433
  sweep) cmd_sweep "${1:-report}" ;;
397
434
  *)
398
435
  echo "Usage: worktree-lifecycle.sh <command> [args]"
399
- echo "Commands: validate, prepare, inspect, integrate, post-deploy, cleanup, status, refresh-lock, lock-status, sweep"
436
+ echo "Commands: validate, prepare, inspect, integrate, verify-merge, post-deploy, cleanup, status, refresh-lock, lock-status, sweep"
400
437
  exit 1
401
438
  ;;
402
439
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -1196,8 +1196,7 @@ while (i < STEPS.length) {
1196
1196
  "Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}.\n" +
1197
1197
  "If provenance is null, report 'provenance missing — publish did not stamp', then end your report with exactly this line: VERDICT: FAIL.\n" +
1198
1198
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
1199
- "Run: basename $(readlink " + crewHome + "/current) call this LIVE_CREW.\n" +
1200
- "If provenance.crew_release does not equal LIVE_CREW, report 'provenance mismatch: crew_release [value from getprovenance] != [LIVE_CREW]', then end your report with exactly this line: VERDICT: FAIL.\n" +
1199
+ "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, report 'provenance mismatch: crew_release [value from getprovenance] not found in release registry', then end your report with exactly this line: VERDICT: FAIL.\n" +
1201
1200
  "If provenance.source_commit equals LIVE_HEAD, the source check passes.\n" +
1202
1201
  "Otherwise the check is NOT failed yet: post-deploy commits an artifact-builder staging commit (\"rebuild: <task_id>\") AFTER the stamp, so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1203
1202
  "1. Run: cd " + REPO_PATH + " && git merge-base --is-ancestor <provenance.source_commit> LIVE_HEAD && echo ANCESTOR_OK (substitute the real stamped hash and LIVE_HEAD; do not run the literal placeholders). If this command fails, report 'provenance mismatch: stamped source_commit is not an ancestor of live HEAD', then end your report with exactly this line: VERDICT: FAIL.\n" +
@@ -1342,7 +1341,47 @@ while (i < STEPS.length) {
1342
1341
  passed: verdictPassed !== null ? verdictPassed : true,
1343
1342
  summary: workerText
1344
1343
  };
1345
- const passed = stepResult.passed === true;
1344
+ let passed = stepResult.passed === true;
1345
+
1346
+ // Deterministic integrate verification: the agent cannot self-certify a
1347
+ // merge. After the Integrate agent claims success, the workflow confirms
1348
+ // mechanically that the task branch tip is an ancestor of main via the
1349
+ // lifecycle script's verify-merge command (which resolves the branch
1350
+ // through the crew registry — never by reconstructing "task/"+taskId — so
1351
+ // the check cannot verify the wrong branch). The VERIFIED marker is matched
1352
+ // by regex on the script's own stdout; agent prose is never read. This
1353
+ // closes the hole where an agent reported "merged empty" while approved
1354
+ // commits were still stranded on the task branch (bug b1b1f919). A genuine
1355
+ // empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
1356
+ // vacuously — the tip is then an ancestor of main. Verification failure is
1357
+ // an operational step failure, not a park: the dispatcher retries Integrate
1358
+ // under its consecutive-failure cap, and the retry finds the commits still
1359
+ // on the branch and performs the real merge — self-healing.
1360
+ if (step.name === "Integrate" && passed) {
1361
+ var integrateVerifyOut = "";
1362
+ try {
1363
+ var integrateVerifyResult = await agent(
1364
+ "Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
1365
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
1366
+ { key: attemptKey("verify-merge-" + taskId, totalReworkCount), label: "Verifying merge landed",
1367
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1368
+ );
1369
+ integrateVerifyOut = (integrateVerifyResult.output || "").trim();
1370
+ } catch (e) {
1371
+ integrateVerifyOut = "";
1372
+ }
1373
+ if (/^VERIFIED:/m.test(integrateVerifyOut)) {
1374
+ log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
1375
+ } else {
1376
+ var integrateVerifyReason = integrateVerifyOut
1377
+ ? integrateVerifyOut.split("\n")[0].slice(0, 200)
1378
+ : "verify-merge produced no usable output";
1379
+ log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
1380
+ stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
1381
+ passed = false;
1382
+ }
1383
+ }
1384
+
1346
1385
  // "rejected" is an explicit phase verdict routed through rework (Review/QA,
1347
1386
  // and the Build/Reproduce reports that feed them). Integrate/Publish work
1348
1387
  // that did not finish is operational — "failed", retryable under the
@@ -1218,7 +1218,47 @@ while (i < STEPS.length) {
1218
1218
  passed: verdictPassed !== null ? verdictPassed : true,
1219
1219
  summary: workerText
1220
1220
  };
1221
- const passed = stepResult.passed === true;
1221
+ let passed = stepResult.passed === true;
1222
+
1223
+ // Deterministic integrate verification: the agent cannot self-certify a
1224
+ // merge. After the Integrate agent claims success, the workflow confirms
1225
+ // mechanically that the task branch tip is an ancestor of main via the
1226
+ // lifecycle script's verify-merge command (which resolves the branch
1227
+ // through the crew registry — never by reconstructing "task/"+taskId — so
1228
+ // the check cannot verify the wrong branch). The VERIFIED marker is matched
1229
+ // by regex on the script's own stdout; agent prose is never read. This
1230
+ // closes the hole where an agent reported "merged empty" while approved
1231
+ // commits were still stranded on the task branch (bug b1b1f919). A genuine
1232
+ // empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
1233
+ // vacuously — the tip is then an ancestor of main. Verification failure is
1234
+ // an operational step failure, not a park: the dispatcher retries Integrate
1235
+ // under its consecutive-failure cap, and the retry finds the commits still
1236
+ // on the branch and performs the real merge — self-healing.
1237
+ if (step.name === "Integrate" && passed) {
1238
+ var integrateVerifyOut = "";
1239
+ try {
1240
+ var integrateVerifyResult = await agent(
1241
+ "Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
1242
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
1243
+ { key: attemptKey("verify-merge-" + taskId, reworkCount), label: "Verifying merge landed",
1244
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1245
+ );
1246
+ integrateVerifyOut = (integrateVerifyResult.output || "").trim();
1247
+ } catch (e) {
1248
+ integrateVerifyOut = "";
1249
+ }
1250
+ if (/^VERIFIED:/m.test(integrateVerifyOut)) {
1251
+ log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
1252
+ } else {
1253
+ var integrateVerifyReason = integrateVerifyOut
1254
+ ? integrateVerifyOut.split("\n")[0].slice(0, 200)
1255
+ : "verify-merge produced no usable output";
1256
+ log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
1257
+ stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
1258
+ passed = false;
1259
+ }
1260
+ }
1261
+
1222
1262
  // "rejected" is an explicit phase verdict routed through rework (Review).
1223
1263
  // Integrate/Publish work that did not finish is operational — "failed",
1224
1264
  // retryable under the dispatcher's consecutive-failure cap.
@@ -1166,8 +1166,7 @@ while (i < STEPS.length) {
1166
1166
  "PROVENANCE CHECK: Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}.\n" +
1167
1167
  "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
1168
1168
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
1169
- "Run: basename $(readlink " + crewHome + "/current) call this LIVE_CREW.\n" +
1170
- "If provenance.crew_release does not equal LIVE_CREW, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] != [LIVE_CREW]\" }.\n" +
1169
+ "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] not found in release registry\" }.\n" +
1171
1170
  "If provenance.source_commit equals LIVE_HEAD, the source check passes — continue to STEP 3.\n" +
1172
1171
  "Otherwise the check is NOT failed yet: post-deploy commits an artifact-builder staging commit (\"rebuild: <task_id>\") AFTER the stamp, so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1173
1172
  "1. Run: cd " + REPO_PATH + " && git merge-base --is-ancestor <provenance.source_commit> LIVE_HEAD && echo ANCESTOR_OK (substitute the real stamped hash and LIVE_HEAD; do not run the literal placeholders). If this command fails, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: stamped source_commit is not an ancestor of live HEAD\" }.\n" +
@@ -1201,8 +1200,7 @@ while (i < STEPS.length) {
1201
1200
  "PROVENANCE CHECK: Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}.\n" +
1202
1201
  "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
1203
1202
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
1204
- "Run: basename $(readlink " + crewHome + "/current) call this LIVE_CREW.\n" +
1205
- "If provenance.crew_release does not equal LIVE_CREW, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] != [LIVE_CREW]\" }.\n" +
1203
+ "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] not found in release registry\" }.\n" +
1206
1204
  "If provenance.source_commit equals LIVE_HEAD, the source check passes — continue to STEP 3.\n" +
1207
1205
  "Otherwise the check is NOT failed yet: post-deploy commits an artifact-builder staging commit (\"rebuild: <task_id>\") AFTER the stamp, so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1208
1206
  "1. Run: cd " + REPO_PATH + " && git merge-base --is-ancestor <provenance.source_commit> LIVE_HEAD && echo ANCESTOR_OK (substitute the real stamped hash and LIVE_HEAD; do not run the literal placeholders). If this command fails, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: stamped source_commit is not an ancestor of live HEAD\" }.\n" +
@@ -1352,7 +1350,47 @@ while (i < STEPS.length) {
1352
1350
  passed: verdictPassed !== null ? verdictPassed : true,
1353
1351
  summary: workerText
1354
1352
  };
1355
- const passed = stepResult.passed === true;
1353
+ let passed = stepResult.passed === true;
1354
+
1355
+ // Deterministic integrate verification: the agent cannot self-certify a
1356
+ // merge. After the Integrate agent claims success, the workflow confirms
1357
+ // mechanically that the task branch tip is an ancestor of main via the
1358
+ // lifecycle script's verify-merge command (which resolves the branch
1359
+ // through the crew registry — never by reconstructing "task/"+taskId — so
1360
+ // the check cannot verify the wrong branch). The VERIFIED marker is matched
1361
+ // by regex on the script's own stdout; agent prose is never read. This
1362
+ // closes the hole where an agent reported "merged empty" while approved
1363
+ // commits were still stranded on the task branch (bug b1b1f919). A genuine
1364
+ // empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
1365
+ // vacuously — the tip is then an ancestor of main. Verification failure is
1366
+ // an operational step failure, not a park: the dispatcher retries Integrate
1367
+ // under its consecutive-failure cap, and the retry finds the commits still
1368
+ // on the branch and performs the real merge — self-healing.
1369
+ if (step.name === "Integrate" && passed) {
1370
+ var integrateVerifyOut = "";
1371
+ try {
1372
+ var integrateVerifyResult = await agent(
1373
+ "Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
1374
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
1375
+ { key: attemptKey("verify-merge-" + taskId, totalReworkCount), label: "Verifying merge landed",
1376
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1377
+ );
1378
+ integrateVerifyOut = (integrateVerifyResult.output || "").trim();
1379
+ } catch (e) {
1380
+ integrateVerifyOut = "";
1381
+ }
1382
+ if (/^VERIFIED:/m.test(integrateVerifyOut)) {
1383
+ log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
1384
+ } else {
1385
+ var integrateVerifyReason = integrateVerifyOut
1386
+ ? integrateVerifyOut.split("\n")[0].slice(0, 200)
1387
+ : "verify-merge produced no usable output";
1388
+ log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
1389
+ stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
1390
+ passed = false;
1391
+ }
1392
+ }
1393
+
1356
1394
  // "rejected" is an explicit phase verdict routed through rework (Review/QA,
1357
1395
  // and the Build/Reproduce reports that feed them). Integrate/Publish work
1358
1396
  // that did not finish is operational — "failed", retryable under the