@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.
- package/README.md +67 -37
- package/app/api/fresh-start/route.ts +9 -5
- package/app/api/projects/clone/route.ts +4 -2
- package/app/api/projects/init/route.test.ts +46 -0
- package/app/api/projects/init/route.ts +24 -4
- package/app/api/usage/route.ts +4 -2
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- package/components/CommandPalette/CommandPalette.tsx +3 -1
- package/components/CommandPalette/useAppCommands.ts +10 -0
- package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
- package/components/Projects/AddProject/CloneForm.tsx +3 -1
- package/components/Projects/AddProject/FolderForm.tsx +98 -4
- package/components/Projects/AddProject/NameForm.tsx +3 -1
- package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
- package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
- package/components/Usage/UsageDialog.tsx +31 -2
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- package/data/projects/add.ts +23 -3
- package/data/usage/index.ts +2 -0
- package/lib/agents/launch.ts +2 -0
- package/lib/chat/driver.ts +8 -0
- package/lib/chat/drivers/claude-approvals.ts +40 -0
- package/lib/chat/drivers/claude.ts +7 -10
- package/lib/chat/refresh.ts +31 -13
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/extras.ts +1 -0
- package/lib/chat/worker/host.ts +61 -22
- package/lib/db/migrations.ts +42 -0
- package/lib/orchestrator/add-project.ts +35 -0
- package/lib/orchestrator/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +6 -5
- package/lib/orchestrator/claude-cli.ts +5 -0
- package/lib/orchestrator/diff.ts +6 -2
- package/lib/orchestrator/external-gates.ts +8 -43
- package/lib/orchestrator/gates.ts +10 -10
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +18 -5
- package/lib/orchestrator/scope.ts +9 -0
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/signoff.ts +13 -8
- package/lib/orchestrator/tool-names.ts +13 -6
- package/lib/orchestrator/tool-schemas.ts +15 -0
- package/lib/orchestrator/tools.ts +2 -0
- package/lib/orchestrator/worker.ts +4 -1
- package/lib/orchestrator/write-guard.ts +295 -0
- package/lib/project-add/index.ts +133 -14
- package/lib/project-add/name.ts +1 -0
- package/lib/secret-paths.ts +9 -0
- package/lib/session-audit/render.ts +1 -1
- package/lib/stacks/land.ts +23 -16
- package/lib/tasks/brief.ts +3 -14
- package/lib/tasks/code-review.ts +69 -107
- package/lib/tasks/gh.ts +3 -2
- package/lib/tasks/index.ts +1 -1
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/check-runs.ts +167 -0
- package/lib/usage/report.ts +25 -0
- package/lib/usage/turns.ts +28 -0
- package/package.json +1 -1
- package/stores/addProject.ts +12 -3
- 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
|
+
}
|
package/lib/project-add/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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;
|
|
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
|
}
|
package/lib/stacks/land.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
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
|
|
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 (
|
|
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
|
}
|
package/lib/tasks/brief.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
43
|
-
-
|
|
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.
|