muse-crew 0.7.11 → 0.7.13
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 +17 -3
- package/docs/guide.md +4 -4
- package/docs/ooda-report.md +32 -5
- package/docs/publish-verification.md +46 -22
- package/lib/AGENTS.md +2 -1
- package/lib/crew-api.js +186 -41
- package/lib/read-ooda-verdict.js +94 -0
- package/lib/readback-disk.js +186 -0
- package/lib/render-html.js +2 -6
- package/lib/see-act.js +2 -6
- package/lib/verify-publish.js +62 -4
- package/lib/write-ooda-verdict.js +19 -2
- package/package.json +5 -2
- package/seed/cron-body-template.md +7 -3
- package/seed/crons.json +1 -1
- package/workflows/bugfix.js +302 -12
- package/workflows/chore.js +236 -10
- package/workflows/crew-dispatch.js +71 -12
- package/workflows/crew-init.js +26 -32
- package/workflows/docs.js +10 -1
- package/workflows/standard.js +237 -11
package/workflows/bugfix.js
CHANGED
|
@@ -28,6 +28,15 @@ const startStepIndex = inputs.start_step_index || 0;
|
|
|
28
28
|
// resolution back via updatetask in the self-claim below.
|
|
29
29
|
const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
|
|
30
30
|
const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
|
|
31
|
+
// One-shot recovery routing: the dispatcher sets inputs.next_phase when it
|
|
32
|
+
// routes this run via an explicit recover-task redirect. The value is
|
|
33
|
+
// consumed (cleared) atomically by the successful self-claim below:
|
|
34
|
+
// claim-task takes expected_next_phase and clears the matching next_phase in
|
|
35
|
+
// the same transaction as the winning session insert, so no platform death
|
|
36
|
+
// can slip between claim and consumption and replay the routing. A stale or
|
|
37
|
+
// superseded routing survives — only an exact match clears.
|
|
38
|
+
// what the dispatcher routed on.
|
|
39
|
+
const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_phase.length > 0) ? inputs.next_phase : null;
|
|
31
40
|
const CLAIM_WORKFLOW_PERSIST = (WORKFLOW_WAS_NULL && RESOLVED_WORKFLOW) ? ", \"workflow\": \"" + RESOLVED_WORKFLOW + "\"" : "";
|
|
32
41
|
|
|
33
42
|
// Visual verdict protocol availability — the workflow parks for parent-run
|
|
@@ -581,6 +590,18 @@ function extractMarkerLines(workerText) {
|
|
|
581
590
|
return markers.join("\n");
|
|
582
591
|
}
|
|
583
592
|
|
|
593
|
+
// Already-merged idempotency (canary 2026-09-15, task 1d692d91): when the
|
|
594
|
+
// builder correctly makes no commit because the deliverable is already on
|
|
595
|
+
// main (a prior merge or hand-repair landed it), it declares
|
|
596
|
+
// `repo_diff: none (already-merged: <sha>)` naming the main commit that
|
|
597
|
+
// carries the work. The sha is hex-only (7-40 chars) so the workflow can
|
|
598
|
+
// interpolate it into the mechanical ancestor check without injection
|
|
599
|
+
// risk. Pure — pinned byte-identical across standard/bugfix/chore.
|
|
600
|
+
function extractAlreadyMerged(workerText) {
|
|
601
|
+
var m = /^repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})\)/im.exec(workerText || "");
|
|
602
|
+
return m ? { sha: m[1].toLowerCase() } : { sha: null };
|
|
603
|
+
}
|
|
604
|
+
|
|
584
605
|
// Worktree confinement: the Build agent must declare the exact worktree
|
|
585
606
|
// path it built in on a `worktree:` marker line. The workflow compares it
|
|
586
607
|
// against WORKTREE_HINT mechanically (exact string match) — never by
|
|
@@ -769,6 +790,12 @@ let mapGateBounceCount = 0;
|
|
|
769
790
|
// rationalized a skip against explicit instruction text — text alone did not
|
|
770
791
|
// hold, so the decision now lives in workflow code, not agent judgment.
|
|
771
792
|
let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
|
|
793
|
+
// Already-merged idempotency: the verified sha from the builder's
|
|
794
|
+
// `repo_diff: none (already-merged: <sha>)` declaration (null when the
|
|
795
|
+
// builder made commits or declared a runtime-state deliverable). The
|
|
796
|
+
// workflow verifies the sha is an ancestor of main at Build closeout;
|
|
797
|
+
// Review's no-diff branch reads this, never the builder's prose.
|
|
798
|
+
let alreadyMergedSha = null;
|
|
772
799
|
// Deterministic publish target — computed by the workflow (registry base +
|
|
773
800
|
// bumpVersion), never by the Publish agent.
|
|
774
801
|
let publishTarget = null; // { base, scope, target }
|
|
@@ -1000,7 +1027,7 @@ while (i < STEPS.length) {
|
|
|
1000
1027
|
const claimResult = await agent(
|
|
1001
1028
|
"Claim this task for the " + step.name + " step.\n" +
|
|
1002
1029
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
|
|
1003
|
-
"Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" }) + "\n" +
|
|
1030
|
+
"Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started", ...(NEXT_PHASE_ROUTED ? { expected_next_phase: NEXT_PHASE_ROUTED } : {}) }) + "\n" +
|
|
1004
1031
|
"If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
|
|
1005
1032
|
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
1006
1033
|
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
@@ -1217,7 +1244,7 @@ while (i < STEPS.length) {
|
|
|
1217
1244
|
"Data-level fallback (only if the browser loop above is NOT POSSIBLE): run in shell and read the stdout JSON:\n" + crewCmd("get-state", {}) + "\n" +
|
|
1218
1245
|
"This returns current sessions, events, and tasks — capture concrete evidence from the data you retrieve.\n" +
|
|
1219
1246
|
"Report your reproduction steps and evidence as plain prose.\n" +
|
|
1220
|
-
"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\"]' — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten).";
|
|
1247
|
+
"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.";
|
|
1221
1248
|
|
|
1222
1249
|
} else if (step.name === "Map") {
|
|
1223
1250
|
var mapGatePara = "";
|
|
@@ -1253,12 +1280,33 @@ while (i < STEPS.length) {
|
|
|
1253
1280
|
"cd " + WORKTREE_HINT + "\n" +
|
|
1254
1281
|
"git add -A\n" +
|
|
1255
1282
|
"git commit -m \"fix: " + safeTitle + "\"\n\n" +
|
|
1256
|
-
"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 main and declare `repo_diff: none` in your report, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
|
|
1283
|
+
"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 main and declare `repo_diff: none` in your report, naming the runtime-state deliverable. If you verified the deliverable is already on main (a prior merge or hand-repair landed it — do NOT re-implement working code), make no commit and declare `repo_diff: none (already-merged: <sha>)` naming the main commit that carries the work; the workflow verifies the sha is an ancestor of main, and a false declaration fails the phase. Otherwise commit your changes normally.\n\n" +
|
|
1257
1284
|
(rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
|
|
1258
1285
|
"Report back in plain prose: what you built and the outcome." +
|
|
1259
1286
|
(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.");
|
|
1260
1287
|
|
|
1261
1288
|
} else if (step.name === "Review") {
|
|
1289
|
+
// Already-merged hydration: when this run did not execute Build itself
|
|
1290
|
+
// (dispatcher resume at Review after a platform death between phases),
|
|
1291
|
+
// recover the workflow-attested verification from the latest completed
|
|
1292
|
+
// Build session notes. The `already_merged_verified:` line was written
|
|
1293
|
+
// by the workflow after a mechanical ancestor check — it is trusted;
|
|
1294
|
+
// the builder's bare declaration never is. Absent the line, the
|
|
1295
|
+
// mechanical fact below reads "none declared" and Cass fails closed.
|
|
1296
|
+
if (!alreadyMergedSha) {
|
|
1297
|
+
var hydNotes = await agent(
|
|
1298
|
+
"Read the latest completed Build session notes for task " + taskId + ".\n" +
|
|
1299
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
|
|
1300
|
+
"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.",
|
|
1301
|
+
{ key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
|
|
1302
|
+
);
|
|
1303
|
+
var hydStr = (typeof hydNotes === "string") ? hydNotes : JSON.stringify(hydNotes);
|
|
1304
|
+
var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
|
|
1305
|
+
if (hvm) {
|
|
1306
|
+
alreadyMergedSha = hvm[1].toLowerCase();
|
|
1307
|
+
log("Hydrated already-merged verification from Build session notes: " + alreadyMergedSha);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1262
1310
|
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" +
|
|
1263
1311
|
(mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + mapperSpec + "\n\n" : "Read the spec (from the task description or spec files under " + crewHome + "/).\n\n") +
|
|
1264
1312
|
"Examine the code changes by running:\n" +
|
|
@@ -1268,7 +1316,7 @@ while (i < STEPS.length) {
|
|
|
1268
1316
|
WORKTREE_HINT + "/\n\n" +
|
|
1269
1317
|
"Check quality, correctness, and spec compliance.\n" +
|
|
1270
1318
|
"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" +
|
|
1271
|
-
"If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise report 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
1319
|
+
"If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with (a) a plausible runtime-state deliverable (e.g. a cron created via the cron tool), or (b) an already-merged declaration `repo_diff: none (already-merged: <sha>)` AND the mechanical fact below confirms the sha verified. MECHANICAL FACT (computed by the workflow, never by the builder): already_merged sha = " + (alreadyMergedSha ? alreadyMergedSha + " (verified ancestor of main: YES)" : "none declared") + ". Otherwise report 'no commits ahead of main and no valid repo_diff: none declaration — the builder likely forgot to commit', then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
1272
1320
|
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches. Two checks:\n" +
|
|
1273
1321
|
"(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main..." + TASK_BRANCH + " -- package.json. If the branch touched `version` in any way, report 'versions are assigned at publish time, never in branches — remove the version change' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
1274
1322
|
"(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
|
|
@@ -1282,7 +1330,7 @@ while (i < STEPS.length) {
|
|
|
1282
1330
|
"Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: fix: " + safeTitle + "\"\n\n" +
|
|
1283
1331
|
"Read the output:\n" +
|
|
1284
1332
|
"- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
|
|
1285
|
-
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable
|
|
1333
|
+
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (declared by Build as repo_diff: none — either a runtime-state deliverable or an already-merged sha the workflow verified). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Report 'merged empty: no repo changes — deliverable was runtime state or already on main', then end your report with exactly this line: VERDICT: PASS. SKIP STEP 2 (push): there is no new commit to push.\n" +
|
|
1286
1334
|
"- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish) and the 10-minute bounded backoff is exhausted. Report 'merge lock held after bounded backoff', then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
1287
1335
|
"- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
|
|
1288
1336
|
"RESOLUTION:\n" +
|
|
@@ -1829,6 +1877,15 @@ while (i < STEPS.length) {
|
|
|
1829
1877
|
// lock was lost: stop the run and park the task — never continue to
|
|
1830
1878
|
// a provenance stamp or version assignment without holding the lock.
|
|
1831
1879
|
var buildPoll = null;
|
|
1880
|
+
// STEP 1b poll-signal accumulators (2026-09-15, task aadeccc3):
|
|
1881
|
+
// the durable audit-dir fallback below needs the poll's own
|
|
1882
|
+
// observations, not just its final verdict — whether our build was
|
|
1883
|
+
// ever seen, whether a stranger's build was ever in flight, and
|
|
1884
|
+
// what the last check observed. OR-ed across all three chunks so
|
|
1885
|
+
// a signal seen in any chunk survives the chunk boundary.
|
|
1886
|
+
var pollSawOurBuild = false;
|
|
1887
|
+
var pollSawStranger = false;
|
|
1888
|
+
var lastObservedAgentId = null;
|
|
1832
1889
|
for (var chunk = 1; chunk <= 3; chunk++) {
|
|
1833
1890
|
if (chunk > 1) {
|
|
1834
1891
|
var refreshPoll = await agent(
|
|
@@ -1851,20 +1908,25 @@ while (i < STEPS.length) {
|
|
|
1851
1908
|
: attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
|
|
1852
1909
|
buildPoll = await agent(
|
|
1853
1910
|
"First call tool_search.load_tool_namespace with paths [\"artifact\"]. Then poll artifact_status for slug \"" + PUBLISH_SLUG + "\" \u2014 for OUR build only, the one whose agent_id is \"" + rebuildAgentId + "\" (the receipt captured when the edit was accepted; the agent_id is the artifact system's in-flight build correlation ID, stable across polls while the build runs). Check every 30 seconds, up to 7 checks (3.5 minutes max). On each check, read the raw build object:\n" +
|
|
1854
|
-
"
|
|
1911
|
+
"On every check, record whether you have positively OBSERVED our build: a running build whose agent_id equals \"" + rebuildAgentId + "\", or a completed-build record whose agent_id equals \"" + rebuildAgentId + "\" (if the tool surfaces one \u2014 match it mechanically, never assume).\n" +
|
|
1912
|
+
"- If no build is running (build is null) and you have NOT observed our build: our build's completion is UNPROVEN. Absence of a running build is not evidence our build ran. Do NOT report done.\n" +
|
|
1913
|
+
"- If no build is running (build is null) and you previously observed our build running: our build finished. Stop and report done.\n" +
|
|
1855
1914
|
"- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
|
|
1856
|
-
"- If the running build's agent_id is present but DIFFERENT:
|
|
1857
|
-
"Return JSON { \"build_done\": <true
|
|
1915
|
+
"- If the running build's agent_id is present but DIFFERENT: that is a stranger's build. Do NOT attribute its completion to our attempt and do NOT wait on it \u2014 keep checking within budget; if the budget expires without observing our build, report done=false. Record it in saw_stranger regardless of what else you observe.\n" +
|
|
1916
|
+
"Return JSON { \"build_done\": <true ONLY when you positively observed our build and it is no longer running, false otherwise>, \"saw_our_build\": <true if you observed our build at any check, false if never>, \"saw_stranger\": true if at ANY check a running build had an agent_id different from ours (\"" + rebuildAgentId + "\"), false otherwise, \"status\": \"<final status or timeout note>\", \"observed_agent_id\": \"<the agent_id seen on the last check, or null when no build was running>\" } and nothing else.",
|
|
1858
1917
|
{ key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
|
|
1859
|
-
schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
|
|
1918
|
+
schema: { type: "object", properties: { build_done: { type: "boolean" }, saw_our_build: { type: "boolean" }, saw_stranger: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
|
|
1860
1919
|
timeoutMs: 270000 }
|
|
1861
1920
|
);
|
|
1921
|
+
pollSawOurBuild = pollSawOurBuild || (buildPoll && buildPoll.saw_our_build === true);
|
|
1922
|
+
pollSawStranger = pollSawStranger || (buildPoll && buildPoll.saw_stranger === true);
|
|
1923
|
+
lastObservedAgentId = (buildPoll && buildPoll.observed_agent_id) || null;
|
|
1862
1924
|
if (buildPoll && buildPoll.build_done) { break; }
|
|
1863
1925
|
}
|
|
1864
1926
|
if (!buildPoll || !buildPoll.build_done) {
|
|
1865
1927
|
buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
|
|
1866
1928
|
}
|
|
1867
|
-
if (buildPoll.build_done) {
|
|
1929
|
+
if (buildPoll.build_done && pollSawOurBuild) {
|
|
1868
1930
|
// STEP 1c (mechanical): NO provenance stamp here. Canary run 8
|
|
1869
1931
|
// (2026-09-11) proved the stamp cannot certify content: the
|
|
1870
1932
|
// builder's applied-report is derived from the carried diff, so
|
|
@@ -1882,7 +1944,116 @@ while (i < STEPS.length) {
|
|
|
1882
1944
|
artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
|
|
1883
1945
|
log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
|
|
1884
1946
|
} else {
|
|
1885
|
-
|
|
1947
|
+
// STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
|
|
1948
|
+
// the poll above only observes IN-FLIGHT builds. A build that
|
|
1949
|
+
// finished between the receipt capture and the poll's first check
|
|
1950
|
+
// leaves no in-flight trace — but the platform's audit harness
|
|
1951
|
+
// leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
|
|
1952
|
+
// <timestamp>-<id>/ per completed build). Diff the audit-dir
|
|
1953
|
+
// listing against the pre-trigger snapshot: a timestamped dir
|
|
1954
|
+
// that appeared during the attempt window is evidence a build
|
|
1955
|
+
// completed. Attribution is by window, not by build identity:
|
|
1956
|
+
// the poll's saw_stranger signal only catches stranger builds in
|
|
1957
|
+
// flight AT a check — a stranger that finished entirely inside
|
|
1958
|
+
// the window is indistinguishable, so any observed stranger
|
|
1959
|
+
// blocks attribution and the outcome stays unknown. This never
|
|
1960
|
+
// re-issues the edit and never stamps provenance — ok=true only
|
|
1961
|
+
// routes to the parent's independent content read-back, which
|
|
1962
|
+
// remains the real verification.
|
|
1963
|
+
//
|
|
1964
|
+
// The poll end-state is read from the poll's own observations,
|
|
1965
|
+
// not from build_done alone: a build in flight at the last check
|
|
1966
|
+
// means the budget was shorter than the latency (or the build is
|
|
1967
|
+
// stuck) — NOT that no build ever started; nothing observed at
|
|
1968
|
+
// any check is the never-started signal.
|
|
1969
|
+
var pollEndState = lastObservedAgentId ? "build-still-running-at-poll-end"
|
|
1970
|
+
: (pollSawOurBuild ? "our-build-observed-then-unconfirmed" : "no-build-observed-in-window");
|
|
1971
|
+
var strangerObserved = pollSawStranger;
|
|
1972
|
+
var newAuditDirsAfterPoll = [];
|
|
1973
|
+
try {
|
|
1974
|
+
var auditAfterPoll = await agent(
|
|
1975
|
+
"List the artifact audit directories for slug \"" + PUBLISH_SLUG + "\" (best-effort, never a gate).\n" +
|
|
1976
|
+
"Run: ls -1 ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/ 2>/dev/null\n" +
|
|
1977
|
+
"Return JSON { \"dirs\": \"<newline-separated names, empty string when the audits directory does not exist or is empty>\" } and nothing else.",
|
|
1978
|
+
{ key: attemptKey("publish-audit-after-poll-" + taskId, totalReworkCount), label: "Re-listing audit dirs after build poll",
|
|
1979
|
+
schema: { type: "object", properties: { dirs: { type: "string" } }, required: ["dirs"] } }
|
|
1980
|
+
);
|
|
1981
|
+
var auditDirsAfterPollList = String((auditAfterPoll && auditAfterPoll.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
|
|
1982
|
+
newAuditDirsAfterPoll = auditDirsAfterPollList.filter(function (d) {
|
|
1983
|
+
return auditDirsBeforeTrigger.indexOf(d) === -1 && /^20\d\d-\d\d-\d\dT\d\d-\d\d-\d\dZ-/.test(d);
|
|
1984
|
+
});
|
|
1985
|
+
log("Publish audit-dir re-list after build poll for task " + taskId + ": " + newAuditDirsAfterPoll.length + " new timestamped dir(s)");
|
|
1986
|
+
} catch (auditAfterPollErr) {
|
|
1987
|
+
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));
|
|
1988
|
+
}
|
|
1989
|
+
// auditReportOk: pure tri-state read of a report.json body —
|
|
1990
|
+
// true (build ok), false (build failed), null (missing or
|
|
1991
|
+
// unreadable — not evidence either way). The child returns the
|
|
1992
|
+
// raw body verbatim; interpretation lives here, never in prose.
|
|
1993
|
+
var auditReportOk = function (raw) {
|
|
1994
|
+
if (typeof raw !== "string") return null;
|
|
1995
|
+
var trimmed = raw.trim();
|
|
1996
|
+
if (trimmed === "" || trimmed === "MISSING") return null;
|
|
1997
|
+
var parsed;
|
|
1998
|
+
try { parsed = JSON.parse(trimmed); } catch (e) { return null; }
|
|
1999
|
+
if (parsed && typeof parsed.ok === "boolean") return parsed.ok;
|
|
2000
|
+
return null;
|
|
2001
|
+
};
|
|
2002
|
+
var auditOkAfterPoll = null;
|
|
2003
|
+
var newestAuditDirAfterPoll = null;
|
|
2004
|
+
if (newAuditDirsAfterPoll.length > 0 && !strangerObserved) {
|
|
2005
|
+
newAuditDirsAfterPoll.sort();
|
|
2006
|
+
newestAuditDirAfterPoll = newAuditDirsAfterPoll[newAuditDirsAfterPoll.length - 1];
|
|
2007
|
+
try {
|
|
2008
|
+
var auditOkRead = await agent(
|
|
2009
|
+
"Read the build report for artifact slug \"" + PUBLISH_SLUG + "\", audit dir \"" + newestAuditDirAfterPoll + "\" (verbatim read, never interpreted, never a gate).\n" +
|
|
2010
|
+
"Run: cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/" + newestAuditDirAfterPoll + "/report.json 2>/dev/null || echo MISSING\n" +
|
|
2011
|
+
"Return JSON { \"raw\": \"<verbatim file contents, or the literal string MISSING when the file does not exist>\" } and nothing else.",
|
|
2012
|
+
{ key: attemptKey("publish-audit-ok-after-poll-" + taskId, totalReworkCount), label: "Reading build report after build poll",
|
|
2013
|
+
schema: { type: "object", properties: { raw: { type: "string" } }, required: ["raw"] } }
|
|
2014
|
+
);
|
|
2015
|
+
auditOkAfterPoll = auditReportOk(auditOkRead && auditOkRead.raw);
|
|
2016
|
+
} catch (auditOkReadErr) {
|
|
2017
|
+
log("Publish build-report read after build poll failed for task " + taskId + " (non-fatal, treated as unknown): " + (auditOkReadErr && auditOkReadErr.message ? auditOkReadErr.message : auditOkReadErr));
|
|
2018
|
+
auditOkAfterPoll = null;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (auditOkAfterPoll === true) {
|
|
2022
|
+
publishBuildLanded = true;
|
|
2023
|
+
artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
|
|
2024
|
+
log("Publish build landed for task " + taskId + " via durable audit evidence — provenance stamp deferred to parent content verification");
|
|
2025
|
+
await recordPublishLedger({
|
|
2026
|
+
commit: mergeCommitForPublish,
|
|
2027
|
+
attempt: rebuildAttemptKey,
|
|
2028
|
+
agent_id: rebuildAgentId,
|
|
2029
|
+
applied_report: publishAppliedObservation,
|
|
2030
|
+
outcome: "submitted",
|
|
2031
|
+
detail: "durable audit evidence shows a build completed during the attempt window (audit dir " + newestAuditDirAfterPoll + ", report ok=true); routed to parent verification"
|
|
2032
|
+
}, totalReworkCount);
|
|
2033
|
+
} else if (auditOkAfterPoll === false) {
|
|
2034
|
+
publishFailure = "Artifact build FAILED for slug " + PUBLISH_SLUG + " (audit dir " + newestAuditDirAfterPoll + ", report ok=false). Explicit negative evidence: a build ran and failed (attribution by window, not by build identity — no stranger build was observed in flight during the poll). The publish did not land — provenance was not stamped. Fail-closed.";
|
|
2035
|
+
await recordPublishLedger({
|
|
2036
|
+
commit: mergeCommitForPublish,
|
|
2037
|
+
attempt: rebuildAttemptKey,
|
|
2038
|
+
agent_id: rebuildAgentId,
|
|
2039
|
+
applied_report: publishAppliedObservation,
|
|
2040
|
+
outcome: "failed",
|
|
2041
|
+
detail: "a build ran and failed (attribution by window, not by build identity): audit dir " + newestAuditDirAfterPoll + " report ok=false; no stranger build observed in flight during the poll"
|
|
2042
|
+
}, totalReworkCount);
|
|
2043
|
+
} else {
|
|
2044
|
+
var unattributableReason = strangerObserved ? "stranger-build-observed-during-poll"
|
|
2045
|
+
: (pollEndState === "build-still-running-at-poll-end" ? "build-still-running-at-poll-end"
|
|
2046
|
+
: (newAuditDirsAfterPoll.length === 0 ? "no-new-audit-dir-in-window" : "audit-report-unreadable-or-missing"));
|
|
2047
|
+
publishFailure = "Artifact build completion unproven (fail-closed, no provenance stamped): unattributable_reason=" + unattributableReason + "; poll_end_state=" + pollEndState + "; " + "saw_our_build=" + pollSawOurBuild + "; new_audit_dirs=" + newAuditDirsAfterPoll.length + ". Attribution is by window, not by build identity. The publish may or may not have landed. Fail-closed.";
|
|
2048
|
+
await recordPublishLedger({
|
|
2049
|
+
commit: mergeCommitForPublish,
|
|
2050
|
+
attempt: rebuildAttemptKey,
|
|
2051
|
+
agent_id: rebuildAgentId,
|
|
2052
|
+
applied_report: publishAppliedObservation,
|
|
2053
|
+
outcome: "unknown",
|
|
2054
|
+
detail: "durable audit-dir fallback could not attribute a completed build to this attempt (unattributable_reason=" + unattributableReason + ", poll_end_state=" + pollEndState + ")"
|
|
2055
|
+
}, totalReworkCount);
|
|
2056
|
+
}
|
|
1886
2057
|
}
|
|
1887
2058
|
} else {
|
|
1888
2059
|
publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
|
|
@@ -2008,7 +2179,7 @@ while (i < STEPS.length) {
|
|
|
2008
2179
|
"File follow-up tasks by running in shell:\n" + crewCmd("create-task", { title: "<short title>", description: "<details>", project: "<project id>", workflow: "bugfix", filed_by: "hazel" }) + "\n(substitute the real values for the placeholders).\n\n" +
|
|
2009
2180
|
"BASELINE SANITY: in the event history you fetched, the task's note events must contain a message starting with `baseline: captured` or `baseline: none`. If no message starts with either prefix, report 'baseline evidence missing at QA — the Map gate was bypassed', then end your report with exactly this line: VERDICT: FAIL.\n\n" +
|
|
2010
2181
|
"Report your test results as plain prose.\n" +
|
|
2011
|
-
"End your report with exactly one line: VERDICT: PASS if testing passes, VERDICT: FAIL if it fails on the visual or the mechanical checks. Checks you could not run are evidence gaps, not silent drops: name every one in --missing — unknown is neither PASS nor FAIL. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl — an empty log is honest, an absent one is a broken report). Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/postchange/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the reported bug, fixed>\" --actual \"<what you observed>\" --missing '[\"honest evidence gap, if any\"]' — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten).";
|
|
2182
|
+
"End your report with exactly one line: VERDICT: PASS if testing passes, VERDICT: FAIL if it fails on the visual or the mechanical checks. Checks you could not run are evidence gaps, not silent drops: name every one in --missing — unknown is neither PASS nor FAIL. First ensure the OODA log exists even if you logged zero steps (touch " + crewHome + "/task-evidence/" + taskId + "/postchange/ooda-log.jsonl — an empty log is honest, an absent one is a broken report). Also write the same verdict machine-readably: node " + crewHome + "/current/lib/write-ooda-verdict.js --dir " + crewHome + "/task-evidence/" + taskId + "/postchange/ --attempt \"1\" --verdict <PASS|FAIL|NOT_POSSIBLE> --summary \"<one line>\" --expected \"<the reported bug, fixed>\" --actual \"<what you observed>\" --missing '[\"honest evidence gap, if any\"]' [--reason \"<why it failed — REQUIRED and non-empty when verdict is FAIL or NOT_POSSIBLE; the script rejects a reason-less negative verdict with exit 2>\"] — this writes verdict.json (the latest verdict) and appends to verdicts.jsonl (the append-only ledger: every attempt's verdict is preserved, never overwritten). A FAIL verdict must carry a machine-readable reason: the workflow closeout cross-checks verdict.json against your prose VERDICT line, and an unreasoned or contradictory verdict fails the phase (never routes to rework).";
|
|
2012
2183
|
}
|
|
2013
2184
|
if (PUBLISH_TYPE === "artifact") {
|
|
2014
2185
|
instructions = "PROVENANCE CHECK (this project publishes to a dashboard artifact).\n" +
|
|
@@ -2176,6 +2347,70 @@ while (i < STEPS.length) {
|
|
|
2176
2347
|
verdictPassed = verdict.passed;
|
|
2177
2348
|
}
|
|
2178
2349
|
|
|
2350
|
+
// QA verdict.json closeout gate (task 30dceb78): the prose VERDICT: line is
|
|
2351
|
+
// the routing signal, but verdict.json is the reason-carrying record the
|
|
2352
|
+
// workflow actually reads. A FAIL verdict must carry a machine-readable
|
|
2353
|
+
// reason; the workflow refuses to route to rework on an unreasoned or
|
|
2354
|
+
// contradictory verdict. The deterministic cross-checker
|
|
2355
|
+
// (lib/read-ooda-verdict.js) runs against the prose verdict: a missing,
|
|
2356
|
+
// corrupt, contradictory, or reason-less record fails the phase for retry
|
|
2357
|
+
// — the dispatcher re-runs QA at the same step under its
|
|
2358
|
+
// consecutive-failure cap — instead of routing to rework. Without this
|
|
2359
|
+
// gate, a bare VERDICT: FAIL with an all-positive report (canary
|
|
2360
|
+
// 2026-09-15, task 1d692d91) rebuilt nothing and parked at Publish on an
|
|
2361
|
+
// unobserved artifact build. The gate applies only to the experiential QA
|
|
2362
|
+
// path (qaVisual), the only path that writes verdict.json.
|
|
2363
|
+
if (step.name === "QA" && qaVisual && verdictPassed !== null) {
|
|
2364
|
+
var qaVerdictDir = crewHome + "/task-evidence/" + taskId + "/postchange";
|
|
2365
|
+
var qaVerdictExpect = verdictPassed ? "PASS" : "FAIL";
|
|
2366
|
+
var qaVerdictOut = "";
|
|
2367
|
+
try {
|
|
2368
|
+
var qaVerdictCheck = await agent(
|
|
2369
|
+
"Run: node " + crewHome + "/current/lib/read-ooda-verdict.js --dir " + qaVerdictDir + " --expect " + qaVerdictExpect + "\n" +
|
|
2370
|
+
"Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
|
|
2371
|
+
{ key: attemptKey("qa-verdict-check-" + taskId, totalReworkCount), label: "Cross-checking QA verdict.json",
|
|
2372
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
2373
|
+
);
|
|
2374
|
+
qaVerdictOut = (qaVerdictCheck && qaVerdictCheck.output ? qaVerdictCheck.output : "").trim();
|
|
2375
|
+
} catch (e) {
|
|
2376
|
+
qaVerdictOut = "";
|
|
2377
|
+
}
|
|
2378
|
+
// The script prints exactly one JSON line; exit info does not survive
|
|
2379
|
+
// the schema'd return, so ok:false on that line is the gate signal.
|
|
2380
|
+
var qaVerdictGate = null;
|
|
2381
|
+
try {
|
|
2382
|
+
var qaVerdictLines = qaVerdictOut.split("\n");
|
|
2383
|
+
qaVerdictGate = JSON.parse(qaVerdictLines[qaVerdictLines.length - 1]);
|
|
2384
|
+
} catch (e) {
|
|
2385
|
+
qaVerdictGate = null;
|
|
2386
|
+
}
|
|
2387
|
+
if (!qaVerdictGate || qaVerdictGate.ok !== true) {
|
|
2388
|
+
var qaGateCode = (qaVerdictGate && qaVerdictGate.code) ? qaVerdictGate.code : "unreadable";
|
|
2389
|
+
var qaGateDetail = (qaVerdictGate && qaVerdictGate.error) ? qaVerdictGate.error : (qaVerdictOut ? qaVerdictOut.slice(0, 200) : "cross-checker produced no usable output");
|
|
2390
|
+
log("QA verdict.json closeout gate failed (" + qaGateCode + "): " + qaGateDetail + " — marking QA failed for retry, NOT routing to rework");
|
|
2391
|
+
await agent(
|
|
2392
|
+
"Record QA verdict gate failure.\n" +
|
|
2393
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
2394
|
+
task_id: taskId,
|
|
2395
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name,
|
|
2396
|
+
status: "failed", notes: "QA verdict.json closeout gate failed (" + qaGateCode + "): " + qaGateDetail + ". The prose VERDICT line said " + qaVerdictExpect + " but verdict.json is missing, corrupt, contradictory, or (for FAIL) carries no machine-readable reason. Unreasoned or contradictory verdicts never route to rework; phase failed for retry" },
|
|
2397
|
+
event: { task_id: taskId, type: "failed",
|
|
2398
|
+
message: "QA verdict.json closeout gate failed (" + qaGateCode + ") — verdict unreasoned or contradictory, phase failed, dispatcher will retry QA" }
|
|
2399
|
+
}),
|
|
2400
|
+
{ key: "record-qa-verdict-gate-fail-" + step.name, label: "Recording QA verdict gate failure" }
|
|
2401
|
+
);
|
|
2402
|
+
return {
|
|
2403
|
+
__hatchWorkflowControl: "blocked",
|
|
2404
|
+
result: {
|
|
2405
|
+
blocked_reason: "QA verdict.json closeout gate failed (" + qaGateCode + ")",
|
|
2406
|
+
message: "QA's prose VERDICT line said " + qaVerdictExpect + " but the machine-readable verdict.json is " + qaGateCode + " (" + qaGateDetail + "). A FAIL verdict must carry a machine-readable reason and the record must agree with the prose line. The phase is marked failed and the dispatcher will retry QA; it is not routed to rework.",
|
|
2407
|
+
task_id: taskId
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
log("QA verdict.json closeout gate passed: verdict.json agrees with prose VERDICT: " + qaVerdictExpect);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2179
2414
|
|
|
2180
2415
|
// Worktree confinement (Build only): the declared worktree path must
|
|
2181
2416
|
// match WORKTREE_HINT exactly. A builder that worked in any other
|
|
@@ -2207,6 +2442,52 @@ while (i < STEPS.length) {
|
|
|
2207
2442
|
};
|
|
2208
2443
|
}
|
|
2209
2444
|
log("Build worktree confinement passed: " + wt.path);
|
|
2445
|
+
|
|
2446
|
+
// Already-merged idempotency: a `repo_diff: none (already-merged:
|
|
2447
|
+
// <sha>)` declaration is verified mechanically — <sha> must resolve
|
|
2448
|
+
// and be an ancestor of main in the configured repo. A fabricated or
|
|
2449
|
+
// mistaken declaration fails the phase here (the dispatcher retries
|
|
2450
|
+
// Build under its consecutive-failure cap); a verified declaration is
|
|
2451
|
+
// recorded in alreadyMergedSha for Review's no-diff branch. Without
|
|
2452
|
+
// this guard, Build correctly doing nothing left Review with no
|
|
2453
|
+
// mechanical way to accept an empty diff, and Cass rejected for "no
|
|
2454
|
+
// commits ahead of main — the builder likely forgot to commit" while
|
|
2455
|
+
// the deliverable sat on main (canary 2026-09-15, task 1d692d91).
|
|
2456
|
+
// The sha is hex-only by construction (extractAlreadyMerged), so
|
|
2457
|
+
// interpolating it into the shell command cannot inject.
|
|
2458
|
+
var am = extractAlreadyMerged(workerText);
|
|
2459
|
+
if (am.sha) {
|
|
2460
|
+
var amCheck = await agent(
|
|
2461
|
+
"Verify the builder's already-merged declaration.\n" +
|
|
2462
|
+
"Run in shell and return the stdout verbatim:\n" +
|
|
2463
|
+
"cd " + REPO_PATH + " && git rev-parse --verify --quiet " + am.sha + " >/dev/null && git merge-base --is-ancestor " + am.sha + " main && echo ALREADY_MERGED_YES || echo ALREADY_MERGED_NO",
|
|
2464
|
+
{ key: "verify-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Verifying already-merged declaration" }
|
|
2465
|
+
);
|
|
2466
|
+
var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
|
|
2467
|
+
if (!/ALREADY_MERGED_YES/.test(amOut)) {
|
|
2468
|
+
log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of main — marking failed for retry");
|
|
2469
|
+
await agent(
|
|
2470
|
+
"Record already-merged verification failure.\n" +
|
|
2471
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
2472
|
+
task_id: taskId,
|
|
2473
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
|
|
2474
|
+
notes: "Build declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of main in the configured repo. The declaration is fabricated or mistaken; the work is not on main. Phase failed for retry" },
|
|
2475
|
+
event: { task_id: taskId, type: "failed", message: "Build already-merged declaration failed verification — " + am.sha + " not an ancestor of main, phase failed, dispatcher will retry" }
|
|
2476
|
+
}),
|
|
2477
|
+
{ key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
|
|
2478
|
+
);
|
|
2479
|
+
return {
|
|
2480
|
+
__hatchWorkflowControl: "blocked",
|
|
2481
|
+
result: {
|
|
2482
|
+
blocked_reason: "Build already-merged declaration failed verification",
|
|
2483
|
+
message: "The builder declared repo_diff: none (already-merged: " + am.sha + ") but " + am.sha + " is not an ancestor of main. The work is not on main; the phase is marked failed and the dispatcher will retry Build.",
|
|
2484
|
+
task_id: taskId
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
alreadyMergedSha = am.sha;
|
|
2489
|
+
log("Build already-merged declaration verified: " + am.sha + " is an ancestor of main");
|
|
2490
|
+
}
|
|
2210
2491
|
}
|
|
2211
2492
|
|
|
2212
2493
|
// Deterministic closeout: no formatter agent. The verdict is mechanical
|
|
@@ -2370,6 +2651,15 @@ while (i < STEPS.length) {
|
|
|
2370
2651
|
} else {
|
|
2371
2652
|
summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
|
|
2372
2653
|
}
|
|
2654
|
+
// Already-merged attestation: when the Build gate verified the builder's
|
|
2655
|
+
// already-merged declaration, the workflow records its own marker line in
|
|
2656
|
+
// the session notes (like the builder markers above, it is appended after
|
|
2657
|
+
// the slice so it can never be amputated). A later run resumed at Review
|
|
2658
|
+
// hydrates alreadyMergedSha from this workflow-attested line — never from
|
|
2659
|
+
// the builder's declaration alone.
|
|
2660
|
+
if (step.name === "Build" && alreadyMergedSha) {
|
|
2661
|
+
summary += "\nalready_merged_verified: " + alreadyMergedSha;
|
|
2662
|
+
}
|
|
2373
2663
|
|
|
2374
2664
|
// Visual verdict evidence (2026-09-15): no parent marker is appended. Hazel
|
|
2375
2665
|
// records her own verdict.json + the append-only verdicts.jsonl in the
|