loadout-ai 0.5.9 → 0.7.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 (108) hide show
  1. package/README.md +181 -121
  2. package/catalog/discovered.json +28066 -23196
  3. package/catalog/packages.json +2 -2
  4. package/dist/src/cli.js +2 -2
  5. package/dist/src/commands/agents.js +6 -23
  6. package/dist/src/commands/catalog.js +254 -27
  7. package/dist/src/commands/health.js +6 -6
  8. package/dist/src/commands/inventory.js +21 -121
  9. package/dist/src/commands/lifecycle.js +13 -51
  10. package/dist/src/commands/mcp.js +10 -73
  11. package/dist/src/commands/setup.js +5 -5
  12. package/dist/src/commands/sharing.js +11 -108
  13. package/dist/src/commands/support.js +42 -11
  14. package/dist/src/core/{adapters.js → agents/adapters.js} +2 -2
  15. package/dist/src/core/{agent-inspection.js → agents/agent-inspection.js} +26 -6
  16. package/dist/src/core/{codex-mcp.js → agents/codex-mcp.js} +1 -1
  17. package/dist/src/core/{health-score-evidence.js → agents/health-score-evidence.js} +8 -8
  18. package/dist/src/core/{runtime-tools.js → agents/runtime-tools.js} +2 -2
  19. package/dist/src/core/{audit.js → catalog/audit.js} +4 -4
  20. package/dist/src/core/{catalog.js → catalog/catalog.js} +18 -5
  21. package/dist/src/core/{components.js → catalog/components.js} +2 -2
  22. package/dist/src/core/{conformance.js → catalog/conformance.js} +2 -2
  23. package/dist/src/core/{profiles.js → catalog/profiles.js} +2 -2
  24. package/dist/src/core/{provenance.js → catalog/provenance.js} +3 -3
  25. package/dist/src/core/{registry.js → catalog/registry.js} +1 -1
  26. package/dist/src/core/{skill-compare.js → catalog/skill-compare.js} +1 -1
  27. package/dist/src/core/{skill-inventory.js → catalog/skill-inventory.js} +3 -3
  28. package/dist/src/core/{skills.js → catalog/skills.js} +1 -1
  29. package/dist/src/core/{candidate-intelligence.js → discovery/candidate-intelligence.js} +6 -6
  30. package/dist/src/core/{discovery-connector.js → discovery/discovery-connector.js} +2 -2
  31. package/dist/src/core/{evaluate.js → discovery/evaluate.js} +2 -2
  32. package/dist/src/core/{mcp-registry-discovery.js → discovery/mcp-registry-discovery.js} +1 -1
  33. package/dist/src/core/{observations.js → discovery/observations.js} +3 -3
  34. package/dist/src/core/{review-queue.js → discovery/review-queue.js} +9 -4
  35. package/dist/src/core/{skills-sh-discovery.js → discovery/skills-sh-discovery.js} +1 -1
  36. package/dist/src/core/{adopt.js → install/adopt.js} +3 -3
  37. package/dist/src/core/{catalog-install.js → install/catalog-install.js} +11 -11
  38. package/dist/src/core/{file-lock.js → install/file-lock.js} +1 -1
  39. package/dist/src/core/{install.js → install/install.js} +5 -5
  40. package/dist/src/core/{package.js → install/package.js} +3 -3
  41. package/dist/src/core/{reconcile.js → install/reconcile.js} +5 -5
  42. package/dist/src/core/{remove.js → install/remove.js} +4 -4
  43. package/dist/src/core/{snapshot.js → install/snapshot.js} +1 -1
  44. package/dist/src/core/{source.js → install/source.js} +1 -1
  45. package/dist/src/core/{sync.js → install/sync.js} +9 -9
  46. package/dist/src/core/{transaction.js → install/transaction.js} +1 -1
  47. package/dist/src/core/{uninstall.js → install/uninstall.js} +4 -4
  48. package/dist/src/core/{update.js → install/update.js} +4 -4
  49. package/dist/src/core/reporting/cli-guide.js +103 -0
  50. package/dist/src/core/reporting/doctor.js +141 -0
  51. package/dist/src/core/{freshness-alerts.js → reporting/freshness-alerts.js} +5 -5
  52. package/dist/src/core/{health.js → reporting/health.js} +6 -6
  53. package/dist/src/core/{loadout-card.js → reporting/loadout-card.js} +1 -1
  54. package/dist/src/core/{readme-claims.js → reporting/readme-claims.js} +1 -1
  55. package/dist/src/core/{readme-facts.js → reporting/readme-facts.js} +2 -2
  56. package/dist/src/core/{share-report.js → reporting/share-report.js} +3 -3
  57. package/dist/src/core/{upgrade.js → reporting/upgrade.js} +7 -7
  58. package/dist/src/core/routing/first-party-skills.js +118 -0
  59. package/dist/src/core/routing/handoff.js +278 -0
  60. package/dist/src/core/{model-config.js → routing/model-config.js} +4 -4
  61. package/dist/src/core/routing/route.js +539 -0
  62. package/dist/src/core/{api.js → runtime/api.js} +4 -4
  63. package/dist/src/core/{canary.js → runtime/canary.js} +1 -1
  64. package/dist/src/core/{github.js → runtime/github.js} +1 -1
  65. package/dist/src/core/{mcp-recipes.js → runtime/mcp-recipes.js} +1 -1
  66. package/dist/src/core/{mcp.js → runtime/mcp.js} +2 -2
  67. package/dist/src/core/{scheduler.js → runtime/scheduler.js} +4 -4
  68. package/dist/src/core/{active-policy.js → workspace/active-policy.js} +2 -2
  69. package/dist/src/core/{active-set.js → workspace/active-set.js} +2 -2
  70. package/dist/src/core/{improve.js → workspace/improve.js} +3 -3
  71. package/dist/src/core/{manifest.js → workspace/manifest.js} +2 -2
  72. package/dist/src/core/{outcomes.js → workspace/outcomes.js} +3 -3
  73. package/dist/src/core/{portable.js → workspace/portable.js} +3 -3
  74. package/dist/src/core/{profile-state.js → workspace/profile-state.js} +1 -1
  75. package/dist/src/core/{recommend.js → workspace/recommend.js} +1 -1
  76. package/dist/src/core/{state.js → workspace/state.js} +3 -3
  77. package/docs/CATALOG.md +2 -2
  78. package/docs/DISCOVERED.md +249 -247
  79. package/docs/UPSTREAM_LICENSE_DECISIONS.md +19 -12
  80. package/docs/evidence/readme-claims.json +22 -19
  81. package/package.json +2 -1
  82. package/skills/loadout-curator/SKILL.md +105 -0
  83. package/skills/loadout-router/SKILL.md +120 -0
  84. package/dist/src/core/cli-guide.js +0 -118
  85. package/dist/src/core/doctor.js +0 -95
  86. /package/dist/src/core/{agent-health-score.js → agents/agent-health-score.js} +0 -0
  87. /package/dist/src/core/{agent-versions.js → agents/agent-versions.js} +0 -0
  88. /package/dist/src/core/{conversion.js → agents/conversion.js} +0 -0
  89. /package/dist/src/core/{paths.js → agents/paths.js} +0 -0
  90. /package/dist/src/core/{runtime-tool-recipe.js → agents/runtime-tool-recipe.js} +0 -0
  91. /package/dist/src/core/{catalog-coverage.js → catalog/catalog-coverage.js} +0 -0
  92. /package/dist/src/core/{safety.js → catalog/safety.js} +0 -0
  93. /package/dist/src/core/{skill-security.js → catalog/skill-security.js} +0 -0
  94. /package/dist/src/core/{community.js → discovery/community.js} +0 -0
  95. /package/dist/src/core/{github-discovery.js → discovery/github-discovery.js} +0 -0
  96. /package/dist/src/core/{private-discovery.js → discovery/private-discovery.js} +0 -0
  97. /package/dist/src/core/{ranking.js → discovery/ranking.js} +0 -0
  98. /package/dist/src/core/{atomic-file.js → install/atomic-file.js} +0 -0
  99. /package/dist/src/core/{diff.js → install/diff.js} +0 -0
  100. /package/dist/src/core/{update-watch.js → install/update-watch.js} +0 -0
  101. /package/dist/src/core/{completion.js → reporting/completion.js} +0 -0
  102. /package/dist/src/core/{loadout-badge.js → reporting/loadout-badge.js} +0 -0
  103. /package/dist/src/core/{terminal.js → reporting/terminal.js} +0 -0
  104. /package/dist/src/core/{access.js → routing/access.js} +0 -0
  105. /package/dist/src/core/{credentials.js → routing/credentials.js} +0 -0
  106. /package/dist/src/core/{sandbox.js → runtime/sandbox.js} +0 -0
  107. /package/dist/src/core/{active-limit.js → workspace/active-limit.js} +0 -0
  108. /package/dist/src/core/{target-occupancy.js → workspace/target-occupancy.js} +0 -0
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
- import { writeFileAtomically } from "./atomic-file.js";
5
- import { loadEffectiveCatalog } from "./catalog.js";
6
- import { ensureDirectory, loadoutHome } from "./paths.js";
7
- import { readInstallState } from "./state.js";
8
- import { buildUpdatePlan } from "./update.js";
4
+ import { writeFileAtomically } from "../install/atomic-file.js";
5
+ import { loadEffectiveCatalog } from "../catalog/catalog.js";
6
+ import { ensureDirectory, loadoutHome } from "../agents/paths.js";
7
+ import { readInstallState } from "../workspace/state.js";
8
+ import { buildUpdatePlan } from "../install/update.js";
9
9
  const decisionsPath = () => join(loadoutHome(), "alert-decisions.json");
