@taicho-ai/framework 0.1.0

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/README.md +4 -0
  2. package/package.json +48 -0
  3. package/src/coaching/patterns.ts +103 -0
  4. package/src/coaching/proposal.ts +29 -0
  5. package/src/coaching/retrieval.ts +27 -0
  6. package/src/coaching/supersede.ts +14 -0
  7. package/src/coaching/teach.ts +74 -0
  8. package/src/core/agent-status.ts +79 -0
  9. package/src/core/auth/constants.ts +53 -0
  10. package/src/core/auth/login.ts +110 -0
  11. package/src/core/auth/pkce.ts +18 -0
  12. package/src/core/auth/profile.ts +38 -0
  13. package/src/core/auth/refresh.ts +57 -0
  14. package/src/core/auth/status.ts +26 -0
  15. package/src/core/command-guard.ts +126 -0
  16. package/src/core/conversation-artifacts.ts +40 -0
  17. package/src/core/conversation-replay.ts +160 -0
  18. package/src/core/costs.ts +97 -0
  19. package/src/core/discovery.ts +22 -0
  20. package/src/core/draft.ts +6 -0
  21. package/src/core/e2e-model.ts +315 -0
  22. package/src/core/embed.ts +221 -0
  23. package/src/core/events.ts +133 -0
  24. package/src/core/firecrawl.ts +25 -0
  25. package/src/core/headless.ts +260 -0
  26. package/src/core/instrument.ts +88 -0
  27. package/src/core/logger.ts +143 -0
  28. package/src/core/mcp/adapter.ts +34 -0
  29. package/src/core/mcp/manager.ts +145 -0
  30. package/src/core/mcp/oauth.ts +119 -0
  31. package/src/core/memory.ts +13 -0
  32. package/src/core/mock-model.ts +70 -0
  33. package/src/core/model.ts +91 -0
  34. package/src/core/pricing.ts +27 -0
  35. package/src/core/providers/openai-codex.ts +67 -0
  36. package/src/core/registry.ts +67 -0
  37. package/src/core/run.ts +909 -0
  38. package/src/core/schedule-cli.ts +55 -0
  39. package/src/core/scheduler.ts +356 -0
  40. package/src/core/tasks.ts +108 -0
  41. package/src/core/team-cli.ts +118 -0
  42. package/src/core/team-routing.ts +58 -0
  43. package/src/core/tools.ts +1104 -0
  44. package/src/core/turn-audit.ts +100 -0
  45. package/src/core/verification.ts +102 -0
  46. package/src/core/workflow-run.ts +119 -0
  47. package/src/index.ts +8 -0
  48. package/src/knowledge/retrieval.ts +73 -0
  49. package/src/knowledge/sync.ts +28 -0
  50. package/src/skills/retrieval.ts +22 -0
  51. package/src/store/annotations.ts +107 -0
  52. package/src/store/artifacts.ts +338 -0
  53. package/src/store/config.ts +187 -0
  54. package/src/store/conversation.ts +107 -0
  55. package/src/store/db.ts +34 -0
  56. package/src/store/files.ts +51 -0
  57. package/src/store/knowledge.ts +201 -0
  58. package/src/store/mcp-store.ts +65 -0
  59. package/src/store/migrate.ts +278 -0
  60. package/src/store/plans.ts +260 -0
  61. package/src/store/policy.ts +66 -0
  62. package/src/store/prefs.ts +64 -0
  63. package/src/store/roster.ts +308 -0
  64. package/src/store/run-transcript.ts +103 -0
  65. package/src/store/schedules.ts +94 -0
  66. package/src/store/seed-skills.ts +75 -0
  67. package/src/store/skills.ts +89 -0
  68. package/src/store/sources.ts +58 -0
  69. package/src/store/spend-ledger.ts +114 -0
  70. package/src/store/task-state.ts +267 -0
  71. package/src/store/teams.ts +227 -0
  72. package/src/store/thread.ts +72 -0
  73. package/src/store/trace.ts +98 -0
  74. package/src/store/vectors.ts +26 -0
  75. package/src/store/workflows.ts +144 -0
