@saadnvd1/agent-os 0.3.477 → 0.3.479

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 (48) hide show
  1. package/README.md +9 -0
  2. package/app/api/sessions/[id]/route.test.ts +66 -0
  3. package/app/api/sessions/[id]/route.ts +10 -7
  4. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  5. package/app/api/tmux/kill-all/route.ts +2 -1
  6. package/components/SessionList/OrchestratorPinRow.tsx +12 -6
  7. package/components/SessionList/QuickChangeRow.tsx +58 -0
  8. package/components/SessionList/SessionRow.tsx +4 -0
  9. package/components/Tasks/TaskRow.tsx +2 -17
  10. package/components/Tasks/task-state-labels.ts +18 -0
  11. package/lib/away/sweep.ts +2 -0
  12. package/lib/chat/hold.ts +6 -2
  13. package/lib/chat/runner.ts +5 -0
  14. package/lib/chat/worker/client.ts +2 -3
  15. package/lib/db/migrations.ts +17 -0
  16. package/lib/db/types.ts +3 -0
  17. package/lib/done/index.ts +2 -8
  18. package/lib/orchestration.ts +4 -5
  19. package/lib/orchestrator/act.ts +3 -6
  20. package/lib/orchestrator/brief.ts +5 -3
  21. package/lib/orchestrator/conditions.ts +2 -1
  22. package/lib/orchestrator/describe.ts +1 -1
  23. package/lib/orchestrator/facts.ts +13 -0
  24. package/lib/orchestrator/git-guard.ts +201 -0
  25. package/lib/orchestrator/pr-wake.ts +9 -1
  26. package/lib/orchestrator/quick-store.ts +18 -0
  27. package/lib/orchestrator/quick.ts +307 -0
  28. package/lib/orchestrator/rebase.ts +11 -1
  29. package/lib/orchestrator/review.ts +3 -0
  30. package/lib/orchestrator/serve.ts +17 -0
  31. package/lib/orchestrator/signoff.ts +28 -6
  32. package/lib/orchestrator/task-state.ts +2 -0
  33. package/lib/orchestrator/tool-names.ts +9 -2
  34. package/lib/orchestrator/tool-schemas.ts +26 -0
  35. package/lib/orchestrator/tools.ts +4 -0
  36. package/lib/orchestrator/write-guard.ts +80 -3
  37. package/lib/sessions/end.ts +15 -0
  38. package/lib/sidebar/shelves.ts +19 -1
  39. package/lib/tasks/finish.ts +2 -8
  40. package/lib/tasks/index.ts +18 -6
  41. package/lib/tasks/move-guard.ts +2 -0
  42. package/lib/tasks/move.ts +2 -6
  43. package/lib/tasks/queue.ts +3 -2
  44. package/lib/tasks/start.ts +2 -4
  45. package/lib/tmux/end.ts +33 -0
  46. package/lib/tmux/orphans.ts +146 -0
  47. package/package.json +1 -1
  48. package/server.ts +9 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The shell commands an orchestrator may run, read before the shell sees
