@a-t-h-i/bot-lobby 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -17
- package/package.json +12 -6
- package/prompts/backend.md +3 -1
- package/prompts/designer.md +24 -1
- package/prompts/master.md +98 -19
- package/prompts/quickfix.md +16 -3
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/agents/backend.ts +2 -2
- package/src/agents/designer.ts +2 -2
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/classifier/triage.ts +24 -7
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +6 -1
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +50 -3
- package/src/lobby/runtime.ts +41 -40
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +8 -2
- package/src/lobby/tabs/tasks.ts +16 -5
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +36 -5
- package/src/master/decisions.ts +10 -2
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +108 -13
- package/src/pi/events.ts +48 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/route.ts +180 -0
- package/src/pi/start-task.ts +56 -4
- package/src/pi/tools.ts +32 -9
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +28 -2
- package/src/schemas/configuration.ts +15 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +52 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/workflow/track.ts +436 -0
- package/src/workflow/workflow.ts +734 -46
package/src/pi/route.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a new request goes before any task exists. The classifier (Jev, or
|
|
3
|
+
* the rules while it is off) reads whether one agent can do it alone, right
|
|
4
|
+
* away. When it can, the oracle confirms with `route_request`: the lobby hands
|
|
5
|
+
* the request to the quick-fix agent and shows the Quick fix tab. Otherwise,
|
|
6
|
+
* or when the oracle says it needs the team, the task starts as before.
|
|
7
|
+
*
|
|
8
|
+
* Nothing is routed without the lobby (it runs quick fixes), when routing is
|
|
9
|
+
* off (`workflow.routeQuickFixes`), or for a request the user already placed
|
|
10
|
+
* (`--task`, `--fast`, `--full`) or a planned task.
|
|
11
|
+
*/
|
|
12
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
import type { Domain } from "../schemas/agent.ts";
|
|
17
|
+
import type { TaskTriage } from "../schemas/task.ts";
|
|
18
|
+
import { chooseRoute, type RequestRoute } from "../workflow/track.ts";
|
|
19
|
+
import { triageFor } from "../classifier/instance.ts";
|
|
20
|
+
import { ownedTask } from "../state/persistence.ts";
|
|
21
|
+
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
22
|
+
import { lobbyFeed } from "../lobby/feed.ts";
|
|
23
|
+
import { startTask, type StartOptions } from "./start-task.ts";
|
|
24
|
+
import { isQuiet, isSubagentProcess } from "./quiet.ts";
|
|
25
|
+
|
|
26
|
+
export const ROUTE_TOOL = "route_request";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hands a request to the lobby's quick-fix agent and shows the Quick fix tab;
|
|
30
|
+
* the job's id, or undefined without a lobby. `builder` is set for a quick
|
|
31
|
+
* feature: it runs on that domain's model, thinking and time limit.
|
|
32
|
+
*/
|
|
33
|
+
export type QuickFixHandoff = (request: string, builder: Domain | undefined, reason: string) => string | undefined;
|
|
34
|
+
|
|
35
|
+
let handoff: QuickFixHandoff | undefined;
|
|
36
|
+
|
|
37
|
+
/** The lobby registers its quick-fix hand-off while it runs. */
|
|
38
|
+
export function setQuickFixHandoff(next: QuickFixHandoff | undefined): void {
|
|
39
|
+
handoff = next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Pending {
|
|
43
|
+
request: string;
|
|
44
|
+
options: StartOptions;
|
|
45
|
+
route: RequestRoute;
|
|
46
|
+
at: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let pending: Pending | undefined;
|
|
50
|
+
|
|
51
|
+
/** A request the oracle has not routed in this long is forgotten: the next one starts afresh. */
|
|
52
|
+
const STALE_MS = 30 * 60_000;
|
|
53
|
+
|
|
54
|
+
/** The request waiting for the oracle to route it, if any. */
|
|
55
|
+
export function pendingRequest(now = Date.now()): { request: string; route: RequestRoute } | undefined {
|
|
56
|
+
if (pending && now - pending.at >= STALE_MS) pending = undefined;
|
|
57
|
+
return pending ? { request: pending.request, route: pending.route } : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A quick feature (bigger than a small change, but self-contained) runs with its builder's settings; a quick fix with the quick-fix agent's. */
|
|
61
|
+
function builderFor(route: RequestRoute): Domain | undefined {
|
|
62
|
+
return route.size === "medium" || route.size === "large" ? route.builder : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the oracle is asked: one step, from the request alone. */
|
|
66
|
+
export function routeMessage(request: string, route: RequestRoute): string {
|
|
67
|
+
return [
|
|
68
|
+
"bot-lobby: a new request, not a task yet.",
|
|
69
|
+
`Request: ${request}`,
|
|
70
|
+
`Read: a ${builderFor(route) ? "quick feature" : "quick fix"} — ${route.reasons.join("; ")} (${route.source}).`,
|
|
71
|
+
"",
|
|
72
|
+
"Confirm where it goes with route_request, in one step and from the request alone: do not read files, plan or ask.",
|
|
73
|
+
"- to=quickfix: one agent does it now and the lobby shows it on the Quick fix tab. Right for anything one person can just do, even a rich piece of work in one file.",
|
|
74
|
+
"- to=task: the team (bot-lobby's workflow), when it needs several areas agreeing on a contract, a survey of an unfamiliar codebase, a risky change, or decisions the user must make first.",
|
|
75
|
+
"Then tell the user in one line where it went.",
|
|
76
|
+
].join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RequestOptions extends StartOptions {
|
|
80
|
+
/** `--task`: always a task. */
|
|
81
|
+
task?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A new request from the lobby or `/bot-lobby <request>`: a task at once, or
|
|
86
|
+
* (when it reads as one agent's work) a question to the oracle first.
|
|
87
|
+
* Resolves with what happened.
|
|
88
|
+
*/
|
|
89
|
+
export async function startRequest(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, request: string, options: RequestOptions = {}): Promise<"task" | "routing" | undefined> {
|
|
90
|
+
const { task: asTask, ...start } = options;
|
|
91
|
+
const config = loadConfig();
|
|
92
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
93
|
+
const placed = asTask || start.track || start.approvedPlan || !config.workflow.routeQuickFixes || !handoff;
|
|
94
|
+
// A session that already drives a task gets startTask's own warning.
|
|
95
|
+
if (placed || ownedTask(root, configDir, ctx.sessionManager.getSessionId())) {
|
|
96
|
+
return (await startTask(pi, ctx, configDir, request, start)) ? "task" : undefined;
|
|
97
|
+
}
|
|
98
|
+
const triage = await triageFor({ cwd: ctx.cwd, root, configDir }, request);
|
|
99
|
+
const route = chooseRoute(request, triage, config.classifier.thresholds.quickFixAt);
|
|
100
|
+
const read: StartOptions = { ...start, triaged: true, ...(triage ? { triage } : {}) };
|
|
101
|
+
if (route.to === "task") return (await startTask(pi, ctx, configDir, request, read)) ? "task" : undefined;
|
|
102
|
+
pending = { request, options: read, route, at: Date.now() };
|
|
103
|
+
lobbyFeed.log("LOBBY", `reads as a ${builderFor(route) ? "quick feature" : "quick fix"} (${route.source}): ${route.reasons[0]} — the oracle confirms`, "info");
|
|
104
|
+
pi.sendUserMessage(routeMessage(request, route), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
105
|
+
return "routing";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const RouteSchema = Type.Object({
|
|
109
|
+
to: StringEnum(["quickfix", "task"] as const, { description: "quickfix: one agent does it now, on the lobby's Quick fix tab; task: the team (bot-lobby's workflow)" }),
|
|
110
|
+
reason: Type.Optional(Type.String({ description: "one line: why it goes there" })),
|
|
111
|
+
request: Type.Optional(Type.String({ description: "the request as it should be done, when the user refined it since; their words otherwise" })),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** The oracle's answer to a routing question: the quick-fix agent, or a task. */
|
|
115
|
+
export async function routeRequest(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, params: { to: "quickfix" | "task"; reason?: string; request?: string }): Promise<{ ok: boolean; text: string }> {
|
|
116
|
+
const waiting = pendingRequest();
|
|
117
|
+
if (!waiting || !pending) return { ok: false, text: "No request is waiting to be routed. Start a task with /bot-lobby <request>." };
|
|
118
|
+
const { options, route } = pending;
|
|
119
|
+
const request = params.request?.trim() || pending.request;
|
|
120
|
+
pending = undefined;
|
|
121
|
+
const why = params.reason?.trim();
|
|
122
|
+
if (params.to === "quickfix") {
|
|
123
|
+
const id = handoff?.(request, builderFor(route), why || route.reasons[0] || "one agent can do it alone");
|
|
124
|
+
if (id) {
|
|
125
|
+
lobbyFeed.log("LOBBY", `the oracle sent it to the quick-fix agent: ${id}${why ? ` — ${why}` : ""}`, "success");
|
|
126
|
+
return { ok: true, text: `${id} is running on the lobby's Quick fix tab, which is now open. Tell the user in one line that this looks like a ${builderFor(route) ? "quick feature" : "quick fix"} and the quick-fix agent is on it; nothing else to do.` };
|
|
127
|
+
}
|
|
128
|
+
// No lobby to run it any more: it becomes a task.
|
|
129
|
+
}
|
|
130
|
+
lobbyFeed.log("LOBBY", `the oracle sent it to the team${why ? ` — ${why}` : ""}`, "info");
|
|
131
|
+
const task = await startTask(pi, ctx, configDir, request, { ...options, routed: true });
|
|
132
|
+
return task
|
|
133
|
+
? { ok: true, text: `Started ${task.id} as a task; its kickoff follows as the next message. Say nothing more now.` }
|
|
134
|
+
: { ok: false, text: "Could not start a task: this session already drives one (finish or cancel it first)." };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `route_request`, active only while a request waits for the oracle, so it
|
|
139
|
+
* never sits in the tool list of plain pi or of a running task.
|
|
140
|
+
*/
|
|
141
|
+
export function registerRouteTool(pi: ExtensionAPI, configDir: string): void {
|
|
142
|
+
if (isSubagentProcess()) return;
|
|
143
|
+
pi.on("session_start", () => {
|
|
144
|
+
pending = undefined;
|
|
145
|
+
});
|
|
146
|
+
pi.on("before_agent_start", () => {
|
|
147
|
+
const active = pi.getActiveTools();
|
|
148
|
+
const want = Boolean(pendingRequest());
|
|
149
|
+
if (want === active.includes(ROUTE_TOOL)) return;
|
|
150
|
+
pi.setActiveTools(want ? [...active, ROUTE_TOOL] : active.filter((name) => name !== ROUTE_TOOL));
|
|
151
|
+
});
|
|
152
|
+
pi.registerTool({
|
|
153
|
+
name: ROUTE_TOOL,
|
|
154
|
+
label: "Route request",
|
|
155
|
+
description: "Send a new bot-lobby request to the quick-fix agent (to=quickfix: one agent does it now, shown on the lobby's Quick fix tab) or to the team (to=task: bot-lobby's workflow). Only while a request waits to be routed.",
|
|
156
|
+
promptSnippet: "Route a new bot-lobby request: quickfix (one agent now) or task (the team)",
|
|
157
|
+
parameters: RouteSchema,
|
|
158
|
+
renderShell: "self",
|
|
159
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
160
|
+
const result = await routeRequest(pi, ctx, configDir, params as { to: "quickfix" | "task"; reason?: string; request?: string });
|
|
161
|
+
return { content: [{ type: "text", text: result.text }], details: { ok: result.ok, to: (params as { to: string }).to } };
|
|
162
|
+
},
|
|
163
|
+
renderCall(args, theme) {
|
|
164
|
+
if (isQuiet()) return new Container();
|
|
165
|
+
const call = args as { to?: string };
|
|
166
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("route_request"))} ${theme.fg("accent", call.to ?? "")}`, 0, 0);
|
|
167
|
+
},
|
|
168
|
+
renderResult(result, _options, theme) {
|
|
169
|
+
if (isQuiet()) return new Container();
|
|
170
|
+
const details = result.details as { ok?: boolean; to?: string } | undefined;
|
|
171
|
+
const icon = details?.ok ? theme.fg("success", "✓") : theme.fg("warning", "!");
|
|
172
|
+
return new Text(`${icon} ${theme.fg("toolTitle", theme.bold("route_request"))} ${theme.fg("muted", `→ ${details?.to === "quickfix" ? "Quick fix tab" : "a task"}`)}`, 0, 0);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Tests: forget any request waiting for the oracle. */
|
|
178
|
+
export function clearPendingRequest(): void {
|
|
179
|
+
pending = undefined;
|
|
180
|
+
}
|
package/src/pi/start-task.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { createTask, taskRequest, type Task } from "../schemas/task.ts";
|
|
8
|
+
import { createTask, taskRequest, type Task, type TaskTrack, type TaskTriage, type TrackPath } from "../schemas/task.ts";
|
|
9
|
+
import type { Domain } from "../schemas/agent.ts";
|
|
10
|
+
import { chooseTrack, trackLine, trackSummary } from "../workflow/track.ts";
|
|
11
|
+
import { lobbyFeed } from "../lobby/feed.ts";
|
|
9
12
|
import { createTaskDir, ensureProjectStructure, nextTaskId, ownedTask, saveTask, taskDirFor } from "../state/persistence.ts";
|
|
10
13
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
11
14
|
import { transition } from "../state/task-state.ts";
|
|
@@ -16,6 +19,7 @@ import { setAutoMode } from "../state/auto.ts";
|
|
|
16
19
|
import { loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest } from "../state/backlog.ts";
|
|
17
20
|
import { triageFor } from "../classifier/instance.ts";
|
|
18
21
|
import { freshContextOn, markContext } from "./fresh-context.ts";
|
|
22
|
+
import { setBudget } from "../state/budget.ts";
|
|
19
23
|
|
|
20
24
|
function uniqueTaskId(root: string, configDir: string, request: string): string {
|
|
21
25
|
const base = nextTaskId(request);
|
|
@@ -25,18 +29,51 @@ function uniqueTaskId(root: string, configDir: string, request: string): string
|
|
|
25
29
|
return id;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
/** In a kickoff after the oracle routed the request to the team (the lobby then shows the request once). */
|
|
33
|
+
export const ROUTED_LINE = "Routed: you sent this request to the team.";
|
|
34
|
+
|
|
35
|
+
/** The fast track's steps: straight to the agents the request needs, QA only for tests, then complete. */
|
|
36
|
+
function fastSteps(track: TaskTrack): string[] {
|
|
37
|
+
const building = track.roster.filter((member): member is Domain => member === "designer" || member === "backend");
|
|
38
|
+
const builders = building.length > 1
|
|
39
|
+
? `one call with assignments for ${building.join(" and ")}, each task stating the contract between them`
|
|
40
|
+
: building.length === 1 ? `domain ${building[0]}` : track.roster.includes("qa") ? "domain qa (the change is its tests)" : "the one domain that owns the files";
|
|
41
|
+
const steps = [
|
|
42
|
+
...(track.roster.includes("researcher") ? ["First summon the researcher for the outside facts it needs: orchestrate action=research with a domain and the question."] : []),
|
|
43
|
+
`Delegate now with orchestrate action=implement: ${builders}; open each task with "Step 1:" (the engine keeps the plan).`,
|
|
44
|
+
...(track.roster.includes("qa") && building.length > 0 ? ["QA takes part (tests): once the change is in, give qa the tests as the last step (action=implement domain=qa), or run action=qa."] : []),
|
|
45
|
+
"Check `git diff --stat` and the report, then orchestrate action=complete with a one-line summary.",
|
|
46
|
+
];
|
|
29
47
|
return [
|
|
48
|
+
"Fast track: the request reads small and clear, so skip the ceremony: no scouts, no proposal, no plan document.",
|
|
49
|
+
...steps.map((step, index) => `${index + 1}. ${step}`),
|
|
50
|
+
track.roster.includes("qa") ? "" : "No QA gate on this track: nothing here needs tests.",
|
|
51
|
+
"If the request is bigger, riskier or less clear than it reads, switch before delegating: orchestrate action=track track=full with a reason. Otherwise do not deliberate over the track.",
|
|
52
|
+
].filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function kickoff(task: Task, budgetMinutes = 0, options: { fastTrack?: boolean; routed?: boolean } = {}): string {
|
|
56
|
+
const track = task.track;
|
|
57
|
+
const head = [
|
|
30
58
|
`A bot-lobby task is active: ${task.id}`,
|
|
31
59
|
`Title: ${task.title}`,
|
|
32
60
|
`Request: ${taskRequest(task)}`,
|
|
33
61
|
`State: ${task.state}`,
|
|
62
|
+
...(options.routed ? [ROUTED_LINE] : []),
|
|
63
|
+
...(track ? [trackSummary(track)] : []),
|
|
64
|
+
...(budgetMinutes > 0 ? [`Time budget: ${budgetMinutes} minutes of work, for you and every agent. Size the plan to fit it and divide it by scope (see Time budget in your prompt).`] : []),
|
|
34
65
|
"",
|
|
66
|
+
];
|
|
67
|
+
if (track?.path === "fast") return [...head, ...fastSteps(track)].join("\n");
|
|
68
|
+
const fastAllowed = Boolean(track) && options.fastTrack !== false && track?.userChoice !== "full" && track?.source !== "plan";
|
|
69
|
+
return [
|
|
70
|
+
...head,
|
|
35
71
|
"Drive it with the orchestrate tool:",
|
|
36
72
|
"1. clarify if the request is genuinely ambiguous,",
|
|
37
73
|
"2. scout the domains the request touches,",
|
|
38
74
|
"3. synthesize the findings and propose a short `- ` bullet list for approval.",
|
|
39
75
|
"Do not implement anything before the user approves the proposal.",
|
|
76
|
+
...(fastAllowed ? ["If it is in fact a small, clear, low-risk change, take the fast track instead: orchestrate action=track track=fast with a reason."] : []),
|
|
40
77
|
].join("\n");
|
|
41
78
|
}
|
|
42
79
|
|
|
@@ -52,6 +89,15 @@ export interface StartOptions {
|
|
|
52
89
|
auto?: boolean;
|
|
53
90
|
/** The task's short title (a planned task's own title); derived from the request otherwise. */
|
|
54
91
|
title?: string;
|
|
92
|
+
/** Minutes of work time the task gets; `workflow.taskBudgetMinutes` when absent. */
|
|
93
|
+
budget?: number;
|
|
94
|
+
/** The user's `--fast` or `--full`: the path the task takes, whatever its request reads as. */
|
|
95
|
+
track?: TrackPath;
|
|
96
|
+
/** The classifier already read the request (routing did): `triage` is its read, or absent when it was off. */
|
|
97
|
+
triaged?: boolean;
|
|
98
|
+
triage?: TaskTriage;
|
|
99
|
+
/** The oracle sent this request to the team after the classifier read it as a quick fix. */
|
|
100
|
+
routed?: boolean;
|
|
55
101
|
}
|
|
56
102
|
|
|
57
103
|
export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, request: string, options: StartOptions = {}): Promise<Task | undefined> {
|
|
@@ -68,9 +114,15 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
|
|
|
68
114
|
createTaskDir(root, configDir, task);
|
|
69
115
|
transition(task, "clarifying");
|
|
70
116
|
// The classifier's read of the request (when it is on) reaches the Master's very first turn.
|
|
71
|
-
const triage = await triageFor({ cwd: ctx.cwd, root, configDir }, request);
|
|
117
|
+
const triage = options.triaged ? options.triage : await triageFor({ cwd: ctx.cwd, root, configDir }, request);
|
|
72
118
|
if (triage) task.triage = triage;
|
|
119
|
+
// How serious the request reads: who takes part, and whether it takes the fast track or the full workflow.
|
|
120
|
+
const config = loadConfig();
|
|
121
|
+
task.track = chooseTrack(request, triage, { fastTrack: config.workflow.fastTrack, ...(options.track ? { forced: options.track } : {}), ...(options.approvedPlan ? { approvedPlan: true } : {}) });
|
|
122
|
+
lobbyFeed.log("LOBBY", trackLine(task.track), "info");
|
|
73
123
|
saveTask(root, configDir, task);
|
|
124
|
+
const budgetMinutes = options.budget ?? config.workflow.taskBudgetMinutes;
|
|
125
|
+
if (budgetMinutes > 0) setBudget(root, configDir, task.id, budgetMinutes);
|
|
74
126
|
if (options.auto) setAutoMode(root, configDir, task.id, true, sessionId);
|
|
75
127
|
// A session that starts a task is named after it, so /resume and the lobby list it by name.
|
|
76
128
|
if (!pi.getSessionName()) pi.setSessionName(task.title);
|
|
@@ -80,7 +132,7 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
|
|
|
80
132
|
// The oracle takes the task on with a clean context: nothing said before the kickoff is sent to its model.
|
|
81
133
|
if (freshContextOn()) markContext(pi, { kind: "start", taskId: task.id, at: Date.now() });
|
|
82
134
|
// A kickoff while pi is still busy (another turn) queues behind it instead of throwing.
|
|
83
|
-
pi.sendUserMessage(kickoff(task), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
135
|
+
pi.sendUserMessage(kickoff(task, budgetMinutes, { fastTrack: config.workflow.fastTrack, ...(options.routed ? { routed: true } : {}) }), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
84
136
|
return task;
|
|
85
137
|
}
|
|
86
138
|
|
package/src/pi/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import type
|
|
3
|
-
import { Container, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { getMarkdownTheme, type AgentToolUpdateCallback, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Container, Markdown, Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
6
6
|
import type { ProcessRunner } from "../execution/pi-runner.ts";
|
|
@@ -8,6 +8,10 @@ import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
|
8
8
|
import { classifier, effortFor, hintsFor, triageFor } from "../classifier/instance.ts";
|
|
9
9
|
import { truncate } from "../text.ts";
|
|
10
10
|
import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
|
|
11
|
+
import { whileAsking } from "../state/budget.ts";
|
|
12
|
+
import { askUser } from "../ask/dialog.ts";
|
|
13
|
+
import type { AskQuestion } from "../ask/types.ts";
|
|
14
|
+
import { unescapeBreaks } from "../lobby/markdown.ts";
|
|
11
15
|
import { isQuiet } from "./quiet.ts";
|
|
12
16
|
import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
|
|
13
17
|
import { agentName, describeRun } from "./run-summary.ts";
|
|
@@ -36,16 +40,22 @@ const OrchestrateSchema = Type.Object({
|
|
|
36
40
|
Type.Object({
|
|
37
41
|
domain: Type.String({ description: "designer, backend, or qa" }),
|
|
38
42
|
task: Type.String({ description: "the concrete step(s) for that domain's worker" }),
|
|
43
|
+
minutes: Type.Optional(Type.Number({ description: "under a time budget: minutes for this worker, by its scope" })),
|
|
39
44
|
}),
|
|
40
45
|
{ description: "implement: run several domains in parallel (distinct domains); workers share files through the file desk" },
|
|
41
46
|
),
|
|
42
47
|
),
|
|
48
|
+
minutes: Type.Optional(Type.Number({ description: "implement (under a time budget): minutes for this step, by its scope; budget: more minutes to ask the user for" })),
|
|
43
49
|
approvalId: Type.Optional(Type.String({ description: "resolve_approval: the approval id from a worker result" })),
|
|
44
50
|
decision: Type.Optional(
|
|
45
51
|
StringEnum(["approved", "rejected"] as const, { description: "resolve_approval: approve or reject the request" }),
|
|
46
52
|
),
|
|
47
53
|
note: Type.Optional(Type.String({ description: "resolve_approval: rationale, or what to do instead when rejected" })),
|
|
48
|
-
reason: Type.Optional(Type.String({ description: "block: why the task cannot continue" })),
|
|
54
|
+
reason: Type.Optional(Type.String({ description: "block: why the task cannot continue; budget: why the task needs more time; track: why the task takes that path or needs those members" })),
|
|
55
|
+
track: Type.Optional(
|
|
56
|
+
StringEnum(["fast", "full"] as const, { description: "track: fast (straight to the roster's agents, no scouts, proposal or plan; QA only for tests) or full (the whole workflow)" }),
|
|
57
|
+
),
|
|
58
|
+
roster: Type.Optional(Type.Array(Type.String(), { description: "track: who takes part — any of designer, backend, qa, researcher" })),
|
|
49
59
|
text: Type.Optional(Type.String({ description: "decide/complete/knowledge: the decision, completion summary, or knowledge text" })),
|
|
50
60
|
file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md" })),
|
|
51
61
|
kind: Type.Optional(
|
|
@@ -64,7 +74,9 @@ const DESCRIPTION = [
|
|
|
64
74
|
"review), knowledge (record approved knowledge or a decision),",
|
|
65
75
|
"compact (replace a knowledge file with a rewritten version, archiving the old one),",
|
|
66
76
|
"resolve_approval (approve or reject a request), complete (declare the task done after the gates",
|
|
67
|
-
"pass), block/resume (escalate or continue),
|
|
77
|
+
"pass), block/resume (escalate or continue), budget (under a time budget: where it stands, or ask the",
|
|
78
|
+
"user for more minutes with a reason), track (the task's path and who takes part: show it, or correct it with",
|
|
79
|
+
"track=fast|full, roster and a reason), status, cancel.",
|
|
68
80
|
"The engine validates every step against the task state machine, so a rejected action means the workflow is not at that step yet.",
|
|
69
81
|
].join(" ");
|
|
70
82
|
|
|
@@ -109,8 +121,11 @@ export function workflowDeps(
|
|
|
109
121
|
classifier: classifier(),
|
|
110
122
|
triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
|
|
111
123
|
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
// Time spent waiting on the user is not the task's: its budget clock waits too.
|
|
125
|
+
ask: async (question) => (hasUI ? whileAsking(() => ctx.ui.input(question)) : undefined),
|
|
126
|
+
choose: async (title, options) => (hasUI ? whileAsking(() => ctx.ui.select(title, options)) : undefined),
|
|
127
|
+
// An agent's own questions (the designer's), relayed as the questionnaire.
|
|
128
|
+
...(hasUI ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
|
|
114
129
|
notify: (message, level = "info") => {
|
|
115
130
|
if (hasUI) ctx.ui.notify(message, level);
|
|
116
131
|
},
|
|
@@ -135,6 +150,14 @@ function runReporter(
|
|
|
135
150
|
};
|
|
136
151
|
}
|
|
137
152
|
|
|
153
|
+
/** A header line over Markdown, rendered as pi renders its own messages (a proposal's bullets, a report's code spans). */
|
|
154
|
+
function headedMarkdown(header: string, body: string, color: (text: string) => string): Container {
|
|
155
|
+
const box = new Container();
|
|
156
|
+
box.addChild(new Text(header, 0, 0));
|
|
157
|
+
if (body.trim()) box.addChild(new Markdown(unescapeBreaks(body), 0, 0, getMarkdownTheme(), { color }));
|
|
158
|
+
return box;
|
|
159
|
+
}
|
|
160
|
+
|
|
138
161
|
/** TUI-only transcript entries; these never enter the model's context. */
|
|
139
162
|
function registerBotLobbyEntries(pi: ExtensionAPI): void {
|
|
140
163
|
pi.registerEntryRenderer("bot-lobby", (entry, { expanded }, theme) => {
|
|
@@ -145,7 +168,7 @@ function registerBotLobbyEntries(pi: ExtensionAPI): void {
|
|
|
145
168
|
}
|
|
146
169
|
const header = `bot-lobby ${data?.taskId ?? ""} — ${data?.kind ?? "note"}`.trim();
|
|
147
170
|
const body = data?.text ?? "";
|
|
148
|
-
return
|
|
171
|
+
return headedMarkdown(theme.fg("accent", theme.bold(header)), expanded ? body : truncate(body, 600), (text) => theme.fg("toolOutput", text));
|
|
149
172
|
});
|
|
150
173
|
}
|
|
151
174
|
|
|
@@ -166,7 +189,7 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
|
|
|
166
189
|
name: "orchestrate",
|
|
167
190
|
label: "Orchestrate",
|
|
168
191
|
description: DESCRIPTION,
|
|
169
|
-
promptSnippet: "Run a bot-lobby workflow step (clarify, scout, propose, plan, decide, status, cancel)",
|
|
192
|
+
promptSnippet: "Run a bot-lobby workflow step (clarify, scout, propose, plan, implement, track, decide, status, cancel)",
|
|
170
193
|
promptGuidelines: [
|
|
171
194
|
"Use orchestrate for every bot-lobby workflow step; it enforces the task state machine and records results.",
|
|
172
195
|
],
|
|
@@ -200,7 +223,7 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
|
|
|
200
223
|
const body = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
201
224
|
const icon = details?.ok ? theme.fg("success", "✓") : theme.fg("warning", "!");
|
|
202
225
|
const header = `${icon} ${theme.fg("toolTitle", theme.bold("orchestrate"))} ${theme.fg("accent", details?.taskId ?? "")} ${theme.fg("muted", `→ ${details?.state ?? "?"}`)}`;
|
|
203
|
-
return
|
|
226
|
+
return expanded && body ? headedMarkdown(header, truncate(body, 2000), (text) => theme.fg("dim", text)) : new Text(header, 0, 0);
|
|
204
227
|
},
|
|
205
228
|
});
|
|
206
229
|
}
|
package/src/pi/ui.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { slotSituations } from "./zen-metrics.ts";
|
|
|
26
26
|
import type { Situation } from "./kaomoji.ts";
|
|
27
27
|
import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
|
|
28
28
|
import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
|
|
29
|
+
import { budgetClock } from "../state/budget.ts";
|
|
29
30
|
|
|
30
31
|
export const STATUS_KEY = "bot-lobby";
|
|
31
32
|
|
|
@@ -52,6 +53,8 @@ let zenOn = false;
|
|
|
52
53
|
* keys the widget's render cache.
|
|
53
54
|
*/
|
|
54
55
|
let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
|
|
56
|
+
/** Where the widget's task lives, so its time budget can be read. */
|
|
57
|
+
let zenPaths: { root: string; configDir: string } | undefined;
|
|
55
58
|
let zenVersion = 0;
|
|
56
59
|
|
|
57
60
|
/** Latest master tool activity; the oracle's speech bubble shows it. */
|
|
@@ -255,7 +258,8 @@ export class ZenScene {
|
|
|
255
258
|
const motion = this.motion(now);
|
|
256
259
|
const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}|${still}`;
|
|
257
260
|
if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
|
|
258
|
-
const
|
|
261
|
+
const time = zenState.task && zenPaths ? budgetClock(zenPaths.root, zenPaths.configDir, zenState.task.id, now) : undefined;
|
|
262
|
+
const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still, ...(time ? { time } : {}) };
|
|
259
263
|
const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => clip(line, width));
|
|
260
264
|
this.cache = { key, theme, lines };
|
|
261
265
|
return lines;
|
|
@@ -356,6 +360,7 @@ function leaveZen(ctx: ExtensionContext): void {
|
|
|
356
360
|
export function applyStatus(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[] = []): void {
|
|
357
361
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
358
362
|
const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
|
|
363
|
+
zenPaths = { root, configDir };
|
|
359
364
|
const sameTask = zenState.task?.id === task?.id;
|
|
360
365
|
setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
|
|
361
366
|
ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
|
package/src/pi/zen-metrics.ts
CHANGED
|
@@ -11,6 +11,13 @@ import { SLOT_IDS, SLOT_LABELS, type SlotId, type SlotState } from "./mascot-art
|
|
|
11
11
|
import { formatDuration, planChecklist } from "./zen.ts";
|
|
12
12
|
import { QUIET_MS, quietFor, slotOf } from "./run-summary.ts";
|
|
13
13
|
import type { Situation } from "./kaomoji.ts";
|
|
14
|
+
import { formatMinutes } from "../state/budget.ts";
|
|
15
|
+
|
|
16
|
+
/** A task's time budget as the scene shows it: work time spent of the whole. */
|
|
17
|
+
export interface SceneTime {
|
|
18
|
+
usedMs: number;
|
|
19
|
+
totalMs: number;
|
|
20
|
+
}
|
|
14
21
|
|
|
15
22
|
export type { SlotId, SlotState };
|
|
16
23
|
|
|
@@ -70,7 +77,10 @@ function latestRunFor(runs: AgentRun[], id: SlotId): AgentRun | undefined {
|
|
|
70
77
|
function runElapsedLabel(run: AgentRun | undefined, now: number): string {
|
|
71
78
|
if (!run) return "—";
|
|
72
79
|
const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
|
|
73
|
-
|
|
80
|
+
const elapsed = end - Date.parse(run.startedAt);
|
|
81
|
+
// Under a time budget: minutes spent of the minutes given (`12/30m`).
|
|
82
|
+
if (run.allotMs) return `${Math.max(0, Math.floor(elapsed / 60_000))}/${Math.round((run.allotMs + (run.extendedMs ?? 0)) / 60_000)}m`;
|
|
83
|
+
return formatDuration(elapsed);
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
|
|
@@ -89,7 +99,7 @@ function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
|
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMetrics {
|
|
102
|
+
export function sceneMetrics(task: Task, runs: AgentRun[], now: number, time?: SceneTime): SceneMetrics {
|
|
93
103
|
const checklist = planChecklist(task.plan ?? "", runs);
|
|
94
104
|
const total = checklist.length;
|
|
95
105
|
const done = checklist.filter((step) => step.status === "done").length;
|
|
@@ -98,7 +108,7 @@ export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMe
|
|
|
98
108
|
const elapsed = Number.isFinite(created) ? Math.max(0, now - created) : 0;
|
|
99
109
|
return {
|
|
100
110
|
...summary,
|
|
101
|
-
elapsedLabel: formatDuration(elapsed),
|
|
111
|
+
elapsedLabel: time ? `${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}` : formatDuration(elapsed),
|
|
102
112
|
slots: SLOT_IDS.map((id) => slotView(id, runs, now)),
|
|
103
113
|
};
|
|
104
114
|
}
|
package/src/pi/zen.ts
CHANGED
|
@@ -37,7 +37,8 @@ import {
|
|
|
37
37
|
type LargeSlot,
|
|
38
38
|
type LargeTaskRow,
|
|
39
39
|
} from "./zen-large.ts";
|
|
40
|
-
import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
|
|
40
|
+
import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SceneTime, type SlotView } from "./zen-metrics.ts";
|
|
41
|
+
import { formatMinutes } from "../state/budget.ts";
|
|
41
42
|
import { feedLine, QUIET_MS, quietFor } from "./run-summary.ts";
|
|
42
43
|
import { shortDuration } from "../text.ts";
|
|
43
44
|
|
|
@@ -378,18 +379,22 @@ function checklistRow(step: PlanStep, ordinal: number): string {
|
|
|
378
379
|
return ` ${icon} ${ordinal}. ${step.text}`;
|
|
379
380
|
}
|
|
380
381
|
|
|
381
|
-
function headerLine(task: Task, now: number, quiet: boolean): string {
|
|
382
|
+
function headerLine(task: Task, now: number, quiet: boolean, time?: SceneTime): string {
|
|
382
383
|
const paused = task.paused ? " (paused)" : "";
|
|
383
|
-
const elapsed = formatDuration(now - Date.parse(task.createdAt));
|
|
384
|
+
const elapsed = time ? `${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}` : formatDuration(now - Date.parse(task.createdAt));
|
|
384
385
|
const mode = quiet ? "tools hidden (alt+t)" : "tools shown";
|
|
385
386
|
return `bot-lobby ${task.id} · ${task.state}${paused} ⏱ ${elapsed} · ${mode}`;
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
/** The pending approval or blocker the user must resolve, with its severity. */
|
|
389
|
-
function taskAlert(task: Task): { text: string; kind: "warning" | "error" } | undefined {
|
|
389
|
+
/** The pending approval or blocker the user must resolve, with its severity; then a spent time budget. */
|
|
390
|
+
function taskAlert(task: Task, time?: SceneTime): { text: string; kind: "warning" | "error" } | undefined {
|
|
390
391
|
const pending = task.approvals.filter((approval) => approval.status === "pending");
|
|
391
392
|
if (pending.length > 0) return { text: `approvals pending: ${pending.map((approval) => approval.id).join(", ")}`, kind: "warning" };
|
|
392
393
|
if (task.blockers.length > 0) return { text: `blocked: ${truncate(task.blockers[0]!.reason, 60)}`, kind: "error" };
|
|
394
|
+
if (time && time.usedMs >= time.totalMs * 0.9) {
|
|
395
|
+
const spent = time.usedMs >= time.totalMs;
|
|
396
|
+
return { text: `time budget ${spent ? "spent" : "nearly spent"}: ${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}`, kind: "warning" };
|
|
397
|
+
}
|
|
393
398
|
return undefined;
|
|
394
399
|
}
|
|
395
400
|
|
|
@@ -518,10 +523,10 @@ function compactPanel(
|
|
|
518
523
|
opts: PanelOptions,
|
|
519
524
|
width: number,
|
|
520
525
|
): string[] {
|
|
521
|
-
const metrics = sceneMetrics(task, runs, now);
|
|
526
|
+
const metrics = sceneMetrics(task, runs, now, opts.time);
|
|
522
527
|
const expressions = opts.expressions ?? {};
|
|
523
528
|
const strip = opts.still ? [] : compactStrip(task.state, metrics.slots, expressions, opts.theme);
|
|
524
|
-
const fixed = [...bannerLines(width), headerLine(task, now, quiet), ...strip];
|
|
529
|
+
const fixed = [...bannerLines(width), headerLine(task, now, quiet, opts.time), ...strip];
|
|
525
530
|
const tail = tailLines(task, runs, now, tick, steps, opts.theme);
|
|
526
531
|
const room = Math.max(0, MAX_PANEL_LINES - fixed.length - tail.length);
|
|
527
532
|
return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => clip(line, width));
|
|
@@ -577,8 +582,8 @@ function sceneInput(
|
|
|
577
582
|
steps: PlanStep[],
|
|
578
583
|
opts: PanelOptions,
|
|
579
584
|
): LargeSceneInput {
|
|
580
|
-
const metrics = sceneMetrics(task, runs, now);
|
|
581
|
-
const alert = taskAlert(task);
|
|
585
|
+
const metrics = sceneMetrics(task, runs, now, opts.time);
|
|
586
|
+
const alert = taskAlert(task, opts.time);
|
|
582
587
|
return {
|
|
583
588
|
taskId: task.id,
|
|
584
589
|
taskTitle: task.title,
|
|
@@ -616,6 +621,8 @@ export interface PanelOptions {
|
|
|
616
621
|
oracleMotion?: OracleMotion;
|
|
617
622
|
/** Leave out the oracle and agent animations: status, alert, activity and checklist only. */
|
|
618
623
|
still?: boolean;
|
|
624
|
+
/** The task's time budget: the elapsed label reads against it. */
|
|
625
|
+
time?: SceneTime;
|
|
619
626
|
}
|
|
620
627
|
|
|
621
628
|
/** The oracle's clocked animation state beyond its expression frame (see expressions.ts). */
|
package/src/roles/reviewer.ts
CHANGED
|
@@ -30,15 +30,23 @@ function parseVerdict(text: string | undefined): Verdict {
|
|
|
30
30
|
return "blocked";
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The severity a finding was tagged with, or undefined when it carries none. */
|
|
34
|
+
function taggedSeverity(text: string): Severity | undefined {
|
|
35
|
+
const match = /^\[([^\]]+)\]/.exec(text);
|
|
36
|
+
return SEVERITIES.find((candidate) => match?.[1]?.toLowerCase().includes(candidate));
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
function parseFinding(text: string): ReviewFinding {
|
|
34
40
|
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(text);
|
|
35
|
-
|
|
36
|
-
return { severity, text: (match?.[2] ?? text).trim() };
|
|
41
|
+
return { severity: taggedSeverity(text) ?? "info", text: (match?.[2] ?? text).trim() };
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** The QA gate never passes by default: a PASS must cite at least one executed check. */
|
|
40
45
|
export const UNVERIFIED_PASS = "unverified pass: PASS without executed checks under ## Verification, downgraded to CHANGES_REQUIRED";
|
|
41
46
|
|
|
47
|
+
/** Minor and info findings never hold the gate; what the reviewer asked for is kept as follow-ups. */
|
|
48
|
+
export const MINOR_ONLY = "CHANGES_REQUIRED for minor or info findings only: passed, its asks kept as follow-ups";
|
|
49
|
+
|
|
42
50
|
/** A verification bullet names a command or check and its result (`- cmd — result`). */
|
|
43
51
|
function executedChecks(verification: string): string[] {
|
|
44
52
|
return bullets(verification).filter((entry) => /\S\s*(?:—|–|-{1,2}|:|=>|→)\s*\S/.test(entry));
|
|
@@ -49,23 +57,34 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
|
|
|
49
57
|
const sections = parseSections(raw);
|
|
50
58
|
const verification = findSection(sections, "verification") ?? "";
|
|
51
59
|
const requiredChanges = bullets(findSection(sections, "required changes"));
|
|
60
|
+
const entries = bullets(findSection(sections, "findings"));
|
|
52
61
|
let verdict = parseVerdict(findSection(sections, "verdict"));
|
|
53
62
|
let downgraded: string | undefined;
|
|
54
|
-
|
|
63
|
+
let relaxed: string | undefined;
|
|
64
|
+
const checked = executedChecks(verification).length > 0;
|
|
65
|
+
if (verdict === "pass" && !checked) {
|
|
55
66
|
verdict = "changes_required";
|
|
56
67
|
downgraded = UNVERIFIED_PASS;
|
|
57
68
|
requiredChanges.push("Re-run the QA gate and record the checks actually executed, each as `- command — result`.");
|
|
69
|
+
} else if (
|
|
70
|
+
verdict === "changes_required" && checked && entries.length > 0
|
|
71
|
+
// Only findings explicitly tagged minor or info: an untagged one may be serious.
|
|
72
|
+
&& entries.every((entry) => { const severity = taggedSeverity(entry); return severity === "minor" || severity === "info"; })
|
|
73
|
+
) {
|
|
74
|
+
verdict = "pass";
|
|
75
|
+
relaxed = MINOR_ONLY;
|
|
58
76
|
}
|
|
59
77
|
return {
|
|
60
78
|
domain,
|
|
61
79
|
role: "reviewer",
|
|
62
80
|
verdict,
|
|
63
|
-
findings:
|
|
81
|
+
findings: entries.map(parseFinding),
|
|
64
82
|
verification,
|
|
65
83
|
requiredChanges,
|
|
66
84
|
optionalImprovements: bullets(findSection(sections, "optional improvements")),
|
|
67
85
|
pushback: parsePushback(sections),
|
|
68
86
|
...(downgraded ? { downgraded } : {}),
|
|
87
|
+
...(relaxed ? { relaxed } : {}),
|
|
69
88
|
raw,
|
|
70
89
|
};
|
|
71
90
|
}
|