@a-t-h-i/bot-lobby 0.3.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.
Files changed (61) hide show
  1. package/README.md +266 -35
  2. package/package.json +1 -1
  3. package/prompts/backend.md +46 -1
  4. package/prompts/designer.md +94 -15
  5. package/prompts/master.md +70 -1
  6. package/prompts/panel.md +39 -0
  7. package/prompts/planner.md +64 -0
  8. package/prompts/qa.md +35 -2
  9. package/prompts/quickfix.md +41 -0
  10. package/prompts/researcher.md +6 -0
  11. package/prompts/reviewer.md +16 -0
  12. package/prompts/scout.md +11 -2
  13. package/prompts/worker.md +35 -2
  14. package/src/desk/client-extension.ts +101 -0
  15. package/src/desk/desk.ts +249 -0
  16. package/src/desk/ipc.ts +178 -0
  17. package/src/desk/session.ts +214 -0
  18. package/src/execution/agent-runner.ts +165 -14
  19. package/src/execution/pi-runner.ts +376 -65
  20. package/src/index.ts +7 -0
  21. package/src/lobby/feed.ts +253 -0
  22. package/src/lobby/issues.ts +227 -0
  23. package/src/lobby/layout.ts +174 -0
  24. package/src/lobby/planner.ts +474 -0
  25. package/src/lobby/quickfix.ts +227 -0
  26. package/src/lobby/runtime.ts +440 -0
  27. package/src/lobby/tabs/home.ts +164 -0
  28. package/src/lobby/tabs/issues.ts +72 -0
  29. package/src/lobby/tabs/metrics.ts +162 -0
  30. package/src/lobby/tabs/plan.ts +160 -0
  31. package/src/lobby/tabs/quickfix.ts +101 -0
  32. package/src/lobby/tabs/tasks.ts +209 -0
  33. package/src/lobby/view.ts +855 -0
  34. package/src/master/master.ts +21 -17
  35. package/src/master/research.ts +8 -7
  36. package/src/pi/activity.ts +165 -0
  37. package/src/pi/commands.ts +46 -59
  38. package/src/pi/events.ts +5 -2
  39. package/src/pi/expressions.ts +43 -12
  40. package/src/pi/kaomoji.ts +227 -0
  41. package/src/pi/mascot-art.ts +5 -15
  42. package/src/pi/model-support.ts +135 -0
  43. package/src/pi/run-summary.ts +172 -0
  44. package/src/pi/settings-ui.ts +162 -60
  45. package/src/pi/start-task.ts +63 -0
  46. package/src/pi/tools.ts +51 -8
  47. package/src/pi/ui.ts +151 -49
  48. package/src/pi/zen-large.ts +41 -4
  49. package/src/pi/zen-metrics.ts +47 -7
  50. package/src/pi/zen.ts +29 -8
  51. package/src/roles/reviewer.ts +24 -4
  52. package/src/roles/worker.ts +7 -1
  53. package/src/schemas/configuration.ts +177 -18
  54. package/src/schemas/findings.ts +26 -0
  55. package/src/schemas/task.ts +27 -1
  56. package/src/state/backlog.ts +106 -0
  57. package/src/state/comments.ts +136 -0
  58. package/src/state/metrics.ts +305 -0
  59. package/src/state/project.ts +9 -0
  60. package/src/text.ts +9 -0
  61. package/src/workflow/workflow.ts +161 -12
@@ -1,13 +1,24 @@
1
+ import type { Domain, Role } from "./agent.ts";
2
+
1
3
  export type ModelRef = "inherit" | string;
2
4
 
3
5
  /** Thinking levels accepted by the pi CLI (`--thinking`). */
4
6
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
5
7
  export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
6
8
 
7
- /** Value meaning "use the model/thinking of the current session". */
9
+ /**
10
+ * A model value meaning "not configured". The master keeps the live session;
11
+ * a subagent falls back to the session's model until settings pin one.
12
+ * Thinking never inherits: every agent runs at the level its settings name.
13
+ */
8
14
  const INHERIT = "inherit";
9
15
  export const INHERIT_MODEL = INHERIT;
16
+ /** Legacy thinking sentinel; configs that still carry it migrate to `DEFAULT_THINKING`. */
10
17
  export const INHERIT_THINKING = INHERIT;
