loadout-ai 0.7.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 (44) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +33 -33
  3. package/catalog/discovered.json +26880 -24184
  4. package/dist/src/cli.js +5 -0
  5. package/dist/src/commands/catalog.js +103 -116
  6. package/dist/src/core/agents/agent-inspection.js +26 -4
  7. package/dist/src/core/catalog/registry.js +58 -10
  8. package/dist/src/core/catalog/safety.js +36 -7
  9. package/dist/src/core/install/source.js +21 -7
  10. package/dist/src/core/reporting/cli-guide.js +3 -3
  11. package/dist/src/core/reporting/completion.js +42 -95
  12. package/dist/src/core/reporting/doctor.js +3 -5
  13. package/dist/src/core/routing/handoff.js +94 -58
  14. package/dist/src/core/routing/policy.js +147 -0
  15. package/dist/src/core/routing/route.js +25 -153
  16. package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
  17. package/docs/CATALOG.md +1 -1
  18. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
  19. package/docs/DISCOVERED.md +252 -251
  20. package/docs/FEATURE_TEST_MATRIX.md +7 -260
  21. package/docs/GITHUB_AUTHORIZATION.md +5 -0
  22. package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
  23. package/docs/RELEASE_REVIEW.md +0 -1
  24. package/package.json +6 -4
  25. package/skills/loadout-router/SKILL.md +43 -78
  26. package/MASTER_PLAN.md +0 -2207
  27. package/docs/ACTIVE_SET.md +0 -53
  28. package/docs/COMPATIBILITY_POLICY.md +0 -22
  29. package/docs/CONVERSION_AND_SANDBOX.md +0 -27
  30. package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
  31. package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
  32. package/docs/PROVIDER_CONFIGURATION.md +0 -45
  33. package/docs/README_RESEARCH.md +0 -36
  34. package/docs/REPOSITORY_STABILIZATION.md +0 -190
  35. package/docs/SAFE_UPDATE_DEMO.md +0 -25
  36. package/docs/SCHEMA_DECISIONS.md +0 -25
  37. package/docs/SUBMISSION_COPY.md +0 -90
  38. package/docs/TEAM_POLICY.md +0 -18
  39. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
  40. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
  41. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
  42. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
  43. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
  44. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
