@a-t-h-i/bot-lobby 0.1.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.
Files changed (61) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +406 -0
  3. package/package.json +46 -0
  4. package/prompts/backend.md +28 -0
  5. package/prompts/designer.md +33 -0
  6. package/prompts/global.md +42 -0
  7. package/prompts/master.md +107 -0
  8. package/prompts/qa.md +26 -0
  9. package/prompts/researcher.md +32 -0
  10. package/prompts/reviewer.md +43 -0
  11. package/prompts/scout.md +36 -0
  12. package/prompts/worker.md +49 -0
  13. package/src/agents/backend.ts +10 -0
  14. package/src/agents/designer.ts +10 -0
  15. package/src/agents/qa.ts +10 -0
  16. package/src/agents/registry.ts +14 -0
  17. package/src/execution/agent-runner.ts +150 -0
  18. package/src/execution/git.ts +42 -0
  19. package/src/execution/pi-runner.ts +292 -0
  20. package/src/index.ts +13 -0
  21. package/src/knowledge/compactor.ts +135 -0
  22. package/src/knowledge/paths.ts +43 -0
  23. package/src/knowledge/selector.ts +82 -0
  24. package/src/knowledge/store.ts +111 -0
  25. package/src/master/decisions.ts +53 -0
  26. package/src/master/master.ts +298 -0
  27. package/src/master/research.ts +98 -0
  28. package/src/master/synthesis.ts +57 -0
  29. package/src/pi/activity.ts +60 -0
  30. package/src/pi/commands.ts +268 -0
  31. package/src/pi/events.ts +76 -0
  32. package/src/pi/expressions.ts +101 -0
  33. package/src/pi/mascot-art.ts +252 -0
  34. package/src/pi/notify.ts +42 -0
  35. package/src/pi/quiet.ts +46 -0
  36. package/src/pi/settings-ui.ts +258 -0
  37. package/src/pi/tool-renderers.ts +121 -0
  38. package/src/pi/tools.ts +158 -0
  39. package/src/pi/ui.ts +227 -0
  40. package/src/pi/zen-large.ts +483 -0
  41. package/src/pi/zen-metrics.ts +80 -0
  42. package/src/pi/zen.ts +460 -0
  43. package/src/prompts/compiler.ts +50 -0
  44. package/src/prompts/loader.ts +20 -0
  45. package/src/roles/markdown.ts +64 -0
  46. package/src/roles/registry.ts +16 -0
  47. package/src/roles/researcher.ts +83 -0
  48. package/src/roles/reviewer.ts +65 -0
  49. package/src/roles/scout.ts +61 -0
  50. package/src/roles/worker.ts +94 -0
  51. package/src/schemas/agent.ts +39 -0
  52. package/src/schemas/configuration.ts +107 -0
  53. package/src/schemas/findings.ts +110 -0
  54. package/src/schemas/task.ts +113 -0
  55. package/src/state/persistence.ts +232 -0
  56. package/src/state/project.ts +99 -0
  57. package/src/state/task-state.ts +22 -0
  58. package/src/text.ts +51 -0
  59. package/src/workflow/approvals.ts +45 -0
  60. package/src/workflow/transitions.ts +41 -0
  61. package/src/workflow/workflow.ts +771 -0
