loadout-ai 0.3.1 → 0.4.1

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 +67 -0
  2. package/MASTER_PLAN.md +177 -13
  3. package/README.md +157 -284
  4. package/dashboard/app.js +4 -4
  5. package/dashboard/index.html +4 -4
  6. package/dist/src/cli.js +27 -21
  7. package/dist/src/core/active-policy.js +172 -38
  8. package/dist/src/core/active-set.js +13 -4
  9. package/dist/src/core/adapters.js +10 -0
  10. package/dist/src/core/adopt.js +165 -32
  11. package/dist/src/core/agent-health-score.js +2 -2
  12. package/dist/src/core/catalog-coverage.js +2 -1
  13. package/dist/src/core/catalog-install.js +8 -1
  14. package/dist/src/core/catalog-release.js +2 -1
  15. package/dist/src/core/conformance.js +74 -0
  16. package/dist/src/core/install.js +11 -11
  17. package/dist/src/core/profiles.js +9 -4
  18. package/dist/src/core/ranking.js +1 -1
  19. package/dist/src/core/readme-claims.js +10 -0
  20. package/dist/src/core/readme-facts.js +40 -0
  21. package/dist/src/core/recommend.js +104 -12
  22. package/dist/src/core/runtime-tools.js +5 -2
  23. package/dist/src/core/scheduler.js +2 -1
  24. package/dist/src/core/snapshot.js +58 -13
  25. package/dist/src/core/state.js +8 -1
  26. package/dist/src/core/target-occupancy.js +50 -0
  27. package/dist/src/core/transaction.js +2 -1
  28. package/dist/src/core/uninstall.js +26 -2
  29. package/dist/src/dashboard.js +5 -2
  30. package/dist/src/shared/schemas.js +57 -0
  31. package/docs/FEATURE_TEST_MATRIX.md +16 -0
  32. package/docs/README_RESEARCH.md +36 -0
  33. package/docs/RELEASE_REVIEW.md +31 -5
  34. package/docs/REPOSITORY_STABILIZATION.md +190 -0
  35. package/docs/TESTING.md +50 -0
  36. package/docs/USER_TEST_GUIDE.md +42 -4
  37. package/docs/assets/loadout-hero.svg +259 -0
  38. package/docs/assets/loadout-mark.svg +54 -0
  39. package/docs/evidence/live-checks-2026-07-19.json +22 -0
  40. package/docs/evidence/live-checks.schema.json +28 -0
  41. package/docs/evidence/readme-claims.json +286 -0
  42. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
  43. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
  44. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
  45. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
  46. package/package.json +8 -4
  47. package/SIMPLE_PLAN.md +0 -44
  48. package/docs/plans/2026-07-18-release-0.3.md +0 -42
  49. package/docs/superpowers/plans/2026-07-18-cli-ux-polish.md +0 -86
@@ -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,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
+ }
@@ -6,6 +6,7 @@ import { planAdapterSkillInstall } from "./adapters.js";
6
6
  import { applySkillPlan, detectInstallConflicts } from "./skills.js";
7
7
  import { activationLibraryPath, installStatePath, recordInstall, recordInstallBatch, recordLibraryInstallBatch, readInstallState, hashDirectory, writeInstallState, } from "./state.js";
8
8
  import { runMutationTransaction } from "./transaction.js";