3
+ * them. Its permissions allow `aos` commands and `git`; this makes sure each
4
+ * is one plain command (no `;`, `&&`, pipes, redirects, substitutions or
5
+ * globs a shell would act on), and that a git command works only in one of
6
+ * its own quick changes' worktrees (`-C <path>`), does nothing that runs
7
+ * other code or writes outside it, and never pushes: quick_pr pushes, and
8
+ * never to the default branch.
9
+ */
10
+
11
+ // What git may do in a quick worktree: look, stage, undo. Committing is
12
+ // quick_pr's alone: it runs with hooks off, where a shell commit would run
13
+ // whatever the project (or the orchestrator's own edit) put in its hooks.
14
+ const GIT_ALLOWED = new Set([
15
+ "status",
16
+ "diff",
17
+ "log",
18
+ "show",
19
+ "add",
20
+ "rm",
21
+ "restore",
22
+ "reset",
23
+ "rev-parse",
24
+ "ls-files",
25
+ ]);
26
+
27
+ // Git config can run programs (core.fsmonitor, diff.external, filter and
28
+ // textconv drivers), so git runs only at the worktree's own root, where it
29
+ // finds the real git dir, never in a folder holding a repository the
30
+ // orchestrator wrote (the write guard refuses HEAD, .git/ and .gitattributes
31
+ // inside quick worktrees). A .gitattributes already committed can name a
32
+ // driver, but a driver's command lives in config, which only a repository
33
+ // the orchestrator can't write defines.
34
+ export const GIT_LAYOUT_FILE =
35
+ /(^|\/)(HEAD|\.gitattributes)$|(^|\/)\.git(\/|$)/i;
36
+
37
+ // `aos` commands carry free text (notify, send): only what a shell would
38
+ // chain, redirect or substitute is refused, not ? ! # * or a plain $.
39
+ export function aosRefusal(command: string): string | null {
40
+ const bad =
41
+ "use one plain aos command: no ;, &&, pipes, redirects, backticks or $( ) outside single quotes";
42
+ let q: string | null = null;
43
+ for (let i = 0; i < command.length; i++) {
44
+ const c = command[i];
45
+ if (q === "'") {
46
+ if (c === "'") q = null;
47
+ continue;
48
+ }
49
+ if (c === "\\") {
50
+ // zsh deletes a backslash-newline before it parses, joining $ to what
51
+ // follows; and its ${(e)...} evaluates what an escaped \$ or \` left.
52
+ const n = command[i + 1] ?? "";
53
+ if (/[\n\r]/.test(n) || (q === '"' && /[$`]/.test(n)))
54
+ return `Refused: ${bad}.`;
55
+ i++;
56
+ continue;
57
+ }
58
+ if (c === "`") return `Refused: ${bad}.`;
59
+ // After a $ only a plain variable name (or $?, $#, $@, $*, $-, a digit)
60
+ // or a literal $ may follow: ${..}, $[..], $'..' and the like evaluate
61
+ // or quote differently in the shell than here.
62
+ if (c === "$") {
63
+ const n = command[i + 1] ?? "";
64
+ if (!/^[\sA-Za-z0-9_?#@*-]?$/.test(n) && !(q === '"' && n === '"'))
65
+ return `Refused: ${bad}.`;
66
+ }
67
+ if (q === '"') {
68
+ if (c === '"') q = null;
69
+ continue;
70
+ }
71
+ if (c === "'" || c === '"') q = c;
72
+ else if (/[;&|<>()\n\r]/.test(c)) return `Refused: ${bad}.`;
73
+ }
74
+ return q ? "Refused: an unclosed quote." : null;
75
+ }
76
+
77
+ // Options that write a file somewhere else, read a command to run, or read
78
+ // outside the repository. Git accepts any unique prefix of a long option
79
+ // (--ext-di, --textc), so this refuses the prefixes, not the spellings.
80
+ const GIT_REFUSED_ARG = /^--(ou|ex|te|no-i)/;
81
+
82
+ // A shell word, with its quotes taken off, or why the command isn't one
83
+ // plain command.
84
+ export function shellWords(command: string): string[] | string {
85
+ const words: string[] = [];
86
+ let word: string | null = null;
87
+ const plain =
88
+ "use one plain command: no ;, &&, ||, pipes, redirects, $, backticks, globs or comments outside quotes (quote anything with those)";
89
+ for (let i = 0; i < command.length; i++) {
90
+ const c = command[i];
91
+ if (c === " " || c === "\t") {
92
+ if (word !== null) words.push(word);
93
+ word = null;
94
+ continue;
95
+ }
96
+ if (c === "'") {
97
+ const end = command.indexOf("'", i + 1);
98
+ if (end < 0) return "an unclosed ' quote";
99
+ word = (word ?? "") + command.slice(i + 1, end);
100
+ i = end;
101
+ continue;
102
+ }
103
+ if (c === '"') {
104
+ let text = "";
105
+ let j = i + 1;
106
+ for (; j < command.length && command[j] !== '"'; j++) {
107
+ const d = command[j];
108
+ if (d === "$" || d === "`") return plain;
109
+ if (d === "\\" && j + 1 < command.length) {
110
+ const n = command[++j];
111
+ text += /["\\$`\n]/.test(n) ? n : `\\${n}`;
112
+ continue;
113
+ }
114
+ text += d;
115
+ }
116
+ if (j >= command.length) return 'an unclosed " quote';
117
+ word = (word ?? "") + text;
118
+ i = j;
119
+ continue;
120
+ }
121
+ if (c === "\\") {
122
+ if (i + 1 >= command.length || command[i + 1] === "\n") return plain;
123
+ word = (word ?? "") + command[++i];
124
+ continue;
125
+ }
126
+ if (/[;&|<>()$`\n\r*?[\]{}!#]/.test(c)) return plain;
127
+ // zsh expands a word starting with = or ~name; only ~ and ~/ are read
128
+ // (as the home folder), by the caller.
129
+ if (word === null && c === "=") return plain;
130
+ if (word === null && c === "~" && !/^~($|[\s/])/.test(command.slice(i)))
131
+ return plain;
132
+ word = (word ?? "") + c;
133
+ }
134
+ if (word !== null) words.push(word);
135
+ return words;
136
+ }
137
+
138
+ export type GitCommand =
139
+ | { refusal: string }
140
+ | { refusal: null; dir: string | null; sub: string | null };
141
+
142
+ // A shell command read for the guard: why it's refused outright, or, for a
143
+ // git command, the folder it runs in (from -C) to check against its quick
144
+ // worktrees. `dir` and `sub` are null for a command that isn't git.
145
+ export function readCommand(command: unknown): GitCommand {
146
+ if (typeof command !== "string" || !command.trim())
147
+ return { refusal: "No command to check." };
148
+ if (/^aos(\s|$)/.test(command.trim())) {
149
+ const refusal = aosRefusal(command.trim());
150
+ return refusal ? { refusal } : { refusal: null, dir: null, sub: null };
151
+ }
152
+ const words = shellWords(command.trim());
153
+ if (typeof words === "string") return { refusal: `Refused: ${words}.` };
154
+ if (words[0] !== "git") {
155
+ // `VAR=value git …` sets an environment variable the shell would act on
156
+ // (GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE), which can redirect git
157
+ // outside its quick worktree.
158
+ if (words[0].includes("="))
159
+ return {
160
+ refusal:
161
+ "Refused: no inline VAR=value before a command; environment variables can point git outside your worktree.",
162
+ };
163
+ return { refusal: null, dir: null, sub: null };
164
+ }
165
+ const usage =
166
+ "git runs only in one of your quick changes' worktrees, as `git -C <its path> <command>`";
167
+ let dir: string | null = null;
168
+ let i = 1;
169
+ for (; i < words.length; i++) {
170
+ const w = words[i];
171
+ if (w === "-C") {
172
+ if (dir !== null || i + 1 >= words.length)
173
+ return { refusal: `Refused: ${usage}, with one -C.` };
174
+ dir = words[++i];
175
+ continue;
176
+ }
177
+ if (w === "--no-pager" || w === "--no-optional-locks") continue;
178
+ if (w.startsWith("-"))
179
+ return {
180
+ refusal: `Refused: git's ${w} option isn't allowed here; ${usage}.`,
181
+ };
182
+ break;
183
+ }
184
+ if (dir === null) return { refusal: `Refused: ${usage}.` };
185
+ const sub = words[i] ?? null;
186
+ if (sub === "push")
187
+ return {
188
+ refusal:
189
+ "Refused: quick_pr pushes a quick change's branch and opens its PR; nothing pushes from the shell, and nothing ever pushes to the default branch.",
190
+ };
191
+ if (!sub || !GIT_ALLOWED.has(sub))
192
+ return {
193
+ refusal: `Refused: git ${sub ?? ""} isn't allowed here. In a quick worktree git may only ${[...GIT_ALLOWED].join(", ")}; quick_pr commits, pushes and opens the PR.`,
194
+ };
195
+ const bad = words.slice(i + 1).find((w) => GIT_REFUSED_ARG.test(w));
196
+ if (bad)
197
+ return {
198
+ refusal: `Refused: git's ${bad} option writes or runs something outside the worktree.`,
199
+ };
200
+ return { refusal: null, dir, sub };
201
+ }
@@ -146,7 +146,15 @@ export async function wakeTasks(
146
146
  const pr = f.task?.pr;
147
147
  const task = pr?.state === "OPEN" && pr.head ? sessionRow(f.id) : null;
148
148
  const repo = pr && repoOfPR(pr.url);
149
- if (!task || task.task_status !== "running" || !repo || !pr?.head) {
149
+ // A quick change has no agent to wake: its PR's events reach the
150
+ // orchestrator that made it, as any task's do.
151
+ if (
152
+ !task ||
153
+ task.task_status !== "running" ||
154
+ task.quick_of ||
155
+ !repo ||
156
+ !pr?.head
157
+ ) {
150
158
  handled.delete(f.id);
151
159
  continue;
152
160
  }
@@ -0,0 +1,18 @@
1
+ // The worktrees of an orchestrator's open quick changes, which its write
2
+ // guard lets it edit (quick.ts). Only running ones: a finished or dropped
3
+ // quick change's folder is gone, or about to be.
4
+
5
+ import { db } from "../db";
6
+ import { expandHome } from "../tasks/session";
7
+
8
+ export function quickWorktrees(orchestratorId: string): string[] {
9
+ return (
10
+ db
11
+ .prepare(
12
+ `SELECT worktree_path AS dir FROM sessions
13
+ WHERE quick_of = ? AND task_status = 'running'
14
+ AND worktree_path IS NOT NULL AND worktree_path != ''`
15
+ )
16
+ .all(orchestratorId) as { dir: string }[]
17
+ ).map((r) => expandHome(r.dir));
18
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Quick changes: an edit too small for a task (a typo, a config value, a
3
+ * one-file fix) made by the orchestrator itself. quick_change cuts a
4
+ * worktree and branch from the project's default branch, as a task row
5
+ * with no agent (`quick_of` names the orchestrator that owns it); its write
6
+ * guard lets it edit there and nowhere else in a project. quick_pr commits,
7
+ * pushes that branch (never the default one) and opens the PR, which then
8
+ * goes through the same gates as any task's. With no task agent to run
9
+ * /do-code-review, the code-review gate of a PR AgentOS recorded as opened
10
+ * by quick_pr is the independent review of its exact head, never anything
11
+ * its body says. Signed off, dropped or done, it's cleaned up like a task.
12
+ */
13
+
14
+ import { randomUUID } from "crypto";
15
+ import fs from "fs";
16
+ import { db, queries, type Session } from "../db";
17
+ import { getDefaultBranch, slugify } from "../git";
18
+ import { currentBranch, git, revParse } from "../git-exec";
19
+ import { isRemoteHost } from "../hosts";
20
+ import { createWorktree, deleteWorktree } from "../worktrees";
21
+ import { PROTECTED_BRANCHES } from "../tasks/finish";
22
+ import { run } from "../tasks/gh";
23
+ import { ATTRIBUTION, renderedLines } from "../tasks/code-review";
24
+ import { expandHome, forgetPR, prFor, projectPathFor } from "../tasks/session";
25
+ import { inBackground } from "../lumifyhub/task-cards";
26
+ import type { CheckRow } from "./checks";
27
+ import { getOrchestrator } from "./home";
28
+ import { addNote } from "./notes";
29
+ import { quickWorktrees } from "./quick-store";
30
+ import { findWorkspaceSession } from "./read";
31
+ import { workspaceProject } from "./targets";
32
+
33
+ // Open quick changes one orchestrator may hold at once.
34
+ export const MAX_OPEN_QUICK = 5;
35
+
36
+ // Git with no hooks: a hook is a script in the project (or one it calls,
37
+ // like node_modules/.bin), which the orchestrator could have edited, so a
38
+ // commit or push it makes runs none.
39
+ const NO_HOOKS = ["-c", "core.hooksPath=/dev/null"];
40
+
41
+ const short = (sha: string) => sha.slice(0, 7);
42
+
43
+ export const isQuick = (s: Pick<Session, "quick_of">) => !!s.quick_of;
44
+
45
+ function ownerOf(workspaceId: string): Session {
46
+ const o = getOrchestrator(workspaceId);
47
+ if (!o) throw new Error("This workspace has no orchestrator yet");
48
+ return o;
49
+ }
50
+
51
+ export async function quickChange(
52
+ workspaceId: string,
53
+ projectRef: string,
54
+ name: string
55
+ ): Promise<string> {
56
+ const owner = ownerOf(workspaceId);
57
+ const project = workspaceProject(workspaceId, projectRef);
58
+ if (isRemoteHost(project.host_id))
59
+ throw new Error(
60
+ `${project.name} runs on another machine; quick changes are made on this one only`
61
+ );
62
+ const open = quickWorktrees(owner.id);
63
+ if (open.length >= MAX_OPEN_QUICK)
64
+ throw new Error(
65
+ `You have ${open.length} quick changes open (${MAX_OPEN_QUICK} at most): finish one with quick_pr, or drop it, first`
66
+ );
67
+ const label = name.trim();
68
+ const id = randomUUID();
69
+ const projectPath = expandHome(project.working_directory);
70
+ const feature = `quick-${slugify(label.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
71
+ const baseBranch = await getDefaultBranch(projectPath);
72
+ const wt = await createWorktree({
73
+ projectPath,
74
+ featureName: feature,
75
+ baseBranch,
76
+ });
77
+ try {
78
+ db.transaction(() => {
79
+ queries
80
+ .createSession(db)
81
+ .run(
82
+ id,
83
+ label,
84
+ `quick-${id}`,
85
+ wt.worktreePath,
86
+ null,
87
+ project.default_model,
88
+ null,
89
+ "sessions",
90
+ "claude",
91
+ 0,
92
+ project.id,
93
+ "local"
94
+ );
95
+ queries
96
+ .updateSessionWorktree(db)
97
+ .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
98
+ db.prepare(
99
+ `UPDATE sessions SET task_prompt = ?, task_status = 'running', name_source = 'user', view = 'chat', quick_of = ? WHERE id = ?`
100
+ ).run(`Quick change: ${label}`, owner.id, id);
101
+ })();
102
+ } catch (e) {
103
+ await deleteWorktree(wt.worktreePath, projectPath, true).catch(() => {});
104
+ throw e;
105
+ }
106
+ addNote(
107
+ workspaceId,
108
+ `Quick change "${label}" in ${project.name} on ${wt.branchName}.`
109
+ );
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.`;
111
+ }
112
+
113
+ // A quick change of this workspace's orchestrator, by name, id or PR.
114
+ export function workspaceQuick(workspaceId: string, ref: string): Session {
115
+ const owner = ownerOf(workspaceId);
116
+ const s = findWorkspaceSession(workspaceId, ref);
117
+ if (s.quick_of !== owner.id)
118
+ throw new Error(
119
+ `${s.name} isn't one of your quick changes; a task's own agent opens its PR`
120
+ );
121
+ if (s.task_status !== "running")
122
+ throw new Error(`${s.name} is already ${s.task_status}`);
123
+ return s;
124
+ }
125
+
126
+ // Why a branch may never be pushed by quick_pr, or null.
127
+ export function pushRefusal(
128
+ branch: string | null,
129
+ defaultBranch: string,
130
+ base: string | null
131
+ ): string | null {
132
+ if (!branch) return "it has no branch";
133
+ if (
134
+ branch === defaultBranch ||
135
+ branch === base ||
136
+ PROTECTED_BRANCHES.has(branch)
137
+ )
138
+ return `its branch is ${branch}, the default branch; quick_pr never pushes to it`;
139
+ return null;
140
+ }
141
+
142
+ const attributed = (text: string) =>
143
+ renderedLines(text).some((l) => ATTRIBUTION.test(l.trimStart()));
144
+
145
+ export async function quickPR(
146
+ workspaceId: string,
147
+ ref: string,
148
+ title: string,
149
+ body: string
150
+ ): Promise<{ id: string; text: string }> {
151
+ const s = workspaceQuick(workspaceId, ref);
152
+ const repo = projectPathFor(s);
153
+ const wt = s.worktree_path ? expandHome(s.worktree_path) : null;
154
+ if (!repo || !wt || !fs.existsSync(wt))
155
+ throw new Error(`${s.name}'s worktree is gone; drop it`);
156
+ if (attributed(title) || attributed(body))
157
+ throw new Error("Take the AI attribution out of the title and body");
158
+ const branch = s.branch_name;
159
+ const refusal = pushRefusal(
160
+ branch,
161
+ await getDefaultBranch(repo),
162
+ s.base_branch
163
+ );
164
+ if (refusal) throw new Error(`Refused: ${refusal}.`);
165
+ const on = await currentBranch(wt);
166
+ if (on !== branch)
167
+ throw new Error(
168
+ `Refused: ${s.name}'s worktree is on ${on || "a detached HEAD"}, not its branch ${branch}`
169
+ );
170
+ await git(wt, [...NO_HOOKS, "add", "-A"]);
171
+ const staged = await git(wt, ["diff", "--cached", "--name-only"]);
172
+ if (staged.trim())
173
+ await git(wt, [...NO_HOOKS, "commit", "-q", "-m", title.trim()]);
174
+ const ahead = (
175
+ await git(wt, [
176
+ "rev-list",
177
+ "--count",
178
+ `refs/remotes/origin/${s.base_branch}..HEAD`,
179
+ ]).catch(() => git(wt, ["rev-list", "--count", `${s.base_branch}..HEAD`]))
180
+ ).trim();
181
+ if (ahead === "0")
182
+ throw new Error(`${s.name} has nothing to commit: edit its worktree first`);
183
+ // The branch by its full name on both sides, so nothing but it moves.
184
+ await git(
185
+ wt,
186
+ [
187
+ ...NO_HOOKS,
188
+ "push",
189
+ "--quiet",
190
+ "-u",
191
+ "origin",
192
+ `refs/heads/${branch}:refs/heads/${branch}`,
193
+ ],
194
+ { timeout: 60_000 }
195
+ );
196
+ const head = (await revParse(wt, "HEAD")).trim();
197
+ const goal = `${title.trim()}\n\n${body.trim()}`;
198
+ db.prepare(`UPDATE sessions SET task_prompt = ? WHERE id = ?`).run(
199
+ goal,
200
+ s.id
201
+ );
202
+ const existing = await prFor(s, true);
203
+ if (existing?.state === "OPEN") {
204
+ forgetPR(s.id);
205
+ return {
206
+ id: s.id,
207
+ text: `Pushed ${short(head)} to ${s.name}'s PR #${existing.number} (${existing.url}).`,
208
+ };
209
+ }
210
+ const out = await run(
211
+ "gh",
212
+ [
213
+ "pr",
214
+ "create",
215
+ "--base",
216
+ s.base_branch!,
217
+ "--head",
218
+ branch!,
219
+ "--title",
220
+ title.trim(),
221
+ "--body",
222
+ body.trim(),
223
+ ],
224
+ wt,
225
+ 60_000
226
+ );
227
+ const m = /https:\/\/\S+\/pull\/(\d+)/.exec(out);
228
+ if (m) queries.updateSessionPR(db).run(m[0], Number(m[1]), "open", s.id);
229
+ forgetPR(s.id);
230
+ addNote(
231
+ workspaceId,
232
+ `Quick change "${s.name}" opened ${m ? `PR #${m[1]}` : "its PR"} at ${short(head)}.`
233
+ );
234
+ return {
235
+ id: s.id,
236
+ text: `Opened ${m ? `PR #${m[1]} (${m[0]})` : "the PR"} for quick change "${s.name}" at ${short(head)}. It merges only through the gates, like a task's: CI, the independent review of its exact head (which is also its code-review gate, since no task agent ran /do-code-review), scope, then sign_off or arm.`,
237
+ };
238
+ }
239
+
240
+ const QUICK_FIX =
241
+ "for a quick change, the independent review of its exact head is its code review";
242
+
243
+ // The code-review gate of a PR quick_pr opened (the row's quick_of, never
244
+ // its body): the independent review of this exact head. Until it passes the
245
+ // quick change waits, before any other gate is judged or counted: a CI that
246
+ // wants a Code review section in the body fails until AgentOS writes one
247
+ // after the review (noteQuickReview). A blocking review counts once, as the
248
+ // review gate's failure, when its verdict arrives.
249
+ export function quickCodeReview(
250
+ sha: string,
251
+ review: CheckRow | null,
252
+ rebased = false
253
+ ): { refusal: null; wait: string | null } {
254
+ if (review?.sha === sha && review.status === "pass")
255
+ return { refusal: null, wait: null };
256
+ const why =
257
+ review?.sha !== sha
258
+ ? rebased
259
+ ? `a rebase moved the branch to ${short(sha)}, so it needs a fresh independent review: call review`
260
+ : `no independent review of ${short(sha)} yet: call review`
261
+ : review.status === "running"
262
+ ? `the review of ${short(sha)} is still running`
263
+ : review.status === "block"
264
+ ? `the review of ${short(sha)} has blocking findings`
265
+ : `the review of ${short(sha)} couldn't run: call review with fresh`;
266
+ return { refusal: null, wait: `${why} (${QUICK_FIX})` };
267
+ }
268
+
269
+ // A PR body section for a repository whose CI wants one (this repo's
270
+ // code-review workflow): written by AgentOS after the independent review
271
+ // of the head passed, never by the orchestrator. The gate doesn't read it.
272
+ const MARK = "<!-- agentos:quick-review -->";
273
+
274
+ export function withQuickReview(body: string, sha: string): string {
275
+ const at = body.indexOf(MARK);
276
+ const kept = (at < 0 ? body : body.slice(0, at)).trimEnd();
277
+ return `${kept}\n\n${MARK}\n## Code review\nReviewed: ${sha}\nAgents: AgentOS's independent review of this exact commit (a quick change, with no task agent to run /do-code-review)\nFixed: none\nDeferred: none\n`;
278
+ }
279
+
280
+ export function noteQuickReview(reviewed: Session, sha: string): void {
281
+ if (!isQuick(reviewed)) return;
282
+ const task = queries.getSession(db).get(reviewed.id) as Session | undefined;
283
+ if (!task || task.task_status !== "running" || !task.pr_number) return;
284
+ const repo = projectPathFor(task);
285
+ if (!repo) return;
286
+ inBackground(`code review section on quick change ${task.id}`, async () => {
287
+ const pr = await prFor(task, true);
288
+ if (!pr || pr.state !== "OPEN" || pr.head !== sha) return;
289
+ const body = await run(
290
+ "gh",
291
+ ["pr", "view", String(pr.number), "--json", "body", "--jq", ".body"],
292
+ repo
293
+ );
294
+ await run(
295
+ "gh",
296
+ [
297
+ "pr",
298
+ "edit",
299
+ String(pr.number),
300
+ "--body",
301
+ withQuickReview(body.trimEnd(), sha),
302
+ ],
303
+ repo
304
+ );
305
+ forgetPR(task.id);
306
+ });
307
+ }
@@ -241,7 +241,7 @@ async function rebaseOne(
241
241
  (await deps.idle(task)) &&
242
242
  !(await local()) &&
243
243
  (await catchUp(git, wt, task.branch_name!, pr.head, to));
244
- if (!follows)
244
+ if (!follows && !task.quick_of)
245
245
  await deps.send(
246
246
  task.id,
247
247
  `AgentOS rebased your branch ${task.branch_name} onto ${branch} on origin (now ${short(to)}), because PR #${pr.number} conflicted with it. Your worktree is still on the old head: before changing anything, run \`git fetch origin && git reset --keep origin/${task.branch_name}\` (commit your work first). Its review carries over when its own patch didn't change.`
@@ -381,6 +381,16 @@ async function askToRebase(
381
381
  pr.head,
382
382
  task.id
383
383
  );
384
+ // A quick change has no agent to ask: it's the orchestrator's to redo.
385
+ if (task.quick_of) {
386
+ queueEvent(
387
+ workspaceId,
388
+ `conflict:${task.id}:${pr.head}`,
389
+ task.id,
390
+ `quick change ${task.name}: PR #${pr.number} conflicts with ${base} and can't be rebased automatically (${why.slice(0, 300)}). Drop it and make the change again in a new quick change.`
391
+ );
392
+ return;
393
+ }
384
394
  queueEvent(
385
395
  workspaceId,
386
396
  `conflict:${task.id}:${pr.head}`,
@@ -43,6 +43,7 @@ import {
43
43
  checkRunTotals,
44
44
  describeCheckRuns,
45
45
  } from "../usage/check-runs";
46
+ import { noteQuickReview } from "./quick";
46
47
  import { workspaceTask } from "./targets";
47
48
  import { untrusted } from "./untrusted";
48
49
 
@@ -272,6 +273,8 @@ export async function reviewAt(
272
273
  })
273
274
  )
274
275
  .then((done) => {
276
+ if (done.status === "pass" && target.task)
277
+ noteQuickReview(target.task, sha);
275
278
  const said =
276
279
  done.status === "pass"
277
280
  ? "passed"
@@ -26,6 +26,8 @@ import { readSession } from "./read";
26
26
  import { reviewTarget, signOffTarget } from "./external-gates";
27
27
  import { land } from "./signoff";
28
28
  import { arm, disarm } from "./armed";
29
+ import { quickChange, quickPR } from "./quick";
30
+ import { review } from "./review";
29
31
  import { parseArgs as p, type ToolName } from "./tool-schemas";
30
32
 
31
33
  export { isToolName, TOOLS, type ToolName } from "./tool-schemas";
@@ -37,6 +39,8 @@ const ACTING = new Set<ToolName>([
37
39
  "start_task",
38
40
  "set_task_model",
39
41
  "start_session",
42
+ "quick_change",
43
+ "quick_pr",
40
44
  "stack",
41
45
  "land",
42
46
  "drop",
@@ -119,6 +123,19 @@ export async function runTool(
119
123
  view: a.view,
120
124
  });
121
125
  }
126
+ case "quick_change": {
127
+ const a = p(tool, raw);
128
+ return quickChange(w, a.project, a.name);
129
+ }
130
+ case "quick_pr": {
131
+ const a = p(tool, raw);
132
+ const opened = await quickPR(w, a.change, a.title, a.body);
133
+ // Its code-review gate is this review: it starts at once.
134
+ const reviewing = await review(w, opened.id).catch(
135
+ (e: Error) => `Its review couldn't start (${e.message}): call review.`
136
+ );
137
+ return `${opened.text}\n${reviewing}`;
138
+ }
122
139
  case "stack": {
123
140
  const a = p(tool, raw);
124
141
  return stack(w, a.target, a.plan_only);
@@ -15,6 +15,7 @@ import { getStack, landInBackground } from "../stacks";
15
15
  import { LAND_DEFAULTS } from "../stacks/land";
16
16
  import { db, stackQueries, type Session } from "../db";
17
17
  import { getCheck } from "./checks";
18
+ import { isQuick, quickCodeReview } from "./quick";
18
19
  import {
19
20
  addedLines,
20
21
  changedFiles,
@@ -82,6 +83,22 @@ export async function judge(
82
83
  };
83
84
  const sha = pr.head;
84
85
  const repo = repoOf(task);
86
+ // A PR quick_pr opened (by the row AgentOS wrote, never the body): its
87
+ // code review is the independent review of this head, and until that
88
+ // passes it waits, before any gate is judged or counted.
89
+ const quick = isQuick(task)
90
+ ? quickCodeReview(
91
+ sha,
92
+ getCheck(task.id, sha, "review"),
93
+ restackedOf(task)?.to === sha
94
+ )
95
+ : null;
96
+ if (quick?.wait)
97
+ return {
98
+ ok: false,
99
+ wait: true,
100
+ text: `${task.name} (PR #${pr.number} at ${short(sha)}) can't merge yet: ${quick.wait}`,
101
+ };
85
102
  return gateVerdict(
86
103
  workspaceId,
87
104
  { id: task.id, name: task.name, repo, refs: task, pr, sha },
@@ -100,12 +117,17 @@ export async function judge(
100
117
  fromCard: !!task.lh_card_id,
101
118
  scope: getCheck(task.id, sha, "scope"),
102
119
  stackRefusal: landing ? null : signOffRefusal(task.id),
103
- codeReview: codeReviewGate({
104
- head: sha,
105
- attribution: pr.attribution,
106
- review: (at) => getCheck(task.id, at, "review"),
107
- restacked: restackedOf(task),
108
- }),
120
+ codeReview: (() => {
121
+ const gate = codeReviewGate({
122
+ head: sha,
123
+ attribution: pr.attribution,
124
+ review: (at) => getCheck(task.id, at, "review"),
125
+ restacked: restackedOf(task),
126
+ });
127
+ // A quick change keeps the body checks (attribution, unreadable);
128
+ // its wait on the review was judged above.
129
+ return quick ? { refusal: gate.refusal, wait: null } : gate;
130
+ })(),
109
131
  }),
110
132
  opts
111
133
  );