18
+ /** Level used for a missing, legacy `inherit` or unknown thinking value. */
19
+ export const DEFAULT_THINKING: ThinkingLevelName = "medium";
20
+ /** Scouts are reconnaissance: always fast, never configurable. */
21
+ export const SCOUT_THINKING: ThinkingLevelName = "low";
11
22
 
12
23
  export function isThinkingLevel(value: string): value is ThinkingLevelName {
13
24
  return (THINKING_LEVELS as readonly string[]).includes(value);
@@ -18,8 +29,26 @@ export interface AgentModelConfig {
18
29
  thinking: string;
19
30
  /** Free-form instructions layered on top of this agent's built-in prompt. */
20
31
  instructions?: string;
32
+ /** Time limit per run; falls back to `workflow.agentTimeoutMs`. */
33
+ timeoutMs?: number;
34
+ }
35
+
36
+ /** Scouts pick a model and a time limit only; their thinking is fixed at `SCOUT_THINKING`. */
37
+ export interface ScoutConfig {
38
+ model: ModelRef;
39
+ timeoutMs: number;
21
40
  }
22
41
 
42
+ /** Subagent kinds with their own settings entry. */
43
+ export const SUBAGENT_KINDS = ["designer", "backend", "qa", "scout", "researcher", "quickfix", "planner"] as const;
44
+ export type SubagentKind = (typeof SUBAGENT_KINDS)[number];
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
+
23
52
  export interface WorkflowConfig {
24
53
  maxReviewIterations: number;
25
54
  maxParallelScouts: number;
@@ -27,8 +56,16 @@ export interface WorkflowConfig {
27
56
  requireApprovalForDependencies: boolean;
28
57
  requireApprovalForArchitectureChanges: boolean;
29
58
  agentTimeoutMs: number;
30
- /** Bounded retries for transient agent failures (crash/timeout), §59. */
59
+ /** Bounded retries for transient agent failures (crash/stall), §59. A spent deadline never retries. */
31
60
  maxAgentRetries: number;
61
+ /** Kill a subagent after this long without any output; 0 disables. */
62
+ stallTimeoutMs: number;
63
+ /** Silence allowed while a single tool call runs (tests, builds); 0 disables. */
64
+ toolStallTimeoutMs: number;
65
+ /** Fraction of the time limit at which an agent is asked to wrap up and report; 0 disables. */
66
+ wrapUpAt: number;
67
+ /** Workers that may run at once when the Master delegates several domains together. */
68
+ maxParallelWorkers: number;
32
69
  }
33
70
 
34
71
  export interface KnowledgeConfig {
@@ -38,20 +75,47 @@ export interface KnowledgeConfig {
38
75
  scratchpadMaxChars: number;
39
76
  }
40
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
+
41
94
  export interface BotLobbyConfig {
42
95
  master: AgentModelConfig;
43
96
  agents: Record<"designer" | "backend" | "qa", AgentModelConfig>;
97
+ scout: ScoutConfig;
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;
44
103
  workflow: WorkflowConfig;
45
104
  knowledge: KnowledgeConfig;
105
+ lobby: LobbyConfig;
46
106
  }
47
107
 
48
108
  export const DEFAULT_CONFIG: BotLobbyConfig = {
49
109
  master: { model: INHERIT_MODEL, thinking: "high", instructions: "" },
50
110
  agents: {
51
- designer: { model: INHERIT_MODEL, thinking: INHERIT_THINKING, instructions: "" },
52
- backend: { model: INHERIT_MODEL, thinking: INHERIT_THINKING, instructions: "" },
53
- qa: { model: INHERIT_MODEL, thinking: INHERIT_THINKING, instructions: "" },
111
+ designer: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
112
+ backend: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
113
+ qa: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
54
114
  },
115
+ scout: { model: INHERIT_MODEL, timeoutMs: 8 * 60 * 1000 },
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 },
55
119
  workflow: {
56
120
  maxReviewIterations: 2,
57
121
  maxParallelScouts: 3,
@@ -60,6 +124,10 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
60
124
  requireApprovalForArchitectureChanges: true,
61
125
  agentTimeoutMs: 15 * 60 * 1000,
62
126
  maxAgentRetries: 1,
127
+ stallTimeoutMs: 5 * 60 * 1000,
128
+ toolStallTimeoutMs: 10 * 60 * 1000,
129
+ wrapUpAt: 0.75,
130
+ maxParallelWorkers: 3,
63
131
  },
64
132
  knowledge: {
65
133
  compactionThreshold: 20000,
@@ -67,25 +135,39 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
67
135
  scratchpadMaxParagraphs: 4,
68
136
  scratchpadMaxChars: 2000,
69
137
  },
138
+ lobby: { autoOpen: true, planningPanel: [...PANEL_MEMBERS] },
70
139
  };
71
140
 
72
- /** Merge one agent's override over its default, dropping an invalid thinking level but keeping the inherit sentinel. */
141
+ function positive(value: unknown): number | undefined {
142
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
143
+ }
144
+
145
+ /** Merge one agent's override over its default; legacy `inherit` or unknown thinking falls back to the default level. */
73
146
  function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
74
147
  const merged = { ...base, ...(override ?? {}) };
75
- const thinking = merged.thinking === INHERIT_THINKING || isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
76
- return { ...merged, thinking };
148
+ const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
149
+ const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
150
+ return { ...merged, thinking, ...(timeoutMs ? { timeoutMs } : {}) };
151
+ }
152
+
153
+ /** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
154
+ function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
155
+ const base = DEFAULT_CONFIG.scout;
156
+ return {
157
+ model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
158
+ timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
159
+ };
77
160
  }
78
161
 
79
- /** Replace every agent's inherit thinking with the live session level; a missing/invalid level omits the flag. */
80
- export function inheritThinking(config: BotLobbyConfig, level: string | undefined): BotLobbyConfig {
81
- const resolved = level && isThinkingLevel(level) ? level : "";
82
- const agents = Object.fromEntries(
83
- Object.entries(config.agents).map(([name, agent]) => [
84
- name,
85
- agent.thinking === INHERIT_THINKING ? { ...agent, thinking: resolved } : { ...agent },
86
- ]),
87
- ) as BotLobbyConfig["agents"];
88
- return { ...config, agents };
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
+ };
89
171
  }
