@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
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { db } from "../db";
14
+ import type { CodeReviewGate } from "../tasks/code-review";
14
15
  import type { ChecksVerdict } from "../tasks/state";
15
16
  import type { CheckRow } from "./checks";
16
17
  import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
@@ -45,11 +46,9 @@ export interface GateInput {
45
46
  fromCard: boolean;
46
47
  scope: CheckRow | null;
47
48
  stackRefusal: string | null;
48
- // Why the PR body's Code review section doesn't cover this commit.
49
- codeReviewRefusal: string | null;
50
- // Why it isn't covered yet but will be (an external PR whose own review
51
- // of this commit stands in for the section).
52
- codeReviewWait?: string | null;
49
+ // Whether a passing independent review covers this commit, and the body
50
+ // carries no AI attribution (tasks/code-review.ts).
51
+ codeReview: CodeReviewGate;
53
52
  // A PR no task owns has no session to read: a draft isn't ready yet, and
54
53
  // a label saying it's blocked holds it.
55
54
  prDraft?: string | null;
@@ -147,11 +146,12 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
147
146
  const stack: GateOutcome = i.stackRefusal
148
147
  ? { gate: "stack", state: "fail", reason: i.stackRefusal }
149
148
  : { gate: "stack", state: "pass" };
150
- const codeReview: GateOutcome = i.codeReviewRefusal
151
- ? { gate: "code-review", state: "fail", reason: i.codeReviewRefusal }
152
- : i.codeReviewWait
153
- ? { gate: "code-review", state: "wait", reason: i.codeReviewWait }
154
- : { gate: "code-review", state: "pass" };
149
+ // Its wait is the review gate's own (no passing review of this exact
150
+ // head), which already holds the merge: only paths without that gate (an
151
+ // approval, a stack's land) read it.
152
+ const codeReview: GateOutcome = i.codeReview.refusal
153
+ ? { gate: "code-review", state: "fail", reason: i.codeReview.refusal }
154
+ : { gate: "code-review", state: "pass" };
155
155
  return [
156
156
  ciGate(i),
157
157
  checkGate("review", i.review, i.sha),
@@ -272,7 +272,7 @@ export function checkMessage(
272
272
  : pr.failingUrl
273
273
  ? `\n\nIts details: ${untrusted("CI", pr.failingUrl)}`
274
274
  : "";
275
- return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, push, then run /do-code-review again and update the Reviewed: line in the PR body for the new head.`;
275
+ return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, then push.`;
276
276
  }
277
277
 
278
278
  // True once the PR's activity at this fingerprint has been handled.
@@ -374,7 +374,7 @@ export function reviewMessage(pr: TaskPR, events: PREvent[]): string {
374
374
  : "";
375
375
  return `- ${e.author} ${what}${body}${notes}${more}`;
376
376
  });
377
- return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, push, then run /do-code-review again and update the Reviewed: line for the new head. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
377
+ return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, then push. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
378
378
  }
379
379
 
380
380
  // Tests only.
@@ -8,11 +8,10 @@
8
8
  * only while its agent is idle with nothing uncommitted, and pushed to the
9
9
  * task's own branch with a lease on the commit fetched (the restack's code,
10
10
  * stacks/git.ts). The base itself and every other branch are never written.
11
- * The heads are recorded so the task's Code review section still covers
12
- * the new head (tasks/code-review.ts), and the section gets a "Restacked:"
13
- * line for CI. When the PR's own patch is unchanged (same `git patch-id`
14
- * against the base before and after), the orchestrator's review of the old
15
- * head carries over; otherwise a fresh one starts.
11
+ * The heads are recorded, and when the PR's own patch is unchanged (same
12
+ * `git patch-id` against the base before and after), the orchestrator's
13
+ * review of the old head carries over to the new one (tasks/code-review.ts);
14
+ * otherwise a fresh one starts.
16
15
  *
17
16
  * Not clean, or not rebasable (uncommitted work, commits only on origin):
18
17
  * the task is asked once, per head, to rebase by hand. The orchestrator
@@ -28,7 +27,6 @@ import { hostLink } from "../hosts/remote-api";
28
27
  import type { TaskPR } from "../tasks/state";