10
10
  function alertId(packageId, kind, evidence) {
11
11
  return `${packageId}-${kind}-${createHash("sha256")
@@ -1,12 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { managedFileReadPath } from "./active-set.js";
5
- import { detectAgents } from "./paths.js";
6
- import { readInstallState } from "./state.js";
7
- import { buildUpdatePlan } from "./update.js";
8
- import { codexMcpServerFingerprint } from "./codex-mcp.js";
9
- import { listInstalledRuntimeToolSkillTargets, listInstalledRuntimeTools, } from "./runtime-tools.js";
4
+ import { managedFileReadPath } from "../workspace/active-set.js";
5
+ import { detectAgents } from "../agents/paths.js";
6
+ import { readInstallState } from "../workspace/state.js";
7
+ import { buildUpdatePlan } from "../install/update.js";
8
+ import { codexMcpServerFingerprint } from "../agents/codex-mcp.js";
9
+ import { listInstalledRuntimeToolSkillTargets, listInstalledRuntimeTools, } from "../agents/runtime-tools.js";
10
10
  async function drift(record, activations) {
11
11
  const changed = [];
12
12
  for (const file of record.files) {
@@ -1,4 +1,4 @@
1
- import { buildLocalAgentHealthScores } from "./health-score-evidence.js";
1
+ import { buildLocalAgentHealthScores } from "../agents/health-score-evidence.js";
2
2
  import { buildHealthReport, gradeHealth } from "./health.js";
3
3
  import { buildPrivacySafeReport, } from "./share-report.js";
4
4
  export async function buildLoadoutCard(options = {}) {
@@ -1,4 +1,4 @@
1
- import { formatSchemaError, readmeClaimManifestSchema, } from "../shared/schemas.js";
1
+ import { formatSchemaError, readmeClaimManifestSchema, } from "../../shared/schemas.js";
2
2
  export { readmeClaimManifestSchema };
3
3
  /** Parse the versioned index of evidence for material README statements. */
4
4
  export function parseReadmeClaimManifest(value) {
@@ -1,5 +1,5 @@
1
- import { supportedAdapterNames } from "./adapters.js";
2
- import { buildCatalogCoverage } from "./catalog-coverage.js";
1
+ import { supportedAdapterNames, } from "../agents/adapters.js";
2
+ import { buildCatalogCoverage } from "../catalog/catalog-coverage.js";
3
3
  function profileFacts(allowlist) {
4
4
  return {
5
5
  sources: Object.keys(allowlist).length,
@@ -1,7 +1,7 @@
1
1
  import { dirname } from "node:path";
2
- import { writeFileAtomically } from "./atomic-file.js";
3
- import { ensureDirectory } from "./paths.js";
4
- import { readInstallState } from "./state.js";
2
+ import { writeFileAtomically } from "../install/atomic-file.js";
3
+ import { ensureDirectory } from "../agents/paths.js";
4
+ import { readInstallState } from "../workspace/state.js";
5
5
  export function parsePrivacySafeLoadoutReport(value) {
6
6
  if (!value || typeof value !== "object" || Array.isArray(value))
7
7
  throw new Error("Privacy-safe Loadout report must be an object");
@@ -1,11 +1,11 @@
1
- import { buildAdapterCapabilityGaps, } from "./adapters.js";
2
- import { applyPreparedCatalogInstall, formatPreparedCatalogInstall, prepareCatalogInstall, } from "./catalog-install.js";
3
- import { DEFAULT_ACTIVE_SKILL_LIMIT } from "./active-limit.js";
4
- import { loadEffectiveCatalog } from "./catalog.js";
1
+ import { buildAdapterCapabilityGaps, } from "../agents/adapters.js";
2
+ import { applyPreparedCatalogInstall, formatPreparedCatalogInstall, prepareCatalogInstall, } from "../install/catalog-install.js";
3
+ import { DEFAULT_ACTIVE_SKILL_LIMIT } from "../workspace/active-limit.js";
4
+ import { loadEffectiveCatalog, } from "../catalog/catalog.js";
5
5
  import { buildHealthReport, formatHealthReport } from "./health.js";
6
- import { buildLocalAgentHealthScores } from "./health-score-evidence.js";
7
- import { formatRecommendations, personalizeRecommendations, recommendPackages, scanProject, } from "./recommend.js";
8
- import { readLocalOutcomes } from "./outcomes.js";
6
+ import { buildLocalAgentHealthScores } from "../agents/health-score-evidence.js";
7
+ import { formatRecommendations, personalizeRecommendations, recommendPackages, scanProject, } from "../workspace/recommend.js";
8
+ import { readLocalOutcomes, } from "../workspace/outcomes.js";
9
9
  /**
10
10
  * Builds the complete first-run preview without changing agent or Loadout state.
11
11
  * Network access is limited to the same immutable catalog preparation performed by
@@ -0,0 +1,118 @@
1
+ import { cp, rm, stat } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { detectAgents } from "../agents/paths.js";
5
+ export const FIRST_PARTY_SKILLS = [
6
+ {
7
+ id: "loadout-router",
8
+ displayName: "Loadout Router",
9
+ summary: "Lets an agent pick the right model tier for a task and hand work to another agent",
10
+ },
11
+ {
12
+ id: "loadout-curator",
13
+ displayName: "Loadout Curator",
14
+ summary: "Lets an agent decide which skills and MCP servers should be active for the current repository",
15
+ },
16
+ ];
17
+ /**
18
+ * Locate the bundled `skills/` directory. Resolution walks upward so it works
19
+ * both from `src/` under tsx and from `dist/src/` in the published package.
20
+ */
21
+ export async function bundledSkillsRoot() {
22
+ let dir = dirname(fileURLToPath(import.meta.url));
23
+ for (let depth = 0; depth < 5; depth += 1) {
24
+ const candidate = join(dir, "skills");
25
+ try {
26
+ if ((await stat(candidate)).isDirectory())
27
+ return candidate;
28
+ }
29
+ catch {
30
+ // Keep walking toward the package root.
31
+ }
32
+ dir = dirname(dir);
33
+ }
34
+ throw new Error("Bundled skills directory not found; this build may be incomplete.");
35
+ }
36
+ export function findFirstPartySkill(id) {
37
+ const skill = FIRST_PARTY_SKILLS.find((item) => item.id === id);
38
+ if (!skill)
39
+ throw new Error(`Unknown Loadout skill '${id}'. Available: ${FIRST_PARTY_SKILLS.map((s) => s.id).join(", ")}`);
40
+ return skill;
41
+ }
42
+ async function directoryExists(path) {
43
+ try {
44
+ return (await stat(path)).isDirectory();
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /**
51
+ * Build a preview of where one first-party skill would be installed. Nothing is
52
+ * written; the caller decides whether to apply.
53
+ */
54
+ export async function planFirstPartySkill(id, options = {}) {
55
+ const skill = findFirstPartySkill(id);
56
+ const source = join(await bundledSkillsRoot(), skill.id);
57
+ if (!(await directoryExists(source)))
58
+ throw new Error(`Bundled skill '${skill.id}' is missing from this build.`);
59
+ const detected = options.detected ?? (await detectAgents());
60
+ const installed = detected.filter((agent) => agent.installed);
61
+ const requested = options.agents?.length
62
+ ? installed.filter((agent) => options.agents.includes(agent.id))
63
+ : installed;
64
+ const targets = [];
65
+ for (const agent of requested) {
66
+ const destination = join(agent.skillsDirectory, skill.id);
67
+ targets.push({
68
+ agent: agent.id,
69
+ displayName: agent.displayName,
70
+ destination,
71
+ replacing: await directoryExists(destination),
72
+ });
73
+ }
74
+ return { skill, source, targets };
75
+ }
76
+ export async function applyFirstPartySkill(plan) {
77
+ for (const target of plan.targets) {
78
+ // Replace wholesale so a reinstall cannot leave files from an older
79
+ // version of the skill behind.
80
+ if (target.replacing)
81
+ await rm(target.destination, { recursive: true });
82
+ await cp(plan.source, target.destination, { recursive: true });
83
+ }
84
+ }
85
+ export async function removeFirstPartySkill(plan) {
86
+ for (const target of plan.targets)
87
+ if (target.replacing)
88
+ await rm(target.destination, { recursive: true });
89
+ }
90
+ export function formatFirstPartySkillList(installedIds = new Set()) {
91
+ const lines = ["Skills that ship with Loadout:", ""];
92
+ for (const skill of FIRST_PARTY_SKILLS) {
93
+ const marker = installedIds.has(skill.id) ? "✓" : "○";
94
+ lines.push(` ${marker} ${skill.id} — ${skill.summary}`);
95
+ }
96
+ lines.push("", "Install with: loadout skills install <id> --yes", "These teach your agents to use Loadout from inside a conversation.");
97
+ return lines.join("\n");
98
+ }
99
+ export function formatFirstPartySkillPlan(plan) {
100
+ if (!plan.targets.length)
101
+ return `No installed agents detected, so '${plan.skill.id}' has nowhere to go. Install an agent first.`;
102
+ const lines = [`${plan.skill.displayName} (${plan.skill.id})`, ""];
103
+ for (const target of plan.targets)
104
+ lines.push(` ${target.replacing ? "replace" : "install"} → ${target.destination} [${target.displayName}]`);
105
+ return lines.join("\n");
106
+ }
107
+ /** Which first-party skills are already present for at least one agent. */
108
+ export async function installedFirstPartySkills(detected) {
109
+ const agents = (detected ?? (await detectAgents())).filter((agent) => agent.installed);
110
+ const found = new Set();
111
+ for (const skill of FIRST_PARTY_SKILLS)
112
+ for (const agent of agents)
113
+ if (await directoryExists(join(agent.skillsDirectory, skill.id))) {
114
+ found.add(skill.id);
115
+ break;
116
+ }
117
+ return found;
118
+ }
@@ -0,0 +1,278 @@
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 "../install/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
+ "## Making agents read this",
57
+ "",
58
+ "A message log only helps if the other agent checks it. Run:",
59
+ "",
60
+ "```",
61
+ "loadout handoff pickup --yes",
62
+ "```",
63
+ "",
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.",
67
+ "",
68
+ ].join("\n");
69
+ await writeFileAtomically(join(dir, PROTOCOL_FILE), protocol);
70
+ // Create empty messages file if it doesn't exist
71
+ try {
72
+ await readFile(messagesPath(projectRoot));
73
+ }
74
+ catch {
75
+ await writeFile(messagesPath(projectRoot), "", "utf8");
76
+ }
77
+ return dir;
78
+ }
79
+ export async function sendHandoff(projectRoot, to, description, options = {}) {
80
+ if (!(await isHandoffInitialized(projectRoot))) {
81
+ throw new Error("Handoff not initialized. Run `loadout handoff init` first.");
82
+ }
83
+ const message = {
84
+ id: randomUUID().slice(0, 8),
85
+ type: options.type ?? "task",
86
+ from: options.from ?? "user",
87
+ to,
88
+ description,
89
+ ...(options.context ? { context: options.context } : {}),
90
+ timestamp: new Date().toISOString(),
91
+ };
92
+ const path = messagesPath(projectRoot);
93
+ const line = JSON.stringify(message) + "\n";
94
+ await writeFile(path, line, { flag: "a" });
95
+ return message;
96
+ }
97
+ export async function markDone(projectRoot, messageId) {
98
+ const messages = await readMessages(projectRoot);
99
+ const original = messages.find((m) => m.id === messageId);
100
+ if (!original)
101
+ throw new Error(`Message '${messageId}' not found`);
102
+ if (original.type === "done")
103
+ throw new Error(`Message '${messageId}' is already done`);
104
+ return sendHandoff(projectRoot, original.from, `Completed: ${original.description}`, {
105
+ from: original.to,
106
+ type: "done",
107
+ context: `Resolves ${messageId}`,
108
+ });
109
+ }
110
+ export async function readMessages(projectRoot) {
111
+ if (!(await isHandoffInitialized(projectRoot)))
112
+ return [];
113
+ try {
114
+ const content = await readFile(messagesPath(projectRoot), "utf8");
115
+ return content
116
+ .split("\n")
117
+ .filter(Boolean)
118
+ .map((line) => JSON.parse(line));
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ export async function getHandoffState(projectRoot) {
125
+ const initialized = await isHandoffInitialized(projectRoot);
126
+ const messages = initialized ? await readMessages(projectRoot) : [];
127
+ const doneIds = new Set(messages
128
+ .filter((m) => m.type === "done")
129
+ .flatMap((m) => {
130
+ const match = m.context?.match(/Resolves (\w+)/);
131
+ return match ? [match[1]] : [];
132
+ }));
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));
135
+ return {
136
+ initialized,
137
+ directory: handoffDir(projectRoot),
138
+ messages,
139
+ pending,
140
+ done,
141
+ };
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Consumption — the half that makes a handoff more than an outbox
145
+ // ---------------------------------------------------------------------------
146
+ /** Pending tasks addressed to one agent, oldest first. */
147
+ export async function readInbox(projectRoot, agent) {
148
+ const state = await getHandoffState(projectRoot);
149
+ return state.pending.filter((m) => m.to === agent);
150
+ }
151
+ /**
152
+ * 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
154
+ * tells each agent to run, so the message log is consumed rather than merely
155
+ * written.
156
+ */
157
+ export function formatInbox(agent, messages) {
158
+ if (!messages.length)
159
+ return `No pending handoff tasks for ${agent}.`;
160
+ const lines = [
161
+ `${messages.length} pending handoff task(s) for ${agent}:`,
162
+ "",
163
+ ];
164
+ for (const m of messages) {
165
+ lines.push(`[${m.id}] from ${m.from} (${m.timestamp.slice(0, 16).replace("T", " ")})`);
166
+ lines.push(` ${m.description}`);
167
+ if (m.context)
168
+ lines.push(` context: ${m.context}`);
169
+ lines.push(` when finished: loadout handoff done ${m.id}`);
170
+ lines.push("");
171
+ }
172
+ lines.push("Work these in order. Mark each done as you complete it so the sender sees progress.");
173
+ return lines.join("\n");
174
+ }
175
+ const PICKUP_START = "<!-- loadout:handoff:start -->";
176
+ const PICKUP_END = "<!-- loadout:handoff:end -->";
177
+ /** The managed instruction block written into an agent's context file. */
178
+ export function pickupBlock(agent) {
179
+ return [
180
+ PICKUP_START,
181
+ "",
182
+ "## Handoff inbox",
183
+ "",
184
+ `At the start of a session, and whenever you finish a task, run:`,
185
+ "",
186
+ "```bash",
187
+ `loadout handoff inbox ${agent}`,
188
+ "```",
189
+ "",
190
+ "If it lists pending tasks, work them in order and run the `loadout handoff done`",
191
+ "command it prints for each one. If it reports none, continue as normal.",
192
+ "",
193
+ PICKUP_END,
194
+ ].join("\n");
195
+ }
196
+ /** Agent context files, relative to the project root. */
197
+ const AGENT_CONTEXT_FILES = {
198
+ "claude-code": "CLAUDE.md",
199
+ codex: "AGENTS.md",
200
+ };
201
+ export function agentContextFile(agent) {
202
+ const file = AGENT_CONTEXT_FILES[agent];
203
+ if (!file)
204
+ throw new Error(`No known context file for agent '${agent}'. Supported: ${Object.keys(AGENT_CONTEXT_FILES).join(", ")}`);
205
+ return file;
206
+ }
207
+ /**
208
+ * Compute the new content for an agent's context file with the pickup block
209
+ * added or refreshed. Existing content is preserved; only the managed block
210
+ * between the markers is replaced.
211
+ */
212
+ export async function planPickup(projectRoot, agent) {
213
+ const file = agentContextFile(agent);
214
+ const path = join(projectRoot, file);
215
+ const block = pickupBlock(agent);
216
+ let existing = "";
217
+ let exists = false;
218
+ try {
219
+ existing = await readFile(path, "utf8");
220
+ exists = true;
221
+ }
222
+ catch {
223
+ // A missing context file is created with just the managed block.
224
+ }
225
+ const start = existing.indexOf(PICKUP_START);
226
+ const end = existing.indexOf(PICKUP_END);
227
+ const replacing = start !== -1 && end !== -1 && end > start;
228
+ const content = replacing
229
+ ? existing.slice(0, start) + block + existing.slice(end + PICKUP_END.length)
230
+ : exists
231
+ ? `${existing.replace(/\s*$/, "")}\n\n${block}\n`
232
+ : `${block}\n`;
233
+ return { path, agent, exists, replacing, content };
234
+ }
235
+ export async function applyPickup(plan) {
236
+ await writeFileAtomically(plan.path, plan.content);
237
+ }
238
+ export function formatPickupPlan(plans) {
239
+ const lines = ["Handoff pickup instructions:", ""];
240
+ 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";
246
+ lines.push(` ${action} ${plan.path}`);
247
+ }
248
+ 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.");
249
+ return lines.join("\n");
250
+ }
251
+ export function formatHandoffStatus(state) {
252
+ if (!state.initialized)
253
+ return "Handoff not initialized. Run `loadout handoff init`.";
254
+ if (state.messages.length === 0)
255
+ return "No handoff messages yet. Send one with `loadout handoff send <agent> <task>`.";
256
+ const lines = [];
257
+ if (state.pending.length) {
258
+ lines.push(`Pending (${state.pending.length}):`);
259
+ for (const m of state.pending) {
260
+ lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
261
+ }
262
+ }
263
+ if (state.done.length) {
264
+ if (lines.length)
265
+ lines.push("");
266
+ lines.push(`Done (${state.done.length}):`);
267
+ for (const m of state.done) {
268
+ lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
269
+ }
270
+ }
271
+ const other = state.messages.filter((m) => m.type !== "task" && m.type !== "done");
272
+ if (other.length) {
273
+ if (lines.length)
274
+ lines.push("");
275
+ lines.push(`Other messages: ${other.length}`);
276
+ }
277
+ return lines.join("\n");
278
+ }
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
- import { formatSchemaError, providerModelConfigurationSchema, } from "../shared/schemas.js";
5
- import { writeFileAtomically } from "./atomic-file.js";
6
- import { ensureDirectory, loadoutHome } from "./paths.js";
7
- import { runMutationTransaction } from "./transaction.js";
4
+ import { formatSchemaError, providerModelConfigurationSchema, } from "../../shared/schemas.js";
5
+ import { writeFileAtomically } from "../install/atomic-file.js";
6
+ import { ensureDirectory, loadoutHome } from "../agents/paths.js";
7
+ import { runMutationTransaction } from "../install/transaction.js";
8
8
  export const defaultModelConfigurationPath = () => join(loadoutHome(), "models.json");
9
9
  /**
10
10
  * Validate a portable, provider-neutral model configuration. This is not a