@saadnvd1/agent-os 0.3.457 → 0.3.460

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 (58) hide show
  1. package/README.md +42 -5
  2. package/app/api/fresh-start/route.ts +19 -0
  3. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
  4. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
  5. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
  6. package/app/api/workspaces/[id]/route.ts +8 -1
  7. package/app/globals.css +10 -0
  8. package/components/Chat/ChatPanel.tsx +87 -4
  9. package/components/Chat/FindBar.tsx +113 -0
  10. package/components/Chat/Tools.tsx +1 -1
  11. package/components/Chat/find-highlight.test.ts +56 -0
  12. package/components/Chat/find-highlight.ts +78 -0
  13. package/components/Chat/rowState.tsx +44 -4
  14. package/components/Chat/stick-to-bottom.ts +7 -1
  15. package/components/Chat/useFind.ts +253 -0
  16. package/components/Pane/MobileTabBar.tsx +18 -0
  17. package/components/Pane/index.tsx +6 -0
  18. package/components/Workspaces/FreshStartForm.tsx +128 -0
  19. package/components/Workspaces/RulesForm.tsx +166 -0
  20. package/components/Workspaces/WorkspaceSettings.tsx +13 -1
  21. package/components/Workspaces/fresh-start.ts +19 -0
  22. package/data/chat/useChat.ts +29 -0
  23. package/data/fresh-start/index.ts +34 -0
  24. package/data/orchestrators/rules.ts +80 -0
  25. package/data/push/topics.ts +4 -0
  26. package/data/workspaces/index.ts +6 -1
  27. package/lib/chat/demo.ts +1 -0
  28. package/lib/chat/events.ts +7 -1
  29. package/lib/chat/find.ts +278 -0
  30. package/lib/chat/refresh-limits.ts +5 -0
  31. package/lib/chat/refresh.ts +82 -0
  32. package/lib/chat/runner.ts +11 -2
  33. package/lib/chat/search.ts +117 -0
  34. package/lib/chat/socket.ts +12 -0
  35. package/lib/chat/worker/extras.ts +11 -1
  36. package/lib/chat/worker/host.ts +70 -5
  37. package/lib/db/changes.ts +3 -0
  38. package/lib/db/migrations.ts +69 -0
  39. package/lib/db/types.ts +9 -0
  40. package/lib/orchestrator/brief.ts +16 -3
  41. package/lib/orchestrator/conditions.ts +10 -3
  42. package/lib/orchestrator/digest.ts +339 -0
  43. package/lib/orchestrator/pr-wake.ts +385 -0
  44. package/lib/orchestrator/presence-binding.ts +5 -0
  45. package/lib/orchestrator/rules.ts +124 -0
  46. package/lib/orchestrator/serve.ts +11 -0
  47. package/lib/orchestrator/tool-names.ts +2 -0
  48. package/lib/orchestrator/tool-schemas.ts +10 -0
  49. package/lib/orchestrator/tools.ts +4 -0
  50. package/lib/orchestrator/watcher.ts +6 -2
  51. package/lib/orchestrator/worker.ts +5 -0
  52. package/lib/security/presence.ts +2 -1
  53. package/lib/tasks/gh.ts +2 -0
  54. package/lib/tasks/pr-activity.ts +295 -0
  55. package/lib/tasks/state.ts +9 -1
  56. package/lib/workspaces.ts +8 -2
  57. package/package.json +1 -1
  58. package/styles/themes.css +5 -0
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Wakes a task's own agent when its PR needs it, so the orchestrator
3
+ * doesn't have to pass the news on. Runs in the watcher's merge pipeline,
4
+ * off the PR poll AgentOS already makes (no poller of its own):
5
+ *
6
+ * - A failing check on the PR's head: the task hears it once per head,
7
+ * with the check's name, the failing step and the log around the error
8
+ * (`gh run view --log-failed`, trimmed). Read from the rollup the poll
9
+ * already has, so only the log costs a call.
10
+ * - A review or conversation comment from a collaborator other than the
11
+ * account gh runs as (which is the task's own): one GraphQL query per
12
+ * repository a minute fingerprints every watched PR, and a PR's reviews
13
+ * and comments are read only when its fingerprint moved. Comment-only
14
+ * wakes stop at COMMENT_WAKE_CAP a task; changes requested always wake.
15
+ *
16
+ * Each event is claimed in task_pr_wakes before its message goes, so it's
17
+ * sent once across restarts. Messages go through the bus like any other:
18
+ * a task mid-turn gets it queued, never interrupted. The orchestrator hears
19
+ * one line per wake. Only open PRs of running tasks are watched: merged,
20
+ * closed, dropped or done stops it. Off per workspace in Settings.
21
+ */
22
+
23
+ import { db, type Session } from "../db";
24
+ import { sendMessage } from "../bus";
25
+ import { getWorkspace } from "../workspaces";
26
+ import { OPEN_TTL_MS } from "../tasks/pr-poll";
27
+ import {
28
+ failedLog,
29
+ fingerprints,
30
+ prActivity,
31
+ repoOfPR,
32
+ type FailedLog,
33
+ type Fingerprint,
34
+ type PREvent,
35
+ type Repo,
36
+ } from "../tasks/pr-activity";
37
+ import type { TaskPR } from "../tasks/state";
38
+ import { ciKey, conditionKey } from "./conditions";
39
+ import { markTold, queueEvent } from "./events";
40
+ import type { SessionFacts } from "./facts";
41
+ import { isPaused } from "./pause";
42
+ import { repoOf } from "./repo";
43
+ import { untrusted, UNTRUSTED_RULE } from "./untrusted";
44
+
45
+ export const COMMENT_WAKE_CAP = 10;
46
+ // A first look at a PR this young takes its whole history as new.
47
+ const YOUNG_PR_MS = 10 * 60_000;
48
+ // Collaborators only: anyone can comment on a public repository's PR.
49
+ const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
50
+ const BODY_MAX = 800;
51
+ const NOTE_MAX = 300;
52
+ const NOTES_MAX = 10;
53
+ const SEND_TRIES = 3;
54
+
55
+ export interface WakeDeps {
56
+ fingerprints: typeof fingerprints;
57
+ activity: typeof prActivity;
58
+ failedLog: typeof failedLog;
59
+ // Message the task's agent through the bus; throws when it failed.
60
+ send: (task: Session, body: string) => Promise<unknown>;
61
+ }
62
+
63
+ // As an AgentOS event, through the bus: queued while the agent is mid-turn.
64
+ export async function sendWake(task: Session, body: string) {
65
+ const { delivery } = await sendMessage({
66
+ fromId: null,
67
+ to: task.id,
68
+ body,
69
+ fromLabel: "AgentOS",
70
+ origin: { kind: "event", label: "AgentOS", body },
71
+ // A chat agent reads it whole; a terminal's is told to read its inbox.
72
+ line: task.view === "chat" ? `[AgentOS event] ${body}` : undefined,
73
+ });
74
+ if (delivery.state === "failed") throw new Error(delivery.why);
75
+ return delivery.state;
76
+ }
77
+
78
+ const defaults: WakeDeps = {
79
+ fingerprints,
80
+ activity: prActivity,
81
+ failedLog,
82
+ send: sendWake,
83
+ };
84
+
85
+ const short = (sha: string) => sha.slice(0, 7);
86
+ const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s);
87
+
88
+ // The fingerprint each task's PR was last handled at, and when each
89
+ // repository was last fingerprinted.
90
+ const handled = new Map<string, string>();
91
+ const polled = new Map<string, number>();
92
+ // Sends that failed, per task and key, so a broken one stops retrying.
93
+ const failures = new Map<string, number>();
94
+
95
+ const sessionRow = (id: string) =>
96
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
97
+ | Session
98
+ | undefined;
99
+
100
+ // True when this call took the key; false when it was taken before.
101
+ const claim = (task: string, key: string, kind: string) =>
102
+ db
103
+ .prepare(
104
+ `INSERT OR IGNORE INTO task_pr_wakes (session_id, key, kind) VALUES (?, ?, ?)`
105
+ )
106
+ .run(task, key, kind).changes > 0;
107
+
108
+ const taken = (task: string, key: string) =>
109
+ !!db
110
+ .prepare(`SELECT 1 FROM task_pr_wakes WHERE session_id = ? AND key = ?`)
111
+ .get(task, key);
112
+
113
+ const unclaim = (task: string, keys: string[]) => {
114
+ const del = db.prepare(
115
+ `DELETE FROM task_pr_wakes WHERE session_id = ? AND key = ?`
116
+ );
117
+ for (const key of keys) del.run(task, key);
118
+ };
119
+
120
+ export const commentWakes = (task: string) =>
121
+ (
122
+ db
123
+ .prepare(
124
+ `SELECT COUNT(*) AS n FROM task_pr_wakes WHERE session_id = ? AND kind = 'comment'`
125
+ )
126
+ .get(task) as { n: number }
127
+ ).n;
128
+
129
+ interface Watched {
130
+ task: Session;
131
+ pr: TaskPR & { head: string };
132
+ repo: Repo;
133
+ dir: string;
134
+ }
135
+
136
+ export async function wakeTasks(
137
+ workspaceId: string,
138
+ facts: SessionFacts[],
139
+ deps: WakeDeps = defaults,
140
+ now = Date.now()
141
+ ): Promise<void> {
142
+ if (!getWorkspace(workspaceId)?.wake_tasks_on_pr || isPaused(workspaceId))
143
+ return;
144
+ const watched: Watched[] = [];
145
+ for (const f of facts) {
146
+ const pr = f.task?.pr;
147
+ const task = pr?.state === "OPEN" && pr.head ? sessionRow(f.id) : null;
148
+ const repo = pr && repoOfPR(pr.url);
149
+ if (!task || task.task_status !== "running" || !repo || !pr?.head) {
150
+ handled.delete(f.id);
151
+ continue;
152
+ }
153
+ let dir: string;
154
+ try {
155
+ dir = repoOf(task);
156
+ } catch {
157
+ continue;
158
+ }
159
+ watched.push({ task, pr: { ...pr, head: pr.head }, repo, dir });
160
+ }
161
+
162
+ for (const w of watched) {
163
+ if (isPaused(workspaceId)) return;
164
+ if (w.pr.checks !== "fail" || !w.pr.failing) continue;
165
+ await wakeOnCheck(workspaceId, w, deps).catch((error) =>
166
+ console.error(`Waking ${w.task.name} on CI:`, error)
167
+ );
168
+ }
169
+
170
+ const byRepo = new Map<string, Watched[]>();
171
+ for (const w of watched) {
172
+ const slug = `${w.repo.owner}/${w.repo.name}`;
173
+ byRepo.set(slug, [...(byRepo.get(slug) ?? []), w]);
174
+ }
175
+ for (const [slug, group] of byRepo) {
176
+ if (isPaused(workspaceId)) return;
177
+ if (now - (polled.get(slug) ?? 0) < OPEN_TTL_MS) continue;
178
+ polled.set(slug, now);
179
+ let prints: Map<number, Fingerprint>;
180
+ try {
181
+ prints = await deps.fingerprints(
182
+ group[0].dir,
183
+ group[0].repo,
184
+ group.map((w) => w.pr.number)
185
+ );
186
+ } catch (error) {
187
+ console.error(`Fingerprinting PRs in ${slug}:`, error);
188
+ continue;
189
+ }
190
+ for (const w of group) {
191
+ const print = prints.get(w.pr.number);
192
+ if (!print || handled.get(w.task.id) === print.print) continue;
193
+ try {
194
+ if (await wakeOnReviews(workspaceId, w, print, deps, now))
195
+ handled.set(w.task.id, print.print);
196
+ } catch (error) {
197
+ console.error(`Waking ${w.task.name} on reviews:`, error);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ // Whether the task is still to be woken now, after the gh calls since it
204
+ // was read: not paused or turned off, and the task still running.
205
+ const stillWatched = (workspaceId: string, taskId: string) =>
206
+ !isPaused(workspaceId) &&
207
+ !!getWorkspace(workspaceId)?.wake_tasks_on_pr &&
208
+ sessionRow(taskId)?.task_status === "running";
209
+
210
+ // Sends after claiming `keys`; a failed send gives them back for the next
211
+ // poll, SEND_TRIES times. False when it didn't go.
212
+ async function wake(
213
+ workspaceId: string,
214
+ w: Watched,
215
+ keys: string[],
216
+ body: string,
217
+ deps: WakeDeps
218
+ ): Promise<boolean> {
219
+ if (!stillWatched(workspaceId, w.task.id)) {
220
+ unclaim(w.task.id, keys);
221
+ return false;
222
+ }
223
+ try {
224
+ await deps.send(w.task, body);
225
+ return true;
226
+ } catch (error) {
227
+ const id = `${w.task.id}\0${keys[0]}`;
228
+ const n = (failures.get(id) ?? 0) + 1;
229
+ failures.set(id, n);
230
+ console.error(`Messaging ${w.task.name} (try ${n}):`, error);
231
+ if (n < SEND_TRIES) unclaim(w.task.id, keys);
232
+ return false;
233
+ }
234
+ }
235
+
236
+ async function wakeOnCheck(
237
+ workspaceId: string,
238
+ w: Watched,
239
+ deps: WakeDeps
240
+ ): Promise<void> {
241
+ const { task, pr } = w;
242
+ const key = `check:${pr.head}`;
243
+ if (taken(task.id, key)) return;
244
+ const log: FailedLog | null = await deps
245
+ .failedLog(w.dir, w.repo, pr.failingUrl)
246
+ .catch((error: unknown) => {
247
+ console.error(`Reading ${task.name}'s CI log:`, error);
248
+ return null;
249
+ });
250
+ // Claimed only now, next to the send, so a restart while the log is
251
+ // read doesn't lose the wake.
252
+ if (!claim(task.id, key, "check")) return;
253
+ if (!(await wake(workspaceId, w, [key], checkMessage(pr, log), deps))) return;
254
+ queueEvent(
255
+ workspaceId,
256
+ `woke:${task.id}:${key}`,
257
+ task.id,
258
+ `task ${task.name} woken: CI ${untrusted("CI", pr.failing!)} failed at ${short(pr.head)}`
259
+ );
260
+ // The orchestrator's own "CI failed" line for this head would only
261
+ // repeat it.
262
+ markTold(workspaceId, conditionKey(ciKey(pr), task.id), task.id);
263
+ }
264
+
265
+ export function checkMessage(
266
+ pr: TaskPR & { head: string },
267
+ log: FailedLog | null
268
+ ): string {
269
+ const step = log?.step ? ` at step ${untrusted("CI", log.step)}` : "";
270
+ const tail = log?.tail
271
+ ? `\n\nThe log around the error:\n${untrusted("CI log", log.tail)}`
272
+ : pr.failingUrl
273
+ ? `\n\nIts details: ${untrusted("CI", pr.failingUrl)}`
274
+ : "";
275
+ return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, push, then run /do-code-review again and update the Reviewed: line in the PR body for the new head.`;
276
+ }
277
+
278
+ // True once the PR's activity at this fingerprint has been handled.
279
+ async function wakeOnReviews(
280
+ workspaceId: string,
281
+ w: Watched,
282
+ print: Fingerprint,
283
+ deps: WakeDeps,
284
+ now: number
285
+ ): Promise<boolean> {
286
+ const { task, pr } = w;
287
+ const since = Date.parse(watchSince(task, print, now));
288
+ const activity = await deps.activity(w.dir, w.repo, pr.number);
289
+ const fresh = activity.events.filter(
290
+ (e) =>
291
+ e.author !== activity.viewer &&
292
+ TRUSTED.has(e.association) &&
293
+ Date.parse(e.at) >= since &&
294
+ worthWaking(e) &&
295
+ !taken(task.id, `event:${e.id}`)
296
+ );
297
+ if (!fresh.length) return true;
298
+ const changes = fresh.some((e) => e.state === "CHANGES_REQUESTED");
299
+ const kind = changes ? "review" : "comment";
300
+ const keys = fresh.map((e) => `event:${e.id}`);
301
+ if (!changes && commentWakes(task.id) >= COMMENT_WAKE_CAP) {
302
+ for (const key of keys) claim(task.id, key, "event");
303
+ if (claim(task.id, "capped", "capped"))
304
+ queueEvent(
305
+ workspaceId,
306
+ `woke:${task.id}:capped`,
307
+ task.id,
308
+ `task ${task.name}: ${COMMENT_WAKE_CAP} comment wakes on PR #${pr.number} reached; further comments won't wake it`
309
+ );
310
+ return true;
311
+ }
312
+ const wakeKey = `wake:${fresh[0].id}`;
313
+ const claimed = [wakeKey, ...keys].filter((key, i) =>
314
+ claim(task.id, key, i === 0 ? kind : "event")
315
+ );
316
+ if (!claimed.includes(wakeKey)) return true;
317
+ if (!(await wake(workspaceId, w, claimed, reviewMessage(pr, fresh), deps)))
318
+ return false;
319
+ const who = [...new Set(fresh.map((e) => e.author))].join(", ");
320
+ queueEvent(
321
+ workspaceId,
322
+ `woke:${task.id}:${wakeKey}`,
323
+ task.id,
324
+ `task ${task.name} woken: ${changes ? "changes requested" : fresh.length === 1 ? "a review comment" : `${fresh.length} review comments`} on PR #${pr.number} from ${who}`
325
+ );
326
+ return true;
327
+ }
328
+
329
+ // An approval or a dismissed review with nothing to say asks for nothing.
330
+ const worthWaking = (e: PREvent) =>
331
+ e.state !== "PENDING" &&
332
+ e.state !== "DISMISSED" &&
333
+ (e.state === "CHANGES_REQUESTED" || !!e.body.trim() || e.notes.length > 0);
334
+
335
+ // When this PR's reviews started being watched, set on the first look:
336
+ // a young PR from its start, an older one from now, so what was said
337
+ // before this watched it isn't news.
338
+ function watchSince(task: Session, print: Fingerprint, now: number): string {
339
+ if (task.pr_wake_since) return task.pr_wake_since;
340
+ const created = Date.parse(print.createdAt);
341
+ const since =
342
+ Number.isFinite(created) && now - created < YOUNG_PR_MS
343
+ ? new Date(created).toISOString()
344
+ : new Date(now).toISOString();
345
+ db.prepare(`UPDATE sessions SET pr_wake_since = ? WHERE id = ?`).run(
346
+ since,
347
+ task.id
348
+ );
349
+ task.pr_wake_since = since;
350
+ return since;
351
+ }
352
+
353
+ export function reviewMessage(pr: TaskPR, events: PREvent[]): string {
354
+ const items = events.map((e) => {
355
+ const what =
356
+ e.state === "CHANGES_REQUESTED"
357
+ ? "requested changes"
358
+ : e.kind === "review"
359
+ ? "reviewed"
360
+ : "commented";
361
+ const body = e.body.trim()
362
+ ? `: ${untrusted(e.author, clip(e.body.trim(), BODY_MAX))}`
363
+ : "";
364
+ const notes = e.notes
365
+ .slice(0, NOTES_MAX)
366
+ .map(
367
+ (n) =>
368
+ `\n - ${n.path}${n.line ? `:${n.line}` : ""}: ${untrusted(e.author, clip(n.body.trim(), NOTE_MAX))}`
369
+ )
370
+ .join("");
371
+ const more =
372
+ e.notes.length > NOTES_MAX
373
+ ? `\n - and ${e.notes.length - NOTES_MAX} more on the PR`
374
+ : "";
375
+ return `- ${e.author} ${what}${body}${notes}${more}`;
376
+ });
377
+ return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, push, then run /do-code-review again and update the Reviewed: line for the new head. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
378
+ }
379
+
380
+ // Tests only.
381
+ export function resetPRWake(): void {
382
+ handled.clear();
383
+ polled.clear();
384
+ failures.clear();
385
+ }
@@ -10,6 +10,8 @@ export const askPresence = (askId: number, binding: string) =>
10
10
  export const resumePresence = (workspaceId: string) => `resume:${workspaceId}`;
11
11
  export const revokePresence = (passkeyId: string) => `revoke:${passkeyId}`;
12
12
  export const APPROVALS_OFF_PRESENCE = "approvals-off";
13
+ // Writing a workspace's standing rules as Saad.
14
+ export const rulesPresence = (workspaceId: string) => `rules:${workspaceId}`;
13
15
 
14
16
  export function presenceBinding(b: {
15
17
  purpose?: PresencePurpose;
@@ -36,6 +38,9 @@ export function presenceBinding(b: {
36
38
  return "enroll";
37
39
  case "approvals-off":
38
40
  return APPROVALS_OFF_PRESENCE;
41
+ case "rules":
42
+ if (!b.workspaceId) throw new PresenceError("Which workspace?");
43
+ return rulesPresence(b.workspaceId);
39
44
  case "revoke":
40
45
  if (!b.passkeyId) throw new PresenceError("Which passkey?");
41
46
  return revokePresence(b.passkeyId);
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A workspace's standing rules: what its orchestrator must keep knowing
3
+ * across fresh starts ("never merge #111", "briefs must not name
4
+ * competitors"). The orchestrator writes them with rule_add and rule_remove,
5
+ * Saad in Settings → Workspaces, and every start reads them into its brief.
6
+ * They restrict and inform; none can approve anything. A removed rule stays
7
+ * in the table, marked.
8
+ */
9
+
10
+ import { db } from "../db";
11
+ import { redact } from "./untrusted";
12
+
13
+ export type RuleAuthor = "orchestrator" | "saad";
14
+
15
+ export interface RuleRow {
16
+ id: number;
17
+ workspace_id: string;
18
+ text: string;
19
+ added_by: RuleAuthor;
20
+ created_at: string;
21
+ updated_at: string;
22
+ removed_at: string | null;
23
+ removed_by: RuleAuthor | null;
24
+ }
25
+
26
+ // Kept small: every one is in every start's prompt.
27
+ export const MAX_RULES = 25;
28
+ export const MAX_RULE_CHARS = 300;
29
+
30
+ function clean(text: string): string {
31
+ const body = redact(text).replace(/\s+/g, " ").trim();
32
+ if (!body) throw new Error("The rule is empty");
33
+ if (body.length > MAX_RULE_CHARS)
34
+ throw new Error(`A rule is at most ${MAX_RULE_CHARS} characters`);
35
+ return body;
36
+ }
37
+
38
+ export function listRules(workspaceId: string): RuleRow[] {
39
+ return db
40
+ .prepare(
41
+ `SELECT * FROM orchestrator_rules
42
+ WHERE workspace_id = ? AND removed_at IS NULL ORDER BY id`
43
+ )
44
+ .all(workspaceId) as RuleRow[];
45
+ }
46
+
47
+ function getRule(workspaceId: string, id: number): RuleRow | null {
48
+ return (
49
+ (db
50
+ .prepare(
51
+ `SELECT * FROM orchestrator_rules
52
+ WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
53
+ )
54
+ .get(id, workspaceId) as RuleRow | undefined) ?? null
55
+ );
56
+ }
57
+
58
+ // The same text twice is the rule already there.
59
+ export function addRule(
60
+ workspaceId: string,
61
+ text: string,
62
+ by: RuleAuthor
63
+ ): RuleRow {
64
+ const body = clean(text);
65
+ return db.transaction(() => {
66
+ const rules = listRules(workspaceId);
67
+ const same = rules.find((r) => r.text === body);
68
+ // Saad writing one the orchestrator kept makes it his.
69
+ if (same && by === "saad" && same.added_by !== "saad") {
70
+ db.prepare(
71
+ `UPDATE orchestrator_rules SET added_by = 'saad', updated_at = datetime('now')
72
+ WHERE id = ?`
73
+ ).run(same.id);
74
+ return getRule(workspaceId, same.id)!;
75
+ }
76
+ if (same) return same;
77
+ if (rules.length >= MAX_RULES)
78
+ throw new Error(
79
+ `This workspace has ${MAX_RULES} rules already; remove one first`
80
+ );
81
+ const { lastInsertRowid } = db
82
+ .prepare(
83
+ `INSERT INTO orchestrator_rules (workspace_id, text, added_by) VALUES (?, ?, ?)`
84
+ )
85
+ .run(workspaceId, body, by);
86
+ return getRule(workspaceId, Number(lastInsertRowid))!;
87
+ })();
88
+ }
89
+
90
+ export function updateRule(
91
+ workspaceId: string,
92
+ id: number,
93
+ text: string
94
+ ): RuleRow {
95
+ const body = clean(text);
96
+ const { changes } = db
97
+ .prepare(
98
+ `UPDATE orchestrator_rules SET text = ?, updated_at = datetime('now')
99
+ WHERE id = ? AND workspace_id = ? AND removed_at IS NULL`
100
+ )
101
+ .run(body, id, workspaceId);
102
+ if (!changes) throw new Error(`No rule ${id} in this workspace`);
103
+ return getRule(workspaceId, id)!;
104
+ }
105
+
106
+ export function removeRule(
107
+ workspaceId: string,
108
+ id: number,
109
+ by: RuleAuthor
110
+ ): RuleRow {
111
+ const rule = getRule(workspaceId, id);
112
+ if (!rule) throw new Error(`No rule ${id} in this workspace`);
113
+ // Saad's restrictions hold until he lifts them: text the orchestrator
114
+ // read can't talk it into removing one.
115
+ if (by === "orchestrator" && rule.added_by === "saad")
116
+ throw new Error(
117
+ `Rule ${id} is Saad's: only he removes it, in Settings → Workspaces`
118
+ );
119
+ db.prepare(
120
+ `UPDATE orchestrator_rules SET removed_at = datetime('now'), removed_by = ?
121
+ WHERE id = ?`
122
+ ).run(by, id);
123
+ return rule;
124
+ }
@@ -15,6 +15,7 @@ import { sessionFacts } from "./facts";
15
15
  import { describeExternal, externalSessions } from "./external-sessions";
16
16
  import { resolveStaleAsks } from "./ask-settle";
17
17
  import { addNote } from "./notes";
18
+ import { addRule, removeRule } from "./rules";
18
19
  import { askSaad } from "./ask-tool";
19
20
  import { listOrchestratorPeers, messageOrchestrator } from "./cross";
20
21
  import { PAUSED_REFUSAL } from "./pause";
@@ -122,6 +123,16 @@ export async function runTool(
122
123
  case "note":
123
124
  addNote(w, p(tool, raw).text);
124
125
  return "Noted.";
126
+ case "rule_add": {
127
+ const rule = addRule(w, p(tool, raw).text, "orchestrator");
128
+ addNote(w, `Standing rule ${rule.id}: ${rule.text}`);
129
+ return `Rule ${rule.id} kept: it's in your brief on every start.`;
130
+ }
131
+ case "rule_remove": {
132
+ const rule = removeRule(w, p(tool, raw).id, "orchestrator");
133
+ addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
134
+ return `Rule ${rule.id} removed.`;
135
+ }
125
136
  case "review": {
126
137
  const a = p(tool, raw);
127
138
  return reviewTarget(w, a.target, { fresh: a.fresh });
@@ -22,6 +22,8 @@ export const TOOL_NAMES = {
22
22
  stop: name("stop"),
23
23
  done: name("done"),
24
24
  note: name("note"),
25
+ rule_add: name("rule_add"),
26
+ rule_remove: name("rule_remove"),
25
27
  review: name("review"),
26
28
  sign_off: name("sign_off"),
27
29
  arm: name("arm"),
@@ -4,6 +4,7 @@
4
4
  import { z } from "zod";
5
5
  import { BRANCH_NAME } from "../git";
6
6
  import { RAISED_KINDS } from "./asks";
7
+ import { MAX_RULE_CHARS } from "./rules";
7
8
 
8
9
  const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
9
10
  const session = ref("Session name, project/name, or id from sessions");
@@ -100,6 +101,15 @@ export const TOOL_SHAPES = {
100
101
  stop: { session },
101
102
  done: { session },
102
103
  note: { text: z.string().trim().min(1).max(2000) },
104
+ rule_add: {
105
+ text: z
106
+ .string()
107
+ .trim()
108
+ .min(1)
109
+ .max(MAX_RULE_CHARS)
110
+ .describe("The rule, one sentence"),
111
+ },
112
+ rule_remove: { id: z.number().int().positive().describe("The rule's id") },
103
113
  review: {
104
114
  target: ref("The task, or its PR (#12 or URL)"),
105
115
  fresh: z
@@ -72,6 +72,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
72
72
  stop: "Stop a session's agent, keeping its worktree and branch.",
73
73
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
74
74
  note: "Add a line to this workspace's decision log; it shows in your chat.",
75
+ rule_add:
76
+ 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name competitors"). It restricts or informs; it can never approve anything.',
77
+ rule_remove:
78
+ "Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
75
79
  review:
76
80
  "Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
77
81
  sign_off:
@@ -17,6 +17,7 @@ import { settleStaleAsks } from "./ask-settle";
17
17
  import { brakesOn, liftBrake } from "./brakes";
18
18
  import { anyArmed, mergeArmed } from "./armed";
19
19
  import { rebaseConflicting } from "./rebase";
20
+ import { wakeTasks } from "./pr-wake";
20
21
 
21
22
  // Asks that stopped needing Saad: their task or PR finished some other
22
23
  // way, or brakes lifted with nothing trying to start.
@@ -61,8 +62,8 @@ export async function diffWorkspace(
61
62
  return nextDiffAt(now, facts, working);
62
63
  }
63
64
 
64
- // Rebases what conflicts with its base, then merges what's armed and
65
- // passes. In the background, one run per workspace at a time, so a git
65
+ // Wakes tasks whose PRs failed CI or got reviews, rebases what conflicts
66
+ // with its base, then merges what's armed and passes. In the background, one run per workspace at a time, so a git
66
67
  // fetch or a merge never holds up event delivery.
67
68
  const piping = new Set<string>();
68
69
 
@@ -70,6 +71,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
70
71
  if (piping.has(workspaceId)) return;
71
72
  piping.add(workspaceId);
72
73
  void (async () => {
74
+ await wakeTasks(workspaceId, facts).catch((error) =>
75
+ console.error(`Waking tasks in ${workspaceId}:`, error)
76
+ );
73
77
  await rebaseConflicting(workspaceId, facts);
74
78
  await mergeArmed(workspaceId, facts);
75
79
  })()
@@ -9,6 +9,7 @@ import {
9
9
  orchestratorToolsDigest,
10
10
  } from "./tools";
11
11
  import type { RoleExtras } from "../chat/worker/extras";
12
+ import { refreshSettings } from "../chat/refresh";
12
13
 
13
14
  // What an orchestrator's chat worker starts with on top of any chat: its
14
15
  // brief, its tools, and permissions fixed by its role.
@@ -29,5 +30,9 @@ export async function orchestratorExtras(
29
30
  },
30
31
  ...ORCHESTRATOR_PERMISSIONS,
31
32
  toolsDigest: orchestratorToolsDigest(),
33
+ refresh: {
34
+ atTokens: refreshSettings().orchestratorTokens,
35
+ role: "orchestrator",
36
+ },
32
37
  };
33
38
  }
@@ -41,7 +41,8 @@ export type PresencePurpose =
41
41
  | "resume"
42
42
  | "enroll"
43
43
  | "revoke"
44
- | "approvals-off";
44
+ | "approvals-off"
45
+ | "rules";
45
46
 
46
47
  export class PresenceError extends Error {
47
48
  constructor(
package/lib/tasks/gh.ts CHANGED
@@ -6,6 +6,7 @@ import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
6
6
  import {
7
7
  checksVerdict,
8
8
  failingCheck,
9
+ failingUrl,
9
10
  rerunsToLookUp,
10
11
  settleReruns,
11
12
  type ActionsRun,
@@ -106,6 +107,7 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
106
107
  checks: checksVerdict(rollup),
107
108
  head: pr.headRefOid,
108
109
  failing: failingCheck(rollup),
110
+ failingUrl: failingUrl(rollup),
109
111
  checkCount: (pr.statusCheckRollup ?? []).length,
110
112
  codeReview: pr.body === undefined ? undefined : parseCodeReview(pr.body),
111
113
  scopeChange: parseScopeChange(pr.body),