@saadnvd1/agent-os 0.3.851 → 0.3.859
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/leads/adopt/route.ts +35 -0
- package/app/api/leads/proposals/route.ts +52 -0
- package/app/api/leads/route.test.ts +95 -0
- package/app/api/leads/route.ts +60 -0
- package/app/api/sessions/[id]/fresh/route.ts +2 -1
- package/app/api/sessions/[id]/route.ts +9 -1
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
- package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
- package/components/Analyst/pair.ts +2 -1
- package/components/Chat/ChatPanel.tsx +1 -0
- package/components/Chat/Forks.tsx +24 -2
- package/components/Orchestrator/AskCard.tsx +6 -0
- package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
- package/components/Pane/index.tsx +5 -7
- package/components/Schedules/ScheduleForm.tsx +2 -1
- package/components/SessionList/RowSubtitle.tsx +2 -1
- package/components/SessionList/SessionRow.tsx +2 -1
- package/components/SessionList/useDoneAction.ts +8 -2
- package/components/SessionList/useSidebarData.ts +8 -3
- package/content/whats-new.json +24 -0
- package/data/done/index.ts +7 -1
- package/hooks/useNeedsYou.ts +10 -4
- package/hooks/useOrchestratorHeader.ts +2 -1
- package/lib/analyst/createLink.ts +3 -1
- package/lib/away/report.ts +2 -1
- package/lib/bus/format.ts +4 -1
- package/lib/bus/index.ts +9 -2
- package/lib/bus/read-scope.ts +4 -3
- package/lib/bus/resolve.ts +4 -1
- package/lib/bus/role-alias.ts +2 -1
- package/lib/bus/targets.ts +8 -3
- package/lib/bus/waits/tick.ts +2 -1
- package/lib/chat/compact-context.ts +2 -1
- package/lib/chat/mention-roots.ts +2 -1
- package/lib/chat/runner.ts +3 -2
- package/lib/chat/settings.ts +3 -2
- package/lib/chat/worker/extras.ts +4 -1
- package/lib/chat/worker/host.ts +6 -5
- package/lib/db/migrations/20261102T1944_leads.ts +142 -0
- package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +10 -2
- package/lib/done/archive.ts +3 -2
- package/lib/done/auto-archive.ts +2 -1
- package/lib/done/bulk.ts +4 -3
- package/lib/done/index.ts +3 -2
- package/lib/done/plan.ts +5 -2
- package/lib/experiments/registry.ts +11 -0
- package/lib/experiments/view.ts +3 -1
- package/lib/fork/brief.ts +2 -2
- package/lib/fork/extras.ts +4 -2
- package/lib/fork/guard.ts +4 -2
- package/lib/fork/scratch.ts +102 -12
- package/lib/fork/settle.ts +31 -7
- package/lib/fork/start.ts +5 -4
- package/lib/fork/tick.ts +19 -0
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +10 -0
- package/lib/fork/worktree.ts +7 -3
- package/lib/health/env/sendFixNotes.ts +3 -2
- package/lib/hosts/peer-actions.ts +2 -1
- package/lib/hosts/peer-sessions.ts +3 -1
- package/lib/leads/actor.ts +16 -0
- package/lib/leads/adopt.ts +150 -0
- package/lib/leads/area.ts +77 -0
- package/lib/leads/brief-common.ts +122 -0
- package/lib/leads/brief.ts +155 -0
- package/lib/leads/charters.ts +99 -0
- package/lib/leads/deliver.ts +24 -0
- package/lib/leads/digest.ts +166 -0
- package/lib/leads/direction.ts +202 -0
- package/lib/leads/home.ts +196 -0
- package/lib/leads/index.ts +8 -0
- package/lib/leads/memos.ts +70 -0
- package/lib/leads/messages.ts +112 -0
- package/lib/leads/profiles.ts +83 -0
- package/lib/leads/roles.ts +114 -0
- package/lib/leads/settings.ts +55 -0
- package/lib/leads/templates.ts +51 -0
- package/lib/leads/tools.ts +159 -0
- package/lib/leads/types.ts +88 -0
- package/lib/leads/usage.ts +46 -0
- package/lib/learning-cards/input.ts +2 -1
- package/lib/live/drive.ts +2 -1
- package/lib/models/launch.ts +2 -1
- package/lib/models/session-role.ts +2 -1
- package/lib/needs-you.ts +12 -6
- package/lib/notify/index.ts +2 -1
- package/lib/orchestrator/act.ts +3 -1
- package/lib/orchestrator/ask-tool.ts +10 -0
- package/lib/orchestrator/ask-view.ts +2 -0
- package/lib/orchestrator/asks.ts +48 -8
- package/lib/orchestrator/brief.ts +32 -67
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/facts.ts +2 -1
- package/lib/orchestrator/full-access.ts +2 -1
- package/lib/orchestrator/home.ts +8 -7
- package/lib/orchestrator/mcp-servers.ts +7 -6
- package/lib/orchestrator/notes.ts +15 -4
- package/lib/orchestrator/overview.ts +3 -1
- package/lib/orchestrator/recipe-tools.ts +2 -1
- package/lib/orchestrator/reminders.ts +2 -1
- package/lib/orchestrator/tools.ts +43 -13
- package/lib/orchestrator/worker.ts +45 -13
- package/lib/orchestrator/write-guard.ts +2 -2
- package/lib/projects.ts +3 -2
- package/lib/resurrect/find.ts +2 -1
- package/lib/resurrect/index.ts +3 -2
- package/lib/schedules/index.ts +3 -2
- package/lib/schedules/start.ts +2 -1
- package/lib/schedules/store.ts +3 -2
- package/lib/session-audit/sources.ts +4 -3
- package/lib/session-groups/workspace.ts +2 -1
- package/lib/sessions/launch.ts +2 -1
- package/lib/sidebar/machines.ts +2 -1
- package/lib/sidebar/row-actions.ts +2 -1
- package/lib/sidebar/row-badge.ts +3 -1
- package/lib/sidebar/row-menu.ts +6 -3
- package/lib/sidebar/shelves.ts +3 -3
- package/lib/status/collect.ts +4 -3
- package/lib/tasks/held-replies.ts +2 -1
- package/lib/tasks/move-targets.ts +2 -1
- package/lib/tasks/pause.ts +2 -1
- package/lib/tasks/starter.ts +3 -2
- package/lib/workspaces.ts +4 -3
- package/lib/worktree-sweep/findSettled.ts +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace's leads: durable chat sessions run like its orchestrator, each
|
|
3
|
+
* heading one department. The orchestrator is the Tech lead in place (same
|
|
4
|
+
* row, same data); the others are rows with the lead role, made by "Add leads"
|
|
5
|
+
* or adopted from an existing chat (./adopt).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { agentosHome } from "../agentos-home";
|
|
12
|
+
import { refuseRealStateUnderTest } from "../test-home-guard";
|
|
13
|
+
import { getWorkspace } from "../workspaces";
|
|
14
|
+
import { resolveRoleOrDefault } from "../models/resolve";
|
|
15
|
+
import {
|
|
16
|
+
ensureOrchestrator,
|
|
17
|
+
getOrchestrator,
|
|
18
|
+
orchestratorDir,
|
|
19
|
+
} from "../orchestrator/home";
|
|
20
|
+
import { isLeadKind, leadKindOf, sqlLead, sqlStanding } from "./roles";
|
|
21
|
+
import { TECH, type LeadKind } from "./types";
|
|
22
|
+
import { markLeadsEnabled } from "./settings";
|
|
23
|
+
import { getCharter, leadAreas, putCharter } from "./charters";
|
|
24
|
+
import { charterTemplate } from "./templates";
|
|
25
|
+
|
|
26
|
+
// Tech first, then the shipped kinds, then custom ones by name.
|
|
27
|
+
const ORDER = [TECH, "marketing", "product"];
|
|
28
|
+
const rank = (kind: string) => {
|
|
29
|
+
const i = ORDER.indexOf(kind);
|
|
30
|
+
return i < 0 ? ORDER.length : i;
|
|
31
|
+
};
|
|
32
|
+
export const byLeadOrder = (a: string, b: string) =>
|
|
33
|
+
rank(a) - rank(b) || a.localeCompare(b);
|
|
34
|
+
|
|
35
|
+
// Where a lead starts and may write freely: the Tech lead keeps the
|
|
36
|
+
// orchestrator's folder.
|
|
37
|
+
export const leadDir = (workspaceId: string, kind: LeadKind) =>
|
|
38
|
+
kind === TECH
|
|
39
|
+
? orchestratorDir(workspaceId)
|
|
40
|
+
: refuseRealStateUnderTest(agentosHome("leads", workspaceId, kind));
|
|
41
|
+
|
|
42
|
+
// A standing session's own folder, whatever its role.
|
|
43
|
+
export const standingDir = (
|
|
44
|
+
s: Pick<Session, "role" | "lead_kind" | "workspace_id">
|
|
45
|
+
) => leadDir(s.workspace_id ?? "", leadKindOf(s) ?? "chief");
|
|
46
|
+
|
|
47
|
+
export function getLead(workspaceId: string, kind: LeadKind): Session | null {
|
|
48
|
+
if (kind === TECH) return getOrchestrator(workspaceId);
|
|
49
|
+
return (
|
|
50
|
+
(db
|
|
51
|
+
.prepare(
|
|
52
|
+
`SELECT * FROM session_rows WHERE ${sqlLead()} AND workspace_id = ? AND lead_kind = ?`
|
|
53
|
+
)
|
|
54
|
+
.get(workspaceId, kind) as Session | undefined) ?? null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The workspace's heads: its orchestrator (Tech) and its other leads.
|
|
59
|
+
export function listLeads(workspaceId: string): Session[] {
|
|
60
|
+
const rows = db
|
|
61
|
+
.prepare(
|
|
62
|
+
`SELECT * FROM session_rows
|
|
63
|
+
WHERE workspace_id = ? AND ${sqlLead()}`
|
|
64
|
+
)
|
|
65
|
+
.all(workspaceId) as Session[];
|
|
66
|
+
return rows.sort((a, b) =>
|
|
67
|
+
byLeadOrder(leadKindOf(a) ?? "", leadKindOf(b) ?? "")
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function leadTitle(workspaceId: string, kind: LeadKind): string {
|
|
72
|
+
return getCharter(workspaceId, kind)?.title || charterTemplate(kind).title;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A lead starts from the generic charter for its kind; one already written
|
|
76
|
+
// (by the person, in their own database) is never overwritten.
|
|
77
|
+
export function seedCharter(workspaceId: string, kind: LeadKind): void {
|
|
78
|
+
if (getCharter(workspaceId, kind)) return;
|
|
79
|
+
const t = charterTemplate(kind);
|
|
80
|
+
putCharter({ workspaceId, leadKind: kind, ...t });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Marks the orchestrator as the Tech lead, once.
|
|
84
|
+
function claimTech(workspaceId: string): Session {
|
|
85
|
+
const tech = ensureOrchestrator(workspaceId);
|
|
86
|
+
db.prepare(
|
|
87
|
+
`UPDATE sessions SET lead_kind = 'tech', lead_since = COALESCE(lead_since, datetime('now'))
|
|
88
|
+
WHERE id = ?`
|
|
89
|
+
).run(tech.id);
|
|
90
|
+
seedCharter(workspaceId, TECH);
|
|
91
|
+
return getOrchestrator(workspaceId)!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createLead(workspaceId: string, kind: LeadKind): Session {
|
|
95
|
+
const existing = getLead(workspaceId, kind);
|
|
96
|
+
if (existing) return existing;
|
|
97
|
+
const workspace = getWorkspace(workspaceId)!;
|
|
98
|
+
seedCharter(workspaceId, kind);
|
|
99
|
+
const dir = leadDir(workspaceId, kind);
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const id = randomUUID();
|
|
102
|
+
// Two at once still make one: the unique (workspace, kind) index decides.
|
|
103
|
+
db.prepare(
|
|
104
|
+
`INSERT OR IGNORE INTO sessions (id, name, tmux_name, working_directory, model,
|
|
105
|
+
group_path, agent_type, auto_approve, project_id, host_id, view, chat_access,
|
|
106
|
+
role, lead_kind, lead_since, workspace_id, pinned)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, 'sessions', 'claude', 0, NULL, 'local', 'chat', 'ask',
|
|
108
|
+
'lead', ?, datetime('now'), ?, 1)`
|
|
109
|
+
).run(
|
|
110
|
+
id,
|
|
111
|
+
`${workspace.name} ${leadTitle(workspaceId, kind)}`,
|
|
112
|
+
`claude-${id}`,
|
|
113
|
+
dir,
|
|
114
|
+
resolveRoleOrDefault("orchestrator", { workspaceId }).model,
|
|
115
|
+
kind,
|
|
116
|
+
workspaceId
|
|
117
|
+
);
|
|
118
|
+
return getLead(workspaceId, kind)!;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function checkKinds(kinds: unknown): LeadKind[] {
|
|
122
|
+
if (!Array.isArray(kinds) || !kinds.every(isLeadKind))
|
|
123
|
+
throw new Error(
|
|
124
|
+
"Each lead is a short lowercase name: letters, digits and dashes"
|
|
125
|
+
);
|
|
126
|
+
return [...new Set(kinds)].filter((k) => k !== TECH);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// "Add leads": the workspace gets its Tech lead (its orchestrator, made if
|
|
130
|
+
// it has none) and the leads picked. Safe to run again; nothing made twice.
|
|
131
|
+
export function addLeads(workspaceId: string, kinds: LeadKind[]): Session[] {
|
|
132
|
+
if (!getWorkspace(workspaceId)) throw new Error("Unknown workspace");
|
|
133
|
+
const picked = checkKinds(kinds);
|
|
134
|
+
markLeadsEnabled(workspaceId);
|
|
135
|
+
claimTech(workspaceId);
|
|
136
|
+
for (const kind of picked) createLead(workspaceId, kind);
|
|
137
|
+
return listLeads(workspaceId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The secret a standing session's worker sends with each tool call, so no
|
|
141
|
+
// other local process can act as it. The orchestrator's is the one it
|
|
142
|
+
// always had.
|
|
143
|
+
export function standingToken(sessionId: string): string {
|
|
144
|
+
db.prepare(
|
|
145
|
+
`UPDATE sessions SET orch_token = COALESCE(orch_token, ?) WHERE id = ?`
|
|
146
|
+
).run(randomBytes(32).toString("hex"), sessionId);
|
|
147
|
+
const row = db
|
|
148
|
+
.prepare(`SELECT orch_token FROM sessions WHERE id = ?`)
|
|
149
|
+
.get(sessionId) as { orch_token: string | null } | undefined;
|
|
150
|
+
if (!row?.orch_token) throw new Error("No such session");
|
|
151
|
+
return row.orch_token;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Which of the workspace's standing sessions holds this token, if any.
|
|
155
|
+
export function standingByToken(
|
|
156
|
+
workspaceId: string,
|
|
157
|
+
token: string | null
|
|
158
|
+
): Session | null {
|
|
159
|
+
if (!token) return null;
|
|
160
|
+
const b = Buffer.from(token);
|
|
161
|
+
const rows = db
|
|
162
|
+
.prepare(
|
|
163
|
+
`SELECT * FROM session_rows
|
|
164
|
+
WHERE workspace_id = ? AND ${sqlStanding()} AND orch_token IS NOT NULL`
|
|
165
|
+
)
|
|
166
|
+
.all(workspaceId) as Session[];
|
|
167
|
+
let found: Session | null = null;
|
|
168
|
+
for (const s of rows) {
|
|
169
|
+
const a = Buffer.from(s.orch_token!);
|
|
170
|
+
if (a.length === b.length && timingSafeEqual(a, b)) found = s;
|
|
171
|
+
}
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface LeadView {
|
|
176
|
+
kind: LeadKind;
|
|
177
|
+
title: string;
|
|
178
|
+
sessionId: string;
|
|
179
|
+
name: string;
|
|
180
|
+
since: string | null;
|
|
181
|
+
areas: { kind: string; value: string }[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function leadViews(workspaceId: string): LeadView[] {
|
|
185
|
+
return listLeads(workspaceId).map((s) => {
|
|
186
|
+
const kind = leadKindOf(s) ?? TECH;
|
|
187
|
+
return {
|
|
188
|
+
kind,
|
|
189
|
+
title: leadTitle(workspaceId, kind),
|
|
190
|
+
sessionId: s.id,
|
|
191
|
+
name: s.name,
|
|
192
|
+
since: s.lead_since ?? null,
|
|
193
|
+
areas: leadAreas(workspaceId, kind),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import type { LeadKind, LeadMemo } from "./types";
|
|
3
|
+
|
|
4
|
+
// About 1.5k tokens: what a lead carries across a fresh start, with its
|
|
5
|
+
// pinned direction.
|
|
6
|
+
export const MEMO_CAP = 6000;
|
|
7
|
+
|
|
8
|
+
interface Row {
|
|
9
|
+
id: number;
|
|
10
|
+
workspace_id: string;
|
|
11
|
+
lead_kind: string;
|
|
12
|
+
text: string;
|
|
13
|
+
created_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const view = (r: Row): LeadMemo => ({
|
|
17
|
+
id: r.id,
|
|
18
|
+
workspaceId: r.workspace_id,
|
|
19
|
+
leadKind: r.lead_kind,
|
|
20
|
+
text: r.text,
|
|
21
|
+
createdAt: r.created_at,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Every version is kept; the newest is the memo.
|
|
25
|
+
export function writeMemo(
|
|
26
|
+
workspaceId: string,
|
|
27
|
+
leadKind: LeadKind,
|
|
28
|
+
text: string
|
|
29
|
+
): LeadMemo {
|
|
30
|
+
const body = text.trim().slice(0, MEMO_CAP);
|
|
31
|
+
if (!body) throw new Error("The memo is empty");
|
|
32
|
+
const { lastInsertRowid } = db
|
|
33
|
+
.prepare(
|
|
34
|
+
`INSERT INTO lead_memos (workspace_id, lead_kind, text) VALUES (?, ?, ?)`
|
|
35
|
+
)
|
|
36
|
+
.run(workspaceId, leadKind, body);
|
|
37
|
+
return view(
|
|
38
|
+
db
|
|
39
|
+
.prepare(`SELECT * FROM lead_memos WHERE id = ?`)
|
|
40
|
+
.get(Number(lastInsertRowid)) as Row
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function latestMemo(
|
|
45
|
+
workspaceId: string,
|
|
46
|
+
leadKind: LeadKind
|
|
47
|
+
): LeadMemo | null {
|
|
48
|
+
const r = db
|
|
49
|
+
.prepare(
|
|
50
|
+
`SELECT * FROM lead_memos WHERE workspace_id = ? AND lead_kind = ?
|
|
51
|
+
ORDER BY id DESC LIMIT 1`
|
|
52
|
+
)
|
|
53
|
+
.get(workspaceId, leadKind) as Row | undefined;
|
|
54
|
+
return r ? view(r) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function memoHistory(
|
|
58
|
+
workspaceId: string,
|
|
59
|
+
leadKind: LeadKind,
|
|
60
|
+
limit = 20
|
|
61
|
+
): LeadMemo[] {
|
|
62
|
+
return (
|
|
63
|
+
db
|
|
64
|
+
.prepare(
|
|
65
|
+
`SELECT * FROM lead_memos WHERE workspace_id = ? AND lead_kind = ?
|
|
66
|
+
ORDER BY id DESC LIMIT ?`
|
|
67
|
+
)
|
|
68
|
+
.all(workspaceId, leadKind, limit) as Row[]
|
|
69
|
+
).map(view);
|
|
70
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import type { LeadKind, LeadMessage } from "./types";
|
|
3
|
+
|
|
4
|
+
const TEXT_CAP = 4000;
|
|
5
|
+
|
|
6
|
+
interface Row {
|
|
7
|
+
id: number;
|
|
8
|
+
workspace_id: string;
|
|
9
|
+
from_kind: string;
|
|
10
|
+
to_kind: string;
|
|
11
|
+
thread: string;
|
|
12
|
+
text: string;
|
|
13
|
+
created_at: string;
|
|
14
|
+
read_at: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const view = (r: Row): LeadMessage => ({
|
|
18
|
+
id: r.id,
|
|
19
|
+
workspaceId: r.workspace_id,
|
|
20
|
+
fromKind: r.from_kind,
|
|
21
|
+
toKind: r.to_kind,
|
|
22
|
+
thread: r.thread,
|
|
23
|
+
text: r.text,
|
|
24
|
+
createdAt: r.created_at,
|
|
25
|
+
readAt: r.read_at,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Lead-to-lead and chief-to-lead mail. The chief writes as "chief".
|
|
29
|
+
export function addLeadMessage(m: {
|
|
30
|
+
workspaceId: string;
|
|
31
|
+
fromKind: LeadKind;
|
|
32
|
+
toKind: LeadKind;
|
|
33
|
+
thread: string;
|
|
34
|
+
text: string;
|
|
35
|
+
}): LeadMessage {
|
|
36
|
+
const text = m.text.trim().slice(0, TEXT_CAP);
|
|
37
|
+
const thread = m.thread.trim().slice(0, 120);
|
|
38
|
+
if (!text || !thread) throw new Error("A message needs a thread and text");
|
|
39
|
+
const { lastInsertRowid } = db
|
|
40
|
+
.prepare(
|
|
41
|
+
`INSERT INTO lead_messages (workspace_id, from_kind, to_kind, thread, text)
|
|
42
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
43
|
+
)
|
|
44
|
+
.run(m.workspaceId, m.fromKind, m.toKind, thread, text);
|
|
45
|
+
return view(
|
|
46
|
+
db
|
|
47
|
+
.prepare(`SELECT * FROM lead_messages WHERE id = ?`)
|
|
48
|
+
.get(Number(lastInsertRowid)) as Row
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function unreadFor(
|
|
53
|
+
workspaceId: string,
|
|
54
|
+
toKind: LeadKind
|
|
55
|
+
): LeadMessage[] {
|
|
56
|
+
return (
|
|
57
|
+
db
|
|
58
|
+
.prepare(
|
|
59
|
+
`SELECT * FROM lead_messages
|
|
60
|
+
WHERE workspace_id = ? AND to_kind = ? AND read_at IS NULL ORDER BY id`
|
|
61
|
+
)
|
|
62
|
+
.all(workspaceId, toKind) as Row[]
|
|
63
|
+
).map(view);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function markRead(
|
|
67
|
+
workspaceId: string,
|
|
68
|
+
toKind: LeadKind,
|
|
69
|
+
upToId: number
|
|
70
|
+
): number {
|
|
71
|
+
return db
|
|
72
|
+
.prepare(
|
|
73
|
+
`UPDATE lead_messages SET read_at = datetime('now')
|
|
74
|
+
WHERE workspace_id = ? AND to_kind = ? AND id <= ? AND read_at IS NULL`
|
|
75
|
+
)
|
|
76
|
+
.run(workspaceId, toKind, upToId).changes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function threadMessages(
|
|
80
|
+
workspaceId: string,
|
|
81
|
+
thread: string,
|
|
82
|
+
limit = 50
|
|
83
|
+
): LeadMessage[] {
|
|
84
|
+
return (
|
|
85
|
+
db
|
|
86
|
+
.prepare(
|
|
87
|
+
`SELECT * FROM (SELECT * FROM lead_messages WHERE workspace_id = ? AND thread = ?
|
|
88
|
+
ORDER BY id DESC LIMIT ?) ORDER BY id`
|
|
89
|
+
)
|
|
90
|
+
.all(workspaceId, thread, limit) as Row[]
|
|
91
|
+
).map(view);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Sent within the window from one lead to another on a thread, for the
|
|
95
|
+
// loop guard.
|
|
96
|
+
export function sentSince(
|
|
97
|
+
workspaceId: string,
|
|
98
|
+
fromKind: LeadKind,
|
|
99
|
+
toKind: LeadKind,
|
|
100
|
+
thread: string,
|
|
101
|
+
sinceSqlite: string
|
|
102
|
+
): number {
|
|
103
|
+
return (
|
|
104
|
+
db
|
|
105
|
+
.prepare(
|
|
106
|
+
`SELECT COUNT(*) AS n FROM lead_messages
|
|
107
|
+
WHERE workspace_id = ? AND from_kind = ? AND to_kind = ? AND thread = ?
|
|
108
|
+
AND created_at >= ?`
|
|
109
|
+
)
|
|
110
|
+
.get(workspaceId, fromKind, toKind, thread, sinceSqlite) as { n: number }
|
|
111
|
+
).n;
|
|
112
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// What each kind of lead may do: one profile per kind, applied where the
|
|
2
|
+
// orchestrator's tools are built (orchestrator/worker.ts) and enforced again
|
|
3
|
+
// by the server on every call (./tools.ts). Tool bodies stay where they are.
|
|
4
|
+
|
|
5
|
+
import type { Session } from "@/lib/db/types";
|
|
6
|
+
import { TOOLS, type ToolName } from "../orchestrator/tool-schemas";
|
|
7
|
+
import { isLeadRole, isTechRole } from "./roles";
|
|
8
|
+
import type { LeadKind } from "./types";
|
|
9
|
+
import { TECH } from "./types";
|
|
10
|
+
|
|
11
|
+
// A lead's own tools, on top of the orchestrator's (./tools.ts).
|
|
12
|
+
export const LEAD_TOOLS = ["update_memo", "propose_pin", "direction"] as const;
|
|
13
|
+
export type LeadToolName = (typeof LEAD_TOOLS)[number];
|
|
14
|
+
export const isLeadToolName = (t: unknown): t is LeadToolName =>
|
|
15
|
+
typeof t === "string" && (LEAD_TOOLS as readonly string[]).includes(t);
|
|
16
|
+
|
|
17
|
+
// Only the Tech lead has these. Landing code is one head's job: reviews,
|
|
18
|
+
// merges, arming, landing stacks and its own quick changes. So are the
|
|
19
|
+
// machine (disk clean-up, moving tasks), the workspace's projects and tiers,
|
|
20
|
+
// other workspaces, its standing rules, reminders and open questions, and
|
|
21
|
+
// starting past the running-task limit.
|
|
22
|
+
export const TECH_ONLY: ReadonlySet<ToolName> = new Set<ToolName>([
|
|
23
|
+
"review",
|
|
24
|
+
"sign_off",
|
|
25
|
+
"arm",
|
|
26
|
+
"disarm",
|
|
27
|
+
"land",
|
|
28
|
+
"done",
|
|
29
|
+
"quick_change",
|
|
30
|
+
"quick_pr",
|
|
31
|
+
"move",
|
|
32
|
+
"sweep_dry_run",
|
|
33
|
+
"sweep_arm",
|
|
34
|
+
"add_project",
|
|
35
|
+
"project_tier",
|
|
36
|
+
"orchestrators",
|
|
37
|
+
"message_orchestrator",
|
|
38
|
+
"rule_add",
|
|
39
|
+
"rule_remove",
|
|
40
|
+
"remind_me",
|
|
41
|
+
"reminders",
|
|
42
|
+
"cancel_reminder",
|
|
43
|
+
"question",
|
|
44
|
+
"resolve_question",
|
|
45
|
+
"start_now",
|
|
46
|
+
"recipe_save",
|
|
47
|
+
"recipe_update",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export interface Profile {
|
|
51
|
+
kind: LeadKind;
|
|
52
|
+
tools: readonly ToolName[];
|
|
53
|
+
leadTools: readonly LeadToolName[];
|
|
54
|
+
// Starts and stops only inside its area (./area.ts).
|
|
55
|
+
inArea: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A standing session's profile, or null for one that has none yet (the
|
|
59
|
+
// chief, piece 7). The orchestrator of a workspace without leads keeps
|
|
60
|
+
// exactly the tools it always had.
|
|
61
|
+
export function profileFor(
|
|
62
|
+
s: Pick<Session, "role" | "lead_kind">,
|
|
63
|
+
workspaceHasLeads: boolean
|
|
64
|
+
): Profile | null {
|
|
65
|
+
if (isTechRole(s.role))
|
|
66
|
+
return {
|
|
67
|
+
kind: TECH,
|
|
68
|
+
tools: TOOLS,
|
|
69
|
+
leadTools: workspaceHasLeads ? LEAD_TOOLS : [],
|
|
70
|
+
inArea: false,
|
|
71
|
+
};
|
|
72
|
+
if (!isLeadRole(s.role) || !s.lead_kind) return null;
|
|
73
|
+
return {
|
|
74
|
+
kind: s.lead_kind,
|
|
75
|
+
tools: TOOLS.filter((t) => !TECH_ONLY.has(t)),
|
|
76
|
+
leadTools: LEAD_TOOLS,
|
|
77
|
+
inArea: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const profileHas = (p: Profile, tool: string): boolean =>
|
|
82
|
+
(p.tools as readonly string[]).includes(tool) ||
|
|
83
|
+
(p.leadTools as readonly string[]).includes(tool);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { SessionRole } from "@/lib/db/types";
|
|
2
|
+
import { TECH, type LeadKind } from "./types";
|
|
3
|
+
|
|
4
|
+
// Client-safe: no database. Every check of `sessions.role` goes through one of
|
|
5
|
+
// the decisions below (role-sites.test.ts fails on a raw one), and each
|
|
6
|
+
// decision is a table over every role, so a new role can't be added without
|
|
7
|
+
// deciding it everywhere.
|
|
8
|
+
|
|
9
|
+
type RoleTable<T> = Readonly<Record<SessionRole | "none", T>>;
|
|
10
|
+
|
|
11
|
+
const decide =
|
|
12
|
+
(t: RoleTable<boolean>) =>
|
|
13
|
+
(role: string | null | undefined): boolean =>
|
|
14
|
+
(t as Record<string, boolean>)[role ?? "none"] ?? false;
|
|
15
|
+
|
|
16
|
+
// Every value `sessions.role` can hold. A standing session belongs to its
|
|
17
|
+
// workspace and lives until its workspace goes: it is never swept as idle,
|
|
18
|
+
// shown as done, archived, forked, moved or counted as a worker, and it
|
|
19
|
+
// starts fresh rather than compacting.
|
|
20
|
+
export const STANDING_ROLES = [
|
|
21
|
+
"orchestrator",
|
|
22
|
+
"lead",
|
|
23
|
+
"chief",
|
|
24
|
+
] as const satisfies readonly SessionRole[];
|
|
25
|
+
|
|
26
|
+
const STANDING: RoleTable<boolean> = {
|
|
27
|
+
none: false,
|
|
28
|
+
orchestrator: true,
|
|
29
|
+
lead: true,
|
|
30
|
+
chief: true,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The workspace's orchestrator, which is its Tech lead: the one head that
|
|
34
|
+
// lands code (sign-off, arming, merges), owns the workspace's own schedule
|
|
35
|
+
// kind and is what "orchestrator" names on the bus. Also the orchestrator's
|
|
36
|
+
// own machinery (its tools, brief and Needs-you bar) until a lead gets a
|
|
37
|
+
// profile of its own.
|
|
38
|
+
const TECH_ROLE: RoleTable<boolean> = {
|
|
39
|
+
none: false,
|
|
40
|
+
orchestrator: true,
|
|
41
|
+
lead: false,
|
|
42
|
+
chief: false,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// A department head: the orchestrator (Tech) or another lead.
|
|
46
|
+
const LEAD: RoleTable<boolean> = {
|
|
47
|
+
none: false,
|
|
48
|
+
orchestrator: true,
|
|
49
|
+
lead: true,
|
|
50
|
+
chief: false,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const ROLE_DECISIONS = {
|
|
54
|
+
standing: STANDING,
|
|
55
|
+
tech: TECH_ROLE,
|
|
56
|
+
lead: LEAD,
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export const isStandingRole = decide(STANDING) as (
|
|
60
|
+
role: string | null | undefined
|
|
61
|
+
) => role is SessionRole;
|
|
62
|
+
export const isTechRole = decide(TECH_ROLE);
|
|
63
|
+
export const isLeadRole = decide(LEAD);
|
|
64
|
+
|
|
65
|
+
const NOUN: RoleTable<string> = {
|
|
66
|
+
none: "session",
|
|
67
|
+
orchestrator: "orchestrator",
|
|
68
|
+
lead: "lead",
|
|
69
|
+
chief: "chief of staff",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// "orchestrator", "lead": for refusals that name what a session is.
|
|
73
|
+
export const roleNoun = (role: string | null | undefined): string =>
|
|
74
|
+
(NOUN as Record<string, string>)[role ?? "none"] ?? NOUN.none;
|
|
75
|
+
export const roleLabel = (role: string | null | undefined): string => {
|
|
76
|
+
const n = roleNoun(role);
|
|
77
|
+
return `${/^[aeiou]/.test(n) ? "an" : "a"} ${n}`;
|
|
78
|
+
};
|
|
79
|
+
export const RoleLabel = (role: string | null | undefined): string => {
|
|
80
|
+
const l = roleLabel(role);
|
|
81
|
+
return l[0].toUpperCase() + l.slice(1);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// The same decisions in SQL, for a column holding `sessions.role`. Every
|
|
85
|
+
// role value is standing, so "no role" is the plain worker.
|
|
86
|
+
const SQL_ROLES = STANDING_ROLES.map((r) => `'${r}'`).join(", ");
|
|
87
|
+
export const sqlStanding = (col = "role") => `${col} IN (${SQL_ROLES})`;
|
|
88
|
+
export const sqlNoRole = (col = "role") => `${col} IS NULL`;
|
|
89
|
+
export const sqlTech = (col = "role") => `${col} = 'orchestrator'`;
|
|
90
|
+
const SQL_LEADS = STANDING_ROLES.filter((r) => LEAD[r])
|
|
91
|
+
.map((r) => `'${r}'`)
|
|
92
|
+
.join(", ");
|
|
93
|
+
export const sqlLead = (col = "role") => `${col} IN (${SQL_LEADS})`;
|
|
94
|
+
|
|
95
|
+
// The department a standing session heads: the orchestrator is always Tech,
|
|
96
|
+
// even before its workspace adds leads; the chief heads none.
|
|
97
|
+
export function leadKindOf(s: {
|
|
98
|
+
role: string | null;
|
|
99
|
+
lead_kind?: string | null;
|
|
100
|
+
}): LeadKind | null {
|
|
101
|
+
if (s.role === "orchestrator") return TECH;
|
|
102
|
+
if (s.role === "lead") return s.lead_kind ?? null;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SLUG = /^[a-z][a-z0-9-]{0,31}$/;
|
|
107
|
+
|
|
108
|
+
export const isLeadKind = (v: unknown): v is LeadKind =>
|
|
109
|
+
typeof v === "string" && SLUG.test(v);
|
|
110
|
+
|
|
111
|
+
// A linked machine may run a newer AgentOS with roles this one doesn't know:
|
|
112
|
+
// any role it reports is standing there.
|
|
113
|
+
export const isPeerStanding = (role: unknown): boolean =>
|
|
114
|
+
typeof role === "string" ? role !== "" : role != null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import type { ChiefAutonomy, WorkspaceLeads } from "./types";
|
|
3
|
+
|
|
4
|
+
interface Row {
|
|
5
|
+
workspace_id: string;
|
|
6
|
+
enabled_at: string;
|
|
7
|
+
chief_autonomy: ChiefAutonomy;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const view = (r: Row): WorkspaceLeads => ({
|
|
11
|
+
workspaceId: r.workspace_id,
|
|
12
|
+
enabledAt: r.enabled_at,
|
|
13
|
+
chiefAutonomy: r.chief_autonomy,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Whether "Add leads" ran on this workspace. The experiment switch alone
|
|
17
|
+
// creates nothing; a workspace without this row is exactly as before.
|
|
18
|
+
export function workspaceLeads(workspaceId: string): WorkspaceLeads | null {
|
|
19
|
+
const row = db
|
|
20
|
+
.prepare(`SELECT * FROM workspace_leads WHERE workspace_id = ?`)
|
|
21
|
+
.get(workspaceId) as Row | undefined;
|
|
22
|
+
return row ? view(row) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const hasLeads = (workspaceId: string | null | undefined): boolean =>
|
|
26
|
+
!!workspaceId && !!workspaceLeads(workspaceId);
|
|
27
|
+
|
|
28
|
+
export function workspacesWithLeads(): WorkspaceLeads[] {
|
|
29
|
+
return (
|
|
30
|
+
db
|
|
31
|
+
.prepare(`SELECT * FROM workspace_leads ORDER BY enabled_at`)
|
|
32
|
+
.all() as Row[]
|
|
33
|
+
).map(view);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function markLeadsEnabled(workspaceId: string): WorkspaceLeads {
|
|
37
|
+
db.prepare(
|
|
38
|
+
`INSERT INTO workspace_leads (workspace_id) VALUES (?)
|
|
39
|
+
ON CONFLICT(workspace_id) DO NOTHING`
|
|
40
|
+
).run(workspaceId);
|
|
41
|
+
return workspaceLeads(workspaceId)!;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setChiefAutonomy(
|
|
45
|
+
workspaceId: string,
|
|
46
|
+
autonomy: ChiefAutonomy
|
|
47
|
+
): boolean {
|
|
48
|
+
return (
|
|
49
|
+
db
|
|
50
|
+
.prepare(
|
|
51
|
+
`UPDATE workspace_leads SET chief_autonomy = ? WHERE workspace_id = ?`
|
|
52
|
+
)
|
|
53
|
+
.run(autonomy, workspaceId).changes > 0
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { TECH, type LeadKind } from "./types";
|
|
2
|
+
|
|
3
|
+
// The generic charters a new lead starts from. Real charters (a person's
|
|
4
|
+
// own voice, market, house rules) are written into their own database
|
|
5
|
+
// from Settings, never shipped here.
|
|
6
|
+
export interface CharterTemplate {
|
|
7
|
+
title: string;
|
|
8
|
+
areaText: string;
|
|
9
|
+
charterMd: string;
|
|
10
|
+
goal: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TEMPLATES: Record<string, CharterTemplate> = {
|
|
14
|
+
[TECH]: {
|
|
15
|
+
title: "Tech lead",
|
|
16
|
+
areaText:
|
|
17
|
+
"Every project in the workspace: tasks, reviews, merges and deploys.",
|
|
18
|
+
charterMd:
|
|
19
|
+
"You own how code lands: every task, review, merge and deploy goes through you and the gates. Other leads hand you code work; you decide how it is built and when it merges.",
|
|
20
|
+
goal: "Ship what the workspace needs, safely, through the gates.",
|
|
21
|
+
},
|
|
22
|
+
marketing: {
|
|
23
|
+
title: "Marketing lead",
|
|
24
|
+
areaText: "Content, pages, guides and the marketing board.",
|
|
25
|
+
charterMd:
|
|
26
|
+
"You own how the product is explained and found: pages, guides, posts and what's new. You draft; anything public waits for the owner's yes. Code changes go to the Tech lead.",
|
|
27
|
+
goal: "Steady, organic growth through useful content.",
|
|
28
|
+
},
|
|
29
|
+
product: {
|
|
30
|
+
title: "Product lead",
|
|
31
|
+
areaText: "What gets built next: the roadmap, cards and specs.",
|
|
32
|
+
charterMd:
|
|
33
|
+
"You own what gets built and why: you turn feedback and ideas into clear cards and specs, keep the roadmap ordered, and hand build work to the Tech lead. Product calls the owner hasn't made are asks.",
|
|
34
|
+
goal: "The next thing built is the most useful one.",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const titleCase = (kind: string) =>
|
|
39
|
+
kind
|
|
40
|
+
.split("-")
|
|
41
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
42
|
+
.join(" ");
|
|
43
|
+
|
|
44
|
+
// A shipped kind's template, or a blank one titled after a custom kind.
|
|
45
|
+
export const charterTemplate = (kind: LeadKind): CharterTemplate =>
|
|
46
|
+
TEMPLATES[kind] ?? {
|
|
47
|
+
title: `${titleCase(kind)} lead`,
|
|
48
|
+
areaText: "",
|
|
49
|
+
charterMd: "",
|
|
50
|
+
goal: "",
|
|
51
|
+
};
|