@a-t-h-i/bot-lobby 0.2.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 +138 -32
- 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 +84 -16
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +125 -35
- 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 +82 -13
- package/src/pi/zen-large.ts +158 -20
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +42 -14
- 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/zen-large.ts
CHANGED
|
@@ -2,11 +2,26 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import {
|
|
3
3
|
BAR,
|
|
4
4
|
BUBBLE,
|
|
5
|
+
CLOSED_EYE,
|
|
6
|
+
EYE_WIDTH,
|
|
7
|
+
GAZE_AHEAD,
|
|
8
|
+
OPEN_EYE,
|
|
5
9
|
ORACLE_AURA,
|
|
6
10
|
ORACLE_AURA_TICKS,
|
|
11
|
+
ORACLE_BLINK,
|
|
12
|
+
ORACLE_BLINKS,
|
|
7
13
|
ORACLE_COLORS,
|
|
8
|
-
ORACLE_IDLE_WORD,
|
|
9
14
|
ORACLE_FRAMES,
|
|
15
|
+
ORACLE_GAZE_TICKS,
|
|
16
|
+
ORACLE_IDLE_WORD,
|
|
17
|
+
ORACLE_LOOK,
|
|
18
|
+
ORACLE_LOOK_PATH,
|
|
19
|
+
ORACLE_MOOD_MOUTHS,
|
|
20
|
+
ORACLE_PUPILS,
|
|
21
|
+
ORACLE_REST,
|
|
22
|
+
ORACLE_TALK,
|
|
23
|
+
ORACLE_WANDER,
|
|
24
|
+
ORACLE_WANDER_TICKS,
|
|
10
25
|
ORACLE_WORDS,
|
|
11
26
|
SLOT_FRAMES,
|
|
12
27
|
SLOT_LABELS,
|
|
@@ -17,13 +32,19 @@ import {
|
|
|
17
32
|
TOWER,
|
|
18
33
|
TOWER_DOOR,
|
|
19
34
|
TOWER_WIDTH,
|
|
35
|
+
type Gaze,
|
|
20
36
|
type OracleFrame,
|
|
37
|
+
type OracleMood,
|
|
21
38
|
type OraclePose,
|
|
22
39
|
type PanelColor,
|
|
23
40
|
type SlotId,
|
|
24
41
|
type SlotState,
|
|
25
42
|
} from "./mascot-art.ts";
|
|
26
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";
|
|
27
48
|
|
|
28
49
|
/**
|
|
29
50
|
* The large animated zen scene: a header box, the oracle tower with its speech
|
|
@@ -56,6 +77,10 @@ export interface LargeSlot {
|
|
|
56
77
|
activity?: string;
|
|
57
78
|
/** Run duration label rendered under the status row; "—" when idle. */
|
|
58
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[];
|
|
59
84
|
}
|
|
60
85
|
|
|
61
86
|
export interface LargeTaskRow {
|
|
@@ -63,6 +88,17 @@ export interface LargeTaskRow {
|
|
|
63
88
|
status: "done" | "current" | "pending";
|
|
64
89
|
}
|
|
65
90
|
|
|
91
|
+
/** The oracle's pose plus the caller-clocked animation state (see expressions.ts). */
|
|
92
|
+
export interface OracleInput {
|
|
93
|
+
pose: OraclePose;
|
|
94
|
+
/** Expression frame: rest, blink, glance or delight (snore while dormant). */
|
|
95
|
+
frame: number;
|
|
96
|
+
/** Sub-step inside the playing expression; steps the blink lids and the glance. */
|
|
97
|
+
phase?: number;
|
|
98
|
+
/** Lip-sync shape index while the oracle talks about something it just said. */
|
|
99
|
+
talk?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
/** Everything the large scene draws; assembled by the caller from task and runs. */
|
|
67
103
|
export interface LargeSceneInput {
|
|
68
104
|
taskId: string;
|
|
@@ -76,7 +112,7 @@ export interface LargeSceneInput {
|
|
|
76
112
|
total: number;
|
|
77
113
|
slots: readonly LargeSlot[];
|
|
78
114
|
tasks: readonly LargeTaskRow[];
|
|
79
|
-
oracle:
|
|
115
|
+
oracle: OracleInput;
|
|
80
116
|
/** Master's live activity for the speech bubble; absent means it waits on the user. */
|
|
81
117
|
oracleActivity?: string;
|
|
82
118
|
/** Plain-words task phase ("writing the plan"); the bubble's fallback second line. */
|
|
@@ -87,6 +123,8 @@ export interface LargeSceneInput {
|
|
|
87
123
|
alertKind?: "warning" | "error";
|
|
88
124
|
/** Name over the tower door, `TOWER_DOOR` when omitted. */
|
|
89
125
|
doorLabel?: string;
|
|
126
|
+
/** Live feed row under the agents: what the busiest agent is doing right now. */
|
|
127
|
+
feed?: FeedLine;
|
|
90
128
|
}
|
|
91
129
|
|
|
92
130
|
export const SLOT_CELL = 15;
|
|
@@ -287,14 +325,26 @@ function bodyLines(
|
|
|
287
325
|
const sections = input.tasks.length > 0;
|
|
288
326
|
const plan = pickPlan(avail, sections);
|
|
289
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)] : [];
|
|
290
332
|
return [
|
|
291
333
|
...towerLines(input, width, theme, plan.tower),
|
|
292
334
|
...(plan.branch ? branchLines(input, width, theme) : []),
|
|
293
335
|
...(plan.strip ? slotLines(input, width, theme) : []),
|
|
294
|
-
...
|
|
336
|
+
...feed,
|
|
337
|
+
...section,
|
|
295
338
|
];
|
|
296
339
|
}
|
|
297
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
|
+
|
|
298
348
|
function blockRows(plan: LayoutPlan): number {
|
|
299
349
|
const tower = TOWER_ROWS[plan.tower];
|
|
300
350
|
return tower + (plan.branch ? BRANCH_ROWS : 0) + (plan.strip ? SLOT_ROWS : 0);
|
|
@@ -310,26 +360,98 @@ function oracleFrame(input: LargeSceneInput): OracleFrame {
|
|
|
310
360
|
return frames[mod(input.oracle.frame, frames.length)]!;
|
|
311
361
|
}
|
|
312
362
|
|
|
363
|
+
function frameIndex(input: LargeSceneInput): number {
|
|
364
|
+
return mod(input.oracle.frame, ORACLE_FRAMES[input.oracle.pose].length);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function phaseOf(input: LargeSceneInput): number {
|
|
368
|
+
const phase = input.oracle.phase ?? 0;
|
|
369
|
+
return Number.isFinite(phase) && phase > 0 ? Math.floor(phase) : 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The oracle's resting mood: worried by a blocker, otherwise steady. */
|
|
373
|
+
export function oracleMood(input: LargeSceneInput): OracleMood {
|
|
374
|
+
return input.alert && input.alertKind === "error" ? "worried" : "steady";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isLooking(input: LargeSceneInput): boolean {
|
|
378
|
+
return frameIndex(input) >= ORACLE_LOOK;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Where the pupils look. The look-around emote scans the room; while talking the
|
|
383
|
+
* oracle looks at you; while agents work it looks down at them, moving on to
|
|
384
|
+
* the next one every `ORACLE_GAZE_TICKS`; otherwise its eyes wander the room.
|
|
385
|
+
*/
|
|
386
|
+
export function oracleGaze(input: LargeSceneInput): Gaze {
|
|
387
|
+
if (input.oracle.pose !== "orchestrating") return GAZE_AHEAD;
|
|
388
|
+
if (isLooking(input)) return ORACLE_LOOK_PATH[Math.min(phaseOf(input), ORACLE_LOOK_PATH.length - 1)]!;
|
|
389
|
+
if (input.oracle.talk !== undefined) return GAZE_AHEAD;
|
|
390
|
+
const working = input.slots.flatMap((slot, index) => (slot.status === "working" ? [index] : []));
|
|
391
|
+
if (working.length === 0) return ORACLE_WANDER[mod(Math.floor(input.tick / ORACLE_WANDER_TICKS), ORACLE_WANDER.length)]!;
|
|
392
|
+
const target = working[mod(Math.floor(input.tick / ORACLE_GAZE_TICKS), working.length)]!;
|
|
393
|
+
const centre = (input.slots.length - 1) / 2;
|
|
394
|
+
return { x: target < centre ? -1 : target > centre ? 1 : 0, y: 1 };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** One eye window: a lid fills it, otherwise the pupil sits at the gaze column. */
|
|
398
|
+
function eyeCell(pupil: string, gaze: Gaze): string {
|
|
399
|
+
if (pupil === CLOSED_EYE) return CLOSED_EYE.repeat(EYE_WIDTH);
|
|
400
|
+
const glyph = pupil === OPEN_EYE ? ORACLE_PUPILS[gaze.y] : pupil;
|
|
401
|
+
const at = Math.floor(EYE_WIDTH / 2) + gaze.x;
|
|
402
|
+
return Array.from({ length: EYE_WIDTH }, (_value, column) => (column === at ? glyph : " ")).join("");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Both pupils: blink lids step with the phase, the frame supplies the rest. */
|
|
406
|
+
function oraclePupils(input: LargeSceneInput): readonly [string, string] {
|
|
407
|
+
if (frameIndex(input) === ORACLE_BLINK) {
|
|
408
|
+
const lids = ORACLE_BLINKS[input.oracle.pose];
|
|
409
|
+
return lids[Math.min(phaseOf(input), lids.length - 1)]!;
|
|
410
|
+
}
|
|
411
|
+
const frame = oracleFrame(input);
|
|
412
|
+
return [frame.winL, frame.winR];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The mouth: lip-sync while talking, the mood's straight line or frown otherwise; dormant it snores. */
|
|
416
|
+
function oracleMouth(input: LargeSceneInput): string {
|
|
417
|
+
if (input.oracle.pose !== "orchestrating") return oracleFrame(input).mouth;
|
|
418
|
+
if (input.oracle.talk !== undefined) return ORACLE_TALK[mod(input.oracle.talk, ORACLE_TALK.length)]!;
|
|
419
|
+
return ORACLE_MOOD_MOUTHS[oracleMood(input)];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The whole face for this frame: orb, both eye windows (three columns each) and the mouth. */
|
|
423
|
+
export function oracleFace(input: LargeSceneInput): { orb: string; left: string; right: string; mouth: string } {
|
|
424
|
+
const [left, right] = oraclePupils(input);
|
|
425
|
+
const gaze = oracleGaze(input);
|
|
426
|
+
return { orb: oracleFrame(input).orb, left: eyeCell(left, gaze), right: eyeCell(right, gaze), mouth: oracleMouth(input) };
|
|
427
|
+
}
|
|
428
|
+
|
|
313
429
|
function oracleAura(input: LargeSceneInput): string {
|
|
314
430
|
const frames = ORACLE_AURA[input.oracle.pose];
|
|
315
431
|
return frames[mod(Math.floor(input.tick / ORACLE_AURA_TICKS), frames.length)]!;
|
|
316
432
|
}
|
|
317
433
|
|
|
318
|
-
|
|
319
|
-
|
|
434
|
+
/** Painted token values for one frame; built once and shared by every tower row. */
|
|
435
|
+
type TowerValues = ReadonlyMap<string, string>;
|
|
436
|
+
|
|
437
|
+
function towerValues(input: LargeSceneInput, theme?: PanelTheme): TowerValues {
|
|
438
|
+
const face = oracleFace(input);
|
|
320
439
|
const accent = ORACLE_COLORS[input.oracle.pose];
|
|
321
440
|
const glow = (text: string) => paint(text, accent.color, theme, accent.bold);
|
|
322
|
-
|
|
323
|
-
[TOWER.tokens.aura,
|
|
324
|
-
[TOWER.tokens.orb,
|
|
325
|
-
[TOWER.tokens.winL,
|
|
326
|
-
[TOWER.tokens.winR,
|
|
327
|
-
[TOWER.tokens.mouth,
|
|
328
|
-
[TOWER.tokens.door,
|
|
441
|
+
return new Map([
|
|
442
|
+
[TOWER.tokens.aura, paint(oracleAura(input), accent.color, theme)],
|
|
443
|
+
[TOWER.tokens.orb, glow(face.orb)],
|
|
444
|
+
[TOWER.tokens.winL, glow(face.left)],
|
|
445
|
+
[TOWER.tokens.winR, glow(face.right)],
|
|
446
|
+
[TOWER.tokens.mouth, glow(face.mouth)],
|
|
447
|
+
[TOWER.tokens.door, glow(input.doorLabel ?? TOWER_DOOR)],
|
|
329
448
|
]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function towerRow(row: string, values: TowerValues, theme?: PanelTheme): string {
|
|
330
452
|
return row
|
|
331
453
|
.split(TOWER_PATTERN)
|
|
332
|
-
.map((part) => values.get(part)
|
|
454
|
+
.map((part) => values.get(part) ?? paint(part, "muted", theme))
|
|
333
455
|
.join("");
|
|
334
456
|
}
|
|
335
457
|
|
|
@@ -384,23 +506,24 @@ function bubbleStart(rows: readonly string[]): number {
|
|
|
384
506
|
* The tail from the oracle to the bubble: the tower row's trailing blanks plus
|
|
385
507
|
* the one-column gap become `╶──` (`──(◉)── ╶──┤`); a full-width row gets a stub.
|
|
386
508
|
*/
|
|
387
|
-
function tailRow(row: string,
|
|
509
|
+
function tailRow(row: string, values: TowerValues, theme?: PanelTheme): string {
|
|
388
510
|
const body = row.replace(/ +$/, "");
|
|
389
511
|
const pad = row.length - body.length;
|
|
390
512
|
const tail = pad >= 1 ? ` ╶${"─".repeat(pad - 1)}` : "╶";
|
|
391
|
-
return towerRow(body,
|
|
513
|
+
return towerRow(body, values, theme) + paint(tail, "muted", theme);
|
|
392
514
|
}
|
|
393
515
|
|
|
394
516
|
function towerLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, size: TowerSize): string[] {
|
|
395
517
|
if (size === "none") return [];
|
|
396
518
|
const rows = size === "small" ? TOWER.smallRows : TOWER.rows;
|
|
397
519
|
const left = " ".repeat(sceneLeft(width) + Math.floor((SCENE_WIDTH - TOWER_WIDTH) / 2));
|
|
520
|
+
const values = towerValues(input, theme);
|
|
398
521
|
const bubble = bubbleRows(input, theme);
|
|
399
522
|
const start = bubbleStart(rows);
|
|
400
523
|
return rows.map((row, index) => {
|
|
401
524
|
const speech = bubble[index - start];
|
|
402
|
-
if (speech === undefined) return left + towerRow(row,
|
|
403
|
-
const tower = index === start + 1 ? tailRow(row,
|
|
525
|
+
if (speech === undefined) return left + towerRow(row, values, theme);
|
|
526
|
+
const tower = index === start + 1 ? tailRow(row, values, theme) : `${towerRow(row, values, theme)} `;
|
|
404
527
|
return left + tower + speech;
|
|
405
528
|
});
|
|
406
529
|
}
|
|
@@ -464,7 +587,9 @@ function slotLines(input: LargeSceneInput, width: number, theme?: PanelTheme): s
|
|
|
464
587
|
|
|
465
588
|
function slotFrame(slot: LargeSlot): readonly string[] {
|
|
466
589
|
const frames = SLOT_FRAMES[slot.id][slot.status];
|
|
467
|
-
|
|
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] ?? [];
|
|
468
593
|
}
|
|
469
594
|
|
|
470
595
|
function faceCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
|
|
@@ -487,7 +612,7 @@ function wordCells(slots: readonly LargeSlot[], offsets: readonly number[], tick
|
|
|
487
612
|
return slots.map((slot, index) => ({
|
|
488
613
|
offset: offsets[index]!,
|
|
489
614
|
text: centre(slotStatusText(slot, tick), SLOT_CELL),
|
|
490
|
-
paint:
|
|
615
|
+
paint: wordPaint(slot, theme),
|
|
491
616
|
}));
|
|
492
617
|
}
|
|
493
618
|
|
|
@@ -499,12 +624,25 @@ function elapsedCells(slots: readonly LargeSlot[], offsets: readonly number[], t
|
|
|
499
624
|
}));
|
|
500
625
|
}
|
|
501
626
|
|
|
502
|
-
/**
|
|
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
|
+
*/
|
|
503
632
|
function slotStatusText(slot: LargeSlot, tick: number): string {
|
|
504
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";
|
|
505
637
|
return `${SPIN_FRAMES[mod(tick, SPIN_FRAMES.length)]!} ${slot.activity ?? SLOT_STATE_WORDS.working}`;
|
|
506
638
|
}
|
|
507
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
|
+
|
|
508
646
|
/** The elapsed row stays dim so the status row keeps the colour. */
|
|
509
647
|
function dimPaint(theme?: PanelTheme): ((text: string) => string) | undefined {
|
|
510
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
|
-
function oracleSlot(task: Task, expressions: ExpressionFrames): LargeSceneInput["oracle"] {
|
|
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 };
|
|
560
|
+
return { pose, frame: foldEmoteFrame(expressions.oracle ?? REST_FRAME), ...motion };
|
|
543
561
|
}
|
|
544
562
|
|
|
545
563
|
function sceneTasks(steps: readonly PlanStep[]): LargeTaskRow[] {
|
|
@@ -556,8 +574,7 @@ function sceneInput(
|
|
|
556
574
|
quiet: boolean,
|
|
557
575
|
tick: number,
|
|
558
576
|
steps: PlanStep[],
|
|
559
|
-
|
|
560
|
-
oracleActivity: string | undefined,
|
|
577
|
+
opts: PanelOptions,
|
|
561
578
|
): LargeSceneInput {
|
|
562
579
|
const metrics = sceneMetrics(task, runs, now);
|
|
563
580
|
const alert = taskAlert(task);
|
|
@@ -570,13 +587,14 @@ function sceneInput(
|
|
|
570
587
|
tick,
|
|
571
588
|
done: metrics.done,
|
|
572
589
|
total: metrics.total,
|
|
573
|
-
slots: sceneSlots(metrics, expressions),
|
|
590
|
+
slots: sceneSlots(metrics, opts.expressions ?? {}, opts.variants ?? {}),
|
|
574
591
|
tasks: sceneTasks(steps),
|
|
575
|
-
oracle: oracleSlot(task, expressions),
|
|
576
|
-
oracleActivity,
|
|
592
|
+
oracle: oracleSlot(task, opts.expressions ?? {}, opts.oracleMotion ?? {}),
|
|
593
|
+
oracleActivity: opts.oracleActivity,
|
|
577
594
|
caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
|
|
578
595
|
alert: alert?.text,
|
|
579
596
|
alertKind: alert?.kind,
|
|
597
|
+
feed: feedLine(runs, now, tick),
|
|
580
598
|
};
|
|
581
599
|
}
|
|
582
600
|
|
|
@@ -589,8 +607,18 @@ export interface PanelOptions {
|
|
|
589
607
|
theme?: PanelTheme;
|
|
590
608
|
/** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
|
|
591
609
|
expressions?: ExpressionFrames;
|
|
610
|
+
/** Caller-drawn variant per slot expression; picks the emote's face. */
|
|
611
|
+
variants?: ExpressionVariants;
|
|
592
612
|
/** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
|
|
593
613
|
oracleActivity?: string;
|
|
614
|
+
/** Caller-clocked oracle animation: the expression's sub-step and the lip-sync shape. */
|
|
615
|
+
oracleMotion?: OracleMotion;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** The oracle's clocked animation state beyond its expression frame (see expressions.ts). */
|
|
619
|
+
export interface OracleMotion {
|
|
620
|
+
phase?: number;
|
|
621
|
+
talk?: number;
|
|
594
622
|
}
|
|
595
623
|
|
|
596
624
|
/**
|
|
@@ -613,7 +641,7 @@ export function panelLines(
|
|
|
613
641
|
const steps = planChecklist(task.plan ?? "", runs);
|
|
614
642
|
const budget = largeLineBudget(opts.rows ?? DEFAULT_ROWS);
|
|
615
643
|
if (width >= LARGE_MIN_WIDTH && budget >= MIN_LARGE_LINES) {
|
|
616
|
-
const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts
|
|
644
|
+
const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts), width, budget, opts.theme);
|
|
617
645
|
return scene.map((line) => truncateToWidth(line, width));
|
|
618
646
|
}
|
|
619
647
|
return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
|
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`,",
|