muse-crew 0.14.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.7",
3
+ "version": "0.14.8",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -312,6 +312,142 @@ function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason)
312
312
  "if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
313
313
  "Then return your report as JSON in exactly the shape specified above.";
314
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
+
315
451
  // parseToolSignals - bug 3472bf36. The work agent's TOOL CHECK emits two
316
452
  // exact signal lines: artifact_tools: ok|missing and
317
453
  // shell_transport: ok|unavailable. The workflow reads ONLY these lines.
@@ -737,7 +873,7 @@ let alreadyMergedSha = null;
737
873
  let branchState = null; // "has-work" | "already-merged:<sha>" | "empty-no-work"
738
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.
739
875
  let versionTouched = false; // npm: branch changed package.json's `version` (mechanical git fact, blocker-34 follow-up)
740
- let branchStateErr = ""; // classifier failure detail (fail-closed grounds)
876
+ let branchStateErr = ""; // classifier failure detail (retry trailer + park note grounds)
741
877
  // Deterministic publish target — computed by the workflow (registry base +
742
878
  // bumpVersion), never by the Publish agent.
743
879
  let publishTarget = null; // { base, scope, target }
@@ -1287,43 +1423,87 @@ while (i < STEPS.length) {
1287
1423
  // (classify-branch in the lifecycle script): has-work |
1288
1424
  // already-merged:<sha> | empty-no-work. Attribution is identity-first
1289
1425
  // from git, then the task's own durable merge records — there is no
1290
- // agent-authored declared-sha input and no cross-process hydration
1291
- // ferry (the re-entry case it served is covered by the durable record
1292
- // → classifier directly). An unparseable result fails closed as
1293
- // empty-no-work — an empty branch never passes Review silently, and
1294
- // the bounce to Build gives the next round a chance to classify.
1295
- try {
1296
- var bsResult = await agent(
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 =
1297
1460
  "Classify the task branch state. This is a mechanical git check, not a judgment call.\n" +
1298
- "Run in shell and return the stdout verbatim, then the single stderr line starting with `DIAG:` verbatim:\n" +
1299
- LIFECYCLE_ENV + LIFECYCLE + " classify-branch " + taskId,
1300
- { key: "classify-branch" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Classifying branch state (mechanical)" }
1301
- );
1302
- var bsStr = (typeof bsResult === "string") ? bsResult : JSON.stringify(bsResult);
1303
- var bsMatch = /^\s*BRANCH_STATE:\s*(has-work|already-merged:[0-9a-f]{40}|empty-no-work)\s*$/im.exec(bsStr);
1304
- if (bsMatch) {
1305
- branchState = bsMatch[1].toLowerCase();
1306
- } else {
1307
- branchStateErr = "unparseable classifier output: " + bsStr.slice(0, 120);
1308
- }
1309
- // DIAG pin (blocker 34 design v2.1): the merge-record scan is
1310
- // load-bearing with no fallback — the classifier's stderr counter is
1311
- // logged verbatim so silent record loss surfaces in the evidence.
1312
- var bsDiagMatch = /^[ \t]*DIAG:\s*classify-branch\s+records=\d+\s+resolvable=\d+[ \t]*$/im.exec(bsStr);
1313
- if (bsDiagMatch) {
1314
- log(bsDiagMatch[0].trim());
1315
- } else {
1316
- log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
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));
1464
+ try {
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"] } }
1470
+ );
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
+ }
1317
1497
  }
1318
- } catch (bsErr) {
1319
- branchStateErr = "classifier call failed: " + String(bsErr && bsErr.message || bsErr).slice(0, 120);
1320
1498
  }
1321
- if (!branchState) {
1322
- branchState = "empty-no-work";
1323
- log("Branch-state classification failed (" + branchStateErr + ") — failing closed as empty-no-work");
1324
- } else if (branchState.indexOf("already-merged:") === 0) {
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) {
1325
1505
  alreadyMergedSha = branchState.slice("already-merged:".length);
1326
- log("Branch state already-merged: " + alreadyMergedSha + " (mechanically verified) — Cass reviews the frozen merge diff");
1506
+ log("Branch state already-merged: " + alreadyMergedSha + " (DIAG pin present — tripwire satisfied) — Cass reviews the frozen merge diff");
1327
1507
  } else {
1328
1508
  log("Branch state: " + branchState);
1329
1509
  }
@@ -1335,23 +1515,55 @@ while (i < STEPS.length) {
1335
1515
  // git fact, not a reviewer judgment. For npm projects the workflow checks
1336
1516
  // it here, before Cass is dispatched — a touched `version` fails Review
1337
1517
  // mechanically (versions are assigned at publish time, never in
1338
- // branches). Fail closed: anything but an explicit VERSION_CLEAN counts
1339
- // as touched.
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.
1340
1525
  if (PUBLISH_TYPE === "npm") {
1341
- try {
1342
- var vtResult = await agent(
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 =
1343
1531
  "Check whether the task branch changed package.json's `version` field. This is a mechanical git check, not a judgment call.\n" +
1344
- "Run in shell and return the stdout verbatim:\n" +
1345
- LIFECYCLE_ENV + LIFECYCLE + " version-check " + taskId,
1346
- { key: "version-check" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Checking package.json version (mechanical)" }
1347
- );
1348
- var vtStr = (typeof vtResult === "string") ? vtResult : JSON.stringify(vtResult);
1349
- var vtMatch = /^\s*VERSION_(TOUCHED|CLEAN)\s*$/im.exec(vtStr);
1350
- versionTouched = !(vtMatch && vtMatch[1].toUpperCase() === "CLEAN");
1351
- } catch (vtErr) {
1352
- versionTouched = true;
1353
- log("Version check failed (" + String(vtErr && vtErr.message || vtErr).slice(0, 120) + ") — failing closed as touched");
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
+ }
1559
+ }
1354
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");
1355
1567
  log("Review: package.json `version` " + (versionTouched ? "touched by the branch — mechanical FAIL, Cass not dispatched" : "untouched — mechanical check clean"));
1356
1568
  }
1357
1569
  // The empty-branch rule Cass used to adjudicate is gone. What she
@@ -1674,13 +1886,26 @@ while (i < STEPS.length) {
1674
1886
  // closeout — the file list is the complete record of what the task
1675
1887
  // changed. The courier is pure hands: it writes the deterministic
1676
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.)
1677
1901
  var publishDiffFilesPath = crewHome + "/.publish-diffs/" + taskId + ".files.json";
1678
1902
  await agent(
1679
1903
  "Write the publish diff file list.\n" +
1680
- "Run exactly this and return the stdout verbatim:\n" +
1904
+ "Run exactly this and return the stdout verbatim as { \"output\": \"<verbatim stdout>\" } and nothing else. Do not interpret it:\n" +
1681
1905
  "mkdir -p " + crewHome + "/.publish-diffs && cat > \"" + publishDiffFilesPath + "\" <<'DIFFFILES_EOF'\n" +
1682
1906
  JSON.stringify(diffSummary.files || []) + "\nDIFFFILES_EOF\n",
1683
- { key: attemptKey("publish-diff-files-" + taskId, totalReworkCount), label: "Persisting publish diff file list" }
1907
+ { key: attemptKey("publish-diff-files-" + taskId, totalReworkCount), label: "Persisting publish diff file list",
1908
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1684
1909
  );
1685
1910
  // Budget counts CHANGED lines (added + removed), not raw unified-diff
1686
1911
  // output lines: context lines and file headers inflated the old
@@ -322,6 +322,142 @@ function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason)
322
322
  "if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
323
323
  "Then return your report as JSON in exactly the shape specified above.";
324
324
  }