29
28
  import { run } from "../tasks/gh";
30
29
  import { git as runGit, gitFetch, revParse } from "../git-exec";
31
- import { withRestackedLine } from "../tasks/code-review";
32
30
  import { outputOf, type Runner } from "../stacks/git";
33
31
  import { restackedHeads, stackNotice } from "../stacks/restack";
34
32
  import { getCheck, putCheck } from "./checks";
@@ -53,37 +51,6 @@ export interface RebaseDeps {
53
51
  pr: TaskPR,
54
52
  sha: string
55
53
  ) => Promise<unknown>;
56
- // Add the Restacked line to the PR body's Code review section.
57
- editBody: (
58
- repo: string,
59
- pr: number,
60
- from: string,
61
- to: string
62
- ) => Promise<void>;
63
- }
64
-
65
- async function editBody(repo: string, pr: number, from: string, to: string) {
66
- const body = await run(
67
- "gh",
68
- ["pr", "view", String(pr), "--json", "body", "--jq", ".body"],
69
- repo,
70
- 15000
71
- );
72
- const next = withRestackedLine(body.replace(/\n$/, ""), from, to);
73
- if (!next) return;
74
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-body-"));
75
- const file = path.join(dir, "body.md");
76
- try {
77
- fs.writeFileSync(file, next);
78
- await run(
79
- "gh",
80
- ["pr", "edit", String(pr), "--body-file", file],
81
- repo,
82
- 30000
83
- );
84
- } finally {
85
- fs.rmSync(dir, { recursive: true, force: true });
86
- }
87
54
  }
88
55
 
89
56
  const defaults: RebaseDeps = {
@@ -94,7 +61,6 @@ const defaults: RebaseDeps = {
94
61
  idle: async (task) => (await statusOf(task)).status === "idle",
95
62
  review: (w, task, pr, sha) =>
96
63
  reviewAt(w, taskReviewTarget(task, { ...pr, head: sha }), sha),
97
- editBody,
98
64
  };
99
65
 
100
66
  // The (head, base tip) pairs already rebased or found not to need it, so a
@@ -263,11 +229,6 @@ async function rebaseOne(
263
229
  deps
264
230
  );
265
231
  }
266
- if (heads.restacked_from && heads.restacked_to)
267
- await deps
268
- .editBody(repo, pr.number, heads.restacked_from, heads.restacked_to)
269
- .catch(() => {});
270
-
271
232
  // The task's worktree follows while it's still idle and untouched;
272
233
  // otherwise the task is told how to catch up.
273
234
  const follows =
