@saadnvd1/agent-os 0.3.468 → 0.3.471

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 (64) hide show
  1. package/README.md +67 -37
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/projects/clone/route.ts +4 -2
  4. package/app/api/projects/init/route.test.ts +46 -0
  5. package/app/api/projects/init/route.ts +24 -4
  6. package/app/api/usage/route.ts +4 -2
  7. package/components/Chat/ChatPanel.tsx +5 -5
  8. package/components/Chat/StaleChip.tsx +7 -6
  9. package/components/CommandPalette/CommandPalette.tsx +3 -1
  10. package/components/CommandPalette/useAppCommands.ts +10 -0
  11. package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
  12. package/components/Projects/AddProject/CloneForm.tsx +3 -1
  13. package/components/Projects/AddProject/FolderForm.tsx +98 -4
  14. package/components/Projects/AddProject/NameForm.tsx +3 -1
  15. package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
  16. package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
  17. package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
  18. package/components/Usage/UsageDialog.tsx +31 -2
  19. package/components/Workspaces/FreshStartForm.tsx +13 -13
  20. package/data/projects/add.ts +23 -3
  21. package/data/usage/index.ts +2 -0
  22. package/lib/agents/launch.ts +2 -0
  23. package/lib/chat/driver.ts +8 -0
  24. package/lib/chat/drivers/claude-approvals.ts +40 -0
  25. package/lib/chat/drivers/claude.ts +7 -10
  26. package/lib/chat/refresh.ts +31 -13
  27. package/lib/chat/stale.ts +25 -16
  28. package/lib/chat/worker/extras.ts +1 -0
  29. package/lib/chat/worker/host.ts +61 -22
  30. package/lib/db/migrations.ts +42 -0
  31. package/lib/orchestrator/add-project.ts +35 -0
  32. package/lib/orchestrator/armed.ts +1 -2
  33. package/lib/orchestrator/brief.ts +6 -5
  34. package/lib/orchestrator/claude-cli.ts +5 -0
  35. package/lib/orchestrator/diff.ts +6 -2
  36. package/lib/orchestrator/external-gates.ts +8 -43
  37. package/lib/orchestrator/gates.ts +10 -10
  38. package/lib/orchestrator/pr-wake.ts +2 -2
  39. package/lib/orchestrator/rebase.ts +6 -45
  40. package/lib/orchestrator/review.ts +18 -5
  41. package/lib/orchestrator/scope.ts +9 -0
  42. package/lib/orchestrator/serve.ts +6 -0
  43. package/lib/orchestrator/signoff.ts +13 -8
  44. package/lib/orchestrator/tool-names.ts +13 -6
  45. package/lib/orchestrator/tool-schemas.ts +15 -0
  46. package/lib/orchestrator/tools.ts +2 -0
  47. package/lib/orchestrator/worker.ts +4 -1
  48. package/lib/orchestrator/write-guard.ts +295 -0
  49. package/lib/project-add/index.ts +133 -14
  50. package/lib/project-add/name.ts +1 -0
  51. package/lib/secret-paths.ts +9 -0
  52. package/lib/session-audit/render.ts +1 -1
  53. package/lib/stacks/land.ts +23 -16
  54. package/lib/tasks/brief.ts +3 -14
  55. package/lib/tasks/code-review.ts +69 -107
  56. package/lib/tasks/gh.ts +3 -2
  57. package/lib/tasks/index.ts +1 -1
  58. package/lib/tasks/state.ts +4 -4
  59. package/lib/usage/check-runs.ts +167 -0
  60. package/lib/usage/report.ts +25 -0
  61. package/lib/usage/turns.ts +28 -0
  62. package/package.json +1 -1
  63. package/stores/addProject.ts +12 -3
  64. package/scripts/check-code-review.ts +0 -21
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Where an orchestrator may write. Anywhere on the machine except the
3
+ * workspace's project repositories and their task worktrees (code changes go
4
+ * through tasks, so the merge gates keep meaning something), AgentOS's own
5
+ * install and state (all of ~/.agent-os but its own folder), secret files,
6
+ * and files that make something run: the dotfiles and dot-folders in its
7
+ * home (shell, git, Claude and tool config), ~/Library, the folders on PATH,
8
+ * and any .git, .claude, .mcp.json, CLAUDE.md or the like. A write to one of
9
+ * those would hand it a shell, or run code in AgentOS's own git, without
10
+ * touching a protected path itself. Its chat worker checks every Write, Edit and NotebookEdit here
11
+ * before it runs (a PreToolUse hook), on the path with every symlink and
12
+ * `..` resolved the way the system will resolve it.
13
+ */
14
+
15
+ import fs from "fs";
16
+ import os from "os";
17
+ import path from "path";
18
+ import { db, type Session } from "../db";
19
+ import { git, GitError } from "../git-exec";
20
+ import { isSecretPath } from "../secret-paths";
21
+ import { WORKTREES_DIR } from "../worktrees";
22
+ import { orchestratorDir } from "./home";
23
+ import { LIMITS_FILE } from "./usage";
24
+
25
+ // The tools that write a file, and the argument naming it.
26
+ export const WRITE_TOOLS: Record<string, string> = {
27
+ Write: "file_path",
28
+ Edit: "file_path",
29
+ MultiEdit: "file_path",
30
+ NotebookEdit: "notebook_path",
31
+ };
32
+
33
+ export interface WriteBounds {
34
+ // Relative paths are taken from here.
35
+ cwd: string;
36
+ // Its own folder, the one part of `state` it may write.
37
+ ownDir: string;
38
+ // AgentOS's state (~/.agent-os).
39
+ state: string;
40
+ // Project repositories, task worktrees and AgentOS's install.
41
+ repos: string[];
42
+ // Its home: no dotfile or dot-folder in it may be written.
43
+ home: string;
44
+ // Folders whose files run (~/Library, PATH, the usage brake's input...).
45
+ runs: string[];
46
+ }
47
+
48
+ // Wherever they are, files that configure what an agent, git or a
49
+ // language runtime runs.
50
+ const RUNS_CODE =
51
+ /(^|\/)(\.git|\.claude|\.codex|\.cursor|\.vscode|\.husky|\.githooks)(\/|$)|(^|\/)(\.mcp\.json|\.claude\.json|CLAUDE(\.local)?\.md|AGENTS\.md|\.envrc|\.gitconfig|\.gitattributes|\.gitmodules|sitecustomize\.py|usercustomize\.py)$|\.pth$/i;
52
+
53
+ // The temporary checkouts AgentOS reviews, rebases and writes PR bodies in
54
+ // (repo.ts, rebase.ts): an edit while a review runs would change what it
55
+ // judges.
56
+ const AGENTOS_TEMP = /(^|\/)aos-(review|rebase|body)-[^/]+(\/|$)/;
57
+
58
+ const MAX_LINKS = 40;
59
+
60
+ // The path as the system resolves it: component by component from the root,
61
+ // each symlink followed where it sits (a dangling one too, since a write
62
+ // creates its target), and `..` taken from the real folder it's in. Parts
63
+ // that don't exist yet are kept as named, under the real folder they'd be
64
+ // made in.
65
+ export async function physicalPath(p: string): Promise<string> {
66
+ const parts = p.split("/").filter(Boolean);
67
+ let at = "/";
68
+ let links = 0;
69
+ while (parts.length) {
70
+ const part = parts.shift()!;
71
+ if (part === ".") continue;
72
+ if (part === "..") {
73
+ at = path.dirname(at);
74
+ continue;
75
+ }
76
+ const next = path.join(at, part);
77
+ const link = await fs.promises.readlink(next).catch(() => null);
78
+ if (link === null) {
79
+ at = next;
80
+ continue;
81
+ }
82
+ if (++links > MAX_LINKS) throw new Error("Too many symlinks");
83
+ parts.unshift(...link.split("/").filter(Boolean));
84
+ if (link.startsWith("/")) at = "/";
85
+ }
86
+ return canonical(at);
87
+ }
88
+
89
+ // Its deepest existing folder as the file system spells it, so a path in
90
+ // another case or Unicode form (one folder on macOS) compares equal.
91
+ async function canonical(p: string): Promise<string> {
92
+ const rest: string[] = [];
93
+ let at = p;
94
+ for (;;) {
95
+ try {
96
+ return path.join(await fs.promises.realpath(at), ...rest.reverse());
97
+ } catch {
98
+ const up = path.dirname(at);
99
+ if (up === at) return p;
100
+ rest.push(path.basename(at));
101
+ at = up;
102
+ }
103
+ }
104
+ }
105
+
106
+ const FOLDS_CASE =
107
+ process.platform === "darwin" || process.platform === "win32";
108
+ const key = (p: string) =>
109
+ FOLDS_CASE ? p.normalize("NFC").toLowerCase() : p.normalize("NFC");
110
+
111
+ const within = (p: string, root: string) => {
112
+ const [a, b] = [key(p), key(root)];
113
+ return a === b || a.startsWith(b === "/" ? b : b + "/");
114
+ };
115
+
116
+ const expandHome = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
117
+
118
+ // Why a write to this path is refused, or null when it may go ahead.
119
+ export async function writeRefusal(
120
+ filePath: string,
121
+ bounds: WriteBounds
122
+ ): Promise<string | null> {
123
+ if (!filePath) return "No file path to check.";
124
+ const given = expandHome(filePath);
125
+ const abs = given.startsWith("/") ? given : `${bounds.cwd}/${given}`;
126
+ let targets: string[];
127
+ let repos: string[];
128
+ let state: string[];
129
+ let ownDir: string[];
130
+ let home: string[];
131
+ let runs: string[];
132
+ try {
133
+ // Where it lands as written, and with `..` taken by name first, as a
134
+ // tool that normalises the path before opening it would; and where it
135
+ // was asked for, before any link: all must be allowed. A link sitting in
136
+ // a protected place (a dotfile kept in a dotfiles repo, Homebrew's
137
+ // bin/git) is refused there, wherever it points.
138
+ targets = [
139
+ ...new Set([
140
+ ...(await Promise.all([
141
+ physicalPath(abs),
142
+ physicalPath(path.resolve(abs)),
143
+ ])),
144
+ path.resolve(abs),
145
+ ]),
146
+ ];
147
+ // Each folder by its own name too, for a path that reaches it by name.
148
+ const both = async (p: string) => [path.resolve(p), await physicalPath(p)];
149
+ repos = (await Promise.all(bounds.repos.map(both))).flat();
150
+ runs = (await Promise.all(bounds.runs.map(both))).flat();
151
+ state = await both(bounds.state);
152
+ ownDir = await both(bounds.ownDir);
153
+ home = await both(bounds.home);
154
+ } catch (e) {
155
+ return `Couldn't resolve ${filePath}: ${(e as Error).message}.`;
156
+ }
157
+ for (const target of targets) {
158
+ const repo = repos.find((r) => within(target, r));
159
+ if (repo)
160
+ return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), so its PR goes through the merge gates.`;
161
+ if (AGENTOS_TEMP.test(target))
162
+ return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
163
+ const own = ownDir.some((d) => within(target, d));
164
+ if (state.some((d) => within(target, d)) && !own)
165
+ return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
166
+ if (isSecretPath(target) || isSecretPath(filePath))
167
+ return `${filePath} holds or guards secrets; orchestrators never write those.`;
168
+ const runsIn = runs.find((r) => within(target, r));
169
+ if (
170
+ RUNS_CODE.test(target) ||
171
+ RUNS_CODE.test(filePath) ||
172
+ runsIn ||
173
+ (!own &&
174
+ home.some(
175
+ (h) =>
176
+ within(target, h) &&
177
+ key(target)
178
+ .slice(key(h).length + 1)
179
+ .startsWith(".")
180
+ ))
181
+ )
182
+ return `${filePath} configures what runs (shell, git, agent or tool config${runsIn ? `, in ${runsIn}` : ""}); orchestrators never write those.`;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ // What an orchestrator's writes are held to, read fresh for each one so a
188
+ // project added since counts: every local project's repositories (not just
189
+ // its workspace's, since a write to another workspace's repo skips its gates
190
+ // as much), every session's worktree, and AgentOS's install and database.
191
+ export async function orchestratorWriteBounds(
192
+ session: Session
193
+ ): Promise<WriteBounds> {
194
+ const projects = db
195
+ .prepare(
196
+ `SELECT working_directory AS dir FROM projects
197
+ WHERE is_uncategorized = 0 AND host_id = 'local'
198
+ UNION SELECT r.path FROM project_repositories r
199
+ JOIN projects p ON p.id = r.project_id
200
+ WHERE p.host_id = 'local'
201
+ UNION SELECT worktree_path FROM sessions
202
+ WHERE worktree_path IS NOT NULL AND worktree_path != ''`
203
+ )
204
+ .all() as { dir: string }[];
205
+ const dbPath = path.resolve(
206
+ process.env.DB_PATH || path.join(process.cwd(), "agent-os.db")
207
+ );
208
+ const repos = projects.map((p) => expandHome(p.dir)).filter((d) => d !== "");
209
+ return {
210
+ cwd: expandHome(session.working_directory),
211
+ home: os.homedir(),
212
+ runs: [
213
+ path.join(os.homedir(), "Library"),
214
+ "/Library/LaunchAgents",
215
+ "/Library/LaunchDaemons",
216
+ path.dirname(LIMITS_FILE),
217
+ ...(await hooksPaths(repos)),
218
+ ...(process.env.XDG_CONFIG_HOME ? [process.env.XDG_CONFIG_HOME] : []),
219
+ ...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
220
+ ],
221
+ ownDir: orchestratorDir(session.workspace_id ?? ""),
222
+ state: path.join(os.homedir(), ".agent-os"),
223
+ repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
224
+ };
225
+ }
226
+
227
+ // Where git looks for hooks instead of .git/hooks (core.hooksPath), in the
228
+ // user's config and each project's: a script there runs on the next commit.
229
+ // Read at most once a minute, not on every write.
230
+ const HOOKS_TTL_MS = 60_000;
231
+ let hooksCache: { key: string; at: number; dirs: string[] } | null = null;
232
+
233
+ export async function hooksPaths(repos: string[]): Promise<string[]> {
234
+ const key = repos.join("\0");
235
+ if (hooksCache?.key === key && Date.now() - hooksCache.at < HOOKS_TTL_MS)
236
+ return hooksCache.dirs;
237
+ // Only "not set" and "not a repository" mean there's nothing to protect;
238
+ // any other failure (a timeout under load) throws, so the write is
239
+ // refused and nothing is cached.
240
+ const run = (cwd: string, args: string[]) =>
241
+ git(cwd, args, { timeout: 5000 })
242
+ .then((out) => out.trim())
243
+ .catch((e: unknown) => {
244
+ if (
245
+ e instanceof GitError &&
246
+ !e.timedOut &&
247
+ (e.exitCode === 1 || /not a git repository/i.test(e.stderr))
248
+ )
249
+ return "";
250
+ throw new Error(`couldn't read git's hooks folder in ${cwd}`);
251
+ });
252
+ // The user's, expanded by git (~ and ~user). A relative one is taken from
253
+ // each repo's top, so it names no one folder: each project's answer
254
+ // below covers it there. Each project's comes as git itself resolves it.
255
+ const global = await run(os.homedir(), [
256
+ "config",
257
+ "--global",
258
+ "--type=path",
259
+ "--get",
260
+ "core.hooksPath",
261
+ ]);
262
+ const found = await Promise.all(
263
+ repos.map(async (r) => {
264
+ // A project whose folder is gone has no hooks to run.
265
+ if (!fs.existsSync(r)) return "";
266
+ const dir = await run(r, [
267
+ "rev-parse",
268
+ "--path-format=absolute",
269
+ "--git-path",
270
+ "hooks",
271
+ ]);
272
+ if (dir !== "" && !dir.startsWith("/"))
273
+ throw new Error(`git named no folder for ${r}'s hooks`);
274
+ return dir;
275
+ })
276
+ );
277
+ const dirs = [
278
+ ...new Set([global, ...found].filter((d) => d.startsWith("/"))),
279
+ ];
280
+ hooksCache = { key, at: Date.now(), dirs };
281
+ return dirs;
282
+ }
283
+
284
+ // The check a PreToolUse hook makes: why this tool call is refused, or null.
285
+ export async function orchestratorToolRefusal(
286
+ session: Session,
287
+ tool: string,
288
+ input: Record<string, unknown>
289
+ ): Promise<string | null> {
290
+ const arg = WRITE_TOOLS[tool];
291
+ if (!arg) return null;
292
+ const filePath = input[arg];
293
+ if (typeof filePath !== "string") return "No file path to check.";
294
+ return writeRefusal(filePath, await orchestratorWriteBounds(session));
295
+ }
@@ -3,15 +3,25 @@
3
3
  * into it, or start a new one from a name (folder, git init, first commit).