90
172
 
91
173
  /** Deep-merge user config over defaults, keeping unknown keys out. */
@@ -101,7 +183,84 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
101
183
  backend: normalizeAgent(DEFAULT_CONFIG.agents.backend, srcAgents.backend),
102
184
  qa: normalizeAgent(DEFAULT_CONFIG.agents.qa, srcAgents.qa),
103
185
  },
186
+ scout: normalizeScout(src.scout as Partial<ScoutConfig> | undefined),
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),
104
190
  workflow,
105
191
  knowledge,
192
+ lobby: normalizeLobby(src.lobby),
106
193
  };
107
194
  }
195
+
196
+ /** True when a config file still carries a thinking value for scouts, which is ignored. */
197
+ export function hasScoutThinking(partial: unknown): boolean {
198
+ const scout = (partial as { scout?: Record<string, unknown> } | undefined)?.scout;
199
+ return Boolean(scout && typeof scout === "object" && "thinking" in scout);
200
+ }
201
+
202
+ /** What one subagent run uses: model (undefined = not configured), thinking and time limit. */
203
+ export interface AgentProfile {
204
+ kind: WorkflowProfileKind;
205
+ model?: string;
206
+ thinking: string;
207
+ timeoutMs: number;
208
+ instructions?: string;
209
+ }
210
+
211
+ /** The settings entry a domain/role run draws from. */
212
+ export function profileKind(domain: Domain, role: Role): WorkflowProfileKind {
213
+ if (role === "scout") return "scout";
214
+ if (role === "researcher") return "researcher";
215
+ return domain;
216
+ }
217
+
218
+ function modelOf(ref: ModelRef): string | undefined {
219
+ return ref === INHERIT_MODEL || !ref.trim() ? undefined : ref;
220
+ }
221
+
222
+ /**
223
+ * Resolve model, thinking and time limit for one run from settings alone.
224
+ * Workers and the QA gate use their domain's entry, scouts and researchers
225
+ * their own; custom instructions always come from the domain, so a designer
226
+ * scout still carries the designer's house rules.
227
+ */
228
+ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role): AgentProfile {
229
+ const kind = profileKind(domain, role);
230
+ const instructions = config.agents[domain].instructions;
231
+ const fallback = config.workflow.agentTimeoutMs;
232
+ if (kind === "scout") {
233
+ return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions };
234
+ }
235
+ const entry = kind === "researcher" ? config.researcher : config.agents[kind];
236
+ return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
237
+ }
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
+
260
+ /** Resolves the model, thinking and time limit one subagent run uses. */
261
+ export type ProfileResolver = (domain: Domain, role: Role) => AgentProfile;
262
+
263
+ /** The resolver a request carries, or plain settings when none was supplied (tests, headless use). */
264
+ export function profileFor(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: Role): AgentProfile {
265
+ return resolver ? resolver(domain, role) : agentProfile(config, domain, role);
266
+ }
@@ -67,6 +67,8 @@ export interface ReviewResult {
67
67
  requiredChanges: string[];
68
68
  optionalImprovements: string[];
69
69
  pushback?: Pushback;
70
+ /** Set when the engine downgraded an unsupported PASS. */
71
+ downgraded?: string;
70
72
  raw: string;
71
73
  }
