@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.
@@ -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: the full plan's required areas are the full workflow's.
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
- `Requirements: ${taskRequest(task)}`,
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 (its tests as the last step, or the QA gate)" : "no QA gate, as nothing here needs tests"}.`,
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
- const QA_INSTRUCTION = [
1155
- "Run the QA quality gate for the completed feature.",
1156
- "Verify requirements, acceptance criteria, regression risk, edge cases, security, accessibility,",
1157
- "UX, reliability, and tests. Passing automated tests alone is not acceptance.",
1158
- ].join(" ");
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() || QA_INSTRUCTION,
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(outcome: ReviewerOutcome, decision: LoopDecision, provenance?: readonly FileProvenance[], lint = ""): string {
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
- `QA gate: ${result.verdict.toUpperCase()} (run ${run.status}${run.error ? `: ${run.error}` : ""})${result.relaxed ? " — only minor findings, which never hold the gate" : ""}`,
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 outcome = await runReviewer({ ...qaRequest(deps, task, diff, params.task, provenance, lint), ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
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
- task.qaVerdict = outcome.result.verdict;
1231
- recordReview(task, "qa", outcome.result);
1232
- recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: "qa" }]);
1233
- if (outcome.result.relaxed) recordDecision(task, `QA round ${iterations} passed: ${outcome.result.relaxed}.`, "qa");
1234
- let decision: LoopDecision = decideReviewLoop(outcome.result.verdict, iterations, reviewLimit(task, deps));
1235
- if (decision === "blocked") decision = await askAtReviewLimit(task, outcome.result, iterations, deps);
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 qaReport(outcome, decision, provenance, lintBrief(lint, deps.config.lint.mode, Boolean(task.lintWaiver)));
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
- function handleTrack(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
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
- // The full workflow always ends with the QA gate.
1462
- const roster = path === "full" ? parseRoster([...asked, "qa"]) : asked;
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: its tests as the last step, or action=qa." : "No QA gate: nothing here needs tests."} Then action=complete.`;
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