4
4
  */
5
5
 
6
+ import os from "os";
6
7
  import path from "path";
7
8
  import { randomUUID } from "crypto";
8
- import { createProject, type ProjectWithRepositories } from "../projects";
9
+ import {
10
+ createProject,
11
+ getProject,
12
+ getProjectWithDevServers,
13
+ type ProjectWithRepositories,
14
+ } from "../projects";
15
+ import { db, queries, type Project } from "../db";
9
16
  import { getHost } from "../hosts";
17
+ import { getWorkspace, setProjectWorkspace } from "../workspaces";
10
18
  import { shellQuote } from "../hosts/ssh";
11
19
  import { run, shellPath, spawnOn } from "./shell";
12
20
  import { onWindowsDrive, WINDOWS_DRIVE_WARNING } from "../wsl";
13
21
 
14
- export const PROJECT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
22
+ import { PROJECT_NAME } from "./name";
23
+
24
+ export { PROJECT_NAME };
15
25
  // https://host/owner/repo(.git), ssh://…, or git@host:owner/repo(.git).
16
26
  const REPO_URL =
17
27
  /^(?:https:\/\/[\w.-]+(?::\d+)?\/|ssh:\/\/[\w.@-]+(?::\d+)?\/|[\w.-]+@[\w.-]+:)[\w./~-]+$/;
