loadout-ai 0.6.0 → 0.8.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 (133) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +129 -26
  3. package/catalog/discovered.json +28469 -24524
  4. package/dist/src/cli.js +7 -2
  5. package/dist/src/commands/agents.js +6 -23
  6. package/dist/src/commands/catalog.js +186 -102
  7. package/dist/src/commands/health.js +6 -6
  8. package/dist/src/commands/inventory.js +19 -120
  9. package/dist/src/commands/lifecycle.js +13 -51
  10. package/dist/src/commands/mcp.js +10 -73
  11. package/dist/src/commands/setup.js +5 -5
  12. package/dist/src/commands/sharing.js +11 -108
  13. package/dist/src/commands/support.js +20 -9
  14. package/dist/src/core/{adapters.js → agents/adapters.js} +2 -2
  15. package/dist/src/core/{agent-inspection.js → agents/agent-inspection.js} +48 -6
  16. package/dist/src/core/{codex-mcp.js → agents/codex-mcp.js} +1 -1
  17. package/dist/src/core/{health-score-evidence.js → agents/health-score-evidence.js} +8 -8
  18. package/dist/src/core/{runtime-tools.js → agents/runtime-tools.js} +2 -2
  19. package/dist/src/core/{audit.js → catalog/audit.js} +4 -4
  20. package/dist/src/core/{catalog.js → catalog/catalog.js} +5 -5
  21. package/dist/src/core/{components.js → catalog/components.js} +2 -2
  22. package/dist/src/core/{conformance.js → catalog/conformance.js} +2 -2
  23. package/dist/src/core/{profiles.js → catalog/profiles.js} +2 -2
  24. package/dist/src/core/{provenance.js → catalog/provenance.js} +3 -3
  25. package/dist/src/core/{registry.js → catalog/registry.js} +59 -11
  26. package/dist/src/core/{safety.js → catalog/safety.js} +36 -7
  27. package/dist/src/core/{skill-compare.js → catalog/skill-compare.js} +1 -1
  28. package/dist/src/core/{skill-inventory.js → catalog/skill-inventory.js} +3 -3
  29. package/dist/src/core/{skills.js → catalog/skills.js} +1 -1
  30. package/dist/src/core/{candidate-intelligence.js → discovery/candidate-intelligence.js} +6 -6
  31. package/dist/src/core/{discovery-connector.js → discovery/discovery-connector.js} +2 -2
  32. package/dist/src/core/{evaluate.js → discovery/evaluate.js} +2 -2
  33. package/dist/src/core/{mcp-registry-discovery.js → discovery/mcp-registry-discovery.js} +1 -1
  34. package/dist/src/core/{observations.js → discovery/observations.js} +3 -3
  35. package/dist/src/core/{review-queue.js → discovery/review-queue.js} +2 -2
  36. package/dist/src/core/{skills-sh-discovery.js → discovery/skills-sh-discovery.js} +1 -1
  37. package/dist/src/core/{adopt.js → install/adopt.js} +3 -3
  38. package/dist/src/core/{catalog-install.js → install/catalog-install.js} +11 -11
  39. package/dist/src/core/{file-lock.js → install/file-lock.js} +1 -1
  40. package/dist/src/core/{install.js → install/install.js} +5 -5
  41. package/dist/src/core/{package.js → install/package.js} +3 -3
  42. package/dist/src/core/{reconcile.js → install/reconcile.js} +5 -5
  43. package/dist/src/core/{remove.js → install/remove.js} +4 -4
  44. package/dist/src/core/{snapshot.js → install/snapshot.js} +1 -1
  45. package/dist/src/core/{source.js → install/source.js} +22 -8
  46. package/dist/src/core/{sync.js → install/sync.js} +9 -9
  47. package/dist/src/core/{transaction.js → install/transaction.js} +1 -1
  48. package/dist/src/core/{uninstall.js → install/uninstall.js} +4 -4
  49. package/dist/src/core/{update.js → install/update.js} +4 -4
  50. package/dist/src/core/{cli-guide.js → reporting/cli-guide.js} +29 -45
  51. package/dist/src/core/reporting/completion.js +90 -0
  52. package/dist/src/core/{doctor.js → reporting/doctor.js} +5 -10
  53. package/dist/src/core/{freshness-alerts.js → reporting/freshness-alerts.js} +5 -5
  54. package/dist/src/core/{health.js → reporting/health.js} +6 -6
  55. package/dist/src/core/{loadout-card.js → reporting/loadout-card.js} +1 -1
  56. package/dist/src/core/{readme-claims.js → reporting/readme-claims.js} +1 -1
  57. package/dist/src/core/{readme-facts.js → reporting/readme-facts.js} +2 -2
  58. package/dist/src/core/{share-report.js → reporting/share-report.js} +3 -3
  59. package/dist/src/core/{upgrade.js → reporting/upgrade.js} +7 -7
  60. package/dist/src/core/routing/first-party-skills.js +118 -0
  61. package/dist/src/core/routing/handoff.js +314 -0
  62. package/dist/src/core/{model-config.js → routing/model-config.js} +4 -4
  63. package/dist/src/core/routing/policy.js +147 -0
  64. package/dist/src/core/{route.js → routing/route.js} +202 -39
  65. package/dist/src/core/{api.js → runtime/api.js} +4 -4
  66. package/dist/src/core/{canary.js → runtime/canary.js} +1 -1
  67. package/dist/src/core/{github.js → runtime/github.js} +1 -1
  68. package/dist/src/core/{mcp-recipes.js → runtime/mcp-recipes.js} +1 -1
  69. package/dist/src/core/{mcp.js → runtime/mcp.js} +2 -2
  70. package/dist/src/core/{scheduler.js → runtime/scheduler.js} +4 -4
  71. package/dist/src/core/{active-policy.js → workspace/active-policy.js} +2 -2
  72. package/dist/src/core/{active-set.js → workspace/active-set.js} +2 -2
  73. package/dist/src/core/{improve.js → workspace/improve.js} +3 -3
  74. package/dist/src/core/{manifest.js → workspace/manifest.js} +2 -2
  75. package/dist/src/core/{outcomes.js → workspace/outcomes.js} +3 -3
  76. package/dist/src/core/{portable.js → workspace/portable.js} +3 -3
  77. package/dist/src/core/{profile-state.js → workspace/profile-state.js} +1 -1
  78. package/dist/src/core/{recommend.js → workspace/recommend.js} +1 -1
  79. package/dist/src/core/{state.js → workspace/state.js} +3 -3
  80. package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
  81. package/docs/CATALOG.md +1 -1
  82. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
  83. package/docs/DISCOVERED.md +250 -248
  84. package/docs/FEATURE_TEST_MATRIX.md +7 -260
  85. package/docs/GITHUB_AUTHORIZATION.md +5 -0
  86. package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
  87. package/docs/RELEASE_REVIEW.md +0 -1
  88. package/docs/evidence/readme-claims.json +22 -19
  89. package/package.json +7 -4
  90. package/skills/loadout-curator/SKILL.md +105 -0
  91. package/skills/loadout-router/SKILL.md +85 -0
  92. package/MASTER_PLAN.md +0 -2207
  93. package/dist/src/core/completion.js +0 -143
  94. package/dist/src/core/handoff.js +0 -161
  95. package/docs/ACTIVE_SET.md +0 -53
  96. package/docs/COMPATIBILITY_POLICY.md +0 -22
  97. package/docs/CONVERSION_AND_SANDBOX.md +0 -27
  98. package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
  99. package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
  100. package/docs/PROVIDER_CONFIGURATION.md +0 -45
  101. package/docs/README_RESEARCH.md +0 -36
  102. package/docs/REPOSITORY_STABILIZATION.md +0 -190
  103. package/docs/SAFE_UPDATE_DEMO.md +0 -25
  104. package/docs/SCHEMA_DECISIONS.md +0 -25
  105. package/docs/SUBMISSION_COPY.md +0 -90
  106. package/docs/TEAM_POLICY.md +0 -18
  107. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
  108. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
  109. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
  110. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
  111. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
  112. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
  113. /package/dist/src/core/{agent-health-score.js → agents/agent-health-score.js} +0 -0
  114. /package/dist/src/core/{agent-versions.js → agents/agent-versions.js} +0 -0
  115. /package/dist/src/core/{conversion.js → agents/conversion.js} +0 -0
  116. /package/dist/src/core/{paths.js → agents/paths.js} +0 -0
  117. /package/dist/src/core/{runtime-tool-recipe.js → agents/runtime-tool-recipe.js} +0 -0
  118. /package/dist/src/core/{catalog-coverage.js → catalog/catalog-coverage.js} +0 -0
  119. /package/dist/src/core/{skill-security.js → catalog/skill-security.js} +0 -0
  120. /package/dist/src/core/{community.js → discovery/community.js} +0 -0
  121. /package/dist/src/core/{github-discovery.js → discovery/github-discovery.js} +0 -0
  122. /package/dist/src/core/{private-discovery.js → discovery/private-discovery.js} +0 -0
  123. /package/dist/src/core/{ranking.js → discovery/ranking.js} +0 -0
  124. /package/dist/src/core/{atomic-file.js → install/atomic-file.js} +0 -0
  125. /package/dist/src/core/{diff.js → install/diff.js} +0 -0
  126. /package/dist/src/core/{update-watch.js → install/update-watch.js} +0 -0
  127. /package/dist/src/core/{loadout-badge.js → reporting/loadout-badge.js} +0 -0
  128. /package/dist/src/core/{terminal.js → reporting/terminal.js} +0 -0
  129. /package/dist/src/core/{access.js → routing/access.js} +0 -0
  130. /package/dist/src/core/{credentials.js → routing/credentials.js} +0 -0
  131. /package/dist/src/core/{sandbox.js → runtime/sandbox.js} +0 -0
  132. /package/dist/src/core/{active-limit.js → workspace/active-limit.js} +0 -0
  133. /package/dist/src/core/{target-occupancy.js → workspace/target-occupancy.js} +0 -0
