@a-t-h-i/bot-lobby 0.6.45 → 0.6.47

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.
@@ -60,3 +60,13 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
60
60
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
61
61
  SOFTWARE.
62
62
  ```
63
+
64
+ ## Typefaces
65
+
66
+ The web UI bundles the Latin subset of these variable fonts (from the
67
+ fontsource packages, `@fontsource-variable/*`) as files in `webui/dist/assets`,
68
+ so the Typeface setting works offline. Each is under the SIL Open Font
69
+ License 1.1 (https://openfontlicense.org): Inter (The Inter Project Authors),
70
+ Geist and Geist Mono (Vercel), IBM Plex Sans (IBM Corp.), Manrope (The
71
+ Manrope Project Authors) and JetBrains Mono (The JetBrains Mono Project
72
+ Authors).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.45",
3
+ "version": "0.6.47",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -57,6 +57,12 @@
57
57
  "@earendil-works/pi-ai": "1.0.1",
58
58
  "@earendil-works/pi-coding-agent": "1.0.1",
59
59
  "@earendil-works/pi-tui": "1.0.1",
60
+ "@fontsource-variable/geist": "^5.3.0",
61
+ "@fontsource-variable/geist-mono": "^5.3.0",
62
+ "@fontsource-variable/ibm-plex-sans": "^5.3.0",
63
+ "@fontsource-variable/inter": "^5.3.0",
64
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
65
+ "@fontsource-variable/manrope": "^5.3.0",
60
66
  "@playwright/test": "1.62.0",
61
67
  "@tailwindcss/vite": "4.3.3",
62
68
  "@types/node": "^22.10.0",
package/prompts/qa.md CHANGED
@@ -55,7 +55,7 @@ you state in the report.
55
55
  6. Run the smallest useful set of them.
56
56
  7. Decide what confidence is still missing.
57
57
  8. Only for a meaningful uncovered risk, add a behavioural test.
58
- 9. PASS or FAIL.
58
+ 9. Give the verdict: PASS, CHANGES_REQUIRED or BLOCKED.
59
59
 
60
60
  Never duplicate coverage that exists, and never rewrite existing tests because
61
61
  you prefer another style.
@@ -101,6 +101,12 @@ re-running it. Use the project's own type checker, build and existing tests
101
101
  before judging by reading, and run the narrowest command that answers the
102
102
  question (one test file, one test name, the touched package's typecheck).
103
103
  Run a whole suite only at HIGH risk, or when the change touches shared logic.
104
+ A failure is the task's only when the change causes it. A test that fails in
105
+ code the diff does not touch, for a reason the diff cannot explain, was
106
+ already failing: list it as an `info` finding and judge the change on the rest.
107
+ When you cannot tell, run that one test at the task's base commit in a
108
+ throwaway worktree (`git worktree add <tmp dir> <base>`; remove it after) —
109
+ never stash, reset or check out over the working tree.
104
110
  Do not re-run a check the worker already ran on the same files with the same
105
111
  result; cite it. Always pass a bash `timeout`, and never start watch mode or
106
112
  long-running servers.
@@ -147,9 +153,11 @@ A PASS is a claim backed by evidence, not an absence of complaints.
147
153
  A PASS without executed checks is downgraded by the engine to
148
154
  CHANGES_REQUIRED.
149
155
  - Check every acceptance criterion; an unmet or unverifiable one is a finding.
150
- - If you could not verify something important (no test runner, a failing
151
- environment, missing access), say so and return CHANGES_REQUIRED or BLOCKED —
152
- never PASS on assumption.
156
+ - If you could not verify something important, say so and never PASS on
157
+ assumption. Verify it another way first (the typecheck, a narrower test,
158
+ reading the code path); if it stays unverifiable, return CHANGES_REQUIRED
159
+ with what is missing. BLOCKED is only for a review that cannot happen at
160
+ all, and it stops the task until the user steps in.
153
161
 
154
162
  ## Testing conventions
155
163
 
@@ -108,7 +108,13 @@ If implementation changes are required, report them to the Master.
108
108
  behaviour, a failing check, an unmet acceptance criterion, a security
109
109
  problem. Tag every finding with its severity.
110
110
  - **BLOCKED** only when you cannot review at all (it does not build, the
111
- checks cannot run).
111
+ checks cannot run). It stops the task until the user steps in, so never use
112
+ it for something the workers can fix.
113
+ - **Pre-existing failures are not the task's.** A check that fails in code
114
+ the diff does not touch, for a reason the diff cannot explain, was already
115
+ failing: note it as an `info` finding and judge the change on the rest. When
116
+ unsure, run that check at the task's base commit in a throwaway worktree
117
+ (`git worktree add`), never by stashing or resetting the working tree.
112
118
  - **A re-review verifies; it does not start over.** When your context lists
113
119
  what the previous round asked for, check each item first and say which are
114
120
  addressed. Do not raise the bar between rounds: a new blocking finding must
@@ -0,0 +1,27 @@
1
+ import { resolve } from "node:path";
2
+
3
+ const executions = new Map<string, Set<AbortController>>();
4
+ const key = (root: string, configDir: string, taskId: string) => JSON.stringify([resolve(root), configDir, taskId]);
5
+
6
+ /** Scope an orchestration action and its workers/children to one project task. */
7
+ export function taskExecution(root: string, configDir: string, taskId: string, parent?: AbortSignal) {
8
+ const id = key(root, configDir, taskId);
9
+ const controller = new AbortController();
10
+ const running = executions.get(id) ?? new Set<AbortController>();
11
+ running.add(controller);
12
+ executions.set(id, running);
13
+ return {
14
+ signal: parent ? AbortSignal.any([parent, controller.signal]) : controller.signal,
15
+ release: () => {
16
+ running.delete(controller);
17
+ if (!running.size) executions.delete(id);
18
+ },
19
+ };
20
+ }
21
+
22
+ /** Abort only this task's orchestration and the subprocesses inheriting its signal. */
23
+ export function cancelTaskExecutions(root: string, configDir: string, taskId: string): void {
24
+ for (const controller of executions.get(key(root, configDir, taskId)) ?? []) {
25
+ controller.abort(new Error("Task abandoned by the user"));
26
+ }
27
+ }
package/src/lobby/host.ts CHANGED
@@ -173,6 +173,10 @@ export interface LobbyService {
173
173
  * background session. A notice, and the background session's key.
174
174
  */
175
175
  resumeTask?(taskId: string): Promise<{ notice: string; key?: string }>;
176
+ acceptTask?(taskId: string): Promise<{ notice: string }>;
177
+ forceCloseTask?(taskId: string, title: string): Promise<{ notice: string }>;
178
+ canForceCloseTask?(taskId: string): boolean;
179
+ resolveTaskApproval?(taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }>;
176
180
  /** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
177
181
  sendToSession(sessionId: string, text: string): string;
178
182
  /** Sessions running in this project, other than this window's, from their heartbeats. */
@@ -4,7 +4,7 @@
4
4
  * `node:*`): the web page imports this file directly.
