@a-t-h-i/bot-lobby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +406 -0
  3. package/package.json +46 -0
  4. package/prompts/backend.md +28 -0
  5. package/prompts/designer.md +33 -0
  6. package/prompts/global.md +42 -0
  7. package/prompts/master.md +107 -0
  8. package/prompts/qa.md +26 -0
  9. package/prompts/researcher.md +32 -0
  10. package/prompts/reviewer.md +43 -0
  11. package/prompts/scout.md +36 -0
  12. package/prompts/worker.md +49 -0
  13. package/src/agents/backend.ts +10 -0
  14. package/src/agents/designer.ts +10 -0
  15. package/src/agents/qa.ts +10 -0
  16. package/src/agents/registry.ts +14 -0
  17. package/src/execution/agent-runner.ts +150 -0
  18. package/src/execution/git.ts +42 -0
  19. package/src/execution/pi-runner.ts +292 -0
  20. package/src/index.ts +13 -0
  21. package/src/knowledge/compactor.ts +135 -0
  22. package/src/knowledge/paths.ts +43 -0
  23. package/src/knowledge/selector.ts +82 -0
  24. package/src/knowledge/store.ts +111 -0
  25. package/src/master/decisions.ts +53 -0
  26. package/src/master/master.ts +298 -0
  27. package/src/master/research.ts +98 -0
  28. package/src/master/synthesis.ts +57 -0
  29. package/src/pi/activity.ts +60 -0
  30. package/src/pi/commands.ts +268 -0
  31. package/src/pi/events.ts +76 -0
  32. package/src/pi/expressions.ts +101 -0
  33. package/src/pi/mascot-art.ts +252 -0
  34. package/src/pi/notify.ts +42 -0
  35. package/src/pi/quiet.ts +46 -0
  36. package/src/pi/settings-ui.ts +258 -0
  37. package/src/pi/tool-renderers.ts +121 -0
  38. package/src/pi/tools.ts +158 -0
  39. package/src/pi/ui.ts +227 -0
  40. package/src/pi/zen-large.ts +483 -0
  41. package/src/pi/zen-metrics.ts +80 -0
  42. package/src/pi/zen.ts +460 -0
  43. package/src/prompts/compiler.ts +50 -0
  44. package/src/prompts/loader.ts +20 -0
  45. package/src/roles/markdown.ts +64 -0
  46. package/src/roles/registry.ts +16 -0
  47. package/src/roles/researcher.ts +83 -0
  48. package/src/roles/reviewer.ts +65 -0
  49. package/src/roles/scout.ts +61 -0
  50. package/src/roles/worker.ts +94 -0
  51. package/src/schemas/agent.ts +39 -0
  52. package/src/schemas/configuration.ts +107 -0
  53. package/src/schemas/findings.ts +110 -0
  54. package/src/schemas/task.ts +113 -0
  55. package/src/state/persistence.ts +232 -0
  56. package/src/state/project.ts +99 -0
  57. package/src/state/task-state.ts +22 -0
  58. package/src/text.ts +51 -0
  59. package/src/workflow/approvals.ts +45 -0
  60. package/src/workflow/transitions.ts +41 -0
  61. package/src/workflow/workflow.ts +771 -0