325
+ // parseClassifyFerry — room #26 blocker 42 (2026-09-21). Shape-constrained
326
+ // ferry parser for the classify-branch agent() call. The schema buys SHAPE,
327
+ // not provenance: the fields are LLM-authored, so the parser stays
328
+ // defensive. Byte-identical across standard.js, bugfix.js, chore.js
329
+ // (pinned by tests/already-merged.test.js). Triplication is structural:
330
+ // each workflow ships as a standalone launch payload (240 KiB budget,
331
+ // individually git-archived) — there is no shared module to hold it.
332
+ // The schema is the guard; the prompt is instruction, never load-bearing.
333
+ // Returns { state, diagField, diagText, failReason }:
334
+ // state: "has-work" | "already-merged:<40-hex>" | "empty-no-work" | null
335
+ // (null = unparseable/ambiguous/channel violation — UNKNOWN)
336
+ // diagField: "stderr" | "stdout" | null (which field sourced the DIAG pin)
337
+ // diagText: the matched DIAG line, or ""
338
+ // failReason: computed reason, for the retry trailer and the park note
339
+ function parseClassifyFerry(bsResult) {
340
+ var bsStdout = (bsResult && typeof bsResult.stdout === "string") ? bsResult.stdout : null;
341
+ var bsStderr = (bsResult && typeof bsResult.stderr === "string") ? bsResult.stderr : null;
342
+ if (bsStdout === null || bsStderr === null) {
343
+ return { state: null, diagField: null, diagText: "",
344
+ failReason: "classifier return fields missing or non-string (stdout and stderr must both be strings)" };
345
+ }
346
+ // State channel: stdout ONLY. A BRANCH_STATE match on stderr is never
347
+ // trusted — workers merge streams constantly, and trusting a channel
348
+ // swap as truth is the wrong shape. Search, never anchor (blocker 11):
349
+ // the ferry labels output ("stdout: BRANCH_STATE: has-work"). Collect
350
+ // ALL matches and dedupe normalized values: exactly one distinct value
351
+ // is truth; zero or two-or-more is UNKNOWN — alternation order never
352
+ // adjudicates.
353
+ var bsRe = /\bBRANCH_STATE:\s*(has-work|already-merged:[0-9a-f]{40}|empty-no-work)(?![\w-])/gi;
354
+ var bsSeen = {};
355
+ var bsM;
356
+ while ((bsM = bsRe.exec(bsStdout)) !== null) {
357
+ bsSeen[bsM[1].toLowerCase()] = true;
358
+ }
359
+ var bsStates = Object.keys(bsSeen);
360
+ if (bsStates.length !== 1) {
361
+ return { state: null, diagField: null, diagText: "",
362
+ failReason: bsStates.length === 0
363
+ ? "no BRANCH_STATE match in the stdout field"
364
+ : "ambiguous BRANCH_STATE values in stdout (" + bsStates.length + " distinct)" };
365
+ }
366
+ var bsState = bsStates[0];
367
+ // DIAG pin: search both fields, stderr first. Keep the DIAG: prefix — a
368
+ // prose mention without it must not satisfy the pin.
369
+ var bsDiagRe = /DIAG:\s*classify-branch\s+records=\d+\s+resolvable=\d+/;
370
+ var bsDiagField = bsDiagRe.test(bsStderr) ? "stderr" : (bsDiagRe.test(bsStdout) ? "stdout" : null);
371
+ var bsDiagText = "";
372
+ if (bsDiagField !== null) {
373
+ bsDiagText = bsDiagRe.exec(bsDiagField === "stderr" ? bsStderr : bsStdout)[0];
374
+ }
375
+ if (bsState.indexOf("already-merged:") === 0 && bsDiagField === null) {
376
+ // already-merged's sha flows into Cass's `git diff <sha>^1 <sha>` — a
377
+ // hallucinated-but-well-formed sha is a false-PASS vector. The script
378
+ // emits exactly one DIAG on every path that emits a BRANCH_STATE, so a
379
+ // missing pin means stderr wasn't faithfully ferried: UNKNOWN.
380
+ // Residual risk, named (blocker-42 fib 4): a worker could fabricate
381
+ // BOTH fields wholesale — the pin is a tripwire, not proof of
382
+ // execution. The workflow has no exec channel for a deterministic
383
+ // cross-check; if the platform ever offers one, verify the sha
384
+ // against the task's durable merge records directly.
385
+ return { state: null, diagField: null, diagText: "",
386
+ failReason: "already-merged without the DIAG pin — stderr not faithfully ferried" };
387
+ }
388
+ return { state: bsState, diagField: bsDiagField, diagText: bsDiagText, failReason: "" };
389
+ }
390
+
391
+ // classifyRetryTrailer — room #26 blocker 42 (2026-09-21). Corrective (not
392
+ // flat) trailer for the in-place classify retries. The failure mode was
393
+ // systematic labeling, not a coin flip: the trailer carries the COMPUTED
394
+ // parse-failure reason, the failed return truncated as a negative example,
395
+ // and the explicit field mapping — feedback from the actual failure,
396
+ // following the buildTransportRetryTrailer idiom. Bound: 2 retries
397
+ // (unmeasured house convention, matches the shared rework bound of 2 —
398
+ // against room retry data). Byte-identical across the three workflows
399
+ // (pinned by tests/already-merged.test.js).
400
+ function classifyRetryTrailer(reason, failedReturn) {
401
+ // Pass-2 simplification: the reason string already carries the truth
402
+ // (throw vs parse failure), so one neutral sentence replaces the
403
+ // throw/parse branch — the stringly-typed prefix contract between the
404
+ // call site and this function is deleted, not moved. Prompt accuracy,
405
+ // not prompt hardening.
406
+ return "\n\nCLASSIFY RETRY: the previous classifier attempt failed (" + reason + "). " +
407
+ "Return ONLY the two named fields: { \"stdout\": \"<the command's exact stdout>\", \"stderr\": \"<the command's exact stderr>\" }. " +
408
+ "Put each stream in its named field — do not add labels like \"stdout:\" in front of the content. " +
409
+ "Previous attempt detail (do not repeat this shape): " + String(failedReturn).slice(0, 200);
410
+ }
411
+
412
+ // parseVersionFerry — review pass 2, blocker-42 class (2026-09-21). The npm
413
+ // version-check ferry had the full pre-42 shape: schema-less agent(),
414
+ // line-anchored parser, and an unparseable fallback asserting a POSITIVE
415
+ // touched claim that spent the shared rework budget. Same treatment as
416
+ // parseClassifyFerry: shape-constrained {stdout} schema, search-and-dedupe
417
+ // parse, UNKNOWN on unparseable with in-place retries, honest park.
418
+ // Byte-identical across standard.js, bugfix.js, chore.js
419
+ // (pinned by tests/already-merged.test.js).
420
+ // Returns { versionState, failReason }:
421
+ // versionState: "touched" | "clean" | null (null = UNKNOWN)
422
+ // failReason: computed reason, for the retry trailer and the park note
423
+ function parseVersionFerry(vtResult) {
424
+ var vtStdout = (vtResult && typeof vtResult.stdout === "string") ? vtResult.stdout : null;
425
+ if (vtStdout === null) {
426
+ return { versionState: null,
427
+ failReason: "version-check return field missing or non-string (stdout must be a string)" };
428
+ }
429
+ // Search, never anchor (blocker 11): the ferry labels output
430
+ // ("stdout: VERSION_CLEAN"). Collect ALL matches and dedupe normalized
431
+ // values: exactly one distinct value is truth; zero or two-or-more is
432
+ // UNKNOWN — alternation order never adjudicates.
433
+ var vtRe = /\bVERSION_(TOUCHED|CLEAN)(?![\w-])/gi;
434
+ var vtSeen = {};
435
+ var vtM;
436
+ while ((vtM = vtRe.exec(vtStdout)) !== null) {
437
+ vtSeen[vtM[1].toUpperCase()] = true;
438
+ }
439
+ var vtStates = Object.keys(vtSeen);
440
+ if (vtStates.length !== 1) {
441
+ return { versionState: null,
442
+ failReason: vtStates.length === 0
443
+ ? "no VERSION_(TOUCHED|CLEAN) match in the stdout field"
444
+ : "ambiguous VERSION values in stdout (" + vtStates.length + " distinct)" };
445
+ }
446
+ return { versionState: vtStates[0] === "CLEAN" ? "clean" : "touched", failReason: "" };
447
+ }
448
+
449
+ // versionRetryTrailer — review pass 2, blocker-42 class (2026-09-21).
450
+ // Neutral corrective trailer for the in-place version-check retries: the
451
+ // computed parse-failure reason plus the failed return as a negative
452
+ // example. Byte-identical across the three workflows
453
+ // (pinned by tests/already-merged.test.js).
454
+ function versionRetryTrailer(reason, failedReturn) {
455
+ return "\n\nVERSION-CHECK RETRY: the previous version-check attempt failed (" + reason + "). " +
456
+ "Return ONLY the named field: { \"stdout\": \"<the command's exact stdout>\" }. " +
457
+ "Put the command's stdout in the named field — do not add labels like \"stdout:\" in front of the content. " +
458
+ "Previous attempt detail (do not repeat this shape): " + String(failedReturn).slice(0, 200);
459
+ }
460
+
325
461
  // parseToolSignals - bug 3472bf36. The work agent's TOOL CHECK emits two