5
5
  */
6
6
 
7
- export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "git", "knowledge", "metrics"] as const;
7
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "git", "knowledge"] as const;
8
8
  export type TabId = (typeof TAB_IDS)[number];
9
9
 
10
10
  export const TAB_LABELS: Record<TabId, string> = {
@@ -12,7 +12,6 @@ export const TAB_LABELS: Record<TabId, string> = {
12
12
  tasks: "Tasks",
13
13
  plan: "Plan",
14
14
  quickfix: "Quick fix",
15
- metrics: "Metrics",
16
15
  git: "Git",
17
16
  knowledge: "Knowledge",
18
17
  };
@@ -9,6 +9,8 @@ import { updateBlockingRequest } from "../state/blocking-requests.ts";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { loadTask, ownedTask, peekOwnedTask, peekTasks, saveTask } from "../state/persistence.ts";
11
11
  import { taskWork } from "../state/work-time.ts";
12
+ import { transition } from "../state/task-state.ts";
13
+ import { cancelTaskExecutions } from "../execution/task-cancellation.ts";
12
14
  import { refreshReview, deferReview } from "../delivery/review.ts";
13
15
  import { deliver, type DeliveryRequest } from "../delivery/operations.ts";
14
16
  import { deliveryStore, deliveryTask } from "../delivery/store.ts";
@@ -18,7 +20,7 @@ import { loadConfig, readDataRoots, saveConfig as writeConfig } from "../state/p
18
20
  import { knowledgeQuestion } from "./knowledge.ts";
19
21
  import { addPlanComment, editComment, readPlanComments, type PlanComment } from "../state/comments.ts";
20
22
  import { isAutoMode } from "../state/auto.ts";
21
- import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
23
+ import { markInboxDelivered, sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
22
24
  import { livePresence } from "../state/presence.ts";
23
25
  import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
24
26
  import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
@@ -50,6 +52,9 @@ import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
50
52
  import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
51
53
  import type { Runtime } from "./runtime.ts";
52
54
  import { findLinters } from "../execution/lint.ts";
55
+ import { handleResolveApproval, lastQaAsks, waiveLint, waiveQa } from "../workflow/workflow.ts";
56
+ import { recordDecision } from "../master/decisions.ts";
57
+ import { fail } from "../webui/api/index.ts";
53
58
 
54
59
  /** The state background callbacks report to; set while the lobby service runs. */
55
60
  let serviceState: Runtime | undefined;
@@ -384,6 +389,73 @@ async function resumeTask(state: Runtime, taskId: string): Promise<{ notice: str
384
389
  }
385
390
  }
386
391
 
392
+ function mutableTask(state: Runtime, taskId: string): Task {
393
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId)) fail(400, "bad_request", "invalid task ID");
394
+ const task = loadTask(state.root, state.configDir, taskId);
395
+ if (!task) fail(404, "not_found", "task not found in this project");
396
+ if (TERMINAL_STATES.includes(task.state)) fail(400, "bad_request", "task is terminal");
397
+ return task;
398
+ }
399
+
400
+ async function wakeTask(state: Runtime, task: Task, message: string): Promise<string> {
401
+ const inbox = sendToInbox(state.root, state.configDir, task.id, message, state.ctx.sessionManager.getSessionId());
402
+ const route = resumeRoute(task, taskDrivers(state));
403
+ if (route.kind === "stopped" || route.kind === "paused") return (await resumeTask(state, task.id)).notice;
404
+ if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) {
405
+ state.pi.sendUserMessage(message, state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
406
+ markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
407
+ } else {
408
+ const session = backgroundSessions().find((entry) => entry.alive && entry.projectRoot === state.root && entry.sessionId === task.ownerSessionId);
409
+ if (session) {
410
+ session.send(message);
411
+ markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
412
+ }
413
+ }
414
+ return "the owning oracle has been notified";
415
+ }
416
+
417
+ function canForceCloseTask(state: Runtime, taskId: string): boolean {
418
+ const task = loadTask(state.root, state.configDir, taskId);
419
+ return Boolean(task && !TERMINAL_STATES.includes(task.state) && (!task.ownerSessionId || task.ownerSessionId === state.ctx.sessionManager.getSessionId()));
420
+ }
421
+
422
+ async function forceCloseTask(state: Runtime, taskId: string, title: string): Promise<{ notice: string }> {
423
+ const task = mutableTask(state, taskId);
424
+ if (title !== task.title) fail(400, "bad_request", "the title must match exactly");
425
+ if (!canForceCloseTask(state, taskId)) fail(400, "bad_request", "cancel this task in its owning session; remote cancellation is unavailable");
426
+ recordDecision(task, "The user force-closed the task from the Tasks screen; work abandoned, code and worktree retained.");
427
+ transition(task, "abandoned");
428
+ saveTask(state.root, state.configDir, task);
429
+ cancelTaskExecutions(state.root, state.configDir, task.id);
430
+ if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) state.ctx.abort();
431
+ lobbyTopics.bump("tasks");
432
+ return { notice: `${task.id} abandoned; code, worktree and history retained` };
433
+ }
434
+
435
+ async function acceptTask(state: Runtime, taskId: string): Promise<{ notice: string }> {
436
+ const task = mutableTask(state, taskId);
437
+ if (!["implementing", "reviewing", "blocked"].includes(task.state)) fail(400, "bad_request", "there is no work to accept yet");
438
+ const asks = lastQaAsks(task);
439
+ if (task.qaVerdict !== "pass" && !task.qaWaiver) waiveQa(task, asks, "from the Tasks screen");
440
+ if (task.lint?.state === "failing") waiveLint(task, "from the Tasks screen");
441
+ recordDecision(task, "The user accepted the work as it is from the Tasks screen; the oracle was asked to complete it.");
442
+ saveTask(state.root, state.configDir, task);
443
+ lobbyTopics.bump("tasks");
444
+ const wake = await wakeTask(state, task, `The user accepted ${task.id}'s work as it is. Call orchestrate action=complete now with a short summary naming outstanding QA/lint asks: ${asks.join("; ") || "none recorded"}. Read the persisted gate waivers first. Do not merge automatically.`);
445
+ return { notice: `${task.id} accepted as it is; ${wake}` };
446
+ }
447
+
448
+ async function resolveTaskApproval(state: Runtime, taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }> {
449
+ const task = mutableTask(state, taskId);
450
+ if (!task.approvals.some((entry) => entry.id === approvalId && entry.status === "pending")) fail(409, "conflict", "approval is no longer pending");
451
+ let message: string;
452
+ try { message = handleResolveApproval(task, { action: "resolve_approval", approvalId, decision, note }); }
453
+ catch (error) { return fail(400, "bad_request", (error as Error).message); }
454
+ saveTask(state.root, state.configDir, task);
455
+ lobbyTopics.bump("tasks");
456
+ return { notice: `${message}; ${await wakeTask(state, task, message)}` };
457
+ }
458
+
387
459
  /** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
388
460
  function sendToTask(state: Runtime, taskId: string, text: string): string {
389
461
  const task = loadTask(state.root, state.configDir, taskId);
@@ -826,6 +898,10 @@ export function createLobbyService(state: Runtime): LobbyService {
826
898
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
827
899
  sendToTask: (taskId, text) => sendToTask(state, taskId, text),
828
900
  resumeTask: (taskId) => resumeTask(state, taskId),
901
+ acceptTask: (taskId) => acceptTask(state, taskId),
902
+ forceCloseTask: (taskId, title) => forceCloseTask(state, taskId, title),
903
+ canForceCloseTask: (taskId) => canForceCloseTask(state, taskId),
904
+ resolveTaskApproval: (taskId, approvalId, decision, note) => resolveTaskApproval(state, taskId, approvalId, decision, note),
829
905
  sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
830
906
  liveSessions: () => liveSessions(state),
831
907
  switchTo: (target) => switchTo(state, target),
@@ -12,7 +12,7 @@ import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.
12
12
  import { scriptsFor } from "../scripts/pick.ts";
13
13
  import { summarizeOutcomes } from "./synthesis.ts";
14
14
  import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
15
- import { parseReviewResult, validateReviewResult } from "../roles/reviewer.ts";
15
+ import { parseReviewResult, readVerdict, validateReviewResult } from "../roles/reviewer.ts";
16
16
  import { truncate } from "../text.ts";
17
17
  import { isScoutResultUsable, parseScoutResult, validateScoutResult } from "../roles/scout.ts";
18
18
  import { treeState } from "../execution/git.ts";
@@ -433,9 +433,9 @@ function reviewerContext(request: ReviewerRequest): string {
433
433
  .join("\n\n");
434
434
  }
435
435
 
436
- /** True when the output carries the reviewer contract's required heading. */
436
+ /** True when a verdict can be read from the output (the contract's heading, or a line naming it). */
437
437
  export function hasReviewVerdict(text: string): boolean {
438
- return /##\s*verdict/i.test(text);
438
+ return readVerdict(text) !== undefined;
439
439
  }
