muse-crew 0.14.6 → 0.14.8

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.
@@ -155,6 +155,62 @@ function extractVerdict(workerText) {
155
155
  if (uniq.length !== 1) return { ok: false, count: matches.length };
156
156
  return { ok: true, passed: last.value === "PASS" };
157
157
  }
158
+ // Room #26 blocker 38 (2026-09-21): content-finding attribution. Hazel
159
+ // reports user-visible content observations in a machine-readable
160
+ // CONTENT-FINDINGS block; the workflow classifies each against the task's
161
+ // publish diff — mechanical set membership, never prose judgment. A finding
162
+ // is attributable to the task's change iff its subject file is in the
163
+ // publish diff. The special subject "live-data" (content in the artifact's
164
+ // runtime data stores — decks, cards, rows) is never in a git diff, so it
165
+ // always classifies environment-attributable: the crew has no live-DB write
166
+ // path (blocker 32: QA writes go to a fresh per-run temp DB), so live
167
+ // content is never the task's change. Findings are always recorded — never
168
+ // suppressed for looking audit-y — but environment-attributable findings
169
+ // never park the journey and never spawn an artifact bugfix. The
170
+ // platform-side half (audit sessions writing to production via the
171
+ // shared-concurrent path) is out of crew scope; see
172
+ // docs/decisions/qa-reproduce.md#content-finding-attribution.
173
+ function extractContentFindings(workerText) {
174
+ // The block is the LAST "CONTENT-FINDINGS:" line; the JSON array follows
175
+ // on the same line. Fail closed (ok:false) when missing or malformed —
176
+ // the caller blocks the phase for retry; unknown attribution must never
177
+ // degrade to "no findings" (the prose grounds stay preserved in the
178
+ // session notes).
179
+ var text = workerText || "";
180
+ var regex = /^CONTENT-FINDINGS:\s*(\[.*\])\s*$/gim;
181
+ var matches = [];
182
+ var m;
183
+ while ((m = regex.exec(text)) !== null) {
184
+ matches.push(m[1]);
185
+ }
186
+ if (matches.length === 0) return { ok: false, reason: "no CONTENT-FINDINGS block" };
187
+ var findings;
188
+ try {
189
+ findings = JSON.parse(matches[matches.length - 1]);
190
+ } catch (e) {
191
+ return { ok: false, reason: "CONTENT-FINDINGS block is not valid JSON" };
192
+ }
193
+ if (!Array.isArray(findings)) return { ok: false, reason: "CONTENT-FINDINGS is not an array" };
194
+ for (var i = 0; i < findings.length; i++) {
195
+ var f = findings[i];
196
+ if (!f || typeof f !== "object" || typeof f.subject !== "string" || typeof f.observation !== "string") {
197
+ return { ok: false, reason: "finding " + i + " needs string subject and observation" };
198
+ }
199
+ }
200
+ return { ok: true, findings: findings };
201
+ }
202
+ function classifyContentFinding(finding, diffFiles) {
203
+ // diffFiles: the task's publish-diff file set (repo-relative paths).
204
+ // Pure set membership — no prose, no judgment.
205
+ var subject = finding.subject;
206
+ if (subject === "live-data") {
207
+ return { attribution: "environment-attributable", reason: "live-data is never in a git publish diff — the crew has no live-DB write path (blocker 32)" };
208
+ }
209
+ if (diffFiles.indexOf(subject) !== -1) {
210
+ return { attribution: "task-change", reason: "subject is in the task's publish diff" };
211
+ }
212
+ return { attribution: "environment-attributable", reason: "subject is not in the task's publish diff" };
213
+ }
158
214
  // See docs/decisions/workflow-core.md#verdict-reask: a report that fails extractVerdict gets bounded re-ask calls.
159
215
  function verdictReaskKey(stepName, reworkSuffix, attempt) {
160
216
  return "verdict-reask-" + stepName + reworkSuffix + "-a" + attempt;
@@ -256,6 +312,142 @@ function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason)
256
312
  "if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
257
313
  "Then return your report as JSON in exactly the shape specified above.";
258
314
  }
315
+ // parseClassifyFerry — room #26 blocker 42 (2026-09-21). Shape-constrained
316
+ // ferry parser for the classify-branch agent() call. The schema buys SHAPE,
317
+ // not provenance: the fields are LLM-authored, so the parser stays
318
+ // defensive. Byte-identical across standard.js, bugfix.js, chore.js
319
+ // (pinned by tests/already-merged.test.js). Triplication is structural:
320
+ // each workflow ships as a standalone launch payload (240 KiB budget,
321
+ // individually git-archived) — there is no shared module to hold it.
322
+ // The schema is the guard; the prompt is instruction, never load-bearing.
323
+ // Returns { state, diagField, diagText, failReason }:
324
+ // state: "has-work" | "already-merged:<40-hex>" | "empty-no-work" | null
325
+ // (null = unparseable/ambiguous/channel violation — UNKNOWN)
326
+ // diagField: "stderr" | "stdout" | null (which field sourced the DIAG pin)
327
+ // diagText: the matched DIAG line, or ""
328
+ // failReason: computed reason, for the retry trailer and the park note
329
+ function parseClassifyFerry(bsResult) {
330
+ var bsStdout = (bsResult && typeof bsResult.stdout === "string") ? bsResult.stdout : null;
331
+ var bsStderr = (bsResult && typeof bsResult.stderr === "string") ? bsResult.stderr : null;
332
+ if (bsStdout === null || bsStderr === null) {
333
+ return { state: null, diagField: null, diagText: "",
334
+ failReason: "classifier return fields missing or non-string (stdout and stderr must both be strings)" };
335
+ }
336
+ // State channel: stdout ONLY. A BRANCH_STATE match on stderr is never
337
+ // trusted — workers merge streams constantly, and trusting a channel
338
+ // swap as truth is the wrong shape. Search, never anchor (blocker 11):
339
+ // the ferry labels output ("stdout: BRANCH_STATE: has-work"). Collect
340
+ // ALL matches and dedupe normalized values: exactly one distinct value
341
+ // is truth; zero or two-or-more is UNKNOWN — alternation order never
342
+ // adjudicates.
343
+ var bsRe = /\bBRANCH_STATE:\s*(has-work|already-merged:[0-9a-f]{40}|empty-no-work)(?![\w-])/gi;
344
+ var bsSeen = {};
345
+ var bsM;
346
+ while ((bsM = bsRe.exec(bsStdout)) !== null) {
347
+ bsSeen[bsM[1].toLowerCase()] = true;
348
+ }
349
+ var bsStates = Object.keys(bsSeen);
350
+ if (bsStates.length !== 1) {
351
+ return { state: null, diagField: null, diagText: "",
352
+ failReason: bsStates.length === 0
353
+ ? "no BRANCH_STATE match in the stdout field"
354
+ : "ambiguous BRANCH_STATE values in stdout (" + bsStates.length + " distinct)" };
355
+ }
356
+ var bsState = bsStates[0];
357
+ // DIAG pin: search both fields, stderr first. Keep the DIAG: prefix — a
358
+ // prose mention without it must not satisfy the pin.
359
+ var bsDiagRe = /DIAG:\s*classify-branch\s+records=\d+\s+resolvable=\d+/;
360
+ var bsDiagField = bsDiagRe.test(bsStderr) ? "stderr" : (bsDiagRe.test(bsStdout) ? "stdout" : null);
361
+ var bsDiagText = "";
362
+ if (bsDiagField !== null) {
363
+ bsDiagText = bsDiagRe.exec(bsDiagField === "stderr" ? bsStderr : bsStdout)[0];
364
+ }
365
+ if (bsState.indexOf("already-merged:") === 0 && bsDiagField === null) {
366
+ // already-merged's sha flows into Cass's `git diff <sha>^1 <sha>` — a
367
+ // hallucinated-but-well-formed sha is a false-PASS vector. The script
368
+ // emits exactly one DIAG on every path that emits a BRANCH_STATE, so a
369
+ // missing pin means stderr wasn't faithfully ferried: UNKNOWN.
370
+ // Residual risk, named (blocker-42 fib 4): a worker could fabricate
371
+ // BOTH fields wholesale — the pin is a tripwire, not proof of
372
+ // execution. The workflow has no exec channel for a deterministic
373
+ // cross-check; if the platform ever offers one, verify the sha
374
+ // against the task's durable merge records directly.
375
+ return { state: null, diagField: null, diagText: "",
376
+ failReason: "already-merged without the DIAG pin — stderr not faithfully ferried" };
377
+ }
378
+ return { state: bsState, diagField: bsDiagField, diagText: bsDiagText, failReason: "" };
379
+ }
380
+
381
+ // classifyRetryTrailer — room #26 blocker 42 (2026-09-21). Corrective (not
382
+ // flat) trailer for the in-place classify retries. The failure mode was
383
+ // systematic labeling, not a coin flip: the trailer carries the COMPUTED
384
+ // parse-failure reason, the failed return truncated as a negative example,
385
+ // and the explicit field mapping — feedback from the actual failure,
386
+ // following the buildTransportRetryTrailer idiom. Bound: 2 retries
387
+ // (unmeasured house convention, matches the shared rework bound of 2 —
388
+ // against room retry data). Byte-identical across the three workflows
389
+ // (pinned by tests/already-merged.test.js).
390
+ function classifyRetryTrailer(reason, failedReturn) {
391
+ // Pass-2 simplification: the reason string already carries the truth
392
+ // (throw vs parse failure), so one neutral sentence replaces the
393
+ // throw/parse branch — the stringly-typed prefix contract between the
394
+ // call site and this function is deleted, not moved. Prompt accuracy,
395
+ // not prompt hardening.
396
+ return "\n\nCLASSIFY RETRY: the previous classifier attempt failed (" + reason + "). " +
397
+ "Return ONLY the two named fields: { \"stdout\": \"<the command's exact stdout>\", \"stderr\": \"<the command's exact stderr>\" }. " +
398
+ "Put each stream in its named field — do not add labels like \"stdout:\" in front of the content. " +
399
+ "Previous attempt detail (do not repeat this shape): " + String(failedReturn).slice(0, 200);
400
+ }
401
+
402
+ // parseVersionFerry — review pass 2, blocker-42 class (2026-09-21). The npm
403
+ // version-check ferry had the full pre-42 shape: schema-less agent(),
404
+ // line-anchored parser, and an unparseable fallback asserting a POSITIVE
405
+ // touched claim that spent the shared rework budget. Same treatment as
406
+ // parseClassifyFerry: shape-constrained {stdout} schema, search-and-dedupe
407
+ // parse, UNKNOWN on unparseable with in-place retries, honest park.
408
+ // Byte-identical across standard.js, bugfix.js, chore.js
409
+ // (pinned by tests/already-merged.test.js).
410
+ // Returns { versionState, failReason }:
411
+ // versionState: "touched" | "clean" | null (null = UNKNOWN)
412
+ // failReason: computed reason, for the retry trailer and the park note
413
+ function parseVersionFerry(vtResult) {
414
+ var vtStdout = (vtResult && typeof vtResult.stdout === "string") ? vtResult.stdout : null;
415
+ if (vtStdout === null) {
416
+ return { versionState: null,
417
+ failReason: "version-check return field missing or non-string (stdout must be a string)" };
418
+ }
419
+ // Search, never anchor (blocker 11): the ferry labels output
420
+ // ("stdout: VERSION_CLEAN"). Collect ALL matches and dedupe normalized
421
+ // values: exactly one distinct value is truth; zero or two-or-more is
422
+ // UNKNOWN — alternation order never adjudicates.
423
+ var vtRe = /\bVERSION_(TOUCHED|CLEAN)(?![\w-])/gi;
424
+ var vtSeen = {};
425
+ var vtM;
426
+ while ((vtM = vtRe.exec(vtStdout)) !== null) {
427
+ vtSeen[vtM[1].toUpperCase()] = true;
428
+ }
429
+ var vtStates = Object.keys(vtSeen);
430
+ if (vtStates.length !== 1) {
431
+ return { versionState: null,
432
+ failReason: vtStates.length === 0
433
+ ? "no VERSION_(TOUCHED|CLEAN) match in the stdout field"
434
+ : "ambiguous VERSION values in stdout (" + vtStates.length + " distinct)" };
435
+ }
436
+ return { versionState: vtStates[0] === "CLEAN" ? "clean" : "touched", failReason: "" };
437
+ }
438
+
439
+ // versionRetryTrailer — review pass 2, blocker-42 class (2026-09-21).
440
+ // Neutral corrective trailer for the in-place version-check retries: the
441
+ // computed parse-failure reason plus the failed return as a negative
442
+ // example. Byte-identical across the three workflows
443
+ // (pinned by tests/already-merged.test.js).
444
+ function versionRetryTrailer(reason, failedReturn) {
445
+ return "\n\nVERSION-CHECK RETRY: the previous version-check attempt failed (" + reason + "). " +
446
+ "Return ONLY the named field: { \"stdout\": \"<the command's exact stdout>\" }. " +
447
+ "Put the command's stdout in the named field — do not add labels like \"stdout:\" in front of the content. " +
448
+ "Previous attempt detail (do not repeat this shape): " + String(failedReturn).slice(0, 200);
449
+ }
450
+
259
451
  // parseToolSignals - bug 3472bf36. The work agent's TOOL CHECK emits two
