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.
- package/CHANGELOG.md +67 -0
- package/MASTER_PLAN.md +177 -13
- package/README.md +157 -284
- package/dashboard/app.js +4 -4
- package/dashboard/index.html +4 -4
- package/dist/src/cli.js +27 -21
- package/dist/src/core/active-policy.js +172 -38
- package/dist/src/core/active-set.js +13 -4
- 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/conformance.js +74 -0
- package/dist/src/core/install.js +11 -11
- 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 +104 -12
- package/dist/src/core/runtime-tools.js +5 -2
- package/dist/src/core/scheduler.js +2 -1
- package/dist/src/core/snapshot.js +58 -13
- package/dist/src/core/state.js +8 -1
- package/dist/src/core/target-occupancy.js +50 -0
- package/dist/src/core/transaction.js +2 -1
- package/dist/src/core/uninstall.js +26 -2
- package/dist/src/dashboard.js +5 -2
- package/dist/src/shared/schemas.js +57 -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 +50 -0
- package/docs/USER_TEST_GUIDE.md +42 -4
- 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/plans/2026-07-20-project-activation-safety.md +469 -0
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
- package/package.json +8 -4
- package/SIMPLE_PLAN.md +0 -44
- package/docs/plans/2026-07-18-release-0.3.md +0 -42
- 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,
|
|
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
|
|
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 (
|
|
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(
|
|
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.
|
|
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
|
|
734
|
-
: "Scan a project and propose
|
|
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
|
|
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
|
|
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
|
|
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>", "
|
|
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(
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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: ${
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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) =>
|
|
266
|
-
|
|
267
|
-
|
|
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
|