@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
@@ -5,8 +5,10 @@ import {
5
5
  TABLES_WATCHED_56,
6
6
  TABLES_WATCHED_58,
7
7
  TABLES_WATCHED_78,
8
+ TABLES_WATCHED_80,
8
9
  } from "./changes";
9
10
  import type Database from "better-sqlite3";
11
+ import { moveSchedulesToRecipes } from "./schedules-move";
10
12
 
11
13
  interface Migration {
12
14
  id: number;
@@ -1830,6 +1832,147 @@ const migrations: Migration[] = [
1830
1832
  if (!has) db.exec(`ALTER TABLE task_queue ADD COLUMN agent_type TEXT`);
1831
1833
  },
1832
1834
  },
1835
+ {
1836
+ id: 80,
1837
+ name: "recipes",
1838
+ up: (db) => {
1839
+ // Recipes: a saved brief that starts a fresh task or session each run
1840
+ // (lib/recipes), and the features a plan splits into pieces
1841
+ // (lib/plans). All additive. A run or piece finds its session by
1842
+ // session_id, so sessions gains no column.
1843
+ db.exec(`
1844
+ CREATE TABLE IF NOT EXISTS recipes (
1845
+ id TEXT PRIMARY KEY,
1846
+ workspace_id TEXT NOT NULL,
1847
+ project_id TEXT NOT NULL,
1848
+ name TEXT NOT NULL,
1849
+ kind TEXT NOT NULL,
1850
+ brief TEXT NOT NULL,
1851
+ model TEXT,
1852
+ view TEXT NOT NULL DEFAULT 'chat',
1853
+ auto_done INTEGER NOT NULL DEFAULT 1,
1854
+ max_minutes INTEGER,
1855
+ carry_cleared_at INTEGER,
1856
+ source_session_id TEXT,
1857
+ created_by_session_id TEXT,
1858
+ updated_by_session_id TEXT,
1859
+ archived_at TEXT,
1860
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1861
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1862
+ );
1863
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_name
1864
+ ON recipes(workspace_id, name COLLATE NOCASE) WHERE archived_at IS NULL;
1865
+
1866
+ CREATE TABLE IF NOT EXISTS recipe_runs (
1867
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1868
+ recipe_id TEXT NOT NULL,
1869
+ seq INTEGER NOT NULL,
1870
+ trigger TEXT NOT NULL,
1871
+ triggered_by_session_id TEXT,
1872
+ schedule_run_id INTEGER,
1873
+ request_id TEXT,
1874
+ note TEXT,
1875
+ prompt TEXT NOT NULL,
1876
+ revision_id INTEGER,
1877
+ status TEXT NOT NULL,
1878
+ session_id TEXT,
1879
+ pr_number INTEGER,
1880
+ pr_url TEXT,
1881
+ summary TEXT,
1882
+ outputs TEXT,
1883
+ summary_source TEXT,
1884
+ error TEXT,
1885
+ cost_usd REAL,
1886
+ duration_ms INTEGER,
1887
+ started_at TEXT,
1888
+ ended_at TEXT,
1889
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1890
+ UNIQUE (recipe_id, seq)
1891
+ );
1892
+ CREATE INDEX IF NOT EXISTS idx_recipe_runs_recipe
1893
+ ON recipe_runs(recipe_id, id DESC);
1894
+ CREATE INDEX IF NOT EXISTS idx_recipe_runs_session
1895
+ ON recipe_runs(session_id);
1896
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipe_runs_one_live
1897
+ ON recipe_runs(recipe_id)
1898
+ WHERE status IN ('queued','starting','running');
1899
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipe_runs_request
1900
+ ON recipe_runs(request_id) WHERE request_id IS NOT NULL;
1901
+
1902
+ CREATE TABLE IF NOT EXISTS recipe_revisions (
1903
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1904
+ recipe_id TEXT NOT NULL,
1905
+ brief TEXT NOT NULL,
1906
+ settings TEXT NOT NULL,
1907
+ edited_by_session_id TEXT,
1908
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1909
+ );
1910
+
1911
+ CREATE TABLE IF NOT EXISTS features (
1912
+ id TEXT PRIMARY KEY,
1913
+ workspace_id TEXT NOT NULL,
1914
+ name TEXT NOT NULL,
1915
+ plan_session_id TEXT NOT NULL,
1916
+ plan_text TEXT NOT NULL,
1917
+ status TEXT NOT NULL,
1918
+ done_at TEXT,
1919
+ cost_usd REAL,
1920
+ duration_ms INTEGER,
1921
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1922
+ );
1923
+ CREATE INDEX IF NOT EXISTS idx_features_plan
1924
+ ON features(plan_session_id);
1925
+
1926
+ CREATE TABLE IF NOT EXISTS feature_pieces (
1927
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1928
+ feature_id TEXT NOT NULL,
1929
+ position INTEGER NOT NULL,
1930
+ name TEXT NOT NULL,
1931
+ slice TEXT NOT NULL,
1932
+ project_id TEXT NOT NULL,
1933
+ after_position INTEGER,
1934
+ after_kind TEXT,
1935
+ reasons TEXT,
1936
+ owns TEXT,
1937
+ status TEXT NOT NULL,
1938
+ session_id TEXT,
1939
+ note TEXT,
1940
+ UNIQUE (feature_id, position)
1941
+ );
1942
+ CREATE INDEX IF NOT EXISTS idx_feature_pieces_session
1943
+ ON feature_pieces(session_id);
1944
+ `);
1945
+ const cols = (
1946
+ db.prepare(`PRAGMA table_info(schedules)`).all() as { name: string }[]
1947
+ ).map((c) => c.name);
1948
+ if (!cols.includes("recipe_id"))
1949
+ db.exec(`ALTER TABLE schedules ADD COLUMN recipe_id TEXT`);
1950
+ if (!cols.includes("migrated_from_kind"))
1951
+ db.exec(`ALTER TABLE schedules ADD COLUMN migrated_from_kind TEXT`);
1952
+ const plan = (
1953
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
1954
+ ).map((c) => c.name);
1955
+ // Edits Saad made to a plan's pieces before approving it (merge, drop).
1956
+ if (!plan.includes("plan_edits"))
1957
+ db.exec(`ALTER TABLE sessions ADD COLUMN plan_edits TEXT`);
1958
+ installChangeTriggers(db, TABLES_WATCHED_80);
1959
+ },
1960
+ },
1961
+ {
1962
+ id: 81,
1963
+ name: "schedules_into_recipes",
1964
+ up: (db) => {
1965
+ // Live task and session schedules become recipes with a timer, and
1966
+ // their runs become recipe runs (lib/db/schedules-move). Reversible:
1967
+ // scripts/revert-schedules-move.ts.
1968
+ const cols = (
1969
+ db.prepare(`PRAGMA table_info(schedules)`).all() as { name: string }[]
1970
+ ).map((c) => c.name);
1971
+ if (!cols.includes("recipe_id") || !cols.includes("migrated_from_kind"))
1972
+ throw new Error("migration 80 hasn't run");
1973
+ moveSchedulesToRecipes(db);
1974
+ },
1975
+ },
1833
1976
  ];