260
452
  // exact signal lines: artifact_tools: ok|missing and
261
453
  // shell_transport: ok|unavailable. The workflow reads ONLY these lines.
@@ -458,13 +650,6 @@ function extractMarkerLines(workerText) {
458
650
  return markers.join("\n");
459
651
  }
460
652
 
461
- // See docs/decisions/publish-path.md#already-merged-idem2: idempotency for already-merged tasks.
462
- function extractAlreadyMerged(workerText) {
463
- var m = /repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})(?=[\s)]|$)/i.exec(workerText || "");
464
- return m ? { sha: m[1].toLowerCase() } : { sha: null };
465
- }
466
-
467
-
468
653
  // See docs/decisions/qa-reproduce.md#worktree-confinement: the Build agent must declare its worktree.
469
654
  function extractWorktree(workerText) {
470
655
  var lines = (workerText || "").split("\n");
@@ -671,12 +856,24 @@ let mapGateBounceCount = 0;
671
856
  // rationalized a skip against explicit instruction text — text alone did not
672
857
  // hold, so the decision now lives in workflow code, not agent judgment.
673
858
  let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
674
- // Already-merged idempotency: the verified sha from the builder's
675
- // `repo_diff: none (already-merged: <sha>)` declaration (null when the
676
- // builder made commits or declared a runtime-state deliverable). The
677
- // workflow verifies the sha is an ancestor of the integration target at Build closeout;
678
- // Review's no-diff branch reads this, never the builder's prose.
859
+ // Already-merged attribution (blocker 34, 2026-09-21): the sha of the
860
+ // task-attributed merge when classify-branch reports already-merged.
861
+ // Set ONLY from the classifier's mechanical output — never from agent
862
+ // prose. The agent-authored sha-bearing repo_diff declaration path is
863
+ // deleted; attribution is identity-first from git, then the task's own
864
+ // durable merge records.
679
865
  let alreadyMergedSha = null;
866
+ // Room #26 blocker 34 (redesign, 2026-09-21): mechanical branch-state
867
+ // classification at Review entry. Git facts are never adjudicated by a
868
+ // reviewer — the workflow classifies the branch with `classify-branch`
869
+ // (pure git) before Cass is dispatched: "has-work",
870
+ // "already-merged:<sha>", or "empty-no-work". An empty branch with no
871
+ // verified sha and no `repo_diff: none` claim fails mechanically without
872
+ // dispatching Cass; reviewer prose is never parsed for git identity.
873
+ let branchState = null; // "has-work" | "already-merged:<sha>" | "empty-no-work"
874
+ let buildClaimedNoDiff = false; // Build declared plain `repo_diff: none` (no repo change — runtime-state deliverable, or believed already-merged). Same-process Build report only; there is no cross-process hydration — an empty branch on a fresh dispatch with no task-attributed merge fails mechanically.
875
+ let versionTouched = false; // npm: branch changed package.json's `version` (mechanical git fact, blocker-34 follow-up)
876
+ let branchStateErr = ""; // classifier failure detail (retry trailer + park note grounds)
680
877
  // Deterministic publish target — computed by the workflow (registry base +
681
878
  // bumpVersion), never by the Publish agent.
682
879
  let publishTarget = null; // { base, scope, target }
@@ -1215,55 +1412,202 @@ while (i < STEPS.length) {
1215
1412
  "cd " + WORKTREE_HINT + "\n" +
1216
1413
  "git add -A\n" +
1217
1414
  "git commit -m \"fix: " + safeTitle + "\"\n\n" +
1218
- "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of the integration target and declare `repo_diff: none` in your report, naming the runtime-state deliverable. If you verified the deliverable is already on the integration target (a prior merge or hand-repair landed it — do NOT re-implement working code), make no commit and declare `repo_diff: none (already-merged: <sha>)` naming the integration-target commit that carries the work; the workflow verifies the sha is an ancestor of the integration target, and a false declaration fails the phase. Otherwise commit your changes normally.\n\n" +
1415
+ "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of the integration target and declare `repo_diff: none` in your report, naming the runtime-state deliverable. If you verified the deliverable is already on the integration target (a prior merge landed it — do NOT re-implement working code), make no commit and declare `repo_diff: none` in your report. The workflow verifies the claim mechanically from git — the task's own merge records, never your declaration, are the proof of delivery. Otherwise commit your changes normally.\n\n" +
1219
1416
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
1220
1417
  "Report back in plain prose: what you built and the outcome." +
1221
1418
  (PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
1222
1419
 
1223
1420
  } else if (step.name === "Review") {
1224
- // See docs/decisions/publish-path.md#already-merged-hydra: when this run did not execute, hydration uses the existing merge.
1225
- if (!alreadyMergedSha) {
1226
- var hydResult = null;
1421
+ // Room #26 blocker 34 (redesign, 2026-09-21): classify the branch
1422
+ // mechanically BEFORE Cass is dispatched. The classifier is pure git
1423
+ // (classify-branch in the lifecycle script): has-work |
1424
+ // already-merged:<sha> | empty-no-work. Attribution is identity-first
1425
+ // from git, then the task's own durable merge records — there is no
1426
+ // agent-authored declared-sha input.
1427
+ // Blocker 42 (2026-09-21): the classifier output crosses an agent()
1428
+ // ferry — the old comment's "no cross-process hydration ferry" was a
1429
+ // fib. Machine facts travel in structured fields ({stdout, stderr}
1430
+ // schema) now, never in prose to be "returned verbatim". The schema
1431
+ // is the guard; the prompt is instruction, never load-bearing.
1432
+ // Unparseable is UNKNOWN, never a positive empty-no-work claim (room
1433
+ // #27: a labeled ferry — "stdout: BRANCH_STATE: has-work" — defeated
1434
+ // the ^-anchored parsers, and the empty-no-work fallback burned the
1435
+ // whole shared rework budget on a branch with real work). UNKNOWN
1436
+ // retries the classify call IN PLACE (up to 2, fresh -u1/-u2 keys — a
1437
+ // full Build round cannot fix an instrument failure, and the only
1438
+ // in-process bounce path spends the shared budget unconditionally).
1439
+ // Instrumentation failures NEVER touch totalReworkCount: an instrument
1440
+ // failure is not a build-quality failure. Exhaustion parks honestly
1441
+ // as unclassifiable — "unknown keeps the task alive (bounded)"; the
1442
+ // park on exhaustion is the fail-closed part. The UNKNOWN path never
1443
+ // asserts empty-no-work: no log line or park note presents it as the
1444
+ // classification. The string can still appear inside quoted evidence
1445
+ // (the verbatim failed return travels in the trailer and the note).
1446
+ // INVARIANT: branchState leaves this block holding a known value, or
1447
+ // the step has parked — unknown never reaches any consumer below.
1448
+ // Re-classify on EVERY Review entry: rework rewinds this step
1449
+ // in-process, and the branch changed between rounds. The loop's
1450
+ // null-gate governs only the in-place instrument retries within
1451
+ // this pass — without the reset, a second Review pass would trust
1452
+ // the first pass's stale classification and a real fix commit
1453
+ // would never "become has-work".
1454
+ branchState = null;
1455
+ branchStateErr = "";
1456
+ alreadyMergedSha = null;
1457
+ var classifyFailedReturn = "";
1458
+ for (var classifyAttempt = 0; classifyAttempt <= 2 && branchState === null; classifyAttempt++) {
1459
+ var classifyPrompt =
1460
+ "Classify the task branch state. This is a mechanical git check, not a judgment call.\n" +
1461
+ "Run in shell: " + LIFECYCLE_ENV + LIFECYCLE + " classify-branch " + taskId + "\n" +
1462
+ "Return the two streams as the two named fields and nothing else: { \"stdout\": \"<the command's exact stdout>\", \"stderr\": \"<the command's exact stderr>\" }." +
1463
+ (classifyAttempt === 0 ? "" : classifyRetryTrailer(branchStateErr, classifyFailedReturn));
1227
1464
  try {
1228
- hydResult = await agent(
1229
- "Read the latest completed Build session for task " + taskId + ".\n" +
1230
- "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1231
- "In the returned sessions array, find the most recent session (by started_at) with task_id \"" + taskId + "\", step \"Build\", and status \"completed\". Return exactly two sections, verbatim, with no commentary:\n" +
1232
- "SHA: <the session's already_merged_sha field value, or the word null when it is null>\n" +
1233
- "NOTES:\n<the session's notes field, verbatim>",
1234
- { key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
1465
+ var bsResult = await agent(
1466
+ classifyPrompt,
1467
+ { key: "classify-branch" + (classifyAttempt === 0 ? "" : "-u" + classifyAttempt) + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
1468
+ label: "Classifying branch state (mechanical)" + (classifyAttempt === 0 ? "" : " (instrument retry " + classifyAttempt + " of 2)"),
1469
+ schema: { type: "object", properties: { stdout: { type: "string" }, stderr: { type: "string" } }, required: ["stdout", "stderr"] } }
1235
1470
  );
1236
- } catch (hydErr) {
1237
- log("Hydration read failed (" + String(hydErr && hydErr.message || hydErr) + "); treating as none declared.");
1471
+ classifyFailedReturn = (bsResult && typeof bsResult === "object" ? JSON.stringify(bsResult) : String(bsResult)).slice(0, 500);
1472
+ var classifyParsed = parseClassifyFerry(bsResult);
1473
+ if (classifyParsed.state !== null) {
1474
+ branchState = classifyParsed.state;
1475
+ branchStateErr = "";
1476
+ // DIAG pin: advisory for has-work/empty-no-work (blocker-34 v2.1
1477
+ // semantics — logged with its source field so silent record loss
1478
+ // surfaces in the evidence); already-merged is gated on the pin
1479
+ // inside parseClassifyFerry.
1480
+ if (classifyParsed.diagField !== null) {
1481
+ log("classify-branch DIAG pin (" + classifyParsed.diagField + " field): " + classifyParsed.diagText);
1482
+ } else {
1483
+ log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
1484
+ }
1485
+ } else {
1486
+ branchStateErr = classifyParsed.failReason;
1487
+ if (classifyAttempt < 2) {
1488
+ log("classify-branch unparseable (" + branchStateErr + ") — retrying in place (attempt " + (classifyAttempt + 1) + " of 2)");
1489
+ }
1490
+ }
1491
+ } catch (bsErr) {
1492
+ classifyFailedReturn = String(bsErr && bsErr.message || bsErr).slice(0, 500);
1493
+ branchStateErr = "classifier call failed: " + String(bsErr && bsErr.message || bsErr).slice(0, 120);
1494
+ if (classifyAttempt < 2) {
1495
+ log("classify-branch instrument failure (" + branchStateErr + ") — retrying in place (attempt " + (classifyAttempt + 1) + " of 2)");
1496
+ }
1238
1497
  }
1239
- var hydStr = hydResult ? ((typeof hydResult === "string") ? hydResult : JSON.stringify(hydResult)) : "";
1240
- var hydSha = /^SHA:\s*([0-9a-f]{7,40})\s*$/im.exec(hydStr);
1241
- if (hydSha) {
1242
- alreadyMergedSha = hydSha[1].toLowerCase();
1243
- log("Hydrated already-merged verification from structured session field: " + alreadyMergedSha);
1244
- } else {
1245
- var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
1246
- if (hvm) {
1247
- alreadyMergedSha = hvm[1].toLowerCase();
1248
- log("Hydrated already-merged verification from Build session notes (fallback): " + alreadyMergedSha);
1498
+ }
1499
+ if (branchState === null) {
1500
+ // Exhaustion: park honestly. An instrument failure is not a
1501
+ // measurement of emptiness — never "empty-no-work".
1502
+ return await parkTask("Branch state unclassifiable after 2 instrument retries (" + branchStateErr + "; last failure: " + classifyFailedReturn + ") — the classifier instrument failed, not the branch. Human attention needed: inspect the task branch directly to determine its state — no trustworthy branch classification was produced.");
1503
+ }
1504
+ if (branchState.indexOf("already-merged:") === 0) {
1505
+ alreadyMergedSha = branchState.slice("already-merged:".length);
1506
+ log("Branch state already-merged: " + alreadyMergedSha + " (DIAG pin present — tripwire satisfied) — Cass reviews the frozen merge diff");
1507
+ } else {
1508
+ log("Branch state: " + branchState);
1509
+ }
1510
+ // buildClaimedNoDiff was set at the Build gate from the full Build
1511
+ // report (same process only). Only an empty branch WITH this claim
1512
+ // goes to Cass for a plausibility judgment; without it, empty-no-work
1513
+ // fails mechanically and Cass is never dispatched.
1514
+ // Blocker-34 follow-up: the package.json `version` check is a mechanical
1515
+ // git fact, not a reviewer judgment. For npm projects the workflow checks
1516
+ // it here, before Cass is dispatched — a touched `version` fails Review
1517
+ // mechanically (versions are assigned at publish time, never in
1518
+ // branches). Review pass 2 (blocker-42 class): this ferry had the full
1519
+ // pre-42 shape — schema-less agent(), line-anchored parser, and an
1520
+ // unparseable fallback asserting a POSITIVE touched claim that spent the
1521
+ // shared rework budget. Same treatment as the classifier:
1522
+ // shape-constrained {stdout} schema, search-and-dedupe parse, UNKNOWN
1523
+ // with in-place retries, honest park. An instrument failure is not a
1524
+ // measurement of touched — never assert touched from unparseable output.
1525
+ if (PUBLISH_TYPE === "npm") {
1526
+ var versionState = null; // "touched" | "clean" | null (null = UNKNOWN)
1527
+ var versionStateErr = "";
1528
+ var versionFailedReturn = "";
1529
+ for (var versionAttempt = 0; versionAttempt <= 2 && versionState === null; versionAttempt++) {
1530
+ var versionPrompt =
1531
+ "Check whether the task branch changed package.json's `version` field. This is a mechanical git check, not a judgment call.\n" +
1532
+ "Run in shell: " + LIFECYCLE_ENV + LIFECYCLE + " version-check " + taskId + "\n" +
1533
+ "Return the command's exact stdout as the single named field and nothing else: { \"stdout\": \"<the command's exact stdout>\" }." +
1534
+ (versionAttempt === 0 ? "" : versionRetryTrailer(versionStateErr, versionFailedReturn));
1535
+ try {
1536
+ var vtResult = await agent(
1537
+ versionPrompt,
1538
+ { key: "version-check" + (versionAttempt === 0 ? "" : "-u" + versionAttempt) + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
1539
+ label: "Checking package.json version (mechanical)" + (versionAttempt === 0 ? "" : " (instrument retry " + versionAttempt + " of 2)"),
1540
+ schema: { type: "object", properties: { stdout: { type: "string" } }, required: ["stdout"] } }
1541
+ );
1542
+ versionFailedReturn = (vtResult && typeof vtResult === "object" ? JSON.stringify(vtResult) : String(vtResult)).slice(0, 500);
1543
+ var vtParsed = parseVersionFerry(vtResult);
1544
+ if (vtParsed.versionState !== null) {
1545
+ versionState = vtParsed.versionState;
1546
+ versionStateErr = "";
1547
+ } else {
1548
+ versionStateErr = vtParsed.failReason;
1549
+ if (versionAttempt < 2) {
1550
+ log("version-check unparseable (" + versionStateErr + ") — retrying in place (attempt " + (versionAttempt + 1) + " of 2)");
1551
+ }
1552
+ }
1553
+ } catch (vtErr) {
1554
+ versionFailedReturn = String(vtErr && vtErr.message || vtErr).slice(0, 500);
1555
+ versionStateErr = "version-check call failed: " + String(vtErr && vtErr.message || vtErr).slice(0, 120);
1556
+ if (versionAttempt < 2) {
1557
+ log("version-check instrument failure (" + versionStateErr + ") — retrying in place (attempt " + (versionAttempt + 1) + " of 2)");
1558
+ }
1249
1559
  }
1250
1560
  }
1561
+ if (versionState === null) {
1562
+ // Exhaustion: park honestly. An instrument failure is not a
1563
+ // measurement of touched — never assert touched.
1564
+ return await parkTask("package.json `version` state unclassifiable after 2 instrument retries (" + versionStateErr + "; last failure: " + versionFailedReturn + ") — the version-check instrument failed, not the branch. Human attention needed: inspect the task branch directly to determine its state — no trustworthy version classification was produced.");
1565
+ }
1566
+ versionTouched = (versionState === "touched");
1567
+ log("Review: package.json `version` " + (versionTouched ? "touched by the branch — mechanical FAIL, Cass not dispatched" : "untouched — mechanical check clean"));
1568
+ }
1569
+ // The empty-branch rule Cass used to adjudicate is gone. What she
1570
+ // examines and what rule she applies depend on the classified state —
1571
+ // never on her own judgment of git identity.
1572
+ var reviewChangeExam, reviewBranchRule;
1573
+ if (branchState === "has-work") {
1574
+ reviewChangeExam =
1575
+ "Examine the code changes by running:\n" +
1576
+ LIFECYCLE_ENV + LIFECYCLE + " inspect " + taskId + "\n\n" +
1577
+ "The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of the integration target (inspect prints the target name). Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n";
1578
+ reviewBranchRule =
1579
+ "MECHANICAL FACT (computed by the workflow from git — never by a reviewer): branch state = has-work. The branch has commits ahead of the integration target. Review their content; branch emptiness is settled and is not yours to judge.\n";
1580
+ } else if (branchState.indexOf("already-merged:") === 0) {
1581
+ reviewChangeExam =
1582
+ "The change under review is the frozen merge " + alreadyMergedSha + " — it is already on the integration target (see MECHANICAL FACT below). Examine it by running:\n" +
1583
+ "cd " + REPO_PATH + " && git diff " + alreadyMergedSha + "^1 " + alreadyMergedSha + "\n\n" +
1584
+ "That first-parent diff is the frozen, attributable change. Base your review ONLY on this diff — do NOT run git log to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n";
1585
+ reviewBranchRule =
1586
+ "MECHANICAL FACT (computed by the workflow from git — never by a reviewer): branch state = already-merged:" + alreadyMergedSha + ". The deliverable landed on the integration target via this task's own prior merge " + alreadyMergedSha + " (verified ancestor of the integration target). The branch is empty by design — emptiness is settled fact, not a finding.\n";
1587
+ } else if (buildClaimedNoDiff) {
1588
+ reviewChangeExam =
1589
+ "The branch has no commits ahead of the integration target (see MECHANICAL FACT below) — there is no diff to examine. Judge the Build report's `repo_diff: none` claim on its plausibility.\n\n";
1590
+ reviewBranchRule =
1591
+ "MECHANICAL FACT (computed by the workflow from git — never by a reviewer): the branch has no commits ahead of the integration target and no task-attributed merge on the target was verified; the Build report declares `repo_diff: none` (no repo change). Approve ONLY if the task's deliverable is plausibly runtime state (e.g. a cron definition, scheduler change, or dashboard/config state created outside the repo). Otherwise report 'no commits ahead of the integration target and no plausible runtime-state deliverable — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n";
1592
+ } else {
1593
+ // Mechanical FAIL — Cass is never dispatched (see the dispatch gate
1594
+ // below). Instructions are unused; keep the shape.
1595
+ reviewChangeExam = "";
1596
+ reviewBranchRule = "";
1251
1597
  }
1252
1598
  instructions = "Review independently and cold. You have NOT seen any reasoning from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
1253
1599
  (mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + mapperSpec + "\n\n" : "Read the spec (from the task description or spec files under " + crewHome + "/).\n\n") +
1254
- "Examine the code changes by running:\n" +
1255
- LIFECYCLE_ENV + LIFECYCLE + " inspect " + taskId + "\n\n" +
1256
- "The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of the integration target (inspect prints the target name). Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n" +
1600
+ reviewChangeExam +
1257
1601
  "You can also read specific files in the worktree at:\n" +
1258
1602
  WORKTREE_HINT + "/\n\n" +
1259
1603
  "Check quality, correctness, and spec compliance.\n" +
1260
1604
  (SURFACE_TERMINAL ? "TERMINAL UX REVIEW: judge the CLI surface against " + UX_DOCTRINE_PATH + " — help accuracy, error quality, exit codes, output clarity. Reject when the bar is not met.\n" : "") +
1261
1605
  (SURFACE_ARTIFACT ? "ARTIFACT UX REVIEW: judge the rendered surface against " + UX_DOCTRINE_PATH + " — alignment, spacing, hierarchy, composition, balance, finish, correctness. Reject when the bar is not met.\n" : "") +
1262
1606
  "Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, report what is stale, then end your report with exactly this line: VERDICT: FAIL.\n" +
1263
- "If the branch has no commits ahead of the integration target (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with (a) a plausible runtime-state deliverable (e.g. a cron created via the cron tool), or (b) an already-merged declaration `repo_diff: none (already-merged: <sha>)` AND the mechanical fact below confirms the sha verified. MECHANICAL FACT (computed by the workflow, never by the builder): already_merged sha = " + (alreadyMergedSha ? alreadyMergedSha + " (verified ancestor of the integration target: YES)" : "none declared") + ". Otherwise report 'no commits ahead of the integration target and no valid repo_diff: none declaration — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n" +
1264
- (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches. Two checks:\n" +
1265
- "(a) The task branch must NOT have changed package.json's `version` field. First resolve the integration target: " + LIFECYCLE_ENV + LIFECYCLE + " integration-target. Then check: cd " + REPO_PATH + " && git diff <target>..." + TASK_BRANCH + " -- package.json (substitute the exact token integration-target printed — when it prints HEAD, use the literal word HEAD: `git diff HEAD...<branch>` diffs the detached checkout against the branch). If the branch touched `version` in any way, report 'versions are assigned at publish time, never in branches — remove the version change' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n" +
1266
- "(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
1607
+ reviewBranchRule +
1608
+ (PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches.\n" +
1609
+ "MECHANICAL FACT (computed by the workflow from git — never by a reviewer): the task branch did not change package.json's `version` field. A branch that touches `version` fails Review mechanically before any reviewer is dispatched, so this is settled — do not re-check it.\n" +
1610
+ "The accepted Build report declares: " + releaseDecisionText() + ". " +
1267
1611
  (releaseDecision
1268
1612
  ? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, report your notes, then end with exactly this line: VERDICT: FAIL."
1269
1613
  : "The decision is missing or malformed — report 'Build report must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines', then end with exactly this line: VERDICT: FAIL.") + "\n" : "") +
@@ -1277,8 +1621,9 @@ while (i < STEPS.length) {
1277
1621
  "Then run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " CREW_STAGED_BASE=<provenance.source_commit, empty when null> integrate " + taskId + " \"merge: fix: " + safeTitle + "\\\"\\n" +
1278
1622
  "The script merges, reconciles, records, and pushes under the merge lock. Informational only: RECONCILED, NO_REMOTE, NO_REMOTE_RECONCILE. Read the output:\n" +
1279
1623
  "- STAGED_BASE_MISMATCH: the line left the staged base (stray checkout). VERDICT: FAIL.\n" +
1280
- "- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or an already-merged sha the workflow verified (Build declared repo_diff: none). No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
1624
+ "- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or a task-attributed merge the workflow verified from the task's own merge records. No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
1281
1625
  "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1626
+ "- STALE_MERGE: the recorded merge no longer matches the task branch (rework moved it after the merge) — the script refused to push stale state. Report the STALE_MERGE line, VERDICT: FAIL.\n" +
1282
1627
  "- LOCK_HELD: 10-minute backoff exhausted. VERDICT: FAIL.\n" +
1283
1628
  "- CONFLICT: the plain merge failed — aborted, the target is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
1284
1629
  "RESOLUTION:\n" +
@@ -1328,8 +1673,9 @@ while (i < STEPS.length) {
1328
1673
  // See docs/decisions/publish-path.md#deterministic-artifact-publish: the work agent never publishes; the parent runs the deterministic publish script.
1329
1674
  // Blocker 22 (one-party publish, room #24): verified re-entry guard.
1330
1675
  // The workflow parks at publish intent; the tick worker issues
1331
- // artifact_edit, verifies by read-back, stamps provenance, and
1332
- // re-queues. The dispatcher resumes this task at the Publish step
1676
+ // artifact_edit; the ack scan stamps provenance on exact per-attempt
1677
+ // version acknowledgement (sole positive completion criterion; no
1678
+ // read-back — docs/publish-verification.md) and re-queues. The dispatcher resumes this task at the Publish step
1333
1679
  // (the parked session's own step — the failed→retry path). If the
1334
1680
  // stamped provenance names THIS task and its source_commit is the
1335
1681
  // repo's current HEAD, the merge commit was already published and
@@ -1385,8 +1731,10 @@ while (i < STEPS.length) {
1385
1731
  }
1386
1732
  // See docs/decisions/publish-path.md#step1-builder-source: the builder's source tree is NOT the crew's repo; verify report before stamping.
1387
1733
  // (2026-09-20, one-party worker-owned publish) A verified re-entry
1388
- // skips the whole issuance tail: the edit already went out, was
1389
- // verified by the parent read-back, and was stamped. The empty-diff
1734
+ // skips the whole issuance tail: the edit already went out, the
1735
+ // ack scan saw the exact per-attempt version acknowledged (sole
1736
+ // positive completion criterion; no read-back) and it was stamped.
1737
+ // The empty-diff
1390
1738
  // park must not fire on this branch.
1391
1739
  if (!publishSkippedNoLock && !publishAlreadyVerified) {
1392
1740
  // The trigger key of the attempt that last ran, for the publish ledger.
@@ -1532,6 +1880,33 @@ while (i < STEPS.length) {
1532
1880
  if (diffSummary.has_rename) {
1533
1881
  return await parkTask("Publish diff contains a rename — the diff transport cannot carry renames. Human attention needed.");
1534
1882
  }
1883
+ // Room #26 blocker 38 (2026-09-21): persist the publish-diff file
1884
+ // set for QA content-finding attribution. The workflow classifies
1885
+ // Hazel's content findings against this set mechanically at QA
1886
+ // closeout — the file list is the complete record of what the task
1887
+ // changed. The courier is pure hands: it writes the deterministic
1888
+ // script's own file list, never interprets it.
1889
+ // Blocker 40 (2026-09-21): this courier carries the same
1890
+ // {output: string} schema as the sibling diff-computation call.
1891
+ // The command is stdout-silent; schema-less, the runtime's
1892
+ // non-empty-result contract throws on the empty string and the
1893
+ // workflow parks fail-closed for a side effect that landed. Empty
1894
+ // stdout is schema-valid, so the contract no longer fires. "The
1895
+ // schema bypasses the contract" is INFERRED, not observed —
1896
+ // falsification: if a schema-carrying courier still parks on
1897
+ // silent stdout, the platform contract is deeper than the schema
1898
+ // and this is wrong; room evidence decides. (The file on disk is
1899
+ // not read as proof until QA closeout — the QA loader fails
1900
+ // closed to "unknown" attribution when it is absent.)
1901
+ var publishDiffFilesPath = crewHome + "/.publish-diffs/" + taskId + ".files.json";
1902
+ await agent(
1903
+ "Write the publish diff file list.\n" +
1904
+ "Run exactly this and return the stdout verbatim as { \"output\": \"<verbatim stdout>\" } and nothing else. Do not interpret it:\n" +
1905
+ "mkdir -p " + crewHome + "/.publish-diffs && cat > \"" + publishDiffFilesPath + "\" <<'DIFFFILES_EOF'\n" +
1906
+ JSON.stringify(diffSummary.files || []) + "\nDIFFFILES_EOF\n",
1907
+ { key: attemptKey("publish-diff-files-" + taskId, totalReworkCount), label: "Persisting publish diff file list",
1908
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1909
+ );
1535
1910
  // Budget counts CHANGED lines (added + removed), not raw unified-diff
1536
1911
  // output lines: context lines and file headers inflated the old
1537
1912
  // split("\n").length count ~2x, parking a 95-line change against a
@@ -1641,11 +2016,13 @@ while (i < STEPS.length) {
1641
2016
  // mechanical outcome above. It must not rebuild or re-stamp: a second
1642
2017
  // artifact_edit would trigger a duplicate build.
1643
2018
  // (2026-09-20, one-party worker-owned publish) On a verified re-entry
1644
- // the publish was issued by the crew worker, verified by the parent
1645
- // read-back, and stamped — the work agent reports that, VERDICT: PASS.
2019
+ // the publish was issued, the ack scan observed the artifact's exact
2020
+ // version acknowledgement, and provenance was stamped — already
2021
+ // verified, nothing to issue. The work agent reports that,
2022
+ // VERDICT: PASS.
1646
2023
  if (publishAlreadyVerified) {
1647
2024
  instructions = "Publish was already completed and verified for this task — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would disturb the finalized state.\n\n" +
1648
- "The crew's publish worker issued the artifact edit for the merged commit, the parent's independent content read-back confirmed the artifact contains the merged change, and provenance was stamped (docs/publish-verification.md). This Publish pass is a verified re-entry after the stamp: there is nothing to issue and nothing to re-verify.\n\n" +
2025
+ "Provenance stamps this task's merge commit as published (docs/publish-verification.md): the tick worker issued the edit, the ack scan observed the artifact's exact version acknowledgement — the sole positive completion criterion — and the publish was stamped. Already verified, nothing to issue and nothing to re-verify.\n\n" +
1649
2026
  "For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
1650
2027
  "Write plain prose describing what was published, then on its own line: VERDICT: PASS\n" +
1651
2028
  "The VERDICT line must be the last line of your report.";
@@ -1658,10 +2035,10 @@ while (i < STEPS.length) {
1658
2035
  instructions = "Publish the merged code to the live artifact.\n\n" +
1659
2036
  "The publish was performed deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would trigger a duplicate build or disturb the finalized state. You perform no publish actions.\n\n" +
1660
2037
  "For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
1661
- "Mechanical outcome (the workflow's mechanical steps; content verification is the parent's, still pending):\n" +
2038
+ "Mechanical outcome (the workflow's mechanical steps; the ack scan's version-acknowledgement check is still pending):\n" +
1662
2039
  "- merge lock refreshed: yes\n" +
1663
2040
  "- artifact rebuild triggered and completed: yes\n" +
1664
- "- provenance stamped: NO — not yet, and you must NOT stamp it. The stamp moved to the parent, which stamps it only after an independent content read-back confirms the artifact actually contains the merged change (docs/publish-verification.md). A stamped-but-hollow build is exactly how canary run 8 (2026-09-11) went green on stale content.\n" +
2041
+ "- provenance stamped: NO — not yet, and you must NOT stamp it. The ack scan stamps it when it observes the artifact's exact on-disk acknowledgement of this attempt's version — version acknowledgement is the sole positive completion criterion (docs/publish-verification.md). A stamped-but-hollow build is exactly how canary run 8 (2026-09-11) went green on stale content.\n" +
1665
2042
  "- post-deploy finalized: yes (worktree removed, merge lock released)\n\n" +
1666
2043
  "POLICY: The live artifact is rebuilt only in this phase, from the repo. Never use artifact_edit to change the artifact directly — fixes go through the repo and the loop. A source fix is not done until the artifact is rebuilt from it here.\n\n" +
1667
2044
  "The repo push already happened in Integrate — do NOT push to git in this phase.\n\n" +
@@ -1693,7 +2070,7 @@ while (i < STEPS.length) {
1693
2070
  "Verify the fix against the task description in the returned data: the state the bug corrupted should now read correctly, and the task's own expected behavior should hold.\n" +
1694
2071
  "You can also check specific data with: node " + CREW_API + " --crew-home " + crewHome + " get-events --json '{\"task_id\":\"<the task id>\"}'.\n" +
1695
2072
  "Do NOT use artifact_inspect — it was removed by the platform (2026-09-14) and does not exist; do not substitute artifact.inspect (malfunction diagnosis, not an inspection tool).\n" +
1696
- "File follow-up tasks by running in shell:\n" + crewCmd("create-task", { title: "<short title>", description: "<details>", project: "<project id>", workflow: "bugfix", filed_by: "hazel" }) + "\n(substitute the real values for the placeholders).\n" +
2073
+ "File follow-up tasks by running in shell (EXCEPT content findings — user-visible content/data goes in the CONTENT-FINDINGS block at the end of these instructions, never through create-task):\n" + crewCmd("create-task", { title: "<short title>", description: "<details>", project: "<project id>", workflow: "bugfix", filed_by: "hazel" }) + "\n(substitute the real values for the placeholders).\n" +
1697
2074
  npmPublishCheck +
1698
2075
  "Report your test results as plain prose.\n" +
1699
2076
  "End your report with exactly one line: VERDICT: PASS if testing passes, VERDICT: FAIL if it fails.";
@@ -1721,7 +2098,7 @@ while (i < STEPS.length) {
1721
2098
  "Verify the fix against the task description in the returned data: the state the bug corrupted should now read correctly, and the task's own expected behavior should hold.\n" +
1722
2099
  "You can also check specific data with: node " + CREW_API + " --crew-home " + crewHome + " get-events --json '{\"task_id\":\"<the task id>\"}'.\n" +
1723
2100
  npmPublishCheck +
1724
- "File follow-up tasks by running in shell:\n" + crewCmd("create-task", { title: "<short title>", description: "<details>", project: "<project id>", workflow: "bugfix", filed_by: "hazel" }) + "\n(substitute the real values for the placeholders).\n\n" +
2101
+ "File follow-up tasks by running in shell (EXCEPT content findings — user-visible content/data goes in the CONTENT-FINDINGS block at the end of these instructions, never through create-task):\n" + crewCmd("create-task", { title: "<short title>", description: "<details>", project: "<project id>", workflow: "bugfix", filed_by: "hazel" }) + "\n(substitute the real values for the placeholders).\n\n" +
1725
2102
  "BASELINE SANITY: in the event history you fetched, the task's note events must contain a message starting with `baseline: captured` or `baseline: none`. If no message starts with either prefix, report 'baseline evidence missing at QA — the Map gate was bypassed', then end your report with exactly this line: VERDICT: FAIL.\n\n" +
1726
2103
  "Report your test results as plain prose.\n" +
1727
2104
  "End your report with exactly one line: VERDICT: PASS if testing passes, VERDICT: FAIL if it fails on the visual or the mechanical checks. Checks you could not run are evidence gaps, not silent drops: name every one in --missing — unknown is neither PASS nor FAIL. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl — an empty log is honest, an absent one is a broken report). Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/postchange/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the reported bug, fixed>\" --actual \"<what you observed>\" --missing '[\"honest evidence gap, if any\"]' [--reason \"<why it failed — REQUIRED and non-empty when verdict is FAIL or NOT_POSSIBLE; the script rejects a reason-less negative verdict with exit 2>\"] — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten). A FAIL verdict must carry a machine-readable reason: the workflow closeout cross-checks verdict.json against your prose VERDICT line, and an unreasoned or contradictory verdict fails the phase (never routes to rework).";
@@ -1748,7 +2125,7 @@ while (i < STEPS.length) {
1748
2125
  "Use the returned tasks, sessions, and events to check the task's data-level effects.\n" +
1749
2126
  "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
1750
2127
  "STEP 3: File follow-up tasks for any related issues you discover.\n" +
1751
- "For each issue, run in shell:\n" +
2128
+ "For each issue (EXCEPT content findings — user-visible content/data goes in the CONTENT-FINDINGS block at the end of these instructions, never through create-task), run in shell:\n" +
1752
2129
  "node " + CREW_API + " --crew-home " + crewHome + " create-task --json '{\"title\": \"<issue title>\", \"description\": \"<issue details>\", \"project\": \"" + LAUNCH_PROJECT_ID + "\", \"workflow\": \"bugfix\", \"filed_by\": \"hazel\"}'\n" +
1753
2130
  "(replace <issue title> and <issue details> with the real values).\n\n" +
1754
2131
  "BASELINE SANITY: in the event history you fetched, the task's note events must contain a message starting with `baseline: captured` or `baseline: none`. If no message starts with either prefix, report 'baseline evidence missing at QA — the Map gate was bypassed', then end your report with exactly this line: VERDICT: FAIL. When the baseline is terminal transcripts, confirm every terminal target you judged has a baseline transcript: a target with no pre-change transcript is an evidence gap — name it in --missing, never invent the baseline.\n\n" +
@@ -1767,6 +2144,21 @@ while (i < STEPS.length) {
1767
2144
  "2. Run: cd " + REPO_PATH + " && git log --format=%s <provenance.source_commit>..LIVE_HEAD (substitute real values). Every subject line MUST start with \"rebuild: \". If any line does not, report 'provenance mismatch: live HEAD moved past the stamped commit with non-rebuild source commits: [paste the offending subject lines]', then end your report with exactly this line: VERDICT: FAIL.\n\n" +
1768
2145
  instructions;
1769
2146
  }
2147
+ // Room #26 blocker 38 (2026-09-21): content-finding attribution
2148
+ // protocol. Hazel reports user-visible content/data observations in a
2149
+ // machine-readable CONTENT-FINDINGS block; the workflow classifies each
2150
+ // against the task's publish diff and files follow-ups itself — Hazel
2151
+ // never files bugfixes for content findings directly, and
2152
+ // environment-attributable content never fails her verdict alone.
2153
+ var contentFindingsProtocol =
2154
+ "CONTENT FINDINGS (machine-read — room #26 blocker 38): if you observe user-visible content or data that looks wrong, stale, or out of place (decks, cards, rows, text, files in the artifact — anything this task did not obviously produce), report it here — do NOT file a follow-up task for it yourself and do NOT fail your verdict for it alone. Emit exactly one line in this shape, immediately BEFORE your final VERDICT line (the verdict stays the last line of your report):\n" +
2155
+ "CONTENT-FINDINGS: [{\"subject\": \"<repo-relative file path, or the literal live-data for content in the artifact's runtime data stores>\", \"observation\": \"<what you saw, one line>\"}, ...]\n" +
2156
+ "Use subject \"live-data\" for anything you saw in the running artifact (UI content, database rows, uploaded files) — you are code-blind and cannot name its file. Use a repo-relative file path only when you know the content lives in a specific file (e.g. from the task description or public docs). When you saw no such content, emit the empty array: CONTENT-FINDINGS: [].\n" +
2157
+ "The workflow classifies each finding against the task's publish diff: a finding whose subject file is in the diff is attributable to this task's change and the workflow files a bugfix for it; anything else is recorded with its attribution (environment-attributable, or unknown when the diff is unavailable) — the finding itself never spawns an artifact bugfix. No finding is ever dropped for looking like test residue: it is classified by attribution and recorded with it. Your VERDICT judges this task's change; content you cannot attribute to it is not a failure of this task.\n";
2158
+ // The content-findings block is machine-read at closeout (blocker 38).
2159
+ // Hazel emits it immediately before the VERDICT line, so the verdict
2160
+ // keeps its trailing-window contract with extractVerdict.
2161
+ instructions += "\n" + contentFindingsProtocol;
1770
2162
  }
1771
2163
 
1772
2164
  // Task event history — Review and Publish are excluded. Review is cold by
@@ -1825,7 +2217,31 @@ while (i < STEPS.length) {
1825
2217
  }
1826
2218
  var workerResult = null;
1827
2219
  var workAttempts = [];
1828
- for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
2220
+ // Room #26 blocker 34 (redesign): an empty branch with no verified sha and
2221
+ // no repo_diff: none claim fails Review mechanically — Cass is never
2222
+ // dispatched, so no reviewer adjudicates the emptiness. The synthetic
2223
+ // report below is written by the workflow and flows through the same
2224
+ // verdict extraction, structured verdict recording, and rejection/bounce
2225
+ // path as a real FAIL.
2226
+ // Blocker-34 follow-up: the npm `version` check joins the mechanical gate.
2227
+ // Either mechanical fact fails Review without dispatching Cass.
2228
+ var mechanicalFailReason = (versionTouched ? "version-touched" : (branchState === "empty-no-work" && !buildClaimedNoDiff ? "empty-no-work" : null));
2229
+ var mechanicalReviewFail = (step.name === "Review" && mechanicalFailReason !== null);
2230
+ if (mechanicalReviewFail) {
2231
+ log("Review: " + mechanicalFailReason + " — mechanical FAIL, Cass not dispatched");
2232
+ workerResult =
2233
+ "MECHANICAL REVIEW VERDICT (written by the workflow — no reviewer was dispatched).\n" +
2234
+ (mechanicalFailReason === "version-touched"
2235
+ ? "Package.json `version` (checked by the workflow from git): the task branch changed the `version` field. Versions are assigned at publish time — never in branches. Remove the version change.\n"
2236
+ : "Branch state (classified by the workflow from git): empty-no-work — the task branch has no commits ahead of the integration target, " +
2237
+ "no task-attributed merge on the target was verified" + (branchStateErr ? " (branch classification itself failed: " + branchStateErr + ")" : "") + ", " +
2238
+ "and the accepted Build report declared no `repo_diff: none` deliverable. " +
2239
+ "No change was reviewed because there is no change to review. The builder likely forgot to commit.\n") +
2240
+ "Worktree: " + WORKTREE_HINT + "\n" +
2241
+ "Recovery: commit the deliverable on the task branch in the worktree above and re-run Review. If the deliverable is genuinely runtime state outside the repo, declare `repo_diff: none` in the Build report instead of leaving the branch empty without a declaration.\n" +
2242
+ "VERDICT: FAIL";
2243
+ }
2244
+ for (var workAttempt = 0; workAttempt <= 2 && !mechanicalReviewFail; workAttempt++) {
1829
2245
  var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), workAttempt);
1830
2246
  var prevAttempt = workAttempt === 0 ? null : workAttempts[workAttempt - 1];
1831
2247
  var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? "discarded" : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
@@ -1926,7 +2342,18 @@ while (i < STEPS.length) {
1926
2342
  session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name,
1927
2343
  status: "failed", notes: "Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)" },
1928
2344
  event: { task_id: taskId, type: "failed",
1929
- message: step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" }
2345
+ message: step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" },
2346
+ // Room #26 blocker 33: INDETERMINATE verdict record — the report
2347
+ // could not be read at all, but its full text is still preserved
2348
+ // as grounds. Review-scoped; other verdict steps keep the
2349
+ // existing failed-session behavior with no verdict row.
2350
+ verdict: (step.name === "Review" ? {
2351
+ step: "Review",
2352
+ attempt: totalReworkCount,
2353
+ reviewer: step.identity,
2354
+ verdict: "INDETERMINATE",
2355
+ grounds: workerText
2356
+ } : null)
1930
2357
  }),
1931
2358
  { key: "record-block-" + step.name, label: "Recording verdict failure" }
1932
2359
  );
@@ -2127,40 +2554,123 @@ while (i < STEPS.length) {
2127
2554
  }
2128
2555
  log("Build worktree confinement passed: " + wt.path);
2129
2556
 
2130
- // See docs/decisions/publish-path.md#already-merged-idem: an already-merged repo_diff is idempotent; no rebuild.
2131
- var am = extractAlreadyMerged(workerText);
2132
- if (am.sha) {
2133
- var amCheck = await agent(
2134
- "Verify the builder's already-merged declaration.\n" +
2135
- "Run in shell and return the stdout verbatim:\n" +
2136
- "TARGET=$(" + LIFECYCLE_ENV + LIFECYCLE + " integration-target) && cd " + REPO_PATH + " && git rev-parse --verify --quiet " + am.sha + " >/dev/null && git merge-base --is-ancestor " + am.sha + " $TARGET && echo ALREADY_MERGED_YES || echo ALREADY_MERGED_NO",
2137
- { key: "verify-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Verifying already-merged declaration" }
2557
+ // Room #26 blocker 34 (redesign, 2026-09-21): capture the plain
2558
+ // `repo_diff: none` (no repo change) claim from the FULL Build report.
2559
+ // Each Build round sets this fresh from its own report; there is no
2560
+ // cross-process fallback. The agent-authored already-merged
2561
+ // declaration and its Build-closeout verification are deleted —
2562
+ // attribution is the classifier's job at Review entry.
2563
+ buildClaimedNoDiff = /repo_diff:\s*none/im.test(workerText || "");
2564
+ }
2565
+
2566
+ // Room #26 blocker 38 (2026-09-21): content-finding attribution at QA
2567
+ // closeout. Hazel's CONTENT-FINDINGS block is extracted deterministically
2568
+ // and each finding is classified against the task's publish-diff file set
2569
+ // (persisted at Publish). Attributable findings become bugfix tasks filed
2570
+ // by the workflow; environment-attributable findings are recorded as task
2571
+ // note events with their attribution — never suppressed, never a bugfix,
2572
+ // never a park. No automatic FAIL override: if the QA agent still
2573
+ // reports FAIL, it stands — finding attribution informs follow-up
2574
+ // filing only.
2575
+ if (step.name === "QA") {
2576
+ var contentFindings = extractContentFindings(workerText);
2577
+ if (!contentFindings.ok) {
2578
+ // Unknown attribution fails closed: a missing or malformed
2579
+ // CONTENT-FINDINGS block blocks the phase for retry — the way an
2580
+ // unreadable VERDICT line does (record-phase failed + halted run).
2581
+ // It must never degrade to "no findings" (fail-open): unknown
2582
+ // attribution converted to an empty set silently drops the
2583
+ // observations the prose grounds carry. Do NOT re-ask an LLM to
2584
+ // repair a machine-readable contract — enforce the contract.
2585
+ log("QA CONTENT-FINDINGS unreadable (" + contentFindings.reason + ") — marking failed for retry");
2586
+ await agent(
2587
+ "Record content-findings failure.\n" +
2588
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2589
+ task_id: taskId,
2590
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
2591
+ notes: "QA report had no machine-readable CONTENT-FINDINGS block (" + contentFindings.reason + "); phase failed for retry" },
2592
+ event: { task_id: taskId, type: "failed", message: "QA CONTENT-FINDINGS block unreadable (" + contentFindings.reason + ") — phase failed, dispatcher will retry" }
2593
+ }),
2594
+ { key: "record-block-" + step.name, label: "Recording content-findings failure" }
2138
2595
  );
2139
- var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
2140
- if (!/ALREADY_MERGED_YES/.test(amOut)) {
2141
- log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of the integration target — marking failed for retry");
2596
+ return {
2597
+ __hatchWorkflowControl: "blocked",
2598
+ result: {
2599
+ blocked_reason: "QA worker report had no machine-readable CONTENT-FINDINGS block (" + contentFindings.reason + ")",
2600
+ message: "The " + step.identity + " agent's work may be valid — its report did not carry a CONTENT-FINDINGS block the workflow could read, and the attribution contract fails closed rather than degrading to \"no findings\". The report is preserved in the workflow log.",
2601
+ task_id: taskId
2602
+ }
2603
+ };
2604
+ }
2605
+ // The publish-diff file set, persisted at Publish. Unreadable →
2606
+ // attribution unknown: every finding records "unknown" with the
2607
+ // reason naming the missing diff (never environment-attributable,
2608
+ // never a bugfix). The QA verdict stands on its own.
2609
+ var qaDiffFiles = [];
2610
+ var qaDiffFilesOk = false;
2611
+ try {
2612
+ var qaDiffFilesOut = await agent(
2613
+ "Run exactly one command and return the stdout verbatim:\n" +
2614
+ "cat \"" + crewHome + "/.publish-diffs/" + taskId + ".files.json\" 2>/dev/null || echo DIFF_FILES_MISSING\n" +
2615
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
2616
+ { key: attemptKey("qa-diff-files-" + taskId, totalReworkCount), label: "Loading publish diff file list",
2617
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
2618
+ );
2619
+ var qaDiffFilesRaw = ((qaDiffFilesOut && qaDiffFilesOut.output) || "").trim();
2620
+ if (qaDiffFilesRaw && qaDiffFilesRaw !== "DIFF_FILES_MISSING") {
2621
+ var qaDiffParsed = JSON.parse(qaDiffFilesRaw);
2622
+ if (Array.isArray(qaDiffParsed)) {
2623
+ qaDiffFiles = qaDiffParsed;
2624
+ qaDiffFilesOk = true;
2625
+ }
2626
+ }
2627
+ } catch (e) {
2628
+ log("QA diff file list unreadable: " + ((e && e.message ? e.message : String(e)) || "").slice(0, 200));
2629
+ }
2630
+ if (!qaDiffFilesOk) {
2631
+ log("QA publish diff file list unavailable — content findings record attribution unknown");
2632
+ }
2633
+ for (var cfi = 0; cfi < contentFindings.findings.length; cfi++) {
2634
+ var cf = contentFindings.findings[cfi];
2635
+ // Unknown attribution fails closed: when the diff file set is
2636
+ // unavailable we cannot attribute, so the finding is recorded as
2637
+ // "unknown" (never environment-attributable, never a bugfix) and the
2638
+ // QA verdict stands — the journey does not continue on unproven
2639
+ // attribution.
2640
+ var cfc = qaDiffFilesOk ? classifyContentFinding(cf, qaDiffFiles)
2641
+ : { attribution: "unknown", reason: "publish diff file set unavailable — cannot attribute" };
2642
+ if (cfc.attribution === "task-change") {
2643
+ log("QA content finding attributable to task change — filing bugfix: " + cf.subject + " :: " + cf.observation.slice(0, 120));
2142
2644
  await agent(
2143
- "Record already-merged verification failure.\n" +
2144
- "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2145
- task_id: taskId,
2146
- session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
2147
- notes: "Build declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of the integration target in the configured repo. The declaration is fabricated or mistaken; the work is not on the integration target. Phase failed for retry" },
2148
- event: { task_id: taskId, type: "failed", message: "Build already-merged declaration failed verification — " + am.sha + " not an ancestor of the integration target, phase failed, dispatcher will retry" }
2149
- }),
2150
- { key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
2645
+ "File the follow-up task.\n" +
2646
+ "Run in shell and return the stdout verbatim:\n" +
2647
+ "node " + CREW_API + " --crew-home " + crewHome + " create-task --json '" +
2648
+ JSON.stringify({ title: "QA content finding: " + cf.observation.slice(0, 120), description: "Content finding from QA on task " + taskId + " (subject: " + cf.subject + ", classified task-change: " + cfc.reason + "). Observation: " + cf.observation, project: LAUNCH_PROJECT_ID, workflow: "bugfix", filed_by: "hazel" }).split("'").join("'\\''") + "'\n" ,
2649
+ { key: attemptKey("qa-content-bugfix-" + taskId + "-" + cfi, totalReworkCount), label: "Filing attributable content bugfix" }
2650
+ );
2651
+ } else {
2652
+ // environment-attributable and unknown findings are recorded as task
2653
+ // note events with their attribution — never suppressed, never a
2654
+ // bugfix, never a park by themselves. The QA verdict stands on its
2655
+ // own: we do not override a FAIL based on finding attribution, because
2656
+ // the verdict may rest on functional grounds the findings do not capture.
2657
+ log("QA content finding " + cfc.attribution + " — recording, no bugfix: " + cf.subject + " :: " + cf.observation.slice(0, 120));
2658
+ await agent(
2659
+ "Record the " + cfc.attribution + " content finding.\n" +
2660
+ "Run in shell and return the stdout verbatim:\n" +
2661
+ crewCmd("log-event", { task_id: taskId, type: "note", message: "content-finding: " + cfc.attribution + " — subject: " + cf.subject + " — " + cf.observation + " (" + cfc.reason + ")" }) + "\n" ,
2662
+ { key: attemptKey("qa-content-record-" + taskId + "-" + cfi, totalReworkCount), label: "Recording " + cfc.attribution + " finding" }
2151
2663
  );
2152
- return {
2153
- __hatchWorkflowControl: "blocked",
2154
- result: {
2155
- blocked_reason: "Build already-merged declaration failed verification",
2156
- message: "The builder declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of the integration target. The work is not on the integration target; the phase is marked failed and the dispatcher will retry Build.",
2157
- task_id: taskId
2158
- }
2159
- };
2160
2664
  }
2161
- alreadyMergedSha = am.sha;
2162
- log("Build already-merged declaration verified: " + am.sha + " is an ancestor of the integration target");
2163
2665
  }
2666
+ // No automatic FAIL override. The QA prompt instructs Hazel that her
2667
+ // VERDICT judges this task's change and unattributable content is not a
2668
+ // failure of this task; if she still reports FAIL, it stands. The
2669
+ // recorded finding attributions (above) give the human the evidence to
2670
+ // distinguish environment residue from task failure. Overriding a FAIL
2671
+ // merely because all listed findings are environment-attributable would
2672
+ // be too broad — the prose may name functional failures the findings do
2673
+ // not capture.
2164
2674
  }
2165
2675
 
2166
2676
  // Deterministic closeout: no formatter agent. The verdict is mechanical
@@ -2172,6 +2682,9 @@ while (i < STEPS.length) {
2172
2682
  summary: workerText
2173
2683
  };
2174
2684
  let passed = stepResult.passed === true;
2685
+ // Blocker 38: content findings are recorded with their attribution in the
2686
+ // session notes above; the QA verdict stands on its own and is never
2687
+ // overridden by finding attribution.
2175
2688
 
2176
2689
  // See docs/decisions/publish-path.md#integrate-verify: the agent cannot verify integrate mechanically; the workflow checks the diff.
2177
2690
  if (step.name === "Integrate" && passed) {
@@ -2274,9 +2787,23 @@ while (i < STEPS.length) {
2274
2787
  } else {
2275
2788
  summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
2276
2789
  }
2277
- // See docs/decisions/publish-path.md#already-merged: when the Build gate verifies already-merged, attestation is recorded.
2278
- if (step.name === "Build" && alreadyMergedSha) {
2279
- summary += "\nalready_merged_verified: " + alreadyMergedSha;
2790
+ // Room #26 blocker 34 (redesign, 2026-09-21): machine-readable review
2791
+ // basis, prepended to Review session notes for observability (proof
2792
+ // rooms read the summary to see what Review examined and why a
2793
+ // reviewer was or was not dispatched).
2794
+ if (step.name === "Review" && branchState) {
2795
+ var reviewBasisNote;
2796
+ if (mechanicalReviewFail) {
2797
+ reviewBasisNote = "REVIEW_BASIS: none — mechanical FAIL (" + mechanicalFailReason + "), no reviewer dispatched";
2798
+ } else if (branchState.indexOf("already-merged:") === 0) {
2799
+ var rbSha = branchState.slice("already-merged:".length);
2800
+ reviewBasisNote = "REVIEW_BASIS: frozen merge " + rbSha + " — reviewed via git diff " + rbSha + "^1 " + rbSha;
2801
+ } else if (branchState === "has-work") {
2802
+ reviewBasisNote = "REVIEW_BASIS: branch diff — reviewed via inspect output (task branch vs integration target)";
2803
+ } else {
2804
+ reviewBasisNote = "REVIEW_BASIS: runtime-state-none — no repo change; plausibility of Build's repo_diff: none claim";
2805
+ }
2806
+ summary = reviewBasisNote + "\n" + summary;
2280
2807
  }
2281
2808
 
2282
2809
  // Visual verdict evidence (2026-09-15): no parent marker is appended. Hazel
@@ -2311,18 +2838,45 @@ while (i < STEPS.length) {
2311
2838
  releaseDecision = extractReleaseDecision(workerText);
2312
2839
  }
2313
2840
 
2841
+ // Room #26 blocker 34 (redesign, 2026-09-21): structured review basis
2842
+ // for the verdict row — what the review actually examined. A mechanical
2843
+ // FAIL has no reviewer and no basis beyond the mechanical fact. Only
2844
+ // computed for Review (branchState is null on other steps).
2845
+ var reviewBasis = null;
2846
+ if (step.name === "Review" && branchState) {
2847
+ reviewBasis =
2848
+ mechanicalReviewFail ? "mechanical-fail" :
2849
+ branchState.indexOf("already-merged:") === 0 ? "frozen-merge:" + branchState.slice("already-merged:".length) :
2850
+ branchState === "has-work" ? "branch-diff" : "runtime-state-none";
2851
+ }
2852
+
2314
2853
  await agent(
2315
2854
  "Update the session and log the event.\n" +
2316
2855
  "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2317
2856
  task_id: taskId,
2318
- // Room #16 blocker 11: the workflow-verified already-merged sha as
2319
- // structured control state. Only the Build gate sets alreadyMergedSha
2320
- // (after the mechanical ancestor check); the API validates the shape
2321
- // and a later write without the field never clears it (COALESCE).
2857
+ // Room #26 blocker 34 (2026-09-21): the already_merged_sha session
2858
+ // field is no longer written — the agent-authored declaration path
2859
+ // is deleted and nothing reads it. Removing the Crew API/database
2860
+ // column is a separate API-surface follow-up, not bundled here.
2322
2861
  session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name,
2323
- status: status, notes: summary, already_merged_sha: (step.name === "Build" ? alreadyMergedSha : null) },
2862
+ status: status, notes: summary },
2324
2863
  event: { task_id: taskId, type: status, identity: step.identity,
2325
- message: step.name + " " + status + " by " + step.identity }
2864
+ message: step.name + " " + status + " by " + step.identity },
2865
+ // Room #26 blocker 33: structured, non-lossy Review verdict record.
2866
+ // The session note above is truncated; the verdict row carries the
2867
+ // full worker report as grounds, written in the same transaction.
2868
+ // attempt is the rework round (0 = first Review).
2869
+ verdict: (step.name === "Review" && verdictPassed !== null ? {
2870
+ step: "Review",
2871
+ attempt: totalReworkCount,
2872
+ // Room #26 blocker 34 (redesign): a mechanical FAIL has no
2873
+ // reviewer — the workflow wrote the verdict. Identity rows still say
2874
+ // the Review step ran.
2875
+ reviewer: mechanicalReviewFail ? "workflow" : step.identity,
2876
+ verdict: verdictPassed ? "PASS" : "FAIL",
2877
+ review_basis: reviewBasis,
2878
+ grounds: workerText
2879
+ } : null)
2326
2880
  }),
2327
2881
  {
2328
2882
  key: "record-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
@@ -2342,21 +2896,20 @@ while (i < STEPS.length) {
2342
2896
 
2343
2897
  // Review/QA rejection bounces to Build
2344
2898
  if (!passed && (step.name === "Review" || step.name === "QA")) {
2899
+ // Room #26 blocker 34 (redesign, 2026-09-21): the false-negative guard,
2900
+ // the Case B budget skip, and the already-merged corrective are all
2901
+ // gone. Emptiness is classified mechanically before Review, so a
2902
+ // rejected Review is never re-adjudicated here — the workflow owns the
2903
+ // git facts, the reviewer owns quality/spec compliance, and the budget
2904
+ // applies uniformly to every rejection. A rejected frozen merge
2905
+ // bounces with the reviewer's notes; a real fix commit becomes
2906
+ // has-work, and a do-nothing loop exhausts the normal budget.
2345
2907
  totalReworkCount++;
2346
2908
  if (totalReworkCount > MAX_TOTAL_REWORK) {
2347
2909
  log("Shared rework budget exhausted for task " + taskId + " — worktree preserved at " + WORKTREE_PRESERVED_HINT + " for manual inspection");
2348
2910
  return await parkTask("Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after " + step.name + " rejection. Worktree preserved.");
2349
2911
  }
2350
2912
  rejectionNotes = summary;
2351
- // Already-merged corrective (room #16 blocker 11): when Review rejected
2352
- // an empty branch but the work is already on the integration target (the workflow verified
2353
- // the sha), Wren must declare it — not re-implement or re-commit
2354
- // already-landed work. Scoped to the empty-branch rejection; any other
2355
- // rejection already carries its own specific notes.
2356
- if (step.name === "Review" && alreadyMergedSha && /no commits ahead of (main|the integration target)/i.test(summary)) {
2357
- rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on the integration target — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of the integration target. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
2358
- log("Rework corrective appended for task " + taskId + ": already-merged " + alreadyMergedSha + " — Wren must declare, not rebuild");
2359
- }
2360
2913
  i = BUILD_INDEX;
2361
2914
  log(step.name + " rejected — bouncing to Build (rework #" + totalReworkCount + " of " + MAX_TOTAL_REWORK + ")");
2362
2915
  continue;