@saadnvd1/agent-os 0.3.533 → 0.3.535

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 (86) hide show
  1. package/README.md +74 -4
  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/ScheduleForm.tsx +20 -2
  25. package/components/Schedules/SchedulesPanel.tsx +1 -0
  26. package/components/SessionList/RowSubtitle.tsx +16 -0
  27. package/components/SessionList/SessionRowMenu.tsx +29 -0
  28. package/components/SessionList/SidebarHeader.tsx +5 -0
  29. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  30. package/components/Settings/SettingsDialog.tsx +9 -0
  31. package/components/Tasks/PlanReview.tsx +253 -0
  32. package/components/Tasks/TaskRow.tsx +19 -44
  33. package/components/Tasks/TasksDialog.tsx +2 -0
  34. package/data/features/index.ts +40 -0
  35. package/data/push/topics.ts +8 -0
  36. package/data/recipes/index.ts +129 -0
  37. package/data/tasks/index.ts +14 -0
  38. package/lib/away/run.ts +4 -1
  39. package/lib/db/changes.ts +8 -0
  40. package/lib/db/migrations.ts +143 -0
  41. package/lib/db/queries.ts +4 -1
  42. package/lib/db/schedules-move.ts +220 -0
  43. package/lib/db/types.ts +6 -0
  44. package/lib/models/plan-step.ts +67 -6
  45. package/lib/notify/copy.ts +28 -10
  46. package/lib/notify/push/events.ts +36 -2
  47. package/lib/notify/push/policy.ts +2 -0
  48. package/lib/notify/push-types.ts +7 -3
  49. package/lib/orchestrator/brief.ts +13 -0
  50. package/lib/orchestrator/conditions.ts +3 -1
  51. package/lib/orchestrator/deliver.ts +2 -0
  52. package/lib/orchestrator/digest.ts +27 -1
  53. package/lib/orchestrator/plan-pieces.ts +41 -0
  54. package/lib/orchestrator/recipe-tools.ts +159 -0
  55. package/lib/orchestrator/review.ts +2 -1
  56. package/lib/orchestrator/serve.ts +25 -0
  57. package/lib/orchestrator/tool-names.ts +5 -0
  58. package/lib/orchestrator/tool-schemas.ts +54 -0
  59. package/lib/orchestrator/tools.ts +10 -0
  60. package/lib/plans/features.ts +200 -0
  61. package/lib/plans/pieces.ts +366 -0
  62. package/lib/plans/release.ts +323 -0
  63. package/lib/plans/view.ts +85 -0
  64. package/lib/recipes/api.ts +19 -0
  65. package/lib/recipes/chip.ts +40 -0
  66. package/lib/recipes/compose.ts +78 -0
  67. package/lib/recipes/draft.ts +93 -0
  68. package/lib/recipes/index.ts +54 -0
  69. package/lib/recipes/result.ts +59 -0
  70. package/lib/recipes/run.ts +202 -0
  71. package/lib/recipes/settle.ts +465 -0
  72. package/lib/recipes/store.ts +378 -0
  73. package/lib/recipes/test-fixtures.ts +49 -0
  74. package/lib/recipes/timer.ts +107 -0
  75. package/lib/recipes/types.ts +116 -0
  76. package/lib/schedules/index.ts +4 -1
  77. package/lib/schedules/start.ts +33 -9
  78. package/lib/schedules/store.ts +6 -2
  79. package/lib/security/demo.ts +1 -0
  80. package/lib/settings-url.ts +1 -0
  81. package/lib/tasks/loop-types.ts +23 -0
  82. package/lib/tasks/plan-view.ts +38 -1
  83. package/package.json +1 -1
  84. package/scripts/revert-schedules-move.ts +24 -0
  85. package/server.ts +2 -0
  86. package/stores/recipesUi.ts +53 -0
