muse-crew 0.12.0 → 0.13.1

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.
@@ -91,13 +91,18 @@ const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
91
91
  const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
92
92
  const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
93
93
  const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
94
- // The six basenames the pin step must materialize — asserted mechanically
94
+ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
95
+ const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
96
+ // The seven basenames the pin step must materialize — asserted mechanically
95
97
  // by workflow code from the verbatim listing, never from agent prose.
96
98
  // COMPUTE_DIFF is the deterministic publish-diff computer (room #14,
97
99
  // 2026-09-17): the diff is computed by this script, never ferried as an
98
100
  // agent JSON string. Pinned like the other publish-critical modules so a
99
101
  // mid-run release swap cannot change it under the workflow.
100
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF].map(function (p) { return p.split("/").pop(); });
102
+ // CLASSIFY_SURFACE is the surface classifier (room #15, 2026-09-18):
103
+ // crew-api.js statically imports it, so the pin must carry it — a pin
104
+ // without it kills every claim with ERR_MODULE_NOT_FOUND.
105
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
101
106
 
102
107
  // Project config — passed by dispatcher, falls back to dashboard defaults
103
108
  const projectConfig = inputs.project_config || {};
@@ -281,7 +286,7 @@ function attemptKey(base, reworkCount) {
281
286
  function pinLifecycle(key) {
282
287
  return agent(
283
288
  "Snapshot lifecycle scripts for version pinning.\n" +
284
- "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 + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
289
+ "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 + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
285
290
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
286
291
  { key: key, label: "Pinning lifecycle scripts",
287
292
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -556,14 +561,34 @@ function extractMarkerLines(workerText) {
556
561
  // builder correctly makes no commit because the deliverable is already on
557
562
  // main (a prior merge or hand-repair landed it), it declares
558
563
  // `repo_diff: none (already-merged: <sha>)` naming the main commit that
559
- // carries the work. The sha is hex-only (7-40 chars) so the workflow can
560
- // interpolate it into the mechanical ancestor check without injection
561
- // risk. Pure — pinned byte-identical across standard/bugfix/chore.
564
+ // carries the work. Room #16 blocker 11 (2026-09-18): the line anchor
565
+ // missed Wren's mid-paragraph declaration, and the persisted notes truncated
566
+ // the tail — so the anchor is gone and a sha followed by `)`, whitespace, or
567
+ // end-of-string (truncation) is accepted. The sha is hex-only (7-40 chars)
568
+ // so the workflow can interpolate it into the mechanical ancestor check
569
+ // without injection risk; a over-long hex run never matches (the lookahead
570
+ // fails on the extra hex char). Pure — pinned byte-identical across
571
+ // standard/bugfix/chore.
562
572
  function extractAlreadyMerged(workerText) {
563
- var m = /^repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})\)/im.exec(workerText || "");
573
+ var m = /repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})(?=[\s)]|$)/i.exec(workerText || "");
564
574
  return m ? { sha: m[1].toLowerCase() } : { sha: null };
565
575
  }
566
576
 
577
+ // Explicit artifact refusal (room #16 blocker 10, 2026-09-18): the rebuild
578
+ // trigger child ends its turn with `ARTIFACT_EDIT_REFUSED: <text>` when
579
+ // artifact_edit explicitly refuses the edit (e.g. the artifact does not
580
+ // exist). A refusal is conclusive negative evidence — the edit provably did
581
+ // NOT go through — distinct from an unconsumed trigger return (unknown).
582
+ // Pure — pinned byte-identical across standard/bugfix/chore.
583
+ // The signal must be the ENTIRE trimmed turn output (not a line within prose):
584
+ // the trigger child is instructed to end its turn with exactly this line and
585
+ // nothing else. A confused child quoting the instructions back in prose must
586
+ // NOT produce a conclusive negative — that degrades to unknown (fail-closed).
587
+ function extractRefusal(workerText) {
588
+ var m = /^ARTIFACT_EDIT_REFUSED:\s*(.+?)\s*$/.exec(String(workerText || "").trim());
589
+ return m ? m[1].slice(0, 300) : null;
590
+ }
591
+
567
592
  // Worktree confinement: the Build agent must declare the exact worktree
