loadout-ai 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +33 -33
  3. package/catalog/discovered.json +26880 -24184
  4. package/dist/src/cli.js +5 -0
  5. package/dist/src/commands/catalog.js +103 -116
  6. package/dist/src/core/agents/agent-inspection.js +26 -4
  7. package/dist/src/core/catalog/registry.js +58 -10
  8. package/dist/src/core/catalog/safety.js +36 -7
  9. package/dist/src/core/install/source.js +21 -7
  10. package/dist/src/core/reporting/cli-guide.js +3 -3
  11. package/dist/src/core/reporting/completion.js +42 -95
  12. package/dist/src/core/reporting/doctor.js +3 -5
  13. package/dist/src/core/routing/handoff.js +94 -58
  14. package/dist/src/core/routing/policy.js +147 -0
  15. package/dist/src/core/routing/route.js +25 -153
  16. package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
  17. package/docs/CATALOG.md +1 -1
  18. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
  19. package/docs/DISCOVERED.md +252 -251
  20. package/docs/FEATURE_TEST_MATRIX.md +7 -260
  21. package/docs/GITHUB_AUTHORIZATION.md +5 -0
  22. package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
  23. package/docs/RELEASE_REVIEW.md +0 -1
  24. package/package.json +6 -4
  25. package/skills/loadout-router/SKILL.md +43 -78
  26. package/MASTER_PLAN.md +0 -2207
  27. package/docs/ACTIVE_SET.md +0 -53
  28. package/docs/COMPATIBILITY_POLICY.md +0 -22
  29. package/docs/CONVERSION_AND_SANDBOX.md +0 -27
  30. package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
  31. package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
  32. package/docs/PROVIDER_CONFIGURATION.md +0 -45
  33. package/docs/README_RESEARCH.md +0 -36
  34. package/docs/REPOSITORY_STABILIZATION.md +0 -190
  35. package/docs/SAFE_UPDATE_DEMO.md +0 -25
  36. package/docs/SCHEMA_DECISIONS.md +0 -25
  37. package/docs/SUBMISSION_COPY.md +0 -90
  38. package/docs/TEAM_POLICY.md +0 -18
  39. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
  40. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
  41. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
  42. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
  43. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
  44. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
package/dist/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, CommanderError } from "commander";
3
3
  import { HIDDEN_FROM_FIRST_SCREEN } from "./core/reporting/cli-guide.js";
4
+ import { registerCompletionCommands } from "./core/reporting/completion.js";
4
5
  import { recoverPendingTransactions } from "./core/install/transaction.js";
5
6
  import { LOADOUT_VERSION, printBeginnerGuide, runWizard, } from "./commands/support.js";
6
7
  import { registerSetup } from "./commands/setup.js";
@@ -34,6 +35,10 @@ registerInventory(program);
34
35
  registerCatalog(program);
35
36
  registerMcp(program);
36
37
  registerLifecycle(program);
38
+ registerCompletionCommands(program.commands.map((command) => ({
39
+ name: command.name(),
40
+ subcommands: command.commands.map((sub) => sub.name()),
41
+ })));
37
42
  for (const command of program.commands)
38
43
  // Commander supports `hidden` when a command is created. These commands are
39
44
  // registered by separate feature blocks, so use the same runtime flag here