440
440
 
441
441
  /** A success without the Verdict section is off-contract and worth resampling. */
@@ -23,11 +23,34 @@ export const reviewerSpec: RoleSpec = {
23
23
 
24
24
  const SEVERITIES: readonly Severity[] = ["critical", "major", "minor", "info"];
25
25
 
26
- function parseVerdict(text: string | undefined): Verdict {
27
- const value = (text ?? "").toUpperCase();
28
- if (value.includes("CHANGES REQUIRED") || value.includes("CHANGES_REQUIRED")) return "changes_required";
29
- if (value.includes("PASS")) return "pass";
30
- return "blocked";
26
+ /** Severity words reviewers use outside the contract's four, mapped onto them. */
27
+ const SEVERITY_ALIASES: ReadonlyArray<[RegExp, Severity]> = [
28
+ [/\b(?:blocker|blocking|severe)\b/, "critical"],
29
+ [/\bhigh\b/, "major"],
30
+ [/\b(?:low|nit|nitpick|trivial|cosmetic|optional|suggestion)\b/, "minor"],
31
+ [/\b(?:note|fyi)\b/, "info"],
32
+ ];
33
+
34
+ /**
35
+ * Verdict words in the order a reviewer writes them. FAIL is the word qa.md
36
+ * used to ask for: it means changes are required, not that QA could not run.
37
+ */
38
+ const VERDICT_TOKENS: ReadonlyArray<[RegExp, Verdict]> = [
39
+ [/\bCHANGES?[\s_-]*(?:REQUIRED|REQUESTED|NEEDED)\b|\b(?:NEEDS|REQUEST(?:ED)?)[\s_-]+CHANGES\b/, "changes_required"],
40
+ [/\bFAIL(?:ED|S|URE)?\b/, "changes_required"],
41
+ [/\bBLOCKED\b/, "blocked"],
42
+ [/\bPASS(?:ED|ES)?\b/, "pass"],
43
+ ];
44
+
45
+ /** The first verdict word in the text, so `PASS — no changes required` reads as the PASS it leads with. */
46
+ function verdictToken(text: string): Verdict | undefined {
47
+ const value = text.toUpperCase();
48
+ let best: { at: number; verdict: Verdict } | undefined;
49
+ for (const [pattern, verdict] of VERDICT_TOKENS) {
50
+ const match = pattern.exec(value);
51
+ if (match && (!best || match.index < best.at)) best = { at: match.index, verdict };
52
+ }
53
+ return best?.verdict;
31
54
  }
32
55
 
33
56
  /** The verdict token may sit in the heading itself (`## Verdict PASS`), leaving an empty body. */
@@ -38,14 +61,35 @@ function verdictHeading(sections: Map<string, string>): string | undefined {
38
61
  return undefined;
39
62
  }
40
63
 
64
+ /**
65
+ * The reviewer's verdict, or undefined when none can be read. The contract's
66
+ * `## Verdict` section comes first; failing that, a line that names the
67
+ * verdict (`**Verdict:** PASS`, `# Verdict: PASS`) and the line after it.
68
+ */
69
+ export function readVerdict(raw: string, sections = parseSections(raw)): Verdict | undefined {
70
+ const heading = verdictHeading(sections);
71
+ if (heading !== undefined) {
72
+ const body = (findSection(sections, "verdict") ?? "").split("\n").filter((line) => line.trim()).slice(0, 2).join("\n");
73
+ const found = verdictToken(`${heading}\n${body}`);
74
+ if (found) return found;
75
+ }
76
+ const lines = raw.split("\n");
77
+ const at = lines.findIndex((line) => /\bverdict\b/i.test(line));
78
+ if (at < 0) return undefined;
79
+ const next = lines.slice(at + 1).find((line) => line.trim()) ?? "";
80
+ return verdictToken(`${lines[at]}\n${next}`);
81
+ }
82
+
41
83
  /** The severity a finding was tagged with, or undefined when it carries none. */
42
84
  function taggedSeverity(text: string): Severity | undefined {
43
- const match = /^\[([^\]]+)\]/.exec(text);
44
- return SEVERITIES.find((candidate) => match?.[1]?.toLowerCase().includes(candidate));
85
+ const tag = /^\*{0,2}\[([^\]]+)\]/.exec(text)?.[1]?.toLowerCase();
86
+ if (!tag) return undefined;
87
+ return SEVERITIES.find((candidate) => tag.includes(candidate))
88
+ ?? SEVERITY_ALIASES.find(([pattern]) => pattern.test(tag))?.[1];
45
89
  }