1834
1977
 
1835
1978
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/queries.ts CHANGED
@@ -32,7 +32,10 @@ export function sessionListColumns(db: Database.Database, alias = ""): string {
32
32
  .split(",")
33
33
  .map((c) => `${prefix}${c}`)
34
34
  .join(", ");
35
- return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief`;
35
+ // The recipe run or feature piece a session is, as JSON, for its row's chip.
36
+ const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
37
+ const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "sessions"}.id)`;
38
+ return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
36
39
  }
37
40
 
38
41
  export const queries = {
@@ -0,0 +1,220 @@
1
+ // Migration 81: live task and session schedules become recipes with a timer
2
+ // (lib/recipes), and their runs become recipe runs. `migrated_from_kind` on
3
+ // the schedule row is what makes it reversible: revertSchedulesMove.
4
+ //
5
+ // A one-off (run_at) and an archived schedule are left as they are: a timer
6
+ // repeats, and history of archived ones stays where it was.
7
+
8
+ import { randomUUID } from "crypto";
9
+ import type Database from "better-sqlite3";
10
+
11
+ interface ScheduleRow {
12
+ id: string;
13
+ workspace_id: string;
14
+ project_id: string | null;
15
+ name: string;
16
+ prompt: string;
17
+ kind: "task" | "session";
18
+ created_by_session_id: string | null;
19
+ created_at: string;
20
+ }
21
+
22
+ interface RunRow {
23
+ id: number;
24
+ trigger: string;
25
+ session_id: string;
26
+ created_at: string;
27
+ task_status: string | null;
28
+ pr_number: number | null;
29
+ pr_url: string | null;
30
+ session_exists: number;
31
+ }
32
+
33
+ const TRIGGER: Record<string, string> = {
34
+ schedule: "timer",
35
+ "catch-up": "catch-up",
36
+ manual: "manual",
37
+ };
38
+
39
+ const FINAL: Record<string, string> = {
40
+ merged: "succeeded",
41
+ done: "succeeded",
42
+ dropped: "failed",
43
+ };
44
+
45
+ // The name is unique among a workspace's live recipes, ignoring case.
46
+ function freeName(
47
+ db: Database.Database,
48
+ workspaceId: string,
49
+ name: string
50
+ ): string {
51
+ const taken = db.prepare(
52
+ `SELECT 1 FROM recipes WHERE workspace_id = ? AND archived_at IS NULL AND name = ? COLLATE NOCASE`
53
+ );
54
+ if (!taken.get(workspaceId, name)) return name;
55
+ for (let n = 1; ; n++) {
56
+ const candidate = `${name} (schedule${n > 1 ? ` ${n}` : ""})`;
57
+ if (!taken.get(workspaceId, candidate)) return candidate;
58
+ }
59
+ }
60
+
61
+ export function moveSchedulesToRecipes(db: Database.Database): {
62
+ schedules: number;
63
+ runs: number;
64
+ } {
65
+ const sessionCols = (
66
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
67
+ ).map((c) => c.name);
68
+ const pr = sessionCols.includes("pr_number")
69
+ ? "s.pr_number, s.pr_url"
70
+ : "NULL AS pr_number, NULL AS pr_url";
71
+ const hasTaskStatus = sessionCols.includes("task_status");
72
+
73
+ const schedules = db
74
+ .prepare(
75
+ `SELECT id, workspace_id, project_id, name, prompt, kind, created_by_session_id, created_at
76
+ FROM schedules
77
+ WHERE archived_at IS NULL AND kind IN ('task','session')
78
+ AND run_at IS NULL AND project_id IS NOT NULL
79
+ ORDER BY created_at, id`
80
+ )
81
+ .all() as ScheduleRow[];
82
+
83
+ const insertRecipe = db.prepare(
84
+ `INSERT INTO recipes (id, workspace_id, project_id, name, kind, brief, model, view, created_by_session_id, updated_by_session_id, created_at)
85
+ VALUES (?, ?, ?, ?, ?, ?, NULL, 'chat', ?, ?, ?)`
86
+ );
87
+ const insertRevision = db.prepare(
88
+ `INSERT INTO recipe_revisions (recipe_id, brief, settings, edited_by_session_id) VALUES (?, ?, ?, ?)`
89
+ );
90
+ const switchSchedule = db.prepare(
91
+ `UPDATE schedules SET kind = 'recipe', recipe_id = ?, migrated_from_kind = ?, updated_at = datetime('now') WHERE id = ?`
92
+ );
93
+ const runsOf = db.prepare(
94
+ `SELECT r.id, r.trigger, r.session_id, r.created_at,
95
+ ${hasTaskStatus ? "s.task_status" : "NULL"} AS task_status, ${pr},
96
+ s.id IS NOT NULL AS session_exists
97
+ FROM schedule_runs r LEFT JOIN sessions s ON s.id = r.session_id
98
+ WHERE r.schedule_id = ? AND r.session_id IS NOT NULL
99
+ ORDER BY r.id`
100
+ );
101
+ const insertRun = db.prepare(
102
+ `INSERT INTO recipe_runs (recipe_id, seq, trigger, schedule_run_id, prompt, status, session_id, pr_number, pr_url, summary, started_at, created_at)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
104
+ );
105
+
106
+ let movedRuns = 0;
107
+ const move = db.transaction(() => {
108
+ for (const s of schedules) {
109
+ const recipeId = randomUUID();
110
+ insertRecipe.run(
111
+ recipeId,
112
+ s.workspace_id,
113
+ s.project_id,
114
+ freeName(db, s.workspace_id, s.name),
115
+ s.kind,
116
+ s.prompt,
117
+ s.created_by_session_id,
118
+ s.created_by_session_id,
119
+ s.created_at
120
+ );
121
+ insertRevision.run(
122
+ recipeId,
123
+ s.prompt,
124
+ JSON.stringify({
125
+ kind: s.kind,
126
+ model: null,
127
+ view: "chat",
128
+ auto_done: true,
129
+ max_minutes: null,
130
+ }),
131
+ s.created_by_session_id
132
+ );
133
+ switchSchedule.run(recipeId, s.kind, s.id);
134
+
135
+ const runs = runsOf.all(s.id) as RunRow[];
136
+ runs.forEach((r, i) => {
137
+ // One live run per recipe: only the latest may still be running
138
+ // (settle judges it); an earlier one with no recorded end is
139
+ // closed as cancelled rather than guessed a result.
140
+ const final = r.task_status ? FINAL[r.task_status] : undefined;
141
+ const last = i === runs.length - 1;
142
+ const status = final ?? (last ? "running" : "cancelled");
143
+ insertRun.run(
144
+ recipeId,
145
+ i + 1,
146
+ TRIGGER[r.trigger] ?? "timer",
147
+ r.id,
148
+ s.prompt,
149
+ status,
150
+ r.session_id,
151
+ r.pr_number,
152
+ r.pr_url,
153
+ status === "cancelled"
154
+ ? "Ran before recipes; its outcome wasn't recorded"
155
+ : null,
156
+ r.created_at,
157
+ r.created_at
158
+ );
159
+ movedRuns++;
160
+ });
161
+ }
162
+ });
163
+ move();
164
+ return { schedules: schedules.length, runs: movedRuns };
165
+ }
166
+
167
+ // Undo the move: each moved schedule gets its old kind (and the brief as it
168
+ // stands now) back, and the recipes the move made go, with their runs and
169
+ // revisions. The runs stay in schedule_runs, which the move never touched.
170
+ // Migration 81 stays recorded as applied, so a restart doesn't redo it.
171
+ export function revertSchedulesMove(db: Database.Database): {
172
+ schedules: number;
173
+ } {
174
+ const moved = db
175
+ .prepare(
176
+ `SELECT id, recipe_id, migrated_from_kind FROM schedules WHERE migrated_from_kind IS NOT NULL`
177
+ )
178
+ .all() as {
179
+ id: string;
180
+ recipe_id: string | null;
181
+ migrated_from_kind: string;
182
+ }[];
183
+ const recipe = db.prepare(
184
+ `SELECT brief, project_id FROM recipes WHERE id = ?`
185
+ );
186
+ const restore = db.prepare(
187
+ `UPDATE schedules SET kind = ?, recipe_id = NULL, migrated_from_kind = NULL,
188
+ prompt = COALESCE(?, prompt), project_id = COALESCE(?, project_id), updated_at = datetime('now')
189
+ WHERE id = ?`
190
+ );
191
+ const drop = (table: string) =>
192
+ db.prepare(`DELETE FROM ${table} WHERE recipe_id = ?`);
193
+ const [dropRuns, dropRevisions] = [
194
+ drop("recipe_runs"),
195
+ drop("recipe_revisions"),
196
+ ];
197
+ const dropRecipe = db.prepare(`DELETE FROM recipes WHERE id = ?`);
198
+
199
+ db.transaction(() => {
200
+ for (const s of moved) {
201
+ const r = s.recipe_id
202
+ ? (recipe.get(s.recipe_id) as
203
+ | { brief: string; project_id: string }
204
+ | undefined)
205
+ : undefined;
206
+ restore.run(
207
+ s.migrated_from_kind,
208
+ r?.brief ?? null,
209
+ r?.project_id ?? null,
210
+ s.id
211
+ );
212
+ if (s.recipe_id) {
213
+ dropRuns.run(s.recipe_id);
214
+ dropRevisions.run(s.recipe_id);
215
+ dropRecipe.run(s.recipe_id);
216
+ }
217
+ }
218
+ })();
219
+ return { schedules: moved.length };
220
+ }
package/lib/db/types.ts CHANGED
@@ -26,6 +26,8 @@ export interface Session {
26
26
  started_by?: string | null;
27
27
  // 1 planned, 0 no plan, null not stated; why, in the starter's words.
28
28
  plan_decided?: number | null;
29
+ // JSON: the pieces Saad merged or dropped before approving the plan.
30
+ plan_edits?: string | null;
29
31
  plan_reason?: string | null;
30
32
  system_prompt: string | null;
31
33
  group_path: string; // Deprecated - use project_id
@@ -55,6 +57,10 @@ export interface Session {
55
57
  task_brief?: string | null;
56
58
  // Lists carry this instead of the brief itself (0/1).
57
59
  has_brief?: number;
60
+ // JSON, in lists: the recipe run this session is ({id, name, seq}), and the
61
+ // feature piece it builds ({feature, name, position, total}).
62
+ recipe_run?: string | null;
63
+ feature_piece?: string | null;
58
64
  // How the session is shown: a chat conversation or a terminal.
59
65
  view: "chat" | "terminal";
60
66
  chat_access: ChatAccess;
@@ -21,6 +21,8 @@ import { ANTHROPIC } from "./roles";
21
21
  import { queueEvent } from "../orchestrator/events";
22
22
  import { addNote } from "../orchestrator/notes";
23
23
  import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
24
+ import { PIECES_PROMPT, pieceBrief } from "../plans/pieces";
25
+ import { createFeature, planFor, planPathOf, splitOf } from "../plans/features";
24
26
 
25
27
  export const PLAN_FILE = ".agentos/plan.md";
26
28
 
@@ -31,7 +33,9 @@ export function planPrompt(task: string, only = false): string {
31
33
  return `${task}
32
34
 
33
35
  ---
34
- Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. Don't end your turn while subagents or background commands you started are still running; wait for their results and write the plan in the same turn. End the turn once the plan is written: ${after}.`;
36
+ Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. Don't end your turn while subagents or background commands you started are still running; wait for their results and write the plan in the same turn. End the turn once the plan is written: ${after}.
37
+
38
+ ${PIECES_PROMPT}`;
35
39
  }
36
40
 
37
41
  export const PLAN_RETRY_PROMPT = `Write the plan to \`${PLAN_FILE}\` now, plan only.`;
@@ -42,10 +46,16 @@ export const planRetryMessageId = (sessionId: string) =>
42
46
  // treated as having nothing running and re-prompted.
43
47
  export const PLAN_BACKGROUND_WAIT_MS = 20 * 60 * 1000;
44
48
 
45
- export function implementPrompt(planned: boolean, amendment?: string): string {
46
- const base = planned
47
- ? `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.`
48
- : `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
49
+ export function implementPrompt(
50
+ planned: boolean,
51
+ amendment?: string,
52
+ piece?: string
53
+ ): string {
54
+ const base = piece
55
+ ? `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.`
56
+ : planned
57
+ ? `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.`
58
+ : `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
49
59
  return amendment
50
60
  ? `${base}\n\nThe plan was approved with this change, which is now part of the task's scope:\n${amendment}`
51
61
  : base;
@@ -87,6 +97,18 @@ export function planSummary(text: string, max = 5): string[] {
87
97
  return picked.map((l) => trim(l.text));
88
98
  }
89
99
 
100
+ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
101
+ const parts = r.pieces.map((p) => {
102
+ const after = p.afterPosition
103
+ ? ` after ${p.afterPosition} is ${p.afterKind}`
104
+ : "";
105
+ const why = p.reasons.length ? ` (${p.reasons.join("+")})` : "";
106
+ return `${p.position} ${p.name}${after}${why}`;
107
+ });
108
+ const folded = r.folded.length ? `, ${r.folded.length} folded` : "";
109
+ return `${r.pieces.length} pieces${folded}: ${parts.join("; ")}`;
110
+ }
111
+
90
112
  // Tells the orchestrator that started a task its plan is written, once.
91
113
  function announcePlan(s: Session, ref: string | null, retried = false): void {
92
114
  if (!s.started_by && !s.held_by_device) return;
@@ -105,6 +127,8 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
105
127
  const summary = planSummary(fs.readFileSync(file, "utf8"));
106
128
  head = `Task "${s.name}": plan written to ${file}; ${next}.`;
107
129
  if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
130
+ const pieces = planFor(s, fs.readFileSync(file, "utf8"));
131
+ if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
108
132
  } catch {
109
133
  // No plan file: the line says so.
110
134
  }
@@ -118,6 +142,14 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
118
142
  queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
119
143
  }
120
144
 
145
+ function readPlan(s: Session): string | null {
146
+ try {
147
+ return fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8");
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
121
153
  export const implementMessageId = (sessionId: string) =>
122
154
  `implement-${sessionId}`;
123
155
 
@@ -146,6 +178,9 @@ async function startImplementing(
146
178
  override: taskOverride(s),
147
179
  });
148
180
  const id = implementMessageId(sessionId);
181
+ const planText = readPlan(s);
182
+ const split = planText ? splitOf(s, planText) : null;
183
+ const workspaceId = sessionWorkspace(s);
149
184
  const claimed = db.transaction(() => {
150
185
  const changed = db
151
186
  .prepare(
@@ -155,11 +190,26 @@ async function startImplementing(
155
190
  .run(sessionId, from).changes;
156
191
  if (!changed) return false;
157
192
  setSessionModel(sessionId, routed);
193
+ let piece: string | undefined;
194
+ let note = amendment;
195
+ if (split?.notBuilding.length)
196
+ note = [amendment, `Not building: ${split.notBuilding.join("; ")}`]
197
+ .filter(Boolean)
198
+ .join("\n");
199
+ if (split && split.pieces.length > 1 && planText && workspaceId) {
200
+ const feature = createFeature(s, planText, split.pieces, workspaceId);
201
+ piece = pieceBrief(
202
+ split.pieces.length,
203
+ split.pieces[0],
204
+ planPathOf(feature.id)
205
+ );
206
+ }
158
207
  enqueue(sessionId, {
159
208
  id,
160
209
  text: implementPrompt(
161
210
  fs.existsSync(path.join(s.worktree_path!, PLAN_FILE)),
162
- amendment
211
+ note,
212
+ piece
163
213
  ),
164
214
  origin: { kind: "decision", label: "Plan step" },
165
215
  });
@@ -308,6 +358,17 @@ export async function handoffAfterPlan(
308
358
  } catch (e) {
309
359
  console.error("plan-done event failed:", e);
310
360
  }
361
+ try {
362
+ const text = readPlan(s);
363
+ const planned = text ? planFor(s, text) : null;
364
+ (await import("../notify/push/events")).pushPlanAwaiting(
365
+ s.id,
366
+ s.name,
367
+ planned?.pieces.length ?? 1
368
+ );
369
+ } catch (e) {
370
+ console.error("plan push failed:", e);
371
+ }
311
372
  // The worker has nothing left to do: it ends at this turn boundary, and
312
373
  // the next message (an approval, or a revision) starts one that resumes.
313
374
  try {
@@ -13,14 +13,14 @@ import {
13
13
  type PushEvent,
14
14
  type PushLevel,
15
15
  } from "./push-types";
16
- import { safeId, safeKey } from "./redact";
16
+ import { safeId, safeKey, scrubText } from "./redact";
17
17
 
18
18
  export interface CategoryDef {
19
19
  level: PushLevel;
20
20
  defaultOn: boolean;
21
21
  /** Settings label. */
22
22
  label: string;
23
- /** The one word or phrase shown as the body. Fixed text, no event data. */
23
+ /** The word or phrase shown as the body unless the sender names one. */
24
24
  word: string;
25
25
  /** App path for an id, or the fallback when the id is missing or unsafe. */
26
26
  link: (id: string | null) => string;
@@ -51,6 +51,13 @@ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
51
51
  word: "Ready for review",
52
52
  link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
53
53
  },
54
+ plan: {
55
+ level: "active",
56
+ defaultOn: true,
57
+ label: "Plan ready",
58
+ word: "Plan ready",
59
+ link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
60
+ },
54
61
  ci: {
55
62
  level: "active",
56
63
  defaultOn: true,
@@ -108,9 +115,18 @@ export function enabledCategories(
108
115
  : defaultCategories();
109
116
  }
110
117
 
118
+ const NAMED_MAX = 80;
119
+
120
+ /** Text a sender named for the alert: one scrubbed line, or null. */
121
+ function named(text: string | null | undefined): string | null {
122
+ const clean = scrubText(text, NAMED_MAX);
123
+ return clean || null;
124
+ }
125
+
111
126
  /**
112
- * The v1 payload for an event. Built only from the registry and a vetted id
113
- * and key, so it carries no event text whatever the caller passes.
127
+ * The v1 payload for an event. Built from the registry, a vetted id and key,
128
+ * and at most a title and body the sender named (a plan's or feature's name),
129
+ * each scrubbed to one short line with no secret-shaped runs.
114
130
  */
115
131
  export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
116
132
  const def = CATEGORIES[event.category];
@@ -118,8 +134,8 @@ export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
118
134
  return {
119
135
  v: 1,
120
136
  c: event.category,
121
- t: PUSH_TITLE,
122
- b: def.word,
137
+ t: named(event.title) ?? PUSH_TITLE,
138
+ b: named(event.body) ?? def.word,
123
139
  l: def.link(id),
124
140
  k: safeKey(event.key) ?? `${event.category}:${id ?? "-"}`,
125
141
  p: def.level,
@@ -169,8 +185,8 @@ export function isAppLink(link: string): boolean {
169
185
  }
170
186
 
171
187
  /**
172
- * True only for a payload the registry could have built: fixed title, the
173
- * category's own word, a known link shape, a plain key. A sender checks this
188
+ * True only for a payload the registry could have built: a scrubbed title
189
+ * and body, a known link shape, a plain key. A sender checks this
174
190
  * before it sends, so nothing else can leave the machine.
175
191
  */
176
192
  export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
@@ -180,8 +196,10 @@ export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
180
196
  const def = CATEGORIES[o.c];
181
197
  return (
182
198
  Object.keys(o).sort().join() === "b,c,k,l,p,t,v" &&
183
- o.t === PUSH_TITLE &&
184
- o.b === def.word &&
199
+ typeof o.t === "string" &&
200
+ named(o.t) === o.t &&
201
+ typeof o.b === "string" &&
202
+ named(o.b) === o.b &&
185
203
  o.p === def.level &&
186
204
  typeof o.l === "string" &&
187
205
  isAppLink(o.l) &&
@@ -52,8 +52,42 @@ export const pushCiFailed = (taskId: string, sha: string) =>
52
52
  forTask("ci", "ci", taskId, sha);
53
53
  export const pushReviewBlocked = (taskId: string, sha: string) =>
54
54
  forTask("ci", "review-block", taskId, sha);
55
- export const pushMerged = (taskId: string) =>
56
- forTask("merged", "merged", taskId);
55
+ // A piece of a feature is covered by the feature's own push when it closes.
56
+ const isFeaturePiece = (sessionId: string) =>
57
+ !!db
58
+ .prepare(`SELECT 1 FROM feature_pieces WHERE session_id = ?`)
59
+ .get(sessionId);
60
+
61
+ export const pushMerged = (taskId: string) => {
62
+ if (!isFeaturePiece(taskId)) forTask("merged", "merged", taskId);
63
+ };
64
+
65
+ export const pushFeatureMerged = (featureId: string, name: string) =>
66
+ fire({
67
+ category: "merged",
68
+ id: null,
69
+ key: `feature:${featureId}`,
70
+ collapse: `group:feature:${featureId}`,
71
+ title: "Feature merged",
72
+ body: name,
73
+ });
74
+
75
+ export const pushPlanAwaiting = (
76
+ taskId: string,
77
+ name: string,
78
+ pieces: number
79
+ ) =>
80
+ fire(
81
+ {
82
+ category: "plan",
83
+ id: taskId,
84
+ key: `plan-ready:${taskId}`,
85
+ collapse: `plan:${taskId}`,
86
+ title: "Plan ready",
87
+ body: `${name}, ${pieces} ${pieces === 1 ? "piece" : "pieces"}`,
88
+ },
89
+ taskId
90
+ );
57
91
 
58
92
  export const pushPlanReady = (sessionId: string, itemId: string) => {
59
93
  // Only a task has a screen to open; a plain chat's plan goes to the list.