@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.
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/package.json +7 -1
- package/prompts/qa.md +12 -4
- package/prompts/reviewer.md +7 -1
- package/src/execution/task-cancellation.ts +27 -0
- package/src/lobby/host.ts +4 -0
- package/src/lobby/prompts.ts +1 -2
- package/src/lobby/service.ts +77 -1
- package/src/master/master.ts +3 -3
- package/src/roles/reviewer.ts +91 -13
- package/src/schemas/findings.ts +2 -0
- package/src/state/persistence.ts +26 -1
- package/src/webui/api/index.ts +13 -6
- package/src/webui/api/tasks.ts +39 -2
- package/src/webui/palette-core.ts +233 -2
- package/src/webui/protocol.ts +6 -74
- package/src/workflow/workflow.ts +18 -4
- package/webui/dist/assets/index-CkQ5Eduy.js +110 -0
- package/webui/dist/assets/index-Dl4yHBRY.css +2 -0
- package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/webui/dist/build.json +57 -69
- package/webui/dist/index.html +2 -2
- package/src/lobby/models/metrics.ts +0 -14
- package/src/webui/api/metrics.ts +0 -96
- package/webui/dist/assets/index-ClwTDiyo.css +0 -2
- package/webui/dist/assets/index-Ct47Kp7Q.js +0 -110
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
package/prompts/reviewer.md
CHANGED
|
@@ -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. */
|
package/src/lobby/prompts.ts
CHANGED
|
@@ -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"
|
|
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
|
};
|
package/src/lobby/service.ts
CHANGED
|
@@ -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),
|
package/src/master/master.ts
CHANGED
|
@@ -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
|
|
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
|
|
438
|
+
return readVerdict(text) !== undefined;
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
/** A success without the Verdict section is off-contract and worth resampling. */
|
package/src/roles/reviewer.ts
CHANGED
|
@@ -23,11 +23,34 @@ export const reviewerSpec: RoleSpec = {
|
|
|
23
23
|
|
|
24
24
|
const SEVERITIES: readonly Severity[] = ["critical", "major", "minor", "info"];
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
44
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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");
|
package/src/schemas/findings.ts
CHANGED
|
@@ -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
|
|
package/src/state/persistence.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/src/webui/api/index.ts
CHANGED
|
@@ -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 }),
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -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
|
|
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 } : {}),
|