46
90
 
47
91
  function parseFinding(text: string): ReviewFinding {
48
- const match = /^\[([^\]]+)\]\s*(.*)$/.exec(text);
92
+ const match = /^\*{0,2}\[([^\]]+)\]\*{0,2}\s*(.*)$/.exec(text);
49
93
  return { severity: taggedSeverity(text) ?? "info", text: (match?.[2] ?? text).trim() };
50
94
  }
51
95
 
@@ -55,9 +99,38 @@ export const UNVERIFIED_PASS = "unverified pass: PASS without executed checks un
55
99
  /** Minor and info findings never hold the gate; what the reviewer asked for is kept as follow-ups. */
56
100
  export const MINOR_ONLY = "CHANGES_REQUIRED for minor or info findings only: passed, its asks kept as follow-ups";
57
101
 
58
- /** A verification bullet names a command or check and its result (`- cmd — result`). */
102
+ /** No verdict could be read: the review did not finish, so it is run again rather than stopping the task. */
103
+ export const NO_VERDICT = "no readable verdict: the review did not finish, so nothing is known yet about the work";
104
+
105
+ /** A check names a command and its result: `cmd — result`, `cmd: result`, `✅ cmd`, `cmd (40/40)` or `cmd passed`. */
106
+ const CHECK_RESULT = /\S\s*(?:—|–|-{1,2}|:|=>|→|->)\s*\S|[✅✓✔❌✗✘]|\(\s*\d+\s*\/\s*\d+\s*\)|\b(?:pass(?:ed|es|ing)?|fail(?:ed|s|ing)?|clean|exit(?:ed)?\s*(?:code\s*)?\d+|\d+\s+(?:tests?|passing|passed|failing|failed))\b/i;
107
+
108
+ /** A table row's cells, or undefined for a line that is not a row. */
109
+ function tableCells(line: string): string[] | undefined {
110
+ if (!/^\|.*\|$/.test(line)) return undefined;
111
+ return line.slice(1, -1).split("|").map((cell) => cell.trim());
112
+ }
113
+
114
+ /**
115
+ * The checks under `## Verification`: bullets, numbered items and table rows
116
+ * that name a command and its result. A reviewer's list style never decides
117
+ * whether the gate believes it ran something.
118
+ */
59
119
  function executedChecks(verification: string): string[] {
60
- return bullets(verification).filter((entry) => /\S\s*(?:—|–|-{1,2}|:|=>|→)\s*\S/.test(entry));
120
+ const lines = verification.split("\n").map((line) => line.trim());
121
+ const checks: string[] = [];
122
+ lines.forEach((line, index) => {
123
+ const cells = tableCells(line);
124
+ if (cells) {
125
+ const separator = cells.every((cell) => /^:?-{2,}:?$/.test(cell));
126
+ const header = tableCells(lines[index + 1] ?? "")?.every((cell) => /^:?-{2,}:?$/.test(cell));
127
+ if (!separator && !header && cells.filter(Boolean).length >= 2) checks.push(cells.join(" — "));
128
+ return;
129
+ }
130
+ const item = /^(?:[-*+]|\d+[.)])\s+(\S.*)$/.exec(line)?.[1];
131
+ if (item && CHECK_RESULT.test(item)) checks.push(item);
132
+ });
133
+ return checks;
61
134
  }
