loadout-ai 0.8.0 → 0.9.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 (82) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/README.md +149 -304
  3. package/SECURITY.md +21 -1
  4. package/catalog/discovered.json +27897 -25327
  5. package/catalog/packages.json +4 -4
  6. package/dist/src/cli.js +12 -4
  7. package/dist/src/commands/agents.js +3 -1
  8. package/dist/src/commands/catalog-candidate.js +150 -0
  9. package/dist/src/commands/catalog-workflows.js +147 -0
  10. package/dist/src/commands/catalog.js +9 -354
  11. package/dist/src/commands/coordinate.js +586 -0
  12. package/dist/src/commands/coordination-discussions.js +194 -0
  13. package/dist/src/commands/coordination-sessions.js +197 -0
  14. package/dist/src/commands/inventory.js +4 -2
  15. package/dist/src/core/{routing → agents}/model-config.js +7 -2
  16. package/dist/src/core/catalog/catalog.js +1 -0
  17. package/dist/src/core/catalog/registry.js +5 -19
  18. package/dist/src/core/coordination/adapters/claude-code.js +154 -0
  19. package/dist/src/core/coordination/adapters/codex.js +137 -0
  20. package/dist/src/core/coordination/adapters/types.js +64 -0
  21. package/dist/src/core/coordination/auth.js +59 -0
  22. package/dist/src/core/coordination/bridge-lease.js +79 -0
  23. package/dist/src/core/coordination/conflict-preview.js +167 -0
  24. package/dist/src/core/coordination/contract-diff.js +106 -0
  25. package/dist/src/core/coordination/coordinator.js +552 -0
  26. package/dist/src/core/coordination/crash-recovery.js +169 -0
  27. package/dist/src/core/coordination/daemon.js +667 -0
  28. package/dist/src/core/coordination/discussion.js +340 -0
  29. package/dist/src/core/coordination/events.js +161 -0
  30. package/dist/src/core/coordination/http-api.js +118 -0
  31. package/dist/src/core/coordination/interrupt-policy.js +89 -0
  32. package/dist/src/core/coordination/lock.js +110 -0
  33. package/dist/src/core/coordination/mcp-server.js +424 -0
  34. package/dist/src/core/coordination/redaction.js +85 -0
  35. package/dist/src/core/coordination/replay.js +188 -0
  36. package/dist/src/core/coordination/retention.js +128 -0
  37. package/dist/src/core/coordination/runtime.js +21 -0
  38. package/dist/src/core/coordination/session-manager.js +366 -0
  39. package/dist/src/core/coordination/watcher.js +128 -0
  40. package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
  41. package/dist/src/core/{routing → delegation}/handoff.js +40 -9
  42. package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
  43. package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
  44. package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
  45. package/dist/src/core/discovery/candidate-intelligence.js +3 -214
  46. package/dist/src/core/discovery/community.js +12 -4
  47. package/dist/src/core/discovery/github-discovery.js +6 -2
  48. package/dist/src/core/discovery/private-discovery.js +6 -2
  49. package/dist/src/core/install/reconcile.js +1 -1
  50. package/dist/src/core/install/uninstall.js +39 -3
  51. package/dist/src/core/reporting/cli-guide.js +9 -5
  52. package/dist/src/core/reporting/completion.js +43 -38
  53. package/dist/src/core/reporting/doctor.js +1 -1
  54. package/dist/src/core/runtime/bounded-json.js +65 -0
  55. package/dist/src/core/runtime/github.js +30 -16
  56. package/dist/src/core/runtime/mcp-recipes.js +1 -1
  57. package/dist/src/core/workspace/active-policy.js +1 -1
  58. package/docs/DEMO_SCRIPT.md +19 -23
  59. package/docs/DISCOVERED.md +251 -251
  60. package/docs/FEATURE_TEST_MATRIX.md +3 -3
  61. package/docs/LIVE_COLLABORATION.md +234 -0
  62. package/docs/REFERENCE.md +163 -0
  63. package/docs/RELEASE_REVIEW.md +1 -1
  64. package/docs/TESTING.md +17 -1
  65. package/docs/USER_TEST_GUIDE.md +138 -3
  66. package/docs/assets/loadout-discover-activate.webp +0 -0
  67. package/docs/assets/loadout-handoff-coordinate.webp +0 -0
  68. package/docs/assets/loadout-social-preview.png +0 -0
  69. package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
  70. package/docs/decisions/002-local-daemon-authentication.md +32 -0
  71. package/docs/decisions/003-bounded-agent-discussions.md +91 -0
  72. package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
  73. package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
  74. package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
  75. package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
  76. package/package.json +13 -4
  77. package/skills/loadout-handoff/SKILL.md +238 -0
  78. package/dist/src/core/routing/policy.js +0 -147
  79. package/dist/src/core/routing/route.js +0 -411
  80. package/docs/assets/loadout-workflow.png +0 -0
  81. package/skills/loadout-router/SKILL.md +0 -85
  82. /package/dist/src/core/{routing → agents}/credentials.js +0 -0
