loadout-ai 0.5.8 → 0.6.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 (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/MASTER_PLAN.md +9 -9
  3. package/README.md +84 -121
  4. package/catalog/discovered.json +59800 -10065
  5. package/catalog/packages.json +4 -3
  6. package/dist/src/cli.js +26 -2979
  7. package/dist/src/commands/agents.js +168 -0
  8. package/dist/src/commands/catalog.js +708 -0
  9. package/dist/src/commands/health.js +98 -0
  10. package/dist/src/commands/inventory.js +417 -0
  11. package/dist/src/commands/lifecycle.js +411 -0
  12. package/dist/src/commands/mcp.js +424 -0
  13. package/dist/src/commands/setup.js +67 -0
  14. package/dist/src/commands/sharing.js +358 -0
  15. package/dist/src/commands/support.js +246 -0
  16. package/dist/src/core/active-limit.js +2 -0
  17. package/dist/src/core/active-policy.js +2 -1
  18. package/dist/src/core/catalog-install.js +47 -10
  19. package/dist/src/core/catalog.js +15 -96
  20. package/dist/src/core/cli-guide.js +43 -37
  21. package/dist/src/core/completion.js +2 -20
  22. package/dist/src/core/doctor.js +73 -24
  23. package/dist/src/core/github.js +3 -2
  24. package/dist/src/core/handoff.js +161 -0
  25. package/dist/src/core/health-score-evidence.js +2 -2
  26. package/dist/src/core/health.js +83 -0
  27. package/dist/src/core/install.js +8 -3
  28. package/dist/src/core/loadout-card.js +14 -2
  29. package/dist/src/core/review-queue.js +7 -2
  30. package/dist/src/core/route.js +248 -0
  31. package/dist/src/core/skill-inventory.js +3 -3
  32. package/dist/src/core/skills.js +6 -7
  33. package/dist/src/core/source.js +10 -3
  34. package/dist/src/core/upgrade.js +10 -9
  35. package/docs/ACTIVE_SET.md +1 -1
  36. package/docs/ACTIVE_SET_POLICY.md +1 -1
  37. package/docs/CATALOG.md +2 -2
  38. package/docs/DEMO_SCRIPT.md +5 -5
  39. package/docs/DISCOVERED.md +250 -253
  40. package/docs/RELEASE_REVIEW.md +28 -143
  41. package/docs/SUBMISSION_COPY.md +4 -4
  42. package/docs/UPSTREAM_LICENSE_DECISIONS.md +19 -12
  43. package/docs/USER_TEST_GUIDE.md +1 -1
  44. package/docs/evidence/readme-claims.json +2 -14
  45. package/package.json +4 -3
  46. package/dist/src/core/benchmark-campaign.js +0 -496
  47. package/dist/src/core/benchmark-evidence.js +0 -458
  48. package/dist/src/core/benchmark-fixtures.js +0 -1157
  49. package/dist/src/core/benchmark-runner.js +0 -301
  50. package/dist/src/core/benchmark-trust.js +0 -795
  51. package/dist/src/core/catalog-release.js +0 -287
  52. package/dist/src/core/compatibility-intelligence.js +0 -465
  53. package/dist/src/core/ecosystem-import.js +0 -1072
  54. package/dist/src/core/head-to-head.js +0 -437
  55. package/dist/src/core/intelligence-feed-build.js +0 -59
  56. package/dist/src/core/intelligence-feed.js +0 -353
  57. package/dist/src/core/registry-api.js +0 -97
  58. package/dist/src/core/release-claims.js +0 -175
  59. package/dist/src/core/signing.js +0 -115
@@ -0,0 +1,161 @@
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 "./atomic-file.js";
5
+ const HANDOFF_DIR = ".handoff";
6
+ const MESSAGES_FILE = "messages.jsonl";
7
+ const PROTOCOL_FILE = "PROTOCOL.md";
8
+ function handoffDir(projectRoot) {
9
+ return join(projectRoot, HANDOFF_DIR);
10
+ }
11
+ function messagesPath(projectRoot) {
12
+ return join(handoffDir(projectRoot), MESSAGES_FILE);
13
+ }
14
+ export async function isHandoffInitialized(projectRoot) {
15
+ try {
16
+ await readFile(join(handoffDir(projectRoot), PROTOCOL_FILE), "utf8");
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ export async function initHandoff(projectRoot) {
24
+ const dir = handoffDir(projectRoot);
25
+ await mkdir(dir, { recursive: true });
26
+ const protocol = [
27
+ "# Handoff Protocol",
28
+ "",
29
+ "This directory enables asynchronous task handoff between AI coding agents.",
30
+ "Generated by `loadout handoff init`. Compatible with the JSONL handoff convention.",
31
+ "",
32
+ "## Message types",
33
+ "",
34
+ "- **task**: assign work to another agent",
35
+ "- **handoff**: transfer in-progress work with context",
36
+ "- **question**: ask another agent for input",
37
+ "- **done**: signal completion",
38
+ "- **status**: progress update",
39
+ "- **error**: report a failure",
40
+ "- **cancel**: withdraw a task",
41
+ "",
42
+ "## Files",
43
+ "",
44
+ "- `messages.jsonl` — append-only message log (one JSON object per line)",
45
+ "- `PROTOCOL.md` — this file",
46
+ "",
47
+ "## Usage",
48
+ "",
49
+ "```",
50
+ "loadout handoff send codex 'write unit tests for auth module'",
51
+ "loadout handoff send claude-code 'review the API changes' --context 'see PR #42'",
52
+ "loadout handoff status",
53
+ "loadout handoff done <message-id>",
54
+ "```",
55
+ "",
56
+ "Agents read this directory on wake. Loadout manages the message log;",
57
+ "each agent's own CLAUDE.md / AGENTS.md can reference .handoff/ for pickup.",
58
+ "",
59
+ ].join("\n");
60
+ await writeFileAtomically(join(dir, PROTOCOL_FILE), protocol);
61
+ // Create empty messages file if it doesn't exist
62
+ try {
63
+ await readFile(messagesPath(projectRoot));
64
+ }
65
+ catch {
66
+ await writeFile(messagesPath(projectRoot), "", "utf8");
67
+ }
68
+ return dir;
69
+ }
70
+ export async function sendHandoff(projectRoot, to, description, options = {}) {
71
+ if (!(await isHandoffInitialized(projectRoot))) {
72
+ throw new Error("Handoff not initialized. Run `loadout handoff init` first.");
73
+ }
74
+ const message = {
75
+ id: randomUUID().slice(0, 8),
76
+ type: options.type ?? "task",
77
+ from: options.from ?? "user",
78
+ to,
79
+ description,
80
+ ...(options.context ? { context: options.context } : {}),
81
+ timestamp: new Date().toISOString(),
82
+ };
83
+ const path = messagesPath(projectRoot);
84
+ const line = JSON.stringify(message) + "\n";
85
+ await writeFile(path, line, { flag: "a" });
86
+ return message;
87
+ }
88
+ export async function markDone(projectRoot, messageId) {
89
+ const messages = await readMessages(projectRoot);
90
+ const original = messages.find((m) => m.id === messageId);
91
+ if (!original)
92
+ throw new Error(`Message '${messageId}' not found`);
93
+ if (original.type === "done")
94
+ throw new Error(`Message '${messageId}' is already done`);
95
+ return sendHandoff(projectRoot, original.from, `Completed: ${original.description}`, {
96
+ from: original.to,
97
+ type: "done",
98
+ context: `Resolves ${messageId}`,
99
+ });
100
+ }
101
+ export async function readMessages(projectRoot) {
102
+ if (!(await isHandoffInitialized(projectRoot)))
103
+ return [];
104
+ try {
105
+ const content = await readFile(messagesPath(projectRoot), "utf8");
106
+ return content
107
+ .split("\n")
108
+ .filter(Boolean)
109
+ .map((line) => JSON.parse(line));
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ }
115
+ export async function getHandoffState(projectRoot) {
116
+ const initialized = await isHandoffInitialized(projectRoot);
117
+ const messages = initialized ? await readMessages(projectRoot) : [];
118
+ const doneIds = new Set(messages
119
+ .filter((m) => m.type === "done")
120
+ .flatMap((m) => {
121
+ const match = m.context?.match(/Resolves (\w+)/);
122
+ return match ? [match[1]] : [];
123
+ }));
124
+ const pending = messages.filter((m) => m.type === "task" && !doneIds.has(m.id));
125
+ const done = messages.filter((m) => m.type === "task" && doneIds.has(m.id));
126
+ return {
127
+ initialized,
128
+ directory: handoffDir(projectRoot),
129
+ messages,
130
+ pending,
131
+ done,
132
+ };
133
+ }
134
+ export function formatHandoffStatus(state) {
135
+ if (!state.initialized)
136
+ return "Handoff not initialized. Run `loadout handoff init`.";
137
+ if (state.messages.length === 0)
138
+ return "No handoff messages yet. Send one with `loadout handoff send <agent> <task>`.";
139
+ const lines = [];
140
+ if (state.pending.length) {
141
+ lines.push(`Pending (${state.pending.length}):`);
142
+ for (const m of state.pending) {
143
+ lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
144
+ }
145
+ }
146
+ if (state.done.length) {
147
+ if (lines.length)
148
+ lines.push("");
149
+ lines.push(`Done (${state.done.length}):`);
150
+ for (const m of state.done) {
151
+ lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
152
+ }
153
+ }
154
+ const other = state.messages.filter((m) => m.type !== "task" && m.type !== "done");
155
+ if (other.length) {
156
+ if (lines.length)
157
+ lines.push("");
158
+ lines.push(`Other messages: ${other.length}`);
159
+ }
160
+ return lines.join("\n");
161
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import { buildAgentHealthScore, } from "./agent-health-score.js";
4
+ import { DEFAULT_ACTIVE_SKILL_LIMIT } from "./active-limit.js";
4
5
  import { managedFileReadPath } from "./active-set.js";
5
6
  import { adapterCapabilities } from "./adapters.js";
6
7
  import { loadEffectiveCatalog } from "./catalog.js";
@@ -10,7 +11,6 @@ import { readSnapshot } from "./snapshot.js";
10
11
  import { scanInstalledSkills } from "./skill-inventory.js";
11
12
  import { readInstallState } from "./state.js";
12
13
  import { transactionRoot } from "./transaction.js";
13
- const ACTIVE_SET_CAPACITY = 30;
14
14
  function freshness(pkg, asOf) {
15
15
  const observed = pkg?.pushedAt ?? pkg?.lastUpdatedAt;
16
16
  if (!observed)
@@ -139,7 +139,7 @@ export async function collectLocalAgentHealthEvidence(options = {}) {
139
139
  },
140
140
  activeSet: {
141
141
  active,
142
- capacity: ACTIVE_SET_CAPACITY,
142
+ capacity: DEFAULT_ACTIVE_SKILL_LIMIT,
143
143
  disabled: activations.filter((item) => item.activationState === "disabled").length,
144
144
  quarantined: activations.filter((item) => item.reviewState === "quarantined").length,
145
145
  },
@@ -167,6 +167,89 @@ export async function buildHealthReport(options = {}) {
167
167
  findings,
168
168
  };
169
169
  }
170
+ /**
171
+ * A one-glance grade derived from the same report the health command already
172
+ * builds. Drift (managed files changed outside Loadout) is an integrity
173
+ * violation and dominates; error findings are next; warnings cap at B. This is
174
+ * a legible summary, not a scored policy — the detailed dimensions live behind
175
+ * `loadout health --explain`.
176
+ */
177
+ export function gradeHealth(report) {
178
+ const reasons = [];
179
+ const fixes = [];
180
+ const drift = report.driftedFiles + report.driftedMcpServers;
181
+ const errors = report.findings.filter((f) => f.level === "error");
182
+ const warnings = report.findings.filter((f) => f.level === "warning");
183
+ if (report.status === "not-configured")
184
+ return {
185
+ letter: "—",
186
+ headline: "Not set up yet",
187
+ reasons: [
188
+ "No Loadout-managed packages, MCP servers, or tools installed.",
189
+ ],
190
+ fixes: [
191
+ "Run `loadout setup --mode stable` (preview first, nothing changes).",
192
+ ],
193
+ };
194
+ let letter = "A";
195
+ if (drift > 0) {
196
+ letter = "F";
197
+ reasons.push(`${drift} managed item(s) changed or disappeared outside Loadout.`);
198
+ fixes.push("Run `loadout rollback` to restore the last snapshot, or `loadout reconcile` to re-adopt.");
199
+ }
200
+ for (const error of errors) {
201
+ if (letter !== "F")
202
+ letter = "D";
203
+ reasons.push(error.message);
204
+ if (error.fix)
205
+ fixes.push(error.fix);
206
+ }
207
+ if (letter !== "F" && letter !== "D") {
208
+ if (report.status === "library-only") {
209
+ letter = "C";
210
+ reasons.push("Skills are installed but none are active for any agent.");
211
+ fixes.push("Run `loadout optimize --project .` to activate a relevant set.");
212
+ }
213
+ else if (warnings.length) {
214
+ letter = "B";
215
+ for (const warning of warnings) {
216
+ reasons.push(warning.message);
217
+ if (warning.fix)
218
+ fixes.push(warning.fix);
219
+ }
220
+ }
221
+ }
222
+ const headline = letter === "A"
223
+ ? "Healthy and up to date"
224
+ : letter === "B"
225
+ ? "Healthy, with items to review"
226
+ : letter === "C"
227
+ ? "Ready, but nothing active"
228
+ : letter === "D"
229
+ ? "Needs attention"
230
+ : "Integrity problem — managed files drifted";
231
+ return { letter, headline, reasons, fixes: [...new Set(fixes)] };
232
+ }
233
+ /** The `loadout status` home screen: a grade, per-agent lines, then fixes. */
234
+ export function formatStatusScreen(report, agentLines) {
235
+ const grade = gradeHealth(report);
236
+ const lines = [
237
+ `Loadout — grade ${grade.letter}: ${grade.headline}`,
238
+ "",
239
+ ...agentLines,
240
+ ];
241
+ if (grade.reasons.length) {
242
+ lines.push("");
243
+ for (const reason of grade.reasons)
244
+ lines.push(` • ${reason}`);
245
+ }
246
+ if (grade.fixes.length) {
247
+ lines.push("");
248
+ for (const fix of grade.fixes)
249
+ lines.push(` → ${fix}`);
250
+ }
251
+ return lines.join("\n");
252
+ }
170
253
  export function formatHealthReport(report) {
171
254
  const icon = report.status === "not-configured" || report.status === "library-only"
172
255
  ? "•"
@@ -309,9 +309,14 @@ export async function applySkillLibraryBatch(entries, options = {}) {
309
309
  current.resolvedCommit.toLowerCase() !==
310
310
  incoming.metadata.resolvedCommit.toLowerCase())
311
311
  throw new Error(`Maximum Library cannot preserve active '${activation.packageId}/${activation.unitId ?? "skill"}' because its reviewed revision differs or is unknown. Update or disable it explicitly first.`);
312
- const includesActiveUnit = incoming.plan.files.some((file) => (file.targetAgent === activation.agent ||
313
- (!file.targetAgent && incoming.plan.targetAgents.length === 1)) &&
314
- basename(file.target) === activation.unitId);
312
+ const incomingActiveTargets = new Set(incoming.plan.files
313
+ .filter((file) => (file.targetAgent === activation.agent ||
314
+ (!file.targetAgent &&
315
+ incoming.plan.targetAgents.length === 1)) &&
316
+ basename(file.target) === activation.unitId)
317
+ .map((file) => file.target));
318
+ const includesActiveUnit = activation.targets.length > 0 &&
319
+ activation.targets.every((target) => incomingActiveTargets.has(target.activePath));
315
320
  if (!includesActiveUnit)
316
321
  throw new Error(`Maximum Library cannot preserve active '${activation.packageId}/${activation.unitId ?? "skill"}' because that unit is absent from the prepared library.`);
317
322
  }
@@ -1,15 +1,19 @@
1
1
  import { buildLocalAgentHealthScores } from "./health-score-evidence.js";
2
+ import { buildHealthReport, gradeHealth } from "./health.js";
2
3
  import { buildPrivacySafeReport, } from "./share-report.js";
3
4
  export async function buildLoadoutCard(options = {}) {
4
- const [report, scores] = await Promise.all([
5
+ const [report, scores, healthReport] = await Promise.all([
5
6
  options.report ? Promise.resolve(options.report) : buildPrivacySafeReport(),
6
7
  options.scores
7
8
  ? Promise.resolve(options.scores)
8
9
  : buildLocalAgentHealthScores({ asOf: options.now }),
10
+ buildHealthReport({}),
9
11
  ]);
12
+ const grade = gradeHealth(healthReport);
10
13
  return {
11
14
  schemaVersion: 1,
12
15
  generatedAt: (options.now ?? new Date()).toISOString(),
16
+ grade: { letter: grade.letter, headline: grade.headline },
13
17
  agents: scores
14
18
  .map((score) => ({
15
19
  id: score.agent,
@@ -38,12 +42,20 @@ export function formatLoadoutCard(card) {
38
42
  return [
39
43
  "# My Loadout",
40
44
  "",
41
- `**${card.totals.activeSkills} active skills · ${card.totals.managedPackages} managed packages · ${card.totals.mcpEntries} MCP entries**`,
45
+ `### Grade ${card.grade.letter} — ${card.grade.headline}`,
46
+ "",
47
+ `**${card.totals.activeSkills} active skills · ${card.totals.managedPackages} managed packages · ${card.totals.mcpEntries} MCP entries** across ${card.agents.length} agent(s)`,
42
48
  "",
43
49
  "| Agent | Evidence health | Rating | Evidence coverage |",
44
50
  "| --- | ---: | --- | ---: |",
45
51
  agentCells,
46
52
  "",
53
+ "Set up your own agents the same way — reviewed, previewed, and rollback-safe:",
54
+ "",
55
+ "```bash",
56
+ "npx loadout-ai",
57
+ "```",
58
+ "",
47
59
  `Updated: ${card.generatedAt}`,
48
60
  "",
49
61
  `> ${card.claimBoundary}`,
@@ -13,7 +13,7 @@ function isQueue(value) {
13
13
  record.items.every((item) => item &&
14
14
  typeof item === "object" &&
15
15
  typeof item.repository === "string" &&
16
- ["pending", "shortlisted", "ignored"].includes(item.decision)));
16
+ ["pending", "shortlisted", "ignored", "promoted"].includes(item.decision)));
17
17
  }
18
18
  export async function readReviewQueue() {
19
19
  try {
@@ -102,7 +102,9 @@ function decisionPriority(decision) {
102
102
  return 0;
103
103
  if (decision === "pending")
104
104
  return 1;
105
- return 2;
105
+ if (decision === "ignored")
106
+ return 2;
107
+ return 3;
106
108
  }
107
109
  function compareQueueItems(left, right) {
108
110
  return (Number(left.alreadyCataloged) - Number(right.alreadyCataloged) ||
@@ -191,6 +193,9 @@ export async function setReviewDecision(repository, decision) {
191
193
  await writeFileAtomically(queuePath(), `${JSON.stringify(queue, null, 2)}\n`);
192
194
  return item;
193
195
  }
196
+ export async function markPromoted(repository) {
197
+ return setReviewDecision(repository, "promoted");
198
+ }
194
199
  export function formatReviewQueue(queue) {
195
200
  const visible = queue.items.filter((item) => !item.alreadyCataloged);
196
201
  return [
@@ -0,0 +1,248 @@
1
+ // Sources:
2
+ // Anthropic: https://allaboutclaude.com/models
3
+ // OpenAI: https://benchlm.ai/openai/api-pricing
4
+ // Prices as of August 2026.
5
+ export const MODEL_CATALOG = [
6
+ // --- Anthropic (Claude Code) ---
7
+ { id: "claude-fable-5", provider: "anthropic", name: "Claude Fable 5", tier: "frontier", inputCostPer1M: 10, outputCostPer1M: 50, nativeAgents: ["claude-code"], current: true, generation: "5" },
8
+ { id: "claude-opus-5", provider: "anthropic", name: "Claude Opus 5", tier: "frontier", inputCostPer1M: 5, outputCostPer1M: 25, nativeAgents: ["claude-code"], current: true, generation: "5" },
9
+ { id: "claude-sonnet-5", provider: "anthropic", name: "Claude Sonnet 5", tier: "standard", inputCostPer1M: 3, outputCostPer1M: 15, nativeAgents: ["claude-code"], current: true, generation: "5" },
10
+ { id: "claude-opus-4-8", provider: "anthropic", name: "Claude Opus 4.8", tier: "frontier", inputCostPer1M: 5, outputCostPer1M: 25, nativeAgents: ["claude-code"], current: false, generation: "4" },
11
+ { id: "claude-opus-4-6", provider: "anthropic", name: "Claude Opus 4.6", tier: "frontier", inputCostPer1M: 5, outputCostPer1M: 25, nativeAgents: ["claude-code"], current: false, generation: "4" },
12
+ { id: "claude-sonnet-4-6", provider: "anthropic", name: "Claude Sonnet 4.6", tier: "standard", inputCostPer1M: 3, outputCostPer1M: 15, nativeAgents: ["claude-code"], current: false, generation: "4" },
13
+ { id: "claude-haiku-4-5", provider: "anthropic", name: "Claude Haiku 4.5", tier: "fast", inputCostPer1M: 1, outputCostPer1M: 5, nativeAgents: ["claude-code"], current: true, generation: "4" },
14
+ // --- OpenAI (Codex) — GPT-5.6 family ---
15
+ { id: "gpt-5.6-sol", provider: "openai", name: "GPT-5.6 Sol", tier: "frontier", inputCostPer1M: 5, outputCostPer1M: 30, nativeAgents: ["codex"], current: true, generation: "5.6" },
16
+ { id: "gpt-5.6-terra", provider: "openai", name: "GPT-5.6 Terra", tier: "standard", inputCostPer1M: 2, outputCostPer1M: 12, nativeAgents: ["codex"], current: true, generation: "5.6" },
17
+ { id: "gpt-5.6-luna", provider: "openai", name: "GPT-5.6 Luna", tier: "fast", inputCostPer1M: 0.20, outputCostPer1M: 1.20, nativeAgents: ["codex"], current: true, generation: "5.6" },
18
+ // --- OpenAI — GPT-5.5 ---
19
+ { id: "gpt-5.5", provider: "openai", name: "GPT-5.5", tier: "frontier", inputCostPer1M: 5, outputCostPer1M: 30, nativeAgents: ["codex"], current: true, generation: "5.5" },
20
+ // --- OpenAI — GPT-5.4 family ---
21
+ { id: "gpt-5.4", provider: "openai", name: "GPT-5.4", tier: "standard", inputCostPer1M: 2.50, outputCostPer1M: 15, nativeAgents: ["codex"], current: true, generation: "5.4" },
22
+ { id: "gpt-5.4-mini", provider: "openai", name: "GPT-5.4 Mini", tier: "fast", inputCostPer1M: 0.75, outputCostPer1M: 4.50, nativeAgents: ["codex"], current: true, generation: "5.4" },
23
+ // --- OpenAI — Codex-specific ---
24
+ { id: "gpt-5.1-codex", provider: "openai", name: "GPT-5.1 Codex", tier: "standard", inputCostPer1M: 1.25, outputCostPer1M: 10, nativeAgents: ["codex"], current: true, generation: "5.1" },
25
+ // --- OpenAI — o-series reasoning ---
26
+ { id: "o3-pro", provider: "openai", name: "o3-pro", tier: "frontier", inputCostPer1M: 20, outputCostPer1M: 80, nativeAgents: ["codex"], current: true, generation: "o3" },
27
+ { id: "o3", provider: "openai", name: "o3", tier: "frontier", inputCostPer1M: 2, outputCostPer1M: 8, nativeAgents: ["codex"], current: true, generation: "o3" },
28
+ { id: "o4-mini", provider: "openai", name: "o4-mini", tier: "standard", inputCostPer1M: 1.10, outputCostPer1M: 4.40, nativeAgents: ["codex"], current: true, generation: "o4" },
29
+ { id: "o3-mini", provider: "openai", name: "o3-mini", tier: "standard", inputCostPer1M: 1.10, outputCostPer1M: 4.40, nativeAgents: ["codex"], current: true, generation: "o3" },
30
+ ];
31
+ const TIER_LABELS = {
32
+ frontier: "Frontier (deep reasoning)",
33
+ standard: "Standard (balanced)",
34
+ fast: "Fast (high throughput)",
35
+ };
36
+ const PHASE_CONFIG = {
37
+ plan: {
38
+ tier: "frontier",
39
+ reason: "Architecture and decomposition need deep reasoning to avoid costly rework",
40
+ agents: ["claude-code"],
41
+ conserveTier: "standard",
42
+ conserveTradeoff: "Slightly shallower architectural reasoning; review the plan more carefully",
43
+ },
44
+ implement: {
45
+ tier: "standard",
46
+ reason: "Implementation is high-volume; standard models score within 5% of frontier on SWE-bench",
47
+ agents: ["claude-code", "codex"],
48
+ conserveTier: "fast",
49
+ conserveTradeoff: "May need more iterations on complex logic; fine for CRUD and boilerplate",
50
+ },
51
+ review: {
52
+ tier: "frontier",
53
+ reason: "Code review catches subtle bugs that cheaper models miss; worth the cost per-review",
54
+ agents: ["claude-code", "codex"],
55
+ conserveTier: "standard",
56
+ conserveTradeoff: "May miss edge-case bugs; pair with a linter",
57
+ },
58
+ test: {
59
+ tier: "fast",
60
+ reason: "Test generation is pattern-heavy; fast models produce equivalent coverage",
61
+ agents: ["claude-code", "codex"],
62
+ },
63
+ debug: {
64
+ tier: "standard",
65
+ reason: "Targeted debugging needs good reasoning but not frontier-level; standard balances cost and quality",
66
+ agents: ["claude-code", "codex"],
67
+ conserveTier: "fast",
68
+ conserveTradeoff: "Works for simple bugs; complex root-cause analysis may suffer",
69
+ },
70
+ document: {
71
+ tier: "fast",
72
+ reason: "Documentation and comments are well-suited to fast models; any decent model writes docs",
73
+ agents: ["claude-code", "codex"],
74
+ },
75
+ };
76
+ const PHASE_KEYWORDS = {
77
+ plan: ["plan", "design", "architect", "architecture", "rfc", "spec", "decompose", "strategy", "system design"],
78
+ implement: ["implement", "build", "code", "create", "add", "feature", "write", "develop", "make"],
79
+ review: ["review", "audit", "check", "inspect", "critique", "pr", "pull request", "diff"],
80
+ test: ["test", "spec", "coverage", "unit", "integration", "e2e", "assert", "vitest", "jest"],
81
+ debug: ["debug", "fix", "bug", "error", "crash", "broken", "investigate", "troubleshoot", "failing"],
82
+ document: ["document", "docs", "readme", "comment", "jsdoc", "changelog", "explain", "docstring"],
83
+ };
84
+ // ---------------------------------------------------------------------------
85
+ // Classification
86
+ // ---------------------------------------------------------------------------
87
+ export function classifyTask(description) {
88
+ const lower = description.toLowerCase();
89
+ let best = "implement";
90
+ let bestScore = 0;
91
+ for (const [phase, keywords] of Object.entries(PHASE_KEYWORDS)) {
92
+ let score = 0;
93
+ for (const kw of keywords) {
94
+ if (lower.includes(kw))
95
+ score++;
96
+ }
97
+ if (score > bestScore) {
98
+ bestScore = score;
99
+ best = phase;
100
+ }
101
+ }
102
+ return best;
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // Model queries
106
+ // ---------------------------------------------------------------------------
107
+ export function modelsForTier(tier, currentOnly = true) {
108
+ return MODEL_CATALOG.filter((m) => m.tier === tier && (!currentOnly || m.current));
109
+ }
110
+ export function modelsByProvider(provider) {
111
+ return MODEL_CATALOG.filter((m) => m.provider === provider && m.current);
112
+ }
113
+ export function cheapestInTier(tier) {
114
+ const models = modelsForTier(tier);
115
+ return models.sort((a, b) => a.inputCostPer1M - b.inputCostPer1M)[0];
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Routing
119
+ // ---------------------------------------------------------------------------
120
+ export function routeTask(description, conserve = false) {
121
+ return routePhase(classifyTask(description), conserve);
122
+ }
123
+ export function routePhase(phase, conserve = false) {
124
+ const config = PHASE_CONFIG[phase];
125
+ if (!config)
126
+ throw new Error(`Unknown phase '${phase}'. Valid: ${Object.keys(PHASE_CONFIG).join(", ")}`);
127
+ const effectiveTier = conserve && config.conserveTier ? config.conserveTier : config.tier;
128
+ const models = modelsForTier(effectiveTier);
129
+ const rec = {
130
+ phase: phase,
131
+ tier: effectiveTier,
132
+ tierLabel: TIER_LABELS[effectiveTier],
133
+ models,
134
+ reason: conserve && config.conserveTier
135
+ ? `Conserve mode: ${config.conserveTradeoff}`
136
+ : config.reason,
137
+ suggestedAgents: config.agents,
138
+ };
139
+ if (!conserve && config.conserveTier) {
140
+ rec.conserveAlternative = {
141
+ tier: config.conserveTier,
142
+ models: modelsForTier(config.conserveTier),
143
+ tradeoff: config.conserveTradeoff,
144
+ };
145
+ }
146
+ return rec;
147
+ }
148
+ export function allPhaseRoutes(conserve = false) {
149
+ return Object.keys(PHASE_CONFIG).map((p) => routePhase(p, conserve));
150
+ }
151
+ export function estimateCostSavings() {
152
+ const normal = allPhaseRoutes(false);
153
+ const conserved = allPhaseRoutes(true);
154
+ const estimates = normal.map((r) => {
155
+ const cheapest = cheapestInTier(r.tier);
156
+ return {
157
+ phase: r.phase,
158
+ tier: r.tier,
159
+ cheapestModel: cheapest?.name ?? "—",
160
+ inputCost: cheapest?.inputCostPer1M ?? 0,
161
+ outputCost: cheapest?.outputCostPer1M ?? 0,
162
+ };
163
+ });
164
+ const normalTotal = estimates.reduce((s, e) => s + e.inputCost, 0);
165
+ const conserveEstimates = conserved.map((r) => cheapestInTier(r.tier)?.inputCostPer1M ?? 0);
166
+ const conserveTotal = conserveEstimates.reduce((s, c) => s + c, 0);
167
+ return { estimates, normalTotal, conserveTotal };
168
+ }
169
+ // ---------------------------------------------------------------------------
170
+ // Formatters
171
+ // ---------------------------------------------------------------------------
172
+ export function formatRouteRecommendation(rec) {
173
+ const modelNames = rec.models.slice(0, 4).map((m) => `${m.name} ($${m.inputCostPer1M}/$${m.outputCostPer1M})`);
174
+ const lines = [
175
+ `Phase: ${rec.phase}`,
176
+ `Tier: ${rec.tierLabel}`,
177
+ `Models: ${modelNames.join("\n ")}`,
178
+ `Agents: ${rec.suggestedAgents.join(", ")}`,
179
+ `Why: ${rec.reason}`,
180
+ ];
181
+ if (rec.conserveAlternative) {
182
+ const alt = rec.conserveAlternative;
183
+ const altCheapest = cheapestInTier(alt.tier);
184
+ lines.push(``, `Conserve: drop to ${alt.tier} tier (${altCheapest?.name ?? "—"} at $${altCheapest?.inputCostPer1M ?? "?"}/$${altCheapest?.outputCostPer1M ?? "?"})`, ` ${alt.tradeoff}`);
185
+ }
186
+ return lines.join("\n");
187
+ }
188
+ export function formatRoutingTable(conserve = false) {
189
+ const routes = allPhaseRoutes(conserve);
190
+ const header = `Phase Tier Cheapest model $/M in $/M out Agents`;
191
+ const divider = "─".repeat(header.length);
192
+ const rows = routes.map((r) => {
193
+ const cheapest = cheapestInTier(r.tier);
194
+ const phase = r.phase.padEnd(12);
195
+ const tier = r.tier.padEnd(11);
196
+ const model = (cheapest?.name ?? "—").padEnd(27);
197
+ const inCost = `$${(cheapest?.inputCostPer1M ?? 0).toFixed(2)}`.padEnd(8);
198
+ const outCost = `$${(cheapest?.outputCostPer1M ?? 0).toFixed(2)}`.padEnd(8);
199
+ const agents = r.suggestedAgents.slice(0, 3).join(", ");
200
+ return `${phase} ${tier} ${model} ${inCost} ${outCost} ${agents}`;
201
+ });
202
+ return [
203
+ conserve ? "ROUTING TABLE (conserve mode — usage-saving tier for each phase)" : "ROUTING TABLE",
204
+ "", header, divider, ...rows,
205
+ ].join("\n");
206
+ }
207
+ export function formatCostTable() {
208
+ const { estimates, normalTotal, conserveTotal } = estimateCostSavings();
209
+ const header = "Phase Tier Cheapest model $/M input";
210
+ const divider = "─".repeat(header.length);
211
+ const rows = estimates.map((e) => {
212
+ const phase = e.phase.padEnd(12);
213
+ const tier = e.tier.padEnd(11);
214
+ const model = e.cheapestModel.padEnd(27);
215
+ return `${phase} ${tier} ${model} $${e.inputCost.toFixed(2)}`;
216
+ });
217
+ const savings = Math.round((1 - conserveTotal / normalTotal) * 100);
218
+ return [
219
+ header, divider, ...rows, divider,
220
+ `Normal total: $${normalTotal.toFixed(2)}/M input across 6 phases`,
221
+ `Conserve total: $${conserveTotal.toFixed(2)}/M input (${savings}% cheaper)`,
222
+ "",
223
+ "Prices are per-million-token list rates. Actual cost depends on prompt length.",
224
+ "Use --conserve when you want to stretch remaining session quota.",
225
+ ].join("\n");
226
+ }
227
+ export function formatModelCatalog(filter) {
228
+ let models = MODEL_CATALOG;
229
+ if (filter?.provider)
230
+ models = models.filter((m) => m.provider === filter.provider);
231
+ if (filter?.tier)
232
+ models = models.filter((m) => m.tier === filter.tier);
233
+ if (filter?.current !== undefined)
234
+ models = models.filter((m) => m.current === filter.current);
235
+ const header = "Model Provider Tier $/M in $/M out Gen Agents";
236
+ const divider = "─".repeat(header.length);
237
+ const rows = models.map((m) => {
238
+ const name = m.name.padEnd(28);
239
+ const provider = m.provider.padEnd(11);
240
+ const tier = m.tier.padEnd(11);
241
+ const inCost = `$${m.inputCostPer1M.toFixed(2)}`.padEnd(8);
242
+ const outCost = `$${m.outputCostPer1M.toFixed(2)}`.padEnd(8);
243
+ const gen = m.generation.padEnd(6);
244
+ const agents = m.nativeAgents.slice(0, 3).join(", ");
245
+ return `${name} ${provider} ${tier} ${inCost} ${outCost} ${gen} ${agents}`;
246
+ });
247
+ return [`${models.length} models${filter?.current === false ? " (including legacy)" : ""}`, "", header, divider, ...rows].join("\n");
248
+ }
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstat, readFile, readdir } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
+ import { DEFAULT_ACTIVE_SKILL_LIMIT } from "./active-limit.js";
4
5
  import { readInstallState } from "./state.js";
5
6
  import { listInstalledRuntimeToolSkillTargets, } from "./runtime-tools.js";
6
7
  const MAX_SCAN_DEPTH = 6;
7
- export const RECOMMENDED_ACTIVE_SKILLS = 30;
8
8
  function cleanFrontmatterValue(value) {
9
9
  if (!value)
10
10
  return undefined;
@@ -197,7 +197,7 @@ export async function scanInstalledSkills(agents) {
197
197
  total: entries.length,
198
198
  managed,
199
199
  unmanaged: entries.length - managed,
200
- overRecommendedLimit: entries.length > RECOMMENDED_ACTIVE_SKILLS,
200
+ overRecommendedLimit: entries.length > DEFAULT_ACTIVE_SKILL_LIMIT,
201
201
  runtimeToolTargets: runtimeTargets.filter((target) => target.agent === agent.id).length,
202
202
  ...(agent.additionalSkillsDirectories?.length
203
203
  ? { additionalDirectories: [...agent.additionalSkillsDirectories] }
@@ -205,7 +205,7 @@ export async function scanInstalledSkills(agents) {
205
205
  };
206
206
  });
207
207
  for (const summary of summaries.filter((item) => item.overRecommendedLimit))
208
- warnings.push(`${summary.displayName} exposes ${summary.total} skills; the recommended active set is at most ${RECOMMENDED_ACTIVE_SKILLS}.`);
208
+ warnings.push(`${summary.displayName} exposes ${summary.total} skills; the recommended active set is at most ${DEFAULT_ACTIVE_SKILL_LIMIT}.`);
209
209
  const managed = skills.filter((entry) => entry.managed).length;
210
210
  return {
211
211
  generatedAt: new Date().toISOString(),