@@ -0,0 +1,314 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { writeFileAtomically } from "../install/atomic-file.js";
5
+ /** Message types that settle a task, so it stops appearing in an inbox. */
6
+ const TERMINAL_TYPES = new Set(["done", "error", "cancel"]);
7
+ const HANDOFF_DIR = ".handoff";
8
+ const MESSAGES_FILE = "messages.jsonl";
9
+ const PROTOCOL_FILE = "PROTOCOL.md";
10
+ function handoffDir(projectRoot) {
11
+ return join(projectRoot, HANDOFF_DIR);
12
+ }
13
+ function messagesPath(projectRoot) {
14
+ return join(handoffDir(projectRoot), MESSAGES_FILE);
15
+ }
16
+ export async function isHandoffInitialized(projectRoot) {
17
+ try {
18
+ await readFile(join(handoffDir(projectRoot), PROTOCOL_FILE), "utf8");
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export async function initHandoff(projectRoot) {
26
+ const dir = handoffDir(projectRoot);
27
+ await mkdir(dir, { recursive: true });
28
+ const protocol = [
29
+ "# Handoff",
30
+ "",
31
+ "A shared task log so two AI coding agents can pass work between them.",
32
+ "Created by `loadout handoff`. Append-only JSONL; no server, no daemon.",
33
+ "",
34
+ "## Using it",
35
+ "",
36
+ "```",
37
+ "loadout handoff codex 'write unit tests for auth' --context 'see src/auth.ts'",
38
+ "loadout handoff codex # what is waiting for codex",
39
+ "loadout handoff # everything pending, both directions",
40
+ "loadout handoff --done <id> # finished",
41
+ "```",
42
+ "",
43
+ "Sending sets this directory up on first use and adds a short block to",
44
+ "CLAUDE.md and AGENTS.md telling each agent to check its inbox at session",
45
+ "start. Only the text between the loadout:handoff markers is managed.",
46
+ "",
47
+ "## Files",
48
+ "",
49
+ "- `messages.jsonl` — the log, one JSON object per line",
50
+ "- `PROTOCOL.md` — this file",
51
+ "",
52
+ "Commit both if you want the log shared across machines.",
53
+ "",
54
+ ].join("\n");
55
+ await writeFileAtomically(join(dir, PROTOCOL_FILE), protocol);
56
+ // Create empty messages file if it doesn't exist
57
+ try {
58
+ await readFile(messagesPath(projectRoot));
59
+ }
60
+ catch {
61
+ await writeFile(messagesPath(projectRoot), "", "utf8");
62
+ }
63
+ return dir;
64
+ }
65
+ export async function sendHandoff(projectRoot, to, description, options = {}) {
66
+ if (!(await isHandoffInitialized(projectRoot))) {
67
+ throw new Error("Handoff is not set up here. Send a task and it will create itself: loadout handoff <agent> '<task>'");
68
+ }
69
+ const message = {
70
+ id: randomUUID().slice(0, 8),
71
+ type: options.type ?? "task",
72
+ from: options.from ?? "user",
73
+ to,
74
+ description,
75
+ ...(options.context ? { context: options.context } : {}),
76
+ ...(options.resolves ? { resolves: options.resolves } : {}),
77
+ timestamp: new Date().toISOString(),
78
+ };
79
+ const path = messagesPath(projectRoot);
80
+ const line = JSON.stringify(message) + "\n";
81
+ await writeFile(path, line, { flag: "a" });
82
+ return message;
83
+ }
84
+ export async function markDone(projectRoot, messageId) {
85
+ const messages = await readMessages(projectRoot);
86
+ const original = messages.find((m) => m.id === messageId);
87
+ if (!original)
88
+ throw new Error(`Message '${messageId}' not found`);
89
+ if (original.type === "done")
90
+ throw new Error(`Message '${messageId}' is already done`);
91
+ return sendHandoff(projectRoot, original.from, `Completed: ${original.description}`, {
92
+ from: original.to,
93
+ type: "done",
94
+ resolves: messageId,
95
+ });
96
+ }
97
+ /**
98
+ * Parse the log one line at a time. A single truncated write previously made
99
+ * the whole inbox look empty, which is the worst possible failure for a queue:
100
+ * silent and total. Bad lines are collected and reported instead.
101
+ */
102
+ export async function readMessagesDetailed(projectRoot) {
103
+ if (!(await isHandoffInitialized(projectRoot)))
104
+ return { messages: [], corrupt: [] };
105
+ let content;
106
+ try {
107
+ content = await readFile(messagesPath(projectRoot), "utf8");
108
+ }
109
+ catch {
110
+ return { messages: [], corrupt: [] };
111
+ }
112
+ const messages = [];
113
+ const corrupt = [];
114
+ content.split("\n").forEach((raw, index) => {
115
+ if (!raw.trim())
116
+ return;
117
+ try {
118
+ const parsed = JSON.parse(raw);
119
+ if (!parsed || typeof parsed.id !== "string" || !parsed.type)
120
+ throw new Error("missing id or type");
121
+ messages.push(parsed);
122
+ }
123
+ catch (error) {
124
+ corrupt.push({
125
+ line: index + 1,
126
+ reason: error instanceof Error ? error.message : "unparseable",
127
+ });
128
+ }
129
+ });
130
+ return { messages, corrupt };
131
+ }
132
+ export async function readMessages(projectRoot) {
133
+ return (await readMessagesDetailed(projectRoot)).messages;
134
+ }
135
+ export async function getHandoffState(projectRoot) {
136
+ const initialized = await isHandoffInitialized(projectRoot);
137
+ const { messages, corrupt } = initialized
138
+ ? await readMessagesDetailed(projectRoot)
139
+ : { messages: [], corrupt: [] };
140
+ // A task is settled by completion, failure, or withdrawal. Treating only
141
+ // `done` as terminal left failed tasks pending forever.
142
+ const resolvedIds = new Set(messages
143
+ .filter((m) => TERMINAL_TYPES.has(m.type))
144
+ .flatMap((m) => {
145
+ if (m.resolves)
146
+ return [m.resolves];
147
+ // Logs written before `resolves` existed encode it in the context.
148
+ const match = m.context?.match(/Resolves (\w+)/);
149
+ return match ? [match[1]] : [];
150
+ }));
151
+ const pending = messages.filter((m) => m.type === "task" && !resolvedIds.has(m.id));
152
+ const done = messages.filter((m) => m.type === "task" && resolvedIds.has(m.id));
153
+ return {
154
+ initialized,
155
+ directory: handoffDir(projectRoot),
156
+ messages,
157
+ pending,
158
+ done,
159
+ corrupt,
160
+ };
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Consumption — the half that makes a handoff more than an outbox
164
+ // ---------------------------------------------------------------------------
165
+ /** Pending tasks addressed to one agent, oldest first. */
166
+ export async function readInbox(projectRoot, agent) {
167
+ const state = await getHandoffState(projectRoot);
168
+ return state.pending.filter((m) => m.to === agent);
169
+ }
170
+ /**
171
+ * Render an agent's inbox as instructions the agent itself can act on. This is
172
+ * what `loadout handoff <agent>` prints, and what the generated pickup block
173
+ * tells each agent to run, so the message log is consumed rather than merely
174
+ * written.
175
+ */
176
+ export function formatInbox(agent, messages) {
177
+ if (!messages.length)
178
+ return `No pending handoff tasks for ${agent}.`;
179
+ const lines = [
180
+ `${messages.length} pending handoff task(s) for ${agent}:`,
181
+ "",
182
+ ];
183
+ for (const m of messages) {
184
+ lines.push(`[${m.id}] from ${m.from} (${m.timestamp.slice(0, 16).replace("T", " ")})`);
185
+ lines.push(` ${m.description}`);
186
+ if (m.context)
187
+ lines.push(` context: ${m.context}`);
188
+ lines.push(` when finished: loadout handoff --done ${m.id}`);
189
+ lines.push("");
190
+ }
191
+ lines.push("Work these in order. Mark each done as you complete it so the sender sees progress.");
192
+ return lines.join("\n");
193
+ }
194
+ const PICKUP_START = "<!-- loadout:handoff:start -->";
195
+ const PICKUP_END = "<!-- loadout:handoff:end -->";
196
+ /** The managed instruction block written into an agent's context file. */
197
+ export function pickupBlock(agent) {
198
+ return [
199
+ PICKUP_START,
200
+ "",
201
+ "## Handoff inbox",
202
+ "",
203
+ `At the start of a session, and whenever you finish a task, run:`,
204
+ "",
205
+ "```bash",
206
+ `loadout handoff ${agent}`,
207
+ "```",
208
+ "",
209
+ "If it lists pending tasks, work them in order and run the `loadout handoff --done`",
210
+ "command it prints for each one. If it reports none, continue as normal.",
211
+ "",
212
+ PICKUP_END,
213
+ ].join("\n");
214
+ }
215
+ /** Agent context files, relative to the project root. */
216
+ const AGENT_CONTEXT_FILES = {
217
+ "claude-code": "CLAUDE.md",
218
+ codex: "AGENTS.md",
219
+ };
220
+ /** True when this agent has a context file Loadout knows how to write. */
221
+ export function isPickupTarget(agent) {
222
+ return agent in AGENT_CONTEXT_FILES;
223
+ }
224
+ export function agentContextFile(agent) {
225
+ const file = AGENT_CONTEXT_FILES[agent];
226
+ if (!file)
227
+ throw new Error(`No known context file for agent '${agent}'. Supported: ${Object.keys(AGENT_CONTEXT_FILES).join(", ")}`);
228
+ return file;
229
+ }
230
+ /**
231
+ * Compute the new content for an agent's context file with the pickup block
232
+ * added or refreshed. Existing content is preserved; only the managed block
233
+ * between the markers is replaced.
234
+ */
235
+ export async function planPickup(projectRoot, agent) {
236
+ const file = agentContextFile(agent);
237
+ const path = join(projectRoot, file);
238
+ const block = pickupBlock(agent);
239
+ let existing = "";
240
+ let exists = false;
241
+ try {
242
+ existing = await readFile(path, "utf8");
243
+ exists = true;
244
+ }
245
+ catch {
246
+ // A missing context file is created with just the managed block.
247
+ }
248
+ const start = existing.indexOf(PICKUP_START);
249
+ const end = existing.indexOf(PICKUP_END);
250
+ const replacing = start !== -1 && end !== -1 && end > start;
251
+ // A block written by an older release still tells the agent to run commands
252
+ // that no longer exist, so a rewrite is a migration, not a no-op.
253
+ const stale = replacing &&
254
+ /loadout handoff (?:inbox|send|init|status|done)\b/.test(existing.slice(start, end));
255
+ const content = replacing
256
+ ? existing.slice(0, start) + block + existing.slice(end + PICKUP_END.length)
257
+ : exists
258
+ ? `${existing.replace(/\s*$/, "")}\n\n${block}\n`
259
+ : `${block}\n`;
260
+ return { path, agent, exists, replacing, stale, content };
261
+ }
262
+ export async function applyPickup(plan) {
263
+ await writeFileAtomically(plan.path, plan.content);
264
+ }
265
+ export function formatPickupPlan(plans) {
266
+ const lines = ["Handoff pickup instructions:", ""];
267
+ for (const plan of plans) {
268
+ const action = plan.stale
269
+ ? "migrate outdated block in"
270
+ : plan.replacing
271
+ ? "refresh managed block in"
272
+ : plan.exists
273
+ ? "append managed block to"
274
+ : "create";
275
+ lines.push(` ${action} ${plan.path}`);
276
+ }
277
+ lines.push("", "This teaches each agent to check its handoff inbox at session start.", "Only the block between the loadout:handoff markers is managed; the rest of", "each file is preserved.");
278
+ return lines.join("\n");
279
+ }
280
+ export function formatHandoffStatus(state) {
281
+ if (!state.initialized)
282
+ return "No handoff log here yet. Send a task and it creates itself: loadout handoff <agent> '<task>'";
283
+ if (state.messages.length === 0)
284
+ return "No handoff messages yet. Send one with `loadout handoff <agent> '<task>'`.";
285
+ const lines = [];
286
+ if (state.pending.length) {
287
+ lines.push(`Pending (${state.pending.length}):`);
288
+ for (const m of state.pending) {
289
+ lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
290
+ }
291
+ }
292
+ if (state.done.length) {
293
+ if (lines.length)
294
+ lines.push("");
295
+ lines.push(`Done (${state.done.length}):`);
296
+ for (const m of state.done) {
297
+ lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
298
+ }
299
+ }
300
+ if (state.corrupt.length) {
301
+ if (lines.length)
302
+ lines.push("");
303
+ lines.push(`Warning: ${state.corrupt.length} unreadable line(s) in the log — ${state.corrupt
304
+ .map((entry) => `line ${entry.line}`)
305
+ .join(", ")}.`, "The other messages are shown; repair or delete those lines to clear this.");
306
+ }
307
+ const other = state.messages.filter((m) => m.type !== "task" && m.type !== "done");
308
+ if (other.length) {
309
+ if (lines.length)
310
+ lines.push("");
311
+ lines.push(`Other messages: ${other.length}`);
312
+ }
313
+ return lines.join("\n");
314
+ }
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
- import { formatSchemaError, providerModelConfigurationSchema, } from "../shared/schemas.js";
5
- import { writeFileAtomically } from "./atomic-file.js";
6
- import { ensureDirectory, loadoutHome } from "./paths.js";
7
- import { runMutationTransaction } from "./transaction.js";
4
+ import { formatSchemaError, providerModelConfigurationSchema, } from "../../shared/schemas.js";
5
+ import { writeFileAtomically } from "../install/atomic-file.js";
6
+ import { ensureDirectory, loadoutHome } from "../agents/paths.js";
7
+ import { runMutationTransaction } from "../install/transaction.js";
8
8
  export const defaultModelConfigurationPath = () => join(loadoutHome(), "models.json");
9
9
  /**
10
10
  * Validate a portable, provider-neutral model configuration. This is not a
@@ -0,0 +1,147 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { writeFileAtomically } from "../install/atomic-file.js";
4
+ import { ensureDirectory, loadoutHome } from "../agents/paths.js";
5
+ import { MODEL_CATALOG } from "./route.js";
6
+ export const BUCKETS = ["hard", "normal", "cheap"];
7
+ export const BUCKET_MEANING = {
8
+ hard: "architecture, security, migrations, tricky debugging, risky review",
9
+ normal: "most implementation, ordinary debugging, refactors",
10
+ cheap: "tests, docs, boilerplate, renames, mechanical edits",
11
+ };
12
+ export const policyPath = () => join(loadoutHome(), "routing.json");
13
+ /**
14
+ * Defaults reflect a deliberate stance: on Claude the real choice is Opus or
15
+ * Sonnet, and the cheapest useful tier lives on Codex. Anyone who disagrees
16
+ * edits the file rather than arguing with a hardcoded table.
17
+ */
18
+ export function defaultPolicy(installed = []) {
19
+ const hasCodex = installed.length === 0 || installed.includes("codex");
20
+ return {
21
+ version: 1,
22
+ rules: {
23
+ hard: "claude-opus-5",
24
+ normal: "claude-sonnet-5",
25
+ // Without Codex there is no fast tier to fall back to.
26
+ cheap: hasCodex ? "gpt-5.6-luna" : "claude-sonnet-5",
27
+ },
28
+ };
29
+ }
30
+ export function findModel(id) {
31
+ return MODEL_CATALOG.find((model) => model.id === id);
32
+ }
33
+ export function validatePolicy(value) {
34
+ const candidate = value;
35
+ if (!candidate || candidate.version !== 1)
36
+ throw new Error("Routing policy must have version 1");
37
+ const rules = candidate.rules;
38
+ if (!rules)
39
+ throw new Error("Routing policy has no rules");
40
+ for (const bucket of BUCKETS) {
41
+ const id = rules[bucket];
42
+ if (typeof id !== "string" || !id)
43
+ throw new Error(`Routing policy is missing a model for '${bucket}'`);
44
+ if (!findModel(id))
45
+ throw new Error(`Routing policy names unknown model '${id}' for '${bucket}'. Run 'loadout route --models' to list valid ids.`);
46
+ }
47
+ return { version: 1, rules: { ...rules } };
48
+ }
49
+ export async function readPolicy(installed = []) {
50
+ try {
51
+ const raw = await readFile(policyPath(), "utf8");
52
+ return { policy: validatePolicy(JSON.parse(raw)), source: "file" };
53
+ }
54
+ catch (error) {
55
+ if (error &&
56
+ typeof error === "object" &&
57
+ error.code === "ENOENT")
58
+ return { policy: defaultPolicy(installed), source: "default" };
59
+ throw error;
60
+ }
61
+ }
62
+ export async function writePolicy(policy) {
63
+ const path = policyPath();
64
+ await ensureDirectory(dirname(path));
65
+ await writeFileAtomically(path, `${JSON.stringify(policy, null, 2)}\n`);
66
+ return path;
67
+ }
68
+ export async function setRule(bucket, modelId, installed = []) {
69
+ if (!findModel(modelId))
70
+ throw new Error(`Unknown model '${modelId}'. Run 'loadout route --models' to list valid ids.`);
71
+ const { policy } = await readPolicy(installed);
72
+ const next = validatePolicy({
73
+ version: 1,
74
+ rules: { ...policy.rules, [bucket]: modelId },
75
+ });
76
+ await writePolicy(next);
77
+ return next;
78
+ }
79
+ /**
80
+ * A deliberately small classifier. It exists so the CLI can answer without an
81
+ * agent present, and it says plainly that it is guessing — the skill, which has
82
+ * the conversation and the code, is expected to do better.
83
+ */
84
+ export function guessBucket(description) {
85
+ const text = description.toLowerCase();
86
+ const hard = /\b(architect|design|migrat|security|secure|auth|login|session|crypto|signature|signing|token|secret|credential|permission|payment|billing|checkout|stripe|webhook|invoice|concurren|race condition|deadlock|schema|rollout|rollback|encrypt|sanitiz|inject)\w*/;
87
+ const cheap = /\b(test|tests|spec|doc|docs|docstring|comment|readme|changelog|rename|typo|format|lint|boilerplate|scaffold)\w*/;
88
+ if (hard.test(text))
89
+ return "hard";
90
+ if (cheap.test(text))
91
+ return "cheap";
92
+ return "normal";
93
+ }
94
+ export function resolveRoute(policy, bucket, installed, guessed) {
95
+ const model = findModel(policy.rules[bucket]);
96
+ const answer = { bucket, model, guessed };
97
+ if (!installed.length)
98
+ return answer;
99
+ const runnable = model.nativeAgents.some((id) => installed.includes(id));
100
+ if (runnable)
101
+ return answer;
102
+ // Prefer a model of the same tier the user can actually run; otherwise the
103
+ // closest one by price, so the advice stays actionable.
104
+ const reachable = MODEL_CATALOG.filter((candidate) => candidate.nativeAgents.some((id) => installed.includes(id)));
105
+ const fallback = reachable.find((candidate) => candidate.tier === model.tier) ??
106
+ reachable.sort((a, b) => Math.abs(a.inputCostPer1M - model.inputCostPer1M) -
107
+ Math.abs(b.inputCostPer1M - model.inputCostPer1M))[0];
108
+ answer.unavailable = {
109
+ reason: `${model.name} needs ${model.nativeAgents.join(" or ")}, which is not installed`,
110
+ ...(fallback ? { fallback } : {}),
111
+ };
112
+ return answer;
113
+ }
114
+ function price(model) {
115
+ return `$${model.inputCostPer1M}/$${model.outputCostPer1M} per M`;
116
+ }
117
+ export function formatPolicy(policy, source) {
118
+ const lines = [
119
+ source === "file"
120
+ ? `Your routing policy — ${policyPath()}`
121
+ : "Default routing policy (not saved yet)",
122
+ "",
123
+ ];
124
+ for (const bucket of BUCKETS) {
125
+ const model = findModel(policy.rules[bucket]);
126
+ lines.push(` ${bucket.padEnd(7)} ${model.name.padEnd(16)} ${price(model)}`);
127
+ lines.push(` ${"".padEnd(7)} ${BUCKET_MEANING[bucket]}`);
128
+ }
129
+ lines.push("", "Change it: loadout route --set normal=gpt-5.6-terra", source === "default"
130
+ ? "Save it: loadout route --save"
131
+ : "Reset it: loadout route --reset");
132
+ return lines.join("\n");
133
+ }
134
+ export function formatAnswer(answer, task) {
135
+ const lines = [];
136
+ if (task)
137
+ lines.push(`Task: ${task}`, "");
138
+ lines.push(`Bucket: ${answer.bucket} — ${BUCKET_MEANING[answer.bucket]}`, `Use: ${answer.model.name} (${price(answer.model)})`);
139
+ if (answer.unavailable) {
140
+ lines.push("", `Note: ${answer.unavailable.reason}.`);
141
+ if (answer.unavailable.fallback)
142
+ lines.push(` Reachable alternative: ${answer.unavailable.fallback.name} (${price(answer.unavailable.fallback)}).`);
143
+ }
144
+ if (answer.guessed)
145
+ lines.push("", "This bucket was guessed from wording alone. If the work is riskier than it", "reads — payments, auth, migrations — treat it as hard and move up.");
146
+ return lines.join("\n");
147
+ }