@@ -0,0 +1,227 @@
1
+ /** Plan 19: team store. teams/<id>/team.md is canon — YAML frontmatter + a markdown charter body,
2
+ * exactly like agents/<id>/agent.md.
3
+ *
4
+ * There is NO teams table. A squad has a handful of teams, so a directory scan IS the whole query
5
+ * surface (the same call made for schedules.ts). Membership lives on the AGENT — `team: <id>` in
6
+ * agent.md — and is queried through the registry's `team` column, which is where the index earns its
7
+ * keep. Nothing about a team is stored in two places.
8
+ *
9
+ * Teams are captain-owned: created by `taicho team add` / `/teams`, never by a model. A team grants
10
+ * capability to its members (TeamTools.grant), so a model that could mint teams could escalate its own
11
+ * privileges. This is why there is no create_team tool, and why grant may name privileged tools. */
12
+ import { YAML } from "bun";
13
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
14
+ import type { Database } from "bun:sqlite";
15
+ import { TeamDef, DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
16
+ import { paths } from "./files";
17
+ import { DEFAULT_WORKER_TOOLS, loadAgent, setAgentTeams } from "./roster";
18
+ import { log } from "../core/logger";
19
+
20
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
21
+
22
+ export function serializeTeam(t: TeamDef): string {
23
+ const { charterBody, ...meta } = t;
24
+ return `---\n${YAML.stringify(meta, null, 2)}\n---\n${charterBody}\n`;
25
+ }
26
+
27
+ export function parseTeam(text: string): TeamDef {
28
+ const m = FRONTMATTER.exec(text);
29
+ if (!m) throw new Error("team.md is missing YAML frontmatter");
30
+ const meta = YAML.parse(m[1]) as Record<string, unknown>;
31
+ const team = TeamDef.parse({ ...meta, charterBody: m[2].trim() });
32
+ assertPolicyRespectsFloor(team);
33
+ return team;
34
+ }
35
+
36
+ /** Plan 14's floor is not a team's to punch through. A worker must ALWAYS be able to produce an artifact
37
+ * and hand it off by reference; a team that denies `write_artifact` would mint exactly the toolless
38
+ * worker `reconcileWorkerTools` exists to rescue. Reject at LOAD, naming the offending tool — a silent
39
+ * drop here would be invisible until a member failed to save its work. */
40
+ export function assertPolicyRespectsFloor(team: TeamDef): void {
41
+ const punched = team.tools.deny.filter((t) => DEFAULT_WORKER_TOOLS.includes(t));
42
+ if (punched.length)
43
+ throw new Error(
44
+ `team "${team.id}" denies ${punched.join(", ")}, which every worker must keep ` +
45
+ `(the artifact baseline: ${DEFAULT_WORKER_TOOLS.join(", ")}). Remove it from tools.deny.`,
46
+ );
47
+ }
48
+
49
+ export function teamExists(ws: string, id: string): boolean {
50
+ return existsSync(paths.teamFile(ws, id));
51
+ }
52
+
53
+ export function loadTeam(ws: string, id: string): TeamDef | null {
54
+ const file = paths.teamFile(ws, id);
55
+ if (!existsSync(file)) return null;
56
+ return parseTeam(readFileSync(file, "utf8"));
57
+ }
58
+
59
+ /** Every team on the squad. A bad team.md is SKIPPED with a warning rather than killing boot — one
60
+ * malformed file must not take the whole squad down. */
61
+ export function listTeams(ws: string): TeamDef[] {
62
+ const dir = paths.teamsDir(ws);
63
+ if (!existsSync(dir)) return [];
64
+ const out: TeamDef[] = [];
65
+ for (const id of readdirSync(dir)) {
66
+ const file = paths.teamFile(ws, id);
67
+ if (!existsSync(file)) continue;
68
+ try { out.push(parseTeam(readFileSync(file, "utf8"))); }
69
+ catch (e) { log.warn(`skipping team ${id}`, e); }
70
+ }
71
+ return out.sort((a, b) => a.id.localeCompare(b.id));
72
+ }
73
+
74
+ export function writeTeam(ws: string, team: TeamDef): void {
75
+ assertPolicyRespectsFloor(team);
76
+ mkdirSync(paths.teamDir(ws, team.id), { recursive: true });
77
+ writeFileSync(paths.teamFile(ws, team.id), serializeTeam(team));
78
+ }
79
+
80
+ export interface NewTeamDraft {
81
+ id: string;
82
+ charter: string;
83
+ lead?: string;
84
+ tools?: { grant?: string[]; deny?: string[] };
85
+ charterBody?: string;
86
+ }
87
+
88
+ /** Agent ids and team ids share ONE namespace, so `delegate_task(to: "news")` is never ambiguous and
89
+ * needs no prefix at the call site. The mirror-image guard lives in roster.createAgent, which checks
90
+ * paths.teamFile directly rather than importing this module — teams.ts already depends on roster.ts
91
+ * for DEFAULT_WORKER_TOOLS, and a cycle here would be gratuitous. */
92
+ export function assertTeamIdFree(ws: string, id: string): void {
93
+ if (existsSync(paths.agentFile(ws, id)))
94
+ throw new Error(`cannot create team "${id}": an agent already has that id (ids are one namespace)`);
95
+ }
96
+
97
+ export function createTeam(ws: string, draft: NewTeamDraft): TeamDef {
98
+ if (teamExists(ws, draft.id)) throw new Error(`team "${draft.id}" already exists`);
99
+ assertTeamIdFree(ws, draft.id);
100
+ const team = TeamDef.parse({
101
+ id: draft.id,
102
+ charter: draft.charter,
103
+ lead: draft.lead,
104
+ tools: draft.tools ?? {},
105
+ charterBody: draft.charterBody ?? "",
106
+ created: new Date().toISOString(),
107
+ });
108
+ writeTeam(ws, team);
109
+ return team;
110
+ }
111
+
112
+ /** Plan 22: seed the universal `default` team if it does not exist. Every agent belongs to it (the
113
+ * membership is implicit — effectiveTeams adds it — so no agent.md is touched), root leads it, and it
114
+ * cannot be deleted (deleteTeam guards the id). Idempotent: a squad that already has the file, or one a
115
+ * captain has customised, is left alone. Runs on boot next to seedRoot. */
116
+ export function seedDefaultTeam(ws: string): void {
117
+ if (teamExists(ws, DEFAULT_TEAM_ID)) return;
118
+ const team = TeamDef.parse({
119
+ id: DEFAULT_TEAM_ID,
120
+ charter: "Everyone on the squad.",
121
+ lead: "root",
122
+ tools: {},
123
+ charterBody:
124
+ "The whole squad. Every agent belongs to this team and it cannot be deleted. " +
125
+ "Form a focused team to address a subset of the squad by capability.",
126
+ created: new Date().toISOString(),
127
+ });
128
+ writeTeam(ws, team);
129
+ }
130
+
131
+ export interface TeamPatch { charter?: string; lead?: string | null; tools?: { grant?: string[]; deny?: string[] }; charterBody?: string; }
132
+
133
+ /** Edit an existing team's charter, lead, tool policy, or body (the captain's `e`/`l`/`p` verbs, the
134
+ * CLI, the approval-gated tool). Re-parses through TeamDef and re-asserts the DEFAULT_WORKER_TOOLS
135
+ * floor on write. `lead: null` clears the lead (back to leadless routing). The id is immutable. */
136
+ export function updateTeam(ws: string, id: string, patch: TeamPatch): TeamDef {
137
+ const cur = loadTeam(ws, id);
138
+ if (!cur) throw new Error(`no team "${id}"`);
139
+ const next = TeamDef.parse({
140
+ ...cur,
141
+ charter: patch.charter ?? cur.charter,
142
+ lead: patch.lead === null ? undefined : (patch.lead ?? cur.lead),
143
+ tools: patch.tools ?? cur.tools,
144
+ charterBody: patch.charterBody ?? cur.charterBody,
145
+ });
146
+ writeTeam(ws, next);
147
+ return next;
148
+ }
149
+
150
+ /** Delete a team (the `d` verb / CLI). The default team is protected — every agent belongs to it. Strips
151
+ * the team from every member's own `teams:` frontmatter (membership is canon on the agent), then removes
152
+ * the team files. Returns the members it detached, for a confirmation line. */
153
+ export async function deleteTeam(ws: string, db: Database, id: string): Promise<string[]> {
154
+ if (id === DEFAULT_TEAM_ID) throw new Error("the default team cannot be deleted — every agent belongs to it");
155
+ if (!teamExists(ws, id)) throw new Error(`no team "${id}"`);
156
+ const members = membersOf(db, id).map((m) => m.id);
157
+ for (const m of members) {
158
+ const agent = await loadAgent(ws, m);
159
+ if (agent.teams.includes(id)) await setAgentTeams(ws, db, m, agent.teams.filter((t) => t !== id));
160
+ }
161
+ rmSync(paths.teamDir(ws, id), { recursive: true, force: true });
162
+ return members;
163
+ }
164
+
165
+ /** Make a team's membership EXACTLY `memberIds` (the wizard's member picker and the `m` verb). Adds the
166
+ * team to newly-selected agents and removes it from de-selected ones — membership is edited on each
167
+ * AGENT, the one source of truth. The default team is not settable: everyone is always a member. */
168
+ export async function setTeamMembers(ws: string, db: Database, teamId: string, memberIds: string[]): Promise<void> {
169
+ if (teamId === DEFAULT_TEAM_ID) throw new Error("every agent is always a member of the default team");
170
+ if (!teamExists(ws, teamId)) throw new Error(`no team "${teamId}"`);
171
+ const current = new Set(membersOf(db, teamId).map((m) => m.id));
172
+ const desired = new Set(memberIds);
173
+ for (const m of desired)
174
+ if (!current.has(m)) {
175
+ const agent = await loadAgent(ws, m); // throws if the id is not an agent
176
+ if (!agent.teams.includes(teamId)) await setAgentTeams(ws, db, m, [...agent.teams, teamId]);
177
+ }
178
+ for (const m of current)
179
+ if (!desired.has(m)) {
180
+ const agent = await loadAgent(ws, m);
181
+ await setAgentTeams(ws, db, m, agent.teams.filter((t) => t !== teamId));
182
+ }
183
+ }
184
+
185
+ /** Create a team AND staff it in one call (the Add wizard, the CLI, the approval-gated tool). The team
186
+ * file is written first so setTeamMembers' existence check passes. */
187
+ export async function createTeamWithMembers(ws: string, db: Database, draft: NewTeamDraft, memberIds: string[]): Promise<TeamDef> {
188
+ const team = createTeam(ws, draft);
189
+ await setTeamMembers(ws, db, team.id, memberIds);
190
+ return team;
191
+ }
192
+
193
+ export interface TeamMember { id: string; role: string }
194
+
195
+ /** A team's roster, derived from the `agent_teams` join (Plan 22) — the many-to-many membership index,
196
+ * itself derived from each agent's own `teams:` frontmatter. Ordered by id so routing and rendering are
197
+ * deterministic. membersOf(DEFAULT_TEAM_ID) returns the whole squad, since every agent carries the
198
+ * implicit default membership. */
199
+ export function membersOf(db: Database, teamId: string): TeamMember[] {
200
+ return db
201
+ .query<TeamMember, [string]>(
202
+ "SELECT r.id, r.role FROM agent_teams at JOIN registry r ON r.id = at.agent_id WHERE at.team_id = ? ORDER BY r.id",
203
+ )
204
+ .all(teamId);
205
+ }
206
+
207
+ export interface TeamProblem { team: string; problem: string }
208
+
209
+ /** Boot validation. A `lead` that is not an agent, or is an agent sitting on a DIFFERENT team, is an
210
+ * inconsistency the captain must see — a lead who isn't on the team it leads would route work out of
211
+ * the team silently. Report rather than throw: one bad team must not block boot, and the captain can
212
+ * fix the file and restart. Returns the problems found (for a boot notice). */
213
+ export function validateTeams(ws: string, db: Database): TeamProblem[] {
214
+ const problems: TeamProblem[] = [];
215
+ const isAgent = db.query<{ id: string }, [string]>("SELECT id FROM registry WHERE id = ?");
216
+ const isMember = db.query<{ agent_id: string }, [string, string]>("SELECT agent_id FROM agent_teams WHERE agent_id = ? AND team_id = ?");
217
+ for (const team of listTeams(ws)) {
218
+ if (!team.lead) continue;
219
+ // Plan 22: a lead must be an agent AND a member of the team it leads (membership is many-to-many, so
220
+ // "is a member" is a join lookup, not a single-column compare). A lead who isn't on the team routes
221
+ // its work out of the team — the inconsistency this reports so the captain can fix the file.
222
+ if (!isAgent.get(team.lead)) problems.push({ team: team.id, problem: `lead "${team.lead}" is not an agent` });
223
+ else if (!isMember.get(team.lead, team.id))
224
+ problems.push({ team: team.id, problem: `lead "${team.lead}" is not a member of the team it leads — add "${team.id}" to its teams` });
225
+ }
226
+ return problems;
227
+ }
@@ -0,0 +1,72 @@
1
+ /** Per-agent conversation thread, append-only JSONL at agents/<id>/thread.jsonl.
2
+ * Used to persist + resume the root conversation across launches. Tolerant of corrupt lines. */
3
+ import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import type { ModelMessage } from "ai";
6
+ import { paths } from "./files";
7
+
8
+ function threadFile(ws: string, agentId: string): string {
9
+ return join(paths.agentDir(ws, agentId), "thread.jsonl");
10
+ }
11
+
12
+ // Must match SUMMARY_MARKER in core/conversation-replay.ts — the first line of a COMPACTED replay cache
13
+ // is a pinned `[CONVERSATION COMPACTION]` summary of the folded (older) turns (Plan 05 Ph3). Defined
14
+ // locally (not imported) to avoid a thread ⇄ conversation-replay import cycle.
15
+ const COMPACTION_HEAD_MARKER = "[CONVERSATION COMPACTION]";
16
+
17
+ /** Is this raw JSONL line the pinned compaction-summary head? (a `user` message whose text opens with
18
+ * the marker) — used so truncation never drops the folded-history summary. */
19
+ function isCompactionHead(line: string): boolean {
20
+ try {
21
+ const m = JSON.parse(line) as ModelMessage;
22
+ return m.role === "user" && typeof m.content === "string" && m.content.startsWith(COMPACTION_HEAD_MARKER);
23
+ } catch { return false; }
24
+ }
25
+
26
+ /** Is this raw JSONL line an `assistant` reply? Used to keep the truncated tail on a turn (user+
27
+ * assistant PAIR) boundary — a raw-message tail cut can land mid-pair and leave a dangling assistant
28
+ * reply (no preceding user turn) as the first replayed message. */
29
+ function isAssistantReply(line: string): boolean {
30
+ try { return (JSON.parse(line) as ModelMessage).role === "assistant"; }
31
+ catch { return false; }
32
+ }
33
+
34
+ export function loadThread(ws: string, agentId: string, maxTurns = 40): ModelMessage[] {
35
+ const f = threadFile(ws, agentId);
36
+ if (!existsSync(f)) return [];
37
+ const lines = readFileSync(f, "utf8").split("\n").filter((l) => l.trim() !== "");
38
+ // The DERIVED replay cache leads with a pinned `[CONVERSATION COMPACTION]` summary head (Plan 05 Ph3)
39
+ // whenever older turns were folded. A naive tail-slice (`slice(-maxTurns)`) would DROP that head for a
40
+ // large replayKeepTurns — silently losing the entire folded-history summary and defeating cross-turn
41
+ // compaction. PIN the head and truncate from the tail's older end instead.
42
+ let head: string[] = [];
43
+ let rest = lines;
44
+ if (lines.length && isCompactionHead(lines[0])) { head = [lines[0]!]; rest = lines.slice(1); }
45
+ const budget = Math.max(0, maxTurns - head.length);
46
+ let kept = budget === 0 ? [] : rest.slice(-budget); // guard: slice(-0) would return the WHOLE array
47
+ // Slice the tail at a turn (user+assistant PAIR) boundary. When `1 + 2*replayKeepTurns > maxTurns` the
48
+ // pinned summary head steals a slot and the raw tail cut lands mid-pair — leaving a DANGLING assistant
49
+ // reply (orphaned from its user turn) as the first replayed message. Drop that orphan so replay opens
50
+ // on a user turn (or the summary head), never on a bare assistant reply.
51
+ if (kept.length && isAssistantReply(kept[0]!)) kept = kept.slice(1);
52
+ const out: ModelMessage[] = [];
53
+ for (const l of [...head, ...kept]) {
54
+ try { out.push(JSON.parse(l) as ModelMessage); } catch { /* skip corrupt line */ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Overwrite thread.jsonl with an exact message list. thread.jsonl is a DERIVED boot-replay cache
60
+ * (the ledger is the append-only truth), so it is REBUILT — not appended — each time a completed
61
+ * turn is recorded through the audit seam. Plan 05 Ph3 writes the compacted replay (rolling summary
62
+ * + recent-K tail) here, so this replaces the old completed-only append. */
63
+ export function writeThread(ws: string, agentId: string, messages: ModelMessage[]): void {
64
+ mkdirSync(paths.agentDir(ws, agentId), { recursive: true });
65
+ const body = messages.map((m) => JSON.stringify(m)).join("\n");
66
+ writeFileSync(threadFile(ws, agentId), body ? body + "\n" : "");
67
+ }
68
+
69
+ export function clearThread(ws: string, agentId: string): void {
70
+ const f = threadFile(ws, agentId);
71
+ if (existsSync(f)) writeFileSync(f, "");
72
+ }
@@ -0,0 +1,98 @@
1
+ /** One JSON file per run under runs/<agent>/<date>-run<n>.json. Files are canon. */
2
+ import { mkdirSync, existsSync, readdirSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { paths } from "./files";
5
+ import { RunTrace } from "@taicho-ai/contracts/trace";
6
+
7
+ function dateStamp(): string { return new Date().toISOString().slice(0, 10); }
8
+ function fileName(id: string): string {
9
+ const i = id.indexOf("/");
10
+ if (i < 0) throw new Error("bad run id: " + id);
11
+ return id.slice(i + 1) + ".json";
12
+ }
13
+
14
+ export function nextRunId(ws: string, agentId: string): string {
15
+ const date = dateStamp();
16
+ const dir = paths.runDir(ws, agentId);
17
+ let max = 0;
18
+ if (existsSync(dir)) {
19
+ const prefix = `${date}-run`;
20
+ for (const f of readdirSync(dir)) {
21
+ if (f.startsWith(prefix)) {
22
+ const n = parseInt(f.slice(prefix.length), 10);
23
+ if (Number.isFinite(n)) max = Math.max(max, n);
24
+ }
25
+ }
26
+ }
27
+ return `${agentId}/${date}-run${max + 1}`;
28
+ }
29
+
30
+ export function writeTrace(ws: string, trace: RunTrace): string {
31
+ const dir = paths.runDir(ws, trace.agent);
32
+ mkdirSync(dir, { recursive: true });
33
+ const file = join(dir, fileName(trace.id));
34
+ writeFileSync(file, JSON.stringify(trace, null, 2));
35
+ return file;
36
+ }
37
+
38
+ export function readTrace(ws: string, id: string): RunTrace {
39
+ const file = join(paths.runDir(ws, id.split("/")[0]), fileName(id));
40
+ return RunTrace.parse(JSON.parse(readFileSync(file, "utf8")));
41
+ }
42
+
43
+ export function listTraces(ws: string, agentId?: string): RunTrace[] {
44
+ const root = join(ws, "runs");
45
+ if (!existsSync(root)) return [];
46
+ const agents = agentId
47
+ ? [agentId]
48
+ : readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
49
+ const out: RunTrace[] = [];
50
+ for (const a of agents) {
51
+ const dir = paths.runDir(ws, a);
52
+ if (!existsSync(dir)) continue;
53
+ for (const f of readdirSync(dir)) {
54
+ if (!f.endsWith(".json")) continue;
55
+ try {
56
+ out.push(RunTrace.parse(JSON.parse(readFileSync(join(dir, f), "utf8"))));
57
+ } catch {
58
+ // skip unparseable / in-progress placeholder files so listing never breaks
59
+ }
60
+ }
61
+ }
62
+ return out.sort((x, y) => x.started.localeCompare(y.started));
63
+ }
64
+
65
+ /** Atomically reserve a unique run id by exclusively creating its trace file as an
66
+ * `interrupted` placeholder. Concurrent reservations for the same agent never collide. */
67
+ export function reserveRunId(ws: string, agentId: string): string {
68
+ const date = dateStamp();
69
+ const dir = paths.runDir(ws, agentId);
70
+ mkdirSync(dir, { recursive: true });
71
+ let n = 1;
72
+ const prefix = `${date}-run`;
73
+ for (const f of readdirSync(dir)) {
74
+ if (f.startsWith(prefix)) {
75
+ const m = parseInt(f.slice(prefix.length), 10);
76
+ if (Number.isFinite(m)) n = Math.max(n, m + 1);
77
+ }
78
+ }
79
+ for (;;) {
80
+ const id = `${agentId}/${date}-run${n}`;
81
+ try {
82
+ writeFileSync(join(dir, `${date}-run${n}.json`), JSON.stringify(placeholderTrace(id, agentId), null, 2), { flag: "wx" });
83
+ return id;
84
+ } catch (e: unknown) {
85
+ if ((e as NodeJS.ErrnoException)?.code === "EEXIST") { n++; continue; }
86
+ throw e;
87
+ }
88
+ }
89
+ }
90
+
91
+ function placeholderTrace(id: string, agent: string): RunTrace {
92
+ return RunTrace.parse({
93
+ id, agent, task: "(running)", triggeredBy: "",
94
+ ledger: { retrieved: [], applied: [], skipped: [] },
95
+ toolCalls: [], artifacts: [], delegatedOut: [], outcome: "interrupted",
96
+ tokens: 0, durationMs: 0, started: new Date().toISOString(),
97
+ });
98
+ }
@@ -0,0 +1,26 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ /** v0 recall: embeddings as blobs + brute-force cosine in TS.
4
+ * At policy-note scale (hundreds) this is microseconds; sqlite-vec is a later optimization. */
5
+ export function putVector(db: Database, ref: string, kind: string, vec: Float32Array) {
6
+ db.query("INSERT OR REPLACE INTO embeddings (ref, kind, vec) VALUES (?, ?, ?)")
7
+ .run(ref, kind, new Uint8Array(vec.buffer.slice(0)));
8
+ }
9
+
10
+ export function topK(db: Database, kind: string, query: Float32Array, k: number) {
11
+ const rows = db.query<{ ref: string; vec: Uint8Array }, [string]>(
12
+ "SELECT ref, vec FROM embeddings WHERE kind = ?").all(kind);
13
+ const scored = rows.map((r) => {
14
+ const v = new Float32Array(r.vec.slice().buffer);
15
+ return { ref: r.ref, score: cosine(query, v) };
16
+ });
17
+ scored.sort((a, b) => b.score - a.score);
18
+ return scored.slice(0, k);
19
+ }
20
+
21
+ function cosine(a: Float32Array, b: Float32Array): number {
22
+ let dot = 0, na = 0, nb = 0;
23
+ const n = Math.min(a.length, b.length);
24
+ for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
25
+ return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
26
+ }
@@ -0,0 +1,144 @@
1
+ /** Plan 23: a team's WORKFLOW — how work moves through it, and what each seat does. Optional, captain-
2
+ * authored, sits at teams/<id>/workflow.md next to team.md. It is CANON and STABLE: a file, not a
3
+ * model-generated plan, so the process is byte-identical on every invocation (the opposite of a Plan 18
4
+ * plan, which regenerates per goal). The lead INSTANTIATES it against the input; it never re-invents it.
5
+ *
6
+ * The file is MEMBER-KEYED: markdown headings name the seats. A heading whose text is a member's agent
7
+ * id is that member's LANE ("when work reaches you, do this"); the reserved heading `orchestration` is
8
+ * the LEAD's slice (the sequence + hand-offs). At run time (run.ts) a member executing UNDER this team
9
+ * gets its lane injected as a context-tier section; the lead additionally gets the orchestration slice.
10
+ *
11
+ * SPARSE + OPTIONAL by construction: no workflow.md → the team runs exactly as it did before Plan 23;
12
+ * a workflow.md with no section for a given member → that member just runs on the agentic brief (its
13
+ * identity + charter + task). Assigning someone to a team can therefore never break — a workflow only
14
+ * ADDS structure at the seats you have actually authored. */
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
+ import { YAML } from "bun";
17
+ import { paths } from "./files";
18
+ import { loadWorkflowDefText, parseWorkflowDef, type WorkflowDef } from "@taicho-ai/graph";
19
+
20
+ /** The reserved seat name for the lead's orchestration slice. An agent literally named `orchestration`
21
+ * would collide with it — deliberately reserved, and documented, so the seat is unambiguous. */
22
+ export const ORCHESTRATION_KEY = "orchestration";
23
+
24
+ const FRONTMATTER = /^---\n[\s\S]*?\n---\n?([\s\S]*)$/;
25
+ // Seats are LEVEL-2 headings (`## <seat>`). A level-1 `# Title` is a document title (ignored), and
26
+ // deeper `### …` headings live INSIDE a lane as its content — so an author can structure a lane freely.
27
+ const HEADING = /^##\s+(.+?)\s*$/;
28
+
29
+ export interface TeamWorkflow {
30
+ /** seat name (lowercased heading text: a member agent id, or `orchestration`) → that section's body. */
31
+ sections: Map<string, string>;
32
+ }
33
+
34
+ /** Parse a workflow.md into its seat sections. Any YAML frontmatter is stripped (reserved for a future
35
+ * structured `steps:` form — the file stays forward-compatible). Content before the first heading is a
36
+ * preamble and is ignored; the format is heading-delimited. */
37
+ export function parseWorkflow(text: string): TeamWorkflow {
38
+ const body = FRONTMATTER.test(text) ? (FRONTMATTER.exec(text)![1] ?? "") : text;
39
+ const sections = new Map<string, string>();
40
+ let key: string | null = null;
41
+ let buf: string[] = [];
42
+ const flush = () => { if (key !== null) sections.set(key, buf.join("\n").trim()); };
43
+ for (const line of body.split("\n")) {
44
+ const m = HEADING.exec(line);
45
+ if (m) { flush(); key = m[1].toLowerCase().trim(); buf = []; }
46
+ else if (key !== null) buf.push(line);
47
+ }
48
+ flush();
49
+ return { sections };
50
+ }
51
+
52
+ export function loadWorkflow(ws: string, teamId: string): TeamWorkflow | null {
53
+ const file = paths.teamWorkflowFile(ws, teamId);
54
+ if (!existsSync(file)) return null;
55
+ return parseWorkflow(readFileSync(file, "utf8"));
56
+ }
57
+
58
+ /** Plan 25: the STRUCTURED workflow definition (the `steps:` frontmatter) for a team, or null when the
59
+ * team has no workflow.md or the file is a Plan 23 prose-only workflow (no `steps:`). The team id is
60
+ * injected from the file location — one source of truth, like membership. */
61
+ export function loadWorkflowDef(ws: string, teamId: string): WorkflowDef | null {
62
+ const file = paths.teamWorkflowFile(ws, teamId);
63
+ if (!existsSync(file)) return null;
64
+ return loadWorkflowDefText(readFileSync(file, "utf8"), teamId);
65
+ }
66
+
67
+ /** Plan 25: write the structured `steps:` frontmatter into a team's workflow.md. The ENGINE calls this
68
+ * (via ctx.proposeWorkflow) only after the captain approves a root proposal — the model never writes
69
+ * workflow canon directly (Plan 23's rule). Validates first (an invalid workflow is never written), and
70
+ * PRESERVES any existing prose lanes (the markdown body below the frontmatter). Version defaults to the
71
+ * next number after any existing structured version, or 1. */
72
+ export function writeWorkflowSteps(
73
+ ws: string,
74
+ teamId: string,
75
+ input: { name: string; version?: number; brief?: string; steps: unknown[] },
76
+ ): void {
77
+ const version = input.version ?? ((loadWorkflowDef(ws, teamId)?.version ?? 0) + 1);
78
+ // Validate BEFORE writing — a bad workflow must never reach canon.
79
+ parseWorkflowDef({ id: input.name, team: teamId, version, brief: input.brief, steps: input.steps });
80
+
81
+ const file = paths.teamWorkflowFile(ws, teamId);
82
+ let body = "";
83
+ if (existsSync(file)) {
84
+ const text = readFileSync(file, "utf8");
85
+ const m = /^---\n[\s\S]*?\n---\n?([\s\S]*)$/.exec(text);
86
+ body = (m ? m[1]! : text).trim(); // keep the lanes (body after any frontmatter, or the whole prose file)
87
+ }
88
+ const fm: Record<string, unknown> = { workflow: input.name, version };
89
+ if (input.brief) fm.brief = input.brief;
90
+ fm.steps = input.steps;
91
+ const yaml = YAML.stringify(fm, null, 2).trimEnd();
92
+
93
+ mkdirSync(paths.teamDir(ws, teamId), { recursive: true });
94
+ writeFileSync(file, `---\n${yaml}\n---\n${body ? `\n${body}\n` : "\n"}`);
95
+ }
96
+
97
+ /** The lane a specific agent plays in this workflow — its own seat section, or undefined if it has none. */
98
+ export function laneFor(wf: TeamWorkflow, agentId: string): string | undefined {
99
+ const s = wf.sections.get(agentId.toLowerCase());
100
+ return s ? s : undefined;
101
+ }
102
+
103
+ /** The orchestration slice — the lead's view of the sequence and hand-offs. Undefined if the file has none. */
104
+ export function orchestrationSlice(wf: TeamWorkflow): string | undefined {
105
+ const s = wf.sections.get(ORCHESTRATION_KEY);
106
+ return s ? s : undefined;
107
+ }
108
+
109
+ /** Does this team have a workflow file at all? Cheap existence check for the Org browser's status line. */
110
+ export function hasWorkflow(ws: string, teamId: string): boolean {
111
+ return existsSync(paths.teamWorkflowFile(ws, teamId));
112
+ }
113
+
114
+ /** The seat names a workflow defines, in file order, with `orchestration` marked. For the Org browser's
115
+ * summary line and the read_workflow tool. */
116
+ export function seatsOf(wf: TeamWorkflow): string[] {
117
+ return [...wf.sections.keys()];
118
+ }
119
+
120
+ /** Write (or overwrite) a team's workflow.md. Captain-owned — the Org browser and hand-edits call this;
121
+ * the model never does. Ensures a trailing newline so the file is well-formed. */
122
+ export function writeWorkflow(ws: string, teamId: string, text: string): void {
123
+ mkdirSync(paths.teamDir(ws, teamId), { recursive: true });
124
+ writeFileSync(paths.teamWorkflowFile(ws, teamId), text.endsWith("\n") ? text : text + "\n");
125
+ }
126
+
127
+ /** Scaffold a STARTER workflow.md for a team from its current members — a template the captain then
128
+ * hand-edits into a real process. Refuses to clobber an existing file. Returns the text written. The
129
+ * header comment explains the two section kinds so an editor knows the shape without leaving the file. */
130
+ export function scaffoldWorkflow(ws: string, teamId: string, members: string[]): string {
131
+ if (hasWorkflow(ws, teamId)) throw new Error(`team "${teamId}" already has a workflow`);
132
+ const lanes = (members.length ? members : ["example-member"])
133
+ .map((m) => `## ${m}\nWhat ${m} does when work reaches it.\n`)
134
+ .join("\n");
135
+ const text =
136
+ `# ${teamId} workflow\n\n` +
137
+ `<!-- Each "## <agent-id>" heading below is that member's LANE — what it does when work reaches it. -->\n` +
138
+ `<!-- "## orchestration" is the lead's view: the sequence and the hand-offs. -->\n` +
139
+ `<!-- Delete a seat you don't want scripted; an unlisted member just runs on the agentic brief. -->\n\n` +
140
+ `## orchestration\nDescribe the order work moves in, and who hands to whom.\n\n` +
141
+ lanes;
142
+ writeWorkflow(ws, teamId, text);
143
+ return text;
144
+ }