9
+ import { inspectTargetOccupancy } from "./target-occupancy.js";
9
10
  export function installedAgents(agents, requested) {
10
11
  const available = agents.filter((agent) => agent.installed);
11
12
  if (!requested || requested.length === 0)
@@ -116,17 +117,8 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
116
117
  for (const target of [
117
118
  ...new Set(plans.flatMap((plan) => plan.files.map((file) => file.target))),
118
119
  ])
119
- try {
120
- await lstat(target);
120
+ if ((await inspectTargetOccupancy(target)).occupied)
121
121
  occupied.push(target);
122
- }
123
- catch (error) {
124
- if (!error ||
125
- typeof error !== "object" ||
126
- !("code" in error) ||
127
- error.code !== "ENOENT")
128
- throw error;
129
- }
130
122
  if (occupied.length && options.allowManagedReplacement) {
131
123
  const state = await readInstallState();
132
124
  const allowed = new Map();
@@ -202,6 +194,9 @@ export async function applySkillInstall(plan, metadata, options = {}) {
202
194
  value: plan,
203
195
  };
204
196
  }, async (freshPlan, snapshot) => {
197
+ // Close the preview/apply race: an empty target may have become occupied
198
+ // after the first check but before the transaction snapshot completed.
199
+ await assertActiveTargetsUnoccupied([freshPlan], options);
205
200
  if (options.replaceManagedTargets)
206
201
  for (const target of [
207
202
  ...new Set(freshPlan.files.map((file) => file.target)),
@@ -264,6 +259,9 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
264
259
  value: { entries, reconciliation },
265
260
  };
266
261
  }, async ({ entries: freshEntries, reconciliation }, snapshot) => {
262
+ // Re-check immediately before any removal or copy for the same reason as
263
+ // the single-package path above.
264
+ await assertActiveTargetsUnoccupied(freshEntries.map((entry) => entry.plan), { allowManagedReplacement: options.replaceManagedTargets });
267
265
  for (const target of reconciliation.obsoleteTargets)
268
266
  await rm(target, { recursive: true, force: true });
269
267
  if (options.replaceManagedTargets)
@@ -279,6 +277,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
279
277
  await assertExactDirectoryCopy(file.source, file.target, "Exact setup copy verification failed");
280
278
  await recordInstallBatch(freshEntries, snapshot.id);
281
279
  await recordManagedProfileReconciliation(reconciliation);
280
+ await options.afterRecord?.();
282
281
  });
283
282
  return applied.snapshotId;
284
283
  }
@@ -286,7 +285,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
286
285
  * Download a batch into the reviewed library without exposing any skill to an
287
286
  * agent yet. This is the safe destination for Maximum Library.
288
287
  */
289
- export async function applySkillLibraryBatch(entries) {
288
+ export async function applySkillLibraryBatch(entries, options = {}) {
290
289
  if (!entries.length)
291
290
  throw new Error("Library batch is empty");
292
291
  const conflicts = detectInstallConflicts(entries.map((entry) => entry.plan));
@@ -343,6 +342,7 @@ export async function applySkillLibraryBatch(entries) {
343
342
  }
344
343
  }
345
344
  await recordLibraryInstallBatch(freshEntries, snapshot.id);
345
+ await options.afterRecord?.();
346
346
  });
347
347
  return applied.snapshotId;
348
348
  }
@@ -1,6 +1,6 @@
1
1
  import { compareCatalogPackages } from "./ranking.js";
2
2
  /**
3
- * Stable is Loadout's recommended daily driver: broad enough to improve normal
3
+ * Stable is Loadout's bounded policy-selected daily driver: broad enough to improve normal
4
4
  * engineering work immediately, but bounded at skill granularity and restricted
5
5
  * to catalog sources with an identified SPDX license. Maximum remains the
6
6
  * explicit broad-library mode.
@@ -45,7 +45,8 @@ export const STABLE_BOOST_PACKAGE_IDS = Object.freeze(Object.keys(STABLE_SKILL_A
45
45
  /**
46
46
  * Trust is deliberately separate from popularity and publisher tier. No
47
47
  * bundled record is labelled human-reviewed or benchmarked until that evidence
48
- * is actually stored; Stable is the policy-recommended subset.
48
+ * is actually stored; Stable is the policy-selected subset. The stored
49
+ * `recommended` value remains for schema compatibility.
49
50
  */
