muse-crew 0.14.9 → 0.14.10

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.
@@ -45,6 +45,48 @@ grep -q "^path=$T/.worktrees/$tid\$" "$T/.worktrees/.registry/$tid" || fail "pre
45
45
  out=$(bash "$LIFECYCLE" prepare "$tid") || fail "prepare (reuse) exited non-zero"
46
46
  echo "$out" | grep -q '^REUSED' || fail "prepare: expected REUSED on second run: $out"
47
47
 
48
+ # Blocker 45 (2026-09-22, room #29 J1): prepare must verify its REUSED
49
+ # invariant. An agent-improvised branch (raw git, bypassing prepare) gets
50
+ # REPAIRED — registry re-registered under the actual branch, never deleted —
51
+ # never silently REUSED.
52
+ tid2="backendtest45"
53
+ out=$(bash "$LIFECYCLE" prepare "$tid2") || fail "prepare (45) exited non-zero: $out"
54
+ echo "$out" | grep -q '^CREATED' || fail "prepare (45): expected CREATED, got: $out"
55
+ # The agent improvises: checks out a branch outside the task/ namespace and
56
+ # commits there, bypassing prepare entirely.
57
+ git -C "$T/.worktrees/$tid2" checkout -qb crew-9eda33ca
58
+ echo improvised >> "$T/.worktrees/$tid2/f.txt"
59
+ (git -C "$T/.worktrees/$tid2" commit -qam improvised) || fail "improvised commit failed"
60
+ out=$(bash "$LIFECYCLE" prepare "$tid2") || fail "prepare (45 repair) exited non-zero: $out"
61
+ echo "$out" | grep -q '^REPAIRED' || fail "prepare: expected REPAIRED on improvised branch, got: $out"
62
+ echo "$out" | grep -q '^REUSED' && fail "prepare: must not print REUSED when the branch invariant fails"
63
+ # The agent's branch survives; the registry attributes the repair honestly.
64
+ git -C "$T" rev-parse --verify "crew-9eda33ca" >/dev/null 2>&1 || fail "repair: agent branch deleted"
65
+ grep -q '^branch=crew-9eda33ca$' "$T/.worktrees/.registry/$tid2" || fail "repair: registry branch not re-registered"
66
+ grep -q '^task_id=backendtest45$' "$T/.worktrees/.registry/$tid2" || fail "repair: registry lost task attribution"
67
+ base_before=$(grep '^base_commit=' "$T/.worktrees/.registry/$tid2")
68
+ # The repair is idempotent: a second prepare repairs again (no spurious
69
+ # REUSED), preserving the registry's other fields.
70
+ out=$(bash "$LIFECYCLE" prepare "$tid2") || fail "prepare (45 second repair) exited non-zero: $out"
71
+ echo "$out" | grep -q '^REPAIRED' || fail "prepare: expected idempotent REPAIRED, got: $out"
72
+ base_after=$(grep '^base_commit=' "$T/.worktrees/.registry/$tid2")
73
+ [ "$base_before" = "$base_after" ] || fail "repair: re-register must preserve base_commit"
74
+ # A worktree back on the canonical branch is REUSED with no repair.
75
+ git -C "$T/.worktrees/$tid2" checkout -q "task/$tid2"
76
+ out=$(bash "$LIFECYCLE" prepare "$tid2") || fail "prepare (45 canonical) exited non-zero: $out"
77
+ echo "$out" | grep -q '^REUSED' || fail "prepare: expected REUSED on canonical branch, got: $out"
78
+ echo "$out" | grep -q '^REPAIRED' && fail "prepare: no spurious REPAIRED on a canonical worktree"
79
+ # resolve-branch falls back to the worktree's actual checked-out branch when
80
+ # the registry is absent and no canonical/prefix ref matches — the
81
+ # classifier's mechanical truth of last resort (not "branch does not exist").
82
+ git -C "$T/.worktrees/$tid2" checkout -qb crew-deadbeef
83
+ rm -f "$T/.worktrees/.registry/$tid2"
84
+ git -C "$T" branch -D -q "task/$tid2"
85
+ resolved=$(bash "$LIFECYCLE" resolve-branch "$tid2") || fail "resolve-branch (45) exited non-zero"
86
+ [ "$resolved" = "crew-deadbeef" ] || fail "resolve-branch: expected the actual branch, got: $resolved"
87
+ bash "$LIFECYCLE" cleanup "$tid2" >/dev/null || fail "cleanup (45) failed"
88
+ [ -d "$T/.worktrees/$tid2" ] && fail "cleanup (45): worktree still exists"
89
+
48
90
  # inspect: diffs the task branch against main
49
91
  echo change >> "$T/.worktrees/$tid/f.txt"
50
92
  (cd "$T/.worktrees/$tid" && git commit -qam work)
@@ -12,11 +12,23 @@
12
12
  # bypassing prepare — task 4e1a1bba, 2026-09-17), resolve_branch() falls
13
13
  # back to enumerating refs: the canonical task/<full-id> ref wins, then a
14
14
  # task/<id-prefix> ref is tolerated (matched by strict prefix, never
