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
package/dist/src/cli.js CHANGED
@@ -20,8 +20,8 @@ import { buildHealthReport, formatHealthReport } from "./core/health.js";
20
20
  import { readInstallState, recordInstallTransaction } from "./core/state.js";
21
21
  import { applyRemove, planRemove } from "./core/remove.js";
22
22
  import { applyUninstall, buildUninstallPlan, formatUninstallPlan, uninstallGlobalCli, } from "./core/uninstall.js";
23
- import { evaluateInstalledProfile, formatInstalledProfileStatus, recordInstalledProfile, } from "./core/profile-state.js";
24
- import { formatRecommendations, personalizeRecommendations, profileManifestPackages, recommendPackages, scanProject, TESTED_PROFILES, } from "./core/recommend.js";
23
+ import { evaluateInstalledProfile, formatInstalledProfileStatus, } from "./core/profile-state.js";
24
+ import { formatRecommendations, personalizeRecommendations, profileManifestPackages, recommendPackages, RECOMMENDATION_BOUNDARY, scanProject, TESTED_PROFILES, } from "./core/recommend.js";
25
25
  import { buildImprovementCycle, formatImprovementCycle, recordImprovementOutcome, writeImprovementCycle, } from "./core/improve.js";
26
26
  import { applySyncPlan, buildSyncPlan } from "./core/sync.js";
27
27
  import { createPackage, packPackage, publishLocalPackage, publishRemotePackage, searchLocalRegistry, } from "./core/registry.js";
@@ -33,7 +33,7 @@ import { generateSigningKeys, signJsonFile, verifyJsonFile, } from "./core/signi
33
33
  import { applyPortableImport, exportPortableLoadout, planPortableImport, } from "./core/portable.js";
34
34
  import { applyCodexMcpConfigPlan, defaultCodexMcpConfigPath, planCodexMcpConfig, } from "./core/codex-mcp.js";
35
35
  import { formatDemoResult, runIsolatedDemo } from "./core/demo.js";
36
- import { catalogTrustStage, resolveCatalogProfile } from "./core/profiles.js";
36
+ import { catalogTrustStage, formatCatalogTrustStage, resolveCatalogProfile, } from "./core/profiles.js";
37
37
  import { discoverHackerNewsRepositories } from "./core/community.js";
38
38
  import { discoverPrivateRepositories } from "./core/private-discovery.js";
39
39
  import { defaultGitHubDiscoveryQueries, discoverGitHubRepositories, } from "./core/github-discovery.js";
@@ -45,7 +45,7 @@ import { compileConversion, } from "./core/conversion.js";
45
45
  import { writeFileAtomically } from "./core/atomic-file.js";
46
46
  import { formatCanaryResult, runCanary } from "./core/canary.js";
47
47
  import { startDashboardServer } from "./dashboard.js";
48
- import { applyPreparedCatalogInstall, formatPreparedCatalogInstall, prepareCatalogInstall, } from "./core/catalog-install.js";
48
+ import { applyPreparedCatalogInstall, formatCatalogApplyGuidance, formatPreparedCatalogInstall, prepareCatalogInstall, } from "./core/catalog-install.js";
49
49
  import { formatInstalledSkillInventory, scanInstalledSkills, } from "./core/skill-inventory.js";
50
50
  import { enrichInventoryWithProvenance, formatProvenanceSummary, resolveCatalogSkillIndex, } from "./core/provenance.js";
51
51
  import { compareSkill, formatSkillComparison } from "./core/skill-compare.js";
