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.
@@ -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": "NOASSERTION",
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": "NOASSERTION",
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
- export const LOADOUT_VERSION = "0.5.9";
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"),
@@ -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 codex
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
- "These remain available, but are hidden from the first screen so daily use stays simple.",
44
+ "Hidden from the first screen so daily use stays focused.",
45
45
  "",
46
- "Discovery and evidence: candidate, discover, review-queue, compare, evaluate, capabilities, skill-audit.",
47
- "Manifests and sharing: init, add, unadd, lock, export, import, audit, create, pack, publish, share, card, badge.",
48
- "Integrations and safety: mcp-recipe, mcp-config, codex-mcp-config, credentials, models, sandbox-run, canary, convert.",
49
- "Lifecycle extras: plan, adopt, uninstall, profiles, versions, outcomes, improve, autopilot, tool, watch, schedule, unschedule, serve.",
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
- "Use `loadout <command> --help` for exact options. Every mutation-capable command previews first or requires --yes.",
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",
@@ -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
- "Loadout doctor",
68
- `Platform: ${report.platform}`,
69
- `User home: ${report.userHome}`,
70
- `State home: ${report.loadoutHome}`,
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
- lines.push(`State status: ${report.loadoutHomeExists ? "present" : "not created"} (${report.loadoutHomeWritable ? "writable" : "not writable"})`, "", "Agents:");
73
- for (const entry of report.agents) {
74
- const status = entry.agent.installed ? "detected" : "not found";
75
- lines.push(` ${entry.agent.installed ? "✓" : "○"} ${entry.agent.displayName}: ${status}`, ` skills: ${entry.agent.skillsDirectory} (${entry.skillsRootExists ? "present" : "will be created"}, ${entry.writable ? "writable" : "not writable"})`);
76
- for (const component of entry.inventory.components) {
77
- const detail = component.scanned
78
- ? `${component.directoryExists ? `${component.entries.length} filesystem item(s)` : "not created"}${component.directory ? ` at ${component.directory}` : ""}`
79
- : (component.note ?? "not inspected");
80
- lines.push(` ${component.type}: ${component.compatibility}; ${detail}`);
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
- for (const warning of entry.inventory.warnings)
83
- lines.push(` ! ${warning}`);
84
- for (const issue of entry.issues)
85
- lines.push(` ! ${issue}`);
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
- if (report.issues.length) {
88
- lines.push("", "Recommendations:");
89
- for (const issue of report.issues)
90
- lines.push(` ! ${issue}`);
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
- 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 [