50
51
  export function catalogTrustStage(pkg) {
51
52
  if (STABLE_BOOST_PACKAGE_IDS.includes(pkg.id) &&
@@ -58,6 +59,10 @@ export function catalogTrustStage(pkg) {
58
59
  return "inspected";
59
60
  return "discovered";
60
61
  }
62
+ /** Keep stored `recommended` values compatible while naming their evidence honestly. */
63
+ export function formatCatalogTrustStage(stage) {
64
+ return stage === "recommended" ? "policy-selected" : stage;
65
+ }
61
66
  export function isStableSkillSelected(packageId, skillName, targetName) {
62
67
  const selected = STABLE_SKILL_ALLOWLIST[packageId];
63
68
  if (!selected)
@@ -233,7 +238,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
233
238
  selected.delete(secondary.id);
234
239
  deferred.push(secondary);
235
240
  }
236
- warnings.push(`Stable Boost selected ${primary.displayName}; deferred ${members
241
+ warnings.push(`Loadout policy selection for Stable chose ${primary.displayName}; deferred ${members
237
242
  .slice(1)
238
243
  .map((pkg) => pkg.displayName)
239
244
  .join(", ")} because they overlap in ${family.label}.`);
@@ -242,7 +247,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
242
247
  warnings.push(`Custom selection retains the soft overlap in ${family.label}. Review ${names} before installation.`);
243
248
  }
244
249
  else {
245
- warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. ${primary.displayName} is the recommended default; review each package before installation.`);
250
+ warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. Loadout policy orders ${primary.displayName} first; review each package before installation.`);
246
251
  }
247
252
  }
248
253
  return {
@@ -96,7 +96,7 @@ export function explainCatalogScore(pkg, now = new Date()) {
96
96
  ],
97
97
  };
98
98
  }
99
- /** A deterministic ordering: review tier first, then explainable evidence, then id. */
99
+ /** A deterministic policy ordering: declared tier, bounded evidence score, then id. */
100
100
  export function compareCatalogPackages(a, b) {
101
101
  return (TIER_ORDER[b.tier] - TIER_ORDER[a.tier] ||
102
102
  explainCatalogScore(b).score - explainCatalogScore(a).score ||
@@ -0,0 +1,10 @@
1
+ import { formatSchemaError, readmeClaimManifestSchema, } from "../shared/schemas.js";
2
+ export { readmeClaimManifestSchema };
3
+ /** Parse the versioned index of evidence for material README statements. */
4
+ export function parseReadmeClaimManifest(value) {
5
+ const result = readmeClaimManifestSchema.safeParse(value);
6
+ if (!result.success) {
7
+ throw new Error(`README claim manifest is invalid: ${formatSchemaError(result.error)}`);
8
+ }
9
+ return result.data;
10
+ }
@@ -0,0 +1,40 @@
1
+ import { supportedAdapterNames } from "./adapters.js";
2
+ import { buildCatalogCoverage } from "./catalog-coverage.js";
3
+ function profileFacts(allowlist) {
4
+ return {
5
+ sources: Object.keys(allowlist).length,
6
+ skillDirectories: Object.values(allowlist).reduce((count, skills) => count + skills.length, 0),
7
+ };
8
+ }
9
+ /**
10
+ * Derive all changeable README facts from passed authoritative source data.
11
+ * This module intentionally neither reads nor parses README text.
12
+ */
13
+ export function deriveReadmeFacts({ catalog, packageJson, agents, profiles, }) {
14
+ const coverage = buildCatalogCoverage(catalog);
15
+ return {
16
+ catalog: {
17
+ records: coverage.records,
18
+ categories: coverage.categoryCount,
19
+ components: coverage.components,
20
+ installShapes: coverage.installShapes,
21
+ assertedLicenses: coverage.assertedLicenses,
22
+ noAssertionLicenses: coverage.noAssertionLicenses,
23
+ },
24
+ profiles: {
25
+ stable: profileFacts(profiles.stable),
26
+ power: profileFacts(profiles.power),
27
+ },
28
+ agents: {
29
+ supportedNames: supportedAdapterNames(agents),
30
+ },
31
+ package: {
32
+ name: packageJson.name,
33
+ version: packageJson.version,
34
+ bin: { ...packageJson.bin },
35
+ },
36
+ runtime: {
37
+ node: packageJson.engines.node,
38
+ },
39
+ };
40
+ }