muse-crew 0.7.19 → 0.8.0

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.
@@ -58,7 +58,10 @@ if (!inputs.crewHome) throw new Error("crewHome is required — pass the crew ho
58
58
  const crewHome = inputs.crewHome;
59
59
  // Crew API: the workflow calls the crew-owned CLI, not the dashboard.
60
60
  // The CLI implements the API.md contract against $CREW_HOME/crew-state.db.
61
- const CREW_API = crewHome + "/current/lib/crew-api.js";
61
+ const CREW_API_SRC = crewHome + "/current/lib/crew-api.js";
62
+ // Pinned at pinLifecycle: after the pin, CREW_API points into RUN_LIB so a
63
+ // mid-flight release swap cannot change the CLI under a running workflow.
64
+ let CREW_API = CREW_API_SRC;
62
65
  // Build a shell command invoking the CLI. Args are JSON-encoded and
63
66
  // single-quote-wrapped for safe shell passing. The agent runs this and
64
67
  // returns the stdout verbatim (the CLI emits JSON on stdout).
@@ -75,9 +78,12 @@ const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
75
78
  const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
76
79
  const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
77
80
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
78
- // The four basenames the pin step must materialize — asserted mechanically
81
+ const CREW_API_PINNED = RUN_LIB + "/crew-api.js";
82
+ const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
83
+ const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
84
+ // The five basenames the pin step must materialize — asserted mechanically
79
85
  // by workflow code from the verbatim listing, never from agent prose.
80
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM].map(function (p) { return p.split("/").pop(); });
86
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED].map(function (p) { return p.split("/").pop(); });
81
87
 
82
88
  // Project config — passed by dispatcher, falls back to dashboard defaults
83
89
  const projectConfig = inputs.project_config || {};
@@ -223,7 +229,7 @@ function attemptKey(base, reworkCount) {
223
229
  return base + (reworkCount > 0 ? "-r" + reworkCount : "");
224
230
  }
225
231
  // pinLifecycle(key) — snapshot the lifecycle scripts into RUN_LIB and return
226
- // the verbatim `ls -1` listing so WORKFLOW CODE asserts the four pinned
232
+ // the verbatim `ls -1` listing so WORKFLOW CODE asserts the five pinned
227
233
  // basenames; the agent cannot self-certify. (The pin step was the one place
228
234
  // the workflows trusted agent prose: task 24be1cd6 walked to Publish on an
229
235
  // empty pin dir.) Byte-identical across standard/bugfix/chore — pinned by