15
- # created). Canonical stays task/<full-id>; ambiguous prefixes fail closed.
15
+ # created), then the existing worktree's actual checked-out branch
16
+ # (blocker 45, 2026-09-22 — an agent-improvised branch outside the task/
17
+ # namespace is invisible to the prefix scan; the classifier must see the
18
+ # world, not "branch does not exist"). Canonical stays task/<full-id>;
19
+ # ambiguous prefixes fail closed.
20
+ #
21
+ # prepare's reuse contract (blocker 45, 2026-09-22): an existing worktree
22
+ # prints REUSED only when it is checked out on the canonical task/<id>
23
+ # branch. A worktree checked out on another branch (an agent improvised
24
+ # with raw git) is repaired — the registry is re-registered under the
25
+ # actual branch (the branch is never deleted: its commits may be the
26
+ # task's work) — and prepare prints REPAIRED instead of REUSED, so the
27
+ # repair is attributable in the record.
16
28
  #
17
29
  # Commands:
18
30
  # validate <task_id> — check task ID is safe for branches/paths
19
- # prepare <task_id> — create worktree + branch, register ownership
31
+ # prepare <task_id> — create worktree + branch, register ownership (existing worktree: REUSED, or REPAIRED when re-registered under an improvised branch)
20
32
  # resolve-branch <task_id> — print the resolved task branch name
21
33
  # inspect <task_id> — diff against the integration target for review
22
34
  # classify-branch <task_id> — mechanical branch-state classification for Review:
@@ -136,6 +148,17 @@ resolve_branch() {
136
148
  echo "ERROR: task id '$task_id' matches multiple task branches: ${matches[*]} — ambiguous, refusing to guess" >&2
137
149
  return 1
138
150
  fi
151
+ # Blocker 45 (2026-09-22): the worktree's actual checkout is the mechanical
152
+ # truth of last resort. An agent-improvised branch (outside the task/
153
+ # namespace) is invisible to the prefix scan above — the classifier must
154
+ # see the world, not "branch does not exist".
155
+ if [ -d "$WORKTREE_DIR/$task_id" ]; then
156
+ local actual
157
+ if actual=$(git -C "$WORKTREE_DIR/$task_id" rev-parse --abbrev-ref HEAD 2>/dev/null) && [ -n "$actual" ]; then
158
+ printf '%s' "$actual"
159
+ return 0
160
+ fi
161
+ fi
139
162
  printf '%s' "$canonical"
140
163
  }
141
164
 
@@ -239,6 +262,25 @@ unregister() {
239
262
  rm -f "$REGISTRY_DIR/$1"
240
263
  }
241
264
 