62
135
 
63
136
  /** Parse a reviewer's markdown into a structured verdict (never throws). */
@@ -66,11 +139,15 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
66
139
  const verification = findSection(sections, "verification") ?? "";
67
140
  const requiredChanges = bullets(findSection(sections, "required changes"));
68
141
  const entries = bullets(findSection(sections, "findings"));
69
- let verdict = parseVerdict(`${verdictHeading(sections) ?? ""}\n${findSection(sections, "verdict") ?? ""}`);
142
+ const read = readVerdict(raw, sections);
143
+ let verdict: Verdict = read ?? "changes_required";
70
144
  let downgraded: string | undefined;
71
145
  let relaxed: string | undefined;
72
146
  const checked = executedChecks(verification).length > 0;
73
- if (verdict === "pass" && !checked) {
147
+ if (!read) {
148
+ // Never a pass, and never a stop either: an unreadable review is run again.
149
+ requiredChanges.push("Re-run the QA gate: the last review returned no readable verdict.");
150
+ } else if (verdict === "pass" && !checked) {
74
151
  verdict = "changes_required";
75
152
  downgraded = UNVERIFIED_PASS;
76
153
  requiredChanges.push("Re-run the QA gate and record the checks actually executed, each as `- command — result`.");
@@ -93,6 +170,7 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
93
170
  pushback: parsePushback(sections),
94
171
  ...(downgraded ? { downgraded } : {}),
95
172
  ...(relaxed ? { relaxed } : {}),
173
+ ...(read ? {} : { unread: NO_VERDICT }),
96
174
  raw,
97
175
  };
98
176
  }
