@saadnvd1/agent-os 0.3.671 → 0.3.673

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 (75) hide show
  1. package/app/api/files/upload-temp/route.test.ts +144 -0
  2. package/app/api/files/upload-temp/route.ts +52 -21
  3. package/app/api/projects/[id]/route.ts +26 -0
  4. package/app/api/sessions/[id]/route.ts +2 -0
  5. package/app/api/sessions/route.ts +6 -2
  6. package/components/Chat/ChatPanel.tsx +9 -2
  7. package/components/Chat/FileChip.tsx +67 -0
  8. package/components/Chat/Messages.tsx +8 -0
  9. package/components/Chat/Queue.tsx +6 -0
  10. package/components/Chat/composer/Attachments.tsx +16 -2
  11. package/components/Chat/composer/ComposerBody.tsx +35 -14
  12. package/components/Chat/composer/Toolbar.tsx +11 -8
  13. package/components/Chat/composer/editor.test.ts +1 -1
  14. package/components/Chat/composer/paste.ts +5 -4
  15. package/components/Chat/composer/useAttachFiles.ts +142 -0
  16. package/components/Chat/composer/useComposerEditor.ts +3 -3
  17. package/components/Chat/useDraft.ts +9 -2
  18. package/components/Draft/DraftPanel.tsx +7 -4
  19. package/components/Projects/ProjectSettingsDialog.tsx +7 -0
  20. package/components/Projects/ProjectTierField.tsx +39 -0
  21. package/components/Terminal/index.tsx +6 -5
  22. package/components/Workspaces/ProjectTiersForm.tsx +33 -0
  23. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  24. package/data/chat/useChat.ts +10 -3
  25. package/data/client-config/queries.ts +18 -0
  26. package/data/projects/queries.ts +4 -0
  27. package/data/sessions/queries.ts +2 -1
  28. package/lib/attachments/check.ts +50 -0
  29. package/lib/attachments/message.ts +49 -0
  30. package/lib/attachments/policy.ts +116 -0
  31. package/lib/attachments/store.ts +92 -0
  32. package/lib/attachments/types.ts +8 -0
  33. package/lib/chat/client-send.ts +6 -2
  34. package/lib/chat/driver.ts +4 -1
  35. package/lib/chat/drivers/claude.ts +2 -0
  36. package/lib/chat/events.ts +13 -1
  37. package/lib/chat/pending.ts +2 -1
  38. package/lib/chat/queued.ts +24 -7
  39. package/lib/chat/runner.ts +11 -2
  40. package/lib/chat/worker/host.ts +7 -3
  41. package/lib/chat/worker/protocol.ts +2 -0
  42. package/lib/config/client-config-types.ts +14 -3
  43. package/lib/config/client-config.ts +1 -1
  44. package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
  45. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  46. package/lib/db/types.ts +4 -0
  47. package/lib/file-upload.ts +61 -27
  48. package/lib/intake/intake.ts +2 -17
  49. package/lib/intake/sniff.ts +1 -1
  50. package/lib/orchestrator/armed.test-helpers.ts +4 -0
  51. package/lib/orchestrator/armed.ts +14 -1
  52. package/lib/orchestrator/brief.ts +9 -2
  53. package/lib/orchestrator/fast-lane-state.ts +69 -0
  54. package/lib/orchestrator/fast-lane.ts +96 -0
  55. package/lib/orchestrator/gates.ts +8 -2
  56. package/lib/orchestrator/project-tier-tool.ts +33 -0
  57. package/lib/orchestrator/review.ts +3 -0
  58. package/lib/orchestrator/serve.ts +6 -1
  59. package/lib/orchestrator/signoff.test-helpers.ts +3 -0
  60. package/lib/orchestrator/signoff.ts +36 -19
  61. package/lib/orchestrator/tool-names.ts +1 -0
  62. package/lib/orchestrator/tool-schemas.ts +8 -1
  63. package/lib/orchestrator/tools.ts +2 -0
  64. package/lib/orchestrator/watcher.ts +5 -0
  65. package/lib/project-tier/getProjectTier.ts +18 -0
  66. package/lib/project-tier/index.ts +3 -0
  67. package/lib/project-tier/setProjectTier.ts +14 -0
  68. package/lib/project-tier/types.ts +3 -0
  69. package/lib/sessions/launch.ts +12 -6
  70. package/lib/start-options.ts +8 -1
  71. package/lib/tasks/finish.ts +2 -1
  72. package/lib/tasks/index.ts +10 -5
  73. package/lib/tasks/state.ts +7 -1
  74. package/package.json +1 -1
  75. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -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;
