takomi 2.1.44 → 2.5.1

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 (50) hide show
  1. package/.pi/README.md +9 -8
  2. package/.pi/agents/architect.md +2 -2
  3. package/.pi/agents/designer.md +2 -2
  4. package/.pi/agents/worker.md +32 -0
  5. package/.pi/extensions/oauth-router/commands.ts +66 -35
  6. package/.pi/extensions/oauth-router/index.ts +51 -7
  7. package/.pi/extensions/oauth-router/report-ui.ts +205 -0
  8. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
  9. package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
  10. package/.pi/extensions/takomi-context-manager/index.ts +3 -2
  11. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +50 -117
  12. package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
  13. package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
  14. package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
  15. package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
  16. package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
  17. package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
  18. package/.pi/extensions/takomi-context-manager/types.ts +6 -0
  19. package/.pi/extensions/takomi-runtime/command-text.ts +5 -4
  20. package/.pi/extensions/takomi-runtime/commands.ts +58 -25
  21. package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
  22. package/.pi/extensions/takomi-runtime/index.ts +385 -124
  23. package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +262 -326
  24. package/.pi/extensions/takomi-runtime/profile.ts +9 -8
  25. package/.pi/extensions/takomi-runtime/routing-policy.ts +97 -66
  26. package/.pi/extensions/takomi-runtime/shared.ts +7 -30
  27. package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
  28. package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
  29. package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
  30. package/.pi/extensions/takomi-subagents/agents.ts +9 -0
  31. package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
  32. package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
  33. package/.pi/extensions/takomi-subagents/index.ts +50 -1
  34. package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
  35. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +62 -47
  36. package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
  37. package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
  38. package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
  39. package/.pi/extensions/takomi-subagents/tool-runner.ts +198 -29
  40. package/.pi/settings.json +39 -36
  41. package/.pi/takomi/model-routing.md +288 -3
  42. package/.pi/takomi-profile.json +54 -50
  43. package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
  44. package/package.json +4 -3
  45. package/src/pi-takomi-core/orchestration.ts +39 -21
  46. package/src/pi-takomi-core/routing.ts +8 -8
  47. package/src/pi-takomi-core/types.ts +35 -5
  48. package/src/pi-takomi-core/workflows.ts +86 -45
  49. package/src/skills-catalog.js +2 -202
  50. package/src/skills-installer.js +4 -1