72
74
 
@@ -107,4 +109,28 @@ export interface AgentRun {
107
109
  usage?: { input: number; output: number; cost: number; turns: number };
108
110
  startedAt: string;
109
111
  finishedAt?: string;
112
+ /** Short target of the activity in flight: a file, command head or pattern. */
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;
118
+ /** Assistant turns and tool calls so far. */
119
+ turns?: number;
120
+ tools?: number;
121
+ /** Epoch ms of the last streamed output; drives the "quiet" warning. */
122
+ lastEventAt?: number;
123
+ /** Transient status worth surfacing: retrying, compacting, wrapping up, waiting on a file. */
124
+ note?: string;
125
+ noteKind?: "info" | "warning";
126
+ /** Model that actually served the run. */
127
+ model?: string;
128
+ /** Thinking level the run was started with. */
129
+ thinking?: string;
130
+ /** Killed by the stall watchdog after going silent. */
131
+ stalled?: boolean;
132
+ /** Asked to wrap up before its deadline; the report may be partial. */
133
+ wrappedUp?: boolean;
134
+ /** File this worker is queued for at the file desk. */
135
+ waitingFor?: string;
110
136
  }
@@ -1,4 +1,4 @@
1
- import type { Domain } from "./agent.ts";
1
+ import type { Domain, Role } from "./agent.ts";
2
2
 
3
3
  export const TASK_STATES = [
4
4
  "created",
@@ -70,6 +70,30 @@ export interface WorkerRunRecord {
70
70
  /** Upper bound on persisted worker records; plans cap at 50 steps. */
71
71
  export const MAX_WORKER_RECORDS = 64;
72
72
 
73
+ /** One finished subagent run of any role, kept for `/bot-lobby runs`. */
74
+ export interface RunLogEntry {
75
+ runId: string;
76
+ domain: Domain;
77
+ role: Role;
78
+ status: "running" | "success" | "failed" | "cancelled" | "timeout";
79
+ startedAt: string;
80
+ finishedAt?: string;
81
+ model?: string;
82
+ thinking?: string;
83
+ turns?: number;
84
+ tools?: number;
85
+ input?: number;
86
+ output?: number;
87
+ cost?: number;
88
+ attempts?: number;
89
+ stalled?: boolean;
90
+ wrappedUp?: boolean;
91
+ error?: string;
92
+ }
93
+
94
+ /** Upper bound on persisted run-log entries. */
95
+ export const MAX_RUN_LOG = 64;
96
+
73
97
  export interface Task {
74
98
  id: string;
75
99
  title: string;
@@ -89,6 +113,8 @@ export interface Task {
89
113
  approvals: Approval[];
90
114
  /** Worker delegations in start order; absent on tasks created before tracking. */
91
115
  workerRuns?: WorkerRunRecord[];
116
+ /** Recent finished runs of every role, newest last. */
117
+ runLog?: RunLogEntry[];
92
118
  createdAt: string;
93
119
  updatedAt: string;
94
120
  /** The pi session (ctx.sessionManager id) that owns this task; absent on legacy tasks. */
@@ -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
+ }