muse-crew 0.17.2 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +11 -0
- package/docs/decisions/composition-machinery.md +180 -0
- package/docs/decisions/publish-path.md +6 -0
- package/docs/decisions/workflow-core.md +5 -4
- package/lib/AGENTS.md +2 -1
- package/lib/bugfix/phases/build.js +168 -0
- package/lib/bugfix/phases/capture.js +170 -0
- package/lib/bugfix/phases/integrate.js +165 -0
- package/lib/bugfix/phases/map.js +129 -0
- package/lib/bugfix/phases/publish.js +592 -0
- package/lib/bugfix/phases/qa.js +356 -0
- package/lib/bugfix/phases/reproduce.js +254 -0
- package/lib/bugfix/phases/review.js +292 -0
- package/lib/bugfix/phases/triage.js +69 -0
- package/lib/chore/CONTRACT.md +181 -0
- package/lib/chore/DISPOSITION.md +98 -0
- package/lib/chore/extract.js +204 -0
- package/lib/chore/phase-lib.js +885 -0
- package/lib/chore/phases/build.js +110 -0
- package/lib/chore/phases/capture.js +82 -0
- package/lib/chore/phases/integrate.js +109 -0
- package/lib/chore/phases/map.js +81 -0
- package/lib/chore/phases/publish.js +543 -0
- package/lib/chore/phases/review.js +255 -0
- package/lib/chore/phases/triage.js +57 -0
- package/lib/chore/prompts/evidence-gatherer.js +41 -0
- package/lib/chore/prompts/evidence-gatherer.schema.json +1 -0
- package/lib/chore/prompts/tool-check.js +15 -0
- package/lib/chore/prompts/trailers.js +56 -0
- package/lib/chore/prompts/verdict-reask.js +28 -0
- package/lib/chore/prompts/verdict-reask.schema.json +1 -0
- package/lib/chore/prompts/work-agent.js +52 -0
- package/lib/chore/prompts/work-agent.schema.json +1 -0
- package/lib/chore/spawn-keys.js +44 -0
- package/lib/chore/spawn-vocab.js +87 -0
- package/lib/chore-run.js +538 -0
- package/lib/chore-tick.js +289 -0
- package/lib/crew-api.js +273 -0
- package/lib/crew-dispatch-worker.js +27 -7
- package/lib/crew-release.sh +7 -2
- package/lib/extract.js +252 -0
- package/lib/prompts/tool-check.js +18 -0
- package/lib/prompts/trailers.js +59 -0
- package/lib/prompts/verdict-reask.js +31 -0
- package/lib/prompts/verdict-reask.schema.json +1 -0
- package/lib/prompts/work-agent.js +56 -0
- package/lib/prompts/work-agent.schema.json +1 -0
- package/lib/reap-spawns.js +407 -0
- package/lib/schema.sql +12 -1
- package/lib/spawn-keys.js +47 -0
- package/lib/spawn-step.js +572 -0
- package/lib/standard/phases/build.js +120 -0
- package/lib/standard/phases/capture.js +163 -0
- package/lib/standard/phases/integrate.js +172 -0
- package/lib/standard/phases/map.js +119 -0
- package/lib/standard/phases/publish.js +565 -0
- package/lib/standard/phases/qa.js +399 -0
- package/lib/standard/phases/review.js +281 -0
- package/lib/standard/phases/triage.js +64 -0
- package/lib/test-detached-integrate.sh +47 -0
- package/lib/workflow-driver.js +605 -0
- package/lib/workflow-lib.js +1012 -0
- package/lib/workflow-spec.js +187 -0
- package/lib/worktree-lifecycle.sh +55 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +61 -9
- package/workflows/bugfix.js +17 -17
- package/workflows/chore.js +16 -16
- package/workflows/docs.js +14 -11
- package/workflows/standard.js +16 -16
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// lib/standard/phases/review.js — Review (cass) phase module (sandbox
|
|
2
|
+
// exit, Phase C Piece 1). Import-safe: no side effects on import, bare
|
|
3
|
+
// `node` exits 0.
|
|
4
|
+
//
|
|
5
|
+
// Reads: branch classification (mechanical git), npm package.json
|
|
6
|
+
// `version` state, Build session notes (release decision, repo_diff:none
|
|
7
|
+
// claim), Map session notes (mapper spec), claim/session context.
|
|
8
|
+
// Writes: completed/rejected session + event (notes carry the
|
|
9
|
+
// REVIEW_BASIS line), structured Review verdict row.
|
|
10
|
+
// Transitions: ADVANCE → Integrate on pass. FAIL (mechanical or Cass's
|
|
11
|
+
// verdict) records "rejected" and rewinds to Build against the SHARED
|
|
12
|
+
// Review/QA rework budget (MAX_TOTAL_REWORK = 2, derived durably from
|
|
13
|
+
// rejected events); exhaustion parks with the worktree preserved.
|
|
14
|
+
//
|
|
15
|
+
// Hybrid phase: the mechanical gates run BEFORE any creative review. A
|
|
16
|
+
// mechanical failure uses synthetic prose through the same extraction
|
|
17
|
+
// path and never dispatches Cass.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
runWorkBoundary, recordPhase, buildEventPreamble, summarizeReport,
|
|
21
|
+
closeoutPassed, ensureClaimed, latestSessionNotes, lifecycle, parkTask,
|
|
22
|
+
lifecycleEnvPrefix, log,
|
|
23
|
+
} from "../../workflow-lib.js";
|
|
24
|
+
import {
|
|
25
|
+
parseClassifyFerry, parseVersionFerry,
|
|
26
|
+
extractReleaseDecision, releaseDecisionText, extractRepoDiffNone,
|
|
27
|
+
} from "../../extract.js";
|
|
28
|
+
|
|
29
|
+
export const PHASE = { name: "Review", identity: "cass" };
|
|
30
|
+
const MAX_TOTAL_REWORK = 2;
|
|
31
|
+
|
|
32
|
+
// runClassifyGate — the mechanical branch classifier (blocker 34/42).
|
|
33
|
+
// Direct execFile, no agent ferry. UNKNOWN retries in place (up to 2);
|
|
34
|
+
// exhaustion parks honestly — never asserts empty-no-work.
|
|
35
|
+
async function runClassifyGate(env) {
|
|
36
|
+
var taskId = env.taskId;
|
|
37
|
+
var branchState = null;
|
|
38
|
+
var branchStateErr = "";
|
|
39
|
+
var classifyFailedReturn = "";
|
|
40
|
+
for (var classifyAttempt = 0; classifyAttempt <= 2 && branchState === null; classifyAttempt++) {
|
|
41
|
+
var parsed;
|
|
42
|
+
try {
|
|
43
|
+
var out = await lifecycle(env, "classify-branch", [taskId]);
|
|
44
|
+
classifyFailedReturn = JSON.stringify({ stdout: out.stdout, stderr: out.stderr }).slice(0, 500);
|
|
45
|
+
parsed = parseClassifyFerry({ stdout: out.stdout, stderr: out.stderr });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
var msg = String((e && e.message) || e);
|
|
48
|
+
classifyFailedReturn = msg.slice(0, 500);
|
|
49
|
+
parsed = { state: null, diagField: null, diagText: "", failReason: "classifier call failed: " + msg.slice(0, 120) };
|
|
50
|
+
}
|
|
51
|
+
if (parsed.state !== null) {
|
|
52
|
+
branchState = parsed.state;
|
|
53
|
+
branchStateErr = "";
|
|
54
|
+
if (parsed.diagField !== null) {
|
|
55
|
+
log("classify-branch DIAG pin (" + parsed.diagField + " field): " + parsed.diagText);
|
|
56
|
+
} else {
|
|
57
|
+
log("classify-branch: no DIAG: record-scan counter returned (scan unverifiable)");
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
branchStateErr = parsed.failReason;
|
|
61
|
+
if (classifyAttempt < 2) {
|
|
62
|
+
log("classify-branch unparseable (" + branchStateErr + ") — retrying in place (attempt " + (classifyAttempt + 1) + " of 2)");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (branchState === null) {
|
|
67
|
+
return { parked: await parkTask(env, "Branch state unclassifiable after 2 instrument retries (" + branchStateErr + "; last failure: " + classifyFailedReturn + ") — the classifier instrument failed, not the branch. Human attention needed: inspect the task branch directly to determine its state — no trustworthy branch classification was produced.") };
|
|
68
|
+
}
|
|
69
|
+
var alreadyMergedSha = null;
|
|
70
|
+
if (branchState.indexOf("already-merged:") === 0) {
|
|
71
|
+
alreadyMergedSha = branchState.slice("already-merged:".length);
|
|
72
|
+
log("Branch state already-merged: " + alreadyMergedSha + " (DIAG pin present — tripwire satisfied) — Cass reviews the frozen merge diff");
|
|
73
|
+
} else {
|
|
74
|
+
log("Branch state: " + branchState);
|
|
75
|
+
}
|
|
76
|
+
return { branchState: branchState, branchStateErr: branchStateErr, alreadyMergedSha: alreadyMergedSha };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// runVersionGate — the mechanical package.json `version` check (npm
|
|
80
|
+
// projects only). Same in-place retry shape as the classifier.
|
|
81
|
+
async function runVersionGate(env) {
|
|
82
|
+
var taskId = env.taskId;
|
|
83
|
+
var versionState = null;
|
|
84
|
+
var versionStateErr = "";
|
|
85
|
+
var versionFailedReturn = "";
|
|
86
|
+
for (var versionAttempt = 0; versionAttempt <= 2 && versionState === null; versionAttempt++) {
|
|
87
|
+
var parsed;
|
|
88
|
+
try {
|
|
89
|
+
var out = await lifecycle(env, "version-check", [taskId]);
|
|
90
|
+
versionFailedReturn = JSON.stringify({ stdout: out.stdout }).slice(0, 500);
|
|
91
|
+
parsed = parseVersionFerry({ stdout: out.stdout });
|
|
92
|
+
} catch (e) {
|
|
93
|
+
var msg = String((e && e.message) || e);
|
|
94
|
+
versionFailedReturn = msg.slice(0, 500);
|
|
95
|
+
parsed = { versionState: null, failReason: "version-check call failed: " + msg.slice(0, 120) };
|
|
96
|
+
}
|
|
97
|
+
if (parsed.versionState !== null) {
|
|
98
|
+
versionState = parsed.versionState;
|
|
99
|
+
versionStateErr = "";
|
|
100
|
+
} else {
|
|
101
|
+
versionStateErr = parsed.failReason;
|
|
102
|
+
if (versionAttempt < 2) {
|
|
103
|
+
log("version-check unparseable (" + versionStateErr + ") — retrying in place (attempt " + (versionAttempt + 1) + " of 2)");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (versionState === null) {
|
|
108
|
+
return { parked: await parkTask(env, "package.json `version` state unclassifiable after 2 instrument retries (" + versionStateErr + "; last failure: " + versionFailedReturn + ") — the version-check instrument failed, not the branch. Human attention needed: inspect the task branch directly to determine its state — no trustworthy version classification was produced.") };
|
|
109
|
+
}
|
|
110
|
+
var touched = (versionState === "touched");
|
|
111
|
+
log("Review: package.json `version` " + (touched ? "touched by the branch — mechanical FAIL, Cass not dispatched" : "untouched — mechanical check clean"));
|
|
112
|
+
return { versionTouched: touched };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// buildInstructions — pure function of ctx. The gate facts are stashed in
|
|
116
|
+
// ctx.state.memo.reviewGate by runPhase before the call (keeps the
|
|
117
|
+
// signature pure). Instruction text verbatim from workflows/standard.js
|
|
118
|
+
// (Review branch).
|
|
119
|
+
export function buildInstructions(ctx) {
|
|
120
|
+
var env = ctx.env;
|
|
121
|
+
var gate = (ctx.state.memo && ctx.state.memo.reviewGate) || {};
|
|
122
|
+
var reviewChangeExam, reviewBranchRule;
|
|
123
|
+
if (gate.branchState === "has-work") {
|
|
124
|
+
reviewChangeExam =
|
|
125
|
+
"Examine the code changes by running:\n" +
|
|
126
|
+
lifecycleEnvPrefix(env) + env.lifecycle + " inspect " + env.taskId + "\n\n" +
|
|
127
|
+
"The inspect output is authoritative: it prints the task branch's actual tip commit (TIP) and every commit ahead of the integration target (inspect prints the target name). Base your review ONLY on this output — do NOT run git log yourself to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n";
|
|
128
|
+
reviewBranchRule =
|
|
129
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): branch state = has-work. The branch has commits ahead of the integration target. Review their content; branch emptiness is settled and is not yours to judge.\n";
|
|
130
|
+
} else if (gate.branchState && gate.branchState.indexOf("already-merged:") === 0) {
|
|
131
|
+
reviewChangeExam =
|
|
132
|
+
"The change under review is the frozen merge " + gate.alreadyMergedSha + " — it is already on the integration target (see MECHANICAL FACT below). Examine it by running:\n" +
|
|
133
|
+
"cd " + env.repoPath + " && git diff " + gate.alreadyMergedSha + "^1 " + gate.alreadyMergedSha + "\n\n" +
|
|
134
|
+
"That first-parent diff is the frozen, attributable change. Base your review ONLY on this diff — do NOT run git log to pick commits, and do NOT discuss commit hashes from any other source (they may come from stale rework rounds or a different repo).\n\n";
|
|
135
|
+
reviewBranchRule =
|
|
136
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): branch state = already-merged:" + gate.alreadyMergedSha + ". The deliverable landed on the integration target via this task's own prior merge " + gate.alreadyMergedSha + " (verified ancestor of the integration target). The branch is empty by design — emptiness is settled fact, not a finding.\n";
|
|
137
|
+
} else if (gate.buildClaimedNoDiff) {
|
|
138
|
+
reviewChangeExam =
|
|
139
|
+
"The branch has no commits ahead of the integration target (see MECHANICAL FACT below) — there is no diff to examine. Judge the Build report's `repo_diff: none` claim on its plausibility.\n\n";
|
|
140
|
+
reviewBranchRule =
|
|
141
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): the branch has no commits ahead of the integration target and no task-attributed merge on the target was verified; the Build report declares `repo_diff: none` (no repo change). Approve ONLY if the task's deliverable is plausibly runtime state (e.g. a cron definition, scheduler change, or dashboard/config state created outside the repo). Otherwise report 'no commits ahead of the integration target and no plausible runtime-state deliverable — the branch is empty and no task-attributed merge was verified', then end your report with exactly this line: VERDICT: FAIL.\n";
|
|
142
|
+
} else {
|
|
143
|
+
// Mechanical FAIL — Cass is never dispatched (see the dispatch gate
|
|
144
|
+
// below). Instructions are unused; keep the shape.
|
|
145
|
+
reviewChangeExam = "";
|
|
146
|
+
reviewBranchRule = "";
|
|
147
|
+
}
|
|
148
|
+
return "Review independently and cold. You have NOT seen any reasoning from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
|
|
149
|
+
(gate.mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + gate.mapperSpec + "\n\n" : "Read the spec at exactly " + env.specPath + " (fall back to the task description if the file is absent).\n\n") +
|
|
150
|
+
reviewChangeExam +
|
|
151
|
+
"You can also read specific files in the worktree at:\n" +
|
|
152
|
+
env.worktreeHint + "/\n\n" +
|
|
153
|
+
"Check quality, correctness, and spec compliance.\n" +
|
|
154
|
+
(env.surfaceTerminal ? "TERMINAL UX REVIEW: judge the CLI surface against " + env.uxDoctrinePath + " — help accuracy, error quality, exit codes, output clarity. Reject when the bar is not met.\n" : "") +
|
|
155
|
+
(env.surfaceArtifact ? "ARTIFACT UX REVIEW: judge the rendered surface against " + env.uxDoctrinePath + " — alignment, spacing, hierarchy, composition, balance, finish, correctness. Reject when the bar is not met.\n" : "") +
|
|
156
|
+
"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" +
|
|
157
|
+
reviewBranchRule +
|
|
158
|
+
(env.publishType === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches.\n" +
|
|
159
|
+
"MECHANICAL FACT (computed by the workflow from git — never by a reviewer): the task branch did not change package.json's `version` field. A branch that touches `version` fails Review mechanically before any reviewer is dispatched, so this is settled — do not re-check it.\n" +
|
|
160
|
+
"The accepted Build report declares: " + releaseDecisionText(gate.releaseDecision) + ". " +
|
|
161
|
+
(gate.releaseDecision
|
|
162
|
+
? "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."
|
|
163
|
+
: "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" : "") +
|
|
164
|
+
"Write your review as plain prose — findings, then decision. End your report with exactly one line: VERDICT: PASS if the work passes, VERDICT: FAIL if it fails.";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function runPhase(ctx) {
|
|
168
|
+
var env = ctx.env, state = ctx.state;
|
|
169
|
+
var taskId = env.taskId;
|
|
170
|
+
var claimed = await ensureClaimed(env, state, PHASE);
|
|
171
|
+
if (claimed.type !== "CLAIMED") return claimed;
|
|
172
|
+
|
|
173
|
+
// Mechanical gates run BEFORE Cass is dispatched. Re-classified on
|
|
174
|
+
// every Review entry: rework rewinds here and the branch changed.
|
|
175
|
+
var cg = await runClassifyGate(env);
|
|
176
|
+
if (cg.parked) return cg.parked;
|
|
177
|
+
var versionTouched = false;
|
|
178
|
+
if (env.publishType === "npm") {
|
|
179
|
+
var vg = await runVersionGate(env);
|
|
180
|
+
if (vg.parked) return vg.parked;
|
|
181
|
+
versionTouched = vg.versionTouched;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Cross-phase handoffs re-derived from durable session notes.
|
|
185
|
+
var buildNotes = await latestSessionNotes(env, "Build", "completed");
|
|
186
|
+
state.memo.reviewGate = {
|
|
187
|
+
branchState: cg.branchState,
|
|
188
|
+
alreadyMergedSha: cg.alreadyMergedSha,
|
|
189
|
+
buildClaimedNoDiff: extractRepoDiffNone(buildNotes),
|
|
190
|
+
releaseDecision: extractReleaseDecision(buildNotes),
|
|
191
|
+
mapperSpec: await latestSessionNotes(env, "Map", "completed"),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// The empty-branch rule Cass used to adjudicate is gone. What she
|
|
195
|
+
// examines and what rule she applies depend on the classified state —
|
|
196
|
+
// never on her own judgment of git identity.
|
|
197
|
+
var mechanicalFailReason = (versionTouched ? "version-touched" : (cg.branchState === "empty-no-work" && !state.memo.reviewGate.buildClaimedNoDiff ? "empty-no-work" : null));
|
|
198
|
+
var mechanicalReviewFail = (mechanicalFailReason !== null);
|
|
199
|
+
var workerText, verdictPassed;
|
|
200
|
+
if (mechanicalReviewFail) {
|
|
201
|
+
log("Review: " + mechanicalFailReason + " — mechanical FAIL, Cass not dispatched");
|
|
202
|
+
workerText =
|
|
203
|
+
"MECHANICAL REVIEW VERDICT (written by the workflow — no reviewer was dispatched).\n" +
|
|
204
|
+
(mechanicalFailReason === "version-touched"
|
|
205
|
+
? "Package.json `version` (checked by the workflow from git): the task branch changed the `version` field. Versions are assigned at publish time — never in branches. Remove the version change.\n"
|
|
206
|
+
: "Branch state (classified by the workflow from git): empty-no-work — the task branch has no commits ahead of the integration target, " +
|
|
207
|
+
"no task-attributed merge on the target was verified" + (cg.branchStateErr ? " (branch classification itself failed: " + cg.branchStateErr + ")" : "") + ", " +
|
|
208
|
+
"and the accepted Build report declared no `repo_diff: none` deliverable. " +
|
|
209
|
+
"No change was reviewed because there is no change to review. The branch is empty and no task-attributed merge was verified.\n") +
|
|
210
|
+
"Worktree: " + env.worktreeHint + "\n" +
|
|
211
|
+
"Recovery: commit the deliverable on the task branch in the worktree above and re-run Review. If the deliverable is genuinely runtime state outside the repo, declare `repo_diff: none` in the Build report instead of leaving the branch empty without a declaration.\n" +
|
|
212
|
+
"VERDICT: FAIL";
|
|
213
|
+
verdictPassed = false;
|
|
214
|
+
} else {
|
|
215
|
+
var boundary = await runWorkBoundary(env, state, {
|
|
216
|
+
phase: PHASE.name, identity: PHASE.identity,
|
|
217
|
+
instructions: buildInstructions(ctx),
|
|
218
|
+
eventPreamble: buildEventPreamble(env, PHASE.name),
|
|
219
|
+
crewApiLine: false, verdictStep: true,
|
|
220
|
+
});
|
|
221
|
+
if (boundary.type !== "BOUNDARY_DONE") return boundary;
|
|
222
|
+
workerText = boundary.workerText;
|
|
223
|
+
verdictPassed = closeoutPassed(boundary);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
var passed = verdictPassed === true;
|
|
227
|
+
var summary = summarizeReport(workerText);
|
|
228
|
+
// Machine-readable review basis, prepended to the session notes for
|
|
229
|
+
// observability (proof rooms read the summary to see what Review
|
|
230
|
+
// examined and why a reviewer was or was not dispatched).
|
|
231
|
+
var reviewBasisNote;
|
|
232
|
+
if (mechanicalReviewFail) {
|
|
233
|
+
reviewBasisNote = "REVIEW_BASIS: none — mechanical FAIL (" + mechanicalFailReason + "), no reviewer dispatched";
|
|
234
|
+
} else if (cg.branchState.indexOf("already-merged:") === 0) {
|
|
235
|
+
reviewBasisNote = "REVIEW_BASIS: frozen merge " + cg.alreadyMergedSha + " — reviewed via git diff " + cg.alreadyMergedSha + "^1 " + cg.alreadyMergedSha;
|
|
236
|
+
} else if (cg.branchState === "has-work") {
|
|
237
|
+
reviewBasisNote = "REVIEW_BASIS: branch diff — reviewed via inspect output (task branch vs integration target)";
|
|
238
|
+
} else {
|
|
239
|
+
reviewBasisNote = "REVIEW_BASIS: runtime-state-none — no repo change; plausibility of Build's repo_diff: none claim";
|
|
240
|
+
}
|
|
241
|
+
summary = reviewBasisNote + "\n" + summary;
|
|
242
|
+
var reviewBasis =
|
|
243
|
+
mechanicalReviewFail ? "mechanical-fail" :
|
|
244
|
+
cg.branchState.indexOf("already-merged:") === 0 ? "frozen-merge:" + cg.alreadyMergedSha :
|
|
245
|
+
cg.branchState === "has-work" ? "branch-diff" : "runtime-state-none";
|
|
246
|
+
var status = passed ? "completed" : "rejected";
|
|
247
|
+
await recordPhase(env, {
|
|
248
|
+
task_id: taskId,
|
|
249
|
+
session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: status, notes: summary },
|
|
250
|
+
event: { task_id: taskId, type: status, identity: PHASE.identity, message: PHASE.name + " " + status + " by " + PHASE.identity },
|
|
251
|
+
// Structured, non-lossy Review verdict record. The session note above
|
|
252
|
+
// is truncated; the verdict row carries the full worker report as
|
|
253
|
+
// grounds, written in the same transaction. attempt is the rework
|
|
254
|
+
// round (0 = first Review).
|
|
255
|
+
verdict: {
|
|
256
|
+
step: "Review",
|
|
257
|
+
attempt: state.reworkCount,
|
|
258
|
+
// A mechanical FAIL has no reviewer — the workflow wrote the verdict.
|
|
259
|
+
reviewer: mechanicalReviewFail ? "workflow" : PHASE.identity,
|
|
260
|
+
verdict: passed ? "PASS" : "FAIL",
|
|
261
|
+
review_basis: reviewBasis,
|
|
262
|
+
grounds: workerText,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (!passed) {
|
|
267
|
+
// Shared rework budget: Review and QA rejections draw from the SAME
|
|
268
|
+
// pool of 2 (source: MAX_TOTAL_REWORK, derived durably by counting
|
|
269
|
+
// "rejected" events). The rejected event above is already recorded,
|
|
270
|
+
// so the new count is state.reworkCount + 1.
|
|
271
|
+
var newRework = state.reworkCount + 1;
|
|
272
|
+
if (newRework > MAX_TOTAL_REWORK) {
|
|
273
|
+
log("Shared rework budget exhausted for task " + taskId + " — worktree preserved at " + env.worktreePreservedHint + " for manual inspection");
|
|
274
|
+
return await parkTask(env, "Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after Review rejection. Worktree preserved.");
|
|
275
|
+
}
|
|
276
|
+
log("Review rejected — bouncing to Build (rework #" + newRework + " of " + MAX_TOTAL_REWORK + ")");
|
|
277
|
+
return { type: "REWIND", next: "Build", reason: "Review rejected — bouncing to Build (rework #" + newRework + " of " + MAX_TOTAL_REWORK + ")" };
|
|
278
|
+
}
|
|
279
|
+
log("Review complete for task " + taskId);
|
|
280
|
+
return { type: "ADVANCE", next: "Integrate" };
|
|
281
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/standard/phases/triage.js — Triage (sage) phase module (sandbox
|
|
2
|
+
// exit, Phase C Piece 1). Import-safe: no side effects on import, bare
|
|
3
|
+
// `node` exits 0.
|
|
4
|
+
//
|
|
5
|
+
// Reads: task/project/surface facts, claim/session context.
|
|
6
|
+
// Writes: completed/rejected session + event; the machine-read
|
|
7
|
+
// `experiential: yes|no` marker line and the optional `terminal_targets:`
|
|
8
|
+
// line. The durable cross-run handoff is the Triage session notes.
|
|
9
|
+
// Transitions: ADVANCE → Capture (a rejected Triage still advances; only
|
|
10
|
+
// Review, Integrate, and Publish have special failure paths).
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
runWorkBoundary, recordPhase, buildEventPreamble, summarizeReport,
|
|
14
|
+
closeoutPassed, ensureClaimed, log,
|
|
15
|
+
} from "../../workflow-lib.js";
|
|
16
|
+
import { extractExperiential } from "../../extract.js";
|
|
17
|
+
|
|
18
|
+
export const PHASE = { name: "Triage", identity: "sage" };
|
|
19
|
+
|
|
20
|
+
// buildInstructions — pure function of ctx. Verbatim from
|
|
21
|
+
// workflows/standard.js (Triage branch).
|
|
22
|
+
export function buildInstructions(ctx) {
|
|
23
|
+
var env = ctx.env;
|
|
24
|
+
return "Validate the task against the project's repo at " + env.repoPath + " — that exact checkout, not any other copy of the project on disk. If you run git commands, cd " + env.repoPath + " first.\nCheck clarity, note dependencies, confirm the standard workflow assignment.\nWrite a brief triage assessment as notes for the next step.\nReport back in plain prose — what you found.\nEXPERIENTIAL FLAG: does this task change anything a user can directly observe in the project's user-facing surface? " + env.surfaceTriageDesc + " For an artifact surface that means rendered and visible — pages, components, styles, layout, copy, visual states. For a terminal surface it means the CLI experience — command output, help text, flags, error messages, defaults. The shared UX bar is " + (env.uxDoctrinePath ? env.uxDoctrinePath + " — flag experiential when the task touches anything it covers." : "not classified for this project — flag experiential when the task touches anything user-observable in the surface described above.") + " If yes it is experiential and gets baseline evidence (plus an experiential verdict where the workflow has a QA phase). For terminal-surface tasks also declare the CLI surface to exercise: end your report with a line `terminal_targets: <comma-separated CLI commands/flags>` (machine-read; optional — falls back to the task description). End your report with exactly one line on its own, lowercase, unrephrased: experiential: yes — or experiential: no. This line is machine-read.";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runPhase(ctx) {
|
|
28
|
+
var env = ctx.env, state = ctx.state;
|
|
29
|
+
var claimed = await ensureClaimed(env, state, PHASE);
|
|
30
|
+
if (claimed.type !== "CLAIMED") return claimed;
|
|
31
|
+
|
|
32
|
+
var boundary = await runWorkBoundary(env, state, {
|
|
33
|
+
phase: PHASE.name, identity: PHASE.identity,
|
|
34
|
+
instructions: buildInstructions(ctx),
|
|
35
|
+
eventPreamble: buildEventPreamble(env, PHASE.name),
|
|
36
|
+
crewApiLine: true, verdictStep: false,
|
|
37
|
+
});
|
|
38
|
+
if (boundary.type !== "BOUNDARY_DONE") return boundary;
|
|
39
|
+
|
|
40
|
+
// Triage is not a verdict step: producing output means passed.
|
|
41
|
+
var passed = closeoutPassed(boundary);
|
|
42
|
+
var workerText = boundary.workerText;
|
|
43
|
+
var summary = summarizeReport(workerText);
|
|
44
|
+
var status = passed ? "completed" : "rejected";
|
|
45
|
+
await recordPhase(env, {
|
|
46
|
+
task_id: env.taskId,
|
|
47
|
+
session: { id: state.activeSessionId, task_id: env.taskId, identity: PHASE.identity, step: PHASE.name, status: status, notes: summary },
|
|
48
|
+
event: { task_id: env.taskId, type: status, identity: PHASE.identity, message: PHASE.name + " " + status + " by " + PHASE.identity },
|
|
49
|
+
});
|
|
50
|
+
if (!passed) {
|
|
51
|
+
// A rejected Triage still advances; the rejection is recorded.
|
|
52
|
+
log("Triage rejected for task " + env.taskId + " — advancing");
|
|
53
|
+
return { type: "ADVANCE", next: "Capture", experiential: null };
|
|
54
|
+
}
|
|
55
|
+
// Stash the terminal targets run-locally (re-derivable from the Triage
|
|
56
|
+
// session notes' terminal_targets: marker line on a resumed run).
|
|
57
|
+
var ttm = /^terminal_targets:\s*(.+)/im.exec(workerText || "");
|
|
58
|
+
state.memo.terminalTargets = ttm ? ttm[1].trim().slice(0, 300) : "";
|
|
59
|
+
log("Triage complete for task " + env.taskId);
|
|
60
|
+
return {
|
|
61
|
+
type: "ADVANCE", next: "Capture",
|
|
62
|
+
experiential: extractExperiential(workerText),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -56,6 +56,13 @@
|
|
|
56
56
|
# with STALE_MERGE — never push the stale state, never fall through to
|
|
57
57
|
# MERGED_EMPTY (an unpushed merge sits on the line).
|
|
58
58
|
#
|
|
59
|
+
# Fixture F4 (fast-forward recovery — 2026-09-27, Park 14): the work
|
|
60
|
+
# landed on the line by fast-forward (no merge commit), but a merge IS
|
|
61
|
+
# recorded and the branch tip is exactly the target tip. Re-running
|
|
62
|
+
# integrate must recover via the ff path (lock + push) — not
|
|
63
|
+
# STALE_MERGE, not MERGED_EMPTY. Exact tip equality only; ancestry
|
|
64
|
+
# alone never recovers (blocker-35).
|
|
65
|
+
#
|
|
59
66
|
# Fixture I (push-target identity — blocker 35, 2026-09-21): the
|
|
60
67
|
# ERROR-after-MERGED retry path. I1: stale record → STALE_MERGE and the
|
|
61
68
|
# remote ref does not move. I2: branch restored to the merged tip → the
|
|
@@ -378,6 +385,46 @@ echo "$out" | grep -q 'recovered:' && fail "F3: recovered a stale merge: $out"
|
|
|
378
385
|
[ "$(git rev-parse HEAD)" = "$F3_M1" ] || fail "F3: HEAD moved during STALE_MERGE"
|
|
379
386
|
echo "fixture F3 (stale record fails closed on re-integrate): all checks passed"
|
|
380
387
|
|
|
388
|
+
# ---------------- Fixture F4: fast-forward recovery — recorded merge, tip == target, no merge commit ----------------
|
|
389
|
+
# (2026-09-27, Park 14): the work landed on the line by fast-forward in a
|
|
390
|
+
# prior attempt, so no merge commit exists — but a merge IS recorded and the
|
|
391
|
+
# branch tip is exactly the target tip. Re-running integrate must recover
|
|
392
|
+
# (take the lock, push) via the ff path — not STALE_MERGE (the tip is the
|
|
393
|
+
# deliverable, not stale), not MERGED_EMPTY (a recorded merge exists, and
|
|
394
|
+
# MERGED_EMPTY would skip the push). ff_integrated accepts only exact tip
|
|
395
|
+
# equality: a branch moved backward after the ff is still an ancestor of
|
|
396
|
+
# the target, and ancestry alone must not recover (blocker-35 F3 shape).
|
|
397
|
+
# Reuse fixture D's repo (no remote): new task, merge, then reset the line
|
|
398
|
+
# to the branch tip, dropping the merge commit.
|
|
399
|
+
cd "$D_DIR"
|
|
400
|
+
export CREW_REPO="$D_DIR"
|
|
401
|
+
export CREW_HOME="$T/home-d"
|
|
402
|
+
tid="ff1"
|
|
403
|
+
"$REPO_DIR/lib/merge-lock.sh" release "stale1" >/dev/null 2>&1 || fail "F4: lock release failed"
|
|
404
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "F4 prepare failed: $out"
|
|
405
|
+
BRANCH=$(bash "$LIFECYCLE" resolve-branch "$tid") || fail "F4 resolve-branch failed"
|
|
406
|
+
echo work >> "$D_DIR/.worktrees/$tid/f.txt"
|
|
407
|
+
(cd "$D_DIR/.worktrees/$tid" && git commit -qam "work w1")
|
|
408
|
+
F4_W1=$(git rev-parse "$BRANCH")
|
|
409
|
+
git worktree remove --force "$D_DIR/.worktrees/$tid" || fail "F4: worktree remove failed"
|
|
410
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "F4 integrate failed: $out"
|
|
411
|
+
echo "$out" | grep -q '^MERGED:' || fail "F4: expected MERGED, got: $out"
|
|
412
|
+
# Simulate the ff landing: the line goes to the branch tip directly, no
|
|
413
|
+
# merge commit. The record still names the merge from the integrate above.
|
|
414
|
+
git reset -q --hard "$F4_W1" || fail "F4: reset to branch tip failed"
|
|
415
|
+
[ "$(git rev-parse HEAD)" = "$F4_W1" ] || fail "F4: HEAD not at branch tip"
|
|
416
|
+
[ "$(git rev-parse "$BRANCH")" = "$F4_W1" ] || fail "F4: branch not at tip"
|
|
417
|
+
git rev-parse -q --verify HEAD^2 >/dev/null && fail "F4: HEAD is still a merge commit"
|
|
418
|
+
"$REPO_DIR/lib/merge-lock.sh" release "$tid" >/dev/null 2>&1 || fail "F4: lock release failed"
|
|
419
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "F4 re-integrate failed: $out"
|
|
420
|
+
echo "$out" | grep -q '^MERGED:' || fail "F4: expected MERGED recovery, got: $out"
|
|
421
|
+
echo "$out" | grep -q 'recovered: branch tip fast-forwarded' || fail "F4: expected ff recovery, got: $out"
|
|
422
|
+
echo "$out" | grep -q '^STALE_MERGE' && fail "F4: STALE_MERGE on a fast-forwarded tip: $out"
|
|
423
|
+
echo "$out" | grep -q '^MERGED_EMPTY' && fail "F4: fell through to MERGED_EMPTY with a recorded merge: $out"
|
|
424
|
+
out=$(bash "$LIFECYCLE" lock-status "$tid" 2>&1)
|
|
425
|
+
echo "$out" | grep -q '^locked=true' || fail "F4: ff recovery did not take the lock: $out"
|
|
426
|
+
echo "fixture F4 (fast-forward recovery): all checks passed"
|
|
427
|
+
|
|
381
428
|
# ---------------- Fixture I: branch + origin — stale push-target and the R5-manual shape ----------------
|
|
382
429
|
# (blocker 35): the ERROR-after-MERGED retry path (push-target) pushed live
|
|
383
430
|
# HEAD with no check. I1: stale record → STALE_MERGE, the remote ref does
|