@@ -1,326 +1,262 @@
1
- import * as fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import {
5
- BUNDLED_TAKOMI_ROUTING_POLICY_PATH,
6
- GLOBAL_PI_SETTINGS_PATH,
7
- GLOBAL_TAKOMI_ROUTING_POLICY_PATH,
8
- PROJECT_PI_SETTINGS_RELATIVE,
9
- TAKOMI_ROUTING_POLICY_RELATIVE,
10
- resolveTakomiRoutingPolicy,
11
- } from "./routing-policy";
12
-
13
- export const TAKOMI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
14
- const PROJECT_TAKOMI_POLICY_ROOT_RELATIVE = path.join(".pi", "takomi");
15
- const MAX_POLICY_BYTES = 128 * 1024;
16
-
17
- type Settings = {
18
- takomi?: { modelRoutingPolicyFile?: string };
19
- subagents?: { agentOverrides?: Record<string, unknown> };
20
- };
21
-
22
- export type TakomiAgentModelDefault = {
23
- agent: string;
24
- model?: string;
25
- thinking?: string;
26
- fallbackModels?: string[];
27
- };
28
-
29
- export type TakomiModelRoutingSnapshot = {
30
- approvedModels: string[];
31
- preferredModels: string[];
32
- sourceFiles: string[];
33
- agentDefaults: TakomiAgentModelDefault[];
34
- };
35
-
36
- function asRecord(value: unknown): Record<string, unknown> {
37
- return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
38
- }
39
-
40
- function unique(values: string[]): string[] {
41
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
42
- }
43
-
44
- async function readSettingsFile(filePath: string): Promise<Settings> {
45
- try {
46
- return JSON.parse(await fs.promises.readFile(filePath, "utf8")) as Settings;
47
- } catch {
48
- return {};
49
- }
50
- }
51
-
52
- function readSettingsFileSync(filePath: string): Settings {
53
- try {
54
- return JSON.parse(fs.readFileSync(filePath, "utf8")) as Settings;
55
- } catch {
56
- return {};
57
- }
58
- }
59
-
60
- function mergeSettings(globalSettings: Settings, projectSettings: Settings): Settings {
61
- const globalOverrides = asRecord(globalSettings.subagents?.agentOverrides);
62
- const projectOverrides = asRecord(projectSettings.subagents?.agentOverrides);
63
- return {
64
- ...globalSettings,
65
- ...projectSettings,
66
- takomi: { ...(globalSettings.takomi ?? {}), ...(projectSettings.takomi ?? {}) },
67
- subagents: {
68
- ...(globalSettings.subagents ?? {}),
69
- ...(projectSettings.subagents ?? {}),
70
- agentOverrides: { ...globalOverrides, ...projectOverrides },
71
- },
72
- };
73
- }
74
-
75
- async function readSettings(cwd: string): Promise<Settings> {
76
- const globalSettings = await readSettingsFile(path.join(os.homedir(), ".pi", "agent", "settings.json"));
77
- const projectSettings = await readSettingsFile(path.resolve(cwd, ".pi/settings.json"));
78
- return mergeSettings(globalSettings, projectSettings);
79
- }
80
-
81
- function readSettingsSync(cwd: string): Settings {
82
- const globalSettings = readSettingsFileSync(path.join(os.homedir(), ".pi", "agent", "settings.json"));
83
- const projectSettings = readSettingsFileSync(path.resolve(cwd, ".pi/settings.json"));
84
- return mergeSettings(globalSettings, projectSettings);
85
- }
86
-
87
- function collectAgentDefaultsFromSettings(settings: Settings): TakomiAgentModelDefault[] {
88
- const overrides = asRecord(settings.subagents?.agentOverrides);
89
- const defaults: TakomiAgentModelDefault[] = [];
90
- for (const [agent, value] of Object.entries(overrides)) {
91
- const record = asRecord(value);
92
- const fallbackModels = Array.isArray(record.fallbackModels)
93
- ? record.fallbackModels.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
94
- : undefined;
95
- defaults.push({
96
- agent,
97
- model: typeof record.model === "string" ? record.model : undefined,
98
- thinking: typeof record.thinking === "string" ? record.thinking : undefined,
99
- fallbackModels,
100
- });
101
- }
102
- return defaults;
103
- }
104
-
105
- function collectModelsFromDefaults(defaults: TakomiAgentModelDefault[]): string[] {
106
- const models: string[] = [];
107
- for (const record of defaults) {
108
- if (record.model) models.push(stripThinkingSuffix(record.model).baseModel);
109
- if (Array.isArray(record.fallbackModels)) {
110
- for (const fallback of record.fallbackModels) models.push(stripThinkingSuffix(fallback).baseModel);
111
- }
112
- }
113
- return models;
114
- }
115
-
116
- function isModelLike(value: string): boolean {
117
- const lower = value.toLowerCase();
118
- return /(^|\/)(gpt|claude|gemini|o[0-9]|qwen|deepseek|llama|mistral|kimi|grok|sonnet|haiku|opus|codex|mini|max)/i.test(lower)
119
- || lower.includes("oauth-router/")
120
- || lower.includes("openai-codex/")
121
- || lower.includes("lmstudio/");
122
- }
123
-
124
- function extractPreferredProvider(text: string): string | undefined {
125
- const match = text.match(/(?:preferred|default)\s+(?:provider|router)(?:\s*\/\s*(?:provider|router))?\s*:\s*([a-z0-9-]+)/i)
126
- ?? text.match(/use\s+([a-z0-9-]+)\s+as\s+(?:the\s+)?(?:provider|router)/i);
127
- return match?.[1];
128
- }
129
-
130
- function collectModelsFromPolicy(text: string): string[] {
131
- // Provider-agnostic policy text like "GPT-5.5" expresses routing intent, not
132
- // a concrete provider. A preferred provider/router header intentionally binds
133
- // those intent labels to executable provider-qualified IDs.
134
- const explicit = (text.match(/[a-z0-9-]+\/[a-z0-9._-]+/gi) ?? []).filter(isModelLike);
135
- const preferredProvider = extractPreferredProvider(text);
136
- const inferred: string[] = [];
137
- if (preferredProvider && /gpt[- ]?5\.5/i.test(text)) inferred.push(`${preferredProvider}/gpt-5.5`);
138
- if (preferredProvider && /gpt[- ]?5\.4(?!\s*mini)/i.test(text)) inferred.push(`${preferredProvider}/gpt-5.4`);
139
- if (preferredProvider && /gpt[- ]?5\.4\s*mini/i.test(text)) inferred.push(`${preferredProvider}/gpt-5.4-mini`);
140
- return unique([...explicit, ...inferred].map((model) => stripThinkingSuffix(model).baseModel));
141
- }
142
-
143
- function readPolicyTextSync(filePath: string): string | undefined {
144
- try {
145
- const info = fs.statSync(filePath);
146
- if (!info.isFile() || info.size > MAX_POLICY_BYTES) return undefined;
147
- const text = fs.readFileSync(filePath, "utf8").trim();
148
- return text || undefined;
149
- } catch {
150
- return undefined;
151
- }
152
- }
153
-
154
- function isPathInside(root: string, candidate: string): boolean {
155
- const relative = path.relative(root, candidate);
156
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
157
- }
158
-
159
- function resolveSafeProjectPolicyPathSync(cwd: string, configured: string): string | undefined {
160
- if (!configured) return undefined;
161
-
162
- const projectPolicyRoot = path.resolve(cwd, PROJECT_TAKOMI_POLICY_ROOT_RELATIVE);
163
- const resolvedPath = path.isAbsolute(configured) ? path.resolve(configured) : path.resolve(cwd, configured);
164
- if (!isPathInside(projectPolicyRoot, resolvedPath)) return undefined;
165
-
166
- try {
167
- const realCwd = fs.realpathSync(cwd);
168
- const realRoot = fs.realpathSync(projectPolicyRoot);
169
- const realFile = fs.realpathSync(resolvedPath);
170
- if (!isPathInside(realCwd, realRoot)) return undefined;
171
- if (!isPathInside(realRoot, realFile)) return undefined;
172
- return realFile;
173
- } catch {
174
- return resolvedPath;
175
- }
176
- }
177
-
178
- function resolvePolicySync(cwd: string): { policyPath?: string; text?: string } {
179
- const projectSettings = readSettingsFileSync(path.join(cwd, PROJECT_PI_SETTINGS_RELATIVE));
180
- const projectTakomi = asRecord(projectSettings.takomi);
181
- const configuredProject = typeof projectTakomi.modelRoutingPolicyFile === "string"
182
- ? projectTakomi.modelRoutingPolicyFile
183
- : TAKOMI_ROUTING_POLICY_RELATIVE;
184
- const configuredProjectPath = resolveSafeProjectPolicyPathSync(cwd, configuredProject);
185
- if (configuredProjectPath) {
186
- const configuredProjectText = readPolicyTextSync(configuredProjectPath);
187
- if (configuredProjectText) return { policyPath: configuredProjectPath, text: configuredProjectText };
188
- }
189
-
190
- const defaultProjectPath = resolveSafeProjectPolicyPathSync(cwd, TAKOMI_ROUTING_POLICY_RELATIVE);
191
- if (defaultProjectPath && path.resolve(defaultProjectPath) !== path.resolve(configuredProjectPath ?? "")) {
192
- const defaultProjectText = readPolicyTextSync(defaultProjectPath);
193
- if (defaultProjectText) return { policyPath: defaultProjectPath, text: defaultProjectText };
194
- }
195
-
196
- const globalSettings = readSettingsFileSync(GLOBAL_PI_SETTINGS_PATH);
197
- const globalTakomi = asRecord(globalSettings.takomi);
198
- const configuredGlobal = typeof globalTakomi.modelRoutingPolicyFile === "string"
199
- ? globalTakomi.modelRoutingPolicyFile
200
- : GLOBAL_TAKOMI_ROUTING_POLICY_PATH;
201
- const configuredGlobalPath = path.isAbsolute(configuredGlobal) ? configuredGlobal : path.join(os.homedir(), configuredGlobal);
202
- const configuredGlobalText = readPolicyTextSync(configuredGlobalPath);
203
- if (configuredGlobalText) return { policyPath: configuredGlobalPath, text: configuredGlobalText };
204
-
205
- if (path.resolve(configuredGlobalPath) !== path.resolve(GLOBAL_TAKOMI_ROUTING_POLICY_PATH)) {
206
- const globalText = readPolicyTextSync(GLOBAL_TAKOMI_ROUTING_POLICY_PATH);
207
- if (globalText) return { policyPath: GLOBAL_TAKOMI_ROUTING_POLICY_PATH, text: globalText };
208
- }
209
-
210
- const bundledText = readPolicyTextSync(BUNDLED_TAKOMI_ROUTING_POLICY_PATH);
211
- return bundledText ? { policyPath: BUNDLED_TAKOMI_ROUTING_POLICY_PATH, text: bundledText } : {};
212
- }
213
-
214
- export function stripThinkingSuffix(model: string): { baseModel: string; thinkingSuffix: string } {
215
- const colonIdx = model.lastIndexOf(":");
216
- if (colonIdx === -1) return { baseModel: model, thinkingSuffix: "" };
217
- const suffix = model.slice(colonIdx + 1).toLowerCase();
218
- if (!(TAKOMI_THINKING_LEVELS as readonly string[]).includes(suffix)) return { baseModel: model, thinkingSuffix: "" };
219
- return { baseModel: model.slice(0, colonIdx), thinkingSuffix: `:${suffix}` };
220
- }
221
-
222
- export function modelFamily(model: string): string {
223
- const baseModel = stripThinkingSuffix(model).baseModel;
224
- return baseModel.split("/").at(-1)?.toLowerCase() ?? baseModel.toLowerCase();
225
- }
226
-
227
- export function isTakomiModelApproved(requested: string, approved: string[]): boolean {
228
- const requestedBase = stripThinkingSuffix(requested).baseModel;
229
- return approved.some((candidate) => stripThinkingSuffix(candidate).baseModel === requestedBase);
230
- }
231
-
232
- export function approvedModelEquivalent(requested: string, approved: string[]): string | undefined {
233
- const { thinkingSuffix } = stripThinkingSuffix(requested);
234
- const requestedFamily = modelFamily(requested);
235
- const equivalent = approved.find((candidate) => modelFamily(candidate) === requestedFamily);
236
- return equivalent ? `${stripThinkingSuffix(equivalent).baseModel}${thinkingSuffix}` : undefined;
237
- }
238
-
239
- export function normalizeModelToApproved(requested: string | undefined, approved: string[], fallback?: string): string | undefined {
240
- if (!requested) return fallback;
241
- if (isTakomiModelApproved(requested, approved)) return requested;
242
- return approvedModelEquivalent(requested, approved) ?? fallback ?? requested;
243
- }
244
-
245
- function normalizedAgentKey(agent: string): string {
246
- return agent.toLowerCase().replace(/[^a-z0-9]+/g, "");
247
- }
248
-
249
- const DEFAULT_ALIAS_KEYS: Record<string, string[]> = {
250
- architect: ["architect", "planner", "oracle", "worker"],
251
- orchestrator: ["orchestrator", "planner", "oracle", "worker"],
252
- planner: ["planner", "oracle", "worker"],
253
- coder: ["coder", "code", "worker", "delegate"],
254
- code: ["code", "coder", "worker", "delegate"],
255
- designer: ["designer", "design", "worker", "delegate"],
256
- design: ["design", "designer", "worker", "delegate"],
257
- reviewer: ["reviewer", "review", "oracle"],
258
- review: ["review", "reviewer", "oracle"],
259
- general: ["general", "worker"],
260
- };
261
-
262
- export function resolveAgentRoutingDefault(snapshot: TakomiModelRoutingSnapshot, agent: string): TakomiAgentModelDefault | undefined {
263
- const normalized = normalizedAgentKey(agent);
264
- const candidates = [normalized, ...(DEFAULT_ALIAS_KEYS[normalized] ?? []), "worker"].map(normalizedAgentKey);
265
- return snapshot.agentDefaults.find((entry) => candidates.includes(normalizedAgentKey(entry.agent)));
266
- }
267
-
268
- export function applyTakomiRoutingDefaults<T extends { agent: string; model?: string; fallbackModels?: string[]; thinking?: string }>(
269
- task: T,
270
- snapshot: TakomiModelRoutingSnapshot,
271
- ): T {
272
- const defaults = resolveAgentRoutingDefault(snapshot, task.agent);
273
- if (!snapshot.approvedModels.length && !defaults) return task;
274
- const approved = snapshot.approvedModels;
275
- const model = normalizeModelToApproved(task.model, approved, defaults?.model);
276
- const thinking = task.thinking ?? defaults?.thinking;
277
- const mergedFallbacks = unique([...(task.fallbackModels ?? []), ...(defaults?.fallbackModels ?? [])]);
278
- const fallbackModels = unique(mergedFallbacks
279
- .map((fallback) => normalizeModelToApproved(fallback, approved))
280
- .filter((fallback): fallback is string => Boolean(fallback))
281
- .filter((fallback) => !approved.length || isTakomiModelApproved(fallback, approved)));
282
-
283
- return {
284
- ...task,
285
- ...(model ? { model } : {}),
286
- ...(thinking ? { thinking } : {}),
287
- ...(fallbackModels.length ? { fallbackModels } : {}),
288
- };
289
- }
290
-
291
- export async function loadTakomiModelRoutingSnapshot(cwd: string): Promise<TakomiModelRoutingSnapshot> {
292
- const settings = await readSettings(cwd);
293
- const agentDefaults = collectAgentDefaultsFromSettings(settings);
294
- const settingsModels = collectModelsFromDefaults(agentDefaults);
295
- const resolvedPolicy = await resolveTakomiRoutingPolicy(cwd);
296
- const sourceFiles = resolvedPolicy.policyPath ? [resolvedPolicy.policyPath] : [];
297
- const policyModels = resolvedPolicy.text ? collectModelsFromPolicy(resolvedPolicy.text) : [];
298
- const approvedModels = unique([...settingsModels, ...policyModels]);
299
- return { approvedModels, preferredModels: settingsModels.length ? unique(settingsModels) : approvedModels, sourceFiles, agentDefaults };
300
- }
301
-
302
- export function loadTakomiModelRoutingSnapshotSync(cwd: string): TakomiModelRoutingSnapshot {
303
- const settings = readSettingsSync(cwd);
304
- const agentDefaults = collectAgentDefaultsFromSettings(settings);
305
- const settingsModels = collectModelsFromDefaults(agentDefaults);
306
- const resolvedPolicy = resolvePolicySync(cwd);
307
- const sourceFiles = resolvedPolicy.policyPath ? [resolvedPolicy.policyPath] : [];
308
- const policyModels = resolvedPolicy.text ? collectModelsFromPolicy(resolvedPolicy.text) : [];
309
- const approvedModels = unique([...settingsModels, ...policyModels]);
310
- return { approvedModels, preferredModels: settingsModels.length ? unique(settingsModels) : approvedModels, sourceFiles, agentDefaults };
311
- }
312
-
313
- export function renderCompactTakomiModelRoutingSummary(snapshot: TakomiModelRoutingSnapshot): string {
314
- if (!snapshot.approvedModels.length && !snapshot.agentDefaults.length) return "";
315
- const defaultLines = snapshot.agentDefaults
316
- .filter((entry) => entry.model)
317
- .map((entry) => `- ${entry.agent}: ${entry.model}${entry.thinking ? ` (${entry.thinking})` : ""}${entry.fallbackModels?.length ? `; fallbacks ${entry.fallbackModels.join(", ")}` : ""}`);
318
- return [
319
- "Active Takomi subagent routing summary:",
320
- snapshot.sourceFiles.length ? `Policy source: ${snapshot.sourceFiles.join(", ")}` : "Policy source: settings/defaults",
321
- snapshot.approvedModels.length ? `Approved model IDs: ${snapshot.approvedModels.join(", ")}` : "Approved model IDs: none discovered",
322
- "When calling takomi_subagent, omit model to use these defaults or use only the approved provider-qualified IDs above. Do not use openai-codex/* when an oauth-router/* equivalent is approved.",
323
- defaultLines.length ? "Role defaults:" : "",
324
- ...defaultLines,
325
- ].filter(Boolean).join("\n");
326
- }
1
+ import * as fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { resolveTakomiRoutingPolicy } from "./routing-policy";
5
+
6
+ export const TAKOMI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
7
+
8
+ type RoutingDefaultInput = {
9
+ model?: unknown;
10
+ thinking?: unknown;
11
+ fallbackModels?: unknown;
12
+ };
13
+
14
+ type RoutingSettings = {
15
+ takomi?: {
16
+ modelRoutingPolicyFile?: string;
17
+ routing?: {
18
+ defaultProvider?: string;
19
+ approvedModels?: string[];
20
+ roleDefaults?: Record<string, RoutingDefaultInput>;
21
+ };
22
+ };
23
+ subagents?: { agentOverrides?: Record<string, RoutingDefaultInput> };
24
+ };
25
+
26
+ export type TakomiRoutingSource = "explicit task" | "project role default" | "global role default" | "harness default";
27
+
28
+ export type TakomiAgentModelDefault = {
29
+ agent: string;
30
+ model?: string;
31
+ thinking?: string;
32
+ fallbackModels?: string[];
33
+ source?: Exclude<TakomiRoutingSource, "explicit task" | "harness default">;
34
+ };
35
+
36
+ export type TakomiModelRoutingSnapshot = {
37
+ approvedModels: string[];
38
+ preferredModels: string[];
39
+ sourceFiles: string[];
40
+ policyFile?: string;
41
+ policyConflicts?: string[];
42
+ defaultProvider?: string;
43
+ agentDefaults: TakomiAgentModelDefault[];
44
+ };
45
+
46
+ const GLOBAL_SETTINGS = path.join(os.homedir(), ".pi", "agent", "settings.json");
47
+ const CANONICAL_AGENTS = new Set(["architect", "designer", "coder", "worker", "reviewer", "orchestrator"]);
48
+
49
+ function asRecord(value: unknown): Record<string, unknown> {
50
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
51
+ }
52
+
53
+ function unique(values: string[]): string[] {
54
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
55
+ }
56
+
57
+ function canonicalAgent(value: string): string | undefined {
58
+ const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "");
59
+ const aliases: Record<string, string> = {
60
+ architecture: "architect",
61
+ design: "designer",
62
+ code: "coder",
63
+ review: "reviewer",
64
+ general: "worker",
65
+ };
66
+ const canonical = aliases[normalized] ?? normalized;
67
+ return CANONICAL_AGENTS.has(canonical) ? canonical : undefined;
68
+ }
69
+
70
+ async function readSettings(filePath: string): Promise<RoutingSettings> {
71
+ try { return JSON.parse(await fs.promises.readFile(filePath, "utf8")) as RoutingSettings; } catch { return {}; }
72
+ }
73
+
74
+ function readSettingsSync(filePath: string): RoutingSettings {
75
+ try { return JSON.parse(fs.readFileSync(filePath, "utf8")) as RoutingSettings; } catch { return {}; }
76
+ }
77
+
78
+ function cleanDefault(agent: string, input: RoutingDefaultInput, source: TakomiAgentModelDefault["source"]): TakomiAgentModelDefault {
79
+ return {
80
+ agent,
81
+ model: typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined,
82
+ thinking: typeof input.thinking === "string" && input.thinking.trim() ? input.thinking.trim() : undefined,
83
+ fallbackModels: Array.isArray(input.fallbackModels)
84
+ ? unique(input.fallbackModels.filter((item): item is string => typeof item === "string"))
85
+ : undefined,
86
+ source,
87
+ };
88
+ }
89
+
90
+ function extractDefaults(settings: RoutingSettings, source: TakomiAgentModelDefault["source"]): Map<string, TakomiAgentModelDefault> {
91
+ const result = new Map<string, TakomiAgentModelDefault>();
92
+ const legacy = asRecord(settings.subagents?.agentOverrides);
93
+ for (const [name, value] of Object.entries(legacy)) {
94
+ const agent = canonicalAgent(name);
95
+ if (agent) result.set(agent, cleanDefault(agent, asRecord(value), source));
96
+ }
97
+ const structured = asRecord(settings.takomi?.routing?.roleDefaults);
98
+ for (const [name, value] of Object.entries(structured)) {
99
+ const agent = canonicalAgent(name);
100
+ if (!agent) continue;
101
+ const previous = result.get(agent);
102
+ const next = cleanDefault(agent, asRecord(value), source);
103
+ result.set(agent, {
104
+ agent,
105
+ model: next.model ?? previous?.model,
106
+ thinking: next.thinking ?? previous?.thinking,
107
+ fallbackModels: next.fallbackModels ?? previous?.fallbackModels,
108
+ source,
109
+ });
110
+ }
111
+ return result;
112
+ }
113
+
114
+ function configuredApprovedModels(settings: RoutingSettings): { present: boolean; models: string[] } {
115
+ const routing = settings.takomi?.routing;
116
+ return {
117
+ present: Boolean(routing && Object.prototype.hasOwnProperty.call(routing, "approvedModels")),
118
+ models: Array.isArray(routing?.approvedModels) ? unique(routing.approvedModels) : [],
119
+ };
120
+ }
121
+
122
+ function modelsFromDefaults(defaults: Iterable<TakomiAgentModelDefault>): string[] {
123
+ const models: string[] = [];
124
+ for (const entry of defaults) {
125
+ if (entry.model) models.push(stripThinkingSuffix(entry.model).baseModel);
126
+ for (const fallback of entry.fallbackModels ?? []) models.push(stripThinkingSuffix(fallback).baseModel);
127
+ }
128
+ return unique(models);
129
+ }
130
+
131
+ export function mergeTakomiRoutingSettings(globalSettings: RoutingSettings, projectSettings: RoutingSettings, sourceFiles: string[] = []): TakomiModelRoutingSnapshot {
132
+ const globalDefaults = extractDefaults(globalSettings, "global role default");
133
+ const projectDefaults = extractDefaults(projectSettings, "project role default");
134
+ const mergedDefaults = new Map(globalDefaults);
135
+ for (const [agent, projectDefault] of projectDefaults) {
136
+ const base = mergedDefaults.get(agent);
137
+ mergedDefaults.set(agent, {
138
+ agent,
139
+ model: projectDefault.model ?? base?.model,
140
+ thinking: projectDefault.thinking ?? base?.thinking,
141
+ fallbackModels: projectDefault.fallbackModels ?? base?.fallbackModels,
142
+ source: "project role default",
143
+ });
144
+ }
145
+
146
+ const globalApproved = configuredApprovedModels(globalSettings);
147
+ const projectApproved = configuredApprovedModels(projectSettings);
148
+ const approvedModels = projectApproved.present
149
+ ? projectApproved.models
150
+ : globalApproved.present
151
+ ? globalApproved.models
152
+ : modelsFromDefaults(mergedDefaults.values());
153
+ const defaultProvider = projectSettings.takomi?.routing?.defaultProvider
154
+ ?? globalSettings.takomi?.routing?.defaultProvider;
155
+
156
+ return {
157
+ approvedModels,
158
+ preferredModels: approvedModels,
159
+ sourceFiles,
160
+ defaultProvider,
161
+ agentDefaults: [...mergedDefaults.values()],
162
+ };
163
+ }
164
+
165
+ export function stripThinkingSuffix(model: string): { baseModel: string; thinkingSuffix: string } {
166
+ const colonIdx = model.lastIndexOf(":");
167
+ if (colonIdx === -1) return { baseModel: model, thinkingSuffix: "" };
168
+ const suffix = model.slice(colonIdx + 1).toLowerCase();
169
+ if (!(TAKOMI_THINKING_LEVELS as readonly string[]).includes(suffix)) return { baseModel: model, thinkingSuffix: "" };
170
+ return { baseModel: model.slice(0, colonIdx), thinkingSuffix: `:${suffix}` };
171
+ }
172
+
173
+ export function isTakomiModelApproved(requested: string, approved: string[]): boolean {
174
+ const requestedBase = stripThinkingSuffix(requested).baseModel;
175
+ return approved.some((candidate) => stripThinkingSuffix(candidate).baseModel === requestedBase);
176
+ }
177
+
178
+ export function resolveAgentRoutingDefault(snapshot: TakomiModelRoutingSnapshot, agent: string): TakomiAgentModelDefault | undefined {
179
+ const canonical = canonicalAgent(agent);
180
+ return canonical ? snapshot.agentDefaults.find((entry) => entry.agent === canonical) : undefined;
181
+ }
182
+
183
+ function qualifyDefaultModel(model: string | undefined, defaultProvider: string | undefined): string | undefined {
184
+ if (!model || model.includes("/") || !defaultProvider) return model;
185
+ return `${defaultProvider}/${model}`;
186
+ }
187
+
188
+ export function applyTakomiRoutingDefaults<T extends { agent: string; model?: string; fallbackModels?: string[]; thinking?: string }>(
189
+ task: T,
190
+ snapshot: TakomiModelRoutingSnapshot,
191
+ ): T {
192
+ const defaults = resolveAgentRoutingDefault(snapshot, task.agent);
193
+ const explicitModel = Boolean(task.model);
194
+ const model = task.model ?? qualifyDefaultModel(defaults?.model, snapshot.defaultProvider);
195
+ const fallbackSource = task.fallbackModels !== undefined
196
+ ? task.fallbackModels
197
+ : explicitModel
198
+ ? []
199
+ : defaults?.fallbackModels ?? [];
200
+ const fallbackModels = unique(fallbackSource.map((item) => qualifyDefaultModel(item, snapshot.defaultProvider) ?? item));
201
+ return {
202
+ ...task,
203
+ ...(model ? { model } : {}),
204
+ ...(task.thinking ?? defaults?.thinking ? { thinking: task.thinking ?? defaults?.thinking } : {}),
205
+ ...(fallbackModels.length ? { fallbackModels } : { fallbackModels: undefined }),
206
+ };
207
+ }
208
+
209
+ function detectPolicyConflicts(text: string | undefined, snapshot: TakomiModelRoutingSnapshot): string[] {
210
+ if (!text) return [];
211
+ const conflicts: string[] = [];
212
+ const prohibited = [...text.matchAll(/(?:do not|never)\s+use\s+([a-z0-9-]+)/gi)].map((match) => match[1].toLowerCase());
213
+ for (const provider of prohibited) {
214
+ const configured = snapshot.approvedModels.filter((model) => stripThinkingSuffix(model).baseModel.toLowerCase().startsWith(`${provider}/`));
215
+ if (configured.length) conflicts.push(`Policy prohibits provider '${provider}', but executable settings approve: ${configured.join(", ")}`);
216
+ }
217
+ const preferred = text.match(/(?:preferred|default)\s+(?:provider|router)\s*:\s*([a-z0-9-]+)/i)?.[1];
218
+ if (preferred && snapshot.defaultProvider && preferred.toLowerCase() !== snapshot.defaultProvider.toLowerCase()) {
219
+ conflicts.push(`Policy names default provider '${preferred}', but executable settings use '${snapshot.defaultProvider}'.`);
220
+ }
221
+ return conflicts;
222
+ }
223
+
224
+ export async function loadTakomiModelRoutingSnapshot(cwd: string): Promise<TakomiModelRoutingSnapshot> {
225
+ const projectPath = path.resolve(cwd, ".pi", "settings.json");
226
+ const [globalSettings, projectSettings, policy] = await Promise.all([
227
+ readSettings(GLOBAL_SETTINGS),
228
+ readSettings(projectPath),
229
+ resolveTakomiRoutingPolicy(cwd),
230
+ ]);
231
+ const files = [GLOBAL_SETTINGS, projectPath].filter((file) => fs.existsSync(file));
232
+ const snapshot = mergeTakomiRoutingSettings(globalSettings, projectSettings, files);
233
+ snapshot.policyFile = policy.policyPath;
234
+ snapshot.policyConflicts = detectPolicyConflicts(policy.text, snapshot);
235
+ return snapshot;
236
+ }
237
+
238
+ export function loadTakomiModelRoutingSnapshotSync(cwd: string): TakomiModelRoutingSnapshot {
239
+ const projectPath = path.resolve(cwd, ".pi", "settings.json");
240
+ return mergeTakomiRoutingSettings(
241
+ readSettingsSync(GLOBAL_SETTINGS),
242
+ readSettingsSync(projectPath),
243
+ [GLOBAL_SETTINGS, projectPath].filter((file) => fs.existsSync(file)),
244
+ );
245
+ }
246
+
247
+ export function renderCompactTakomiModelRoutingSummary(snapshot: TakomiModelRoutingSnapshot): string {
248
+ if (!snapshot.approvedModels.length && !snapshot.agentDefaults.length) return "";
249
+ const defaultLines = snapshot.agentDefaults.map((entry) =>
250
+ `- ${entry.agent}: ${entry.model ?? "Pi default"}${entry.thinking ? ` (${entry.thinking})` : ""}${entry.fallbackModels?.length ? `; fallbacks ${entry.fallbackModels.join(", ")}` : ""} [${entry.source ?? "harness default"}]`
251
+ );
252
+ return [
253
+ "Active Takomi subagent routing summary:",
254
+ `Executable settings: ${snapshot.sourceFiles.join(", ") || "harness defaults"}`,
255
+ snapshot.policyFile ? `Advisory routing guidance: ${snapshot.policyFile}` : "",
256
+ snapshot.approvedModels.length ? `Approved exact model IDs: ${snapshot.approvedModels.join(", ")}` : "Approved exact model IDs: none configured",
257
+ "Provider-qualified model IDs are atomic. Takomi never substitutes a different provider by model-family matching.",
258
+ "Resolution: explicit task model → project role default → global role default → harness/Pi default.",
259
+ ...(snapshot.policyConflicts?.length ? ["ROUTING CONFIGURATION ERROR:", ...snapshot.policyConflicts.map((item) => `- ${item}`)] : []),
260
+ ...defaultLines,
261
+ ].filter(Boolean).join("\n");
262
+ }