@saadnvd1/agent-os 0.3.853 → 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/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/useSidebarData.ts +8 -3
- package/content/whats-new.json +16 -0
- 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/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,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
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The tool calls of a workspace's standing sessions, as the server answers
|
|
2
|
+
// them: the caller's profile decides which tools it has, a lead's starts
|
|
3
|
+
// and stops stay inside its area, and what it does is recorded as its own.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { Session } from "@/lib/db/types";
|
|
7
|
+
import { isToolName, runTool } from "../orchestrator/serve";
|
|
8
|
+
import { owner } from "../owner";
|
|
9
|
+
import { asActor } from "./actor";
|
|
10
|
+
import { areaRefusal } from "./area";
|
|
11
|
+
import { currentDirection, openProposals, proposePin } from "./direction";
|
|
12
|
+
import { leadTitle, listLeads } from "./home";
|
|
13
|
+
import { latestMemo, writeMemo } from "./memos";
|
|
14
|
+
import {
|
|
15
|
+
isLeadToolName,
|
|
16
|
+
profileFor,
|
|
17
|
+
profileHas,
|
|
18
|
+
type LeadToolName,
|
|
19
|
+
type Profile,
|
|
20
|
+
} from "./profiles";
|
|
21
|
+
import { isLeadKind, leadKindOf, roleNoun } from "./roles";
|
|
22
|
+
import { hasLeads } from "./settings";
|
|
23
|
+
import type { LeadKind } from "./types";
|
|
24
|
+
|
|
25
|
+
export const LEAD_TOOL_SHAPES = {
|
|
26
|
+
update_memo: {
|
|
27
|
+
text: z
|
|
28
|
+
.string()
|
|
29
|
+
.trim()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(6000)
|
|
32
|
+
.describe(
|
|
33
|
+
"The whole memo, rewritten: what you're working on, what's decided, what's next"
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
propose_pin: {
|
|
37
|
+
item: z
|
|
38
|
+
.string()
|
|
39
|
+
.trim()
|
|
40
|
+
.min(1)
|
|
41
|
+
.max(200)
|
|
42
|
+
.describe("The chat item id of the owner's message to pin"),
|
|
43
|
+
},
|
|
44
|
+
direction: {
|
|
45
|
+
lead: z
|
|
46
|
+
.string()
|
|
47
|
+
.trim()
|
|
48
|
+
.max(32)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
"Another lead's kind (marketing, product, tech); yours if left out"
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
} satisfies Record<LeadToolName, z.ZodRawShape>;
|
|
55
|
+
|
|
56
|
+
export const LEAD_TOOL_DESCRIPTIONS: Record<LeadToolName, string> = {
|
|
57
|
+
update_memo:
|
|
58
|
+
"Rewrite your memo: the short note (about 1.5k tokens) you carry across a fresh start, with your pinned direction. Every version is kept.",
|
|
59
|
+
propose_pin:
|
|
60
|
+
"Propose one of the owner's own messages in your chat as pinned direction. The quote is copied by the server, and it is direction only once he confirms it with a tap.",
|
|
61
|
+
direction:
|
|
62
|
+
"The pinned direction in force for you or another lead of this workspace (newest first), with your open proposals and memo.",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function parseLeadArgs<T extends LeadToolName>(tool: T, args: unknown) {
|
|
66
|
+
const parsed = z
|
|
67
|
+
.object(LEAD_TOOL_SHAPES[tool])
|
|
68
|
+
.strict()
|
|
69
|
+
.safeParse(args ?? {});
|
|
70
|
+
if (!parsed.success)
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Bad arguments for ${tool}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "args"} ${i.message}`).join("; ")}`
|
|
73
|
+
);
|
|
74
|
+
return parsed.data as unknown as z.infer<
|
|
75
|
+
z.ZodObject<(typeof LEAD_TOOL_SHAPES)[T]>
|
|
76
|
+
>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function directionText(w: string, kind: LeadKind, me: LeadKind): string {
|
|
80
|
+
const pins = currentDirection(w, kind);
|
|
81
|
+
const title = leadTitle(w, kind);
|
|
82
|
+
const lines = [
|
|
83
|
+
pins.length
|
|
84
|
+
? `Pinned direction for the ${title}, from ${owner()}, newest first:\n${pins.map((p) => `- [pin ${p.id}, ${p.setAt.slice(0, 10)}] "${p.quote}"`).join("\n")}`
|
|
85
|
+
: `The ${title} has no pinned direction.`,
|
|
86
|
+
];
|
|
87
|
+
if (kind === me) {
|
|
88
|
+
const open = openProposals(w, kind);
|
|
89
|
+
if (open.length)
|
|
90
|
+
lines.push(
|
|
91
|
+
`Proposed, waiting for ${owner()}'s tap:\n${open.map((p) => `- [proposal ${p.id}] "${p.quote}"`).join("\n")}`
|
|
92
|
+
);
|
|
93
|
+
const memo = latestMemo(w, kind);
|
|
94
|
+
lines.push(memo ? `Your memo:\n${memo.text}` : "You have no memo yet.");
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runLeadTool(
|
|
100
|
+
w: string,
|
|
101
|
+
caller: Session,
|
|
102
|
+
kind: LeadKind,
|
|
103
|
+
tool: LeadToolName,
|
|
104
|
+
raw: unknown
|
|
105
|
+
): string {
|
|
106
|
+
switch (tool) {
|
|
107
|
+
case "update_memo": {
|
|
108
|
+
const memo = writeMemo(w, kind, parseLeadArgs(tool, raw).text);
|
|
109
|
+
return `Memo saved (${memo.text.length} characters). A fresh start reads it with your pinned direction.`;
|
|
110
|
+
}
|
|
111
|
+
case "propose_pin": {
|
|
112
|
+
const out = proposePin({
|
|
113
|
+
workspaceId: w,
|
|
114
|
+
leadKind: kind,
|
|
115
|
+
sessionId: caller.id,
|
|
116
|
+
chatItemId: parseLeadArgs(tool, raw).item,
|
|
117
|
+
});
|
|
118
|
+
if ("error" in out) throw new Error(out.error);
|
|
119
|
+
return `Proposed as pin ${out.id}: "${out.quote}". It's direction once ${owner()} confirms it.`;
|
|
120
|
+
}
|
|
121
|
+
case "direction": {
|
|
122
|
+
const lead = parseLeadArgs(tool, raw).lead ?? kind;
|
|
123
|
+
if (!isLeadKind(lead)) throw new Error(`No lead "${lead}"`);
|
|
124
|
+
if (!listLeads(w).some((s) => leadKindOf(s) === lead))
|
|
125
|
+
throw new Error(`This workspace has no ${lead} lead`);
|
|
126
|
+
return directionText(w, lead, kind);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class NotYours extends Error {}
|
|
132
|
+
export class UnknownTool extends Error {}
|
|
133
|
+
|
|
134
|
+
// One tool call from a standing session. Unknown names stay "unknown", as
|
|
135
|
+
// they always were; a known tool outside the caller's profile is refused.
|
|
136
|
+
export async function runStandingTool(
|
|
137
|
+
w: string,
|
|
138
|
+
caller: Session,
|
|
139
|
+
tool: string,
|
|
140
|
+
args: unknown
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
const profile: Profile | null = profileFor(caller, hasLeads(w));
|
|
143
|
+
if (!profile)
|
|
144
|
+
throw new NotYours(`The ${roleNoun(caller.role)} has no tools here yet`);
|
|
145
|
+
if (!isToolName(tool) && !isLeadToolName(tool))
|
|
146
|
+
throw new UnknownTool("Unknown tool");
|
|
147
|
+
if (!profileHas(profile, tool))
|
|
148
|
+
throw new NotYours(
|
|
149
|
+
`${tool} isn't one of your tools as ${leadTitle(w, profile.kind)}: it's the Tech lead's. Hand it over with send.`
|
|
150
|
+
);
|
|
151
|
+
if (isLeadToolName(tool))
|
|
152
|
+
return runLeadTool(w, caller, profile.kind, tool, args);
|
|
153
|
+
if (!isToolName(tool)) throw new UnknownTool("Unknown tool");
|
|
154
|
+
const refusal = profile.inArea
|
|
155
|
+
? areaRefusal(w, profile.kind, tool, args)
|
|
156
|
+
: null;
|
|
157
|
+
if (refusal) throw new Error(refusal);
|
|
158
|
+
return asActor(caller, () => runTool(w, tool, args ?? {}));
|
|
159
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A lead's department: the shipped kinds, or a custom slug.
|
|
2
|
+
export type LeadKind = string;
|
|
3
|
+
|
|
4
|
+
export const TECH: LeadKind = "tech";
|
|
5
|
+
export const BUILT_IN_KINDS = ["tech", "marketing", "product"] as const;
|
|
6
|
+
|
|
7
|
+
// How far a workspace's chief of staff may go without being asked. V1 acts
|
|
8
|
+
// only on request; its sweep reads and raises asks.
|
|
9
|
+
export type ChiefAutonomy = "on_request" | "acts";
|
|
10
|
+
|
|
11
|
+
export interface WorkspaceLeads {
|
|
12
|
+
workspaceId: string;
|
|
13
|
+
enabledAt: string;
|
|
14
|
+
chiefAutonomy: ChiefAutonomy;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LeadCharter {
|
|
18
|
+
workspaceId: string;
|
|
19
|
+
leadKind: LeadKind;
|
|
20
|
+
title: string;
|
|
21
|
+
areaText: string;
|
|
22
|
+
charterMd: string;
|
|
23
|
+
goal: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type LeadAreaKind = "project" | "path" | "board";
|
|
28
|
+
|
|
29
|
+
export interface LeadArea {
|
|
30
|
+
kind: LeadAreaKind;
|
|
31
|
+
value: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DirectionPin {
|
|
35
|
+
id: number;
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
leadKind: LeadKind;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
chatItemId: string;
|
|
40
|
+
quote: string;
|
|
41
|
+
setAt: string;
|
|
42
|
+
supersededBy: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ProposalStatus = "open" | "pinned" | "dropped";
|
|
46
|
+
|
|
47
|
+
export interface PinProposal {
|
|
48
|
+
id: number;
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
leadKind: LeadKind;
|
|
51
|
+
sessionId: string;
|
|
52
|
+
chatItemId: string;
|
|
53
|
+
quote: string;
|
|
54
|
+
status: ProposalStatus;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
resolvedAt: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LeadMemo {
|
|
60
|
+
id: number;
|
|
61
|
+
workspaceId: string;
|
|
62
|
+
leadKind: LeadKind;
|
|
63
|
+
text: string;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LeadMessage {
|
|
68
|
+
id: number;
|
|
69
|
+
workspaceId: string;
|
|
70
|
+
fromKind: LeadKind;
|
|
71
|
+
toKind: LeadKind;
|
|
72
|
+
thread: string;
|
|
73
|
+
text: string;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
readAt: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LeadUsage {
|
|
79
|
+
sessionId: string;
|
|
80
|
+
leadKind: LeadKind;
|
|
81
|
+
role: string;
|
|
82
|
+
// Model calls (one per assistant response).
|
|
83
|
+
calls: number;
|
|
84
|
+
inputTokens: number;
|
|
85
|
+
cacheReadTokens: number;
|
|
86
|
+
cacheWriteTokens: number;
|
|
87
|
+
outputTokens: number;
|
|
88
|
+
}
|