@@ -0,0 +1,14 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261019T1200_chat_queue_files",
5
+ up: (db) => {
6
+ // Files attached to a queued message, as a JSON list of references to
7
+ // the session's attachments folder (never the bytes).
8
+ const cols = db.prepare(`PRAGMA table_info(chat_queue)`).all() as {
9
+ name: string;
10
+ }[];
11
+ if (!cols.some((c) => c.name === "files"))
12
+ db.exec(`ALTER TABLE chat_queue ADD COLUMN files TEXT`);
13
+ },
14
+ } 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;
@@ -1,34 +1,68 @@
1
- /**
2
- * Upload a file to temporary storage and return its path.
3
- * Converts the file to base64 and POSTs to /api/files/upload-temp.
4
- *
5
- * @param file - The file to upload
6
- * @returns The path to the uploaded file, or null if upload failed
7
- */
8
- export async function uploadFileToTemp(file: File): Promise<string | null> {
9
- const buffer = await file.arrayBuffer();
10
- const base64 = btoa(
11
- new Uint8Array(buffer).reduce(
12
- (data, byte) => data + String.fromCharCode(byte),
13
- ""
14
- )
15
- );
1
+ import type { AttachmentKind } from "./attachments/policy";
2
+ import type { ChatFile } from "./attachments/types";
16
3
 
17
- const res = await fetch("/api/files/upload-temp", {
18
- method: "POST",
19
- headers: { "Content-Type": "application/json" },
20
- body: JSON.stringify({
21
- filename: file.name || `file-${Date.now()}`,
22
- base64,
23
- mimeType: file.type || "application/octet-stream",
24
- }),
4
+ export type UploadResult =
5
+ | { ok: true; file: ChatFile; kind: AttachmentKind }
6
+ | { ok: false; error: string };
7
+
8
+ function toBase64(file: File): Promise<string> {
9
+ return new Promise((resolve, reject) => {
10
+ const reader = new FileReader();
11
+ reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
12
+ reader.onerror = () => reject(reader.error);
13
+ reader.readAsDataURL(file);
25
14
  });
15
+ }
26
16
 
27
- const data = await res.json();
28
- if (data.path) {
29
- return data.path;
17
+ /**
18
+ * Upload a file through /api/files/upload-temp. With a `sessionId` (or
19
+ * "draft") the server checks it against its attachment limits and keeps it in
20
+ * that session's attachments folder; without one it goes to temp storage.
21
+ * The error is the server's own message, ready to show.
22
+ */
23
+ export async function uploadFile(
24
+ file: File,
25
+ sessionId?: string
26
+ ): Promise<UploadResult> {
27
+ try {
28
+ const res = await fetch("/api/files/upload-temp", {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify({
32
+ filename: file.name || `file-${Date.now()}`,
33
+ base64: await toBase64(file),
34
+ mimeType: file.type || "application/octet-stream",
35
+ sessionId,
36
+ }),
37
+ });
38
+ const data = await res.json().catch(() => ({}));
39
+ if (res.ok && data.path)
40
+ return {
41
+ ok: true,
42
+ kind: data.kind,
43
+ file: {
44
+ path: data.path,
45
+ name: data.name,
46
+ size: data.size,
47
+ mediaType: data.mediaType,
48
+ },
49
+ };
50
+ return {
51
+ ok: false,
52
+ error: data.error ?? `Couldn't upload ${file.name} (${res.status})`,
53
+ };
54
+ } catch {
55
+ return { ok: false, error: `Couldn't upload ${file.name}` };
30
56
  }
57
+ }
31
58
 
32
- console.error("Upload failed:", data.error);
59
+ /** The saved path, or null when the upload failed (terminal and picker). */
60
+ export async function uploadFileToTemp(
61
+ file: File,
62
+ sessionId?: string
63
+ ): Promise<string | null> {
64
+ const r = await uploadFile(file, sessionId);
65
+ if (r.ok) return r.file.path;
66
+ console.error("Upload failed:", r.error);
33
67
  return null;
34
68
  }
@@ -8,6 +8,7 @@ import { getWorkspace } from "../workspaces";
8
8
  import { INTAKE_LIMITS, MAX_INLINE_IMAGE_BYTES } from "./limits";
9
9
  import { buildIntakeMessage, type IntakeMeta } from "./message";
10
10
  import { sniff } from "./sniff";
11
+ import { safeName } from "../attachments/store";
11
12
 
12
13
  export class IntakeError extends Error {
13
14
  constructor(
@@ -39,23 +40,7 @@ const day = (t: number) => new Date(t).toISOString().slice(0, 10);
39
40
  const workspaceRoot = (workspaceId: string) =>
40
41
  path.join(dataRoot(), "intake", segment(workspaceId));
41
42
 
42
- /** A phone's filename as one safe path segment. */
43
- export function safeName(name: string, taken: Set<string>, n: number): string {
44
- let base = path.basename(name.replace(/\\/g, "/"));
45
- base = base
46
- .replace(/[^a-zA-Z0-9.-]/g, "_")
47
- .replace(/^\.+/, "")
48
- .slice(0, 80);
49
- if (!base) base = `file-${n}`;
50
- let out = base;
51
- for (let i = 2; taken.has(out); i++) {
52
- const dot = base.lastIndexOf(".");
53
- out =
54
- dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
55
- }
56
- taken.add(out);
57
- return out;
58
- }
43
+ export { safeName };
59
44
 
60
45
  function checkMeta(meta: IntakeMeta) {
61
46
  if (!meta || typeof meta !== "object") throw new IntakeError(400, "Bad meta");
@@ -9,7 +9,7 @@ const BLOCKED_NAME =
9
9
  const starts = (b: Buffer, sig: number[], at = 0) =>
10
10
  sig.every((v, i) => b[at + i] === v);
11
11
 
12
- function isText(b: Buffer): boolean {
12
+ export function isText(b: Buffer): boolean {
13
13
  const head = b.subarray(0, 8192);
14
14
  if (head.includes(0)) return false;
15
15
  try {
@@ -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,