@@ -2,9 +2,11 @@ import { explainCatalogScore, loadEffectiveCatalog, loadCatalog, promoteCatalogC
2
2
  import { detectAgents, parseAgentSelection } from "../core/agents/paths.js";
3
3
  import { resolve } from "node:path";
4
4
  import { readFile } from "node:fs/promises";
5
- import { allPhaseRoutes, formatCostTable, formatModelCatalog, formatRouteRecommendation, formatRoutingTable, routePhase, routeTask, } from "../core/routing/route.js";
5
+ import { formatModelCatalog, MODEL_CATALOG } from "../core/routing/route.js";
6
+ import { BUCKETS, formatAnswer, formatPolicy, guessBucket, policyPath, readPolicy, resolveRoute, setRule, writePolicy, } from "../core/routing/policy.js";
7
+ import { rm } from "node:fs/promises";
6
8
  import { applyFirstPartySkill, FIRST_PARTY_SKILLS, formatFirstPartySkillList, formatFirstPartySkillPlan, installedFirstPartySkills, planFirstPartySkill, removeFirstPartySkill, } from "../core/routing/first-party-skills.js";
7
- import { applyPickup, formatHandoffStatus, formatInbox, formatPickupPlan, getHandoffState, initHandoff, isHandoffInitialized, markDone, planPickup, readInbox, sendHandoff, } from "../core/routing/handoff.js";
9
+ import { applyPickup, formatHandoffStatus, formatInbox, getHandoffState, initHandoff, isHandoffInitialized, isPickupTarget, markDone, planPickup, readInbox, sendHandoff, } from "../core/routing/handoff.js";
8
10
  import { catalogTrustStage, formatCatalogTrustStage, } from "../core/catalog/profiles.js";
9
11
  import { discoverHackerNewsRepositories } from "../core/discovery/community.js";
10
12
  import { discoverPrivateRepositories } from "../core/discovery/private-discovery.js";
@@ -656,144 +658,129 @@ export function registerCatalog(program) {
656
658
  });
657
659
  const handoff = program
658
660
  .command("handoff")
659
- .description("Cross-agent file-based task handoff — send work between Claude Code, Codex, and other agents");
661
+ .description("Hand a task to your other agent, or see what has been handed to you");
660
662
  handoff
