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
@@ -0,0 +1,108 @@
1
+ import { join } from "node:path";
2
+ export function installabilityFor(components) {
3
+ if (components.some((component) => ["skill", "rule", "command", "agent"].includes(component)))
4
+ return "portable-components";
5
+ if (components.some((component) => component === "mcp" || component === "plugin"))
6
+ return "explicit-runtime-setup";
7
+ return "unsupported-source-shape";
8
+ }
9
+ export function componentsFor(inspection) {
10
+ const result = [];
11
+ if (inspection.counts.skills)
12
+ result.push("skill");
13
+ if (inspection.counts.rules)
14
+ result.push("rule");
15
+ if (inspection.counts.commands)
16
+ result.push("command");
17
+ if (inspection.counts.agents)
18
+ result.push("agent");
19
+ if (inspection.plugins.some((plugin) => !plugin.warnings.some((warning) => warning.startsWith("invalid plugin manifest"))))
20
+ result.push("plugin");
21
+ if (inspection.mcpServers.length)
22
+ result.push("mcp");
23
+ return result;
24
+ }
25
+ export function evidencePathsFor(inspection) {
26
+ return [
27
+ ...inspection.skills.map((item) => join(item.path, "SKILL.md")),
28
+ ...inspection.resources.map((item) => item.path),
29
+ ...inspection.plugins
30
+ .filter((item) => !item.warnings.some((warning) => warning.startsWith("invalid plugin manifest")))
31
+ .map((item) => item.path),
32
+ ...inspection.mcpServers.map((item) => item.path),
33
+ ]
34
+ .map((path) => path.split(/[\\/]/).join("/"))
35
+ .filter((path) => path !== "." && !path.startsWith("../"))
36
+ .filter((path, index, paths) => paths.indexOf(path) === index)
37
+ .sort();
38
+ }
39
+ const STOP_WORDS = new Set([
40
+ "the",
41
+ "and",
42
+ "for",
43
+ "with",
44
+ "from",
45
+ "that",
46
+ "this",
47
+ "your",
48
+ "into",
49
+ "agent",
50
+ "agents",
51
+ "skill",
52
+ "skills",
53
+ "mcp",
54
+ "server",
55
+ "servers",
56
+ "workflow",
57
+ "workflows",
58
+ "code",
59
+ "coding",
60
+ "developer",
61
+ "development",
62
+ "platform",
63
+ "context",
64
+ "open",
65
+ "source",
66
+ "using",
67
+ "across",
68
+ "tool",
69
+ "tools",
70
+ "github",
71
+ ]);
72
+ function tokens(...values) {
73
+ return new Set(values
74
+ .join(" ")
75
+ .toLowerCase()
76
+ .split(/[^a-z0-9]+/)
77
+ .filter((word) => word.length >= 3 && !STOP_WORDS.has(word)));
78
+ }
79
+ export function overlapFor(discovery, inspection, catalog) {
80
+ const candidateTokens = tokens(discovery.repository, discovery.description, discovery.topics.join(" "), ...inspection.skills.flatMap((item) => [item.name, item.description]), ...inspection.resources.map((item) => item.name), ...inspection.plugins.flatMap((item) => [item.name, item.description]), ...inspection.mcpServers.map((item) => item.name));
81
+ const candidateComponents = new Set(componentsFor(inspection));
82
+ return catalog
83
+ .map((pkg) => {
84
+ const packageTokens = tokens(pkg.id, pkg.displayName, pkg.description, pkg.category, pkg.topics?.join(" "));
85
+ const common = [...candidateTokens]
86
+ .filter((word) => packageTokens.has(word))
87
+ .sort();
88
+ // Containment is more useful than union similarity here: a large skill
89
+ // collection can overlap a focused package even when most collection
90
+ // tokens describe unrelated capabilities.
91
+ const smallerVocabulary = Math.min(candidateTokens.size, packageTokens.size);
92
+ const score = smallerVocabulary ? common.length / smallerVocabulary : 0;
93
+ const componentMatch = pkg.components?.some((component) => candidateComponents.has(component)) ?? false;
94
+ return {
95
+ packageId: pkg.id,
96
+ repository: pkg.repository,
97
+ score: Math.round(score * 1000) / 1000,
98
+ relationship: componentMatch && common.length >= 2 && score >= 0.2
99
+ ? "possible-overlap"
100
+ : "same-tooling-area",
101
+ evidence: common.slice(0, 8).map((word) => `shared term: ${word}`),
102
+ };
103
+ })
104
+ .filter((item) => item.evidence.length >= 1 && item.score >= 0.1)
105
+ .sort((left, right) => right.score - left.score ||
106
+ left.packageId.localeCompare(right.packageId))
107
+ .slice(0, 5);
108
+ }
@@ -0,0 +1,108 @@
1
+ import { componentsFor, evidencePathsFor, installabilityFor, } from "./candidate-intelligence-evidence.js";
2
+ export function isTextArray(value) {
3
+ return (Array.isArray(value) && value.every((item) => typeof item === "string"));
4
+ }
5
+ export function finiteNonnegative(value) {
6
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
7
+ }
8
+ export function validDate(value) {
9
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
10
+ }
11
+ export function validPersistedEvaluation(value) {
12
+ if (!value || typeof value !== "object" || Array.isArray(value))
13
+ return false;
14
+ const evaluation = value;
15
+ const categories = evaluation.categories;
16
+ return (evaluation.evaluatorVersion === 1 &&
17
+ typeof evaluation.uncertainty === "string" &&
18
+ Array.isArray(categories) &&
19
+ categories.length === 2 &&
20
+ new Set(categories.map((item) => item.category)).size === 2 &&
21
+ categories.every((item) => (item.category === "skills" || item.category === "mcp") &&
22
+ ["ready", "needs-review", "blocked", "not-applicable"].includes(item.status) &&
23
+ isTextArray(item.findings)));
24
+ }
25
+ function safeEvidencePath(value) {
26
+ return (typeof value === "string" &&
27
+ Boolean(value) &&
28
+ !value.startsWith("/") &&
29
+ !value.split(/[\\/]/).includes(".."));
30
+ }
31
+ export function validPersistedInspection(value) {
32
+ if (!value || typeof value !== "object" || Array.isArray(value))
33
+ return false;
34
+ const inspection = value;
35
+ const validCount = (count) => typeof count === "number" && Number.isSafeInteger(count) && count >= 0;
36
+ const validNamedPath = (item, type) => Boolean(item &&
37
+ typeof item === "object" &&
38
+ !Array.isArray(item) &&
39
+ item.type === type &&
40
+ typeof item.name === "string" &&
41
+ safeEvidencePath(item.path));
42
+ if (!Array.isArray(inspection.skills) ||
43
+ !inspection.skills.every((item) => validNamedPath(item, "skill") &&
44
+ (item.description === undefined ||
45
+ typeof item.description === "string")) ||
46
+ !Array.isArray(inspection.resources) ||
47
+ !inspection.resources.every((item) => ["rule", "command", "agent"].includes(item.type) &&
48
+ validNamedPath(item, item.type)) ||
49
+ !Array.isArray(inspection.plugins) ||
50
+ !inspection.plugins.every((item) => validNamedPath(item, "plugin") &&
51
+ (item.description === undefined ||
52
+ typeof item.description === "string") &&
53
+ (item.version === undefined || typeof item.version === "string") &&
54
+ (item.author === undefined || typeof item.author === "string") &&
55
+ Array.isArray(item.components) &&
56
+ item.components.every((component) => [
57
+ "skill",
58
+ "rule",
59
+ "command",
60
+ "agent",
61
+ "mcp",
62
+ "plugin",
63
+ "root",
64
+ ].includes(component)) &&
65
+ isTextArray(item.hookEvents) &&
66
+ isTextArray(item.mcpServers) &&
67
+ isTextArray(item.warnings)) ||
68
+ !Array.isArray(inspection.mcpServers) ||
69
+ !inspection.mcpServers.every((item) => validNamedPath(item, "mcp") &&
70
+ ["command", "url", "unknown"].includes(item.transport) &&
71
+ (item.command === undefined || typeof item.command === "string") &&
72
+ (item.url === undefined || typeof item.url === "string") &&
73
+ validCount(item.argumentCount) &&
74
+ validCount(item.environmentVariableCount) &&
75
+ isTextArray(item.warnings)) ||
76
+ !inspection.counts ||
77
+ !validCount(inspection.counts.skills) ||
78
+ !validCount(inspection.counts.rules) ||
79
+ !validCount(inspection.counts.commands) ||
80
+ !validCount(inspection.counts.agents) ||
81
+ !validCount(inspection.counts.plugins) ||
82
+ !validCount(inspection.counts.mcpServers) ||
83
+ !validCount(inspection.counts.manifests) ||
84
+ !isTextArray(inspection.warnings))
85
+ return false;
86
+ return (inspection.counts.skills === inspection.skills.length &&
87
+ inspection.counts.rules ===
88
+ inspection.resources.filter((item) => item.type === "rule").length &&
89
+ inspection.counts.commands ===
90
+ inspection.resources.filter((item) => item.type === "command").length &&
91
+ inspection.counts.agents ===
92
+ inspection.resources.filter((item) => item.type === "agent").length &&
93
+ inspection.counts.plugins === inspection.plugins.length &&
94
+ inspection.counts.mcpServers === inspection.mcpServers.length);
95
+ }
96
+ export function assertDossierEvidence(dossier) {
97
+ if (!validPersistedInspection(dossier.inspection))
98
+ throw new Error("Candidate dossier inspection evidence is invalid");
99
+ const inspection = { root: ".", ...dossier.inspection };
100
+ const components = componentsFor(inspection);
101
+ const evidencePaths = evidencePathsFor(inspection);
102
+ if (JSON.stringify(dossier.components) !== JSON.stringify(components))
103
+ throw new Error("Candidate dossier components do not match inspection evidence");
104
+ if (dossier.installability !== installabilityFor(components))
105
+ throw new Error("Candidate dossier installability does not match inspection evidence");
106
+ if (JSON.stringify(dossier.evidencePaths) !== JSON.stringify(evidencePaths))
107
+ throw new Error("Candidate dossier evidencePaths do not match inspection evidence");
108
+ }
@@ -11,15 +11,10 @@ import { inspectPackage } from "../install/package.js";
11
11
  import { ensureDirectory, loadoutHome } from "../agents/paths.js";