package/src/pi/zen.ts ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Zen panel composition.
3
+ *
4
+ * `panelLines` owns the tier choice: the large animated scene at
5
+ * `width >= LARGE_MIN_WIDTH` while the terminal height allows, and the compact
6
+ * animated strip below that. Each agent's live one-word activity and elapsed time
7
+ * arrive on its run. Everything here is pure: expression frames and the spinner
8
+ * tick arrive from the caller, and every timestamp arrives as `now`.
9
+ */
10
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import type { AgentRun } from "../schemas/findings.ts";
12
+ import { TERMINAL_STATES, type Task, type TaskState } from "../schemas/task.ts";
13
+ import { truncate } from "../text.ts";
14
+ import {
15
+ BANNER_NARROW,
16
+ BANNER_TITLE,
17
+ COMPACT_FRAMES,
18
+ COMPACT_WIDTH,
19
+ SCENE_PROPS,
20
+ SLOT_IDS,
21
+ SLOT_STATE_COLORS,
22
+ SLOT_STATE_GLYPHS,
23
+ SPIN_FRAMES,
24
+ type OraclePose,
25
+ type PanelColor,
26
+ type SlotId,
27
+ type SlotState,
28
+ } from "./mascot-art.ts";
29
+ import { REST_FRAME } from "./expressions.ts";
30
+ import {
31
+ LARGE_MIN_WIDTH,
32
+ MAX_LARGE_LINES,
33
+ MAX_TASK_ROWS,
34
+ largeLines,
35
+ type LargeSceneInput,
36
+ type LargeSlot,
37
+ type LargeTaskRow,
38
+ } from "./zen-large.ts";
39
+ import { runStatus, sceneMetrics, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
40
+
41
+ /** Minimal slice of pi's Theme the panel needs; keeps zen.ts decoupled from the agent. */
42
+ export interface PanelTheme {
43
+ fg(color: PanelColor, text: string): string;
44
+ bold(text: string): string;
45
+ }
46
+
47
+ /** Human-readable duration such as "9s" or "2m 05s"; a non-finite input reads "0s". */
48
+ export function formatDuration(ms: number): string {
49
+ const seconds = Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 1000)) : 0;
50
+ const minutes = Math.floor(seconds / 60);
51
+ return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, "0")}s` : `${seconds}s`;
52
+ }
53
+
54
+ function runElapsed(run: AgentRun, now: number): number {
55
+ const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
56
+ const elapsed = end - Date.parse(run.startedAt);
57
+ return Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
58
+ }
59
+
60
+ /** Upper bound on parsed plan steps so the widget stays bounded. */
61
+ export const MAX_PLAN_STEPS = 50;
62
+ /** Upper bound on compact-tier panel rows. */
63
+ export const MAX_PANEL_LINES = 14;
64
+
65
+ const CHECKLIST_ROWS = 3;
66
+ const PREFIX_CHARS = 32;
67
+ const MIN_PREFIX = 8;
68
+ const HEADER_LINE = /^\s*(?:#{1,6}\s+\S.*|\*\*[^*]+\*\*)\s*$/;
69
+ const STEP_SECTION = /sequence|steps|order/i;
70
+ const NUMBERED_STEP_LINE = /^\s*\d+[.)]\s+(.*\S)\s*$/;
71
+ const INDENTED_BULLET = /^\s*[*-]\s+(.*\S)\s*$/;
72
+ const TOP_LEVEL_BULLET = /^[*-]\s+(.*\S)\s*$/;
73
+
74
+ export type PlanStepStatus = "done" | "current" | "pending";
75
+
76
+ export interface PlanStep {
77
+ text: string;
78
+ status: PlanStepStatus;
79
+ }
80
+
81
+ /** Numbered `1.`/`1)` text, or a bullet inside a step section; undefined otherwise. */
82
+ function stepText(line: string): string | undefined {
83
+ return NUMBERED_STEP_LINE.exec(line)?.[1] ?? INDENTED_BULLET.exec(line)?.[1];
84
+ }
85
+
86
+ function topBulletText(line: string): string | undefined {
87
+ return TOP_LEVEL_BULLET.exec(line)?.[1];
88
+ }
89
+
90
+ function collectSteps(lines: readonly string[], accept: (line: string) => string | undefined): string[] {
91
+ const found: string[] = [];
92
+ for (const line of lines) {
93
+ const text = accept(line);
94
+ if (text !== undefined) found.push(text);
95
+ }
96
+ return found;
97
+ }
98
+
99
+ /** Body of the first `sequence|steps|order` header, up to the next header; undefined when absent. */
100
+ function stepSection(lines: readonly string[]): string[] | undefined {
101
+ const start = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
102
+ if (start < 0) return undefined;
103
+ const body: string[] = [];
104
+ for (const line of lines.slice(start + 1)) {
105
+ if (HEADER_LINE.test(line)) break;
106
+ body.push(line);
107
+ }
108
+ return body;
109
+ }
110
+
111
+ /**
112
+ * Step texts from a free-form plan, capped. A `sequence|steps|order` section wins; otherwise
113
+ * numbered lines win; when neither exists, top-level bullets are the last resort, so unrelated
114
+ * bullet lists under other headers never leak into the checklist.
115
+ */
116
+ export function planSteps(plan: string): string[] {
117
+ const lines = plan.split("\n");
118
+ const section = stepSection(lines);
119
+ if (section) {
120
+ const sectioned = collectSteps(section, stepText);
121
+ if (sectioned.length > 0) return sectioned.slice(0, MAX_PLAN_STEPS);
122
+ }
123
+ const numbered = collectSteps(lines, (line) => NUMBERED_STEP_LINE.exec(line)?.[1]);
124
+ if (numbered.length > 0) return numbered.slice(0, MAX_PLAN_STEPS);
125
+ return collectSteps(lines, topBulletText).slice(0, MAX_PLAN_STEPS);
126
+ }
127
+
128
+ function normalize(text: string): string {
129
+ return text.replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
130
+ }
131
+
132
+ function matchesInstruction(step: string, instruction: string): boolean {
133
+ const hay = normalize(instruction);
134
+ const path = /`([^`]+)`/.exec(step)?.[1];
135
+ if (path && hay.includes(normalize(path))) return true;
136
+ const tail = normalize(step.replace(/`[^`]+`/g, " ").replace(/^[\s:;,.—–-]+/, ""));
137
+ return tail.length >= MIN_PREFIX && hay.includes(tail.slice(0, PREFIX_CHARS));
138
+ }
139
+
140
+ /** The latest worker run carrying an instruction; its step is the current one. */
141
+ export function latestWorkerRun(runs: AgentRun[]): AgentRun | undefined {
142
+ let latest: AgentRun | undefined;
143
+ for (const run of runs) {
144
+ if (run.role !== "worker" || !run.instruction) continue;
145
+ if (!latest || Date.parse(run.startedAt) >= Date.parse(latest.startedAt)) latest = run;
146
+ }
147
+ return latest;
148
+ }
149
+
150
+ /** Index of the plan step an instruction targets, -1 when nothing matches. */
151
+ export function currentStepIndex(steps: readonly string[], instruction: string | undefined): number {
152
+ return instruction ? steps.findIndex((step) => matchesInstruction(step, instruction)) : -1;
153
+ }
154
+
155
+ function stepStatus(index: number, current: number): PlanStepStatus {
156
+ if (current < 0) return index === 0 ? "current" : "pending";
157
+ if (index < current) return "done";
158
+ return index === current ? "current" : "pending";
159
+ }
160
+
161
+ /** Lowest index not yet in `completed`, or -1 when every step is. */
162
+ function nextOpenStep(steps: readonly string[], completed: ReadonlySet<number>): number {
163
+ return steps.findIndex((_text, index) => !completed.has(index));
164
+ }
165
+
166
+ /** Marks every step up to and including `index` as completed. */
167
+ function markThrough(completed: Set<number>, index: number): void {
168
+ for (let step = 0; step <= index; step += 1) completed.add(step);
169
+ }
170
+
171
+ /**
172
+ * Steps completed by successful worker runs, in start order. A run whose instruction matches
173
+ * a step completes everything up to that step; a run that matches nothing -- the common case
174
+ * for a reworded instruction -- completes the next still-open step, so the count only grows
175
+ * and a failure can never tick one off.
176
+ */
177
+ function completedSteps(steps: readonly string[], runs: AgentRun[]): number {
178
+ const completed = new Set<number>();
179
+ for (const run of runs) {
180
+ if (run.role !== "worker" || run.status !== "success") continue;
181
+ const matched = currentStepIndex(steps, run.instruction);
182
+ const target = matched >= 0 ? matched : nextOpenStep(steps, completed);
183
+ if (target >= 0) markThrough(completed, target);
184
+ }
185
+ return completed.size;
186
+ }
187
+
188
+ /**
189
+ * Done/current/pending per plan step, matched from the latest worker instruction.
190
+ * A succeeded run completes its step, so the next step becomes current (and the
191
+ * last step reads done); running, failed, cancelled and timeout runs keep it current.
192
+ * Progress is monotonic: steps already completed by successful worker runs stay done,
193
+ * and a later call that names an earlier step can never tick it back.
194
+ */
195
+ export function planChecklist(plan: string, runs: AgentRun[]): PlanStep[] {
196
+ const steps = planSteps(plan);
197
+ const latest = latestWorkerRun(runs);
198
+ const matched = currentStepIndex(steps, latest?.instruction);
199
+ const latestCurrent = matched >= 0 ? (latest?.status === "success" ? matched + 1 : matched) : -1;
200
+ const current = Math.max(completedSteps(steps, runs), latestCurrent);
201
+ return steps.map((text, index) => ({ text, status: stepStatus(index, current) }));
202
+ }
203
+
204
+ /** Up to `count` consecutive step indexes centered on the current step, hard-capped at `max`. */
205
+ export function checklistWindow(steps: readonly PlanStep[], count: number, max = CHECKLIST_ROWS): number[] {
206
+ const size = Math.min(count, max);
207
+ if (steps.length <= size) return steps.map((_step, index) => index);
208
+ const found = steps.findIndex((step) => step.status === "current");
209
+ const current = found < 0 ? steps.length - 1 : found;
210
+ const start = Math.min(Math.max(current - Math.floor((size - 1) / 2), 0), steps.length - size);
211
+ return Array.from({ length: size }, (_value, offset) => start + offset);
212
+ }
213
+
214
+ function stepLine(step: PlanStep, ordinal: number): string {
215
+ const icon = step.status === "done" ? "✓" : step.status === "current" ? "◐" : "○";
216
+ return ` ${icon} ${ordinal}. ${step.text}`;
217
+ }
218
+
219
+ function headerLine(task: Task, now: number, quiet: boolean): string {
220
+ const paused = task.paused ? " (paused)" : "";
221
+ const elapsed = formatDuration(now - Date.parse(task.createdAt));
222
+ const mode = quiet ? "tools hidden (alt+t)" : "tools shown";
223
+ return `bot-lobby ${task.id} · ${task.state}${paused} ⏱ ${elapsed} · ${mode}`;
224
+ }
225
+
226
+ /** The pending approval or blocker the user must resolve, with its severity. */
227
+ function taskAlert(task: Task): { text: string; kind: "warning" | "error" } | undefined {
228
+ const pending = task.approvals.filter((approval) => approval.status === "pending");
229
+ if (pending.length > 0) return { text: `approvals pending: ${pending.map((approval) => approval.id).join(", ")}`, kind: "warning" };
230
+ if (task.blockers.length > 0) return { text: `blocked: ${truncate(task.blockers[0]!.reason, 60)}`, kind: "error" };
231
+ return undefined;
232
+ }
233
+
234
+ const BANNER_WIDTH = 58;
235
+ const BANNER_MIN_WIDTH = 60;
236
+
237
+ /** Boxed title for wide terminals, one-line title otherwise; never wider than `width`. */
238
+ export function bannerLines(width: number): string[] {
239
+ if (width < 1) return [];
240
+ if (width < BANNER_MIN_WIDTH) return [truncateToWidth(BANNER_NARROW, width, "")];
241
+ const inner = BANNER_WIDTH - 2;
242
+ const titleWidth = visibleWidth(BANNER_TITLE);
243
+ const left = Math.floor((inner - titleWidth) / 2);
244
+ const border = "─".repeat(inner);
245
+ return [`┌${border}┐`, `│${" ".repeat(left)}${BANNER_TITLE}${" ".repeat(inner - titleWidth - left)}│`, `└${border}┘`];
246
+ }
247
+
248
+
249
+ /** One-line replacement for pi's suppressed streaming indicator; names the live activity too. */
250
+ export function workingLine(runs: AgentRun[], tick: number, theme?: PanelTheme, now = Date.now()): string {
251
+ const running = runs.filter((run) => run.status === "running");
252
+ const spin = SPIN_FRAMES[Math.abs(tick) % SPIN_FRAMES.length]!;
253
+ if (running.length > 0) {
254
+ const run = running.at(-1)!;
255
+ const spinner = theme ? theme.fg("accent", spin) : spin;
256
+ const activity = run.activity ?? "working";
257
+ return ` ${spinner} agents working (${running.length}) · ${run.domain}/${run.role} ${activity} ${formatDuration(runElapsed(run, now))}`;
258
+ }
259
+ const last = runs.at(-1);
260
+ if (!last) return " ○ waiting for the first agent…";
261
+ const status = paintStatus(runStatus(last.status), last.status, theme);
262
+ return ` · no agents running · last ${last.domain}/${last.role} ${status} ${formatDuration(runElapsed(last, now))}`;
263
+ }
264
+
265
+ function paintStatus(status: SlotState, text: string, theme?: PanelTheme): string {
266
+ if (!theme) return text;
267
+ const style = SLOT_STATE_COLORS[status];
268
+ const painted = theme.fg(style.color, text);
269
+ return style.bold ? theme.bold(painted) : painted;
270
+ }
271
+
272
+ /* -------------------------------------------------------------------------
273
+ * Expression frames. The caller schedules blinks and emotes (expressions.ts)
274
+ * and passes one frame index per slot and the oracle; the art only wraps it,
275
+ * so nothing here reads a clock or a random source.
276
+ * ---------------------------------------------------------------------- */
277
+
278
+ /** Caller-chosen expression frame per slot and the oracle; absent reads as rest. */
279
+ export type ExpressionFrames = Partial<Record<SlotId | "oracle", number>>;
280
+
281
+ /** Frame `index` wrapped into the art's frame list; a non-finite index rests. */
282
+ function frameAt(frames: readonly string[], index: number): string {
283
+ if (frames.length === 0) return "";
284
+ const at = ((Math.floor(index) % frames.length) + frames.length) % frames.length;
285
+ return frames[at] ?? "";
286
+ }
287
+
288
+ /* -------------------------------------------------------------------------
289
+ * Compact tier (< LARGE_MIN_WIDTH): banner, header, the one-row-per-agent strip
290
+ * and the checklist; the working line names the newest running agent's activity
291
+ * and elapsed. Geometry matches the long-standing 40-column strip.
292
+ * ---------------------------------------------------------------------- */
293
+
294
+ const COMPACT_GAP = 2;
295
+ const COMPACT_INNER = SLOT_IDS.length * COMPACT_WIDTH + (SLOT_IDS.length - 1) * COMPACT_GAP;
296
+ const COMPACT_STATUS_GAP = " ".repeat(COMPACT_WIDTH + COMPACT_GAP - 1);
297
+
298
+ function compactFrame(slot: SlotView, expressions: ExpressionFrames): string {
299
+ return frameAt(COMPACT_FRAMES[slot.id][slot.status], expressions[slot.id] ?? REST_FRAME);
300
+ }
301
+
302
+ function compactRow(content: string, color: PanelColor, theme?: PanelTheme): string {
303
+ const body = truncateToWidth(content, COMPACT_INNER, "", true);
304
+ return `|${theme ? theme.fg(color, body) : body}|`;
305
+ }
306
+
307
+ /** Caption, animated sprites, then the coloured status glyph per slot. */
308
+ function compactStrip(state: TaskState, slots: readonly SlotView[], expressions: ExpressionFrames, theme?: PanelTheme): string[] {
309
+ const sprites = slots.map((slot) => paintStatus(slot.status, compactFrame(slot, expressions), theme)).join(" ".repeat(COMPACT_GAP));
310
+ const glyphs = slots.map((slot) => paintStatus(slot.status, SLOT_STATE_GLYPHS[slot.status], theme)).join(COMPACT_STATUS_GAP);
311
+ return [
312
+ compactRow(SCENE_PROPS[state], "dim", theme),
313
+ compactRow(sprites, "muted", theme),
314
+ compactRow(glyphs, "muted", theme),
315
+ ];
316
+ }
317
+
318
+ /** Alert, working line, and the `steps x/y` summary, in priority order. */
319
+ function tailLines(task: Task, runs: AgentRun[], now: number, tick: number, steps: PlanStep[], theme?: PanelTheme): string[] {
320
+ const alert = taskAlert(task);
321
+ const rows = alert ? [alert.text] : [];
322
+ rows.push(workingLine(runs, tick, theme, now));
323
+ if (steps.length > 0) {
324
+ const done = steps.filter((step) => step.status === "done").length;
325
+ rows.push(`steps ${done}/${steps.length}`);
326
+ }
327
+ return rows;
328
+ }
329
+
330
+ function checklistLines(steps: PlanStep[], count: number): string[] {
331
+ if (steps.length === 0 || count <= 0) return [];
332
+ return checklistWindow(steps, count).map((index) => stepLine(steps[index]!, index + 1));
333
+ }
334
+
335
+ function compactPanel(
336
+ task: Task,
337
+ runs: AgentRun[],
338
+ now: number,
339
+ quiet: boolean,
340
+ tick: number,
341
+ steps: PlanStep[],
342
+ opts: PanelOptions,
343
+ width: number,
344
+ ): string[] {
345
+ const metrics = sceneMetrics(task, runs, now);
346
+ const expressions = opts.expressions ?? {};
347
+ const fixed = [...bannerLines(width), headerLine(task, now, quiet), ...compactStrip(task.state, metrics.slots, expressions, opts.theme)];
348
+ const tail = tailLines(task, runs, now, tick, steps, opts.theme);
349
+ const room = Math.max(0, MAX_PANEL_LINES - fixed.length - tail.length);
350
+ return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => truncateToWidth(line, width));
351
+ }
352
+
353
+ /* -------------------------------------------------------------------------
354
+ * Large tier (>= LARGE_MIN_WIDTH): the animated scene from zen-large.ts.
355
+ * ---------------------------------------------------------------------- */
356
+
357
+ /** Terminal rows assumed when the caller cannot report them. */
358
+ const DEFAULT_ROWS = 40;
359
+ /** Share of the terminal height the large scene may use. */
360
+ const ROW_FRACTION = 0.75;
361
+ /** Below this line budget the large scene loses its agent strip, so compact reads better. */
362
+ const MIN_LARGE_LINES = 12;
363
+
364
+ /** Line budget for the large scene: a clamped fraction of the terminal height. */
365
+ export function largeLineBudget(rows: number): number {
366
+ const safe = Number.isFinite(rows) && rows > 0 ? rows : DEFAULT_ROWS;
367
+ return Math.min(MAX_LARGE_LINES, Math.max(0, Math.floor(safe * ROW_FRACTION)));
368
+ }
369
+
370
+ function oraclePose(task: Task): OraclePose {
371
+ return task.paused || TERMINAL_STATES.includes(task.state) ? "dormant" : "orchestrating";
372
+ }
373
+
374
+ function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames): LargeSlot[] {
375
+ return metrics.slots.map((slot) => ({
376
+ ...slot,
377
+ frame: expressions[slot.id] ?? REST_FRAME,
378
+ }));
379
+ }
380
+
381
+ function oracleSlot(task: Task, expressions: ExpressionFrames): LargeSceneInput["oracle"] {
382
+ const pose = oraclePose(task);
383
+ return { pose, frame: expressions.oracle ?? REST_FRAME };
384
+ }
385
+
386
+ function sceneTasks(steps: readonly PlanStep[]): LargeTaskRow[] {
387
+ return checklistWindow(steps, MAX_TASK_ROWS, MAX_TASK_ROWS).map((index) => ({
388
+ text: steps[index]!.text,
389
+ status: steps[index]!.status,
390
+ }));
391
+ }
392
+
393
+ function sceneInput(
394
+ task: Task,
395
+ runs: AgentRun[],
396
+ now: number,
397
+ quiet: boolean,
398
+ tick: number,
399
+ steps: PlanStep[],
400
+ expressions: ExpressionFrames,
401
+ oracleActivity: string | undefined,
402
+ ): LargeSceneInput {
403
+ const metrics = sceneMetrics(task, runs, now);
404
+ const alert = taskAlert(task);
405
+ return {
406
+ taskId: task.id,
407
+ taskTitle: task.title,
408
+ state: task.paused ? `${task.state} (paused)` : task.state,
409
+ elapsedLabel: metrics.elapsedLabel,
410
+ quietHint: quiet ? "tools hidden (alt+t)" : "tools shown",
411
+ tick,
412
+ done: metrics.done,
413
+ total: metrics.total,
414
+ slots: sceneSlots(metrics, expressions),
415
+ tasks: sceneTasks(steps),
416
+ oracle: oracleSlot(task, expressions),
417
+ oracleActivity,
418
+ alert: alert?.text,
419
+ alertKind: alert?.kind,
420
+ };
421
+ }
422
+
423
+ export interface PanelOptions {
424
+ width?: number;
425
+ /** Terminal rows; drives the large tier's line budget. */
426
+ rows?: number;
427
+ /** Spinner frame; only the working line animates off it. */
428
+ tick?: number;
429
+ theme?: PanelTheme;
430
+ /** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
431
+ expressions?: ExpressionFrames;
432
+ /** Live master activity word for the oracle spinner; absent reads "working". */
433
+ oracleActivity?: string;
434
+ }
435
+
436
+ /**
437
+ * Zen panel lines. At `width >= LARGE_MIN_WIDTH` with enough terminal height the
438
+ * large animated scene owns the panel; otherwise the compact strip keeps the
439
+ * banner, header, agent row, alert, live activity/elapsed working line and
440
+ * checklist. No line ever
441
+ * exceeds `width`.
442
+ */
443
+ export function panelLines(
444
+ task: Task | undefined,
445
+ runs: AgentRun[],
446
+ now: number,
447
+ quiet: boolean,
448
+ opts: PanelOptions = {},
449
+ ): string[] {
450
+ if (!task) return [];
451
+ const width = opts.width ?? 100;
452
+ const tick = opts.tick ?? 0;
453
+ const steps = planChecklist(task.plan ?? "", runs);
454
+ const budget = largeLineBudget(opts.rows ?? DEFAULT_ROWS);
455
+ if (width >= LARGE_MIN_WIDTH && budget >= MIN_LARGE_LINES) {
456
+ const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts.expressions ?? {}, opts.oracleActivity), width, budget, opts.theme);
457
+ return scene.map((line) => truncateToWidth(line, width));
458
+ }
459
+ return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
460
+ }
@@ -0,0 +1,50 @@
1
+ import type { Domain, Role } from "../schemas/agent.ts";
2
+ import { ROLE_SPECS } from "../roles/registry.ts";
3
+ import { DOMAIN_SPECS } from "../agents/registry.ts";
4
+ import { loadPrompt } from "./loader.ts";
5
+
6
+ export type PromptDomain = Domain | "master";
7
+
8
+ export interface CompileInput {
9
+ domain: PromptDomain;
10
+ role?: Role;
11
+ /** Per-agent custom instructions layered on top of the built-in prompts. */
12
+ instructions?: string;
13
+ /** Task context: requirements, objective, approved plan, boundaries. */
14
+ task: string;
15
+ standards?: string;
16
+ knowledge?: string;
17
+ decisions?: string;
18
+ workflowContext?: string;
19
+ }
20
+
21
+ function domainPromptFile(domain: PromptDomain): string {
22
+ return domain === "master" ? "master.md" : DOMAIN_SPECS[domain].promptFile;
23
+ }
24
+
25
+ function section(title: string, body?: string): string {
26
+ const text = body?.trim();
27
+ return text ? `## ${title}\n\n${text}` : "";
28
+ }
29
+
30
+ /**
31
+ * Compose the layered prompt: global + domain + role + task context +
32
+ * standards + knowledge + decisions + workflow context + output contract.
33
+ * Empty layers are dropped so agents never receive empty headings.
34
+ */
35
+ export function compilePrompt(input: CompileInput): string {
36
+ const spec = input.role ? ROLE_SPECS[input.role] : undefined;
37
+ const layers = [
38
+ loadPrompt("global.md"),
39
+ loadPrompt(domainPromptFile(input.domain)),
40
+ spec ? loadPrompt(spec.promptFile) : "",
41
+ section("Custom Instructions", input.instructions),
42
+ section("Task Context", input.task),
43
+ section("Standards", input.standards),
44
+ section("Knowledge", input.knowledge),
45
+ section("Decisions", input.decisions),
46
+ section("Workflow Context", input.workflowContext),
47
+ spec?.contract ?? "",
48
+ ];
49
+ return layers.filter((layer) => layer.trim().length > 0).join("\n\n---\n\n");
50
+ }
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "prompts");
6
+ const cache = new Map<string, string>();
7
+
8
+ /** Load a prompt layer by filename from the extension's prompts/ directory. */
9
+ export function loadPrompt(file: string): string {
10
+ const cached = cache.get(file);
11
+ if (cached !== undefined) return cached;
12
+ const content = readFileSync(join(PROMPTS_DIR, file), "utf8").trim();
13
+ cache.set(file, content);
14
+ return content;
15
+ }
16
+
17
+ /** Drop memoized prompts so prompt edits are picked up without a restart. */
18
+ export function clearPromptCache(): void {
19
+ cache.clear();
20
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Lenient markdown section parsing for agent output contracts. Agent prose is
3
+ * untrusted input: headings vary in case, bullets vary in marker, and sections
4
+ * may be missing entirely. Everything here degrades instead of throwing.
5
+ */
6
+
7
+ import type { Pushback } from "../schemas/findings.ts";
8
+
9
+ /** Map lowercase heading text to its body for `##`/`###` sections. */
10
+ export function parseSections(markdown: string): Map<string, string> {
11
+ const sections = new Map<string, string>();
12
+ let current: string | undefined;
13
+ for (const line of markdown.split("\n")) {
14
+ const heading = /^#{2,3}\s+(.+?)\s*$/.exec(line);
15
+ if (heading) {
16
+ current = heading[1]!.toLowerCase();
17
+ sections.set(current, "");
18
+ continue;
19
+ }
20
+ if (current) sections.set(current, `${sections.get(current) ?? ""}${line}\n`);
21
+ }
22
+ return sections;
23
+ }
24
+
25
+ /** Read a `**Name:** value` field from a section body, trimmed. */
26
+ export function fieldValue(body: string, name: string): string | undefined {
27
+ const match = new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`, "i").exec(body);
28
+ return match?.[1]?.trim();
29
+ }
30
+
31
+ /** Optional `## Pushback` block: request + reason (required) and an alternative. */
32
+ export function parsePushback(sections: Map<string, string>): Pushback | undefined {
33
+ const body = findSection(sections, "pushback");
34
+ if (!body) return undefined;
35
+ const reason = fieldValue(body, "Reason");
36
+ if (!reason) return undefined;
37
+ const alternative = fieldValue(body, "Alternative");
38
+ return { request: fieldValue(body, "Request") ?? "the assigned change", reason, alternative };
39
+ }
40
+ /** Find a section whose heading contains `name` (case-insensitive). */
41
+ export function findSection(sections: Map<string, string>, name: string): string | undefined {
42
+ const needle = name.toLowerCase();
43
+ for (const [heading, body] of sections) {
44
+ if (heading.includes(needle)) return body.trim();
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** Extract bullet items from a section body, stripping list markers. */
50
+ export function bullets(text: string | undefined): string[] {
51
+ return (text ?? "")
52
+ .split("\n")
53
+ .map((line) => line.trim())
54
+ .filter((line) => /^[-*]\s+\S/.test(line))
55
+ .map((line) => line.replace(/^[-*]\s+/, "").trim())
56
+ .filter((line) => line.length > 0);
57
+ }
58
+
59
+ /** Parse a `path — reason` bullet into its parts. */
60
+ export function parseFileBullet(text: string): { path: string; reason: string } {
61
+ const match = /^`?([^`\s]+)`?\s*(?:[—–]|--|-)\s*(.*)$/.exec(text);
62
+ if (match) return { path: match[1]!, reason: match[2]!.trim() };
63
+ return { path: text.trim(), reason: "" };
64
+ }
@@ -0,0 +1,16 @@
1
+ import type { Role, RoleSpec } from "../schemas/agent.ts";
2
+ import { scoutSpec } from "./scout.ts";
3
+ import { workerSpec } from "./worker.ts";
4
+ import { reviewerSpec } from "./reviewer.ts";
5
+ import { researcherSpec } from "./researcher.ts";
6
+
7
+ export const ROLE_SPECS: Record<Role, RoleSpec> = {
8
+ scout: scoutSpec,
9
+ worker: workerSpec,
10
+ reviewer: reviewerSpec,
11
+ researcher: researcherSpec,
12
+ };
13
+
14
+ export function roleSpec(role: Role): RoleSpec {
15
+ return ROLE_SPECS[role];
16
+ }