@@ -25,6 +35,8 @@ export interface FolderListing {
25
35
  parent: string | null;
26
36
  folders: string[];
27
37
  isGitRepo: boolean;
38
+ // The folder with every symlink resolved, for the secrets check.
39
+ realPath: string;
28
40
  // Set when the folder is somewhere a project shouldn't live (a Windows
29
41
  // drive under WSL).
30
42
  warning: string | null;
@@ -38,28 +50,129 @@ export async function listFolders(
38
50
  checkHost(hostId);
39
51
  const out = await run(
40
52
  hostId,
41
- `cd ${shellPath(dir || "~")} && pwd && { test -e .git && echo GIT || echo NOGIT; } && find . -mindepth 1 -maxdepth 1 -type d ! -name '.*' 2>/dev/null | sort | head -500`
53
+ `cd ${shellPath(dir || "~")} && pwd && pwd -P && { test -e .git && echo GIT || echo NOGIT; } && find . -mindepth 1 -maxdepth 1 -type d ! -name '.*' 2>/dev/null | sort | head -500`
42
54
  );
43
- const [abs, git, ...rest] = out.split("\n");
55
+ const [abs, real, git, ...rest] = out.split("\n");
44
56
  return {
45
57
  path: abs,
46
58
  parent: abs === "/" ? null : path.posix.dirname(abs),
47
59
  folders: rest.filter(Boolean).map((f) => f.replace(/^\.\//, "")),
48
60
  isGitRepo: git === "GIT",
61
+ realPath: real,
49
62
  warning: onWindowsDrive(abs) ? WINDOWS_DRIVE_WARNING : null,
50
63
  };
51
64
  }
52
65
 
66
+ function checkWorkspace(workspaceId: string | null | undefined) {
67
+ if (workspaceId && !getWorkspace(workspaceId))
68
+ throw new Error("Unknown workspace");
69
+ }
70
+
71
+ // Folders that hold keys and credentials are never a project's home.
72
+ const SECRET_DIR =
73
+ /(^|\/)(\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.docker|\.password-store|\.agent-os|Keychains|tokenvault)(\/|$)/i;
74
+
75
+ export function isSecretPath(dir: string): boolean {
76
+ const p = dir.replace(os.homedir(), "~");
77
+ return SECRET_DIR.test(p) || /^~\/\.config(\/|$)/i.test(p);
78
+ }
79
+
80
+ const folderKey = (hostId: string, dir: string) => {
81
+ const abs =
82
+ hostId === "local" ? dir.replace(/^~(?=$|\/)/, os.homedir()) : dir;
83
+ return `${hostId}\0${abs.replace(/\/+$/, "") || "/"}`;
84
+ };
85
+
86
+ // The project that already lives in this folder on this machine, if any.
87
+ export function projectAt(hostId: string, dir: string): Project | null {
88
+ const key = folderKey(hostId, dir);
89
+ const row = (
90
+ queries.getProjectFolders(db).all() as {
91
+ id: string;
92
+ host_id: string;
93
+ working_directory: string;
94
+ is_uncategorized: number;
95
+ }[]
96
+ ).find(
97
+ (p) =>
98
+ !p.is_uncategorized &&
99
+ folderKey(p.host_id || "local", p.working_directory) === key
100
+ );
101
+ return (row && getProject(row.id)) || null;
102
+ }
103
+
104
+ // Adding a folder that's already a project: the caller is told which, and
105
+ // can ask again with moveExisting to bring it into the workspace instead.
106
+ export class ProjectExistsError extends Error {
107
+ constructor(readonly project: Project) {
108
+ super(`${project.name} is already a project`);
109
+ }
110
+ }
111
+
112
+ export interface AddFolderOptions {
113
+ workspaceId?: string | null;
114
+ name?: string;
115
+ // An existing project at the folder moves into workspaceId rather than
116
+ // failing.
117
+ moveExisting?: boolean;
118
+ // ...but only one in no workspace: taking a project from another
119
+ // workspace is refused (the orchestrator's limit).
120
+ moveOnlyUnassigned?: boolean;
121
+ requireGit?: boolean;
122
+ }
123
+
124
+ export interface AddedFolder {
125
+ project: ProjectWithRepositories;
126
+ // What happened: made, or an existing one moved (from its old
127
+ // workspace) or already there.
128
+ outcome: "created" | "moved" | "unchanged";
129
+ from: string | null;
130
+ }
131
+
132
+ // The one way a folder becomes a project in a workspace, for the add
133
+ // dialog and the orchestrator's add_project alike.
53
134
  export async function addFolder(
54
135
  hostId: string,
55
- dir: string
56
- ): Promise<ProjectWithRepositories> {
57
- const { path: abs } = await listFolders(hostId, dir);
58
- return createProject({
59
- name: path.posix.basename(abs) || "project",
60
- workingDirectory: abs,
61
- hostId,
62
- });
136
+ dir: string,
137
+ opts: AddFolderOptions = {}
138
+ ): Promise<AddedFolder> {
139
+ checkHost(hostId);
140
+ checkWorkspace(opts.workspaceId);
141
+ if (isSecretPath(dir)) throw new Error("That folder holds secrets");
142
+ const { path: abs, realPath, isGitRepo } = await listFolders(hostId, dir);
143
+ if (isSecretPath(abs) || isSecretPath(realPath))
144
+ throw new Error("That folder holds secrets");
145
+ if (opts.requireGit && !isGitRepo)
146
+ throw new Error(`${abs} isn't a git repository`);
147
+ const workspaceId = opts.workspaceId ?? null;
148
+ const existing = projectAt(hostId, abs) ?? projectAt(hostId, realPath);
149
+ if (existing) {
150
+ if (!opts.moveExisting) throw new ProjectExistsError(existing);
151
+ const from = existing.workspace_id;
152
+ if (opts.moveOnlyUnassigned && from && from !== workspaceId)
153
+ throw new Error(
154
+ `${existing.name} belongs to the ${getWorkspace(from)?.name ?? "another"} workspace; only Saad moves a project between workspaces`
155
+ );
156
+ const outcome =
157
+ !workspaceId || from === workspaceId ? "unchanged" : "moved";
158
+ if (outcome === "moved") setProjectWorkspace(existing.id, workspaceId);
159
+ return {
160
+ project: getProjectWithDevServers(existing.id) as ProjectWithRepositories,
161
+ outcome,
162
+ from,
163
+ };
164
+ }
165
+ const name = opts.name?.trim() || path.posix.basename(abs) || "project";
166
+ return {
167
+ project: createProject({
168
+ name,
169
+ workingDirectory: abs,
170
+ hostId,
171
+ workspaceId,
172
+ }),
173
+ outcome: "created",
174
+ from: null,
175
+ };
63
176
  }
64
177
 
65
178
  // A machine with no git identity still gets its first commit, under a
@@ -71,9 +184,11 @@ const IDENTITY =
71
184
  export async function initProject(
72
185
  hostId: string,
73
186
  parent: string,
74
- name: string
187
+ name: string,
188
+ workspaceId: string | null = null
75
189
  ): Promise<ProjectWithRepositories> {
76
190
  checkHost(hostId);
191
+ checkWorkspace(workspaceId);
77
192
  if (!PROJECT_NAME.test(name))
78
193
  throw new Error("Use letters, numbers, dots, dashes or underscores");
79
194
  const out = await run(
@@ -84,6 +199,7 @@ export async function initProject(
84
199
  name,
85
200
  workingDirectory: out.trim().split("\n").pop() || name,
86
201
  hostId,
202
+ workspaceId,
87
203
  });
88
204
  }
89
205
 
@@ -119,9 +235,11 @@ export const getCloneJob = (id: string) => jobs.get(id) ?? null;
119
235
  export function startClone(
120
236
  hostId: string,
121
237
  parent: string,
122
- url: string
238
+ url: string,
239
+ workspaceId: string | null = null
123
240
  ): CloneJob {
124
241
  checkHost(hostId);
242
+ checkWorkspace(workspaceId);
125
243
  if (!REPO_URL.test(url)) throw new Error("That isn't a repository URL");
126
244
  const name = repoNameOf(url);
127
245
  if (!name) throw new Error("Couldn't tell the repository's name");
@@ -182,6 +300,7 @@ export function startClone(
182
300
  name,
183
301
  workingDirectory: stdout.trim().split("\n").pop() || name,
184
302
  hostId,
303
+ workspaceId,
185
304
  });
186
305
  job.projectId = project.id;
187
306
  job.status = "done";
@@ -0,0 +1 @@
1
+ export const PROJECT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
@@ -0,0 +1,9 @@
1
+ // Files that hold or guard secrets: env files, keys, credential stores and
2
+ // the folders keys live in (.ssh, .aws, .gnupg, .kube, .docker, gh, gcloud,
3
+ // tokenvault). Tested against a repo-relative or absolute path. A merge that
4
+ // touches one goes to Saad (lib/orchestrator/diff), and an orchestrator may
5
+ // never write one (lib/orchestrator/write-guard).
6
+ export const SECRET_PATH =
7
+ /(^|\/)\.env(\.[^/]*)?$|(^|\/)[^/]*(secret|credential|tokenvault|keychain)[^/]*(\/|$)|\.(pem|key|p12|pfx)$|(^|\/)(id_(rsa|dsa|ecdsa|ed25519)|\.npmrc|\.netrc|\.pgpass)$|(^|\/)\.(ssh|aws|azure|gnupg|kube|docker)(\/|$)|(^|\/)\.config\/(gh|gcloud|op)(\/|$)/i;
8
+
9
+ export const isSecretPath = (p: string): boolean => SECRET_PATH.test(p);
@@ -427,7 +427,7 @@ export function renderSummary(
427
427
  )
428
428
  );
429
429
  out.push(
430
- "\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript; reviews are counted from the checks above and from do-code-review subagents."
430
+ "\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript; each run's tokens are recorded in `check_runs` and shown in Usage."
431
431
  );
432
432
  return out.join("\n") + "\n";
433
433
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Land a stack: merge every PR bottom-up, ported from dispatch's `land`.
3
- * A preflight checks EVERY PR before anything merges: CI, and a Code review
4
- * section in its body for its head commit. Each sign-off restacks
3
+ * A preflight checks EVERY PR before anything merges: CI, and a passing
4
+ * independent review of its head commit (tasks/code-review.ts). Each sign-off restacks
5
5
  * what sits on it, so a restacked PR's checks are waited out again (its green
6
6
  * was against the parent's branch). It stops at the first failure and says
7
7
  * what landed. A chat item's agent is stopped between turns before anything
@@ -17,6 +17,8 @@ import {
17
17
  codeReviewRefusal,
18
18
  type TaskPR,
19
19
  } from "../tasks";
20
+ import type { ReviewState } from "../tasks/code-review";
21
+ import { getCheck } from "../orchestrator/checks";
20
22
  import { itemName } from "./guard";
21
23
  import { refreshItems } from "./tick";
22
24
  import { outputOf } from "./git";
@@ -34,6 +36,8 @@ export interface LandDeps {
34
36
  signOff: (sessionId: string, head?: string) => Promise<void>;
35
37
  beforeMerge?: MergeGate;
36
38
  prOf: (session: Session) => Promise<TaskPR | null>;
39
+ // The independent review of a task at a commit.
40
+ reviewOf: (sessionId: string, sha: string) => ReviewState | null;
37
41
  sleep: (ms: number) => Promise<void>;
38
42
  refresh: (stackId: string) => Promise<void>;
39
43
  waitMs: number;
@@ -52,6 +56,7 @@ export const LAND_DEFAULTS: LandDeps = {
52
56
  await signOffTask(id, { wait: true, head });
53
57
  },
54
58
  prOf: (s) => taskPR(s, true),
59
+ reviewOf: (id, sha) => getCheck(id, sha, "review"),
55
60
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
56
61
  refresh: refreshItems,
57
62
  waitMs: 45 * 60_000,
@@ -90,8 +95,20 @@ async function waitGreen(
90
95
  }
91
96
  }
92
97
 
98
+ // Why an item can't merge on its review: none passed of its head, or of
99
+ // the head the task pushed before a restack moved it.
100
+ function unreviewed(item: StackItemRow, pr: TaskPR, deps: LandDeps) {
101
+ return codeReviewRefusal({
102
+ head: pr.head,
103
+ attribution: pr.attribution,
104
+ review: (sha) =>
105
+ item.session_id ? deps.reviewOf(item.session_id, sha) : null,
106
+ restacked: { from: item.restacked_from, to: item.restacked_to },
107
+ });
108
+ }
109
+
93
110
  // Without the orchestrator's gate: the item's head right before merging,
94
- // only if its Code review section covers that commit. A push since the
111
+ // only if a passing review covers that commit. A push since the
95
112
  // preflight needs a new review; the merge is pinned to this head.
96
113
  async function reviewedHead(
97
114
  item: StackItemRow,
@@ -100,12 +117,7 @@ async function reviewedHead(
100
117
  const now = q.item(db, item.id) ?? item;
101
118
  const session = sessionOf(now.session_id);
102
119
  const pr = session ? await deps.prOf(session) : null;
103
- const refusal = pr
104
- ? codeReviewRefusal(pr.codeReview, pr.head, {
105
- from: now.restacked_from,
106
- to: now.restacked_to,
107
- })
108
- : "its PR is gone";
120
+ const refusal = pr ? unreviewed(now, pr, deps) : "its PR is gone";
109
121
  if (refusal) throw new Stop(`stopped at ${itemName(now)}: ${refusal}`);
110
122
  return pr!.head!;
111
123
  }
@@ -157,14 +169,9 @@ export async function landStack(
157
169
  const pr =
158
170
  session && item.status === "pr" ? await deps.prOf(session) : null;
159
171
  const verdict = canSignOff(pr);
160
- const unreviewed =
161
- pr &&
162
- codeReviewRefusal(pr.codeReview, pr.head, {
163
- from: item.restacked_from,
164
- to: item.restacked_to,
165
- });
172
+ const refusal = pr && unreviewed(item, pr, deps);
166
173
  if (!verdict.ok) missing.push(`${itemName(item)}: ${verdict.reason}`);
167
- else if (unreviewed) missing.push(`${itemName(item)}: ${unreviewed}`);
174
+ else if (refusal) missing.push(`${itemName(item)}: ${refusal}`);
168
175
  else if (item.error) missing.push(`${itemName(item)}: ${item.error}`);
169
176
  hadChecks.set(item.id, pr?.checks !== "none");
170
177
  }
@@ -16,18 +16,9 @@ function stackedLines(baseBranch: string, stack: StackedOn): string {
16
16
  return `
17
17
  - This task is STACKED on ${stack.name} (${on}), which has not merged. Its commits are already in your branch and are not yours: don't change or revert them.
18
18
  - Open your pull request against ${baseBranch} (\`gh pr create --base ${baseBranch}\`) so its diff is only your commits, and start the body with the line "Stacked on ${on} (${stack.name})".
19
- - Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. Your code review still counts for the head AgentOS's restack left; if you push after that, review again and update the section.${also}`;
19
+ - Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. AgentOS reviews your PR itself; a review of your head still counts for the head its restack left, and a push after that gets a new one.${also}`;
20
20
  }
21
21
 
22
- // What the PR body's review section looks like. lib/tasks/code-review.ts
23
- // parses it, and merges refuse a PR without one for its head commit.
24
- export const CODE_REVIEW_SECTION = `
25
- ## Code review
26
- Reviewed: <the full sha of the commit you reviewed, which is the PR's head>
27
- Agents: <the review agents that ran>
28
- Fixed: <each finding fixed, one line each, or "none">
29
- Deferred: <each finding not fixed and why, or "none">`;
30
-
31
22
  // The system brief every task agent runs under. It finishes by opening a PR
32
23
  // and stopping: merging is the human's sign-off, never the agent's.
33
24
  export function buildTaskBrief(opts: {
@@ -39,10 +30,8 @@ export function buildTaskBrief(opts: {
39
30
 
40
31
  - Branch: ${opts.branch} (based on ${opts.baseBranch}). Stay in this directory and on this branch.
41
32
  - Do the task fully. Run the project's own checks (typecheck, tests, lint) when they exist and keep them passing.
42
- - When the work is done, commit with a conventional commit message, then review it before any pull request: run \`/do-code-review\` (the project's skill in .claude/skills/do-code-review; if the project has none, \`/code-review\`). Fix every Blocking and High finding, commit, and review again until none are left (two rounds at most; past that, list what's still open in the PR body).
43
- - Then run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it, and ends with this section:
44
- ${CODE_REVIEW_SECTION}
45
- Merges are refused without it, and when its "Reviewed:" commit is not the PR's head: if you push again, review again and update the body (\`gh pr edit --body-file\`). Then stop and wait.
33
+ - When the work is done, commit with a conventional commit message, run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it. Then stop and wait.
34
+ - You don't need to review it yourself: AgentOS runs an independent review of the PR's exact head commit before it can merge, and the orchestrator sends you any blocking findings to fix. For a risky change you may still run \`/do-code-review\` (or \`/code-review\`) before the PR.
46
35
  - Never merge the pull request, never push to ${opts.baseBranch}, and never delete branches or worktrees. A human reviews and merges.${opts.stack ? stackedLines(opts.baseBranch, opts.stack) : ""}
47
36
  - Write scratch files (PR bodies, review notes, logs, test output) outside the worktree, in your scratchpad or \$TMPDIR. Anything uncommitted left in the worktree keeps it from being removed when the task is done.
48
37
  - Do not add any AI or assistant attribution to commits or the pull request.