12
12
  import { fetchRepositorySnapshot, normalizeRepository, } from "../install/source.js";
13
13
  import { safeTerminalText } from "../reporting/terminal.js";
14
+ import { componentsFor, evidencePathsFor, installabilityFor, overlapFor, } from "./candidate-intelligence-evidence.js";
15
+ import { assertDossierEvidence, finiteNonnegative, isTextArray, validDate, validPersistedEvaluation, validPersistedInspection, } from "./candidate-intelligence-validation.js";
14
16
  const dossierDirectory = () => join(loadoutHome(), "candidates");
15
17
  const sourceVerifiedDossiers = new WeakMap();
16
- function installabilityFor(components) {
17
- if (components.some((component) => ["skill", "rule", "command", "agent"].includes(component)))
18
- return "portable-components";
19
- if (components.some((component) => component === "mcp" || component === "plugin"))
20
- return "explicit-runtime-setup";
21
- return "unsupported-source-shape";
22
- }
23
18
  function dossierIntegrity(dossier) {
24
19
  return createHash("sha256").update(JSON.stringify(dossier)).digest("hex");
25
20
  }
@@ -28,19 +23,11 @@ function bundledDiscoveryPath() {
28
23
  const candidates = [
29
24
  join(moduleDirectory, "..", "..", "catalog", "discovered.json"),
30
25
  join(moduleDirectory, "..", "..", "..", "catalog", "discovered.json"),
26
+ join(moduleDirectory, "..", "..", "..", "..", "catalog", "discovered.json"),
31
27
  join(process.cwd(), "catalog", "discovered.json"),
32
28
  ];
33
29
  return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
34
30
  }
