@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.
- package/README.md +36 -11
- package/package.json +1 -1
- package/prompts/master.md +24 -3
- package/prompts/qa.md +139 -77
- package/prompts/reviewer.md +4 -0
- package/src/classifier/qa-risk.ts +537 -0
- package/src/lobby/host.ts +19 -0
- package/src/lobby/planner.ts +145 -0
- package/src/lobby/quickfix.ts +40 -1
- package/src/lobby/recovery.ts +415 -0
- package/src/lobby/resume.ts +66 -0
- package/src/lobby/runtime.ts +80 -7
- package/src/lobby/service.ts +196 -11
- package/src/lobby/sessions.ts +13 -3
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/pi/commands.ts +38 -2
- package/src/pi/events.ts +10 -6
- package/src/pi/start-task.ts +31 -4
- package/src/pi/switch.ts +7 -2
- package/src/pi/tools.ts +3 -0
- package/src/pi/ui.ts +9 -2
- package/src/schemas/configuration.ts +2 -2
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/state/plan-history.ts +142 -0
- package/src/state/recovery.ts +170 -0
- package/src/state/themes.ts +127 -0
- package/src/webui/api/index.ts +24 -2
- package/src/webui/api/planner.ts +47 -1
- package/src/webui/api/status.ts +2 -0
- package/src/webui/api/tasks.ts +36 -5
- package/src/webui/api/themes.ts +37 -0
- package/src/webui/command.ts +26 -1
- package/src/webui/dev/fake-service.ts +60 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/palette-core.ts +260 -0
- package/src/webui/protocol.ts +59 -0
- package/src/webui/server.ts +10 -0
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +196 -34
- package/webui/dist/assets/index-BzCxEueR.css +2 -0
- package/webui/dist/assets/index-qisGNoCh.js +108 -0
- package/webui/dist/build.json +51 -35
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-C1IwecNS.css +0 -2
- 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
|
+
}
|
package/src/webui/protocol.ts
CHANGED
|
@@ -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;
|
package/src/webui/server.ts
CHANGED
|
@@ -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);
|
package/src/workflow/track.ts
CHANGED
|
@@ -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
|
-
//
|
|
315
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|