265
+ # Blocker 45 (2026-09-22): repair the registry's branch attribution without
266
+ # touching anything else. An existing entry keeps its task_id, base_commit,
267
+ # and created fields — only branch= is rewritten. A missing entry is
268
+ # registered fresh (base_commit "unknown" — honest, not fabricated). The
269
+ # agent's branch is never deleted: its commits may be the task's work.
270
+ reregister_branch() {
271
+ local task_id="$1" branch="$2"
272
+ local reg="$REGISTRY_DIR/$task_id"
273
+ mkdir -p "$REGISTRY_DIR"
274
+ if [ -f "$reg" ] && grep -q '^branch=' "$reg"; then
275
+ # Escape sed-replacement metacharacters (branch names can carry &).
276
+ local esc=${branch//\\/\\\\}
277
+ esc=${esc//&/\\&}
278
+ sed -i "s|^branch=.*|branch=$esc|" "$reg"
279
+ else
280
+ register "$task_id" "unknown" "$branch" "$WORKTREE_DIR/$task_id"
281
+ fi
282
+ }
283
+
242
284
  # --- commands ---
243
285
 
244
286
  cmd_validate() {
@@ -256,9 +298,27 @@ cmd_prepare() {
256
298
 
257
299
  # Reuse existing worktree
258
300
  if [ -d "$WORKTREE_DIR/$task_id" ]; then
259
- echo "REUSED: worktree exists at .worktrees/$task_id"
260
- local head
301
+ local head actual_branch canonical_branch
261
302
  head=$(cd "$WORKTREE_DIR/$task_id" && git rev-parse HEAD)
303
+ if ! actual_branch=$(cd "$WORKTREE_DIR/$task_id" && git rev-parse --abbrev-ref HEAD 2>/dev/null); then
304
+ echo "ERROR: worktree .worktrees/$task_id exists but its checked-out branch is unreadable — refusing to REUSE an unverifiable worktree"
305
+ return 1
306
+ fi
307
+ canonical_branch="task/$task_id"
308
+ # Blocker 45 (2026-09-22): REUSED asserted an invariant it never checked —
309
+ # the worktree is on the task's canonical branch. Room #29: a Build agent
310
+ # improvised branch crew-9eda33ca; the retry's prepare REUSED it blindly
311
+ # and Review's classify-branch parked on "branch does not exist". Verify
312
+ # the invariant; on mismatch repair the registry (never delete the
313
+ # agent's branch — its commits may be the task's work) and attribute the
314
+ # repair with REPAIRED instead of REUSED.
315
+ if [ "$actual_branch" != "$canonical_branch" ]; then
316
+ reregister_branch "$task_id" "$actual_branch"
317
+ echo "REPAIRED: worktree .worktrees/$task_id checked out on branch $actual_branch (expected $canonical_branch); registry re-registered"
318
+ echo "HEAD: $head"
319
+ return 0
320
+ fi
321
+ echo "REUSED: worktree exists at .worktrees/$task_id"
262
322
  echo "HEAD: $head"
263
323
  return 0
264
324
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.9",
3
+ "version": "0.14.10",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -825,8 +825,52 @@ if (CAPTURE_INDEX < 0) throw new Error("STEPS missing 'Capture' step");
825
825
  const REWORK_STEP = STEPS[BUILD_INDEX].name;
826
826
  // Shared rework budget: Review and QA rejections draw from the SAME pool of 2.
827
827
  // E.g. 2 Review bounces + 1 QA bounce = 3 total > budget -> task blocks.
828
+ // Blocker 46 (2026-09-22): the budget is task-durable, not per-run. A run
829
+ // ends on every operational failure (agent timeout, worktree-confinement
830
+ // failure) and the dispatcher launches a fresh run — the old `let ... = 0`
831
+ // reset the counter on each launch, so a task whose runs kept ending
832
+ // operationally between rejections reworked unboundedly while the designed
833
+ // park stayed unreachable (room #29 J1: 3 rejections across 4 runs, never
834
+ // parked). The counter is seeded from the task's durable event stream at
835
+ // run start (seedReworkCount, after the pin block); in-run increments are
836
+ // unchanged.
828
837
  const MAX_TOTAL_REWORK = 2;
829
838
  let totalReworkCount = 0;
839
+ // Blocker 46: the first-claim test used totalReworkCount === 0 as a proxy
840
+ // for "this run is entering its first step". The counter is now seeded from
841
+ // task history, so the proxy lies on re-run launches — use an explicit
842
+ // run-local flag instead. The first claim (self-claim with raced-duplicate
843
+ // stand-down) must fire exactly once per run.
844
+ let firstClaimDone = false;
845
+
846
+ // Blocker 46: count verdict-level `rejected` events (Review/QA rejections —
847
+ // record-phase writes the event type equal to the session status) in the
848
+ // task's durable event stream. Operational failure ends are `failed`, never
849
+ // `rejected`, so they must not spend retry headroom. A failed seed must not
850
+ // strand the run: start at 0 and let the in-run budget govern this run; the
851
+ // seed is retried on the next run.
852
+ async function seedReworkCount() {
853
+ try {
854
+ var seed = await agent(
855
+ "Read this task's event history.\n" +
856
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId, limit: 100 }) + "\n" +
857
+ "Count the events whose type is exactly \"rejected\".\n" +
858
+ "Return JSON { \"rejected_count\": <integer> } and nothing else.",
859
+ {
860
+ key: "rework-seed-" + taskId,
861
+ label: "Seeding shared rework budget from task history",
862
+ schema: { type: "object", properties: { rejected_count: { type: "number" } }, required: ["rejected_count"] }
863
+ }
864
+ );
865
+ var n = Math.floor(Number(seed && seed.rejected_count) || 0);
866
+ if (!(n >= 0)) n = 0;
867
+ if (n > 0) log("Shared rework budget seeded from task history: " + n + " prior rejection(s)");
868
+ return n;
869
+ } catch (e) {
870
+ log("Rework-budget seed unavailable (" + ((e && e.message) || String(e)).slice(0, 120) + ") — starting at 0");
871
+ return 0;
872
+ }
873
+ }
830
874
  let rejectionNotes = inputs.rejection_notes || "";
831
875
  let mapperSpec = "";
832
876
  // Visual verdict state: Sage's experiential flag (true/false/null until the
@@ -940,6 +984,11 @@ log("Lifecycle scripts pinned to " + RUN_LIB);
940
984
  CREW_API = CREW_API_PINNED;
941
985
  log("Crew API pinned to " + CREW_API);
942
986
 
987
+ // Blocker 46: seed the task-durable rework budget from the task's event
988
+ // history before the phase loop. Pinned copy, so the seed reads the same
989
+ // release this run executes.
990
+ totalReworkCount = await seedReworkCount();
991
+
943
992
  // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
944
993
  // minted at this run's first claim (never a PID — short-lived agent PIDs
945
994
  // made every concurrent acquire take the stale path). Set exactly once on
@@ -948,7 +997,7 @@ let lockHolder = taskId;
948
997
 
949
998
  while (i < STEPS.length) {
950
999
  const step = STEPS[i];
951
- const isFirstClaim = (i === startStepIndex && totalReworkCount === 0);
1000
+ const isFirstClaim = (i === startStepIndex && !firstClaimDone);
952
1001
 
953
1002
  phase(step.name);
954
1003
  log(step.name + " step (" + step.identity + ") for task " + taskId);
@@ -1113,6 +1162,7 @@ while (i < STEPS.length) {
1113
1162
  return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
1114
1163
  }
1115
1164
  activeSessionId = claimResult.session_id;
1165
+ firstClaimDone = true; // blocker 46: exactly once per run
1116
1166
  } else {
1117
1167
  const claimResult = await agent(
1118
1168
  "Claim a session for this task step.\n" +
@@ -2080,14 +2130,15 @@ while (i < STEPS.length) {
2080
2130
  "You are code-blind QA. You NEVER read source files. Public docs are not source — read them as a user would.\n" +
2081
2131
  "STEP 1: Experiential visual inspection — drive the fixed artifact as a user would, one browser step at a time, and verify the reported bug is actually fixed.\n" +
2082
2132
  "You have a see-act driver: " + crewHome + "/current/lib/see-act.js (a node script; one browser action per invocation; it prints one JSON line to stdout). It launches its own Chromium through a self-contained loopback proxy — the ONLY url you may give it is the local artifact server you start below. Never point it at any other URL.\n" +
2133
+ "SESSION PROTOCOL (multi-step flows): one-shot invocations launch a fresh browser each time, so they cannot drive flows where a later step needs an earlier step's living page state (open a dialog, then confirm it — the second step needs the first step's page state). For those, hold ONE browser alive: node " + crewHome + "/current/lib/see-act.js session-start --name qa --url http://localhost:<N>/ --log " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl — launches Chromium once in a detached daemon (its control server binds 127.0.0.1 only; the daemon exits after 10 min with no act). The --log path is the SAME ooda-log.jsonl the QA guard reads: every session-act appends its own JSON line in the exact step schema (step, attempt, action, args, exit, screenshot, observation), so you do NOT call append-ooda-step.js for session acts — READ each act's JSON result (the screenshot field is the path to READ with your read tool) and decide the next action. Then drive it: node " + crewHome + "/current/lib/see-act.js session-act --name qa --attempt \"1\" <aria|shot|click|scroll|type> [args] — one action inside the living page. Prefix EVERY session-act invocation with SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ (shell env vars do not persist between your commands, so prefix each one). When done: node " + crewHome + "/current/lib/see-act.js session-end --name qa — closes the browser, prints the session summary, and keeps the session dir as evidence. If session-start exits 3 with a \"NOT POSSIBLE: <reason>\" string, the environment cannot drive a browser: report NOT POSSIBLE: <reason> and continue with the mechanical checks — your VERDICT then covers mechanical checks only.\n" +
2083
2134
  "Actions: aria | shot [--out <png>] [--full] | click [--out <png>] --selector <css> | scroll [--out <png>] --y <pixels|bottom> | type [--out <png>] --selector <css> --text <text>. Add --viewport mobile for a 390x844 frame. The JSON reports console_errors — treat any as a defect signal. Exit code 3 with not_possible set (a \"NOT POSSIBLE: <reason>\" string) means this environment cannot drive a browser: report NOT POSSIBLE: <reason> and continue with the mechanical checks — your VERDICT then covers mechanical checks only.\n" +
2084
2135
  "a. Verify the built artifact exists: test -d ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/client/dist && test -f ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/server/dist/actions.js — if either is missing, report NOT POSSIBLE: built artifact not present at ~/workspace/ts-spaces/" + PUBLISH_SLUG + " and continue with the mechanical checks.\n" +
2085
2136
  "b. Start the local artifact server detached with a log file, then poll the log for readiness (single shell invocation): CREW_HOME=" + crewHome + " setsid nohup node " + crewHome + "/current/lib/serve-artifact.js --space-dir ~/workspace/ts-spaces/" + PUBLISH_SLUG + " --port 0 --tag " + taskId + "-qa > /tmp/qa-server-" + taskId + ".log 2>&1 < /dev/null & for i in $(seq 1 30); do grep -q READY /tmp/qa-server-" + taskId + ".log && break; sleep 1; done; grep -o 'READY port=[0-9]*' /tmp/qa-server-" + taskId + ".log | cut -d= -f2 — the printed number is <N>. If no READY appears, report NOT POSSIBLE: local artifact server did not start (see /tmp/qa-server-" + taskId + ".log) and continue with the mechanical checks.\n" +
2086
2137
  "c. Bounded see-act loop, at most 8 steps: re-run the reproduction steps for the reported bug — does it still occur? Then check the surrounding views for regressions. Prefix EVERY see-act invocation with SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ (shell env vars do not persist between your commands, so prefix each one) — every screenshot is then archived automatically as 001-shot-desktop.png, 002-click-desktop.png, ..., so no frame can be lost. Omit --out; the JSON's screenshot field is the path to READ with your read tool (you can see images).\n" +
2087
- "c2. After EVERY see-act invocation, append it to the OODA report: node " + crewHome + "/current/lib/append-ooda-step.js --log " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl --attempt \"1\" --step <N> --action <aria|shot|click|scroll|type> --exit <exit-code> --screenshot <screenshot-from-JSON> --observation \"<1-2 sentences: what the frame showed and what you concluded>\" (omit --screenshot for aria). Steps are strictly monotonic: your first step is --step 1, then 2, 3, ... — the script rejects anything else. If you run the loop again (a new idea, after a restart), that run is --attempt \"2\": attempts never overwrite, they accumulate. Your observation is the payload — say what you saw and what it means, not just what you did. A step you ran but did not log is invisible to everyone after you.\n" +
2138
+ "c2. After EVERY one-shot see-act invocation, append it to the OODA report: node " + crewHome + "/current/lib/append-ooda-step.js --log " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl --attempt \"1\" --step <N> --action <aria|shot|click|scroll|type> --exit <exit-code> --screenshot <screenshot-from-JSON> --observation \"<1-2 sentences: what the frame showed and what you concluded>\" (omit --screenshot for aria). (Session acts are self-logging — session-act already appended its line; for those, READ the result instead of re-logging.) Steps are strictly monotonic: your first step is --step 1, then 2, 3, ... — the script rejects anything else. If you run the loop again (a new idea, after a restart), that run is --attempt \"2\": attempts never overwrite, they accumulate. Your observation is the payload — say what you saw and what it means, not just what you did. A step you ran but did not log is invisible to everyone after you.\n" +
2088
2139
  "c2b. IMAGE TOOLS (when a raw screenshot is not enough evidence): python3 " + crewHome + "/current/lib/edit-image.py crop --in <frame.png> --out <crop.png> --region x,y,w,h — pixel-exact crop; zoom --factor <f> --center x,y — pixel-crisp zoom back to the original frame size; label --text \"<caption>\" — caption bar; nup --cols 2 --in <a.png> --in <b.png> --labels \"before|after\" — side-by-side grid. Deterministic, no network. Log each with --action crop|zoom|label|nup, --attempt \"1\", and --screenshot <the derivative you READ>. For rich compositions (before/after with real typography, annotated callouts), write an HTML page and render it: node " + crewHome + "/current/lib/render-html.js --in <page.html> --out <composed.png> [--width 1200] — headless Chromium, hermetic (remote assets are blocked and fail the render), deterministic; log with --action compose and --screenshot <the composition you READ>. Derivatives supplement, never replace: keep the source frame archived and name it in --args. READ every derivative you log — an unread image is not evidence.\n" +
2089
2140
  "c3. Start with: SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ node " + crewHome + "/current/lib/see-act.js --url http://localhost:<N>/ aria — read the JSON, log the step. Then: SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ node " + crewHome + "/current/lib/see-act.js --url http://localhost:<N>/ shot — READ the screenshot, log the step. Act on what you see: click, scroll, type, then re-observe, logging each step. Prefer aria (cheap text) to find controls; screenshot when the view changes and for your final verdict frames (one desktop, one mobile). If a click exits non-zero, do NOT retry the same ref blindly: re-run aria first (refs go stale between invocations), then click the fresh ref exactly once. If it still fails, log the failure and move on — a flaky control is a finding, not a loop.\n" +
2090
- "d. One action per invocation, fresh browser each time: anything reachable by (navigate, one action) is testable — e.g. clicking any tab or button from the landing page. Sequences needing prior in-page state (open a dialog, then confirm it) are not; if the fix needs such a sequence to verify, report NOT POSSIBLE for that part and judge what you can.\n" +
2141
+ "d. Reach: with the session protocol, any flow reachable by N in-page actions is drivable — open the dialog, then confirm it, then judge the result. Without a session (one-shot invocations), anything reachable by (navigate, one action) is testable and sequences needing prior in-page state are not — use a session for those. Report NOT POSSIBLE only when the tooling itself fails (session-start exits 3): a flow you could not reach is not NOT POSSIBLE — name the exact step that stopped you in verdict.json's missing evidence and continue with the mechanical checks.\n" +
2091
2142
  "e. Judge as a user against the task description: is the reported bug fixed AND is nothing else visibly broken? Look for broken layout, overflow, missing or wrong content, stale data, and console errors. Compare against the task's expected behavior, never against source code (you are code-blind). Every frame you captured is already archived under " + crewHome + "/task-evidence/" + taskId + "/postchange/ and indexed in ooda-log.jsonl. A frame you did not read is not evidence. Loading, error, or blank frames never pass. If you cannot complete the loop, say exactly which steps are missing — unknown is not PASS.\n" +
2092
2143
  "f. Kill ONLY the server you started: pkill -f 'serve-artifact[.]js.*--tag " + taskId + "-qa' — never another task's server. (The [.] keeps pkill from matching its own command line.) Do not leave it running.\n" +
2093
2144
  "Then continue with the mechanical checks below. Your VERDICT covers both the visual and the mechanical checks.\n\n" +
@@ -793,6 +793,50 @@ if (CAPTURE_INDEX < 0) throw new Error("STEPS missing 'Capture' step");
793
793
  const REWORK_STEP = STEPS[BUILD_INDEX].name;
794
794
  const MAX_REWORK = 2;
795
795
  let reworkCount = 0;
796
+ // Blocker 46 (2026-09-22): the budget is task-durable, not per-run. A run
797
+ // ends on every operational failure (agent timeout, worktree-confinement
798
+ // failure) and the dispatcher launches a fresh run — the old `let ... = 0`
799
+ // reset the counter on each launch, so a task whose runs kept ending
800
+ // operationally between rejections reworked unboundedly while the designed
801
+ // park stayed unreachable (room #29 J1: 3 rejections across 4 runs, never
802
+ // parked). The counter is seeded from the task's durable event stream at
803
+ // run start (seedReworkCount, after the pin block); in-run increments are
804
+ // unchanged.
805
+ // Blocker 46: the first-claim test used reworkCount === 0 as a proxy for
806
+ // "this run is entering its first step". The counter is now seeded from
807
+ // task history, so the proxy lies on re-run launches — use an explicit
808
+ // run-local flag instead. The first claim (self-claim with raced-duplicate
809
+ // stand-down) must fire exactly once per run.
810
+ let firstClaimDone = false;
811
+
812
+ // Blocker 46: count verdict-level `rejected` events (Review/QA rejections —
813
+ // record-phase writes the event type equal to the session status) in the
814
+ // task's durable event stream. Operational failure ends are `failed`, never
815
+ // `rejected`, so they must not spend retry headroom. A failed seed must not
816
+ // strand the run: start at 0 and let the in-run budget govern this run; the
817
+ // seed is retried on the next run.
818
+ async function seedReworkCount() {
819
+ try {
820
+ var seed = await agent(
821
+ "Read this task's event history.\n" +
822
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId, limit: 100 }) + "\n" +
823
+ "Count the events whose type is exactly \"rejected\".\n" +
824
+ "Return JSON { \"rejected_count\": <integer> } and nothing else.",
825
+ {
826
+ key: "rework-seed-" + taskId,
827
+ label: "Seeding shared rework budget from task history",
828
+ schema: { type: "object", properties: { rejected_count: { type: "number" } }, required: ["rejected_count"] }
829
+ }
830
+ );
831
+ var n = Math.floor(Number(seed && seed.rejected_count) || 0);
832
+ if (!(n >= 0)) n = 0;
833
+ if (n > 0) log("Shared rework budget seeded from task history: " + n + " prior rejection(s)");
834
+ return n;
835
+ } catch (e) {
836
+ log("Rework-budget seed unavailable (" + ((e && e.message) || String(e)).slice(0, 120) + ") — starting at 0");
837
+ return 0;
838
+ }
839
+ }
796
840
  let rejectionNotes = inputs.rejection_notes || "";
797
841
  let mapperSpec = "";
798
842
  // Visual verdict state: Sage's experiential flag (true/false/null until the
@@ -904,6 +948,11 @@ log("Lifecycle scripts pinned to " + RUN_LIB);
904
948
  CREW_API = CREW_API_PINNED;
905
949
  log("Crew API pinned to " + CREW_API);
906
950
 
951
+ // Blocker 46: seed the task-durable rework budget from the task's event
952
+ // history before the phase loop. Pinned copy, so the seed reads the same
953
+ // release this run executes.
954
+ reworkCount = await seedReworkCount();
955
+
907
956
  // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
908
957
  // minted at this run's first claim (never a PID — short-lived agent PIDs
909
958
  // made every concurrent acquire take the stale path). Set exactly once on
@@ -912,7 +961,7 @@ let lockHolder = taskId;
912
961
 
913
962
  while (i < STEPS.length) {
914
963
  const step = STEPS[i];
915
- const isFirstClaim = (i === startStepIndex && reworkCount === 0);
964
+ const isFirstClaim = (i === startStepIndex && !firstClaimDone);
916
965
 
917
966
  phase(step.name);
918
967
  log(step.name + " step (" + step.identity + ") for task " + taskId);
@@ -1084,6 +1133,7 @@ while (i < STEPS.length) {
1084
1133
  return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
1085
1134
  }
1086
1135
  activeSessionId = claimResult.session_id;
1136
+ firstClaimDone = true; // blocker 46: exactly once per run
1087
1137
  await telemetryEvent("claim", step.name + " claimed");
1088
1138
  // Flush telemetry before proceeding — the claim is a critical point.
1089
1139
  // If a subsequent agent() fails, we want the claim event persisted.
@@ -790,8 +790,52 @@ if (CAPTURE_INDEX < 0) throw new Error("STEPS missing 'Capture' step");
790
790
  const REWORK_STEP = STEPS[BUILD_INDEX].name;
791
791
  // Shared rework budget: Review and QA rejections draw from the SAME pool of 2.
792
792
  // E.g. 2 Review bounces + 1 QA bounce = 3 total > budget -> task blocks.
793
+ // Blocker 46 (2026-09-22): the budget is task-durable, not per-run. A run
794
+ // ends on every operational failure (agent timeout, worktree-confinement
795
+ // failure) and the dispatcher launches a fresh run — the old `let ... = 0`
796
+ // reset the counter on each launch, so a task whose runs kept ending
797
+ // operationally between rejections reworked unboundedly while the designed
798
+ // park stayed unreachable (room #29 J1: 3 rejections across 4 runs, never
799
+ // parked). The counter is seeded from the task's durable event stream at
800
+ // run start (seedReworkCount, after the pin block); in-run increments are
801
+ // unchanged.
793
802
  const MAX_TOTAL_REWORK = 2;
794
803
  let totalReworkCount = 0;
804
+ // Blocker 46: the first-claim test used totalReworkCount === 0 as a proxy
805
+ // for "this run is entering its first step". The counter is now seeded from
806
+ // task history, so the proxy lies on re-run launches — use an explicit
807
+ // run-local flag instead. The first claim (self-claim with raced-duplicate
808
+ // stand-down) must fire exactly once per run.
809
+ let firstClaimDone = false;
810
+
811
+ // Blocker 46: count verdict-level `rejected` events (Review/QA rejections —
812
+ // record-phase writes the event type equal to the session status) in the
813
+ // task's durable event stream. Operational failure ends are `failed`, never
814
+ // `rejected`, so they must not spend retry headroom. A failed seed must not
815
+ // strand the run: start at 0 and let the in-run budget govern this run; the
816
+ // seed is retried on the next run.
817
+ async function seedReworkCount() {
818
+ try {
819
+ var seed = await agent(
820
+ "Read this task's event history.\n" +
821
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId, limit: 100 }) + "\n" +
822
+ "Count the events whose type is exactly \"rejected\".\n" +
823
+ "Return JSON { \"rejected_count\": <integer> } and nothing else.",
824
+ {
825
+ key: "rework-seed-" + taskId,
826
+ label: "Seeding shared rework budget from task history",
827
+ schema: { type: "object", properties: { rejected_count: { type: "number" } }, required: ["rejected_count"] }
828
+ }
829
+ );
830
+ var n = Math.floor(Number(seed && seed.rejected_count) || 0);
831
+ if (!(n >= 0)) n = 0;
832
+ if (n > 0) log("Shared rework budget seeded from task history: " + n + " prior rejection(s)");
833
+ return n;
834
+ } catch (e) {
835
+ log("Rework-budget seed unavailable (" + ((e && e.message) || String(e)).slice(0, 120) + ") — starting at 0");
836
+ return 0;
837
+ }
838
+ }
795
839
  let rejectionNotes = inputs.rejection_notes || "";
796
840
  let mapperSpec = "";
797
841
  // Visual verdict state: Sage's experiential flag (true/false/null until the
@@ -899,6 +943,11 @@ log("Lifecycle scripts pinned to " + RUN_LIB);
899
943
  CREW_API = CREW_API_PINNED;
900
944
  log("Crew API pinned to " + CREW_API);
901
945
 
946
+ // Blocker 46: seed the task-durable rework budget from the task's event
947
+ // history before the phase loop. Pinned copy, so the seed reads the same
948
+ // release this run executes.
949
+ totalReworkCount = await seedReworkCount();
950
+
902
951
  // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
903
952
  // minted at this run's first claim (never a PID — short-lived agent PIDs
904
953
  // made every concurrent acquire take the stale path). Set exactly once on
@@ -907,7 +956,7 @@ let lockHolder = taskId;
907
956
 
908
957
  while (i < STEPS.length) {
909
958
  const step = STEPS[i];
910
- const isFirstClaim = (i === startStepIndex && totalReworkCount === 0);
959
+ const isFirstClaim = (i === startStepIndex && !firstClaimDone);
911
960
 
912
961
  phase(step.name);
913
962
  log(step.name + " step (" + step.identity + ") for task " + taskId);
@@ -1072,6 +1121,7 @@ while (i < STEPS.length) {
1072
1121
  return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
1073
1122
  }
1074
1123
  activeSessionId = claimResult.session_id;
1124
+ firstClaimDone = true; // blocker 46: exactly once per run
1075
1125
  } else {
1076
1126
  const claimResult = await agent(
1077
1127
  "Claim a session for this task step.\n" +
@@ -1969,14 +2019,15 @@ while (i < STEPS.length) {
1969
2019
  "Public docs (API.md, README, published action schemas) are NOT source code — read them freely, exactly as a user would.\n\n" +
1970
2020
  "STEP 1: Experiential visual inspection — drive the artifact as a user would, one browser step at a time.\n" +
1971
2021
  "You have a see-act driver: " + crewHome + "/current/lib/see-act.js (a node script; one browser action per invocation; it prints one JSON line to stdout). It launches its own Chromium through a self-contained loopback proxy — the ONLY url you may give it is the local artifact server you start below. Never point it at any other URL.\n" +
2022
+ "SESSION PROTOCOL (multi-step flows): one-shot invocations launch a fresh browser each time, so they cannot drive flows where a later step needs an earlier step's living page state (open a deck, then click Study — the second step needs the first step's page state). For those, hold ONE browser alive: node " + crewHome + "/current/lib/see-act.js session-start --name qa --url http://localhost:<N>/ --log " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl — launches Chromium once in a detached daemon (its control server binds 127.0.0.1 only; the daemon exits after 10 min with no act). The --log path is the SAME ooda-log.jsonl the QA guard reads: every session-act appends its own JSON line in the exact step schema (step, attempt, action, args, exit, screenshot, observation), so you do NOT call append-ooda-step.js for session acts — READ each act's JSON result (the screenshot field is the path to READ with your read tool) and decide the next action. Then drive it: node " + crewHome + "/current/lib/see-act.js session-act --name qa --attempt \"1\" <aria|shot|click|scroll|type> [args] — one action inside the living page. Prefix EVERY session-act invocation with SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ (shell env vars do not persist between your commands, so prefix each one). When done: node " + crewHome + "/current/lib/see-act.js session-end --name qa — closes the browser, prints the session summary, and keeps the session dir as evidence. If session-start exits 3 with a \"NOT POSSIBLE: <reason>\" string, the environment cannot drive a browser: report NOT POSSIBLE: <reason> and judge what you can.\n" +
1972
2023
  "Actions: aria | shot [--out <png>] [--full] | click [--out <png>] --selector <css> | scroll [--out <png>] --y <pixels|bottom> | type [--out <png>] --selector <css> --text <text>. Add --viewport mobile for a 390x844 frame. The JSON reports console_errors — treat any as a defect signal. Exit code 3 with not_possible set (a \"NOT POSSIBLE: <reason>\" string) means this environment cannot drive a browser: report NOT POSSIBLE: <reason> and continue with the mechanical checks — your VERDICT then covers mechanical checks only.\n" +
1973
2024
  "a. Verify the built artifact exists: test -d ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/client/dist && test -f ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/server/dist/actions.js — if either is missing, report NOT POSSIBLE: built artifact not present at ~/workspace/ts-spaces/" + PUBLISH_SLUG + " and continue with the mechanical checks.\n" +
1974
2025
  "b. Start the local artifact server detached with a log file, then poll the log for readiness (single shell invocation): CREW_HOME=" + crewHome + " setsid nohup node " + crewHome + "/current/lib/serve-artifact.js --space-dir ~/workspace/ts-spaces/" + PUBLISH_SLUG + " --port 0 --tag " + taskId + "-qa > /tmp/qa-server-" + taskId + ".log 2>&1 < /dev/null & for i in $(seq 1 30); do grep -q READY /tmp/qa-server-" + taskId + ".log && break; sleep 1; done; grep -o 'READY port=[0-9]*' /tmp/qa-server-" + taskId + ".log | cut -d= -f2 — the printed number is <N>. If no READY appears, report NOT POSSIBLE: local artifact server did not start (see /tmp/qa-server-" + taskId + ".log) and continue with the mechanical checks.\n" +
1975
2026
  "c. Bounded see-act loop, at most 8 steps: drive the artifact as a user would, one browser step at a time. Prefix EVERY see-act invocation with SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ (shell env vars do not persist between your commands, so prefix each one) — every screenshot is then archived automatically as 001-shot-desktop.png, 002-click-desktop.png, ..., so no frame can be lost. Omit --out; the JSON's screenshot field is the path to READ with your read tool (you can see images).\n" +
1976
- "c2. After EVERY see-act invocation, append it to the OODA report: node " + crewHome + "/current/lib/append-ooda-step.js --log " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl --attempt \"1\" --step <N> --action <aria|shot|click|scroll|type> --exit <exit-code> --screenshot <screenshot-from-JSON> --observation \"<1-2 sentences: what the frame showed and what you concluded>\" (omit --screenshot for aria). Steps are strictly monotonic: your first step is --step 1, then 2, 3, ... — the script rejects anything else. If you run the loop again (a new idea, after a restart), that run is --attempt \"2\": attempts never overwrite, they accumulate. Your observation is the payload — say what you saw and what it means, not just what you did. A step you ran but did not log is invisible to everyone after you.\n" +
2027
+ "c2. After EVERY one-shot see-act invocation, append it to the OODA report: node " + crewHome + "/current/lib/append-ooda-step.js --log " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl --attempt \"1\" --step <N> --action <aria|shot|click|scroll|type> --exit <exit-code> --screenshot <screenshot-from-JSON> --observation \"<1-2 sentences: what the frame showed and what you concluded>\" (omit --screenshot for aria). (Session acts are self-logging — session-act already appended its line; for those, READ the result instead of re-logging.) Steps are strictly monotonic: your first step is --step 1, then 2, 3, ... — the script rejects anything else. If you run the loop again (a new idea, after a restart), that run is --attempt \"2\": attempts never overwrite, they accumulate. Your observation is the payload — say what you saw and what it means, not just what you did. A step you ran but did not log is invisible to everyone after you.\n" +
1977
2028
  "c2b. IMAGE TOOLS (when a raw screenshot is not enough evidence): python3 " + crewHome + "/current/lib/edit-image.py crop --in <frame.png> --out <crop.png> --region x,y,w,h — pixel-exact crop; zoom --factor <f> --center x,y — pixel-crisp zoom back to the original frame size; label --text \"<caption>\" — caption bar; nup --cols 2 --in <a.png> --in <b.png> --labels \"before|after\" — side-by-side grid. Deterministic, no network. Log each with --action crop|zoom|label|nup, --attempt \"1\", and --screenshot <the derivative you READ>. For rich compositions (before/after with real typography, annotated callouts), write an HTML page and render it: node " + crewHome + "/current/lib/render-html.js --in <page.html> --out <composed.png> [--width 1200] — headless Chromium, hermetic (remote assets are blocked and fail the render), deterministic; log with --action compose and --screenshot <the composition you READ>. Derivatives supplement, never replace: keep the source frame archived and name it in --args. READ every derivative you log — an unread image is not evidence.\n" +
1978
2029
  "c3. Start with: SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ node " + crewHome + "/current/lib/see-act.js --url http://localhost:<N>/ aria — read the JSON, log the step. Then: SEE_ACT_ARCHIVE_DIR=" + crewHome + "/task-evidence/" + taskId + "/postchange/ node " + crewHome + "/current/lib/see-act.js --url http://localhost:<N>/ shot — READ the screenshot, log the step. Act on what you see: click, scroll, type, then re-observe, logging each step. Prefer aria (cheap text) to find controls; screenshot when the view changes and for your final verdict frames (one desktop, one mobile). If a click exits non-zero, do NOT retry the same ref blindly: re-run aria first (refs go stale between invocations), then click the fresh ref exactly once. If it still fails, log the failure and move on — a flaky control is a finding, not a loop.\n" +
1979
- "d. One action per invocation, fresh browser each time: anything reachable by (navigate, one action) is testable — e.g. clicking any tab or button from the landing page. Sequences needing prior in-page state (open a dialog, then confirm it) are not; if the task's change needs such a sequence, report NOT POSSIBLE for that part and judge what you can.\n" +
2030
+ "d. Reach: with the session protocol, any flow reachable by N in-page actions is drivable — open the deck, then click Study, then judge the study view. Without a session (one-shot invocations), anything reachable by (navigate, one action) is testable and sequences needing prior in-page state are not — use a session for those. Report NOT POSSIBLE only when the tooling itself fails (session-start exits 3): a flow you could not reach is not NOT POSSIBLE — name the exact step that stopped you in verdict.json's missing evidence and judge what you did reach.\n" +
1980
2031
  "e. Judge as a user against the task description: does the change render correctly? Look for broken layout, overflow, missing or wrong content, stale data, and console errors. Compare against the task's expected behavior, never against source code (you are code-blind). Every frame you captured is already archived under " + crewHome + "/task-evidence/" + taskId + "/postchange/ and indexed in ooda-log.jsonl. A frame you did not read is not evidence. Loading, error, or blank frames never pass. If you cannot complete the loop, say exactly which steps are missing — unknown is not PASS.\n" +
1981
2032
  "f. Kill ONLY the server you started: pkill -f 'serve-artifact[.]js.*--tag " + taskId + "-qa' — never another task's server. (The [.] keeps pkill from matching its own command line.) Do not leave it running.\n" +
1982
2033
  "Then continue with the mechanical checks below. Your VERDICT covers both the visual and the mechanical checks.\n\n" +