loadout-ai 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/MASTER_PLAN.md +173 -9
- package/README.md +57 -19
- package/catalog/discovered.json +8422 -6957
- package/catalog/packages.json +26 -0
- package/dist/src/cli.js +140 -68
- package/dist/src/core/active-policy.js +216 -42
- package/dist/src/core/active-set.js +14 -5
- package/dist/src/core/adopt.js +1 -1
- package/dist/src/core/catalog-coverage.js +2 -1
- package/dist/src/core/catalog-install.js +23 -8
- package/dist/src/core/cli-guide.js +5 -2
- package/dist/src/core/codex-mcp.js +49 -5
- package/dist/src/core/completion.js +0 -3
- package/dist/src/core/health.js +19 -11
- package/dist/src/core/install.js +6 -39
- package/dist/src/core/mcp-recipes.js +13 -4
- package/dist/src/core/mcp.js +6 -5
- package/dist/src/core/model-config.js +1 -1
- package/dist/src/core/portable.js +1 -1
- package/dist/src/core/recommend.js +129 -27
- package/dist/src/core/remove.js +28 -2
- package/dist/src/core/runtime-tool-recipe.js +2 -2
- package/dist/src/core/runtime-tools.js +15 -4
- package/dist/src/core/snapshot.js +20 -0
- package/dist/src/core/state.js +9 -0
- package/dist/src/core/sync.js +1 -1
- package/dist/src/core/target-occupancy.js +50 -0
- package/dist/src/core/transaction.js +2 -1
- package/dist/src/shared/schemas.js +1 -0
- package/docs/CATALOG.md +3 -2
- package/docs/DISCOVERED.md +253 -255
- package/docs/FEATURE_TEST_MATRIX.md +19 -32
- package/docs/GITHUB_AUTHORIZATION.md +2 -2
- package/docs/TESTING.md +3 -16
- package/docs/USER_TEST_GUIDE.md +39 -13
- package/docs/assets/loadout-workflow.png +0 -0
- package/docs/evidence/readme-claims.json +0 -8
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
- package/package.json +4 -6
- package/dashboard/app.js +0 -607
- package/dashboard/index.html +0 -249
- package/dashboard/styles.css +0 -384
- package/dist/src/core/demo.js +0 -136
- package/dist/src/dashboard.js +0 -421
|
@@ -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([
|
|
@@ -17,13 +19,46 @@ const FOUNDATION = new Set([
|
|
|
17
19
|
"code-review-excellence",
|
|
18
20
|
"api-design-principles",
|
|
19
21
|
"architecture-patterns",
|
|
20
|
-
"accessibility-compliance",
|
|
21
22
|
"deployment-pipeline-design",
|
|
22
23
|
"documentation-writer",
|
|
23
24
|
"openai-docs",
|
|
24
|
-
"web-design-guidelines",
|
|
25
25
|
]);
|
|
26
|
-
const SPECIALIZED_WITHOUT_SIGNAL = /(?:appstore|backtesting|bats-|oracle|salesforce|power-bi|dataverse|qdrant|azure-|aws-|mcp-server-generator|sandbox-npm|migration)/;
|
|
26
|
+
const SPECIALIZED_WITHOUT_SIGNAL = /(?:appstore|msstore|backtesting|bats-|oracle|salesforce|power-bi|dataverse|qdrant|azure-|aws-|mcp-server-generator|sandbox-npm|migration|social-(?:publish|media)|marketing|copywriting)/;
|
|
27
|
+
/**
|
|
28
|
+
* A matching word such as `mcp`, `cli`, or `publish` is not enough to make a
|
|
29
|
+
* skill compatible with the detected project. These gates run before scoring
|
|
30
|
+
* so a strong generic signal cannot pull in tooling for another ecosystem.
|
|
31
|
+
* Explicit pins remain an intentional user override.
|
|
32
|
+
*/
|
|
33
|
+
function hasProjectCompatibility(name, project) {
|
|
34
|
+
const hasLanguage = (language) => project.languages.includes(language);
|
|
35
|
+
const languageRequirements = [
|
|
36
|
+
[/(?:dotnet|csharp|aspnet|nuget|maui)/, ".net"],
|
|
37
|
+
[
|
|
38
|
+
/(?:python|pytest|fastapi|django|flask|pydantic|poetry|uv-package)/,
|
|
39
|
+
"python",
|
|
40
|
+
],
|
|
41
|
+
[/(?:^|-)go(?:-|$)|golang/, "go"],
|
|
42
|
+
[/(?:rust|cargo)/, "rust"],
|
|
43
|
+
[/(?:^|-)java(?:-|$)|spring-boot/, "java"],
|
|
44
|
+
[/(?:ruby|rails|bundler)/, "ruby"],
|
|
45
|
+
[/(?:elixir|phoenix)/, "elixir"],
|
|
46
|
+
];
|
|
47
|
+
for (const [pattern, language] of languageRequirements)
|
|
48
|
+
if (pattern.test(name) && !hasLanguage(language))
|
|
49
|
+
return false;
|
|
50
|
+
if (/(?:nodejs-)?backend|api-server/.test(name) &&
|
|
51
|
+
!project.roles.includes("backend"))
|
|
52
|
+
return false;
|
|
53
|
+
if (/(?:^|-)vercel(?:-|$)/.test(name) &&
|
|
54
|
+
!project.frameworks.includes("next.js") &&
|
|
55
|
+
!project.files.includes("vercel.json"))
|
|
56
|
+
return false;
|
|
57
|
+
if (/(?:publish-to-pages|github-pages|cloudflare-pages)/.test(name) &&
|
|
58
|
+
!project.frameworks.some((framework) => ["react", "next.js", "vue", "svelte"].includes(framework)))
|
|
59
|
+
return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
27
62
|
const SOURCE_PRIORITY = {
|
|
28
63
|
superpowers: 80,
|
|
29
64
|
context7: 75,
|
|
@@ -47,7 +82,7 @@ const GENERAL = [
|
|
|
47
82
|
const SIGNAL_RULES = [
|
|
48
83
|
{
|
|
49
84
|
signal: (project) => project.languages.includes("javascript/typescript"),
|
|
50
|
-
pattern: /(?:javascript|typescript|nodejs|
|
|
85
|
+
pattern: /(?:javascript|typescript|nodejs|webapp)|(?:^|-)npm(?:-|$)/,
|
|
51
86
|
label: "JavaScript/TypeScript project",
|
|
52
87
|
},
|
|
53
88
|
{
|
|
@@ -85,7 +120,82 @@ const SIGNAL_RULES = [
|
|
|
85
120
|
pattern: /playwright|e2e|webapp-testing/,
|
|
86
121
|
label: "Playwright project",
|
|
87
122
|
},
|
|
123
|
+
{
|
|
124
|
+
signal: (project) => project.roles.includes("node-cli"),
|
|
125
|
+
pattern: /^(?:cli|cli-design|cli-mastery|node-cli|command-line|command-line-tools?|building-cli(?:-apps?)?)$/,
|
|
126
|
+
label: "Node CLI",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
signal: (project) => project.roles.includes("npm-package"),
|
|
130
|
+
pattern: /(?:^|-)npm(?:-|$)|package|publish/,
|
|
131
|
+
label: "npm package",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
signal: (project) => project.roles.includes("release"),
|
|
135
|
+
pattern: /release|publish|package/,
|
|
136
|
+
label: "release automation",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
signal: (project) => project.roles.includes("mcp"),
|
|
140
|
+
pattern: /mcp|model-context-protocol/,
|
|
141
|
+
label: "MCP tooling",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
signal: (project) => project.roles.includes("security"),
|
|
145
|
+
pattern: /security|threat|secrets|owasp/,
|
|
146
|
+
label: "security policy",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
signal: (project) => project.roles.includes("obsidian-vault"),
|
|
150
|
+
pattern: /obsidian|json-canvas|defuddle/,
|
|
151
|
+
label: "Obsidian vault",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
signal: (project) => project.tools.includes("vitest"),
|
|
155
|
+
pattern: /vitest/,
|
|
156
|
+
label: "Vitest",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
signal: (project) => project.tools.includes("commander"),
|
|
160
|
+
pattern: /^(?:cli|cli-design|cli-mastery|node-cli|commander|command-line|command-line-tools?|building-cli(?:-apps?)?)$/,
|
|
161
|
+
label: "Commander CLI",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
signal: (project) => project.tools.includes("zod"),
|
|
165
|
+
pattern: /zod|schema-validation|(?:^|-)validation(?:-|$)/,
|
|
166
|
+
label: "Zod schemas",
|
|
167
|
+
},
|
|
88
168
|
];
|
|
169
|
+
const FAMILY_CAPS = {
|
|
170
|
+
"browser-testing": 3,
|
|
171
|
+
documentation: 2,
|
|
172
|
+
"code-review": 2,
|
|
173
|
+
architecture: 2,
|
|
174
|
+
planning: 3,
|
|
175
|
+
security: 3,
|
|
176
|
+
"language-tooling": 5,
|
|
177
|
+
testing: 3,
|
|
178
|
+
uncategorized: 3,
|
|
179
|
+
};
|
|
180
|
+
function candidateFamily(unitId) {
|
|
181
|
+
if (/playwright|e2e|webapp-testing|browser-testing/.test(unitId))
|
|
182
|
+
return "browser-testing";
|
|
183
|
+
if (/docs|documentation|readme/.test(unitId))
|
|
184
|
+
return "documentation";
|
|
185
|
+
if (/review|refactor/.test(unitId))
|
|
186
|
+
return "code-review";
|
|
187
|
+
if (/architect|api-design|openapi/.test(unitId))
|
|
188
|
+
return "architecture";
|
|
189
|
+
if (/brainstorm|planning|writing-plans/.test(unitId))
|
|
190
|
+
return "planning";
|
|
191
|
+
if (/security|threat|secrets|owasp/.test(unitId))
|
|
192
|
+
return "security";
|
|
193
|
+
if (/test|coverage|vitest|jest/.test(unitId))
|
|
194
|
+
return "testing";
|
|
195
|
+
if (/javascript|typescript|nodejs|npm|package|cli|zod|schema/.test(unitId))
|
|
196
|
+
return "language-tooling";
|
|
197
|
+
return "uncategorized";
|
|
198
|
+
}
|
|
89
199
|
function selector(record) {
|
|
90
200
|
if (!record.unitId)
|
|
91
201
|
return undefined;
|
|
@@ -96,6 +206,10 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
|
|
|
96
206
|
if (!id || !record.unitId)
|
|
97
207
|
return undefined;
|
|
98
208
|
const name = record.unitId.toLowerCase();
|
|
209
|
+
if (/(?:^|-)jest(?:-|$)/.test(name) &&
|
|
210
|
+
project.tools.includes("vitest") &&
|
|
211
|
+
!project.tools.includes("jest"))
|
|
212
|
+
return undefined;
|
|
99
213
|
let score = 0;
|
|
100
214
|
const reasons = [];
|
|
101
215
|
if (pinned.has(id) || pinned.has(record.unitId)) {
|
|
@@ -103,6 +217,8 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
|
|
|
103
217
|
reasons.push("explicitly pinned");
|
|
104
218
|
}
|
|
105
219
|
const explicitlyPinned = score >= 1000;
|
|
220
|
+
if (!explicitlyPinned && !hasProjectCompatibility(name, project))
|
|
221
|
+
return undefined;
|
|
106
222
|
if (!explicitlyPinned && SPECIALIZED_WITHOUT_SIGNAL.test(name))
|
|
107
223
|
return undefined;
|
|
108
224
|
if (FOUNDATION.has(name)) {
|
|
@@ -136,25 +252,8 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
|
|
|
136
252
|
reasons: [...new Set(reasons)],
|
|
137
253
|
};
|
|
138
254
|
}
|
|
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
|
|
255
|
+
function rankedCandidates(records, project, pins, outcomes, taskFamilies) {
|
|
256
|
+
const scored = records
|
|
158
257
|
.filter((record) => record.activationState === "disabled" &&
|
|
159
258
|
record.cacheState === "downloaded" &&
|
|
160
259
|
record.reviewState === "reviewed")
|
|
@@ -165,19 +264,24 @@ export async function planProjectActivation(projectPath, options = {}) {
|
|
|
165
264
|
(SOURCE_PRIORITY[left.packageId] ?? 0) ||
|
|
166
265
|
left.unitId.localeCompare(right.unitId) ||
|
|
167
266
|
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
267
|
const unique = [];
|
|
171
268
|
const claimedUnits = new Set();
|
|
269
|
+
const familyCounts = new Map();
|
|
172
270
|
for (const item of scored) {
|
|
173
271
|
const fullPin = pins.has(item.selector);
|
|
174
272
|
if (!fullPin && claimedUnits.has(item.unitId))
|
|
175
273
|
continue;
|
|
274
|
+
const family = candidateFamily(item.unitId);
|
|
275
|
+
if (!fullPin && (familyCounts.get(family) ?? 0) >= FAMILY_CAPS[family])
|
|
276
|
+
continue;
|
|
176
277
|
claimedUnits.add(item.unitId);
|
|
278
|
+
familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
|
|
177
279
|
unique.push(item);
|
|
178
280
|
}
|
|
179
|
-
|
|
180
|
-
|
|
281
|
+
return unique;
|
|
282
|
+
}
|
|
283
|
+
function alternativesFor(selected, scored) {
|
|
284
|
+
return selected.flatMap((chosen) => {
|
|
181
285
|
const deferred = scored
|
|
182
286
|
.filter((candidate) => candidate.unitId === chosen.unitId &&
|
|
183
287
|
candidate.selector !== chosen.selector)
|
|
@@ -186,7 +290,67 @@ export async function planProjectActivation(projectPath, options = {}) {
|
|
|
186
290
|
? [{ unitId: chosen.unitId, selected: chosen.selector, deferred }]
|
|
187
291
|
: [];
|
|
188
292
|
});
|
|
293
|
+
}
|
|
294
|
+
/** Deterministic, local-only active-set selection over the reviewed library. */
|
|
295
|
+
export async function planProjectActivation(projectPath, options = {}) {
|
|
296
|
+
const limit = options.limit ?? 40;
|
|
297
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
298
|
+
throw new Error("--limit must be an integer from 1 to 200");
|
|
299
|
+
const project = await scanProject(projectPath);
|
|
300
|
+
const state = await readInstallState();
|
|
301
|
+
const outcomes = await readLocalOutcomes();
|
|
302
|
+
const taskFamilies = projectTaskFamilies(project);
|
|
303
|
+
const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
|
|
304
|
+
(!options.agents || options.agents.includes(record.agent)));
|
|
305
|
+
const requestedAgents = options.agents ?? [
|
|
306
|
+
...new Set(relevant.map((record) => record.agent)),
|
|
307
|
+
];
|
|
308
|
+
const detected = (await detectAgents()).filter((agent) => requestedAgents.includes(agent.id));
|
|
309
|
+
const inventory = await scanInstalledSkills(detected);
|
|
310
|
+
const pins = new Set(options.pins ?? []);
|
|
189
311
|
const warnings = [];
|
|
312
|
+
const agentPlans = requestedAgents.map((agent) => {
|
|
313
|
+
const summary = inventory.agents.find((item) => item.agent === agent);
|
|
314
|
+
if (!summary)
|
|
315
|
+
throw new Error(`Could not scan active skills for requested agent '${agent}'`);
|
|
316
|
+
const capacity = Math.max(0, limit - summary.total);
|
|
317
|
+
const ranked = rankedCandidates(relevant.filter((record) => record.agent === agent), project, pins, outcomes, taskFamilies);
|
|
318
|
+
const selected = ranked.slice(0, capacity);
|
|
319
|
+
if (!capacity)
|
|
320
|
+
warnings.push(`${summary.displayName} has reached the active-set limit ${limit}; disable skills or raise --limit.`);
|
|
321
|
+
return {
|
|
322
|
+
agent,
|
|
323
|
+
displayName: summary.displayName,
|
|
324
|
+
activeBefore: summary.total,
|
|
325
|
+
managedBefore: summary.managed,
|
|
326
|
+
unmanagedBefore: summary.unmanaged,
|
|
327
|
+
capacity,
|
|
328
|
+
selected,
|
|
329
|
+
alternatives: alternativesFor(selected, ranked),
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
const activationPlans = await Promise.all(agentPlans
|
|
333
|
+
.filter((agentPlan) => agentPlan.selected.length)
|
|
334
|
+
.map((agentPlan) => planActivationChange("enable", agentPlan.selected.map((item) => item.selector), { agents: [agentPlan.agent] })));
|
|
335
|
+
const activation = activationPlans.length
|
|
336
|
+
? {
|
|
337
|
+
action: "enable",
|
|
338
|
+
packages: [
|
|
339
|
+
...new Set(activationPlans.flatMap((plan) => plan.packages)),
|
|
340
|
+
],
|
|
341
|
+
requestedAgents,
|
|
342
|
+
changes: activationPlans.flatMap((plan) => plan.changes),
|
|
343
|
+
skipped: activationPlans.flatMap((plan) => plan.skipped),
|
|
344
|
+
blocked: activationPlans.some((plan) => plan.blocked),
|
|
345
|
+
warnings: activationPlans.flatMap((plan) => plan.warnings),
|
|
346
|
+
}
|
|
347
|
+
: undefined;
|
|
348
|
+
const selected = [
|
|
349
|
+
...new Map(agentPlans
|
|
350
|
+
.flatMap((agentPlan) => agentPlan.selected)
|
|
351
|
+
.map((item) => [item.selector, item])).values(),
|
|
352
|
+
];
|
|
353
|
+
const alternatives = agentPlans.flatMap((item) => item.alternatives);
|
|
190
354
|
const known = new Set(relevant.flatMap((record) => {
|
|
191
355
|
const id = selector(record);
|
|
192
356
|
return id ? [id, record.unitId] : [];
|
|
@@ -194,17 +358,13 @@ export async function planProjectActivation(projectPath, options = {}) {
|
|
|
194
358
|
const unknownPins = [...pins].filter((pin) => !known.has(pin));
|
|
195
359
|
if (unknownPins.length)
|
|
196
360
|
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
361
|
return {
|
|
203
362
|
project,
|
|
204
363
|
limit,
|
|
205
364
|
...(options.agents ? { agents: options.agents } : {}),
|
|
206
|
-
|
|
207
|
-
|
|
365
|
+
agentPlans,
|
|
366
|
+
activeBefore: Math.max(0, ...agentPlans.map((item) => item.activeBefore)),
|
|
367
|
+
capacity: Math.min(limit, ...agentPlans.map((item) => item.capacity)),
|
|
208
368
|
selected,
|
|
209
369
|
alternatives,
|
|
210
370
|
...(activation ? { activation } : {}),
|
|
@@ -214,17 +374,31 @@ export async function planProjectActivation(projectPath, options = {}) {
|
|
|
214
374
|
export async function applyProjectActivation(plan) {
|
|
215
375
|
if (!plan.activation || !plan.selected.length)
|
|
216
376
|
throw new Error("No reviewed library skills were selected for activation");
|
|
217
|
-
return applyActivationChange(plan.activation
|
|
377
|
+
return applyActivationChange(plan.activation, {
|
|
378
|
+
preflight: async () => {
|
|
379
|
+
const requested = plan.agentPlans.map((item) => item.agent);
|
|
380
|
+
const detected = (await detectAgents()).filter((agent) => requested.includes(agent.id));
|
|
381
|
+
const inventory = await scanInstalledSkills(detected);
|
|
382
|
+
for (const agentPlan of plan.agentPlans) {
|
|
383
|
+
const current = inventory.agents.find((item) => item.agent === agentPlan.agent);
|
|
384
|
+
if (!current)
|
|
385
|
+
throw new Error(`Could not re-scan active skills for requested agent '${agentPlan.agent}'`);
|
|
386
|
+
if (current.total + agentPlan.selected.length > plan.limit)
|
|
387
|
+
throw new Error(`${agentPlan.displayName} active skill capacity changed after preview: ${current.total} active plus ${agentPlan.selected.length} proposed exceeds limit ${plan.limit}`);
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
});
|
|
218
391
|
}
|
|
219
392
|
export function formatProjectActivation(plan) {
|
|
220
|
-
const detected = [...plan.project.languages, ...plan.project.frameworks];
|
|
221
393
|
return [
|
|
222
394
|
`Project: ${plan.project.root}`,
|
|
223
|
-
`Detected: ${
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
395
|
+
`Detected: ${formatDetectedSignals(plan.project) || "no known project signals"}`,
|
|
396
|
+
...plan.agentPlans.flatMap((agentPlan) => [
|
|
397
|
+
`${agentPlan.displayName}: ${agentPlan.activeBefore} active (${agentPlan.managedBefore} managed, ${agentPlan.unmanagedBefore} unmanaged); ${agentPlan.capacity}/${plan.limit} slots available`,
|
|
398
|
+
`Proposed additions for ${agentPlan.displayName}: ${agentPlan.selected.length}`,
|
|
399
|
+
...agentPlan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
|
|
400
|
+
...agentPlan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
|
|
401
|
+
]),
|
|
228
402
|
...(plan.activation
|
|
229
403
|
? ["", "Exact activation delta:", formatActivationPlan(plan.activation)]
|
|
230
404
|
: []),
|
|
@@ -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,
|
|
@@ -358,7 +367,7 @@ export async function applyActivationChange(plan) {
|
|
|
358
367
|
}
|
|
359
368
|
state.activations = records.map((record) => replacements.get(activationKey(record)) ?? record);
|
|
360
369
|
await writeInstallState(state);
|
|
361
|
-
});
|
|
370
|
+
}, { label: `${plan.action} project skill set` });
|
|
362
371
|
return applied.snapshotId;
|
|
363
372
|
}
|
|
364
373
|
function formatLibraryHeadline(report) {
|
package/dist/src/core/adopt.js
CHANGED
|
@@ -208,7 +208,7 @@ export async function applySkillAdoption(plan, options = {}) {
|
|
|
208
208
|
throw new Error("The skill changed after preview; scan again before adopting it.");
|
|
209
209
|
},
|
|
210
210
|
});
|
|
211
|
-
});
|
|
211
|
+
}, { label: `adopt ${plan.packageId}` });
|
|
212
212
|
return applied.snapshotId;
|
|
213
213
|
}
|
|
214
214
|
export function formatAdoptionPlan(plan) {
|
|
@@ -77,7 +77,8 @@ export function formatCatalogCoverage(report) {
|
|
|
77
77
|
.join(", ");
|
|
78
78
|
return [
|
|
79
79
|
`Screened catalog: ${report.technicallyScreenedRecords}/${report.records} technically complete records (target ${report.targetRecords})`,
|
|
80
|
-
`
|
|
80
|
+
`Catalog maturity: ${report.records} sourced · ${report.technicallyScreenedRecords} technically inspected · ${report.recommendedRecords} selected for Stable`,
|
|
81
|
+
`Higher-confidence evidence: ${report.trustStages["human-reviewed"]} stored human-review attestations · ${report.trustStages.benchmarked} signed benchmark results`,
|
|
81
82
|
`Evidence: ${report.immutablePins} immutable pins · ${report.assertedLicenses} asserted licenses · ${report.noAssertionLicenses} NOASSERTION`,
|
|
82
83
|
`Coverage: ${Object.values(report.categories).length} categories · ${report.evaluationReady} evaluation-ready · ${report.activityObserved} with refreshed activity`,
|
|
83
84
|
`Install shape: ${report.installShapes.skills} skill · ${report.installShapes.mcpOnly} MCP-only · ${report.installShapes.mixed} mixed`,
|
|
@@ -123,14 +123,17 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
123
123
|
catch (error) {
|
|
124
124
|
completed += 1;
|
|
125
125
|
const reason = error instanceof Error ? error.message : String(error);
|
|
126
|
+
const fullyQuarantined = rejected.size > 0 && /No SKILL\.md found/.test(reason);
|
|
126
127
|
options.onProgress?.({
|
|
127
128
|
packageId: pkg.id,
|
|
128
129
|
completed,
|
|
129
130
|
total: installable.length,
|
|
130
131
|
status: "skipped",
|
|
131
|
-
message:
|
|
132
|
+
message: fullyQuarantined
|
|
133
|
+
? `${pkg.displayName}: all ${rejected.size} discovered skill unit(s) were quarantined`
|
|
134
|
+
: `${pkg.displayName} could not be prepared: ${reason}`,
|
|
132
135
|
});
|
|
133
|
-
if (
|
|
136
|
+
if (fullyQuarantined)
|
|
134
137
|
return { quarantined: [...rejected.values()] };
|
|
135
138
|
return {
|
|
136
139
|
result: {
|
|
@@ -195,7 +198,7 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
195
198
|
reconciliation,
|
|
196
199
|
};
|
|
197
200
|
}
|
|
198
|
-
export function formatPreparedCatalogInstall(prepared) {
|
|
201
|
+
export function formatPreparedCatalogInstall(prepared, options = {}) {
|
|
199
202
|
const targetDirectories = prepared.entries.reduce((total, entry) => total + entry.plan.files.length, 0);
|
|
200
203
|
const directoriesPerAgent = prepared.agents.length
|
|
201
204
|
? Math.ceil(targetDirectories / prepared.agents.length)
|
|
@@ -215,11 +218,13 @@ export function formatPreparedCatalogInstall(prepared) {
|
|
|
215
218
|
"Automatic skill setup does not require an OpenAI, Anthropic, or OpenRouter API key; credentialed MCP/runtime integrations remain explicit and deferred.",
|
|
216
219
|
];
|
|
217
220
|
if (directoriesPerAgent > RECOMMENDED_ACTIVE_SKILL_LIMIT)
|
|
218
|
-
lines.push(`Capacity
|
|
221
|
+
lines.push(`Capacity notice: about ${directoriesPerAgent} skill directories per agent exceeds Stable's ${RECOMMENDED_ACTIVE_SKILL_LIMIT}-skill bound.${prepared.selection.mode === "maximum" ? " Maximum stores them in the disabled library; optimize or activate a project-relevant working set." : prepared.selection.mode === "power" ? " Power is the explicit larger active mode; choose Stable or project optimization when lower context use matters." : " Use project-aware activation for a smaller working set."}`);
|
|
219
222
|
if (failures.length)
|
|
220
223
|
lines.push(`Preparation failures (installation will remain blocked): ${failures.map((item) => item.packageId).join(", ")}`);
|
|
221
224
|
if (quarantined.length)
|
|
222
225
|
lines.push(`Quarantined invalid skill units: ${quarantined.length} (safe siblings remain available)`);
|
|
226
|
+
if (explicit.length)
|
|
227
|
+
lines.push(`Deferred integration setup: ${explicit.length} record(s) remain opt-in through MCP/runtime commands.`);
|
|
223
228
|
if (prepared.collisions.length)
|
|
224
229
|
lines.push(`Overlapping skill targets resolved: ${prepared.collisions.length} lower-ranked duplicate directories deferred`);
|
|
225
230
|
if (retired.length) {
|
|
@@ -228,11 +233,19 @@ export function formatPreparedCatalogInstall(prepared) {
|
|
|
228
233
|
lines.push(`Retire ${item.packageId}${item.unitId ? `/${item.unitId}` : ""} from ${item.agent}`);
|
|
229
234
|
}
|
|
230
235
|
if (risky.length)
|
|
231
|
-
lines.push(
|
|
236
|
+
lines.push(options.details
|
|
237
|
+
? `Additional risk approval required: ${risky.map((entry) => entry.package.id).join(", ")}`
|
|
238
|
+
: `Additional risk approval required for ${risky.length} repositor${risky.length === 1 ? "y" : "ies"}. Run the preview with --details before approval.`);
|
|
232
239
|
for (const warning of prepared.resolution.warnings)
|
|
233
240
|
lines.push(`Warning: ${warning}`);
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
if (failures.length)
|
|
242
|
+
for (const item of failures)
|
|
243
|
+
lines.push(`Failed ${item.packageId}${item.unitId ? `/${item.unitId}` : ""}: ${item.reason}`);
|
|
244
|
+
if (options.details)
|
|
245
|
+
for (const item of prepared.skipped.filter((item) => item.kind !== "preparation-failed"))
|
|
246
|
+
lines.push(`${item.kind === "quarantined" ? "Quarantined" : "Deferred"} ${item.packageId}${item.unitId ? `/${item.unitId}` : ""}: ${item.reason}`);
|
|
247
|
+
else if (quarantined.length || explicit.length)
|
|
248
|
+
lines.push("Use --details to show every quarantined or deferred unit.");
|
|
236
249
|
return lines.join("\n");
|
|
237
250
|
}
|
|
238
251
|
export async function applyPreparedCatalogInstall(prepared, options = {}) {
|
|
@@ -258,5 +271,7 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
|
|
|
258
271
|
});
|
|
259
272
|
}
|
|
260
273
|
export function formatCatalogApplyGuidance(riskApprovalRequired) {
|
|
261
|
-
return
|
|
274
|
+
return riskApprovalRequired
|
|
275
|
+
? "Preview complete; nothing was changed. Inspect the plan with --details, then re-run with --yes --approve-risk only if you accept every reported finding."
|
|
276
|
+
: "Preview complete; nothing was changed. Re-run with --yes to install this exact screened plan.";
|
|
262
277
|
}
|
|
@@ -7,6 +7,7 @@ START HERE
|
|
|
7
7
|
|
|
8
8
|
2. Preview the recommended everyday setup
|
|
9
9
|
loadout setup --mode stable
|
|
10
|
+
loadout profiles
|
|
10
11
|
|
|
11
12
|
3. Find additions that fit the project in this folder
|
|
12
13
|
loadout recommend --project .
|
|
@@ -17,8 +18,10 @@ START HERE
|
|
|
17
18
|
loadout alerts
|
|
18
19
|
loadout candidate list --limit 10
|
|
19
20
|
|
|
20
|
-
5.
|
|
21
|
-
loadout
|
|
21
|
+
5. See reviewed MCP integrations and their credential needs
|
|
22
|
+
loadout mcp-recipe
|
|
23
|
+
loadout mcp-recipe --credential-free
|
|
24
|
+
loadout mcp-recipe playwright --agent codex
|
|
22
25
|
|
|
23
26
|
If you decide to install something, Loadout shows a preview first and creates a
|
|
24
27
|
snapshot before changing managed files. Recover with: loadout rollback
|
|
@@ -16,6 +16,49 @@ function tomlString(value) {
|
|
|
16
16
|
function tableHeader(name) {
|
|
17
17
|
return `[mcp_servers.${tomlString(name)}]`;
|
|
18
18
|
}
|
|
19
|
+
function tablePrefix(name) {
|
|
20
|
+
return `[mcp_servers.${tomlString(name)}`;
|
|
21
|
+
}
|
|
22
|
+
export function codexMcpServerBlock(content, name) {
|
|
23
|
+
safeName(name);
|
|
24
|
+
const lines = content.match(/.*(?:\n|$)/g)?.filter(Boolean) ?? [];
|
|
25
|
+
const header = tableHeader(name);
|
|
26
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
27
|
+
if (start < 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
const prefix = tablePrefix(name);
|
|
30
|
+
let end = start + 1;
|
|
31
|
+
while (end < lines.length) {
|
|
32
|
+
const trimmed = lines[end].trim();
|
|
33
|
+
if (trimmed.startsWith("[") && !trimmed.startsWith(`${prefix}.`))
|
|
34
|
+
break;
|
|
35
|
+
end += 1;
|
|
36
|
+
}
|
|
37
|
+
return lines.slice(start, end).join("");
|
|
38
|
+
}
|
|
39
|
+
export function codexMcpServerFingerprint(content, name) {
|
|
40
|
+
const block = codexMcpServerBlock(content, name);
|
|
41
|
+
return block ? createHash("sha256").update(block).digest("hex") : undefined;
|
|
42
|
+
}
|
|
43
|
+
export function removeCodexMcpServerBlock(content, name) {
|
|
44
|
+
const block = codexMcpServerBlock(content, name);
|
|
45
|
+
if (!block)
|
|
46
|
+
return content;
|
|
47
|
+
return content.replace(block, "").replace(/\n{3,}/g, "\n\n");
|
|
48
|
+
}
|
|
49
|
+
export async function writeCodexMcpConfigContent(path, content) {
|
|
50
|
+
const target = resolve(path);
|
|
51
|
+
await mkdir(dirname(target), { recursive: true });
|
|
52
|
+
const temporary = join(dirname(target), `.${basename(target)}.loadout-${randomUUID()}.tmp`);
|
|
53
|
+
try {
|
|
54
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
55
|
+
await rename(temporary, target);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
await rm(temporary, { force: true });
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
19
62
|
export function defaultCodexMcpConfigPath() {
|
|
20
63
|
return join(userHome(), ".codex", "config.toml");
|
|
21
64
|
}
|
|
@@ -73,7 +116,7 @@ export async function planCodexMcpConfig(path, server, name = server.name) {
|
|
|
73
116
|
async function snapshotPath(id) {
|
|
74
117
|
return join(loadoutHome(), "mcp-snapshots", `${id}.json`);
|
|
75
118
|
}
|
|
76
|
-
export async function applyCodexMcpConfigPlan(plan) {
|
|
119
|
+
export async function applyCodexMcpConfigPlan(plan, options = {}) {
|
|
77
120
|
const applied = await runMutationTransaction(async () => {
|
|
78
121
|
let content = "";
|
|
79
122
|
let existed = true;
|
|
@@ -100,10 +143,10 @@ export async function applyCodexMcpConfigPlan(plan) {
|
|
|
100
143
|
};
|
|
101
144
|
const stored = await snapshotPath(snapshot.id);
|
|
102
145
|
return {
|
|
103
|
-
targets: [plan.path, stored],
|
|
146
|
+
targets: [plan.path, stored, ...(options.extraTargets ?? [])],
|
|
104
147
|
value: { plan, snapshot, stored },
|
|
105
148
|
};
|
|
106
|
-
}, async ({ plan: freshPlan, snapshot, stored }) => {
|
|
149
|
+
}, async ({ plan: freshPlan, snapshot, stored }, transactionSnapshot) => {
|
|
107
150
|
await mkdir(dirname(freshPlan.path), { recursive: true });
|
|
108
151
|
const temporary = join(dirname(freshPlan.path), `.${basename(freshPlan.path)}.loadout-${randomUUID()}.tmp`);
|
|
109
152
|
try {
|
|
@@ -111,12 +154,13 @@ export async function applyCodexMcpConfigPlan(plan) {
|
|
|
111
154
|
await rename(temporary, freshPlan.path);
|
|
112
155
|
await mkdir(dirname(stored), { recursive: true });
|
|
113
156
|
await writeFile(stored, JSON.stringify(snapshot), { mode: 0o600 });
|
|
157
|
+
await options.afterApply?.(snapshot, transactionSnapshot);
|
|
114
158
|
}
|
|
115
159
|
catch (error) {
|
|
116
160
|
await rm(temporary, { force: true });
|
|
117
161
|
throw error;
|
|
118
162
|
}
|
|
119
163
|
return snapshot;
|
|
120
|
-
});
|
|
121
|
-
return applied.result;
|
|
164
|
+
}, { label: `configure Codex MCP ${plan.serverName}` });
|
|
165
|
+
return { ...applied.result, rollbackSnapshotId: applied.snapshotId };
|
|
122
166
|
}
|
|
@@ -16,8 +16,6 @@ const commands = [
|
|
|
16
16
|
"add",
|
|
17
17
|
"unadd",
|
|
18
18
|
"list",
|
|
19
|
-
"demo",
|
|
20
|
-
"test-drive",
|
|
21
19
|
"scan",
|
|
22
20
|
"status",
|
|
23
21
|
"versions",
|
|
@@ -86,7 +84,6 @@ const commands = [
|
|
|
86
84
|
"outcomes",
|
|
87
85
|
"remove",
|
|
88
86
|
"uninstall",
|
|
89
|
-
"dashboard",
|
|
90
87
|
"serve",
|
|
91
88
|
"completion",
|
|
92
89
|
];
|