loadout-ai 0.7.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.
- package/CHANGELOG.md +148 -1
- package/README.md +160 -315
- package/SECURITY.md +21 -1
- package/catalog/discovered.json +31269 -26003
- package/catalog/packages.json +4 -4
- package/dist/src/cli.js +13 -0
- package/dist/src/commands/agents.js +3 -1
- package/dist/src/commands/catalog-candidate.js +150 -0
- package/dist/src/commands/catalog-workflows.js +147 -0
- package/dist/src/commands/catalog.js +9 -367
- package/dist/src/commands/coordinate.js +586 -0
- package/dist/src/commands/coordination-discussions.js +194 -0
- package/dist/src/commands/coordination-sessions.js +197 -0
- package/dist/src/commands/inventory.js +4 -2
- package/dist/src/core/agents/agent-inspection.js +26 -4
- package/dist/src/core/{routing → agents}/model-config.js +7 -2
- package/dist/src/core/catalog/catalog.js +1 -0
- package/dist/src/core/catalog/registry.js +44 -10
- package/dist/src/core/catalog/safety.js +36 -7
- package/dist/src/core/coordination/adapters/claude-code.js +154 -0
- package/dist/src/core/coordination/adapters/codex.js +137 -0
- package/dist/src/core/coordination/adapters/types.js +64 -0
- package/dist/src/core/coordination/auth.js +59 -0
- package/dist/src/core/coordination/bridge-lease.js +79 -0
- package/dist/src/core/coordination/conflict-preview.js +167 -0
- package/dist/src/core/coordination/contract-diff.js +106 -0
- package/dist/src/core/coordination/coordinator.js +552 -0
- package/dist/src/core/coordination/crash-recovery.js +169 -0
- package/dist/src/core/coordination/daemon.js +667 -0
- package/dist/src/core/coordination/discussion.js +340 -0
- package/dist/src/core/coordination/events.js +161 -0
- package/dist/src/core/coordination/http-api.js +118 -0
- package/dist/src/core/coordination/interrupt-policy.js +89 -0
- package/dist/src/core/coordination/lock.js +110 -0
- package/dist/src/core/coordination/mcp-server.js +424 -0
- package/dist/src/core/coordination/redaction.js +85 -0
- package/dist/src/core/coordination/replay.js +188 -0
- package/dist/src/core/coordination/retention.js +128 -0
- package/dist/src/core/coordination/runtime.js +21 -0
- package/dist/src/core/coordination/session-manager.js +366 -0
- package/dist/src/core/coordination/watcher.js +128 -0
- package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
- package/dist/src/core/{routing → delegation}/handoff.js +130 -63
- package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
- package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence.js +3 -214
- package/dist/src/core/discovery/community.js +12 -4
- package/dist/src/core/discovery/github-discovery.js +6 -2
- package/dist/src/core/discovery/private-discovery.js +6 -2
- package/dist/src/core/install/reconcile.js +1 -1
- package/dist/src/core/install/source.js +21 -7
- package/dist/src/core/install/uninstall.js +39 -3
- package/dist/src/core/reporting/cli-guide.js +10 -6
- package/dist/src/core/reporting/completion.js +61 -109
- package/dist/src/core/reporting/doctor.js +4 -6
- package/dist/src/core/runtime/bounded-json.js +65 -0
- package/dist/src/core/runtime/github.js +30 -16
- package/dist/src/core/runtime/mcp-recipes.js +1 -1
- package/dist/src/core/workspace/active-policy.js +1 -1
- package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
- package/docs/CATALOG.md +1 -1
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
- package/docs/DEMO_SCRIPT.md +19 -23
- package/docs/DISCOVERED.md +251 -250
- package/docs/FEATURE_TEST_MATRIX.md +10 -263
- package/docs/GITHUB_AUTHORIZATION.md +5 -0
- package/docs/LIVE_COLLABORATION.md +234 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
- package/docs/REFERENCE.md +163 -0
- package/docs/RELEASE_REVIEW.md +1 -2
- package/docs/TESTING.md +17 -1
- package/docs/USER_TEST_GUIDE.md +138 -3
- package/docs/assets/loadout-discover-activate.webp +0 -0
- package/docs/assets/loadout-handoff-coordinate.webp +0 -0
- package/docs/assets/loadout-social-preview.png +0 -0
- package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
- package/docs/decisions/002-local-daemon-authentication.md +32 -0
- package/docs/decisions/003-bounded-agent-discussions.md +91 -0
- package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
- package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
- package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
- package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
- package/package.json +18 -7
- package/skills/loadout-handoff/SKILL.md +238 -0
- package/MASTER_PLAN.md +0 -2207
- package/dist/src/core/routing/route.js +0 -539
- package/docs/ACTIVE_SET.md +0 -53
- package/docs/COMPATIBILITY_POLICY.md +0 -22
- package/docs/CONVERSION_AND_SANDBOX.md +0 -27
- package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
- package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
- package/docs/PROVIDER_CONFIGURATION.md +0 -45
- package/docs/README_RESEARCH.md +0 -36
- package/docs/REPOSITORY_STABILIZATION.md +0 -190
- package/docs/SAFE_UPDATE_DEMO.md +0 -25
- package/docs/SCHEMA_DECISIONS.md +0 -25
- package/docs/SUBMISSION_COPY.md +0 -90
- package/docs/TEAM_POLICY.md +0 -18
- package/docs/assets/loadout-workflow.png +0 -0
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
- package/skills/loadout-router/SKILL.md +0 -120
- /package/dist/src/core/{routing → agents}/credentials.js +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.`);
|
|
@@ -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,
|
|
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;
|
|
@@ -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
|
|
20
|
-
loadout
|
|
21
|
-
loadout handoff send codex "..." give a task to your other agent
|
|
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-
|
|
24
|
+
loadout skills install loadout-handoff --yes
|
|
26
25
|
|
|
27
|
-
Then
|
|
28
|
-
|
|
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
|
]);
|