muse-crew 0.14.6 → 0.14.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +45 -5
- package/docs/decisions/AGENTS.md +2 -0
- package/docs/decisions/publish-path.md +56 -8
- package/docs/decisions/qa-reproduce.md +30 -0
- package/lib/AGENTS.md +6 -5
- package/lib/crew-api.js +403 -7
- package/lib/merge-lock.sh +153 -41
- package/lib/qa-db.js +132 -0
- package/lib/schema.sql +50 -0
- package/lib/serve-artifact.js +46 -2
- package/lib/test-detached-integrate.sh +122 -0
- package/lib/test-merge-lock.sh +30 -1
- package/lib/worktree-lifecycle.sh +284 -34
- package/package.json +1 -1
- package/seed/cron-body-template.md +6 -6
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +434 -106
- package/workflows/chore.js +251 -108
- package/workflows/crew-dispatch.js +57 -4
- package/workflows/docs.js +24 -2
- package/workflows/standard.js +439 -111
- package/workflows/upgrade.js +13 -1
package/workflows/chore.js
CHANGED
|
@@ -101,6 +101,13 @@ async function telemetryFlush() {
|
|
|
101
101
|
}
|
|
102
102
|
async function telemetryEnd(status) {
|
|
103
103
|
if (!telemetryRunId) return;
|
|
104
|
+
// Room #26 blocker 37: record-run-end rejects any status outside the
|
|
105
|
+
// schema CHECK — and this function swallowed that throw as "non-fatal",
|
|
106
|
+
// leaving the run row 'running' forever. An invalid status is a
|
|
107
|
+
// programmer error: fail loud. Transport errors stay non-fatal.
|
|
108
|
+
if (!["completed", "failed", "parked", "timed_out"].includes(status)) {
|
|
109
|
+
throw new Error("telemetryEnd: invalid status " + JSON.stringify(status) + " — must be completed|failed|parked|timed_out");
|
|
110
|
+
}
|
|
104
111
|
// Flush any buffered events before recording the end.
|
|
105
112
|
await telemetryFlush();
|
|
106
113
|
try {
|
|
@@ -517,12 +524,6 @@ function extractMarkerLines(workerText) {
|
|
|
517
524
|
return markers.join("\n");
|
|
518
525
|
}
|
|
519
526
|
|
|
520
|
-
// See docs/decisions/publish-path.md#already-merged-idem2: idempotency for already-merged tasks.
|
|
521
|
-
function extractAlreadyMerged(workerText) {
|
|
522
|
-
var m = /repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})(?=[\s)]|$)/i.exec(workerText || "");
|
|
523
|
-
return m ? { sha: m[1].toLowerCase() } : { sha: null };
|
|
524
|
-
}
|
|
525
|
-
|
|
526
527
|
|
|
527
528
|
// See docs/decisions/qa-reproduce.md#worktree-confinement: the Build agent must declare its worktree.
|
|
528
529
|
function extractWorktree(workerText) {
|
|
@@ -676,12 +677,24 @@ let mapGateBounceCount = 0;
|
|
|
676
677
|
// rationalized a skip against explicit instruction text — text alone did not
|
|
677
678
|
// hold, so the decision now lives in workflow code, not agent judgment.
|
|
678
679
|
let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
|
|
679
|
-
// Already-merged
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
680
|
+
// Already-merged attribution (blocker 34, 2026-09-21): the sha of the
|
|
681
|
+
// task-attributed merge when classify-branch reports already-merged.
|
|
682
|
+
// Set ONLY from the classifier's mechanical output — never from agent
|
|
683
|
+
// prose. The agent-authored sha-bearing repo_diff declaration path is
|
|
684
|
+
// deleted; attribution is identity-first from git, then the task's own
|
|
685
|
+
// durable merge records.
|
|
684
686
|
let alreadyMergedSha = null;
|
|
687
|
+
// Room #26 blocker 34 (redesign, 2026-09-21): mechanical branch-state
|
|
688
|
+
// classification at Review entry. Git facts are never adjudicated by a
|
|
689
|
+
// reviewer — the workflow classifies the branch with `classify-branch`
|
|
690
|
+
// (pure git) before Cass is dispatched: "has-work",
|
|
691
|
+
// "already-merged:<sha>", or "empty-no-work". An empty branch with no
|
|
692
|
+
// verified sha and no `repo_diff: none` claim fails mechanically without
|
|
693
|
+
// dispatching Cass; reviewer prose is never parsed for git identity.
|
|
694
|
+
let branchState = null; // "has-work" | "already-merged:<sha>" | "empty-no-work"
|
|
695
|
+
let buildClaimedNoDiff = false; // Build declared plain `repo_diff: none` (no repo change — runtime-state deliverable, or believed already-merged). Same-process Build report only; there is no cross-process hydration — an empty branch on a fresh dispatch with no task-attributed merge fails mechanically.
|
|
696
|
+
let versionTouched = false; // npm: branch changed package.json's `version` (mechanical git fact, blocker-34 follow-up)
|
|
697
|
+
let branchStateErr = ""; // classifier failure detail (fail-closed grounds)
|
|
685
698
|
// Deterministic publish target — computed by the workflow (registry base +
|
|
686
699
|
// bumpVersion), never by the Publish agent.
|
|
687
700
|
let publishTarget = null; // { base, scope, target }
|
|
@@ -927,7 +940,12 @@ while (i < STEPS.length) {
|
|
|
927
940
|
);
|
|
928
941
|
if (!claimResult.claimed) {
|
|
929
942
|
log("Standing down — task " + taskId + " was already claimed by another run");
|
|
930
|
-
|
|
943
|
+
// Room #26 blocker 37: telemetryEnd only accepts schema-valid
|
|
944
|
+
// statuses ("duplicate" violated the CHECK and silently left the run
|
|
945
|
+
// row 'running' forever). The stand-down is a clean exit — the run
|
|
946
|
+
// completed without doing work; the duplicate verdict itself stays in
|
|
947
|
+
// the workflow's returned status and this log line.
|
|
948
|
+
await telemetryEnd("completed");
|
|
931
949
|
return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
|
|
932
950
|
}
|
|
933
951
|
activeSessionId = claimResult.session_id;
|
|
@@ -1121,55 +1139,126 @@ while (i < STEPS.length) {
|
|
|
1121
1139
|
"cd " + WORKTREE_HINT + "\n" +
|
|
1122
1140
|
"git add -A\n" +
|
|
1123
1141
|
"git commit -m \"chore: " + safeTitle + "\"\n\n" +
|
|
1124
|
-
"If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of the integration target and declare `repo_diff: none` in your report, naming the runtime-state deliverable. If you verified the deliverable is already on the integration target (a prior merge
|
|
1142
|
+
"If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of the integration target and declare `repo_diff: none` in your report, naming the runtime-state deliverable. If you verified the deliverable is already on the integration target (a prior merge landed it — do NOT re-implement working code), make no commit and declare `repo_diff: none` in your report. The workflow verifies the claim mechanically from git — the task's own merge records, never your declaration, are the proof of delivery. Otherwise commit your changes normally.\n\n" +
|
|
1125
1143
|
(rejectionNotes ? "REWORK after rejection. Address:\n" + rejectionNotes + "\n\n" : "") +
|
|
1126
1144
|
"Report back in plain prose: what you built and the outcome." +
|
|
1127
1145
|
(PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
|
|
1128
1146
|
|
|
1129
1147
|
} else if (step.name === "Review") {
|
|
1130
|
-
//
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1148
|
+
// Room #26 blocker 34 (redesign, 2026-09-21): classify the branch
|
|
1149
|
+
// mechanically BEFORE Cass is dispatched. The classifier is pure git
|
|
1150
|
+
// (classify-branch in the lifecycle script): has-work |
|
|
1151
|
+
// already-merged:<sha> | empty-no-work. Attribution is identity-first
|
|
1152
|
+
// from git, then the task's own durable merge records — there is no
|
|
1153
|
+
// agent-authored declared-sha input and no cross-process hydration
|
|
1154
|
+
// ferry (the re-entry case it served is covered by the durable record
|
|
1155
|
+
// → classifier directly). An unparseable result fails closed as
|
|
1156
|
+
// empty-no-work — an empty branch never passes Review silently, and
|
|
1157
|
+
// the bounce to Build gives the next round a chance to classify.
|
|
1158
|
+
try {
|
|
1159
|
+
var bsResult = await agent(
|
|
1160
|
+
"Classify the task branch state. This is a mechanical git check, not a judgment call.\n" +
|
|
1161
|
+
"Run in shell and return the stdout verbatim, then the single stderr line starting with `DIAG:` verbatim:\n" +
|
|
1162
|
+
LIFECYCLE_ENV + LIFECYCLE + " classify-branch " + taskId,
|
|
1163
|
+
{ key: "classify-branch" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Classifying branch state (mechanical)" }
|
|
1164
|
+
);
|
|
1165
|
+
var bsStr = (typeof bsResult === "string") ? bsResult : JSON.stringify(bsResult);
|
|
1166
|
+
var bsMatch = /^\s*BRANCH_STATE:\s*(has-work|already-merged:[0-9a-f]{40}|empty-no-work)\s*$/im.exec(bsStr);
|
|
1167
|
+
if (bsMatch) {
|
|
1168
|
+
branchState = bsMatch[1].toLowerCase();
|
|
1169
|
+
} else {
|
|
1170
|
+
branchStateErr = "unparseable classifier output: " + bsStr.slice(0, 120);
|
|
1144
1171
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1172
|
+
// DIAG pin (blocker 34 design v2.1): the merge-record scan is
|
|
1173
|
+
// load-bearing with no fallback — the classifier's stderr counter is
|
|
1174
|
+
// logged verbatim so silent record loss surfaces in the evidence.
|
|
1175
|
+
var bsDiagMatch = /^[ \t]*DIAG:\s*classify-branch\s+records=\d+\s+resolvable=\d+[ \t]*$/im.exec(bsStr);
|
|
1176
|
+
if (bsDiagMatch) {
|
|
1177
|
+
log(bsDiagMatch[0].trim());
|
|
1150
1178
|
} else {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1179
|
+
log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
|
|
1180
|
+
}
|
|
1181
|
+
} catch (bsErr) {
|
|
1182
|
+
branchStateErr = "classifier call failed: " + String(bsErr && bsErr.message || bsErr).slice(0, 120);
|
|
1183
|
+
}
|
|
1184
|
+
if (!branchState) {
|
|
1185
|
+
branchState = "empty-no-work";
|
|
1186
|
+
log("Branch-state classification failed (" + branchStateErr + ") — failing closed as empty-no-work");
|
|
1187
|
+
} else if (branchState.indexOf("already-merged:") === 0) {
|
|
1188
|
+
alreadyMergedSha = branchState.slice("already-merged:".length);
|
|
1189
|
+
log("Branch state already-merged: " + alreadyMergedSha + " (mechanically verified) — Cass reviews the frozen merge diff");
|
|
1190
|
+
} else {
|
|
1191
|
+
log("Branch state: " + branchState);
|
|
1192
|
+
}
|
|
1193
|
+
// buildClaimedNoDiff was set at the Build gate from the full Build
|
|
1194
|
+
// report (same process only). Only an empty branch WITH this claim
|
|
1195
|
+
// goes to Cass for a plausibility judgment; without it, empty-no-work
|
|
1196
|
+
// fails mechanically and Cass is never dispatched.
|
|
1197
|
+
// Blocker-34 follow-up: the package.json `version` check is a mechanical
|
|
1198
|
+
// git fact, not a reviewer judgment. For npm projects the workflow checks
|
|
1199
|
+
// it here, before Cass is dispatched — a touched `version` fails Review
|
|
1200
|
+
// mechanically (versions are assigned at publish time, never in
|
|
1201
|
+
// branches). Fail closed: anything but an explicit VERSION_CLEAN counts
|
|
1202
|
+
// as touched.
|
|
1203
|
+
if (PUBLISH_TYPE === "npm") {
|
|
1204
|
+
try {
|
|
1205
|
+
var vtResult = await agent(
|
|
1206
|
+
"Check whether the task branch changed package.json's `version` field. This is a mechanical git check, not a judgment call.\n" +
|
|
1207
|
+
"Run in shell and return the stdout verbatim:\n" +
|
|
1208
|
+
LIFECYCLE_ENV + LIFECYCLE + " version-check " + taskId,
|
|
1209
|
+
{ key: "version-check" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Checking package.json version (mechanical)" }
|
|
1210
|
+
);
|
|
1211
|
+
var vtStr = (typeof vtResult === "string") ? vtResult : JSON.stringify(vtResult);
|
|
1212
|
+
var vtMatch = /^\s*VERSION_(TOUCHED|CLEAN)\s*$/im.exec(vtStr);
|
|
1213
|
+
versionTouched = !(vtMatch && vtMatch[1].toUpperCase() === "CLEAN");
|
|
1214
|
+
} catch (vtErr) {
|
|
1215
|
+
versionTouched = true;
|
|
1216
|
+
log("Version check failed (" + String(vtErr && vtErr.message || vtErr).slice(0, 120) + ") — failing closed as touched");
|
|
1156
1217
|
}
|
|
1218
|
+
log("Review: package.json `version` " + (versionTouched ? "touched by the branch — mechanical FAIL, Cass not dispatched" : "untouched — mechanical check clean"));
|
|
1219
|
+
}
|
|
1220
|
+
// The empty-branch rule Cass used to adjudicate is gone. What she
|
|
1221
|
+
// examines and what rule she applies depend on the classified state —
|
|
1222
|
+
// never on her own judgment of git identity.
|
|
1223
|
+
var reviewChangeExam, reviewBranchRule;
|
|
1224
|
+
if (branchState === "has-work") {
|
|
1225
|
+
reviewChangeExam =
|
|
1226
|
+
"Examine the code changes by running:\n" +
|
|
1227
|
+
LIFECYCLE_ENV + LIFECYCLE + " inspect " + taskId + "\n\n" +
|
|
1228
|
+
"The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of the integration target (inspect prints the target name). Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n";
|
|
1229
|
+
reviewBranchRule =
|
|
1230
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): branch state = has-work. The branch has commits ahead of the integration target. Review their content; branch emptiness is settled and is not yours to judge.\n";
|
|
1231
|
+
} else if (branchState.indexOf("already-merged:") === 0) {
|
|
1232
|
+
reviewChangeExam =
|
|
1233
|
+
"The change under review is the frozen merge " + alreadyMergedSha + " — it is already on the integration target (see MECHANICAL FACT below). Examine it by running:\n" +
|
|
1234
|
+
"cd " + REPO_PATH + " && git diff " + alreadyMergedSha + "^1 " + alreadyMergedSha + "\n\n" +
|
|
1235
|
+
"That first-parent diff is the frozen, attributable change. Base your review ONLY on this diff — do NOT run git log to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n";
|
|
1236
|
+
reviewBranchRule =
|
|
1237
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): branch state = already-merged:" + alreadyMergedSha + ". The deliverable landed on the integration target via this task's own prior merge " + alreadyMergedSha + " (verified ancestor of the integration target). The branch is empty by design — emptiness is settled fact, not a finding.\n";
|
|
1238
|
+
} else if (buildClaimedNoDiff) {
|
|
1239
|
+
reviewChangeExam =
|
|
1240
|
+
"The branch has no commits ahead of the integration target (see MECHANICAL FACT below) — there is no diff to examine. Judge the Build report's `repo_diff: none` claim on its plausibility.\n\n";
|
|
1241
|
+
reviewBranchRule =
|
|
1242
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): the branch has no commits ahead of the integration target and no task-attributed merge on the target was verified; the Build report declares `repo_diff: none` (no repo change). Approve ONLY if the task's deliverable is plausibly runtime state (e.g. a cron definition, scheduler change, or dashboard/config state created outside the repo). Otherwise report 'no commits ahead of the integration target and no plausible runtime-state deliverable — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n";
|
|
1243
|
+
} else {
|
|
1244
|
+
// Mechanical FAIL — Cass is never dispatched (see the dispatch gate
|
|
1245
|
+
// below). Instructions are unused; keep the shape.
|
|
1246
|
+
reviewChangeExam = "";
|
|
1247
|
+
reviewBranchRule = "";
|
|
1157
1248
|
}
|
|
1158
1249
|
instructions = "Review independently and cold. No prior context 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" +
|
|
1159
1250
|
(mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + mapperSpec + "\n\n" : "Read the spec from the task description.\n\n") +
|
|
1160
|
-
|
|
1161
|
-
LIFECYCLE_ENV + LIFECYCLE + " inspect " + taskId + "\n\n" +
|
|
1162
|
-
"The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of the integration target (inspect prints the target name). Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n" +
|
|
1251
|
+
reviewChangeExam +
|
|
1163
1252
|
"You can also read specific files in the worktree at:\n" +
|
|
1164
1253
|
WORKTREE_HINT + "/\n\n" +
|
|
1165
1254
|
"Check quality, correctness, spec compliance.\n" +
|
|
1166
1255
|
(SURFACE_TERMINAL ? "TERMINAL UX REVIEW: judge the CLI surface against " + UX_DOCTRINE_PATH + " — help accuracy, error quality, exit codes, output clarity. Reject when the bar is not met.\n" : "") +
|
|
1167
1256
|
(SURFACE_ARTIFACT ? "ARTIFACT UX REVIEW: judge the rendered surface against " + UX_DOCTRINE_PATH + " — alignment, spacing, hierarchy, composition, balance, finish, correctness. Reject when the bar is not met.\n" : "") +
|
|
1168
1257
|
"Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, report what is stale, then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
1169
|
-
|
|
1170
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches
|
|
1171
|
-
"
|
|
1172
|
-
"
|
|
1258
|
+
reviewBranchRule +
|
|
1259
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches.\n" +
|
|
1260
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): the task branch did not change package.json's `version` field. A branch that touches `version` fails Review mechanically before any reviewer is dispatched, so this is settled — do not re-check it.\n" +
|
|
1261
|
+
"The accepted Build report declares: " + releaseDecisionText() + ". " +
|
|
1173
1262
|
(releaseDecision
|
|
1174
1263
|
? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, report your notes, then end with exactly this line: VERDICT: FAIL."
|
|
1175
1264
|
: "The decision is missing or malformed — report 'Build report must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines', then end with exactly this line: VERDICT: FAIL.") + "\n" : "") +
|
|
@@ -1183,8 +1272,9 @@ while (i < STEPS.length) {
|
|
|
1183
1272
|
"Then run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " CREW_STAGED_BASE=<provenance.source_commit, empty when null> integrate " + taskId + " \"merge: chore: " + safeTitle + "\\\"\\n" +
|
|
1184
1273
|
"The script merges, reconciles, records, and pushes under the merge lock. Informational only: RECONCILED, NO_REMOTE, NO_REMOTE_RECONCILE. Read the output:\n" +
|
|
1185
1274
|
"- STAGED_BASE_MISMATCH: the line left the staged base (stray checkout). VERDICT: FAIL.\n" +
|
|
1186
|
-
"- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or
|
|
1275
|
+
"- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or a task-attributed merge the workflow verified from the task's own merge records. No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
|
|
1187
1276
|
"- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
|
|
1277
|
+
"- STALE_MERGE: the recorded merge no longer matches the task branch (rework moved it after the merge) — the script refused to push stale state. Report the STALE_MERGE line, VERDICT: FAIL.\n" +
|
|
1188
1278
|
"- LOCK_HELD: 10-minute backoff exhausted. VERDICT: FAIL.\n" +
|
|
1189
1279
|
"- CONFLICT: the plain merge failed — aborted, the target is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
|
|
1190
1280
|
"RESOLUTION:\n" +
|
|
@@ -1234,8 +1324,9 @@ while (i < STEPS.length) {
|
|
|
1234
1324
|
// See docs/decisions/publish-path.md#deterministic-artifact-publish: the work agent never publishes; the parent runs the deterministic publish script.
|
|
1235
1325
|
// Blocker 22 (one-party publish, room #24): verified re-entry guard.
|
|
1236
1326
|
// The workflow parks at publish intent; the tick worker issues
|
|
1237
|
-
// artifact_edit
|
|
1238
|
-
//
|
|
1327
|
+
// artifact_edit; the ack scan stamps provenance on exact per-attempt
|
|
1328
|
+
// version acknowledgement (sole positive completion criterion; no
|
|
1329
|
+
// read-back — docs/publish-verification.md) and re-queues. The dispatcher resumes this task at the Publish step
|
|
1239
1330
|
// (the parked session's own step — the failed→retry path). If the
|
|
1240
1331
|
// stamped provenance names THIS task and its source_commit is the
|
|
1241
1332
|
// repo's current HEAD, the merge commit was already published and
|
|
@@ -1291,8 +1382,10 @@ while (i < STEPS.length) {
|
|
|
1291
1382
|
}
|
|
1292
1383
|
// See docs/decisions/publish-path.md#step1-builder-source: the builder's source tree is NOT the crew's repo; verify report before stamping.
|
|
1293
1384
|
// (2026-09-20, one-party worker-owned publish) A verified re-entry
|
|
1294
|
-
// skips the whole issuance tail: the edit already went out,
|
|
1295
|
-
//
|
|
1385
|
+
// skips the whole issuance tail: the edit already went out, the
|
|
1386
|
+
// ack scan saw the exact per-attempt version acknowledged (sole
|
|
1387
|
+
// positive completion criterion; no read-back) and it was stamped.
|
|
1388
|
+
// The empty-diff
|
|
1296
1389
|
// park must not fire on this branch.
|
|
1297
1390
|
if (!publishSkippedNoLock && !publishAlreadyVerified) {
|
|
1298
1391
|
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
@@ -1547,11 +1640,13 @@ while (i < STEPS.length) {
|
|
|
1547
1640
|
// mechanical outcome above. It must not rebuild or re-stamp: a second
|
|
1548
1641
|
// artifact_edit would trigger a duplicate build.
|
|
1549
1642
|
// (2026-09-20, one-party worker-owned publish) On a verified re-entry
|
|
1550
|
-
// the publish was issued
|
|
1551
|
-
//
|
|
1643
|
+
// the publish was issued, the ack scan observed the artifact's exact
|
|
1644
|
+
// version acknowledgement, and provenance was stamped — already
|
|
1645
|
+
// verified, nothing to issue. The work agent reports that,
|
|
1646
|
+
// VERDICT: PASS.
|
|
1552
1647
|
if (publishAlreadyVerified) {
|
|
1553
1648
|
instructions = "Publish was already completed and verified for this task — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would disturb the finalized state.\n\n" +
|
|
1554
|
-
"
|
|
1649
|
+
"Provenance stamps this task's merge commit as published (docs/publish-verification.md): the tick worker issued the edit, the ack scan observed the artifact's exact version acknowledgement — the sole positive completion criterion — and the publish was stamped. Already verified, nothing to issue and nothing to re-verify.\n\n" +
|
|
1555
1650
|
"For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
|
|
1556
1651
|
"Write plain prose describing what was published, then on its own line: VERDICT: PASS\n" +
|
|
1557
1652
|
"The VERDICT line must be the last line of your report.";
|
|
@@ -1564,10 +1659,10 @@ while (i < STEPS.length) {
|
|
|
1564
1659
|
instructions = "Publish the merged code to the live artifact.\n\n" +
|
|
1565
1660
|
"The publish was performed deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would trigger a duplicate build or disturb the finalized state. You perform no publish actions.\n\n" +
|
|
1566
1661
|
"For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
|
|
1567
|
-
"Mechanical outcome (the workflow's mechanical steps;
|
|
1662
|
+
"Mechanical outcome (the workflow's mechanical steps; the ack scan's version-acknowledgement check is still pending):\n" +
|
|
1568
1663
|
"- merge lock refreshed: yes\n" +
|
|
1569
1664
|
"- artifact rebuild triggered and completed: yes\n" +
|
|
1570
|
-
"- provenance stamped: NO — not yet, and you must NOT stamp it. The
|
|
1665
|
+
"- provenance stamped: NO — not yet, and you must NOT stamp it. The ack scan stamps it when it observes the artifact's exact on-disk acknowledgement of this attempt's version — version acknowledgement is the sole positive completion criterion (docs/publish-verification.md). A stamped-but-hollow build is exactly how canary run 8 (2026-09-11) went green on stale content.\n" +
|
|
1571
1666
|
"- post-deploy finalized: yes (worktree removed, merge lock released)\n\n" +
|
|
1572
1667
|
"POLICY: The live artifact is rebuilt only in this phase, from the repo. Never use artifact_edit to change the artifact directly — fixes go through the repo and the loop. A source fix is not done until the artifact is rebuilt from it here.\n\n" +
|
|
1573
1668
|
"The repo push already happened in Integrate — do NOT push to git in this phase.\n\n" +
|
|
@@ -1611,7 +1706,31 @@ while (i < STEPS.length) {
|
|
|
1611
1706
|
var workKeyBase = "work-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "");
|
|
1612
1707
|
var workerResult = null;
|
|
1613
1708
|
var workAttempts = [];
|
|
1614
|
-
|
|
1709
|
+
// Room #26 blocker 34 (redesign): an empty branch with no verified sha and
|
|
1710
|
+
// no repo_diff: none claim fails Review mechanically — Cass is never
|
|
1711
|
+
// dispatched, so no reviewer adjudicates the emptiness. The synthetic
|
|
1712
|
+
// report below is written by the workflow and flows through the same
|
|
1713
|
+
// verdict extraction, structured verdict recording, and rejection/bounce
|
|
1714
|
+
// path as a real FAIL.
|
|
1715
|
+
// Blocker-34 follow-up: the npm `version` check joins the mechanical gate.
|
|
1716
|
+
// Either mechanical fact fails Review without dispatching Cass.
|
|
1717
|
+
var mechanicalFailReason = (versionTouched ? "version-touched" : (branchState === "empty-no-work" && !buildClaimedNoDiff ? "empty-no-work" : null));
|
|
1718
|
+
var mechanicalReviewFail = (step.name === "Review" && mechanicalFailReason !== null);
|
|
1719
|
+
if (mechanicalReviewFail) {
|
|
1720
|
+
log("Review: " + mechanicalFailReason + " — mechanical FAIL, Cass not dispatched");
|
|
1721
|
+
workerResult =
|
|
1722
|
+
"MECHANICAL REVIEW VERDICT (written by the workflow — no reviewer was dispatched).\n" +
|
|
1723
|
+
(mechanicalFailReason === "version-touched"
|
|
1724
|
+
? "Package.json `version` (checked by the workflow from git): the task branch changed the `version` field. Versions are assigned at publish time — never in branches. Remove the version change.\n"
|
|
1725
|
+
: "Branch state (classified by the workflow from git): empty-no-work — the task branch has no commits ahead of the integration target, " +
|
|
1726
|
+
"no task-attributed merge on the target was verified" + (branchStateErr ? " (branch classification itself failed: " + branchStateErr + ")" : "") + ", " +
|
|
1727
|
+
"and the accepted Build report declared no `repo_diff: none` deliverable. " +
|
|
1728
|
+
"No change was reviewed because there is no change to review. The builder likely forgot to commit.\n") +
|
|
1729
|
+
"Worktree: " + WORKTREE_HINT + "\n" +
|
|
1730
|
+
"Recovery: commit the deliverable on the task branch in the worktree above and re-run Review. If the deliverable is genuinely runtime state outside the repo, declare `repo_diff: none` in the Build report instead of leaving the branch empty without a declaration.\n" +
|
|
1731
|
+
"VERDICT: FAIL";
|
|
1732
|
+
}
|
|
1733
|
+
for (var workAttempt = 0; workAttempt <= 2 && !mechanicalReviewFail; workAttempt++) {
|
|
1615
1734
|
var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workAttempt);
|
|
1616
1735
|
var prevAttempt = workAttempt === 0 ? null : workAttempts[workAttempt - 1];
|
|
1617
1736
|
var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? "discarded" : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
|
|
@@ -1709,7 +1828,18 @@ while (i < STEPS.length) {
|
|
|
1709
1828
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1710
1829
|
task_id: taskId,
|
|
1711
1830
|
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: "Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)" },
|
|
1712
|
-
event: { task_id: taskId, type: "failed", message: step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" }
|
|
1831
|
+
event: { task_id: taskId, type: "failed", message: step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" },
|
|
1832
|
+
// Room #26 blocker 33: INDETERMINATE verdict record — the report
|
|
1833
|
+
// could not be read at all, but its full text is still preserved
|
|
1834
|
+
// as grounds. Review-scoped; other verdict steps keep the
|
|
1835
|
+
// existing failed-session behavior with no verdict row.
|
|
1836
|
+
verdict: (step.name === "Review" ? {
|
|
1837
|
+
step: "Review",
|
|
1838
|
+
attempt: reworkCount,
|
|
1839
|
+
reviewer: step.identity,
|
|
1840
|
+
verdict: "INDETERMINATE",
|
|
1841
|
+
grounds: workerText
|
|
1842
|
+
} : null)
|
|
1713
1843
|
}) + "\n",
|
|
1714
1844
|
{ key: "record-block-" + step.name, label: "Recording verdict failure" }
|
|
1715
1845
|
);
|
|
@@ -1755,40 +1885,13 @@ while (i < STEPS.length) {
|
|
|
1755
1885
|
}
|
|
1756
1886
|
log("Build worktree confinement passed: " + wt.path);
|
|
1757
1887
|
|
|
1758
|
-
//
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
{ key: "verify-already-merged" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Verifying already-merged declaration" }
|
|
1766
|
-
);
|
|
1767
|
-
var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
|
|
1768
|
-
if (!/ALREADY_MERGED_YES/.test(amOut)) {
|
|
1769
|
-
log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of the integration target — marking failed for retry");
|
|
1770
|
-
await agent(
|
|
1771
|
-
"Record already-merged verification failure.\n" +
|
|
1772
|
-
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1773
|
-
task_id: taskId,
|
|
1774
|
-
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
|
|
1775
|
-
notes: "Build declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of the integration target in the configured repo. The declaration is fabricated or mistaken; the work is not on the integration target. Phase failed for retry" },
|
|
1776
|
-
event: { task_id: taskId, type: "failed", message: "Build already-merged declaration failed verification — " + am.sha + " not an ancestor of the integration target, phase failed, dispatcher will retry" }
|
|
1777
|
-
}),
|
|
1778
|
-
{ key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
|
|
1779
|
-
);
|
|
1780
|
-
return {
|
|
1781
|
-
__hatchWorkflowControl: "blocked",
|
|
1782
|
-
result: {
|
|
1783
|
-
blocked_reason: "Build already-merged declaration failed verification",
|
|
1784
|
-
message: "The builder declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of the integration target. The work is not on the integration target; the phase is marked failed and the dispatcher will retry Build.",
|
|
1785
|
-
task_id: taskId
|
|
1786
|
-
}
|
|
1787
|
-
};
|
|
1788
|
-
}
|
|
1789
|
-
alreadyMergedSha = am.sha;
|
|
1790
|
-
log("Build already-merged declaration verified: " + am.sha + " is an ancestor of the integration target");
|
|
1791
|
-
}
|
|
1888
|
+
// Room #26 blocker 34 (redesign, 2026-09-21): capture the plain
|
|
1889
|
+
// `repo_diff: none` (no repo change) claim from the FULL Build report.
|
|
1890
|
+
// Each Build round sets this fresh from its own report; there is no
|
|
1891
|
+
// cross-process fallback. The agent-authored already-merged
|
|
1892
|
+
// declaration and its Build-closeout verification are deleted —
|
|
1893
|
+
// attribution is the classifier's job at Review entry.
|
|
1894
|
+
buildClaimedNoDiff = /repo_diff:\s*none/im.test(workerText || "");
|
|
1792
1895
|
}
|
|
1793
1896
|
|
|
1794
1897
|
// Deterministic closeout: no formatter agent. The verdict is mechanical
|
|
@@ -1901,9 +2004,23 @@ while (i < STEPS.length) {
|
|
|
1901
2004
|
} else {
|
|
1902
2005
|
summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
|
|
1903
2006
|
}
|
|
1904
|
-
//
|
|
1905
|
-
|
|
1906
|
-
|
|
2007
|
+
// Room #26 blocker 34 (redesign, 2026-09-21): machine-readable review
|
|
2008
|
+
// basis, prepended to Review session notes for observability (proof
|
|
2009
|
+
// rooms read the summary to see what Review examined and why a
|
|
2010
|
+
// reviewer was or was not dispatched).
|
|
2011
|
+
if (step.name === "Review" && branchState) {
|
|
2012
|
+
var reviewBasisNote;
|
|
2013
|
+
if (mechanicalReviewFail) {
|
|
2014
|
+
reviewBasisNote = "REVIEW_BASIS: none — mechanical FAIL (" + mechanicalFailReason + "), no reviewer dispatched";
|
|
2015
|
+
} else if (branchState.indexOf("already-merged:") === 0) {
|
|
2016
|
+
var rbSha = branchState.slice("already-merged:".length);
|
|
2017
|
+
reviewBasisNote = "REVIEW_BASIS: frozen merge " + rbSha + " — reviewed via git diff " + rbSha + "^1 " + rbSha;
|
|
2018
|
+
} else if (branchState === "has-work") {
|
|
2019
|
+
reviewBasisNote = "REVIEW_BASIS: branch diff — reviewed via inspect output (task branch vs integration target)";
|
|
2020
|
+
} else {
|
|
2021
|
+
reviewBasisNote = "REVIEW_BASIS: runtime-state-none — no repo change; plausibility of Build's repo_diff: none claim";
|
|
2022
|
+
}
|
|
2023
|
+
summary = reviewBasisNote + "\n" + summary;
|
|
1907
2024
|
}
|
|
1908
2025
|
|
|
1909
2026
|
// Capture mapper's spec for Build and Review
|
|
@@ -1925,16 +2042,43 @@ while (i < STEPS.length) {
|
|
|
1925
2042
|
releaseDecision = extractReleaseDecision(workerText);
|
|
1926
2043
|
}
|
|
1927
2044
|
|
|
2045
|
+
// Room #26 blocker 34 (redesign, 2026-09-21): structured review basis
|
|
2046
|
+
// for the verdict row — what the review actually examined. A mechanical
|
|
2047
|
+
// FAIL has no reviewer and no basis beyond the mechanical fact. Only
|
|
2048
|
+
// computed for Review (branchState is null on other steps).
|
|
2049
|
+
var reviewBasis = null;
|
|
2050
|
+
if (step.name === "Review" && branchState) {
|
|
2051
|
+
reviewBasis =
|
|
2052
|
+
mechanicalReviewFail ? "mechanical-fail" :
|
|
2053
|
+
branchState.indexOf("already-merged:") === 0 ? "frozen-merge:" + branchState.slice("already-merged:".length) :
|
|
2054
|
+
branchState === "has-work" ? "branch-diff" : "runtime-state-none";
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1928
2057
|
await agent(
|
|
1929
2058
|
"Update session and log event.\n" +
|
|
1930
2059
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1931
2060
|
task_id: taskId,
|
|
1932
|
-
// Room #
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
1936
|
-
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary
|
|
1937
|
-
event: { task_id: taskId, type: status, identity: step.identity, message: step.name + " " + status + " by " + step.identity }
|
|
2061
|
+
// Room #26 blocker 34 (2026-09-21): the already_merged_sha session
|
|
2062
|
+
// field is no longer written — the agent-authored declaration path
|
|
2063
|
+
// is deleted and nothing reads it. Removing the Crew API/database
|
|
2064
|
+
// column is a separate API-surface follow-up, not bundled here.
|
|
2065
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary },
|
|
2066
|
+
event: { task_id: taskId, type: status, identity: step.identity, message: step.name + " " + status + " by " + step.identity },
|
|
2067
|
+
// Room #26 blocker 33: structured, non-lossy Review verdict record.
|
|
2068
|
+
// The session note above is truncated; the verdict row carries the
|
|
2069
|
+
// full worker report as grounds, written in the same transaction.
|
|
2070
|
+
// attempt is the rework round (0 = first Review).
|
|
2071
|
+
verdict: (step.name === "Review" && verdictPassed !== null ? {
|
|
2072
|
+
step: "Review",
|
|
2073
|
+
attempt: reworkCount,
|
|
2074
|
+
// Room #26 blocker 34 (redesign): a mechanical FAIL has no
|
|
2075
|
+
// reviewer — the workflow wrote the verdict. Identity rows still say
|
|
2076
|
+
// the Review step ran.
|
|
2077
|
+
reviewer: mechanicalReviewFail ? "workflow" : step.identity,
|
|
2078
|
+
verdict: verdictPassed ? "PASS" : "FAIL",
|
|
2079
|
+
review_basis: reviewBasis,
|
|
2080
|
+
grounds: workerText
|
|
2081
|
+
} : null)
|
|
1938
2082
|
}) + "\n",
|
|
1939
2083
|
{
|
|
1940
2084
|
key: "record-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
@@ -1943,21 +2087,20 @@ while (i < STEPS.length) {
|
|
|
1943
2087
|
);
|
|
1944
2088
|
|
|
1945
2089
|
if (!passed && step.name === "Review") {
|
|
2090
|
+
// Room #26 blocker 34 (redesign, 2026-09-21): the false-negative guard,
|
|
2091
|
+
// the Case B budget skip, and the already-merged corrective are all
|
|
2092
|
+
// gone. Emptiness is classified mechanically before Review, so a
|
|
2093
|
+
// rejected Review is never re-adjudicated here — the workflow owns the
|
|
2094
|
+
// git facts, the reviewer owns quality/spec compliance, and the budget
|
|
2095
|
+
// applies uniformly to every rejection. A rejected frozen merge
|
|
2096
|
+
// bounces with the reviewer's notes; a real fix commit becomes
|
|
2097
|
+
// has-work, and a do-nothing loop exhausts the normal budget.
|
|
1946
2098
|
reworkCount++;
|
|
1947
2099
|
if (reworkCount > MAX_REWORK) {
|
|
1948
2100
|
log("Max rework attempts reached for task " + taskId + " — worktree preserved at " + WORKTREE_PRESERVED_HINT + " for manual inspection");
|
|
1949
2101
|
return await parkTask("Exceeded " + MAX_REWORK + " rework attempts after Review rejection. Worktree preserved.");
|
|
1950
2102
|
}
|
|
1951
2103
|
rejectionNotes = summary;
|
|
1952
|
-
// Already-merged corrective (room #16 blocker 11): when Review rejected
|
|
1953
|
-
// an empty branch but the work is already on the integration target (the workflow verified
|
|
1954
|
-
// the sha), Wren must declare it — not re-implement or re-commit
|
|
1955
|
-
// already-landed work. Scoped to the empty-branch rejection; any other
|
|
1956
|
-
// rejection already carries its own specific notes.
|
|
1957
|
-
if (step.name === "Review" && alreadyMergedSha && /no commits ahead of (main|the integration target)/i.test(summary)) {
|
|
1958
|
-
rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on the integration target — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of the integration target. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
|
|
1959
|
-
log("Rework corrective appended for task " + taskId + ": already-merged " + alreadyMergedSha + " — Wren must declare, not rebuild");
|
|
1960
|
-
}
|
|
1961
2104
|
i = BUILD_INDEX;
|
|
1962
2105
|
log("Review rejected — bouncing to Build (rework #" + reworkCount + ")");
|
|
1963
2106
|
continue;
|
|
@@ -46,10 +46,43 @@ const MAX_TASKS_PER_POLL = inputs.maxTasksPerPoll || 6;
|
|
|
46
46
|
|
|
47
47
|
// Workflow step registry: static per release. The poll cron body reads
|
|
48
48
|
// <crewHome>/workflows/registry.json (generated at release time from the
|
|
49
|
-
// workflow files, which stay the single source of truth) and
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
49
|
+
// workflow files, which stay the single source of truth) and ferries the
|
|
50
|
+
// RAW FILE TEXT as inputs.registryText. The dispatcher parses it
|
|
51
|
+
// deterministically — the poller LLM never parses-and-re-emits a nested
|
|
52
|
+
// object into the tool call. (Room #26: 15+ "schema validation" launch
|
|
53
|
+
// rejections 09-19→09-21 from the LLM garbling the nested object, plus one
|
|
54
|
+
// hallucinated-but-shape-valid registry that silently drove dispatch with
|
|
55
|
+
// wrong step tables.) A string arg is trivially well-formed at the
|
|
56
|
+
// tool-call boundary — the same ferry pattern the board already uses.
|
|
57
|
+
// Fall back to the agent() load only when the caller predates the registry
|
|
58
|
+
// — slow, but never broken.
|
|
59
|
+
function parseRegistryText(text) {
|
|
60
|
+
if (text === undefined || text === null) {
|
|
61
|
+
return { ok: false, reason: "registryText is missing" };
|
|
62
|
+
}
|
|
63
|
+
if (typeof text !== "string") {
|
|
64
|
+
return { ok: false, reason: "registryText is not a string" };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return { ok: true, registry: JSON.parse(text) };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { ok: false, reason: "registryText is not valid JSON: " + e.message };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
var registryResult = null;
|
|
73
|
+
var registryTextResult = parseRegistryText(inputs.registryText);
|
|
74
|
+
if (registryTextResult.ok) {
|
|
75
|
+
registryResult = registryTextResult.registry;
|
|
76
|
+
} else if (inputs.registryText !== undefined && inputs.registryText !== null) {
|
|
77
|
+
// Present but unusable: name the defect, then fall through to the slow load.
|
|
78
|
+
log("WARNING: " + registryTextResult.reason + " — falling back to slow registry load");
|
|
79
|
+
} else if (inputs.registry !== undefined && inputs.registry !== null) {
|
|
80
|
+
// Legacy: pre-string-ferry poller prompts passed the parsed object.
|
|
81
|
+
// Same validation applies; a garbled object falls through to the slow
|
|
82
|
+
// load exactly like a missing one.
|
|
83
|
+
log("WARNING: legacy inputs.registry object received — prefer the registryText string ferry");
|
|
84
|
+
registryResult = inputs.registry;
|
|
85
|
+
}
|
|
53
86
|
// Nested-registry seam (2026-09-20): a malformed nested registry object
|
|
54
87
|
// (e.g. a workflow key mapping to null/undefined, or a steps entry without
|
|
55
88
|
// a name) used to die deep in the lookup tables with a cryptic TypeError
|
|
@@ -78,6 +111,18 @@ function validateRegistry(reg) {
|
|
|
78
111
|
if (typeof wf.reworkTarget !== "string") {
|
|
79
112
|
return { ok: false, reason: "workflow '" + wfNames[i] + "' has no reworkTarget string" };
|
|
80
113
|
}
|
|
114
|
+
// Content pin (room #26): a shape-valid registry can still be
|
|
115
|
+
// hallucinated — 2026-09-21 a poller ferried docs.reworkTarget="Build"
|
|
116
|
+
// (docs.js has no Build step) and it passed shape validation, silently
|
|
117
|
+
// driving dispatch with wrong step tables. The rework target must name
|
|
118
|
+
// a real step of its own workflow; this invariant is release-independent.
|
|
119
|
+
var stepNames = {};
|
|
120
|
+
for (var j = 0; j < wf.steps.length; j++) {
|
|
121
|
+
stepNames[wf.steps[j].name] = true;
|
|
122
|
+
}
|
|
123
|
+
if (!stepNames[wf.reworkTarget]) {
|
|
124
|
+
return { ok: false, reason: "workflow '" + wfNames[i] + "' reworkTarget '" + wf.reworkTarget + "' names no step" };
|
|
125
|
+
}
|
|
81
126
|
}
|
|
82
127
|
return { ok: true };
|
|
83
128
|
}
|
|
@@ -113,6 +158,14 @@ if (!registryResult) {
|
|
|
113
158
|
}
|
|
114
159
|
}
|
|
115
160
|
);
|
|
161
|
+
// The slow agent load is the same untrusted ferry as the args path — a
|
|
162
|
+
// hallucinated registry would silently drive dispatch with wrong step
|
|
163
|
+
// tables. Re-run the existing validation on the slow-loaded result and
|
|
164
|
+
// fail closed with the same reason; there is no second fallback.
|
|
165
|
+
var slowCheck = validateRegistry(registryResult);
|
|
166
|
+
if (!slowCheck.ok) {
|
|
167
|
+
throw new Error("registry slow load failed validation (" + slowCheck.reason + ") — cannot derive step tables; aborting dispatch");
|
|
168
|
+
}
|
|
116
169
|
}
|
|
117
170
|
|
|
118
171
|
// Derive lookup tables from canonical definitions
|