@a-t-h-i/bot-lobby 0.6.4 → 0.6.6

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 (44) hide show
  1. package/README.md +150 -12
  2. package/package.json +12 -3
  3. package/prompts/backend.md +3 -1
  4. package/prompts/designer.md +24 -1
  5. package/prompts/global.md +18 -0
  6. package/prompts/master.md +122 -20
  7. package/prompts/quickfix.md +16 -3
  8. package/prompts/worker.md +7 -2
  9. package/src/agents/backend.ts +2 -2
  10. package/src/agents/designer.ts +2 -2
  11. package/src/ask/dialog.ts +11 -1
  12. package/src/classifier/triage.ts +24 -7
  13. package/src/index.ts +4 -1
  14. package/src/lobby/feed.ts +7 -2
  15. package/src/lobby/keys.ts +0 -1
  16. package/src/lobby/quickfix.ts +29 -3
  17. package/src/lobby/runtime.ts +29 -26
  18. package/src/lobby/tabs/home.ts +6 -42
  19. package/src/lobby/tabs/quickfix.ts +1 -1
  20. package/src/lobby/tabs/tasks.ts +3 -2
  21. package/src/lobby/view.ts +18 -23
  22. package/src/master/decisions.ts +10 -2
  23. package/src/pi/activity.ts +1 -33
  24. package/src/pi/commands.ts +22 -11
  25. package/src/pi/events.ts +3 -17
  26. package/src/pi/plan-checklist.ts +305 -0
  27. package/src/pi/route.ts +180 -0
  28. package/src/pi/run-summary.ts +12 -3
  29. package/src/pi/settings-ui.ts +2 -2
  30. package/src/pi/start-task.ts +51 -5
  31. package/src/pi/tools.ts +13 -7
  32. package/src/pi/ui.ts +18 -284
  33. package/src/roles/worker.ts +11 -3
  34. package/src/schemas/configuration.ts +19 -6
  35. package/src/schemas/task.ts +31 -0
  36. package/src/workflow/brief.ts +59 -0
  37. package/src/workflow/track.ts +436 -0
  38. package/src/workflow/workflow.ts +151 -10
  39. package/src/pi/expressions.ts +0 -169
  40. package/src/pi/kaomoji.ts +0 -227
  41. package/src/pi/mascot-art.ts +0 -359
  42. package/src/pi/zen-large.ts +0 -699
  43. package/src/pi/zen-metrics.ts +0 -130
  44. package/src/pi/zen.ts +0 -659