326
462
  // exact signal lines: artifact_tools: ok|missing and
327
463
  // shell_transport: ok|unavailable. The workflow reads ONLY these lines.
@@ -694,7 +830,7 @@ let alreadyMergedSha = null;
694
830
  let branchState = null; // "has-work" | "already-merged:<sha>" | "empty-no-work"
695
831
  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.
696
832
  let versionTouched = false; // npm: branch changed package.json's `version` (mechanical git fact, blocker-34 follow-up)
697
- let branchStateErr = ""; // classifier failure detail (fail-closed grounds)
833
+ let branchStateErr = ""; // classifier failure detail (retry trailer + park note grounds)
698
834
  // Deterministic publish target — computed by the workflow (registry base +
699
835
  // bumpVersion), never by the Publish agent.
700
836
  let publishTarget = null; // { base, scope, target }
@@ -1150,43 +1286,87 @@ while (i < STEPS.length) {
1150
1286
  // (classify-branch in the lifecycle script): has-work |
1151
1287
  // already-merged:<sha> | empty-no-work. Attribution is identity-first
1152
1288
  // from git, then the task's own durable merge records — there is no
1153
- // agent-authored declared-sha input and no cross-process hydration
1154
- // ferry (the re-entry case it served is covered by the durable record
1155
- // → classifier directly). An unparseable result fails closed as
1156
- // empty-no-work — an empty branch never passes Review silently, and
1157
- // the bounce to Build gives the next round a chance to classify.
1158
- try {
1159
- var bsResult = await agent(
1289
+ // agent-authored declared-sha input.
1290
+ // Blocker 42 (2026-09-21): the classifier output crosses an agent()
1291
+ // ferry — the old comment's "no cross-process hydration ferry" was a
1292
+ // fib. Machine facts travel in structured fields ({stdout, stderr}
1293
+ // schema) now, never in prose to be "returned verbatim". The schema
1294
+ // is the guard; the prompt is instruction, never load-bearing.
1295
+ // Unparseable is UNKNOWN, never a positive empty-no-work claim (room
1296
+ // #27: a labeled ferry — "stdout: BRANCH_STATE: has-work" — defeated
1297
+ // the ^-anchored parsers, and the empty-no-work fallback burned the
1298
+ // whole shared rework budget on a branch with real work). UNKNOWN
1299
+ // retries the classify call IN PLACE (up to 2, fresh -u1/-u2 keys — a
1300
+ // full Build round cannot fix an instrument failure, and the only
1301
+ // in-process bounce path spends the shared budget unconditionally).
1302
+ // Instrumentation failures NEVER touch reworkCount: an instrument
1303
+ // failure is not a build-quality failure. Exhaustion parks honestly
1304
+ // as unclassifiable — "unknown keeps the task alive (bounded)"; the
1305
+ // park on exhaustion is the fail-closed part. The UNKNOWN path never
1306
+ // asserts empty-no-work: no log line or park note presents it as the
1307
+ // classification. The string can still appear inside quoted evidence
1308
+ // (the verbatim failed return travels in the trailer and the note).
1309
+ // INVARIANT: branchState leaves this block holding a known value, or
1310
+ // the step has parked — unknown never reaches any consumer below.
1311
+ // Re-classify on EVERY Review entry: rework rewinds this step
1312
+ // in-process, and the branch changed between rounds. The loop's
1313
+ // null-gate governs only the in-place instrument retries within
1314
+ // this pass — without the reset, a second Review pass would trust
1315
+ // the first pass's stale classification and a real fix commit
1316
+ // would never "become has-work".
1317
+ branchState = null;
1318
+ branchStateErr = "";
1319
+ alreadyMergedSha = null;
1320
+ var classifyFailedReturn = "";
1321
+ for (var classifyAttempt = 0; classifyAttempt <= 2 && branchState === null; classifyAttempt++) {
1322
+ var classifyPrompt =
1160
1323
  "Classify the task branch state. This is a mechanical git check, not a judgment call.\n" +
1161
- "Run in shell and return the stdout verbatim, then the single stderr line starting with `DIAG:` verbatim:\n" +
1162
- LIFECYCLE_ENV + LIFECYCLE + " classify-branch " + taskId,
1163
- { key: "classify-branch" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Classifying branch state (mechanical)" }
1164
- );
1165
- var bsStr = (typeof bsResult === "string") ? bsResult : JSON.stringify(bsResult);
1166
- var bsMatch = /^\s*BRANCH_STATE:\s*(has-work|already-merged:[0-9a-f]{40}|empty-no-work)\s*$/im.exec(bsStr);
1167
- if (bsMatch) {
1168
- branchState = bsMatch[1].toLowerCase();
1169
- } else {
1170
- branchStateErr = "unparseable classifier output: " + bsStr.slice(0, 120);
1171
- }
1172
- // DIAG pin (blocker 34 design v2.1): the merge-record scan is
1173
- // load-bearing with no fallback — the classifier's stderr counter is
1174
- // logged verbatim so silent record loss surfaces in the evidence.
1175
- var bsDiagMatch = /^[ \t]*DIAG:\s*classify-branch\s+records=\d+\s+resolvable=\d+[ \t]*$/im.exec(bsStr);
1176
- if (bsDiagMatch) {
1177
- log(bsDiagMatch[0].trim());
1178
- } else {
1179
- log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
1324
+ "Run in shell: " + LIFECYCLE_ENV + LIFECYCLE + " classify-branch " + taskId + "\n" +
1325
+ "Return the two streams as the two named fields and nothing else: { \"stdout\": \"<the command's exact stdout>\", \"stderr\": \"<the command's exact stderr>\" }." +
1326
+ (classifyAttempt === 0 ? "" : classifyRetryTrailer(branchStateErr, classifyFailedReturn));
1327
+ try {
1328
+ var bsResult = await agent(
1329
+ classifyPrompt,
1330
+ { key: "classify-branch" + (classifyAttempt === 0 ? "" : "-u" + classifyAttempt) + (reworkCount > 0 ? "-r" + reworkCount : ""),
1331
+ label: "Classifying branch state (mechanical)" + (classifyAttempt === 0 ? "" : " (instrument retry " + classifyAttempt + " of 2)"),
1332
+ schema: { type: "object", properties: { stdout: { type: "string" }, stderr: { type: "string" } }, required: ["stdout", "stderr"] } }
1333
+ );
1334
+ classifyFailedReturn = (bsResult && typeof bsResult === "object" ? JSON.stringify(bsResult) : String(bsResult)).slice(0, 500);
1335
+ var classifyParsed = parseClassifyFerry(bsResult);
1336
+ if (classifyParsed.state !== null) {
1337
+ branchState = classifyParsed.state;
1338
+ branchStateErr = "";
1339
+ // DIAG pin: advisory for has-work/empty-no-work (blocker-34 v2.1
1340
+ // semantics — logged with its source field so silent record loss
1341
+ // surfaces in the evidence); already-merged is gated on the pin
1342
+ // inside parseClassifyFerry.
1343
+ if (classifyParsed.diagField !== null) {
1344
+ log("classify-branch DIAG pin (" + classifyParsed.diagField + " field): " + classifyParsed.diagText);
1345
+ } else {
1346
+ log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
1347
+ }
1348
+ } else {
1349
+ branchStateErr = classifyParsed.failReason;
1350
+ if (classifyAttempt < 2) {
1351
+ log("classify-branch unparseable (" + branchStateErr + ") — retrying in place (attempt " + (classifyAttempt + 1) + " of 2)");
1352
+ }
1353
+ }
1354
+ } catch (bsErr) {
1355
+ classifyFailedReturn = String(bsErr && bsErr.message || bsErr).slice(0, 500);
1356
+ branchStateErr = "classifier call failed: " + String(bsErr && bsErr.message || bsErr).slice(0, 120);
1357
+ if (classifyAttempt < 2) {
1358
+ log("classify-branch instrument failure (" + branchStateErr + ") — retrying in place (attempt " + (classifyAttempt + 1) + " of 2)");
1359
+ }
1180
1360
  }
1181
- } catch (bsErr) {
1182
- branchStateErr = "classifier call failed: " + String(bsErr && bsErr.message || bsErr).slice(0, 120);
1183
1361
  }
1184
- if (!branchState) {
1185
- branchState = "empty-no-work";
1186
- log("Branch-state classification failed (" + branchStateErr + ") — failing closed as empty-no-work");
1187
- } else if (branchState.indexOf("already-merged:") === 0) {
1362
+ if (branchState === null) {
1363
+ // Exhaustion: park honestly. An instrument failure is not a
1364
+ // measurement of emptiness — never "empty-no-work".
1365
+ 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.");
1366
+ }
1367
+ if (branchState.indexOf("already-merged:") === 0) {
1188
1368
  alreadyMergedSha = branchState.slice("already-merged:".length);
1189
- log("Branch state already-merged: " + alreadyMergedSha + " (mechanically verified) — Cass reviews the frozen merge diff");
1369
+ log("Branch state already-merged: " + alreadyMergedSha + " (DIAG pin present — tripwire satisfied) — Cass reviews the frozen merge diff");
1190
1370
  } else {
1191
1371
  log("Branch state: " + branchState);
1192
1372
  }
@@ -1198,23 +1378,55 @@ while (i < STEPS.length) {
1198
1378
  // git fact, not a reviewer judgment. For npm projects the workflow checks
1199
1379
  // it here, before Cass is dispatched — a touched `version` fails Review
1200
1380
  // mechanically (versions are assigned at publish time, never in
1201
- // branches). Fail closed: anything but an explicit VERSION_CLEAN counts
1202
- // as touched.
1381
+ // branches). Review pass 2 (blocker-42 class): this ferry had the full
1382
+ // pre-42 shape — schema-less agent(), line-anchored parser, and an
1383
+ // unparseable fallback asserting a POSITIVE touched claim that spent the
1384
+ // shared rework budget. Same treatment as the classifier:
1385
+ // shape-constrained {stdout} schema, search-and-dedupe parse, UNKNOWN
1386
+ // with in-place retries, honest park. An instrument failure is not a
1387
+ // measurement of touched — never assert touched from unparseable output.
1203
1388
  if (PUBLISH_TYPE === "npm") {
1204
- try {
1205
- var vtResult = await agent(
1389
+ var versionState = null; // "touched" | "clean" | null (null = UNKNOWN)
1390
+ var versionStateErr = "";
1391
+ var versionFailedReturn = "";
1392
+ for (var versionAttempt = 0; versionAttempt <= 2 && versionState === null; versionAttempt++) {
1393
+ var versionPrompt =
1206
1394
  "Check whether the task branch changed package.json's `version` field. This is a mechanical git check, not a judgment call.\n" +
1207
- "Run in shell and return the stdout verbatim:\n" +
1208
- LIFECYCLE_ENV + LIFECYCLE + " version-check " + taskId,
1209
- { key: "version-check" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Checking package.json version (mechanical)" }
1210
- );
1211
- var vtStr = (typeof vtResult === "string") ? vtResult : JSON.stringify(vtResult);
1212
- var vtMatch = /^\s*VERSION_(TOUCHED|CLEAN)\s*$/im.exec(vtStr);
1213
- versionTouched = !(vtMatch && vtMatch[1].toUpperCase() === "CLEAN");
1214
- } catch (vtErr) {
1215
- versionTouched = true;
1216
- log("Version check failed (" + String(vtErr && vtErr.message || vtErr).slice(0, 120) + ") — failing closed as touched");
1395
+ "Run in shell: " + LIFECYCLE_ENV + LIFECYCLE + " version-check " + taskId + "\n" +
1396
+ "Return the command's exact stdout as the single named field and nothing else: { \"stdout\": \"<the command's exact stdout>\" }." +
1397
+ (versionAttempt === 0 ? "" : versionRetryTrailer(versionStateErr, versionFailedReturn));
1398
+ try {
1399
+ var vtResult = await agent(
1400
+ versionPrompt,
1401
+ { key: "version-check" + (versionAttempt === 0 ? "" : "-u" + versionAttempt) + (reworkCount > 0 ? "-r" + reworkCount : ""),
1402
+ label: "Checking package.json version (mechanical)" + (versionAttempt === 0 ? "" : " (instrument retry " + versionAttempt + " of 2)"),
1403
+ schema: { type: "object", properties: { stdout: { type: "string" } }, required: ["stdout"] } }
1404
+ );
1405
+ versionFailedReturn = (vtResult && typeof vtResult === "object" ? JSON.stringify(vtResult) : String(vtResult)).slice(0, 500);
1406
+ var vtParsed = parseVersionFerry(vtResult);
1407
+ if (vtParsed.versionState !== null) {
1408
+ versionState = vtParsed.versionState;
1409
+ versionStateErr = "";
1410
+ } else {
1411
+ versionStateErr = vtParsed.failReason;
1412
+ if (versionAttempt < 2) {
1413
+ log("version-check unparseable (" + versionStateErr + ") — retrying in place (attempt " + (versionAttempt + 1) + " of 2)");
1414
+ }
1415
+ }
1416
+ } catch (vtErr) {
1417
+ versionFailedReturn = String(vtErr && vtErr.message || vtErr).slice(0, 500);
1418
+ versionStateErr = "version-check call failed: " + String(vtErr && vtErr.message || vtErr).slice(0, 120);
1419
+ if (versionAttempt < 2) {
1420
+ log("version-check instrument failure (" + versionStateErr + ") — retrying in place (attempt " + (versionAttempt + 1) + " of 2)");
1421
+ }
1422
+ }
1423
+ }
1424
+ if (versionState === null) {
1425
+ // Exhaustion: park honestly. An instrument failure is not a
1426
+ // measurement of touched — never assert touched.
1427
+ 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.");
1217
1428
  }
1429
+ versionTouched = (versionState === "touched");
1218
1430
  log("Review: package.json `version` " + (versionTouched ? "touched by the branch — mechanical FAIL, Cass not dispatched" : "untouched — mechanical check clean"));
1219
1431
  }
1220
1432
  // The empty-branch rule Cass used to adjudicate is gone. What she
@@ -320,6 +320,142 @@ function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason)
320
320
  "if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
321
321
  "Then return your report as JSON in exactly the shape specified above.";
322
322
  }
323
+ // parseClassifyFerry — room #26 blocker 42 (2026-09-21). Shape-constrained
324
+ // ferry parser for the classify-branch agent() call. The schema buys SHAPE,
325
+ // not provenance: the fields are LLM-authored, so the parser stays
326
+ // defensive. Byte-identical across standard.js, bugfix.js, chore.js
327
+ // (pinned by tests/already-merged.test.js). Triplication is structural:
328
+ // each workflow ships as a standalone launch payload (240 KiB budget,
329
+ // individually git-archived) — there is no shared module to hold it.
330
+ // The schema is the guard; the prompt is instruction, never load-bearing.
331
+ // Returns { state, diagField, diagText, failReason }:
332
+ // state: "has-work" | "already-merged:<40-hex>" | "empty-no-work" | null
333
+ // (null = unparseable/ambiguous/channel violation — UNKNOWN)
334
+ // diagField: "stderr" | "stdout" | null (which field sourced the DIAG pin)
335
+ // diagText: the matched DIAG line, or ""
336
+ // failReason: computed reason, for the retry trailer and the park note
337
+ function parseClassifyFerry(bsResult) {
338
+ var bsStdout = (bsResult && typeof bsResult.stdout === "string") ? bsResult.stdout : null;
339
+ var bsStderr = (bsResult && typeof bsResult.stderr === "string") ? bsResult.stderr : null;
340
+ if (bsStdout === null || bsStderr === null) {
341
+ return { state: null, diagField: null, diagText: "",
342
+ failReason: "classifier return fields missing or non-string (stdout and stderr must both be strings)" };
343
+ }
344
+ // State channel: stdout ONLY. A BRANCH_STATE match on stderr is never
345
+ // trusted — workers merge streams constantly, and trusting a channel
346
+ // swap as truth is the wrong shape. Search, never anchor (blocker 11):
347
+ // the ferry labels output ("stdout: BRANCH_STATE: has-work"). Collect
348
+ // ALL matches and dedupe normalized values: exactly one distinct value
349
+ // is truth; zero or two-or-more is UNKNOWN — alternation order never
350
+ // adjudicates.
351
+ var bsRe = /\bBRANCH_STATE:\s*(has-work|already-merged:[0-9a-f]{40}|empty-no-work)(?![\w-])/gi;
352
+ var bsSeen = {};
353
+ var bsM;
354
+ while ((bsM = bsRe.exec(bsStdout)) !== null) {
355
+ bsSeen[bsM[1].toLowerCase()] = true;
356
+ }
357
+ var bsStates = Object.keys(bsSeen);
358
+ if (bsStates.length !== 1) {
359
+ return { state: null, diagField: null, diagText: "",
360
+ failReason: bsStates.length === 0
361
+ ? "no BRANCH_STATE match in the stdout field"
362
+ : "ambiguous BRANCH_STATE values in stdout (" + bsStates.length + " distinct)" };
363
+ }
364
+ var bsState = bsStates[0];
365
+ // DIAG pin: search both fields, stderr first. Keep the DIAG: prefix — a
366
+ // prose mention without it must not satisfy the pin.
367
+ var bsDiagRe = /DIAG:\s*classify-branch\s+records=\d+\s+resolvable=\d+/;
368
+ var bsDiagField = bsDiagRe.test(bsStderr) ? "stderr" : (bsDiagRe.test(bsStdout) ? "stdout" : null);
369
+ var bsDiagText = "";
370
+ if (bsDiagField !== null) {
371
+ bsDiagText = bsDiagRe.exec(bsDiagField === "stderr" ? bsStderr : bsStdout)[0];
372
+ }
373
+ if (bsState.indexOf("already-merged:") === 0 && bsDiagField === null) {
374
+ // already-merged's sha flows into Cass's `git diff <sha>^1 <sha>` — a
375
+ // hallucinated-but-well-formed sha is a false-PASS vector. The script
376
+ // emits exactly one DIAG on every path that emits a BRANCH_STATE, so a
377
+ // missing pin means stderr wasn't faithfully ferried: UNKNOWN.
378
+ // Residual risk, named (blocker-42 fib 4): a worker could fabricate
379
+ // BOTH fields wholesale — the pin is a tripwire, not proof of
380
+ // execution. The workflow has no exec channel for a deterministic
381
+ // cross-check; if the platform ever offers one, verify the sha
382
+ // against the task's durable merge records directly.
383
+ return { state: null, diagField: null, diagText: "",
384
+ failReason: "already-merged without the DIAG pin — stderr not faithfully ferried" };
385
+ }
386
+ return { state: bsState, diagField: bsDiagField, diagText: bsDiagText, failReason: "" };
387
+ }
388
+
389
+ // classifyRetryTrailer — room #26 blocker 42 (2026-09-21). Corrective (not
390
+ // flat) trailer for the in-place classify retries. The failure mode was
391
+ // systematic labeling, not a coin flip: the trailer carries the COMPUTED
392
+ // parse-failure reason, the failed return truncated as a negative example,
393
+ // and the explicit field mapping — feedback from the actual failure,
394
+ // following the buildTransportRetryTrailer idiom. Bound: 2 retries
395
+ // (unmeasured house convention, matches the shared rework bound of 2 —
396
+ // against room retry data). Byte-identical across the three workflows
397
+ // (pinned by tests/already-merged.test.js).
398
+ function classifyRetryTrailer(reason, failedReturn) {
399
+ // Pass-2 simplification: the reason string already carries the truth
400
+ // (throw vs parse failure), so one neutral sentence replaces the
401
+ // throw/parse branch — the stringly-typed prefix contract between the
402
+ // call site and this function is deleted, not moved. Prompt accuracy,
403
+ // not prompt hardening.
404
+ return "\n\nCLASSIFY RETRY: the previous classifier attempt failed (" + reason + "). " +
405
+ "Return ONLY the two named fields: { \"stdout\": \"<the command's exact stdout>\", \"stderr\": \"<the command's exact stderr>\" }. " +
406
+ "Put each stream in its named field — do not add labels like \"stdout:\" in front of the content. " +
407
+ "Previous attempt detail (do not repeat this shape): " + String(failedReturn).slice(0, 200);
408
+ }
409
+
410
+ // parseVersionFerry — review pass 2, blocker-42 class (2026-09-21). The npm
411
+ // version-check ferry had the full pre-42 shape: schema-less agent(),
412
+ // line-anchored parser, and an unparseable fallback asserting a POSITIVE
413
+ // touched claim that spent the shared rework budget. Same treatment as
414
+ // parseClassifyFerry: shape-constrained {stdout} schema, search-and-dedupe
415
+ // parse, UNKNOWN on unparseable with in-place retries, honest park.
416
+ // Byte-identical across standard.js, bugfix.js, chore.js
417
+ // (pinned by tests/already-merged.test.js).
418
+ // Returns { versionState, failReason }:
419
+ // versionState: "touched" | "clean" | null (null = UNKNOWN)
420
+ // failReason: computed reason, for the retry trailer and the park note
421
+ function parseVersionFerry(vtResult) {
422
+ var vtStdout = (vtResult && typeof vtResult.stdout === "string") ? vtResult.stdout : null;
423
+ if (vtStdout === null) {
424
+ return { versionState: null,
425
+ failReason: "version-check return field missing or non-string (stdout must be a string)" };
426
+ }
427
+ // Search, never anchor (blocker 11): the ferry labels output
428
+ // ("stdout: VERSION_CLEAN"). Collect ALL matches and dedupe normalized
429
+ // values: exactly one distinct value is truth; zero or two-or-more is
430
+ // UNKNOWN — alternation order never adjudicates.
431
+ var vtRe = /\bVERSION_(TOUCHED|CLEAN)(?![\w-])/gi;
432
+ var vtSeen = {};
433
+ var vtM;
434
+ while ((vtM = vtRe.exec(vtStdout)) !== null) {
435
+ vtSeen[vtM[1].toUpperCase()] = true;
436
+ }
437
+ var vtStates = Object.keys(vtSeen);
438
+ if (vtStates.length !== 1) {
439
+ return { versionState: null,
440
+ failReason: vtStates.length === 0
441
+ ? "no VERSION_(TOUCHED|CLEAN) match in the stdout field"
442
+ : "ambiguous VERSION values in stdout (" + vtStates.length + " distinct)" };
443
+ }
444
+ return { versionState: vtStates[0] === "CLEAN" ? "clean" : "touched", failReason: "" };
445
+ }
446
+
447
+ // versionRetryTrailer — review pass 2, blocker-42 class (2026-09-21).
448
+ // Neutral corrective trailer for the in-place version-check retries: the
449
+ // computed parse-failure reason plus the failed return as a negative
450
+ // example. Byte-identical across the three workflows
451
+ // (pinned by tests/already-merged.test.js).
452
+ function versionRetryTrailer(reason, failedReturn) {
453
+ return "\n\nVERSION-CHECK RETRY: the previous version-check attempt failed (" + reason + "). " +
454
+ "Return ONLY the named field: { \"stdout\": \"<the command's exact stdout>\" }. " +
455
+ "Put the command's stdout in the named field — do not add labels like \"stdout:\" in front of the content. " +
456
+ "Previous attempt detail (do not repeat this shape): " + String(failedReturn).slice(0, 200);
457
+ }
458
+
323
459
  // parseToolSignals - bug 3472bf36. The work agent's TOOL CHECK emits two
324
460
  // exact signal lines: artifact_tools: ok|missing and
325
461
  // shell_transport: ok|unavailable. The workflow reads ONLY these lines.
@@ -696,7 +832,7 @@ let alreadyMergedSha = null;
696
832
  let branchState = null; // "has-work" | "already-merged:<sha>" | "empty-no-work"
697
833
  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.
698
834
  let versionTouched = false; // npm: branch changed package.json's `version` (mechanical git fact, blocker-34 follow-up)
699
- let branchStateErr = ""; // classifier failure detail (fail-closed grounds)
835
+ let branchStateErr = ""; // classifier failure detail (retry trailer + park note grounds)
700
836
  // Deterministic publish target — computed by the workflow (registry base +
701
837
  // bumpVersion), never by the Publish agent.
702
838
  let publishTarget = null; // { base, scope, target }
@@ -1176,43 +1312,87 @@ while (i < STEPS.length) {
1176
1312
  // (classify-branch in the lifecycle script): has-work |
1177
1313
  // already-merged:<sha> | empty-no-work. Attribution is identity-first
1178
1314
  // from git, then the task's own durable merge records — there is no
1179
- // agent-authored declared-sha input and no cross-process hydration
1180
- // ferry (the re-entry case it served is covered by the durable record
1181
- // → classifier directly). An unparseable result fails closed as
1182
- // empty-no-work — an empty branch never passes Review silently, and
1183
- // the bounce to Build gives the next round a chance to classify.
1184
- try {
1185
- var bsResult = await agent(
1315
+ // agent-authored declared-sha input.
1316
+ // Blocker 42 (2026-09-21): the classifier output crosses an agent()
1317
+ // ferry — the old comment's "no cross-process hydration ferry" was a
1318
+ // fib. Machine facts travel in structured fields ({stdout, stderr}
1319
+ // schema) now, never in prose to be "returned verbatim". The schema
1320
+ // is the guard; the prompt is instruction, never load-bearing.
1321
+ // Unparseable is UNKNOWN, never a positive empty-no-work claim (room
1322
+ // #27: a labeled ferry — "stdout: BRANCH_STATE: has-work" — defeated
1323
+ // the ^-anchored parsers, and the empty-no-work fallback burned the
1324
+ // whole shared rework budget on a branch with real work). UNKNOWN
1325
+ // retries the classify call IN PLACE (up to 2, fresh -u1/-u2 keys — a
1326
+ // full Build round cannot fix an instrument failure, and the only
1327
+ // in-process bounce path spends the shared budget unconditionally).
1328
+ // Instrumentation failures NEVER touch totalReworkCount: an instrument
1329
+ // failure is not a build-quality failure. Exhaustion parks honestly
1330
+ // as unclassifiable — "unknown keeps the task alive (bounded)"; the
1331
+ // park on exhaustion is the fail-closed part. The UNKNOWN path never
1332
+ // asserts empty-no-work: no log line or park note presents it as the
1333
+ // classification. The string can still appear inside quoted evidence
1334
+ // (the verbatim failed return travels in the trailer and the note).
1335
+ // INVARIANT: branchState leaves this block holding a known value, or
1336
+ // the step has parked — unknown never reaches any consumer below.
1337
+ // Re-classify on EVERY Review entry: rework rewinds this step
1338
+ // in-process, and the branch changed between rounds. The loop's
1339
+ // null-gate governs only the in-place instrument retries within
1340
+ // this pass — without the reset, a second Review pass would trust
1341
+ // the first pass's stale classification and a real fix commit
1342
+ // would never "become has-work".
1343
+ branchState = null;
1344
+ branchStateErr = "";
1345
+ alreadyMergedSha = null;
1346
+ var classifyFailedReturn = "";
1347
+ for (var classifyAttempt = 0; classifyAttempt <= 2 && branchState === null; classifyAttempt++) {
1348
+ var classifyPrompt =
1186
1349
  "Classify the task branch state. This is a mechanical git check, not a judgment call.\n" +
1187
- "Run in shell and return the stdout verbatim, then the single stderr line starting with `DIAG:` verbatim:\n" +
1188
- LIFECYCLE_ENV + LIFECYCLE + " classify-branch " + taskId,
1189
- { key: "classify-branch" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Classifying branch state (mechanical)" }
1190
- );
1191
- var bsStr = (typeof bsResult === "string") ? bsResult : JSON.stringify(bsResult);
1192
- var bsMatch = /^\s*BRANCH_STATE:\s*(has-work|already-merged:[0-9a-f]{40}|empty-no-work)\s*$/im.exec(bsStr);
1193
- if (bsMatch) {
1194
- branchState = bsMatch[1].toLowerCase();
1195
- } else {
1196
- branchStateErr = "unparseable classifier output: " + bsStr.slice(0, 120);
1197
- }
1198
- // DIAG pin (blocker 34 design v2.1): the merge-record scan is
1199
- // load-bearing with no fallback — the classifier's stderr counter is
1200
- // logged verbatim so silent record loss surfaces in the evidence.
1201
- var bsDiagMatch = /^[ \t]*DIAG:\s*classify-branch\s+records=\d+\s+resolvable=\d+[ \t]*$/im.exec(bsStr);
1202
- if (bsDiagMatch) {
1203
- log(bsDiagMatch[0].trim());
1204
- } else {
1205
- log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
1350
+ "Run in shell: " + LIFECYCLE_ENV + LIFECYCLE + " classify-branch " + taskId + "\n" +
1351
+ "Return the two streams as the two named fields and nothing else: { \"stdout\": \"<the command's exact stdout>\", \"stderr\": \"<the command's exact stderr>\" }." +
1352
+ (classifyAttempt === 0 ? "" : classifyRetryTrailer(branchStateErr, classifyFailedReturn));
1353
+ try {
1354
+ var bsResult = await agent(
1355
+ classifyPrompt,
1356
+ { key: "classify-branch" + (classifyAttempt === 0 ? "" : "-u" + classifyAttempt) + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
1357
+ label: "Classifying branch state (mechanical)" + (classifyAttempt === 0 ? "" : " (instrument retry " + classifyAttempt + " of 2)"),
1358
+ schema: { type: "object", properties: { stdout: { type: "string" }, stderr: { type: "string" } }, required: ["stdout", "stderr"] } }
1359
+ );
1360
+ classifyFailedReturn = (bsResult && typeof bsResult === "object" ? JSON.stringify(bsResult) : String(bsResult)).slice(0, 500);
1361
+ var classifyParsed = parseClassifyFerry(bsResult);
1362
+ if (classifyParsed.state !== null) {
1363
+ branchState = classifyParsed.state;
1364
+ branchStateErr = "";
1365
+ // DIAG pin: advisory for has-work/empty-no-work (blocker-34 v2.1
1366
+ // semantics — logged with its source field so silent record loss
1367
+ // surfaces in the evidence); already-merged is gated on the pin
1368
+ // inside parseClassifyFerry.
1369
+ if (classifyParsed.diagField !== null) {
1370
+ log("classify-branch DIAG pin (" + classifyParsed.diagField + " field): " + classifyParsed.diagText);
1371
+ } else {
1372
+ log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
1373
+ }
1374
+ } else {
1375
+ branchStateErr = classifyParsed.failReason;
1376
+ if (classifyAttempt < 2) {
1377
+ log("classify-branch unparseable (" + branchStateErr + ") — retrying in place (attempt " + (classifyAttempt + 1) + " of 2)");
1378
+ }
1379
+ }
1380
+ } catch (bsErr) {
1381
+ classifyFailedReturn = String(bsErr && bsErr.message || bsErr).slice(0, 500);
1382
+ branchStateErr = "classifier call failed: " + String(bsErr && bsErr.message || bsErr).slice(0, 120);
1383
+ if (classifyAttempt < 2) {
1384
+ log("classify-branch instrument failure (" + branchStateErr + ") — retrying in place (attempt " + (classifyAttempt + 1) + " of 2)");
1385
+ }
1206
1386
  }
1207
- } catch (bsErr) {
1208
- branchStateErr = "classifier call failed: " + String(bsErr && bsErr.message || bsErr).slice(0, 120);
1209
1387
  }
1210
- if (!branchState) {
1211
- branchState = "empty-no-work";
1212
- log("Branch-state classification failed (" + branchStateErr + ") — failing closed as empty-no-work");
1213
- } else if (branchState.indexOf("already-merged:") === 0) {
1388
+ if (branchState === null) {
1389
+ // Exhaustion: park honestly. An instrument failure is not a
1390
+ // measurement of emptiness — never "empty-no-work".
1391
+ 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.");
1392
+ }
1393
+ if (branchState.indexOf("already-merged:") === 0) {
1214
1394
  alreadyMergedSha = branchState.slice("already-merged:".length);
1215
- log("Branch state already-merged: " + alreadyMergedSha + " (mechanically verified) — Cass reviews the frozen merge diff");
1395
+ log("Branch state already-merged: " + alreadyMergedSha + " (DIAG pin present — tripwire satisfied) — Cass reviews the frozen merge diff");
1216
1396
  } else {
1217
1397
  log("Branch state: " + branchState);
1218
1398
  }
@@ -1224,23 +1404,55 @@ while (i < STEPS.length) {
1224
1404
  // git fact, not a reviewer judgment. For npm projects the workflow checks
1225
1405
  // it here, before Cass is dispatched — a touched `version` fails Review
1226
1406
  // mechanically (versions are assigned at publish time, never in
1227
- // branches). Fail closed: anything but an explicit VERSION_CLEAN counts
1228
- // as touched.
1407
+ // branches). Review pass 2 (blocker-42 class): this ferry had the full
1408
+ // pre-42 shape — schema-less agent(), line-anchored parser, and an
1409
+ // unparseable fallback asserting a POSITIVE touched claim that spent the
1410
+ // shared rework budget. Same treatment as the classifier:
1411
+ // shape-constrained {stdout} schema, search-and-dedupe parse, UNKNOWN
1412
+ // with in-place retries, honest park. An instrument failure is not a
1413
+ // measurement of touched — never assert touched from unparseable output.
1229
1414
  if (PUBLISH_TYPE === "npm") {
1230
- try {
1231
- var vtResult = await agent(
1415
+ var versionState = null; // "touched" | "clean" | null (null = UNKNOWN)
1416
+ var versionStateErr = "";
1417
+ var versionFailedReturn = "";
1418
+ for (var versionAttempt = 0; versionAttempt <= 2 && versionState === null; versionAttempt++) {
1419
+ var versionPrompt =
1232
1420
  "Check whether the task branch changed package.json's `version` field. This is a mechanical git check, not a judgment call.\n" +
1233
- "Run in shell and return the stdout verbatim:\n" +
1234
- LIFECYCLE_ENV + LIFECYCLE + " version-check " + taskId,
1235
- { key: "version-check" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Checking package.json version (mechanical)" }
1236
- );
1237
- var vtStr = (typeof vtResult === "string") ? vtResult : JSON.stringify(vtResult);
1238
- var vtMatch = /^\s*VERSION_(TOUCHED|CLEAN)\s*$/im.exec(vtStr);
1239
- versionTouched = !(vtMatch && vtMatch[1].toUpperCase() === "CLEAN");
1240
- } catch (vtErr) {
1241
- versionTouched = true;
1242
- log("Version check failed (" + String(vtErr && vtErr.message || vtErr).slice(0, 120) + ") — failing closed as touched");
1421
+ "Run in shell: " + LIFECYCLE_ENV + LIFECYCLE + " version-check " + taskId + "\n" +
1422
+ "Return the command's exact stdout as the single named field and nothing else: { \"stdout\": \"<the command's exact stdout>\" }." +
1423
+ (versionAttempt === 0 ? "" : versionRetryTrailer(versionStateErr, versionFailedReturn));
1424
+ try {
1425
+ var vtResult = await agent(
1426
+ versionPrompt,
1427
+ { key: "version-check" + (versionAttempt === 0 ? "" : "-u" + versionAttempt) + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
1428
+ label: "Checking package.json version (mechanical)" + (versionAttempt === 0 ? "" : " (instrument retry " + versionAttempt + " of 2)"),
1429
+ schema: { type: "object", properties: { stdout: { type: "string" } }, required: ["stdout"] } }
1430
+ );
1431
+ versionFailedReturn = (vtResult && typeof vtResult === "object" ? JSON.stringify(vtResult) : String(vtResult)).slice(0, 500);
1432
+ var vtParsed = parseVersionFerry(vtResult);
1433
+ if (vtParsed.versionState !== null) {
1434
+ versionState = vtParsed.versionState;
1435
+ versionStateErr = "";
1436
+ } else {
1437
+ versionStateErr = vtParsed.failReason;
1438
+ if (versionAttempt < 2) {
1439
+ log("version-check unparseable (" + versionStateErr + ") — retrying in place (attempt " + (versionAttempt + 1) + " of 2)");
1440
+ }
1441
+ }
1442
+ } catch (vtErr) {
1443
+ versionFailedReturn = String(vtErr && vtErr.message || vtErr).slice(0, 500);
1444
+ versionStateErr = "version-check call failed: " + String(vtErr && vtErr.message || vtErr).slice(0, 120);
1445
+ if (versionAttempt < 2) {
1446
+ log("version-check instrument failure (" + versionStateErr + ") — retrying in place (attempt " + (versionAttempt + 1) + " of 2)");
1447
+ }
1448
+ }
1243
1449
  }
1450
+ if (versionState === null) {
1451
+ // Exhaustion: park honestly. An instrument failure is not a
1452
+ // measurement of touched — never assert touched.
1453
+ 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.");
1454
+ }
1455
+ versionTouched = (versionState === "touched");
1244
1456
  log("Review: package.json `version` " + (versionTouched ? "touched by the branch — mechanical FAIL, Cass not dispatched" : "untouched — mechanical check clean"));
1245
1457
  }
1246
1458
  // The empty-branch rule Cass used to adjudicate is gone. What she
@@ -1563,13 +1775,26 @@ while (i < STEPS.length) {
1563
1775
  // closeout — the file list is the complete record of what the task
1564
1776
  // changed. The courier is pure hands: it writes the deterministic
1565
1777
  // script's own file list, never interprets it.
1778
+ // Blocker 40 (2026-09-21): this courier carries the same
1779
+ // {output: string} schema as the sibling diff-computation call.
1780
+ // The command is stdout-silent; schema-less, the runtime's
1781
+ // non-empty-result contract throws on the empty string and the
1782
+ // workflow parks fail-closed for a side effect that landed. Empty
1783
+ // stdout is schema-valid, so the contract no longer fires. "The
1784
+ // schema bypasses the contract" is INFERRED, not observed —
1785
+ // falsification: if a schema-carrying courier still parks on
1786
+ // silent stdout, the platform contract is deeper than the schema
1787
+ // and this is wrong; room evidence decides. (The file on disk is
1788
+ // not read as proof until QA closeout — the QA loader fails
1789
+ // closed to "unknown" attribution when it is absent.)
1566
1790
  var publishDiffFilesPath = crewHome + "/.publish-diffs/" + taskId + ".files.json";
1567
1791
  await agent(
1568
1792
  "Write the publish diff file list.\n" +
1569
- "Run exactly this and return the stdout verbatim:\n" +
1793
+ "Run exactly this and return the stdout verbatim as { \"output\": \"<verbatim stdout>\" } and nothing else. Do not interpret it:\n" +
1570
1794
  "mkdir -p " + crewHome + "/.publish-diffs && cat > \"" + publishDiffFilesPath + "\" <<'DIFFFILES_EOF'\n" +
1571
1795
  JSON.stringify(diffSummary.files || []) + "\nDIFFFILES_EOF\n",
1572
- { key: attemptKey("publish-diff-files-" + taskId, totalReworkCount), label: "Persisting publish diff file list" }
1796
+ { key: attemptKey("publish-diff-files-" + taskId, totalReworkCount), label: "Persisting publish diff file list",
1797
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
1573
1798
  );
1574
1799
  // Budget counts CHANGED lines (added + removed), not raw unified-diff
1575
1800
  // output lines: context lines and file headers inflated the old