@a-t-h-i/bot-lobby 0.3.0 → 0.4.0
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 +128 -30
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +58 -1
- package/prompts/qa.md +35 -2
- package/prompts/researcher.md +6 -0
- package/prompts/reviewer.md +16 -0
- package/prompts/scout.md +11 -2
- package/prompts/worker.md +35 -2
- package/src/desk/client-extension.ts +101 -0
- package/src/desk/desk.ts +249 -0
- package/src/desk/ipc.ts +178 -0
- package/src/desk/session.ts +214 -0
- package/src/execution/agent-runner.ts +160 -14
- package/src/execution/pi-runner.ts +351 -65
- package/src/index.ts +4 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +45 -0
- package/src/pi/commands.ts +28 -3
- package/src/pi/expressions.ts +43 -12
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +5 -15
- package/src/pi/model-support.ts +101 -0
- package/src/pi/run-summary.ts +170 -0
- package/src/pi/settings-ui.ts +158 -60
- package/src/pi/tools.ts +50 -7
- package/src/pi/ui.ts +48 -7
- package/src/pi/zen-large.ts +41 -4
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +29 -8
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +112 -18
- package/src/schemas/findings.ts +20 -0
- package/src/schemas/task.ts +26 -1
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +127 -10
package/src/pi/tools.ts
CHANGED
|
@@ -4,11 +4,12 @@ import { Container, 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";
|
|
7
|
-
import { inheritThinking } from "../schemas/configuration.ts";
|
|
8
7
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
9
8
|
import { truncate } from "../text.ts";
|
|
10
9
|
import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
|
|
11
10
|
import { isQuiet } from "./quiet.ts";
|
|
11
|
+
import { createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
|
|
12
|
+
import { agentName, describeRun } from "./run-summary.ts";
|
|
12
13
|
import {
|
|
13
14
|
ORCHESTRATE_ACTIONS,
|
|
14
15
|
runWorkflowAction,
|
|
@@ -29,6 +30,15 @@ const OrchestrateSchema = Type.Object({
|
|
|
29
30
|
plan: Type.Optional(Type.String({ description: "plan: the detailed internal plan" })),
|
|
30
31
|
domain: Type.Optional(Type.String({ description: "implement/research: designer, backend, or qa" })),
|
|
31
32
|
task: Type.Optional(Type.String({ description: "implement: the concrete step for that domain's worker" })),
|
|
33
|
+
assignments: Type.Optional(
|
|
34
|
+
Type.Array(
|
|
35
|
+
Type.Object({
|
|
36
|
+
domain: Type.String({ description: "designer, backend, or qa" }),
|
|
37
|
+
task: Type.String({ description: "the concrete step(s) for that domain's worker" }),
|
|
38
|
+
}),
|
|
39
|
+
{ description: "implement: run several domains in parallel (distinct domains); workers share files through the file desk" },
|
|
40
|
+
),
|
|
41
|
+
),
|
|
32
42
|
approvalId: Type.Optional(Type.String({ description: "resolve_approval: the approval id from a worker result" })),
|
|
33
43
|
decision: Type.Optional(
|
|
34
44
|
StringEnum(["approved", "rejected"] as const, { description: "resolve_approval: approve or reject the request" }),
|
|
@@ -49,7 +59,7 @@ const DESCRIPTION = [
|
|
|
49
59
|
"Actions: clarify (ask the user), scout (domain reconnaissance in parallel), research (summon the",
|
|
50
60
|
"read-only researcher for cited internet evidence on a complex change, tool, plugin, doc set or",
|
|
51
61
|
"dependency), propose (record the proposal and request approval), plan (record the internal",
|
|
52
|
-
"plan), implement (delegate
|
|
62
|
+
"plan), implement (delegate a step to a domain worker, or several domains in parallel with assignments), qa (final quality gate and the only",
|
|
53
63
|
"review), knowledge (record approved knowledge or a decision),",
|
|
54
64
|
"compact (replace a knowledge file with a rewritten version, archiving the old one),",
|
|
55
65
|
"resolve_approval (approve or reject a request), complete (declare the task done after the gates",
|
|
@@ -57,22 +67,39 @@ const DESCRIPTION = [
|
|
|
57
67
|
"The engine validates every step against the task state machine, so a rejected action means the workflow is not at that step yet.",
|
|
58
68
|
].join(" ");
|
|
59
69
|
|
|
60
|
-
/**
|
|
70
|
+
/** Resolve a `provider/id` (or bare id) against the models this session knows. */
|
|
71
|
+
export function modelLookup(ctx: ExtensionContext): ModelLookup {
|
|
72
|
+
return (ref) => {
|
|
73
|
+
const slash = ref.indexOf("/");
|
|
74
|
+
if (slash > 0) return ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
75
|
+
return ctx.modelRegistry.getAvailable().find((model) => model.id === ref);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build the engine dependencies from the current Pi context; every agent's model and thinking come from settings. */
|
|
61
80
|
export function workflowDeps(
|
|
62
81
|
ctx: ExtensionContext,
|
|
63
82
|
configDir: string,
|
|
64
83
|
signal: AbortSignal | undefined,
|
|
65
84
|
onUpdate: ((run: AgentRun) => void) | undefined,
|
|
66
85
|
runProcess?: ProcessRunner,
|
|
67
|
-
thinking?: string,
|
|
68
86
|
): WorkflowDeps {
|
|
69
87
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
70
88
|
const hasUI = ctx.hasUI;
|
|
89
|
+
const config = loadConfig();
|
|
90
|
+
const warn = (message: string) => {
|
|
91
|
+
if (hasUI) ctx.ui.notify(message, "warning");
|
|
92
|
+
};
|
|
71
93
|
return {
|
|
72
94
|
root,
|
|
73
95
|
configDir,
|
|
74
96
|
cwd: ctx.cwd,
|
|
75
|
-
config
|
|
97
|
+
config,
|
|
98
|
+
profile: createProfileResolver(config, {
|
|
99
|
+
lookup: modelLookup(ctx),
|
|
100
|
+
sessionModel: ctx.model ? modelRef(ctx.model) : undefined,
|
|
101
|
+
warn,
|
|
102
|
+
}),
|
|
76
103
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
77
104
|
signal,
|
|
78
105
|
onUpdate,
|
|
@@ -106,13 +133,28 @@ function runReporter(
|
|
|
106
133
|
/** TUI-only transcript entries; these never enter the model's context. */
|
|
107
134
|
function registerBotLobbyEntries(pi: ExtensionAPI): void {
|
|
108
135
|
pi.registerEntryRenderer("bot-lobby", (entry, { expanded }, theme) => {
|
|
109
|
-
const data = entry.data as { kind?: string; taskId?: string; text?: string } | undefined;
|
|
136
|
+
const data = entry.data as { kind?: string; taskId?: string; text?: string; ok?: boolean } | undefined;
|
|
137
|
+
if (data?.kind === "run") {
|
|
138
|
+
// One compact line per finished subagent run.
|
|
139
|
+
return new Text(theme.fg(data.ok ? "dim" : "warning", data.text ?? ""), 0, 0);
|
|
140
|
+
}
|
|
110
141
|
const header = `bot-lobby ${data?.taskId ?? ""} — ${data?.kind ?? "note"}`.trim();
|
|
111
142
|
const body = data?.text ?? "";
|
|
112
143
|
return new Text(`${theme.fg("accent", theme.bold(header))}\n${theme.fg("toolOutput", expanded ? body : truncate(body, 600))}`, 0, 0);
|
|
113
144
|
});
|
|
114
145
|
}
|
|
115
146
|
|
|
147
|
+
/** A transcript line per finished run, plus a warning for anything that stalled or ran out of time. */
|
|
148
|
+
function reportFinishedRuns(pi: ExtensionAPI, ctx: ExtensionContext, result: WorkflowResult): void {
|
|
149
|
+
for (const run of result.runs ?? []) {
|
|
150
|
+
const ok = run.status === "success" && !run.wrappedUp;
|
|
151
|
+
pi.appendEntry("bot-lobby", { kind: "run", taskId: result.taskId, text: describeRun(run), ok });
|
|
152
|
+
if (!ctx.hasUI) continue;
|
|
153
|
+
if (run.stalled) ctx.ui.notify(`bot-lobby: ${agentName(run)} ${run.role} stalled — ${run.error ?? "no output"}`, "warning");
|
|
154
|
+
else if (run.status === "timeout") ctx.ui.notify(`bot-lobby: ${agentName(run)} ${run.role} ${run.error ?? "hit its time limit"}`, "warning");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
116
158
|
export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, runProcess?: ProcessRunner): void {
|
|
117
159
|
registerBotLobbyEntries(pi);
|
|
118
160
|
pi.registerTool({
|
|
@@ -128,11 +170,12 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
|
|
|
128
170
|
renderShell: "self",
|
|
129
171
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
130
172
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
131
|
-
const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) => reportRuns(ctx, root, configDir, runs)), runProcess
|
|
173
|
+
const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) => reportRuns(ctx, root, configDir, runs)), runProcess);
|
|
132
174
|
const result = await runWorkflowAction(params as OrchestrateParams, deps);
|
|
133
175
|
if (params.action === "propose" && params.proposal && result.ok) {
|
|
134
176
|
pi.appendEntry("bot-lobby", { kind: "proposal", taskId: params.taskId, text: params.proposal });
|
|
135
177
|
}
|
|
178
|
+
reportFinishedRuns(pi, ctx, result);
|
|
136
179
|
applyStatus(ctx, root, configDir);
|
|
137
180
|
return {
|
|
138
181
|
content: [{ type: "text", text: result.message }],
|
package/src/pi/ui.ts
CHANGED
|
@@ -13,11 +13,16 @@ import {
|
|
|
13
13
|
ORACLE_GAP,
|
|
14
14
|
SLOT_GAP,
|
|
15
15
|
talkFrame,
|
|
16
|
+
triggerEmote,
|
|
17
|
+
WORKING_BLINK_CHANCE,
|
|
18
|
+
WORKING_GAP,
|
|
16
19
|
type ExpressionGap,
|
|
17
20
|
type ExpressionState,
|
|
18
21
|
} from "./expressions.ts";
|
|
19
22
|
import { ORACLE_THINKING } from "./activity.ts";
|
|
20
|
-
import { SLOT_IDS } from "./mascot-art.ts";
|
|
23
|
+
import { SLOT_IDS, type SlotId } from "./mascot-art.ts";
|
|
24
|
+
import { slotSituations } from "./zen-metrics.ts";
|
|
25
|
+
import type { Situation } from "./kaomoji.ts";
|
|
21
26
|
import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
|
|
22
27
|
import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
|
|
23
28
|
|
|
@@ -159,9 +164,31 @@ export function expressionTickDelay(states: readonly ExpressionState[], now: num
|
|
|
159
164
|
type ExpressionKey = keyof ExpressionFrames;
|
|
160
165
|
const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
|
|
161
166
|
|
|
162
|
-
/** The oracle keeps
|
|
163
|
-
function gapFor(key: ExpressionKey): ExpressionGap {
|
|
164
|
-
|
|
167
|
+
/** The oracle keeps its own schedule; working agents are livelier than idle ones. */
|
|
168
|
+
function gapFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): ExpressionGap {
|
|
169
|
+
if (key === "oracle") return ORACLE_GAP;
|
|
170
|
+
return situations?.[key].status === "working" ? WORKING_GAP : SLOT_GAP;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function blinkChanceFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): number | undefined {
|
|
174
|
+
return key !== "oracle" && situations?.[key].status === "working" ? WORKING_BLINK_CHANCE : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A compact fingerprint of what a slot is going through; a change may earn a reaction. */
|
|
178
|
+
export function situationKey(situation: Situation): string {
|
|
179
|
+
return [situation.status, situation.flag ?? "", situation.handover ? "handover" : "", situation.wrappedUp ? "wrapped" : ""].join("|");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether a slot's change of situation deserves an immediate emote: it started
|
|
184
|
+
* work, finished, failed, got flagged (waiting, quiet, retrying) or received a
|
|
185
|
+
* file. Going idle, or a flag clearing, passes quietly.
|
|
186
|
+
*/
|
|
187
|
+
export function isReaction(previous: string | undefined, next: Situation): boolean {
|
|
188
|
+
if (previous === undefined || previous === situationKey(next)) return false;
|
|
189
|
+
if (next.status === "idle") return false;
|
|
190
|
+
if (next.status !== "working") return true;
|
|
191
|
+
return !previous.startsWith("working|") || next.flag !== undefined || next.handover === true;
|
|
165
192
|
}
|
|
166
193
|
|
|
167
194
|
/** Animated zen scene + plan checklist shown above the editor while a task is active. */
|
|
@@ -172,6 +199,7 @@ class ZenWidget implements Component {
|
|
|
172
199
|
private timer: ReturnType<typeof setInterval>;
|
|
173
200
|
private disposed = false;
|
|
174
201
|
private readonly expressions: Record<ExpressionKey, ExpressionState>;
|
|
202
|
+
private readonly situations: Partial<Record<SlotId, string>> = {};
|
|
175
203
|
private readonly tui: TUI;
|
|
176
204
|
private readonly theme: () => Theme;
|
|
177
205
|
private readonly rng: () => number;
|
|
@@ -202,7 +230,15 @@ class ZenWidget implements Component {
|
|
|
202
230
|
}
|
|
203
231
|
|
|
204
232
|
private play(now: number): void {
|
|
205
|
-
|
|
233
|
+
const situations = slotSituations(zenState.runs, now);
|
|
234
|
+
for (const key of EXPRESSION_KEYS) {
|
|
235
|
+
this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key, situations), blinkChanceFor(key, situations));
|
|
236
|
+
}
|
|
237
|
+
for (const id of SLOT_IDS) {
|
|
238
|
+
const situation = situations[id];
|
|
239
|
+
if (isReaction(this.situations[id], situation)) this.expressions[id] = triggerEmote(now, this.rng, gapFor(id, situations));
|
|
240
|
+
this.situations[id] = situationKey(situation);
|
|
241
|
+
}
|
|
206
242
|
}
|
|
207
243
|
|
|
208
244
|
/** One interval, retimed when work starts or stops or an expression plays. */
|
|
@@ -222,6 +258,10 @@ class ZenWidget implements Component {
|
|
|
222
258
|
return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
private variants(): Partial<Record<SlotId, number>> {
|
|
262
|
+
return Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant]));
|
|
263
|
+
}
|
|
264
|
+
|
|
225
265
|
/**
|
|
226
266
|
* The panel only changes with the tick, an expression frame, the widget state or
|
|
227
267
|
* the elapsed second, so every other repaint (typing in the editor, streaming
|
|
@@ -233,11 +273,12 @@ class ZenWidget implements Component {
|
|
|
233
273
|
const theme = this.theme();
|
|
234
274
|
const expressions = this.frames();
|
|
235
275
|
const quiet = isQuiet();
|
|
236
|
-
const
|
|
276
|
+
const variants = this.variants();
|
|
277
|
+
const frameKey = [...EXPRESSION_KEYS.map((key) => expressions[key] ?? 0), ...SLOT_IDS.map((id) => variants[id] ?? 0)].join(",");
|
|
237
278
|
const motion = this.motion(now);
|
|
238
279
|
const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
|
|
239
280
|
if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
|
|
240
|
-
const opts = { width, rows, tick: this.tick, theme, expressions, oracleActivity, oracleMotion: motion };
|
|
281
|
+
const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion };
|
|
241
282
|
const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
|
|
242
283
|
this.cache = { key, theme, lines };
|
|
243
284
|
return lines;
|
package/src/pi/zen-large.ts
CHANGED
|
@@ -41,6 +41,10 @@ import {
|
|
|
41
41
|
type SlotState,
|
|
42
42
|
} from "./mascot-art.ts";
|
|
43
43
|
import type { PanelTheme } from "./zen.ts";
|
|
44
|
+
import type { SlotFlag } from "./zen-metrics.ts";
|
|
45
|
+
import { EMOTE_FRAME } from "./expressions.ts";
|
|
46
|
+
import type { FeedLine } from "./run-summary.ts";
|
|
47
|
+
import { shortDuration } from "../text.ts";
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
50
|
* The large animated zen scene: a header box, the oracle tower with its speech
|
|
@@ -73,6 +77,10 @@ export interface LargeSlot {
|
|
|
73
77
|
activity?: string;
|
|
74
78
|
/** Run duration label rendered under the status row; "—" when idle. */
|
|
75
79
|
elapsedLabel: string;
|
|
80
|
+
/** Waiting on a file, gone quiet or retrying; replaces the activity word while set. */
|
|
81
|
+
flag?: SlotFlag;
|
|
82
|
+
/** This expression's emote frames (open, blink, action, action); the status default when absent. */
|
|
83
|
+
emote?: readonly string[];
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
export interface LargeTaskRow {
|
|
@@ -115,6 +123,8 @@ export interface LargeSceneInput {
|
|
|
115
123
|
alertKind?: "warning" | "error";
|
|
116
124
|
/** Name over the tower door, `TOWER_DOOR` when omitted. */
|
|
117
125
|
doorLabel?: string;
|
|
126
|
+
/** Live feed row under the agents: what the busiest agent is doing right now. */
|
|
127
|
+
feed?: FeedLine;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export const SLOT_CELL = 15;
|
|
@@ -315,14 +325,26 @@ function bodyLines(
|
|
|
315
325
|
const sections = input.tasks.length > 0;
|
|
316
326
|
const plan = pickPlan(avail, sections);
|
|
317
327
|
const entries = plan.section ? clamp(avail - blockRows(plan) - 1, 0, MAX_TASK_ROWS) : 0;
|
|
328
|
+
const section = sectionLines(input, width, theme, entries);
|
|
329
|
+
// The feed row takes a spare row only: it never costs the tower, the agents or a checklist row.
|
|
330
|
+
const spare = avail - blockRows(plan) - section.length;
|
|
331
|
+
const feed = input.feed && plan.strip && spare >= 1 ? [feedRow(input.feed, width, theme)] : [];
|
|
318
332
|
return [
|
|
319
333
|
...towerLines(input, width, theme, plan.tower),
|
|
320
334
|
...(plan.branch ? branchLines(input, width, theme) : []),
|
|
321
335
|
...(plan.strip ? slotLines(input, width, theme) : []),
|
|
322
|
-
...
|
|
336
|
+
...feed,
|
|
337
|
+
...section,
|
|
323
338
|
];
|
|
324
339
|
}
|
|
325
340
|
|
|
341
|
+
/** One dim line under the agent strip; warnings take the warning colour. */
|
|
342
|
+
function feedRow(feed: FeedLine, width: number, theme?: PanelTheme): string {
|
|
343
|
+
const left = sceneLeft(width) + 1;
|
|
344
|
+
const text = truncateToWidth(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
|
|
345
|
+
return " ".repeat(left) + (feed.kind === "warning" ? paint(text, "warning", theme) : paint(text, "dim", theme));
|
|
346
|
+
}
|
|
347
|
+
|
|
326
348
|
function blockRows(plan: LayoutPlan): number {
|
|
327
349
|
const tower = TOWER_ROWS[plan.tower];
|
|
328
350
|
return tower + (plan.branch ? BRANCH_ROWS : 0) + (plan.strip ? SLOT_ROWS : 0);
|
|
@@ -565,7 +587,9 @@ function slotLines(input: LargeSceneInput, width: number, theme?: PanelTheme): s
|
|
|
565
587
|
|
|
566
588
|
function slotFrame(slot: LargeSlot): readonly string[] {
|
|
567
589
|
const frames = SLOT_FRAMES[slot.id][slot.status];
|
|
568
|
-
|
|
590
|
+
const index = mod(slot.frame, frames.length);
|
|
591
|
+
const live = index >= EMOTE_FRAME ? slot.emote?.[index - EMOTE_FRAME] : undefined;
|
|
592
|
+
return live !== undefined ? [live] : frames[index] ?? [];
|
|
569
593
|
}
|
|
570
594
|
|
|
571
595
|
function faceCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
|
|
@@ -588,7 +612,7 @@ function wordCells(slots: readonly LargeSlot[], offsets: readonly number[], tick
|
|
|
588
612
|
return slots.map((slot, index) => ({
|
|
589
613
|
offset: offsets[index]!,
|
|
590
614
|
text: centre(slotStatusText(slot, tick), SLOT_CELL),
|
|
591
|
-
paint:
|
|
615
|
+
paint: wordPaint(slot, theme),
|
|
592
616
|
}));
|
|
593
617
|
}
|
|
594
618
|
|
|
@@ -600,12 +624,25 @@ function elapsedCells(slots: readonly LargeSlot[], offsets: readonly number[], t
|
|
|
600
624
|
}));
|
|
601
625
|
}
|
|
602
626
|
|
|
603
|
-
/**
|
|
627
|
+
/**
|
|
628
|
+
* Working agents show the braille spinner with their live activity, or a flag
|
|
629
|
+
* when they wait on a file, go quiet or retry; every other state keeps its
|
|
630
|
+
* glyph and word.
|
|
631
|
+
*/
|
|
604
632
|
function slotStatusText(slot: LargeSlot, tick: number): string {
|
|
605
633
|
if (slot.status !== "working") return `${SLOT_STATE_GLYPHS[slot.status]} ${SLOT_STATE_WORDS[slot.status]}`;
|
|
634
|
+
if (slot.flag?.kind === "waiting") return "⧗ waiting";
|
|
635
|
+
if (slot.flag?.kind === "quiet") return `! quiet ${shortDuration(slot.flag.ms)}`;
|
|
636
|
+
if (slot.flag?.kind === "retry") return "↻ retrying";
|
|
606
637
|
return `${SPIN_FRAMES[mod(tick, SPIN_FRAMES.length)]!} ${slot.activity ?? SLOT_STATE_WORDS.working}`;
|
|
607
638
|
}
|
|
608
639
|
|
|
640
|
+
/** Flagged working agents paint their status row in the warning colour. */
|
|
641
|
+
function wordPaint(slot: LargeSlot, theme?: PanelTheme): ((text: string) => string) | undefined {
|
|
642
|
+
if (slot.status === "working" && slot.flag && theme) return (text) => paint(text, "warning", theme, true);
|
|
643
|
+
return statusPaint(slot.status, theme);
|
|
644
|
+
}
|
|
645
|
+
|
|
609
646
|
/** The elapsed row stays dim so the status row keeps the colour. */
|
|
610
647
|
function dimPaint(theme?: PanelTheme): ((text: string) => string) | undefined {
|
|
611
648
|
return theme ? (text) => theme.fg("dim", text) : undefined;
|
package/src/pi/zen-metrics.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type { AgentRun } from "../schemas/findings.ts";
|
|
|
9
9
|
import type { Task } from "../schemas/task.ts";
|
|
10
10
|
import { SLOT_IDS, SLOT_LABELS, type SlotId, type SlotState } from "./mascot-art.ts";
|
|
11
11
|
import { formatDuration, planChecklist } from "./zen.ts";
|
|
12
|
+
import { QUIET_MS, quietFor, slotOf } from "./run-summary.ts";
|
|
13
|
+
import type { Situation } from "./kaomoji.ts";
|
|
12
14
|
|
|
13
15
|
export type { SlotId, SlotState };
|
|
14
16
|
|
|
@@ -21,6 +23,24 @@ export interface SlotView {
|
|
|
21
23
|
activity?: string;
|
|
22
24
|
/** "—" without a run, live since `startedAt` while running, fixed once terminal. */
|
|
23
25
|
elapsedLabel: string;
|
|
26
|
+
/** Something about a working agent worth flagging in its status row. */
|
|
27
|
+
flag?: SlotFlag;
|
|
28
|
+
/** The run was asked to wrap up early. */
|
|
29
|
+
wrappedUp?: boolean;
|
|
30
|
+
/** A file was just handed to this agent by the file desk. */
|
|
31
|
+
handover?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Status-row flags for a working agent, in priority order: waiting on a file, gone quiet, retrying. */
|
|
35
|
+
export type SlotFlag = { kind: "waiting" } | { kind: "quiet"; ms: number } | { kind: "retry" };
|
|
36
|
+
|
|
37
|
+
function slotFlag(run: AgentRun | undefined, now: number): SlotFlag | undefined {
|
|
38
|
+
if (!run || run.status !== "running") return undefined;
|
|
39
|
+
if (run.waitingFor) return { kind: "waiting" };
|
|
40
|
+
const quiet = quietFor(run, now);
|
|
41
|
+
if (quiet >= QUIET_MS) return { kind: "quiet", ms: quiet };
|
|
42
|
+
if (run.noteKind === "warning" && run.note && /retry/.test(run.note)) return { kind: "retry" };
|
|
43
|
+
return undefined;
|
|
24
44
|
}
|
|
25
45
|
|
|
26
46
|
export interface SceneMetrics {
|
|
@@ -36,12 +56,6 @@ export function runStatus(status: AgentRun["status"]): SlotState {
|
|
|
36
56
|
return status === "success" ? "done" : "failed";
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
/** The column a run belongs to; a researcher reports under RESEARCH from any domain. */
|
|
40
|
-
function slotOf(run: AgentRun): SlotId {
|
|
41
|
-
if (run.role === "researcher") return "research";
|
|
42
|
-
if (run.domain === "backend") return "dev";
|
|
43
|
-
return run.domain === "designer" ? "design" : "qa";
|
|
44
|
-
}
|
|
45
59
|
|
|
46
60
|
function latestRunFor(runs: AgentRun[], id: SlotId): AgentRun | undefined {
|
|
47
61
|
let latest: AgentRun | undefined;
|
|
@@ -62,7 +76,17 @@ function runElapsedLabel(run: AgentRun | undefined, now: number): string {
|
|
|
62
76
|
function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
|
|
63
77
|
const run = latestRunFor(runs, id);
|
|
64
78
|
const status: SlotState = run ? runStatus(run.status) : "idle";
|
|
65
|
-
|
|
79
|
+
const flag = slotFlag(run, now);
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
label: SLOT_LABELS[id],
|
|
83
|
+
status,
|
|
84
|
+
activity: run?.activity,
|
|
85
|
+
elapsedLabel: runElapsedLabel(run, now),
|
|
86
|
+
...(flag ? { flag } : {}),
|
|
87
|
+
...(run?.wrappedUp ? { wrappedUp: true } : {}),
|
|
88
|
+
...(run?.status === "running" && run.note?.startsWith("got ") ? { handover: true } : {}),
|
|
89
|
+
};
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMetrics {
|
|
@@ -78,3 +102,19 @@ export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMe
|
|
|
78
102
|
slots: SLOT_IDS.map((id) => slotView(id, runs, now)),
|
|
79
103
|
};
|
|
80
104
|
}
|
|
105
|
+
|
|
106
|
+
/** Each slot's situation for its face: status, activity and flags (see kaomoji.ts). */
|
|
107
|
+
export function slotSituation(slot: SlotView): Situation {
|
|
108
|
+
return {
|
|
109
|
+
status: slot.status,
|
|
110
|
+
...(slot.activity ? { activity: slot.activity } : {}),
|
|
111
|
+
...(slot.flag ? { flag: slot.flag.kind } : {}),
|
|
112
|
+
...(slot.wrappedUp ? { wrappedUp: true } : {}),
|
|
113
|
+
...(slot.handover ? { handover: true } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Per-slot situations straight from runs, for the widget's reaction triggers. */
|
|
118
|
+
export function slotSituations(runs: AgentRun[], now: number): Record<SlotId, Situation> {
|
|
119
|
+
return Object.fromEntries(SLOT_IDS.map((id) => [id, slotSituation(slotView(id, runs, now))])) as Record<SlotId, Situation>;
|
|
120
|
+
}
|
package/src/pi/zen.ts
CHANGED
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
type SlotId,
|
|
27
27
|
type SlotState,
|
|
28
28
|
} from "./mascot-art.ts";
|
|
29
|
-
import { REST_FRAME } from "./expressions.ts";
|
|
29
|
+
import { EMOTE_FRAME, REST_FRAME } from "./expressions.ts";
|
|
30
|
+
import { slotEmote } from "./kaomoji.ts";
|
|
30
31
|
import {
|
|
31
32
|
LARGE_MIN_WIDTH,
|
|
32
33
|
MAX_LARGE_LINES,
|
|
@@ -36,7 +37,9 @@ import {
|
|
|
36
37
|
type LargeSlot,
|
|
37
38
|
type LargeTaskRow,
|
|
38
39
|
} from "./zen-large.ts";
|
|
39
|
-
import { runStatus, sceneMetrics, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
|
|
40
|
+
import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
|
|
41
|
+
import { feedLine, QUIET_MS, quietFor } from "./run-summary.ts";
|
|
42
|
+
import { shortDuration } from "../text.ts";
|
|
40
43
|
|
|
41
44
|
/** Minimal slice of pi's Theme the panel needs; keeps zen.ts decoupled from the agent. */
|
|
42
45
|
export interface PanelTheme {
|
|
@@ -412,8 +415,10 @@ export function workingLine(runs: AgentRun[], tick: number, theme?: PanelTheme,
|
|
|
412
415
|
if (running.length > 0) {
|
|
413
416
|
const run = running.at(-1)!;
|
|
414
417
|
const spinner = theme ? theme.fg("accent", spin) : spin;
|
|
415
|
-
const activity = run.activity ?? "working"
|
|
416
|
-
|
|
418
|
+
const activity = `${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`;
|
|
419
|
+
const quiet = quietFor(run, now);
|
|
420
|
+
const warning = run.waitingFor ? ` · waiting for ${run.waitingFor}` : quiet >= QUIET_MS ? ` · quiet ${shortDuration(quiet)}` : "";
|
|
421
|
+
return ` ${spinner} agents working (${running.length}) · ${run.domain}/${run.role} ${activity} ${formatDuration(runElapsed(run, now))}${warning}`;
|
|
417
422
|
}
|
|
418
423
|
const last = runs.at(-1);
|
|
419
424
|
if (!last) return " ○ waiting for the first agent…";
|
|
@@ -437,6 +442,18 @@ function paintStatus(status: SlotState, text: string, theme?: PanelTheme): strin
|
|
|
437
442
|
/** Caller-chosen expression frame per slot and the oracle; absent reads as rest. */
|
|
438
443
|
export type ExpressionFrames = Partial<Record<SlotId | "oracle", number>>;
|
|
439
444
|
|
|
445
|
+
/** Per-expression variant per slot; picks which kaomoji an emote shows. */
|
|
446
|
+
export type ExpressionVariants = Partial<Record<SlotId, number>>;
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The oracle and the compact strip keep their original two emote steps: the
|
|
450
|
+
* four emote frames (open, blink, action, action) fold onto them pairwise.
|
|
451
|
+
*/
|
|
452
|
+
export function foldEmoteFrame(frame: number): number {
|
|
453
|
+
if (!Number.isFinite(frame) || frame < EMOTE_FRAME) return frame;
|
|
454
|
+
return EMOTE_FRAME + Math.min(1, Math.floor((frame - EMOTE_FRAME) / 2));
|
|
455
|
+
}
|
|
456
|
+
|
|
440
457
|
/** Frame `index` wrapped into the art's frame list; a non-finite index rests. */
|
|
441
458
|
function frameAt(frames: readonly string[], index: number): string {
|
|
442
459
|
if (frames.length === 0) return "";
|
|
@@ -455,7 +472,7 @@ const COMPACT_INNER = SLOT_IDS.length * COMPACT_WIDTH + (SLOT_IDS.length - 1) *
|
|
|
455
472
|
const COMPACT_STATUS_GAP = " ".repeat(COMPACT_WIDTH + COMPACT_GAP - 1);
|
|
456
473
|
|
|
457
474
|
function compactFrame(slot: SlotView, expressions: ExpressionFrames): string {
|
|
458
|
-
return frameAt(COMPACT_FRAMES[slot.id][slot.status], expressions[slot.id] ?? REST_FRAME);
|
|
475
|
+
return frameAt(COMPACT_FRAMES[slot.id][slot.status], foldEmoteFrame(expressions[slot.id] ?? REST_FRAME));
|
|
459
476
|
}
|
|
460
477
|
|
|
461
478
|
function compactRow(content: string, color: PanelColor, theme?: PanelTheme): string {
|
|
@@ -530,16 +547,17 @@ function oraclePose(task: Task): OraclePose {
|
|
|
530
547
|
return task.paused || TERMINAL_STATES.includes(task.state) ? "dormant" : "orchestrating";
|
|
531
548
|
}
|
|
532
549
|
|
|
533
|
-
function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames): LargeSlot[] {
|
|
550
|
+
function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames, variants: ExpressionVariants): LargeSlot[] {
|
|
534
551
|
return metrics.slots.map((slot) => ({
|
|
535
552
|
...slot,
|
|
536
553
|
frame: expressions[slot.id] ?? REST_FRAME,
|
|
554
|
+
emote: slotEmote(slot.id, slotSituation(slot), variants[slot.id] ?? 0),
|
|
537
555
|
}));
|
|
538
556
|
}
|
|
539
557
|
|
|
540
558
|
function oracleSlot(task: Task, expressions: ExpressionFrames, motion: OracleMotion): LargeSceneInput["oracle"] {
|
|
541
559
|
const pose = oraclePose(task);
|
|
542
|
-
return { pose, frame: expressions.oracle ?? REST_FRAME, ...motion };
|
|
560
|
+
return { pose, frame: foldEmoteFrame(expressions.oracle ?? REST_FRAME), ...motion };
|
|
543
561
|
}
|
|
544
562
|
|
|
545
563
|
function sceneTasks(steps: readonly PlanStep[]): LargeTaskRow[] {
|
|
@@ -569,13 +587,14 @@ function sceneInput(
|
|
|
569
587
|
tick,
|
|
570
588
|
done: metrics.done,
|
|
571
589
|
total: metrics.total,
|
|
572
|
-
slots: sceneSlots(metrics, opts.expressions ?? {}),
|
|
590
|
+
slots: sceneSlots(metrics, opts.expressions ?? {}, opts.variants ?? {}),
|
|
573
591
|
tasks: sceneTasks(steps),
|
|
574
592
|
oracle: oracleSlot(task, opts.expressions ?? {}, opts.oracleMotion ?? {}),
|
|
575
593
|
oracleActivity: opts.oracleActivity,
|
|
576
594
|
caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
|
|
577
595
|
alert: alert?.text,
|
|
578
596
|
alertKind: alert?.kind,
|
|
597
|
+
feed: feedLine(runs, now, tick),
|
|
579
598
|
};
|
|
580
599
|
}
|
|
581
600
|
|
|
@@ -588,6 +607,8 @@ export interface PanelOptions {
|
|
|
588
607
|
theme?: PanelTheme;
|
|
589
608
|
/** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
|
|
590
609
|
expressions?: ExpressionFrames;
|
|
610
|
+
/** Caller-drawn variant per slot expression; picks the emote's face. */
|
|
611
|
+
variants?: ExpressionVariants;
|
|
591
612
|
/** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
|
|
592
613
|
oracleActivity?: string;
|
|
593
614
|
/** Caller-clocked oracle animation: the expression's sub-step and the lip-sync shape. */
|
package/src/roles/reviewer.ts
CHANGED
|
@@ -13,7 +13,8 @@ export const reviewerSpec: RoleSpec = {
|
|
|
13
13
|
"Begin with `## Verdict` followed by exactly one of `PASS`, `CHANGES_REQUIRED`, or `BLOCKED`.",
|
|
14
14
|
"Then `## Findings`, `## Verification`, `## Required Changes`, `## Optional Improvements`.",
|
|
15
15
|
"Findings entries are `- [severity] text — \\`path:line\\``.",
|
|
16
|
-
"Verification entries are `- command — result
|
|
16
|
+
"Verification entries are `- command — result` for checks you actually executed; a PASS without them is",
|
|
17
|
+
"treated as CHANGES_REQUIRED.",
|
|
17
18
|
"You must not modify implementation files. Report required changes instead.",
|
|
18
19
|
"An optional `## Pushback` (`**Request:**`, `**Reason:**`, optional `**Alternative:**`) flags a change request you",
|
|
19
20
|
"believe is wrong, with your reason; keep it separate from your findings.",
|
|
@@ -35,18 +36,36 @@ function parseFinding(text: string): ReviewFinding {
|
|
|
35
36
|
return { severity, text: (match?.[2] ?? text).trim() };
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/** The QA gate never passes by default: a PASS must cite at least one executed check. */
|
|
40
|
+
export const UNVERIFIED_PASS = "unverified pass: PASS without executed checks under ## Verification, downgraded to CHANGES_REQUIRED";
|
|
41
|
+
|
|
42
|
+
/** A verification bullet names a command or check and its result (`- cmd — result`). */
|
|
43
|
+
function executedChecks(verification: string): string[] {
|
|
44
|
+
return bullets(verification).filter((entry) => /\S\s*(?:—|–|-{1,2}|:|=>|→)\s*\S/.test(entry));
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
/** Parse a reviewer's markdown into a structured verdict (never throws). */
|
|
39
48
|
export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
|
|
40
49
|
const sections = parseSections(raw);
|
|
50
|
+
const verification = findSection(sections, "verification") ?? "";
|
|
51
|
+
const requiredChanges = bullets(findSection(sections, "required changes"));
|
|
52
|
+
let verdict = parseVerdict(findSection(sections, "verdict"));
|
|
53
|
+
let downgraded: string | undefined;
|
|
54
|
+
if (verdict === "pass" && executedChecks(verification).length === 0) {
|
|
55
|
+
verdict = "changes_required";
|
|
56
|
+
downgraded = UNVERIFIED_PASS;
|
|
57
|
+
requiredChanges.push("Re-run the QA gate and record the checks actually executed, each as `- command — result`.");
|
|
58
|
+
}
|
|
41
59
|
return {
|
|
42
60
|
domain,
|
|
43
61
|
role: "reviewer",
|
|
44
|
-
verdict
|
|
62
|
+
verdict,
|
|
45
63
|
findings: bullets(findSection(sections, "findings")).map(parseFinding),
|
|
46
|
-
verification
|
|
47
|
-
requiredChanges
|
|
64
|
+
verification,
|
|
65
|
+
requiredChanges,
|
|
48
66
|
optionalImprovements: bullets(findSection(sections, "optional improvements")),
|
|
49
67
|
pushback: parsePushback(sections),
|
|
68
|
+
...(downgraded ? { downgraded } : {}),
|
|
50
69
|
raw,
|
|
51
70
|
};
|
|
52
71
|
}
|
|
@@ -55,6 +74,7 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
|
|
|
55
74
|
export function validateReviewResult(result: ReviewResult): string[] {
|
|
56
75
|
const issues: string[] = [];
|
|
57
76
|
if (!/##\s*verdict/i.test(result.raw)) issues.push("missing Verdict section");
|
|
77
|
+
if (result.downgraded) issues.push(result.downgraded);
|
|
58
78
|
if (result.verdict !== "pass" && result.findings.length === 0 && result.requiredChanges.length === 0) {
|
|
59
79
|
issues.push("non-pass verdict without findings or required changes");
|
|
60
80
|
}
|
package/src/roles/worker.ts
CHANGED
|
@@ -3,10 +3,16 @@ import type { Blocker } from "../schemas/task.ts";
|
|
|
3
3
|
import type { FileChange, KnowledgeProposal, WorkerResult } from "../schemas/findings.ts";
|
|
4
4
|
import { bullets, fieldValue, findSection, parseFileBullet, parsePushback, parseSections } from "./markdown.ts";
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Workers implement, so they get the full built-in set, including the fast
|
|
8
|
+
* search tools; the file desk adds its own tools when workers run in parallel.
|
|
9
|
+
*/
|
|
10
|
+
export const WORKER_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
11
|
+
|
|
7
12
|
export const workerSpec: RoleSpec = {
|
|
8
13
|
role: "worker",
|
|
9
14
|
promptFile: "worker.md",
|
|
15
|
+
tools: WORKER_TOOLS,
|
|
10
16
|
contract: [
|
|
11
17
|
"### Output contract",
|
|
12
18
|
"Respond with exactly these sections and nothing else: `## Completed`, `## Files Changed`,",
|