@saadnvd1/agent-os 0.3.533 → 0.3.534

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 (83) hide show
  1. package/README.md +68 -2
  2. package/app/api/features/pieces/[id]/release/route.ts +16 -0
  3. package/app/api/features/route.ts +8 -0
  4. package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
  5. package/app/api/recipes/[id]/preview/route.ts +19 -0
  6. package/app/api/recipes/[id]/route.ts +51 -0
  7. package/app/api/recipes/[id]/run/route.ts +35 -0
  8. package/app/api/recipes/[id]/runs/route.ts +23 -0
  9. package/app/api/recipes/draft/route.ts +14 -0
  10. package/app/api/recipes/route.ts +35 -0
  11. package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
  12. package/components/CommandPalette/useAppCommands.ts +20 -0
  13. package/components/Features/FeaturesSection.tsx +100 -0
  14. package/components/Features/index.ts +1 -0
  15. package/components/Recipes/RecipeForm.tsx +251 -0
  16. package/components/Recipes/RecipePage.tsx +127 -0
  17. package/components/Recipes/RecipesPanel.tsx +225 -0
  18. package/components/Recipes/RunChip.tsx +16 -0
  19. package/components/Recipes/RunHistory.tsx +88 -0
  20. package/components/Recipes/RunSheet.tsx +84 -0
  21. package/components/Recipes/TimerFields.tsx +193 -0
  22. package/components/Recipes/format.ts +51 -0
  23. package/components/Recipes/index.ts +1 -0
  24. package/components/Schedules/SchedulesPanel.tsx +1 -0
  25. package/components/SessionList/RowSubtitle.tsx +16 -0
  26. package/components/SessionList/SessionRowMenu.tsx +29 -0
  27. package/components/SessionList/SidebarHeader.tsx +5 -0
  28. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  29. package/components/Settings/SettingsDialog.tsx +9 -0
  30. package/components/Tasks/PlanReview.tsx +253 -0
  31. package/components/Tasks/TaskRow.tsx +19 -44
  32. package/components/Tasks/TasksDialog.tsx +2 -0
  33. package/data/features/index.ts +40 -0
  34. package/data/push/topics.ts +8 -0
  35. package/data/recipes/index.ts +129 -0
  36. package/data/tasks/index.ts +14 -0
  37. package/lib/away/run.ts +4 -1
  38. package/lib/db/changes.ts +8 -0
  39. package/lib/db/migrations.ts +127 -0
  40. package/lib/db/queries.ts +4 -1
  41. package/lib/db/types.ts +6 -0
  42. package/lib/models/plan-step.ts +67 -6
  43. package/lib/notify/copy.ts +28 -10
  44. package/lib/notify/push/events.ts +36 -2
  45. package/lib/notify/push/policy.ts +2 -0
  46. package/lib/notify/push-types.ts +7 -3
  47. package/lib/orchestrator/brief.ts +13 -0
  48. package/lib/orchestrator/conditions.ts +3 -1
  49. package/lib/orchestrator/deliver.ts +2 -0
  50. package/lib/orchestrator/digest.ts +27 -1
  51. package/lib/orchestrator/plan-pieces.ts +41 -0
  52. package/lib/orchestrator/recipe-tools.ts +159 -0
  53. package/lib/orchestrator/review.ts +2 -1
  54. package/lib/orchestrator/serve.ts +25 -0
  55. package/lib/orchestrator/tool-names.ts +5 -0
  56. package/lib/orchestrator/tool-schemas.ts +54 -0
  57. package/lib/orchestrator/tools.ts +10 -0
  58. package/lib/plans/features.ts +200 -0
  59. package/lib/plans/pieces.ts +366 -0
  60. package/lib/plans/release.ts +323 -0
  61. package/lib/plans/view.ts +85 -0
  62. package/lib/recipes/api.ts +19 -0
  63. package/lib/recipes/chip.ts +40 -0
  64. package/lib/recipes/compose.ts +78 -0
  65. package/lib/recipes/draft.ts +93 -0
  66. package/lib/recipes/index.ts +54 -0
  67. package/lib/recipes/result.ts +59 -0
  68. package/lib/recipes/run.ts +202 -0
  69. package/lib/recipes/settle.ts +465 -0
  70. package/lib/recipes/store.ts +378 -0
  71. package/lib/recipes/test-fixtures.ts +49 -0
  72. package/lib/recipes/timer.ts +107 -0
  73. package/lib/recipes/types.ts +116 -0
  74. package/lib/schedules/index.ts +4 -1
  75. package/lib/schedules/start.ts +33 -9
  76. package/lib/schedules/store.ts +6 -2
  77. package/lib/security/demo.ts +1 -0
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/tasks/loop-types.ts +23 -0
  80. package/lib/tasks/plan-view.ts +38 -1
  81. package/package.json +1 -1
  82. package/server.ts +2 -0
  83. package/stores/recipesUi.ts +53 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The orchestrator's recipe tools: list, run, save and edit. A recipe is a
