@saadnvd1/agent-os 0.3.558 → 0.3.561

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 (51) hide show
  1. package/README.md +1 -0
  2. package/app/api/sessions/route.ts +3 -3
  3. package/app/api/shares/[id]/route.ts +23 -0
  4. package/app/api/shares/preview/route.ts +30 -0
  5. package/app/api/shares/route.test.ts +455 -0
  6. package/app/api/shares/route.ts +101 -0
  7. package/app/page.tsx +5 -0
  8. package/components/Chat/Artifact.tsx +17 -1
  9. package/components/Pane/MobileTabBar.tsx +19 -0
  10. package/components/Pane/PaneMenu.tsx +16 -0
  11. package/components/SessionList/SessionRowMenu.tsx +16 -0
  12. package/components/Settings/SettingsDialog.test.ts +3 -0
  13. package/components/Settings/SettingsDialog.tsx +9 -0
  14. package/components/Settings/commands.ts +9 -0
  15. package/components/Settings/entry-points.test.ts +1 -0
  16. package/components/Share/ShareDialog.tsx +336 -0
  17. package/components/Share/format.test.ts +20 -0
  18. package/components/Share/format.ts +33 -0
  19. package/components/Shares/SharedLinksPanel.tsx +147 -0
  20. package/data/shares/index.ts +2 -0
  21. package/data/shares/keys.ts +4 -0
  22. package/data/shares/queries.ts +111 -0
  23. package/lib/chat/drivers/claude.ts +5 -4
  24. package/lib/connect/control-api.ts +84 -0
  25. package/lib/db/migrations.ts +50 -0
  26. package/lib/db/queries.ts +4 -3
  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/reconcile.ts +201 -0
  31. package/lib/orchestrator/untrusted.ts +12 -2
  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/settle.ts +3 -0
  36. package/lib/security/demo.ts +1 -0
  37. package/lib/session-audit/normalize.ts +1 -1
  38. package/lib/settings-url.ts +1 -0
  39. package/lib/share/connect.ts +86 -0
  40. package/lib/share/context.ts +50 -0
  41. package/lib/share/redact.ts +190 -0
  42. package/lib/share/render.tsx +161 -0
  43. package/lib/share/route.ts +33 -0
  44. package/lib/share/service.ts +159 -0
  45. package/lib/share/snapshot.ts +261 -0
  46. package/lib/share/static-markup.ts +16 -0
  47. package/lib/share/store.ts +39 -0
  48. package/lib/share/styles.ts +83 -0
  49. package/lib/sidebar/shelves.ts +5 -6
  50. package/package.json +1 -1
  51. package/stores/shareUi.ts +17 -0
@@ -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(
@@ -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
+ }
@@ -20,8 +20,8 @@ const TOKEN_PATTERNS: RegExp[] = [...SECRET_TOKENS, /\b[a-fA-F0-9]{40,}\b/g];
20
20
 
21
21
  // A long base64-ish run that reads as random rather than as a path or a
22
22
  // slug: mixed case, digits, few separators.
23
- const BASE64_RUN = /[A-Za-z0-9+/_-]{40,}={0,2}/g;
24
- const looksRandom = (s: string) =>
23
+ export const BASE64_RUN = /[A-Za-z0-9+/_-]{40,}={0,2}/g;
24
+ export const looksRandom = (s: string) =>
25
25
  /[A-Z]/.test(s) &&
26
26
  /[a-z]/.test(s) &&
27
27
  /[0-9]/.test(s) &&
@@ -31,6 +31,16 @@ const looksRandom = (s: string) =>
31
31
  export const SECRET_ASSIGNMENT =
32
32
  /^(\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*\s*[=:]\s*).+$/gm;
33
33
 
34
+ // A URL's userinfo (https://key@host, postgres://user:pw@host) and a query
35
+ // parameter that carries a credential (?token=, &api_key=).
36
+ export const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi;
37
+ export const SECRET_PARAM =
38
+ /([?&][\w.-]*(?:key|token|secret|password|auth|sig|code)[\w.-]*=)[^&\s#]+/gi;
39
+
40
+ // A private key block's opening line.
41
+ export const PRIVATE_KEY_BLOCK =
42
+ /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )?PRIVATE KEY-----|$)/g;
43
+
34
44
  // The password in a URL: postgres://user:secret@host.
35
45
  const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)[^@\s]+@/gi;
36
46
 
@@ -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
  }
@@ -37,6 +37,12 @@ export interface PiecesResult {
37
37
  pieces: Piece[];
38
38
  folded: Folded[];
39
39
  warnings: string[];
40
+ // Items the plan's Pieces block lists (0 when it has none), however well
41
+ // they parsed: a plan that builds as fewer than it listed is never silent.
42
+ listed: number;
43
+ // Problems that must stop a task from building on a guess: the pieces
44
+ // returned are then the one-piece fallback, for a person to approve or fix.
45
+ errors: string[];
40
46
  }
