muse-crew 0.4.3 → 0.4.4
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/AGENTS.md +2 -0
- package/API.md +68 -21
- package/README.md +18 -2
- package/docs/guide.md +81 -11
- package/docs/visual-verdict.md +120 -0
- package/identities/hazel.md +22 -0
- package/lib/AGENTS.md +5 -1
- package/lib/build-registry.js +80 -0
- package/lib/compose-evidence.py +144 -0
- package/lib/crew-release.sh +9 -1
- package/lib/merge-lock.sh +18 -0
- package/lib/orphan-sweep.sh +70 -7
- package/lib/publish-npm.sh +18 -11
- package/lib/test-merge-lock.sh +69 -0
- package/lib/test-orphan-sweep.sh +74 -8
- package/lib/test-version-write.sh +97 -0
- package/lib/test-worktree-backend.sh +79 -0
- package/lib/worktree-lifecycle.sh +69 -28
- package/package.json +1 -1
- package/seed/cron-body-template.md +5 -3
- package/seed/workflows/bugfix.md +7 -2
- package/seed/workflows/chore.md +7 -3
- package/seed/workflows/standard.md +7 -2
- package/workflows/AGENTS.md +4 -4
- package/workflows/bugfix.js +961 -167
- package/workflows/chore.js +826 -140
- package/workflows/crew-dispatch.js +405 -98
- package/workflows/crew-init.js +4 -35
- package/workflows/docs.js +342 -22
- package/workflows/standard.js +965 -159
- package/workflows/tests/retry-cap.test.mjs +102 -0
- package/workflows/tests/work-agent-failure.test.mjs +122 -0
package/workflows/chore.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const meta = {
|
|
2
2
|
name: "crew-chore",
|
|
3
|
-
description: "Chore workflow: Triage → Map → Build → Review → Integrate → Publish",
|
|
4
|
-
phases: ["Triage", "Map", "Build", "Review", "Integrate", "Publish"],
|
|
3
|
+
description: "Chore workflow: Triage → Capture → Map → Build → Review → Integrate → Publish",
|
|
4
|
+
phases: ["Triage", "Capture", "Map", "Build", "Review", "Integrate", "Publish"],
|
|
5
5
|
steps: [
|
|
6
6
|
{ name: "Triage", identity: "sage" },
|
|
7
|
+
{ name: "Capture", identity: "hazel" },
|
|
7
8
|
{ name: "Map", identity: "mara" },
|
|
8
9
|
{ name: "Build", identity: "wren" },
|
|
9
10
|
{ name: "Review", identity: "cass" },
|
|
@@ -17,7 +18,6 @@ const inputs = args ?? {};
|
|
|
17
18
|
const taskId = inputs.task_id;
|
|
18
19
|
const taskTitle = inputs.task_title || "";
|
|
19
20
|
const taskDescription = inputs.task_description || "";
|
|
20
|
-
const firstSessionId = inputs.session_id || null;
|
|
21
21
|
const startStepIndex = inputs.start_step_index || 0;
|
|
22
22
|
|
|
23
23
|
// Config from args — backward-compatible fallbacks for manual launches
|
|
@@ -27,7 +27,7 @@ const ORCH_PATH = crewHome + "/.orchestration";
|
|
|
27
27
|
// Pin lifecycle scripts to this run
|
|
28
28
|
const LIFECYCLE_SRC = crewHome + "/lib/worktree-lifecycle.sh";
|
|
29
29
|
const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
|
|
30
|
-
const RUN_LIB = "/
|
|
30
|
+
const RUN_LIB = crewHome + "/.pins/" + taskId; // persistent disk, NOT /tmp (tmpfs wiped by cell reboots — canary b5efd1b1); stale pins reaped by orphan-sweep
|
|
31
31
|
const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
|
|
32
32
|
const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
|
|
33
33
|
const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
|
|
@@ -41,6 +41,21 @@ const projectConfig = inputs.project_config || {};
|
|
|
41
41
|
// compares the task's live project against this on every phase boundary.
|
|
42
42
|
const LAUNCH_PROJECT_ID = inputs.project_id || "";
|
|
43
43
|
const REPO_PATH = projectConfig.repo_path || "~/workspace/ts-spaces/orchestra-dashboard";
|
|
44
|
+
|
|
45
|
+
// Worktree layout: the task's worktree lives at
|
|
46
|
+
// <repo>/.worktrees/<task_id> on branch task/<task_id>. The lifecycle
|
|
47
|
+
// manager (lib/worktree-lifecycle.sh) owns creation and removal; the
|
|
48
|
+
// registry at <repo>/.worktrees/.registry/<task_id> is the source of
|
|
49
|
+
// truth for task→branch/path.
|
|
50
|
+
// Env prefix baked into every lifecycle invocation the agents run.
|
|
51
|
+
const LIFECYCLE_ENV = "CREW_REPO=" + REPO_PATH + " ";
|
|
52
|
+
// The task branch is always task/<task_id>.
|
|
53
|
+
const TASK_BRANCH = "task/" + taskId;
|
|
54
|
+
// Where the agent works.
|
|
55
|
+
const WORKTREE_HINT = REPO_PATH + "/.worktrees/" + taskId;
|
|
56
|
+
// Where preserved work lives when the rework budget is exhausted.
|
|
57
|
+
const WORKTREE_PRESERVED_HINT = ".worktrees/" + taskId;
|
|
58
|
+
|
|
44
59
|
const PUBLISH_TYPE = projectConfig.deploy_type || "";
|
|
45
60
|
const PUBLISH_SLUG = projectConfig.deploy_slug || DASHBOARD_SLUG;
|
|
46
61
|
const PROJECT_DESC = projectConfig.description || "React + TypeScript web dashboard (client/src/, server/src/, drizzle/)";
|
|
@@ -50,17 +65,302 @@ if (!taskId) {
|
|
|
50
65
|
throw new Error("task_id is required in args");
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
// Closeout is deterministic: the work agent returns the runtime's native
|
|
69
|
+
// transport envelope {"status": "ok", "result": "<prose report>"} with no
|
|
70
|
+
// schema, so the workflow receives the report as a plain string. There is no
|
|
71
|
+
// {"report"} wrapper: that invented shape invited agents to improvise sibling
|
|
72
|
+
// keys (notably "status"), which the runtime duck-types as its own envelope
|
|
73
|
+
// and fatally misparses. The envelope is the runtime's own documented shape
|
|
74
|
+
// — not a demand for machine-structured reasoning.
|
|
75
|
+
// The verdict is extracted mechanically by extractVerdict below — never by an
|
|
76
|
+
// agent. The summary is the worker's report truncated. The release decision
|
|
77
|
+
// comes from extractReleaseDecision. No formatter agent: it added a failure
|
|
78
|
+
// mode while contributing nothing the workflow doesn't compute itself.
|
|
79
|
+
// Steps whose passed=false drives a control-flow branch (rework bounce,
|
|
80
|
+
// block) declare their verdict explicitly on a VERDICT: line. The verdict is
|
|
81
|
+
// extracted DETERMINISTICALLY by workflow code (extractVerdict) — never by
|
|
82
|
+
// an agent. Missing, malformed, or contradictory lines fail the phase (never silently pass).
|
|
83
|
+
const VERDICT_STEPS = ["Build", "Review", "Integrate", "Publish"];
|
|
84
|
+
function extractVerdict(workerText) {
|
|
85
|
+
// The verdict is the LAST VERDICT: PASS/FAIL in the report (contract: end
|
|
86
|
+
// your report with the verdict). This ignores literal VERDICT strings echoed
|
|
87
|
+
// from the worker's instructions (which contain quoted examples). Fail
|
|
88
|
+
// closed if: no verdict found, the last verdict is not in the trailing 100
|
|
89
|
+
// chars (verdict must be at the end), or conflicting verdicts appear in the
|
|
90
|
+
// trailing 200 chars. Word boundary prevents "PASSING" matching as PASS.
|
|
91
|
+
var text = workerText || "";
|
|
92
|
+
var regex = /VERDICT:\s*(PASS|FAIL)\b/gi;
|
|
93
|
+
var matches = [];
|
|
94
|
+
var m;
|
|
95
|
+
while ((m = regex.exec(text)) !== null) {
|
|
96
|
+
matches.push({ value: /FAIL/i.test(m[0]) ? "FAIL" : "PASS", index: m.index });
|
|
97
|
+
}
|
|
98
|
+
if (matches.length === 0) return { ok: false, count: 0 };
|
|
99
|
+
var last = matches[matches.length - 1];
|
|
100
|
+
if (last.index < text.length - 100) return { ok: false, count: matches.length };
|
|
101
|
+
var trailing = matches.filter(function (x) { return x.index >= text.length - 200; });
|
|
102
|
+
var uniq = trailing.map(function (x) { return x.value; }).filter(function (v, i, a) { return a.indexOf(v) === i; });
|
|
103
|
+
if (uniq.length !== 1) return { ok: false, count: matches.length };
|
|
104
|
+
return { ok: true, passed: last.value === "PASS" };
|
|
105
|
+
}
|
|
106
|
+
// Verdict re-ask (bug cd18ccc2): a verdict-step report that fails
|
|
107
|
+
// extractVerdict is not failed immediately. Stochastic verdict-line
|
|
108
|
+
// non-compliance (the agent did the work but omitted or garbled the VERDICT
|
|
109
|
+
// line) gets up to two bounded follow-up agent() calls whose only job is to
|
|
110
|
+
// read the preserved report and emit exactly one VERDICT line. The verdict
|
|
111
|
+
// is still extracted mechanically by extractVerdict — the re-ask agent
|
|
112
|
+
// transcribes, never decides the phase outcome. Each attempt uses a fresh
|
|
113
|
+
// stable-key suffix so a cached failure can never replay deterministically.
|
|
114
|
+
// Exhaustion keeps the existing fail-closed behavior. This is structure, not
|
|
115
|
+
// prompt hardening: no instruction text was stern-ified to get here.
|
|
116
|
+
function verdictReaskKey(stepName, reworkSuffix, attempt) {
|
|
117
|
+
return "verdict-reask-" + stepName + reworkSuffix + "-a" + attempt;
|
|
118
|
+
}
|
|
119
|
+
function buildVerdictReaskPrompt(stepName, workerText) {
|
|
120
|
+
return "Mechanical transcription task. Read the work report below and emit its verdict.\n\n" +
|
|
121
|
+
"WORK REPORT (verbatim):\n" + workerText + "\n\n" +
|
|
122
|
+
"Decide from the report's own content whether the " + stepName + " step clearly describes successful completion: " +
|
|
123
|
+
"if it does, the verdict is PASS; otherwise — failure, error, unfinished work, or unclear — the verdict is FAIL. " +
|
|
124
|
+
"Do NOT copy any VERDICT line from the report — decide from the content.\n\n" +
|
|
125
|
+
"Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your verdict line here\"}. " +
|
|
126
|
+
"The result must be exactly one line and nothing else: VERDICT: PASS or VERDICT: FAIL.";
|
|
127
|
+
}
|
|
128
|
+
async function reaskVerdict(stepName, reworkSuffix, workerText) {
|
|
129
|
+
var verdict = { ok: false, count: 0 };
|
|
130
|
+
for (var attempt = 1; attempt <= 2; attempt++) {
|
|
131
|
+
var reaskResult = null;
|
|
132
|
+
try {
|
|
133
|
+
reaskResult = await agent(buildVerdictReaskPrompt(stepName, workerText), {
|
|
134
|
+
key: verdictReaskKey(stepName, reworkSuffix, attempt),
|
|
135
|
+
label: "Verdict re-ask: " + stepName + " (attempt " + attempt + " of 2)",
|
|
136
|
+
timeoutMs: 180000
|
|
137
|
+
});
|
|
138
|
+
} catch (e) {
|
|
139
|
+
log(stepName + " verdict re-ask attempt " + attempt + " errored: " + ((e && e.message ? e.message : String(e)) || "").slice(0, 200));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
var reaskText = (typeof reaskResult === "string") ? reaskResult : "";
|
|
143
|
+
verdict = extractVerdict(reaskText);
|
|
144
|
+
if (verdict.ok) {
|
|
145
|
+
log(stepName + " verdict re-ask attempt " + attempt + " recovered verdict: " + (verdict.passed ? "PASS" : "FAIL"));
|
|
146
|
+
return verdict;
|
|
147
|
+
}
|
|
148
|
+
log(stepName + " verdict re-ask attempt " + attempt + " produced no readable verdict (" + verdict.count + " trailing-window matches)");
|
|
149
|
+
}
|
|
150
|
+
return verdict;
|
|
151
|
+
}
|
|
152
|
+
// Transport retry: the work-agent agent() call can throw even when the agent
|
|
153
|
+
// did the work. Stochastic envelope non-compliance (bare prose instead of
|
|
154
|
+
// the native {"status":"ok","result":"..."} envelope) trips the runtime's
|
|
155
|
+
// JSON-candidate heuristic when the prose contains a {...}-looking
|
|
156
|
+
// substring — canary 39457ee9's QA report quoted the change's own
|
|
157
|
+
// {/* ... */} JSX comment, the runtime tried to parse it as JSON, threw,
|
|
158
|
+
// and the workflow discarded a complete VERDICT: PASS report as "no output".
|
|
159
|
+
// The verdict re-ask covers an unreadable verdict inside a RECEIVED report;
|
|
160
|
+
// this covers the report never arriving. The assignment is retried boundedly
|
|
161
|
+
// with fresh keys (never a cached replay) before failing closed. Re-entry is
|
|
162
|
+
// safe: lifecycle scripts answer REUSED for existing worktrees/branches, the
|
|
163
|
+
// retry trailer tells the agent to check existing state first and report
|
|
164
|
+
// rather than duplicate completed side effects, and the rework path already
|
|
165
|
+
// re-runs Build after rejection — Build re-entry is an established pattern.
|
|
166
|
+
function workRetryKey(stepName, reworkSuffix, attempt) {
|
|
167
|
+
return "work-" + stepName + reworkSuffix + "-t" + attempt;
|
|
168
|
+
}
|
|
169
|
+
// replay-key scoping for bug 1b8bb875 — runtime keys agent() calls by explicit key per workflow process; on in-process rework a re-executed phase must mint fresh keys; uses the same -r<N> suffix as the phase loop; pure function of inputs, no clock, no randomness (determinism contract). The count is a parameter because chore.js names the counter reworkCount while standard.js/bugfix.js name it totalReworkCount.
|
|
170
|
+
function attemptKey(base, reworkCount) {
|
|
171
|
+
return base + (reworkCount > 0 ? "-r" + reworkCount : "");
|
|
172
|
+
}
|
|
173
|
+
function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
|
|
174
|
+
// reason: "discarded" (the runtime threw the output away — it could not be
|
|
175
|
+
// machine-read) or "empty" (agent() returned without throwing but produced
|
|
176
|
+
// nothing usable). The trailer tells the retry what to expect, not just to
|
|
177
|
+
// try again.
|
|
178
|
+
var why = reason === "empty"
|
|
179
|
+
? "your previous attempt returned no usable output"
|
|
180
|
+
: "your previous attempt's output could not be machine-read as JSON and was discarded";
|
|
181
|
+
return "\n\nTRANSPORT RETRY (attempt " + attempt + " of 2): " + why + ". " +
|
|
182
|
+
"First check existing state (worktree/branch at " + repoPath + "/.worktrees/" + taskId + ", the task branch, dashboard sessions for this task) — " +
|
|
183
|
+
"if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
|
|
184
|
+
"Then return your report as JSON in exactly the shape specified above.";
|
|
185
|
+
}
|
|
186
|
+
function describeWorkAgentFailure(stepName, identity, attempts) {
|
|
187
|
+
// Honest classification of a work-agent call that yielded no usable
|
|
188
|
+
// report, with the per-attempt evidence preserved in the session notes.
|
|
189
|
+
// Two distinct cases:
|
|
190
|
+
// - agent() THREW: the runtime discarded output it could not machine-read
|
|
191
|
+
// (e.g. prose tripping the JSON-candidate heuristic). The raw output is
|
|
192
|
+
// gone — the workflow never received it — so the surviving error text is
|
|
193
|
+
// recorded here instead. (An earlier comment claimed the raw output was
|
|
194
|
+
// "preserved in the run record"; that was false — nothing preserved it —
|
|
195
|
+
// and the claim is removed.)
|
|
196
|
+
// - agent() RETURNED EMPTY without throwing: the child produced nothing
|
|
197
|
+
// usable. Each attempt's outcome is the evidence.
|
|
198
|
+
// attempts: [{threw, error, outcome}, ...], in order.
|
|
199
|
+
var parts = [];
|
|
200
|
+
for (var i = 0; i < attempts.length; i++) {
|
|
201
|
+
var a = attempts[i];
|
|
202
|
+
parts.push("attempt " + (i + 1) + "/" + attempts.length + ": " +
|
|
203
|
+
(a.threw ? "threw '" + a.error + "'" : "returned " + a.outcome));
|
|
204
|
+
}
|
|
205
|
+
var detail = parts.join("; ").replace(/"/g, "'").slice(0, 400);
|
|
206
|
+
var anyThrow = false;
|
|
207
|
+
for (var j = 0; j < attempts.length; j++) {
|
|
208
|
+
if (attempts[j].threw) { anyThrow = true; break; }
|
|
209
|
+
}
|
|
210
|
+
if (anyThrow) {
|
|
211
|
+
return {
|
|
212
|
+
notes: "Work agent produced no machine-readable report after " + attempts.length + " attempts; runtime discarded the output (" + detail + ")",
|
|
213
|
+
eventMessage: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts — phase failed, dispatcher will retry",
|
|
214
|
+
blockedReason: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts",
|
|
215
|
+
message: "The " + identity + " agent's output could not be machine-read (" + attempts.length + " attempts exhausted); the runtime discarded the raw output before the workflow could see it. Surviving evidence: " + detail
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
notes: "Work agent returned no usable output after " + attempts.length + " attempts (" + detail + ")",
|
|
220
|
+
eventMessage: stepName + " work agent returned no usable output after " + attempts.length + " attempts — phase failed, dispatcher will retry",
|
|
221
|
+
blockedReason: stepName + " work agent returned no usable output",
|
|
222
|
+
message: "The " + identity + " agent returned no usable output after " + attempts.length + " attempts (" + detail + ")."
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Release declaration extraction: deterministic. The Build worker declares
|
|
227
|
+
// release:/version_bump: in its report; workflow code reads them directly
|
|
228
|
+
// from the full worker report. A missing or malformed declaration fails
|
|
229
|
+
// closed to null (Review then rejects).
|
|
230
|
+
function extractReleaseDecision(workerText) {
|
|
231
|
+
var t = workerText || "";
|
|
232
|
+
var r = /release:\s*(yes|no)\b/i.exec(t);
|
|
233
|
+
if (!r) return null;
|
|
234
|
+
var b = /version_bump:\s*(patch|minor|major)\b/i.exec(t);
|
|
235
|
+
var rel = r[1].toLowerCase();
|
|
236
|
+
if (rel === "yes" && !b) return null;
|
|
237
|
+
return { release: rel, version_bump: b ? b[1].toLowerCase() : null };
|
|
238
|
+
}
|
|
239
|
+
// Marker line preservation: machine-readable lines (repo_diff:, release:,
|
|
240
|
+
// version_bump:, VERDICT:, TARGET_VERSION=, published:) are extracted from
|
|
241
|
+
// the full worker report and appended after the summary slice, so a long
|
|
242
|
+
// report can never amputate them. Later phases read these from session notes.
|
|
243
|
+
function extractMarkerLines(workerText) {
|
|
244
|
+
var lines = (workerText || "").split("\n");
|
|
245
|
+
var markers = [];
|
|
246
|
+
for (var i = 0; i < lines.length; i++) {
|
|
247
|
+
var line = lines[i].trim();
|
|
248
|
+
if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:)/i.test(line)) {
|
|
249
|
+
markers.push(line);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return markers.join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Visual verdict shared functions: byte-identical across standard.js,
|
|
256
|
+
// bugfix.js, and chore.js (pinned by tests/visual-verdict.test.js — same
|
|
257
|
+
// contract as buildTransportRetryTrailer).
|
|
258
|
+
function extractExperiential(workerText) {
|
|
259
|
+
// Reads Sage's experiential: yes|no marker line. Missing or malformed
|
|
260
|
+
// fails closed to null — the flag is opt-in; null is treated as
|
|
261
|
+
// non-experiential (never silently parks a task on a garbled line).
|
|
262
|
+
var t = workerText || "";
|
|
263
|
+
var r = /experiential:\s*(yes|no)\b/i.exec(t);
|
|
264
|
+
if (!r) return null;
|
|
265
|
+
return r[1].toLowerCase() === "yes";
|
|
266
|
+
}
|
|
267
|
+
function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets) {
|
|
268
|
+
// Deterministic visual-capture frame. kind: "baseline" | "postchange".
|
|
269
|
+
// This string IS the capture script: fixed viewport matrix, scroll
|
|
270
|
+
// positions, and interaction states — the inspection agent executes it
|
|
271
|
+
// verbatim, nothing is improvised. Task-specific targets fill the slots.
|
|
272
|
+
var title = String(taskTitle || "").replace(/"/g, "'").slice(0, 120);
|
|
273
|
+
var targets = String(captureTargets || "").trim() ||
|
|
274
|
+
String(taskDescription || "").replace(/"/g, "'").slice(0, 300);
|
|
275
|
+
return "VISUAL CAPTURE — " + kind.toUpperCase() + " — task: " + title + ". " +
|
|
276
|
+
"Target views/controls: " + targets + ". " +
|
|
277
|
+
"For EACH target, capture exactly: " +
|
|
278
|
+
"(1) desktop 1440x900, full view, scrolled to top; " +
|
|
279
|
+
"(2) desktop 1440x900, scrolled so the target is vertically centered; " +
|
|
280
|
+
"(3) mobile 390x844, scrolled so the target is vertically centered; " +
|
|
281
|
+
"(4) desktop 1440x900, hover state on the target control; " +
|
|
282
|
+
"(5) desktop 1440x900, keyboard-focus state on the target control; " +
|
|
283
|
+
"(6) desktop 1440x900, active/pressed state if the target is a button or control. " +
|
|
284
|
+
"Also record: console error count, the ARIA tree of the target region, any horizontal overflow. " +
|
|
285
|
+
"Name captures " + kind + "-<n>-<viewport>-<state>. Return the captures, not a summary.";
|
|
286
|
+
}
|
|
287
|
+
// Experiential flag resolution: the task is experiential when Sage's Triage
|
|
288
|
+
// report ends with the machine-read marker "experiential: yes". The flag is
|
|
289
|
+
// opt-in — a missing or garbled line degrades to "unknown", which callers
|
|
290
|
+
// treat as non-experiential. The task is never parked on a garbled line.
|
|
291
|
+
async function resolveExperiential() {
|
|
292
|
+
if (isExperiential === true) return "yes";
|
|
293
|
+
if (isExperiential === false) return "no";
|
|
294
|
+
var expCheck = null;
|
|
295
|
+
try {
|
|
296
|
+
expCheck = await agent(
|
|
297
|
+
"Find this task's Triage step session notes from the dashboard.\n" +
|
|
298
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }.\n" +
|
|
299
|
+
"Find the session with task_id \"" + taskId + "\" and step \"Triage\" (status completed) in the returned sessions array and read its notes field.\n" +
|
|
300
|
+
"Return JSON { \"experiential_line\": \"<the exact text of the experiential: marker line from the notes, or empty string if absent>\" } and nothing else.",
|
|
301
|
+
{
|
|
302
|
+
key: "resolve-experiential-" + taskId,
|
|
303
|
+
label: "Resolving experiential flag from Triage notes",
|
|
304
|
+
schema: { type: "object", properties: { experiential_line: { type: "string" } }, required: ["experiential_line"] }
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
|
|
309
|
+
return "unknown";
|
|
310
|
+
}
|
|
311
|
+
var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
|
|
312
|
+
if (marker === true) return "yes";
|
|
313
|
+
if (marker === false) return "no";
|
|
314
|
+
return "unknown";
|
|
315
|
+
}
|
|
316
|
+
// Baseline evidence status: reads the task's note events for the exact
|
|
317
|
+
// protocol prefixes (explicit state, never English matching). Returns
|
|
318
|
+
// { baseline_found, baseline_kind, baseline_refs, requested_count }.
|
|
319
|
+
// found = any note starting exactly "baseline: captured" or "baseline:
|
|
320
|
+
// none" (kind/refs come from the LATEST such message); requested_count =
|
|
321
|
+
// the number of notes starting exactly "baseline: requested". Each call
|
|
322
|
+
// uses a fresh key: the evidence changes between calls (the parent logs
|
|
323
|
+
// the capture while this run is parked), so a cached replay would lie.
|
|
324
|
+
let baselineStatusCallCount = 0;
|
|
325
|
+
async function baselineStatus() {
|
|
326
|
+
baselineStatusCallCount++;
|
|
327
|
+
try {
|
|
328
|
+
var ev = await agent(
|
|
329
|
+
"Read this task's note events from the dashboard.\n" +
|
|
330
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
|
|
331
|
+
"Consider only events with type \"note\". For each message, check whether it starts exactly with \"baseline: captured\", \"baseline: none\", or \"baseline: requested\" (exact prefix, case-sensitive).\n" +
|
|
332
|
+
"Return JSON { \"baseline_found\": <true if any message starts exactly with \"baseline: captured\" or \"baseline: none\">, \"baseline_kind\": \"<\"captured\" or \"none\", from the LATEST such message, or empty string if none>\", \"baseline_refs\": \"<the text after the prefix in that latest message, or empty string>\", \"requested_count\": <count of messages starting exactly with \"baseline: requested\"> } and nothing else.",
|
|
333
|
+
{
|
|
334
|
+
key: "baseline-status-" + taskId + "-" + baselineStatusCallCount,
|
|
335
|
+
label: "Reading baseline evidence status",
|
|
336
|
+
schema: {
|
|
337
|
+
type: "object",
|
|
338
|
+
properties: {
|
|
339
|
+
baseline_found: { type: "boolean" },
|
|
340
|
+
baseline_kind: { type: "string" },
|
|
341
|
+
baseline_refs: { type: "string" },
|
|
342
|
+
requested_count: { type: "number" }
|
|
343
|
+
},
|
|
344
|
+
required: ["baseline_found", "baseline_kind", "baseline_refs", "requested_count"]
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
return {
|
|
349
|
+
baseline_found: !!(ev && ev.baseline_found),
|
|
350
|
+
baseline_kind: (ev && ev.baseline_kind) || "",
|
|
351
|
+
baseline_refs: (ev && ev.baseline_refs) || "",
|
|
352
|
+
requested_count: (ev && ev.requested_count) || 0
|
|
353
|
+
};
|
|
354
|
+
} catch (e) {
|
|
355
|
+
log("baselineStatus: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as no evidence");
|
|
356
|
+
return { baseline_found: false, baseline_kind: "", baseline_refs: "", requested_count: 0 };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
60
359
|
|
|
61
360
|
// STEPS inline — export const meta is parsed as metadata, not a runtime binding
|
|
62
361
|
const STEPS = [
|
|
63
362
|
{ name: "Triage", identity: "sage" },
|
|
363
|
+
{ name: "Capture", identity: "hazel" },
|
|
64
364
|
{ name: "Map", identity: "mara" },
|
|
65
365
|
{ name: "Build", identity: "wren" },
|
|
66
366
|
{ name: "Review", identity: "cass" },
|
|
@@ -69,13 +369,23 @@ const STEPS = [
|
|
|
69
369
|
];
|
|
70
370
|
const BUILD_INDEX = STEPS.findIndex(s => s.name === 'Build');
|
|
71
371
|
if (BUILD_INDEX < 0) throw new Error("STEPS missing 'Build' step");
|
|
372
|
+
const CAPTURE_INDEX = STEPS.findIndex(s => s.name === 'Capture');
|
|
373
|
+
if (CAPTURE_INDEX < 0) throw new Error("STEPS missing 'Capture' step");
|
|
72
374
|
const REWORK_STEP = STEPS[BUILD_INDEX].name;
|
|
73
375
|
const MAX_REWORK = 2;
|
|
74
376
|
let reworkCount = 0;
|
|
75
377
|
let rejectionNotes = inputs.rejection_notes || "";
|
|
76
378
|
let mapperSpec = "";
|
|
379
|
+
// Visual verdict state: Sage's experiential flag (true/false/null until the
|
|
380
|
+
// Triage report is read), Mara's capture targets for the post-change capture
|
|
381
|
+
// plan, and the Map-gate bounce counter (keeps agent stable-keys unique when
|
|
382
|
+
// a Map-gate bounce re-runs Capture/Map in the same run). Chore has no QA
|
|
383
|
+
// phase, so there is no visual-verdict function — Capture only.
|
|
384
|
+
let isExperiential = null;
|
|
385
|
+
let captureTargets = "";
|
|
386
|
+
let mapGateBounceCount = 0;
|
|
77
387
|
// Merge-time versioning: the release decision is extracted deterministically
|
|
78
|
-
// from the accepted Build
|
|
388
|
+
// from the accepted Build worker report (extractReleaseDecision) so the Publish
|
|
79
389
|
// agent never decides whether to publish. Two consecutive Publish runs
|
|
80
390
|
// rationalized a skip against explicit instruction text — text alone did not
|
|
81
391
|
// hold, so the decision now lives in workflow code, not agent judgment.
|
|
@@ -89,18 +399,37 @@ function bumpVersion(base, scope) {
|
|
|
89
399
|
if (scope === "minor") return p[0] + "." + (p[1] + 1) + ".0";
|
|
90
400
|
return p[0] + "." + p[1] + "." + (p[2] + 1); // patch (default)
|
|
91
401
|
}
|
|
92
|
-
function extractReleaseDecision(text) {
|
|
93
|
-
const t = text || "";
|
|
94
|
-
const r = /^release:\s*(yes|no)\s*$/im.exec(t);
|
|
95
|
-
if (!r) return null;
|
|
96
|
-
const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
|
|
97
|
-
if (r[1].toLowerCase() === "yes" && !b) return null;
|
|
98
|
-
return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
|
|
99
|
-
}
|
|
100
402
|
function releaseDecisionText() {
|
|
101
|
-
if (!releaseDecision) return "no
|
|
403
|
+
if (!releaseDecision) return "no machine-readable release decision from the Build report";
|
|
102
404
|
return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
|
|
103
405
|
}
|
|
406
|
+
// Park the task for human attention and end the run. "blocked" is never
|
|
407
|
+
// manually authored — the dashboard derives it mechanically from unmet
|
|
408
|
+
// dependencies — so a workflow outcome that needs a human parks the task
|
|
409
|
+
// instead. Parking is one atomic dashboard action (parktask): the parked
|
|
410
|
+
// state and the explanatory note land in one transaction, never half.
|
|
411
|
+
// The dispatcher skips parked tasks; a human moving parked→todo
|
|
412
|
+
// mechanically resets the retry counters. Returns the workflow result
|
|
413
|
+
// envelope the launcher sees. If the park call itself fails, the run
|
|
414
|
+
// reports "failed" (retryable) so the next tick re-attempts the park —
|
|
415
|
+
// a lost park is never reported as parked.
|
|
416
|
+
async function parkTask(reason) {
|
|
417
|
+
log("Parking task " + taskId + " for human attention: " + reason);
|
|
418
|
+
var parkMessage = ("Parked: " + reason).slice(0, 1000);
|
|
419
|
+
try {
|
|
420
|
+
await agent(
|
|
421
|
+
"Park this task for human attention.\n" +
|
|
422
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"parktask\", args: " +
|
|
423
|
+
JSON.stringify({ task_id: taskId, message: parkMessage }) + ".\n" +
|
|
424
|
+
"The parked state is the human-attention signal — the dispatcher skips parked tasks.",
|
|
425
|
+
{ key: "park-task", label: "Parking task for human attention", schema: { type: "object" } }
|
|
426
|
+
);
|
|
427
|
+
} catch (parkErr) {
|
|
428
|
+
log("PARK FAILED for task " + taskId + ": " + (parkErr && parkErr.message ? parkErr.message : parkErr) + " — park did not land, reporting failed so the next tick retries");
|
|
429
|
+
return { status: "failed", task_id: taskId, reason: "park failed: " + reason, park_failed: true };
|
|
430
|
+
}
|
|
431
|
+
return { status: "parked", task_id: taskId, reason: reason };
|
|
432
|
+
}
|
|
104
433
|
let i = startStepIndex;
|
|
105
434
|
|
|
106
435
|
// Pin lifecycle scripts
|
|
@@ -139,15 +468,14 @@ while (i < STEPS.length) {
|
|
|
139
468
|
if (currentProject !== LAUNCH_PROJECT_ID) {
|
|
140
469
|
const abortMessage = "Task project changed mid-run from '" + LAUNCH_PROJECT_ID + "' to '" + currentProject + "' — aborting stale run. The dispatcher will re-launch from " + REWORK_STEP + " with the new project context.";
|
|
141
470
|
log(abortMessage);
|
|
142
|
-
const abortSessionPatch = (isFirstClaim && firstSessionId) ? "\"id\": \"" + firstSessionId + "\", " : "";
|
|
143
471
|
await agent(
|
|
144
472
|
"Abort the stale run and remove its worktree from the old project's repo.\n" +
|
|
145
473
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
146
|
-
"{ \"task_id\": \"" + taskId + "\",
|
|
474
|
+
"{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + REWORK_STEP + "\", \"status\": \"failed\", " +
|
|
147
475
|
"\"notes\": " + JSON.stringify(abortMessage + " Rebuild from the Map session notes in the task's event history.") + " }.\n" +
|
|
148
476
|
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
149
477
|
"{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"identity\": \"" + step.identity + "\", \"message\": " + JSON.stringify(abortMessage) + " }.\n" +
|
|
150
|
-
"Then run:
|
|
478
|
+
"Then run: "+ LIFECYCLE_ENV + " cleanup " + taskId + "\n" +
|
|
151
479
|
"The cleanup output should contain CLEANUP.",
|
|
152
480
|
{ key: "abort-project-change", label: "Aborting stale run (project changed)", schema: { type: "object" } }
|
|
153
481
|
);
|
|
@@ -161,16 +489,16 @@ while (i < STEPS.length) {
|
|
|
161
489
|
log("Publish skipped for task " + taskId + " — no publish target configured (deploy_type empty)");
|
|
162
490
|
await agent(
|
|
163
491
|
"Release the merge lock and clean up without publishing.\n" +
|
|
164
|
-
"Run:
|
|
492
|
+
"Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
|
|
165
493
|
"If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
|
|
166
|
-
{ key: "publish-skip-cleanup", label: "Skipping Publish (no target)", schema: { type: "object" } }
|
|
494
|
+
{ key: attemptKey("publish-skip-cleanup", reworkCount), label: "Skipping Publish (no target)", schema: { type: "object" } }
|
|
167
495
|
);
|
|
168
496
|
i++;
|
|
169
497
|
continue;
|
|
170
498
|
}
|
|
171
499
|
if (step.name === "Publish" && PUBLISH_TYPE !== "npm" && PUBLISH_TYPE !== "artifact" && PUBLISH_TYPE !== "vercel") {
|
|
172
500
|
log("Unknown publish target for task " + taskId + ": " + PUBLISH_TYPE);
|
|
173
|
-
return
|
|
501
|
+
return await parkTask("Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish).");
|
|
174
502
|
}
|
|
175
503
|
|
|
176
504
|
// Merge-time versioning, decided deterministically: the release decision was
|
|
@@ -180,15 +508,15 @@ while (i < STEPS.length) {
|
|
|
180
508
|
// therefore has no decision point to rationalize into a skip.
|
|
181
509
|
if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
|
|
182
510
|
if (!releaseDecision) {
|
|
183
|
-
return
|
|
511
|
+
return await parkTask("Build report has no machine-readable release:/version_bump: declaration — cannot assign version at publish time.");
|
|
184
512
|
}
|
|
185
513
|
if (releaseDecision.release === "no") {
|
|
186
|
-
log("Publish skipped for task " + taskId + " — accepted Build
|
|
514
|
+
log("Publish skipped for task " + taskId + " — accepted Build report declared release: no");
|
|
187
515
|
await agent(
|
|
188
516
|
"Release the merge lock and clean up without publishing.\n" +
|
|
189
|
-
"Run:
|
|
517
|
+
"Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
|
|
190
518
|
"If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
|
|
191
|
-
{ key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
|
|
519
|
+
{ key: attemptKey("publish-skip-release-no", reworkCount), label: "Skipping Publish (release: no)", schema: { type: "object" } }
|
|
192
520
|
);
|
|
193
521
|
i++;
|
|
194
522
|
continue;
|
|
@@ -204,26 +532,50 @@ while (i < STEPS.length) {
|
|
|
204
532
|
"Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
|
|
205
533
|
"If the output is NOT_FOUND, run: node -p \"require('" + REPO_PATH + "/package.json').version\"\n" +
|
|
206
534
|
"Return JSON { \"base\": \"<the version string, trimmed>\" } and nothing else.",
|
|
207
|
-
{ key: "publish-base-" + taskId, label: "Reading registry base version",
|
|
535
|
+
{ key: attemptKey("publish-base-" + taskId, reworkCount), label: "Reading registry base version",
|
|
208
536
|
schema: { type: "object", properties: { base: { type: "string" } }, required: ["base"] } }
|
|
209
537
|
);
|
|
210
538
|
var pubScope = releaseDecision.version_bump || "patch";
|
|
211
539
|
var pubBase = (baseResult.base || "").trim();
|
|
212
540
|
if (!/^\d+\.\d+\.\d+$/.test(pubBase)) {
|
|
213
|
-
return
|
|
214
|
-
reason: "Publish base version unreadable: '" + pubBase + "'. Fail-closed." };
|
|
541
|
+
return await parkTask("Publish base version unreadable: '" + pubBase + "'. Fail-closed.");
|
|
215
542
|
}
|
|
216
543
|
publishTarget = { base: pubBase, scope: pubScope, target: bumpVersion(pubBase, pubScope) };
|
|
217
544
|
log("Publish target for task " + taskId + ": " + pubBase + " + " + pubScope + " -> " + publishTarget.target);
|
|
218
545
|
} catch (e) {
|
|
219
|
-
return
|
|
220
|
-
reason: "Deterministic pre-publish failed: " + (e && e.message ? e.message : e) + ". Fail-closed." };
|
|
546
|
+
return await parkTask("Deterministic pre-publish failed: " + (e && e.message ? e.message : e) + ". Fail-closed.");
|
|
221
547
|
}
|
|
222
548
|
}
|
|
223
549
|
|
|
550
|
+
// Self-claim — the dispatcher only recommends; the launched workflow claims
|
|
551
|
+
// the task as its first action, so a claim can never exist without a launched
|
|
552
|
+
// agent behind it. If another run claimed the task first (two poll ticks
|
|
553
|
+
// raced in the window before this run's claim), claimtask returns
|
|
554
|
+
// claimed:false and this run stands down as a duplicate.
|
|
224
555
|
let activeSessionId;
|
|
225
|
-
if (isFirstClaim
|
|
226
|
-
|
|
556
|
+
if (isFirstClaim) {
|
|
557
|
+
const claimResult = await agent(
|
|
558
|
+
"Claim this task for the " + step.name + " step.\n" +
|
|
559
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + taskId + "\", \"state\": \"in_progress\" }.\n" +
|
|
560
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
|
|
561
|
+
"{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started\" }.\n" +
|
|
562
|
+
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
563
|
+
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
564
|
+
{
|
|
565
|
+
key: "claim-" + step.name + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
566
|
+
label: "Claiming " + step.name,
|
|
567
|
+
schema: {
|
|
568
|
+
type: "object",
|
|
569
|
+
properties: { claimed: { type: "boolean" }, session_id: { type: "string" } },
|
|
570
|
+
required: ["claimed", "session_id"]
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
);
|
|
574
|
+
if (!claimResult.claimed) {
|
|
575
|
+
log("Standing down — task " + taskId + " was already claimed by another run");
|
|
576
|
+
return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
|
|
577
|
+
}
|
|
578
|
+
activeSessionId = claimResult.session_id;
|
|
227
579
|
} else {
|
|
228
580
|
const claimResult = await agent(
|
|
229
581
|
"Claim a session for this task step.\n" +
|
|
@@ -231,7 +583,7 @@ while (i < STEPS.length) {
|
|
|
231
583
|
"{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started" + (reworkCount > 0 ? " (rework #" + reworkCount + ")" : "") + "\" }.\n" +
|
|
232
584
|
"Return the session_id from the response.",
|
|
233
585
|
{
|
|
234
|
-
key: "claim-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
|
|
586
|
+
key: "claim-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
235
587
|
label: "Claiming " + step.name,
|
|
236
588
|
schema: {
|
|
237
589
|
type: "object",
|
|
@@ -243,84 +595,184 @@ while (i < STEPS.length) {
|
|
|
243
595
|
activeSessionId = claimResult.session_id;
|
|
244
596
|
}
|
|
245
597
|
|
|
598
|
+
// ── Capture: baseline evidence for experiential tasks ─────────────
|
|
599
|
+
// Hazel's QA capture pass runs right after Triage, before Map, for tasks
|
|
600
|
+
// Sage flagged experiential. The capture itself is parent-driven (the
|
|
601
|
+
// inspection handoff arrives at the root agent, outside this script), so
|
|
602
|
+
// when no baseline evidence is recorded yet the script logs a note event
|
|
603
|
+
// and parks with the exact parent protocol + resume path. Never fails the
|
|
604
|
+
// task over missing evidence: after two requests, baseline:none is
|
|
605
|
+
// recorded and the task continues without baseline comparison.
|
|
606
|
+
if (step.name === "Capture") {
|
|
607
|
+
var capExp = await resolveExperiential();
|
|
608
|
+
var bounceSuffix = (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : "");
|
|
609
|
+
if (capExp !== "yes" || PUBLISH_TYPE !== "artifact") {
|
|
610
|
+
log("Capture skipped for task " + taskId + " — " + (capExp !== "yes" ? "not experiential" : "publish target is not artifact"));
|
|
611
|
+
await agent(
|
|
612
|
+
"Update the session and log the event.\n" +
|
|
613
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
614
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": \"Capture skipped — not an experiential artifact task\" }.\n" +
|
|
615
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
616
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
|
|
617
|
+
{ key: "record-Capture" + bounceSuffix, label: "Recording Capture result", schema: { type: "object" } }
|
|
618
|
+
);
|
|
619
|
+
i++;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
var capStatus = await baselineStatus();
|
|
623
|
+
if (capStatus.baseline_found) {
|
|
624
|
+
log("Capture: baseline evidence already recorded for task " + taskId + " (" + capStatus.baseline_kind + ")");
|
|
625
|
+
await agent(
|
|
626
|
+
"Update the session and log the event.\n" +
|
|
627
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
628
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": " + JSON.stringify("Baseline evidence already recorded: " + capStatus.baseline_kind + " " + capStatus.baseline_refs) + " }.\n" +
|
|
629
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
630
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
|
|
631
|
+
{ key: "record-Capture" + bounceSuffix, label: "Recording Capture result", schema: { type: "object" } }
|
|
632
|
+
);
|
|
633
|
+
i++;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
var attemptN = capStatus.requested_count + 1;
|
|
637
|
+
if (capStatus.requested_count >= 2) {
|
|
638
|
+
log("Capture: baseline capture unavailable after 2 requests for task " + taskId + " — recording baseline:none");
|
|
639
|
+
await agent(
|
|
640
|
+
"Record that no baseline was capturable.\n" +
|
|
641
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
642
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"note\", \"identity\": \"" + step.identity + "\", \"message\": \"baseline: none (capture unavailable after 2 attempts)\" }.\n" +
|
|
643
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
644
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": \"baseline: none — continuing without baseline comparison\" }.\n" +
|
|
645
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
646
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
|
|
647
|
+
{ key: "record-Capture-none" + bounceSuffix, label: "Recording baseline: none", schema: { type: "object" } }
|
|
648
|
+
);
|
|
649
|
+
i++;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
log("Capture: requesting baseline capture (attempt " + attemptN + ") for task " + taskId);
|
|
653
|
+
await agent(
|
|
654
|
+
"Request the baseline capture and record the request.\n" +
|
|
655
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
656
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"note\", \"identity\": \"" + step.identity + "\", \"message\": \"baseline: requested (attempt " + attemptN + ")\" }.\n" +
|
|
657
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
658
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": " + JSON.stringify("Baseline capture requested (attempt " + attemptN + ") — parent protocol: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map. The Capture step re-checks baseline evidence when re-run.") + " }.",
|
|
659
|
+
{ key: "record-Capture-request" + bounceSuffix, label: "Recording baseline capture request", schema: { type: "object" } }
|
|
660
|
+
);
|
|
661
|
+
return await parkTask("Baseline capture requested (attempt " + attemptN + ") — parent: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Map gate: experiential tasks need baseline evidence ────────────
|
|
665
|
+
// Mara must verify baseline evidence exists before writing the spec. If
|
|
666
|
+
// missing, bounce back to Capture — never to Build, never a task failure.
|
|
667
|
+
var mapBaselineRefs = "";
|
|
668
|
+
var mapBaselineNone = false;
|
|
669
|
+
if (step.name === "Map") {
|
|
670
|
+
if ((await resolveExperiential()) === "yes") {
|
|
671
|
+
var gateStatus = await baselineStatus();
|
|
672
|
+
if (!gateStatus.baseline_found) {
|
|
673
|
+
log("Map gate: no baseline evidence for experiential task " + taskId + " — bouncing to Capture");
|
|
674
|
+
await agent(
|
|
675
|
+
"Record the Map gate bounce.\n" +
|
|
676
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
677
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"Map gate bounce: no baseline evidence recorded for this experiential task — no spec was written. Baseline evidence must be captured before the spec.\" }.\n" +
|
|
678
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
679
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"identity\": \"" + step.identity + "\", \"message\": \"Map gate bounce — baseline evidence missing, returning to Capture\" }.",
|
|
680
|
+
{ key: "record-Map-bounce" + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""), label: "Recording Map gate bounce", schema: { type: "object" } }
|
|
681
|
+
);
|
|
682
|
+
mapGateBounceCount++;
|
|
683
|
+
i = CAPTURE_INDEX;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
mapBaselineRefs = gateStatus.baseline_refs;
|
|
687
|
+
mapBaselineNone = (gateStatus.baseline_kind === "none");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
246
691
|
var safeTitle = taskTitle.replace(/"/g, "'").replace(/\\/g, "\\\\").replace(/`/g, "'");
|
|
247
692
|
var instructions = "";
|
|
248
693
|
|
|
249
694
|
if (step.name === "Triage") {
|
|
250
|
-
instructions = "Validate the task, check clarity, note dependencies, confirm the chore workflow assignment.\
|
|
695
|
+
instructions = "Validate the task, check clarity, note dependencies, confirm the chore workflow assignment.\nReport back in plain prose — what you found.\nEXPERIENTIAL FLAG: does this task change anything rendered and visible in the project's user-facing artifact (pages, components, styles, layout, copy, visual states)? If yes it is experiential and gets baseline captures. End your report with exactly one line on its own, lowercase, unrephrased: experiential: yes — or experiential: no. This line is machine-read.";
|
|
251
696
|
|
|
252
697
|
} else if (step.name === "Map") {
|
|
253
|
-
|
|
698
|
+
var mapGatePara = "";
|
|
699
|
+
if (mapBaselineRefs || mapBaselineNone) {
|
|
700
|
+
mapGatePara = "\nBASELINE GATE (experiential task): " +
|
|
701
|
+
(mapBaselineNone
|
|
702
|
+
? "no baseline was capturable (baseline: none recorded) — write the spec without baseline comparison and note it."
|
|
703
|
+
: "pre-change baseline captures: " + mapBaselineRefs + " — consult the affected views when writing the spec.") +
|
|
704
|
+
" If the baseline evidence is missing with no baseline:none recorded, do not write the spec — report 'baseline evidence missing — Map gate bounce required' and stop.\n" +
|
|
705
|
+
"Declare capture targets for the post-change visual capture: end your report with a line `capture_targets: <comma-separated views/controls this change affects>` (optional; falls back to the task description).";
|
|
706
|
+
}
|
|
707
|
+
instructions = "Research options, pick the path, write a clear spec for the builder.\nThe builder will edit source files in a git worktree of the project at " + REPO_PATH + ".\nProject: " + PROJECT_DESC + "\nTo understand the current code, read source files directly using the read tool. Do NOT use artifact_inspect — it is async and will not return in time.\nIdentify the exact files and changes needed. Be specific: file paths, what to add or change.\nReport back in plain prose — what you specified." + mapGatePara;
|
|
254
708
|
|
|
255
709
|
} else if (step.name === "Build") {
|
|
256
710
|
instructions = "STEP 1: Prepare your worktree.\n" +
|
|
257
|
-
"Run:
|
|
258
|
-
"If the output says CREATED or REUSED, proceed. If it says ERROR, stop and
|
|
711
|
+
"Run: "+ LIFECYCLE_ENV + " prepare " + taskId + "\n" +
|
|
712
|
+
"If the output says CREATED or REUSED, proceed. If it says ERROR, stop and report the failure clearly.\n\n" +
|
|
259
713
|
"STEP 2: Edit source files to implement the mapper's spec below.\n" +
|
|
260
714
|
(mapperSpec ? "MAPPER'S SPEC (implement exactly this):\n" + mapperSpec + "\n\n" : "") +
|
|
261
|
-
"Your working directory: " +
|
|
715
|
+
"Your working directory: " + WORKTREE_HINT + "/\n" +
|
|
262
716
|
"This is the project source: " + PROJECT_DESC + "\n" +
|
|
263
717
|
"Edit the TypeScript source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
|
|
264
718
|
"Do not add unrequested features.\n" +
|
|
265
719
|
"PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
|
|
266
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time — never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your
|
|
720
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time — never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your report with exactly these two lines:\n" +
|
|
267
721
|
"release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
|
|
268
722
|
"version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
|
|
269
723
|
"Example: release: yes\\nversion_bump: minor\n\n" : "") +
|
|
270
724
|
"STEP 3: Commit your changes.\n" +
|
|
271
|
-
"cd " +
|
|
725
|
+
"cd " + WORKTREE_HINT + "\n" +
|
|
272
726
|
"git add -A\n" +
|
|
273
727
|
"git commit -m \"chore: " + safeTitle + "\"\n\n" +
|
|
274
|
-
"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
|
|
728
|
+
"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" +
|
|
275
729
|
(rejectionNotes ? "REWORK after rejection. Address:\n" + rejectionNotes + "\n\n" : "") +
|
|
276
|
-
"
|
|
730
|
+
"Report back in plain prose: what you built and the outcome." +
|
|
731
|
+
(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 final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
|
|
277
732
|
|
|
278
733
|
} else if (step.name === "Review") {
|
|
279
734
|
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" +
|
|
280
735
|
(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") +
|
|
281
736
|
"Examine the code changes by running:\n" +
|
|
282
|
-
|
|
737
|
+
LIFECYCLE_ENV + LIFECYCLE + " inspect " + taskId + "\n\n" +
|
|
283
738
|
"The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of main. Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n" +
|
|
284
739
|
"You can also read specific files in the worktree at:\n" +
|
|
285
|
-
|
|
740
|
+
WORKTREE_HINT + "/\n\n" +
|
|
286
741
|
"Check quality, correctness, spec compliance.\n" +
|
|
287
|
-
"Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate,
|
|
288
|
-
"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
|
|
742
|
+
"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" +
|
|
743
|
+
"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" +
|
|
289
744
|
(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" +
|
|
290
|
-
"(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...
|
|
291
|
-
"(b) The accepted Build
|
|
745
|
+
"(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" +
|
|
746
|
+
"(b) The accepted Build report declares: " + releaseDecisionText() + ". " +
|
|
292
747
|
(releaseDecision
|
|
293
|
-
? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped,
|
|
294
|
-
: "The decision is missing or malformed —
|
|
295
|
-
"
|
|
296
|
-
"If it fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes\" }.\n" +
|
|
297
|
-
"No prose, no markdown, just the JSON object.";
|
|
748
|
+
? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, report your notes, then end with exactly this line: VERDICT: FAIL."
|
|
749
|
+
: "The decision is missing or malformed — report 'Build report must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines', then end with exactly this line: VERDICT: FAIL.") + "\n" : "") +
|
|
750
|
+
"Write your review as plain prose — findings, then decision. End your report with exactly one line: VERDICT: PASS if it passes, VERDICT: FAIL if it fails.";
|
|
298
751
|
|
|
299
752
|
} else if (step.name === "Integrate") {
|
|
300
753
|
instructions = "Merge the approved task branch into main.\n\n" +
|
|
301
|
-
"Run:
|
|
754
|
+
"Run: "+ LIFECYCLE_ENV + " integrate " + taskId + " \"merge: chore: " + safeTitle + "\"\n\n" +
|
|
302
755
|
"Read the output:\n" +
|
|
303
756
|
"- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
|
|
304
|
-
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit.
|
|
305
|
-
"- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish).
|
|
757
|
+
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). 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', then end your report with exactly this line: VERDICT: PASS. SKIP STEP 2 (push): there is no new commit to push.\n" +
|
|
758
|
+
"- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Report 'merge lock held', then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
306
759
|
"- 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" +
|
|
307
760
|
"RESOLUTION:\n" +
|
|
308
|
-
"R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep):
|
|
761
|
+
"R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): "+ LIFECYCLE_ENV + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge " + TASK_BRANCH + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch " + TASK_BRANCH + " is never modified.\n" +
|
|
309
762
|
"R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
|
|
310
|
-
"R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (
|
|
763
|
+
"R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock ("+ LIFECYCLE_ENV + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
|
|
311
764
|
"R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
|
|
312
765
|
"R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
|
|
313
766
|
"R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
|
|
314
|
-
"ESCALATE —
|
|
315
|
-
"- If it contains ERROR, something else failed.
|
|
767
|
+
"ESCALATE — report 'conflict needs human resolution', then end your report with exactly this line: VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
|
|
768
|
+
"- If it contains ERROR, something else failed. Report the error, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
|
|
316
769
|
"\n" +
|
|
317
770
|
"STEP 2: Push the merged main to the remote repository.\n" +
|
|
318
771
|
"Run: cd " + REPO_PATH + " && git push origin main\n" +
|
|
319
772
|
"- If the push succeeds, report the merged commit hash.\n" +
|
|
320
773
|
"- If the push is rejected as non-fast-forward (the remote has commits not present locally),\n" +
|
|
321
|
-
" NEVER force-push. Do not run any --force variant.
|
|
322
|
-
"
|
|
323
|
-
"Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true/false }. No prose, no markdown, just the JSON object.";
|
|
774
|
+
" NEVER force-push. Do not run any --force variant. Report 'git push origin main rejected as non-fast-forward — remote main has diverged; manual resolution required' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
|
|
775
|
+
"Report back in plain prose — what happened at each step — and end your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
|
|
324
776
|
|
|
325
777
|
} else if (step.name === "Publish") {
|
|
326
778
|
if (PUBLISH_TYPE === "npm") {
|
|
@@ -338,44 +790,146 @@ while (i < STEPS.length) {
|
|
|
338
790
|
" CREW_HOME=" + crewHome + " LIFECYCLE=" + LIFECYCLE + " RELEASE_SCRIPT=" + RELEASE_SCRIPT +
|
|
339
791
|
" bash " + PUBLISH_NPM + "\n\n" +
|
|
340
792
|
"Do NOT run npm, npm pack, npm publish, or the python publish script yourself. Do NOT compare local and registry versions. Do NOT decide whether to publish.\n\n" +
|
|
341
|
-
"If the command exits nonzero,
|
|
342
|
-
"If it exits zero, paste the script's COMPLETE marker block verbatim into your
|
|
793
|
+
"If the command exits nonzero, put the script's PUBLISH_FAILED line in your report, then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
794
|
+
"If it exits zero, paste the script's COMPLETE marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
|
|
343
795
|
"TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
|
|
344
|
-
"published: muse-crew@" + publishTarget.target + "\n
|
|
345
|
-
"
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
//
|
|
796
|
+
"published: muse-crew@" + publishTarget.target + "\n" +
|
|
797
|
+
"VERDICT: PASS\n\n";
|
|
798
|
+
} else if (PUBLISH_TYPE === "artifact") {
|
|
799
|
+
// Deterministic artifact publish (canary b5efd1b1, 2026-09-10): the work
|
|
800
|
+
// agent claimed "Rebuilt and deployed" while no build ran and no
|
|
801
|
+
// provenance was stamped — prose-trusted side effects, the same failure
|
|
802
|
+
// class as the npm double-skip (bb739316). The npm path already runs one
|
|
803
|
+
// deterministic script; the artifact path now has the same shape. Lock
|
|
804
|
+
// refresh, rebuild trigger, build-completion poll, provenance stamp, and
|
|
805
|
+
// post-deploy are narrow schema'd bookkeeping calls owned by the
|
|
806
|
+
// workflow — the work agent reports on the mechanical outcome and
|
|
807
|
+
// cannot skip what it never owned. Any step failing parks with an
|
|
808
|
+
// honest, step-specific reason (fail-closed). The post-hoc
|
|
809
|
+
// getprovenance-vs-HEAD verification below stays as the final gate.
|
|
810
|
+
var artifactPublish = null;
|
|
811
|
+
var publishLockRefreshed = false;
|
|
812
|
+
try {
|
|
813
|
+
// STEP 0 (mechanical): refresh the merge lock so a long build cannot
|
|
814
|
+
// go stale mid-publish.
|
|
815
|
+
var lockRefresh = await agent(
|
|
816
|
+
"Run: "+ LIFECYCLE_ENV + " refresh-lock " + taskId + "\n" +
|
|
817
|
+
"Return JSON { \"refreshed\": <true if the command exited zero, false otherwise>, \"output\": \"<trimmed stdout>\" } and nothing else.",
|
|
818
|
+
{ key: attemptKey("publish-lock-refresh-" + taskId, reworkCount), label: "Refreshing merge lock for publish",
|
|
819
|
+
schema: { type: "object", properties: { refreshed: { type: "boolean" }, output: { type: "string" } }, required: ["refreshed"] } }
|
|
820
|
+
);
|
|
821
|
+
if (!lockRefresh.refreshed) {
|
|
822
|
+
return await parkTask("Publish lock refresh failed: " + (lockRefresh.output || "no output") + ". Fail-closed — lock state unknown.");
|
|
823
|
+
}
|
|
824
|
+
publishLockRefreshed = true;
|
|
825
|
+
// STEP 1 (mechanical): trigger the rebuild with one narrow call. The
|
|
826
|
+
// agent only makes the artifact_edit call and reports whether it was
|
|
827
|
+
// accepted — no prose claim to trust. If the artifact tool namespace
|
|
828
|
+
// is missing from this child it reports honestly and the workflow
|
|
829
|
+
// retries once with a fresh key (bounded); anything else parks.
|
|
830
|
+
var rebuildPrompt =
|
|
831
|
+
"Call artifact_edit with slug \"" + PUBLISH_SLUG + "\" and verbatim_request:\n" +
|
|
832
|
+
"'Rebuild the application from current source. Do not modify any source files — just rebuild and deploy what is on disk.'\n" +
|
|
833
|
+
"If the artifact_edit tool is not available in this session, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_edit unavailable\" }.\n" +
|
|
834
|
+
"Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\" } and nothing else.";
|
|
835
|
+
var rebuildSchema =
|
|
836
|
+
{ type: "object", properties: { edit_started: { type: "boolean" }, error: { type: "string" } }, required: ["edit_started"] };
|
|
837
|
+
var rebuildTrigger = await agent(rebuildPrompt,
|
|
838
|
+
{ key: attemptKey("publish-artifact-rebuild-" + taskId, reworkCount), label: "Triggering artifact rebuild", schema: rebuildSchema });
|
|
839
|
+
if (!rebuildTrigger.edit_started && rebuildTrigger.error === "artifact_edit unavailable") {
|
|
840
|
+
log("Publish rebuild trigger: artifact_edit unavailable — one bounded retry with a fresh key");
|
|
841
|
+
rebuildTrigger = await agent(rebuildPrompt,
|
|
842
|
+
{ key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry1", reworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
|
|
843
|
+
}
|
|
844
|
+
var publishFailure = null;
|
|
845
|
+
if (rebuildTrigger.edit_started) {
|
|
846
|
+
// STEP 1b (mechanical): bounded poll for build completion.
|
|
847
|
+
var buildPoll = await agent(
|
|
848
|
+
"Poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 20 checks (10 minutes max).\n" +
|
|
849
|
+
"Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
|
|
850
|
+
{ key: attemptKey("publish-artifact-poll-" + taskId, reworkCount), label: "Waiting for artifact build to complete",
|
|
851
|
+
schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
|
|
852
|
+
timeoutMs: 660000 }
|
|
853
|
+
);
|
|
854
|
+
if (buildPoll.build_done) {
|
|
855
|
+
// STEP 1c (mechanical): stamp provenance from workflow-computed values.
|
|
856
|
+
var provStamp = await agent(
|
|
857
|
+
"Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this SRC.\n" +
|
|
858
|
+
"Run: basename $(readlink " + crewHome + "/current) — call this REL.\n" +
|
|
859
|
+
"Run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS.\n" +
|
|
860
|
+
"Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"setprovenance\", args:\n" +
|
|
861
|
+
"{ \"source_commit\": \"<SRC trimmed>\", \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\", \"task_id\": \"" + taskId + "\" }.\n" +
|
|
862
|
+
"Return JSON { \"stamped\": <true if the setprovenance response contains ok: true, false otherwise>, \"source_commit\": \"<SRC trimmed>\", \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
|
|
863
|
+
{ key: attemptKey("publish-artifact-stamp-" + taskId, reworkCount), label: "Stamping artifact provenance",
|
|
864
|
+
schema: { type: "object", properties: { stamped: { type: "boolean" }, source_commit: { type: "string" }, crew_release: { type: "string" }, published_at: { type: "string" } }, required: ["stamped", "source_commit", "crew_release", "published_at"] } }
|
|
865
|
+
);
|
|
866
|
+
if (provStamp.stamped) {
|
|
867
|
+
artifactPublish = { source_commit: provStamp.source_commit, crew_release: provStamp.crew_release, published_at: provStamp.published_at };
|
|
868
|
+
} else {
|
|
869
|
+
publishFailure = "Provenance stamp failed after a completed build (source_commit " + (provStamp.source_commit || "unknown") + "). The build landed but is unstamped — fail-closed.";
|
|
870
|
+
}
|
|
871
|
+
} else {
|
|
872
|
+
publishFailure = "Artifact build did not complete within budget: " + (buildPoll.status || "timeout") + ". The publish may or may not have landed — provenance was not stamped.";
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
|
|
876
|
+
}
|
|
877
|
+
// STEP 2 (mechanical, always once the lock was refreshed): post-deploy
|
|
878
|
+
// commits builder leftovers if any, removes the worktree, and releases
|
|
879
|
+
// the merge lock — even when the publish itself failed, so a skipped
|
|
880
|
+
// or failed publish can never leave the lock held (canary b5efd1b1).
|
|
881
|
+
var postDeploy = await agent(
|
|
882
|
+
"Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
|
|
883
|
+
"Return JSON { \"deployed\": <true if the output contains DEPLOYED, false otherwise>, \"output\": \"<trimmed output>\" } and nothing else.",
|
|
884
|
+
{ key: attemptKey("publish-postdeploy-" + taskId, reworkCount), label: "Finalizing publish (post-deploy)",
|
|
885
|
+
schema: { type: "object", properties: { deployed: { type: "boolean" }, output: { type: "string" } }, required: ["deployed"] } }
|
|
886
|
+
);
|
|
887
|
+
if (publishFailure) {
|
|
888
|
+
return await parkTask(publishFailure + (postDeploy.deployed
|
|
889
|
+
? " Post-deploy finalized cleanup."
|
|
890
|
+
: " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
|
|
891
|
+
}
|
|
892
|
+
if (!postDeploy.deployed) {
|
|
893
|
+
return await parkTask("Post-deploy failed after a stamped publish: " + (postDeploy.output || "no output") + ". The publish landed but worktree cleanup and lock release are unknown — human attention needed.");
|
|
894
|
+
}
|
|
895
|
+
log("Deterministic artifact publish completed for task " + taskId + ": provenance at " + artifactPublish.source_commit);
|
|
896
|
+
} catch (pubErr) {
|
|
897
|
+
// Best-effort cleanup: if the lock was refreshed, try to release it
|
|
898
|
+
// before parking so the failure cannot wedge the next run.
|
|
899
|
+
if (publishLockRefreshed) {
|
|
900
|
+
try {
|
|
901
|
+
await agent(
|
|
902
|
+
"Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
|
|
903
|
+
"Return JSON { \"deployed\": <true if the output contains DEPLOYED, false otherwise> } and nothing else.",
|
|
904
|
+
{ key: attemptKey("publish-postdeploy-cleanup-" + taskId, reworkCount), label: "Releasing lock after publish failure",
|
|
905
|
+
schema: { type: "object", properties: { deployed: { type: "boolean" } }, required: ["deployed"] } }
|
|
906
|
+
);
|
|
907
|
+
} catch (cleanupErr) {
|
|
908
|
+
log("Publish cleanup post-deploy also failed: " + (cleanupErr && cleanupErr.message ? cleanupErr.message : cleanupErr));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return await parkTask("Deterministic artifact publish failed: " + (pubErr && pubErr.message ? pubErr.message : pubErr) + ". Fail-closed.");
|
|
912
|
+
}
|
|
913
|
+
// The work agent no longer performs the publish — it reports on the
|
|
914
|
+
// mechanical outcome above. It must not rebuild or re-stamp: a second
|
|
915
|
+
// artifact_edit would trigger a duplicate build.
|
|
349
916
|
instructions = "Publish the merged code to the live artifact.\n\n" +
|
|
917
|
+
"The publish was performed deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would trigger a duplicate build or disturb the finalized state. You perform no publish actions.\n\n" +
|
|
918
|
+
"For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
|
|
919
|
+
"Mechanical outcome (every step succeeded and was verified by the workflow):\n" +
|
|
920
|
+
"- merge lock refreshed: yes\n" +
|
|
921
|
+
"- artifact rebuild triggered and completed: yes\n" +
|
|
922
|
+
"- provenance stamped: yes — source_commit " + artifactPublish.source_commit + ", crew_release " + artifactPublish.crew_release + ", published_at " + artifactPublish.published_at + "\n" +
|
|
923
|
+
"- post-deploy finalized: yes (worktree removed, merge lock released)\n\n" +
|
|
350
924
|
"POLICY: The live artifact is rebuilt only in this phase, from the repo. Never use artifact_edit to change the artifact directly — fixes go through the repo and the loop. A source fix is not done until the artifact is rebuilt from it here.\n\n" +
|
|
351
925
|
"The repo push already happened in Integrate — do NOT push to git in this phase.\n\n" +
|
|
352
|
-
"
|
|
353
|
-
"
|
|
354
|
-
|
|
355
|
-
"Get the change summary: cd " + REPO_PATH + " && git log -1 --stat\n" +
|
|
356
|
-
"Then call artifact_edit with slug \"" + PUBLISH_SLUG + "\" and verbatim_request:\n" +
|
|
357
|
-
"'Rebuild the application from current source. Do not modify any source files — just rebuild and deploy what is on disk.'\n" +
|
|
358
|
-
"Wait for the build to complete by polling artifact_status until it is no longer running.\n\n" +
|
|
359
|
-
"STEP 1B: Stamp publication provenance.\n" +
|
|
360
|
-
"Run: cd " + REPO_PATH + " && git rev-parse HEAD\n" +
|
|
361
|
-
"Run: basename $(readlink " + crewHome + "/current)\n" +
|
|
362
|
-
"Run: date -u +%Y-%m-%dT%H:%M:%SZ\n" +
|
|
363
|
-
"Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"setprovenance\", args:\n" +
|
|
364
|
-
"{ \"source_commit\": \"<rev-parse output>\", \"crew_release\": \"<basename output>\", \"published_at\": \"<date output>\", \"task_id\": \"" + taskId + "\" }.\n" +
|
|
365
|
-
"Confirm the response contains ok: true. If setprovenance fails, report it in your summary but still run post-deploy to release the lock.\n\n" +
|
|
366
|
-
"STEP 2: Finalize.\n" +
|
|
367
|
-
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
|
|
368
|
-
"If the output contains DEPLOYED, publishing is complete.\n\n" +
|
|
369
|
-
"If artifact_edit failed, still run post-deploy to release the merge lock and clean up.\n" +
|
|
370
|
-
"Report the failure: { \"passed\": false, \"summary\": \"artifact publish failed: [details]\" }.\n\n" +
|
|
371
|
-
"Your final response MUST be valid JSON and nothing else: { \"summary\": \"published changes\", \"passed\": true }.\n" +
|
|
372
|
-
"No prose, no markdown, just the JSON object.";
|
|
373
|
-
} else if (PUBLISH_TYPE === "vercel") {
|
|
926
|
+
"Write plain prose describing what was published, then on its own line: VERDICT: PASS\n" +
|
|
927
|
+
"The VERDICT line must be the last line of your report.";
|
|
928
|
+
} else if (PUBLISH_TYPE === "vercel") {
|
|
374
929
|
// vercel publish is not yet implemented — block without inventing behavior
|
|
375
930
|
instructions = "The project's publish target is \"vercel\", which is not yet implemented.\n" +
|
|
376
931
|
"Do NOT invent publish behavior — do not guess CLI commands, APIs, or deployment steps.\n" +
|
|
377
|
-
"
|
|
378
|
-
"No prose, no markdown, just the JSON object.";
|
|
932
|
+
"Report the situation in prose, then end your report with exactly this line: VERDICT: FAIL.";
|
|
379
933
|
}
|
|
380
934
|
}
|
|
381
935
|
|
|
@@ -390,82 +944,214 @@ while (i < STEPS.length) {
|
|
|
390
944
|
"The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
|
|
391
945
|
}
|
|
392
946
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
947
|
+
// Work agent returns the runtime's native envelope {"status": "ok",
|
|
948
|
+
// "result": "<prose>"} with no schema. The runtime requires JSON output;
|
|
949
|
+
// the envelope is its own documented shape, so there is nothing for the
|
|
950
|
+
// agent to improvise. The workflow receives the prose report as a plain
|
|
951
|
+
// string. The verdict is still extracted deterministically from the report
|
|
952
|
+
// text by extractVerdict below — never by an agent.
|
|
953
|
+
var workPromptBase =
|
|
396
954
|
"Read the identity file at " + ORCH_PATH + "/identities/" + step.identity + ".md using the read tool, and embody that character fully.\n\n" +
|
|
397
955
|
"## Your Assignment\n\n" +
|
|
398
956
|
"Task: " + taskTitle + "\nTask ID: " + taskId + "\nDescription: " + taskDescription + "\nStep: " + step.name + "\n" +
|
|
399
957
|
(step.name !== "Review" ? "Dashboard slug: " + DASHBOARD_SLUG + "\n" : "") +
|
|
400
|
-
"\n## Instructions\n\n" + eventPreamble + instructions + "\n\nCONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\nStay in character. Do the work thoroughly
|
|
401
|
-
{
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
958
|
+
"\n## Instructions\n\n" + eventPreamble + instructions + "\n\nCONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\nStay in character. Do the work thoroughly.\n\n" +
|
|
959
|
+
"Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your report here\"}. " +
|
|
960
|
+
"The result is plain prose describing what you did and found. For verdict steps, end the report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
|
|
961
|
+
var workKeyBase = "work-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "");
|
|
962
|
+
var workerResult = null;
|
|
963
|
+
var workAttempts = [];
|
|
964
|
+
for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
|
|
965
|
+
var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workAttempt);
|
|
966
|
+
var retryReason = workAttempt === 0 ? null : (workAttempts[workAttempt - 1].threw ? "discarded" : "empty");
|
|
967
|
+
try {
|
|
968
|
+
workerResult = await agent(
|
|
969
|
+
workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),
|
|
970
|
+
{
|
|
971
|
+
key: workKey,
|
|
972
|
+
label: step.identity + ": " + step.name + " on \"" + taskTitle + "\"" + (workAttempt === 0 ? "" : " (transport retry " + workAttempt + " of 2)"),
|
|
973
|
+
timeoutMs: 3600000
|
|
974
|
+
}
|
|
975
|
+
);
|
|
976
|
+
// Success path: a non-blank string report is usable — keep it and stop
|
|
977
|
+
// retrying. Without this branch every attempt is discarded and the phase
|
|
978
|
+
// always fails (regression shipped in ecef136 when Date.now() was
|
|
979
|
+
// removed from this loop).
|
|
980
|
+
if (typeof workerResult === "string" && workerResult.trim()) {
|
|
981
|
+
if (workAttempt > 0) log(step.name + " work agent transport retry " + workAttempt + " returned a machine-readable report");
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
var emptyOutcome = workerResult === null ? "null" : (typeof workerResult === "string" ? "blank string" : typeof workerResult);
|
|
985
|
+
workAttempts.push({ threw: false, error: "", outcome: emptyOutcome });
|
|
986
|
+
log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 returned no usable output (" + emptyOutcome + ") — retrying with a fresh key");
|
|
987
|
+
workerResult = null;
|
|
988
|
+
} catch (e) {
|
|
989
|
+
var attemptErr = (e && e.message ? e.message : String(e)).replace(/"/g, "'").slice(0, 160);
|
|
990
|
+
workAttempts.push({ threw: true, error: attemptErr, outcome: "" });
|
|
991
|
+
log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 threw: " + attemptErr);
|
|
992
|
+
workerResult = null;
|
|
406
993
|
}
|
|
407
|
-
);
|
|
408
|
-
} catch (e) {
|
|
409
|
-
log(step.name + " agent failed: " + (e.message || String(e)).slice(0, 500));
|
|
410
|
-
stepResult = null;
|
|
411
994
|
}
|
|
995
|
+
// The report arrives as a plain string — the runtime unwraps the envelope.
|
|
996
|
+
var workerText = (typeof workerResult === "string") ? workerResult : null;
|
|
412
997
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
log(step.name + "
|
|
998
|
+
if (typeof workerText !== "string" || !workerText.trim()) {
|
|
999
|
+
var workFailure = describeWorkAgentFailure(step.name, step.identity, workAttempts);
|
|
1000
|
+
log(step.name + " " + workFailure.notes + " — marking failed for retry");
|
|
416
1001
|
await agent(
|
|
417
|
-
"Record
|
|
1002
|
+
"Record work failure.\n" +
|
|
418
1003
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
419
|
-
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"
|
|
1004
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"" + workFailure.notes + "\" }.\n" +
|
|
420
1005
|
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
421
|
-
"{ \"task_id\": \"" + taskId + "\", \"type\": \"
|
|
422
|
-
{ key: "record-block-" + step.name, label: "Recording
|
|
1006
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + workFailure.eventMessage + "\" }.",
|
|
1007
|
+
{ key: "record-block-" + step.name, label: "Recording work failure", schema: { type: "object" } }
|
|
423
1008
|
);
|
|
424
1009
|
return {
|
|
425
1010
|
__hatchWorkflowControl: "blocked",
|
|
426
1011
|
result: {
|
|
427
|
-
blocked_reason:
|
|
428
|
-
message:
|
|
1012
|
+
blocked_reason: workFailure.blockedReason,
|
|
1013
|
+
message: workFailure.message,
|
|
429
1014
|
task_id: taskId
|
|
430
1015
|
}
|
|
431
1016
|
};
|
|
432
1017
|
}
|
|
1018
|
+
// Verdict derivation (deterministic): for VERDICT_STEPS the workflow owns
|
|
1019
|
+
// the verdict — extracted by regex from the trailing window from the worker's own report.
|
|
1020
|
+
// A missing, malformed, or contradictory VERDICT line first gets a bounded
|
|
1021
|
+
// mechanical re-ask (reaskVerdict); only if that is exhausted does the phase
|
|
1022
|
+
// fail closed (blocked):
|
|
1023
|
+
// On verdict failure the session is marked "failed", NOT "blocked":
|
|
1024
|
+
// the dispatcher retries failed sessions at the same step, while a
|
|
1025
|
+
// "blocked" session is never picked up again (blocked is reserved for
|
|
1026
|
+
// unmet dependencies). The __hatchWorkflowControl: "blocked" return below
|
|
1027
|
+
// stays — that is the runtime's halt-the-run signal, a separate vocabulary.
|
|
1028
|
+
var verdictPassed = null;
|
|
1029
|
+
if (VERDICT_STEPS.indexOf(step.name) >= 0) {
|
|
1030
|
+
var verdict = extractVerdict(workerText);
|
|
1031
|
+
if (!verdict.ok) {
|
|
1032
|
+
// Bounded mechanical re-ask before failing the phase: the report may
|
|
1033
|
+
// be valid with a stochastically omitted or garbled verdict line.
|
|
1034
|
+
log(step.name + " verdict line missing or ambiguous (" + verdict.count + " trailing-window matches) — attempting bounded re-ask");
|
|
1035
|
+
verdict = await reaskVerdict(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workerText);
|
|
1036
|
+
}
|
|
1037
|
+
if (!verdict.ok) {
|
|
1038
|
+
log(step.name + " verdict re-ask exhausted — marking failed for retry");
|
|
1039
|
+
await agent(
|
|
1040
|
+
"Record verdict failure.\n" +
|
|
1041
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
1042
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)\" }.\n" +
|
|
1043
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
1044
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry\" }.",
|
|
1045
|
+
{ key: "record-block-" + step.name, label: "Recording verdict failure", schema: { type: "object" } }
|
|
1046
|
+
);
|
|
1047
|
+
return {
|
|
1048
|
+
__hatchWorkflowControl: "blocked",
|
|
1049
|
+
result: {
|
|
1050
|
+
blocked_reason: step.name + " worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)",
|
|
1051
|
+
message: "The " + step.identity + " agent's work may be valid — its report did not declare a verdict the workflow could read, and two bounded re-ask attempts could not transcribe one. The report is preserved in the workflow log.",
|
|
1052
|
+
task_id: taskId
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
verdictPassed = verdict.passed;
|
|
1057
|
+
}
|
|
433
1058
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
1059
|
+
// Deterministic closeout: no formatter agent. The verdict is mechanical
|
|
1060
|
+
// (extractVerdict above); the summary is the worker's report truncated.
|
|
1061
|
+
// For verdict steps passed comes from the verdict; for non-verdict steps
|
|
1062
|
+
// the worker producing output means the step passed.
|
|
1063
|
+
var stepResult = {
|
|
1064
|
+
passed: verdictPassed !== null ? verdictPassed : true,
|
|
1065
|
+
summary: workerText
|
|
1066
|
+
};
|
|
1067
|
+
const passed = stepResult.passed === true;
|
|
1068
|
+
// "rejected" is an explicit phase verdict routed through rework (Review).
|
|
1069
|
+
// Integrate/Publish work that did not finish is operational — "failed",
|
|
1070
|
+
// retryable under the dispatcher's consecutive-failure cap.
|
|
1071
|
+
const status = passed ? "completed" : (step.name === "Integrate" || step.name === "Publish" ? "failed" : "rejected");
|
|
437
1072
|
|
|
438
1073
|
// Deterministic publish verification: the agent cannot self-certify a publish.
|
|
1074
|
+
var publishVerified = false;
|
|
439
1075
|
if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
|
|
440
1076
|
try {
|
|
441
1077
|
var verifyResult = await agent(
|
|
442
1078
|
"Run: npm view muse-crew version 2>/dev/null. Return JSON { \"registry_version\": \"<output trimmed>\" } and nothing else.",
|
|
443
|
-
{ key: "verify-publish-" + taskId, label: "Verifying published version",
|
|
1079
|
+
{ key: attemptKey("verify-publish-" + taskId, reworkCount), label: "Verifying published version",
|
|
444
1080
|
schema: { type: "object", properties: { registry_version: { type: "string" } }, required: ["registry_version"] } }
|
|
445
1081
|
);
|
|
446
1082
|
var regVer = (verifyResult.registry_version || "").trim();
|
|
447
1083
|
if (regVer !== publishTarget.target) {
|
|
448
|
-
return
|
|
449
|
-
|
|
450
|
-
publishTarget.target + " (" + publishTarget.base + " + " + publishTarget.scope + "). The publish did not land." };
|
|
1084
|
+
return await parkTask("Publish verification failed: registry shows " + regVer + " but the release decision required " +
|
|
1085
|
+
publishTarget.target + " (" + publishTarget.base + " + " + publishTarget.scope + "). The publish did not land.");
|
|
451
1086
|
}
|
|
452
1087
|
log("Publish verified for task " + taskId + ": registry at " + regVer);
|
|
1088
|
+
publishVerified = true;
|
|
453
1089
|
} catch (e) {
|
|
454
|
-
return
|
|
455
|
-
reason: "Publish verification failed: could not read the registry version (" + (e && e.message ? e.message : e) + "). Fail-closed." };
|
|
1090
|
+
return await parkTask("Publish verification failed: could not read the registry version (" + (e && e.message ? e.message : e) + "). Fail-closed.");
|
|
456
1091
|
}
|
|
457
1092
|
}
|
|
458
1093
|
|
|
1094
|
+
// Artifact publish verification: the worker cannot self-certify a deploy.
|
|
1095
|
+
// The workflow reads the artifact's provenance and confirms it points at the
|
|
1096
|
+
// integrated commit. A stale or missing provenance means the publish did not
|
|
1097
|
+
// land — fail closed, do not trust the worker's prose.
|
|
1098
|
+
if (step.name === "Publish" && PUBLISH_TYPE === "artifact" && PUBLISH_SLUG) {
|
|
1099
|
+
try {
|
|
1100
|
+
var headResult = await agent(
|
|
1101
|
+
"Run: cd " + REPO_PATH + " && git rev-parse HEAD. Return JSON { \"head\": \"<output trimmed>\" } and nothing else.",
|
|
1102
|
+
{ key: attemptKey("verify-publish-head-" + taskId, reworkCount), label: "Reading integrated commit",
|
|
1103
|
+
schema: { type: "object", properties: { head: { type: "string" } }, required: ["head"] } }
|
|
1104
|
+
);
|
|
1105
|
+
var expectedCommit = (headResult.head || "").trim();
|
|
1106
|
+
var provResult = await agent(
|
|
1107
|
+
"Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}. " +
|
|
1108
|
+
"Return JSON { \"source_commit\": \"<provenance.source_commit>\", \"published_at\": \"<provenance.published_at>\" } and nothing else.",
|
|
1109
|
+
{ key: attemptKey("verify-publish-prov-" + taskId, reworkCount), label: "Verifying artifact provenance",
|
|
1110
|
+
schema: { type: "object", properties: { source_commit: { type: "string" }, published_at: { type: "string" } }, required: ["source_commit"] } }
|
|
1111
|
+
);
|
|
1112
|
+
var provCommit = (provResult.source_commit || "").trim();
|
|
1113
|
+
if (!provCommit || provCommit !== expectedCommit) {
|
|
1114
|
+
return await parkTask("Publish verification failed: artifact provenance shows source_commit '" + provCommit +
|
|
1115
|
+
"' but the integrated HEAD is '" + expectedCommit + "'. The publish did not land or provenance was not stamped.");
|
|
1116
|
+
}
|
|
1117
|
+
log("Publish verified for task " + taskId + ": artifact provenance at " + provCommit);
|
|
1118
|
+
publishVerified = true;
|
|
1119
|
+
} catch (e) {
|
|
1120
|
+
return await parkTask("Publish verification failed: could not read artifact provenance (" + (e && e.message ? e.message : e) + "). Fail-closed.");
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Session notes. Machine-readable marker lines are extracted from the full
|
|
1125
|
+
// worker report and appended AFTER the slice so a long report can never
|
|
1126
|
+
// amputate them; later phases (Review reading repo_diff:, QA backstop
|
|
1127
|
+
// reading TARGET_VERSION=) depend on them.
|
|
1128
|
+
let summary;
|
|
1129
|
+
var workerMarkers = extractMarkerLines(workerText);
|
|
1130
|
+
if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget && publishVerified) {
|
|
1131
|
+
const markerLines = "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
|
|
1132
|
+
"published: muse-crew@" + publishTarget.target;
|
|
1133
|
+
summary = (stepResult.summary || "Step completed").slice(0, 2000 - markerLines.length - workerMarkers.length - 2) + "\n" + markerLines + (workerMarkers ? "\n" + workerMarkers : "");
|
|
1134
|
+
} else {
|
|
1135
|
+
summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
|
|
1136
|
+
}
|
|
1137
|
+
|
|
459
1138
|
// Capture mapper's spec for Build and Review
|
|
460
1139
|
if (step.name === "Map" && passed) {
|
|
461
1140
|
mapperSpec = summary;
|
|
1141
|
+
var ctm = /capture_targets:\s*(.+)/i.exec(workerText);
|
|
1142
|
+
captureTargets = ctm ? ctm[1].trim().slice(0, 300) : "";
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Capture Sage's experiential flag (machine-read marker line).
|
|
1146
|
+
if (step.name === "Triage" && passed) {
|
|
1147
|
+
isExperiential = extractExperiential(workerText);
|
|
462
1148
|
}
|
|
463
1149
|
|
|
464
|
-
// Capture the accepted Build
|
|
465
|
-
//
|
|
466
|
-
//
|
|
1150
|
+
// Capture the accepted Build report's machine-readable release decision.
|
|
1151
|
+
// Deterministic extraction from the full worker report. A missing or
|
|
1152
|
+
// malformed declaration fails closed to null and Review rejects.
|
|
467
1153
|
if (step.name === "Build" && passed) {
|
|
468
|
-
releaseDecision = extractReleaseDecision(
|
|
1154
|
+
releaseDecision = extractReleaseDecision(workerText);
|
|
469
1155
|
}
|
|
470
1156
|
|
|
471
1157
|
await agent(
|
|
@@ -475,7 +1161,7 @@ while (i < STEPS.length) {
|
|
|
475
1161
|
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
476
1162
|
"{ \"task_id\": \"" + taskId + "\", \"type\": \"" + status + "\", \"identity\": \"" + step.identity + "\", \"message\": \"" + step.name + " " + status + " by " + step.identity + "\" }.",
|
|
477
1163
|
{
|
|
478
|
-
key: "record-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
|
|
1164
|
+
key: "record-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
479
1165
|
label: "Recording " + step.name + " result",
|
|
480
1166
|
schema: { type: "object" }
|
|
481
1167
|
}
|
|
@@ -484,8 +1170,8 @@ while (i < STEPS.length) {
|
|
|
484
1170
|
if (!passed && step.name === "Review") {
|
|
485
1171
|
reworkCount++;
|
|
486
1172
|
if (reworkCount > MAX_REWORK) {
|
|
487
|
-
log("Max rework attempts reached for task " + taskId + " — worktree preserved at
|
|
488
|
-
return
|
|
1173
|
+
log("Max rework attempts reached for task " + taskId + " — worktree preserved at " + WORKTREE_PRESERVED_HINT + " for manual inspection");
|
|
1174
|
+
return await parkTask("Exceeded " + MAX_REWORK + " rework attempts after Review rejection. Worktree preserved.");
|
|
489
1175
|
}
|
|
490
1176
|
rejectionNotes = summary;
|
|
491
1177
|
i = BUILD_INDEX;
|
|
@@ -495,12 +1181,12 @@ while (i < STEPS.length) {
|
|
|
495
1181
|
|
|
496
1182
|
if (!passed && step.name === "Integrate") {
|
|
497
1183
|
log("Integration failed for task " + taskId + ": " + summary);
|
|
498
|
-
return { status: "
|
|
1184
|
+
return { status: "failed", task_id: taskId, reason: "Integration failed: " + summary };
|
|
499
1185
|
}
|
|
500
1186
|
|
|
501
1187
|
if (!passed && step.name === "Publish") {
|
|
502
1188
|
log("Publish failed for task " + taskId + ": " + summary);
|
|
503
|
-
return { status: "
|
|
1189
|
+
return { status: "failed", task_id: taskId, reason: "Publish failed: " + summary };
|
|
504
1190
|
}
|
|
505
1191
|
|
|
506
1192
|
i++;
|