@@ -277,7 +238,7 @@ async function rebaseOne(
277
238
  if (!follows)
278
239
  await deps.send(
279
240
  task.id,
280
- `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). Your Code review section still covers it.`
241
+ `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.`
281
242
  );
282
243
  const reviewed = await carryReview(workspaceId, task, pr, onto, to, deps);
283
244
  queueEvent(
@@ -422,6 +383,6 @@ async function askToRebase(
422
383
  );
423
384
  await deps.send(
424
385
  task.id,
425
- `Your PR #${pr.number} conflicts with ${base}, and AgentOS couldn't rebase it for you: ${why.slice(0, 300)}. At your next stopping point: commit your work, run \`git fetch origin && git rebase origin/${base}\`, resolve the conflicts, re-run your checks, push with \`git push --force-with-lease\`, then run /do-code-review again and update the Code review section for the new head.`
386
+ `Your PR #${pr.number} conflicts with ${base}, and AgentOS couldn't rebase it for you: ${why.slice(0, 300)}. At your next stopping point: commit your work, run \`git fetch origin && git rebase origin/${base}\`, resolve the conflicts, re-run your checks, push with \`git push --force-with-lease\`. The orchestrator reviews the new head.`
426
387
  );
427
388
  }
@@ -38,6 +38,11 @@ import {
38
38
  toVerdict,
39
39
  } from "./review-prompt";
40
40
  import { checkScope } from "./scope";
41
+ import {
42
+ checkRunRecorder,
43
+ checkRunTotals,
44
+ describeCheckRuns,
45
+ } from "../usage/check-runs";
41
46
  import { workspaceTask } from "./targets";
42
47
  import { untrusted } from "./untrusted";
43
48
 
@@ -52,10 +57,8 @@ export function describeReview(row: CheckRow): string {
52
57
  if (row.status === "running") return `The review of ${at} is still running.`;
53
58
  if (row.status === "error")
54
59
  return `The review of ${at} couldn't run: ${row.detail}`;
55
- const head =
56
- row.status === "pass"
57
- ? `Review of ${at}: pass.`
58
- : `Review of ${at}: blocking findings.`;
60
+ const cost = checkRunTotals(row.session_id, row.sha);
61
+ const head = `${row.status === "pass" ? `Review of ${at}: pass.` : `Review of ${at}: blocking findings.`}${cost ? ` (${describeCheckRuns(cost)})` : ""}`;
59
62
  return row.detail ? `${head}\n${untrusted("reviewer", row.detail)}` : head;
60
63
  }
61
64
 
@@ -115,6 +118,14 @@ async function reviewJob(
115
118
  const amendments = task ? amendmentsOf(task.id).map((a) => a.text) : [];
116
119
  const scope = task ? { amendments, scopeClaim: pr.scopeChange ?? null } : {};
117
120
  const dir = await checkout(repo, sha);
121
+ const onUsage = checkRunRecorder({
122
+ workspaceId,
123
+ sessionId: target.id,
124
+ label: target.label,
125
+ prNumber: pr.number,
126
+ sha,
127
+ kind: "review",
128
+ });
118
129
  try {
119
130
  const verdicts = [];
120
131
  for (const [i, part] of parts.entries()) {
@@ -143,6 +154,7 @@ async function reviewJob(
143
154
  schema: REVIEW_SCHEMA,
144
155
  tools: ["Read", "Grep", "Glob"],
145
156
  allow: [`Read(/${dir}/**)`, `Grep(/${dir}/**)`, `Glob(/${dir}/**)`],
157
+ onUsage,
146
158
  });
147
159
  verdicts.push(toVerdict(answer));
148
160
  }
@@ -246,11 +258,12 @@ export async function reviewAt(
246
258
  : done.status === "block"
247
259
  ? "found blocking issues"
248
260
  : "couldn't run";
261
+ const cost = checkRunTotals(target.id, sha);
249
262
  queueEvent(
250
263
  workspaceId,
251
264
  `review:${target.id}:${sha}`,
252
265
  target.id,
253
- `${target.label}: review of ${short(sha)} ${said}${countVerdicts(workspaceId, target, sha)}`
266
+ `${target.label}: review of ${short(sha)} ${said}${cost ? ` (${describeCheckRuns(cost)})` : ""}${countVerdicts(workspaceId, target, sha)}`
254
267
  );
255
268
  return done;
256
269
  });
@@ -8,6 +8,7 @@ import { connectedClient } from "../lumifyhub/connection";
8
8
  import { promptFromCard } from "../lumifyhub/task-cards";
9
9
  import { putCheck, type CheckRow } from "./checks";
10
10
  import type { ClaudeRunner } from "./claude-cli";
11
+ import { checkRunRecorder } from "../usage/check-runs";
11
12
  import type { ChangedFile } from "./diff";
12
13
  import { fence } from "./review-prompt";
13
14
 
@@ -78,6 +79,14 @@ export async function checkScope(input: {
78
79
  prompt: `The card, as data:\n${fence("card", card)}${amended}\n\nFiles changed:\n${list}\n\n${label}\n${fence("diff", part)}`,
79
80
  schema: SCOPE_SCHEMA,
80
81
  tools: [],
82
+ onUsage: checkRunRecorder({
83
+ workspaceId,
84
+ sessionId: task.id,
85
+ label: `task ${task.name}`,
86
+ prNumber: task.pr_number ?? null,
87
+ sha,
88
+ kind: "scope",
89
+ }),
81
90
  })) as { within?: boolean; reason?: string };
82
91
  if (answer.within !== true) break;
83
92
  }
@@ -15,6 +15,7 @@ import { sessionFacts } from "./facts";
15
15
  import { describeExternal, externalSessions } from "./external-sessions";
