@saadnvd1/agent-os 0.3.671 → 0.3.672

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.
@@ -1,5 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
2
3
  import { setProjectWorkspace } from "@/lib/workspaces";
4
+ import { PROJECT_TIERS, setProjectTier } from "@/lib/project-tier";
3
5
  import {
4
6
  getProjectWithDevServers,
5
7
  updateProject,
@@ -7,6 +9,8 @@ import {
7
9
  toggleProjectExpanded,
8
10
  } from "@/lib/projects";
9
11
 
12
+ const tierSchema = z.enum(PROJECT_TIERS);
13
+
10
14
  interface RouteParams {
11
15
  params: Promise<{ id: string }>;
12
16
  }
@@ -46,6 +50,17 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
46
50
  workspaceId,
47
51
  } = body;
48
52
 
53
+ let tier: z.infer<typeof tierSchema> | undefined;
54
+ if (body.tier !== undefined) {
55
+ const parsed = tierSchema.safeParse(body.tier);
56
+ if (!parsed.success)
57
+ return NextResponse.json(
58
+ { error: "tier must be production or fast" },
59
+ { status: 400 }
60
+ );
61
+ tier = parsed.data;
62
+ }
63
+
49
64
  // Handle expanded toggle separately
50
65
  if (typeof expanded === "boolean") {
51
66
  toggleProjectExpanded(id, expanded);
@@ -56,6 +71,17 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
56
71
  setProjectWorkspace(id, workspaceId);
57
72
  }
58
73
 
74
+ if (tier) {
75
+ try {
76
+ setProjectTier(id, tier);
77
+ } catch {
78
+ return NextResponse.json(
79
+ { error: "Project not found or cannot be modified" },
80
+ { status: 404 }
81
+ );
82
+ }
83
+ }
84
+
59
85
  // Update other fields if provided
