loadout-ai 0.5.9 → 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.
- package/README.md +70 -113
- package/catalog/discovered.json +26482 -22861
- package/catalog/packages.json +2 -2
- package/dist/src/commands/catalog.js +132 -2
- package/dist/src/commands/inventory.js +2 -1
- package/dist/src/commands/support.js +22 -2
- package/dist/src/core/catalog.js +13 -0
- package/dist/src/core/cli-guide.js +8 -7
- package/dist/src/core/doctor.js +73 -24
- package/dist/src/core/handoff.js +161 -0
- package/dist/src/core/review-queue.js +7 -2
- package/dist/src/core/route.js +248 -0
- package/docs/CATALOG.md +2 -2
- package/docs/DISCOVERED.md +249 -248
- package/docs/UPSTREAM_LICENSE_DECISIONS.md +19 -12
- package/package.json +1 -1
package/catalog/packages.json
CHANGED
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
"description": "The open agent skills tool - npx skills.",
|
|
209
209
|
"category": "skill-discovery",
|
|
210
210
|
"tier": "stable",
|
|
211
|
-
"license": "
|
|
211
|
+
"license": "MIT",
|
|
212
212
|
"components": ["skill"],
|
|
213
213
|
"operatingSystems": ["windows", "macos", "linux"],
|
|
214
214
|
"stars": 26124,
|
|
@@ -271,7 +271,7 @@
|
|
|
271
271
|
"description": "An MCP server for interacting with Sentry via LLMs.",
|
|
272
272
|
"category": "observability",
|
|
273
273
|
"tier": "official",
|
|
274
|
-
"license": "
|
|
274
|
+
"license": "FSL-1.1-ALv2",
|
|
275
275
|
"components": ["skill", "mcp"],
|
|
276
276
|
"operatingSystems": ["windows", "macos", "linux"],
|
|
277
277
|
"stars": 771,
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { explainCatalogScore, loadEffectiveCatalog, loadCatalog, rankCatalog, refreshCatalog, } from "../core/catalog.js";
|
|
1
|
+
import { explainCatalogScore, loadEffectiveCatalog, loadCatalog, promoteCatalogCandidate, rankCatalog, refreshCatalog, validateCatalog, } from "../core/catalog.js";
|
|
2
2
|
import { parseAgentSelection } from "../core/paths.js";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { allPhaseRoutes, formatCostTable, formatModelCatalog, formatRouteRecommendation, formatRoutingTable, routePhase, routeTask, } from "../core/route.js";
|
|
6
|
+
import { formatHandoffStatus, getHandoffState, initHandoff, isHandoffInitialized, markDone, sendHandoff, } from "../core/handoff.js";
|
|
4
7
|
import { catalogTrustStage, formatCatalogTrustStage, } from "../core/profiles.js";
|
|
5
8
|
import { discoverHackerNewsRepositories } from "../core/community.js";
|
|
6
9
|
import { discoverPrivateRepositories } from "../core/private-discovery.js";
|
|
7
10
|
import { defaultGitHubDiscoveryQueries, discoverGitHubRepositories, } from "../core/github-discovery.js";
|
|
8
11
|
import { formatStarHistory, readCatalogObservations, } from "../core/observations.js";
|
|
9
12
|
import { writeFileAtomically } from "../core/atomic-file.js";
|
|
10
|
-
import { formatReviewQueue, mergeReviewQueue, readReviewQueue, setReviewDecision, } from "../core/review-queue.js";
|
|
13
|
+
import { formatReviewQueue, markPromoted, mergeReviewQueue, readReviewQueue, setReviewDecision, } from "../core/review-queue.js";
|
|
11
14
|
import { applyProviderModelSelection, defaultModelConfigurationPath, formatProviderModelConfiguration, planProviderModelSelection, readProviderModelConfiguration, requestOpenRouter, } from "../core/model-config.js";
|
|
12
15
|
import { parseCompletionShell, renderShellCompletion, } from "../core/completion.js";
|
|
13
16
|
import { buildCatalogCoverage, formatCatalogCoverage, } from "../core/catalog-coverage.js";
|
|
@@ -199,6 +202,35 @@ export function registerCatalog(program) {
|
|
|
199
202
|
else
|
|
200
203
|
console.log(`Approved proposal written to ${output}.`);
|
|
201
204
|
});
|
|
205
|
+
candidate
|
|
206
|
+
.command("promote")
|
|
207
|
+
.allowExcessArguments(false)
|
|
208
|
+
.description("Merge a reviewed proposal into the local catalog; requires --approve")
|
|
209
|
+
.argument("<proposal>", "proposal JSON file from `candidate propose --approve`")
|
|
210
|
+
.option("--approve", "confirm human review and write to catalog")
|
|
211
|
+
.option("--json", "emit machine-readable JSON")
|
|
212
|
+
.action(async (proposalPath, options) => {
|
|
213
|
+
const proposal = JSON.parse(await readFile(resolve(proposalPath), "utf8"));
|
|
214
|
+
validateCatalog([proposal], { requireEvidence: true });
|
|
215
|
+
if (!options.approve) {
|
|
216
|
+
if (options.json)
|
|
217
|
+
return console.log(JSON.stringify({ proposal, promoted: false, catalogMutated: false }, null, 2));
|
|
218
|
+
console.log(JSON.stringify(proposal, null, 2));
|
|
219
|
+
console.log("Preview only. Re-run with --approve to merge into the catalog.");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const result = await promoteCatalogCandidate(proposal);
|
|
223
|
+
const item = await markPromoted(proposal.repository);
|
|
224
|
+
if (options.json)
|
|
225
|
+
return console.log(JSON.stringify({
|
|
226
|
+
proposal,
|
|
227
|
+
promoted: true,
|
|
228
|
+
catalogMutated: true,
|
|
229
|
+
...result,
|
|
230
|
+
reviewQueue: item,
|
|
231
|
+
}, null, 2));
|
|
232
|
+
console.log(`Promoted '${proposal.id}' into ${result.catalogPath} (${result.totalRecords} records). Review queue: ${item.repository} marked promoted.`);
|
|
233
|
+
});
|
|
202
234
|
program
|
|
203
235
|
.command("discover")
|
|
204
236
|
.description("Find public community leads; discovery never installs anything")
|
|
@@ -568,6 +600,104 @@ export function registerCatalog(program) {
|
|
|
568
600
|
});
|
|
569
601
|
console.log(`Verified model selection '${id}'. No credential value was stored or printed.`);
|
|
570
602
|
});
|
|
603
|
+
const handoff = program
|
|
604
|
+
.command("handoff")
|
|
605
|
+
.description("Cross-agent file-based task handoff — send work between Claude Code, Codex, and other agents");
|
|
606
|
+
handoff
|
|
607
|
+
.command("init")
|
|
608
|
+
.description("Create the .handoff/ protocol directory in the current project")
|
|
609
|
+
.action(async () => {
|
|
610
|
+
const projectRoot = process.cwd();
|
|
611
|
+
if (await isHandoffInitialized(projectRoot)) {
|
|
612
|
+
console.log("Handoff already initialized at .handoff/");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const dir = await initHandoff(projectRoot);
|
|
616
|
+
console.log(`Initialized handoff protocol at ${dir}`);
|
|
617
|
+
console.log("Add .handoff/ to version control so both agents share the message log.");
|
|
618
|
+
});
|
|
619
|
+
handoff
|
|
620
|
+
.command("send")
|
|
621
|
+
.description("Send a task or message to another agent")
|
|
622
|
+
.argument("<agent>", "target agent (e.g. codex, claude-code)")
|
|
623
|
+
.argument("<description...>", "task description")
|
|
624
|
+
.option("--from <agent>", "sending agent", "user")
|
|
625
|
+
.option("--type <type>", "message type: task, handoff, question, status, error, cancel", "task")
|
|
626
|
+
.option("--context <text>", "additional context for the receiving agent")
|
|
627
|
+
.option("--json", "emit machine-readable JSON")
|
|
628
|
+
.action(async (agent, descriptionWords, options) => {
|
|
629
|
+
const message = await sendHandoff(process.cwd(), agent, descriptionWords.join(" "), {
|
|
630
|
+
from: options.from,
|
|
631
|
+
type: options.type,
|
|
632
|
+
context: options.context,
|
|
633
|
+
});
|
|
634
|
+
console.log(options.json
|
|
635
|
+
? JSON.stringify(message, null, 2)
|
|
636
|
+
: `Sent ${message.type} ${message.id} → ${message.to}: ${message.description}`);
|
|
637
|
+
});
|
|
638
|
+
handoff
|
|
639
|
+
.command("done")
|
|
640
|
+
.description("Mark a handoff task as completed")
|
|
641
|
+
.argument("<id>", "message id to mark done")
|
|
642
|
+
.option("--json", "emit machine-readable JSON")
|
|
643
|
+
.action(async (id, options) => {
|
|
644
|
+
const message = await markDone(process.cwd(), id);
|
|
645
|
+
console.log(options.json
|
|
646
|
+
? JSON.stringify(message, null, 2)
|
|
647
|
+
: `Marked ${id} as done`);
|
|
648
|
+
});
|
|
649
|
+
handoff
|
|
650
|
+
.command("status")
|
|
651
|
+
.description("Show pending and completed handoff tasks")
|
|
652
|
+
.option("--json", "emit machine-readable JSON")
|
|
653
|
+
.action(async (options) => {
|
|
654
|
+
const state = await getHandoffState(process.cwd());
|
|
655
|
+
console.log(options.json
|
|
656
|
+
? JSON.stringify(state, null, 2)
|
|
657
|
+
: formatHandoffStatus(state));
|
|
658
|
+
});
|
|
659
|
+
const route = program
|
|
660
|
+
.command("route")
|
|
661
|
+
.description("Recommend the right model tier and agent for a task — plan, implement, review, test, debug, or document");
|
|
662
|
+
route
|
|
663
|
+
.argument("[description...]", "natural-language task description")
|
|
664
|
+
.option("--phase <phase>", "explicit phase instead of auto-classify")
|
|
665
|
+
.option("--conserve", "recommend cheaper tiers to stretch remaining session quota")
|
|
666
|
+
.option("--cost", "show cost comparison table across all phases")
|
|
667
|
+
.option("--models", "list the full model catalog Loadout knows about")
|
|
668
|
+
.option("--provider <name>", "filter models by provider (anthropic, openai, google, deepseek, meta)")
|
|
669
|
+
.option("--tier <tier>", "filter models by tier (frontier, standard, fast)")
|
|
670
|
+
.option("--json", "emit machine-readable JSON")
|
|
671
|
+
.action(async (descriptionWords, options) => {
|
|
672
|
+
if (options.models) {
|
|
673
|
+
console.log(options.json
|
|
674
|
+
? JSON.stringify((await import("../core/route.js")).MODEL_CATALOG, null, 2)
|
|
675
|
+
: formatModelCatalog({
|
|
676
|
+
provider: options.provider,
|
|
677
|
+
tier: options.tier,
|
|
678
|
+
}));
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (options.cost) {
|
|
682
|
+
console.log(options.json
|
|
683
|
+
? JSON.stringify((await import("../core/route.js")).estimateCostSavings(), null, 2)
|
|
684
|
+
: formatCostTable());
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const description = descriptionWords.join(" ").trim();
|
|
688
|
+
if (!description && !options.phase) {
|
|
689
|
+
console.log(options.json
|
|
690
|
+
? JSON.stringify(allPhaseRoutes(options.conserve), null, 2)
|
|
691
|
+
: formatRoutingTable(options.conserve));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const rec = options.phase
|
|
695
|
+
? routePhase(options.phase, options.conserve)
|
|
696
|
+
: routeTask(description, options.conserve);
|
|
697
|
+
console.log(options.json
|
|
698
|
+
? JSON.stringify(rec, null, 2)
|
|
699
|
+
: formatRouteRecommendation(rec));
|
|
700
|
+
});
|
|
571
701
|
program
|
|
572
702
|
.command("completion")
|
|
573
703
|
.description("Print a shell-completion script; redirect it to your shell profile")
|
|
@@ -406,11 +406,12 @@ export function registerInventory(program) {
|
|
|
406
406
|
program
|
|
407
407
|
.command("doctor")
|
|
408
408
|
.description("Check agents, skill directories, permissions, and Loadout setup")
|
|
409
|
+
.option("--verbose", "show per-component detail for each agent")
|
|
409
410
|
.option("--json", "print a machine-readable report")
|
|
410
411
|
.action(async (options) => {
|
|
411
412
|
const report = await runDoctor();
|
|
412
413
|
console.log(options.json
|
|
413
414
|
? JSON.stringify(report, null, 2)
|
|
414
|
-
: formatDoctorReport(report));
|
|
415
|
+
: formatDoctorReport(report, { verbose: options.verbose }));
|
|
415
416
|
});
|
|
416
417
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { detectAgents, parseAgentSelection } from "../core/paths.js";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { applyPreparedCatalogInstall, formatCatalogApplyGuidance, formatPreparedCatalogInstall, prepareCatalogInstall, } from "../core/catalog-install.js";
|
|
5
7
|
import { formatInstalledSkillInventory, scanInstalledSkills, } from "../core/skill-inventory.js";
|
|
6
8
|
import { interactiveModelApiAccess, parseModelApiAccess, } from "../core/access.js";
|
|
@@ -213,7 +215,25 @@ export async function runWizard() {
|
|
|
213
215
|
console.log("\nPreview first — nothing changes until you approve, and every change is snapshotted for rollback.\n");
|
|
214
216
|
await runSetup({ package: [] });
|
|
215
217
|
}
|
|
216
|
-
|
|
218
|
+
const _require = createRequire(import.meta.url);
|
|
219
|
+
function findPackageJson() {
|
|
220
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
221
|
+
for (let i = 0; i < 5; i++) {
|
|
222
|
+
const candidate = join(dir, "package.json");
|
|
223
|
+
try {
|
|
224
|
+
const pkg = _require(candidate);
|
|
225
|
+
if (pkg.name === "loadout-ai")
|
|
226
|
+
return candidate;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// not found, keep walking
|
|
230
|
+
}
|
|
231
|
+
dir = dirname(dir);
|
|
232
|
+
}
|
|
233
|
+
// ponytail: fallback for dist layout — 3 levels up from dist/src/commands/
|
|
234
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
|
|
235
|
+
}
|
|
236
|
+
export const LOADOUT_VERSION = _require(findPackageJson()).version;
|
|
217
237
|
export function durableSchedulerLauncher() {
|
|
218
238
|
return [
|
|
219
239
|
join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
|
package/dist/src/core/catalog.js
CHANGED
|
@@ -218,6 +218,19 @@ export async function refreshCatalog(packages, options = {}) {
|
|
|
218
218
|
observationFailures: observations.failures,
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
|
+
/** Merge a human-approved proposal into the bundled catalog. */
|
|
222
|
+
export async function promoteCatalogCandidate(proposal) {
|
|
223
|
+
const catalogPath = bundledCatalogPath();
|
|
224
|
+
const current = JSON.parse(await readFile(catalogPath, "utf8"));
|
|
225
|
+
if (current.some((pkg) => pkg.id === proposal.id))
|
|
226
|
+
throw new Error(`Catalog already contains id '${proposal.id}'`);
|
|
227
|
+
if (current.some((pkg) => pkg.repository.toLowerCase() === proposal.repository.toLowerCase()))
|
|
228
|
+
throw new Error(`Catalog already contains repository '${proposal.repository}'`);
|
|
229
|
+
const merged = [...current, proposal].sort((a, b) => a.id.localeCompare(b.id));
|
|
230
|
+
validateCatalog(merged, { requireEvidence: true });
|
|
231
|
+
await writeFile(catalogPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
232
|
+
return { catalogPath, totalRecords: merged.length };
|
|
233
|
+
}
|
|
221
234
|
export function rankCatalog(packages) {
|
|
222
235
|
return [...packages].sort(compareCatalogPackages);
|
|
223
236
|
}
|
|
@@ -23,7 +23,7 @@ START HERE
|
|
|
23
23
|
5. See reviewed MCP integrations and their credential needs
|
|
24
24
|
loadout mcp-recipe
|
|
25
25
|
loadout mcp-recipe --credential-free
|
|
26
|
-
loadout mcp-recipe playwright --agent
|
|
26
|
+
loadout mcp-recipe playwright --agent claude-code
|
|
27
27
|
|
|
28
28
|
If you decide to install something, Loadout shows a preview first and creates a
|
|
29
29
|
snapshot before changing managed files. Recover with: loadout rollback
|
|
@@ -41,14 +41,14 @@ loadout advanced
|
|
|
41
41
|
export const ADVANCED_GUIDE = [
|
|
42
42
|
"ADVANCED COMMANDS",
|
|
43
43
|
"",
|
|
44
|
-
"
|
|
44
|
+
"Hidden from the first screen so daily use stays focused.",
|
|
45
45
|
"",
|
|
46
|
-
"Discovery
|
|
47
|
-
"
|
|
48
|
-
"Integrations
|
|
49
|
-
"Lifecycle
|
|
46
|
+
"Discovery: candidate, discover, review-queue, evaluate, skill-audit",
|
|
47
|
+
"Sharing: init, lock, export, import, share, card, badge",
|
|
48
|
+
"Integrations: mcp-recipe, mcp-config, credentials, models, handoff, convert",
|
|
49
|
+
"Lifecycle: plan, adopt, uninstall, profiles, autopilot, tool, watch",
|
|
50
50
|
"",
|
|
51
|
-
"
|
|
51
|
+
"`loadout <command> --help` for options. Mutations preview first or require --yes.",
|
|
52
52
|
].join("\n");
|
|
53
53
|
/**
|
|
54
54
|
* The first-screen help shows only the everyday core (~24 commands). Everything
|
|
@@ -93,6 +93,7 @@ export const HIDDEN_FROM_FIRST_SCREEN = new Set([
|
|
|
93
93
|
// integrations / credentials / runtime
|
|
94
94
|
"credentials",
|
|
95
95
|
"models",
|
|
96
|
+
"handoff",
|
|
96
97
|
"completion",
|
|
97
98
|
"mcp-recipe",
|
|
98
99
|
"mcp-config",
|
package/dist/src/core/doctor.js
CHANGED
|
@@ -62,34 +62,83 @@ export async function runDoctor() {
|
|
|
62
62
|
issues,
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
-
export function formatDoctorReport(report) {
|
|
65
|
+
export function formatDoctorReport(report, options = {}) {
|
|
66
|
+
const installed = report.agents.filter((a) => a.agent.installed);
|
|
67
|
+
const notInstalled = report.agents.filter((a) => !a.agent.installed);
|
|
68
|
+
const allIssues = [
|
|
69
|
+
...report.issues,
|
|
70
|
+
...installed.flatMap((a) => a.issues),
|
|
71
|
+
];
|
|
72
|
+
const grade = allIssues.length === 0 && installed.length > 0
|
|
73
|
+
? "HEALTHY"
|
|
74
|
+
: allIssues.length <= 2
|
|
75
|
+
? "OK"
|
|
76
|
+
: "NEEDS ATTENTION";
|
|
66
77
|
const lines = [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`
|
|
70
|
-
`State
|
|
78
|
+
`loadout doctor — ${grade}`,
|
|
79
|
+
"",
|
|
80
|
+
`Platform: ${report.platform}`,
|
|
81
|
+
`State: ${report.loadoutHome} ${report.loadoutHomeExists ? "✓" : "⚠ not created"} ${report.loadoutHomeWritable ? "writable" : "⚠ not writable"}`,
|
|
82
|
+
`Agents: ${installed.length} detected, ${notInstalled.length} available`,
|
|
83
|
+
"",
|
|
71
84
|
];
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
// --- Detected agents: compact summary ---
|
|
86
|
+
if (installed.length) {
|
|
87
|
+
lines.push("DETECTED AGENTS");
|
|
88
|
+
for (const entry of installed) {
|
|
89
|
+
const skillCount = entry.inventory.components
|
|
90
|
+
.filter((c) => c.scanned && c.directoryExists)
|
|
91
|
+
.reduce((sum, c) => sum + c.entries.length, 0);
|
|
92
|
+
const supported = entry.inventory.components
|
|
93
|
+
.filter((c) => c.compatibility === "native" || c.compatibility === "adapted")
|
|
94
|
+
.map((c) => c.type);
|
|
95
|
+
lines.push(` ✓ ${entry.agent.displayName}`, ` ${entry.agent.skillsDirectory}`, ` ${skillCount} items | supports: ${supported.join(", ")}`);
|
|
96
|
+
if (entry.issues.length) {
|
|
97
|
+
for (const issue of entry.issues)
|
|
98
|
+
lines.push(` ⚠ ${issue}`);
|
|
99
|
+
}
|
|
100
|
+
if (options.verbose) {
|
|
101
|
+
for (const component of entry.inventory.components) {
|
|
102
|
+
if (component.compatibility === "unsupported")
|
|
103
|
+
continue;
|
|
104
|
+
const detail = component.scanned
|
|
105
|
+
? component.directoryExists
|
|
106
|
+
? `${component.entries.length} items`
|
|
107
|
+
: "not created"
|
|
108
|
+
: (component.note ?? "—");
|
|
109
|
+
lines.push(` ${component.type}: ${detail}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
81
112
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
lines.push("");
|
|
114
|
+
}
|
|
115
|
+
// --- Not installed: one-liner ---
|
|
116
|
+
if (notInstalled.length) {
|
|
117
|
+
lines.push(`NOT DETECTED: ${notInstalled.map((a) => a.agent.displayName).join(", ")}`, "");
|
|
118
|
+
}
|
|
119
|
+
// --- Issues ---
|
|
120
|
+
if (allIssues.length) {
|
|
121
|
+
lines.push("ISSUES");
|
|
122
|
+
for (const issue of allIssues)
|
|
123
|
+
lines.push(` ⚠ ${issue}`);
|
|
124
|
+
lines.push("");
|
|
86
125
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
126
|
+
// --- Next steps ---
|
|
127
|
+
lines.push("NEXT STEPS");
|
|
128
|
+
if (allIssues.length) {
|
|
129
|
+
lines.push(" Fix the issues above, then run `loadout doctor` again.");
|
|
130
|
+
}
|
|
131
|
+
else if (installed.length === 0) {
|
|
132
|
+
lines.push(" Install an AI coding agent (Claude Code, Codex, Cursor, etc.),", " then run `loadout doctor` again.");
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
lines.push(" loadout status see what's managed", " loadout setup install a curated skill set", " loadout route find the right model for your task", " loadout health check for updates");
|
|
91
136
|
}
|
|
92
|
-
else
|
|
93
|
-
lines.push("", "No global blocking issues found.");
|
|
94
137
|
return lines.join("\n");
|
|
95
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Legacy-compatible verbose format. Used when --verbose is passed.
|
|
141
|
+
*/
|
|
142
|
+
export function formatDoctorReportVerbose(report) {
|
|
143
|
+
return formatDoctorReport(report, { verbose: true });
|
|
144
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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 [
|