@@ -183,14 +183,14 @@ async function runSetup(options) {
183
183
  try {
184
184
  if (!mode) {
185
185
  if (!interactive) {
186
- console.log("Loadout is CLI-first. Run `loadout setup --mode stable` for the recommended daily driver, `--mode power` for broader opt-in skills, or `--mode maximum` to download the screened library without activating it.");
186
+ console.log("Loadout is CLI-first. Run `loadout setup --mode stable` for Loadout's bounded policy selection, `--mode power` for broader opt-in skills, or `--mode maximum` to download the screened library without activating it.");
187
187
  return;
188
188
  }
189
189
  reader = createInterface({
190
190
  input: process.stdin,
191
191
  output: process.stdout,
192
192
  });
193
- const answer = (await reader.question("Choose a loadout: [1] Stable Daily Driver (recommended), [2] Power Boost, [3] Maximum Library, [4] Custom: ")).trim();
193
+ const answer = (await reader.question("Choose a loadout: [1] Stable Daily Driver (Loadout policy selection), [2] Power Boost, [3] Maximum Library, [4] Custom: ")).trim();
194
194
  mode =
195
195
  answer === "2"
196
196
  ? "power"
@@ -228,7 +228,7 @@ async function runSetup(options) {
228
228
  let riskApproved = Boolean(options.approveRisk);
229
229
  if (!approved) {
230
230
  if (!interactive) {
231
- console.log("Preview complete; nothing was changed. Re-run with --yes to install this exact screened plan.");
231
+ console.log(formatCatalogApplyGuidance(Boolean(risky)));
232
232
  return;
233
233
  }
234
234
  reader ??= createInterface({
@@ -258,7 +258,6 @@ async function runSetup(options) {
258
258
  const snapshotId = await applyPreparedCatalogInstall(prepared, {
259
259
  approveRisk: riskApproved,
260
260
  });
261
- await recordInstalledProfile(prepared);
262
261
  console.log(`\nLoadout installed ${prepared.entries.length} repositories for ${prepared.agents.length} agent(s). Snapshot: ${snapshotId}`);
263
262
  console.log("Next: `loadout status`, `loadout optimize --project .`, or `loadout autopilot --yes` for opt-in daily read-only discovery and update checks.");
264
263
  }
@@ -266,7 +265,7 @@ async function runSetup(options) {
266
265
  reader?.close();
267
266
  }
268
267
  }
269
- const LOADOUT_VERSION = "0.3.1";
268
+ const LOADOUT_VERSION = "0.4.1";
270
269
  function durableSchedulerLauncher() {
271
270
  return [
272
271
  join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
@@ -730,8 +729,8 @@ for (const workflow of ["activate", "optimize"]) {
730
729
  program
731
730
  .command(workflow)
732
731
  .description(workflow === "activate"
733
- ? "Select and activate reviewed library skills for a project"
734
- : "Scan a project and propose the best reviewed active-set additions")
732
+ ? "Select and activate inspected library skills for a project"
733
+ : "Scan a project and propose rule-selected inspected active-set additions")
735
734
  .option("--project <path>", "project directory to scan", ".")
736
735
  .option("--agents <ids>", "comma-separated agent ids")
737
736
  .option("--limit <count>", "maximum active skills per agent", "40")
@@ -1055,6 +1054,7 @@ program
1055
1054
  ? JSON.stringify({
1056
1055
  signals,
1057
1056
  recommendations,
1057
+ recommendationBoundary: RECOMMENDATION_BOUNDARY,
1058
1058
  personalization: options.agent
1059
1059
  ? {
1060
1060
  agent: options.agent,
@@ -1066,7 +1066,7 @@ program
1066
1066
  });
1067
1067
  program
1068
1068
  .command("profiles")
1069
- .description("List tested Loadout profiles or inspect one profile")
1069
+ .description("List Loadout policy profiles or inspect one profile")
1070
1070
  .argument("[name]", "profile name")
1071
1071
  .option("--json", "emit machine-readable JSON")
1072
1072
  .option("--apply-to <path>", "add the selected profile packages to a manifest")
@@ -1183,7 +1183,7 @@ program
1183
1183
  .description("Take ownership of one explicitly selected installed skill without changing its bytes")
1184
1184
  .argument("<skill>", "installed skill name, directory name, or exact path")
1185
1185
  .requiredOption("--agent <id>", "agent that owns the installed skill")
1186
- .option("--refresh-provenance", "rebuild the reviewed catalog skill index")
1186
+ .option("--refresh-provenance", "rebuild the inspected catalog skill index")
1187
1187
  .option("--yes", "record ownership; otherwise show a dry-run plan")
1188
1188
  .option("--json", "emit machine-readable JSON")
1189
1189
  .action(async (skill, options) => {
@@ -1218,7 +1218,7 @@ program
1218
1218
  .description("Compare an installed or reviewed skill with evidence-related catalog alternatives without changing anything")
1219
1219
  .argument("<skill>", "installed skill name, directory name, or catalog package id")
1220
1220
  .option("--agent <id>", "select one installed agent when names are ambiguous")
1221
- .option("--refresh", "rebuild the reviewed catalog skill index")
1221
+ .option("--refresh", "rebuild the inspected catalog skill index")
1222
1222
  .option("--offline", "never fetch; use the existing local index only")
1223
1223
  .option("--limit <count>", "maximum alternatives to show", "10")
1224
1224
  .option("--json", "emit the complete machine-readable comparison")
@@ -1559,6 +1559,7 @@ program
1559
1559
  category: pkg.category,
1560
1560
  tier: pkg.tier,
1561
1561
  trustStage: catalogTrustStage(pkg),
1562
+ evidenceLabel: formatCatalogTrustStage(catalogTrustStage(pkg)),
1562
1563
  },
1563
1564
  ranking: explainCatalogScore(pkg),
1564
1565
  }, null, 2));
@@ -1580,7 +1581,7 @@ program
1580
1581
  const updated = pkg.lastUpdatedAt
1581
1582
  ? ` — updated ${pkg.lastUpdatedAt.slice(0, 10)}`
1582
1583
  : "";
1583
- console.log(`${pkg.displayName} [${pkg.tier}; ${catalogTrustStage(pkg)}] ★${pkg.stars ?? "?"} — ${pkg.repository}${topics}${updated}`);
1584
+ console.log(`${pkg.displayName} [${pkg.tier}; ${formatCatalogTrustStage(catalogTrustStage(pkg))}] ★${pkg.stars ?? "?"} — ${pkg.repository}${topics}${updated}`);
1584
1585
  }
1585
1586
  for (const failure of result.failures)
1586
1587
  console.error(`Warning: could not refresh ${failure.repository}: ${failure.error}`);
@@ -1648,7 +1649,7 @@ candidate
1648
1649
  .description("Convert a reviewed dossier into a catalog-record proposal; never edits the catalog")
1649
1650
  .argument("<dossier>", "persisted candidate dossier JSON")
1650
1651
  .requiredOption("--id <id>", "lowercase kebab-case catalog id")
1651
- .requiredOption("--category <category>", "reviewed catalog category")
1652
+ .requiredOption("--category <category>", "inspected catalog category")
1652
1653
  .requiredOption("--platforms <ids>", "explicitly reviewed comma-separated platforms: windows,macos,linux")
1653
1654
  .option("--display-name <name>", "reviewed display name")
1654
1655
  .option("--description <text>", "reviewed description")
@@ -2590,13 +2591,12 @@ program
2590
2591
  });
2591
2592
  console.log(formatPreparedCatalogInstall(prepared));
2592
2593
  if (!options.yes) {
2593
- console.log("Preview complete; nothing was changed. Use --yes after reviewing to install this exact plan.");
2594
+ console.log(formatCatalogApplyGuidance(prepared.entries.some((entry) => entry.safety.approvalRequired)));
2594
2595
  return;
2595
2596
  }
2596
2597
  const snapshotId = await applyPreparedCatalogInstall(prepared, {
2597
2598
  approveRisk: options.approveRisk,
2598
2599
  });
2599
- await recordInstalledProfile(prepared);
2600
2600
  console.log(`Installed ${prepared.entries.length} repositories as one transaction. Snapshot: ${snapshotId}`);
2601
2601
  return;
2602
2602
  }
@@ -2671,7 +2671,9 @@ program
2671
2671
  const chosen = options.snapshot ?? snapshotIds.at(-1);
2672
2672
  if (!chosen)
2673
2673
  throw new Error("No Loadout snapshots found");
2674
- await restoreSnapshot(await readSnapshot(chosen));
2674
+ await restoreSnapshot(await readSnapshot(chosen), {
2675
+ requireUnchangedPostMutationState: true,
2676
+ });
2675
2677
  return chosen;
2676
2678
  });
2677
2679
  console.log(`Restored snapshot ${selected}`);
@@ -2716,7 +2718,6 @@ program
2716
2718
  profileSnapshotId = await applyPreparedCatalogInstall(prepared, {
2717
2719
  approveRisk: options.approveRisk,
2718
2720
  });
2719
- await recordInstalledProfile(prepared);
2720
2721
  if (!options.json)
2721
2722
  console.log(`Applied reviewed ${saved.mode} profile changes. Snapshot: ${profileSnapshotId}`);
2722
2723
  profile = await evaluateInstalledProfile(catalog);
@@ -2883,7 +2884,12 @@ for (const command of program.commands)
2883
2884
  if (HIDDEN_FROM_FIRST_SCREEN.has(command.name()))
2884
2885
  command._hidden = true;
2885
2886
  program.addHelpText("after", "\nStart here: `loadout guide` shows the safe everyday path. `loadout advanced` lists the retained maintainer and integration commands.\n");
2886
- program.action(printBeginnerGuide);
2887
+ program.argument("[unknown-command]");
2888
+ program.action((unknownCommand) => {
2889
+ if (unknownCommand)
2890
+ throw new Error(`Unknown command '${unknownCommand}'. Run 'loadout --help' to list available commands.`);
2891
+ printBeginnerGuide();
2892
+ });
2887
2893
  try {
2888
2894
  await recoverPendingTransactions();
2889
2895
  await program.parseAsync();
@@ -1,5 +1,7 @@
1
1
  import { applyActivationChange, formatActivationPlan, planActivationChange, } from "./active-set.js";
2
- import { scanProject } from "./recommend.js";
2
+ import { detectAgents } from "./paths.js";
3
+ import { formatDetectedSignals, scanProject } from "./recommend.js";
4
+ import { scanInstalledSkills } from "./skill-inventory.js";
3
5
  import { readInstallState } from "./state.js";
4
6
  import { outcomeAdjustment, projectTaskFamilies, readLocalOutcomes, } from "./outcomes.js";
5
7
  const FOUNDATION = new Set([
@@ -85,7 +87,77 @@ const SIGNAL_RULES = [
85
87
  pattern: /playwright|e2e|webapp-testing/,
86
88
  label: "Playwright project",
87
89
  },
90
+ {
91
+ signal: (project) => project.roles.includes("node-cli"),
92
+ pattern: /(?:^|-)cli(?:-|$)|command-line/,
93
+ label: "Node CLI",
94
+ },
95
+ {
96
+ signal: (project) => project.roles.includes("npm-package"),
97
+ pattern: /npm|package|publish/,
98
+ label: "npm package",
99
+ },
100
+ {
101
+ signal: (project) => project.roles.includes("release"),
102
+ pattern: /release|publish|package/,
103
+ label: "release automation",
104
+ },
105
+ {
106
+ signal: (project) => project.roles.includes("mcp"),
107
+ pattern: /mcp|model-context-protocol/,
108
+ label: "MCP tooling",
109
+ },
110
+ {
111
+ signal: (project) => project.roles.includes("security"),
112
+ pattern: /security|threat|secrets|owasp/,
113
+ label: "security policy",
114
+ },
115
+ {
116
+ signal: (project) => project.tools.includes("vitest"),
117
+ pattern: /vitest/,
118
+ label: "Vitest",
119
+ },
120
+ {
121
+ signal: (project) => project.tools.includes("commander"),
122
+ pattern: /(?:^|-)cli(?:-|$)|commander|command-line/,
123
+ label: "Commander CLI",
124
+ },
125
+ {
126
+ signal: (project) => project.tools.includes("zod"),
127
+ pattern: /zod|schema|validation/,
128
+ label: "Zod schemas",
129
+ },
88
130
  ];
131
+ const FAMILY_CAPS = {
132
+ "browser-testing": 3,
133
+ documentation: 2,
134
+ "code-review": 2,
135
+ architecture: 2,
136
+ planning: 3,
137
+ security: 3,
138
+ "language-tooling": 5,
139
+ testing: 3,
140
+ uncategorized: 3,
141
+ };
142
+ function candidateFamily(unitId) {
143
+ if (/playwright|e2e|webapp-testing|browser-testing/.test(unitId))
144
+ return "browser-testing";
145
+ if (/docs|documentation|readme/.test(unitId))
146
+ return "documentation";
147
+ if (/review|refactor/.test(unitId))
148
+ return "code-review";
149
+ if (/architect|api-design|openapi/.test(unitId))
150
+ return "architecture";
151
+ if (/brainstorm|planning|writing-plans/.test(unitId))
152
+ return "planning";
153
+ if (/security|threat|secrets|owasp/.test(unitId))
154
+ return "security";
155
+ if (/test|coverage|vitest|jest/.test(unitId))
156
+ return "testing";
157
+ if (/javascript|typescript|nodejs|npm|package|cli|zod|schema/.test(unitId))
158
+ return "language-tooling";
159
+ return "uncategorized";
160
+ }
89
161
  function selector(record) {
90
162
  if (!record.unitId)
91
163
  return undefined;
@@ -96,6 +168,10 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
96
168
  if (!id || !record.unitId)
97
169
  return undefined;
98
170
  const name = record.unitId.toLowerCase();
171
+ if (/(?:^|-)jest(?:-|$)/.test(name) &&
172
+ project.tools.includes("vitest") &&
173
+ !project.tools.includes("jest"))
174
+ return undefined;
99
175
  let score = 0;
100
176
  const reasons = [];
101
177
  if (pinned.has(id) || pinned.has(record.unitId)) {
@@ -136,25 +212,8 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
136
212
  reasons: [...new Set(reasons)],
137
213
  };
138
214
  }
139
- /** Deterministic, local-only active-set selection over the reviewed library. */
140
- export async function planProjectActivation(projectPath, options = {}) {
141
- const limit = options.limit ?? 40;
142
- if (!Number.isInteger(limit) || limit < 1 || limit > 200)
143
- throw new Error("--limit must be an integer from 1 to 200");
144
- const project = await scanProject(projectPath);
145
- const state = await readInstallState();
146
- const outcomes = await readLocalOutcomes();
147
- const taskFamilies = projectTaskFamilies(project);
148
- const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
149
- (!options.agents || options.agents.includes(record.agent)));
150
- const byAgent = new Map();
151
- for (const record of relevant)
152
- if (record.activationState === "active")
153
- byAgent.set(record.agent, (byAgent.get(record.agent) ?? 0) + 1);
154
- const activeBefore = Math.max(0, ...byAgent.values());
155
- const capacity = Math.max(0, limit - activeBefore);
156
- const pins = new Set(options.pins ?? []);
157
- const scored = relevant
215
+ function rankedCandidates(records, project, pins, outcomes, taskFamilies) {
216
+ const scored = records
158
217
  .filter((record) => record.activationState === "disabled" &&
159
218
  record.cacheState === "downloaded" &&
160
219
  record.reviewState === "reviewed")
@@ -165,19 +224,24 @@ export async function planProjectActivation(projectPath, options = {}) {
165
224
  (SOURCE_PRIORITY[left.packageId] ?? 0) ||
166
225
  left.unitId.localeCompare(right.unitId) ||
167
226
  left.packageId.localeCompare(right.packageId));
168
- // One capability name wins by evidence order; overlapping collections remain
169
- // in the library and can still be explicitly pinned by full selector.
170
227
  const unique = [];
171
228
  const claimedUnits = new Set();
229
+ const familyCounts = new Map();
172
230
  for (const item of scored) {
173
231
  const fullPin = pins.has(item.selector);
174
232
  if (!fullPin && claimedUnits.has(item.unitId))
175
233
  continue;
234
+ const family = candidateFamily(item.unitId);
235
+ if (!fullPin && (familyCounts.get(family) ?? 0) >= FAMILY_CAPS[family])
236
+ continue;
176
237
  claimedUnits.add(item.unitId);
238
+ familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
177
239
  unique.push(item);
178
240
  }
179
- const selected = unique.slice(0, capacity);
180
- const alternatives = selected.flatMap((chosen) => {
241
+ return unique;
242
+ }
243
+ function alternativesFor(selected, scored) {
244
+ return selected.flatMap((chosen) => {
181
245
  const deferred = scored
182
246
  .filter((candidate) => candidate.unitId === chosen.unitId &&
183
247
  candidate.selector !== chosen.selector)
@@ -186,7 +250,67 @@ export async function planProjectActivation(projectPath, options = {}) {
186
250
  ? [{ unitId: chosen.unitId, selected: chosen.selector, deferred }]
187
251
  : [];
188
252
  });
253
+ }
254
+ /** Deterministic, local-only active-set selection over the reviewed library. */
255
+ export async function planProjectActivation(projectPath, options = {}) {
256
+ const limit = options.limit ?? 40;
257
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
258
+ throw new Error("--limit must be an integer from 1 to 200");
259
+ const project = await scanProject(projectPath);
260
+ const state = await readInstallState();
261
+ const outcomes = await readLocalOutcomes();
262
+ const taskFamilies = projectTaskFamilies(project);
263
+ const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
264
+ (!options.agents || options.agents.includes(record.agent)));
265
+ const requestedAgents = options.agents ?? [
266
+ ...new Set(relevant.map((record) => record.agent)),
267
+ ];
268
+ const detected = (await detectAgents()).filter((agent) => requestedAgents.includes(agent.id));
269
+ const inventory = await scanInstalledSkills(detected);
270
+ const pins = new Set(options.pins ?? []);
189
271
  const warnings = [];
272
+ const agentPlans = requestedAgents.map((agent) => {
273
+ const summary = inventory.agents.find((item) => item.agent === agent);
274
+ if (!summary)
275
+ throw new Error(`Could not scan active skills for requested agent '${agent}'`);
276
+ const capacity = Math.max(0, limit - summary.total);
277
+ const ranked = rankedCandidates(relevant.filter((record) => record.agent === agent), project, pins, outcomes, taskFamilies);
278
+ const selected = ranked.slice(0, capacity);
279
+ if (!capacity)
280
+ warnings.push(`${summary.displayName} has reached the active-set limit ${limit}; disable skills or raise --limit.`);
281
+ return {
282
+ agent,
283
+ displayName: summary.displayName,
284
+ activeBefore: summary.total,
285
+ managedBefore: summary.managed,
286
+ unmanagedBefore: summary.unmanaged,
287
+ capacity,
288
+ selected,
289
+ alternatives: alternativesFor(selected, ranked),
290
+ };
291
+ });
292
+ const activationPlans = await Promise.all(agentPlans
293
+ .filter((agentPlan) => agentPlan.selected.length)
294
+ .map((agentPlan) => planActivationChange("enable", agentPlan.selected.map((item) => item.selector), { agents: [agentPlan.agent] })));
295
+ const activation = activationPlans.length
296
+ ? {
297
+ action: "enable",
298
+ packages: [
299
+ ...new Set(activationPlans.flatMap((plan) => plan.packages)),
300
+ ],
301
+ requestedAgents,
302
+ changes: activationPlans.flatMap((plan) => plan.changes),
303
+ skipped: activationPlans.flatMap((plan) => plan.skipped),
304
+ blocked: activationPlans.some((plan) => plan.blocked),
305
+ warnings: activationPlans.flatMap((plan) => plan.warnings),
306
+ }
307
+ : undefined;
308
+ const selected = [
309
+ ...new Map(agentPlans
310
+ .flatMap((agentPlan) => agentPlan.selected)
311
+ .map((item) => [item.selector, item])).values(),
312
+ ];
313
+ const alternatives = agentPlans.flatMap((item) => item.alternatives);
190
314
  const known = new Set(relevant.flatMap((record) => {
191
315
  const id = selector(record);
192
316
  return id ? [id, record.unitId] : [];
@@ -194,17 +318,13 @@ export async function planProjectActivation(projectPath, options = {}) {
194
318
  const unknownPins = [...pins].filter((pin) => !known.has(pin));
195
319
  if (unknownPins.length)
196
320
  warnings.push(`Pinned skill(s) are not in the managed library: ${unknownPins.join(", ")}`);
197
- if (!capacity)
198
- warnings.push(`Active-set limit ${limit} is already reached; disable skills or raise --limit.`);
199
- const activation = selected.length
200
- ? await planActivationChange("enable", selected.map((item) => item.selector), { ...(options.agents ? { agents: options.agents } : {}) })
201
- : undefined;
202
321
  return {
203
322
  project,
204
323
  limit,
205
324
  ...(options.agents ? { agents: options.agents } : {}),
206
- activeBefore,
207
- capacity,
325
+ agentPlans,
326
+ activeBefore: Math.max(0, ...agentPlans.map((item) => item.activeBefore)),
327
+ capacity: Math.min(limit, ...agentPlans.map((item) => item.capacity)),
208
328
  selected,
209
329
  alternatives,
210
330
  ...(activation ? { activation } : {}),
@@ -214,17 +334,31 @@ export async function planProjectActivation(projectPath, options = {}) {
214
334
  export async function applyProjectActivation(plan) {
215
335
  if (!plan.activation || !plan.selected.length)
216
336
  throw new Error("No reviewed library skills were selected for activation");
217
- return applyActivationChange(plan.activation);
337
+ return applyActivationChange(plan.activation, {
338
+ preflight: async () => {
339
+ const requested = plan.agentPlans.map((item) => item.agent);
340
+ const detected = (await detectAgents()).filter((agent) => requested.includes(agent.id));
341
+ const inventory = await scanInstalledSkills(detected);
342
+ for (const agentPlan of plan.agentPlans) {
343
+ const current = inventory.agents.find((item) => item.agent === agentPlan.agent);
344
+ if (!current)
345
+ throw new Error(`Could not re-scan active skills for requested agent '${agentPlan.agent}'`);
346
+ if (current.total + agentPlan.selected.length > plan.limit)
347
+ throw new Error(`${agentPlan.displayName} active skill capacity changed after preview: ${current.total} active plus ${agentPlan.selected.length} proposed exceeds limit ${plan.limit}`);
348
+ }
349
+ },
350
+ });
218
351
  }
219
352
  export function formatProjectActivation(plan) {
220
- const detected = [...plan.project.languages, ...plan.project.frameworks];
221
353
  return [
222
354
  `Project: ${plan.project.root}`,
223
- `Detected: ${detected.join(", ") || "no known project signals"}`,
224
- `Active-set budget: ${plan.activeBefore}/${plan.limit} active; ${plan.capacity} slot(s) available`,
225
- `Proposed additions: ${plan.selected.length}`,
226
- ...plan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
227
- ...plan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
355
+ `Detected: ${formatDetectedSignals(plan.project) || "no known project signals"}`,
356
+ ...plan.agentPlans.flatMap((agentPlan) => [
357
+ `${agentPlan.displayName}: ${agentPlan.activeBefore} active (${agentPlan.managedBefore} managed, ${agentPlan.unmanagedBefore} unmanaged); ${agentPlan.capacity}/${plan.limit} slots available`,
358
+ `Proposed additions for ${agentPlan.displayName}: ${agentPlan.selected.length}`,
359
+ ...agentPlan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
360
+ ...agentPlan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
361
+ ]),
228
362
  ...(plan.activation
229
363
  ? ["", "Exact activation delta:", formatActivationPlan(plan.activation)]
230
364
  : []),
@@ -3,6 +3,7 @@ import { cp, lstat, mkdir, readFile, readdir, rm } from "node:fs/promises";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { agentSkillsDirectory, ensureDirectory } from "./paths.js";
5
5
  import { activationLibraryPath, installStatePath, readInstallState, writeInstallState, } from "./state.js";
6
+ import { inspectTargetOccupancy } from "./target-occupancy.js";
6
7
  import { runMutationTransaction } from "./transaction.js";
7
8
  /** Resolve a managed file to its active location or disabled library copy. */
8
9
  export function managedFileReadPath(packageId, filePath, activations) {
@@ -262,9 +263,12 @@ export async function planActivationChange(action, packageIds, options = {}) {
262
263
  const libraryActual = await hashTree(record.libraryPath);
263
264
  change.blockers.push(...exactTreeDifferences(record.libraryFiles, libraryActual).map((message) => `library copy: ${message}`));
264
265
  }
265
- const occupied = (await Promise.all(change.targets.map((target) => pathExists(target.activePath)))).filter(Boolean).length;
266
- if (occupied)
267
- change.blockers.push(`${occupied} active target(s) already exist and would be overwritten`);
266
+ const occupied = (await Promise.all(change.targets.map(async (target) => ({
267
+ target,
268
+ result: await inspectTargetOccupancy(target.activePath),
269
+ })))).filter((item) => item.result.occupied);
270
+ if (occupied.length)
271
+ change.blockers.push(`${occupied.length} active target(s) already contain protected content and would be overwritten; first target: ${occupied[0].target.activePath} (${occupied[0].result.reason})`);
268
272
  }
269
273
  changes.push(change);
270
274
  }
@@ -283,8 +287,9 @@ export async function planActivationChange(action, packageIds, options = {}) {
283
287
  function activationKey(record) {
284
288
  return `${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`;
285
289
  }
286
- export async function applyActivationChange(plan) {
290
+ export async function applyActivationChange(plan, options = {}) {
287
291
  const applied = await runMutationTransaction(async () => {
292
+ await options.preflight?.();
288
293
  const fresh = await planActivationChange(plan.action, plan.packages, {
289
294
  ...(plan.requestedAgents ? { agents: plan.requestedAgents } : {}),
290
295
  });
@@ -337,6 +342,10 @@ export async function applyActivationChange(plan) {
337
342
  else {
338
343
  for (const target of change.targets) {
339
344
  const source = join(change.libraryPath, target.libraryRelativePath);
345
+ const occupancy = await inspectTargetOccupancy(target.activePath);
346
+ if (occupancy.occupied)
347
+ throw new Error(`${change.packageId}/${change.agent}: activation target became occupied: ${target.activePath} (${occupancy.reason})`);
348
+ await rm(target.activePath, { recursive: true, force: true });
340
349
  await mkdir(dirname(target.activePath), { recursive: true });
341
350
  await cp(source, target.activePath, {
342
351
  recursive: true,
@@ -140,6 +140,16 @@ export function adapterCapabilities(agent) {
140
140
  throw new Error(`No adapter capability declaration for '${agent}'`);
141
141
  return found;
142
142
  }
143
+ /**
144
+ * Return the display names of agents with an explicitly declared native skill
145
+ * directory. This is a filesystem-support boundary, not a claim about native
146
+ * application runtime behavior.
147
+ */
148
+ export function supportedAdapterNames(adapters = ADAPTER_CAPABILITIES) {
149
+ return adapters
150
+ .filter((adapter) => adapter.components.skill === "native")
151
+ .map((adapter) => adapter.displayName);
152
+ }
143
153
  /**
144
154
  * Produce a read-only native-skill plan for one agent. This is deliberately
145
155
  * narrower than package normalization: adapters may only target a directory