60
86
  if (
61
87
  name ||
@@ -12,6 +12,8 @@ import {
12
12
  import { Button } from "@/components/ui/button";
13
13
  import { Input } from "@/components/ui/input";
14
14
  import { Textarea } from "@/components/ui/textarea";
15
+ import { ProjectTierField } from "./ProjectTierField";
16
+ import type { ProjectTier } from "@/lib/project-tier/types";
15
17
  import {
16
18
  Select,
17
19
  SelectContent,
@@ -95,6 +97,7 @@ export function ProjectSettingsDialog({
95
97
  getDefaultModelForAgent("claude")
96
98
  );
97
99
  const [initialPrompt, setInitialPrompt] = useState("");
100
+ const [tier, setTier] = useState<ProjectTier>("fast");
98
101
  const [devServers, setDevServers] = useState<DevServerConfig[]>([]);
99
102
  const [repositories, setRepositories] = useState<RepositoryConfig[]>([]);
100
103
  const [isLoading, setIsLoading] = useState(false);
@@ -118,6 +121,7 @@ export function ProjectSettingsDialog({
118
121
  setName(project.name);
119
122
  setWorkingDirectory(project.working_directory);
120
123
  setAgentType(project.agent_type);
124
+ setTier(project.tier);
121
125
  setDefaultModel(
122
126
  isSupportedModelForAgent(project.agent_type, project.default_model)
123
127
  ? project.default_model
@@ -336,6 +340,7 @@ export function ProjectSettingsDialog({
336
340
  agentType,
337
341
  defaultModel,
338
342
  initialPrompt: initialPrompt.trim() || null,
343
+ tier,
339
344
  });
340
345
 
341
346
  // Handle dev server changes
@@ -526,6 +531,8 @@ export function ProjectSettingsDialog({
526
531
  </Select>
527
532
  </div>
528
533
 
534
+ <ProjectTierField value={tier} onChange={setTier} />
535
+
529
536
  {/* Initial Prompt */}
530
537
  <div className="space-y-2">
531
538
  <label className="text-sm font-medium">Initial Prompt</label>
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "@/components/ui/select";
10
+ import type { ProjectTier } from "@/lib/project-tier/types";
11
+
12
+ const DETAIL: Record<ProjectTier, string> = {
13
+ production: "CI, the independent review and scope must pass before a merge.",
14
+ fast: "A task's PR merges as soon as it opens: no CI wait, no review.",
15
+ };
16
+
17
+ export function ProjectTierField({
18
+ value,
19
+ onChange,
20
+ }: {
21
+ value: ProjectTier;
22
+ onChange: (tier: ProjectTier) => void;
23
+ }) {
24
+ return (
25
+ <div className="space-y-2">
26
+ <label className="text-sm font-medium">Tier</label>
27
+ <Select value={value} onValueChange={(v) => onChange(v as ProjectTier)}>
28
+ <SelectTrigger>
29
+ <SelectValue />
30
+ </SelectTrigger>
31
+ <SelectContent>
32
+ <SelectItem value="fast">Fast</SelectItem>
33
+ <SelectItem value="production">Production</SelectItem>
34
+ </SelectContent>
35
+ </Select>
36
+ <p className="text-muted-foreground text-xs">{DETAIL[value]}</p>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import { SettingToggle } from "@/components/Settings/SettingToggle";
4
+ import { useProjectsQuery, useUpdateProject } from "@/data/projects";
5
+
6
+ // A workspace's projects, each switched between production (every merge gate)
7
+ // and fast (a task's PR merges as soon as it opens).
8
+ export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
9
+ const { data: projects } = useProjectsQuery();
10
+ const update = useUpdateProject();
11
+ const mine = (projects ?? []).filter(
12
+ (p) => p.workspace_id === workspaceId && !p.is_uncategorized
13
+ );
14
+ if (!mine.length) return null;
15
+ return (
16
+ <div className="space-y-2">
17
+ {mine.map((p) => (
18
+ <SettingToggle
19
+ key={p.id}
20
+ title={`${p.name}: production`}
21
+ detail="On: CI, the independent review and scope gate its merges. Off (fast): a task's PR merges as soon as it opens."
22
+ checked={p.tier === "production"}
23
+ onChange={(on) =>
24
+ update.mutate({
25
+ projectId: p.id,
26
+ tier: on ? "production" : "fast",
27
+ })
28
+ }
29
+ />
30
+ ))}
31
+ </div>
32
+ );
33
+ }
@@ -7,6 +7,7 @@ import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
8
  import { McpServersForm } from "./McpServersForm";
9
9
  import { HqFolderForm } from "./HqFolderForm";
10
+ import { ProjectTiersForm } from "./ProjectTiersForm";
10
11
  import { StallForm } from "./StallForm";
11
12
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
12
13
  import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
@@ -49,6 +50,7 @@ export function WorkspaceSettings() {
49
50
  <WorkspaceModelsForm workspaceId={w.id} />
50
51
  <McpServersForm workspace={w} />
51
52
  <HqFolderForm workspaceId={w.id} />
53
+ <ProjectTiersForm workspaceId={w.id} />
52
54
  <SettingToggle
53
55
  title="Wake tasks on CI failures and reviews"
54
56
  detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
@@ -3,6 +3,7 @@ import type {
3
3
  ProjectWithRepositories,
4
4
  DetectedDevServer,
5
5
  } from "@/lib/projects";
6
+ import type { ProjectTier } from "@/lib/db/types";
6
7
  import { projectKeys } from "./keys";
7
8
  import { sessionKeys } from "../sessions/keys";
8
9
 
@@ -131,6 +132,7 @@ export function useUpdateProject() {
131
132
  agentType,
132
133
  defaultModel,
133
134
  initialPrompt,
135
+ tier,
134
136
  }: {
135
137
  projectId: string;
136
138
  name?: string;
@@ -138,6 +140,7 @@ export function useUpdateProject() {
138
140
  agentType?: string;
139
141
  defaultModel?: string;
140
142
  initialPrompt?: string | null;
143
+ tier?: ProjectTier;
141
144
  }) => {
142
145
  const res = await fetch(`/api/projects/${projectId}`, {
143
146
  method: "PATCH",
@@ -148,6 +151,7 @@ export function useUpdateProject() {
148
151
  agentType,
149
152
  defaultModel,
150
153
  initialPrompt,
154
+ tier,
151
155
  }),
152
156
  });
153
157
  if (!res.ok) throw new Error("Failed to update project");
@@ -0,0 +1,38 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261018T2130_project_tier",
5
+ up: (db) => {
6
+ // A project's tier: "production" tasks pass every merge gate, "fast"
7
+ // ones merge as soon as their PR opens (orchestrator/fast-lane.ts). The
8
+ // LumifyHub and Workstak projects start production, in the same step as
9
+ // the column, so running this again never overwrites a tier Saad changed.
10
+ const cols = db.prepare(`PRAGMA table_info(projects)`).all() as {
11
+ name: string;
12
+ }[];
13
+ if (!cols.some((c) => c.name === "tier")) {
14
+ db.exec(
15
+ `ALTER TABLE projects ADD COLUMN tier TEXT NOT NULL DEFAULT 'fast'`
16
+ );
17
+ db.exec(
18
+ `UPDATE projects SET tier = 'production'
19
+ WHERE lower(name) LIKE 'lumifyhub%' OR lower(name) LIKE 'workstak%'
20
+ OR workspace_id IN (
21
+ SELECT id FROM workspaces WHERE lower(name) IN ('lumifyhub', 'workstak')
22
+ )`
23
+ );
24
+ }
25
+ // What the fast lane has done to a task: the PR it armed (so a disarm
26
+ // sticks) and the merge whose CI it still watches.
27
+ db.exec(`CREATE TABLE IF NOT EXISTS fast_lane_tasks (
28
+ session_id TEXT PRIMARY KEY,
29
+ workspace_id TEXT NOT NULL,
30
+ auto_armed_pr INTEGER,
31
+ merged_sha TEXT,
32
+ merged_pr INTEGER,
33
+ merged_at TEXT,
34
+ ci_checked_at TEXT,
35
+ ci_done_at TEXT
36
+ )`);
37
+ },
38
+ } satisfies Migration;
package/lib/db/types.ts CHANGED
@@ -175,6 +175,9 @@ export interface Group {
175
175
  created_at: string;
176
176
  }
177
177
 
178
+ // production: every merge gate applies. fast: a task's PR merges when it opens.
179
+ export type ProjectTier = "production" | "fast";
180
+
178
181
  export interface Project {
179
182
  id: string;
180
183
  name: string;
@@ -190,6 +193,7 @@ export interface Project {
190
193
  lh_board_id: string | null;
191
194
  lh_board_name: string | null;
192
195
  lh_board_page_id: string | null;
196
+ tier: ProjectTier;
193
197
  // What its tasks need from a machine (JSON array), and where that came from.
194
198
  machine_needs?: string | null;
195
199
  machine_needs_source?: string | null;
@@ -125,6 +125,10 @@ export function setup() {
125
125
  workingDirectory: repo,
126
126
  });
127
127
  setProjectWorkspace(project.id, workspace.id);
128
+ // These tests judge today's gates; fast-lane.test.ts flips it.
129
+ db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
130
+ project.id
131
+ );
128
132
  ensureOrchestrator(workspace.id);
129
133
  const task = seedSession({
130
134
  projectId: project.id,
@@ -26,6 +26,7 @@ import { getOrchestrator } from "./home";
26
26
  import { isPaused } from "./pause";
27
27
  import { review } from "./review";
28
28
  import { judge, mergeJudged } from "./signoff";
29
+ import { isFast } from "../project-tier/getProjectTier";
29
30
  import { workspaceTask } from "./targets";
30
31
  import { markStatusDirty } from "../status/dirty";
31
32
  import { setPr } from "../db/session-parts";
@@ -88,9 +89,10 @@ export const anyArmed = (workspaceId: string) =>
88
89
  armedTasks(workspaceId).length > 0;
89
90
 
90
91
  // The orchestrator's `arm` and `disarm`.
91
- export function arm(workspaceId: string, ref: string): string {
92
+ export async function arm(workspaceId: string, ref: string): Promise<string> {
92
93
  const task = workspaceTask(workspaceId, ref);
93
94
  armTask(task);
95
+ if (isFast(task)) return armedNow(workspaceId, task);
94
96
  return `Armed ${task.name}: AgentOS merges its PR the moment every sign_off gate passes at its head, and tells you with one event. A gate failure also comes as one event; it stays armed for the fix, unless the failure goes to Saad. No need to call sign_off.`;
95
97
  }
96
98
 
@@ -124,6 +126,17 @@ export function armTask(task: Session): void {
124
126
  setArmed(task.id, true);
125
127
  }
126
128
 
129
+ // A fast task merges the moment it's armed, if its PR is open.
130
+ async function armedNow(workspaceId: string, task: Session): Promise<string> {
131
+ const verdict = await judge(workspaceId, task, false, { count: false });
132
+ if (!verdict.ok)
133
+ return `Armed ${task.name} (fast project); not merged yet.\n${verdict.text}\nIt stays armed and merges once that is fixed.`;
134
+ await merge(workspaceId, task, verdict, defaults);
135
+ return stillArmed(workspaceId, task.id)
136
+ ? `Armed ${task.name} (fast project); the merge was refused, see the event. It stays armed.`
137
+ : `Merged ${task.name} (fast project): PR #${verdict.pr} at ${short(verdict.sha)}, no CI wait or review.`;
138
+ }
139
+
127
140
  export function disarm(workspaceId: string, ref: string): string {
128
141
  const task = workspaceTask(workspaceId, ref);
129
142
  setArmed(task.id, false);
@@ -28,6 +28,7 @@ export interface BriefProject {
28
28
  path: string;
29
29
  board: string | null;
30
30
  defaultBranch: string;
31
+ tier?: "production" | "fast";
31
32
  }
32
33
 
33
34
  // Read when the brief is built, so a switch flipped in Settings reaches the
@@ -121,7 +122,7 @@ Reading:
121
122
  Acting:
122
123
  - \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
123
124
  - \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
124
- - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
125
+ - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task in a production project (see "Triage every job"); a fast project skips the plan unless you pass plan true. Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
125
126
  - \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
126
127
  - \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
127
128
  - \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
@@ -145,6 +146,7 @@ Acting:
145
146
  - \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
146
147
  - \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
147
148
  - \`${TOOL_NAMES.resurrect}\` (session, reason): bring a dropped, done or archived session back with its conversation and worktree, in the state it had (a plan-only task waits for approval again). Use it when a drop or done was a mistake, or when Saad asks; refused for a merged PR, a missing conversation or a removed project. A restored task counts against the running limit and the brakes like a start.
149
+ - \`${TOOL_NAMES.project_tier}\` (project?, tier?): read the workspace's projects with their tiers, or one's; with tier (production or fast) set it. Only when Saad says to.
148
150
  - \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
149
151
  - \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
150
152
  - \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
@@ -166,11 +168,15 @@ Act through these tools, not the shell: they're scoped to this workspace and bra
166
168
 
167
169
  ## Untrusted text
168
170
 
171
+ ### Tiers
172
+
173
+ Every project has a tier (shown on its line above; \`project_tier\` reads or sets it, and you set it only when Saad says to). production: everything above applies. fast (the default, for Saad's internal tools and AgentOS itself): a task's PR merges as soon as it opens or is pushed, so rigorous testing and CI don't hold it. AgentOS arms a fast task by itself when its PR opens, and \`arm\` on one merges it right away. Only these still apply: base, stack order, no AI attribution in the PR body, Saad's hold (BLOCKED, an open ask, a draft), the rules on the diff (no secrets, nothing outside the repository, not only lockfiles), merge approvals for sensitive files, and the hard lines. There is no ci or review gate, no card scope check and no loop guard, \`review\` doesn't start a reviewer, and a fast task skips the plan step unless you pass plan true. If CI fails after a fast merge you get one event; fix it forward with a task.
174
+
169
175
  ${UNTRUSTED_RULE}`;
170
176
 
171
177
  function projectLine(p: BriefProject): string {
172
178
  const board = p.board ? `, LumifyHub board "${p.board}"` : "";
173
- return `- ${p.name}: \`${p.path}\` (default branch ${p.defaultBranch}${board})`;
179
+ return `- ${p.name}: \`${p.path}\` (default branch ${p.defaultBranch}${board}, tier ${p.tier ?? "production"})`;
174
180
  }
175
181
 
176
182
  export function orchestratorBrief(input: {
@@ -217,6 +223,7 @@ export async function loadOrchestratorBrief(session: Session): Promise<string> {
217
223
  defaultBranch: await getDefaultBranch(p.working_directory).catch(
218
224
  () => "main"
219
225
  ),
226
+ tier: p.tier,
220
227
  }))
221
228
  );
222
229
  return orchestratorBrief({
@@ -0,0 +1,69 @@
1
+ import { db } from "../db";
2
+
3
+ const now = () => new Date().toISOString().slice(0, 19).replace("T", " ");
4
+
5
+ export interface FastLaneRow {
6
+ session_id: string;
7
+ workspace_id: string;
8
+ auto_armed_pr: number | null;
9
+ merged_sha: string | null;
10
+ merged_pr: number | null;
11
+ merged_at: string | null;
12
+ ci_checked_at: string | null;
13
+ ci_done_at: string | null;
14
+ }
15
+
16
+ export function fastLaneRow(sessionId: string): FastLaneRow | undefined {
17
+ return db
18
+ .prepare(`SELECT * FROM fast_lane_tasks WHERE session_id = ?`)
19
+ .get(sessionId) as FastLaneRow | undefined;
20
+ }
21
+
22
+ export function noteAutoArmed(
23
+ sessionId: string,
24
+ workspaceId: string,
25
+ pr: number
26
+ ): void {
27
+ db.prepare(
28
+ `INSERT INTO fast_lane_tasks (session_id, workspace_id, auto_armed_pr)
29
+ VALUES (?, ?, ?)
30
+ ON CONFLICT(session_id) DO UPDATE SET auto_armed_pr = excluded.auto_armed_pr`
31
+ ).run(sessionId, workspaceId, pr);
32
+ }
33
+
34
+ // A fast merge: the PR whose CI is watched for a while afterwards.
35
+ export function recordFastMerge(
36
+ sessionId: string,
37
+ workspaceId: string,
38
+ pr: number,
39
+ sha: string
40
+ ): void {
41
+ db.prepare(
42
+ `INSERT INTO fast_lane_tasks (session_id, workspace_id, merged_sha, merged_pr, merged_at)
43
+ VALUES (?, ?, ?, ?, ?)
44
+ ON CONFLICT(session_id) DO UPDATE SET
45
+ merged_sha = excluded.merged_sha, merged_pr = excluded.merged_pr,
46
+ merged_at = excluded.merged_at, ci_checked_at = NULL, ci_done_at = NULL`
47
+ ).run(sessionId, workspaceId, sha, pr, now());
48
+ }
49
+
50
+ export function markCiChecked(sessionId: string, done: boolean): void {
51
+ db.prepare(
52
+ `UPDATE fast_lane_tasks SET ci_checked_at = ?, ci_done_at = CASE WHEN ? THEN ? ELSE ci_done_at END
53
+ WHERE session_id = ?`
54
+ ).run(now(), done ? 1 : 0, now(), sessionId);
55
+ }
56
+
57
+ // Merges still inside their CI watch.
58
+ export function watchedMerges(
59
+ workspaceId: string,
60
+ sinceHours: number
61
+ ): FastLaneRow[] {
62
+ return db
63
+ .prepare(
64
+ `SELECT * FROM fast_lane_tasks
65
+ WHERE workspace_id = ? AND merged_at IS NOT NULL AND ci_done_at IS NULL
66
+ AND merged_at > datetime('now', ?)`
67
+ )
68
+ .all(workspaceId, `-${sinceHours} hours`) as FastLaneRow[];
69
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The fast lane: a project whose tier is "fast" (project-tier) has no CI
3
+ * wait, independent review or scope check on a task's PR. Its gates are
4
+ * trimmed in gates.ts and signoff.ts; this arms each fast task once when its
5
+ * PR opens, so armed.ts merges it on the same pass, and watches the CI of
6
+ * what it merged for a while, telling the orchestrator once if that fails.
7
+ */
8
+
9
+ import { db, type Session } from "../db";
10
+ import { run } from "../tasks/gh";
11
+ import { checksVerdict, failingCheck, type RollupEntry } from "../tasks/state";
12
+ import { armTask } from "./armed";
13
+ import { queueEventOnce } from "./events";
14
+ import type { SessionFacts } from "./facts";
15
+ import { isPaused } from "./pause";
16
+ import { repoOf } from "./repo";
17
+ import { isFast } from "../project-tier/getProjectTier";
18
+ import {
19
+ fastLaneRow,
20
+ markCiChecked,
21
+ noteAutoArmed,
22
+ watchedMerges,
23
+ } from "./fast-lane-state";
24
+
25
+ const CI_WATCH_HOURS = 3;
26
+ const CI_RECHECK_MS = 2 * 60 * 1000;
27
+
28
+ export async function autoArmFast(
29
+ workspaceId: string,
30
+ facts: SessionFacts[]
31
+ ): Promise<void> {
32
+ if (isPaused(workspaceId)) return;
33
+ for (const f of facts) {
34
+ const pr = f.task?.pr;
35
+ if (!pr || pr.state !== "OPEN") continue;
36
+ const task = db
37
+ .prepare(
38
+ `SELECT * FROM session_rows WHERE id = ? AND task_status = 'running'
39
+ AND merge_armed_at IS NULL`
40
+ )
41
+ .get(f.id) as Session | undefined;
42
+ if (!task || !isFast(task)) continue;
43
+ if (fastLaneRow(task.id)?.auto_armed_pr === pr.number) continue;
44
+ try {
45
+ armTask(task);
46
+ noteAutoArmed(task.id, workspaceId, pr.number);
47
+ } catch {
48
+ // With Saad, or no orchestrator to tell: retried next pass.
49
+ }
50
+ }
51
+ }
52
+
53
+ async function ghRollup(task: Session, pr: number): Promise<RollupEntry[]> {
54
+ const out = await run(
55
+ "gh",
56
+ ["pr", "view", String(pr), "--json", "statusCheckRollup"],
57
+ repoOf(task),
58
+ 15000
59
+ );
60
+ return (JSON.parse(out).statusCheckRollup ?? []) as RollupEntry[];
61
+ }
62
+
63
+ export async function followFastMerges(
64
+ workspaceId: string,
65
+ rollupOf: typeof ghRollup = ghRollup
66
+ ): Promise<void> {
67
+ const now = Date.now();
68
+ for (const row of watchedMerges(workspaceId, CI_WATCH_HOURS)) {
69
+ const checked = row.ci_checked_at
70
+ ? Date.parse(row.ci_checked_at.replace(" ", "T") + "Z")
71
+ : 0;
72
+ if (now - checked < CI_RECHECK_MS) continue;
73
+ const task = db
74
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
75
+ .get(row.session_id) as Session | undefined;
76
+ if (!task || !row.merged_pr) {
77
+ markCiChecked(row.session_id, true);
78
+ continue;
79
+ }
80
+ try {
81
+ const rollup = await rollupOf(task, row.merged_pr);
82
+ const verdict = checksVerdict(rollup);
83
+ if (verdict === "fail") {
84
+ queueEventOnce(
85
+ workspaceId,
86
+ `fast-ci:${row.session_id}:${row.merged_sha}`,
87
+ row.session_id,
88
+ `task ${task.name}: merged on the fast lane (PR #${row.merged_pr}), but CI failed after the merge${failingCheck(rollup) ? ` (${failingCheck(rollup)})` : ""}. Fix forward with a task.`
89
+ );
90
+ }
91
+ markCiChecked(row.session_id, verdict !== "pending");
92
+ } catch {
93
+ markCiChecked(row.session_id, false);
94
+ }
95
+ }
96
+ }
@@ -165,7 +165,12 @@ export function checkGate(
165
165
  return { gate, state: "pass" };
166
166
  }
167
167
 
168
- export function evaluateGates(i: GateInput): GateOutcome[] {
168
+ // `fast`: the fast lane keeps Saad's hold, the rules on the diff, the stack
169
+ // and the PR body's attribution; no CI, review or card scope check.
170
+ export function evaluateGates(
171
+ i: GateInput,
172
+ opts: { fast?: boolean } = {}
173
+ ): GateOutcome[] {
169
174
  const blocked: GateOutcome = i.prBlocked
170
175
  ? { gate: "blocked", state: "fail", reason: i.prBlocked }
171
176
  : i.prDraft
@@ -185,7 +190,7 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
185
190
  : { gate: "blocked", state: "pass" };
186
191
  const scope: GateOutcome = i.ruleBreaks.length
187
192
  ? { gate: "scope", state: "fail", reason: i.ruleBreaks.join("; ") }
188
- : i.fromCard
193
+ : i.fromCard && !opts.fast
189
194
  ? checkGate("scope", i.scope, i.sha)
190
195
  : { gate: "scope", state: "pass" };
191
196
  const stack: GateOutcome = i.stackRefusal
@@ -197,6 +202,7 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
197
202
  const codeReview: GateOutcome = i.codeReview.refusal
198
203
  ? { gate: "code-review", state: "fail", reason: i.codeReview.refusal }
199
204
  : { gate: "code-review", state: "pass" };
205
+ if (opts.fast) return [codeReview, blocked, scope, stack];
200
206
  return [
201
207
  ciGate(i),
202
208
  checkGate("review", i.review, i.sha),
@@ -0,0 +1,33 @@
1
+ import {
2
+ PROJECT_TIERS,
3
+ setProjectTier,
4
+ type ProjectTier,
5
+ } from "../project-tier";
6
+ import { addNote } from "./notes";
7
+ import { workspaceProjects } from "./brief";
8
+ import { workspaceProject } from "./targets";
9
+
10
+ const line = (name: string, tier: ProjectTier) =>
11
+ `- ${name}: ${tier}${tier === "fast" ? " (merges when its PR opens)" : " (CI, review and scope gate its merges)"}`;
12
+
13
+ // project_tier: read one project's tier, or all of the workspace's; with a
14
+ // tier, set it (Saad's call: only when he says so).
15
+ export function projectTierTool(
16
+ workspaceId: string,
17
+ projectRef?: string,
18
+ tier?: (typeof PROJECT_TIERS)[number]
19
+ ): string {
20
+ if (!projectRef) {
21
+ if (tier) throw new Error("Say which project to set");
22
+ const all = workspaceProjects(workspaceId);
23
+ return all.length
24
+ ? all.map((p) => line(p.name, p.tier)).join("\n")
25
+ : "No projects in this workspace.";
26
+ }
27
+ const project = workspaceProject(workspaceId, projectRef);
28
+ if (!tier) return line(project.name, project.tier);
29
+ if (tier === project.tier) return line(project.name, project.tier);
30
+ setProjectTier(project.id, tier);
31
+ addNote(workspaceId, `Set ${project.name} to ${tier}.`);
32
+ return `Set ${project.name} to ${tier}.\n${line(project.name, tier)}`;
33
+ }
@@ -55,6 +55,7 @@ import {
55
55
  import { noteQuickReview } from "./quick";
56
56
  import { workspaceTask } from "./targets";
57
57
  import { untrusted } from "./untrusted";
58
+ import { isFast } from "../project-tier/getProjectTier";
58
59
 
59
60
  const short = (sha: string) => sha.slice(0, 7);
60
61
  // The most diff one reviewer is given, and the most parts a change too big
@@ -267,6 +268,8 @@ export async function reviewAt(
267
268
  sha: string,
268
269
  opts: ReviewOpts = {}
269
270
  ): Promise<string> {
271
+ if (target.task && isFast(target.task))
272
+ return `${target.task.name} is in a fast project: no independent review; it merges through the fast gates.`;
270
273
  const known = getCheck(target.id, sha, "review");
271
274
  // A card task's scope check runs after its review; one never stored (a
272
275
  // restart between the two) means running the job again.
@@ -26,6 +26,7 @@ import { sessionFacts } from "./facts";
26
26
  import { describeExternal, externalSessions } from "./external-sessions";
27
27
  import { resolveStaleAsks } from "./ask-settle";
28
28
  import { addNote } from "./notes";
29
+ import { projectTierTool } from "./project-tier-tool";
29
30
  import { addProject } from "./add-project";
30
31
  import { addRule, removeRule } from "./rules";
31
32
  import { askSaad } from "./ask-tool";
@@ -237,6 +238,10 @@ export async function runTool(
237
238
  const a = p(tool, raw);
238
239
  return addProject(w, a.path, a.name);
239
240
  }
241
+ case "project_tier": {
242
+ const a = p(tool, raw);
243
+ return projectTierTool(w, a.project, a.tier);
244
+ }
240
245
  case "note":
241
246
  addNote(w, p(tool, raw).text);
242
247
  return "Noted.";
@@ -277,7 +282,7 @@ export async function runTool(
277
282
  return signOffTarget(w, a.task, { direct: a.direct, reason: a.reason });
278
283
  }
279
284
  case "arm":
280
- return arm(w, p(tool, raw).task);
285
+ return await arm(w, p(tool, raw).task);
281
286
  case "disarm":
282
287
  return disarm(w, p(tool, raw).task);
283
288
  case "ask_saad":
@@ -231,6 +231,9 @@ export function setup(
231
231
  workingDirectory: repo,
232
232
  });
233
233
  setProjectWorkspace(project.id, workspace.id);
234
+ db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
235
+ project.id
236
+ );
234
237
  ensureOrchestrator(workspace.id);
235
238
  const task = seedSession({
236
239
  projectId: project.id,
@@ -41,6 +41,8 @@ import {
41
41
  reviewSignature,
42
42
  } from "./loop-guard";
43
43
  import { addNote } from "./notes";
44
+ import { isFast } from "../project-tier/getProjectTier";
45
+ import { recordFastMerge } from "./fast-lane-state";
44
46
  import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
45
47
  import { workSubject } from "./asks";
46
48
  import { carryApproval } from "./approval-carry";
@@ -71,7 +73,7 @@ export function restackedOf(task: Session) {
71
73
 
72
74
  // `approval`: Saad approved this merge on his asks list, for this commit.
73
75
  export type Verdict =
74
- | { ok: true; sha: string; pr: number; approval?: number }
76
+ | { ok: true; sha: string; pr: number; approval?: number; fast?: true }
75
77
  | { ok: false; wait: boolean; text: string };
76
78
 
77
79
  // Whether the task may merge now, and at which commit. Landing merges a
@@ -107,13 +109,15 @@ export async function judge(
107
109
  // A PR quick_pr opened (by the row AgentOS wrote, never the body): its
108
110
  // code review is the independent review of this head, and until that
109
111
  // passes it waits, before any gate is judged or counted.
110
- const quick = isQuick(task)
111
- ? quickCodeReview(
112
- sha,
113
- getCheck(task.id, sha, "review"),
114
- restackedOf(task)?.to === sha
115
- )
116
- : null;
112
+ const fast = isFast(task);
113
+ const quick =
114
+ isQuick(task) && !fast
115
+ ? quickCodeReview(
116
+ sha,
117
+ getCheck(task.id, sha, "review"),
118
+ restackedOf(task)?.to === sha
119
+ )
120
+ : null;
117
121
  // Saad's approval of this head stands in for that review (gateVerdict).
118
122
  if (
119
123
  quick?.wait &&
@@ -157,7 +161,7 @@ export async function judge(
157
161
  return quick ? { refusal: gate.refusal, wait: null } : gate;
158
162
  })(),
159
163
  }),
160
- opts
164
+ { ...opts, fast }
161
165
  );
162
166
  }
163
167
 
@@ -178,8 +182,9 @@ export async function gateVerdict(
178
182
  workspaceId: string,
179
183
  work: GateWork,
180
184
  inputs: () => Promise<Omit<GateInput, "sha" | "ruleBreaks">>,
181
- opts: { count?: boolean } = {}
185
+ opts: { count?: boolean; fast?: boolean } = {}
182
186
  ): Promise<Verdict> {
187
+ const fast = !!opts.fast;
183
188
  const no = (text: string, wait = false): Verdict => ({
184
189
  ok: false,
185
190
  wait,
@@ -216,7 +221,9 @@ export async function gateVerdict(
216
221
  : null;
217
222
  const refusal = i.codeReview.refusal ?? stack;
218
223
  if (refusal) return no(`${approved} ${refusal}`);
219
- const ci = ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
224
+ const ci = fast
225
+ ? { state: "pass" as const }
226
+ : ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
220
227
  if (ci.state === "fail") return no(`${approved} ${ci.reason}`);
221
228
  if (ci.state === "wait") return no(`${approved} ${ci.reason}`, true);
222
229
  // "escalate" is a repository with no CI: Saad is who that goes to.
@@ -245,7 +252,7 @@ export async function gateVerdict(
245
252
  }
246
253
  // Here too, not only in the review: one reviewed in parts while approvals
247
254
  // were off goes to Saad once they're on.
248
- if (mergeApprovalsOn()) {
255
+ if (mergeApprovalsOn() && !fast) {
249
256
  const size = (await fullDiff(repo, base, sha)).length;
250
257
  if (size > DIFF_CAP) {
251
258
  const why = `PR #${pr.number}'s diff at ${short(sha)} is ${size} characters, too big to review whole`;
@@ -255,8 +262,11 @@ export async function gateVerdict(
255
262
 
256
263
  const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
257
264
  const live = await inputs();
258
- const repoCI = await repoCIFor(repo, base, live);
259
- const outcomes = evaluateGates({ sha, ruleBreaks: ruled, ...live, repoCI });
265
+ const repoCI = fast ? undefined : await repoCIFor(repo, base, live);
266
+ const outcomes = evaluateGates(
267
+ { sha, ruleBreaks: ruled, ...live, repoCI },
268
+ { fast }
269
+ );
260
270
  const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
261
271
  const toSaad = outcomes.find((o) => o.state === "escalate");
262
272
  if (toSaad)
@@ -266,9 +276,12 @@ export async function gateVerdict(
266
276
  const failed = outcomes.filter((o) => o.state === "fail");
267
277
  const waiting = outcomes.filter((o) => o.state === "wait");
268
278
  if (!failed.length && !waiting.length)
269
- return { ok: true, sha, pr: pr.number };
279
+ return fast
280
+ ? { ok: true, sha, pr: pr.number, fast: true }
281
+ : { ok: true, sha, pr: pr.number };
270
282
  const lines: string[] = [];
271
- if (opts.count === false)
283
+ // The fast lane has no loop guard: a failure is told, never counted.
284
+ if (opts.count === false || fast)
272
285
  lines.push(...failed.map((o) => `- ${o.gate} failed: ${o.reason}`));
273
286
  else {
274
287
  for (const o of outcomes)
@@ -346,11 +359,15 @@ export async function mergeJudged(
346
359
  await refundIfRefused(task.id, verdict.approval, async () => {
347
360
  method = await signOffTask(task.id, { head: verdict.sha, wait: opts.wait });
348
361
  });
362
+ if (verdict.fast)
363
+ recordFastMerge(task.id, workspaceId, verdict.pr, verdict.sha);
349
364
  addNote(
350
365
  workspaceId,
351
- verdict.approval
352
- ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
353
- : `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
366
+ verdict.fast
367
+ ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on the fast lane: no CI wait or review.`
368
+ : verdict.approval
369
+ ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
370
+ : `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
354
371
  );
355
372
  return `Merged ${task.name}: PR #${verdict.pr} ${method ? MERGED_AS[method] : "merged"} at ${short(verdict.sha)}.`;
356
373
  }
@@ -40,6 +40,7 @@ export const TOOL_NAMES = {
40
40
  archive: name("archive"),
41
41
  resurrect: name("resurrect"),
42
42
  add_project: name("add_project"),
43
+ project_tier: name("project_tier"),
43
44
  note: name("note"),
44
45
  question: name("question"),
45
46
  resolve_question: name("resolve_question"),
@@ -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_TIERS } from "../project-tier/types";
8
9
  import { PROJECT_NAME } from "../project-add/name";
9
10
  import { TASK_ROLES, type ModelRole } from "../models/roles";
10
11
  import { PRIORITIES } from "../tasks/schedule-types";
@@ -160,8 +161,9 @@ export const TOOL_SHAPES = {
160
161
  model: modelOverride,
161
162
  plan: z
162
163
  .boolean()
164
+ .optional()
163
165
  .describe(
164
- "Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
166
+ "Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself. Default: false in a fast project, the workspace's setting in a production one"
165
167
  ),
166
168
  plan_only: z
167
169
  .boolean()
@@ -174,6 +176,7 @@ export const TOOL_SHAPES = {
174
176
  .trim()
175
177
  .min(3)
176
178
  .max(200)
179
+ .optional()
177
180
  .describe(
178
181
  'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
179
182
  ),
@@ -338,6 +341,10 @@ export const TOOL_SHAPES = {
338
341
  .optional()
339
342
  .describe("A new project's name (default: the folder's)"),
340
343
  },
344
+ project_tier: {
345
+ project: z.string().trim().min(1).max(200).optional(),
346
+ tier: z.enum(PROJECT_TIERS).optional(),
347
+ },
341
348
  note: { text: z.string().trim().min(1).max(2000) },
342
349
  question: {
343
350
  text: z
@@ -165,6 +165,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
165
165
  "Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
166
166
  add_project:
167
167
  "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.",
168
+ project_tier:
169
+ "Read or set a project's tier. production: every merge gate applies (CI, independent review, scope). fast: a task's PR merges as soon as it opens, with no CI wait or review. Without a project, lists this workspace's; with a project, reads it; with a tier too, sets it, only when Saad says to.",
168
170
  note: "Add a line to this workspace's decision log; it shows in your chat.",
169
171
  rule_add:
170
172
  '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.',
@@ -5,6 +5,7 @@
5
5
  * every 15s while anything in the workspace is working, every 60s when not.
6
6
  */
7
7
 
8
+ import { autoArmFast, followFastMerges } from "./fast-lane";
8
9
  import { chatState, chatStateNow, sendChat } from "../chat/runner";
9
10
  import type { ChatState } from "../chat/events";
10
11
  import { getWorkspace } from "../workspaces";
@@ -96,7 +97,11 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
96
97
  console.error(`Judging asked commits in ${workspaceId}:`, error)
97
98
  );
98
99
  await rebaseConflicting(workspaceId, facts);
100
+ await autoArmFast(workspaceId, facts);
99
101
  await mergeArmed(workspaceId, facts);
102
+ await followFastMerges(workspaceId).catch((error) =>
103
+ console.error(`Following fast merges in ${workspaceId}:`, error)
104
+ );
100
105
  })()
101
106
  .catch((error) =>
102
107
  console.error(`Merge pipeline for ${workspaceId}:`, error)
@@ -0,0 +1,18 @@
1
+ import { db } from "@/lib/db";
2
+ import type { ProjectTier } from "./types";
3
+
4
+ // An unknown project counts as production: a missing row never opens the
5
+ // fast lane.
6
+ export function projectTier(projectId: string | null): ProjectTier {
7
+ if (!projectId) return "production";
8
+ const row = db
9
+ .prepare(`SELECT tier FROM projects WHERE id = ?`)
10
+ .get(projectId) as { tier: string } | undefined;
11
+ return row?.tier === "fast" ? "fast" : "production";
12
+ }
13
+
14
+ export const taskTier = (task: { project_id: string | null }): ProjectTier =>
15
+ projectTier(task.project_id);
16
+
17
+ export const isFast = (task: { project_id: string | null }): boolean =>
18
+ taskTier(task) === "fast";
@@ -0,0 +1,3 @@
1
+ export { PROJECT_TIERS, type ProjectTier } from "./types";
2
+ export { projectTier, taskTier, isFast } from "./getProjectTier";
3
+ export { setProjectTier } from "./setProjectTier";
@@ -0,0 +1,14 @@
1
+ import { getProject } from "@/lib/projects";
2
+ import { db } from "@/lib/db";
3
+ import type { Project } from "@/lib/db/types";
4
+ import type { ProjectTier } from "./types";
5
+
6
+ export function setProjectTier(projectId: string, tier: ProjectTier): Project {
7
+ const project = getProject(projectId);
8
+ if (!project || project.is_uncategorized)
9
+ throw new Error("Project not found or cannot be modified");
10
+ db.prepare(
11
+ `UPDATE projects SET tier = ?, updated_at = datetime('now') WHERE id = ?`
12
+ ).run(tier, projectId);
13
+ return getProject(projectId)!;
14
+ }
@@ -0,0 +1,3 @@
1
+ export type { ProjectTier } from "@/lib/db/types";
2
+
3
+ export const PROJECT_TIERS = ["production", "fast"] as const;
@@ -40,7 +40,14 @@ export async function startOptions(
40
40
  const opts = { workspaceId: project?.workspace_id ?? null };
41
41
  roles.plan = resolveRole("plan", opts, models).ref;
42
42
  roles.implement = resolveRole("implement", opts, models).ref;
43
- planDefault = taskRouting(opts.workspaceId, "chat", undefined).plan;
43
+ planDefault = taskRouting(
44
+ opts.workspaceId,
45
+ "chat",
46
+ undefined,
47
+ undefined,
48
+ undefined,
49
+ project?.tier
50
+ ).plan;
44
51
  } catch (error) {
45
52
  planError = error instanceof Error ? error.message : String(error);
46
53
  }
@@ -40,6 +40,7 @@ import { sessionEnded } from "../experiments/hooks";
40
40
  import { snapshotWorktree } from "../resurrect/snapshot";
41
41
  import { keepSessionFiles } from "../session-artifacts/collect";
42
42
  import { setPr, setTask } from "../db/session-parts";
43
+ import { isFast } from "../project-tier/getProjectTier";
43
44
 
44
45
  export const sessionMergePolicy = (session: Session): MergePolicy =>
45
46
  mergePolicy(session.project_id ? getProject(session.project_id) : null);
@@ -167,7 +168,7 @@ export async function signOffTask(
167
168
  throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
168
169
  const judgedBase = opts.base ?? session.base_branch;
169
170
  const pr = await prFor(session, true);
170
- const verdict = canSignOff(pr);
171
+ const verdict = canSignOff(pr, { ci: !isFast(session) });
171
172
  if (!verdict.ok) throw new Error(verdict.reason);
172
173
  if (pr!.base && pr!.base !== judgedBase)
173
174
  throw new Error(
@@ -15,7 +15,7 @@ import { effectiveModels } from "../models/config";
15
15
  import { ANTHROPIC } from "../models/roles";
16
16
  import { processTable, runsSomething } from "../process-table";
17
17
  import { randomUUID } from "crypto";
18
- import { db, queries, type Session } from "../db";
18
+ import { db, queries, type ProjectTier, type Session } from "../db";
19
19
  import { getProject } from "../projects";
20
20
  import {
21
21
  createWorktree,
@@ -267,7 +267,8 @@ async function startTask(
267
267
  view,
268
268
  opts.model,
269
269
  opts.plan,
270
- opts.planOnly
270
+ opts.planOnly,
271
+ project.tier
271
272
  ).plan;
272
273
  const session = await startRemoteTask(hostId, project, {
273
274
  id,
@@ -310,7 +311,8 @@ async function startTask(
310
311
  view,
311
312
  opts.model,
312
313
  opts.plan,
313
- opts.planOnly
314
+ opts.planOnly,
315
+ project.tier
314
316
  )
315
317
  : otherAgentRouting(opts.model);
316
318
  const projectPath = expandHome(project.working_directory);
@@ -590,7 +592,9 @@ export function taskRouting(
590
592
  view: StartView,
591
593
  model: unknown,
592
594
  plan?: boolean,
593
- planOnly?: boolean
595
+ planOnly?: boolean,
596
+ // A fast project skips the plan step unless the caller asks for it.
597
+ tier: ProjectTier = "production"
594
598
  ) {
595
599
  const override = parseOverride(model);
596
600
  const models = effectiveModels();
@@ -601,7 +605,8 @@ export function taskRouting(
601
605
  const planStep =
602
606
  view === "chat" &&
603
607
  (!!planOnly ||
604
- ((plan ?? models.planStep) && planning.ref !== implement.ref));
608
+ ((plan ?? (tier === "fast" ? false : models.planStep)) &&
609
+ planning.ref !== implement.ref));
605
610
  return {
606
611
  override,
607
612
  plan: planStep,
@@ -97,13 +97,19 @@ export function needsHuman(state: TaskState): boolean {
97
97
  ].includes(state);
98
98
  }
99
99
 
100
- export function canSignOff(pr: TaskPR | null): {
100
+ // `ci: false` (a fast project): checks that are failing or running don't
101
+ // refuse the merge.
102
+ export function canSignOff(
103
+ pr: TaskPR | null,
104
+ opts: { ci?: boolean } = {}
105
+ ): {
101
106
  ok: boolean;
102
107
  reason?: string;
103
108
  } {
104
109
  if (!pr) return { ok: false, reason: "No pull request yet" };
105
110
  if (pr.state !== "OPEN")
106
111
  return { ok: false, reason: `PR is ${pr.state.toLowerCase()}` };
112
+ if (opts.ci === false) return { ok: true };
107
113
  if (pr.checks === "fail")
108
114
  return { ok: false, reason: "CI checks are failing" };
109
115
  if (pr.checks === "pending")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.671",
3
+ "version": "0.3.672",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"