muse-crew 0.6.1 → 0.6.3

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.1",
3
+ "version": "0.6.3",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -323,8 +323,14 @@ function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets
323
323
  // opt-in — a missing or garbled line degrades to "unknown", which callers
324
324
  // treat as non-experiential. The task is never parked on a garbled line.
325
325
  async function resolveExperiential() {
326
- if (isExperiential === true) return "yes";
327
- if (isExperiential === false) return "no";
326
+ // Triage notes are immutable within a run: cache the resolved outcome —
327
+ // yes, no, AND unknown — so the dashboard lookup runs at most once per
328
+ // run. Without the unknown branch a missing experiential marker would
329
+ // re-fire the same "resolve-experiential-<taskId>" agent key on every
330
+ // Capture/Map entry and throw a duplicate replay key error.
331
+ if (experientialResolved !== null) return experientialResolved;
332
+ if (isExperiential === true) return (experientialResolved = "yes");
333
+ if (isExperiential === false) return (experientialResolved = "no");
328
334
  var expCheck = null;
329
335
  try {
330
336
  expCheck = await agent(
@@ -340,12 +346,14 @@ async function resolveExperiential() {
340
346
  );
341
347
  } catch (e) {
342
348
  log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
343
- return "unknown";
349
+ experientialResolved = "unknown";
350
+ return experientialResolved;
344
351
  }
345
352
  var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
346
- if (marker === true) return "yes";
347
- if (marker === false) return "no";
348
- return "unknown";
353
+ if (marker === true) experientialResolved = "yes";
354
+ else if (marker === false) experientialResolved = "no";
355
+ else experientialResolved = "unknown";
356
+ return experientialResolved;
349
357
  }
350
358
  // Baseline evidence status: reads the task's note events for the exact
351
359
  // protocol prefixes (explicit state, never English matching). Returns
@@ -456,6 +464,9 @@ let mapperSpec = "";
456
464
  // plan, and the Map-gate bounce counter (keeps agent stable-keys unique when
457
465
  // a Map-gate bounce re-runs Capture/Map in the same run).
458
466
  let isExperiential = null;
467
+ // Caches all three resolveExperiential() outcomes (yes/no/unknown); Triage
468
+ // notes are immutable within a run, so the lookup runs at most once.
469
+ let experientialResolved = null;
459
470
  let captureTargets = "";
460
471
  let mapGateBounceCount = 0;
461
472
  // Merge-time versioning: the release decision is extracted deterministically
@@ -544,7 +555,7 @@ while (i < STEPS.length) {
544
555
  "Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
545
556
  "Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
546
557
  {
547
- key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
558
+ key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
548
559
  label: "Checking project before " + step.name,
549
560
  schema: { type: "object", properties: { project: { type: "string" } }, required: ["project"] }
550
561
  }
@@ -1331,7 +1342,47 @@ while (i < STEPS.length) {
1331
1342
  passed: verdictPassed !== null ? verdictPassed : true,
1332
1343
  summary: workerText
1333
1344
  };
1334
- const passed = stepResult.passed === true;
1345
+ let passed = stepResult.passed === true;
1346
+
1347
+ // Deterministic integrate verification: the agent cannot self-certify a
1348
+ // merge. After the Integrate agent claims success, the workflow confirms
1349
+ // mechanically that the task branch tip is an ancestor of main via the
1350
+ // lifecycle script's verify-merge command (which resolves the branch
1351
+ // through the crew registry — never by reconstructing "task/"+taskId — so
1352
+ // the check cannot verify the wrong branch). The VERIFIED marker is matched
1353
+ // by regex on the script's own stdout; agent prose is never read. This
1354
+ // closes the hole where an agent reported "merged empty" while approved
1355
+ // commits were still stranded on the task branch (bug b1b1f919). A genuine
1356
+ // empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
1357
+ // vacuously — the tip is then an ancestor of main. Verification failure is
1358
+ // an operational step failure, not a park: the dispatcher retries Integrate
1359
+ // under its consecutive-failure cap, and the retry finds the commits still
1360
+ // on the branch and performs the real merge — self-healing.
1361
+ if (step.name === "Integrate" && passed) {
1362
+ var integrateVerifyOut = "";
1363
+ try {
1364
+ var integrateVerifyResult = await agent(
1365
+ "Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
1366
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
1367
+ { key: attemptKey("verify-merge-" + taskId, totalReworkCount), label: "Verifying merge landed",
1368
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1369
+ );
1370
+ integrateVerifyOut = (integrateVerifyResult.output || "").trim();
1371
+ } catch (e) {
1372
+ integrateVerifyOut = "";
1373
+ }
1374
+ if (/^VERIFIED:/m.test(integrateVerifyOut)) {
1375
+ log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
1376
+ } else {
1377
+ var integrateVerifyReason = integrateVerifyOut
1378
+ ? integrateVerifyOut.split("\n")[0].slice(0, 200)
1379
+ : "verify-merge produced no usable output";
1380
+ log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
1381
+ stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
1382
+ passed = false;
1383
+ }
1384
+ }
1385
+
1335
1386
  // "rejected" is an explicit phase verdict routed through rework (Review/QA,
1336
1387
  // and the Build/Reproduce reports that feed them). Integrate/Publish work
1337
1388
  // that did not finish is operational — "failed", retryable under the
@@ -321,8 +321,14 @@ function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets
321
321
  // opt-in — a missing or garbled line degrades to "unknown", which callers
322
322
  // treat as non-experiential. The task is never parked on a garbled line.
323
323
  async function resolveExperiential() {
324
- if (isExperiential === true) return "yes";
325
- if (isExperiential === false) return "no";
324
+ // Triage notes are immutable within a run: cache the resolved outcome —
325
+ // yes, no, AND unknown — so the dashboard lookup runs at most once per
326
+ // run. Without the unknown branch a missing experiential marker would
327
+ // re-fire the same "resolve-experiential-<taskId>" agent key on every
328
+ // Capture/Map entry and throw a duplicate replay key error.
329
+ if (experientialResolved !== null) return experientialResolved;
330
+ if (isExperiential === true) return (experientialResolved = "yes");
331
+ if (isExperiential === false) return (experientialResolved = "no");
326
332
  var expCheck = null;
327
333
  try {
328
334
  expCheck = await agent(
@@ -338,12 +344,14 @@ async function resolveExperiential() {
338
344
  );
339
345
  } catch (e) {
340
346
  log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
341
- return "unknown";
347
+ experientialResolved = "unknown";
348
+ return experientialResolved;
342
349
  }
343
350
  var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
344
- if (marker === true) return "yes";
345
- if (marker === false) return "no";
346
- return "unknown";
351
+ if (marker === true) experientialResolved = "yes";
352
+ else if (marker === false) experientialResolved = "no";
353
+ else experientialResolved = "unknown";
354
+ return experientialResolved;
347
355
  }
348
356
  // Baseline evidence status: reads the task's note events for the exact
349
357
  // protocol prefixes (explicit state, never English matching). Returns
@@ -414,6 +422,9 @@ let mapperSpec = "";
414
422
  // a Map-gate bounce re-runs Capture/Map in the same run). Chore has no QA
415
423
  // phase, so there is no visual-verdict function — Capture only.
416
424
  let isExperiential = null;
425
+ // Caches all three resolveExperiential() outcomes (yes/no/unknown); Triage
426
+ // notes are immutable within a run, so the lookup runs at most once.
427
+ let experientialResolved = null;
417
428
  let captureTargets = "";
418
429
  let mapGateBounceCount = 0;
419
430
  // Merge-time versioning: the release decision is extracted deterministically
@@ -502,7 +513,7 @@ while (i < STEPS.length) {
502
513
  "Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
503
514
  "Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
504
515
  {
505
- key: "project-check-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
516
+ key: "project-check-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
506
517
  label: "Checking project before " + step.name,
507
518
  schema: { type: "object", properties: { project: { type: "string" } }, required: ["project"] }
508
519
  }
@@ -1207,7 +1218,47 @@ while (i < STEPS.length) {
1207
1218
  passed: verdictPassed !== null ? verdictPassed : true,
1208
1219
  summary: workerText
1209
1220
  };
1210
- 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
+
1211
1262
  // "rejected" is an explicit phase verdict routed through rework (Review).
1212
1263
  // Integrate/Publish work that did not finish is operational — "failed",
1213
1264
  // retryable under the dispatcher's consecutive-failure cap.
@@ -323,8 +323,14 @@ function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets
323
323
  // opt-in — a missing or garbled line degrades to "unknown", which callers
324
324
  // treat as non-experiential. The task is never parked on a garbled line.
325
325
  async function resolveExperiential() {
326
- if (isExperiential === true) return "yes";
327
- if (isExperiential === false) return "no";
326
+ // Triage notes are immutable within a run: cache the resolved outcome —
327
+ // yes, no, AND unknown — so the dashboard lookup runs at most once per
328
+ // run. Without the unknown branch a missing experiential marker would
329
+ // re-fire the same "resolve-experiential-<taskId>" agent key on every
330
+ // Capture/Map entry and throw a duplicate replay key error.
331
+ if (experientialResolved !== null) return experientialResolved;
332
+ if (isExperiential === true) return (experientialResolved = "yes");
333
+ if (isExperiential === false) return (experientialResolved = "no");
328
334
  var expCheck = null;
329
335
  try {
330
336
  expCheck = await agent(
@@ -340,12 +346,14 @@ async function resolveExperiential() {
340
346
  );
341
347
  } catch (e) {
342
348
  log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
343
- return "unknown";
349
+ experientialResolved = "unknown";
350
+ return experientialResolved;
344
351
  }
345
352
  var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
346
- if (marker === true) return "yes";
347
- if (marker === false) return "no";
348
- return "unknown";
353
+ if (marker === true) experientialResolved = "yes";
354
+ else if (marker === false) experientialResolved = "no";
355
+ else experientialResolved = "unknown";
356
+ return experientialResolved;
349
357
  }
350
358
  // Baseline evidence status: reads the task's note events for the exact
351
359
  // protocol prefixes (explicit state, never English matching). Returns
@@ -455,6 +463,9 @@ let mapperSpec = "";
455
463
  // plan, and the Map-gate bounce counter (keeps agent stable-keys unique when
456
464
  // a Map-gate bounce re-runs Capture/Map in the same run).
457
465
  let isExperiential = null;
466
+ // Caches all three resolveExperiential() outcomes (yes/no/unknown); Triage
467
+ // notes are immutable within a run, so the lookup runs at most once.
468
+ let experientialResolved = null;
458
469
  let captureTargets = "";
459
470
  let mapGateBounceCount = 0;
460
471
  // Merge-time versioning: the release decision is extracted deterministically
@@ -543,7 +554,7 @@ while (i < STEPS.length) {
543
554
  "Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
544
555
  "Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
545
556
  {
546
- key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
557
+ key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
547
558
  label: "Checking project before " + step.name,
548
559
  schema: { type: "object", properties: { project: { type: "string" } }, required: ["project"] }
549
560
  }
@@ -1341,7 +1352,47 @@ while (i < STEPS.length) {
1341
1352
  passed: verdictPassed !== null ? verdictPassed : true,
1342
1353
  summary: workerText
1343
1354
  };
1344
- const passed = stepResult.passed === true;
1355
+ let passed = stepResult.passed === true;
1356
+
1357
+ // Deterministic integrate verification: the agent cannot self-certify a
1358
+ // merge. After the Integrate agent claims success, the workflow confirms
1359
+ // mechanically that the task branch tip is an ancestor of main via the
1360
+ // lifecycle script's verify-merge command (which resolves the branch
1361
+ // through the crew registry — never by reconstructing "task/"+taskId — so
1362
+ // the check cannot verify the wrong branch). The VERIFIED marker is matched
1363
+ // by regex on the script's own stdout; agent prose is never read. This
1364
+ // closes the hole where an agent reported "merged empty" while approved
1365
+ // commits were still stranded on the task branch (bug b1b1f919). A genuine
1366
+ // empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
1367
+ // vacuously — the tip is then an ancestor of main. Verification failure is
1368
+ // an operational step failure, not a park: the dispatcher retries Integrate
1369
+ // under its consecutive-failure cap, and the retry finds the commits still
1370
+ // on the branch and performs the real merge — self-healing.
1371
+ if (step.name === "Integrate" && passed) {
1372
+ var integrateVerifyOut = "";
1373
+ try {
1374
+ var integrateVerifyResult = await agent(
1375
+ "Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
1376
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
1377
+ { key: attemptKey("verify-merge-" + taskId, totalReworkCount), label: "Verifying merge landed",
1378
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1379
+ );
1380
+ integrateVerifyOut = (integrateVerifyResult.output || "").trim();
1381
+ } catch (e) {
1382
+ integrateVerifyOut = "";
1383
+ }
1384
+ if (/^VERIFIED:/m.test(integrateVerifyOut)) {
1385
+ log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
1386
+ } else {
1387
+ var integrateVerifyReason = integrateVerifyOut
1388
+ ? integrateVerifyOut.split("\n")[0].slice(0, 200)
1389
+ : "verify-merge produced no usable output";
1390
+ log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
1391
+ stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
1392
+ passed = false;
1393
+ }
1394
+ }
1395
+
1345
1396
  // "rejected" is an explicit phase verdict routed through rework (Review/QA,
1346
1397
  // and the Build/Reproduce reports that feed them). Integrate/Publish work
1347
1398
  // that did not finish is operational — "failed", retryable under the