@saadnvd1/agent-os 0.3.557 → 0.3.560

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 (38) hide show
  1. package/README.md +1 -1
  2. package/app/api/learning-cards/[id]/route.test.ts +62 -0
  3. package/app/api/learning-cards/[id]/route.ts +36 -0
  4. package/app/api/recipes/draft/route.ts +6 -1
  5. package/app/api/recipes/route.ts +3 -0
  6. package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
  7. package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
  8. package/app/api/sessions/route.ts +3 -3
  9. package/components/Chat/ChatPanel.tsx +2 -0
  10. package/components/LearningCards/LearningCard.test.ts +156 -0
  11. package/components/LearningCards/LearningCard.tsx +210 -0
  12. package/data/learning-cards/index.ts +79 -0
  13. package/lib/chat/store.ts +16 -0
  14. package/lib/db/migrations.ts +53 -0
  15. package/lib/db/queries.ts +4 -3
  16. package/lib/experiments/hooks.ts +12 -4
  17. package/lib/learning-cards/__fixtures__/db.ts +52 -0
  18. package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
  19. package/lib/learning-cards/analyze.ts +228 -0
  20. package/lib/learning-cards/apply.ts +196 -0
  21. package/lib/learning-cards/index.ts +29 -0
  22. package/lib/learning-cards/input.ts +116 -0
  23. package/lib/learning-cards/pairs.ts +111 -0
  24. package/lib/learning-cards/signals.ts +98 -0
  25. package/lib/learning-cards/store.ts +103 -0
  26. package/lib/learning-cards/types.ts +79 -0
  27. package/lib/models/plan-step.ts +107 -15
  28. package/lib/orchestrator/conditions.ts +12 -4
  29. package/lib/orchestrator/events.ts +23 -0
  30. package/lib/orchestrator/quick.ts +14 -1
  31. package/lib/orchestrator/reconcile.ts +201 -0
  32. package/lib/plans/features.ts +69 -13
  33. package/lib/plans/pieces.ts +70 -15
  34. package/lib/plans/release.ts +46 -4
  35. package/lib/recipes/draft.ts +62 -33
  36. package/lib/recipes/settle.ts +3 -0
  37. package/lib/sidebar/shelves.ts +5 -6
  38. package/package.json +1 -1
@@ -22,8 +22,14 @@ import { queueEvent } from "../orchestrator/events";
22
22
  import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
23
23
  import { addNote } from "../orchestrator/notes";
24
24
  import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
25
- import { PIECES_PROMPT, pieceBrief } from "../plans/pieces";
26
- import { createFeature, planFor, planPathOf, splitOf } from "../plans/features";
25
+ import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
26
+ import {
27
+ createFeature,
28
+ ownPieceOf,
29
+ planFor,
30
+ planPathOf,
31
+ splitOf,
32
+ } from "../plans/features";
27
33
 
28
34
  export const PLAN_FILE = ".agentos/plan.md";
29
35
 
@@ -52,10 +58,11 @@ export function implementPrompt(
52
58
  amendment?: string,
53
59
  piece?: string
54
60
  ): string {
61
+ const where = ` Work only in this task's repository; if part of the work belongs in another repository, say so in your final message instead of doing it there.`;
55
62
  const base = piece
56
- ? `The plan is in \`${PLAN_FILE}\`. ${piece}\nFinish it as the task's instructions above say. Where the plan turns out wrong, do what's right and say so.`
63
+ ? `The plan is in \`${PLAN_FILE}\`. ${piece}\nFinish it as the task's instructions above say. Where the plan turns out wrong, do what's right and say so.${where}`
57
64
  : planned
58
- ? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.`
65
+ ? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.${where}`
59
66
  : `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
60
67
  return amendment
61
68
  ? `${base}\n\nThe plan was approved with this change, which is now part of the task's scope:\n${amendment}`
@@ -104,7 +111,7 @@ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
104
111
  ? ` after ${p.afterPosition} is ${p.afterKind}`
105
112
  : "";
106
113
  const why = p.reasons.length ? ` (${p.reasons.join("+")})` : "";
107
- return `${p.position} ${p.name}${after}${why}`;
114
+ return `${p.position} ${p.name} [${p.project.name}]${after}${why}`;
108
115
  });
109
116
  const folded = r.folded.length ? `, ${r.folded.length} folded` : "";