@@ -1,87 +1,18 @@
1
- const commands = [
2
- "guide",
3
- "advanced",
4
- "setup",
5
- "upgrade",
6
- "init",
7
- "lock",
8
- "export",
9
- "import",
10
- "audit",
11
- "create",
12
- "pack",
13
- "publish",
14
- "search",
15
- "add",
16
- "unadd",
17
- "list",
18
- "scan",
19
- "reconcile",
20
- "status",
21
- "versions",
22
- "skill-audit",
23
- "doctor",
24
- "health",
25
- "compare",
26
- "optimize",
27
- "activate",
28
- "enable",
29
- "disable",
30
- "adopt",
31
- "library",
32
- "update",
33
- "rollback",
34
- "discover",
35
- "candidate",
36
- "review-queue",
37
- "review",
38
- "alerts",
39
- "alert-ignore",
40
- "alert-pin",
41
- "alert-unpin",
42
- "alert-pins",
43
- "mcp",
44
- "mcp-recipe",
45
- "mcp-config",
46
- "models",
47
- "credentials",
48
- "schedule",
49
- "unschedule",
50
- "autopilot",
51
- "tool",
52
- "report",
53
- "share",
54
- "card",
55
- "compare-loadouts",
56
- "badge",
57
- "inspect",
58
- "evaluate",
59
- "watch",
60
- "sandbox-run",
61
- "codex-mcp-config",
62
- "plan",
63
- "install",
64
- "convert",
65
- "canary",
66
- "capabilities",
67
- "catalog",
68
- "profiles",
69
- "recommend",
70
- "improve",
71
- "improve-feedback",
72
- "sync",
73
- "outcome",
74
- "outcomes",
75
- "remove",
76
- "uninstall",
77
- "serve",
78
- "completion",
79
- ];
80
- const modelCommands = ["status", "set", "verify"];
81
- const credentialCommands = ["status", "set", "check", "delete"];
82
- const candidateCommands = ["list", "inspect", "propose"];
1
+ let registry = [];
2
+ /** Called once at startup with the assembled program. */
3
+ export function registerCompletionCommands(tree) {
4
+ registry = tree
5
+ .filter((entry) => entry.name && entry.name !== "help")
6
+ .sort((a, b) => a.name.localeCompare(b.name));
7
+ }
8
+ export function completionCommands() {
9
+ return registry.map((entry) => entry.name);
10
+ }
11
+ export function completionSubcommands(name) {
12
+ return registry.find((entry) => entry.name === name)?.subcommands ?? [];
13
+ }
83
14
  export function renderShellCompletion(shell) {
84
- const words = commands.join(" ");
15
+ const words = completionCommands().join(" ");
85
16
  switch (shell) {
86
17
  case "bash":
87
18
  return `# Loadout command completion
@@ -91,11 +22,11 @@ _loadout() {
91
22
  if [[ COMP_CWORD -eq 1 ]]; then
92
23
  COMPREPLY=( $(compgen -W "$commands" -- "$current") )
93
24
  elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "models" ]]; then
94
- COMPREPLY=( $(compgen -W "${modelCommands.join(" ")}" -- "$current") )
25
+ COMPREPLY=( $(compgen -W "${completionSubcommands("models").join(" ")}" -- "$current") )
95
26
  elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "credentials" ]]; then
96
- COMPREPLY=( $(compgen -W "${credentialCommands.join(" ")}" -- "$current") )
27
+ COMPREPLY=( $(compgen -W "${completionSubcommands("credentials").join(" ")}" -- "$current") )
97
28
  elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "candidate" ]]; then
98
- COMPREPLY=( $(compgen -W "${candidateCommands.join(" ")}" -- "$current") )
29
+ COMPREPLY=( $(compgen -W "${completionSubcommands("candidate").join(" ")}" -- "$current") )
99
30
  fi
100
31
  }
101
32
  complete -F _loadout loadout
@@ -103,10 +34,18 @@ complete -F _loadout loadout
103
34
  case "zsh":
104
35
  return `#compdef loadout
105
36
  typeset -a commands model_commands credential_commands candidate_commands
106
- commands=(${commands.map((command) => `'${command}'`).join(" ")})
107
- model_commands=(${modelCommands.map((command) => `'${command}'`).join(" ")})
108
- credential_commands=(${credentialCommands.map((command) => `'${command}'`).join(" ")})
109
- candidate_commands=(${candidateCommands.map((command) => `'${command}'`).join(" ")})
37
+ commands=(${completionCommands()
38
+ .map((command) => `'${command}'`)
39
+ .join(" ")})
40
+ model_commands=(${completionSubcommands("models")
41
+ .map((command) => `'${command}'`)
42
+ .join(" ")})
43
+ credential_commands=(${completionSubcommands("credentials")
44
+ .map((command) => `'${command}'`)
45
+ .join(" ")})
46
+ candidate_commands=(${completionSubcommands("candidate")
47
+ .map((command) => `'${command}'`)
48
+ .join(" ")})
110
49
  if (( CURRENT == 2 )); then
111
50
  _describe 'command' commands
112
51
  elif (( CURRENT == 3 )) && [[ $words[2] == models ]]; then
@@ -119,10 +58,10 @@ fi
119
58
  `;
120
59
  case "fish":
121
60
  return [
122
- ...commands.map((command) => `complete -c loadout -f -n '__fish_use_subcommand' -a ${command}`),
123
- ...modelCommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from models' -a ${command}`),
124
- ...credentialCommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from credentials' -a ${command}`),
125
- ...candidateCommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from candidate' -a ${command}`),
61
+ ...completionCommands().map((command) => `complete -c loadout -f -n '__fish_use_subcommand' -a ${command}`),
62
+ ...completionSubcommands("models").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from models' -a ${command}`),
63
+ ...completionSubcommands("credentials").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from credentials' -a ${command}`),
64
+ ...completionSubcommands("candidate").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from candidate' -a ${command}`),
126
65
  ]