3
+ * saved brief, never an approval: a run goes through the same brakes, gates,
4
+ * Pause and task queue as a task the orchestrator started itself. It sets no
5
+ * trigger; timers are Saad's.
6
+ */
7
+
8
+ import { formatRunTime } from "../schedules/cron";
9
+ import { workspaceProject } from "./targets";
10
+ import { getOrchestrator } from "./home";
11
+ import { untrusted } from "./untrusted";
12
+ import {
13
+ AlreadyRunning,
14
+ createRecipe,
15
+ findRecipe,
16
+ getTimer,
17
+ listRecipes,
18
+ listRuns,
19
+ startRun,
20
+ updateRecipe,
21
+ type Recipe,
22
+ type RecipeRun,
23
+ } from "../recipes";
24
+ import { outputsText } from "../recipes/result";
25
+
26
+ const when = (iso: string | null) =>
27
+ iso
28
+ ? formatRunTime(Date.parse(`${iso.replace(" ", "T")}Z`) || Date.now())
29
+ : "?";
30
+
31
+ function runLine(r: RecipeRun, recipe: Recipe): string {
32
+ const facts = outputsText(r.outputs);
33
+ const bits = [facts, r.summary].filter(Boolean).join(". ");
34
+ const text = bits ? `: ${untrusted(recipe.name, bits)}` : "";
35
+ const pr = r.pr_number ? ` PR #${r.pr_number}` : "";
36
+ const by =
37
+ r.trigger === "timer" || r.trigger === "catch-up"
38
+ ? "timer"
39
+ : r.trigger === "tool"
40
+ ? "you"
41
+ : "Saad";
42
+ return `#${r.seq} ${r.status} (${when(r.ended_at ?? r.started_at ?? r.created_at)}, by ${by})${pr}${text}`;
43
+ }
44
+
45
+ function summaryLine(recipe: Recipe): string {
46
+ const [last] = listRuns(recipe.id, { limit: 1 });
47
+ const timer = getTimer(recipe.id);
48
+ const next =
49
+ timer?.enabled && timer.nextRunAt
50
+ ? `, next ${formatRunTime(timer.nextRunAt, timer.timezone)}`
51
+ : "";
52
+ return `- "${recipe.name}" (${recipe.kind}${last ? `; last ${runLine(last, recipe)}` : "; never run"}${next})`;
53
+ }
54
+
55
+ // `recipes`: every recipe, or one in full with its brief and recent runs.
56
+ export function recipesText(w: string, name?: string): string {
57
+ if (name) {
58
+ const r = findRecipe(w, name);
59
+ const timer = getTimer(r.id);
60
+ const runs = listRuns(r.id, { limit: 5 });
61
+ return [
62
+ `Recipe "${r.name}" (${r.kind}, ${r.view}, project ${projectName(w, r)})${r.model ? `, model ${r.model}` : ""}${r.max_minutes ? `, time limit ${r.max_minutes} min` : ""}.`,
63
+ timer
64
+ ? `Timer: ${timer.description} ${timer.timezone}${timer.enabled ? "" : " (off)"}, set by Saad.`
65
+ : "No timer.",
66
+ `Brief:\n${r.brief}`,
67
+ runs.length
68
+ ? `Recent runs:\n${runs.map((x) => `- ${runLine(x, r)}`).join("\n")}`
69
+ : "Never run.",
70
+ ].join("\n");
71
+ }
72
+ const all = listRecipes(w);
73
+ if (!all.length)
74
+ return "No recipes yet. Save one with recipe_save when a job comes up a second time.";
75
+ return all.map(summaryLine).join("\n");
76
+ }
77
+
78
+ const projectName = (w: string, r: Recipe) =>
79
+ workspaceProject(w, r.project_id).name;
80
+
81
+ export async function runRecipe(
82
+ w: string,
83
+ name: string,
84
+ note?: string
85
+ ): Promise<string> {
86
+ const recipe = findRecipe(w, name);
87
+ const orchestrator = getOrchestrator(w);
88
+ try {
89
+ const { run, queued } = await startRun(recipe.id, {
90
+ trigger: "tool",
91
+ actorSessionId: orchestrator?.id ?? null,
92
+ note,
93
+ });
94
+ return queued
95
+ ? `Queued run #${run.seq} of "${recipe.name}" behind the task limit; it starts by itself. You'll get an event when it finishes.`
96
+ : `Started run #${run.seq} of "${recipe.name}" (${recipe.kind}). You'll get an event when it finishes${recipe.kind === "task" ? "; its PR goes through the gates like any task's" : ""}.`;
97
+ } catch (error) {
98
+ if (error instanceof AlreadyRunning)
99
+ return `"${recipe.name}" already has run #${error.run.seq} going. Not starting another; you'll get an event when it finishes.`;
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ export function saveRecipe(
105
+ w: string,
106
+ a: {
107
+ name: string;
108
+ project: string;
109
+ kind: "task" | "session";
110
+ brief: string;
111
+ model?: string;
112
+ view?: "chat" | "terminal";
113
+ }
114
+ ): string {
115
+ const project = workspaceProject(w, a.project);
116
+ const orchestrator = getOrchestrator(w);
117
+ const recipe = createRecipe(
118
+ {
119
+ workspaceId: w,
120
+ projectId: project.id,
121
+ name: a.name,
122
+ kind: a.kind,
123
+ brief: a.brief,
124
+ model: a.model,
125
+ view: a.view,
126
+ },
127
+ orchestrator?.id ?? null
128
+ );
129
+ return `Saved recipe "${recipe.name}" (${recipe.kind}, ${project.name}). Run it with run_recipe; Saad sees it in Recipes and sets any timer.`;
130
+ }
131
+
132
+ export function updateRecipeTool(
133
+ w: string,
134
+ a: {
135
+ name: string;
136
+ brief?: string;
137
+ new_name?: string;
138
+ model?: string;
139
+ view?: "chat" | "terminal";
140
+ auto_done?: boolean;
141
+ max_minutes?: number;
142
+ }
143
+ ): string {
144
+ const recipe = findRecipe(w, a.name);
145
+ const orchestrator = getOrchestrator(w);
146
+ updateRecipe(
147
+ recipe.id,
148
+ {
149
+ brief: a.brief,
150
+ name: a.new_name,
151
+ model: a.model,
152
+ view: a.view,
153
+ autoDone: a.auto_done,
154
+ maxMinutes: a.max_minutes,
155
+ },
156
+ orchestrator?.id ?? null
157
+ );
158
+ return `Updated recipe "${a.new_name ?? recipe.name}". Earlier versions of the brief are kept; Saad sees the change.`;
159
+ }
@@ -10,6 +10,7 @@
10
10
  * the orchestrator hears the verdict as an event.
11
11
  */
12
12
 
13
+ import { reviewGoal } from "../plans/features";
13
14
  import { pushReviewBlocked, pushReviewReady } from "../notify/push/events";
14
15
  import { db, type Session } from "../db";
15
16
  import { amendmentsOf } from "./amendments";
@@ -319,7 +320,7 @@ export function taskReviewTarget(task: Session, pr: TaskPR): ReviewTarget {
319
320
  id: task.id,
320
321
  name: task.name,
321
322
  label: `task ${task.name}`,
322
- goal: task.task_prompt ?? task.name,
323
+ goal: reviewGoal(task.id) ?? task.task_prompt ?? task.name,
323
324
  repo: repoOf(task),
324
325
  refs: task,
325
326
  pr,
@@ -32,6 +32,13 @@ import { land } from "./signoff";
32
32
  import { arm, disarm } from "./armed";
33
33
  import { quickChange, quickPR } from "./quick";
34
34
  import { review } from "./review";
35
+ import { planPiecesText } from "./plan-pieces";
36
+ import {
37
+ recipesText,
38
+ runRecipe,
39
+ saveRecipe,
40
+ updateRecipeTool,
41
+ } from "./recipe-tools";
35
42
  import { cancelReminder, listReminders, remindMe } from "./reminders";
36
43
  import { parseArgs as p, type ToolName } from "./tool-schemas";
37
44
 
@@ -44,6 +51,9 @@ const ACTING = new Set<ToolName>([
44
51
  "start_task",
45
52
  "set_task_model",
46
53
  "start_session",
54
+ "run_recipe",
55
+ "recipe_save",
56
+ "recipe_update",
47
57
  "quick_change",
48
58
  "quick_pr",
49
59
  "stack",
@@ -51,6 +61,7 @@ const ACTING = new Set<ToolName>([
51
61
  "drop",
52
62
  "move",
53
63
  "approve_plan",
64
+ "plan_pieces",
54
65
  "stop",
55
66
  "done",
56
67
  "review",
@@ -126,6 +137,10 @@ export async function runTool(
126
137
  const task = workspaceTask(w, a.task);
127
138
  return approveTaskPlan(task, a.message);
128
139
  }
140
+ case "plan_pieces": {
141
+ const a = p(tool, raw);
142
+ return planPiecesText(workspaceTask(w, a.task), a.merge, a.drop);
143
+ }
129
144
  case "set_task_model": {
130
145
  const a = p(tool, raw);
131
146
  const task = workspaceTask(w, a.task);
@@ -196,6 +211,16 @@ export async function runTool(
196
211
  const a = p(tool, raw);
197
212
  return remindMe(w, a.at, a.prompt, a.name);
198
213
  }
214
+ case "recipes":
215
+ return recipesText(w, p(tool, raw).name);
216
+ case "run_recipe": {
217
+ const a = p(tool, raw);
218
+ return runRecipe(w, a.name, a.note);
219
+ }
220
+ case "recipe_save":
221
+ return saveRecipe(w, p(tool, raw));
222
+ case "recipe_update":
223
+ return updateRecipeTool(w, p(tool, raw));
199
224
  case "reminders":
200
225
  return listReminders(w);
201
226
  case "cancel_reminder":
@@ -23,6 +23,7 @@ export const TOOL_NAMES = {
23
23
  stack_status: name("stack_status"),
24
24
  land: name("land"),
25
25
  approve_plan: name("approve_plan"),
26
+ plan_pieces: name("plan_pieces"),
26
27
  drop: name("drop"),
27
28
  move: name("move"),
28
29
  stop: name("stop"),
@@ -38,6 +39,10 @@ export const TOOL_NAMES = {
38
39
  arm: name("arm"),
39
40
  disarm: name("disarm"),
40
41
  remind_me: name("remind_me"),
42
+ recipes: name("recipes"),
43
+ run_recipe: name("run_recipe"),
44
+ recipe_save: name("recipe_save"),
45
+ recipe_update: name("recipe_update"),
41
46
  reminders: name("reminders"),
42
47
  cancel_reminder: name("cancel_reminder"),
43
48
  ask_saad: name("ask_saad"),
@@ -210,6 +210,17 @@ export const TOOL_SHAPES = {
210
210
  "Amends the plan: recorded on the task's brief as a scope change, so its review judges the PR against it. Only for a change Saad made"
211
211
  ),
212
212
  },
213
+ plan_pieces: {
214
+ task,
215
+ merge: z
216
+ .array(z.number().int().min(2).max(6))
217
+ .optional()
218
+ .describe("Waiting pieces to fold into the piece before them"),
219
+ drop: z
220
+ .array(z.number().int().min(2).max(6))
221
+ .optional()
222
+ .describe("Waiting pieces to leave out; pieces after them are held"),
223
+ },
213
224
  drop: { task, reason: z.string().trim().min(1).max(2000) },
214
225
  move: {
215
226
  task,
@@ -275,6 +286,49 @@ export const TOOL_SHAPES = {
275
286
  .describe("What to remind yourself of, in words you'll understand later"),
276
287
  name: z.string().trim().min(1).max(80).optional(),
277
288
  },
289
+ recipes: {
290
+ name: ref("A recipe's name or id; leave out to list them all").optional(),
291
+ },
292
+ run_recipe: {
293
+ name: ref("The recipe's name or id, from recipes"),
294
+ note: z
295
+ .string()
296
+ .trim()
297
+ .min(1)
298
+ .max(1000)
299
+ .optional()
300
+ .describe(
301
+ 'A one-off line for this run only ("Includes #87"); the brief stays as saved'
302
+ ),
303
+ },
304
+ recipe_save: {
305
+ name: z.string().trim().min(1).max(80).describe("2-6 words, no quotes"),
306
+ project,
307
+ kind: z
308
+ .enum(["task", "session"])
309
+ .describe(
310
+ "task ends in a PR through the gates; session changes no code and has no PR"
311
+ ),
312
+ brief: z
313
+ .string()
314
+ .trim()
315
+ .min(1)
316
+ .max(4000)
317
+ .describe(
318
+ "Short: what to run and how to judge it. The steps belong in a script in the repo"
319
+ ),
320
+ model: modelRef.optional(),
321
+ view,
322
+ },
323
+ recipe_update: {
324
+ name: ref("The recipe's name or id"),
325
+ brief: z.string().trim().min(1).max(4000).optional(),
326
+ new_name: z.string().trim().min(1).max(80).optional(),
327
+ model: modelRef.optional(),
328
+ view,
329
+ auto_done: z.boolean().optional(),
330
+ max_minutes: z.number().int().min(1).max(1440).optional(),
331
+ },
278
332
  reminders: {},
279
333
  cancel_reminder: { id: ref("The reminder's id, from reminders") },
280
334
  review: {
@@ -77,6 +77,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
77
77
  land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
78
78
  approve_plan:
79
79
  'Approve the plan of a plan_only task that is waiting ("plan ready"): it continues on the implementation model, once. An optional message amends the plan and is recorded on the task\'s brief as a scope change. Reject a plan with drop.',
80
+ plan_pieces:
81
+ "A task whose plan split into pieces: each piece, its repo, what it waits on and its state. With merge or drop (position numbers) it edits the pieces still waiting: a merged piece's slice goes to the piece before it, a dropped one is left out and anything waiting on it is held. It can't add pieces or change reasons.",
80
82
  drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
81
83
  move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
82
84
  stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
@@ -90,6 +92,14 @@ const DESCRIPTIONS: Record<ToolName, string> = {
90
92
  "Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
91
93
  remind_me:
92
94
  "Set a one-off reminder for yourself in this workspace: at the time given it reaches you once as a scheduled prompt (not an approval), even if AgentOS was off then. At least 10 minutes ahead; a few at a time at most.",
95
+ recipes:
96
+ "This workspace's recipes: saved briefs for jobs that come up again (a TestFlight build, an audit, a report), each with its last run and next timer. With a name, one recipe in full: its brief and recent runs.",
97
+ run_recipe:
98
+ "Run a recipe now: a fresh task or session starts from its saved brief, plus the last run's reported facts and an optional one-off note. Braked, queued and gated like start_task and start_session. A recipe is never an approval. One run at a time; while it is going this says so. You get an event when it finishes.",
99
+ recipe_save:
100
+ "Save a recipe when a job comes up a second time. A short brief (the steps belong in a script in the repo), a kind (task: ends in a PR; session: no PR) and a project. It sets no timer: Saad does.",
101
+ recipe_update:
102
+ "Change a recipe's brief or settings (never its timer). Earlier versions are kept and Saad sees the change.",
93
103
  reminders:
94
104
  "List this workspace's upcoming reminders and the last few that ran.",
95
105
  cancel_reminder:
@@ -0,0 +1,200 @@
1
+ // A plan that split into pieces: the feature, its pieces, and the one-time
2
+ // checks that turn a plan file into rows. A one-piece plan makes none.
3
+
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ import { randomUUID } from "crypto";
8
+ import { db, type Session } from "../db";
9
+ import { loadProjectConfig } from "../project-config";
10
+ import {
11
+ applyEdits,
12
+ checkPieces,
13
+ parseEdits,
14
+ type CheckContext,
15
+ type PiecesResult,
16
+ type Piece,
17
+ type ProjectRef,
18
+ } from "./pieces";
19
+
20
+ export type PieceStatus =
21
+ | "waiting"
22
+ | "building"
23
+ | "merged"
24
+ | "blocked"
25
+ | "dropped";
26
+
27
+ export interface Feature {
28
+ id: string;
29
+ workspace_id: string;
30
+ name: string;
31
+ plan_session_id: string;
32
+ plan_text: string;
33
+ status: "building" | "done" | "dropped";
34
+ done_at: string | null;
35
+ cost_usd: number | null;
36
+ duration_ms: number | null;
37
+ created_at: string;
38
+ }
39
+
40
+ export interface FeaturePiece {
41
+ id: number;
42
+ feature_id: string;
43
+ position: number;
44
+ name: string;
45
+ slice: string;
46
+ project_id: string;
47
+ after_position: number | null;
48
+ after_kind: "merged" | "deployed" | null;
49
+ reasons: string | null;
50
+ owns: string | null;
51
+ status: PieceStatus;
52
+ session_id: string | null;
53
+ note: string | null;
54
+ }
55
+
56
+ const expandHome = (p: string) => p.replace(/^~/, os.homedir());
57
+
58
+ export const plansDir = () =>
59
+ process.env.AGENTOS_PLANS_DIR ||
60
+ path.join(os.homedir(), ".agent-os", "plans");
61
+ export const planPathOf = (featureId: string) =>
62
+ path.join(plansDir(), `${featureId}.md`);
63
+
64
+ function ref(id: string): ProjectRef | null {
65
+ const p = db
66
+ .prepare(`SELECT id, name, working_directory FROM projects WHERE id = ?`)
67
+ .get(id) as
68
+ | { id: string; name: string; working_directory: string }
69
+ | undefined;
70
+ if (!p) return null;
71
+ const deploy = loadProjectConfig(expandHome(p.working_directory)).config
72
+ .deploy;
73
+ return { id: p.id, name: p.name, deploys: !!deploy?.deployed };
74
+ }
75
+
76
+ export function checkContext(
77
+ s: Pick<Session, "project_id">
78
+ ): CheckContext | null {
79
+ const own = s.project_id ? ref(s.project_id) : null;
80
+ if (!own) return null;
81
+ const rows = db
82
+ .prepare(
83
+ `SELECT id, name FROM projects
84
+ WHERE workspace_id = (SELECT workspace_id FROM projects WHERE id = ?)`
85
+ )
86
+ .all(own.id) as { id: string; name: string }[];
87
+ return {
88
+ own,
89
+ find: (name) => {
90
+ const hit = rows.find(
91
+ (r) => r.name.toLowerCase() === name.trim().toLowerCase()
92
+ );
93
+ return hit ? ref(hit.id) : null;
94
+ },
95
+ };
96
+ }
97
+
98
+ // The plan's pieces after the checker, with Saad's edits applied. Null when
99
+ // there's no plan file or no project: one piece, as always.
100
+ export function planFor(s: Session, text: string): PiecesResult | null {
101
+ const ctx = checkContext(s);
102
+ return ctx ? checkPieces(text, ctx) : null;
103
+ }
104
+
105
+ export function getFeature(id: string): Feature | undefined {
106
+ return db.prepare(`SELECT * FROM features WHERE id = ?`).get(id) as
107
+ | Feature
108
+ | undefined;
109
+ }
110
+
111
+ export function piecesOf(featureId: string): FeaturePiece[] {
112
+ return db
113
+ .prepare(
114
+ `SELECT * FROM feature_pieces WHERE feature_id = ? ORDER BY position`
115
+ )
116
+ .all(featureId) as FeaturePiece[];
117
+ }
118
+
119
+ export function pieceOfSession(sessionId: string): FeaturePiece | undefined {
120
+ return db
121
+ .prepare(`SELECT * FROM feature_pieces WHERE session_id = ?`)
122
+ .get(sessionId) as FeaturePiece | undefined;
123
+ }
124
+
125
+ export function featureOfPlan(sessionId: string): Feature | undefined {
126
+ return db
127
+ .prepare(`SELECT * FROM features WHERE plan_session_id = ?`)
128
+ .get(sessionId) as Feature | undefined;
129
+ }
130
+
131
+ export interface Split {
132
+ pieces: Piece[];
133
+ notBuilding: string[];
134
+ }
135
+
136
+ // The pieces to build for a plan (one, when it isn't split). Edits Saad
137
+ // made on the plan view apply only here, at approval.
138
+ export function splitOf(s: Session, text: string): Split | null {
139
+ const result = planFor(s, text);
140
+ if (!result || result.pieces.length < 2) return null;
141
+ const { pieces, notBuilding } = applyEdits(result, parseEdits(s.plan_edits));
142
+ return { pieces, notBuilding };
143
+ }
144
+
145
+ // One transaction: the feature, its pieces, and the plan kept past the
146
+ // worktree. Piece 1 is the planning task's own.
147
+ export function createFeature(
148
+ s: Session,
149
+ text: string,
150
+ pieces: Piece[],
151
+ workspaceId: string
152
+ ): Feature {
153
+ const id = randomUUID();
154
+ fs.mkdirSync(plansDir(), { recursive: true });
155
+ fs.writeFileSync(planPathOf(id), text);
156
+ db.transaction(() => {
157
+ db.prepare(
158
+ `INSERT INTO features (id, workspace_id, name, plan_session_id, plan_text, status)
159
+ VALUES (?, ?, ?, ?, ?, 'building')`
160
+ ).run(id, workspaceId, s.name, s.id, text);
161
+ const insert = db.prepare(
162
+ `INSERT INTO feature_pieces
163
+ (feature_id, position, name, slice, project_id, after_position, after_kind,
164
+ reasons, owns, status, session_id)
165
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
166
+ );
167
+ for (const p of pieces)
168
+ insert.run(
169
+ id,
170
+ p.position,
171
+ p.name,
172
+ p.slice,
173
+ p.project.id,
174
+ p.afterPosition,
175
+ p.afterKind,
176
+ p.reasons.join("+") || null,
177
+ p.owns.join(", ") || null,
178
+ p.position === 1 ? "building" : "waiting",
179
+ p.position === 1 ? s.id : null
180
+ );
181
+ })();
182
+ return getFeature(id)!;
183
+ }
184
+
185
+ const PLAN_IN_REVIEW = 20_000;
186
+
187
+ // What a piece's review judges the diff against: its slice, not the whole
188
+ // task. The last piece standing is also asked whether the plan is done.
189
+ export function reviewGoal(sessionId: string): string | null {
190
+ const piece = pieceOfSession(sessionId);
191
+ const feature = piece ? getFeature(piece.feature_id) : undefined;
192
+ if (!piece || !feature) return null;
193
+ const all = piecesOf(feature.id);
194
+ const live = all.filter((p) => p.status !== "dropped");
195
+ const owns = piece.owns ? `\nThis piece owns: ${piece.owns}.` : "";
196
+ const head = `Piece ${piece.position} of ${live.length}, "${piece.name}": ${piece.slice}${owns}`;
197
+ const last = live.every((p) => p.id === piece.id || p.status === "merged");
198
+ if (!last || live.length < 2) return head;
199
+ return `${head}\n\nThis is the last piece. Also check that the plan as a whole is now done, and list anything in it that no piece built. The plan:\n${feature.plan_text.slice(0, PLAN_IN_REVIEW)}`;
200
+ }