@@ -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
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The `## Pieces` block a plan ends with, and the deterministic check that
3
+ * decides how many pieces it really is. A plan is one piece unless it names
4
+ * one of four reasons for splitting, and these rules (not a model) enforce
5
+ * that: a piece without a standing reason is folded into the one before it.
6
+ */
7
+
8
+ export const MAX_PIECES = 6;
9
+
10
+ export type PieceReason = "a" | "b" | "c" | "d";
11
+
12
+ export interface ProjectRef {
13
+ id: string;
14
+ name: string;
15
+ // Whether the project's agentos.json names a `deploy.deployed` file.
16
+ deploys: boolean;
17
+ }
18
+
19
+ export interface Piece {
20
+ position: number;
21
+ name: string;
22
+ slice: string;
23
+ project: ProjectRef;
24
+ afterPosition: number | null;
25
+ afterKind: "merged" | "deployed" | null;
26
+ reasons: PieceReason[];
27
+ owns: string[];
28
+ }
29
+
30
+ export interface Folded {
31
+ name: string;
32
+ into: number;
33
+ why: string;
34
+ }
35
+
36
+ export interface PiecesResult {
37
+ pieces: Piece[];
38
+ folded: Folded[];
39
+ warnings: string[];
40
+ }
41
+
42
+ interface Raw {
43
+ position: number;
44
+ name: string;
45
+ slice: string;
46
+ repo: string | null;
47
+ after: { position: number; kind: "merged" | "deployed" } | null;
48
+ reasons: PieceReason[];
49
+ owns: string[];
50
+ }
51
+
52
+ const HEADING = /^##\s+pieces\s*$/i;
53
+ const ITEM = /^\s{0,3}(\d+)[.)]\s+(.*\S)\s*$/;
54
+ const AFTER = /^\s*after:\s*(\d+)\s+is\s+(merged|deployed)\s*$/i;
55
+ const WHY = /^\s*why split:\s*(.+?)\s*$/i;
56
+ const OWNS = /^\s*owns:\s*(.+?)\s*$/i;
57
+
58
+ function block(text: string): string[] | null {
59
+ const lines = text.split("\n");
60
+ let start = -1;
61
+ lines.forEach((l, i) => {
62
+ if (HEADING.test(l.trim())) start = i;
63
+ });
64
+ if (start < 0) return null;
65
+ const out: string[] = [];
66
+ for (const l of lines.slice(start + 1)) {
67
+ if (/^#{1,2}\s/.test(l)) break;
68
+ out.push(l);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function parseItem(position: number, rest: string): Raw {
74
+ let repo: string | null = null;
75
+ const withRepo = rest.match(/^(.*?)\s*\(repo:\s*([^)]+)\)\s*$/i);
76
+ if (withRepo) {
77
+ rest = withRepo[1];
78
+ repo = withRepo[2].trim();
79
+ }
80
+ const split = rest.indexOf(":");
81
+ const name = (split > 0 ? rest.slice(0, split) : rest).trim();
82
+ const slice = (split > 0 ? rest.slice(split + 1) : rest).trim();
83
+ return {
84
+ position,
85
+ name: name.slice(0, 80) || `Piece ${position}`,
86
+ slice: slice || name,
87
+ repo,
88
+ after: null,
89
+ reasons: [],
90
+ owns: [],
91
+ };
92
+ }
93
+
94
+ function parseBlock(lines: string[]): Raw[] | null {
95
+ const items: Raw[] = [];
96
+ for (const line of lines) {
97
+ if (!line.trim()) continue;
98
+ const item = line.match(ITEM);
99
+ if (item) {
100
+ items.push(parseItem(Number(item[1]), item[2]));
101
+ continue;
102
+ }
103
+ const current = items[items.length - 1];
104
+ if (!current) return null;
105
+ const after = line.match(AFTER);
106
+ const why = line.match(WHY);
107
+ const owns = line.match(OWNS);
108
+ if (after)
109
+ current.after = {
110
+ position: Number(after[1]),
111
+ kind: after[2].toLowerCase() as "merged" | "deployed",
112
+ };
113
+ else if (why)
114
+ current.reasons = [
115
+ ...new Set(
116
+ (why[1].toLowerCase().match(/\(([abcd])\)/g) ?? []).map(
117
+ (m) => m[1] as PieceReason
118
+ )
119
+ ),
120
+ ];
121
+ else if (owns)
122
+ current.owns = owns[1]
123
+ .split(/[,;]/)
124
+ .map((o) => o.trim().toLowerCase())
125
+ .filter(Boolean);
126
+ else return null;
127
+ }
128
+ if (!items.length || items.some((it, i) => it.position !== i + 1))
129
+ return null;
130
+ return items;
131
+ }
132
+
133
+ export interface CheckContext {
134
+ // The project the plan's task works in: a piece naming none gets this.
135
+ own: ProjectRef;
136
+ find: (name: string) => ProjectRef | null;
137
+ }
138
+
139
+ function one(text: string, ctx: CheckContext, warnings: string[]) {
140
+ return {
141
+ pieces: [
142
+ {
143
+ position: 1,
144
+ name: "Plan",
145
+ slice: text.trim().slice(0, 500),
146
+ project: ctx.own,
147
+ afterPosition: null,
148
+ afterKind: null,
149
+ reasons: [],
150
+ owns: [],
151
+ } satisfies Piece,
152
+ ],
153
+ folded: [] as Folded[],
154
+ warnings,
155
+ };
156
+ }
157
+
158
+ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
159
+ const lines = block(text);
160
+ if (!lines) return one("", ctx, []);
161
+ const raw = parseBlock(lines);
162
+ if (!raw)
163
+ return one("", ctx, [
164
+ "The Pieces block couldn't be read, so this is one piece.",
165
+ ]);
166
+ if (raw.length > MAX_PIECES)
167
+ return one("", ctx, [
168
+ `${raw.length} pieces is past the limit of ${MAX_PIECES}; built as one piece.`,
169
+ ]);
170
+
171
+ const warnings: string[] = [];
172
+ const projects = raw.map((r) => {
173
+ if (!r.repo) return ctx.own;
174
+ const found = ctx.find(r.repo);
175
+ if (!found)
176
+ warnings.push(`"${r.name}": no project named ${r.repo}; using this one.`);
177
+ return found ?? ctx.own;
178
+ });
179
+
180
+ // Where each original piece ended up, for `after:` lines that pointed at a
181
+ // piece that was folded.
182
+ const landed = new Map<number, number>();
183
+ const pieces: Piece[] = [];
184
+ const folded: Folded[] = [];
185
+
186
+ raw.forEach((r, i) => {
187
+ const project = projects[i];
188
+ const fold = (why: string) => {
189
+ const into = pieces[pieces.length - 1];
190
+ into.slice = `${into.slice}\n${r.name}: ${r.slice}`;
191
+ into.owns = [...new Set([...into.owns, ...r.owns])];
192
+ landed.set(r.position, into.position);
193
+ folded.push({ name: r.name, into: into.position, why });
194
+ };
195
+ const keep = (p: Omit<Piece, "position">) => {
196
+ const position = pieces.length + 1;
197
+ pieces.push({ ...p, position });
198
+ landed.set(r.position, position);
199
+ };
200
+
201
+ if (i === 0) {
202
+ keep({
203
+ name: r.name,
204
+ slice: r.slice,
205
+ project,
206
+ afterPosition: null,
207
+ afterKind: null,
208
+ reasons: [],
209
+ owns: r.owns,
210
+ });
211
+ return;
212
+ }
213
+
214
+ let reasons = r.reasons;
215
+ if (!reasons.length) return fold("no reason for a separate piece");
216
+
217
+ if (reasons.includes("a")) {
218
+ const others = projects.filter((_, j) => j !== i);
219
+ if (others.some((p) => p.id === project.id)) {
220
+ reasons = reasons.filter((x) => x !== "a");
221
+ warnings.push(
222
+ `"${r.name}" claimed (a) but shares a repo with another piece.`
223
+ );
224
+ }
225
+ }
226
+
227
+ let afterPosition: number | null = null;
228
+ let afterKind = r.after?.kind ?? null;
229
+ if (r.after) {
230
+ const target = landed.get(r.after.position);
231
+ if (target === undefined || r.after.position >= r.position) {
232
+ warnings.push(`"${r.name}": after: ${r.after.position} isn't earlier.`);
233
+ afterKind = null;
234
+ } else {
235
+ afterPosition = target;
236
+ }
237
+ }
238
+
239
+ if (reasons.includes("b")) {
240
+ const dep = afterPosition ? pieces[afterPosition - 1] : null;
241
+ if (afterKind !== "deployed" || !dep) {
242
+ reasons = reasons.filter((x) => x !== "b");
243
+ } else if (!dep.project.deploys) {
244
+ reasons = reasons.filter((x) => x !== "b");
245
+ afterKind = "merged";
246
+ warnings.push(
247
+ `"${r.name}" waits on a deploy that ${dep.project.name} can't check; it waits for the merge instead.`
248
+ );
249
+ }
250
+ } else if (afterKind === "deployed") {
251
+ const dep = afterPosition ? pieces[afterPosition - 1] : null;
252
+ if (!dep?.project.deploys) afterKind = "merged";
253
+ }
254
+
255
+ if (!reasons.length) return fold("its reason didn't hold");
256
+
257
+ keep({
258
+ name: r.name,
259
+ slice: r.slice,
260
+ project,
261
+ afterPosition,
262
+ afterKind: afterPosition ? (afterKind ?? "merged") : null,
263
+ reasons,
264
+ owns: r.owns,
265
+ });
266
+ });
267
+
268
+ const seen = new Map<string, number>();
269
+ for (const p of pieces)
270
+ for (const o of p.owns) {
271
+ const first = seen.get(o);
272
+ if (first !== undefined && first !== p.position)
273
+ warnings.push(`"${o}" is owned by pieces ${first} and ${p.position}.`);
274
+ else seen.set(o, p.position);
275
+ }
276
+
277
+ return { pieces, folded, warnings };
278
+ }
279
+
280
+ export const PIECES_PROMPT = `At the end of the plan, add a \`## Pieces\` block. The default is exactly one piece, which is what almost every task is, and one piece needs no more than:
281
+
282
+ ## Pieces
283
+ 1. <name>: <one-line slice> (repo: <project>)
284
+
285
+ Add another piece only when one of these holds, and say which on a \`why split:\` line:
286
+ (a) it spans repositories (a task works in one repo);
287
+ (b) one part must be live (merged and deployed) before the other can merge;
288
+ (c) one part needs the user mid-way (signing, a credential, a product call) and the rest doesn't;
289
+ (d) one part must be revertible on its own: a migration that rewrites or deletes existing rows, or an auth or security change bundled with unrelated work. An additive migration doesn't count.
290
+ Diff size is never a reason. A later piece looks like:
291
+ 2. <name>: <one-line slice> (repo: <project>)
292
+ after: 1 is merged (or: 1 is deployed)
293
+ why split: (a) + (b)
294
+ owns: migration 81
295
+ A shared resource (a migration id, a new table, a config key) goes on exactly one piece's \`owns:\` line. A piece with no valid reason is folded into the one before it.`;
296
+
297
+ // Shown to a piece's agent in place of the whole plan.
298
+ export function pieceBrief(
299
+ total: number,
300
+ piece: Pick<Piece, "position" | "name" | "slice" | "owns">,
301
+ planPath: string,
302
+ before: string[] = []
303
+ ): string {
304
+ const owns = piece.owns.length
305
+ ? `\nThis piece owns: ${piece.owns.join(", ")}. No other piece touches it.`
306
+ : "";
307
+ const earlier = before.length
308
+ ? `\nPieces before you: ${before.join("; ")}.`
309
+ : "";
310
+ return `Build piece ${piece.position} of ${total} only, "${piece.name}": ${piece.slice}${owns}${earlier}\nThe rest of the plan is other pieces' work; don't start it. Full plan: ${planPath}`;
311
+ }
312
+
313
+ export interface PlanEdits {
314
+ // Positions to fold into the piece before them, and to leave out.
315
+ merge?: number[];
316
+ drop?: number[];
317
+ }
318
+
319
+ export function parseEdits(raw: string | null | undefined): PlanEdits {
320
+ try {
321
+ const o = JSON.parse(raw ?? "{}") as PlanEdits;
322
+ const ints = (v: unknown) =>
323
+ Array.isArray(v) ? v.filter((n) => Number.isInteger(n)) : [];
324
+ return { merge: ints(o.merge), drop: ints(o.drop) };
325
+ } catch {
326
+ return {};
327
+ }
328
+ }
329
+
330
+ // Saad's edits to the pieces before approving: a merged piece joins the one
331
+ // before it, a dropped one is left out (and listed, so nothing vanishes).
332
+ // Piece 1 can't be dropped or merged.
333
+ export function applyEdits(
334
+ result: PiecesResult,
335
+ edits: PlanEdits
336
+ ): { pieces: Piece[]; notBuilding: string[] } {
337
+ const drop = new Set(edits.drop ?? []);
338
+ const merge = new Set(edits.merge ?? []);
339
+ const notBuilding: string[] = [];
340
+ const kept: Piece[] = [];
341
+ const landed = new Map<number, number>();
342
+ for (const p of result.pieces) {
343
+ const before = kept[kept.length - 1];
344
+ if (p.position > 1 && drop.has(p.position)) {
345
+ notBuilding.push(`${p.name}: ${p.slice}`);
346
+ continue;
347
+ }
348
+ if (p.position > 1 && merge.has(p.position) && before) {
349
+ before.slice = `${before.slice}\n${p.name}: ${p.slice}`;
350
+ before.owns = [...new Set([...before.owns, ...p.owns])];
351
+ landed.set(p.position, before.position);
352
+ continue;
353
+ }
354
+ const position = kept.length + 1;
355
+ landed.set(p.position, position);
356
+ const after =
357
+ p.afterPosition === null ? undefined : landed.get(p.afterPosition);
358
+ kept.push({
359
+ ...p,
360
+ position,
361
+ afterPosition: after && after < position ? after : null,
362
+ afterKind: after && after < position ? p.afterKind : null,
363
+ });
364
+ }
365
+ return { pieces: kept, notBuilding };
366
+ }