568
593
  // path it built in on a `worktree:` marker line. The workflow compares it
569
594
  // against WORKTREE_HINT mechanically (exact string match) — never by
@@ -1254,23 +1279,40 @@ while (i < STEPS.length) {
1254
1279
  } else if (step.name === "Review") {
1255
1280
  // Already-merged hydration: when this run did not execute Build itself
1256
1281
  // (dispatcher resume at Review after a platform death between phases),
1257
- // recover the workflow-attested verification from the latest completed
1258
- // Build session notes. The `already_merged_verified:` line was written
1259
- // by the workflow after a mechanical ancestor check — it is trusted;
1260
- // the builder's bare declaration never is. Absent the line, the
1261
- // mechanical fact below reads "none declared" and Cass fails closed.
1282
+ // recover the workflow-verified sha. Room #16 blocker 11: the structured
1283
+ // session field is read FIRST — the `already_merged_verified:` notes line
1284
+ // is only a fallback, because session notes are hard-capped at 3000
1285
+ // chars and a truthful declaration at the report's tail was silently
1286
+ // truncated. The structured value was written by the workflow after a
1287
+ // mechanical ancestor check — it is trusted; the builder's bare
1288
+ // declaration never is. Absent both, the mechanical fact below reads
1289
+ // "none declared" and Cass fails closed. The hydration read is best-effort:
1290
+ // a transport throw degrades to "none declared" rather than crashing Review.
1262
1291
  if (!alreadyMergedSha) {
1263
- var hydNotes = await agent(
1264
- "Read the latest completed Build session notes for task " + taskId + ".\n" +
1265
- "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1266
- "In the returned sessions array, find the most recent session (by started_at) with task_id \"" + taskId + "\", step \"Build\", and status \"completed\". Return ONLY its notes field, verbatim, with no commentary.",
1267
- { key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
1268
- );
1269
- var hydStr = (typeof hydNotes === "string") ? hydNotes : JSON.stringify(hydNotes);
1270
- var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
1271
- if (hvm) {
1272
- alreadyMergedSha = hvm[1].toLowerCase();
1273
- log("Hydrated already-merged verification from Build session notes: " + alreadyMergedSha);
1292
+ var hydResult = null;
1293
+ try {
1294
+ hydResult = await agent(
1295
+ "Read the latest completed Build session for task " + taskId + ".\n" +
1296
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1297
+ "In the returned sessions array, find the most recent session (by started_at) with task_id \"" + taskId + "\", step \"Build\", and status \"completed\". Return exactly two sections, verbatim, with no commentary:\n" +
1298
+ "SHA: <the session's already_merged_sha field value, or the word null when it is null>\n" +
1299
+ "NOTES:\n<the session's notes field, verbatim>",
1300
+ { key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
1301
+ );
1302
+ } catch (hydErr) {
1303
+ log("Hydration read failed (" + String(hydErr && hydErr.message || hydErr) + "); treating as none declared.");
1304
+ }
1305
+ var hydStr = hydResult ? ((typeof hydResult === "string") ? hydResult : JSON.stringify(hydResult)) : "";
1306
+ var hydSha = /^SHA:\s*([0-9a-f]{7,40})\s*$/im.exec(hydStr);
1307
+ if (hydSha) {
1308
+ alreadyMergedSha = hydSha[1].toLowerCase();
1309
+ log("Hydrated already-merged verification from structured session field: " + alreadyMergedSha);
1310
+ } else {
1311
+ var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
1312
+ if (hvm) {
1313
+ alreadyMergedSha = hvm[1].toLowerCase();
1314
+ log("Hydrated already-merged verification from Build session notes (fallback): " + alreadyMergedSha);
1315
+ }
1274
1316
  }
1275
1317
  }
1276
1318
  instructions = "Review independently and cold. You have NOT seen any reasoning from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
@@ -1413,6 +1455,85 @@ while (i < STEPS.length) {
1413
1455
  // Skipped entirely when no lock was held — nothing merged, nothing
1414
1456
  // to ship.
1415
1457
  if (!publishSkippedNoLock) {
1458
+ // The trigger key of the attempt that last ran, for the publish ledger.
1459
+ // Minted once here (not re-minted per use site) so the ledger always
1460
+ // records the exact key that was issued — and so a re-minted duplicate
1461
+ // can never drift from it. Defined before the preflight so pre-trigger
1462
+ // parks (room #16 blocker 10) record the same attempt key.
1463
+ var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount);
1464
+ // STEP 0.5 (mechanical, room #16 blocker 10): assert the artifact
1465
+ // target exists before any artifact_status / artifact_edit call. The
1466
+ // project was classified as an artifact surface (deploy_slug set),
1467
+ // but setup never provisioned the artifact — Publish then entered
1468
+ // the trigger path against a slug with no on-disk target and the
1469
+ // edit failed opaquely ("web artifact <slug> was not found on
1470
+ // disk"), which the ledger could only record as unknown. A missing
1471
+ // target is conclusive negative evidence: the edit provably did NOT
1472
+ // go through, so this parks rejected (not unknown) with the actual
1473
+ // missing path — no trigger issued, no blind retry, no observation
1474
+ // polling. The check is a pure filesystem stat; the path is
1475
+ // workflow-computed, never agent prose. An inconclusive check
1476
+ // (throw / unparseable signal) is fail-closed unknown: without
1477
+ // proof the target exists, no edit is issued. The slug is
1478
+ // interpolated into a shell command — a slug outside [a-zA-Z0-9_-]
1479
+ // (e.g. from a hand-edited space.json) is treated as inconclusive
1480
+ // rather than risking shell injection.
1481
+ var artifactTargetDir = "~/workspace/ts-spaces/" + PUBLISH_SLUG + "/";
1482
+ var preflightSignal = "";
1483
+ var preflightInconclusive = false;
1484
+ // Misconfiguration fast path: artifact surface with no slug is not a
1485
+ // signal problem — it's a project setup defect. Park rejected with a
1486
+ // truthful reason, not "inconclusive."
1487
+ if (!PUBLISH_SLUG) {
1488
+ await recordPublishLedger({
1489
+ commit: mergeCommitForPublish,
1490
+ attempt: rebuildAttemptKey,
1491
+ agent_id: null,
1492
+ applied_report: null,
1493
+ outcome: "rejected",
1494
+ detail: "artifact surface with empty deploy_slug (preflight): the project is classified as artifact but has no deploy_slug — misconfiguration, not a missing artifact. No edit was issued."
1495
+ }, totalReworkCount);
1496
+ return await parkTask("Publish cannot proceed for task " + taskId + ": the project is classified as an artifact surface but has no deploy_slug. This is a project configuration defect — set a deploy_slug for the project, then re-run Publish. Human attention needed.");
1497
+ }
1498
+ if (!/^[a-zA-Z0-9_-]+$/.test(PUBLISH_SLUG)) {
1499
+ preflightInconclusive = true;
1500
+ log("Publish artifact preflight for task " + taskId + ": PUBLISH_SLUG has an unsafe shape — inconclusive, fail-closed");
1501
+ } else try {
1502
+ var preflight = await agent(
1503
+ "Check whether the artifact target directory exists.\n" +
1504
+ "Run in shell: test -d ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/ && echo ARTIFACT_TARGET: present || echo ARTIFACT_TARGET: missing\n" +
1505
+ "Return JSON { \"signal\": \"<the exact ARTIFACT_TARGET line>\" } and nothing else.",
1506
+ { key: attemptKey("publish-artifact-preflight-" + taskId, totalReworkCount), label: "Checking artifact target exists",
1507
+ schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] } }
1508
+ );
1509
+ preflightSignal = String((preflight && preflight.signal) || "");
1510
+ } catch (preflightErr) {
1511
+ preflightInconclusive = true;
1512
+ log("Publish artifact preflight for task " + taskId + " threw (" + (preflightErr && preflightErr.message ? preflightErr.message : preflightErr) + ") — inconclusive, fail-closed");
1513
+ }
1514
+ if (!preflightInconclusive && /ARTIFACT_TARGET:\s*missing/.test(preflightSignal)) {
1515
+ await recordPublishLedger({
1516
+ commit: mergeCommitForPublish,
1517
+ attempt: rebuildAttemptKey,
1518
+ agent_id: null,
1519
+ applied_report: null,
1520
+ outcome: "rejected",
1521
+ detail: "artifact target directory missing (preflight): " + artifactTargetDir + " does not exist — setup never provisioned the artifact for deploy_slug " + PUBLISH_SLUG + ". The edit provably did not go through: no trigger issued, no blind retry"
1522
+ }, totalReworkCount);
1523
+ return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target directory " + artifactTargetDir + " does not exist. The project is classified as an artifact surface (deploy_slug " + PUBLISH_SLUG + ") but setup never provisioned the artifact — this is conclusive (rejected, not unknown): no edit was issued. Create the artifact via the Muse UI (Publish edits an existing artifact; it never creates one), then re-run init and Publish. Human attention needed.");
1524
+ }
1525
+ if (preflightInconclusive || !/ARTIFACT_TARGET:\s*present/.test(preflightSignal)) {
1526
+ await recordPublishLedger({
1527
+ commit: mergeCommitForPublish,
1528
+ attempt: rebuildAttemptKey,
1529
+ agent_id: null,
1530
+ applied_report: null,
1531
+ outcome: "unknown",
1532
+ detail: "artifact preflight inconclusive (no parsable ARTIFACT_TARGET signal): target existence unproven, so the trigger was NOT issued; unknown parks fail closed with no blind retry"
1533
+ }, totalReworkCount);
1534
+ return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target preflight was inconclusive (no parsable signal). Target existence is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.");
1535
+ }
1536
+ log("Publish artifact preflight for task " + taskId + ": target " + artifactTargetDir + " present");
1416
1537
  // (below) the diff computation, rebuild trigger, application
1417
1538
  // verification, bounded poll, and provenance stamp. The builder
1418
1539
  // only makes the artifact_edit call and reports the applied
@@ -1429,7 +1550,7 @@ while (i < STEPS.length) {
1429
1550
  // first publish (no provenance stamped yet).
1430
1551
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1431
1552
  var provResult = await agent(
1432
- crewCmd("get-provenance", {}) + "\n" +
1553
+ crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
1433
1554
  "Return JSON { \"provenance\": <the CLI's provenance object, or null when nothing is stamped> } and nothing else. Do not interpret it.",
1434
1555
  { key: attemptKey("publish-provenance-base-" + taskId, totalReworkCount), label: "Reading stamped publish base",
1435
1556
  schema: { type: "object", properties: { provenance: { type: ["object", "null"] } }, required: ["provenance"] } }
@@ -1523,14 +1644,10 @@ while (i < STEPS.length) {
1523
1644
  "- After applying, rebuild and deploy.'\n" +
1524
1645
  "Edit-request contract (read carefully):\n" +
1525
1646
  "- Call artifact_edit exactly once with the slug and verbatim_request above. Never retry the edit yourself: if the edit is not accepted, do NOT call artifact_edit again — end your turn.\n" +
1647
+ "- If artifact_edit explicitly refuses the edit (the call is rejected — e.g. the artifact does not exist), do NOT call artifact_edit again: end your turn with exactly one line and nothing else: ARTIFACT_EDIT_REFUSED: <the refusal text, one line>.\n" +
1526
1648
  "- If artifact_edit is not available after the load, do NOT improvise — end your turn.\n" +
1527
1649
  "- You do NOT call setprovenance, artifact_inspect, or post-deploy yourself.\n" +
1528
1650
  "No report is needed: do not return JSON, do not summarize what you did, do not echo the diff. End your turn after the artifact_edit call.\n";
1529
- // The trigger key of the attempt that last ran, for the publish ledger.
1530
- // Minted once here (not re-minted per use site) so the ledger always
1531
- // records the exact key that was issued — and so a re-minted duplicate
1532
- // can never drift from it.
1533
- var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount);
1534
1651
  // The artifact build's agent_id, attributed to this edit by the
1535
1652
  // workflow-owned observation below. The agent_id is the artifact
1536
1653
  // system's in-flight correlation ID (research 2026-09-12):
@@ -1696,9 +1813,27 @@ while (i < STEPS.length) {
1696
1813
  // re-trigger duplicated the edit on 2026-09-12).
1697
1814
  var rebuildTrigger = null;
1698
1815
  try {
1699
- var triggerResultLength = String(await agent(rebuildPrompt,
1700
- { key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "").length;
1701
- log("Publish rebuild trigger for task " + taskId + " returned (" + triggerResultLength + " chars; awaited but return intentionally unconsumed)");
1816
+ var triggerText = String(await agent(rebuildPrompt,
1817
+ { key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
1818
+ log("Publish rebuild trigger for task " + taskId + " returned (" + triggerText.length + " chars; awaited; scanned only for the explicit refusal signal)");
1819
+ // Explicit refusal (room #16 blocker 10): the child ends its turn
1820
+ // with ARTIFACT_EDIT_REFUSED when artifact_edit explicitly refused.
1821
+ // Conclusive negative evidence — the edit provably did NOT go
1822
+ // through — so this parks rejected and skips observation polling.
1823
+ // A missing/unparseable signal is NOT a refusal: it stays unknown
1824
+ // and fail-closed below.
1825
+ var refusalText = extractRefusal(triggerText);
1826
+ if (refusalText) {
1827
+ await recordPublishLedger({
1828
+ commit: mergeCommitForPublish,
1829
+ attempt: rebuildAttemptKey,
1830
+ agent_id: null,
1831
+ applied_report: null,
1832
+ outcome: "rejected",
1833
+ detail: "artifact_edit explicitly refused the edit (parsed ARTIFACT_EDIT_REFUSED signal): " + refusalText + " — conclusive negative: the edit provably did not go through, no observation polling, no blind retry"
1834
+ }, totalReworkCount);
1835
+ return await parkTask("Publish cannot proceed for task " + taskId + ": artifact_edit explicitly refused the edit (" + refusalText + "). This is conclusive (rejected, not unknown): the edit did not go through. Repair or provision the artifact target, then re-run Publish. Human attention needed.");
1836
+ }
1702
1837
  } catch (triggerErr) {
1703
1838
  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");
1704
1839
  }
@@ -2226,7 +2361,7 @@ while (i < STEPS.length) {
2226
2361
  "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
2227
2362
  "Use the returned tasks, sessions, and events to check the task's data-level effects.\n" +
2228
2363
  "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
2229
- "PROVENANCE CHECK: Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
2364
+ "PROVENANCE CHECK: Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
2230
2365
  "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
2231
2366
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
2232
2367
  "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from get-provenance] not found in release registry\" }.\n" +
@@ -2703,11 +2838,11 @@ while (i < STEPS.length) {
2703
2838
  // dashboard QA source check).
2704
2839
  try {
2705
2840
  var provRefresh = await agent(
2706
- "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
2841
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
2707
2842
  "If the response has no provenance (null), return JSON { \"refreshed\": false, \"reason\": \"no-record\" } and stop. " +
2708
2843
  "Otherwise run: basename $(readlink " + crewHome + "/current) — call this REL; " +
2709
2844
  "run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS. " +
2710
- "Then run in shell:\n" + crewCmd("set-provenance", { source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
2845
+ "Then run in shell:\n" + crewCmd("set-provenance", { project_id: LAUNCH_PROJECT_ID, source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
2711
2846
  "(substitute the real existing source_commit, REL, and TS for the placeholders). " +
2712
2847
  "Return JSON { \"refreshed\": <true if the set-provenance stdout contains ok: true, false otherwise>, \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
2713
2848
  { key: attemptKey("publish-provenance-refresh-" + taskId, totalReworkCount), label: "Refreshing dashboard provenance after crew release",
@@ -2813,7 +2948,11 @@ while (i < STEPS.length) {
2813
2948
  "Update the session and log the event.\n" +
2814
2949
  "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2815
2950
  task_id: taskId,
2816
- session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary },
2951
+ // Room #16 blocker 11: the workflow-verified already-merged sha as
2952
+ // structured control state. Only the Build gate sets alreadyMergedSha
2953
+ // (after the mechanical ancestor check); the API validates the shape
2954
+ // and a later write without the field never clears it (COALESCE).
2955
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary, already_merged_sha: (step.name === "Build" ? alreadyMergedSha : null) },
2817
2956
  event: { task_id: taskId, type: status, identity: step.identity, message: step.name + " " + status + " by " + step.identity }
2818
2957
  }),
2819
2958
  {
@@ -2830,6 +2969,15 @@ while (i < STEPS.length) {
2830
2969
  return await parkTask("Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after " + step.name + " rejection. Worktree preserved.");
2831
2970
  }
2832
2971
  rejectionNotes = summary;
2972
+ // Already-merged corrective (room #16 blocker 11): when Review rejected
2973
+ // an empty branch but the work is already on main (the workflow verified
2974
+ // the sha), Wren must declare it — not re-implement or re-commit
2975
+ // already-landed work. Scoped to the empty-branch rejection; any other
2976
+ // rejection already carries its own specific notes.
2977
+ if (step.name === "Review" && alreadyMergedSha && /no commits ahead of main/i.test(summary)) {
2978
+ rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on main — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of main. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
2979
+ log("Rework corrective appended for task " + taskId + ": already-merged " + alreadyMergedSha + " — Wren must declare, not rebuild");
2980
+ }
2833
2981
  i = BUILD_INDEX;
2834
2982
  log(step.name + " rejected — bouncing to Build (rework #" + totalReworkCount + " of " + MAX_TOTAL_REWORK + ")");
2835
2983
  continue;
@@ -157,9 +157,14 @@ const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
157
157
  const CREW_API_PINNED = RUN_LIB + "/crew-api.js";
158
158
  const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
159
159
  const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
160
- // The five basenames the pin step must materialize — asserted mechanically
160
+ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
161
+ const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
162
+ // The six basenames the pin step must materialize — asserted mechanically
161
163
  // by workflow code from the verbatim listing, never from agent prose.
162
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED].map(function (p) { return p.split("/").pop(); });
164
+ // CLASSIFY_SURFACE is the surface classifier (room #15, 2026-09-18):
165
+ // crew-api.js statically imports it, so the pin must carry it — a pin
166
+ // without it kills every claim with ERR_MODULE_NOT_FOUND.
167
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
163
168
 
164
169
  // Project config — passed by dispatcher, falls back to dashboard defaults
165
170
  const projectConfig = inputs.project_config || {};
@@ -190,7 +195,7 @@ if (!taskId) {
190
195
  function pinLifecycle(key) {
191
196
  return agent(
192
197
  "Snapshot lifecycle scripts for version pinning.\n" +
193
- "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" +
198
+ "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 + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
194
199
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
195
200
  { key: key, label: "Pinning lifecycle scripts",
196
201
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }