@a-t-h-i/bot-lobby 0.6.26 → 0.6.28

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 (48) hide show
  1. package/README.md +36 -11
  2. package/package.json +1 -1
  3. package/prompts/master.md +24 -3
  4. package/prompts/qa.md +139 -77
  5. package/prompts/reviewer.md +4 -0
  6. package/src/classifier/qa-risk.ts +537 -0
  7. package/src/lobby/host.ts +19 -0
  8. package/src/lobby/planner.ts +145 -0
  9. package/src/lobby/quickfix.ts +40 -1
  10. package/src/lobby/recovery.ts +415 -0
  11. package/src/lobby/resume.ts +66 -0
  12. package/src/lobby/runtime.ts +80 -7
  13. package/src/lobby/service.ts +196 -11
  14. package/src/lobby/sessions.ts +13 -3
  15. package/src/lobby/task-rows.ts +2 -0
  16. package/src/master/decisions.ts +5 -2
  17. package/src/master/master.ts +6 -0
  18. package/src/pi/commands.ts +38 -2
  19. package/src/pi/events.ts +10 -6
  20. package/src/pi/start-task.ts +31 -4
  21. package/src/pi/switch.ts +7 -2
  22. package/src/pi/tools.ts +3 -0
  23. package/src/pi/ui.ts +9 -2
  24. package/src/schemas/configuration.ts +2 -2
  25. package/src/schemas/task.ts +32 -0
  26. package/src/state/backlog.ts +19 -3
  27. package/src/state/plan-history.ts +142 -0
  28. package/src/state/recovery.ts +170 -0
  29. package/src/state/themes.ts +127 -0
  30. package/src/webui/api/index.ts +24 -2
  31. package/src/webui/api/planner.ts +47 -1
  32. package/src/webui/api/status.ts +2 -0
  33. package/src/webui/api/tasks.ts +36 -5
  34. package/src/webui/api/themes.ts +37 -0
  35. package/src/webui/command.ts +26 -1
  36. package/src/webui/dev/fake-service.ts +60 -0
  37. package/src/webui/dev/fixtures.ts +4 -1
  38. package/src/webui/palette-core.ts +260 -0
  39. package/src/webui/protocol.ts +59 -0
  40. package/src/webui/server.ts +10 -0
  41. package/src/workflow/track.ts +46 -7
  42. package/src/workflow/workflow.ts +196 -34
  43. package/webui/dist/assets/index-BzCxEueR.css +2 -0
  44. package/webui/dist/assets/index-qisGNoCh.js +108 -0
  45. package/webui/dist/build.json +51 -35
  46. package/webui/dist/index.html +2 -2
  47. package/webui/dist/assets/index-C1IwecNS.css +0 -2
  48. package/webui/dist/assets/index-C4Ww36TW.js +0 -105
@@ -12,6 +12,7 @@ import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby
12
12
  import type { PlanComment } from "../../state/comments.ts";
13
13
  import type { LobbyService } from "../../lobby/host.ts";
14
14
  import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
15
+ import type { PlanRecord, PlanSummary } from "../../state/plan-history.ts";
15
16
 
