@a-t-h-i/bot-lobby 0.4.0 → 0.5.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/src/pi/ui.ts CHANGED
@@ -129,8 +129,17 @@ export function isMinimized(): boolean {
129
129
  return minimized;
130
130
  }
131
131
 
132
+ /** Told when minimize changes, so the lobby can step aside too. */
133
+ let minimizeListener: ((value: boolean) => void) | undefined;
134
+
135
+ export function onMinimizeChange(listener: ((value: boolean) => void) | undefined): void {
136
+ minimizeListener = listener;
137
+ }
138
+
132
139
  export function setMinimized(value: boolean): void {
140
+ if (minimized === value) return;
133
141
  minimized = value;
142
+ minimizeListener?.(value);
134
143
  }
135
144
 
136
145
  /** Flip minimize/restore and refresh the footer; the session is unchanged. */
@@ -191,45 +200,27 @@ export function isReaction(previous: string | undefined, next: Situation): boole
191
200
  return !previous.startsWith("working|") || next.flag !== undefined || next.handover === true;
192
201
  }
193
202
 
194
- /** Animated zen scene + plan checklist shown above the editor while a task is active. */
195
- class ZenWidget implements Component {
196
- private tick = 0;
197
- private cache: { key: string; theme: Theme; lines: string[] } | undefined;
198
- private delay = liveTickDelay();
199
- private timer: ReturnType<typeof setInterval>;
200
- private disposed = false;
203
+ /**
204
+ * The animated zen scene: the tick, each sprite's expression schedule and the
205
+ * reactions to what the agents go through. The widget and the lobby's home tab
206
+ * each own one and advance it from their own clock.
207
+ */
208
+ export class ZenScene {
209
+ tick = 0;
201
210
  private readonly expressions: Record<ExpressionKey, ExpressionState>;
202
211
  private readonly situations: Partial<Record<SlotId, string>> = {};
203
- private readonly tui: TUI;
204
- private readonly theme: () => Theme;
205
212
  private readonly rng: () => number;
213
+ private cache: { key: string; theme: Theme | undefined; lines: string[] } | undefined;
206
214
 
207
- constructor(tui: TUI, theme: () => Theme, rng: () => number = Math.random) {
208
- this.tui = tui;
209
- this.theme = theme;
215
+ constructor(rng: () => number = Math.random, now = Date.now()) {
210
216
  this.rng = rng;
211
- const now = Date.now();
212
217
  const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng, gapFor(key))] as const);
213
218
  this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
214
- this.timer = setInterval(() => this.advance(), this.delay);
215
- mountedWidget = this;
216
- }
217
-
218
- /** Repaint now: state changed between ticks. */
219
- refresh(): void {
220
- if (!this.disposed) this.tui.requestRender();
221
219
  }
222
220
 
223
- private advance(): void {
224
- if (this.disposed) return;
221
+ /** One clock step: expressions advance and agents react to their new situations. */
222
+ advance(now = Date.now()): void {
225
223
  this.tick += 1;
226
- const now = Date.now();
227
- this.play(now);
228
- this.retime(now);
229
- this.tui.requestRender();
230
- }
231
-
232
- private play(now: number): void {
233
224
  const situations = slotSituations(zenState.runs, now);
234
225
  for (const key of EXPRESSION_KEYS) {
235
226
  this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key, situations), blinkChanceFor(key, situations));
@@ -241,39 +232,24 @@ class ZenWidget implements Component {
241
232
  }
242
233
  }
243
234
 
