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/chore.js
CHANGED
|
@@ -26,6 +26,15 @@ const startStepIndex = inputs.start_step_index || 0;
|
|
|
26
26
|
// resolution back via updatetask in the self-claim below.
|
|
27
27
|
const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
|
|
28
28
|
const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
|
|
29
|
+
// One-shot recovery routing: the dispatcher sets inputs.next_phase when it
|
|
30
|
+
// routes this run via an explicit recover-task redirect. The value is
|
|
31
|
+
// consumed (cleared) atomically by the successful self-claim below:
|
|
32
|
+
// claim-task takes expected_next_phase and clears the matching next_phase in
|
|
33
|
+
// the same transaction as the winning session insert, so no platform death
|
|
34
|
+
// can slip between claim and consumption and replay the routing. A stale or
|
|
35
|
+
// superseded routing survives — only an exact match clears.
|
|
36
|
+
// what the dispatcher routed on.
|
|
37
|
+
const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_phase.length > 0) ? inputs.next_phase : null;
|
|
29
38
|
|
|
30
39
|
// Visual verdict protocol availability — the workflow parks for parent-run
|
|
31
40
|
// baseline capture ONLY when the protocol is fully shipped. The protocol
|
|
@@ -638,6 +647,18 @@ function extractMarkerLines(workerText) {
|
|
|
638
647
|
return markers.join("\n");
|
|
639
648
|
}
|
|
640
649
|
|
|
650
|
+
// Already-merged idempotency (canary 2026-09-15, task 1d692d91): when the
|
|
651
|
+
// builder correctly makes no commit because the deliverable is already on
|
|
652
|
+
// main (a prior merge or hand-repair landed it), it declares
|
|
653
|
+
// `repo_diff: none (already-merged: <sha>)` naming the main commit that
|
|
654
|
+
// carries the work. The sha is hex-only (7-40 chars) so the workflow can
|
|
655
|
+
// interpolate it into the mechanical ancestor check without injection
|
|
656
|
+
// risk. Pure — pinned byte-identical across standard/bugfix/chore.
|
|
657
|
+
function extractAlreadyMerged(workerText) {
|
|
658
|
+
var m = /^repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})\)/im.exec(workerText || "");
|
|
659
|
+
return m ? { sha: m[1].toLowerCase() } : { sha: null };
|
|
660
|
+
}
|
|
661
|
+
|
|
641
662
|
// Worktree confinement: the Build agent must declare the exact worktree
|
|
642
663
|
// path it built in on a `worktree:` marker line. The workflow compares it
|
|
643
664
|
// against WORKTREE_HINT mechanically (exact string match) — never by
|
|
@@ -815,6 +836,12 @@ let mapGateBounceCount = 0;
|
|
|
815
836
|
// rationalized a skip against explicit instruction text — text alone did not
|
|
816
837
|
// hold, so the decision now lives in workflow code, not agent judgment.
|
|
817
838
|
let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
|
|
839
|
+
// Already-merged idempotency: the verified sha from the builder's
|
|
840
|
+
// `repo_diff: none (already-merged: <sha>)` declaration (null when the
|
|
841
|
+
// builder made commits or declared a runtime-state deliverable). The
|
|
842
|
+
// workflow verifies the sha is an ancestor of main at Build closeout;
|
|
843
|
+
// Review's no-diff branch reads this, never the builder's prose.
|
|
844
|
+
let alreadyMergedSha = null;
|
|
818
845
|
// Deterministic publish target — computed by the workflow (registry base +
|
|
819
846
|
// bumpVersion), never by the Publish agent.
|
|
820
847
|
let publishTarget = null; // { base, scope, target }
|
|
@@ -1054,7 +1081,7 @@ while (i < STEPS.length) {
|
|
|
1054
1081
|
const claimResult = await agent(
|
|
1055
1082
|
"Claim this task for the " + step.name + " step.\n" +
|
|
1056
1083
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
|
|
1057
|
-
"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" +
|
|
1084
|
+
"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" +
|
|
1058
1085
|
"If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
|
|
1059
1086
|
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
1060
1087
|
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
@@ -1266,12 +1293,33 @@ while (i < STEPS.length) {
|
|
|
1266
1293
|
"cd " + WORKTREE_HINT + "\n" +
|
|
1267
1294
|
"git add -A\n" +
|
|
1268
1295
|
"git commit -m \"chore: " + safeTitle + "\"\n\n" +
|
|
1269
|
-
"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" +
|
|
1296
|
+
"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" +
|
|
1270
1297
|
(rejectionNotes ? "REWORK after rejection. Address:\n" + rejectionNotes + "\n\n" : "") +
|
|
1271
1298
|
"Report back in plain prose: what you built and the outcome." +
|
|
1272
1299
|
(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.");
|
|
1273
1300
|
|
|
1274
1301
|
} else if (step.name === "Review") {
|
|
1302
|
+
// Already-merged hydration: when this run did not execute Build itself
|
|
1303
|
+
// (dispatcher resume at Review after a platform death between phases),
|
|
1304
|
+
// recover the workflow-attested verification from the latest completed
|
|
1305
|
+
// Build session notes. The `already_merged_verified:` line was written
|
|
1306
|
+
// by the workflow after a mechanical ancestor check — it is trusted;
|
|
1307
|
+
// the builder's bare declaration never is. Absent the line, the
|
|
1308
|
+
// mechanical fact below reads "none declared" and Cass fails closed.
|
|
1309
|
+
if (!alreadyMergedSha) {
|
|
1310
|
+
var hydNotes = await agent(
|
|
1311
|
+
"Read the latest completed Build session notes for task " + taskId + ".\n" +
|
|
1312
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
|
|
1313
|
+
"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.",
|
|
1314
|
+
{ key: "hydrate-already-merged" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Hydrating already-merged verification" }
|
|
1315
|
+
);
|
|
1316
|
+
var hydStr = (typeof hydNotes === "string") ? hydNotes : JSON.stringify(hydNotes);
|
|
1317
|
+
var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
|
|
1318
|
+
if (hvm) {
|
|
1319
|
+
alreadyMergedSha = hvm[1].toLowerCase();
|
|
1320
|
+
log("Hydrated already-merged verification from Build session notes: " + alreadyMergedSha);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1275
1323
|
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" +
|
|
1276
1324
|
(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") +
|
|
1277
1325
|
"Examine the code changes by running:\n" +
|
|
@@ -1281,7 +1329,7 @@ while (i < STEPS.length) {
|
|
|
1281
1329
|
WORKTREE_HINT + "/\n\n" +
|
|
1282
1330
|
"Check quality, correctness, spec compliance.\n" +
|
|
1283
1331
|
"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" +
|
|
1284
|
-
"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" +
|
|
1332
|
+
"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" +
|
|
1285
1333
|
(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" +
|
|
1286
1334
|
"(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" +
|
|
1287
1335
|
"(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
|
|
@@ -1295,7 +1343,7 @@ while (i < STEPS.length) {
|
|
|
1295
1343
|
"Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: chore: " + safeTitle + "\"\n\n" +
|
|
1296
1344
|
"Read the output:\n" +
|
|
1297
1345
|
"- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
|
|
1298
|
-
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable
|
|
1346
|
+
"- 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" +
|
|
1299
1347
|
"- 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" +
|
|
1300
1348
|
"- 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" +
|
|
1301
1349
|
"RESOLUTION:\n" +
|
|
@@ -1842,6 +1890,15 @@ while (i < STEPS.length) {
|
|
|
1842
1890
|
// lock was lost: stop the run and park the task — never continue to
|
|
1843
1891
|
// a provenance stamp or version assignment without holding the lock.
|
|
1844
1892
|
var buildPoll = null;
|
|
1893
|
+
// STEP 1b poll-signal accumulators (2026-09-15, task aadeccc3):
|
|
1894
|
+
// the durable audit-dir fallback below needs the poll's own
|
|
1895
|
+
// observations, not just its final verdict — whether our build was
|
|
1896
|
+
// ever seen, whether a stranger's build was ever in flight, and
|
|
1897
|
+
// what the last check observed. OR-ed across all three chunks so
|
|
1898
|
+
// a signal seen in any chunk survives the chunk boundary.
|
|
1899
|
+
var pollSawOurBuild = false;
|
|
1900
|
+
var pollSawStranger = false;
|
|
1901
|
+
var lastObservedAgentId = null;
|
|
1845
1902
|
for (var chunk = 1; chunk <= 3; chunk++) {
|
|
1846
1903
|
if (chunk > 1) {
|
|
1847
1904
|
var refreshPoll = await agent(
|
|
@@ -1864,20 +1921,25 @@ while (i < STEPS.length) {
|
|
|
1864
1921
|
: attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, reworkCount);
|
|
1865
1922
|
buildPoll = await agent(
|
|
1866
1923
|
"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" +
|
|
1867
|
-
"
|
|
1924
|
+
"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" +
|
|
1925
|
+
"- 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" +
|
|
1926
|
+
"- If no build is running (build is null) and you previously observed our build running: our build finished. Stop and report done.\n" +
|
|
1868
1927
|
"- If the running build's agent_id equals \"" + rebuildAgentId + "\": still ours \u2014 keep waiting.\n" +
|
|
1869
|
-
"- If the running build's agent_id is present but DIFFERENT:
|
|
1870
|
-
"Return JSON { \"build_done\": <true
|
|
1928
|
+
"- 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" +
|
|
1929
|
+
"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.",
|
|
1871
1930
|
{ key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
|
|
1872
|
-
schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" }, observed_agent_id: { type: ["string", "null"] } }, required: ["build_done"] },
|
|
1931
|
+
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"] },
|
|
1873
1932
|
timeoutMs: 270000 }
|
|
1874
1933
|
);
|
|
1934
|
+
pollSawOurBuild = pollSawOurBuild || (buildPoll && buildPoll.saw_our_build === true);
|
|
1935
|
+
pollSawStranger = pollSawStranger || (buildPoll && buildPoll.saw_stranger === true);
|
|
1936
|
+
lastObservedAgentId = (buildPoll && buildPoll.observed_agent_id) || null;
|
|
1875
1937
|
if (buildPoll && buildPoll.build_done) { break; }
|
|
1876
1938
|
}
|
|
1877
1939
|
if (!buildPoll || !buildPoll.build_done) {
|
|
1878
1940
|
buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
|
|
1879
1941
|
}
|
|
1880
|
-
if (buildPoll.build_done) {
|
|
1942
|
+
if (buildPoll.build_done && pollSawOurBuild) {
|
|
1881
1943
|
// STEP 1c (mechanical): NO provenance stamp here. Canary run 8
|
|
1882
1944
|
// (2026-09-11) proved the stamp cannot certify content: the
|
|
1883
1945
|
// builder's applied-report is derived from the carried diff, so
|
|
@@ -1893,7 +1955,116 @@ while (i < STEPS.length) {
|
|
|
1893
1955
|
artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
|
|
1894
1956
|
log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
|
|
1895
1957
|
} else {
|
|
1896
|
-
|
|
1958
|
+
// STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
|
|
1959
|
+
// the poll above only observes IN-FLIGHT builds. A build that
|
|
1960
|
+
// finished between the receipt capture and the poll's first check
|
|
1961
|
+
// leaves no in-flight trace — but the platform's audit harness
|
|
1962
|
+
// leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
|
|
1963
|
+
// <timestamp>-<id>/ per completed build). Diff the audit-dir
|
|
1964
|
+
// listing against the pre-trigger snapshot: a timestamped dir
|
|
1965
|
+
// that appeared during the attempt window is evidence a build
|
|
1966
|
+
// completed. Attribution is by window, not by build identity:
|
|
1967
|
+
// the poll's saw_stranger signal only catches stranger builds in
|
|
1968
|
+
// flight AT a check — a stranger that finished entirely inside
|
|
1969
|
+
// the window is indistinguishable, so any observed stranger
|
|
1970
|
+
// blocks attribution and the outcome stays unknown. This never
|
|
1971
|
+
// re-issues the edit and never stamps provenance — ok=true only
|
|
1972
|
+
// routes to the parent's independent content read-back, which
|
|
1973
|
+
// remains the real verification.
|
|
1974
|
+
//
|
|
1975
|
+
// The poll end-state is read from the poll's own observations,
|
|
1976
|
+
// not from build_done alone: a build in flight at the last check
|
|
1977
|
+
// means the budget was shorter than the latency (or the build is
|
|
1978
|
+
// stuck) — NOT that no build ever started; nothing observed at
|
|
1979
|
+
// any check is the never-started signal.
|
|
1980
|
+
var pollEndState = lastObservedAgentId ? "build-still-running-at-poll-end"
|
|
1981
|
+
: (pollSawOurBuild ? "our-build-observed-then-unconfirmed" : "no-build-observed-in-window");
|
|
1982
|
+
var strangerObserved = pollSawStranger;
|
|
1983
|
+
var newAuditDirsAfterPoll = [];
|
|
1984
|
+
try {
|
|
1985
|
+
var auditAfterPoll = await agent(
|
|
1986
|
+
"List the artifact audit directories for slug \"" + PUBLISH_SLUG + "\" (best-effort, never a gate).\n" +
|
|
1987
|
+
"Run: ls -1 ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/ 2>/dev/null\n" +
|
|
1988
|
+
"Return JSON { \"dirs\": \"<newline-separated names, empty string when the audits directory does not exist or is empty>\" } and nothing else.",
|
|
1989
|
+
{ key: attemptKey("publish-audit-after-poll-" + taskId, reworkCount), label: "Re-listing audit dirs after build poll",
|
|
1990
|
+
schema: { type: "object", properties: { dirs: { type: "string" } }, required: ["dirs"] } }
|
|
1991
|
+
);
|
|
1992
|
+
var auditDirsAfterPollList = String((auditAfterPoll && auditAfterPoll.dirs) || "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
|
|
1993
|
+
newAuditDirsAfterPoll = auditDirsAfterPollList.filter(function (d) {
|
|
1994
|
+
return auditDirsBeforeTrigger.indexOf(d) === -1 && /^20\d\d-\d\d-\d\dT\d\d-\d\d-\d\dZ-/.test(d);
|
|
1995
|
+
});
|
|
1996
|
+
log("Publish audit-dir re-list after build poll for task " + taskId + ": " + newAuditDirsAfterPoll.length + " new timestamped dir(s)");
|
|
1997
|
+
} catch (auditAfterPollErr) {
|
|
1998
|
+
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));
|
|
1999
|
+
}
|
|
2000
|
+
// auditReportOk: pure tri-state read of a report.json body —
|
|
2001
|
+
// true (build ok), false (build failed), null (missing or
|
|
2002
|
+
// unreadable — not evidence either way). The child returns the
|
|
2003
|
+
// raw body verbatim; interpretation lives here, never in prose.
|
|
2004
|
+
var auditReportOk = function (raw) {
|
|
2005
|
+
if (typeof raw !== "string") return null;
|
|
2006
|
+
var trimmed = raw.trim();
|
|
2007
|
+
if (trimmed === "" || trimmed === "MISSING") return null;
|
|
2008
|
+
var parsed;
|
|
2009
|
+
try { parsed = JSON.parse(trimmed); } catch (e) { return null; }
|
|
2010
|
+
if (parsed && typeof parsed.ok === "boolean") return parsed.ok;
|
|
2011
|
+
return null;
|
|
2012
|
+
};
|
|
2013
|
+
var auditOkAfterPoll = null;
|
|
2014
|
+
var newestAuditDirAfterPoll = null;
|
|
2015
|
+
if (newAuditDirsAfterPoll.length > 0 && !strangerObserved) {
|
|
2016
|
+
newAuditDirsAfterPoll.sort();
|
|
2017
|
+
newestAuditDirAfterPoll = newAuditDirsAfterPoll[newAuditDirsAfterPoll.length - 1];
|
|
2018
|
+
try {
|
|
2019
|
+
var auditOkRead = await agent(
|
|
2020
|
+
"Read the build report for artifact slug \"" + PUBLISH_SLUG + "\", audit dir \"" + newestAuditDirAfterPoll + "\" (verbatim read, never interpreted, never a gate).\n" +
|
|
2021
|
+
"Run: cat ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/audits/" + newestAuditDirAfterPoll + "/report.json 2>/dev/null || echo MISSING\n" +
|
|
2022
|
+
"Return JSON { \"raw\": \"<verbatim file contents, or the literal string MISSING when the file does not exist>\" } and nothing else.",
|
|
2023
|
+
{ key: attemptKey("publish-audit-ok-after-poll-" + taskId, reworkCount), label: "Reading build report after build poll",
|
|
2024
|
+
schema: { type: "object", properties: { raw: { type: "string" } }, required: ["raw"] } }
|
|
2025
|
+
);
|
|
2026
|
+
auditOkAfterPoll = auditReportOk(auditOkRead && auditOkRead.raw);
|
|
2027
|
+
} catch (auditOkReadErr) {
|
|
2028
|
+
log("Publish build-report read after build poll failed for task " + taskId + " (non-fatal, treated as unknown): " + (auditOkReadErr && auditOkReadErr.message ? auditOkReadErr.message : auditOkReadErr));
|
|
2029
|
+
auditOkAfterPoll = null;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
if (auditOkAfterPoll === true) {
|
|
2033
|
+
publishBuildLanded = true;
|
|
2034
|
+
artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
|
|
2035
|
+
log("Publish build landed for task " + taskId + " via durable audit evidence — provenance stamp deferred to parent content verification");
|
|
2036
|
+
await recordPublishLedger({
|
|
2037
|
+
commit: mergeCommitForPublish,
|
|
2038
|
+
attempt: rebuildAttemptKey,
|
|
2039
|
+
agent_id: rebuildAgentId,
|
|
2040
|
+
applied_report: publishAppliedObservation,
|
|
2041
|
+
outcome: "submitted",
|
|
2042
|
+
detail: "durable audit evidence shows a build completed during the attempt window (audit dir " + newestAuditDirAfterPoll + ", report ok=true); routed to parent verification"
|
|
2043
|
+
}, reworkCount);
|
|
2044
|
+
} else if (auditOkAfterPoll === false) {
|
|
2045
|
+
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.";
|
|
2046
|
+
await recordPublishLedger({
|
|
2047
|
+
commit: mergeCommitForPublish,
|
|
2048
|
+
attempt: rebuildAttemptKey,
|
|
2049
|
+
agent_id: rebuildAgentId,
|
|
2050
|
+
applied_report: publishAppliedObservation,
|
|
2051
|
+
outcome: "failed",
|
|
2052
|
+
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"
|
|
2053
|
+
}, reworkCount);
|
|
2054
|
+
} else {
|
|
2055
|
+
var unattributableReason = strangerObserved ? "stranger-build-observed-during-poll"
|
|
2056
|
+
: (pollEndState === "build-still-running-at-poll-end" ? "build-still-running-at-poll-end"
|
|
2057
|
+
: (newAuditDirsAfterPoll.length === 0 ? "no-new-audit-dir-in-window" : "audit-report-unreadable-or-missing"));
|
|
2058
|
+
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.";
|
|
2059
|
+
await recordPublishLedger({
|
|
2060
|
+
commit: mergeCommitForPublish,
|
|
2061
|
+
attempt: rebuildAttemptKey,
|
|
2062
|
+
agent_id: rebuildAgentId,
|
|
2063
|
+
applied_report: publishAppliedObservation,
|
|
2064
|
+
outcome: "unknown",
|
|
2065
|
+
detail: "durable audit-dir fallback could not attribute a completed build to this attempt (unattributable_reason=" + unattributableReason + ", poll_end_state=" + pollEndState + ")"
|
|
2066
|
+
}, reworkCount);
|
|
2067
|
+
}
|
|
1897
2068
|
}
|
|
1898
2069
|
} else {
|
|
1899
2070
|
publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
|
|
@@ -2144,6 +2315,52 @@ while (i < STEPS.length) {
|
|
|
2144
2315
|
};
|
|
2145
2316
|
}
|
|
2146
2317
|
log("Build worktree confinement passed: " + wt.path);
|
|
2318
|
+
|
|
2319
|
+
// Already-merged idempotency: a `repo_diff: none (already-merged:
|
|
2320
|
+
// <sha>)` declaration is verified mechanically — <sha> must resolve
|
|
2321
|
+
// and be an ancestor of main in the configured repo. A fabricated or
|
|
2322
|
+
// mistaken declaration fails the phase here (the dispatcher retries
|
|
2323
|
+
// Build under its consecutive-failure cap); a verified declaration is
|
|
2324
|
+
// recorded in alreadyMergedSha for Review's no-diff branch. Without
|
|
2325
|
+
// this guard, Build correctly doing nothing left Review with no
|
|
2326
|
+
// mechanical way to accept an empty diff, and Cass rejected for "no
|
|
2327
|
+
// commits ahead of main — the builder likely forgot to commit" while
|
|
2328
|
+
// the deliverable sat on main (canary 2026-09-15, task 1d692d91).
|
|
2329
|
+
// The sha is hex-only by construction (extractAlreadyMerged), so
|
|
2330
|
+
// interpolating it into the shell command cannot inject.
|
|
2331
|
+
var am = extractAlreadyMerged(workerText);
|
|
2332
|
+
if (am.sha) {
|
|
2333
|
+
var amCheck = await agent(
|
|
2334
|
+
"Verify the builder's already-merged declaration.\n" +
|
|
2335
|
+
"Run in shell and return the stdout verbatim:\n" +
|
|
2336
|
+
"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",
|
|
2337
|
+
{ key: "verify-already-merged" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Verifying already-merged declaration" }
|
|
2338
|
+
);
|
|
2339
|
+
var amOut = (typeof amCheck === "string") ? amCheck : JSON.stringify(amCheck);
|
|
2340
|
+
if (!/ALREADY_MERGED_YES/.test(amOut)) {
|
|
2341
|
+
log("Build already-merged declaration failed verification — " + am.sha + " is not an ancestor of main — marking failed for retry");
|
|
2342
|
+
await agent(
|
|
2343
|
+
"Record already-merged verification failure.\n" +
|
|
2344
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
2345
|
+
task_id: taskId,
|
|
2346
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
|
|
2347
|
+
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" },
|
|
2348
|
+
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" }
|
|
2349
|
+
}),
|
|
2350
|
+
{ key: "record-already-merged-fail-" + step.name, label: "Recording already-merged verification failure" }
|
|
2351
|
+
);
|
|
2352
|
+
return {
|
|
2353
|
+
__hatchWorkflowControl: "blocked",
|
|
2354
|
+
result: {
|
|
2355
|
+
blocked_reason: "Build already-merged declaration failed verification",
|
|
2356
|
+
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.",
|
|
2357
|
+
task_id: taskId
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
alreadyMergedSha = am.sha;
|
|
2362
|
+
log("Build already-merged declaration verified: " + am.sha + " is an ancestor of main");
|
|
2363
|
+
}
|
|
2147
2364
|
}
|
|
2148
2365
|
|
|
2149
2366
|
// Deterministic closeout: no formatter agent. The verdict is mechanical
|
|
@@ -2304,6 +2521,15 @@ while (i < STEPS.length) {
|
|
|
2304
2521
|
} else {
|
|
2305
2522
|
summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
|
|
2306
2523
|
}
|
|
2524
|
+
// Already-merged attestation: when the Build gate verified the builder's
|
|
2525
|
+
// already-merged declaration, the workflow records its own marker line in
|
|
2526
|
+
// the session notes (like the builder markers above, it is appended after
|
|
2527
|
+
// the slice so it can never be amputated). A later run resumed at Review
|
|
2528
|
+
// hydrates alreadyMergedSha from this workflow-attested line — never from
|
|
2529
|
+
// the builder's declaration alone.
|
|
2530
|
+
if (step.name === "Build" && alreadyMergedSha) {
|
|
2531
|
+
summary += "\nalready_merged_verified: " + alreadyMergedSha;
|
|
2532
|
+
}
|
|
2307
2533
|
|
|
2308
2534
|
// Capture mapper's spec for Build and Review
|
|
2309
2535
|
if (step.name === "Map" && passed) {
|
|
@@ -103,18 +103,44 @@ phase("dispatch");
|
|
|
103
103
|
// the schema demanded a top-level object with ready_tasks. The LLM resolved
|
|
104
104
|
// the contradiction non-deterministically: some ticks validated, some
|
|
105
105
|
// burned retries and blocked the whole dispatcher.)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
106
|
+
// Bounded in-tick retry (canary 2026-09-15): the read-board agent
|
|
107
|
+
// occasionally ferries malformed JSON (observed twice: a literal
|
|
108
|
+
// `.replace()` code fragment appended to the dispatch-state string).
|
|
109
|
+
// A malformed ferry used to abort the whole dispatcher tick; retrying
|
|
110
|
+
// the identical dispatcher call recovered both times. Retry the read
|
|
111
|
+
// in-tick instead of burning a tick — two attempts with distinct
|
|
112
|
+
// replay keys, fail closed after that.
|
|
113
|
+
var boardReturn = null;
|
|
114
|
+
var boardReadError = null;
|
|
115
|
+
for (var readAttempt = 1; readAttempt <= 2; readAttempt++) {
|
|
116
|
+
var readBoardKey = readAttempt === 1 ? "read-board" : "read-board-r" + readAttempt;
|
|
117
|
+
try {
|
|
118
|
+
boardReturn = await agent(
|
|
119
|
+
"Read the dispatch state.\n" +
|
|
120
|
+
"Run in shell and return the stdout as a raw string:\n" + crewCmd("get-dispatch-state", {}) + "\n" +
|
|
121
|
+
"Return the command's stdout JSON as a plain string, byte-for-byte, unmodified. " +
|
|
122
|
+
"Do NOT parse the JSON — your return value must be the raw stdout string, never an object. " +
|
|
123
|
+
"Do not select fields, do not rewrite, summarize, paraphrase, or reformat anything. " +
|
|
124
|
+
"The result has keys ready_tasks, projects, config, counts — ferry the string exactly as received.",
|
|
125
|
+
{
|
|
126
|
+
key: readBoardKey,
|
|
127
|
+
label: "Reading board state" + (readAttempt > 1 ? " (attempt " + readAttempt + ")" : "")
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
// Parse eagerly inside the attempt so a malformed ferry retries
|
|
131
|
+
// instead of aborting the tick. (function declarations hoist.)
|
|
132
|
+
parseBoardJson(boardReturn);
|
|
133
|
+
boardReadError = null;
|
|
134
|
+
break;
|
|
135
|
+
} catch (readErr) {
|
|
136
|
+
boardReadError = readErr;
|
|
137
|
+
log("WARNING: read-board attempt " + readAttempt + " failed (" + (readErr && readErr.message ? readErr.message : readErr) + ")" + (readAttempt < 2 ? " — retrying" : " — attempts exhausted"));
|
|
138
|
+
boardReturn = null;
|
|
116
139
|
}
|
|
117
|
-
|
|
140
|
+
}
|
|
141
|
+
if (boardReturn === null) {
|
|
142
|
+
throw new Error("read-board: all 2 attempts failed to ferry parseable board JSON: " + (boardReadError && boardReadError.message ? boardReadError.message : boardReadError));
|
|
143
|
+
}
|
|
118
144
|
|
|
119
145
|
// Deterministic board parse — the read-board agent is told to return the
|
|
120
146
|
// CLI stdout as a raw string; the workflow parses it here. Fails closed:
|
|
@@ -210,6 +236,10 @@ function projectTaskRecord(t) {
|
|
|
210
236
|
blocked: t.blocked,
|
|
211
237
|
deps: Array.isArray(t.deps) ? t.deps.slice() : [],
|
|
212
238
|
priority: t.priority,
|
|
239
|
+
// next_phase is the one-shot recovery routing set by recover-task. It is
|
|
240
|
+
// ferried verbatim here and consumed (validated, routed, cleared) by the
|
|
241
|
+
// deterministic eligibility logic below — never interpreted by the LLM.
|
|
242
|
+
next_phase: (typeof t.next_phase === "string" && t.next_phase.length > 0) ? t.next_phase : null,
|
|
213
243
|
latest_session: ls ? { id: ls.id, status: ls.status, step: ls.step, notes: ls.notes } : null,
|
|
214
244
|
retry: {
|
|
215
245
|
consecutive_failures: (typeof cf === "number" && cf >= 0) ? cf : 0,
|
|
@@ -497,6 +527,30 @@ for (var t = 0; t < allTasks.length; t++) {
|
|
|
497
527
|
var workflow = task.workflow || "standard";
|
|
498
528
|
var steps = WORKFLOWS[workflow] || WORKFLOWS.standard;
|
|
499
529
|
|
|
530
|
+
// One-shot recovery routing: recover-task sets next_phase to an explicit
|
|
531
|
+
// human-chosen phase. It takes precedence over every session-derived path
|
|
532
|
+
// below — the human's redirect wins over the automatic resume — but never
|
|
533
|
+
// over work in flight, and never on a task in a terminal state.
|
|
534
|
+
// Invalid values fail closed: logged, skipped, and NOT cleared, so a
|
|
535
|
+
// human can fix it with a corrected recover-task. Valid values bypass
|
|
536
|
+
// the retry cap by design: an explicit redirect is not an automatic
|
|
537
|
+
// retry and must not consume retry budget. The launched workflow consumes
|
|
538
|
+
// (clears) next_phase on its successful self-claim, exactly once.
|
|
539
|
+
if (task.next_phase) {
|
|
540
|
+
if (task.state !== "todo" && task.state !== "in_progress") {
|
|
541
|
+
log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" set but state is " + task.state + "; left set for inspection");
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (latest && latest.status === "running") continue; // work in flight
|
|
545
|
+
var npIdx = steps.indexOf(task.next_phase);
|
|
546
|
+
if (npIdx < 0) {
|
|
547
|
+
log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" not in " + workflow + " step registry; left set for a corrected recover-task");
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
eligible.push({ task: task, startStep: npIdx, reason: "next_phase", workflow: workflow, nextPhase: task.next_phase });
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
500
554
|
if (task.state === "todo") {
|
|
501
555
|
// Idle playtests always enter at QA — the filing carries the full
|
|
502
556
|
// assignment, so Triage/Map have nothing to add.
|
|
@@ -844,7 +898,12 @@ for (var p = 0; p < toProcess.length; p++) {
|
|
|
844
898
|
// whether the task record had no workflow (playtest filings carry explicit
|
|
845
899
|
// workflow:'standard', so they correctly get false).
|
|
846
900
|
resolved_workflow: iworkflow,
|
|
847
|
-
workflow_was_null: (itask.workflow == null)
|
|
901
|
+
workflow_was_null: (itask.workflow == null),
|
|
902
|
+
// One-shot recovery routing: the value the dispatcher routed on. The
|
|
903
|
+
// workflow's successful self-claim consumes (clears) it atomically via
|
|
904
|
+
// claim-task's expected_next_phase — only an exact match clears, so a
|
|
905
|
+
// newer recover-task written in the race window survives.
|
|
906
|
+
next_phase: item.nextPhase || null
|
|
848
907
|
};
|
|
849
908
|
|
|
850
909
|
log("Recommended " + iworkflow + " for \"" + itask.title + "\" [" + taskProject + "] at step " + nextStepName);
|
package/workflows/crew-init.js
CHANGED
|
@@ -29,21 +29,28 @@ if (!!dashboardSlug !== !!dashboardRepoPath) throw new Error("dashboardSlug and
|
|
|
29
29
|
|
|
30
30
|
const orchDir = crewHome + "/.orchestration";
|
|
31
31
|
|
|
32
|
-
// ── Gate 0: Launchable-path
|
|
32
|
+
// ── Gate 0: Launchable-path validation ─────────────────────────────
|
|
33
33
|
// workflow_launch only accepts workspace-contained scripts. A crewHome
|
|
34
34
|
// outside the workspace would produce an instance whose workflows can
|
|
35
35
|
// never launch (the Gate 1 canary's /home/hatch/.crew-canary-gate1
|
|
36
36
|
// defect, 2026-09-11: workers resorted to changing, manually staged
|
|
37
37
|
// copies). Fail closed here so no such instance can ever be created.
|
|
38
|
+
// The crew home is state, not a repository: it is NEVER git-initialized.
|
|
39
|
+
// (2026-09-15: removed the old git-init — its unborn repo let a clean
|
|
40
|
+
// room register the crew home itself as a project's repo_path, and the
|
|
41
|
+
// worktree lifecycle then failed closed on the home's untracked state
|
|
42
|
+
// files. A home with no .git can never pass create-project's
|
|
43
|
+
// isGitRepoPath check, so the trap is structurally impossible. The
|
|
44
|
+
// crew's own source repo remains fully registerable — that is how a
|
|
45
|
+
// crew works on itself.)
|
|
38
46
|
// The dashboard repo is validated too: init registers the dashboard
|
|
39
47
|
// project against it, and a non-git path would only fail later at the
|
|
40
|
-
// first Build
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// the user's own project repository; a non-git path is a user error.
|
|
48
|
+
// first Build. Unlike the crew home, the dashboard repo is NEVER
|
|
49
|
+
// auto-created — it is the user's own project repository; a non-git
|
|
50
|
+
// path is a user error.
|
|
44
51
|
// Gate 0 decision logic is pure JS — the agent is a sensor, not a judge.
|
|
45
|
-
// It reports raw facts (HOME, expanded paths, the
|
|
46
|
-
//
|
|
52
|
+
// It reports raw facts (HOME, expanded paths, the dashboard repo's git
|
|
53
|
+
// rev-parse exit code); the launchable verdicts are computed here,
|
|
47
54
|
// deterministically. An agent asked for a verdict can mis-apply the
|
|
48
55
|
// prefix rule (e.g. the "$ws-evil" trailing-slash trick); code cannot.
|
|
49
56
|
// (Prompt hardening is a smell — this is the mechanical version.)
|
|
@@ -53,15 +60,15 @@ function expandTilde(p, home) {
|
|
|
53
60
|
return p;
|
|
54
61
|
}
|
|
55
62
|
function gate0Decide(facts) {
|
|
56
|
-
// facts: { home, crewHomeExpanded,
|
|
63
|
+
// facts: { home, crewHomeExpanded, dashboardRepoExpanded, dashboardRepoGitExitCode }
|
|
57
64
|
// The dashboard fields are null in CLI-only mode (no dashboard) — the
|
|
58
65
|
// dashboard verdicts are then vacuously true (nothing to validate).
|
|
66
|
+
// The crew home carries no repo verdict: homes are state, never repos.
|
|
59
67
|
var workspace = facts.home + "/workspace";
|
|
60
68
|
var launchable = facts.crewHomeExpanded.indexOf(workspace + "/") === 0;
|
|
61
|
-
var repoValid = facts.gitExitCode === 0;
|
|
62
69
|
var dashboardRepoLaunchable = facts.dashboardRepoExpanded == null || facts.dashboardRepoExpanded.indexOf(workspace + "/") === 0;
|
|
63
70
|
var dashboardRepoValid = facts.dashboardRepoGitExitCode == null || facts.dashboardRepoGitExitCode === 0;
|
|
64
|
-
return { workspace: workspace, launchable: launchable,
|
|
71
|
+
return { workspace: workspace, launchable: launchable, dashboardRepoLaunchable: dashboardRepoLaunchable, dashboardRepoValid: dashboardRepoValid };
|
|
65
72
|
}
|
|
66
73
|
var gateFacts;
|
|
67
74
|
try {
|
|
@@ -73,19 +80,16 @@ try {
|
|
|
73
80
|
"1. home=$(echo $HOME)\\n" +
|
|
74
81
|
"2. Expand a leading ~/ in the requested crewHome against $HOME (leave other paths untouched).\\n" +
|
|
75
82
|
"3. If the expanded crewHome does not exist: mkdir -p <expanded crewHome>\\n" +
|
|
76
|
-
"4.
|
|
77
|
-
"5. If EXIT is not 0: run git -C <expanded crewHome> init -b main (or git init if -b not supported)\\n" +
|
|
78
|
-
"6. Re-run: git -C <expanded crewHome> rev-parse --git-dir; echo EXIT:$?\\n" +
|
|
83
|
+
"4. If <expanded crewHome>/.git exists, remove it (rm -rf <expanded crewHome>/.git). The crew home is state, never a git repository — a vestigial .git would let the home be mistaken for a project repo.\\n" +
|
|
79
84
|
(dashboardRepoPath ?
|
|
80
|
-
"
|
|
81
|
-
"
|
|
85
|
+
"5. Expand a leading ~/ in the requested dashboardRepoPath against $HOME (leave other paths untouched).\\n" +
|
|
86
|
+
"6. Run: git -C <expanded dashboardRepoPath> rev-parse --git-dir; echo EXIT:$?\\n" +
|
|
82
87
|
" Do NOT create or git-init the dashboard repo — it must already be a git repository.\\n" +
|
|
83
|
-
"
|
|
84
|
-
" where
|
|
85
|
-
"
|
|
86
|
-
"7. Return JSON { home, crewHomeExpanded, gitExitCode, dashboardRepoExpanded: null, dashboardRepoGitExitCode: null }.\\n") +
|
|
88
|
+
"7. Return JSON { home, crewHomeExpanded, dashboardRepoExpanded, dashboardRepoGitExitCode }\\n" +
|
|
89
|
+
" where dashboardRepoGitExitCode is the dashboard repo's exit code (no init attempted).\\n" :
|
|
90
|
+
"5. Return JSON { home, crewHomeExpanded, dashboardRepoExpanded: null, dashboardRepoGitExitCode: null }.\\n") +
|
|
87
91
|
"\\n" +
|
|
88
|
-
"This ensures the crew home is a
|
|
92
|
+
"This ensures the crew home exists inside the workspace and is not a git repository" + (dashboardRepoPath ? ", and that the dashboard repo exists as one" : "") + ". Do not judge — just ensure and report.",
|
|
89
93
|
{
|
|
90
94
|
key: "gate-0",
|
|
91
95
|
label: "Validate crew home",
|
|
@@ -94,11 +98,10 @@ try {
|
|
|
94
98
|
properties: {
|
|
95
99
|
home: { type: "string" },
|
|
96
100
|
crewHomeExpanded: { type: "string" },
|
|
97
|
-
gitExitCode: { type: "number" },
|
|
98
101
|
dashboardRepoExpanded: { type: ["string", "null"] },
|
|
99
102
|
dashboardRepoGitExitCode: { type: ["number", "null"] }
|
|
100
103
|
},
|
|
101
|
-
required: ["home", "crewHomeExpanded", "
|
|
104
|
+
required: ["home", "crewHomeExpanded", "dashboardRepoExpanded", "dashboardRepoGitExitCode"]
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
);
|
|
@@ -115,15 +118,6 @@ if (!gateResult.launchable) {
|
|
|
115
118
|
}
|
|
116
119
|
};
|
|
117
120
|
}
|
|
118
|
-
if (!gateResult.repoValid) {
|
|
119
|
-
return {
|
|
120
|
-
__hatchWorkflowControl: "blocked",
|
|
121
|
-
result: {
|
|
122
|
-
blocked_reason: "crewHome git init failed",
|
|
123
|
-
message: "crewHome (" + gateFacts.crewHomeExpanded + ") could not be initialized as a git repository (git exit code " + gateFacts.gitExitCode + "). Check permissions and try again."
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
121
|
if (!gateResult.dashboardRepoLaunchable) {
|
|
128
122
|
return {
|
|
129
123
|
__hatchWorkflowControl: "blocked",
|
|
@@ -142,7 +136,7 @@ if (!gateResult.dashboardRepoValid) {
|
|
|
142
136
|
}
|
|
143
137
|
};
|
|
144
138
|
}
|
|
145
|
-
log("Gate 0: crewHome " + gateFacts.crewHomeExpanded + " is workspace-contained
|
|
139
|
+
log("Gate 0: crewHome " + gateFacts.crewHomeExpanded + " is workspace-contained (a state dir, never a git repo)" + (gateFacts.dashboardRepoExpanded ? "; dashboard repo " + gateFacts.dashboardRepoExpanded + " is workspace-contained and a valid git repo" : " (CLI-only mode: no dashboard)"));
|
|
146
140
|
|
|
147
141
|
// ── Crew name ─────────────────────────────────────────────────────────
|
|
148
142
|
// The human names their crew during setup (SETUP-EXPERIENCE.md). Stored as
|
package/workflows/docs.js
CHANGED
|
@@ -22,6 +22,15 @@ const startStepIndex = inputs.start_step_index || 0;
|
|
|
22
22
|
// resolution back via updatetask in the self-claim below.
|
|
23
23
|
const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
|
|
24
24
|
const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
|
|
25
|
+
// One-shot recovery routing: the dispatcher sets inputs.next_phase when it
|
|
26
|
+
// routes this run via an explicit recover-task redirect. The value is
|
|
27
|
+
// consumed (cleared) atomically by the successful self-claim below:
|
|
28
|
+
// claim-task takes expected_next_phase and clears the matching next_phase in
|
|
29
|
+
// the same transaction as the winning session insert, so no platform death
|
|
30
|
+
// can slip between claim and consumption and replay the routing. A stale or
|
|
31
|
+
// superseded routing survives — only an exact match clears.
|
|
32
|
+
// what the dispatcher routed on.
|
|
33
|
+
const NEXT_PHASE_ROUTED = (typeof inputs.next_phase === "string" && inputs.next_phase.length > 0) ? inputs.next_phase : null;
|
|
25
34
|
|
|
26
35
|
// Config from args — backward-compatible fallbacks for manual launches
|
|
27
36
|
const crewHome = inputs.crewHome || "~/workspace/.jarvis";
|
|
@@ -148,7 +157,7 @@ while (i < STEPS.length) {
|
|
|
148
157
|
const claimResult = await agent(
|
|
149
158
|
"Claim this task for the " + step.name + " step.\n" +
|
|
150
159
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
|
|
151
|
-
"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" +
|
|
160
|
+
"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" +
|
|
152
161
|
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
153
162
|
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
154
163
|
{
|