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.
- package/README.md +181 -121
- package/catalog/discovered.json +28066 -23196
- package/catalog/packages.json +2 -2
- package/dist/src/cli.js +2 -2
- package/dist/src/commands/agents.js +6 -23
- package/dist/src/commands/catalog.js +254 -27
- package/dist/src/commands/health.js +6 -6
- package/dist/src/commands/inventory.js +21 -121
- package/dist/src/commands/lifecycle.js +13 -51
- package/dist/src/commands/mcp.js +10 -73
- package/dist/src/commands/setup.js +5 -5
- package/dist/src/commands/sharing.js +11 -108
- package/dist/src/commands/support.js +42 -11
- package/dist/src/core/{adapters.js → agents/adapters.js} +2 -2
- package/dist/src/core/{agent-inspection.js → agents/agent-inspection.js} +26 -6
- package/dist/src/core/{codex-mcp.js → agents/codex-mcp.js} +1 -1
- package/dist/src/core/{health-score-evidence.js → agents/health-score-evidence.js} +8 -8
- package/dist/src/core/{runtime-tools.js → agents/runtime-tools.js} +2 -2
- package/dist/src/core/{audit.js → catalog/audit.js} +4 -4
- package/dist/src/core/{catalog.js → catalog/catalog.js} +18 -5
- package/dist/src/core/{components.js → catalog/components.js} +2 -2
- package/dist/src/core/{conformance.js → catalog/conformance.js} +2 -2
- package/dist/src/core/{profiles.js → catalog/profiles.js} +2 -2
- package/dist/src/core/{provenance.js → catalog/provenance.js} +3 -3
- package/dist/src/core/{registry.js → catalog/registry.js} +1 -1
- package/dist/src/core/{skill-compare.js → catalog/skill-compare.js} +1 -1
- package/dist/src/core/{skill-inventory.js → catalog/skill-inventory.js} +3 -3
- package/dist/src/core/{skills.js → catalog/skills.js} +1 -1
- package/dist/src/core/{candidate-intelligence.js → discovery/candidate-intelligence.js} +6 -6
- package/dist/src/core/{discovery-connector.js → discovery/discovery-connector.js} +2 -2
- package/dist/src/core/{evaluate.js → discovery/evaluate.js} +2 -2
- package/dist/src/core/{mcp-registry-discovery.js → discovery/mcp-registry-discovery.js} +1 -1
- package/dist/src/core/{observations.js → discovery/observations.js} +3 -3
- package/dist/src/core/{review-queue.js → discovery/review-queue.js} +9 -4
- package/dist/src/core/{skills-sh-discovery.js → discovery/skills-sh-discovery.js} +1 -1
- package/dist/src/core/{adopt.js → install/adopt.js} +3 -3
- package/dist/src/core/{catalog-install.js → install/catalog-install.js} +11 -11
- package/dist/src/core/{file-lock.js → install/file-lock.js} +1 -1
- package/dist/src/core/{install.js → install/install.js} +5 -5
- package/dist/src/core/{package.js → install/package.js} +3 -3
- package/dist/src/core/{reconcile.js → install/reconcile.js} +5 -5
- package/dist/src/core/{remove.js → install/remove.js} +4 -4
- package/dist/src/core/{snapshot.js → install/snapshot.js} +1 -1
- package/dist/src/core/{source.js → install/source.js} +1 -1
- package/dist/src/core/{sync.js → install/sync.js} +9 -9
- package/dist/src/core/{transaction.js → install/transaction.js} +1 -1
- package/dist/src/core/{uninstall.js → install/uninstall.js} +4 -4
- package/dist/src/core/{update.js → install/update.js} +4 -4
- package/dist/src/core/reporting/cli-guide.js +103 -0
- package/dist/src/core/reporting/doctor.js +141 -0
- package/dist/src/core/{freshness-alerts.js → reporting/freshness-alerts.js} +5 -5
- package/dist/src/core/{health.js → reporting/health.js} +6 -6
- package/dist/src/core/{loadout-card.js → reporting/loadout-card.js} +1 -1
- package/dist/src/core/{readme-claims.js → reporting/readme-claims.js} +1 -1
- package/dist/src/core/{readme-facts.js → reporting/readme-facts.js} +2 -2
- package/dist/src/core/{share-report.js → reporting/share-report.js} +3 -3
- package/dist/src/core/{upgrade.js → reporting/upgrade.js} +7 -7
- package/dist/src/core/routing/first-party-skills.js +118 -0
- package/dist/src/core/routing/handoff.js +278 -0
- package/dist/src/core/{model-config.js → routing/model-config.js} +4 -4
- package/dist/src/core/routing/route.js +539 -0
- package/dist/src/core/{api.js → runtime/api.js} +4 -4
- package/dist/src/core/{canary.js → runtime/canary.js} +1 -1
- package/dist/src/core/{github.js → runtime/github.js} +1 -1
- package/dist/src/core/{mcp-recipes.js → runtime/mcp-recipes.js} +1 -1
- package/dist/src/core/{mcp.js → runtime/mcp.js} +2 -2
- package/dist/src/core/{scheduler.js → runtime/scheduler.js} +4 -4
- package/dist/src/core/{active-policy.js → workspace/active-policy.js} +2 -2
- package/dist/src/core/{active-set.js → workspace/active-set.js} +2 -2
- package/dist/src/core/{improve.js → workspace/improve.js} +3 -3
- package/dist/src/core/{manifest.js → workspace/manifest.js} +2 -2
- package/dist/src/core/{outcomes.js → workspace/outcomes.js} +3 -3
- package/dist/src/core/{portable.js → workspace/portable.js} +3 -3
- package/dist/src/core/{profile-state.js → workspace/profile-state.js} +1 -1
- package/dist/src/core/{recommend.js → workspace/recommend.js} +1 -1
- package/dist/src/core/{state.js → workspace/state.js} +3 -3
- package/docs/CATALOG.md +2 -2
- package/docs/DISCOVERED.md +249 -247
- package/docs/UPSTREAM_LICENSE_DECISIONS.md +19 -12
- package/docs/evidence/readme-claims.json +22 -19
- package/package.json +2 -1
- package/skills/loadout-curator/SKILL.md +105 -0
- package/skills/loadout-router/SKILL.md +120 -0
- package/dist/src/core/cli-guide.js +0 -118
- package/dist/src/core/doctor.js +0 -95
- /package/dist/src/core/{agent-health-score.js → agents/agent-health-score.js} +0 -0
- /package/dist/src/core/{agent-versions.js → agents/agent-versions.js} +0 -0
- /package/dist/src/core/{conversion.js → agents/conversion.js} +0 -0
- /package/dist/src/core/{paths.js → agents/paths.js} +0 -0
- /package/dist/src/core/{runtime-tool-recipe.js → agents/runtime-tool-recipe.js} +0 -0
- /package/dist/src/core/{catalog-coverage.js → catalog/catalog-coverage.js} +0 -0
- /package/dist/src/core/{safety.js → catalog/safety.js} +0 -0
- /package/dist/src/core/{skill-security.js → catalog/skill-security.js} +0 -0
- /package/dist/src/core/{community.js → discovery/community.js} +0 -0
- /package/dist/src/core/{github-discovery.js → discovery/github-discovery.js} +0 -0
- /package/dist/src/core/{private-discovery.js → discovery/private-discovery.js} +0 -0
- /package/dist/src/core/{ranking.js → discovery/ranking.js} +0 -0
- /package/dist/src/core/{atomic-file.js → install/atomic-file.js} +0 -0
- /package/dist/src/core/{diff.js → install/diff.js} +0 -0
- /package/dist/src/core/{update-watch.js → install/update-watch.js} +0 -0
- /package/dist/src/core/{completion.js → reporting/completion.js} +0 -0
- /package/dist/src/core/{loadout-badge.js → reporting/loadout-badge.js} +0 -0
- /package/dist/src/core/{terminal.js → reporting/terminal.js} +0 -0
- /package/dist/src/core/{access.js → routing/access.js} +0 -0
- /package/dist/src/core/{credentials.js → routing/credentials.js} +0 -0
- /package/dist/src/core/{sandbox.js → runtime/sandbox.js} +0 -0
- /package/dist/src/core/{active-limit.js → workspace/active-limit.js} +0 -0
- /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 "
|
|
5
|
-
import { loadEffectiveCatalog } from "
|
|
6
|
-
import { ensureDirectory, loadoutHome } from "
|
|
7
|
-
import { readInstallState } from "
|
|
8
|
-
import { buildUpdatePlan } from "
|
|
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 "
|
|
5
|
-
import { detectAgents } from "
|
|
6
|
-
import { readInstallState } from "
|
|
7
|
-
import { buildUpdatePlan } from "
|
|
8
|
-
import { codexMcpServerFingerprint } from "
|
|
9
|
-
import { listInstalledRuntimeToolSkillTargets, listInstalledRuntimeTools, } from "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
2
|
-
import { buildCatalogCoverage } from "
|
|
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 "
|
|
3
|
-
import { ensureDirectory } from "
|
|
4
|
-
import { readInstallState } from "
|
|
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 "
|
|
2
|
-
import { applyPreparedCatalogInstall, formatPreparedCatalogInstall, prepareCatalogInstall, } from "
|
|
3
|
-
import { DEFAULT_ACTIVE_SKILL_LIMIT } from "
|
|
4
|
-
import { loadEffectiveCatalog } from "
|
|
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 "
|
|
7
|
-
import { formatRecommendations, personalizeRecommendations, recommendPackages, scanProject, } from "
|
|
8
|
-
import { readLocalOutcomes } from "
|
|
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 "
|
|
5
|
-
import { writeFileAtomically } from "
|
|
6
|
-
import { ensureDirectory, loadoutHome } from "
|
|
7
|
-
import { runMutationTransaction } from "
|
|
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
|