@@ -0,0 +1,436 @@
1
+ /**
2
+ * A task's track: how serious its request reads, and so who takes part and
3
+ * how much process it gets. A small, clear, low-risk request takes the fast
4
+ * track: the oracle delegates straight to the agents it needs (no scouts, no
5
+ * proposal round, no plan document), QA takes part only when the change
6
+ * needs tests, and the researcher only when it needs facts from outside the
7
+ * repository. Anything bigger, riskier or unclear takes the full workflow.
8
+ *
9
+ * The engine reads the request once as the task starts, with the rules below
10
+ * (instant, no model), refined by the classifier's triage when that is on.
11
+ * The oracle confirms the track by acting on it or corrects it with
12
+ * `action=track`; the user can force either path with `--fast` or `--full`.
13
+ * Once work is under way a track only gets stricter.
14
+ */
15
+ import type { Domain } from "../schemas/agent.ts";
16
+ import type { Task, TaskTrack, TaskTriage, TrackMember, TrackPath, TriageSize } from "../schemas/task.ts";
17
+
18
+ export const TRACK_MEMBERS: readonly TrackMember[] = ["designer", "backend", "qa", "researcher"];
19
+
20
+ /** How each member reads in the lobby and the oracle's context. */
21
+ export const MEMBER_LABELS: Record<TrackMember, string> = {
22
+ designer: "DESIGN (frontend)",
23
+ backend: "DEV (backend)",
24
+ qa: "QA (tests)",
25
+ researcher: "RESEARCH (web)",
26
+ };
27
+
28
+ /** Names the oracle or a user may use for a member. */
29
+ const MEMBER_ALIASES: Record<string, TrackMember> = {
30
+ designer: "designer",
31
+ design: "designer",
32
+ frontend: "designer",
33
+ "front-end": "designer",
34
+ ui: "designer",
35
+ backend: "backend",
36
+ "back-end": "backend",
37
+ dev: "backend",
38
+ qa: "qa",
39
+ tests: "qa",
40
+ test: "qa",
41
+ researcher: "researcher",
42
+ research: "researcher",
43
+ };
44
+
45
+ /** A roster in its canonical order; unknown names are an error naming the choices. */
46
+ export function parseRoster(values: readonly string[]): TrackMember[] {
47
+ const members = new Set<TrackMember>();
48
+ for (const value of values) {
49
+ const member = MEMBER_ALIASES[value.trim().toLowerCase()];
50
+ if (!member) throw new Error(`"${value}" is not a roster member (designer, backend, qa, researcher)`);
51
+ members.add(member);
52
+ }
53
+ return TRACK_MEMBERS.filter((member) => members.has(member));
54
+ }
55
+
56
+ /* ------------------------------------------------------------ the rules */
57
+
58
+ function terms(list: readonly string[]): RegExp {
59
+ const alternatives = [...list]
60
+ .sort((a, b) => b.length - a.length)
61
+ .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/[\s-]+/g, "[\\s-]+"));
62
+ return new RegExp(`(?<![\\w-])(?:${alternatives.join("|")})(?![\\w-])`, "i");
63
+ }
64
+
65
+ const FRONTEND = terms([
66
+ "ui", "ux", "frontend", "front-end", "page", "pages", "screen", "screens", "button", "buttons", "css", "scss", "sass",
67
+ "style", "styles", "styling", "stylesheet", "layout", "component", "components", "modal", "dialog", "popup", "form", "forms",
68
+ "input field", "react", "vue", "svelte", "angular", "html", "jsx", "tsx", "color", "colour", "colors", "colours", "font",
69
+ "fonts", "typography", "responsive", "mobile", "animation", "animations", "icon", "icons", "tooltip", "tooltips", "navbar",
70
+ "nav bar", "navigation", "menu", "dropdown", "header", "footer", "sidebar", "theme", "dark mode", "light mode", "tailwind",
71
+ "tab", "tabs", "landing page", "hero", "banner", "card", "cards", "placeholder", "label", "labels", "image", "images", "logo",
72
+ "spacing", "padding", "margin", "align", "alignment", "accessibility", "a11y", "aria", "wireframe", "mockup", "hover",
73
+ "scroll", "scrolling", "chart", "charts", "dashboard", "toast", "spinner", "loading state", "empty state", "checkbox",
74
+ "toggle", "carousel", "breadcrumb", "three.js", "threejs", "webgl", "webgpu", "canvas", "shader", "shaders", "3d", "svg",
75
+ "game",
76
+ ]);
77
+
78
+ const BACKEND = terms([
79
+ "api", "apis", "endpoint", "endpoints", "backend", "back-end", "server", "servers", "server-side", "database", "databases",
80
+ "db", "sql", "postgres", "postgresql", "mysql", "sqlite", "mongo", "mongodb", "redis", "query", "queries", "migration",
81
+ "migrations", "schema", "column", "columns", "auth", "authentication", "authorization", "login", "logout", "sign in",
82
+ "sign up", "signup", "session", "sessions", "token", "tokens", "jwt", "oauth", "service", "services", "microservice",
83
+ "queue", "queues", "job", "jobs", "cron", "cache", "caching", "webhook", "webhooks", "controller", "controllers", "route",
84
+ "routes", "router", "middleware", "orm", "graphql", "rest api", "grpc", "websocket", "websockets", "lambda", "cli", "email",
85
+ "emails", "upload", "uploads", "payment", "payments", "stripe", "rate limit", "rate limiting", "logging", "docker",
86
+ "deploy", "deployment", "pipeline", "env var", "environment variable", "validation", "csv export", "notification",
87
+ "notifications", "backup", "backups", "parser", "status code", "http", "404", "500", "index on", "kafka", "rabbitmq",
88
+ "elasticsearch",
89
+ ]);
90
+
91
+ const TESTS = terms([
92
+ "test", "tests", "testing", "tested", "unit test", "unit tests", "e2e", "end-to-end", "integration test", "integration tests",
93
+ "coverage", "qa", "regression", "regressions", "flaky", "assertion", "assertions", "test suite", "ci",
94
+ ]);
95
+
96
+ const BUGFIX = terms([
97
+ "bug", "bugs", "buggy", "fix", "fixes", "broken", "breaks", "crash", "crashes", "crashing", "error", "errors", "exception",
98
+ "fails", "failing", "failure", "wrong", "incorrect", "doesn't work", "does not work", "not working", "isn't working",
99
+ "nan", "undefined",
100
+ ]);
101
+
102
+ const RESEARCH = terms([
103
+ "latest", "newest", "current version", "up-to-date", "deprecated in", "release notes", "official docs",
104
+ "documentation for", "docs for", "look up", "search the web", "search online", "web search", "on the internet",
105
+ "alternatives", "alternative to", "which library", "what library", "best library", "which package", "best practice",
106
+ "best practices", "recommended way", "industry standard", "benchmark", "benchmarks", "pricing", "rfc", "wcag", "cve",
107
+ "security advisory", "third-party api", "sdk", "upgrade to", "migrate to", "version of", "browser support",
108
+ "compatibility", "compatible with", "research", "verify online",
109
+ ]);
110
+
111
+ /** Serious whatever its size: security, money, data, production. Such a change gets a plan and the QA gate. */
112
+ const RISK = terms([
113
+ "security", "secure", "vulnerability", "vulnerabilities", "cve", "exploit", "xss", "csrf", "injection", "auth",
114
+ "authentication", "authorization", "authorisation", "permission", "permissions", "rbac", "access control", "password",
115
+ "passwords", "credential", "credentials", "secret", "secrets", "api key", "api keys", "encryption", "encrypt", "decrypt",
116
+ "oauth", "jwt", "session store", "session storage", "cookie", "cookies", "payment", "payments", "billing", "invoice", "invoices", "checkout", "stripe", "refund", "refunds",
117
+ "migration", "migrations", "migrate", "data loss", "delete data", "drop table", "backfill", "production", "prod",
118
+ "deploy", "deployment", "gdpr", "privacy", "pii", "personal data", "race condition", "concurrency", "transaction",
119
+ "transactions",
120
+ ]);
121
+
122
+ const LARGE = terms([
123
+ "architecture", "architectural", "re-architect", "rearchitect", "rewrite", "overhaul", "from scratch", "subsystem",
124
+ "new system", "entire", "whole app", "whole codebase", "whole project", "across the codebase", "across the app",
125
+ "across the project", "everywhere", "every page", "every screen", "all pages", "all screens", "cross-cutting",
126
+ "multi-tenant", "multitenant", "microservices", "monorepo", "internationalization", "internationalisation",
127
+ "localization", "localisation", "i18n", "real-time", "realtime", "offline support", "plugin system", "redesign",
128
+ "replatform",
129
+ ]);
130
+
131
+ const MEDIUM = terms([
132
+ "refactor", "refactoring", "restructure", "reorganize", "reorganise", "consolidate", "deduplicate", "integrate",
133
+ "integration", "support for", "slow", "slowness", "performance", "memory leak", "leak", "leaks", "intermittent",
134
+ "intermittently", "sometimes", "randomly", "deadlock", "hangs", "freezes", "timeouts", "upgrade", "upgrading",
135
+ "dependency", "dependencies", "library", "sdk", "latest version", "new version", "redis", "kafka", "rabbitmq",
136
+ "elasticsearch", "schema", "websocket", "websockets", "queue", "queues", "webhook", "webhooks", "dark mode", "theming",
137
+ ]);
138
+
139
+ /** A new capability: building a page, a flow, an endpoint or a system is more than a small change. */
140
+ const FEATURE = /\b(?:add|adds|adding|create|build|implement|introduce|develop|design|set up|setup)\b(?:[^.\n]|\.(?=\S)){0,40}?\b(?:pages?|screens?|features?|flows?|endpoints?|apis?|services?|systems?|modules?|dashboards?|integrations?|wizards?|workflows?|pipelines?|onboarding|notifications?|reports?)\b/i;
141
+
142
+ const TRIVIAL = terms([
143
+ "typo", "typos", "spelling", "misspelled", "misspelling", "grammar", "wording", "reword", "rephrase", "rename", "renaming",
144
+ "copy change", "text change", "label", "color", "colour", "colors", "colours", "font size", "font weight", "padding",
145
+ "margin", "spacing", "align", "alignment", "capitalize", "capitalise", "capitalization", "capitalisation", "icon",
146
+ "placeholder", "tooltip", "broken link", "dead link", "docstring", "readme", "changelog", "bump", "version bump",
147
+ "one-line", "one line", "one-liner", "unused import", "unused imports", "unused variable", "remove unused", "dead code",
148
+ "lint", "linting", "formatting", "whitespace", "indentation", "log message", "error message",
149
+ ]);
150
+
151
+ /** Self-contained: the whole thing lives in one place, so one agent can build it end to end. */
152
+ const SOLO = terms([
153
+ "single file", "single-file", "one file", "in one file", "one html file", "single html", "single html file", "single page",
154
+ "single-page", "standalone", "stand-alone", "self-contained", "self contained", "one-off script",
155
+ ]);
156
+
157
+ const DOCS = terms(["readme", "docs", "documentation", "changelog", "docstring", "docstrings", "jsdoc", "comment", "comments"]);
158
+
159
+ const VAGUE = terms([
160
+ "improve", "better", "nicer", "cleaner", "optimize", "optimise", "enhance", "polish", "revamp", "clean up", "cleanup",
161
+ "tidy", "modernize", "modernise", "something", "somehow", "etc",
162
+ ]);
163
+
164
+ const UNDECIDED = terms(["not sure", "unsure", "should we", "which is better", "what do you think", "pros and cons", "tbd", "or maybe"]);
165
+
166
+ const INVESTIGATE = /^\s*(?:why|how come|explain|investigate|diagnose|figure out|find out|look into|understand)\b/i;
167
+
168
+ const FRONTEND_FILE = /\.(?:css|scss|sass|less|html?|jsx|tsx|vue|svelte|astro)\b/i;
169
+ const BACKEND_FILE = /\.(?:py|go|rs|java|kt|rb|php|cs|sql|prisma|proto)\b/i;
170
+ const TEST_FILE = /\.(?:test|spec)\.[a-z]+\b|(?:^|[\s/`'"(])(?:tests?|__tests__|spec)\//i;
171
+ /** A file, a path or code in backticks: the request names what to change. */
172
+ const CONCRETE = /[\w.-]+\/[\w./-]+|\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|css|scss|html|md|json|ya?ml|toml|sql|vue|svelte|sh)\b|`[^`\n]+`/;
173
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\S/;
174
+
175
+ /** What the rules make of a request. */
176
+ export interface RequestReading {
177
+ size: TriageSize;
178
+ roster: TrackMember[];
179
+ /** Why it is serious whatever its size, when it is. */
180
+ risk?: string;
181
+ /** Why it reads unclear, when it does. */
182
+ unclear?: string;
183
+ /** Why it is self-contained (one file, one page), when it says so. */
184
+ solo?: string;
185
+ reasons: string[];
186
+ }
187
+
188
+ function matched(pattern: RegExp, text: string): string | undefined {
189
+ return pattern.exec(text)?.[0].toLowerCase().replace(/\s+/g, " ");
190
+ }
191
+
192
+ /**
193
+ * Read a request with plain rules: its size, who it needs, and whether it is
194
+ * risky or unclear. Deliberately cautious: a request the rules cannot place
195
+ * as small and clear goes to the full workflow, where the oracle can still
196
+ * take it fast.
197
+ */
198
+ export function readRequest(request: string): RequestReading {
199
+ const text = request.trim();
200
+ const words = text.split(/\s+/).filter(Boolean).length;
201
+ const items = text.split("\n").filter((line) => LIST_ITEM.test(line)).length;
202
+ const concrete = CONCRETE.test(text);
203
+ const reasons: string[] = [];
204
+
205
+ const large = matched(LARGE, text) ?? (words > 200 ? `${words} words` : items >= 8 ? `${items} requirements` : undefined);
206
+ const feature = matched(FEATURE, text);
207
+ const medium = feature ? `new capability (${feature})` : matched(MEDIUM, text) ?? (words > 60 ? `${words} words` : items >= 3 ? `${items} requirements` : undefined);
208
+ const trivial = words <= 30 ? matched(TRIVIAL, text) : undefined;
209
+ const size: TriageSize = large ? "large" : medium ? "medium" : trivial ? "trivial" : "small";
210
+ reasons.push(large ? `large: ${large}` : medium ? `medium: ${medium}` : trivial ? `trivial: ${trivial}` : "small change");
211
+
212
+ const frontend = matched(FRONTEND, text) ?? (FRONTEND_FILE.test(text) ? "a frontend file" : undefined);
213
+ const backend = matched(BACKEND, text) ?? (BACKEND_FILE.test(text) ? "a backend file" : undefined);
214
+ const docsOnly = !frontend && !backend && DOCS.test(text);
215
+ const roster = new Set<TrackMember>();
216
+ if (frontend) roster.add("designer");
217
+ if (backend) roster.add("backend");
218
+ const asked = matched(TESTS, text) ?? (TEST_FILE.test(text) ? "a test file" : undefined);
219
+ const bugfix = size !== "trivial" && !docsOnly ? matched(BUGFIX, text) : undefined;
220
+ const tests = asked
221
+ ? `tests: asked for (${asked})`
222
+ : bugfix
223
+ ? `tests: a bug fix needs a regression test (${bugfix})`
224
+ : backend && size !== "trivial"
225
+ ? `tests: backend logic (${backend})`
226
+ : size === "medium" || size === "large"
227
+ ? "tests: the QA gate checks a change this size"
228
+ : undefined;
229
+ if (tests) {
230
+ roster.add("qa");
231
+ reasons.push(tests);
232
+ }
233
+ const research = matched(RESEARCH, text);
234
+ if (research) {
235
+ roster.add("researcher");
236
+ reasons.push(`research: ${research}`);
237
+ }
238
+
239
+ const risk = matched(RISK, text);
240
+ if (risk) reasons.push(`serious: touches ${risk}`);
241
+ const vague = words < 8 && !concrete ? matched(VAGUE, text) : undefined;
242
+ const undecided = matched(UNDECIDED, text);
243
+ const unclear = words < 3
244
+ ? "too short to act on"
245
+ : INVESTIGATE.test(text) ? "asks to investigate before any change"
246
+ : undecided ? `leaves a decision open (${undecided})`
247
+ : vague ? `vague (${vague})` : undefined;
248
+ if (unclear) reasons.push(`unclear: ${unclear}`);
249
+ const solo = matched(SOLO, text);
250
+ if (solo) reasons.push(`self-contained: ${solo}`);
251
+
252
+ return { size, roster: TRACK_MEMBERS.filter((member) => roster.has(member)), ...(risk ? { risk } : {}), ...(unclear ? { unclear } : {}), ...(solo ? { solo } : {}), reasons };
253
+ }
254
+
255
+ /* ------------------------------------------------------------ choosing a track */
256
+
257
+ /** A triage probability the track takes as a yes. */
258
+ const YES = 0.5;
259
+ /** Below this the classifier's size is a guess, and the rules' size stands. */
260
+ const SIZE_CONFIDENCE = 0.6;
261
+ /** The classifier's "one engineer can do it alone" taken as a yes (`classifier.thresholds.quickFixAt`'s default). */
262
+ const SOLO_YES = 0.7;
263
+
264
+ export interface TrackOptions {
265
+ /** `workflow.fastTrack`: false puts every task on the full workflow. */
266
+ fastTrack: boolean;
267
+ /** The user's `--fast` or `--full`. */
268
+ forced?: TrackPath;
269
+ /** The task follows a plan agreed in the planning panel. */
270
+ approvedPlan?: boolean;
271
+ }
272
+
273
+ /**
274
+ * The track a new task starts on. The rules read the request; the
275
+ * classifier's triage, when there is one, has the last word on size, domains
276
+ * and clarity. Risk and missing clarity always mean the full workflow, and
277
+ * QA or the researcher join when either reading asks for them.
278
+ */
279
+ export function chooseTrack(request: string, triage: TaskTriage | undefined, options: TrackOptions, now = new Date().toISOString()): TaskTrack {
280
+ const rules = readRequest(request);
281
+ let size = rules.size;
282
+ let roster = new Set(rules.roster);
283
+ let unclear = rules.unclear;
284
+ const reasons = [...rules.reasons];
285
+ if (triage) {
286
+ if (triage.sizeConfidence >= SIZE_CONFIDENCE) size = triage.size;
287
+ const building = (["designer", "backend"] as const).filter((domain) => (triage.domains[domain] ?? 0) >= YES);
288
+ if (building.length > 0) roster = new Set([...building, ...[...roster].filter((member) => member === "qa" || member === "researcher")]);
289
+ if ((triage.domains.qa ?? 0) >= YES) roster.add("qa");
290
+ if (triage.research >= YES) roster.add("researcher");
291
+ unclear = triage.ambiguous >= YES ? "the classifier reads it as ambiguous" : undefined;
292
+ reasons.splice(0, reasons.length, `classifier: ${triage.size} (${triage.sizeConfidence.toFixed(2)})${triage.kind ? `, ${triage.kind}` : ""}`, ...rules.reasons.filter((reason) => !/^(?:trivial|small|medium|large|unclear)\b/.test(reason)));
293
+ if (unclear) reasons.push(`unclear: ${unclear}`);
294
+ }
295
+ const source: TaskTrack["source"] = triage ? "classifier" : "rules";
296
+ // A self-contained build (one file, one page) needs no survey or plan round even when it is rich.
297
+ const selfContained = Boolean(rules.solo) || (triage?.solo ?? 0) >= SOLO_YES;
298
+ const fast = (size === "trivial" || size === "small" || (size === "medium" && selfContained)) && !rules.risk && !unclear;
299
+ const [path, why, by]: [TrackPath, string[], TaskTrack["source"]] = options.forced
300
+ ? [options.forced, [`the user asked for the ${options.forced === "fast" ? "fast track" : "full workflow"}`], "user"]
301
+ : options.approvedPlan
302
+ ? ["full", ["follows a plan agreed in the planning panel"], "plan"]
303
+ : !options.fastTrack
304
+ ? ["full", ["the fast track is off in settings"], source]
305
+ : [fast ? "fast" : "full", [], source];
306
+ // The full workflow always ends with the QA gate.
307
+ if (path === "full") roster.add("qa");
308
+ return {
309
+ path,
310
+ size,
311
+ roster: TRACK_MEMBERS.filter((member) => roster.has(member)),
312
+ reasons: [...why, ...reasons],
313
+ source: by,
314
+ ...(options.forced ? { userChoice: options.forced } : {}),
315
+ at: now,
316
+ };
317
+ }
318
+
319
+ /* ------------------------------------------------------------ quick fix or task */
320
+
321
+ /** Where a new request goes before any task exists: one agent now (the lobby's quick fix), or the team. */
322
+ export interface RequestRoute {
323
+ to: "quickfix" | "task";
324
+ size: TriageSize;
325
+ /** The domain that would build it: a quick feature (medium or larger) runs on its model, thinking and time limit. */
326
+ builder: Domain;
327
+ /** Why, a few words each. */
328
+ reasons: string[];
329
+ source: "classifier" | "rules";
330
+ }
331
+
332
+ /**
333
+ * Whether one agent can do a new request alone, right away. The classifier's
334
+ * "one engineer, alone, now" answer decides when it is on and sure either way
335
+ * (`quickFixAt`); the rules decide otherwise: small and in one area, or
336
+ * self-contained (one file, one page) even when rich. Anything serious,
337
+ * unclear, large, spread across frontend and backend, or needing outside
338
+ * facts goes to the team. The oracle confirms a quick fix before it runs.
339
+ */
340
+ export function chooseRoute(request: string, triage: TaskTriage | undefined, quickFixAt = SOLO_YES): RequestRoute {
341
+ const rules = readRequest(request);
342
+ const building = rules.roster.filter((member) => member === "designer" || member === "backend");
343
+ const against = [
344
+ rules.risk ? `serious: touches ${rules.risk}` : "",
345
+ rules.unclear ? `unclear: ${rules.unclear}` : "",
346
+ rules.size === "large" ? rules.reasons[0]! : "",
347
+ rules.size === "medium" && !rules.solo ? rules.reasons[0]! : "",
348
+ building.length > 1 ? "touches frontend and backend" : "",
349
+ rules.roster.includes("researcher") ? "needs outside facts (the researcher)" : "",
350
+ ].filter(Boolean);
351
+ const size = triage && triage.sizeConfidence >= SIZE_CONFIDENCE ? triage.size : rules.size;
352
+ const frontend = triage ? (triage.domains.designer ?? 0) >= Math.max(YES, triage.domains.backend ?? 0) : building.length === 1 && building[0] === "designer";
353
+ const builder: Domain = frontend ? "designer" : "backend";
354
+ const one = building.length === 1 ? (building[0] === "designer" ? "frontend" : "backend") : "one place";
355
+ const base = { size, builder };
356
+ const ruled: RequestRoute = against.length > 0
357
+ ? { ...base, to: "task", reasons: against, source: "rules" }
358
+ : { ...base, to: "quickfix", reasons: [rules.solo ? `self-contained (${rules.solo}): one agent can build it end to end` : `${rules.reasons[0]}, in ${one}`], source: "rules" };
359
+ const solo = triage?.solo;
360
+ if (!triage || solo === undefined) return ruled;
361
+ // Serious is serious, whatever the classifier makes of the work itself.
362
+ if (rules.risk) return { ...base, to: "task", reasons: [`serious: touches ${rules.risk}`], source: "rules" };
363
+ if (triage.ambiguous >= YES) return { ...base, to: "task", reasons: [`the classifier reads it as ambiguous (${triage.ambiguous.toFixed(2)})`], source: "classifier" };
364
+ if (solo >= quickFixAt) return { ...base, to: "quickfix", reasons: [`the classifier: one engineer can do it alone (${solo.toFixed(2)})`], source: "classifier" };
365
+ if (solo <= 1 - quickFixAt) return { ...base, to: "task", reasons: [`the classifier: it needs the team (one engineer alone: ${solo.toFixed(2)})`], source: "classifier" };
366
+ return { ...ruled, reasons: [...ruled.reasons, `the classifier is unsure (${solo.toFixed(2)})`] };
367
+ }
368
+
369
+ /* ------------------------------------------------------------ what a track asks of the engine */
370
+
371
+ export function onFastTrack(task: Task): boolean {
372
+ return task.track?.path === "fast";
373
+ }
374
+
375
+ /** Whether QA must take part before the task completes: always on the full workflow, on the fast track when the change needs tests. */
376
+ export function qaRequired(task: Task): boolean {
377
+ return !onFastTrack(task) || Boolean(task.track?.roster.includes("qa"));
378
+ }
379
+
380
+ function finishedAt(run: { startedAt: string; finishedAt?: string }): number {
381
+ return Date.parse(run.finishedAt ?? run.startedAt);
382
+ }
383
+
384
+ /**
385
+ * On the fast track QA takes part as the last step: a QA worker run that
386
+ * started after every other domain's work finished, so the tests it wrote and
387
+ * ran cover the change as it stands.
388
+ */
389
+ export function qaWorkerCameLast(task: Task): boolean {
390
+ const done = (task.workerRuns ?? []).filter((run) => run.status === "success");
391
+ const qa = done.filter((run) => run.domain === "qa");
392
+ if (qa.length === 0) return false;
393
+ const others = done.filter((run) => run.domain !== "qa");
394
+ const last = Math.max(0, ...others.map(finishedAt));
395
+ return qa.some((run) => Date.parse(run.startedAt) >= last);
396
+ }
397
+
398
+ /** QA has taken part as the task's track asks: the gate passed, or (fast track) its worker came last. */
399
+ export function qaTookPart(task: Task): boolean {
400
+ return task.qaVerdict === "pass" || (onFastTrack(task) && qaWorkerCameLast(task));
401
+ }
402
+
403
+ /** QA's part is still to come: the time budget keeps its reserve until then. */
404
+ export function qaStillDue(task: Task): boolean {
405
+ return qaRequired(task) && !qaTookPart(task);
406
+ }
407
+
408
+ /** A worker step finished: the fast track completes only once something was built. */
409
+ export function builtSomething(task: Task): boolean {
410
+ return (task.workerRuns ?? []).some((run) => run.status === "success");
411
+ }
412
+
413
+ /* ------------------------------------------------------------ words */
414
+
415
+ export function rosterWords(roster: readonly TrackMember[]): string {
416
+ return roster.length > 0 ? roster.map((member) => MEMBER_LABELS[member]).join(", ") : "the one domain that owns the files (the oracle picks)";
417
+ }
418
+
419
+ /** The track in one line, for the oracle's context and `/bot-lobby status`. */
420
+ export function trackSummary(track: TaskTrack): string {
421
+ const path = track.path === "fast" ? "fast track" : "full workflow";
422
+ return `Track: ${path} (${track.size}; set by ${track.source}) · who takes part: ${rosterWords(track.roster)} · why: ${track.reasons.join("; ")}`;
423
+ }
424
+
425
+ /** A short line for the activity log. */
426
+ export function trackLine(track: TaskTrack): string {
427
+ const members = track.roster.map((member) => MEMBER_LABELS[member].split(" ")[0]).join(", ");
428
+ return `track: ${track.path} · ${track.size} · ${members || "domain to pick"} · ${track.source}`;
429
+ }
430
+
431
+ /** What the oracle does next on the fast track, once a worker has reported. */
432
+ export function fastNext(task: Task): string {
433
+ 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.";
434
+ if (qaTookPart(task)) return "Next (fast track): QA has taken part (its step came last). Check `git diff --stat`, then call action=complete.";
435
+ 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.";
436
+ }