16
16
  import { resolveStaleAsks } from "./ask-settle";
17
17
  import { addNote } from "./notes";
18
+ import { addProject } from "./add-project";
18
19
  import { addRule, removeRule } from "./rules";
19
20
  import { askSaad } from "./ask-tool";
20
21
  import { listOrchestratorPeers, messageOrchestrator } from "./cross";
@@ -41,6 +42,7 @@ const ACTING = new Set<ToolName>([
41
42
  "review",
42
43
  "sign_off",
43
44
  "arm",
45
+ "add_project",
44
46
  ]);
45
47
 
46
48
  // The orchestrator's tools, each scoped to its workspace and its arguments
@@ -120,6 +122,10 @@ export async function runTool(
120
122
  return stop(w, p(tool, raw).session);
121
123
  case "done":
122
124
  return done(w, p(tool, raw).session);
125
+ case "add_project": {
126
+ const a = p(tool, raw);
127
+ return addProject(w, a.path, a.name);
128
+ }
123
129
  case "note":
124
130
  addNote(w, p(tool, raw).text);
125
131
  return "Noted.";
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
11
11
  import { prFor } from "../tasks/session";
12
- import { codeReviewRefusal, signOffTask } from "../tasks";
12
+ import { codeReviewGate, signOffTask } from "../tasks";
13
13
  import { signOffRefusal } from "../stacks/guard";
14
14
  import { getStack, landInBackground } from "../stacks";
15
15
  import { LAND_DEFAULTS } from "../stacks/land";
@@ -91,11 +91,12 @@ export async function judge(
91
91
  fromCard: !!task.lh_card_id,
92
92
  scope: getCheck(task.id, sha, "scope"),
93
93
  stackRefusal: landing ? null : signOffRefusal(task.id),
94
- codeReviewRefusal: codeReviewRefusal(
95
- pr.codeReview,
96
- sha,
97
- restackedOf(task)
98
- ),
94
+ codeReview: codeReviewGate({
95
+ head: sha,
96
+ attribution: pr.attribution,
97
+ review: (at) => getCheck(task.id, at, "review"),
98
+ restacked: restackedOf(task),
99
+ }),
99
100
  }),
100
101
  opts
101
102
  );