@@ -231,7 +237,7 @@ function attemptKey(base, reworkCount) {
231
237
  function pinLifecycle(key) {
232
238
  return agent(
233
239
  "Snapshot lifecycle scripts for version pinning.\n" +
234
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
240
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
235
241
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
236
242
  { key: key, label: "Pinning lifecycle scripts",
237
243
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -522,7 +528,7 @@ function extractMarkerLines(workerText) {
522
528
  var markers = [];
523
529
  for (var i = 0; i < lines.length; i++) {
524
530
  var line = lines[i].trim();
525
- if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:|worktree:)/i.test(line)) {
531
+ if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|layer:|capture_targets:|worktree:)/i.test(line)) {
526
532
  markers.push(line);
527
533
  }
528
534
  }
@@ -577,6 +583,16 @@ function extractExperiential(workerText) {
577
583
  if (!r) return null;
578
584
  return r[1].toLowerCase() === "yes";
579
585
  }
586
+ // Layer flag: reads Sage's layer: artifact|engine|docs marker line.
587
+ // Missing or malformed degrades to null (unknown) — callers degrade to
588
+ // "artifact" (today's single-strategy behavior), never park a task on a
589
+ // garbled line.
590
+ function extractLayer(workerText) {
591
+ var t = workerText || "";
592
+ var r = /layer:\s*(artifact|engine|docs)\b/i.exec(t);
593
+ if (!r) return null;
594
+ return r[1].toLowerCase();
595
+ }
580
596
  function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets) {
581
597
  // Deterministic visual-capture frame. kind: "baseline" | "postchange".
582
598
  // This string IS the capture script: fixed viewport matrix, scroll
@@ -634,6 +650,41 @@ async function resolveExperiential() {
634
650
  else experientialResolved = "unknown";
635
651
  return experientialResolved;
636
652
  }
653
+ // Layer resolution: the task's layer is "artifact", "engine", or "docs" —
654
+ // the machine-read "layer:" marker Sage's Triage report ends with. Unknown
655
+ // (missing/garbled line, failed lookup) degrades to "artifact": today's
656
+ // single-strategy behavior, never a park. Mirrors resolveExperiential()
657
+ // (same cache shape, same Triage-notes re-read); the layer flag is captured
658
+ // at Triage closeout (bugLayer) so the common path needs no extra agent call.
659
+ async function resolveLayer() {
660
+ // Triage notes are immutable within a run: cache the resolved layer so
661
+ // the dashboard lookup runs at most once per run.
662
+ if (layerResolved !== null) return layerResolved;
663
+ if (bugLayer) return (layerResolved = bugLayer);
664
+ var layerCheck = null;
665
+ try {
666
+ layerCheck = await agent(
667
+ "Find this task's Triage step session notes from the crew API.\n" +
668
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
669
+ "Find the session with task_id \"" + taskId + "\" and step \"Triage\" (status completed) in the returned sessions array and read its notes field.\n" +
670
+ "Return JSON { \"layer_line\": \"<the exact text of the layer: marker line from the notes, or empty string if absent>\" } and nothing else.",
671
+ {
672
+ key: "resolve-layer-" + taskId,
673
+ label: "Resolving bug layer from Triage notes",
674
+ schema: { type: "object", properties: { layer_line: { type: "string" } }, required: ["layer_line"] }
675
+ }
676
+ );
677
+ } catch (e) {
678
+ log("resolveLayer: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
679
+ layerResolved = "artifact";
680
+ return layerResolved;
681
+ }
682
+ var layer = extractLayer(layerCheck && layerCheck.layer_line ? layerCheck.layer_line : "");
683
+ // Unknown degrades to "artifact" (today's behavior) — never park a task
684
+ // on a garbled line.
685
+ layerResolved = layer || "artifact";
686
+ return layerResolved;
687
+ }
637
688
  // Baseline evidence status: reads the task's note events for the exact
638
689
  // protocol prefixes (explicit state, never English matching). Returns
639
690
  // { baseline_found, baseline_kind, baseline_refs, requested_count, evidence_count }.
@@ -718,6 +769,12 @@ let mapperSpec = "";
718
769
  // plan, and the Map-gate bounce counter (keeps agent stable-keys unique when
719
770
  // a Map-gate bounce re-runs Capture/Map in the same run).
720
771
  let isExperiential = null;
772
+ // Sage's bug-layer flag ("artifact"|"engine"|"docs", null until the Triage
773
+ // report is read). Drives the Reproduce phase's strategy dispatch.
774
+ let bugLayer = null;
775
+ // Caches the resolveLayer() outcome; Triage notes are immutable within a
776
+ // run, so the lookup runs at most once.
777
+ let layerResolved = null;
721
778
  // Caches all three resolveExperiential() outcomes (yes/no/unknown); Triage
722
779
  // notes are immutable within a run, so the lookup runs at most once.
723
780
  let experientialResolved = null;
@@ -803,7 +860,7 @@ let i = startStepIndex;
803
860
  // ── Pin lifecycle scripts ────────────────────────────────────────────
804
861
  // Copy lifecycle scripts into a per-task temp dir so this run is immune
805
862
  // to upgrades that land while it's in flight. Verified mechanically:
806
- // workflow code asserts the four basenames from the verbatim listing —
863
+ // workflow code asserts the five basenames from the verbatim listing —
807
864
  // the agent cannot self-certify. Any miss parks the task before Triage.
808
865
  const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
809
866
  const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
@@ -811,6 +868,10 @@ if (missingInitialPins.length > 0) {
811
868
  return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
812
869
  }
813
870
  log("Lifecycle scripts pinned to " + RUN_LIB);
871
+ // From here on, every crew-api.js invocation uses the pinned copy: immune
872
+ // to a release swap landing mid-flight.
873
+ CREW_API = CREW_API_PINNED;
874
+ log("Crew API pinned to " + CREW_API);
814
875
 
815
876
  // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
816
877
  // minted at this run's first claim (never a PID — short-lived agent PIDs
@@ -1128,7 +1189,9 @@ while (i < STEPS.length) {
1128
1189
  var mapBaselineRefs = "";
1129
1190
  var mapBaselineNone = false;
1130
1191
  if (step.name === "Map") {
1131
- if ((await resolveExperiential()) === "yes") {
1192
+ // Must match Capture's run condition (experiential + artifact publish):
1193
+ // when Capture skips, no baseline notes exist, so the gate must not apply.
1194
+ if ((await resolveExperiential()) === "yes" && PUBLISH_TYPE === "artifact") {
1132
1195
  var gateStatus = await baselineStatus();
1133
1196
  if (!gateStatus.baseline_found) {
1134
1197
  log("Map gate: no baseline evidence for experiential task " + taskId + " — bouncing to Capture");
@@ -1161,14 +1224,26 @@ while (i < STEPS.length) {
1161
1224
  qaVisual = (await resolveExperiential()) === "yes" && PUBLISH_TYPE === "artifact";
1162
1225
  }
1163
1226
 
1227
+ // Reproduce-layer routing (2026-09-16): Sage's Triage classifies the bug's
1228
+ // layer (artifact|engine|docs) with the machine-read "layer:" marker; the
1229
+ // workflow dispatches the Reproduce strategy mechanically on it. Unknown
1230
+ // degrades to "artifact" (today's single-strategy behavior). resolveLayer
1231
+ // caches per run, so Reproduce entry costs one agent call at most.
1232
+ var reproLayer = "artifact";
1233
+ if (step.name === "Reproduce") {
1234
+ reproLayer = await resolveLayer();
1235
+ log("Reproduce dispatching at the bug's layer for task " + taskId + ": " + reproLayer);
1236
+ }
1237
+
1164
1238
  var safeTitle = taskTitle.replace(/"/g, "'").replace(/\\/g, "\\\\").replace(/`/g, "'");
1165
1239
  var instructions = "";
1166
1240
 
1167
1241
  if (step.name === "Triage") {
1168
- instructions = "Validate the task against the project's repo at " + REPO_PATH + " — that exact checkout, not any other copy of the project on disk. If you run git commands, cd " + REPO_PATH + " first.\nCheck clarity, note dependencies, confirm the bugfix workflow assignment.\nIf the task needs decomposition, note that in your assessment.\nReport back in plain prose — what you found.\nEXPERIENTIAL FLAG: does this task change anything rendered and visible in the project's user-facing artifact (pages, components, styles, layout, copy, visual states)? If yes it is experiential and gets baseline captures (plus a visual verdict where the workflow has a QA phase). End your report with exactly one line on its own, lowercase, unrephrased: experiential: yes — or experiential: no. This line is machine-read.";
1242
+ instructions = "Validate the task against the project's repo at " + REPO_PATH + " — that exact checkout, not any other copy of the project on disk. If you run git commands, cd " + REPO_PATH + " first.\nCheck clarity, note dependencies, confirm the bugfix workflow assignment.\nIf the task needs decomposition, note that in your assessment.\nReport back in plain prose — what you found.\nEXPERIENTIAL FLAG: does this task change anything rendered and visible in the project's user-facing artifact (pages, components, styles, layout, copy, visual states)? If yes it is experiential and gets baseline captures (plus a visual verdict where the workflow has a QA phase). End your report with exactly one line on its own, lowercase, unrephrased: experiential: yes — or experiential: no. This line is machine-read.\nLAYER FLAG: classify the bug's layer — where the reported misbehavior lives. artifact: user-facing behavior of the project's rendered artifact (something a user sees or clicks). engine: the crew's own machinery — workflows, lib scripts, shell scripts, tests, scheduler. docs: a documentation gap or error. End your report with exactly one line on its own, lowercase, unrephrased: layer: artifact — or layer: engine — or layer: docs. This line is machine-read. If the bug genuinely spans layers, pick the layer where reproduction must happen and note the ambiguity in prose.";
1169
1243
 
1170
1244
  } else if (step.name === "Reproduce") {
1171
- instructions = "Reproduce the bug from a user's perspective — by USING the artifact, not by reading data. You are CODE-BLIND — do NOT read source code.\n" +
1245
+ if (reproLayer !== "engine" && reproLayer !== "docs") {
1246
+ instructions = "Reproduce the bug from a user's perspective — by USING the artifact, not by reading data. You are CODE-BLIND — do NOT read source code.\n" +
1172
1247
  "You have a see-act driver: " + crewHome + "/current/lib/see-act.js (a node script; one browser action per invocation; it prints one JSON line to stdout). It launches its own Chromium through a self-contained loopback proxy — the ONLY url you may give it is the local artifact server you start below. Never point it at any other URL.\n" +
1173
1248
  "Actions: aria | shot [--out <png>] [--full] | click [--out <png>] --selector <css> | scroll [--out <png>] --y <pixels|bottom> | type [--out <png>] --selector <css> --text <text>. Add --viewport mobile for a 390x844 frame. The JSON reports console_errors — treat any as a defect signal. Exit code 3 with not_possible set (a \"NOT POSSIBLE: <reason>\" string) means this environment cannot drive a browser: report NOT POSSIBLE: <reason> and fall back to the data-level investigation at the end.\n" +
1174
1249
  "a. Verify the built artifact exists: test -d ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/client/dist && test -f ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/server/dist/actions.js — if either is missing, report NOT POSSIBLE: built artifact not present at ~/workspace/ts-spaces/" + PUBLISH_SLUG + " and fall back to the data-level investigation.\n" +
@@ -1184,6 +1259,18 @@ while (i < STEPS.length) {
1184
1259
  "This returns current sessions, events, and tasks — capture concrete evidence from the data you retrieve.\n" +
1185
1260
  "Report your reproduction steps and evidence as plain prose.\n" +
1186
1261
  "End your report with exactly one line: VERDICT: PASS if you reproduced the reported bug (your frames show the reported misbehavior), VERDICT: FAIL if you could not. --expected names the bug as reported (its visible manifestation); --actual names what your frames actually showed. Checks you could not run are evidence gaps, not silent drops: name every one in --missing. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/repro/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 + "/repro/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the bug as reported — its visible manifestation>\" --actual \"<what your frames actually showed>\" --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 or NOT_POSSIBLE verdict without a machine-readable --reason cannot be written — state the reason.";
1262
+ } else if (reproLayer === "engine") {
1263
+ instructions = "REPRODUCE AT THE BUG'S LAYER. Triage classified this bug as layer: engine — it lives in the crew's own machinery (workflows, lib scripts, shell scripts, tests, scheduler), not in the rendered artifact. Reproduce it deterministically with shell commands in the repo checkout at " + REPO_PATH + " — that exact checkout, not any other copy of the project on disk. You are NOT code-blind here: reading source to find the failing mechanism is expected.\n" +
1264
+ "Do NOT start an artifact server. Do NOT invoke see-act.js or any browser loop — the experiential loop is for artifact-layer bugs only, and driving it here fails the phase loudly. If you cannot reproduce without a browser, say so and FAIL with a reason naming the layer you tried.\n" +
1265
+ "Reproduce with the commands that match the bug: run the failing script directly, re-run the failing test suite (bash tests/run.sh), grep the workflow source, query scheduler/crew state via the crew API. Concrete evidence only: the command lines you ran, their verbatim stdout, and their exit codes.\n" +
1266
+ "Report your reproduction steps and evidence as plain prose.\n" +
1267
+ "End your report with exactly one line: VERDICT: PASS if you reproduced the reported bug (your command output shows the reported misbehavior), VERDICT: FAIL if you could not. --expected names the bug as reported; --actual names what your commands actually showed. Checks you could not run are evidence gaps, not silent drops: name every one in --missing. Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/repro/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the bug as reported>\" --actual \"<what your commands actually showed>\" --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 or NOT_POSSIBLE verdict without a machine-readable --reason cannot be written — state the reason.";
1268
+ } else {
1269
+ instructions = "REPRODUCE AT THE BUG'S LAYER. Triage classified this bug as layer: docs — a documentation gap or error. Reproduce it by reading the file, not the browser: open the document in the repo checkout at " + REPO_PATH + " (that exact checkout, not any other copy of the project on disk) and verify the reported gap or error is actually there (a missing section, a stale instruction, a wrong claim). Quote the exact lines you found — or their absence.\n" +
1270
+ "Do NOT start an artifact server. Do NOT invoke see-act.js or any browser loop — the experiential loop is for artifact-layer bugs only, and driving it here fails the phase loudly. If you cannot verify the gap without a browser, say so and FAIL with a reason naming the layer you tried.\n" +
1271
+ "Report your findings as plain prose: what the document says today versus what it should say.\n" +
1272
+ "End your report with exactly one line: VERDICT: PASS if you confirmed the reported docs gap, VERDICT: FAIL if you could not. Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/repro/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the bug as reported>\" --actual \"<what the document actually says>\" --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 or NOT_POSSIBLE verdict without a machine-readable --reason cannot be written — state the reason.";
1273
+ }
1187
1274
 
1188
1275
  } else if (step.name === "Map") {
1189
1276
  var mapGatePara = "";
@@ -1315,6 +1402,10 @@ while (i < STEPS.length) {
1315
1402
  "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1316
1403
  "skipped: no-lock-held (empty-diff Integrate — nothing merged, nothing to ship)\n" +
1317
1404
  "VERDICT: PASS\n\n" +
1405
+ "If the script's output contains PUBLISH_SKIPPED=no-npm-publish, the publish was skipped gracefully: npm publish is not configured on this machine (helper or credential absent) — the merge stands, the version was not cut, nothing was shipped. Paste the marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
1406
+ "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1407
+ "skipped: no-npm-publish (npm publish not configured — helper or credential missing; nothing versioned or published)\n" +
1408
+ "VERDICT: PASS\n\n" +
1318
1409
  "If it exits zero, paste the script's COMPLETE marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
1319
1410
  "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1320
1411
  "published: muse-crew@" + publishTarget.target + "\n" +
@@ -1334,9 +1425,10 @@ while (i < STEPS.length) {
1334
1425
  // 2026-09-11), so the stamp moved to the parent — after the build
1335
1426
  // lands, the workflow records the session completed and parks with
1336
1427
  // "publish: verification-requested". The parent owns verification
1337
- // (docs/publish-verification.md); the independent read-back step is
1338
- // currently unavailable (no agent-callable read-back tool exists —
1339
- // artifact_inspect was removed by the platform 2026-09-14).
1428
+ // (docs/publish-verification.md); the primary sensor is the
1429
+ // deterministic lib/readback-disk.js (the agent-callable read-back
1430
+ // tool is unavailable — artifact_inspect was removed by the platform
1431
+ // 2026-09-14 — so the LLM-inspector path is manual-fallback only).
1340
1432
  // QA's provenance check enforces the stamp mechanically.
1341
1433
  var artifactPublish = null;
1342
1434
  var publishLockRefreshed = false;
@@ -1408,9 +1500,17 @@ while (i < STEPS.length) {
1408
1500
  } else if (!/^[0-9a-f]{40}$/.test(publishBase)) {
1409
1501
  return await parkTask("Publish base '" + publishBase + "' is not a valid commit SHA — cannot compute the publish diff. Human attention needed.");
1410
1502
  }
1503
+ // The empty tree is not a commit: git merge-base --is-ancestor fails on it.
1504
+ // The workflow knows publishBase == EMPTY_TREE_SHA (set above), so it
1505
+ // hardcodes ANCESTOR=yes for a first publish instead of asking the agent
1506
+ // to execute the conditional (clean-room 2026-09-16: the agent ran
1507
+ // merge-base on the empty tree directly and parked).
1508
+ var ancestorShell = (publishBase === EMPTY_TREE_SHA)
1509
+ ? "ANCESTOR=yes && "
1510
+ : "git merge-base --is-ancestor \"$BASE\" \"$HEAD\" && ANCESTOR=yes || ANCESTOR=no && ";
1411
1511
  var diffResult = await agent(
1412
1512
  "Run: cd " + REPO_PATH + " && BASE='" + publishBase + "' && HEAD=$(git rev-parse HEAD) && " +
1413
- "if [ \"$BASE\" = '" + EMPTY_TREE_SHA + "' ]; then ANCESTOR=yes; else git merge-base --is-ancestor \"$BASE\" \"$HEAD\" && ANCESTOR=yes || ANCESTOR=no; fi && " +
1513
+ ancestorShell +
1414
1514
  "echo '---COMMIT---' && echo \"$HEAD\" && echo '---BASE---' && echo \"$BASE\" && echo '---ANCESTOR---' && echo \"$ANCESTOR\" && " +
1415
1515
  "if [ \"$ANCESTOR\" = yes ]; then echo '---DIFF---' && git diff \"$BASE\" \"$HEAD\" && echo '---NAMES---' && git diff-tree --no-commit-id --name-only -r \"$BASE\" \"$HEAD\"; fi\n" +
1416
1516
  "Return JSON { \"commit\": \"<HEAD trimmed>\", \"base\": \"<BASE trimmed>\", \"ancestor\": \"<yes|no>\", \"diff\": \"<raw unified diff, may be multi-line>\", \"files\": \"<newline-separated paths>\" } and nothing else.",
@@ -1483,8 +1583,10 @@ while (i < STEPS.length) {
1483
1583
  // The builder's applied report is gone (2026-09-16): it rode on the
1484
1584
  // trigger's JSON closeout contract, which is removed below. The
1485
1585
  // parent's independent read-back (docs/publish-verification.md) is
1486
- // the verification — this field stays "missing-report" on every
1487
- // ledger line the workflow writes.
1586
+ // the verification — this field stays "missing-report" on ledger
1587
+ // lines for issued triggers; pre-trigger parks (toolcheck
1588
+ // rejected/inconclusive) and unattributed-unknown parks write null
1589
+ // (no trigger was observed, so there is nothing to report).
1488
1590
  var publishAppliedObservation = "missing-report";
1489
1591
  // Durable-evidence snapshot (2026-09-14): the observation below only
1490
1592
  // detects IN-FLIGHT builds. A build that finished before the
@@ -1495,10 +1597,13 @@ while (i < STEPS.length) {
1495
1597
  // fallback can diff before/after: a directory appearing during the
1496
1598
  // trigger window is positive evidence the edit went through and
1497
1599
  // the build completed. Best-effort and non-gating: if the snapshot
1498
- // fails, the durable check is skipped and the fallback behaves as
1499
- // before. No wall-clock in-script (deterministic replay) — the
1600
+ // fails, auditBeforeOk stays false and BOTH fallback comparisons
1601
+ // are disabled (2026-09-16, critic finding 4) — without a baseline,
1602
+ // an empty before-list would make every historical audit dir look
1603
+ // "new". No wall-clock in-script (deterministic replay) — the
1500
1604
  // comparison is a pure before/after set diff.
1501
1605
  var auditDirsBeforeTrigger = [];
1606
+ var auditBeforeOk = false;
1502
1607
  try {
1503
1608
  var auditBefore = await agent(
1504
1609
  "List the artifact audit directories for slug \"" + PUBLISH_SLUG + "\" (best-effort snapshot, never a gate).\n" +
@@ -1508,9 +1613,10 @@ while (i < STEPS.length) {
1508
1613
  schema: { type: "object", properties: { dirs: { type: "string" } }, required: ["dirs"] } }
1509
1614
  );
1510
1615
  auditDirsBeforeTrigger = String((auditBefore && auditBefore.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1616
+ auditBeforeOk = true;
1511
1617
  log("Publish audit-dir snapshot before trigger for task " + taskId + ": " + auditDirsBeforeTrigger.length + " entries");
1512
1618
  } catch (auditBeforeErr) {
1513
- log("Publish audit-dir snapshot before trigger failed for task " + taskId + " (non-fatal, durable-evidence check degraded): " + (auditBeforeErr && auditBeforeErr.message ? auditBeforeErr.message : auditBeforeErr));
1619
+ log("Publish audit-dir snapshot before trigger failed for task " + taskId + " (non-fatal): audit fallback DISABLED for this attempt — without a baseline, historical dirs would look new: " + (auditBeforeErr && auditBeforeErr.message ? auditBeforeErr.message : auditBeforeErr));
1514
1620
  }
1515
1621
  // Fire-and-forget trigger + workflow-owned observation (2026-09-16,
1516
1622
  // clean-room task e2a8d9f8): the trigger's JSON closeout contract
@@ -1534,10 +1640,16 @@ while (i < STEPS.length) {
1534
1640
  // Pre-trigger toolcheck (tiny, schema'd): the artifact namespace is
1535
1641
  // deferred for workflow children — the child self-loads it and emits
1536
1642
  // one exact signal line, read mechanically (never English prose).
1537
- // Explicit negative evidence (missing) gets one bounded retry with a
1538
- // fresh key, then parks: without the tools the edit provably did NOT
1539
- // go through, so this is the one safe retry on the publish path.
1643
+ // Only a parsed ARTIFACT_TOOLS: missing signal is explicit negative
1644
+ // evidence: it gets one bounded retry with a fresh key, then parks
1645
+ // rejected — without the tools the edit provably did NOT go through,
1646
+ // so this is the one safe retry on the publish path. A throw (or an
1647
+ // unparseable signal) is INCONCLUSIVE transport noise, never
1648
+ // evidence of missing tools (2026-09-16, critic finding 3): it is
1649
+ // recorded, it retries once in case the flake clears, but it can
1650
+ // never take the rejected path.
1540
1651
  var publishToolsOk = false;
1652
+ var publishToolsMissing = false;
1541
1653
  for (var toolcheckAttempt = 1; toolcheckAttempt <= 2 && !publishToolsOk; toolcheckAttempt++) {
1542
1654
  try {
1543
1655
  var toolcheckResult = await agent(
@@ -1549,12 +1661,29 @@ while (i < STEPS.length) {
1549
1661
  label: "Checking artifact tool availability" + (toolcheckAttempt === 1 ? "" : " (retry)"),
1550
1662
  schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] } }
1551
1663
  );
1552
- publishToolsOk = /ARTIFACT_TOOLS:\s*ok/.test(String((toolcheckResult && toolcheckResult.signal) || ""));
1553
- log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2): " + (publishToolsOk ? "tools ok" : "tools missing"));
1664
+ var toolSignal = String((toolcheckResult && toolcheckResult.signal) || "");
1665
+ if (/ARTIFACT_TOOLS:\s*ok/.test(toolSignal)) {
1666
+ publishToolsOk = true;
1667
+ } else if (/ARTIFACT_TOOLS:\s*missing/.test(toolSignal)) {
1668
+ publishToolsMissing = true;
1669
+ }
1670
+ log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2): " +
1671
+ (publishToolsOk ? "tools ok" : publishToolsMissing ? "tools missing (explicit parsed signal)" : "inconclusive (no ARTIFACT_TOOLS signal parsed)"));
1554
1672
  } catch (toolcheckErr) {
1555
- log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2) failed (" + (toolcheckErr && toolcheckErr.message ? toolcheckErr.message : toolcheckErr) + ") — counted as missing for this attempt");
1673
+ log("Publish artifact toolcheck for task " + taskId + " (attempt " + toolcheckAttempt + " of 2) threw (" + (toolcheckErr && toolcheckErr.message ? toolcheckErr.message : toolcheckErr) + ") — inconclusive: a throw proves nothing about tool availability, never counted as missing");
1556
1674
  }
1557
1675
  }
1676
+ if (!publishToolsOk && !publishToolsMissing) {
1677
+ await recordPublishLedger({
1678
+ commit: mergeCommitForPublish,
1679
+ attempt: rebuildAttemptKey,
1680
+ agent_id: null,
1681
+ applied_report: null,
1682
+ outcome: "unknown",
1683
+ detail: "artifact toolcheck inconclusive after two attempts (throws or unparseable signals — never an explicit ARTIFACT_TOOLS: missing): tool availability unproven, so the trigger was NOT issued; unknown parks fail closed with no blind retry"
1684
+ }, totalReworkCount);
1685
+ return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact toolcheck was inconclusive after two attempts (no explicit ARTIFACT_TOOLS signal parsed — a throw is transport noise, not evidence). Tool availability is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.");
1686
+ }
1558
1687
  if (!publishToolsOk) {
1559
1688
  await recordPublishLedger({
1560
1689
  commit: mergeCommitForPublish,
@@ -1562,9 +1691,9 @@ while (i < STEPS.length) {
1562
1691
  agent_id: null,
1563
1692
  applied_report: null,
1564
1693
  outcome: "rejected",
1565
- detail: "artifact tool namespace missing in two toolcheck attempts (explicit negative evidence): the edit provably did not go through — no trigger issued, no blind retry"
1694
+ detail: "artifact tool namespace explicitly missing (parsed ARTIFACT_TOOLS: missing signal, one bounded retry spent): the edit provably did not go through — no trigger issued, no blind retry"
1566
1695
  }, totalReworkCount);
1567
- return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact tool namespace was missing in two toolcheck attempts (explicit negative evidence — the edit provably did not go through, so no trigger was issued and nothing was retried blindly). Human attention needed.");
1696
+ return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact tool namespace was explicitly missing (parsed signal — the edit provably did not go through, so no trigger was issued and nothing was retried blindly). Human attention needed.");
1568
1697
  }
1569
1698
  // Pre-trigger build-state baseline (tiny, schema'd): one read of
1570
1699
  // artifact_status. The post-trigger observation diffs against this
@@ -1590,19 +1719,26 @@ while (i < STEPS.length) {
1590
1719
  baselineFailed = true;
1591
1720
  log("Publish pre-trigger baseline read failed for task " + taskId + " (" + (baselineErr && baselineErr.message ? baselineErr.message : baselineErr) + ") — receipt attribution skipped; durable audit-dir evidence is the only positive signal");
1592
1721
  }
1593
- // The trigger itself: fire-and-forget transport for the
1594
- // artifact_edit call. NO schema — the return value is not consumed,
1595
- // so the runtime's JSON-candidate heuristic never runs on this
1596
- // call. A transport throw is possible and inconclusive: the edit
1597
- // may still have gone through, so the outcome stays unknown until
1598
- // the observation below confirms it — never inferred from the
1599
- // throw, and never blind-retried (a blind re-trigger duplicated the
1600
- // edit on 2026-09-12).
1722
+ // The trigger itself: the artifact_edit call is AWAITED (the workflow
1723
+ // waits for it to complete) but its return value is intentionally
1724
+ // UNCONSUMED — NO schema, so no schema validation can fail this
1725
+ // call: a schema-less call resolves to the child's raw response as
1726
+ // a plain string (probed live 2026-09-16 — never parsed, never
1727
+ // throws on content). One caveat, also probed: the runtime still
1728
+ // scans the response for a JSON candidate, and an unparseable
1729
+ // {...}-looking substring in the child's prose throws ("response
1730
+ // JSON candidate", probe P6). The prompt tells the child to end its
1731
+ // turn with no prose at all, which keeps the common case clean —
1732
+ // but the channel is stochastic, so any throw is possible and
1733
+ // inconclusive: the edit may still have gone through, so the
1734
+ // outcome stays unknown until the observation below confirms it —
1735
+ // never inferred from the throw, and never blind-retried (a blind
1736
+ // re-trigger duplicated the edit on 2026-09-12).
1601
1737
  var rebuildTrigger = null;
1602
1738
  try {
1603
1739
  var triggerResultLength = String(await agent(rebuildPrompt,
1604
1740
  { key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "").length;
1605
- log("Publish rebuild trigger for task " + taskId + " returned (" + triggerResultLength + " chars, fire-and-forget: not consumed)");
1741
+ log("Publish rebuild trigger for task " + taskId + " returned (" + triggerResultLength + " chars; awaited but return intentionally unconsumed)");
1606
1742
  } catch (triggerErr) {
1607
1743
  log("Publish rebuild trigger for task " + taskId + " threw (" + (triggerErr && triggerErr.message ? triggerErr.message : triggerErr) + ") — outcome unknown until observation confirms it; the edit may have gone through");
1608
1744
  }
@@ -1637,6 +1773,16 @@ while (i < STEPS.length) {
1637
1773
  log("Publish post-trigger build-state check failed for task " + taskId + " (" + (buildCheckErr && buildCheckErr.message ? buildCheckErr.message : buildCheckErr) + ") — this signal is unknown, not negative");
1638
1774
  }
1639
1775
  var observedAgentId = (buildState && buildState.build && typeof buildState.build.agent_id === "string" && buildState.build.agent_id) || null;
1776
+ // Known limitation (failure-mode audit 2026-09-16): attribution
1777
+ // is timing-based — any agent_id new relative to the baseline is
1778
+ // treated as this edit's receipt. A stranger's build starting inside
1779
+ // the trigger window is indistinguishable by timing and would be
1780
+ // misattributed here. The consequence is bounded: the completion
1781
+ // poll below tracks the recorded id, and the parent's mechanical
1782
+ // content read-back (docs/publish-verification.md) certifies the
1783
+ // exact commit's content — a wrong build's content fails closed as
1784
+ // verification-failed, never stamped. Timing narrows the candidate;
1785
+ // content decides.
1640
1786
  var receiptAgentId = (!buildStateFailed && !baselineFailed && observedAgentId && observedAgentId !== baselineAgentId) ? observedAgentId : null;
1641
1787
  if (receiptAgentId) {
1642
1788
  // The edit went through — a build with a new agent_id appeared
@@ -1656,6 +1802,31 @@ while (i < STEPS.length) {
1656
1802
  }, totalReworkCount);
1657
1803
  } else {
1658
1804
  var newAuditDirs = [];
1805
+ // auditReportOk: pure tri-state read of a report.json body —
1806
+ // true (build ok), false (build failed), null (missing or
1807
+ // unreadable — not evidence either way). The child returns the
1808
+ // raw body verbatim; interpretation lives here, never in prose.
1809
+ // Defined here so both the immediate and post-poll audit
1810
+ // fallbacks share it.
1811
+ var auditReportOk = function (raw) {
1812
+ if (typeof raw !== "string") return null;
1813
+ var trimmed = raw.trim();
1814
+ if (trimmed === "" || trimmed === "MISSING") return null;
1815
+ var parsed;
1816
+ try { parsed = JSON.parse(trimmed); } catch (e) { return null; }
1817
+ if (parsed && typeof parsed.ok === "boolean") return parsed.ok;
1818
+ return null;
1819
+ };
1820
+ // (2026-09-16, critic finding 2) When durable audit evidence
1821
+ // confirms (or refutes) the build, there is no receipt agent_id
1822
+ // to chain the completion poll to — skipReceiptPoll bypasses the
1823
+ // poll below, which with a null receipt could only observe
1824
+ // strangers or nothing.
1825
+ var skipReceiptPoll = false;
1826
+ // publishFailure is declared here (moved up from below) so the
1827
+ // immediate audit fallback can record an explicit build failure
1828
+ // without the later declaration resetting it.
1829
+ var publishFailure = null;
1659
1830
  try {
1660
1831
  var auditAfter = await agent(
1661
1832
  "List the artifact audit directories for slug \"" + PUBLISH_SLUG + "\" (best-effort, never a gate).\n" +
@@ -1666,25 +1837,77 @@ while (i < STEPS.length) {
1666
1837
  );
1667
1838
  var auditDirsAfterTrigger = String((auditAfter && auditAfter.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1668
1839
  // Only timestamped build dirs count — the "latest" symlink
1669
- // and anything else are not builds.
1670
- newAuditDirs = auditDirsAfterTrigger.filter(function (d) {
1840
+ // and anything else are not builds. Gated on auditBeforeOk:
1841
+ // without a baseline every historical dir would look new.
1842
+ newAuditDirs = auditBeforeOk ? auditDirsAfterTrigger.filter(function (d) {
1671
1843
  return auditDirsBeforeTrigger.indexOf(d) === -1 && /^20\d\d-\d\d-\d\dT\d\d-\d\d-\d\dZ-/.test(d);
1672
- });
1844
+ }) : [];
1673
1845
  } catch (auditAfterErr) {
1674
1846
  log("Publish audit-dir re-list after trigger failed for task " + taskId + " (non-fatal, durable-evidence check degraded): " + (auditAfterErr && auditAfterErr.message ? auditAfterErr.message : auditAfterErr));
1675
1847
  }
1676
1848
  if (newAuditDirs.length > 0) {
1677
1849
  rebuildTrigger = { edit_started: true };
1678
1850
  rebuildAgentId = null;
1679
- log("Publish rebuild trigger for task " + taskId + ": new audit dir(s) during the trigger window (" + newAuditDirs.join(", ") + ") — the edit went through and the build completed; no in-flight receipt was observed.");
1680
- await recordPublishLedger({
1681
- commit: mergeCommitForPublish,
1682
- attempt: rebuildAttemptKey,
1683
- agent_id: null,
1684
- applied_report: publishAppliedObservation,
1685
- outcome: "submitted",
1686
- detail: "fire-and-forget trigger; edit confirmed via durable audit evidence (new audit dir " + newAuditDirs[0] + "); no in-flight receipt observed"
1687
- }, totalReworkCount);
1851
+ newAuditDirs.sort();
1852
+ var newestImmediateDir = newAuditDirs[newAuditDirs.length - 1];
1853
+ log("Publish rebuild trigger for task " + taskId + ": new audit dir(s) during the trigger window (" + newAuditDirs.join(", ") + ") — the edit went through and a build completed; no in-flight receipt was observed.");
1854
+ // (2026-09-16, critic finding 2) Durable audit evidence exists,
1855
+ // but there is no receipt agent_id to chain the completion poll
1856
+ // to — polling with a null receipt can only observe strangers
1857
+ // (any running build differs from "null") or nothing, burning
1858
+ // 10.5 minutes to park unknown. Read the build report now
1859
+ // instead of polling: ok=true confirms completion and routes
1860
+ // directly to parent verification (the poll is skipped);
1861
+ // ok=false is explicit failure; unreadable is unknown.
1862
+ var immediateReportOk = null;
1863
+ try {
1864
+ var immediateOkRead = await agent(
1865
+ "Read the artifact build report for slug \"" + PUBLISH_SLUG + "\".\n" +
1866
+ "Run: cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/" + newestImmediateDir + "/report.json 2>/dev/null || echo MISSING\n" +
1867
+ "Return JSON { \"raw\": \"<verbatim file contents, or the literal string MISSING when the file does not exist>\" } and nothing else.",
1868
+ { key: attemptKey("publish-audit-ok-immediate-" + taskId, totalReworkCount), label: "Reading build report for audit-confirmed build",
1869
+ schema: { type: "object", properties: { raw: { type: "string" } }, required: ["raw"] } }
1870
+ );
1871
+ immediateReportOk = auditReportOk(immediateOkRead && immediateOkRead.raw);
1872
+ } catch (immediateOkErr) {
1873
+ log("Publish build-report read for audit-confirmed dir failed for task " + taskId + " (treated as unknown): " + (immediateOkErr && immediateOkErr.message ? immediateOkErr.message : immediateOkErr));
1874
+ immediateReportOk = null;
1875
+ }
1876
+ if (immediateReportOk === true) {
1877
+ publishBuildLanded = true;
1878
+ artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
1879
+ skipReceiptPoll = true;
1880
+ log("Publish build landed for task " + taskId + " via immediate durable audit evidence (audit dir " + newestImmediateDir + ", report ok=true) — receipt poll skipped (no receipt to chain to), routing directly to parent verification");
1881
+ await recordPublishLedger({
1882
+ commit: mergeCommitForPublish,
1883
+ attempt: rebuildAttemptKey,
1884
+ agent_id: null,
1885
+ applied_report: publishAppliedObservation,
1886
+ outcome: "submitted",
1887
+ detail: "durable audit evidence shows a build completed during the attempt window (audit dir " + newestImmediateDir + ", report ok=true); receipt poll skipped (no receipt agent_id), routed to parent verification"
1888
+ }, totalReworkCount);
1889
+ } else if (immediateReportOk === false) {
1890
+ skipReceiptPoll = true;
1891
+ publishFailure = "Artifact build FAILED for slug " + PUBLISH_SLUG + " (audit dir " + newestImmediateDir + ", report ok=false — immediate audit evidence, no receipt observed). Explicit negative evidence: a build ran and failed. The publish did not land — provenance was not stamped. Fail-closed.";
1892
+ await recordPublishLedger({
1893
+ commit: mergeCommitForPublish,
1894
+ attempt: rebuildAttemptKey,
1895
+ agent_id: null,
1896
+ applied_report: publishAppliedObservation,
1897
+ outcome: "failed",
1898
+ detail: "a build ran and failed: audit dir " + newestImmediateDir + " report ok=false (immediate audit evidence, no receipt)"
1899
+ }, totalReworkCount);
1900
+ } else {
1901
+ await recordPublishLedger({
1902
+ commit: mergeCommitForPublish,
1903
+ attempt: rebuildAttemptKey,
1904
+ agent_id: null,
1905
+ applied_report: null,
1906
+ outcome: "unknown",
1907
+ detail: "new audit dir " + newestImmediateDir + " appeared during the trigger window but its build report is unreadable/missing; no receipt agent_id to poll — outcome unknown, fail-closed with no blind retry"
1908
+ }, totalReworkCount);
1909
+ return await parkTask("Publish outcome unknown for task " + taskId + ": a new audit dir (" + newestImmediateDir + ") appeared during the trigger window but its build report is unreadable, and no in-flight receipt was observed to poll. The edit may have completed. Correlate the accepted edit via the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl — do NOT reissue the edit blindly: if the trigger was accepted, a retry duplicates it (2026-09-12). Verify independently whether the build completed (audit dir + report, or the parent's content read-back) before deciding the next step. Fail-closed.");
1910
+ }
1688
1911
  } else {
1689
1912
  // No attributable build and no durable evidence — but that
1690
1913
  // proves nothing (a fast-completing build can finish between
@@ -1701,7 +1924,7 @@ while (i < STEPS.length) {
1701
1924
  outcome: "unknown",
1702
1925
  detail: "fire-and-forget trigger; post-trigger build-state poll saw no attributable build (or the check failed) and the audit-dir diff found no new dir; the edit may have been accepted as pending_init"
1703
1926
  }, totalReworkCount);
1704
- return await parkTask("Publish outcome unknown for task " + taskId + ": the rebuild trigger was issued fire-and-forget (no JSON closeout for the runtime heuristic to misfire on), and the follow-up observation could not attribute a build to the edit for slug " + PUBLISH_SLUG + " — no in-flight build with a new agent_id appeared in the poll window and no new audit dir landed. The edit may have been accepted as pending_init, so no retry was issued: a blind retry duplicated the edit on 2026-09-12. The attempt is recorded in the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl (commit " + String(mergeCommitForPublish || "unknown").slice(0, 12) + "). Correlate the accepted edit via the ledger and the builder's eventual completion before re-driving Publish. Fail-closed.");
1927
+ return await parkTask("Publish outcome unknown for task " + taskId + ": the rebuild trigger was issued fire-and-forget (no schema, so no validation failure mode; a candidate-parse throw stays possible and is inconclusive), and the follow-up observation could not attribute a build to the edit for slug " + PUBLISH_SLUG + " — no in-flight build with a new agent_id appeared in the poll window and no new audit dir landed. The edit may have been accepted as pending_init, so no retry was issued: a blind retry duplicated the edit on 2026-09-12. The attempt is recorded in the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl (commit " + String(mergeCommitForPublish || "unknown").slice(0, 12) + "). Correlate the accepted edit via the ledger and the builder's eventual completion — do NOT reissue the edit blindly. Verify independently whether the build completed before deciding the next step. Fail-closed.");
1705
1928
  }
1706
1929
  }
1707
1930
 
@@ -1714,8 +1937,9 @@ while (i < STEPS.length) {
1714
1937
  // already recorded the ledger's submitted line on both positive paths
1715
1938
  // and parked on unknown — there is no applied report to observe and
1716
1939
  // no rejection signal to record.
1717
- var publishFailure = null;
1718
- if (rebuildTrigger.edit_started) {
1940
+ // (publishFailure is declared with the immediate audit fallback
1941
+ // above so an explicit build failure there survives to here.)
1942
+ if (rebuildTrigger.edit_started && !skipReceiptPoll) {
1719
1943
  // (2026-09-16) There is no builder report: the fire-and-forget
1720
1944
  // trigger carries no JSON contract, so there is nothing to
1721
1945
  // compare and no pre-hash diagnostic. The builder's old
@@ -1816,10 +2040,11 @@ while (i < STEPS.length) {
1816
2040
  // the old report check was circular — a fabricated report
1817
2041
  // passed by construction, and every phase went green on a hollow
1818
2042
  // build. The stamp moves to the parent (docs/publish-verification.md);
1819
- // the independent read-back step is currently unavailable (no
1820
- // agent-callable read-back tool exists — artifact_inspect was
1821
- // removed by the platform 2026-09-14), so the parent cannot
1822
- // confirm content and the task parks for verification.
2043
+ // the deterministic lib/readback-disk.js is the primary sensor
2044
+ // (the agent-callable read-back tool is unavailable —
2045
+ // artifact_inspect was removed by the platform 2026-09-14 — so
2046
+ // the LLM-inspector path is manual-fallback only), and the task
2047
+ // parks for parent verification.
1823
2048
  // QA's provenance check enforces the stamp mechanically.
1824
2049
  // An unverified publish fails loudly in QA instead of passing
1825
2050
  // silently here.
@@ -1862,26 +2087,17 @@ while (i < STEPS.length) {
1862
2087
  schema: { type: "object", properties: { dirs: { type: "string" } }, required: ["dirs"] } }
1863
2088
  );
1864
2089
  var auditDirsAfterPollList = String((auditAfterPoll && auditAfterPoll.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
1865
- newAuditDirsAfterPoll = auditDirsAfterPollList.filter(function (d) {
2090
+ // Gated on auditBeforeOk (critic finding 4): without a baseline
2091
+ // every historical dir would look new.
2092
+ newAuditDirsAfterPoll = auditBeforeOk ? auditDirsAfterPollList.filter(function (d) {
1866
2093
  return auditDirsBeforeTrigger.indexOf(d) === -1 && /^20\d\d-\d\d-\d\dT\d\d-\d\d-\d\dZ-/.test(d);
1867
- });
2094
+ }) : [];
1868
2095
  log("Publish audit-dir re-list after build poll for task " + taskId + ": " + newAuditDirsAfterPoll.length + " new timestamped dir(s)");
1869
2096
  } catch (auditAfterPollErr) {
1870
2097
  log("Publish audit-dir re-list after build poll failed for task " + taskId + " (non-fatal, durable-evidence check degraded): " + (auditAfterPollErr && auditAfterPollErr.message ? auditAfterPollErr.message : auditAfterPollErr));
1871
2098
  }
1872
- // auditReportOk: pure tri-state read of a report.json body —
1873
- // true (build ok), false (build failed), null (missing or
1874
- // unreadable — not evidence either way). The child returns the
1875
- // raw body verbatim; interpretation lives here, never in prose.
1876
- var auditReportOk = function (raw) {
1877
- if (typeof raw !== "string") return null;
1878
- var trimmed = raw.trim();
1879
- if (trimmed === "" || trimmed === "MISSING") return null;
1880
- var parsed;
1881
- try { parsed = JSON.parse(trimmed); } catch (e) { return null; }
1882
- if (parsed && typeof parsed.ok === "boolean") return parsed.ok;
1883
- return null;
1884
- };
2099
+ // The shared auditReportOk (defined with the immediate fallback
2100
+ // above) interprets the raw body here too.
1885
2101
  var auditOkAfterPoll = null;
1886
2102
  var newestAuditDirAfterPoll = null;
1887
2103
  if (newAuditDirsAfterPoll.length > 0 && !strangerObserved) {
@@ -1942,7 +2158,7 @@ while (i < STEPS.length) {
1942
2158
  } else {
1943
2159
  // Unreachable: the observation above either attributes the edit
1944
2160
  // (edit_started) or parks. Defensive only — never a silent pass.
1945
- publishFailure = "Artifact rebuild trigger failed: the edit was not attributed to any observed build. The publish did not land.";
2161
+ publishFailure = "Artifact rebuild trigger failed: the edit was not attributed to any observed build. The publish is unattributed (not proven landed, not proven failed) — provenance was not stamped. Fail-closed.";
1946
2162
  }
1947
2163
  } // end: publishSkippedNoLock — no rebuild, no stamp, nothing to ship
1948
2164
  // STEP 2 (mechanical, always — skip path included): post-deploy
@@ -2114,6 +2330,35 @@ while (i < STEPS.length) {
2114
2330
  "Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your report here\"}. " +
2115
2331
  "The result is plain prose describing what you did and found. For verdict steps, end the report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
2116
2332
  var workKeyBase = "work-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "");
2333
+ // Reproduce wrong-layer guard baseline (2026-09-17): snapshot the repro
2334
+ // evidence dir listing BEFORE the Reproduce work agent runs. The guard
2335
+ // after the run diffs against this baseline — only frames created by the
2336
+ // current attempt count as a wrong-layer violation; stale frames from
2337
+ // pre-fix attempts appear in both snapshots and are excluded (set diff,
2338
+ // no wall-clock, deterministic). One snapshot per phase attempt covers
2339
+ // all transport retries inside the dispatch loop. null = baseline
2340
+ // unavailable; the guard then fails closed for retry.
2341
+ var reproEvidenceDir = crewHome + "/task-evidence/" + taskId + "/repro";
2342
+ var reproFramesBefore = null; // null = baseline unavailable (guard must fail closed)
2343
+ if (step.name === "Reproduce" && (reproLayer === "engine" || reproLayer === "docs")) {
2344
+ try {
2345
+ var reproBefore = await agent(
2346
+ "Run: ls " + reproEvidenceDir + " 2>/dev/null | grep -E -- '-(shot|click|scroll|type)-' || echo NONE\n" +
2347
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
2348
+ { key: attemptKey("repro-layer-guard-before-" + taskId, totalReworkCount),
2349
+ label: "Snapshotting repro evidence before Reproduce attempt",
2350
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
2351
+ );
2352
+ var reproBeforeOut = (reproBefore && reproBefore.output ? reproBefore.output : "").trim();
2353
+ reproFramesBefore = (reproBeforeOut === "NONE" || reproBeforeOut === "")
2354
+ ? []
2355
+ : reproBeforeOut.split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
2356
+ log("Reproduce guard baseline for task " + taskId + ": " + reproFramesBefore.length + " pre-existing frame(s)");
2357
+ } catch (e) {
2358
+ reproFramesBefore = null;
2359
+ log("Reproduce guard baseline snapshot failed for task " + taskId + " — guard will fail closed for retry");
2360
+ }
2361
+ }
2117
2362
  var workerResult = null;
2118
2363
  var workAttempts = [];
2119
2364
  for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
@@ -2315,6 +2560,91 @@ while (i < STEPS.length) {
2315
2560
  log("QA visual-loop guard passed: experiential evidence present");
2316
2561
  }
2317
2562
 
2563
+ // Reproduce wrong-layer guard (2026-09-16): for engine/docs tasks the
2564
+ // experiential see-act loop is forbidden — a reproducer that drives the
2565
+ // browser burns the run and emits a wrong-layer verdict (three tasks
2566
+ // parked on exactly this in the 2026-09-17 UTC shift). The workflow
2567
+ // checks the repro evidence dir mechanically for see-act frame artifacts
2568
+ // (the driver's archive naming: <n>-<action>-<desktop|mobile>.png).
2569
+ // Attempt-scoped (2026-09-17): the after-listing is diffed against the
2570
+ // baseline taken before this phase attempt's dispatch loop — only frames
2571
+ // CREATED BY the current attempt count as a violation; stale frames from
2572
+ // earlier attempts appear in both snapshots and are excluded. Without a
2573
+ // baseline the guard is inconclusive and fails closed for retry (passing
2574
+ // could route a genuinely wrong-layer verdict to Map).
2575
+ if (step.name === "Reproduce" && (reproLayer === "engine" || reproLayer === "docs") && verdictPassed !== null) {
2576
+ var reproEvidenceDir = crewHome + "/task-evidence/" + taskId + "/repro";
2577
+ var reproFramesOut = "";
2578
+ try {
2579
+ var reproFramesCheck = await agent(
2580
+ "Run: ls " + reproEvidenceDir + " 2>/dev/null | grep -E -- '-(shot|click|scroll|type)-' || echo NONE\n" +
2581
+ "Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
2582
+ { key: attemptKey("repro-layer-guard-" + taskId, totalReworkCount), label: "Checking for wrong-layer driver invocation",
2583
+ schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
2584
+ );
2585
+ reproFramesOut = (reproFramesCheck && reproFramesCheck.output ? reproFramesCheck.output : "").trim();
2586
+ } catch (e) {
2587
+ reproFramesOut = "";
2588
+ }
2589
+ var reproAfterList = (reproFramesOut && reproFramesOut !== "NONE")
2590
+ ? reproFramesOut.split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; })
2591
+ : [];
2592
+ var reproGuardFailKind = null; // "wrong-layer" | "baseline-unavailable" | null
2593
+ var reproGuardDetail = "";
2594
+ if (reproFramesBefore === null) {
2595
+ reproGuardFailKind = "baseline-unavailable";
2596
+ } else {
2597
+ var reproBeforeSet = new Set(reproFramesBefore);
2598
+ var reproNewFrames = reproAfterList.filter(function (f) { return !reproBeforeSet.has(f); });
2599
+ if (reproNewFrames.length > 0) {
2600
+ reproGuardFailKind = "wrong-layer";
2601
+ reproGuardDetail = reproNewFrames.slice(0, 5).join(", ").slice(0, 300);
2602
+ }
2603
+ }
2604
+ if (reproGuardFailKind !== null) {
2605
+ var reproGuardNotes, reproGuardMessage;
2606
+ if (reproGuardFailKind === "baseline-unavailable") {
2607
+ log("Reproduce wrong-layer guard failed (baseline unavailable) — cannot attribute frames to this attempt on task " + taskId + " — marking failed for retry");
2608
+ reproGuardNotes = "Reproduce wrong-layer guard baseline unavailable: task is classified layer: " + reproLayer + " and the before-snapshot of " + reproEvidenceDir + " failed, so frames cannot be attributed to this attempt (fail-closed — passing could route a wrong-layer verdict to Map). Phase failed for retry; the retry re-snapshots";
2609
+ reproGuardMessage = "Reproduce wrong-layer guard baseline unavailable for a " + reproLayer + "-layer task — frames in " + reproEvidenceDir + " cannot be attributed to this attempt, phase failed, dispatcher will retry Reproduce";
2610
+ } else {
2611
+ log("Reproduce wrong-layer guard failed — see-act frames from THIS attempt on a " + reproLayer + "-layer task " + taskId + " (" + reproGuardDetail + ") — marking failed for retry");
2612
+ reproGuardNotes = "Reproduce wrong-layer guard failed: task is classified layer: " + reproLayer + ", but the experiential see-act browser loop was invoked (frames attributable to this attempt in " + reproEvidenceDir + ": " + reproGuardDetail + "). Engine bugs reproduce with deterministic repo commands, docs bugs by reading the file — the browser loop is for artifact-layer bugs only. Phase failed for retry";
2613
+ reproGuardMessage = "Reproduce wrong-layer guard failed — see-act loop invoked on a " + reproLayer + "-layer task (" + reproGuardDetail + " attributable to this attempt), phase failed, dispatcher will retry Reproduce";
2614
+ }
2615
+ await agent(
2616
+ "Record wrong-layer driver invocation.\n" +
2617
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2618
+ task_id: taskId,
2619
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name,
2620
+ status: "failed", notes: reproGuardNotes },
2621
+ event: { task_id: taskId, type: "failed",
2622
+ message: reproGuardMessage }
2623
+ }),
2624
+ { key: "record-repro-layer-guard-fail-" + step.name, label: "Recording wrong-layer guard failure" }
2625
+ );
2626
+ if (reproGuardFailKind === "baseline-unavailable") {
2627
+ return {
2628
+ __hatchWorkflowControl: "blocked",
2629
+ result: {
2630
+ "blocked_reason": "Reproduce wrong-layer guard baseline unavailable — cannot attribute frames to this attempt; retrying",
2631
+ message: reproGuardMessage,
2632
+ task_id: taskId
2633
+ }
2634
+ };
2635
+ }
2636
+ return {
2637
+ __hatchWorkflowControl: "blocked",
2638
+ result: {
2639
+ "blocked_reason": "Reproduce invoked the experiential browser loop on a " + reproLayer + "-layer task",
2640
+ message: reproGuardMessage,
2641
+ task_id: taskId
2642
+ }
2643
+ };
2644
+ }
2645
+ log("Reproduce wrong-layer guard passed: no see-act frames from this attempt on " + reproLayer + "-layer task " + taskId);
2646
+ }
2647
+
2318
2648
 
2319
2649
  // Worktree confinement (Build only): the declared worktree path must
2320
2650
  // match WORKTREE_HINT exactly. A builder that worked in any other
@@ -2453,16 +2783,24 @@ while (i < STEPS.length) {
2453
2783
  // Skip-aware (park 2026-09-11): when the deterministic publish script found
2454
2784
  // no merge lock held (empty-diff Integrate), it skips the publish path
2455
2785
  // gracefully and emits the machine-readable PUBLISH_SKIPPED=no-lock-held
2456
- // marker. Verification is then vacuous — nothing was shipped, and the
2786
+ // marker. The preflight (bugfix 2026-09-17) emits
2787
+ // PUBLISH_SKIPPED=no-npm-publish when npm publish is not configured on
2788
+ // this machine (helper or credential absent) — also before any mutation.
2789
+ // Verification is then vacuous — nothing was shipped, and the
2457
2790
  // registry must NOT have moved. The marker is script-emitted explicit state
2458
2791
  // (pasted verbatim per the Publish agent instructions), not agent prose; a
2459
2792
  // report without the marker still runs the full verification fail-closed.
2460
2793
  var publishVerified = false;
2461
2794
  var npmPublishSkipped = false;
2462
2795
  if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
2463
- if (/^PUBLISH_SKIPPED=no-lock-held$/m.test(workerText || "")) {
2796
+ var publishSkipMatch = /^PUBLISH_SKIPPED=(no-lock-held|no-npm-publish)$/m.exec(workerText || "");
2797
+ if (publishSkipMatch) {
2464
2798
  npmPublishSkipped = true;
2465
- log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): publish verification vacuous, nothing was shipped");
2799
+ if (publishSkipMatch[1] === "no-npm-publish") {
2800
+ log("Publish skipped for task " + taskId + " (no-npm-publish — npm publish not configured): publish verification vacuous, nothing was shipped");
2801
+ } else {
2802
+ log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): publish verification vacuous, nothing was shipped");
2803
+ }
2466
2804
  }
2467
2805
  if (!npmPublishSkipped) {
2468
2806
  try {
@@ -2582,6 +2920,10 @@ while (i < STEPS.length) {
2582
2920
  // Capture Sage's experiential flag (machine-read marker line).
2583
2921
  if (step.name === "Triage" && passed) {
2584
2922
  isExperiential = extractExperiential(workerText);
2923
+ // Capture Sage's bug-layer flag (machine-read marker line): drives the
2924
+ // Reproduce strategy dispatch. Unknown (null) degrades to "artifact" at
2925
+ // resolveLayer — never parks on a garbled line.
2926
+ bugLayer = extractLayer(workerText);
2585
2927
  }
2586
2928
 
2587
2929
  // Capture the accepted Build report's machine-readable release decision.