127
66
  .join("\n")
128
67
  .concat("\n");
@@ -130,7 +69,15 @@ fi
130
69
  return `Register-ArgumentCompleter -Native -CommandName loadout -ScriptBlock {
131
70
  param($wordToComplete, $commandAst, $cursorPosition)
132
71
  $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
133
- $candidates = if ($elements.Count -ge 2 -and $elements[1] -eq 'models') { @(${modelCommands.map((command) => `'${command}'`).join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'credentials') { @(${credentialCommands.map((command) => `'${command}'`).join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'candidate') { @(${candidateCommands.map((command) => `'${command}'`).join(", ")}) } else { @(${commands.map((command) => `'${command}'`).join(", ")}) }
72
+ $candidates = if ($elements.Count -ge 2 -and $elements[1] -eq 'models') { @(${completionSubcommands("models")
73
+ .map((command) => `'${command}'`)
74
+ .join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'credentials') { @(${completionSubcommands("credentials")
75
+ .map((command) => `'${command}'`)
76
+ .join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'candidate') { @(${completionSubcommands("candidate")
77
+ .map((command) => `'${command}'`)
78
+ .join(", ")}) } else { @(${completionCommands()
79
+ .map((command) => `'${command}'`)
80
+ .join(", ")}) }
134
81
  $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
135
82
  }
136
83
  `;
@@ -1,7 +1,7 @@
1
1
  import { access, constants } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { detectAgents, directoryExists, loadoutHome, userHome, } from "../agents/paths.js";
