@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.
- package/README.md +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/** agent.md is canon: YAML frontmatter (the AgentDef minus identity) + markdown body (the SOUL).
|
|
2
|
+
* Parsed with Bun.YAML (native). The registry table is a derived index of this. */
|
|
3
|
+
import { YAML } from "bun";
|
|
4
|
+
import { mkdir, writeFile, readdir, readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { Database } from "bun:sqlite";
|
|
8
|
+
import { AgentDef } from "@taicho-ai/contracts/agent";
|
|
9
|
+
import { DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
|
|
10
|
+
import { paths } from "./files";
|
|
11
|
+
import { syncRegistry } from "../core/registry";
|
|
12
|
+
import type { TaichoConfig } from "./config";
|
|
13
|
+
import { log } from "../core/logger";
|
|
14
|
+
|
|
15
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
|
|
16
|
+
|
|
17
|
+
export function serializeAgent(a: AgentDef): string {
|
|
18
|
+
const { identity, ...meta } = a;
|
|
19
|
+
// Block-style YAML (indent 2) keeps agent.md frontmatter human-readable/editable.
|
|
20
|
+
return `---\n${YAML.stringify(meta, null, 2)}\n---\n${identity}\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseAgent(text: string): AgentDef {
|
|
24
|
+
const m = FRONTMATTER.exec(text);
|
|
25
|
+
if (!m) throw new Error("agent.md is missing YAML frontmatter");
|
|
26
|
+
const meta = YAML.parse(m[1]) as Record<string, unknown>;
|
|
27
|
+
return AgentDef.parse({ ...meta, identity: m[2].trim() });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ROOT_IDENTITY = `You are the root orchestrator of a taicho squad — the captain's standing assistant.
|
|
31
|
+
|
|
32
|
+
Your job is to TURN THE CAPTAIN'S INTENT INTO ACTION, never to do the domain work yourself:
|
|
33
|
+
- When the captain needs a capability no agent has, call create_agent to PROPOSE a worker (a clear id, a one-line role, and an identity that gives it a strong point of view). The captain approves before it exists.
|
|
34
|
+
- When a fitting agent exists, use find_agents to locate it and delegate_task to hand off the goal.
|
|
35
|
+
- When the captain's intent is ambiguous, call ask_human with 2-4 concrete options to get clarity BEFORE acting — don't guess at what they meant.
|
|
36
|
+
- Keep your own replies short. You coordinate; the squad produces artifacts.
|
|
37
|
+
- When the captain points you at an MCP server's setup docs, call read_url on that page, infer the server config from it (a \`url\` for hosted servers, or a \`command\` for local ones), and propose it with add_mcp_server for approval. If it needs a secret, ask_human for the env-var name and tell the captain to set it, then reference it as \${VAR}. If the connect fails, read the error and retry a corrected config. Once connected, offer to create_agent a worker wired to \`mcp:<server>\`.
|
|
38
|
+
- When the captain teaches a repeatable procedure worth reusing, call propose_skill to codify it as a reviewed skill (the captain approves before it's saved).`;
|
|
39
|
+
|
|
40
|
+
/** Root's built-in capabilities. Kept in one place so existing roots get reconciled to the current
|
|
41
|
+
* set on boot (older roots drift — e.g. predate ask_human / the MCP tools). */
|
|
42
|
+
export const ROOT_TOOLS = ["create_agent", "create_team", "read_workflow", "run_workflow", "propose_workflow", "resume_workflow", "delegate_task", "dispatch_task", "check_task", "await_task", "find_agents", "ask_human", "read_url", "add_mcp_server", "remember", "recall", "propose_skill", "run_command", "save_artifact", "read_artifact", "list_artifacts", "annotate_artifact", "list_annotations", "write_plan", "update_plan_item", "read_plan"];
|
|
43
|
+
|
|
44
|
+
export async function seedRoot(ws: string, defaults?: TaichoConfig["defaults"]): Promise<void> {
|
|
45
|
+
const file = paths.agentFile(ws, "root");
|
|
46
|
+
if (await Bun.file(file).exists()) {
|
|
47
|
+
// Reconcile: ensure an existing root carries the current built-in tools (preserve any extras).
|
|
48
|
+
const root = await loadAgent(ws, "root");
|
|
49
|
+
const missing = ROOT_TOOLS.filter((t) => !root.tools.includes(t));
|
|
50
|
+
if (missing.length) {
|
|
51
|
+
root.tools = [...root.tools, ...missing];
|
|
52
|
+
await writeFile(file, serializeAgent(root));
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const root = AgentDef.parse({
|
|
57
|
+
id: "root",
|
|
58
|
+
role: "Orchestrator — interviews the captain, proposes and coordinates worker agents",
|
|
59
|
+
identity: ROOT_IDENTITY,
|
|
60
|
+
tools: ROOT_TOOLS,
|
|
61
|
+
canSee: ["*"], canDelegateTo: ["*"], isRoot: true,
|
|
62
|
+
created: new Date().toISOString(),
|
|
63
|
+
budgets: defaults?.budgets,
|
|
64
|
+
});
|
|
65
|
+
await mkdir(paths.agentDir(ws, "root"), { recursive: true });
|
|
66
|
+
await writeFile(file, serializeAgent(root));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const LIBRARIAN_ID = "librarian";
|
|
70
|
+
export const LIBRARIAN_TOOLS = ["read_source", "remember", "recall", "forget", "reindex_knowledge"];
|
|
71
|
+
|
|
72
|
+
const LIBRARIAN_IDENTITY = `You are the librarian of a taicho squad — the keeper of the squad's shared knowledge graph.
|
|
73
|
+
|
|
74
|
+
Your job is to turn source documents into a clean graph, and to prune memory on command:
|
|
75
|
+
- To INGEST a source document: read it with read_source, then extract the ENTITIES and RELATIONSHIPS it asserts — not chunks of prose. remember each entity/fact/decision (choose a fitting kind), and link them with typed edges (relates_to, depends_on, part_of, contradicts, derived_from). recall first to reuse existing node ids when linking. Keep each node atomic and self-contained.
|
|
76
|
+
- Prefer a few well-connected nodes over many redundant ones.
|
|
77
|
+
- To PRUNE on the captain's request, use forget with the NARROWEST filter that satisfies the intent — by kind (e.g. all decisions), by sourcePrefix (e.g. "worker-x:" for one assistant's memory), or by explicit ids. Report exactly what you removed.
|
|
78
|
+
- After bulk hand-edits to node files, call reindex_knowledge to rebuild the index and refresh vectors.
|
|
79
|
+
- Keep replies short and factual — you curate; you don't do domain work.`;
|
|
80
|
+
|
|
81
|
+
/** Seed the built-in librarian next to root. Reconciles an existing librarian's toolset like seedRoot. */
|
|
82
|
+
export async function seedLibrarian(ws: string, defaults?: TaichoConfig["defaults"]): Promise<void> {
|
|
83
|
+
const file = paths.agentFile(ws, LIBRARIAN_ID);
|
|
84
|
+
if (await Bun.file(file).exists()) {
|
|
85
|
+
const lib = await loadAgent(ws, LIBRARIAN_ID);
|
|
86
|
+
const missing = LIBRARIAN_TOOLS.filter((t) => !lib.tools.includes(t));
|
|
87
|
+
if (missing.length) {
|
|
88
|
+
lib.tools = [...lib.tools, ...missing];
|
|
89
|
+
await writeFile(file, serializeAgent(lib));
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const lib = AgentDef.parse({
|
|
94
|
+
id: LIBRARIAN_ID,
|
|
95
|
+
role: "Librarian — extracts entities from source documents, curates and prunes the knowledge graph",
|
|
96
|
+
identity: LIBRARIAN_IDENTITY,
|
|
97
|
+
tools: LIBRARIAN_TOOLS,
|
|
98
|
+
canSee: [], canDelegateTo: [], isRoot: false,
|
|
99
|
+
created: new Date().toISOString(),
|
|
100
|
+
budgets: defaults?.budgets,
|
|
101
|
+
});
|
|
102
|
+
await mkdir(paths.agentDir(ws, LIBRARIAN_ID), { recursive: true });
|
|
103
|
+
await writeFile(file, serializeAgent(lib));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Plan 22: `teams` is an agent's full EFFECTIVE membership — the implicit `default` plus its explicit
|
|
107
|
+
* teams, default first (as loadIndex reads it from agent_teams). OPTIONAL on the type so a caller
|
|
108
|
+
* constructing a row by hand — a test fixture, a slash-command stub — need not spell it out; loadIndex
|
|
109
|
+
* always populates it. An unaffiliated agent still carries `["default"]`. */
|
|
110
|
+
export interface RegistryRow { id: string; role: string; is_root: number; teams?: string[]; }
|
|
111
|
+
|
|
112
|
+
export interface NewAgentDraft {
|
|
113
|
+
id: string; role: string; identity: string; tools?: string[]; teams?: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The default worker capability baseline (Plan 01 hand-off-by-reference + Plan 14 lifecycle contract).
|
|
117
|
+
* EVERY worker created via create_agent gets these UNCONDITIONALLY: the structured artifact tools to
|
|
118
|
+
* PRODUCE a work product (save_artifact — or the legacy simple-markdown write_artifact wrapper),
|
|
119
|
+
* CONSUME others' by reference (read_artifact), DISCOVER them (list_artifacts), and give/receive
|
|
120
|
+
* feedback that drives a revision (annotate_artifact / list_annotations). This is the squad's FLOOR:
|
|
121
|
+
* a worker without it can only hand work back as loose `final.md` text — the root/2026-07-04-run6 gap.
|
|
122
|
+
* Privileged / opt-in capabilities (delegate_task, run_command, create_agent, ask_human, the KB tools,
|
|
123
|
+
* the Plan 18 plan tools, and any mcp:<server> ref — Plan 08 least privilege) are deliberately NOT here:
|
|
124
|
+
* a model requests those explicitly via create_agent's `tools`, which ADDS to this baseline, never
|
|
125
|
+
* REPLACES it. A plan is not needed to produce an artifact, so write_plan/update_plan_item/read_plan
|
|
126
|
+
* stay an opt-in grant — otherwise every worker on a squad would own one and the panel would be a
|
|
127
|
+
* forest. Root holds them by default; a team lead asks. */
|
|
128
|
+
export const DEFAULT_WORKER_TOOLS = ["write_artifact", "save_artifact", "read_artifact", "list_artifacts", "annotate_artifact", "list_annotations"];
|
|
129
|
+
|
|
130
|
+
/** Merge a model-proposed tool list onto the worker baseline (Plan 14 T1 — baseline-merge, the robust
|
|
131
|
+
* fix over empty-means-default). The artifact baseline is ALWAYS present (deduped, baseline-first);
|
|
132
|
+
* `requested` only ADDS extras. So an explicit `tools: []` — or a `tools` list that simply forgot the
|
|
133
|
+
* artifact tools — can no longer defeat the default and mint a toolless worker (the bug this closes:
|
|
134
|
+
* `??` only filled null/undefined, so `tools: []` sailed through). */
|
|
135
|
+
export function workerTools(requested?: string[]): string[] {
|
|
136
|
+
const out: string[] = [];
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
for (const t of [...DEFAULT_WORKER_TOOLS, ...(requested ?? [])])
|
|
139
|
+
if (t && !seen.has(t)) { seen.add(t); out.push(t); }
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function loadIndex(db: Database): RegistryRow[] {
|
|
144
|
+
const base = db.query<{ id: string; role: string; is_root: number }, []>("SELECT id, role, is_root FROM registry").all();
|
|
145
|
+
const byAgent = new Map<string, string[]>();
|
|
146
|
+
for (const m of db.query<{ agent_id: string; team_id: string }, []>("SELECT agent_id, team_id FROM agent_teams ORDER BY agent_id, ord").all()) {
|
|
147
|
+
const l = byAgent.get(m.agent_id) ?? [];
|
|
148
|
+
l.push(m.team_id);
|
|
149
|
+
byAgent.set(m.agent_id, l);
|
|
150
|
+
}
|
|
151
|
+
return base.map((r) => ({ ...r, teams: byAgent.get(r.id) ?? [] }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An agent's effective teams (default first), read from the derived index. The single-team question
|
|
155
|
+
* the old `registry.team` column answered — now that membership is many-to-many. */
|
|
156
|
+
export function teamsOf(db: Database, agentId: string): string[] {
|
|
157
|
+
return db.query<{ team_id: string }, [string]>("SELECT team_id FROM agent_teams WHERE agent_id = ? ORDER BY ord").all(agentId).map((r) => r.team_id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function loadAgent(ws: string, id: string): Promise<AgentDef> {
|
|
161
|
+
return parseAgent(await readFile(paths.agentFile(ws, id), "utf8"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Full scan agents/<id>/agent.md -> registry. Async; call on boot only when the index is empty. */
|
|
165
|
+
export async function reindex(ws: string, db: Database): Promise<void> {
|
|
166
|
+
const dir = join(ws, "agents");
|
|
167
|
+
if (!existsSync(dir)) return;
|
|
168
|
+
const ids = await readdir(dir);
|
|
169
|
+
const agents: AgentDef[] = [];
|
|
170
|
+
for (const id of ids) {
|
|
171
|
+
const file = paths.agentFile(ws, id);
|
|
172
|
+
if (!existsSync(file)) continue;
|
|
173
|
+
try { agents.push(parseAgent(await readFile(file, "utf8"))); }
|
|
174
|
+
catch (e) { log.warn(`skipping agent ${id}`, e); }
|
|
175
|
+
}
|
|
176
|
+
// Plan 20 (review finding): delete-then-rebuild, like every sibling reindex (plans/tasks/skills/kb).
|
|
177
|
+
// syncRegistry alone is upsert-only, so a hand-DELETED agent.md left a ghost registry row that no
|
|
178
|
+
// boot or /agents reindex ever removed. Files are canon; the whole table is derived. Plan 22: clear
|
|
179
|
+
// the membership join too, or a removed agent's team rows would linger (membersOf would over-report).
|
|
180
|
+
db.query("DELETE FROM registry").run();
|
|
181
|
+
db.query("DELETE FROM agent_teams").run();
|
|
182
|
+
if (agents.length) syncRegistry(db, agents);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function createAgent(ws: string, db: Database, draft: NewAgentDraft, _taughtBy: string, defaults?: TaichoConfig["defaults"]): Promise<AgentDef> {
|
|
186
|
+
const file = paths.agentFile(ws, draft.id);
|
|
187
|
+
if (existsSync(file)) throw new Error(`agent "${draft.id}" already exists`);
|
|
188
|
+
// Plan 19: agent ids and team ids share ONE namespace, so `delegate_task(to: "news")` never has to
|
|
189
|
+
// guess which kind of thing it is addressing. Checked against the file directly — importing
|
|
190
|
+
// store/teams.ts here would close a cycle (teams.ts needs DEFAULT_WORKER_TOOLS from this module).
|
|
191
|
+
if (existsSync(paths.teamFile(ws, draft.id)))
|
|
192
|
+
throw new Error(`cannot create agent "${draft.id}": a team already has that id (ids are one namespace)`);
|
|
193
|
+
// Plan 22: an agent may be born onto SEVERAL teams; every named one must exist (the implicit `default`
|
|
194
|
+
// is never listed here, so it is not — and cannot be — checked). A missing team is a hard error, not
|
|
195
|
+
// a silent dangling membership.
|
|
196
|
+
const teams = (draft.teams ?? []).filter((t) => t !== DEFAULT_TEAM_ID);
|
|
197
|
+
for (const t of teams) if (!existsSync(paths.teamFile(ws, t))) throw new Error(`no team "${t}"`);
|
|
198
|
+
const agent = AgentDef.parse({
|
|
199
|
+
id: draft.id, role: draft.role, identity: draft.identity, teams,
|
|
200
|
+
// Lifecycle contract (Plan 14 T1/T2): the DEFAULT_WORKER_TOOLS artifact baseline (produce + hand
|
|
201
|
+
// off + consume by reference + annotate/revise) is ALWAYS merged in. An explicit `draft.tools`
|
|
202
|
+
// list ADDS extras (delegate_task, run_command, an "mcp:<server>" ref, …) — it does NOT replace
|
|
203
|
+
// the baseline. This is the enforced contract that create_agent can never again mint a toolless
|
|
204
|
+
// worker: the old `draft.tools ?? [defaults]` let an explicit `tools: []` sail through and defeat
|
|
205
|
+
// the default (?? only fills null/undefined), which is how the whole squad in root/2026-07-04-run6
|
|
206
|
+
// was born unable to save/read/hand-off artifacts. NO MCP grant by default (Plan 08 least
|
|
207
|
+
// privilege — MCP is opt-in via an explicit "mcp:<server>" ref passed in draft.tools).
|
|
208
|
+
tools: workerTools(draft.tools),
|
|
209
|
+
// Plan 19/22: a worker created ONTO teams sees THOSE teams, not all sixty strangers on the squad —
|
|
210
|
+
// which is what keeps root's 30-agent roster cliff unreachable. An unaffiliated worker keeps the old
|
|
211
|
+
// see-everyone default, so nothing changes for a squad that never creates a team.
|
|
212
|
+
canSee: teams.length ? teams.map((t) => `team:${t}`) : ["*"],
|
|
213
|
+
canDelegateTo: [], isRoot: false,
|
|
214
|
+
created: new Date().toISOString(),
|
|
215
|
+
budgets: defaults?.budgets,
|
|
216
|
+
});
|
|
217
|
+
await mkdir(paths.agentDir(ws, agent.id), { recursive: true });
|
|
218
|
+
await writeFile(file, serializeAgent(agent));
|
|
219
|
+
syncRegistry(db, [agent]);
|
|
220
|
+
return agent;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Is this canSee the AUTO-DERIVED shape createAgent produces — see-everyone (`["*"]`) or purely
|
|
224
|
+
* team-scoped (`team:<id>` entries only)? If so, re-teaming the agent may safely re-derive it so a
|
|
225
|
+
* moved worker sees its new teammates. A canSee that names exact agent ids is a hand-customization and
|
|
226
|
+
* is left untouched. An empty canSee (the librarian) is NOT default-shaped — leave it. */
|
|
227
|
+
function isDefaultShapedCanSee(canSee: string[]): boolean {
|
|
228
|
+
if (canSee.length === 1 && canSee[0] === "*") return true;
|
|
229
|
+
return canSee.length > 0 && canSee.every((e) => e.startsWith("team:"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface AgentPatch { role?: string; identity?: string; tools?: string[]; teams?: string[]; canSee?: string[]; canDelegateTo?: string[]; }
|
|
233
|
+
|
|
234
|
+
/** Plan 22: edit an existing agent (the captain's `e`/`c`/`t`/`b` verbs and the CLI). Applies only the
|
|
235
|
+
* fields present in `patch`, re-parses through AgentDef (so a bad value is rejected), rewrites agent.md,
|
|
236
|
+
* and refreshes the derived index. Team edits are validated to exist, the Plan 14 artifact floor is
|
|
237
|
+
* re-merged onto a worker's tools, and a default-shaped canSee is kept in sync with the new teams. */
|
|
238
|
+
export async function updateAgent(ws: string, db: Database, id: string, patch: AgentPatch): Promise<AgentDef> {
|
|
239
|
+
const file = paths.agentFile(ws, id);
|
|
240
|
+
if (!existsSync(file)) throw new Error(`no agent "${id}"`);
|
|
241
|
+
const cur = await loadAgent(ws, id);
|
|
242
|
+
const teams = (patch.teams ?? cur.teams).filter((t) => t !== DEFAULT_TEAM_ID);
|
|
243
|
+
for (const t of teams) if (!existsSync(paths.teamFile(ws, t))) throw new Error(`no team "${t}"`);
|
|
244
|
+
let canSee = patch.canSee ?? cur.canSee;
|
|
245
|
+
if (patch.teams && !patch.canSee && isDefaultShapedCanSee(cur.canSee))
|
|
246
|
+
canSee = teams.length ? teams.map((t) => `team:${t}`) : ["*"];
|
|
247
|
+
const isBuiltIn = cur.isRoot || id === LIBRARIAN_ID;
|
|
248
|
+
const next = AgentDef.parse({
|
|
249
|
+
...cur,
|
|
250
|
+
role: patch.role ?? cur.role,
|
|
251
|
+
identity: patch.identity ?? cur.identity,
|
|
252
|
+
// A worker's tool edit always keeps the artifact floor (Plan 14); root/librarian keep their curated set.
|
|
253
|
+
tools: patch.tools ? (isBuiltIn ? patch.tools : workerTools(patch.tools)) : cur.tools,
|
|
254
|
+
teams,
|
|
255
|
+
canSee,
|
|
256
|
+
canDelegateTo: patch.canDelegateTo ?? cur.canDelegateTo,
|
|
257
|
+
});
|
|
258
|
+
await writeFile(file, serializeAgent(next));
|
|
259
|
+
syncRegistry(db, [next]);
|
|
260
|
+
return next;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Set an agent's explicit team memberships (the `t` verb / member pickers). A thin updateAgent. */
|
|
264
|
+
export async function setAgentTeams(ws: string, db: Database, id: string, teams: string[]): Promise<AgentDef> {
|
|
265
|
+
return updateAgent(ws, db, id, { teams });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Retire an agent (the `d` verb / CLI). Root and the librarian are protected — the squad needs its
|
|
269
|
+
* captain and its keeper of knowledge. Removes the agent's files and its derived rows (registry +
|
|
270
|
+
* membership). A team this agent LED is left with a dangling `lead`, which validateTeams reports on the
|
|
271
|
+
* next boot — deliberately report-and-ask, not a silent rewrite of the team's intent. */
|
|
272
|
+
export async function deleteAgent(ws: string, db: Database, id: string): Promise<void> {
|
|
273
|
+
if (id === "root") throw new Error("cannot retire root — the squad needs its captain");
|
|
274
|
+
if (id === LIBRARIAN_ID) throw new Error("cannot retire the librarian — it keeps the knowledge graph");
|
|
275
|
+
const dir = paths.agentDir(ws, id);
|
|
276
|
+
if (!existsSync(dir)) throw new Error(`no agent "${id}"`);
|
|
277
|
+
await rm(dir, { recursive: true, force: true });
|
|
278
|
+
db.query("DELETE FROM registry WHERE id = ?").run(id);
|
|
279
|
+
db.query("DELETE FROM agent_teams WHERE agent_id = ?").run(id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Plan 14 T3 backfill — rescue existing workers that were born TOOLLESS. A worker persisted with an
|
|
283
|
+
* EMPTY tools list ([]) can't save/read/hand-off artifacts; it can only call the unconditional baseline
|
|
284
|
+
* (find_skills/use_skill) and hand work back as loose text (the root/2026-07-04-run6 squad). On boot we
|
|
285
|
+
* grant such workers the DEFAULT_WORKER_TOOLS baseline and rewrite their agent.md, so a live squad becomes
|
|
286
|
+
* usable without hand-editing each file. This is deliberately a CODE-level migration keyed on the empty
|
|
287
|
+
* list — the definitively-broken signal — NOT on "missing some artifact tool": a NON-empty explicit grant
|
|
288
|
+
* is a deliberate curation choice (root, librarian, or a hand-restricted worker) and is left untouched.
|
|
289
|
+
* Root/librarian are reconciled separately (seedRoot/seedLibrarian) and never carry an empty list, but we
|
|
290
|
+
* skip isRoot defensively. Returns the ids fixed (for a boot notice). */
|
|
291
|
+
export async function reconcileWorkerTools(ws: string): Promise<string[]> {
|
|
292
|
+
const dir = join(ws, "agents");
|
|
293
|
+
if (!existsSync(dir)) return [];
|
|
294
|
+
const fixed: string[] = [];
|
|
295
|
+
for (const id of await readdir(dir)) {
|
|
296
|
+
const file = paths.agentFile(ws, id);
|
|
297
|
+
if (!existsSync(file)) continue;
|
|
298
|
+
let agent: AgentDef;
|
|
299
|
+
try { agent = parseAgent(await readFile(file, "utf8")); }
|
|
300
|
+
catch (e) { log.warn(`reconcileWorkerTools: skipping ${id}`, e); continue; }
|
|
301
|
+
if (agent.isRoot || agent.id === LIBRARIAN_ID) continue; // built-ins reconcile via their seed fns
|
|
302
|
+
if (agent.tools.length > 0) continue; // deliberate non-empty grant — leave alone
|
|
303
|
+
agent.tools = workerTools([]); // born toolless → grant the worker baseline
|
|
304
|
+
await writeFile(file, serializeAgent(agent));
|
|
305
|
+
fixed.push(agent.id);
|
|
306
|
+
}
|
|
307
|
+
return fixed;
|
|
308
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Per-run evidence files. Trace JSON remains the summary; this directory keeps debuggable context. */
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ModelMessage } from "ai";
|
|
5
|
+
import { paths } from "./files";
|
|
6
|
+
import type { RunTrace } from "@taicho-ai/contracts/trace";
|
|
7
|
+
|
|
8
|
+
export interface RunTranscriptEvent {
|
|
9
|
+
ts: string;
|
|
10
|
+
kind: string;
|
|
11
|
+
iteration?: number;
|
|
12
|
+
data?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeRunInput(ws: string, runId: string, input: {
|
|
16
|
+
runId: string;
|
|
17
|
+
triggeredBy: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
task: string;
|
|
20
|
+
messagesPassedToModel: ModelMessage[];
|
|
21
|
+
parentRunId?: string;
|
|
22
|
+
}): void {
|
|
23
|
+
const dir = paths.runRecordDir(ws, runId);
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
writeFileSync(join(dir, "input.json"), JSON.stringify(input, null, 2));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function appendRunTranscript(ws: string, runId: string, event: RunTranscriptEvent): void {
|
|
29
|
+
const dir = paths.runRecordDir(ws, runId);
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
appendFileSync(join(dir, "transcript.jsonl"), JSON.stringify(event) + "\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Plan 04 Phase 5: overwrite the resume checkpoint each iteration with the loop's message array
|
|
35
|
+
* (its only real state) + the iteration index. A crashed run's last checkpoint is where a future
|
|
36
|
+
* resume would restart. Overwritten (not appended) — only the latest matters. */
|
|
37
|
+
export function writeRunCheckpoint(ws: string, runId: string, state: { iteration: number; messages: ModelMessage[] }): void {
|
|
38
|
+
const dir = paths.runRecordDir(ws, runId);
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
writeFileSync(join(dir, "checkpoint.json"), JSON.stringify({ runId, ...state, updated: new Date().toISOString() }, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a run's transcript.jsonl back as parsed events (the waterfall reader; returns [] if absent).
|
|
44
|
+
* Unparseable lines are skipped so a truncated/in-progress file never throws. */
|
|
45
|
+
export function readRunTranscript(ws: string, runId: string): RunTranscriptEvent[] {
|
|
46
|
+
const file = join(paths.runRecordDir(ws, runId), "transcript.jsonl");
|
|
47
|
+
if (!existsSync(file)) return [];
|
|
48
|
+
const out: RunTranscriptEvent[] = [];
|
|
49
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
50
|
+
if (!line.trim()) continue;
|
|
51
|
+
try { out.push(JSON.parse(line) as RunTranscriptEvent); } catch { /* skip a partial line */ }
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function writeRunFinal(ws: string, runId: string, text: string): void {
|
|
57
|
+
const dir = paths.runRecordDir(ws, runId);
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
writeFileSync(join(dir, "final.md"), text);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function writeRunFailure(ws: string, runId: string, trace: RunTrace, text: string): void {
|
|
63
|
+
if (trace.outcome === "completed") return;
|
|
64
|
+
const dir = paths.runRecordDir(ws, runId);
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
const childLines = trace.delegatedOut.length ? trace.delegatedOut.map((id) => `- ${id}`).join("\n") : "none";
|
|
67
|
+
writeFileSync(join(dir, "failure.md"), [
|
|
68
|
+
`# ${runId} failed evidence`,
|
|
69
|
+
"",
|
|
70
|
+
`outcome: ${trace.outcome}`,
|
|
71
|
+
`agent: ${trace.agent}`,
|
|
72
|
+
`task: ${trace.task}`,
|
|
73
|
+
`triggeredBy: ${trace.triggeredBy}`,
|
|
74
|
+
`tokens: ${trace.tokens}`,
|
|
75
|
+
`durationMs: ${trace.durationMs}`,
|
|
76
|
+
"",
|
|
77
|
+
"## Final text",
|
|
78
|
+
text || "(none)",
|
|
79
|
+
"",
|
|
80
|
+
"## Child runs",
|
|
81
|
+
childLines,
|
|
82
|
+
"",
|
|
83
|
+
"## Notes",
|
|
84
|
+
trace.notes.length ? trace.notes.map((n) => `- ${n}`).join("\n") : "none",
|
|
85
|
+
"",
|
|
86
|
+
].join("\n"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function writeChildRuns(ws: string, runId: string, childRuns: RunTrace[]): void {
|
|
90
|
+
const dir = paths.runRecordDir(ws, runId);
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
writeFileSync(join(dir, "child-runs.json"), JSON.stringify(childRuns.map((t) => ({
|
|
93
|
+
runId: t.id,
|
|
94
|
+
agent: t.agent,
|
|
95
|
+
task: t.task,
|
|
96
|
+
outcome: t.outcome,
|
|
97
|
+
usableOutput: t.outcome === "completed",
|
|
98
|
+
tokens: t.tokens,
|
|
99
|
+
aggregate: t.aggregate,
|
|
100
|
+
artifacts: t.artifacts,
|
|
101
|
+
delegatedOut: t.delegatedOut,
|
|
102
|
+
})), null, 2));
|
|
103
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Plan 04 Phase 6 — durable schedule store. Files under `schedules/<id>.json` are canon: a schedule
|
|
2
|
+
* survives a restart and is reconciled/armed on boot (`listSchedules`). The set is small (a handful
|
|
3
|
+
* of captain-created schedules), so a file scan is the whole query surface — no DB index needed
|
|
4
|
+
* (unlike `/tasks`, which needs fast filtered queries over many rows). */
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { paths } from "./files";
|
|
8
|
+
import { Schedule, type ScheduleSpec } from "@taicho-ai/contracts/schedule";
|
|
9
|
+
import { validateTrigger } from "../core/scheduler";
|
|
10
|
+
import { log } from "../core/logger";
|
|
11
|
+
|
|
12
|
+
function scheduleFile(ws: string, id: string): string {
|
|
13
|
+
return join(paths.scheduleDir(ws), `${id}.json`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A stable, filesystem-safe id: an explicit `--id name` (sanitized) or a generated one. */
|
|
17
|
+
function mkScheduleId(explicit?: string): string {
|
|
18
|
+
if (explicit) {
|
|
19
|
+
const clean = explicit.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
20
|
+
if (clean) return clean;
|
|
21
|
+
}
|
|
22
|
+
return `sched_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Atomic write — temp file + `rename` (atomic on POSIX) — so a crash mid-write can never leave a
|
|
26
|
+
* half-written `<id>.json` that `listSchedules` would silently drop (losing the schedule). The temp
|
|
27
|
+
* suffix isn't `.json`, so a leftover temp from a crashed write is ignored by `listSchedules`.
|
|
28
|
+
* Mirrors the pattern already used by `artifacts.ts`. */
|
|
29
|
+
function write(ws: string, schedule: Schedule): void {
|
|
30
|
+
mkdirSync(paths.scheduleDir(ws), { recursive: true });
|
|
31
|
+
const dest = scheduleFile(ws, schedule.id);
|
|
32
|
+
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
|
33
|
+
writeFileSync(tmp, JSON.stringify(schedule, null, 2));
|
|
34
|
+
renameSync(tmp, dest);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Create + persist a new schedule. Validates the trigger (a bad cron expression throws HERE, at
|
|
38
|
+
* creation, never silently at fire time). Rejects a duplicate explicit id. */
|
|
39
|
+
export function createSchedule(ws: string, spec: ScheduleSpec, nowMs = Date.now()): Schedule {
|
|
40
|
+
validateTrigger(spec.trigger, nowMs); // throws on an unfireable cron
|
|
41
|
+
const id = mkScheduleId(spec.id);
|
|
42
|
+
if (existsSync(scheduleFile(ws, id))) throw new Error(`schedule "${id}" already exists`);
|
|
43
|
+
const now = new Date(nowMs).toISOString();
|
|
44
|
+
const schedule = Schedule.parse({
|
|
45
|
+
id,
|
|
46
|
+
goal: spec.goal,
|
|
47
|
+
agent: spec.agent ?? "root",
|
|
48
|
+
trigger: spec.trigger,
|
|
49
|
+
approve: spec.approve ?? "reject",
|
|
50
|
+
enabled: true,
|
|
51
|
+
created: now,
|
|
52
|
+
updated: now,
|
|
53
|
+
runCount: 0,
|
|
54
|
+
});
|
|
55
|
+
write(ws, schedule);
|
|
56
|
+
return schedule;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readSchedule(ws: string, id: string): Schedule | null {
|
|
60
|
+
const f = scheduleFile(ws, id);
|
|
61
|
+
if (!existsSync(f)) return null;
|
|
62
|
+
try { return Schedule.parse(JSON.parse(readFileSync(f, "utf8"))); }
|
|
63
|
+
catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** All schedules, newest first — the boot reconcile/arm list AND the `/schedules list` source. */
|
|
67
|
+
export function listSchedules(ws: string): Schedule[] {
|
|
68
|
+
const dir = paths.scheduleDir(ws);
|
|
69
|
+
if (!existsSync(dir)) return [];
|
|
70
|
+
const out: Schedule[] = [];
|
|
71
|
+
for (const f of readdirSync(dir)) {
|
|
72
|
+
if (!f.endsWith(".json")) continue;
|
|
73
|
+
try { out.push(Schedule.parse(JSON.parse(readFileSync(join(dir, f), "utf8")))); }
|
|
74
|
+
catch (e) { log.warn(`skipped unparseable schedule file ${f}`, e); } // surfaced, not silently lost
|
|
75
|
+
}
|
|
76
|
+
return out.sort((a, b) => b.created.localeCompare(a.created));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Field-level patch of a schedule (the runner advances lastRunAt/nextDueAt/runCount; fire records
|
|
80
|
+
* lastRunId/lastStatus). Reads-merges-writes so concurrent patches never clobber each other's fields. */
|
|
81
|
+
export function updateSchedule(ws: string, id: string, patch: Partial<Schedule>): Schedule | null {
|
|
82
|
+
const cur = readSchedule(ws, id);
|
|
83
|
+
if (!cur) return null;
|
|
84
|
+
const next = Schedule.parse({ ...cur, ...patch, id: cur.id, updated: new Date().toISOString() });
|
|
85
|
+
write(ws, next);
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function removeSchedule(ws: string, id: string): boolean {
|
|
90
|
+
const f = scheduleFile(ws, id);
|
|
91
|
+
if (!existsSync(f)) return false;
|
|
92
|
+
rmSync(f);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Starter skills shipped so the squad is useful out of the box. Written as canonical files on first
|
|
2
|
+
* boot (only when skills/ is empty), then indexed by reindexSkills. Fixed ids ⇒ idempotent. */
|
|
3
|
+
import { mkdir, writeFile, readdir } from "node:fs/promises";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { Skill } from "@taicho-ai/contracts/skill";
|
|
6
|
+
import { serializeSkill } from "./skills";
|
|
7
|
+
import { paths } from "./files";
|
|
8
|
+
|
|
9
|
+
const SEED_TS = "2026-07-02T00:00:00.000Z";
|
|
10
|
+
|
|
11
|
+
export const STARTER_SKILLS: Skill[] = [
|
|
12
|
+
Skill.parse({
|
|
13
|
+
id: "skill_write_artifact", name: "write-a-clear-artifact",
|
|
14
|
+
description: "Produce a clean, useful artifact when a task asks you to write or document something.",
|
|
15
|
+
tags: ["writing", "artifact", "documentation"], created: SEED_TS,
|
|
16
|
+
body: [
|
|
17
|
+
"Follow this before you save_artifact a work product (the structured, always-granted hand-off tool):",
|
|
18
|
+
"1. Put the goal/answer in one line at the top — lead with the conclusion, then the detail.",
|
|
19
|
+
"2. Structure with headings; prefer lists and tables over walls of prose where they aid scanning.",
|
|
20
|
+
"3. Be concrete: real names, paths, commands, numbers. Cut vague filler.",
|
|
21
|
+
"4. Self-check: does it answer the ACTUAL request? Is anything unverified? Cut it or flag it explicitly.",
|
|
22
|
+
"5. Give it a clear title and a lowercase-hyphen id that names the deliverable, plus a short summary readers see before they pull the body; you get back a handle (id@vN) to hand off by reference.",
|
|
23
|
+
].join("\n"),
|
|
24
|
+
}),
|
|
25
|
+
Skill.parse({
|
|
26
|
+
id: "skill_delegate", name: "delegate-a-task-well",
|
|
27
|
+
description: "Hand a goal to another agent effectively when the work isn't yours to do directly.",
|
|
28
|
+
tags: ["delegation", "coordination", "squad"], created: SEED_TS,
|
|
29
|
+
body: [
|
|
30
|
+
"When delegating with delegate_task:",
|
|
31
|
+
"1. If you're unsure who should do it, call find_agents(capability) first and pick by role match.",
|
|
32
|
+
"2. Hand off a GOAL (the outcome), not step-by-step orders — let the agent apply its judgment.",
|
|
33
|
+
"3. Put the context it needs (constraints, prior decisions, ids/artifacts to build on) in `context`.",
|
|
34
|
+
"4. One clear deliverable per delegation; split unrelated goals into separate delegations.",
|
|
35
|
+
"5. Never delegate to yourself or an ancestor (cycles are refused). Respect the work-item budget.",
|
|
36
|
+
"6. Use the returned result. If it failed, read the error before retrying a different way.",
|
|
37
|
+
].join("\n"),
|
|
38
|
+
}),
|
|
39
|
+
Skill.parse({
|
|
40
|
+
id: "skill_hand_off_artifacts", name: "hand-off-work-by-reference",
|
|
41
|
+
description: "Move work products between agents (and to the human) by reference — save outputs as artifacts and consume others' by handle instead of pasting content into the conversation.",
|
|
42
|
+
tags: ["artifact", "handoff", "delegation", "context-hygiene"], created: SEED_TS,
|
|
43
|
+
body: [
|
|
44
|
+
"Keep heavy content OUT of the conversation — hand it off by reference:",
|
|
45
|
+
"1. PRODUCE: when you finish a real work product, save_artifact it (give a clear title, a free-form `type` tag, and a short `summary`). You get back a handle like research-foo@v1.",
|
|
46
|
+
"2. HAND OFF: when you delegate_task, pass the handles in `inputArtifacts` — never paste the content into `context`. The child reads what it needs with read_artifact.",
|
|
47
|
+
"3. CONSUME: given an input artifact, call read_artifact(handle) — you get metadata + summary by default; add includeBody:true only when you truly need the (size-capped) body.",
|
|
48
|
+
"4. DISCOVER: list_artifacts (filter by producer/type/role/q) to find what others have produced before re-deriving it.",
|
|
49
|
+
"5. REVISE: to update an artifact, save_artifact with the SAME id — it becomes a new immutable version; nothing is overwritten. Set `parents` to record lineage.",
|
|
50
|
+
"6. EXTERNAL: if the work product lives in a system an MCP server fronts (a Notion page, a ClickUp task), save it with `external` (a locator) instead of a local body.",
|
|
51
|
+
].join("\n"),
|
|
52
|
+
}),
|
|
53
|
+
Skill.parse({
|
|
54
|
+
id: "skill_use_kb", name: "use-the-knowledgebase",
|
|
55
|
+
description: "Recall shared knowledge before acting and record durable facts so the squad stops re-deriving them.",
|
|
56
|
+
tags: ["knowledge", "memory", "recall", "remember"], created: SEED_TS,
|
|
57
|
+
body: [
|
|
58
|
+
"Use the squad knowledgebase on repeatable work:",
|
|
59
|
+
"1. recall(query) FIRST — reuse existing facts/decisions instead of re-deriving them.",
|
|
60
|
+
"2. When you learn something durable (a decision, entity, or fact), remember it with a clear title and typed edges to related nodes (recall first to get ids to link to).",
|
|
61
|
+
"3. Keep nodes atomic and self-contained; prefer linking over duplicating.",
|
|
62
|
+
"4. When it matters, cite the node ids you relied on in your output.",
|
|
63
|
+
].join("\n"),
|
|
64
|
+
}),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export async function seedSkills(ws: string): Promise<void> {
|
|
68
|
+
const dir = paths.skillsDir(ws);
|
|
69
|
+
await mkdir(dir, { recursive: true });
|
|
70
|
+
if (existsSync(dir)) {
|
|
71
|
+
const has = (await readdir(dir)).some((f) => f.endsWith(".md"));
|
|
72
|
+
if (has) return; // don't clobber existing curation
|
|
73
|
+
}
|
|
74
|
+
for (const s of STARTER_SKILLS) await writeFile(paths.skillFile(ws, s.id), serializeSkill(s));
|
|
75
|
+
}
|