@@ -129,7 +130,8 @@ export async function gateVerdict(
129
130
  const held = holdingEscalations(work.id);
130
131
  // Saad approved merging this exact commit. On an ask the orchestrator
131
132
  // raised (ask_saad with its task and sha), held or not, it merges once
132
- // whatever the gates say, but only with a code review of that commit and
133
+ // whatever the gates say, but only once an independent review of that
134
+ // commit finished (its findings are his to override, not that it ran) and
133
135
  // its stack parent merged: the orchestrator wrote that ask, and no answer
134
136
  // to it stands in for those. An approval of the gates' own ask on a held
135
137
  // task merges as it always has (heldVerdict).
@@ -139,7 +141,10 @@ export async function gateVerdict(
139
141
  (approval.raised_by === "orchestrator" || !held.length)
140
142
  ) {
141
143
  const i = await inputs();
142
- const refusal = i.codeReviewRefusal ?? i.stackRefusal;
144
+ const refusal =
145
+ i.codeReview.refusal ??
146
+ (i.codeReview.ran ? null : i.codeReview.wait) ??
147
+ i.stackRefusal;
143
148
  if (refusal)
144
149
  return no(
145
150
  `${work.name} (PR #${pr.number} at ${short(sha)}): Saad approved this commit, but ${refusal}`
@@ -21,6 +21,7 @@ export const TOOL_NAMES = {
21
21
  drop: name("drop"),
22
22
  stop: name("stop"),
23
23
  done: name("done"),
24
+ add_project: name("add_project"),
24
25
  note: name("note"),
25
26
  rule_add: name("rule_add"),
26
27
  rule_remove: name("rule_remove"),
@@ -32,11 +33,13 @@ export const TOOL_NAMES = {
32
33
  } as const;
33
34
 
34
35
  // What it may do, fixed by its role: it reads other sessions' text, so it
35
- // never gets a general shell or file edits. Anything not listed is refused
36
- // without asking. It acts through its own tools, which are scoped to its
37
- // workspace and braked; the shell keeps only `aos` commands that read, and
38
- // notify, which reaches only Saad, so `aos send`, `task`, `spawn` and
39
- // `stack` can't go around them.
36
+ // never gets a general shell. Anything not listed is refused without asking.
37
+ // It acts through its own tools, which are scoped to its workspace and
38
+ // braked; the shell keeps only `aos` commands that read, and notify, which
39
+ // reaches only Saad, so `aos send`, `task`, `spawn` and `stack` can't go
40
+ // around them. It writes files, but never in a project repository, a task
41
+ // worktree, AgentOS's own state or a secret file: its worker checks each
42
+ // write first (write-guard.ts).
40
43
  export const ORCHESTRATOR_PERMISSIONS: {
41
44
  permissionMode: PermissionMode;
42
45
  allowedTools: string[];
@@ -48,6 +51,10 @@ export const ORCHESTRATOR_PERMISSIONS: {
48
51
  "Read",
49
52
  "Grep",
50
53
  "Glob",
54
+ "Write",
55
+ "Edit",
56
+ "MultiEdit",
57
+ "NotebookEdit",
51
58
  "ToolSearch",
52
59
  "TodoWrite",
53
60
  "Bash(aos peers:*)",
@@ -60,5 +67,5 @@ export const ORCHESTRATOR_PERMISSIONS: {
60
67
  "Bash(aos docs:*)",
61
68
  "Bash(aos doc:*)",
62
69
  ],
63
- disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
70
+ disallowedTools: [],
64
71
  };
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { BRANCH_NAME } from "../git";
6
6
  import { RAISED_KINDS } from "./asks";
7
7
  import { MAX_RULE_CHARS } from "./rules";
8
+ import { PROJECT_NAME } from "../project-add/name";
8
9
 
9
10
  const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
10
11
  const session = ref("Session name, project/name, or id from sessions");
@@ -100,6 +101,20 @@ export const TOOL_SHAPES = {
100
101
  drop: { task, reason: z.string().trim().min(1).max(2000) },
101
102
  stop: { session },
102
103
  done: { session },
104
+ add_project: {
105
+ path: z
106
+ .string()
107
+ .trim()
108
+ .min(1)
109
+ .max(1000)
110
+ .describe("The git repository's absolute path on this machine"),
111
+ name: z
112
+ .string()
113
+ .trim()
114
+ .regex(PROJECT_NAME, "use letters, numbers, dots, dashes or underscores")
115
+ .optional()
116
+ .describe("A new project's name (default: the folder's)"),
117
+ },
103
118
  note: { text: z.string().trim().min(1).max(2000) },
104
119
  rule_add: {
105
120
  text: z
@@ -71,6 +71,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
71
71
  drop: "Reject a task: close its PR and remove its worktree. Logged with the reason.",
72
72
  stop: "Stop a session's agent, keeping its worktree and branch.",
73
73
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
74
+ add_project:
75
+ "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
74
76
  note: "Add a line to this workspace's decision log; it shows in your chat.",
75
77
  rule_add:
76
78
  'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
@@ -10,9 +10,11 @@ import {
10
10
  } from "./tools";
11
11
  import type { RoleExtras } from "../chat/worker/extras";
12
12
  import { refreshSettings } from "../chat/refresh";
13
+ import { orchestratorToolRefusal } from "./write-guard";
13
14
 
14
15
  // What an orchestrator's chat worker starts with on top of any chat: its
15
- // brief, its tools, and permissions fixed by its role.
16
+ // brief, its tools, permissions fixed by its role, and the guard on where
17
+ // it may write.
16
18
  export async function orchestratorExtras(
17
19
  session: Session
18
20
  ): Promise<RoleExtras> {
@@ -29,6 +31,7 @@ export async function orchestratorExtras(
29
31
  ),
30
32
  },
31
33
  ...ORCHESTRATOR_PERMISSIONS,
34
+ toolGuard: (tool, input) => orchestratorToolRefusal(session, tool, input),
32
35
  toolsDigest: orchestratorToolsDigest(),
33
36
  refresh: {
34
37
  atTokens: refreshSettings().orchestratorTokens,