661
- .command("init")
662
- .description("Create the .handoff/ protocol directory in the current project")
663
- .action(async () => {
664
- const projectRoot = process.cwd();
665
- if (await isHandoffInitialized(projectRoot)) {
666
- console.log("Handoff already initialized at .handoff/");
663
+ .argument("[agent]", "who should do it, for example codex")
664
+ .argument("[task...]", "what they should do")
665
+ .option("--context <text>", "anything they need that is not in the task")
666
+ .option("--from <agent>", "who is sending", "user")
667
+ .option("--done <id>", "mark a task finished")
668
+ .option("--json", "emit machine-readable JSON")
669
+ .action(async (agent, taskWords, options) => {
670
+ const cwd = process.cwd();
671
+ if (options.done) {
672
+ const message = await markDone(cwd, options.done);
673
+ console.log(options.json
674
+ ? JSON.stringify(message, null, 2)
675
+ : `Marked ${options.done} done.`);
667
676
  return;
668
677
  }
669
- const dir = await initHandoff(projectRoot);
670
- console.log(`Initialized handoff protocol at ${dir}`);
671
- console.log("Add .handoff/ to version control so both agents share the message log.");
672
- });
673
- handoff
674
- .command("send")
675
- .description("Send a task or message to another agent")
676
- .argument("<agent>", "target agent (e.g. codex, claude-code)")
677
- .argument("<description...>", "task description")
678
- .option("--from <agent>", "sending agent", "user")
679
- .option("--type <type>", "message type: task, handoff, question, status, error, cancel", "task")
680
- .option("--context <text>", "additional context for the receiving agent")
681
- .option("--json", "emit machine-readable JSON")
682
- .action(async (agent, descriptionWords, options) => {
683
- const message = await sendHandoff(process.cwd(), agent, descriptionWords.join(" "), {
678
+ // No agent named: show what is waiting, for everyone.
679
+ if (!agent) {
680
+ const state = await getHandoffState(cwd);
681
+ console.log(options.json
682
+ ? JSON.stringify(state, null, 2)
683
+ : formatHandoffStatus(state));
684
+ return;
685
+ }
686
+ // An agent with no task means "show me my inbox".
687
+ const task = taskWords.join(" ").trim();
688
+ if (!task) {
689
+ const messages = await readInbox(cwd, agent);
690
+ console.log(options.json
691
+ ? JSON.stringify(messages, null, 2)
692
+ : formatInbox(agent, messages));
693
+ return;
694
+ }
695
+ // Sending is the common case, so it sets itself up rather than failing
696
+ // with instructions to run two other commands first.
697
+ const setup = [];
698
+ if (!(await isHandoffInitialized(cwd))) {
699
+ await initHandoff(cwd);
700
+ setup.push("created .handoff/");
701
+ }
702
+ for (const target of [options.from, agent]) {
703
+ if (!isPickupTarget(target))
704
+ continue;
705
+ const plan = await planPickup(cwd, target);
706
+ if (!plan.replacing) {
707
+ await applyPickup(plan);
708
+ setup.push(`told ${target} to check its inbox`);
709
+ }
710
+ }
711
+ const message = await sendHandoff(cwd, agent, task, {
684
712
  from: options.from,
685
- type: options.type,
686
- context: options.context,
713
+ ...(options.context ? { context: options.context } : {}),
687
714
  });
688
- console.log(options.json
689
- ? JSON.stringify(message, null, 2)
690
- : `Sent ${message.type} ${message.id} → ${message.to}: ${message.description}`);
691
- });
692
- handoff
693
- .command("done")
694
- .description("Mark a handoff task as completed")
695
- .argument("<id>", "message id to mark done")
696
- .option("--json", "emit machine-readable JSON")
697
- .action(async (id, options) => {
698
- const message = await markDone(process.cwd(), id);
699
- console.log(options.json
700
- ? JSON.stringify(message, null, 2)
701
- : `Marked ${id} as done`);
702
- });
703
- handoff
704
- .command("inbox")
705
- .description("Show pending tasks addressed to one agent, as instructions it can act on")
706
- .argument("<agent>", "agent reading its inbox (e.g. claude-code, codex)")
707
- .option("--json", "emit machine-readable JSON")
708
- .action(async (agent, options) => {
709
- const messages = await readInbox(process.cwd(), agent);
710
- console.log(options.json
711
- ? JSON.stringify(messages, null, 2)
712
- : formatInbox(agent, messages));
713
- });
714
- handoff
715
- .command("pickup")
716
- .description("Teach agents to check their handoff inbox by adding a managed block to CLAUDE.md / AGENTS.md")
717
- .option("--agents <ids>", "comma-separated agents to instruct", "claude-code,codex")
718
- .option("--yes", "write the files after previewing")
719
- .action(async (options) => {
720
- const agents = options.agents
721
- .split(",")
722
- .map((value) => value.trim())
723
- .filter(Boolean);
724
- const plans = [];
725
- for (const agent of agents)
726
- plans.push(await planPickup(process.cwd(), agent));
727
- console.log(formatPickupPlan(plans));
728
- if (!options.yes) {
729
- console.log("\nPreview only. Re-run with --yes to write these files.");
715
+ if (options.json) {
716
+ console.log(JSON.stringify({ message, setup }, null, 2));
730
717
  return;
731
718
  }
732
- for (const plan of plans)
733
- await applyPickup(plan);
734
- console.log(`\nWrote pickup instructions for ${plans.length} agent(s). They will check their inbox on the next session.`);
735
- });
736
- handoff
737
- .command("status")
738
- .description("Show pending and completed handoff tasks")
739
- .option("--json", "emit machine-readable JSON")
740
- .action(async (options) => {
741
- const state = await getHandoffState(process.cwd());
742
- console.log(options.json
743
- ? JSON.stringify(state, null, 2)
744
- : formatHandoffStatus(state));
719
+ for (const line of setup)
720
+ console.log(` ${line}`);
721
+ console.log(`Sent to ${agent}: ${task}`);
722
+ console.log(`It will pick this up next session, or now with: loadout handoff ${agent}`);
745
723
  });
746
724
  const route = program
747
725
  .command("route")
748
- .description("Recommend the right model tier and agent for a task — plan, implement, review, test, debug, or document");
726
+ .description("Which model to use for a task, according to a routing policy you own");
749
727
  route
750
- .argument("[description...]", "natural-language task description")
751
- .option("--phase <phase>", "explicit phase instead of auto-classify")
752
- .option("--conserve", "recommend cheaper tiers to stretch remaining session quota")
753
- .option("--cost", "show cost comparison table across all phases")
754
- .option("--models", "list the full model catalog Loadout knows about")
755
- .option("--provider <name>", "filter models by provider (anthropic, openai, google, deepseek, meta)")
756
- .option("--tier <tier>", "filter models by tier (frontier, standard, fast)")
728
+ .argument("[description...]", "what you are about to do")
729
+ .option("--bucket <bucket>", "state the bucket yourself: hard, normal, or cheap")
730
+ .option("--set <bucket=model>", "change the model for one bucket and save")
731
+ .option("--save", "write the current defaults to disk so you can edit them")
732
+ .option("--reset", "delete your policy and go back to the defaults")
733
+ .option("--models", "list the model ids you can name in a policy")
757
734
  .option("--json", "emit machine-readable JSON")
758
735
  .action(async (descriptionWords, options) => {
759
736
  if (options.models) {
760
737
  console.log(options.json
761
- ? JSON.stringify((await import("../core/routing/route.js")).MODEL_CATALOG, null, 2)
762
- : formatModelCatalog({
763
- provider: options.provider,
764
- tier: options.tier,
765
- }));
738
+ ? JSON.stringify(MODEL_CATALOG, null, 2)
739
+ : formatModelCatalog());
766
740
  return;
767
741
  }
768
- if (options.cost) {
769
- console.log(options.json
770
- ? JSON.stringify((await import("../core/routing/route.js")).estimateCostSavings(), null, 2)
771
- : formatCostTable());
742
+ const detected = await detectAgents();
743
+ const installed = detected
744
+ .filter((agent) => agent.installed)
745
+ .map((agent) => agent.id);
746
+ if (options.reset) {
747
+ await rm(policyPath(), { force: true });
748
+ console.log("Removed your routing policy; defaults apply again.");
749
+ return;
750
+ }
751
+ if (options.set) {
752
+ const separator = options.set.indexOf("=");
753
+ if (separator <= 0)
754
+ throw new Error("--set expects <bucket>=<model>, for example normal=gpt-5.6-terra");
755
+ const bucket = options.set.slice(0, separator).trim();
756
+ if (!BUCKETS.includes(bucket))
757
+ throw new Error(`Unknown bucket '${bucket}'. Valid: ${BUCKETS.join(", ")}`);
758
+ const updated = await setRule(bucket, options.set.slice(separator + 1).trim(), installed);
759
+ console.log(formatPolicy(updated, "file"));
760
+ return;
761
+ }
762
+ const { policy, source } = await readPolicy(installed);
763
+ if (options.save) {
764
+ const path = await writePolicy(policy);
765
+ console.log(`Saved your routing policy to ${path}\n`);
766
+ console.log(formatPolicy(policy, "file"));
772
767
  return;
773
768
  }
774
769
  const description = descriptionWords.join(" ").trim();
775
- if (!description && !options.phase) {
770
+ if (!description && !options.bucket) {
776
771
  console.log(options.json
777
- ? JSON.stringify(allPhaseRoutes(options.conserve), null, 2)
778
- : formatRoutingTable(options.conserve));
772
+ ? JSON.stringify({ policy, source }, null, 2)
773
+ : formatPolicy(policy, source));
779
774
  return;
780
775
  }
781
- const rec = options.phase
782
- ? routePhase(options.phase, options.conserve)
783
- : routeTask(description, options.conserve);
784
- // Recommendations are only useful when they name agents this machine
785
- // actually has, so detection drives both the advice and the handoff line.
786
- const detected = await detectAgents();
787
- const installedAgents = detected
788
- .filter((agent) => agent.installed)
789
- .map((agent) => agent.id);
776
+ const stated = options.bucket;
777
+ if (stated && !BUCKETS.includes(stated))
778
+ throw new Error(`Unknown bucket '${stated}'. Valid: ${BUCKETS.join(", ")}`);
779
+ const bucket = stated ?? guessBucket(description);
780
+ const answer = resolveRoute(policy, bucket, installed, !stated);
790
781
  console.log(options.json
791
- ? JSON.stringify({ ...rec, installedAgents }, null, 2)
792
- : formatRouteRecommendation(rec, {
793
- installedAgents,
794
- ...(description ? { description } : {}),
795
- handoffReady: await isHandoffInitialized(process.cwd()),
796
- }));
782
+ ? JSON.stringify(answer, null, 2)
783
+ : formatAnswer(answer, description || undefined));
797
784
  });
798
785
  program
799
786
  .command("completion")
@@ -136,6 +136,30 @@ export async function inspectAgent(agent) {
136
136
  export async function inspectAgents(agents) {
137
137
  return Promise.all(agents.map(inspectAgent));
138
138
  }
139
+ /**
140
+ * Count installable units rather than raw filesystem entries. A skill is one
141
+ * directory containing SKILL.md, so counting every nested file reports a number
142
+ * many times larger than the number of skills the user actually has.
143
+ */
144
+ export function countComponentUnits(component) {
145
+ if (!component.scanned || !component.directoryExists)
146
+ return 0;
147
+ const skillRoots = new Set();
148
+ let looseFiles = 0;
149
+ for (const entry of component.entries) {
150
+ if (entry.kind !== "file")
151
+ continue;
152
+ const segments = entry.path.split("/");
153
+ const name = segments[segments.length - 1];
154
+ if (name === "SKILL.md") {
155
+ // Depth 1 means SKILL.md sits at the component root, which is one unit.
156
+ skillRoots.add(segments.slice(0, -1).join("/") || ".");
157
+ }
158
+ else if (segments.length === 1)
159
+ looseFiles += 1;
160
+ }
161
+ return skillRoots.size || looseFiles;
162
+ }
139
163
  /**
140
164
  * One agent's inventory. The default is a two-line summary: what is installed
141
165
  * and which component kinds the adapter supports. Adapter internals — the
@@ -155,16 +179,14 @@ export function formatAgentInventory(inventory, options = {}) {
155
179
  }
156
180
  }
157
181
  else {
158
- const items = inventory.components
159
- .filter((component) => component.scanned && component.directoryExists)
160
- .reduce((total, component) => total + component.entries.length, 0);
182
+ const items = inventory.components.reduce((total, component) => total + countComponentUnits(component), 0);
161
183
  const supported = inventory.components
162
184
  .filter((component) => component.compatibility !== "unsupported")
163
185
  .map((component) => component.type);
164
186
  const skills = inventory.components.find((component) => component.type === "skill");
165
187
  if (skills?.directory)
166
188
  lines.push(` ${skills.directory}`);
167
- lines.push(` ${items} items | supports: ${supported.join(", ")}`);
189
+ lines.push(` ${items} skills | supports: ${supported.join(", ")}`);
168
190
  }
169
191
  for (const warning of inventory.warnings)
170
192
  lines.push(` ! ${warning}`);
@@ -1,9 +1,11 @@
1
- import { createHash } from "node:crypto";
2
- import { cp, lstat, mkdir, readFile, readdir, rm, writeFile, } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { loadoutHome } from "../agents/paths.js";
5
5
  import { analyzeUpdateSafety } from "./safety.js";
6
6
  const NAME = /^[a-z0-9][a-z0-9._-]*$/;
7
+ /** A SHA-256 digest, and nothing that could traverse a path. */
8
+ const DIGEST = /^[a-f0-9]{64}$/;
7
9
  const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
8
10
  export function parsePackageDescriptor(value) {
9
11
  if (!value || typeof value !== "object")
@@ -266,18 +268,54 @@ function registryEndpoint(base, path) {
266
268
  endpoint.hash = "";
267
269
  return endpoint;
268
270
  }
271
+ /** Registry responses are untrusted input: bound the wait and the body. */
272
+ const REGISTRY_TIMEOUT_MS = 30_000;
273
+ const REGISTRY_MAX_BYTES = 32 * 1024 * 1024;
274
+ async function registryJson(input, init = {}) {
275
+ const response = await fetch(input, {
276
+ ...init,
277
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
278
+ });
279
+ // Trust the declared length when present, but still cap what is read: a
280
+ // server may understate or omit it.
281
+ const declared = Number(response.headers.get("content-length"));
282
+ if (Number.isFinite(declared) && declared > REGISTRY_MAX_BYTES)
283
+ throw new Error("Remote registry response exceeds the size limit");
284
+ const body = await response.arrayBuffer();
285
+ if (body.byteLength > REGISTRY_MAX_BYTES)
286
+ throw new Error("Remote registry response exceeds the size limit");
287
+ const text = new TextDecoder().decode(body);
288
+ let value;
289
+ try {
290
+ value = JSON.parse(text);
291
+ }
292
+ catch {
293
+ throw new Error("Remote registry returned a malformed JSON response");
294
+ }
295
+ return { ok: response.ok, status: response.status, value };
296
+ }
269
297
  export async function fetchRemoteRegistryPackage(registry, name, version) {
270
298
  if (!NAME.test(name) || !VERSION.test(version))
271
299
  throw new Error("Invalid remote registry package name or version");
272
300
  const base = registryUrl(registry);
273
- const response = await fetch(registryEndpoint(base, `/v1/packages/${encodeURIComponent(name)}/${encodeURIComponent(version)}`));
301
+ const response = await registryJson(registryEndpoint(base, `/v1/packages/${encodeURIComponent(name)}/${encodeURIComponent(version)}`));
274
302
  if (!response.ok)
275
303
  throw new Error(`Remote registry returned ${response.status}`);
276
- const bundle = (await response.json());
304
+ const bundle = response.value;
305
+ // `digest` arrives from a remote server and is used to build a path that is
306
+ // later deleted recursively. Validate its shape before it can become one:
307
+ // a value such as "../../.." would otherwise escape the cache root.
308
+ if (typeof bundle?.digest !== "string" || !DIGEST.test(bundle.digest))
309
+ throw new Error("Remote registry returned a bundle whose digest is not a SHA-256 hex string");
277
310
  const key = createHash("sha256")
278
311
  .update(base.origin + base.pathname)
279
312
  .digest("hex");
280
- const target = join(loadoutHome(), "cache", "registry", key, name, version, bundle.digest);
313
+ const root = join(loadoutHome(), "cache", "registry", key, name, version);
314
+ const target = resolve(root, bundle.digest);
315
+ // Belt and braces: even with a validated digest, never operate on a path
316
+ // that resolved outside the directory it was meant to live in.
317
+ if (target !== root && !target.startsWith(root + sep))
318
+ throw new Error("Remote registry bundle resolved outside the cache root");
281
319
  try {
282
320
  const packed = await packPackage(target);
283
321
  if (packed.digest === bundle.digest)
@@ -286,9 +324,19 @@ export async function fetchRemoteRegistryPackage(registry, name, version) {
286
324
  catch {
287
325
  /* import below */
288
326
  }
289
- await mkdir(resolve(target, ".."), { recursive: true });
290
- await rm(target, { recursive: true, force: true });
291
- await importRegistryBundle(bundle, target);
327
+ // Import into a scratch directory and swap it in, so a failed or malicious
328
+ // import cannot leave the cache holding a half-written package.
329
+ await mkdir(root, { recursive: true });
330
+ const staging = `${target}.incoming-${randomUUID().slice(0, 8)}`;
331
+ try {
332
+ await importRegistryBundle(bundle, staging);
333
+ await rm(target, { recursive: true, force: true });
334
+ await rename(staging, target);
335
+ }
336
+ catch (error) {
337
+ await rm(staging, { recursive: true, force: true });
338
+ throw error;
339
+ }
292
340
  return { path: target, digest: bundle.digest };
293
341
  }
294
342
  export async function publishRemotePackage(root, registry, token, options = {}) {
@@ -296,7 +344,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
296
344
  throw new Error("Remote registry token is required");
297
345
  const bundle = await createRegistryBundle(root);
298
346
  const base = registryUrl(registry);
299
- const response = await fetch(registryEndpoint(base, "/v1/packages"), {
347
+ const response = await registryJson(registryEndpoint(base, "/v1/packages"), {
300
348
  method: "POST",
301
349
  headers: {
302
350
  authorization: `Bearer ${token}`,
@@ -305,7 +353,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
305
353
  },
306
354
  body: JSON.stringify(bundle),
307
355
  });
308
- const result = (await response.json());
356
+ const result = response.value;
309
357
  if (!response.ok)
310
358
  throw new Error(result.error ?? `Remote registry returned ${response.status}`);
311
359
  return {
@@ -38,27 +38,37 @@ const SUSPICIOUS_INSTRUCTIONS = [
38
38
  export function detectSecretKinds(content) {
39
39
  return SECRET_PATTERNS.filter((check) => check.pattern.test(content)).map((check) => check.name);
40
40
  }
41
- async function files(root) {
41
+ const MAX_INSPECTABLE_BYTES = 2_000_000;
42
+ const MAX_DEPTH = 10;
43
+ async function files(root, gaps = []) {
42
44
  const result = new Map();
43
45
  const base = resolve(root);
44
46
  try {
45
47
  const rootInfo = await stat(base);
46
- if (rootInfo.isFile() && rootInfo.size <= 2_000_000) {
48
+ if (rootInfo.isFile()) {
49
+ if (rootInfo.size > MAX_INSPECTABLE_BYTES) {
50
+ gaps.push({ path: basename(base), reason: "too-large" });
51
+ return result;
52
+ }
47
53
  result.set(basename(base), await readFile(base));
48
54
  return result;
49
55
  }
50
56
  }
51
57
  catch {
58
+ gaps.push({ path: basename(base), reason: "unreadable" });
52
59
  return result;
53
60
  }
54
61
  async function visit(directory, depth) {
55
- if (depth > 10)
62
+ if (depth > MAX_DEPTH) {
63
+ gaps.push({ path: relative(base, directory), reason: "too-deep" });
56
64
  return;
65
+ }
57
66
  let entries;
58
67
  try {
59
68
  entries = await readdir(directory, { withFileTypes: true });
60
69
  }
61
70
  catch {
71
+ gaps.push({ path: relative(base, directory), reason: "unreadable" });
62
72
  return;
63
73
  }
64
74
  for (const entry of entries) {
@@ -73,14 +83,19 @@ async function files(root) {
73
83
  }
74
84
  if (!entry.isFile())
75
85
  continue;
86
+ const shown = relative(base, absolute).split("\\").join("/");
76
87
  try {
77
88
  const info = await stat(absolute);
78
- if (info.size > 2_000_000)
89
+ if (info.size > MAX_INSPECTABLE_BYTES) {
90
+ gaps.push({ path: shown, reason: "too-large" });
79
91
  continue;
80
- result.set(relative(base, absolute).split("\\").join("/"), await readFile(absolute));
92
+ }
93
+ result.set(shown, await readFile(absolute));
81
94
  }
82
95
  catch {
83
- /* repository may change while being inspected */
96
+ // The repository may change while being inspected; either way this
97
+ // file was not reviewed, so record it rather than passing it silently.
98
+ gaps.push({ path: shown, reason: "unreadable" });
84
99
  }
85
100
  }
86
101
  }
@@ -131,8 +146,9 @@ function collectEnvironmentNames(content) {
131
146
  }
132
147
  /** Analyze changed repository files without running package code or revealing secret values. */
133
148
  export async function analyzeUpdateSafety(oldPath, newPath) {
149
+ const gaps = [];
134
150
  const oldFiles = oldPath ? await files(oldPath) : new Map();
135
- const newFiles = await files(newPath);
151
+ const newFiles = await files(newPath, gaps);
136
152
  const changed = changedPaths(oldFiles, newFiles);
137
153
  const findings = [];
138
154
  const domains = new Set();
@@ -227,6 +243,19 @@ export async function analyzeUpdateSafety(oldPath, newPath) {
227
243
  paths: [...new Set(instructionPaths)].sort(),
228
244
  names: [...instructionKinds].sort(),
229
245
  });
246
+ // Fail closed. Anything the scan could not open is exactly what a reviewer
247
+ // needs told about, so an uninspectable file requires approval rather than
248
+ // producing a clean report.
249
+ if (gaps.length) {
250
+ const reasons = [...new Set(gaps.map((gap) => gap.reason))].sort();
251
+ findings.push({
252
+ severity: "blocking",
253
+ category: "uninspectable",
254
+ message: `Safety scan could not inspect ${gaps.length} path(s) (${reasons.join(", ")}); they were not reviewed.`,
255
+ paths: [...new Set(gaps.map((gap) => gap.path))].sort(),
256
+ names: reasons,
257
+ });
258
+ }
230
259
  return {
231
260
  approvalRequired: findings.some((finding) => finding.severity === "blocking"),
232
261
  findings,
@@ -6,9 +6,26 @@ import { execFile } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { loadoutHome, ensureDirectory } from "../agents/paths.js";
8
8
  const execFileAsync = promisify(execFile);
9
+ /**
10
+ * Bounds apply to every fetch, not only to callers who remember to ask. An
11
+ * unbounded clone of an untrusted repository can hang a Git process forever or
12
+ * fill the disk, so callers may raise a limit but none may omit one.
13
+ */
14
+ export const REPOSITORY_FETCH_DEFAULTS = {
15
+ /** A reviewed skill repository clones well inside this. */
16
+ timeoutMs: 120_000,
17
+ maxBytes: 256 * 1024 * 1024,
18
+ maxFiles: 20_000,
19
+ };
20
+ function withFetchDefaults(options) {
21
+ return {
22
+ ...options,
23
+ timeoutMs: options.timeoutMs ?? REPOSITORY_FETCH_DEFAULTS.timeoutMs,
24
+ maxBytes: options.maxBytes ?? REPOSITORY_FETCH_DEFAULTS.maxBytes,
25
+ maxFiles: options.maxFiles ?? REPOSITORY_FETCH_DEFAULTS.maxFiles,
26
+ };
27
+ }
9
28
  async function enforceRepositoryBounds(root, options) {
10
- if (options.maxBytes === undefined && options.maxFiles === undefined)
11
- return;
12
29
  let bytes = 0;
13
30
  let files = 0;
14
31
  const pending = [root];
@@ -35,8 +52,6 @@ async function enforceRepositoryBounds(root, options) {
35
52
  }
36
53
  }
37
54
  export function validateGitHubTreeBounds(value, options) {
38
- if (options.maxBytes === undefined && options.maxFiles === undefined)
39
- return;
40
55
  if (!value || typeof value !== "object" || Array.isArray(value))
41
56
  throw new Error("GitHub tree preflight returned an invalid response");
42
57
  const response = value;
@@ -63,8 +78,6 @@ export function validateGitHubTreeBounds(value, options) {
63
78
  }
64
79
  }
65
80
  async function enforceGitHubTreeBounds(repository, commit, options) {
66
- if (options.maxBytes === undefined && options.maxFiles === undefined)
67
- return;
68
81
  const signal = AbortSignal.timeout(options.timeoutMs ?? 120_000);
69
82
  const token = process.env.GITHUB_TOKEN?.trim();
70
83
  const response = await (options.fetcher ?? fetch)(`https://api.github.com/repos/${repository}/git/trees/${commit}?recursive=1`, {
@@ -181,7 +194,8 @@ function normalizeRef(ref) {
181
194
  throw new Error(`Invalid Git ref: ${ref}`);
182
195
  return ref;
183
196
  }
184
- export async function fetchRepositorySnapshot(input, options = {}) {
197
+ export async function fetchRepositorySnapshot(input, requested = {}) {
198
+ const options = withFetchDefaults(requested);
185
199
  const repository = normalizeRepository(input);
186
200
  const gitEnvironment = await isolatedGitEnvironment(loadoutHome());
187
201
  const ref = options.ref ? normalizeRef(options.ref) : undefined;
@@ -16,9 +16,9 @@ IN A PROJECT
16
16
 
17
17
  WHILE YOU WORK
18
18
 
19
- loadout route <describe a task> which model tier fits, and what it costs
20
- loadout route --conserve <task> cheaper tiers when you are low on quota
21
- loadout handoff send codex "..." give a task to your other agent
19
+ loadout route your model policy: hard / normal / cheap
20
+ loadout route <describe a task> which of those it looks like, and what to use
21
+ loadout handoff codex "..." give a task to your other agent
22
22
 
23
23
  USE IT INSIDE YOUR AGENT
24
24