110
117
  return `${r.pieces.length} pieces${folded}: ${parts.join("; ")}`;
@@ -130,6 +137,8 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
130
137
  if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
131
138
  const pieces = planFor(s, fs.readFileSync(file, "utf8"));
132
139
  if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
140
+ if (pieces?.folded.length)
141
+ head += `\nPlan listed ${pieces.listed} pieces, building ${pieces.pieces.length}: ${pieces.folded.map((f) => `${f.name} folded (${f.why})`).join("; ")}`;
133
142
  } catch {
134
143
  // No plan file: the line says so.
135
144
  }
@@ -143,6 +152,46 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
143
152
  queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
144
153
  }
145
154
 
155
+ // Plans that build as other than one piece in this repository say so, once.
156
+ function sayHowItSplit(
157
+ s: Session,
158
+ workspaceId: string,
159
+ split: NonNullable<ReturnType<typeof splitOf>>,
160
+ nothingOwn: boolean
161
+ ): void {
162
+ if (nothingOwn)
163
+ queueEvent(
164
+ workspaceId,
165
+ `plan-routed:${s.id}`,
166
+ s.id,
167
+ `Task "${s.name}" split its plan into ${split.pieces.length} ${split.pieces.length === 1 ? "piece" : "pieces"} and none is first in its own repository, so it builds nothing itself and ends; each piece starts as its own task: ${split.pieces.map((p) => `${p.position} ${p.name} [${p.project.name}]`).join("; ")}.`
168
+ );
169
+ else if (split.folded.length)
170
+ queueEvent(
171
+ workspaceId,
172
+ `plan-folded:${s.id}`,
173
+ s.id,
174
+ `Plan of "${s.name}" listed ${split.listed} pieces, building ${split.pieces.length}: ${split.folded.map((f) => `${f.name} folded into ${f.into} (${f.why})`).join("; ")}. The task was told to build the folded work too.`
175
+ );
176
+ }
177
+
178
+ // A planning task with no piece of its own is finished: its pieces are
179
+ // started as tasks in their projects, now rather than on the next sweep.
180
+ async function handOffToPieces(s: Session): Promise<void> {
181
+ try {
182
+ const { doneSession } = await import("../done");
183
+ await doneSession(s.id, { by: "direct", noMerge: true });
184
+ } catch (e) {
185
+ console.error(`Ending planning task ${s.id} failed:`, e);
186
+ }
187
+ try {
188
+ const { releasePieces } = await import("../plans/release");
189
+ await releasePieces();
190
+ } catch (e) {
191
+ console.error("Starting the pieces failed:", e);
192
+ }
193
+ }
194
+
146
195
  function readPlan(s: Session): string | null {
147
196
  try {
148
197
  return fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8");
@@ -180,8 +229,14 @@ async function startImplementing(
180
229
  });
181
230
  const id = implementMessageId(sessionId);
182
231
  const planText = readPlan(s);
183
- const split = planText ? splitOf(s, planText) : null;
232
+ let split = planText ? splitOf(s, planText) : null;
184
233
  const workspaceId = sessionWorkspace(s);
234
+ // Approved despite pieces that couldn't be registered: built as one piece,
235
+ // and told so, rather than guessing which repository each belongs to.
236
+ const unreadable = split?.errors.length ? split.errors : null;
237
+ if (unreadable) split = null;
238
+ let nothingOwn = false;
239
+ let featureId: string | null = null;
185
240
  const claimed = db.transaction(() => {
186
241
  const changed = db
187
242
  .prepare(
@@ -193,18 +248,38 @@ async function startImplementing(
193
248
  setSessionModel(sessionId, routed);
194
249
  let piece: string | undefined;
195
250
  let note = amendment;
251
+ if (unreadable)
252
+ note = [
253
+ amendment,
254
+ `The plan's pieces couldn't be registered (${unreadable.join("; ")}); build the whole plan as one piece in this repository.`,
255
+ ]
256
+ .filter(Boolean)
257
+ .join("\n");
196
258
  if (split?.notBuilding.length)
197
259
  note = [amendment, `Not building: ${split.notBuilding.join("; ")}`]
198
260
  .filter(Boolean)
199
261
  .join("\n");
200
- if (split && split.pieces.length > 1 && planText && workspaceId) {
201
- const feature = createFeature(s, planText, split.pieces, workspaceId);
202
- piece = pieceBrief(
203
- split.pieces.length,
204
- split.pieces[0],
205
- planPathOf(feature.id)
206
- );
262
+ if (split?.feature && planText && workspaceId) {
263
+ const feature = createFeature(s, planText, split.pieces, workspaceId, {
264
+ listed: split.listed,
265
+ folded: split.folded,
266
+ });
267
+ featureId = feature.id;
268
+ const own = ownPieceOf(split.pieces, s.project_id);
269
+ if (!own) nothingOwn = true;
270
+ else if (split.pieces.length === 1 && split.folded.length)
271
+ piece = foldedBrief(split);
272
+ else
273
+ piece = pieceBrief(
274
+ split.pieces.length,
275
+ own,
276
+ planPathOf(feature.id),
277
+ [],
278
+ own.project.name
279
+ );
207
280
  }
281
+ // Every piece is another task's: nothing for this one to implement.
282
+ if (nothingOwn) return true;
208
283
  enqueue(sessionId, {
209
284
  id,
210
285
  text: implementPrompt(
@@ -224,6 +299,12 @@ async function startImplementing(
224
299
  console.error("plan-done event failed:", e);
225
300
  }
226
301
  }
302
+ if (split?.feature && featureId && workspaceId)
303
+ sayHowItSplit(s, workspaceId, split, nothingOwn);
304
+ if (nothingOwn) {
305
+ await handOffToPieces(s);
306
+ return routed.ref;
307
+ }
227
308
  await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
228
309
  await send(sessionId, id);
229
310
  return routed.ref;
@@ -342,9 +423,12 @@ export async function handoffAfterPlan(
342
423
  deleteQueued(sessionId, planRetryMessageId(sessionId));
343
424
  const retried = waiting.get(sessionId)?.retried ?? false;
344
425
  forget(sessionId);
345
- // No approvals mode doesn't stop at the plan to wait for one.
426
+ // No approvals mode doesn't stop at the plan to wait for one. Pieces that
427
+ // couldn't be registered always do: building on a guess is the bug.
346
428
  const auto = !!s.plan_only && noApprovalsOn();
347
- if (!s.plan_only || auto) {
429
+ const planText = readPlan(s);
430
+ const pieceErrors = (planText ? splitOf(s, planText)?.errors : null) ?? [];
431
+ if ((!s.plan_only || auto) && !pieceErrors.length) {
348
432
  const ref = await startImplementing(
349
433
  s,
350
434
  "planning",
@@ -374,6 +458,14 @@ export async function handoffAfterPlan(
374
458
  } catch (e) {
375
459
  console.error("plan-done event failed:", e);
376
460
  }
461
+ const workspaceId = sessionWorkspace(s);
462
+ if (pieceErrors.length && workspaceId)
463
+ queueEvent(
464
+ workspaceId,
465
+ `plan-pieces:${s.id}`,
466
+ s.id,
467
+ `Plan of "${s.name}": couldn't register its pieces: ${pieceErrors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
468
+ );
377
469
  try {
378
470
  const text = readPlan(s);
379
471
  const planned = text ? planFor(s, text) : null;
@@ -39,7 +39,9 @@ export interface StackFacts {
39
39
  }[];
40
40
  }
41
41
 
42
- export const IDLE_NO_PR_MS = 30 * 60 * 1000;
42
+ // How long after a task's turn ended with no PR before it is news: longer
43
+ // than the open-PR cache, so a PR it just opened is seen first.
44
+ export const NO_PR_AFTER_TURN_MS = 2 * 60 * 1000;
43
45
 
44
46
  function hash(text: string): string {
45
47
  let h = 0;
@@ -133,17 +135,23 @@ function sessionConditions(
133
135
  const shouldPR = (!!f.task || !!f.branch) && !f.quick && !runForSession(f.id);
134
136
  const finished = !!f.task && isFinished(f.task.state);
135
137
  const idleFor = now - f.lastActive;
138
+ // A turn that ended with no PR and nothing blocked is stuck: it may
139
+ // have opened a PR somewhere AgentOS doesn't look, or be waiting on
140
+ // nothing. Keyed on when it last did anything, so it is one event per
141
+ // stall and anything it does after makes the next stall a new one.
136
142
  if (
137
143
  shouldPR &&
138
144
  !pr &&
139
145
  !finished &&
140
146
  !f.plan?.awaiting &&
141
147
  f.status !== "running" &&
142
- idleFor >= IDLE_NO_PR_MS
148
+ blocked === null &&
149
+ idleFor >= NO_PR_AFTER_TURN_MS
143
150
  )
144
151
  add(
145
- "idle",
146
- `${who}: idle ${Math.floor(idleFor / 60000)}m, no PR`,
152
+ `no-pr:${f.lastActive}`,
153
+ `${who}: ended its turn ${Math.floor(idleFor / 60000)}m ago with no PR${f.activity ? ` (last: ${untrusted(f.name, f.activity)})` : ""}. If it opened a PR in another repository, or finished without one, nothing tracks it.`,
154
+ false,
147
155
  false,
148
156
  true
149
157
  );
@@ -182,6 +182,29 @@ export function queueEvent(
182
182
  ).run(workspaceId, `${ONCE}${key}`, subject, line, ARMED, iso(now));
183
183
  }
184
184
 
185
+ // Like queueEvent, for a line a sweep would otherwise repeat: a key that
186
+ // already exists, delivered or not, is left alone. Put the state in the key
187
+ // so a change of state is a new line.
188
+ export function queueEventOnce(
189
+ workspaceId: string,
190
+ key: string,
191
+ subject: string | null,
192
+ line: string,
193
+ now = Date.now()
194
+ ): boolean {
195
+ return (
196
+ db
197
+ .prepare(
198
+ `INSERT INTO orchestrator_events
199
+ (workspace_id, key, subject, line, sticky, low, hits, created_at, delivered_at)
200
+ VALUES (?, ?, ?, ?, 1, 0, ?, ?, NULL)
201
+ ON CONFLICT(workspace_id, key) DO NOTHING`
202
+ )
203
+ .run(workspaceId, `${ONCE}${key}`, subject, line, ARMED, iso(now))
204
+ .changes > 0
205
+ );
206
+ }
207
+
185
208
  // A condition the orchestrator has been told about another way (a one-off
186
209
  // event said it), so a later diff doesn't send it again.
187
210
  export function markTold(
@@ -53,6 +53,15 @@ export async function quickChange(
53
53
  projectRef: string,
54
54
  name: string
55
55
  ): Promise<string> {
56
+ return (await startQuickChange(workspaceId, projectRef, name)).text;
57
+ }
58
+
59
+ // The same, for a caller that needs the new session's id and worktree.
60
+ export async function startQuickChange(
61
+ workspaceId: string,
62
+ projectRef: string,
63
+ name: string
64
+ ): Promise<{ id: string; worktreePath: string; text: string }> {
56
65
  const owner = ownerOf(workspaceId);
57
66
  const project = workspaceProject(workspaceId, projectRef);
58
67
  if (isRemoteHost(project.host_id))
@@ -107,7 +116,11 @@ export async function quickChange(
107
116
  workspaceId,
108
117
  `Quick change "${label}" in ${project.name} on ${wt.branchName}.`
109
118
  );
110
- return `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Edit files there with Write and Edit; git there only as \`git -C ${wt.worktreePath} <status|diff|add|...>\`. Then quick_pr commits, pushes and opens its PR.`;
119
+ return {
120
+ id,
121
+ worktreePath: wt.worktreePath,
122
+ text: `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Edit files there with Write and Edit; git there only as \`git -C ${wt.worktreePath} <status|diff|add|...>\`. Then quick_pr commits, pushes and opens its PR.`,
123
+ };
111
124
  }
112
125
 
113
126
  // A quick change of this workspace's orchestrator, by name, id or PR.
@@ -0,0 +1,201 @@
1
+ // A task's agent opened a PR in a repository that isn't its task's: nothing
2
+ // polls that repository for the task's branch, so no task owns the PR and no
3
+ // CI or review gate runs. The PRs agents open are read back from their
4
+ // stored `gh pr create` calls, and one outside its task's repository is
5
+ // raised to the orchestrator once.
6
+
7
+ import { db, type Session } from "../db";
8
+ import { getSetting, putSetting } from "../settings-store";
9
+ import { sessionWorkspace } from "../models/resolve";
10
+ import { run } from "../tasks/gh";
11
+ import { expandHome } from "../tasks/session";
12
+ import { workspaceProjects } from "./brief";
13
+ import { queueEventOnce } from "./events";
14
+ import { repoSlug } from "./repo-slug";
15
+
16
+ const MARK = "reconcile.chat_items_rowid";
17
+ // An item is stored when its tool call starts and updated when it ends, so a
18
+ // call that finished after the mark moved past it is still inside the window.
19
+ const OVERLAP = 2000;
20
+ const BATCH = 500;
21
+ const EVERY_MS = 2 * 60 * 1000;
22
+ const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/g;
23
+ const CREATE = /\bgh\s+pr\s+create\b/;
24
+
25
+ export interface ReconcileDeps {
26
+ slugOf: (dir: string) => Promise<string | null>;
27
+ // The PR's state and head branch, or null when it can't be read.
28
+ view: (url: string) => Promise<{ state: string; head: string } | null>;
29
+ }
30
+
31
+ export const realReconcileDeps: ReconcileDeps = {
32
+ slugOf: repoSlug,
33
+ view: async (url) => {
34
+ try {
35
+ const out = await run(
36
+ "gh",
37
+ ["pr", "view", url, "--json", "state,headRefName"],
38
+ process.cwd(),
39
+ 15000
40
+ );
41
+ const pr = JSON.parse(out) as { state: string; headRefName: string };
42
+ return { state: pr.state, head: pr.headRefName };
43
+ } catch {
44
+ return null;
45
+ }
46
+ },
47
+ };
48
+
49
+ // Stores every PR URL an agent's `gh pr create` printed since last time.
50
+ export function recordAgentPrs(): number {
51
+ const top =
52
+ (
53
+ db.prepare(`SELECT MAX(rowid) AS m FROM chat_items`).get() as {
54
+ m: number | null;
55
+ }
56
+ ).m ?? 0;
57
+ const mark = Number(getSetting(MARK) ?? top - OVERLAP);
58
+ const rows = db
59
+ .prepare(
60
+ `SELECT rowid AS rid, session_id, data FROM chat_items
61
+ WHERE rowid > ? AND data LIKE '%pr create%' AND data LIKE '%/pull/%'
62
+ ORDER BY rowid LIMIT ?`
63
+ )
64
+ .all(mark - OVERLAP, BATCH) as {
65
+ rid: number;
66
+ session_id: string;
67
+ data: string;
68
+ }[];
69
+ const insert = db.prepare(
70
+ `INSERT OR IGNORE INTO agent_prs (url, session_id, repo, number) VALUES (?, ?, ?, ?)`
71
+ );
72
+ let added = 0;
73
+ for (const row of rows) {
74
+ let item: { kind?: string; input?: unknown; output?: unknown };
75
+ try {
76
+ item = JSON.parse(row.data);
77
+ } catch {
78
+ continue;
79
+ }
80
+ if (
81
+ item.kind !== "tool" ||
82
+ typeof item.output !== "string" ||
83
+ !CREATE.test(JSON.stringify(item.input ?? ""))
84
+ )
85
+ continue;
86
+ for (const m of item.output.matchAll(PR_URL))
87
+ added += insert.run(
88
+ m[0],
89
+ row.session_id,
90
+ `${m[1]}/${m[2]}`.toLowerCase(),
91
+ Number(m[3])
92
+ ).changes;
93
+ }
94
+ putSetting(MARK, String(rows.length === BATCH ? rows[BATCH - 1].rid : top));
95
+ return added;
96
+ }
97
+
98
+ interface AgentPr {
99
+ url: string;
100
+ session_id: string;
101
+ repo: string;
102
+ number: number;
103
+ }
104
+
105
+ const told = (url: string) =>
106
+ db
107
+ .prepare(`UPDATE agent_prs SET told_at = datetime('now') WHERE url = ?`)
108
+ .run(url);
109
+
110
+ async function ownedBy(
111
+ pr: AgentPr,
112
+ head: string,
113
+ deps: ReconcileDeps
114
+ ): Promise<boolean> {
115
+ const rows = db
116
+ .prepare(
117
+ `SELECT s.pr_url, s.branch_name, p.working_directory FROM sessions s
118
+ LEFT JOIN projects p ON p.id = s.project_id
119
+ WHERE s.task_status IS NOT NULL AND (LOWER(s.pr_url) = ? OR s.branch_name = ?)`
120
+ )
121
+ .all(pr.url.toLowerCase(), head) as {
122
+ pr_url: string | null;
123
+ branch_name: string | null;
124
+ working_directory: string | null;
125
+ }[];
126
+ for (const r of rows) {
127
+ if (r.pr_url?.toLowerCase() === pr.url.toLowerCase()) return true;
128
+ if (
129
+ r.working_directory &&
130
+ (await deps.slugOf(expandHome(r.working_directory))) === pr.repo
131
+ )
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+
137
+ // Raises each unclaimed agent PR outside its task's repository, once.
138
+ export async function classifyAgentPrs(
139
+ deps: ReconcileDeps = realReconcileDeps,
140
+ now = Date.now()
141
+ ): Promise<number> {
142
+ const open = db
143
+ .prepare(`SELECT * FROM agent_prs WHERE told_at IS NULL`)
144
+ .all() as AgentPr[];
145
+ let raised = 0;
146
+ for (const pr of open) {
147
+ const task = db
148
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
149
+ .get(pr.session_id) as Session | undefined;
150
+ const workspaceId = task ? sessionWorkspace(task) : null;
151
+ if (!task || !workspaceId) {
152
+ told(pr.url);
153
+ continue;
154
+ }
155
+ const projects = workspaceProjects(workspaceId);
156
+ const slugs = await Promise.all(
157
+ projects.map(async (p) => ({
158
+ name: p.name,
159
+ id: p.id,
160
+ slug: await deps.slugOf(expandHome(p.working_directory)),
161
+ }))
162
+ );
163
+ const own = slugs.find((p) => p.id === task.project_id);
164
+ // Its own repository is polled for its branch like any task's.
165
+ if (own?.slug === pr.repo) {
166
+ told(pr.url);
167
+ continue;
168
+ }
169
+ const view = await deps.view(pr.url);
170
+ // Unreadable now: look again next time.
171
+ if (!view) continue;
172
+ if (view.state !== "OPEN" || (await ownedBy(pr, view.head, deps))) {
173
+ told(pr.url);
174
+ continue;
175
+ }
176
+ const where = slugs.find((p) => p.slug === pr.repo);
177
+ queueEventOnce(
178
+ workspaceId,
179
+ `stray-pr:${pr.url}`,
180
+ task.id,
181
+ `Task "${task.name}" opened a PR outside its repo: ${pr.url} (${where ? `project ${where.name} of this workspace` : "not a repository of this workspace"}). No task owns it, so no CI or review gate runs on it: review and sign it off as an external PR, or drop it.`,
182
+ now
183
+ );
184
+ told(pr.url);
185
+ raised++;
186
+ }
187
+ return raised;
188
+ }
189
+
190
+ let last = 0;
191
+
192
+ // From the settle loop; reads stored chat items, so a restart loses nothing.
193
+ export async function reconcileSweep(
194
+ now = Date.now(),
195
+ deps: ReconcileDeps = realReconcileDeps
196
+ ): Promise<void> {
197
+ if (now - last < EVERY_MS) return;
198
+ last = now;
199
+ recordAgentPrs();
200
+ await classifyAgentPrs(deps, now);
201
+ }
@@ -12,6 +12,7 @@ import {
12
12
  checkPieces,
13
13
  parseEdits,
14
14
  type CheckContext,
15
+ type Folded,
15
16
  type PiecesResult,
16
17
  type Piece,
17
18
  type ProjectRef,
@@ -35,6 +36,9 @@ export interface Feature {
35
36
  cost_usd: number | null;
36
37
  duration_ms: number | null;
37
38
  created_at: string;
39
+ // Items the plan's Pieces block listed, and what the checker folded.
40
+ listed: number | null;
41
+ folded: string | null;
38
42
  }
39
43
 
40
44
  export interface FeaturePiece {
@@ -131,33 +135,82 @@ export function featureOfPlan(sessionId: string): Feature | undefined {
131
135
  export interface Split {
132
136
  pieces: Piece[];
133
137
  notBuilding: string[];
138
+ listed: number;
139
+ folded: Folded[];
140
+ // Why the plan's pieces can't be registered; when set, nothing is built
141
+ // on a guess.
142
+ errors: string[];
143
+ // Whether the plan needs a feature: false for one piece in the task's own
144
+ // repository that only has edits to carry.
145
+ feature: boolean;
134
146
  }
135
147
 
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.
148
+ // The pieces to build for a plan, or null when it is one piece in the task's
149
+ // own repository, as almost every plan is. A plan that lists more than it
150
+ // builds, or whose one piece belongs to another project, is not null: it
151
+ // gets a feature so nothing it listed goes missing. Edits Saad made on the
152
+ // plan view apply only here, at approval.
138
153
  export function splitOf(s: Session, text: string): Split | null {
139
154
  const result = planFor(s, text);
140
- if (!result || result.pieces.length < 2) return null;
155
+ if (!result) return null;
141
156
  const { pieces, notBuilding } = applyEdits(result, parseEdits(s.plan_edits));
142
- return { pieces, notBuilding };
157
+ const elsewhere = pieces.some((p) => p.project.id !== s.project_id);
158
+ const feature = pieces.length > 1 || elsewhere || result.folded.length > 0;
159
+ if (!feature && !notBuilding.length && !result.errors.length) return null;
160
+ return {
161
+ pieces,
162
+ notBuilding,
163
+ listed: result.listed,
164
+ folded: result.folded,
165
+ errors: result.errors,
166
+ feature,
167
+ };
168
+ }
169
+
170
+ // The piece the planning task builds itself: the first one in its own
171
+ // repository that doesn't wait on another. Every other piece starts as its own
172
+ // task in its own project.
173
+ export function ownPieceOf(
174
+ pieces: Piece[],
175
+ ownProjectId: string | null
176
+ ): Piece | undefined {
177
+ return pieces.find(
178
+ (p) => p.project.id === ownProjectId && p.afterPosition === null
179
+ );
143
180
  }
144
181
 
145
182
  // One transaction: the feature, its pieces, and the plan kept past the
146
- // worktree. Piece 1 is the planning task's own.
183
+ // worktree. The planning task's own piece (if it has one) is already
184
+ // building; the rest wait to start as tasks of their own.
147
185
  export function createFeature(
148
186
  s: Session,
149
187
  text: string,
150
188
  pieces: Piece[],
151
- workspaceId: string
189
+ workspaceId: string,
190
+ meta: { listed: number; folded: Folded[] } = {
191
+ listed: pieces.length,
192
+ folded: [],
193
+ }
152
194
  ): Feature {
195
+ const own = ownPieceOf(pieces, s.project_id)?.position;
153
196
  const id = randomUUID();
154
197
  fs.mkdirSync(plansDir(), { recursive: true });
155
198
  fs.writeFileSync(planPathOf(id), text);
156
199
  db.transaction(() => {
157
200
  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);
201
+ `INSERT INTO features (id, workspace_id, name, plan_session_id, plan_text, status, listed, folded)
202
+ VALUES (?, ?, ?, ?, ?, 'building', ?, ?)`
203
+ ).run(
204
+ id,
205
+ workspaceId,
206
+ s.name,
207
+ s.id,
208
+ text,
209
+ meta.listed,
210
+ meta.folded.length
211
+ ? meta.folded.map((f) => `${f.name}: ${f.why}`).join("\n")
212
+ : null
213
+ );
161
214
  const insert = db.prepare(
162
215
  `INSERT INTO feature_pieces
163
216
  (feature_id, position, name, slice, project_id, after_position, after_kind,
@@ -175,8 +228,8 @@ export function createFeature(
175
228
  p.afterKind,
176
229
  p.reasons.join("+") || null,
177
230
  p.owns.join(", ") || null,
178
- p.position === 1 ? "building" : "waiting",
179
- p.position === 1 ? s.id : null
231
+ p.position === own ? "building" : "waiting",
232
+ p.position === own ? s.id : null
180
233
  );
181
234
  })();
182
235
  return getFeature(id)!;
@@ -195,6 +248,9 @@ export function reviewGoal(sessionId: string): string | null {
195
248
  const owns = piece.owns ? `\nThis piece owns: ${piece.owns}.` : "";
196
249
  const head = `Piece ${piece.position} of ${live.length}, "${piece.name}": ${piece.slice}${owns}`;
197
250
  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)}`;
251
+ const folded = feature.folded
252
+ ? ` Pieces the plan listed that were built into others: ${feature.folded}.`
253
+ : "";
254
+ if (!last || (live.length < 2 && !feature.folded)) return head;
255
+ return `${head}\n\nThis is the last piece.${folded} 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
256
  }