@@ -1,12 +1,32 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { readdir, rm, rmdir } from "node:fs/promises";
3
- import { dirname, parse, resolve } from "node:path";
3
+ import { dirname, parse, relative, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { loadoutHome } from "../agents/paths.js";
6
6
  import { applyRemove, planRemove } from "./remove.js";
7
7
  import { applyRuntimeToolPlan, listInstalledRuntimeTools, planRuntimeTool, } from "../agents/runtime-tools.js";
8
8
  import { applyNativeSchedulerBundle, planNativeScheduler, } from "../runtime/scheduler.js";
9
- import { readInstallState } from "../workspace/state.js";
9
+ import { hashDirectory, readInstallState } from "../workspace/state.js";
10
+ import { FIRST_PARTY_SKILLS, planFirstPartySkill, } from "../delegation/first-party-skills.js";
11
+ async function directorySignature(root) {
12
+ return JSON.stringify((await hashDirectory(root))
13
+ .map((file) => ({ path: relative(root, file.path), sha256: file.sha256 }))
14
+ .sort((left, right) => left.path.localeCompare(right.path)));
15
+ }
16
+ async function firstPartyUninstallTargets() {
17
+ const targets = [];
18
+ for (const skill of FIRST_PARTY_SKILLS) {
19
+ const plan = await planFirstPartySkill(skill.id);
20
+ const sourceSignature = await directorySignature(plan.source);
21
+ for (const target of plan.targets.filter((item) => item.replacing))
22
+ targets.push({
23
+ id: skill.id,
24
+ path: target.destination,
25
+ modified: (await directorySignature(target.destination)) !== sourceSignature,
26
+ });
27
+ }
28
+ return targets;
29
+ }
10
30
  export async function buildUninstallPlan(dependencies = {}) {
11
31
  const stateHome = loadoutHome();
12
32
  const state = await readInstallState();
@@ -18,19 +38,25 @@ export async function buildUninstallPlan(dependencies = {}) {
18
38
  ];
19
39
  const packages = await Promise.all(packageIds.map(planRemove));
20
40
  const runtimeTools = await (dependencies.runtimeTools ?? listInstalledRuntimeTools)(stateHome);
41
+ const firstPartySkills = await firstPartyUninstallTargets();
21
42
  const schedulers = dependencies.schedulerPlans?.() ??
22
43
  ["updates", "discovery"].map((job) => planNativeScheduler("unschedule", { job }));
23
44
  const warnings = packages.flatMap((entry) => entry.warnings);
24
45
  if (runtimeTools.length)
25
46
  warnings.push(`${runtimeTools.length} Loadout-managed runtime tool(s) will be restored to their pre-install snapshots.`);
47
+ const modifiedFirstPartySkills = firstPartySkills.filter((target) => target.modified);
48
+ if (modifiedFirstPartySkills.length)
49
+ warnings.push(`${modifiedFirstPartySkills.length} first-party skill install(s) differ from this Loadout release. Review them, or use --force to remove them.`);
26
50
  warnings.push("Loadout's cache, disabled library, history, and rollback snapshots will be deleted. This final state cleanup cannot itself be rolled back.");
27
51
  return {
28
52
  stateHome,
29
53
  packages,
30
54
  runtimeTools,
55
+ firstPartySkills,
31
56
  schedulers,
32
57
  disabledLibraryRecords: (state.activations ?? []).filter((entry) => entry.activationState === "disabled").length,
33
- blocked: packages.some((entry) => entry.blocked),
58
+ blocked: packages.some((entry) => entry.blocked) ||
59
+ modifiedFirstPartySkills.length > 0,
34
60
  warnings,
35
61
  };
36
62
  }
@@ -96,6 +122,10 @@ export async function applyUninstall(plan, dependencies = {}, options = {}) {
96
122
  options.onProgress?.(`${packagePlan.preserveFiles ? "Forgetting adopted ownership" : "Removing managed package"} [${index + 1}/${fresh.packages.length}]: ${packagePlan.packageId}`);
97
123
  await applyRemove(packagePlan, { force: options.force });
98
124
  }
125
+ for (const target of fresh.firstPartySkills) {
126
+ options.onProgress?.(`Removing first-party skill: ${target.id}`);
127
+ await rm(target.path, { recursive: true, force: true });
128
+ }
99
129
  await removeEmptyManagedDirectories(fresh.packages);
100
130
  if (fresh.schedulers.length) {
101
131
  options.onProgress?.("Removing daily read-only schedules");
@@ -108,6 +138,7 @@ export async function applyUninstall(plan, dependencies = {}, options = {}) {
108
138
  return {
109
139
  removedPackages: fresh.packages.length,
110
140
  removedRuntimeTools: fresh.runtimeTools.length,
141
+ removedFirstPartySkills: fresh.firstPartySkills.length,
111
142
  };
112
143
  }
113
144
  const execFileAsync = promisify(execFile);
@@ -127,6 +158,7 @@ export function formatUninstallPlan(plan, options = {}) {
127
158
  "",
128
159
  `Managed packages: ${plan.packages.length}`,
129
160
  `Managed runtime tools: ${plan.runtimeTools.length ? plan.runtimeTools.join(", ") : "none"}`,
161
+ `First-party skills: ${plan.firstPartySkills.length}`,
130
162
  `Disabled library records: ${plan.disabledLibraryRecords}`,
131
163
  `Daily jobs to remove: ${plan.schedulers.map((item) => item.job).join(", ") || "none"}`,
132
164
  `State and cache: ${plan.stateHome}`,
@@ -135,6 +167,10 @@ export function formatUninstallPlan(plan, options = {}) {
135
167
  "",
136
168
  "BLOCKED: managed files were changed outside Loadout.",
137
169
  ...modifiedPaths.slice(0, 10).map((path) => ` Modified: ${path}`),
170
+ ...plan.firstPartySkills
171
+ .filter((target) => target.modified)
172
+ .slice(0, 10)
173
+ .map((target) => ` Modified: ${target.path}`),
138
174
  ...(modifiedPaths.length > 10
139
175
  ? [` …and ${modifiedPaths.length - 10} more`]
140
176
  : []),
@@ -16,16 +16,15 @@ IN A PROJECT
16
16
 
17
17
  WHILE YOU WORK
18
18
 
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
19
  loadout handoff codex "..." give a task to your other agent
20
+ loadout handoff what is waiting, both directions
22
21
 
23
22
  USE IT INSIDE YOUR AGENT
24
23
 
25
- loadout skills install loadout-router --yes
24
+ loadout skills install loadout-handoff --yes
26
25
 
27
- Then just ask your agent "which model should I use for this?" instead of
28
- coming back here.
26
+ Then your agents check their own inboxes and can hand work to each other
27
+ without you relaying it.
29
28
 
30
29
  KEEPING CURRENT
31
30
 
@@ -45,6 +44,7 @@ export const ADVANCED_GUIDE = [
45
44
  "Discovery: candidate, discover, review-queue, evaluate, skill-audit",
46
45
  "Sharing: init, lock, export, import, share, card",
47
46
  "Integrations: mcp-recipe, mcp-config, credentials, models, handoff, skills, convert",
47
+ "Coordination: coordinate (coord), daemon, serve",
48
48
  "Lifecycle: plan, adopt, uninstall, profiles, autopilot, tool, watch",
49
49
  "",
50
50
  "`loadout <command> --help` for options. Mutations preview first or require --yes.",
@@ -100,4 +100,8 @@ export const HIDDEN_FROM_FIRST_SCREEN = new Set([
100
100
  "alert-pin",
101
101
  "alert-unpin",
102
102
  "alert-pins",
103
+ // coordination
104
+ "coordinate",
105
+ "daemon",
106
+ "serve",
103
107
  ]);
@@ -11,76 +11,81 @@ export function completionCommands() {
11
11
  export function completionSubcommands(name) {
12
12
  return registry.find((entry) => entry.name === name)?.subcommands ?? [];
13
13
  }
14
+ /** Every executable top-level and parent/child command path. */
15
+ export function completionCommandPaths() {
16
+ return registry.flatMap((entry) => [
17
+ entry.name,
18
+ ...entry.subcommands.map((subcommand) => `${entry.name} ${subcommand}`),
19
+ ]);
20
+ }
14
21
  export function renderShellCompletion(shell) {
15
22
  const words = completionCommands().join(" ");
23
+ const parents = registry.filter((entry) => entry.subcommands.length > 0);
16
24
  switch (shell) {
17
- case "bash":
25
+ case "bash": {
26
+ const bashCases = parents
27
+ .map((entry) => ` "${entry.name}") candidates="${entry.subcommands.join(" ")}" ;;`)
28
+ .join("\n");
18
29
  return `# Loadout command completion
19
30
  _loadout() {
20
31
  local current="\${COMP_WORDS[COMP_CWORD]}"
21
32
  local commands="${words}"
33
+ local candidates=""
22
34
  if [[ COMP_CWORD -eq 1 ]]; then
23
- COMPREPLY=( $(compgen -W "$commands" -- "$current") )
24
- elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "models" ]]; then
25
- COMPREPLY=( $(compgen -W "${completionSubcommands("models").join(" ")}" -- "$current") )
26
- elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "credentials" ]]; then
27
- COMPREPLY=( $(compgen -W "${completionSubcommands("credentials").join(" ")}" -- "$current") )
28
- elif [[ COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "candidate" ]]; then
29
- COMPREPLY=( $(compgen -W "${completionSubcommands("candidate").join(" ")}" -- "$current") )
35
+ candidates="$commands"
36
+ elif [[ COMP_CWORD -eq 2 ]]; then
37
+ case "\${COMP_WORDS[1]}" in
38
+ ${bashCases}
39
+ esac
30
40
  fi
41
+ COMPREPLY=( $(compgen -W "$candidates" -- "$current") )
31
42
  }
32
43
  complete -F _loadout loadout
33
44
  `;
34
- case "zsh":
45
+ }
46
+ case "zsh": {
47
+ const zshCases = parents
48
+ .map((entry) => ` '${entry.name}') _values '${entry.name} command' ${entry.subcommands.map((command) => `'${command}'`).join(" ")} ;;`)
49
+ .join("\n");
35
50
  return `#compdef loadout
36
- typeset -a commands model_commands credential_commands candidate_commands
51
+ typeset -a commands
37
52
  commands=(${completionCommands()
38
53
  .map((command) => `'${command}'`)
39
54
  .join(" ")})
40
- model_commands=(${completionSubcommands("models")
41
- .map((command) => `'${command}'`)
42
- .join(" ")})
43
- credential_commands=(${completionSubcommands("credentials")
44
- .map((command) => `'${command}'`)
45
- .join(" ")})
46
- candidate_commands=(${completionSubcommands("candidate")
47
- .map((command) => `'${command}'`)
48
- .join(" ")})
49
55
  if (( CURRENT == 2 )); then
50
56
  _describe 'command' commands
51
- elif (( CURRENT == 3 )) && [[ $words[2] == models ]]; then
52
- _describe 'models command' model_commands
53
- elif (( CURRENT == 3 )) && [[ $words[2] == credentials ]]; then
54
- _describe 'credentials command' credential_commands
55
- elif (( CURRENT == 3 )) && [[ $words[2] == candidate ]]; then
56
- _describe 'candidate command' candidate_commands
57
+ elif (( CURRENT == 3 )); then
58
+ case $words[2] in
59
+ ${zshCases}
60
+ esac
57
61
  fi
58
62
  `;
63
+ }
59
64
  case "fish":
60
65
  return [
61
66
  ...completionCommands().map((command) => `complete -c loadout -f -n '__fish_use_subcommand' -a ${command}`),
62
- ...completionSubcommands("models").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from models' -a ${command}`),
63
- ...completionSubcommands("credentials").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from credentials' -a ${command}`),
64
- ...completionSubcommands("candidate").map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from candidate' -a ${command}`),
67
+ ...parents.flatMap((entry) => entry.subcommands.map((command) => `complete -c loadout -f -n '__fish_seen_subcommand_from ${entry.name}' -a ${command}`)),
65
68
  ]
66
69
  .join("\n")
67
70
  .concat("\n");
68
- case "powershell":
71
+ case "powershell": {
72
+ const powershellCases = parents
73
+ .map((entry, index) => `${index === 0 ? "if" : "elseif"} ($elements.Count -ge 2 -and $elements[1] -eq '${entry.name}') { @(${entry.subcommands.map((command) => `'${command}'`).join(", ")}) }`)
74
+ .join(" ");
75
+ const topLevel = `@(${completionCommands()
76
+ .map((command) => `'${command}'`)
77
+ .join(", ")})`;
78
+ const powershellCandidates = powershellCases
79
+ ? `${powershellCases} else { ${topLevel} }`
80
+ : topLevel;
69
81
  return `Register-ArgumentCompleter -Native -CommandName loadout -ScriptBlock {
70
82
  param($wordToComplete, $commandAst, $cursorPosition)
71
83
  $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
72
- $candidates = if ($elements.Count -ge 2 -and $elements[1] -eq 'models') { @(${completionSubcommands("models")
73
- .map((command) => `'${command}'`)
74
- .join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'credentials') { @(${completionSubcommands("credentials")
75
- .map((command) => `'${command}'`)
76
- .join(", ")}) } elseif ($elements.Count -ge 2 -and $elements[1] -eq 'candidate') { @(${completionSubcommands("candidate")
77
- .map((command) => `'${command}'`)
78
- .join(", ")}) } else { @(${completionCommands()
79
- .map((command) => `'${command}'`)
80
- .join(", ")}) }
84
+ $candidates = ${powershellCandidates}
81
85
  $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
82
86
  }
83
87
  `;
88
+ }
84
89
  }
85
90
  }
86
91
  export function parseCompletionShell(value) {
@@ -127,7 +127,7 @@ export function formatDoctorReport(report, options = {}) {
127
127
  lines.push(" Install an AI coding agent (Claude Code, Codex, Cursor, etc.),", " then run `loadout doctor` again.");
128
128
  }
129
129
  else {
130
- lines.push(" loadout status see what's managed", " loadout setup install a curated skill set", " loadout route find the right model for your task", " loadout health check for updates");
130
+ lines.push(" loadout status see what's managed", " loadout setup install a curated skill set", " loadout health check for updates");
131
131
  }
132
132
  return lines.join("\n");
133
133
  }
@@ -0,0 +1,65 @@
1
+ function combinedSignal(caller, timeoutMs) {
2
+ const timeout = AbortSignal.timeout(timeoutMs);
3
+ return caller ? AbortSignal.any([caller, timeout]) : timeout;
4
+ }
5
+ async function readBoundedBody(response, maxBytes, label) {
6
+ const length = response.headers.get("content-length");
7
+ if (length !== null && /^\d+$/.test(length) && Number(length) > maxBytes) {
8
+ throw new Error(`${label} response exceeds the size limit`);
9
+ }
10
+ if (!response.body) {
11
+ const bytes = new Uint8Array(await response.arrayBuffer());
12
+ if (bytes.byteLength > maxBytes)
13
+ throw new Error(`${label} response exceeds the size limit`);
14
+ return bytes;
15
+ }
16
+ const reader = response.body.getReader();
17
+ const chunks = [];
18
+ let total = 0;
19
+ try {
20
+ while (true) {
21
+ const { done, value } = await reader.read();
22
+ if (done)
23
+ break;
24
+ total += value.byteLength;
25
+ if (total > maxBytes) {
26
+ await reader.cancel(`${label} response exceeds the size limit`);
27
+ throw new Error(`${label} response exceeds the size limit`);
28
+ }
29
+ chunks.push(value);
30
+ }
31
+ }
32
+ finally {
33
+ reader.releaseLock();
34
+ }
35
+ const bytes = new Uint8Array(total);
36
+ let offset = 0;
37
+ for (const chunk of chunks) {
38
+ bytes.set(chunk, offset);
39
+ offset += chunk.byteLength;
40
+ }
41
+ return bytes;
42
+ }
43
+ /** Fetch and parse untrusted JSON without allowing an unbounded wait or body. */
44
+ export async function boundedJson(fetcher, input, init, limits) {
45
+ if (!Number.isFinite(limits.timeoutMs) || limits.timeoutMs <= 0)
46
+ throw new Error("JSON request timeout must be greater than zero");
47
+ if (!Number.isSafeInteger(limits.maxBytes) || limits.maxBytes <= 0)
48
+ throw new Error("JSON response byte limit must be a positive integer");
49
+ const response = await fetcher(input, {
50
+ ...init,
51
+ signal: combinedSignal(init.signal, limits.timeoutMs),
52
+ });
53
+ const body = await readBoundedBody(response, limits.maxBytes, limits.label);
54
+ if (!response.ok)
55
+ return { response, value: undefined };
56
+ try {
57
+ return {
58
+ response,
59
+ value: JSON.parse(new TextDecoder().decode(body)),
60
+ };
61
+ }
62
+ catch {
63
+ throw new Error(`${limits.label} returned malformed JSON`);
64
+ }
65
+ }
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { loadoutHome } from "../agents/paths.js";
5
+ import { boundedJson } from "./bounded-json.js";
5
6
  function normalizeRepository(input) {
6
7
  const value = input
7
8
  .trim()
@@ -44,10 +45,13 @@ export async function fetchGitHubMetadata(input, options = {}) {
44
45
  if (process.env.GITHUB_TOKEN)
45
46
  headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
46
47
  let response;
48
+ let value;
47
49
  try {
48
- response = await fetcher(`https://api.github.com/repos/${repository}`, {
49
- headers,
50
- });
50
+ ({ response, value } = await boundedJson(fetcher, `https://api.github.com/repos/${repository}`, { headers }, {
51
+ timeoutMs: 30_000,
52
+ maxBytes: 4 * 1024 * 1024,
53
+ label: "GitHub metadata",
54
+ }));
51
55
  }
52
56
  catch (error) {
53
57
  if (cached)
@@ -60,21 +64,27 @@ export async function fetchGitHubMetadata(input, options = {}) {
60
64
  return cached;
61
65
  throw new Error(`GitHub metadata request failed (${response.status}${rateLimited ? ", rate limit exceeded" : ""}) for ${repository}`);
62
66
  }
63
- const value = (await response.json());
67
+ if (!value || typeof value !== "object")
68
+ throw new Error(`GitHub metadata response is invalid for ${repository}`);
69
+ const record = value;
64
70
  const metadata = {
65
71
  repository,
66
- stars: typeof value.stargazers_count === "number" ? value.stargazers_count : 0,
67
- description: typeof value.description === "string" ? value.description : "",
68
- defaultBranch: typeof value.default_branch === "string" ? value.default_branch : "main",
69
- topics: Array.isArray(value.topics)
70
- ? value.topics.filter((topic) => typeof topic === "string")
72
+ stars: typeof record.stargazers_count === "number" ? record.stargazers_count : 0,
73
+ description: typeof record.description === "string" ? record.description : "",
74
+ defaultBranch: typeof record.default_branch === "string"
75
+ ? record.default_branch
76
+ : "main",
77
+ topics: Array.isArray(record.topics)
78
+ ? record.topics.filter((topic) => typeof topic === "string")
71
79
  : [],
72
- openIssues: typeof value.open_issues_count === "number" ? value.open_issues_count : 0,
73
- archived: value.archived === true,
74
- lastUpdatedAt: typeof value.updated_at === "string"
75
- ? value.updated_at
80
+ openIssues: typeof record.open_issues_count === "number"
81
+ ? record.open_issues_count
82
+ : 0,
83
+ archived: record.archived === true,
84
+ lastUpdatedAt: typeof record.updated_at === "string"
85
+ ? record.updated_at
76
86
  : new Date().toISOString(),
77
- pushedAt: typeof value.pushed_at === "string" ? value.pushed_at : null,
87
+ pushedAt: typeof record.pushed_at === "string" ? record.pushed_at : null,
78
88
  fetchedAt: new Date().toISOString(),
79
89
  };
80
90
  await mkdir(join(loadoutHome(), "cache", "github-metadata"), {
@@ -107,8 +117,13 @@ export async function fetchGitHubReleaseMetadata(input, options = {}) {
107
117
  if (process.env.GITHUB_TOKEN)
108
118
  headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
109
119
  let response;
120
+ let releases;
110
121
  try {
111
- response = await fetcher(`https://api.github.com/repos/${repository}/releases?per_page=1`, { headers });
122
+ ({ response, value: releases } = await boundedJson(fetcher, `https://api.github.com/repos/${repository}/releases?per_page=1`, { headers }, {
123
+ timeoutMs: 30_000,
124
+ maxBytes: 4 * 1024 * 1024,
125
+ label: "GitHub releases",
126
+ }));
112
127
  }
113
128
  catch (error) {
114
129
  if (cached)
@@ -120,7 +135,6 @@ export async function fetchGitHubReleaseMetadata(input, options = {}) {
120
135
  return cached;
121
136
  throw new Error(`GitHub release request failed (${response.status}) for ${repository}`);
122
137
  }
123
- const releases = (await response.json());
124
138
  const release = Array.isArray(releases) ? releases[0] : undefined;
125
139
  const record = release && typeof release === "object" ? release : {};
126
140
  const assets = Array.isArray(record.assets)
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
- import { createCredentialResolver } from "../routing/credentials.js";
4
+ import { createCredentialResolver } from "../agents/credentials.js";
5
5
  import { planMcpConfig } from "./mcp.js";
6
6
  /**
7
7
  * Small, source-linked recipes. These configure a connection only; authorizing
@@ -378,7 +378,7 @@ export async function planProjectActivation(projectPath, options = {}) {
378
378
  }
379
379
  export async function applyProjectActivation(plan) {
380
380
  if (!plan.activation || !plan.selected.length)
381
- throw new Error("No reviewed library skills were selected for activation");
381
+ return null;
382
382
  return applyActivationChange(plan.activation, {
383
383
  preflight: async () => {
384
384
  const requested = plan.agentPlans.map((item) => item.agent);
@@ -10,7 +10,7 @@ not show the retired dashboard. The final YouTube upload must be public.
10
10
  2. Confirm the release and product health:
11
11
 
12
12
  ```bash
13
- npm install --global loadout-ai@0.5.9
13
+ npm install --global loadout-ai@0.9.0
14
14
  hash -r
15
15
  loadout --version
16
16
  loadout health
@@ -107,33 +107,32 @@ loadout install --mode custom --package humanizer --agents codex
107
107
  > skill such as Humanizer can be installed directly through Custom mode. Each path
108
108
  > discloses permissions and credentials before approval.
109
109
 
110
- ### 2:05 to 2:25: today and tomorrow
110
+ ### 2:05 to 2:28: hand work from Claude Code to Codex
111
111
 
112
112
  **Screen:**
113
113
 
114
114
  ```bash
115
- loadout update
116
- loadout candidate list --limit 5
115
+ loadout skills install loadout-handoff --yes
116
+ loadout handoff codex "write tests for the auth module" --context "see src/auth.ts"
117
+ loadout handoff
117
118
  ```
118
119
 
119
120
  **Say:**
120
121
 
121
- > Update checks managed pinned sources without silently changing them. The discovery
122
- > feed watches a wider ecosystem and ranks new projects for review. A repository can
123
- > be noticed quickly without being trusted or installed blindly.
122
+ > Loadout also gives Claude Code and Codex a tiny shared handoff log. Claude can leave
123
+ > a bounded task with context, and Codex sees it when the next session checks its
124
+ > inbox. It is intentionally a local file workflow—not a background service or a live
125
+ > chat channel.
124
126
 
125
- ### 2:25 to 2:48: Codex, GPT-5.6, and close
127
+ ### 2:28 to 2:48: close
126
128
 
127
- **Screen:** README section “Built with Codex and GPT-5.6”, then return to the hero.
129
+ **Screen:** Return to the README hero and the handoff row in the workflow image.
128
130
 
129
131
  **Say:**
130
132
 
131
- > We used Codex and GPT-5.6 throughout the build: architecture, threat modelling,
132
- > cross-platform implementation, repo-wide debugging, tests, and the real founder
133
- > acceptance loop. Those tests caught rollback, project-scope, inventory, and update
134
- > bugs on my actual Codex and Claude profiles. Loadout itself stays local and does not
135
- > require an OpenAI API key. One command gives your AI agents a loadout you can
136
- > understand, improve, and undo.
133
+ > Loadout stays local and does not require an LLM API key to manage skills. One CLI
134
+ > gives your coding agents a setup you can inspect, improve, pass work through, and
135
+ > undo. Agent extensions, under control.
137
136
 
138
137
  ## Final edit and upload
139
138
 
@@ -142,15 +141,12 @@ loadout candidate list --limit 5
142
141
  - Add a small title card: **Loadout: Agent extensions, under control.**
143
142
  - Export under three minutes and upload publicly to YouTube.
144
143
  - Watch the uploaded video once, confirm audio and text are legible, then paste the
145
- exact public URL into README and Devpost.
144
+ exact public URL into the README.
146
145
 
147
146
  ## Submission checklist
148
147
 
149
148
  - [ ] Public YouTube video is under three minutes and its URL opens signed out.
150
- - [ ] Voiceover explains the product, Codex usage, and GPT-5.6 usage.
151
- - [ ] `/feedback` was run in Codex and the session ID is in Devpost.
152
- - [ ] Repository URL is accessible to Devpost and OpenAI.
153
- - [ ] README has npm setup and the judge testing path.
154
- - [ ] Every teammate has accepted the Devpost invitation.
155
- - [ ] Category is **Developer Tools**.
156
- - [ ] Submission is submitted, not left as a draft.
149
+ - [ ] Voiceover shows preview, rollback, and Claude Code ↔ Codex handoff.
150
+ - [ ] Repository and npm links open in a signed-out browser.
151
+ - [ ] README installation and user-test paths match the published version.
152
+ - [ ] No terminal frame exposes tokens, usernames, or private repository names.