41
47
 
42
48
  interface Raw {
@@ -51,7 +57,10 @@ interface Raw {
51
57
 
52
58
  const HEADING = /^##\s+pieces\s*$/i;
53
59
  const ITEM = /^\s{0,3}(\d+)[.)]\s+(.*\S)\s*$/;
54
- const AFTER = /^\s*after:\s*(\d+)\s+is\s+(merged|deployed)\s*$/i;
60
+ // An item as a person might write it, to count what the plan listed even
61
+ // when the strict parser rejects the block.
62
+ const LOOSE_ITEM = /^\s{0,3}(?:[-*+]\s+)?\**\d+[.)]/;
63
+ const AFTER = /^\s*after:\s*(?:piece\s+)?(\d+)\s+is\s+(merged|deployed)\b.*$/i;
55
64
  const WHY = /^\s*why split:\s*(.+?)\s*$/i;
56
65
  const OWNS = /^\s*owns:\s*(.+?)\s*$/i;
57
66
 
@@ -70,6 +79,14 @@ function block(text: string): string[] | null {
70
79
  return out;
71
80
  }
72
81
 
82
+ // Bullets and bold around the numbers and keys a model sometimes adds
83
+ // ("- **after:** 1 is merged") don't change what a line says.
84
+ function plain(line: string): string {
85
+ return line
86
+ .replace(/^(\s*)[-*+]\s+(?=\**(?:\d+[.)]|after|why split|owns))/i, "$1")
87
+ .replace(/\*\*|__/g, "");
88
+ }
89
+
73
90
  function parseItem(position: number, rest: string): Raw {
74
91
  let repo: string | null = null;
75
92
  const withRepo = rest.match(/^(.*?)\s*\(repo:\s*([^)]+)\)\s*$/i);