244
- /** One interval, retimed when work starts or stops or an expression plays. */
245
- private retime(now: number): void {
246
- const delay = expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
247
- if (delay === this.delay) return;
248
- this.delay = delay;
249
- clearInterval(this.timer);
250
- this.timer = setInterval(() => this.advance(), delay);
235
+ /** The delay until the next step: fast while an expression plays or the oracle talks. */
236
+ delay(now = Date.now()): number {
237
+ return expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
251
238
  }
252
239
 
253
240
  private motion(now: number): OracleMotion {
254
241
  return { phase: expressionPhase(this.expressions.oracle, now), talk: talkFrame(oracleSpokeAt, now) };
255
242
  }
256
243
 
257
- private frames(): Partial<Record<ExpressionKey, number>> {
258
- return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
259
- }
260
-
261
- private variants(): Partial<Record<SlotId, number>> {
262
- return Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant]));
263
- }
264
-
265
244
  /**
266
- * The panel only changes with the tick, an expression frame, the widget state or
267
- * the elapsed second, so every other repaint (typing in the editor, streaming
268
- * output) reuses the last lines instead of recomposing the scene.
245
+ * The scene lines for the current zen state. They only change with the tick,
246
+ * an expression frame, the widget state or the elapsed second, so any other
247
+ * repaint (typing, streaming output) reuses the last lines.
269
248
  */
270
- render(width: number): string[] {
271
- const now = Date.now();
272
- const rows = this.tui.terminal.rows;
273
- const theme = this.theme();
274
- const expressions = this.frames();
249
+ lines(width: number, rows: number, theme: Theme | undefined, now = Date.now()): string[] {
250
+ const expressions = Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame])) as Partial<Record<ExpressionKey, number>>;
251
+ const variants = Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant])) as Partial<Record<SlotId, number>>;
275
252
  const quiet = isQuiet();
276
- const variants = this.variants();
277
253
  const frameKey = [...EXPRESSION_KEYS.map((key) => expressions[key] ?? 0), ...SLOT_IDS.map((id) => variants[id] ?? 0)].join(",");
278
254
  const motion = this.motion(now);
279
255
  const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