@@ -0,0 +1,232 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { KnowledgeConfig } from "../schemas/configuration.ts";
4
+ import type { Domain } from "../schemas/agent.ts";
5
+ import { TERMINAL_STATES, isTaskState, type Task } from "../schemas/task.ts";
6
+ import { dataRoot, firstLegacyDataRoot, readDataRoots } from "./project.ts";
7
+ import {
8
+ AGENT_DIR_NAMES,
9
+ KNOWLEDGE_FILES,
10
+ knowledgeDir,
11
+ scratchpadPath,
12
+ taskDir,
13
+ tasksRoot,
14
+ type KnowledgeAgent,
15
+ } from "../knowledge/paths.ts";
16
+ import { DEFAULT_KNOWLEDGE_CONTENT, ensureFile, readFileOr, writeFileEnsured } from "../knowledge/store.ts";
17
+
18
+ /** Idempotently create the full knowledge + tasks layout with seed files. */
19
+ export function ensureProjectStructure(root: string, configDir: string): void {
20
+ const dr = dataRoot(root, configDir);
21
+ const legacy = firstLegacyDataRoot(root, configDir);
22
+ const agents = Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[];
23
+ for (const agent of agents) {
24
+ const dir = knowledgeDir(dr, agent);
25
+ mkdirSync(dir, { recursive: true });
26
+ for (const file of KNOWLEDGE_FILES[agent]) {
27
+ // Seed from the newest existing pre-rename tree so legacy knowledge migrates instead of being shadowed by defaults.
28
+ const migrated = legacy ? readFileOr(join(knowledgeDir(legacy, agent), file)) : "";
29
+ ensureFile(join(dir, file), migrated || (DEFAULT_KNOWLEDGE_CONTENT[file] ?? `# ${file}\n`));
30
+ }
31
+ }
32
+ mkdirSync(tasksRoot(dr), { recursive: true });
33
+ }
34
+
35
+ /** Create the temporary task dir with state.json, proposal/plan, scratchpads. */
36
+ export function createTaskDir(root: string, configDir: string, task: Task): void {
37
+ const dir = taskDir(dataRoot(root, configDir), task.id);
38
+ mkdirSync(dir, { recursive: true });
39
+ writeFileEnsured(join(dir, "state.json"), JSON.stringify(task, null, 2));
40
+ ensureFile(join(dir, "proposal.md"), "");
41
+ ensureFile(join(dir, "plan.md"), "");
42
+ for (const domain of ["designer", "backend", "qa"] as const) {
43
+ ensureFile(scratchpadPath(dir, domain), `# ${domain} scratchpad\n`);
44
+ }
45
+ }
46
+
47
+ export function taskDirFor(root: string, configDir: string, taskId: string): string {
48
+ return taskDir(dataRoot(root, configDir), taskId);
49
+ }
50
+
51
+ /** Per-task dirs to read, newest first: bot-lobby, then the pre-rename trees. */
52
+ export function taskReadDirs(root: string, configDir: string, taskId: string): string[] {
53
+ return readDataRoots(root, configDir).map((dr) => taskDir(dr, taskId));
54
+ }
55
+
56
+ function readIfExists(path: string): string | undefined {
57
+ if (!existsSync(path)) return undefined;
58
+ try {
59
+ return readFileSync(path, "utf8");
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /** Read the first existing per-task artifact; the new tree wins per file. */
66
+ export function readTaskArtifact(
67
+ root: string,
68
+ configDir: string,
69
+ taskId: string,
70
+ relative: string,
71
+ ): string | undefined {
72
+ for (const dir of taskReadDirs(root, configDir, taskId)) {
73
+ const text = readIfExists(join(dir, relative));
74
+ if (text !== undefined) return text;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ export function saveTask(root: string, configDir: string, task: Task): void {
80
+ writeFileEnsured(join(taskDir(dataRoot(root, configDir), task.id), "state.json"), JSON.stringify(task, null, 2));
81
+ }
82
+
83
+ /** Read a task state: the bot-lobby copy wins, else the newest pre-rename copy. */
84
+ export function loadTask(root: string, configDir: string, taskId: string): Task | undefined {
85
+ for (const dr of readDataRoots(root, configDir)) {
86
+ const path = join(taskDir(dr, taskId), "state.json");
87
+ if (!existsSync(path)) continue;
88
+ try {
89
+ return JSON.parse(readFileSync(path, "utf8")) as Task;
90
+ } catch {
91
+ // A bot-lobby state.json that exists but cannot be parsed is surfaced, not shadowed.
92
+ return undefined;
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /** Enforce the scratchpad size cap (§20) before writing. */
99
+ export function capScratchpad(content: string, cfg: KnowledgeConfig): string {
100
+ const paragraphs = content.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
101
+ const limited = paragraphs.slice(0, cfg.scratchpadMaxParagraphs).join("\n\n");
102
+ if (limited.length <= cfg.scratchpadMaxChars) return limited;
103
+ return `${limited.slice(0, cfg.scratchpadMaxChars)}\n\n[truncated]`;
104
+ }
105
+
106
+ export function writeScratchpad(dir: string, domain: Domain, content: string, cfg: KnowledgeConfig): void {
107
+ writeFileEnsured(scratchpadPath(dir, domain), capScratchpad(content, cfg));
108
+ }
109
+
110
+ /** Read one task dir's state.json; missing and unreadable both yield undefined. */
111
+ function readTaskAt(dir: string): Task | undefined {
112
+ const path = join(dir, "state.json");
113
+ if (!existsSync(path)) return undefined;
114
+ try {
115
+ return JSON.parse(readFileSync(path, "utf8")) as Task;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Task dirs across the merged read roots, bot-lobby first and winning per
123
+ * entry, so a legacy task stays visible until its id exists in the new tree.
124
+ */
125
+ function taskEntries(root: string, configDir: string): { entry: string; dir: string }[] {
126
+ const entries: { entry: string; dir: string }[] = [];
127
+ const seen = new Set<string>();
128
+ for (const dr of readDataRoots(root, configDir)) {
129
+ const dir = tasksRoot(dr);
130
+ if (!existsSync(dir)) continue;
131
+ for (const entry of readdirSync(dir)) {
132
+ if (seen.has(entry)) continue;
133
+ seen.add(entry);
134
+ entries.push({ entry, dir: join(dir, entry) });
135
+ }
136
+ }
137
+ return entries;
138
+ }
139
+
140
+ /** All tasks on disk, newest first. Unreadable task dirs are skipped. */
141
+ export function listTasks(root: string, configDir: string): Task[] {
142
+ const tasks: Task[] = [];
143
+ for (const { entry, dir } of taskEntries(root, configDir)) {
144
+ const task = readTaskAt(dir);
145
+ if (task) tasks.push(task);
146
+ }
147
+ return tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
148
+ }
149
+
150
+ /** Tasks on disk plus the ids whose state.json could not be read (§59). */
151
+ export function taskHealth(root: string, configDir: string): { tasks: Task[]; corrupted: string[] } {
152
+ const tasks: Task[] = [];
153
+ const corrupted: string[] = [];
154
+ for (const { entry, dir } of taskEntries(root, configDir)) {
155
+ const task = readTaskAt(dir);
156
+ if (!task || task.id !== entry || !isTaskState(task.state)) corrupted.push(entry);
157
+ else tasks.push(task);
158
+ }
159
+ return { tasks: tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), corrupted };
160
+ }
161
+
162
+ /** The task the Master is currently driving, if any. */
163
+ export function activeTask(root: string, configDir: string, sessionId?: string): Task | undefined {
164
+ if (sessionId !== undefined) return ownedTask(root, configDir, sessionId);
165
+ return listTasks(root, configDir).find((task) => !TERMINAL_STATES.includes(task.state));
166
+ }
167
+
168
+ /** The non-terminal task a session owns, if any. */
169
+ export function ownedTask(root: string, configDir: string, sessionId: string): Task | undefined {
170
+ return listTasks(root, configDir).find((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId === sessionId);
171
+ }
172
+
173
+ /** The newest non-terminal task with no owner, if any. */
174
+ export function ownerlessTask(root: string, configDir: string): Task | undefined {
175
+ return listTasks(root, configDir).find((task) => !TERMINAL_STATES.includes(task.state) && !task.ownerSessionId);
176
+ }
177
+
178
+ /** A specific task, or the session's active one when no id is given. */
179
+ export function selectTask(root: string, configDir: string, taskId?: string, sessionId?: string): Task | undefined {
180
+ return taskId ? loadTask(root, configDir, taskId) : activeTask(root, configDir, sessionId);
181
+ }
182
+
183
+ /** Reassign a task to a session; explicit takeover for orphaned tasks. */
184
+ export function claimTask(root: string, configDir: string, taskId: string, sessionId: string): Task | undefined {
185
+ const task = loadTask(root, configDir, taskId);
186
+ if (!task) return undefined;
187
+ task.ownerSessionId = sessionId;
188
+ task.updatedAt = new Date().toISOString();
189
+ saveTask(root, configDir, task);
190
+ return task;
191
+ }
192
+
193
+ /** Dash-slug of a request: lowercase, non-alphanumerics collapsed, capped at `max`. */
194
+ export function taskSlug(request: string, max = 40): string {
195
+ return request
196
+ .toLowerCase()
197
+ .replace(/[^a-z0-9]+/g, "-")
198
+ .replace(/^-+|-+$/g, "")
199
+ .slice(0, max)
200
+ .replace(/-+$/, "");
201
+ }
202
+
203
+ /**
204
+ * Task id from the request (`TASK-<slug>`); requests with no usable slug fall
205
+ * back to the timestamped `TASK-task-<timestamp>` form callers already suffix.
206
+ */
207
+ export function nextTaskId(request: string, now = new Date()): string {
208
+ const slug = taskSlug(request);
209
+ if (slug.length > 0) return `TASK-${slug}`;
210
+ return `TASK-task-${now.toISOString().replace(/[-:T]/g, "").slice(0, 14)}`;
211
+ }
212
+
213
+ /** Files that make up a task's temporary working state (§20). */
214
+ const TASK_ARTIFACTS = ["proposal.md", "plan.md", "designer.md", "backend.md", "qa.md"];
215
+
216
+ /**
217
+ * §20: on completion the scratchpads are deleted while the distilled
218
+ * knowledge, decisions, and the task's own state.json record are retained.
219
+ */
220
+ export function removeTaskScratchpads(root: string, configDir: string, taskId: string): void {
221
+ const dir = taskDir(dataRoot(root, configDir), taskId);
222
+ if (!existsSync(dir)) return;
223
+ for (const file of TASK_ARTIFACTS) rmSync(join(dir, file), { force: true });
224
+ for (const file of readdirSync(dir)) {
225
+ if (file.startsWith("scout-")) rmSync(join(dir, file), { force: true });
226
+ }
227
+ }
228
+
229
+ /** Delete the temporary task dir after knowledge has been distilled. */
230
+ export function cleanupTaskDir(root: string, configDir: string, taskId: string): void {
231
+ rmSync(taskDir(dataRoot(root, configDir), taskId), { recursive: true, force: true });
232
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { DEFAULT_CONFIG, resolveConfig, type BotLobbyConfig } from "../schemas/configuration.ts";
5
+
6
+ const DEFAULT_CONFIG_DIR = ".pi";
7
+
8
+ /** Live per-project tree name; every write targets it. */
9
+ const DATA_DIR = "bot-lobby";
10
+
11
+ /** Pre-rename per-project tree names, newest first; read-only fallbacks. */
12
+ const LEGACY_DATA_DIRS = ["dev-lobby", "dev-house"] as const;
13
+
14
+ type LegacyDataDir = (typeof LEGACY_DATA_DIRS)[number];
15
+
16
+ /** Walk up to the nearest ancestor holding a config dir or git root. */
17
+ export function detectProjectRoot(cwd: string, configDir = DEFAULT_CONFIG_DIR): string {
18
+ let dir = resolve(cwd);
19
+ for (;;) {
20
+ if (existsSync(join(dir, configDir)) || existsSync(join(dir, ".git"))) return dir;
21
+ const parent = dirname(dir);
22
+ if (parent === dir) return resolve(cwd);
23
+ dir = parent;
24
+ }
25
+ }
26
+
27
+ /** Root of all per-project bot-lobby data: <root>/<configDir>/bot-lobby. Every write targets it. */
28
+ export function dataRoot(root: string, configDir = DEFAULT_CONFIG_DIR): string {
29
+ return join(root, configDir, DATA_DIR);
30
+ }
31
+
32
+ /** One pre-rename per-project root by tree name; read-only. */
33
+ export function legacyDataRoot(root: string, configDir = DEFAULT_CONFIG_DIR, name: LegacyDataDir = LEGACY_DATA_DIRS[0]): string {
34
+ return join(root, configDir, name);
35
+ }
36
+
37
+ /** Pre-rename trees that exist on disk, newest first. */
38
+ function existingLegacyRoots(root: string, configDir: string): string[] {
39
+ return LEGACY_DATA_DIRS.map((name) => legacyDataRoot(root, configDir, name)).filter((dir) => existsSync(dir));
40
+ }
41
+
42
+ /**
43
+ * Roots that merged reads walk, newest first: the bot-lobby tree always, then
44
+ * every existing pre-rename tree (dev-lobby, then dev-house). Readers take the
45
+ * first root that has a task id or a knowledge file, so pre-rename data stays
46
+ * visible per task and per file instead of vanishing as soon as any bot-lobby
47
+ * data exists.
48
+ */
49
+ export function readDataRoots(root: string, configDir = DEFAULT_CONFIG_DIR): string[] {
50
+ return [dataRoot(root, configDir), ...existingLegacyRoots(root, configDir)];
51
+ }
52
+
53
+ /** Newest existing pre-rename tree, if any; seeding reads it, writes never touch it. */
54
+ export function firstLegacyDataRoot(root: string, configDir = DEFAULT_CONFIG_DIR): string | undefined {
55
+ return existingLegacyRoots(root, configDir)[0];
56
+ }
57
+
58
+ /**
59
+ * Root of the user-global bot-lobby config. Config is global so one setup
60
+ * applies to every project; tasks and knowledge stay per-project. Tests point
61
+ * `BOT_LOBBY_CONFIG_DIR` at a temp directory.
62
+ */
63
+ export function globalConfigDir(): string {
64
+ return process.env.BOT_LOBBY_CONFIG_DIR ?? join(homedir(), ".pi", "bot-lobby");
65
+ }
66
+
67
+ export function globalConfigPath(): string {
68
+ return join(globalConfigDir(), "config.json");
69
+ }
70
+
71
+ /**
72
+ * Config source for reads: the bot-lobby config when it exists, otherwise the
73
+ * newest existing pre-rename one (dev-lobby, then dev-house). An explicit
74
+ * `BOT_LOBBY_CONFIG_DIR` overrides (tests, custom setups) and never falls back,
75
+ * so legacy dirs stay out of hermetic tests.
76
+ */
77
+ function configSourcePath(): string {
78
+ if (process.env.BOT_LOBBY_CONFIG_DIR !== undefined) return globalConfigPath();
79
+ const candidates = [globalConfigPath(), ...LEGACY_DATA_DIRS.map((dir) => join(homedir(), ".pi", dir, "config.json"))];
80
+ return candidates.find((path) => existsSync(path)) ?? candidates[0]!;
81
+ }
82
+
83
+ /** Load the global config; fall back to defaults on any read/parse error. */
84
+ export function loadConfig(): BotLobbyConfig {
85
+ try {
86
+ return resolveConfig(JSON.parse(readFileSync(configSourcePath(), "utf8")));
87
+ } catch {
88
+ return DEFAULT_CONFIG;
89
+ }
90
+ }
91
+
92
+ /** Persist the global config through a temp file + rename so a crash cannot truncate it. */
93
+ export function saveConfig(config: BotLobbyConfig): void {
94
+ mkdirSync(globalConfigDir(), { recursive: true });
95
+ const target = globalConfigPath();
96
+ const tmp = `${target}.tmp`;
97
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`);
98
+ renameSync(tmp, target);
99
+ }
@@ -0,0 +1,22 @@
1
+ import type { Task, TaskState } from "../schemas/task.ts";
2
+ import { assertTransition } from "../workflow/transitions.ts";
3
+
4
+ /**
5
+ * Single mutation point for task state. Every state change flows through here
6
+ * so the engine — not any agent — is the only thing that can move a task.
7
+ */
8
+ export type TransitionListener = (task: Task, to: TaskState) => void;
9
+
10
+ let listener: TransitionListener | undefined;
11
+
12
+ /** Install the single transition observer (the extension's attention notifier). */
13
+ export function onTransition(fn: TransitionListener): void {
14
+ listener = fn;
15
+ }
16
+
17
+ export function transition(task: Task, to: TaskState, now = new Date().toISOString()): void {
18
+ assertTransition(task.state, to);
19
+ task.state = to;
20
+ task.updatedAt = now;
21
+ listener?.(task, to);
22
+ }
package/src/text.ts ADDED
@@ -0,0 +1,51 @@
1
+ /** Truncate text to a character budget, marking how much was omitted. */
2
+ export function truncate(text: string, maxChars: number): string {
3
+ if (maxChars <= 0) return "";
4
+ if (text.length <= maxChars) return text;
5
+ return `${text.slice(0, maxChars)}\n[...${text.length - maxChars} characters omitted]`;
6
+ }
7
+
8
+ /** Filler and imperative words that carry no topic signal in a request. */
9
+ const FILLER_WORDS = new Set([
10
+ "let's",
11
+ "lets",
12
+ "please",
13
+ "can",
14
+ "you",
15
+ "could",
16
+ "i",
17
+ "we",
18
+ "want",
19
+ "need",
20
+ "would",
21
+ "like",
22
+ "to",
23
+ "help",
24
+ "me",
25
+ "a",
26
+ "an",
27
+ "the",
28
+ "for",
29
+ "our",
30
+ "my",
31
+ ]);
32
+
33
+ /** Case- and punctuation-insensitive key used to match a word against the fillers. */
34
+ function fillerKey(word: string): string {
35
+ return word
36
+ .toLowerCase()
37
+ .replace(/[\u2018\u2019\u02bc]/g, "'")
38
+ .replace(/[^a-z0-9']/g, "");
39
+ }
40
+
41
+ /**
42
+ * The first `maxWords` content words of a request, used as the short task title.
43
+ * Deterministic and total: whitespace-only input yields `""`, and a request
44
+ * made entirely of filler words falls back to its raw first words.
45
+ */
46
+ export function shortTitle(request: string, maxWords = 3): string {
47
+ if (maxWords <= 0) return "";
48
+ const words = request.trim().split(/\s+/).filter(Boolean);
49
+ const content = words.filter((word) => !FILLER_WORDS.has(fillerKey(word)));
50
+ return (content.length > 0 ? content : words).slice(0, maxWords).join(" ");
51
+ }
@@ -0,0 +1,45 @@
1
+ import type { Approval, ApprovalKind, Task } from "../schemas/task.ts";
2
+ import type { Domain } from "../schemas/agent.ts";
3
+
4
+ /** Record a Worker-requested exception so it blocks further work until resolved. */
5
+ export function requestApproval(
6
+ task: Task,
7
+ kind: ApprovalKind,
8
+ domain: Domain,
9
+ detail: string,
10
+ now = new Date().toISOString(),
11
+ ): Approval {
12
+ const approval: Approval = { id: `APR-${task.approvals.length + 1}`, kind, domain, detail, status: "pending", createdAt: now };
13
+ task.approvals.push(approval);
14
+ return approval;
15
+ }
16
+
17
+ export function pendingApprovals(task: Task, domain?: Domain): Approval[] {
18
+ return task.approvals.filter(
19
+ (approval) => approval.status === "pending" && (domain === undefined || approval.domain === domain),
20
+ );
21
+ }
22
+
23
+ export function resolveApproval(
24
+ task: Task,
25
+ id: string,
26
+ status: "approved" | "rejected",
27
+ note?: string,
28
+ ): Approval | undefined {
29
+ const approval = task.approvals.find((entry) => entry.id === id && entry.status === "pending");
30
+ if (!approval) return undefined;
31
+ approval.status = status;
32
+ approval.note = note;
33
+ return approval;
34
+ }
35
+
36
+ /**
37
+ * Engine gate: a domain cannot keep implementing while it has an unresolved
38
+ * dependency or architecture request.
39
+ */
40
+ export function assertNoPendingApprovals(task: Task, domain: Domain): void {
41
+ const pending = pendingApprovals(task, domain);
42
+ if (pending.length === 0) return;
43
+ const list = pending.map((approval) => `${approval.id} (${approval.kind}): ${approval.detail}`).join("; ");
44
+ throw new Error(`${domain} has unresolved approvals — resolve them first: ${list}`);
45
+ }
@@ -0,0 +1,41 @@
1
+ import { TERMINAL_STATES, type TaskState } from "../schemas/task.ts";
2
+
3
+ /**
4
+ * Workflow transitions per the build plan §8. These are the only legal
5
+ * workflow moves; the engine enforces them regardless of what any agent asks.
6
+ */
7
+ export const TRANSITIONS: Record<TaskState, readonly TaskState[]> = {
8
+ created: ["clarifying"],
9
+ clarifying: ["scouting", "awaiting_approval"],
10
+ scouting: ["synthesizing"],
11
+ synthesizing: ["awaiting_approval"],
12
+ awaiting_approval: ["planning", "abandoned"],
13
+ planning: ["implementing"],
14
+ implementing: ["reviewing", "blocked"],
15
+ reviewing: ["implementing", "completed", "blocked"],
16
+ blocked: ["implementing", "abandoned"],
17
+ completed: [],
18
+ abandoned: [],
19
+ };
20
+
21
+ /** User cancellation / decline abandons from any non-terminal state. */
22
+ export function canAbandon(state: TaskState): boolean {
23
+ return !TERMINAL_STATES.includes(state);
24
+ }
25
+
26
+ export function canTransition(from: TaskState, to: TaskState): boolean {
27
+ if (to === "abandoned") return canAbandon(from);
28
+ if (from === to) return !TERMINAL_STATES.includes(from);
29
+ return (TRANSITIONS[from] as readonly TaskState[]).includes(to);
30
+ }
31
+
32
+ export function nextStates(from: TaskState): readonly TaskState[] {
33
+ const base = TRANSITIONS[from];
34
+ return canAbandon(from) && !base.includes("abandoned") ? [...base, "abandoned"] : base;
35
+ }
36
+
37
+ export function assertTransition(from: TaskState, to: TaskState): void {
38
+ if (!canTransition(from, to)) {
39
+ throw new Error(`Invalid state transition: ${from} -> ${to}`);
40
+ }
41
+ }