35
- function isTextArray(value) {
36
- return (Array.isArray(value) && value.every((item) => typeof item === "string"));
37
- }
38
- function finiteNonnegative(value) {
39
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
40
- }
41
- function validDate(value) {
42
- return typeof value === "string" && !Number.isNaN(Date.parse(value));
43
- }
44
31
  function parseDiscovery(value) {
45
32
  if (!value || typeof value !== "object" || Array.isArray(value))
46
33
  throw new Error("Discovery artifact must be an object");
@@ -163,106 +150,6 @@ export async function listDiscoveryCandidates(options = {}) {
163
150
  left.repository.localeCompare(right.repository))
164
151
  .slice(0, options.limit ?? 20);
165
152
  }
166
- function componentsFor(inspection) {
167
- const result = [];
168
- if (inspection.counts.skills)
169
- result.push("skill");
170
- if (inspection.counts.rules)
171
- result.push("rule");
172
- if (inspection.counts.commands)
173
- result.push("command");
174
- if (inspection.counts.agents)
175
- result.push("agent");
176
- if (inspection.plugins.some((plugin) => !plugin.warnings.some((warning) => warning.startsWith("invalid plugin manifest"))))
177
- result.push("plugin");
178
- if (inspection.mcpServers.length)
179
- result.push("mcp");
180
- return result;
181
- }
182
- function evidencePathsFor(inspection) {
183
- return [
184
- ...inspection.skills.map((item) => join(item.path, "SKILL.md")),
185
- ...inspection.resources.map((item) => item.path),
186
- ...inspection.plugins
187
- .filter((item) => !item.warnings.some((warning) => warning.startsWith("invalid plugin manifest")))
188
- .map((item) => item.path),
189
- ...inspection.mcpServers.map((item) => item.path),
190
- ]
191
- .map((path) => path.split(/[\\/]/).join("/"))
192
- .filter((path) => path !== "." && !path.startsWith("../"))
193
- .filter((path, index, paths) => paths.indexOf(path) === index)
194
- .sort();
195
- }
196
- const STOP_WORDS = new Set([
197
- "the",
198
- "and",
199
- "for",
200
- "with",
201
- "from",
202
- "that",
203
- "this",
204
- "your",
205
- "into",
206
- "agent",
207
- "agents",
208
- "skill",
209
- "skills",
210
- "mcp",
211
- "server",
212
- "servers",
213
- "workflow",
214
- "workflows",
215
- "code",
216
- "coding",
217
- "developer",
218
- "development",
219
- "platform",
220
- "context",
221
- "open",
222
- "source",
223
- "using",
224
- "across",
225
- "tool",
226
- "tools",
227
- "github",
228
- ]);
229
- function tokens(...values) {
230
- return new Set(values
231
- .join(" ")
232
- .toLowerCase()
233
- .split(/[^a-z0-9]+/)
234
- .filter((word) => word.length >= 3 && !STOP_WORDS.has(word)));
235
- }
236
- function overlapFor(discovery, inspection, catalog) {
237
- const candidateTokens = tokens(discovery.repository, discovery.description, discovery.topics.join(" "), ...inspection.skills.flatMap((item) => [item.name, item.description]), ...inspection.resources.map((item) => item.name), ...inspection.plugins.flatMap((item) => [item.name, item.description]), ...inspection.mcpServers.map((item) => item.name));
238
- const candidateComponents = new Set(componentsFor(inspection));
239
- return catalog
240
- .map((pkg) => {
241
- const packageTokens = tokens(pkg.id, pkg.displayName, pkg.description, pkg.category, pkg.topics?.join(" "));
242
- const common = [...candidateTokens]
243
- .filter((word) => packageTokens.has(word))
244
- .sort();
245
- // Containment is more useful than union similarity here: a large skill
246
- // collection can overlap a focused package even when most collection
247
- // tokens describe unrelated capabilities.
248
- const smallerVocabulary = Math.min(candidateTokens.size, packageTokens.size);
249
- const score = smallerVocabulary ? common.length / smallerVocabulary : 0;
250
- const componentMatch = pkg.components?.some((component) => candidateComponents.has(component)) ?? false;
251
- return {
252
- packageId: pkg.id,
253
- repository: pkg.repository,
254
- score: Math.round(score * 1000) / 1000,
255
- relationship: componentMatch && common.length >= 2 && score >= 0.2
256
- ? "possible-overlap"
257
- : "same-tooling-area",
258
- evidence: common.slice(0, 8).map((word) => `shared term: ${word}`),
259
- };
260
- })
261
- .filter((item) => item.evidence.length >= 1 && item.score >= 0.1)
262
- .sort((left, right) => right.score - left.score ||
263
- left.packageId.localeCompare(right.packageId))
264
- .slice(0, 5);
265
- }
266
153
  export async function buildCandidateDossier(repository, options = {}) {
267
154
  const normalized = normalizeRepository(repository);
268
155
  const artifact = await readDiscoveryArtifact(options.discoveryPath);
@@ -370,104 +257,6 @@ export async function writeCandidateDossier(dossier, output) {
370
257
  await writeFileAtomically(target, `${JSON.stringify(dossier, null, 2)}\n`);
371
258
  return target;
372
259
  }
373
- function validPersistedEvaluation(value) {
374
- if (!value || typeof value !== "object" || Array.isArray(value))
375
- return false;
376
- const evaluation = value;
377
- const categories = evaluation.categories;
378
- return (evaluation.evaluatorVersion === 1 &&
379
- typeof evaluation.uncertainty === "string" &&
380
- Array.isArray(categories) &&
381
- categories.length === 2 &&
382
- new Set(categories.map((item) => item.category)).size === 2 &&
383
- categories.every((item) => (item.category === "skills" || item.category === "mcp") &&
384
- ["ready", "needs-review", "blocked", "not-applicable"].includes(item.status) &&
385
- isTextArray(item.findings)));
386
- }
387
- function safeEvidencePath(value) {
388
- return (typeof value === "string" &&
389
- Boolean(value) &&
390
- !value.startsWith("/") &&
391
- !value.split(/[\\/]/).includes(".."));
392
- }
393
- function validPersistedInspection(value) {
394
- if (!value || typeof value !== "object" || Array.isArray(value))
395
- return false;
396
- const inspection = value;
397
- const validCount = (count) => typeof count === "number" && Number.isSafeInteger(count) && count >= 0;
398
- const validNamedPath = (item, type) => Boolean(item &&
399
- typeof item === "object" &&
400
- !Array.isArray(item) &&
401
- item.type === type &&
402
- typeof item.name === "string" &&
403
- safeEvidencePath(item.path));
404
- if (!Array.isArray(inspection.skills) ||
405
- !inspection.skills.every((item) => validNamedPath(item, "skill") &&
406
- (item.description === undefined ||
407
- typeof item.description === "string")) ||
408
- !Array.isArray(inspection.resources) ||
409
- !inspection.resources.every((item) => ["rule", "command", "agent"].includes(item.type) &&
410
- validNamedPath(item, item.type)) ||
411
- !Array.isArray(inspection.plugins) ||
412
- !inspection.plugins.every((item) => validNamedPath(item, "plugin") &&
413
- (item.description === undefined ||
414
- typeof item.description === "string") &&
415
- (item.version === undefined || typeof item.version === "string") &&
416
- (item.author === undefined || typeof item.author === "string") &&
417
- Array.isArray(item.components) &&
418
- item.components.every((component) => [
419
- "skill",
420
- "rule",
421
- "command",
422
- "agent",
423
- "mcp",
424
- "plugin",
425
- "root",
426
- ].includes(component)) &&
427
- isTextArray(item.hookEvents) &&
428
- isTextArray(item.mcpServers) &&
429
- isTextArray(item.warnings)) ||
430
- !Array.isArray(inspection.mcpServers) ||
431
- !inspection.mcpServers.every((item) => validNamedPath(item, "mcp") &&
432
- ["command", "url", "unknown"].includes(item.transport) &&
433
- (item.command === undefined || typeof item.command === "string") &&
434
- (item.url === undefined || typeof item.url === "string") &&
435
- validCount(item.argumentCount) &&
436
- validCount(item.environmentVariableCount) &&
437
- isTextArray(item.warnings)) ||
438
- !inspection.counts ||
439
- !validCount(inspection.counts.skills) ||
440
- !validCount(inspection.counts.rules) ||
441
- !validCount(inspection.counts.commands) ||
442
- !validCount(inspection.counts.agents) ||
443
- !validCount(inspection.counts.plugins) ||
444
- !validCount(inspection.counts.mcpServers) ||
445
- !validCount(inspection.counts.manifests) ||
446
- !isTextArray(inspection.warnings))
447
- return false;
448
- return (inspection.counts.skills === inspection.skills.length &&
449
- inspection.counts.rules ===
450
- inspection.resources.filter((item) => item.type === "rule").length &&
451
- inspection.counts.commands ===
452
- inspection.resources.filter((item) => item.type === "command").length &&
453
- inspection.counts.agents ===
454
- inspection.resources.filter((item) => item.type === "agent").length &&
455
- inspection.counts.plugins === inspection.plugins.length &&
456
- inspection.counts.mcpServers === inspection.mcpServers.length);
457
- }
458
- function assertDossierEvidence(dossier) {
459
- if (!validPersistedInspection(dossier.inspection))
460
- throw new Error("Candidate dossier inspection evidence is invalid");
461
- const inspection = { root: ".", ...dossier.inspection };
462
- const components = componentsFor(inspection);
463
- const evidencePaths = evidencePathsFor(inspection);
464
- if (JSON.stringify(dossier.components) !== JSON.stringify(components))
465
- throw new Error("Candidate dossier components do not match inspection evidence");
466
- if (dossier.installability !== installabilityFor(components))
467
- throw new Error("Candidate dossier installability does not match inspection evidence");
468
- if (JSON.stringify(dossier.evidencePaths) !== JSON.stringify(evidencePaths))
469
- throw new Error("Candidate dossier evidencePaths do not match inspection evidence");
470
- }
471
260
  export async function readCandidateDossier(path) {
472
261
  const target = resolve(path);
473
262
  const info = await stat(target);
@@ -1,3 +1,4 @@
1
+ import { boundedJson } from "../runtime/bounded-json.js";
1
2
  const API_BASE = "https://hacker-news.firebaseio.com/v0";
2
3
  const GITHUB_REPOSITORY = /(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/gi;
3
4
  function repositoryFromText(value) {
@@ -50,20 +51,27 @@ export async function discoverHackerNewsRepositories(options = {}) {
50
51
  const keywords = (options.keywords ?? [])
51
52
  .map((keyword) => keyword.trim().toLowerCase())
52
53
  .filter(Boolean);
53
- const idsResponse = await fetcher(`${API_BASE}/topstories.json`);
54
+ const { response: idsResponse, value: ids } = await boundedJson(fetcher, `${API_BASE}/topstories.json`, {}, {
55
+ timeoutMs: 30_000,
56
+ maxBytes: 4 * 1024 * 1024,
57
+ label: "Hacker News",
58
+ });
54
59
  if (!idsResponse.ok)
55
60
  throw new Error(`Hacker News top stories request failed (${idsResponse.status})`);
56
- const ids = (await idsResponse.json());
57
61
  if (!Array.isArray(ids))
58
62
  throw new Error("Hacker News top stories response is invalid");
59
63
  const selectedIds = ids
60
64
  .filter((id) => typeof id === "number" && Number.isInteger(id))
61
65
  .slice(0, limit);
62
66
  const stories = await Promise.all(selectedIds.map(async (id) => {
63
- const response = await fetcher(`${API_BASE}/item/${id}.json`);
67
+ const { response, value } = await boundedJson(fetcher, `${API_BASE}/item/${id}.json`, {}, {
68
+ timeoutMs: 30_000,
69
+ maxBytes: 1024 * 1024,
70
+ label: "Hacker News",
71
+ });
64
72
  if (!response.ok)
65
73
  return undefined;
66
- return (await response.json());
74
+ return value;
67
75
  }));
68
76
  const deduplicated = new Map();
69
77
  for (const story of stories) {
@@ -1,3 +1,4 @@
1
+ import { boundedJson } from "../runtime/bounded-json.js";
1
2
  /** A rolling window prevents the default discovery query from aging in place. */
2
3
  export function defaultGitHubDiscoveryQuery(now = new Date()) {
3
4
  return defaultGitHubDiscoveryQueries(now)[0];
@@ -41,7 +42,11 @@ export async function discoverGitHubRepositories(options) {
41
42
  order: "desc",
42
43
  per_page: String(limit),
43
44
  });
44
- const response = await (options.fetcher ?? fetch)(`https://api.github.com/search/repositories?${params}`, { headers });
45
+ const { response, value } = await boundedJson(options.fetcher ?? fetch, `https://api.github.com/search/repositories?${params}`, { headers }, {
46
+ timeoutMs: 30_000,
47
+ maxBytes: 8 * 1024 * 1024,
48
+ label: "GitHub repository discovery",
49
+ });
45
50
  if (!response.ok) {
46
51
  const remaining = response.headers.get("x-ratelimit-remaining");
47
52
  const reset = response.headers.get("x-ratelimit-reset");
@@ -51,7 +56,6 @@ export async function discoverGitHubRepositories(options) {
51
56
  : undefined;
52
57
  throw new Error(`GitHub repository discovery failed (${response.status})${remaining === "0" ? `; rate limit exhausted${resetAt ? ` until ${resetAt}` : ""}` : retryAfter ? `; retry after ${retryAfter} second(s)` : ""}`);
53
58
  }
54
- const value = await response.json();
55
59
  if (!value || typeof value !== "object")
56
60
  throw new Error("GitHub repository discovery response is invalid");
57
61
  const items = value.items;
@@ -1,3 +1,4 @@
1
+ import { boundedJson } from "../runtime/bounded-json.js";
1
2
  /**
2
3
  * Opt-in private discovery using a caller-provided GitHub token. OAuth/App
3
4
  * brokering remains a deployment concern; this function proves the safe data
@@ -7,16 +8,19 @@ export async function discoverPrivateRepositories(options = {}) {
7
8
  const token = options.token ?? process.env.GITHUB_TOKEN;
8
9
  if (!token)
9
10
  throw new Error("Private discovery requires an explicit GITHUB_TOKEN or token option; public discovery needs no credentials");
10
- const response = await (options.fetcher ?? fetch)("https://api.github.com/user/repos?visibility=private&affiliation=owner,collaborator,organization_member&per_page=100", {
11
+ const { response, value } = await boundedJson(options.fetcher ?? fetch, "https://api.github.com/user/repos?visibility=private&affiliation=owner,collaborator,organization_member&per_page=100", {
11
12
  headers: {
12
13
  accept: "application/vnd.github+json",
13
14
  authorization: `Bearer ${token}`,
14
15
  "user-agent": "loadout-discovery",
15
16
  },
17
+ }, {
18
+ timeoutMs: 30_000,
19
+ maxBytes: 8 * 1024 * 1024,
20
+ label: "Private GitHub discovery",
16
21
  });
17
22
  if (!response.ok)
18
23
  throw new Error(`Private GitHub discovery failed (${response.status})`);
19
- const value = await response.json();
20
24
  if (!Array.isArray(value))
21
25
  throw new Error("Private GitHub discovery response is invalid");
22
26
  return value.flatMap((item) => {
@@ -230,7 +230,7 @@ export async function applyReconcilePlan(plan, options = {}) {
230
230
  : [];
231
231
  const selected = [...exact, ...outdated];
232
232
  if (!selected.length)
233
- throw new Error("No exact or explicitly selected outdated skills to reconcile");
233
+ return { snapshotId: null, adopted: 0, updated: 0 };
234
234
  const risky = outdated.filter((item) => item.approvalRequired);
235
235
  if (risky.length && !options.approveRisk)
236
236
  throw new Error(`Replacing ${risky.length} outdated skill group(s) requires --approve-risk after reviewing the reported findings.`);