@a-t-h-i/bot-lobby 0.6.26 → 0.6.27
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/README.md +7 -1
- package/package.json +1 -1
- package/prompts/master.md +24 -3
- package/prompts/qa.md +139 -77
- package/prompts/reviewer.md +4 -0
- package/src/classifier/qa-risk.ts +537 -0
- package/src/lobby/planner.ts +14 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/pi/events.ts +4 -3
- package/src/pi/start-task.ts +31 -4
- package/src/pi/tools.ts +3 -0
- package/src/schemas/configuration.ts +2 -2
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/webui/api/tasks.ts +1 -0
- package/src/webui/protocol.ts +3 -0
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +196 -34
- package/webui/dist/assets/index-BUzOUrih.css +2 -0
- package/webui/dist/assets/{index-C4Ww36TW.js → index-myC4UiEg.js} +18 -15
- package/webui/dist/build.json +18 -10
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-C1IwecNS.css +0 -2
package/src/workflow/workflow.ts
CHANGED
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
runReviewer,
|
|
40
40
|
runScouts,
|
|
41
41
|
runWorker,
|
|
42
|
-
type ReviewerOutcome,
|
|
43
42
|
type ReviewerRequest,
|
|
44
43
|
type ScoutOutcome,
|
|
45
44
|
type WorkerOutcome,
|
|
@@ -64,13 +63,16 @@ import type { EffortRouter } from "../classifier/effort.ts";
|
|
|
64
63
|
import { answerClarify } from "../classifier/triage.ts";
|
|
65
64
|
import { appendMetrics, metricFromRun } from "../state/metrics.ts";
|
|
66
65
|
import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
|
|
67
|
-
import { fastNext, onFastTrack, parseRoster, qaRequired, qaStillDue, qaTookPart, rosterWords, trackSummary } from "./track.ts";
|
|
66
|
+
import { builtSomething, fastNext, onFastTrack, parseRoster, PLANNED_NEXT, qaRequired, qaStillDue, qaTookPart, readRequest, rosterWords, satOut, trackSummary } from "./track.ts";
|
|
67
|
+
import { requestBesidePlan } from "../state/backlog.ts";
|
|
68
68
|
import { missingWorktree, taskCwd } from "../execution/workspace.ts";
|
|
69
69
|
import { completionReview } from "../delivery/review.ts";
|
|
70
70
|
import { execCommand, type Exec } from "../lobby/issues.ts";
|
|
71
71
|
import type { LintExec } from "../execution/lint.ts";
|
|
72
72
|
import type { LintReport } from "../schemas/lint.ts";
|
|
73
73
|
import { lintBrief, lintContext, lintHolds, lintNote, lintRefusal, lintSummary, runLintGate } from "./lint.ts";
|
|
74
|
+
import { assessQaRisk, DEPTHS, qaRiskBrief, qaRiskLine, unreadRisk } from "../classifier/qa-risk.ts";
|
|
75
|
+
import type { QaRiskAssessment, TrackMember } from "../schemas/task.ts";
|
|
74
76
|
|
|
75
77
|
export const ORCHESTRATE_ACTIONS = [
|
|
76
78
|
"clarify",
|
|
@@ -167,6 +169,8 @@ export interface WorkflowDeps {
|
|
|
167
169
|
lintExec?: LintExec;
|
|
168
170
|
/** Told each new lint report on a task's touched files (the lobby's activity log). */
|
|
169
171
|
onLint?: (taskId: string, report: LintReport) => void;
|
|
172
|
+
/** Told each QA risk read of a task's change (the lobby's activity log). */
|
|
173
|
+
onQaRisk?: (taskId: string, assessment: QaRiskAssessment) => void;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
176
|
export interface WorkflowResult {
|
|
@@ -233,6 +237,7 @@ export function describeTask(task: Task): string {
|
|
|
233
237
|
`Title: ${task.title}`,
|
|
234
238
|
`Request: ${truncate(taskRequest(task), 200)}`,
|
|
235
239
|
task.track ? trackSummary(task.track) : "",
|
|
240
|
+
task.qaRisk ? `${qaRiskLine(task.qaRisk)} — ${task.qaRisk.reasoning}` : "",
|
|
236
241
|
task.domains.length > 0 ? `Domains: ${task.domains.join(", ")}` : "",
|
|
237
242
|
`Review iterations: ${Object.entries(task.reviewIterations).map(([d, n]) => `${d}=${n}`).join(", ")}`,
|
|
238
243
|
pendingApprovals(task).length > 0
|
|
@@ -466,6 +471,8 @@ function researchRequestFor(
|
|
|
466
471
|
|
|
467
472
|
async function handleResearch(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
468
473
|
requireState(task, RESEARCH_STATES);
|
|
474
|
+
const benched = benchedRefusal(task, "researcher");
|
|
475
|
+
if (benched) throw new Error(benched);
|
|
469
476
|
const domain = parseDomain(params.domain, "research");
|
|
470
477
|
const instruction = params.instruction?.trim();
|
|
471
478
|
if (!instruction) throw new Error("research requires instruction (the question to investigate)");
|
|
@@ -542,8 +549,8 @@ function handlePlan(task: Task, params: OrchestrateParams, deps: WorkflowDeps):
|
|
|
542
549
|
requireState(task, ["planning", ...AMEND_PLAN_STATES]);
|
|
543
550
|
const plan = params.plan?.trim();
|
|
544
551
|
if (!plan) throw new Error("plan requires the plan text");
|
|
545
|
-
// The fast track keeps a short plan
|
|
546
|
-
const missing = onFastTrack(task) ? [] : validatePlan(plan);
|
|
552
|
+
// The fast track keeps a short plan, and a planned task's is the one the panel agreed: the required areas are for a plan the oracle writes.
|
|
553
|
+
const missing = onFastTrack(task) || task.approvedPlan ? [] : validatePlan(plan);
|
|
547
554
|
if (missing.length > 0) throw new Error(`plan is missing: ${missing.join(", ")}`);
|
|
548
555
|
const amending = AMEND_PLAN_STATES.includes(task.state);
|
|
549
556
|
task.plan = plan;
|
|
@@ -673,7 +680,8 @@ export function planWithin(plan: string, budget: number): string {
|
|
|
673
680
|
|
|
674
681
|
function workerTaskText(task: Task, planBudget = 6000): string {
|
|
675
682
|
return [
|
|
676
|
-
|
|
683
|
+
// A planned task's request is its plan: it is sent once, as the plan.
|
|
684
|
+
`Requirements: ${task.approvedPlan && task.plan ? requestBesidePlan(taskRequest(task)) : taskRequest(task)}`,
|
|
677
685
|
task.proposal ? `Approved objective: ${task.proposal}` : "",
|
|
678
686
|
task.plan ? `Approved plan:\n${planWithin(task.plan, planBudget)}` : "",
|
|
679
687
|
task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
|
|
@@ -687,6 +695,8 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
|
|
|
687
695
|
return {
|
|
688
696
|
...(time ? { time } : {}),
|
|
689
697
|
...(ask ? { ask } : {}),
|
|
698
|
+
// QA writing tests works to the same budget as the gate: the risk of what is built so far.
|
|
699
|
+
...(domain === "qa" && task.qaRisk ? { risk: qaRiskBrief(task.qaRisk) } : {}),
|
|
690
700
|
taskId: task.id,
|
|
691
701
|
domain,
|
|
692
702
|
instruction,
|
|
@@ -912,7 +922,7 @@ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutc
|
|
|
912
922
|
task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
|
|
913
923
|
updateScratchpad(deps, task, outcome);
|
|
914
924
|
const grown = qaJoinsGrownFastTask(task, outcome);
|
|
915
|
-
const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : FULL_NEXT);
|
|
925
|
+
const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : qaRequired(task) ? FULL_NEXT : PLANNED_NEXT);
|
|
916
926
|
return [report, grown, timeReport(outcome)].filter(Boolean).join("\n");
|
|
917
927
|
}
|
|
918
928
|
|
|
@@ -932,6 +942,37 @@ function qaJoinsGrownFastTask(task: Task, outcome: WorkerOutcome): string {
|
|
|
932
942
|
return "QA now takes part: a fast-track change that needs a new dependency or an architecture change is checked before it completes.";
|
|
933
943
|
}
|
|
934
944
|
|
|
945
|
+
/** Read the risk of what the task has built so far, from its diff: the gate's way in, for a QA step or completion. */
|
|
946
|
+
async function assessBuilt(task: Task, deps: WorkflowDeps): Promise<QaRiskAssessment | undefined> {
|
|
947
|
+
const base = await reviewBase(task, deps);
|
|
948
|
+
const [diff, tree] = await Promise.all([
|
|
949
|
+
readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps) }),
|
|
950
|
+
changeState(deps, task, base),
|
|
951
|
+
]);
|
|
952
|
+
const read = await readTaskRisk(task, deps, diff, tree, base);
|
|
953
|
+
if (read) {
|
|
954
|
+
task.qaRisk = read;
|
|
955
|
+
deps.onQaRisk?.(task.id, read);
|
|
956
|
+
}
|
|
957
|
+
return read;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* The fast track left QA out because the request read small and safe; the
|
|
962
|
+
* change it built is what counts. One Jev reads as MEDIUM or HIGH risk gets
|
|
963
|
+
* QA before it completes. Returns the refusal for the oracle, or "".
|
|
964
|
+
*/
|
|
965
|
+
async function qaJoinsRiskyFastTask(task: Task, deps: WorkflowDeps): Promise<string> {
|
|
966
|
+
const track = task.track;
|
|
967
|
+
if (!track || track.path !== "fast" || track.roster.includes("qa") || task.qaWaiver || !builtSomething(task)) return "";
|
|
968
|
+
const risk = await assessBuilt(task, deps);
|
|
969
|
+
if (!risk || (risk.risk !== "medium" && risk.risk !== "high")) return "";
|
|
970
|
+
track.roster = parseRoster([...track.roster, "qa"]);
|
|
971
|
+
track.reasons = [...track.reasons, `tests: the change reads ${risk.risk} risk`];
|
|
972
|
+
recordDecision(task, `QA joined the fast track: the change reads ${risk.risk.toUpperCase()} risk (${truncate(risk.riskFactors.join("; "), 200)}).`);
|
|
973
|
+
return `QA joins before completion: ${qaRiskLine(risk)}. Run action=qa, then complete.`;
|
|
974
|
+
}
|
|
975
|
+
|
|
935
976
|
/** States a fast task starts its work from: before anything is planned, and never while the user decides on a proposal. */
|
|
936
977
|
const FAST_START_STATES: readonly TaskState[] = ["created", "clarifying", "scouting", "synthesizing"];
|
|
937
978
|
|
|
@@ -942,6 +983,8 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
942
983
|
}
|
|
943
984
|
if (!starting) requireState(task, ["planning", "implementing", "reviewing"]);
|
|
944
985
|
const assignments = parseAssignments(params);
|
|
986
|
+
const benched = assignments.map((entry) => benchedRefusal(task, entry.domain)).filter(Boolean);
|
|
987
|
+
if (benched.length > 0) throw new Error(benched.join("\n"));
|
|
945
988
|
const short = !deps.config.workflow.briefCheck ? [] : assignments.map((entry) => briefRejection(task.id, entry.domain, entry.instruction)).filter(Boolean);
|
|
946
989
|
if (short.length > 0) throw new Error(short.join("\n"));
|
|
947
990
|
for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
|
|
@@ -953,6 +996,8 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
953
996
|
if (task.track?.autoPlan) addFastSteps(task, deps, assignments);
|
|
954
997
|
if (task.state !== "implementing") transition(task, "implementing");
|
|
955
998
|
await takeBaseline(task, deps);
|
|
999
|
+
// A QA step after work is in verifies it at its risk: read the change first.
|
|
1000
|
+
if (assignments.some((entry) => entry.domain === "qa") && builtSomething(task)) await assessBuilt(task, deps);
|
|
956
1001
|
let report: string;
|
|
957
1002
|
try {
|
|
958
1003
|
if (assignments.length === 1) {
|
|
@@ -993,13 +1038,35 @@ function startFast(task: Task): void {
|
|
|
993
1038
|
oneLine(taskRequest(task), 600),
|
|
994
1039
|
"",
|
|
995
1040
|
"## Track",
|
|
996
|
-
`Fast track (${track.size}): ${rosterWords(track.roster)}. No scouts, proposal round or plan review; ${qaRequired(task) ? "QA takes part before completion (
|
|
1041
|
+
`Fast track (${track.size}): ${rosterWords(track.roster)}. No scouts, proposal round or plan review; ${qaRequired(task) ? "QA takes part before completion (the QA gate, sized to the change)" : "no QA gate unless the change turns out riskier than it read"}.`,
|
|
997
1042
|
].join("\n");
|
|
998
1043
|
track.autoPlan = true;
|
|
999
1044
|
}
|
|
1000
1045
|
recordDecision(task, `Fast track: started without a proposal round (${track.size}; ${rosterWords(track.roster)}).`);
|
|
1001
1046
|
}
|
|
1002
1047
|
|
|
1048
|
+
/**
|
|
1049
|
+
* A task from a plan agreed in the planning panel starts with that plan as
|
|
1050
|
+
* its own: the panel already asked the questions, read the code and agreed
|
|
1051
|
+
* the steps, so there is no clarifying, scouting, proposal or plan to write.
|
|
1052
|
+
* The engine walks the same states (every transition stays legal) to
|
|
1053
|
+
* planning, where the oracle delegates the first step.
|
|
1054
|
+
*/
|
|
1055
|
+
export function startFromAgreedPlan(task: Task, plan: string, taskDir: string): void {
|
|
1056
|
+
for (const state of ["clarifying", "awaiting_approval", "planning"] as const) if (task.state !== state) transition(task, state);
|
|
1057
|
+
task.plan = plan;
|
|
1058
|
+
writeFileEnsured(join(taskDir, "plan.md"), plan);
|
|
1059
|
+
const out = task.track?.satOut ?? [];
|
|
1060
|
+
const benched = out.length > 0 ? ` ${rosterWords(out)} sat out the panel, so ${out.length === 1 ? "it does" : "they do"} not work on the task${out.includes("qa") ? " and there is no QA gate" : ""}.` : "";
|
|
1061
|
+
recordDecision(task, `Started from the plan agreed in the planning panel (${task.approvedPlan}): no clarifying, scouting or proposal round.${benched}`);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** The refusal for work given to a member who sat out the task's planning panel, or "" when it took part. */
|
|
1065
|
+
function benchedRefusal(task: Task, member: TrackMember): string {
|
|
1066
|
+
if (!satOut(task, member)) return "";
|
|
1067
|
+
return `${rosterWords([member])} sat out the planning panel for this plan (${task.approvedPlan}), so it does not work on this task. Give the step to a member who took part (${rosterWords(task.track?.roster ?? [])}); if it truly needs ${rosterWords([member])}, ask the user to let it join with action=track (roster with it, and the reason).`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1003
1070
|
/** `Step 3: …`, `Steps 2-4 — …` at the start of a delegation. */
|
|
1004
1071
|
const STEP_PREFIX = /^\s*(?:\*\*)?steps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|and|&)\s*#?\s*(\d+))?(?:\*\*)?\s*[:.)\u2013\u2014-]?\s*/i;
|
|
1005
1072
|
|
|
@@ -1114,13 +1181,14 @@ function handleResolveApproval(task: Task, params: OrchestrateParams): string {
|
|
|
1114
1181
|
function scratchpadSummary(deps: WorkflowDeps, task: Task, domain: Domain): string {
|
|
1115
1182
|
return readTaskArtifact(deps.root, deps.configDir, task.id, `${domain}.md`) ?? "";
|
|
1116
1183
|
}
|
|
1117
|
-
function recordReview(task: Task, domain: Domain, result: ReviewResult): void {
|
|
1184
|
+
function recordReview(task: Task, domain: Domain, result: ReviewResult, checksOnly = false): void {
|
|
1118
1185
|
task.reviewRecords.push({
|
|
1119
1186
|
domain,
|
|
1120
1187
|
verdict: result.verdict,
|
|
1121
1188
|
findings: result.findings.map((finding) => ({ severity: finding.severity, text: finding.text })),
|
|
1122
1189
|
requiredChanges: result.requiredChanges,
|
|
1123
1190
|
createdAt: new Date().toISOString(),
|
|
1191
|
+
...(checksOnly ? { checksOnly: true } : {}),
|
|
1124
1192
|
});
|
|
1125
1193
|
}
|
|
1126
1194
|
/** Each domain's scratchpad, its newest entries kept when it is long (the latest fix round matters most). */
|
|
@@ -1151,14 +1219,17 @@ function previousRound(task: Task): string {
|
|
|
1151
1219
|
].join("\n");
|
|
1152
1220
|
}
|
|
1153
1221
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1222
|
+
/** The gate's brief at the change's risk: the depth and budget come from Jev's read, the levels from qa.md. */
|
|
1223
|
+
function qaInstruction(risk: QaRiskAssessment): string {
|
|
1224
|
+
return [
|
|
1225
|
+
`Run the QA gate on the completed work at ${risk.risk.toUpperCase()} risk: ${DEPTHS[risk.risk]} verification, as "QA risk" in your context sets it.`,
|
|
1226
|
+
"Check the acceptance criteria and the focus areas; run the existing tests that cover them before writing any; stay within the test budget, a ceiling and never a quota;",
|
|
1227
|
+
"and stop as soon as more checking would add little confidence. Report an implementation defect as a finding for its owner to fix; do not fix production code yourself.",
|
|
1228
|
+
].join(" ");
|
|
1229
|
+
}
|
|
1159
1230
|
|
|
1160
1231
|
/** The QA gate looks at every domain's work, not just one worker's diff, knowing who changed each file. */
|
|
1161
|
-
function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string, provenance?: readonly FileProvenance[], lint?: LintReport): ReviewerRequest {
|
|
1232
|
+
function qaRequest(deps: WorkflowDeps, task: Task, diff: string, risk: QaRiskAssessment, instruction?: string, provenance?: readonly FileProvenance[], lint?: LintReport): ReviewerRequest {
|
|
1162
1233
|
const rounds = previousRound(task);
|
|
1163
1234
|
return {
|
|
1164
1235
|
taskId: task.id,
|
|
@@ -1168,9 +1239,10 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: s
|
|
|
1168
1239
|
...(rounds ? { previousRound: rounds } : {}),
|
|
1169
1240
|
scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
|
|
1170
1241
|
diff,
|
|
1242
|
+
risk: qaRiskBrief(risk),
|
|
1171
1243
|
...(provenance && provenance.length > 0 ? { provenance: provenanceLines(provenance) } : {}),
|
|
1172
1244
|
...(lint ? { lint: lintContext(lint, deps.config.lint.mode) } : {}),
|
|
1173
|
-
instruction: instruction?.trim() ||
|
|
1245
|
+
instruction: instruction?.trim() || qaInstruction(risk),
|
|
1174
1246
|
cwd: deps.cwd,
|
|
1175
1247
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
1176
1248
|
config: deps.config,
|
|
@@ -1184,11 +1256,10 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: s
|
|
|
1184
1256
|
/** What the loop does after a QA round: finish, fix and review again, stop, or (the user's call) accept the work as it is. */
|
|
1185
1257
|
type LoopDecision = "accept" | "iterate" | "blocked" | "waived";
|
|
1186
1258
|
|
|
1187
|
-
function qaReport(
|
|
1188
|
-
const { result, run, issues } = outcome;
|
|
1259
|
+
function qaReport(header: string, result: ReviewResult, decision: LoopDecision, provenance?: readonly FileProvenance[], lint = "", issues: readonly string[] = []): string {
|
|
1189
1260
|
const passed = result.verdict === "pass";
|
|
1190
1261
|
return [
|
|
1191
|
-
|
|
1262
|
+
header,
|
|
1192
1263
|
result.findings.length > 0
|
|
1193
1264
|
? `Findings:\n${result.findings.map((finding) => `- [${finding.severity}] ${truncate(finding.text, 300)}`).join("\n")}`
|
|
1194
1265
|
: "",
|
|
@@ -1211,12 +1282,67 @@ function qaReport(outcome: ReviewerOutcome, decision: LoopDecision, provenance?:
|
|
|
1211
1282
|
.join("\n");
|
|
1212
1283
|
}
|
|
1213
1284
|
|
|
1285
|
+
/** Changes of this task's own: its agents' (or nobody's on record), not quick fixes, other tasks' or what was there before. */
|
|
1286
|
+
const TASKS_OWN = new Set(["planned", "unattributed"]);
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Jev's read of what the task built, from its diff (the engine's rules when
|
|
1290
|
+
* Jev is off), saved on the task and told to the lobby; undefined when git
|
|
1291
|
+
* cannot say what changed or nothing of the task's has.
|
|
1292
|
+
*/
|
|
1293
|
+
async function readTaskRisk(task: Task, deps: WorkflowDeps, diff: string, tree: { files: FileProvenance[] } | undefined, base: string | undefined): Promise<QaRiskAssessment | undefined> {
|
|
1294
|
+
const files = tree ? tree.files.filter((file) => file.kinds.some((kind) => TASKS_OWN.has(kind))).map((file) => file.path) : (await treeChanges(deps, base))?.files;
|
|
1295
|
+
if (!files || files.length === 0) return undefined;
|
|
1296
|
+
const reasons = readRequest(taskRequest(task)).reasons;
|
|
1297
|
+
const bugfix = task.triage?.kind === "bugfix" || reasons.some((reason) => reason.startsWith("tests: a bug fix"));
|
|
1298
|
+
return assessQaRisk({ files, diff, request: taskRequest(task), bugfix }, deps.classifier, deps.signal);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** The QA risk the gate works to: the read, or a full review when the change could not be read. */
|
|
1302
|
+
async function assessTask(task: Task, deps: WorkflowDeps, diff: string, tree: { files: FileProvenance[] } | undefined, base: string | undefined): Promise<QaRiskAssessment> {
|
|
1303
|
+
const read = await readTaskRisk(task, deps, diff, tree, base);
|
|
1304
|
+
let risk = read ?? unreadRisk(/^Unable to read git state/.test(diff) ? "git could not say what changed" : "no change of this task's is in the working tree");
|
|
1305
|
+
const still = qaStillNeeded(task);
|
|
1306
|
+
if (!risk.qaRequired && still) risk = { ...risk, qaRequired: true, reasoning: `${risk.reasoning} QA runs anyway: ${still}.` };
|
|
1307
|
+
task.qaRisk = risk;
|
|
1308
|
+
deps.onQaRisk?.(task.id, risk);
|
|
1309
|
+
return risk;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** Why a change with nothing that runs still gets a QA agent: the user asked for tests, or QA asked for changes it must see made. */
|
|
1313
|
+
function qaStillNeeded(task: Task): string | undefined {
|
|
1314
|
+
const last = task.reviewRecords.filter((record) => record.domain === "qa").at(-1);
|
|
1315
|
+
if (last && last.verdict !== "pass" && !last.checksOnly) return "the last QA round asked for changes, and QA checks they are made";
|
|
1316
|
+
if (readRequest(taskRequest(task)).reasons.some((reason) => reason.startsWith("tests: asked for"))) return "the request asks for tests";
|
|
1317
|
+
return undefined;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* The verdict of the engine's deterministic checks on a change with nothing
|
|
1322
|
+
* that runs: new lint errors on its lines hold it in block mode, as they hold
|
|
1323
|
+
* completion; anything else passes without a QA agent.
|
|
1324
|
+
*/
|
|
1325
|
+
function checksVerdict(lint: LintReport | undefined, deps: WorkflowDeps, waived: boolean): ReviewResult {
|
|
1326
|
+
const holds = lintHolds(lint, deps.config.lint.mode, waived);
|
|
1327
|
+
const ran = lint && lint.state !== "skipped" && deps.config.lint.mode !== "off";
|
|
1328
|
+
return {
|
|
1329
|
+
domain: "qa",
|
|
1330
|
+
role: "reviewer",
|
|
1331
|
+
verdict: holds ? "changes_required" : "pass",
|
|
1332
|
+
findings: holds && lint ? [{ severity: "major", text: `new lint errors on lines the task changed: ${lintSummary(lint)}` }] : [],
|
|
1333
|
+
verification: ran ? `- engine lint on the touched files — ${lintSummary(lint)}` : "- engine lint — nothing to lint, or lint is off",
|
|
1334
|
+
requiredChanges: holds ? ["Fix the new lint errors at their cause (never silence the rule), then re-run action=qa."] : [],
|
|
1335
|
+
optionalImprovements: [],
|
|
1336
|
+
raw: "",
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1214
1340
|
async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
1215
1341
|
requireState(task, ["implementing", "reviewing"]);
|
|
1342
|
+
if (satOut(task, "qa")) throw new Error(`${benchedRefusal(task, "qa")} There is no QA gate: complete once every step is done.`);
|
|
1216
1343
|
if (task.state !== "reviewing") transition(task, "reviewing");
|
|
1217
1344
|
const iterations = (task.reviewIterations?.qa ?? 0) + 1;
|
|
1218
1345
|
task.reviewIterations = { qa: iterations };
|
|
1219
|
-
const time = qaTime(task, deps);
|
|
1220
1346
|
const base = await reviewBase(task, deps);
|
|
1221
1347
|
const [diff, tree] = await Promise.all([
|
|
1222
1348
|
readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps) }),
|
|
@@ -1225,16 +1351,34 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
|
|
|
1225
1351
|
const provenance = tree?.files;
|
|
1226
1352
|
// The gate's base is the task's own; a legacy task measured from an older commit lints from a fresh look.
|
|
1227
1353
|
const lint = await lintTask(task, deps, base === task.baseline?.head ? tree : undefined);
|
|
1228
|
-
const
|
|
1354
|
+
const lintLine = lintBrief(lint, deps.config.lint.mode, Boolean(task.lintWaiver));
|
|
1355
|
+
// Before any QA agent runs, Jev reads what was built: the gate's depth and test budget follow its risk.
|
|
1356
|
+
const risk = await assessTask(task, deps, diff, tree, base);
|
|
1357
|
+
if (!risk.qaRequired) {
|
|
1358
|
+
const result = checksVerdict(lint, deps, Boolean(task.lintWaiver));
|
|
1359
|
+
const decision = await settleQaRound(task, result, iterations, deps, true);
|
|
1360
|
+
const header = `QA gate: ${result.verdict.toUpperCase()} on the engine's checks; no QA agent ran. ${qaRiskLine(risk)}.\nWhy: ${risk.reasoning}\nChecks:\n${result.verification}`;
|
|
1361
|
+
return qaReport(header, result, decision, provenance, lintLine);
|
|
1362
|
+
}
|
|
1363
|
+
const time = qaTime(task, deps);
|
|
1364
|
+
const outcome = await runReviewer({ ...qaRequest(deps, task, diff, risk, params.task, provenance, lint), ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
|
|
1229
1365
|
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1366
|
+
const { result, run } = outcome;
|
|
1367
|
+
recordAdvisoryPushbacks(task, [{ pushback: result.pushback, who: "qa" }]);
|
|
1368
|
+
if (result.relaxed) recordDecision(task, `QA round ${iterations} passed: ${result.relaxed}.`, "qa");
|
|
1369
|
+
const decision = await settleQaRound(task, result, iterations, deps);
|
|
1370
|
+
const header = `QA gate: ${result.verdict.toUpperCase()} (run ${run.status}${run.error ? `: ${run.error}` : ""})${result.relaxed ? " — only minor findings, which never hold the gate" : ""}\n${qaRiskLine(risk)}`;
|
|
1371
|
+
return qaReport(header, result, decision, provenance, lintLine, outcome.issues);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** A QA round's verdict, recorded on the task; then the loop decides, and at its limit the user. */
|
|
1375
|
+
async function settleQaRound(task: Task, result: ReviewResult, iterations: number, deps: WorkflowDeps, checksOnly = false): Promise<LoopDecision> {
|
|
1376
|
+
task.qaVerdict = result.verdict;
|
|
1377
|
+
recordReview(task, "qa", result, checksOnly);
|
|
1378
|
+
let decision: LoopDecision = decideReviewLoop(result.verdict, iterations, reviewLimit(task, deps));
|
|
1379
|
+
if (decision === "blocked") decision = await askAtReviewLimit(task, result, iterations, deps);
|
|
1236
1380
|
if (decision === "accept") task.blockers = task.blockers.filter((blocker) => blocker.domain !== "qa");
|
|
1237
|
-
return
|
|
1381
|
+
return decision;
|
|
1238
1382
|
}
|
|
1239
1383
|
|
|
1240
1384
|
/** Review rounds allowed: the configured limit plus any the user granted. */
|
|
@@ -1364,8 +1508,10 @@ function handleWhiteboard(task: Task, params: OrchestrateParams, deps: WorkflowD
|
|
|
1364
1508
|
|
|
1365
1509
|
/** §63: record history, drop scratchpads, then mark the task completed. */
|
|
1366
1510
|
async function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
1367
|
-
// The fast track completes straight from its work; the full workflow from review.
|
|
1368
|
-
requireState(task, onFastTrack(task) ? ["implementing", "reviewing", "blocked"] : ["reviewing", "blocked"]);
|
|
1511
|
+
// The fast track (and a plan QA sat out) completes straight from its work; the full workflow from review.
|
|
1512
|
+
requireState(task, onFastTrack(task) || !qaRequired(task) ? ["implementing", "reviewing", "blocked"] : ["reviewing", "blocked"]);
|
|
1513
|
+
const joined = await qaJoinsRiskyFastTask(task, deps);
|
|
1514
|
+
if (joined) throw new Error(joined);
|
|
1369
1515
|
// Without QA's part (where the track asks for it) only the user can let the task finish: they are asked, never overruled.
|
|
1370
1516
|
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) await offerAcceptance(task, deps);
|
|
1371
1517
|
if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
|
|
@@ -1445,7 +1591,18 @@ function handleResume(task: Task, params: OrchestrateParams): string {
|
|
|
1445
1591
|
* or the settings; once work is under way a track only gets stricter (the
|
|
1446
1592
|
* full workflow, more members, QA never dropped).
|
|
1447
1593
|
*/
|
|
1448
|
-
|
|
1594
|
+
/** Let members who sat out the plan's panel join the task: only the user can, and nobody is asked in auto mode. */
|
|
1595
|
+
async function letJoin(task: Task, joining: readonly TrackMember[], reason: string, deps: WorkflowDeps): Promise<void> {
|
|
1596
|
+
const words = rosterWords(joining);
|
|
1597
|
+
const them = joining.length === 1 ? "it" : "them";
|
|
1598
|
+
if (isAutoMode(deps.root, deps.configDir, task.id)) throw new Error(`${words} sat out the planning panel for this plan, and only the user can let ${them} work on the task (auto mode is on: nobody can be asked)`);
|
|
1599
|
+
const yes = `Let ${them} join`;
|
|
1600
|
+
const choice = await deps.choose(`${words} sat out the planning panel for ${task.id}. The oracle asks to add ${them}: ${reason}`, [yes, `Keep ${them} out`]);
|
|
1601
|
+
if (choice !== yes) throw new Error(`the user kept ${words} out of this task: give the work to the members who took part in the plan`);
|
|
1602
|
+
recordDecision(task, `The user let ${words} join the task, though ${joining.length === 1 ? "it" : "they"} sat out the planning panel: ${reason}`);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async function handleTrack(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
1449
1606
|
const current = task.track;
|
|
1450
1607
|
if (!params.track && !params.roster) return current ? trackSummary(current) : "This task began before tracks: it takes the full workflow.";
|
|
1451
1608
|
const reason = oneLine(params.reason?.trim() || params.text?.trim() || "", 240);
|
|
@@ -1458,8 +1615,12 @@ function handleTrack(task: Task, params: OrchestrateParams, deps: WorkflowDeps):
|
|
|
1458
1615
|
if (!shaping) throw new Error(`a task takes the fast track before its work is planned; this one is ${task.state}`);
|
|
1459
1616
|
}
|
|
1460
1617
|
const asked = params.roster ? parseRoster(params.roster) : [...(current?.roster ?? [])];
|
|
1461
|
-
//
|
|
1462
|
-
const
|
|
1618
|
+
// A member who sat out the plan's panel joins only when the user says so.
|
|
1619
|
+
const joining = (current?.satOut ?? []).filter((member) => asked.includes(member));
|
|
1620
|
+
if (joining.length > 0) await letJoin(task, joining, reason, deps);
|
|
1621
|
+
const benched = (current?.satOut ?? []).filter((member) => !joining.includes(member));
|
|
1622
|
+
// The full workflow ends with the QA gate, unless QA sat out the planning panel.
|
|
1623
|
+
const roster = path === "full" && !benched.includes("qa") ? parseRoster([...asked, "qa"]) : asked;
|
|
1463
1624
|
if (!shaping && current?.roster.includes("qa") && !roster.includes("qa")) throw new Error("QA stays on the roster once the work is under way");
|
|
1464
1625
|
const grew = current?.path === "fast" && path === "full";
|
|
1465
1626
|
task.track = {
|
|
@@ -1470,13 +1631,14 @@ function handleTrack(task: Task, params: OrchestrateParams, deps: WorkflowDeps):
|
|
|
1470
1631
|
source: "oracle",
|
|
1471
1632
|
...(current?.userChoice ? { userChoice: current.userChoice } : {}),
|
|
1472
1633
|
...(current?.autoPlan ? { autoPlan: true } : {}),
|
|
1634
|
+
...(benched.length > 0 ? { satOut: benched } : {}),
|
|
1473
1635
|
at: new Date().toISOString(),
|
|
1474
1636
|
};
|
|
1475
1637
|
recordDecision(task, `Track: ${path === "fast" ? "fast track" : "full workflow"}; ${rosterWords(roster)} — ${reason}`);
|
|
1476
1638
|
if (path === "fast") {
|
|
1477
|
-
return `Fast track: ${rosterWords(roster)}. Delegate straight away with action=implement (open each task with "Step N:"); no scouts, proposal or plan. ${qaRequired(task) ? "QA takes part before completion:
|
|
1639
|
+
return `Fast track: ${rosterWords(roster)}. Delegate straight away with action=implement (open each task with "Step N:"); no scouts, proposal or plan. ${qaRequired(task) ? "QA takes part before completion: action=qa, sized to the change." : "No QA gate unless the change turns out riskier than it read."} Then action=complete.`;
|
|
1478
1640
|
}
|
|
1479
|
-
if (!shaping) return `Full workflow from here: ${rosterWords(roster)}. The QA gate runs before the task completes${grew ? "; tell the user in one line why the task grew" : ""}.`;
|
|
1641
|
+
if (!shaping) return `Full workflow from here: ${rosterWords(roster)}. ${qaRequired(task) ? "The QA gate runs before the task completes" : "No QA gate: QA sat out the planning panel"}${grew ? "; tell the user in one line why the task grew" : ""}.`;
|
|
1480
1642
|
return `Full workflow: ${rosterWords(roster)}. Scout what the request touches, then propose a short bullet list for approval.`;
|
|
1481
1643
|
}
|
|
1482
1644
|
|