@@ -93,8 +110,9 @@ function parseItem(position: number, rest: string): Raw {
93
110
 
94
111
  function parseBlock(lines: string[]): Raw[] | null {
95
112
  const items: Raw[] = [];
96
- for (const line of lines) {
97
- if (!line.trim()) continue;
113
+ for (const original of lines) {
114
+ if (!original.trim()) continue;
115
+ const line = plain(original);
98
116
  const item = line.match(ITEM);
99
117
  if (item) {
100
118
  items.push(parseItem(Number(item[1]), item[2]));
@@ -123,6 +141,8 @@ function parseBlock(lines: string[]): Raw[] | null {
123
141
  .split(/[,;]/)
124
142
  .map((o) => o.trim().toLowerCase())
125
143
  .filter(Boolean);
144
+ // A wrapped slice: an indented line that is none of the keys.
145
+ else if (/^\s{2,}\S/.test(original)) current.slice += ` ${line.trim()}`;
126
146
  else return null;
127
147
  }
128
148
  if (!items.length || items.some((it, i) => it.position !== i + 1))
@@ -136,7 +156,13 @@ export interface CheckContext {
136
156
  find: (name: string) => ProjectRef | null;
137
157
  }
138
158
 
139
- function one(text: string, ctx: CheckContext, warnings: string[]) {
159
+ function one(
160
+ text: string,
161
+ ctx: CheckContext,
162
+ warnings: string[],
163
+ listed = 0,
164
+ errors: string[] = []
165
+ ): PiecesResult {
140
166
  return {
141
167
  pieces: [
142
168
  {
@@ -152,30 +178,41 @@ function one(text: string, ctx: CheckContext, warnings: string[]) {
152
178
  ],
153
179
  folded: [] as Folded[],
154
180
  warnings,
181
+ listed,
182
+ errors,
155
183
  };
156
184
  }
157
185
 
158
186
  export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
159
187
  const lines = block(text);
160
188
  if (!lines) return one("", ctx, []);
189
+ const listed = lines.filter((l) => LOOSE_ITEM.test(l)).length;
161
190
  const raw = parseBlock(lines);
162
191
  if (!raw)
163
- return one("", ctx, [
164
- "The Pieces block couldn't be read, so this is one piece.",
192
+ return one("", ctx, [], listed, [
193
+ "the Pieces block couldn't be read (a numbered item followed by anything but after:, why split: and owns: lines)",
194
+ ]);
195
+ if (raw.length !== listed)
196
+ return one("", ctx, [], listed, [
197
+ `the Pieces block lists ${listed} items but ${raw.length} were read`,
165
198
  ]);
166
199
  if (raw.length > MAX_PIECES)
167
- return one("", ctx, [
168
- `${raw.length} pieces is past the limit of ${MAX_PIECES}; built as one piece.`,
200
+ return one("", ctx, [], listed, [
201
+ `${raw.length} pieces is past the limit of ${MAX_PIECES}`,
169
202
  ]);
170
203
 
171
204
  const warnings: string[] = [];
205
+ const errors: string[] = [];
172
206
  const projects = raw.map((r) => {
173
207
  if (!r.repo) return ctx.own;
174
208
  const found = ctx.find(r.repo);
175
209
  if (!found)
176
- warnings.push(`"${r.name}": no project named ${r.repo}; using this one.`);
210
+ errors.push(
211
+ `"${r.name}": (repo: ${r.repo}) is not a project of this workspace`
212
+ );
177
213
  return found ?? ctx.own;
178
214
  });
215
+ if (errors.length) return one("", ctx, [], listed, errors);
179
216
 
180
217
  // Where each original piece ended up, for `after:` lines that pointed at a
181
218
  // piece that was folded.
@@ -212,12 +249,15 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
212
249
  }
213
250
 
214
251
  let reasons = r.reasons;
215
- if (!reasons.length) return fold("no reason for a separate piece");
252
+ if (!reasons.length)
253
+ return fold("no `why split:` line naming one of (a) to (d)");
254
+ const lost: string[] = [];
216
255
 
217
256
  if (reasons.includes("a")) {
218
257
  const others = projects.filter((_, j) => j !== i);
219
258
  if (others.some((p) => p.id === project.id)) {
220
259
  reasons = reasons.filter((x) => x !== "a");
260
+ lost.push("(a) shares a repo with another piece");
221
261
  warnings.push(
222
262
  `"${r.name}" claimed (a) but shares a repo with another piece.`
223
263
  );
@@ -240,8 +280,10 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
240
280
  const dep = afterPosition ? pieces[afterPosition - 1] : null;
241
281
  if (afterKind !== "deployed" || !dep) {
242
282
  reasons = reasons.filter((x) => x !== "b");
283
+ lost.push("(b) needs `after: N is deployed`, not merged");
243
284
  } else if (!dep.project.deploys) {
244
285
  reasons = reasons.filter((x) => x !== "b");
286
+ lost.push(`(b) waits on a deploy ${dep.project.name} can't check`);
245
287
  afterKind = "merged";
246
288
  warnings.push(
247
289
  `"${r.name}" waits on a deploy that ${dep.project.name} can't check; it waits for the merge instead.`
@@ -252,7 +294,8 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
252
294
  if (!dep?.project.deploys) afterKind = "merged";
253
295
  }
254
296
 
255
- if (!reasons.length) return fold("its reason didn't hold");
297
+ if (!reasons.length)
298
+ return fold(`its reason didn't hold: ${lost.join("; ")}`);
256
299
 
257
300
  keep({
258
301
  name: r.name,
@@ -274,7 +317,15 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
274
317
  else seen.set(o, p.position);
275
318
  }
276
319
 
277
- return { pieces, folded, warnings };
320
+ return { pieces, folded, warnings, listed, errors };
321
+ }
322
+
323
+ // What a task that builds fewer pieces than its plan listed is told, so the
324
+ // folded work is built here and not left for pieces that will never start.
325
+ export function foldedBrief(
326
+ result: Pick<PiecesResult, "listed" | "folded">
327
+ ): string {
328
+ return `The plan listed ${result.listed} pieces; they're built as ONE here (${result.folded.map((f) => `${f.name}: ${f.why}`).join("; ")}). Build all of it in this PR, including the folded pieces: ${result.folded.map((f) => f.name).join(", ")}. Nothing else will build them.`;
278
329
  }
279
330
 
280
331
  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:
@@ -292,14 +343,15 @@ Diff size is never a reason. A later piece looks like:
292
343
  after: 1 is merged (or: 1 is deployed)
293
344
  why split: (a) + (b)
294
345
  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.`;
346
+ 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. Reason (b) needs \`after: N is deployed\`; \`after: N is merged\` alone is only ordering, not a reason. A piece in another project runs as its own task in that project; this task builds only the pieces in its own repository.`;
296
347
 
297
348
  // Shown to a piece's agent in place of the whole plan.
298
349
  export function pieceBrief(
299
350
  total: number,
300
351
  piece: Pick<Piece, "position" | "name" | "slice" | "owns">,
301
352
  planPath: string,
302
- before: string[] = []
353
+ before: string[] = [],
354
+ project?: string
303
355
  ): string {
304
356
  const owns = piece.owns.length
305
357
  ? `\nThis piece owns: ${piece.owns.join(", ")}. No other piece touches it.`
@@ -307,7 +359,10 @@ export function pieceBrief(
307
359
  const earlier = before.length
308
360
  ? `\nPieces before you: ${before.join("; ")}.`
309
361
  : "";
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}`;
362
+ const where = project
363
+ ? `\nWork only in this task's repository (${project}) and its worktree. Don't create worktrees, branches or PRs in another repository: AgentOS starts the pieces that belong there as their own tasks.`
364
+ : "";
365
+ return `Build piece ${piece.position} of ${total} only, "${piece.name}": ${piece.slice}${owns}${earlier}${where}\nThe rest of the plan is other pieces' work; don't start it. Full plan: ${planPath}`;
311
366
  }
312
367
 
313
368
  export interface PlanEdits {