@@ -100,7 +178,7 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
100
178
  /** An unknown verdict must never be treated as acceptance (§42). */
101
179
  export function validateReviewResult(result: ReviewResult): string[] {
102
180
  const issues: string[] = [];
103
- if (!/##\s*verdict/i.test(result.raw)) issues.push("missing Verdict section");
181
+ if (result.unread) issues.push("missing Verdict section");
104
182
  if (result.downgraded) issues.push(result.downgraded);
105
183
  if (result.verdict !== "pass" && result.findings.length === 0 && result.requiredChanges.length === 0) {
106
184
  issues.push("non-pass verdict without findings or required changes");
@@ -89,6 +89,8 @@ export interface ReviewResult {
89
89
  downgraded?: string;
90
90
  /** Set when the engine passed a CHANGES_REQUIRED that named only minor or info findings. */
91
91
  relaxed?: string;
92
+ /** Set when no verdict could be read from the review (a failed or off-contract run). */
93
+ unread?: string;
92
94
  raw: string;
93
95
  }
94
96
 
@@ -89,13 +89,38 @@ export function readTaskArtifact(
89
89
 
90
90
  export function saveTask(root: string, configDir: string, task: Task): void {
91
91
  const latest = loadTask(root, configDir, task.id);
92
- if (latest) mergePhaseTiming(task, timingBaselines.get(task), latest);
92
+ // Abandonment is final: an in-flight action must not overwrite its audit or resurrect it.
93
+ if (latest?.state === "abandoned") {
94
+ Object.assign(task, latest);
95
+ return;
96
+ }
97
+ if (latest) {
98
+ preserveUserInterventions(task, latest);
99
+ mergePhaseTiming(task, timingBaselines.get(task), latest);
100
+ }
93
101
  const path = join(taskDir(dataRoot(root, configDir), task.id), "state.json");
94
102
  forgetCached(path);
95
103
  writeFileEnsured(path, JSON.stringify(task, null, 2));
96
104
  rememberTiming(task);
97
105
  }
98
106
 
107
+ /** Explicit user waivers and resolved approvals must survive an in-flight worker's snapshot. */
108
+ function preserveUserInterventions(task: Task, latest: Task): void {
109
+ const newlyWaived = !task.qaWaiver && latest.qaWaiver;
110
+ task.qaWaiver ??= latest.qaWaiver;
111
+ task.lintWaiver ??= latest.lintWaiver;
112
+ if (newlyWaived && !TERMINAL_STATES.includes(task.state)) {
113
+ task.blockers = [];
114
+ if (["implementing", "blocked"].includes(task.state)) task.state = "reviewing";
115
+ }
116
+ for (const approval of task.approvals) {
117
+ const resolved = latest.approvals.find((entry) => entry.id === approval.id && entry.status !== "pending");
118
+ if (approval.status === "pending" && resolved) Object.assign(approval, resolved);
119
+ }
120
+ const keys = new Set(task.decisions.map((entry) => JSON.stringify(entry)));
121
+ task.decisions.push(...latest.decisions.filter((entry) => !keys.has(JSON.stringify(entry))));
122
+ }
123
+
99
124
  /** Read a task state: the bot-lobby copy wins, else the newest pre-rename copy. */
100
125
  export function loadTask(root: string, configDir: string, taskId: string): Task | undefined {
101
126
  for (const dr of readDataRoots(root, configDir)) {
@@ -15,8 +15,7 @@ import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
15
15
  import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
16
16
  import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave, plannerPrevious, plannerPreviousArchive, plannerPreviousDelete, plannerPreviousGet, plannerPreviousOpen } from "./planner.ts";
17
17
  import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
18
- import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver } from "./tasks.ts";
19
- import { metricsGet } from "./metrics.ts";
18
+ import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver, tasksAccept, tasksForceClose, tasksResolveApproval } from "./tasks.ts";
20
19
  import { knowledgeAdd, knowledgeAsk, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
21
20
  import { plansDiscard, plansGet, plansStart } from "./plans.ts";
22
21
  import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
@@ -131,6 +130,18 @@ function buildRoutes(): Record<string, Route> {
131
130
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
132
131
  run: (body, ctx) => tasksResume(body as { taskId: string }, ctx),
133
132
  };
133
+ routes["tasks.accept"] = {
134
+ schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
135
+ run: (body, ctx) => tasksAccept(body as { taskId: string }, ctx),
136
+ };
137
+ routes["tasks.forceClose"] = {
138
+ schema: Type.Object({ taskId: TaskId, title: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
139
+ run: (body, ctx) => tasksForceClose(body as { taskId: string; title: string }, ctx),
140
+ };
141
+ routes["tasks.resolveApproval"] = {
142
+ schema: Type.Object({ taskId: TaskId, approvalId: TaskId, decision: Type.Union([Type.Literal("approved"), Type.Literal("rejected")]), note: Type.Optional(NoticeText) }, { additionalProperties: false }),
143
+ run: (body, ctx) => tasksResolveApproval(body as Parameters<typeof tasksResolveApproval>[0], ctx),
144
+ };
134
145
  routes["tasks.deliveryReview"] = {
135
146
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
136
147
  run: (body, ctx) => tasksDeliveryReview(body as { taskId: string }, ctx),
@@ -268,10 +279,6 @@ function buildRoutes(): Record<string, Route> {
268
279
  schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
269
280
  run: (body, ctx) => quickfixMovedToTask(body as { id: string }, ctx),
270
281
  };
271
- routes["metrics.get"] = {
272
- schema: Type.Object({ groupBy: Type.Union([Type.Literal("model"), Type.Literal("model-kind")]), query: Type.Optional(Type.String({ maxLength: 500 })), from: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), to: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), timeZone: Type.Optional(Type.String({ maxLength: 100 })) }, { additionalProperties: false }),
273
- run: (body, ctx) => metricsGet(body as Parameters<typeof metricsGet>[0], ctx),
274
- };
275
282
  routes["knowledge.files"] = { schema: Empty, run: (_body, ctx) => knowledgeFiles(ctx) };
276
283
  routes["knowledge.ask"] = {
277
284
  schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
@@ -282,17 +282,52 @@ export function tasksOpen(body: { taskId: string }, ctx: ApiContext): { sessionI
282
282
  return { notice: "The task's owning session has ended or is unavailable. No replacement session was created." };
283
283
  }
284
284
 
285
+ /** Interventions only apply to an active task in this project, never an archive. */
286
+ function interventionTask(taskId: string, ctx: ApiContext): Task {
287
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId)) fail(400, "bad_request", "invalid task ID");
288
+ const task = ctx.service.tasks().find((entry) => entry.id === taskId);
289
+ if (!task) fail(404, "not_found", "task not found in this project");
290
+ if (TERMINAL_STATES.includes(task.state)) fail(400, "bad_request", "this task is already finished");
291
+ return task;
292
+ }
293
+
294
+ function acceptable(task: Task): boolean {
295
+ return ["implementing", "reviewing", "blocked"].includes(task.state);
296
+ }
297
+
298
+ export async function tasksAccept(body: { taskId: string }, ctx: ApiContext): Promise<{ notice: string }> {
299
+ const task = interventionTask(body.taskId, ctx);
300
+ if (!acceptable(task)) fail(400, "bad_request", "only implementing, reviewing or blocked tasks can be accepted");
301
+ if (!ctx.service.acceptTask) fail(400, "bad_request", "accepting tasks is unavailable");
302
+ return ctx.service.acceptTask(body.taskId);
303
+ }
304
+
305
+ export async function tasksForceClose(body: { taskId: string; title: string }, ctx: ApiContext): Promise<{ notice: string }> {
306
+ const task = interventionTask(body.taskId, ctx);
307
+ if (body.title !== task.title) fail(400, "bad_request", "the title must match exactly");
308
+ if (!ctx.service.forceCloseTask) fail(400, "bad_request", "force closing tasks is unavailable");
309
+ return ctx.service.forceCloseTask(body.taskId, body.title);
310
+ }
311
+
312
+ export async function tasksResolveApproval(body: { taskId: string; approvalId: string; decision: "approved" | "rejected"; note?: string }, ctx: ApiContext): Promise<{ notice: string }> {
313
+ const task = interventionTask(body.taskId, ctx);
314
+ if (!pendingApprovals(task).some((approval) => approval.id === body.approvalId)) fail(409, "conflict", "approval is no longer pending; refresh the task");
315
+ if (!ctx.service.resolveTaskApproval) fail(400, "bad_request", "resolving task approvals is unavailable");
316
+ return ctx.service.resolveTaskApproval(body.taskId, body.approvalId, body.decision, body.note);
317
+ }
318
+
285
319
  /** How many recent runs the detail lists (the terminal's count). */
286
320
  const DETAIL_RUNS = 6;
287
321
 
288
322
  /** The terminal's "Waiting on" approvals: `kind` plus `for <domain>: <detail>`. */
289
323
  function waitingOf(task: Task): TaskDetail["waiting"] {
290
- return pendingApprovals(task).map((approval) => ({ kind: approval.kind, detail: `for ${approval.domain}: ${approval.detail}` }));
324
+ return pendingApprovals(task).map((approval) => ({ id: approval.id, domain: approval.domain, kind: approval.kind, detail: `for ${approval.domain}: ${approval.detail}` }));
291
325
  }
292
326
 
293
327
  /** One task read whole: request, plan or proposal, step checklist, waits and recent runs. */
294
328
  export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail {
295
- const task = [...ctx.service.tasks(), ...ctx.service.archivedTasks()].find((entry) => entry.id === body.taskId);
329
+ const active = ctx.service.tasks().find((entry) => entry.id === body.taskId);
330
+ const task = active ?? ctx.service.archivedTasks().find((entry) => entry.id === body.taskId);
296
331
  if (!task) fail(404, "not_found", `no task ${body.taskId}`);
297
332
  const found = task as Task;
298
333
  const request = taskRequest(found);
@@ -301,6 +336,8 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
301
336
  const clock = clockOf(found, work);
302
337
  const details = found.plan ? planDetails(found.plan) : undefined;
303
338
  return {
339
+ canAccept: Boolean(active && ctx.service.acceptTask) && acceptable(found),
340
+ canForceClose: Boolean(active && ctx.service.forceCloseTask) && !TERMINAL_STATES.includes(found.state) && (ctx.service.canForceCloseTask?.(found.id) ?? true),
304
341
  timing: projectPhaseTiming(found, new Date(now).toISOString()),
305
342
  ...(clock ? { work: clock } : {}),
306
343
  ...(found.delivery ? { delivery: found.delivery } : {}),