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.
- package/CHANGELOG.md +72 -0
- package/README.md +33 -33
- package/catalog/discovered.json +26880 -24184
- package/dist/src/cli.js +5 -0
- package/dist/src/commands/catalog.js +103 -116
- package/dist/src/core/agents/agent-inspection.js +26 -4
- package/dist/src/core/catalog/registry.js +58 -10
- package/dist/src/core/catalog/safety.js +36 -7
- package/dist/src/core/install/source.js +21 -7
- package/dist/src/core/reporting/cli-guide.js +3 -3
- package/dist/src/core/reporting/completion.js +42 -95
- package/dist/src/core/reporting/doctor.js +3 -5
- package/dist/src/core/routing/handoff.js +94 -58
- package/dist/src/core/routing/policy.js +147 -0
- package/dist/src/core/routing/route.js +25 -153
- package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
- package/docs/CATALOG.md +1 -1
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
- package/docs/DISCOVERED.md +252 -251
- package/docs/FEATURE_TEST_MATRIX.md +7 -260
- package/docs/GITHUB_AUTHORIZATION.md +5 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
- package/docs/RELEASE_REVIEW.md +0 -1
- package/package.json +6 -4
- package/skills/loadout-router/SKILL.md +43 -78
- package/MASTER_PLAN.md +0 -2207
- package/docs/ACTIVE_SET.md +0 -53
- package/docs/COMPATIBILITY_POLICY.md +0 -22
- package/docs/CONVERSION_AND_SANDBOX.md +0 -27
- package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
- package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
- package/docs/PROVIDER_CONFIGURATION.md +0 -45
- package/docs/README_RESEARCH.md +0 -36
- package/docs/REPOSITORY_STABILIZATION.md +0 -190
- package/docs/SAFE_UPDATE_DEMO.md +0 -25
- package/docs/SCHEMA_DECISIONS.md +0 -25
- package/docs/SUBMISSION_COPY.md +0 -90
- package/docs/TEAM_POLICY.md +0 -18
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
|
@@ -1,87 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 =
|
|
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 "${
|
|
25
|
+
COMPREPLY=( $(compgen -W "${completionSubcommands("models").join(" ")}" -- "$current") )
|
|
95
26
|
elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "credentials" ]]; then
|
|
96
|
-
COMPREPLY=( $(compgen -W "${
|
|
27
|
+
COMPREPLY=( $(compgen -W "${completionSubcommands("credentials").join(" ")}" -- "$current") )
|
|
97
28
|
elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "candidate" ]]; then
|
|
98
|
-
COMPREPLY=( $(compgen -W "${
|
|
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=(${
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
...
|
|
123
|
-
...
|
|
124
|
-
...
|
|
125
|
-
...
|
|
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') { @(${
|
|
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}
|
|
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
|
|
29
|
+
"# Handoff",
|
|
28
30
|
"",
|
|
29
|
-
"
|
|
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.",
|
|
31
33
|
"",
|
|
32
|
-
"##
|
|
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
|
|
51
|
-
"loadout handoff
|
|
52
|
-
"loadout handoff
|
|
53
|
-
"loadout handoff done <
|
|
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
|
-
"
|
|
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
|
-
"
|
|
47
|
+
"## Files",
|
|
59
48
|
"",
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"```",
|
|
49
|
+
"- `messages.jsonl` — the log, one JSON object per line",
|
|
50
|
+
"- `PROTOCOL.md` — this file",
|
|
63
51
|
"",
|
|
64
|
-
"
|
|
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
|
|
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
|
-
|
|
94
|
+
resolves: messageId,
|
|
108
95
|
});
|
|
109
96
|
}
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
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" && !
|
|
134
|
-
const done = messages.filter((m) => m.type === "task" &&
|
|
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
|
|
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
|
|
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.
|
|
242
|
-
? "
|
|
243
|
-
: plan.
|
|
244
|
-
? "
|
|
245
|
-
:
|
|
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 "
|
|
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
|
|
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
|
+
}
|