@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.
@@ -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: { pose: OraclePose; frame: number };
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
- ...sectionLines(input, width, theme, entries),
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
- function towerRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
319
- const frame = oracleFrame(input);
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
- const values = new Map<string, () => string>([
323
- [TOWER.tokens.aura, () => paint(oracleAura(input), accent.color, theme)],
324
- [TOWER.tokens.orb, () => glow(frame.orb)],
325
- [TOWER.tokens.winL, () => glow(frame.winL)],
326
- [TOWER.tokens.winR, () => glow(frame.winR)],
327
- [TOWER.tokens.mouth, () => glow(frame.mouth)],
328
- [TOWER.tokens.door, () => glow(input.doorLabel ?? TOWER_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)?.() ?? paint(part, "muted", theme))
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, input: LargeSceneInput, theme?: PanelTheme): 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, input, theme) + paint(tail, "muted", theme);
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, input, theme);
403
- const tower = index === start + 1 ? tailRow(row, input, theme) : `${towerRow(row, input, theme)} `;
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
- return frames[mod(slot.frame, frames.length)] ?? [];
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: statusPaint(slot.status, theme),
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
- /** Working agents show the braille spinner with their live activity; every other state keeps its glyph and word. */
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;
@@ -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
- return { id, label: SLOT_LABELS[id], status, activity: run?.activity, elapsedLabel: runElapsedLabel(run, now) };
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
- return ` ${spinner} agents working (${running.length}) · ${run.domain}/${run.role} ${activity} ${formatDuration(runElapsed(run, now))}`;
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
- expressions: ExpressionFrames,
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.expressions ?? {}, opts.oracleActivity), width, budget, opts.theme);
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);
@@ -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: parseVerdict(findSection(sections, "verdict")),
62
+ verdict,
45
63
  findings: bullets(findSection(sections, "findings")).map(parseFinding),
46
- verification: findSection(sections, "verification") ?? "",
47
- requiredChanges: bullets(findSection(sections, "required changes")),
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
  }
@@ -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
- /** Worker has no tool allowlist: it needs the full set to implement. */
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`,",