4
- import { inspectAgents } from "../agents/agent-inspection.js";
4
+ import { countComponentUnits, inspectAgents, } from "../agents/agent-inspection.js";
5
5
  async function writable(path) {
6
6
  // A not-yet-created directory is healthy when any existing ancestor is
7
7
  // writable; this handles first-run paths such as ~/.agents/skills.
@@ -83,13 +83,11 @@ export function formatDoctorReport(report, options = {}) {
83
83
  if (installed.length) {
84
84
  lines.push("DETECTED AGENTS");
85
85
  for (const entry of installed) {
86
- const skillCount = entry.inventory.components
87
- .filter((c) => c.scanned && c.directoryExists)
88
- .reduce((sum, c) => sum + c.entries.length, 0);
86
+ const skillCount = entry.inventory.components.reduce((sum, component) => sum + countComponentUnits(component), 0);
89
87
  const supported = entry.inventory.components
90
88
  .filter((c) => c.compatibility === "native" || c.compatibility === "adapted")
91
89
  .map((c) => c.type);
92
- lines.push(` ✓ ${entry.agent.displayName}`, ` ${entry.agent.skillsDirectory}`, ` ${skillCount} items | supports: ${supported.join(", ")}`);
90
+ lines.push(` ✓ ${entry.agent.displayName}`, ` ${entry.agent.skillsDirectory}`, ` ${skillCount} skills | supports: ${supported.join(", ")}`);
93
91
  if (entry.issues.length) {
94
92
  for (const issue of entry.issues)
95
93
  lines.push(` ⚠ ${issue}`);
@@ -2,6 +2,8 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
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"]);
5
7
  const HANDOFF_DIR = ".handoff";
6
8
  const MESSAGES_FILE = "messages.jsonl";
7
9
  const PROTOCOL_FILE = "PROTOCOL.md";
@@ -24,46 +26,30 @@ export async function initHandoff(projectRoot) {
24
26
  const dir = handoffDir(projectRoot);
25
27
  await mkdir(dir, { recursive: true });
26
28
  const protocol = [
27
- "# Handoff Protocol",
29
+ "# Handoff",
28
30
  "",
29
- "This directory enables asynchronous task handoff between AI coding agents.",
30
- "Generated by `loadout handoff init`. Compatible with the JSONL handoff convention.",
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.",
31
33
  "",
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",
34
+ "## Using it",
48
35
  "",
49
36
  "```",
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>",
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",
54
41
  "```",
55
42
  "",
56
- "## Making agents read this",
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.",
57
46
  "",
58
- "A message log only helps if the other agent checks it. Run:",
47
+ "## Files",
59
48
  "",
60
- "```",
61
- "loadout handoff pickup --yes",
62
- "```",
49
+ "- `messages.jsonl` — the log, one JSON object per line",
50
+ "- `PROTOCOL.md` — this file",
63
51
  "",
64
- "That adds a managed block to CLAUDE.md and AGENTS.md telling each agent to",
65
- "run `loadout handoff inbox <agent>` at session start and act on what it finds.",
66
- "Only the block between the loadout:handoff markers is managed.",
52
+ "Commit both if you want the log shared across machines.",
67
53
  "",
68
54
  ].join("\n");
69
55
  await writeFileAtomically(join(dir, PROTOCOL_FILE), protocol);
@@ -78,7 +64,7 @@ export async function initHandoff(projectRoot) {
78
64
  }
79
65
  export async function sendHandoff(projectRoot, to, description, options = {}) {
80
66
  if (!(await isHandoffInitialized(projectRoot))) {
81
- throw new Error("Handoff not initialized. Run `loadout handoff init` first.");
67
+ throw new Error("Handoff is not set up here. Send a task and it will create itself: loadout handoff <agent> '<task>'");
82
68
  }
83
69
  const message = {
84
70
  id: randomUUID().slice(0, 8),
@@ -87,6 +73,7 @@ export async function sendHandoff(projectRoot, to, description, options = {}) {
87
73
  to,
88
74
  description,
89
75
  ...(options.context ? { context: options.context } : {}),
76
+ ...(options.resolves ? { resolves: options.resolves } : {}),
90
77
  timestamp: new Date().toISOString(),
91
78
  };
92
79
  const path = messagesPath(projectRoot);
@@ -104,40 +91,72 @@ export async function markDone(projectRoot, messageId) {
104
91
  return sendHandoff(projectRoot, original.from, `Completed: ${original.description}`, {
105
92
  from: original.to,
106
93
  type: "done",
107
- context: `Resolves ${messageId}`,
94
+ resolves: messageId,
108
95
  });
109
96
  }
110
- export async function readMessages(projectRoot) {
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) {
111
103
  if (!(await isHandoffInitialized(projectRoot)))
112
- return [];
104
+ return { messages: [], corrupt: [] };
105
+ let content;
113
106
  try {
114
- const content = await readFile(messagesPath(projectRoot), "utf8");
115
- return content
116
- .split("\n")
117
- .filter(Boolean)
118
- .map((line) => JSON.parse(line));
107
+ content = await readFile(messagesPath(projectRoot), "utf8");
119
108
  }
120
109
  catch {
121
- return [];
110
+ return { messages: [], corrupt: [] };
122
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;
123
134
  }
124
135
  export async function getHandoffState(projectRoot) {
125
136
  const initialized = await isHandoffInitialized(projectRoot);
126
- const messages = initialized ? await readMessages(projectRoot) : [];
127
- const doneIds = new Set(messages
128
- .filter((m) => m.type === "done")
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))
129
144
  .flatMap((m) => {
145
+ if (m.resolves)
146
+ return [m.resolves];
147
+ // Logs written before `resolves` existed encode it in the context.
130
148
  const match = m.context?.match(/Resolves (\w+)/);
131
149
  return match ? [match[1]] : [];
132
150
  }));
133
- const pending = messages.filter((m) => m.type === "task" && !doneIds.has(m.id));
134
- const done = messages.filter((m) => m.type === "task" && doneIds.has(m.id));
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));
135
153
  return {
136
154
  initialized,
137
155
  directory: handoffDir(projectRoot),
138
156
  messages,
139
157
  pending,
140
158
  done,
159
+ corrupt,
141
160
  };
142
161
  }
143
162
  // ---------------------------------------------------------------------------
@@ -150,7 +169,7 @@ export async function readInbox(projectRoot, agent) {
150
169
  }
151
170
  /**
152
171
  * Render an agent's inbox as instructions the agent itself can act on. This is
153
- * what `loadout handoff inbox` prints, and what the generated pickup block
172
+ * what `loadout handoff <agent>` prints, and what the generated pickup block
154
173
  * tells each agent to run, so the message log is consumed rather than merely
155
174
  * written.
156
175
  */
@@ -166,7 +185,7 @@ export function formatInbox(agent, messages) {
166
185
  lines.push(` ${m.description}`);
167
186
  if (m.context)
168
187
  lines.push(` context: ${m.context}`);
169
- lines.push(` when finished: loadout handoff done ${m.id}`);
188
+ lines.push(` when finished: loadout handoff --done ${m.id}`);
170
189
  lines.push("");
171
190
  }
172
191
  lines.push("Work these in order. Mark each done as you complete it so the sender sees progress.");
@@ -184,10 +203,10 @@ export function pickupBlock(agent) {
184
203
  `At the start of a session, and whenever you finish a task, run:`,
185
204
  "",
186
205
  "```bash",
187
- `loadout handoff inbox ${agent}`,
206
+ `loadout handoff ${agent}`,
188
207
  "```",
189
208
  "",
190
- "If it lists pending tasks, work them in order and run the `loadout handoff done`",
209
+ "If it lists pending tasks, work them in order and run the `loadout handoff --done`",
191
210
  "command it prints for each one. If it reports none, continue as normal.",
192
211
  "",
193
212
  PICKUP_END,
@@ -198,6 +217,10 @@ const AGENT_CONTEXT_FILES = {
198
217
  "claude-code": "CLAUDE.md",
199
218
  codex: "AGENTS.md",
200
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
+ }
201
224
  export function agentContextFile(agent) {
202
225
  const file = AGENT_CONTEXT_FILES[agent];
203
226
  if (!file)
@@ -225,12 +248,16 @@ export async function planPickup(projectRoot, agent) {
225
248
  const start = existing.indexOf(PICKUP_START);
226
249
  const end = existing.indexOf(PICKUP_END);
227
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));
228
255
  const content = replacing
229
256
  ? existing.slice(0, start) + block + existing.slice(end + PICKUP_END.length)
230
257
  : exists
231
258
  ? `${existing.replace(/\s*$/, "")}\n\n${block}\n`
232
259
  : `${block}\n`;
233
- return { path, agent, exists, replacing, content };
260
+ return { path, agent, exists, replacing, stale, content };
234
261
  }
235
262
  export async function applyPickup(plan) {
236
263
  await writeFileAtomically(plan.path, plan.content);
@@ -238,11 +265,13 @@ export async function applyPickup(plan) {
238
265
  export function formatPickupPlan(plans) {
239
266
  const lines = ["Handoff pickup instructions:", ""];
240
267
  for (const plan of plans) {
241
- const action = plan.replacing
242
- ? "refresh managed block in"
243
- : plan.exists
244
- ? "append managed block to"
245
- : "create";
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";
246
275
  lines.push(` ${action} ${plan.path}`);
247
276
  }
248
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.");
@@ -250,9 +279,9 @@ export function formatPickupPlan(plans) {
250
279
  }
251
280
  export function formatHandoffStatus(state) {
252
281
  if (!state.initialized)
253
- return "Handoff not initialized. Run `loadout handoff init`.";
282
+ return "No handoff log here yet. Send a task and it creates itself: loadout handoff <agent> '<task>'";
254
283
  if (state.messages.length === 0)
255
- return "No handoff messages yet. Send one with `loadout handoff send <agent> <task>`.";
284
+ return "No handoff messages yet. Send one with `loadout handoff <agent> '<task>'`.";
256
285
  const lines = [];
257
286
  if (state.pending.length) {
258
287
  lines.push(`Pending (${state.pending.length}):`);
@@ -268,6 +297,13 @@ export function formatHandoffStatus(state) {
268
297
  lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
269
298
  }
270
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
+ }
271
307
  const other = state.messages.filter((m) => m.type !== "task" && m.type !== "done");
272
308
  if (other.length) {
273
309
  if (lines.length)
@@ -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
+ }