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.
Files changed (49) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/MASTER_PLAN.md +141 -33
  3. package/README.md +160 -255
  4. package/dashboard/app.js +4 -4
  5. package/dashboard/index.html +4 -4
  6. package/dist/src/cli.js +189 -32
  7. package/dist/src/core/active-set.js +37 -2
  8. package/dist/src/core/adapters.js +10 -0
  9. package/dist/src/core/adopt.js +165 -32
  10. package/dist/src/core/agent-health-score.js +2 -2
  11. package/dist/src/core/catalog-coverage.js +2 -1
  12. package/dist/src/core/catalog-install.js +8 -1
  13. package/dist/src/core/catalog-release.js +2 -1
  14. package/dist/src/core/cli-guide.js +101 -0
  15. package/dist/src/core/completion.js +3 -0
  16. package/dist/src/core/conformance.js +74 -0
  17. package/dist/src/core/install.js +36 -3
  18. package/dist/src/core/mcp-recipes.js +21 -0
  19. package/dist/src/core/profile-state.js +101 -0
  20. package/dist/src/core/profiles.js +9 -4
  21. package/dist/src/core/ranking.js +1 -1
  22. package/dist/src/core/readme-claims.js +10 -0
  23. package/dist/src/core/readme-facts.js +40 -0
  24. package/dist/src/core/recommend.js +9 -3
  25. package/dist/src/core/remove.js +6 -7
  26. package/dist/src/core/runtime-tools.js +10 -2
  27. package/dist/src/core/scheduler.js +4 -3
  28. package/dist/src/core/snapshot.js +58 -13
  29. package/dist/src/core/state.js +13 -3
  30. package/dist/src/core/transaction.js +2 -1
  31. package/dist/src/core/uninstall.js +133 -0
  32. package/dist/src/core/update.js +87 -58
  33. package/dist/src/dashboard.js +5 -2
  34. package/dist/src/shared/schemas.js +69 -0
  35. package/docs/FEATURE_TEST_MATRIX.md +16 -0
  36. package/docs/README_RESEARCH.md +36 -0
  37. package/docs/RELEASE_REVIEW.md +31 -5
  38. package/docs/REPOSITORY_STABILIZATION.md +190 -0
  39. package/docs/TESTING.md +75 -2
  40. package/docs/USER_TEST_GUIDE.md +202 -0
  41. package/docs/assets/loadout-hero.svg +259 -0
  42. package/docs/assets/loadout-mark.svg +54 -0
  43. package/docs/evidence/live-checks-2026-07-19.json +22 -0
  44. package/docs/evidence/live-checks.schema.json +28 -0
  45. package/docs/evidence/readme-claims.json +286 -0
  46. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
  47. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
  48. package/package.json +8 -4
  49. package/SIMPLE_PLAN.md +0 -44
@@ -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
- return {
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
- provenance: match.provenance,
39
- reviewed: Boolean(exact),
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
- const current = createHash("sha256")
67
- .update(await readFile(join(plan.path, "SKILL.md")))
68
- .digest("hex");
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: plan };
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: ${plan.reviewed ? "reviewed exact catalog match" : "unreviewed"}`,
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 reviewed library and re-check capacity after activation changes.",
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
- `Agent Health Score: ${score.score}/${score.maximumScore} (${score.rating}; evidence coverage ${score.evidenceCoverage}%)`,
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
- `Recommendation trust: ${report.recommendedRecords} Stable sources · human license approval still required before release`,
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
+ ]);
@@ -1,4 +1,6 @@
1
1
  const commands = [
2
+ "guide",
3
+ "advanced",
2
4
  "setup",
3
5
  "upgrade",
4
6
  "init",
@@ -83,6 +85,7 @@ const commands = [
83
85
  "outcome",
84
86
  "outcomes",
85
87
  "remove",
88
+ "uninstall",
86
89
  "dashboard",
87
90
  "serve",
88
91
  "completion",
@@ -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
+ }
@@ -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",