loadout-ai 0.2.3 → 0.4.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 +71 -0
- package/MASTER_PLAN.md +141 -33
- package/README.md +160 -255
- package/dashboard/app.js +4 -4
- package/dashboard/index.html +4 -4
- package/dist/src/cli.js +189 -32
- package/dist/src/core/active-set.js +37 -2
- package/dist/src/core/adapters.js +10 -0
- package/dist/src/core/adopt.js +165 -32
- package/dist/src/core/agent-health-score.js +2 -2
- package/dist/src/core/catalog-coverage.js +2 -1
- package/dist/src/core/catalog-install.js +8 -1
- package/dist/src/core/catalog-release.js +2 -1
- package/dist/src/core/cli-guide.js +101 -0
- package/dist/src/core/completion.js +3 -0
- package/dist/src/core/conformance.js +74 -0
- package/dist/src/core/install.js +36 -3
- package/dist/src/core/mcp-recipes.js +21 -0
- package/dist/src/core/profile-state.js +101 -0
- package/dist/src/core/profiles.js +9 -4
- package/dist/src/core/ranking.js +1 -1
- package/dist/src/core/readme-claims.js +10 -0
- package/dist/src/core/readme-facts.js +40 -0
- package/dist/src/core/recommend.js +9 -3
- package/dist/src/core/remove.js +6 -7
- package/dist/src/core/runtime-tools.js +10 -2
- package/dist/src/core/scheduler.js +4 -3
- package/dist/src/core/snapshot.js +58 -13
- package/dist/src/core/state.js +13 -3
- package/dist/src/core/transaction.js +2 -1
- package/dist/src/core/uninstall.js +133 -0
- package/dist/src/core/update.js +87 -58
- package/dist/src/dashboard.js +5 -2
- package/dist/src/shared/schemas.js +69 -0
- package/docs/FEATURE_TEST_MATRIX.md +16 -0
- package/docs/README_RESEARCH.md +36 -0
- package/docs/RELEASE_REVIEW.md +31 -5
- package/docs/REPOSITORY_STABILIZATION.md +190 -0
- package/docs/TESTING.md +75 -2
- package/docs/USER_TEST_GUIDE.md +202 -0
- package/docs/assets/loadout-hero.svg +259 -0
- package/docs/assets/loadout-mark.svg +54 -0
- package/docs/evidence/live-checks-2026-07-19.json +22 -0
- package/docs/evidence/live-checks.schema.json +28 -0
- package/docs/evidence/readme-claims.json +286 -0
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
- package/package.json +8 -4
- package/SIMPLE_PLAN.md +0 -44
package/dist/src/core/adopt.js
CHANGED
|
@@ -1,10 +1,125 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { basename, join } from "node:path";
|
|
2
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join, posix, relative, sep } from "node:path";
|
|
4
4
|
import { enrichInventoryWithProvenance } from "./provenance.js";
|
|
5
5
|
import { scanInstalledSkills } from "./skill-inventory.js";
|
|
6
6
|
import { installStatePath, recordInstall } from "./state.js";
|
|
7
7
|
import { runMutationTransaction } from "./transaction.js";
|
|
8
|
+
const issuedAdoptionPlans = new WeakMap();
|
|
9
|
+
function safeRelativePath(root, path) {
|
|
10
|
+
const value = relative(root, path).split(sep).join("/");
|
|
11
|
+
if (!value || isAbsolute(value) || value === ".." || value.startsWith("../"))
|
|
12
|
+
throw new Error(`Unsafe path while inspecting adoption tree: ${path}`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
async function captureAdoptionTree(root) {
|
|
16
|
+
const rootInfo = await lstat(root);
|
|
17
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())
|
|
18
|
+
throw new Error(`Refusing unsafe adoption root: ${root}`);
|
|
19
|
+
const entries = [];
|
|
20
|
+
async function visit(directory) {
|
|
21
|
+
const children = (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
22
|
+
for (const child of children) {
|
|
23
|
+
const path = join(directory, child.name);
|
|
24
|
+
const relativePath = safeRelativePath(root, path);
|
|
25
|
+
const info = await lstat(path);
|
|
26
|
+
if (info.isSymbolicLink())
|
|
27
|
+
throw new Error(`Refusing symlink while inspecting adoption tree: ${path}`);
|
|
28
|
+
if (info.isDirectory()) {
|
|
29
|
+
entries.push({ path: relativePath, type: "directory" });
|
|
30
|
+
await visit(path);
|
|
31
|
+
}
|
|
32
|
+
else if (info.isFile()) {
|
|
33
|
+
entries.push({
|
|
34
|
+
path: relativePath,
|
|
35
|
+
type: "file",
|
|
36
|
+
sha256: createHash("sha256")
|
|
37
|
+
.update(await readFile(path))
|
|
38
|
+
.digest("hex"),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
throw new Error(`Refusing special file while inspecting adoption tree: ${path}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await visit(root);
|
|
47
|
+
return entries;
|
|
48
|
+
}
|
|
49
|
+
function validateTreeEvidence(entries) {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!entry.path ||
|
|
53
|
+
isAbsolute(entry.path) ||
|
|
54
|
+
entry.path === ".." ||
|
|
55
|
+
entry.path.startsWith("../") ||
|
|
56
|
+
entry.path.includes("\\") ||
|
|
57
|
+
entry.path.split("/").includes("..") ||
|
|
58
|
+
posix.normalize(entry.path) !== entry.path ||
|
|
59
|
+
(entry.type === "file" && !/^[a-f0-9]{64}$/.test(entry.sha256 ?? "")) ||
|
|
60
|
+
(entry.type === "directory" && entry.sha256 !== undefined) ||
|
|
61
|
+
(entry.type !== "file" && entry.type !== "directory") ||
|
|
62
|
+
seen.has(entry.path))
|
|
63
|
+
throw new Error("The adoption preview contains unsafe tree evidence; preview again.");
|
|
64
|
+
seen.add(entry.path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function adoptionWarnings(exact, reviewed) {
|
|
68
|
+
if (reviewed)
|
|
69
|
+
return [];
|
|
70
|
+
if (exact)
|
|
71
|
+
return [
|
|
72
|
+
"SKILL.md matches the reviewed catalog, but auxiliary local entries are not covered by catalog evidence; adoption remains unreviewed.",
|
|
73
|
+
];
|
|
74
|
+
return [
|
|
75
|
+
"The installed bytes do not exactly match the reviewed catalog; adoption records ownership but does not mark them reviewed.",
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
function derivedReviewState(plan) {
|
|
79
|
+
const exact = plan.provenance.kind === "catalog-exact";
|
|
80
|
+
const tree = plan.treeEvidence ?? [];
|
|
81
|
+
return {
|
|
82
|
+
exact,
|
|
83
|
+
reviewed: exact &&
|
|
84
|
+
tree.length === 1 &&
|
|
85
|
+
tree[0].type === "file" &&
|
|
86
|
+
tree[0].path === "SKILL.md",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function deepFreeze(value) {
|
|
90
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
91
|
+
for (const child of Object.values(value))
|
|
92
|
+
deepFreeze(child);
|
|
93
|
+
Object.freeze(value);
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
function canonicalInstallPlan(packageId, agent, name, path, warnings) {
|
|
98
|
+
return {
|
|
99
|
+
packageId,
|
|
100
|
+
targetAgents: [agent.id],
|
|
101
|
+
warnings,
|
|
102
|
+
files: [
|
|
103
|
+
{
|
|
104
|
+
source: path,
|
|
105
|
+
target: path,
|
|
106
|
+
targetAgent: agent.id,
|
|
107
|
+
componentType: "skill",
|
|
108
|
+
compatibility: "native",
|
|
109
|
+
skillName: name,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function expectedRecordedFiles(plan) {
|
|
115
|
+
return plan.treeEvidence
|
|
116
|
+
.filter((entry) => entry.type === "file" && Boolean(entry.sha256))
|
|
117
|
+
.map((entry) => ({
|
|
118
|
+
path: join(plan.path, entry.path),
|
|
119
|
+
sha256: entry.sha256,
|
|
120
|
+
}))
|
|
121
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
122
|
+
}
|
|
8
123
|
function slug(value) {
|
|
9
124
|
return (value
|
|
10
125
|
.toLowerCase()
|
|
@@ -29,64 +144,82 @@ export async function planSkillAdoption(skill, agent, index) {
|
|
|
29
144
|
? match.provenance.candidates[0]
|
|
30
145
|
: undefined;
|
|
31
146
|
const packageId = `adopted-${agent.id}-${slug(match.name)}`;
|
|
32
|
-
|
|
147
|
+
const treeEvidence = await captureAdoptionTree(match.path);
|
|
148
|
+
const reviewed = Boolean(exact) &&
|
|
149
|
+
treeEvidence.length === 1 &&
|
|
150
|
+
treeEvidence[0].type === "file" &&
|
|
151
|
+
treeEvidence[0].path === "SKILL.md";
|
|
152
|
+
const warnings = adoptionWarnings(Boolean(exact), reviewed);
|
|
153
|
+
const plan = {
|
|
33
154
|
packageId,
|
|
34
|
-
agent,
|
|
155
|
+
agent: { ...agent },
|
|
35
156
|
name: match.name,
|
|
36
157
|
path: match.path,
|
|
37
158
|
fingerprint: match.fingerprint,
|
|
38
|
-
|
|
39
|
-
|
|
159
|
+
treeEvidence,
|
|
160
|
+
provenance: {
|
|
161
|
+
...match.provenance,
|
|
162
|
+
evidence: [...match.provenance.evidence],
|
|
163
|
+
candidates: match.provenance.candidates.map((candidate) => ({
|
|
164
|
+
...candidate,
|
|
165
|
+
})),
|
|
166
|
+
},
|
|
167
|
+
reviewed,
|
|
40
168
|
...(exact
|
|
41
169
|
? { repository: exact.repository, resolvedCommit: exact.commit }
|
|
42
170
|
: {}),
|
|
43
|
-
installPlan:
|
|
44
|
-
packageId,
|
|
45
|
-
targetAgents: [agent.id],
|
|
46
|
-
warnings: exact
|
|
47
|
-
? []
|
|
48
|
-
: [
|
|
49
|
-
"The installed bytes do not exactly match the reviewed catalog; adoption records ownership but does not mark them reviewed.",
|
|
50
|
-
],
|
|
51
|
-
files: [
|
|
52
|
-
{
|
|
53
|
-
source: match.path,
|
|
54
|
-
target: match.path,
|
|
55
|
-
targetAgent: agent.id,
|
|
56
|
-
componentType: "skill",
|
|
57
|
-
compatibility: "native",
|
|
58
|
-
skillName: match.name,
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
},
|
|
171
|
+
installPlan: canonicalInstallPlan(packageId, agent, match.name, match.path, warnings),
|
|
62
172
|
};
|
|
173
|
+
deepFreeze(plan);
|
|
174
|
+
issuedAdoptionPlans.set(plan, deepFreeze({
|
|
175
|
+
packageId: plan.packageId,
|
|
176
|
+
path: plan.path,
|
|
177
|
+
treeEvidence: plan.treeEvidence,
|
|
178
|
+
installPlan: plan.installPlan,
|
|
179
|
+
...(plan.repository ? { repository: plan.repository } : {}),
|
|
180
|
+
...(plan.resolvedCommit ? { resolvedCommit: plan.resolvedCommit } : {}),
|
|
181
|
+
reviewed: derivedReviewState(plan).reviewed,
|
|
182
|
+
}));
|
|
183
|
+
return plan;
|
|
63
184
|
}
|
|
64
|
-
export async function applySkillAdoption(plan) {
|
|
185
|
+
export async function applySkillAdoption(plan, options = {}) {
|
|
186
|
+
const issued = issuedAdoptionPlans.get(plan);
|
|
187
|
+
if (!issued)
|
|
188
|
+
throw new Error("The adoption plan was not issued by this planner process; preview again.");
|
|
65
189
|
const applied = await runMutationTransaction(async () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (current !== plan.fingerprint)
|
|
190
|
+
validateTreeEvidence(issued.treeEvidence);
|
|
191
|
+
const current = await captureAdoptionTree(issued.path);
|
|
192
|
+
if (JSON.stringify(current) !== JSON.stringify(issued.treeEvidence))
|
|
70
193
|
throw new Error("The skill changed after preview; scan again before adopting it.");
|
|
71
|
-
return { targets: [installStatePath()], value:
|
|
194
|
+
return { targets: [installStatePath()], value: issued };
|
|
72
195
|
}, async (freshPlan, snapshot) => {
|
|
196
|
+
await options.beforeRecord?.();
|
|
73
197
|
await recordInstall(freshPlan.installPlan, snapshot.id, {
|
|
74
198
|
...(freshPlan.repository ? { repository: freshPlan.repository } : {}),
|
|
75
199
|
...(freshPlan.resolvedCommit
|
|
76
200
|
? { resolvedCommit: freshPlan.resolvedCommit }
|
|
77
201
|
: {}),
|
|
78
202
|
reviewed: freshPlan.reviewed,
|
|
203
|
+
}, {
|
|
204
|
+
expectedFiles: expectedRecordedFiles(freshPlan),
|
|
205
|
+
verifyBeforeWrite: async () => {
|
|
206
|
+
const current = await captureAdoptionTree(freshPlan.path);
|
|
207
|
+
if (JSON.stringify(current) !== JSON.stringify(freshPlan.treeEvidence))
|
|
208
|
+
throw new Error("The skill changed after preview; scan again before adopting it.");
|
|
209
|
+
},
|
|
79
210
|
});
|
|
80
211
|
});
|
|
81
212
|
return applied.snapshotId;
|
|
82
213
|
}
|
|
83
214
|
export function formatAdoptionPlan(plan) {
|
|
215
|
+
const review = derivedReviewState(plan);
|
|
84
216
|
return [
|
|
85
217
|
`Adopt: ${plan.name} for ${plan.agent.displayName}`,
|
|
86
218
|
`Path: ${plan.path}`,
|
|
87
219
|
`Managed id: ${plan.packageId}`,
|
|
88
220
|
`Provenance: ${plan.provenance.kind} (${plan.provenance.confidence})`,
|
|
89
|
-
`Review state: ${
|
|
221
|
+
`Review state: ${review.reviewed ? "reviewed exact catalog match" : "unreviewed"}`,
|
|
222
|
+
`Bound tree entries: ${plan.treeEvidence?.length ?? 0}`,
|
|
90
223
|
...plan.installPlan.warnings.map((warning) => `Warning: ${warning}`),
|
|
91
224
|
].join("\n");
|
|
92
225
|
}
|
|
@@ -248,7 +248,7 @@ function activeSetCapacity(evidence) {
|
|
|
248
248
|
`Disable or consolidate at least ${active - capacity} active skill(s), prioritizing duplicates and weak evidence.`,
|
|
249
249
|
]
|
|
250
250
|
: [
|
|
251
|
-
"Keep nonessential packages in the
|
|
251
|
+
"Keep nonessential packages in the inspected library and re-check capacity after activation changes.",
|
|
252
252
|
]);
|
|
253
253
|
}
|
|
254
254
|
function compatibility(evidence) {
|
|
@@ -464,7 +464,7 @@ export function buildAgentHealthScore(evidence) {
|
|
|
464
464
|
}
|
|
465
465
|
export function formatAgentHealthScore(score) {
|
|
466
466
|
const lines = [
|
|
467
|
-
`
|
|
467
|
+
`Evidence coverage and managed-state hygiene: ${score.score}/${score.maximumScore} (${score.rating}; evidence coverage ${score.evidenceCoverage}%)`,
|
|
468
468
|
`Agent: ${score.agent}${score.asOf ? ` · observed ${score.asOf}` : ""}`,
|
|
469
469
|
];
|
|
470
470
|
for (const item of score.dimensions) {
|
|
@@ -42,6 +42,7 @@ export function buildCatalogCoverage(catalog, targetRecords = 50) {
|
|
|
42
42
|
const platforms = tally(catalog.flatMap((pkg) => pkg.operatingSystems ?? []), operatingSystems);
|
|
43
43
|
return {
|
|
44
44
|
records: catalog.length,
|
|
45
|
+
categoryCount: Object.keys(categories).length,
|
|
45
46
|
targetRecords,
|
|
46
47
|
technicallyScreenedRecords: catalog.filter(technicallyScreened).length,
|
|
47
48
|
recommendedRecords: catalog.filter((pkg) => catalogTrustStage(pkg) === "recommended").length,
|
|
@@ -76,7 +77,7 @@ export function formatCatalogCoverage(report) {
|
|
|
76
77
|
.join(", ");
|
|
77
78
|
return [
|
|
78
79
|
`Screened catalog: ${report.technicallyScreenedRecords}/${report.records} technically complete records (target ${report.targetRecords})`,
|
|
79
|
-
`
|
|
80
|
+
`Policy selection: ${report.recommendedRecords} Stable sources · ${report.trustStages["human-reviewed"]} human-reviewed · ${report.trustStages.benchmarked} benchmarked`,
|
|
80
81
|
`Evidence: ${report.immutablePins} immutable pins · ${report.assertedLicenses} asserted licenses · ${report.noAssertionLicenses} NOASSERTION`,
|
|
81
82
|
`Coverage: ${Object.values(report.categories).length} categories · ${report.evaluationReady} evaluation-ready · ${report.activityObserved} with refreshed activity`,
|
|
82
83
|
`Install shape: ${report.installShapes.skills} skill · ${report.installShapes.mcpOnly} MCP-only · ${report.installShapes.mixed} mixed`,
|
|
@@ -5,6 +5,7 @@ import { applySkillLibraryBatch, applySkillInstallBatch, buildSkillPlan, install
|
|
|
5
5
|
import { fetchRepositorySnapshot, } from "./source.js";
|
|
6
6
|
import { analyzeInstallPlanSafety, } from "./safety.js";
|
|
7
7
|
import { formatModelApiAccess } from "./access.js";
|
|
8
|
+
import { recordInstalledProfile } from "./profile-state.js";
|
|
8
9
|
export const RECOMMENDED_ACTIVE_SKILL_LIMIT = 30;
|
|
9
10
|
async function parallelMap(values, concurrency, worker) {
|
|
10
11
|
const results = new Array(values.length);
|
|
@@ -244,12 +245,18 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
|
|
|
244
245
|
if (risky.length && !options.approveRisk)
|
|
245
246
|
throw new Error(`Additional risk approval is required for: ${risky.map((entry) => entry.package.id).join(", ")}. Review the plan, then use --approve-risk.`);
|
|
246
247
|
return prepared.selection.mode === "maximum"
|
|
247
|
-
? applySkillLibraryBatch(prepared.entries
|
|
248
|
+
? applySkillLibraryBatch(prepared.entries, {
|
|
249
|
+
afterRecord: () => recordInstalledProfile(prepared).then(() => undefined),
|
|
250
|
+
})
|
|
248
251
|
: applySkillInstallBatch(prepared.entries, [], {
|
|
249
252
|
replaceManagedTargets: true,
|
|
250
253
|
reconcileManagedTargets: true,
|
|
251
254
|
...(prepared.reconciliation
|
|
252
255
|
? { expectedReconciliation: prepared.reconciliation }
|
|
253
256
|
: {}),
|
|
257
|
+
afterRecord: () => recordInstalledProfile(prepared).then(() => undefined),
|
|
254
258
|
});
|
|
255
259
|
}
|
|
260
|
+
export function formatCatalogApplyGuidance(riskApprovalRequired) {
|
|
261
|
+
return `Preview complete; nothing was changed. Re-run with --yes${riskApprovalRequired ? " --approve-risk" : ""} to install this exact screened plan.`;
|
|
262
|
+
}
|
|
@@ -7,7 +7,7 @@ import { catalogTrustPath, loadEffectiveCatalog, readCatalogTrustState, readTrus
|
|
|
7
7
|
import { ensureDirectory } from "./paths.js";
|
|
8
8
|
import { loadoutHome } from "./paths.js";
|
|
9
9
|
import { withFileLock } from "./file-lock.js";
|
|
10
|
-
import { createSnapshot } from "./snapshot.js";
|
|
10
|
+
import { createSnapshot, recordSnapshotPostMutationState } from "./snapshot.js";
|
|
11
11
|
import { verifyEnvelope } from "./signing.js";
|
|
12
12
|
import { beginTransaction, completeTransaction, markTransactionCommitting, rollbackTransaction, recoverPendingTransactions, } from "./transaction.js";
|
|
13
13
|
const MAX_RELEASE_BYTES = 5 * 1024 * 1024;
|
|
@@ -248,6 +248,7 @@ export async function applyCatalogRelease(preview, options = {}) {
|
|
|
248
248
|
}, null, 2)}\n`);
|
|
249
249
|
await markTransactionCommitting(transaction);
|
|
250
250
|
await writeFileAtomically(target, `${JSON.stringify(state, null, 2)}\n`);
|
|
251
|
+
await recordSnapshotPostMutationState(snapshot);
|
|
251
252
|
await completeTransaction(transaction, { releaseLock: false });
|
|
252
253
|
await writeFileAtomically(catalogTrustPath(), `${JSON.stringify({
|
|
253
254
|
...pinned,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Plain-language entry points for people using the CLI, not maintaining it. */
|
|
2
|
+
export const BEGINNER_GUIDE = `
|
|
3
|
+
START HERE
|
|
4
|
+
|
|
5
|
+
1. See what Loadout currently manages
|
|
6
|
+
loadout library
|
|
7
|
+
|
|
8
|
+
2. Preview the recommended everyday setup
|
|
9
|
+
loadout setup --mode stable
|
|
10
|
+
|
|
11
|
+
3. Find additions that fit the project in this folder
|
|
12
|
+
loadout recommend --project .
|
|
13
|
+
loadout optimize --project .
|
|
14
|
+
|
|
15
|
+
4. Check for safer updates and new discoveries
|
|
16
|
+
loadout health
|
|
17
|
+
loadout alerts
|
|
18
|
+
loadout candidate list --limit 10
|
|
19
|
+
|
|
20
|
+
5. If you want a visual view, start the private local dashboard
|
|
21
|
+
loadout dashboard
|
|
22
|
+
|
|
23
|
+
If you decide to install something, Loadout shows a preview first and creates a
|
|
24
|
+
snapshot before changing managed files. Recover with: loadout rollback
|
|
25
|
+
|
|
26
|
+
Nothing above changes your agents. For the full maintainer/tooling surface, run:
|
|
27
|
+
loadout advanced
|
|
28
|
+
`.trim();
|
|
29
|
+
export const ADVANCED_GUIDE = [
|
|
30
|
+
"ADVANCED COMMANDS",
|
|
31
|
+
"",
|
|
32
|
+
"These remain available, but are hidden from the first screen so daily use stays simple.",
|
|
33
|
+
"",
|
|
34
|
+
"Discovery and evidence: candidate, discover, review-queue, intelligence, compatibility, benchmark.",
|
|
35
|
+
"Packages and sharing: init, add, sync, lock, export, import, audit, create, pack, publish, registry-serve.",
|
|
36
|
+
"Integrations and safety: mcp-recipe, mcp-config, codex-mcp-config, credentials, models, sandbox-run, canary.",
|
|
37
|
+
"Automation and release: watch, schedule, unschedule, catalog-sign, catalog-verify, catalog-update, claims.",
|
|
38
|
+
"",
|
|
39
|
+
"Use `loadout <command> --help` for exact options. Every mutation-capable command previews first or requires --yes.",
|
|
40
|
+
].join("\n");
|
|
41
|
+
/** Commands retained for specialist workflows but omitted from beginner help. */
|
|
42
|
+
export const HIDDEN_FROM_FIRST_SCREEN = new Set([
|
|
43
|
+
"init",
|
|
44
|
+
"lock",
|
|
45
|
+
"export",
|
|
46
|
+
"import",
|
|
47
|
+
"audit",
|
|
48
|
+
"create",
|
|
49
|
+
"pack",
|
|
50
|
+
"publish",
|
|
51
|
+
"registry-serve",
|
|
52
|
+
"search",
|
|
53
|
+
"report",
|
|
54
|
+
"outcomes",
|
|
55
|
+
"outcome",
|
|
56
|
+
"share",
|
|
57
|
+
"card",
|
|
58
|
+
"compare-loadouts",
|
|
59
|
+
"badge",
|
|
60
|
+
"claims",
|
|
61
|
+
"alert-ignore",
|
|
62
|
+
"alert-pin",
|
|
63
|
+
"alert-unpin",
|
|
64
|
+
"alert-pins",
|
|
65
|
+
"improve",
|
|
66
|
+
"improve-feedback",
|
|
67
|
+
"adopt",
|
|
68
|
+
"intelligence",
|
|
69
|
+
"compatibility",
|
|
70
|
+
"skill-audit",
|
|
71
|
+
"interop",
|
|
72
|
+
"benchmark",
|
|
73
|
+
"capabilities",
|
|
74
|
+
"candidate",
|
|
75
|
+
"catalog-update",
|
|
76
|
+
"discover",
|
|
77
|
+
"review-queue",
|
|
78
|
+
"review",
|
|
79
|
+
"credentials",
|
|
80
|
+
"models",
|
|
81
|
+
"keygen",
|
|
82
|
+
"catalog-sign",
|
|
83
|
+
"catalog-verify",
|
|
84
|
+
"completion",
|
|
85
|
+
"mcp-recipe",
|
|
86
|
+
"mcp",
|
|
87
|
+
"inspect",
|
|
88
|
+
"evaluate",
|
|
89
|
+
"head-to-head",
|
|
90
|
+
"watch",
|
|
91
|
+
"schedule",
|
|
92
|
+
"unschedule",
|
|
93
|
+
"sandbox-run",
|
|
94
|
+
"mcp-config",
|
|
95
|
+
"codex-mcp-config",
|
|
96
|
+
"plan",
|
|
97
|
+
"install",
|
|
98
|
+
"convert",
|
|
99
|
+
"canary",
|
|
100
|
+
"serve",
|
|
101
|
+
]);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ADAPTER_CAPABILITIES, agentComponentDirectory } from "./adapters.js";
|
|
2
|
+
import { AGENT_DEFINITIONS, agentSkillsDirectory } from "./paths.js";
|
|
3
|
+
const PLATFORM_BY_RUNNER = {
|
|
4
|
+
"ubuntu-latest": "linux",
|
|
5
|
+
"macos-latest": "macos",
|
|
6
|
+
"windows-latest": "windows",
|
|
7
|
+
};
|
|
8
|
+
/** Derive bounded platform evidence from the manually triggered CI job. */
|
|
9
|
+
export function platformEvidenceFromCiWorkflow(workflow) {
|
|
10
|
+
const jobStart = workflow.search(/^ {2}cross-platform:\s*$/m);
|
|
11
|
+
const dispatchConfigured = /^ {2}workflow_dispatch:\s*$/m.test(workflow);
|
|
12
|
+
if (jobStart < 0 || !dispatchConfigured)
|
|
13
|
+
throw new Error("The cross-platform CI job and workflow_dispatch trigger are required before platform evidence can be claimed.");
|
|
14
|
+
const afterStart = workflow.slice(jobStart + 1);
|
|
15
|
+
const nextJob = afterStart.search(/^ {2}[a-zA-Z0-9_-]+:\s*$/m);
|
|
16
|
+
const job = nextJob < 0 ? afterStart : afterStart.slice(0, nextJob);
|
|
17
|
+
if (!/if:\s*github\.event_name\s*==\s*['"]workflow_dispatch['"]/.test(job))
|
|
18
|
+
throw new Error("The cross-platform CI job must remain explicitly bounded to workflow_dispatch.");
|
|
19
|
+
const match = job.match(/^\s+os:\s*\[([^\]]+)\]\s*$/m);
|
|
20
|
+
if (!match)
|
|
21
|
+
throw new Error("The cross-platform CI job has no explicit OS matrix.");
|
|
22
|
+
const runners = match[1].split(",").map((value) => value.trim());
|
|
23
|
+
return runners.map((runner) => {
|
|
24
|
+
const platform = PLATFORM_BY_RUNNER[runner];
|
|
25
|
+
if (!platform)
|
|
26
|
+
throw new Error(`The cross-platform CI job uses an unrecognized runner '${runner}'.`);
|
|
27
|
+
return {
|
|
28
|
+
platform,
|
|
29
|
+
kind: "ci-configured",
|
|
30
|
+
source: ".github/workflows/ci.yml (cross-platform job)",
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function declaredAgents() {
|
|
35
|
+
return AGENT_DEFINITIONS.map((definition) => ({
|
|
36
|
+
id: definition.id,
|
|
37
|
+
displayName: definition.displayName,
|
|
38
|
+
installed: false,
|
|
39
|
+
skillsDirectory: agentSkillsDirectory(definition.id),
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build conservative adapter evidence. Merely constructing this matrix does
|
|
44
|
+
* not prove a filesystem run or execution inside an agent application.
|
|
45
|
+
*/
|
|
46
|
+
export function buildAdapterConformanceMatrix(agents = declaredAgents(), platformEvidence = []) {
|
|
47
|
+
const detected = new Map(agents.map((agent) => [agent.id, agent]));
|
|
48
|
+
return ADAPTER_CAPABILITIES.map((adapter) => {
|
|
49
|
+
const agent = detected.get(adapter.agent);
|
|
50
|
+
return {
|
|
51
|
+
agent: adapter.agent,
|
|
52
|
+
displayName: adapter.displayName,
|
|
53
|
+
pathKnown: Boolean(agent) &&
|
|
54
|
+
Boolean(agentComponentDirectory(agent, "skill")) &&
|
|
55
|
+
adapter.components.skill === "native",
|
|
56
|
+
filesystemVerified: false,
|
|
57
|
+
nativeApplicationVerified: false,
|
|
58
|
+
platformEvidence: platformEvidence.map((item) => ({
|
|
59
|
+
...item,
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Promote only the filesystem evidence after the caller has completed the
|
|
66
|
+
* disposable plan/apply/inspect/disable/enable/rollback lifecycle.
|
|
67
|
+
*/
|
|
68
|
+
export function markFilesystemConformanceVerified(matrix, agent) {
|
|
69
|
+
if (!matrix.some((entry) => entry.agent === agent && entry.pathKnown))
|
|
70
|
+
throw new Error(`Cannot verify filesystem conformance for '${agent}' without a known native skill path.`);
|
|
71
|
+
return matrix.map((entry) => entry.agent === agent
|
|
72
|
+
? { ...entry, filesystemVerified: true }
|
|
73
|
+
: { ...entry });
|
|
74
|
+
}
|
package/dist/src/core/install.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { cp, lstat, rm } from "node:fs/promises";
|
|
2
|
+
import { cp, lstat, readdir, rm } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, posix, relative, win32, } from "node:path";
|
|
4
4
|
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
5
5
|
import { planAdapterSkillInstall } from "./adapters.js";
|
|
@@ -117,7 +117,32 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
|
|
|
117
117
|
...new Set(plans.flatMap((plan) => plan.files.map((file) => file.target))),
|
|
118
118
|
])
|
|
119
119
|
try {
|
|
120
|
-
await lstat(target);
|
|
120
|
+
const info = await lstat(target);
|
|
121
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
122
|
+
const queue = [target];
|
|
123
|
+
let entriesChecked = 0;
|
|
124
|
+
let empty = true;
|
|
125
|
+
while (queue.length && empty) {
|
|
126
|
+
const directory = queue.pop();
|
|
127
|
+
for (const entry of await readdir(directory, {
|
|
128
|
+
withFileTypes: true,
|
|
129
|
+
})) {
|
|
130
|
+
entriesChecked += 1;
|
|
131
|
+
if (entriesChecked > 10_000) {
|
|
132
|
+
empty = false;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
136
|
+
queue.push(join(directory, entry.name));
|
|
137
|
+
else {
|
|
138
|
+
empty = false;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (empty)
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
121
146
|
occupied.push(target);
|
|
122
147
|
}
|
|
123
148
|
catch (error) {
|
|
@@ -202,6 +227,9 @@ export async function applySkillInstall(plan, metadata, options = {}) {
|
|
|
202
227
|
value: plan,
|
|
203
228
|
};
|
|
204
229
|
}, async (freshPlan, snapshot) => {
|
|
230
|
+
// Close the preview/apply race: an empty target may have become occupied
|
|
231
|
+
// after the first check but before the transaction snapshot completed.
|
|
232
|
+
await assertActiveTargetsUnoccupied([freshPlan], options);
|
|
205
233
|
if (options.replaceManagedTargets)
|
|
206
234
|
for (const target of [
|
|
207
235
|
...new Set(freshPlan.files.map((file) => file.target)),
|
|
@@ -264,6 +292,9 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
264
292
|
value: { entries, reconciliation },
|
|
265
293
|
};
|
|
266
294
|
}, async ({ entries: freshEntries, reconciliation }, snapshot) => {
|
|
295
|
+
// Re-check immediately before any removal or copy for the same reason as
|
|
296
|
+
// the single-package path above.
|
|
297
|
+
await assertActiveTargetsUnoccupied(freshEntries.map((entry) => entry.plan), { allowManagedReplacement: options.replaceManagedTargets });
|
|
267
298
|
for (const target of reconciliation.obsoleteTargets)
|
|
268
299
|
await rm(target, { recursive: true, force: true });
|
|
269
300
|
if (options.replaceManagedTargets)
|
|
@@ -279,6 +310,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
279
310
|
await assertExactDirectoryCopy(file.source, file.target, "Exact setup copy verification failed");
|
|
280
311
|
await recordInstallBatch(freshEntries, snapshot.id);
|
|
281
312
|
await recordManagedProfileReconciliation(reconciliation);
|
|
313
|
+
await options.afterRecord?.();
|
|
282
314
|
});
|
|
283
315
|
return applied.snapshotId;
|
|
284
316
|
}
|
|
@@ -286,7 +318,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
286
318
|
* Download a batch into the reviewed library without exposing any skill to an
|
|
287
319
|
* agent yet. This is the safe destination for Maximum Library.
|
|
288
320
|
*/
|
|
289
|
-
export async function applySkillLibraryBatch(entries) {
|
|
321
|
+
export async function applySkillLibraryBatch(entries, options = {}) {
|
|
290
322
|
if (!entries.length)
|
|
291
323
|
throw new Error("Library batch is empty");
|
|
292
324
|
const conflicts = detectInstallConflicts(entries.map((entry) => entry.plan));
|
|
@@ -343,6 +375,7 @@ export async function applySkillLibraryBatch(entries) {
|
|
|
343
375
|
}
|
|
344
376
|
}
|
|
345
377
|
await recordLibraryInstallBatch(freshEntries, snapshot.id);
|
|
378
|
+
await options.afterRecord?.();
|
|
346
379
|
});
|
|
347
380
|
return applied.snapshotId;
|
|
348
381
|
}
|
|
@@ -16,6 +16,7 @@ export const REVIEWED_MCP_RECIPES = [
|
|
|
16
16
|
command: "npx",
|
|
17
17
|
args: ["-y", "@playwright/mcp@0.0.78"],
|
|
18
18
|
environment: [],
|
|
19
|
+
modelApiProviders: [],
|
|
19
20
|
fixedEnvironment: {},
|
|
20
21
|
permissions: [
|
|
21
22
|
"browser automation",
|
|
@@ -26,6 +27,25 @@ export const REVIEWED_MCP_RECIPES = [
|
|
|
26
27
|
reviewedAt: "2026-07-16T00:00:00Z",
|
|
27
28
|
artifact: "npm:@playwright/mcp@0.0.78#sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==",
|
|
28
29
|
},
|
|
30
|
+
{
|
|
31
|
+
id: "chrome-devtools",
|
|
32
|
+
displayName: "Chrome DevTools MCP",
|
|
33
|
+
source: "https://github.com/ChromeDevTools/chrome-devtools-mcp/tree/f621d0052fd241dca76e7f615b20e9d320ba965f",
|
|
34
|
+
serverName: "chrome-devtools",
|
|
35
|
+
command: "npx",
|
|
36
|
+
args: ["-y", "chrome-devtools-mcp@1.6.0"],
|
|
37
|
+
environment: [],
|
|
38
|
+
modelApiProviders: [],
|
|
39
|
+
fixedEnvironment: {},
|
|
40
|
+
permissions: [
|
|
41
|
+
"control a local Chrome browser",
|
|
42
|
+
"inspect pages, network activity, performance, and console output",
|
|
43
|
+
],
|
|
44
|
+
connection: "stdio",
|
|
45
|
+
reviewedCommit: "f621d0052fd241dca76e7f615b20e9d320ba965f",
|
|
46
|
+
reviewedAt: "2026-07-18T00:00:00Z",
|
|
47
|
+
artifact: "npm:chrome-devtools-mcp@1.6.0#sha512-VZX6f/OjQSYhy2BGGRs+y3LsrsAQAz/HwZCWKBLVyST/4r/3zjVEjjVW7gMCVbRDuspnVdcp5hQDPrQ5UFrdZw==",
|
|
48
|
+
},
|
|
29
49
|
{
|
|
30
50
|
id: "github-readonly",
|
|
31
51
|
displayName: "GitHub MCP Server (read-only)",
|
|
@@ -43,6 +63,7 @@ export const REVIEWED_MCP_RECIPES = [
|
|
|
43
63
|
"ghcr.io/github/github-mcp-server@sha256:7b1384cdd6d025c09256af2fb6cb79bc5e87aedc957c8826b5e50d8cb82f0be3",
|
|
44
64
|
],
|
|
45
65
|
environment: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
|
|
66
|
+
modelApiProviders: [],
|
|
46
67
|
fixedEnvironment: { GITHUB_READ_ONLY: "1" },
|
|
47
68
|
permissions: ["read GitHub repositories, issues, pull requests, and users"],
|
|
48
69
|
connection: "stdio",
|