16
17
  interface StreamState {
17
18
  step?: ReturnType<typeof setTimeout>;
@@ -264,6 +265,7 @@ function fakeBackground(entry: NonNullable<ScenarioFixture["backgroundSessions"]
264
265
  },
265
266
  ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
266
267
  ...(entry.planId ? { planId: entry.planId } : {}),
268
+ ...(entry.taskId ? { taskId: entry.taskId } : {}),
267
269
  feed,
268
270
  dialogs,
269
271
  get alive() {
@@ -528,6 +530,24 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
528
530
  },
529
531
  sendToTask: (taskId: string, _text: string) => (tasks.some((task) => (task as { id: string }).id === taskId) ? `sent — the session driving ${taskId} passes it to its oracle` : `no task ${taskId}`),
530
532
  sendToSession: (_sessionId: string, _text: string) => "sent — that session passes it to its oracle within a few seconds",
533
+ resumeTask: async (taskId: string) => {
534
+ const task = tasks.find((entry) => (entry as { id: string }).id === taskId) as { id: string; title: string; paused?: boolean; ownerSessionId?: string } | undefined;
535
+ if (!task) return { notice: `no task ${taskId}` };
536
+ const owner = task.ownerSessionId;
537
+ const background = backgrounds.find((session) => session.alive && ((owner && session.sessionId === owner) || session.taskId === taskId));
538
+ const elsewhere = Boolean(owner && (fixture.liveSessions ?? []).some((session) => session.sessionId === owner));
539
+ lobbyTopics.bump("tasks");
540
+ if (owner === fixture.status.sessionId || background || elsewhere) {
541
+ task.paused = false;
542
+ const where = background ? `in ${background.name}` : elsewhere ? "— the session driving it in another terminal carries on" : "in this window";
543
+ return { notice: `resumed ${taskId} ${where}`, ...(background ? { key: background.key } : {}) };
544
+ }
545
+ task.paused = false;
546
+ const session = fakeBackground({ key: `S${backgrounds.length + 1}`, name: task.title, status: "starting", taskId });
547
+ backgrounds.push(session);
548
+ lobbyTopics.bump("sessions");
549
+ return { notice: `resumed ${taskId} in a background session`, key: session.key };
550
+ },
531
551
  sessions: () => [...backgrounds],
532
552
  startSession: (start: { request?: string; plan?: { id: string; title: string }; auto?: boolean }) => {
533
553
  const key = `S${backgrounds.length + 1}`;
@@ -537,6 +557,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
537
557
  },
538
558
  liveSessions: () => [...(fixture.liveSessions ?? [])],
539
559
  planner: () => planning,
560
+ ...previousPlansFake(fixture.mockPreviousPlans ?? []),
540
561
  newPlanner: (seed?: { issue: { number: number; title: string; url?: string }; body: string }, seats?: string[]) => {
541
562
  planning = fakePlanner({ ...defaultPlanner(), ...(seed ? { messages: [{ role: "you", text: seed.body, at: Date.now() }] } : {}), ...(seats ? { seats } : {}) });
542
563
  return planning;
@@ -571,3 +592,42 @@ export function disposeFixtureService(service: LobbyService): void {
571
592
  for (const id of opened.get(service) ?? []) promptHub.dismiss(id);
572
593
  opened.delete(service);
573
594
  }
595
+
596
+ /** Previous plans held in memory: listed, read, archived, deleted and carried on as the page asks. */
597
+ function previousPlansFake(seed: NonNullable<ScenarioFixture["mockPreviousPlans"]>) {
598
+ const records = seed.map((plan) => ({ ...plan }))
599
+ const find = (id: string) => {
600
+ const record = records.find((entry) => entry.id === id && !entry.savedAs?.length)
601
+ if (!record) throw new Error(`no plan ${id}`)
602
+ return record
603
+ }
604
+ const asRecord = (plan: (typeof records)[number]) => ({
605
+ id: plan.id,
606
+ title: plan.title,
607
+ createdAt: plan.createdAt,
608
+ updatedAt: plan.updatedAt,
609
+ ...(plan.archivedAt ? { archivedAt: plan.archivedAt } : {}),
610
+ snapshot: { version: 1, messages: plan.messages, ...(plan.draft ? { reply: { status: "grilling", questions: [], plan: plan.draft } } : {}), questions: [], notes: plan.notes ?? [], members: [], seats: ["backend", "designer", "qa", "researcher"], pins: [], satOut: [], lastStatus: [], sat: [], memberNotes: [], turns: plan.rounds ?? 1, mode: "normal", answered: [], lineComments: [], savedParts: [], autoContinued: false, attempts: 1, running: false },
611
+ }) as unknown as PlanRecord
612
+ return {
613
+ previousPlans: (archived: boolean): PlanSummary[] => records
614
+ .filter((plan) => !plan.savedAs?.length && Boolean(plan.archivedAt) === archived)
615
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
616
+ .map((plan) => ({ id: plan.id, title: plan.title, createdAt: plan.createdAt, updatedAt: plan.updatedAt, ...(plan.archivedAt ? { archivedAt: plan.archivedAt } : {}), messages: plan.messages.length, rounds: plan.rounds ?? 1, hasDraft: Boolean(plan.draft) })),
617
+ previousPlan: (id: string) => asRecord(find(id)),
618
+ archivePlan: (id: string, archived: boolean) => {
619
+ const plan = find(id)
620
+ if (archived) plan.archivedAt = new Date().toISOString()
621
+ else delete plan.archivedAt
622
+ lobbyTopics.bump("planner")
623
+ return archived ? `archived "${plan.title}" — show archived plans to bring it back` : `"${plan.title}" is back among the previous plans`
624
+ },
625
+ deletePlan: (id: string) => {
626
+ const plan = find(id)
627
+ records.splice(records.indexOf(plan), 1)
628
+ lobbyTopics.bump("planner")
629
+ return `deleted "${plan.title}" for good`
630
+ },
631
+ reopenPlan: (id: string) => `carrying on "${find(id).title}"`,
632
+ }
633
+ }
@@ -75,9 +75,11 @@ export interface ScenarioFixture {
75
75
  /** Comments by task id for `tasks.comments`. */
76
76
  taskComments?: Record<string, Array<Record<string, unknown>>>;
77
77
  /** Background sessions for `sessions.list` (plain data, made live below). */
78
- backgroundSessions?: Array<{ key: string; name: string; status: string; sessionId?: string; planId?: string; dialogs?: Array<Record<string, unknown>> }>;
78
+ backgroundSessions?: Array<{ key: string; name: string; status: string; sessionId?: string; planId?: string; taskId?: string; dialogs?: Array<Record<string, unknown>> }>;
79
79
  /** Live sessions in other terminals for `sessions.list`. */
80
80
  liveSessions?: Array<{ sessionId: string; pid: number; name?: string; taskId?: string; mode: string }>;
81
+ /** Previous plans (planning sessions never saved as a task) for `planner.previous*`. */
82
+ mockPreviousPlans?: Array<{ id: string; title: string; createdAt: string; updatedAt: string; archivedAt?: string; savedAs?: string[]; draft?: string; rounds?: number; messages: Array<Record<string, unknown>>; notes?: Array<Record<string, unknown>> }>;
81
83
  /** The planning session for the `planner.*` calls (absent means none started). */
82
84
  mockPlanner?: {
83
85
  seats: string[];
@@ -164,6 +166,7 @@ export function loadScenario(name: string | undefined): ScenarioFixture {
164
166
  autoTasks: Array.isArray(parsed.autoTasks) ? (parsed.autoTasks as string[]) : [],
165
167
  backgroundSessions: Array.isArray(parsed.backgroundSessions) ? (parsed.backgroundSessions as ScenarioFixture["backgroundSessions"]) : [],
166
168
  liveSessions: Array.isArray(parsed.liveSessions) ? (parsed.liveSessions as ScenarioFixture["liveSessions"]) : [],
169
+ mockPreviousPlans: Array.isArray(parsed.mockPreviousPlans) ? (parsed.mockPreviousPlans as ScenarioFixture["mockPreviousPlans"]) : [],
167
170
  mockPlanner: isRecord(parsed.mockPlanner) ? (parsed.mockPlanner as unknown as ScenarioFixture["mockPlanner"]) : undefined,
168
171
  mockQuickfix: Array.isArray(parsed.mockQuickfix) ? (parsed.mockQuickfix as ScenarioFixture["mockQuickfix"]) : [],
169
172
  mockMetrics: Array.isArray(parsed.mockMetrics) ? (parsed.mockMetrics as ScenarioFixture["mockMetrics"]) : [],
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Colour themes, as plain data and pure functions (no DOM, so they can be
3
+ * unit-tested). A theme is two maps of shadcn variables, one for light and one
4
+ * for dark: exactly what tweakcn exports. A few themes come with the page; the
5
+ * user can paste or upload another from tweakcn (its `:root { … } .dark { … }`
6
+ * CSS, or its registry JSON).
7
+ *
8
+ * An imported file is never injected as it is. Only variables from an allow
9
+ * list are kept, and a value is kept only when it is made of colour, length
10
+ * and font-name characters (no `url()`, no `;`, no braces), so a theme can
11
+ * recolour the page but cannot load anything or break out of its rule.
12
+ */
13
+
14
+ export type Vars = Record<string, string>
15
+
16
+ export interface Palette {
17
+ id: string
18
+ name: string
19
+ /** Overrides for the light page; absent means the page's own colours. */
20
+ light?: Vars
21
+ /** Overrides for the dark page. */
22
+ dark?: Vars
23
+ }
24
+
25
+ /** The variables a theme may set, as tweakcn names them. Radius and shadows are ignored: the page keeps one calm 8px corner. */
26
+ const COLOUR_VARS = [
27
+ "background",
28
+ "foreground",
29
+ "card",
30
+ "card-foreground",
31
+ "popover",
32
+ "popover-foreground",
33
+ "primary",
34
+ "primary-foreground",
35
+ "secondary",
36
+ "secondary-foreground",
37
+ "muted",
38
+ "muted-foreground",
39
+ "accent",
40
+ "accent-foreground",
41
+ "destructive",
42
+ "border",
43
+ "input",
44
+ "ring",
45
+ "chart-1",
46
+ "chart-2",
47
+ "chart-3",
48
+ "chart-4",
49
+ "chart-5",
50
+ ] as const
51
+
52
+ const FONT_VARS = ["font-sans", "font-mono"] as const
53
+
54
+ const ALLOWED = new Set<string>([...COLOUR_VARS, ...FONT_VARS])
55
+
56
+ /** What a colour or length may be made of. */
57
+ const SAFE_VALUE = /^[\w\s#%.,()/+*'"-]{1,300}$/
58
+ const BAD_VALUE = /url\s*\(|expression|import|javascript|\\|<|>/i
59
+
60
+ export const MAX_THEME_BYTES = 200_000
61
+
62
+ /** `oklch(0.6 0.2 250)`, `#fff`, `hsl(…)`, `var(--x)`, a font stack: kept; anything else: dropped. */
63
+ export function safeValue(raw: string): string | undefined {
64
+ const value = raw.replace(/!important/gi, "").trim()
65
+ if (!value || !SAFE_VALUE.test(value) || BAD_VALUE.test(value)) return undefined
66
+ return value
67
+ }
68
+
69
+ function keep(name: string, raw: string, into: Vars): void {
70
+ const key = name.replace(/^--/, "").toLowerCase()
71
+ if (!ALLOWED.has(key)) return
72
+ const value = safeValue(raw)
73
+ if (value) into[key] = value
74
+ }
75
+
76
+ function declarations(block: string): Vars {
77
+ const found: Vars = {}
78
+ for (const match of block.matchAll(/--([a-z0-9-]+)\s*:\s*([^;}]+)/gi)) keep(match[1]!, match[2]!, found)
79
+ return found
80
+ }
81
+
82
+ /** A stored theme read back with the same filter as an import (the store is the user's to edit). */
83
+ export function cleanVars(map: unknown): Vars | undefined {
84
+ if (!map || typeof map !== "object") return undefined
85
+ const out: Vars = {}
86
+ for (const [key, value] of Object.entries(map as Record<string, unknown>)) if (typeof value === "string") keep(key, value, out)
87
+ return Object.keys(out).length > 0 ? out : undefined
88
+ }
89
+
90
+ export interface Parsed {
91
+ light?: Vars
92
+ dark?: Vars
93
+ name?: string
94
+ }
95
+
96
+ /** A theme from tweakcn's CSS (`:root {…} .dark {…}`) or its registry JSON (`{ "cssVars": { "light": {…}, "dark": {…} } }`). */
97
+ export function parseTheme(source: string): Parsed | { error: string } {
98
+ const text = source.trim()
99
+ if (!text) return { error: "Paste a theme first." }
100
+ if (text.length > MAX_THEME_BYTES) return { error: "That file is too large to be a theme." }
101
+ let light: Vars = {}
102
+ let dark: Vars = {}
103
+ let name: string | undefined
104
+ if (text.startsWith("{")) {
105
+ let json: unknown
106
+ try {
107
+ json = JSON.parse(text)
108
+ } catch {
109
+ return { error: "That is not valid JSON or CSS." }
110
+ }
111
+ const record = json as { name?: unknown; title?: unknown; cssVars?: { theme?: Record<string, unknown>; light?: Record<string, unknown>; dark?: Record<string, unknown> } }
112
+ const vars = record.cssVars
113
+ if (!vars || typeof vars !== "object") return { error: "That JSON has no cssVars. Copy the theme's CSS from tweakcn instead." }
114
+ const read = (map: Record<string, unknown> | undefined): Vars => {
115
+ const out: Vars = {}
116
+ for (const [key, value] of Object.entries(map ?? {})) if (typeof value === "string") keep(key, value, out)
117
+ return out
118
+ }
119
+ light = { ...read(vars.theme), ...read(vars.light) }
120
+ dark = { ...read(vars.theme), ...read(vars.dark) }
121
+ name = typeof record.title === "string" ? record.title : typeof record.name === "string" ? record.name : undefined
122
+ } else {
123
+ // Only the plain `:root` and `.dark` rules; `@theme inline` and the rest are the page's own business.
124
+ for (const match of text.matchAll(/(?<=^|[}\s])((?::root|html|\.dark|:root\.dark|html\.dark)(?:\s*,\s*[^{]+)?)\s*\{([^}]*)\}/g)) {
125
+ const selector = match[1]!.trim()
126
+ const vars = declarations(match[2]!)
127
+ if (/\.dark/.test(selector)) dark = { ...dark, ...vars }
128
+ else light = { ...light, ...vars }
129
+ }
130
+ }
131
+ const count = (vars: Vars) => Object.keys(vars).filter((key) => (COLOUR_VARS as readonly string[]).includes(key)).length
132
+ if (count(light) < 5 && count(dark) < 5) return { error: "No theme colours found. Copy the code from tweakcn's Code panel (the :root and .dark blocks)." }
133
+ return {
134
+ ...(count(light) >= 5 ? { light } : {}),
135
+ ...(count(dark) >= 5 ? { dark } : {}),
136
+ ...(name ? { name } : {}),
137
+ }
138
+ }
139
+
140
+ /** Page variables a theme does not name are worked out from the ones it does, so a theme from tweakcn is whole. */
141
+ const DERIVED: Vars = {
142
+ "glass-border": "var(--border)",
143
+ typing: "var(--ring)",
144
+ "tab-active": "color-mix(in oklab, var(--primary) 14%, var(--background))",
145
+ you: "color-mix(in oklab, var(--primary) 9%, var(--card))",
146
+ heading: "var(--foreground)",
147
+ link: "var(--primary)",
148
+ bullet: "var(--primary)",
149
+ }
150
+
151
+ const SANS_FALLBACK = `"Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
152
+ const MONO_FALLBACK = `ui-monospace, "JetBrains Mono", "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace`
153
+
154
+ function rule(selector: string, vars: Vars): string {
155
+ const lines: string[] = []
156
+ for (const [key, value] of Object.entries(vars)) {
157
+ if (key === "font-sans") lines.push(`--app-font-sans:${value},${SANS_FALLBACK};`)
158
+ else if (key === "font-mono") lines.push(`--app-font-mono:${value},${MONO_FALLBACK};`)
159
+ else lines.push(`--${key}:${value};`)
160
+ }
161
+ for (const [key, value] of Object.entries(DERIVED)) if (!(key in vars)) lines.push(`--${key}:${value};`)
162
+ return `${selector}{${lines.join("")}}`
163
+ }
164
+
165
+ /** The style sheet for a theme, or `""` for the page's own colours. */
166
+ export function paletteCss(palette: Palette): string {
167
+ const parts: string[] = []
168
+ if (palette.light) parts.push(rule(":root", palette.light))
169
+ if (palette.dark) parts.push(rule(".dark", palette.dark))
170
+ return parts.join("\n")
171
+ }
172
+
173
+ /** A palette from one hue, in oklch: a tinted mist and a tinted near-black, the hue as the accent. Chroma 0 is a neutral theme. */
174
+ function tinted(id: string, name: string, hue: number, chroma: number, lift = 0): Palette {
175
+ const h = hue
176
+ const c = chroma
177
+ const n = (value: number) => Number(value.toFixed(3))
178
+ const light: Vars = {
179
+ background: `oklch(0.975 ${n(c * 0.1)} ${h})`,
180
+ foreground: `oklch(0.22 ${n(c * 0.25)} ${h})`,
181
+ card: "oklch(1 0 0)",
182
+ "card-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
183
+ popover: "oklch(1 0 0 / 0.92)",
184
+ "popover-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
185
+ primary: `oklch(${n(0.5 + lift)} ${c} ${h})`,
186
+ "primary-foreground": "oklch(0.99 0 0)",
187
+ secondary: `oklch(0.3 ${n(c * 0.3)} ${h} / 0.06)`,
188
+ "secondary-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
189
+ muted: `oklch(0.3 ${n(c * 0.3)} ${h} / 0.05)`,
190
+ "muted-foreground": `oklch(0.5 ${n(c * 0.2)} ${h})`,
191
+ accent: `oklch(${n(0.5 + lift)} ${c} ${h} / 0.1)`,
192
+ "accent-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
193
+ destructive: "oklch(0.52 0.19 28)",
194
+ border: `oklch(0.92 ${n(c * 0.08)} ${h})`,
195
+ input: `oklch(0.87 ${n(c * 0.1)} ${h})`,
196
+ ring: `oklch(${n(0.6 + lift)} ${n(c * 0.9)} ${h})`,
197
+ "chart-1": `oklch(${n(0.5 + lift)} ${c} ${h})`,
198
+ "chart-2": "oklch(0.52 0.12 160)",
199
+ "chart-3": "oklch(0.55 0.1 200)",
200
+ "chart-4": "oklch(0.55 0.15 340)",
201
+ "chart-5": "oklch(0.6 0.13 75)",
202
+ }
203
+ const dark: Vars = {
204
+ background: `oklch(0.145 ${n(c * 0.08)} ${h})`,
205
+ foreground: `oklch(0.93 ${n(c * 0.05)} ${h})`,
206
+ card: `oklch(0.18 ${n(c * 0.09)} ${h})`,
207
+ "card-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
208
+ popover: `oklch(0.18 ${n(c * 0.09)} ${h} / 0.94)`,
209
+ "popover-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
210
+ primary: `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
211
+ "primary-foreground": `oklch(0.18 ${n(c * 0.4)} ${h})`,
212
+ secondary: "oklch(1 0 0 / 0.045)",
213
+ "secondary-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
214
+ muted: "oklch(1 0 0 / 0.04)",
215
+ "muted-foreground": `oklch(0.7 ${n(c * 0.15)} ${h})`,
216
+ accent: `oklch(0.76 ${n(c * 0.85)} ${h} / 0.14)`,
217
+ "accent-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
218
+ destructive: "oklch(0.74 0.12 25)",
219
+ border: `oklch(0.25 ${n(c * 0.1)} ${h})`,
220
+ input: `oklch(0.3 ${n(c * 0.1)} ${h})`,
221
+ ring: `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
222
+ "chart-1": `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
223
+ "chart-2": "oklch(0.78 0.11 160)",
224
+ "chart-3": "oklch(0.8 0.1 195)",
225
+ "chart-4": "oklch(0.78 0.11 340)",
226
+ "chart-5": "oklch(0.8 0.12 80)",
227
+ }
228
+ return { id, name, light, dark }
229
+ }
230
+
231
+ export const DEFAULT_ID = "mist"
232
+
233
+ /** The themes that come with the page; Mist is the page's own colours and sets nothing. */
234
+ export const PRESETS: readonly Palette[] = [
235
+ { id: DEFAULT_ID, name: "Mist" },
236
+ tinted("forest", "Forest", 155, 0.12),
237
+ tinted("ocean", "Ocean", 225, 0.12),
238
+ tinted("violet", "Violet", 305, 0.16),
239
+ tinted("rose", "Rose", 5, 0.16),
240
+ tinted("amber", "Amber", 70, 0.14, 0.06),
241
+ tinted("mono", "Mono", 260, 0),
242
+ ]
243
+
244
+ /** Colours for a preview swatch, per mode: Mist's own values by hand, the rest from the theme. */
245
+ const MIST_SWATCH = {
246
+ light: { background: "#f4f5f9", card: "#ffffff", primary: "#4650d1", accent: "#e6e9fb" },
247
+ dark: { background: "#08090c", card: "#0e1015", primary: "#8f9bff", accent: "#151a33" },
248
+ } as const
249
+
250
+ export function swatch(palette: Palette, dark: boolean): { background: string; card: string; primary: string; accent: string } {
251
+ const vars = dark ? palette.dark : palette.light
252
+ const base = MIST_SWATCH[dark ? "dark" : "light"]
253
+ if (!vars) return { ...base }
254
+ return {
255
+ background: vars.background ?? base.background,
256
+ card: vars.card ?? base.card,
257
+ primary: vars.primary ?? base.primary,
258
+ accent: vars.primary ? `color-mix(in oklab, ${vars.primary} 18%, ${vars.background ?? base.background})` : base.accent,
259
+ }
260
+ }
@@ -12,6 +12,7 @@ import type { LiveSession } from "../lobby/host.ts";
12
12
  import type { TaskRow } from "../lobby/task-rows.ts";
13
13
  import type { PlanComment } from "../state/comments.ts";
14
14
  import type { BotLobbyConfig, PanelMember } from "../schemas/configuration.ts";
15
+ import type { QaRiskAssessment } from "../schemas/task.ts";
15
16
  import type { MemberState, PanelNote, PanelQuestion, PlannerMessage } from "../lobby/planner.ts";
16
17
  import type { QuickFixJob } from "../lobby/quickfix.ts";
17
18
 
@@ -80,6 +81,21 @@ export interface KeyInfo {
80
81
  }
81
82
 
82
83
  /** `status.get`: everything the page shell needs before it reads a tab. */
84
+ /** A colour theme the user saved, kept for every pi session (`themes.json`). */
85
+ export interface SavedThemeInfo {
86
+ id: string;
87
+ name: string;
88
+ light?: Record<string, string>;
89
+ dark?: Record<string, string>;
90
+ savedAt: string;
91
+ }
92
+
93
+ /** The saved themes and the chosen one. */
94
+ export interface ThemeStoreInfo {
95
+ themes: SavedThemeInfo[];
96
+ active?: string;
97
+ }
98
+
83
99
  export interface StatusInfo {
84
100
  workspace: { name: string; branch?: string };
85
101
  branch?: string;
@@ -93,6 +109,8 @@ export interface StatusInfo {
93
109
  tabs: TabInfo[];
94
110
  keys: KeyInfo[];
95
111
  windows: Array<{ name: string; url: string }>;
112
+ /** What this window carried on after pi stopped unexpectedly: the page says so once. */
113
+ recovered?: { at: number; text: string };
96
114
  }
97
115
 
98
116
  /** Agent work time as of the reply: the page adds the time that passes while `running`. */
@@ -215,6 +233,34 @@ export interface PlannerSnapshot {
215
233
  busy: boolean;
216
234
  }
217
235
 
236
+ /** A previous plan as a list shows it: a planning session never saved as a task. */
237
+ export interface PreviousPlanInfo {
238
+ id: string;
239
+ title: string;
240
+ createdAt: string;
241
+ updatedAt: string;
242
+ archivedAt?: string;
243
+ messages: number;
244
+ rounds: number;
245
+ hasDraft: boolean;
246
+ }
247
+
248
+ /** A previous plan, whole, to read. */
249
+ export interface PreviousPlanDetail {
250
+ id: string;
251
+ title: string;
252
+ createdAt: string;
253
+ updatedAt: string;
254
+ archivedAt?: string;
255
+ rounds: number;
256
+ seats: PanelMember[];
257
+ messages: PlannerMessage[];
258
+ draft?: string;
259
+ notes: PanelNote[];
260
+ /** Where it started, when it came from an issue. */
261
+ seed?: string;
262
+ }
263
+
218
264
  /** One issue a planning session can start from. */
219
265
  export interface PlannerSeedInfo {
220
266
  issue: { number: number; title: string; url?: string };
@@ -381,6 +427,8 @@ export interface TaskDetail {
381
427
  blockers: Array<{ reason: string; need: string }>;
382
428
  /** The last six runs, one line each (`✓ DEV worker · 3m 12s · …`). */
383
429
  runs: string[];
430
+ /** Jev's last read of what the built change warrants of QA: level, test budget, focus and evidence. */
431
+ qaRisk?: QaRiskAssessment;
384
432
  }
385
433
 
386
434
  /** `plans.get`: one saved plan. */
@@ -549,6 +597,7 @@ export interface Api {
549
597
  "tasks.message": { request: { taskId: string; text: string; attachments?: string[] }; result: { notice: string } };
550
598
  "tasks.get": { request: { taskId: string }; result: TaskDetail };
551
599
  "tasks.open": { request: { taskId: string }; result: { sessionId?: string; key?: string; notice?: string } };
600
+ "tasks.resume": { request: { taskId: string }; result: { notice: string; key?: string } };
552
601
  "tasks.deliveryReview": { request: { taskId: string }; result: { delivery: Delivery } };
553
602
  "tasks.deliveryDefer": { request: { taskId: string; reviewId: string }; result: { delivery: Delivery } };
554
603
  "tasks.deliver": { request: { taskId: string; reviewId: string; action: DeliveryAction; confirmMain?: boolean }; result: { delivery: Delivery } };
@@ -573,6 +622,11 @@ export interface Api {
573
622
  "planner.retry": { request: Record<string, never>; result: { notice: string } };
574
623
  "planner.commentLine": { request: { line: string; text: string }; result: { notice: string } };
575
624
  "planner.answer": { request: Record<string, never>; result: { notice: string } };
625
+ "planner.previous": { request: { archived?: boolean }; result: { plans: PreviousPlanInfo[] } };
626
+ "planner.previousGet": { request: { id: string }; result: PreviousPlanDetail };
627
+ "planner.previousArchive": { request: { id: string; archived: boolean }; result: { notice: string } };
628
+ "planner.previousDelete": { request: { id: string }; result: { notice: string } };
629
+ "planner.previousOpen": { request: { id: string }; result: { notice: string } };
576
630
  "planner.save": { request: Record<string, never>; result: { notice: string } };
577
631
  "quickfix.list": { request: Record<string, never>; result: { jobs: QuickFixJob[] } };
578
632
  "quickfix.submit": { request: { text: string; attachments?: string[] }; result: { notice: string; id: string } };
@@ -609,6 +663,11 @@ export interface Api {
609
663
  "settings.get": { request: Record<string, never>; result: SettingsInfo };
610
664
  "settings.set": { request: { patch: Record<string, unknown> }; result: { config: BotLobbyConfig } };
611
665
  "settings.linters": { request: Record<string, never>; result: { linters: SettingsLinter[] } };
666
+ "themes.get": { request: Record<string, never>; result: ThemeStoreInfo };
667
+ "themes.save": { request: { name: string; light?: Record<string, string>; dark?: Record<string, string> }; result: ThemeStoreInfo & { id: string } };
668
+ "themes.rename": { request: { id: string; name: string }; result: ThemeStoreInfo };
669
+ "themes.remove": { request: { id: string }; result: ThemeStoreInfo };
670
+ "themes.choose": { request: { id: string }; result: ThemeStoreInfo };
612
671
  }
613
672
 
614
673
  export type ApiName = keyof Api;
@@ -31,6 +31,14 @@ import { EventHub } from "./events.ts";
31
31
  import { servePreview } from "./files.ts";
32
32
  import { MAX_UPLOAD_BYTES, saveUpload } from "./uploads.ts";
33
33
 
34
+ /** What a call made while pi switches sessions is told; the page reads again once the new session is up. */
35
+ export const SWITCHING = "pi is switching sessions — the page catches up in a moment";
36
+
37
+ /** pi's refusal of a context from a session it has replaced or reloaded. */
38
+ export function staleSession(error: unknown): boolean {
39
+ return /ctx is stale after session replacement or reload/i.test((error as Error)?.message ?? "");
40
+ }
41
+
34
42
  /** The default port; the server tries it, then the next free port up to +20. */
35
43
  export const DEFAULT_PORT = 7347;
36
44
  /** How many ports past the base are tried. */
@@ -320,6 +328,8 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
320
328
  return;
321
329
  }
322
330
  if (error instanceof HttpError) sendError(res, error.code, error.message, error.status);
331
+ // Between pi replacing the session and the lobby following it, the old session answers nothing.
332
+ else if (staleSession(error)) sendError(res, "failed", SWITCHING, 503);
323
333
  else {
324
334
  lobbyFeed.log("LOBBY", `the web server could not do that — ${error.message}`, "error");
325
335
  sendError(res, "failed", "the lobby could not do that", 500);
@@ -276,6 +276,8 @@ export interface TrackOptions {
276
276
  forced?: TrackPath;
277
277
  /** The task follows a plan agreed in the planning panel. */
278
278
  approvedPlan?: boolean;
279
+ /** That panel's seats that took part; QA not among them sat the plan out, and the task runs without it. Absent: not known (an older plan). */
280
+ planPanel?: readonly string[];
279
281
  }
280
282
 
281
283
  /**
@@ -311,8 +313,10 @@ export function chooseTrack(request: string, triage: TaskTriage | undefined, opt
311
313
  : !options.fastTrack
312
314
  ? ["full", ["the fast track is off in settings"], source]
313
315
  : [fast ? "fast" : "full", [], source];
314
- // The full workflow always ends with the QA gate.
315
- if (path === "full") roster.add("qa");
316
+ // An agreed plan's panel decides who works on it; otherwise the full workflow always ends with the QA gate.
317
+ const panel = options.approvedPlan ? options.planPanel : undefined;
318
+ const benched = panel ? seatPlanPanel(roster, panel, why) : [];
319
+ if (!panel && path === "full") roster.add("qa");
316
320
  return {
317
321
  path,
318
322
  size,
@@ -320,10 +324,36 @@ export function chooseTrack(request: string, triage: TaskTriage | undefined, opt
320
324
  reasons: [...why, ...reasons],
321
325
  source: by,
322
326
  ...(options.forced ? { userChoice: options.forced } : {}),
327
+ ...(benched.length > 0 ? { satOut: benched } : {}),
323
328
  at: now,
324
329
  };
325
330
  }
326
331
 
332
+ const BUILDERS: readonly TrackMember[] = ["designer", "backend"];
333
+
334
+ /**
335
+ * The roster of a task from an agreed plan: exactly the members who took part
336
+ * in the panel. One who sat it out (the classifier left it out, or the user
337
+ * unseated it) does not work on the task, and without QA there is no QA gate.
338
+ * When no builder sat (the oracle planned alone), the request's reading picks
339
+ * who builds. Returns who sat out, and says why in `why`.
340
+ */
341
+ function seatPlanPanel(roster: Set<TrackMember>, panel: readonly string[], why: string[]): TrackMember[] {
342
+ const sat = parseRoster(panel.filter((member) => MEMBER_ALIASES[member]));
343
+ const builders = sat.some((member) => BUILDERS.includes(member)) ? [] : [...roster].filter((member) => BUILDERS.includes(member));
344
+ const kept = new Set([...sat, ...builders]);
345
+ roster.clear();
346
+ for (const member of TRACK_MEMBERS) if (kept.has(member)) roster.add(member);
347
+ const out = TRACK_MEMBERS.filter((member) => !kept.has(member));
348
+ why.push(`the planning panel decides who works on it${out.length > 0 ? `; sat out: ${rosterWords(out)}` : ""}${builders.length > 0 ? `; no builder sat, so the request picks ${rosterWords(builders)}` : ""}`);
349
+ return out;
350
+ }
351
+
352
+ /** A member who sat out the planning panel of the task's plan and was not let back in: it does not work on the task. */
353
+ export function satOut(task: Task, member: TrackMember): boolean {
354
+ return Boolean(task.track?.satOut?.includes(member));
355
+ }
356
+
327
357
  /* ------------------------------------------------------------ quick fix or task */
328
358
 
329
359
  /** Where a new request goes before any task exists: one agent now (the lobby's quick fix), or the team. */
@@ -380,9 +410,14 @@ export function onFastTrack(task: Task): boolean {
380
410
  return task.track?.path === "fast";
381
411
  }
382
412
 
383
- /** Whether QA must take part before the task completes: always on the full workflow, on the fast track when the change needs tests. */
413
+ /**
414
+ * Whether QA must take part before the task completes: when it is on the
415
+ * roster, and always on the full workflow, except for a plan QA sat out in the
416
+ * planning panel (the user's call).
417
+ */
384
418
  export function qaRequired(task: Task): boolean {
385
- return !onFastTrack(task) || Boolean(task.track?.roster.includes("qa"));
419
+ if (task.track?.roster.includes("qa")) return true;
420
+ return !onFastTrack(task) && !satOut(task, "qa");
386
421
  }
387
422
 
388
423
  function finishedAt(run: { startedAt: string; finishedAt?: string }): number {
@@ -427,7 +462,8 @@ export function rosterWords(roster: readonly TrackMember[]): string {
427
462
  /** The track in one line, for the oracle's context and `/bot-lobby status`. */
428
463
  export function trackSummary(track: TaskTrack): string {
429
464
  const path = track.path === "fast" ? "fast track" : "full workflow";
430
- return `Track: ${path} (${track.size}; set by ${track.source}) · who takes part: ${rosterWords(track.roster)} · why: ${track.reasons.join("; ")}`;
465
+ const out = track.satOut?.length ? ` · sat out the plan, so not working on it: ${rosterWords(track.satOut)}` : "";
466
+ return `Track: ${path} (${track.size}; set by ${track.source}) · who takes part: ${rosterWords(track.roster)}${out} · why: ${track.reasons.join("; ")}`;
431
467
  }
432
468
 
433
469
  /** A short line for the activity log. */
@@ -436,9 +472,12 @@ export function trackLine(track: TaskTrack): string {
436
472
  return `track: ${track.path} · ${track.size} · ${members || "domain to pick"} · ${track.source}`;
437
473
  }
438
474
 
475
+ /** What the oracle does next on a planned task QA sat out, once a worker has reported. */
476
+ export const PLANNED_NEXT = "Next: check `git diff --stat` and this report, then delegate the plan's next step. Once every step is done, call action=complete: QA sat out the planning panel, so there is no QA gate.";
477
+
439
478
  /** What the oracle does next on the fast track, once a worker has reported. */
440
479
  export function fastNext(task: Task): string {
441
- if (!qaRequired(task)) return "Next (fast track): check `git diff --stat` and this report; if the work is in, call action=complete. No QA gate on this track: nothing here needs tests.";
480
+ if (!qaRequired(task)) return "Next (fast track): check `git diff --stat` and this report; if the work is in, call action=complete. No QA gate on this track: nothing here needs tests (Jev reads the change at completion, and QA joins one that turns out riskier).";
442
481
  if (qaTookPart(task)) return "Next (fast track): QA has taken part (its step came last). Check `git diff --stat`, then call action=complete.";
443
- return "Next (fast track): check `git diff --stat`. QA takes part before completion: give qa the tests as the last step (action=implement domain=qa), or run action=qa; then call action=complete.";
482
+ return "Next (fast track): check `git diff --stat`. QA takes part before completion: run action=qa (Jev sizes it to the change; one with nothing that runs passes on the engine's checks); then call action=complete.";
444
483
  }