@@ -287,6 +263,59 @@ class ZenWidget implements Component {
287
263
  invalidate(): void {
288
264
  this.cache = undefined;
289
265
  }
266
+ }
267
+
268
+ /** The zen state as the lobby reads it: the session's active task and its runs. */
269
+ export function zenSnapshot(): { task: Task | undefined; runs: readonly AgentRun[]; version: number; oracleActivity: string | undefined } {
270
+ return { task: zenState.task, runs: zenState.runs, version: zenVersion, oracleActivity };
271
+ }
272
+
273
+ /** Zen scene + plan checklist shown above the editor while a task is active and the lobby is closed. */
274
+ class ZenWidget implements Component {
275
+ private readonly scene: ZenScene;
276
+ private delay = liveTickDelay();
277
+ private timer: ReturnType<typeof setInterval>;
278
+ private disposed = false;
279
+ private readonly tui: TUI;
280
+ private readonly theme: () => Theme;
281
+
282
+ constructor(tui: TUI, theme: () => Theme, rng: () => number = Math.random) {
283
+ this.tui = tui;
284
+ this.theme = theme;
285
+ this.scene = new ZenScene(rng);
286
+ this.timer = setInterval(() => this.advance(), this.delay);
287
+ mountedWidget = this;
288
+ }
289
+
290
+ /** Repaint now: state changed between ticks. */
291
+ refresh(): void {
292
+ if (!this.disposed) this.tui.requestRender();
293
+ }
294
+
295
+ private advance(): void {
296
+ if (this.disposed) return;
297
+ const now = Date.now();
298
+ this.scene.advance(now);
299
+ this.retime(now);
300
+ this.tui.requestRender();
301
+ }
302
+
303
+ /** One interval, retimed when work starts or stops or an expression plays. */
304
+ private retime(now: number): void {
305
+ const delay = this.scene.delay(now);
306
+ if (delay === this.delay) return;
307
+ this.delay = delay;
308
+ clearInterval(this.timer);
309
+ this.timer = setInterval(() => this.advance(), delay);
310
+ }
311
+
312
+ render(width: number): string[] {
313
+ return this.scene.lines(width, this.tui.terminal.rows, this.theme());
314
+ }
315
+
316
+ invalidate(): void {
317
+ this.scene.invalidate();
318
+ }
290
319
 
291
320
  dispose(): void {
292
321
  this.disposed = true;
@@ -295,6 +324,21 @@ class ZenWidget implements Component {
295
324
  }
296
325
  }
297
326
 
327
+ /** True while the full-screen lobby is showing; the small widget then stays unmounted. */
328
+ let widgetSuppressed: () => boolean = () => false;
329
+ let widgetMounted = false;
330
+
331
+ /** The lobby registers how to tell whether it covers the screen. */
332
+ export function setWidgetSuppressor(check: () => boolean): void {
333
+ widgetSuppressed = check;
334
+ }
335
+
336
+ function unmountWidget(ctx: ExtensionContext): void {
337
+ if (!widgetMounted) return;
338
+ widgetMounted = false;
339
+ ctx.ui.setWidget(STATUS_KEY, undefined);
340
+ }
341
+
298
342
  function leaveZen(ctx: ExtensionContext): void {
299
343
  if (!zenOn) return;
300
344
  zenOn = false;
@@ -317,17 +361,32 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
317
361
  const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
318
362
  if (!active) {
319
363
  leaveZen(ctx);
364
+ widgetMounted = false;
320
365
  ctx.ui.setWidget(STATUS_KEY, undefined);
321
366
  return;
322
367
  }
368
+ zenOn = true;
323
369
  ctx.ui.setWorkingVisible(false);
324
370
  ctx.ui.setWorkingIndicator({ frames: [] });
325
- if (!zenOn) {
326
- zenOn = true;
371
+ if (widgetSuppressed()) return unmountWidget(ctx);
372
+ if (!widgetMounted) {
373
+ widgetMounted = true;
327
374
  ctx.ui.setWidget(STATUS_KEY, (tui) => new ZenWidget(tui, () => ctx.ui.theme));
328
375
  }
329
376
  }
330
377
 
378
+ /** The session's active task as the widget last loaded it (undefined when none or minimized). */
379
+ export function currentZenTask(): Task | undefined {
380
+ return zenState.task;
381
+ }
382
+
383
+ /** Called with every streamed run update, so the lobby's activity log can follow the agents. */
384
+ let runListener: ((runs: readonly AgentRun[]) => void) | undefined;
385
+
386
+ export function onRunUpdates(listener: ((runs: readonly AgentRun[]) => void) | undefined): void {
387
+ runListener = listener;
388
+ }
389
+
331
390
  /**
332
391
  * Streamed run updates (start, every activity change, finish) from an in-flight
333
392
  * `orchestrate` call. The task on disk does not change mid-call, so this only
@@ -335,6 +394,7 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
335
394
  * Before any task is loaded it falls back to `applyStatus` so the widget appears.
336
395
  */
337
396
  export function reportRuns(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[]): void {
397
+ runListener?.(runs);
338
398
  if (!zenState.task && !minimized) {
339
399
  applyStatus(ctx, root, configDir, runs);
340
400
  return;
@@ -344,6 +404,7 @@ export function reportRuns(ctx: ExtensionContext, root: string, configDir: strin
344
404
 
345
405
  export function clearStatus(ctx: ExtensionContext): void {
346
406
  leaveZen(ctx);
407
+ widgetMounted = false;
347
408
  oracleActivity = undefined;
348
409
  zenState = { task: undefined, live: [], runs: [] };
349
410
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -40,9 +40,15 @@ export interface ScoutConfig {
40
40
  }
41
41
 
42
42
  /** Subagent kinds with their own settings entry. */
43
- export const SUBAGENT_KINDS = ["designer", "backend", "qa", "scout", "researcher"] as const;
43
+ export const SUBAGENT_KINDS = ["designer", "backend", "qa", "scout", "researcher", "quickfix", "planner"] as const;
44
44
  export type SubagentKind = (typeof SUBAGENT_KINDS)[number];
45
45
 
46
+ /** Lobby agents that run outside the workflow: direct quick fixes and the task planner. */
47
+ export type LobbyAgentKind = "quickfix" | "planner";
48
+
49
+ /** Settings kinds a workflow run (domain + role) can draw from. */
50
+ export type WorkflowProfileKind = Domain | "scout" | "researcher";
51
+
46
52
  export interface WorkflowConfig {
47
53
  maxReviewIterations: number;
48
54
  maxParallelScouts: number;
@@ -69,13 +75,34 @@ export interface KnowledgeConfig {
69
75
  scratchpadMaxChars: number;
70
76
  }
71
77
 
78
+ /** Planning panel seats: each domain agent, plus the researcher, questions the user in plan mode. */
79
+ export const PANEL_MEMBERS = ["backend", "designer", "qa", "researcher"] as const;
80
+ export type PanelMember = (typeof PANEL_MEMBERS)[number];
81
+
82
+ export function isPanelMember(value: string): value is PanelMember {
83
+ return (PANEL_MEMBERS as readonly string[]).includes(value);
84
+ }
85
+
86
+ /** The full-screen lobby. */
87
+ export interface LobbyConfig {
88
+ /** Open by itself when this session starts or resumes a task. */
89
+ autoOpen: boolean;
90
+ /** Who sits on the planning panel next to the oracle, until toggled in the Plan tab. */
91
+ planningPanel: PanelMember[];
92
+ }
93
+
72
94
  export interface BotLobbyConfig {
73
95
  master: AgentModelConfig;
74
96
  agents: Record<"designer" | "backend" | "qa", AgentModelConfig>;
75
97
  scout: ScoutConfig;
76
98
  researcher: AgentModelConfig;
99
+ /** Direct quick fixes from the lobby: no scouting, planning or review. */
100
+ quickFix: AgentModelConfig;
101
+ /** The task planner that grills the user until a plan is clear; `timeoutMs` bounds one turn. */
102
+ planner: AgentModelConfig;
77
103
  workflow: WorkflowConfig;
78
104
  knowledge: KnowledgeConfig;
105
+ lobby: LobbyConfig;
79
106
  }
80
107
 
81
108
  export const DEFAULT_CONFIG: BotLobbyConfig = {
@@ -87,6 +114,8 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
87
114
  },
88
115
  scout: { model: INHERIT_MODEL, timeoutMs: 8 * 60 * 1000 },
89
116
  researcher: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
117
+ quickFix: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
118
+ planner: { model: INHERIT_MODEL, thinking: "high", instructions: "", timeoutMs: 5 * 60 * 1000 },
90
119
  workflow: {
91
120
  maxReviewIterations: 2,
92
121
  maxParallelScouts: 3,
@@ -106,6 +135,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
106
135
  scratchpadMaxParagraphs: 4,
107
136
  scratchpadMaxChars: 2000,
108
137
  },
138
+ lobby: { autoOpen: true, planningPanel: [...PANEL_MEMBERS] },
109
139
  };
110
140
 
111
141
  function positive(value: unknown): number | undefined {
@@ -129,6 +159,17 @@ function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig
129
159
  };
130
160
  }
131
161
 
162
+ function normalizeLobby(value: unknown): LobbyConfig {
163
+ const source = value as { autoOpen?: unknown; planningPanel?: unknown } | undefined;
164
+ const panel = Array.isArray(source?.planningPanel)
165
+ ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
166
+ : [...DEFAULT_CONFIG.lobby.planningPanel];
167
+ return {
168
+ autoOpen: typeof source?.autoOpen === "boolean" ? source.autoOpen : DEFAULT_CONFIG.lobby.autoOpen,
169
+ planningPanel: PANEL_MEMBERS.filter((member) => panel.includes(member)),
170
+ };
171
+ }
172
+
132
173
  /** Deep-merge user config over defaults, keeping unknown keys out. */
133
174
  export function resolveConfig(partial: unknown): BotLobbyConfig {
134
175
  const src = (partial ?? {}) as Record<string, unknown>;
@@ -144,8 +185,11 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
144
185
  },
145
186
  scout: normalizeScout(src.scout as Partial<ScoutConfig> | undefined),
146
187
  researcher: normalizeAgent(DEFAULT_CONFIG.researcher, src.researcher as Partial<AgentModelConfig> | undefined),
188
+ quickFix: normalizeAgent(DEFAULT_CONFIG.quickFix, src.quickFix as Partial<AgentModelConfig> | undefined),
189
+ planner: normalizeAgent(DEFAULT_CONFIG.planner, src.planner as Partial<AgentModelConfig> | undefined),
147
190
  workflow,
148
191
  knowledge,
192
+ lobby: normalizeLobby(src.lobby),
149
193
  };
150
194
  }
151
195
 
@@ -157,7 +201,7 @@ export function hasScoutThinking(partial: unknown): boolean {
157
201
 
158
202
  /** What one subagent run uses: model (undefined = not configured), thinking and time limit. */
159
203
  export interface AgentProfile {
160
- kind: SubagentKind;
204
+ kind: WorkflowProfileKind;
161
205
  model?: string;
162
206
  thinking: string;
163
207
  timeoutMs: number;
@@ -165,7 +209,7 @@ export interface AgentProfile {
165
209
  }
166
210
 
167
211
  /** The settings entry a domain/role run draws from. */
168
- export function profileKind(domain: Domain, role: Role): SubagentKind {
212
+ export function profileKind(domain: Domain, role: Role): WorkflowProfileKind {
169
213
  if (role === "scout") return "scout";
170
214
  if (role === "researcher") return "researcher";
171
215
  return domain;
@@ -192,6 +236,27 @@ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role)
192
236
  return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
193
237
  }
194
238
 
239
+ /** The settings entry of a lobby agent (quick fix or planner). */
240
+ export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind): AgentModelConfig {
241
+ return kind === "quickfix" ? config.quickFix : config.planner;
242
+ }
243
+
244
+ /** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
245
+ export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
246
+ const entry = lobbyAgentConfig(config, kind);
247
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions };
248
+ }
249
+
250
+ /**
251
+ * A planning panel seat's profile: the domain's (or the researcher's) model,
252
+ * thinking and custom instructions, bounded by the planner's per-turn limit.
253
+ */
254
+ export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
255
+ const entry = member === "researcher" ? config.researcher : config.agents[member];
256
+ const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
257
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions };
258
+ }
259
+
195
260
  /** Resolves the model, thinking and time limit one subagent run uses. */
196
261
  export type ProfileResolver = (domain: Domain, role: Role) => AgentProfile;
197
262
 
@@ -111,6 +111,10 @@ export interface AgentRun {
111
111
  finishedAt?: string;
112
112
  /** Short target of the activity in flight: a file, command head or pattern. */
113
113
  detail?: string;
114
+ /** The tool call in flight in plain words (`reading users.ts`); feeds the lobby's activity log. */
115
+ step?: string;
116
+ /** The agent's latest finished thought, bounded; the lobby shows it in its thinking pane. */
117
+ thought?: string;
114
118
  /** Assistant turns and tool calls so far. */
115
119
  turns?: number;
116
120
  tools?: number;
@@ -121,6 +125,8 @@ export interface AgentRun {
121
125
  noteKind?: "info" | "warning";
122
126
  /** Model that actually served the run. */
123
127
  model?: string;
128
+ /** Thinking level the run was started with. */
129
+ thinking?: string;
124
130
  /** Killed by the stall watchdog after going silent. */
125
131
  stalled?: boolean;
126
132
  /** Asked to wrap up before its deadline; the report may be partial. */
@@ -79,6 +79,7 @@ export interface RunLogEntry {
79
79
  startedAt: string;
80
80
  finishedAt?: string;
81
81
  model?: string;
82
+ thinking?: string;
82
83
  turns?: number;
83
84
  tools?: number;
84
85
  input?: number;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pending tasks: plans the user worked out with the planner (optionally from a
3
+ * GitHub issue) and saved for later. They are not bot-lobby tasks yet, so no
4
+ * session owns them and the workflow never sees them; starting one creates a
5
+ * real task in the session that starts it. One file per entry, so two sessions
6
+ * saving at once cannot overwrite each other.
7
+ */
8
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { writeFileEnsured } from "../knowledge/store.ts";
11
+ import { dataRoot } from "./project.ts";
12
+ import { taskSlug } from "./persistence.ts";
13
+
14
+ export interface IssueRef {
15
+ number: number;
16
+ title: string;
17
+ url?: string;
18
+ }
19
+
20
+ export interface PlannedTask {
21
+ id: string;
22
+ title: string;
23
+ /** The agreed plan in Markdown; it becomes the task request when started. */
24
+ brief: string;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ status: "pending" | "started";
28
+ issue?: IssueRef;
29
+ /** The bot-lobby task started from this entry. */
30
+ startedTaskId?: string;
31
+ }
32
+
33
+ export function backlogDir(root: string, configDir: string): string {
34
+ return join(dataRoot(root, configDir), "backlog");
35
+ }
36
+
37
+ function entryPath(root: string, configDir: string, id: string): string {
38
+ return join(backlogDir(root, configDir), `${id}.json`);
39
+ }
40
+
41
+ function isPlannedTask(value: unknown): value is PlannedTask {
42
+ const entry = value as Partial<PlannedTask> | undefined;
43
+ return Boolean(entry && typeof entry.id === "string" && typeof entry.title === "string" && typeof entry.brief === "string");
44
+ }
45
+
46
+ /** Save a new pending task; the id is `PLAN-<slug>` with a numeric suffix when taken. */
47
+ export function savePlannedTask(
48
+ root: string,
49
+ configDir: string,
50
+ input: { title: string; brief: string; issue?: IssueRef },
51
+ now = new Date(),
52
+ ): PlannedTask {
53
+ const title = input.title.trim() || "planned task";
54
+ const brief = input.brief.trim();
55
+ if (!brief) throw new Error("a planned task needs a plan");
56
+ const base = `PLAN-${taskSlug(title, 32) || now.getTime().toString(36)}`;
57
+ let id = base;
58
+ for (let n = 2; existsSync(entryPath(root, configDir, id)); n++) id = `${base}-${n}`;
59
+ const at = now.toISOString();
60
+ const entry: PlannedTask = { id, title, brief, createdAt: at, updatedAt: at, status: "pending", ...(input.issue ? { issue: input.issue } : {}) };
61
+ writeFileEnsured(entryPath(root, configDir, id), JSON.stringify(entry, null, 2));
62
+ return entry;
63
+ }
64
+
65
+ export function loadPlannedTask(root: string, configDir: string, id: string): PlannedTask | undefined {
66
+ try {
67
+ const value = JSON.parse(readFileSync(entryPath(root, configDir, id), "utf8")) as unknown;
68
+ return isPlannedTask(value) ? value : undefined;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /** Every saved entry, pending first, newest first within each group; unreadable files are skipped. */
75
+ export function listPlannedTasks(root: string, configDir: string): PlannedTask[] {
76
+ const dir = backlogDir(root, configDir);
77
+ if (!existsSync(dir)) return [];
78
+ const entries: PlannedTask[] = [];
79
+ for (const file of readdirSync(dir)) {
80
+ if (!file.endsWith(".json")) continue;
81
+ const entry = loadPlannedTask(root, configDir, file.slice(0, -".json".length));
82
+ if (entry) entries.push(entry);
83
+ }
84
+ return entries.sort((a, b) => {
85
+ if (a.status !== b.status) return a.status === "pending" ? -1 : 1;
86
+ return b.updatedAt.localeCompare(a.updatedAt);
87
+ });
88
+ }
89
+
90
+ export function markPlannedTaskStarted(root: string, configDir: string, id: string, taskId: string, now = new Date()): PlannedTask | undefined {
91
+ const entry = loadPlannedTask(root, configDir, id);
92
+ if (!entry) return undefined;
93
+ const next: PlannedTask = { ...entry, status: "started", startedTaskId: taskId, updatedAt: now.toISOString() };
94
+ writeFileEnsured(entryPath(root, configDir, id), JSON.stringify(next, null, 2));
95
+ return next;
96
+ }
97
+
98
+ export function discardPlannedTask(root: string, configDir: string, id: string): void {
99
+ rmSync(entryPath(root, configDir, id), { force: true });
100
+ }
101
+
102
+ /** The request a started task carries: the agreed plan, plus the issue it came from. */
103
+ export function plannedTaskRequest(entry: PlannedTask): string {
104
+ const source = entry.issue ? `\n\nFrom GitHub issue #${entry.issue.number}: ${entry.issue.title}${entry.issue.url ? ` (${entry.issue.url})` : ""}` : "";
105
+ return `${entry.title}\n\nAgreed plan (from the planning session):\n${entry.brief}${source}`;
106
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Plan comments: notes the user leaves on a task's approved plan (or its
3
+ * proposal) from the lobby, in any pi session. They live beside the task as an
4
+ * append-only JSON-lines log, never inside state.json, because the owning
5
+ * session rewrites state.json at the end of every workflow step and would
6
+ * silently drop a comment written by another session in the meantime.
7
+ *
8
+ * Each line is one event: a comment, its delivery to the owning Master, or the
9
+ * Master addressing it with an amended plan. Reading folds the events.
10
+ */
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { taskDir } from "../knowledge/paths.ts";
14
+ import { dataRoot } from "./project.ts";
15
+
16
+ export type CommentStatus = "open" | "delivered" | "addressed";
17
+
18
+ export interface PlanComment {
19
+ id: string;
20
+ taskId: string;
21
+ text: string;
22
+ createdAt: string;
23
+ /** The pi session that wrote the comment, when known. */
24
+ by?: string;
25
+ status: CommentStatus;
26
+ deliveredAt?: string;
27
+ addressedAt?: string;
28
+ }
29
+
30
+ type CommentEvent =
31
+ | { kind: "comment"; id: string; text: string; at: string; by?: string }
32
+ | { kind: "delivered"; id: string; at: string }
33
+ | { kind: "addressed"; id: string; at: string };
34
+
35
+ /** Longest comment kept; the Master gets it verbatim. */
36
+ export const MAX_COMMENT_CHARS = 2000;
37
+
38
+ export function commentsPath(root: string, configDir: string, taskId: string): string {
39
+ return join(taskDir(dataRoot(root, configDir), taskId), "comments.jsonl");
40
+ }
41
+
42
+ function append(path: string, event: CommentEvent): void {
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
45
+ }
46
+
47
+ function readEvents(path: string): CommentEvent[] {
48
+ if (!existsSync(path)) return [];
49
+ let text: string;
50
+ try {
51
+ text = readFileSync(path, "utf8");
52
+ } catch {
53
+ return [];
54
+ }
55
+ const events: CommentEvent[] = [];
56
+ for (const line of text.split("\n")) {
57
+ if (!line.trim()) continue;
58
+ try {
59
+ const event = JSON.parse(line) as CommentEvent;
60
+ if (event && typeof event.id === "string" && typeof event.kind === "string") events.push(event);
61
+ } catch {
62
+ // A torn line from a crashed writer is skipped, never fatal.
63
+ }
64
+ }
65
+ return events;
66
+ }
67
+
68
+ /** Fold the event log into comments, oldest first. */
69
+ export function foldComments(taskId: string, events: readonly CommentEvent[]): PlanComment[] {
70
+ const byId = new Map<string, PlanComment>();
71
+ for (const event of events) {
72
+ if (event.kind === "comment") {
73
+ if (byId.has(event.id)) continue;
74
+ byId.set(event.id, { id: event.id, taskId, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), status: "open" });
75
+ continue;
76
+ }
77
+ const comment = byId.get(event.id);
78
+ if (!comment) continue;
79
+ if (event.kind === "delivered" && comment.status === "open") {
80
+ comment.status = "delivered";
81
+ comment.deliveredAt = event.at;
82
+ } else if (event.kind === "addressed" && comment.status !== "addressed") {
83
+ comment.status = "addressed";
84
+ comment.addressedAt = event.at;
85
+ }
86
+ }
87
+ return [...byId.values()];
88
+ }
89
+
90
+ export function readPlanComments(root: string, configDir: string, taskId: string): PlanComment[] {
91
+ return foldComments(taskId, readEvents(commentsPath(root, configDir, taskId)));
92
+ }
93
+
94
+ /** Record a new comment; blank text is refused. */
95
+ export function addPlanComment(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): PlanComment {
96
+ const body = text.trim().slice(0, MAX_COMMENT_CHARS);
97
+ if (!body) throw new Error("a plan comment needs some text");
98
+ const at = now.toISOString();
99
+ const id = `C-${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
100
+ append(commentsPath(root, configDir, taskId), { kind: "comment", id, text: body, at, ...(by ? { by } : {}) });
101
+ return { id, taskId, text: body, createdAt: at, ...(by ? { by } : {}), status: "open" };
102
+ }
103
+
104
+ export function markCommentsDelivered(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
105
+ const path = commentsPath(root, configDir, taskId);
106
+ for (const id of ids) append(path, { kind: "delivered", id, at: now.toISOString() });
107
+ }
108
+
109
+ export function markCommentsAddressed(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
110
+ const path = commentsPath(root, configDir, taskId);
111
+ for (const id of ids) append(path, { kind: "addressed", id, at: now.toISOString() });
112
+ }
113
+
114
+ /** Comments the owning Master has not been told about yet. */
115
+ export function undeliveredComments(comments: readonly PlanComment[]): PlanComment[] {
116
+ return comments.filter((comment) => comment.status === "open");
117
+ }
118
+
119
+ /** Comments the plan does not reflect yet: new or delivered but not addressed. */
120
+ export function pendingComments(comments: readonly PlanComment[]): PlanComment[] {
121
+ return comments.filter((comment) => comment.status !== "addressed");
122
+ }
123
+
124
+ /**
125
+ * The message the owning Master receives for new comments: plan comments ask
126
+ * for an amended plan, comments before a plan exists ask for a new proposal.
127
+ */
128
+ export function commentMessage(taskId: string, comments: readonly PlanComment[], hasPlan: boolean): string {
129
+ const noun = comments.length === 1 ? "a comment" : `${comments.length} comments`;
130
+ const target = hasPlan ? "the approved plan" : "the proposal";
131
+ const lines = comments.map((comment) => `- ${comment.text.replace(/\s*\n\s*/g, " ")}`);
132
+ const ask = hasPlan
133
+ ? "Amend the plan to address them: call orchestrate action=plan with the full revised plan (it replaces the current one and marks these comments addressed), then continue the work. If a comment needs no change, say why."
134
+ : "Take them into account: revise the proposal and call orchestrate action=propose again.";
135
+ return [`The user left ${noun} on ${target} of ${taskId} from the lobby:`, ...lines, "", ask].join("\n");
136
+ }