loadout-ai 0.4.0 → 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 +25 -0
- package/MASTER_PLAN.md +94 -3
- package/README.md +2 -2
- package/dist/src/cli.js +1 -1
- package/dist/src/core/active-policy.js +172 -38
- package/dist/src/core/active-set.js +13 -4
- package/dist/src/core/install.js +3 -36
- package/dist/src/core/recommend.js +95 -9
- package/dist/src/core/target-occupancy.js +50 -0
- package/docs/USER_TEST_GUIDE.md +17 -5
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1 - 2026-07-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Detect local Node CLI, npm package, release, Vitest, Commander, Zod, MCP,
|
|
8
|
+
and security signals when recommending project tooling.
|
|
9
|
+
- Label recommendations as skill libraries, explicit MCP/runtime setup, or
|
|
10
|
+
unavailable instead of presenting every catalog record as automatically
|
|
11
|
+
activatable.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Calculate project activation capacity separately for each agent and count
|
|
16
|
+
both Loadout-managed and pre-existing unmanaged skills toward `--limit`.
|
|
17
|
+
- Prefer compact, diverse project skill sets and reject mismatched Jest-only
|
|
18
|
+
guidance for Vitest-only repositories.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Allow project activation through recursively empty directories restored by
|
|
23
|
+
rollback while continuing to block files, symlinks, unreadable paths, and
|
|
24
|
+
unsupported entries.
|
|
25
|
+
- Re-check per-agent capacity and target occupancy inside the activation
|
|
26
|
+
transaction so filesystem changes after preview abort without partial edits.
|
|
27
|
+
|
|
3
28
|
## 0.4.0 - 2026-07-20
|
|
4
29
|
|
|
5
30
|
### Added
|
package/MASTER_PLAN.md
CHANGED
|
@@ -92,9 +92,12 @@ merged `codex/relatable-readme-hero` remote branch remains safe to delete after
|
|
|
92
92
|
observed failure and turn reproducible ones into regression tests.
|
|
93
93
|
- [ ] `P18-13 [HUMAN+TERRA]` Publish `loadout-ai@0.4.0` to npm, then test that exact
|
|
94
94
|
registry tarball in a fresh terminal through Stable -> rollback -> Power ->
|
|
95
|
-
rollback -> Maximum -> project optimization ->
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
rollback -> Maximum -> project optimization -> complete uninstall.
|
|
96
|
+
Publication completed on July 20. A clean temporary install resolved the exact
|
|
97
|
+
registry tarball, reported version `0.4.0`, and completed `loadout demo` with
|
|
98
|
+
rollback verification. The founder's real Codex/Claude CLI lifecycle test
|
|
99
|
+
remains required before this item can be checked; dashboard testing was removed
|
|
100
|
+
after founder review rejected it as a conflicting product surface.
|
|
98
101
|
- [ ] `P18-17 [HUMAN]` Resolve the GitHub account billing or spending-limit condition
|
|
99
102
|
that prevents Actions jobs from starting, rerun CI and daily discovery on the
|
|
100
103
|
integrated commit, and record the result. CI runs
|
|
@@ -107,6 +110,94 @@ merged `codex/relatable-readme-hero` remote branch remains safe to delete after
|
|
|
107
110
|
GitHub currently returns 404 for the protection endpoint, so protection is
|
|
108
111
|
absent or not observable; do not describe it as enabled.
|
|
109
112
|
|
|
113
|
+
### Founder acceptance findings and 0.4.1 corrections
|
|
114
|
+
|
|
115
|
+
- [x] `P18-21A [HUMAN+TERRA]` Verify the published 0.4.0 Stable lifecycle on the
|
|
116
|
+
founder's real Claude Code and Codex paths. Stable installed four pinned sources
|
|
117
|
+
as 60 managed activations, preserved all 12 unmanaged Claude skills, reported no
|
|
118
|
+
managed drift, and restored the explicit pre-install snapshot successfully.
|
|
119
|
+
- [ ] `P18-21B [TERRA]` Make rollback history understandable before 0.4.1. The first
|
|
120
|
+
founder test exposed that bare `loadout rollback` selected a newer no-op
|
|
121
|
+
dashboard/sync snapshot whose pre-state already contained Stable. No data was
|
|
122
|
+
lost, but `rollback --list` showed opaque IDs only and `Restored snapshot` did
|
|
123
|
+
not disclose that zero effective files changed. Add timestamp, mutation kind,
|
|
124
|
+
affected scope, and no-op/current-state guidance; add a regression journey with
|
|
125
|
+
two adjacent snapshots and explicit older-snapshot rollback.
|
|
126
|
+
- [ ] `P18-22 [TERRA]` Remove the dashboard before the public release. Founder review
|
|
127
|
+
confirmed that it presents recommendation presets (`stable`, `web`,
|
|
128
|
+
`collaboration`, `maximum`) as policy profiles while the real CLI contract is
|
|
129
|
+
`stable`, `power`, `maximum`, and `custom`; its manifest-sync mutation model is
|
|
130
|
+
not the CLI setup workflow. Remove the command, loopback server, browser assets,
|
|
131
|
+
dashboard-only dependencies/tests/docs/evidence, and npm package contents.
|
|
132
|
+
Preserve any unique useful inspection capability through existing CLI commands.
|
|
133
|
+
- [ ] `P18-23 [HUMAN+TERRA]` Complete the remaining CLI-only founder path on the exact
|
|
134
|
+
npm package. Power and its explicit rollback are complete: snapshot
|
|
135
|
+
`1784546929191-9cfb0705dbed` restored zero Loadout-managed activations, retained
|
|
136
|
+
all 12 unmanaged Claude skills, returned Codex to zero skills, and left no
|
|
137
|
+
duplicate groups. Maximum setup is also complete: snapshot
|
|
138
|
+
`1784547473319-7581eb19ee9c` stored 2,316 screened skill copies from 29 packages
|
|
139
|
+
in the disabled library, activated none of them, and again preserved the 12
|
|
140
|
+
unmanaged Claude skills. Remaining path: project activation -> recommendation/
|
|
141
|
+
optimization -> Graphify install/remove -> credential-free MCP inventory ->
|
|
142
|
+
read-only update/discovery -> complete uninstall -> reinstall. Record every
|
|
143
|
+
mutation snapshot ID and use explicit rollback IDs during acceptance.
|
|
144
|
+
- [ ] `P18-24 [SOL+TERRA]` Redesign Power before 0.4.1 based on the real founder run.
|
|
145
|
+
The published transaction itself passed: eight immutable sources installed for
|
|
146
|
+
Claude Code and Codex, 100 managed activations were created, 12 unmanaged Claude
|
|
147
|
+
skills were preserved, duplicate targets were resolved, six rejected skill
|
|
148
|
+
units were excluded, and 1,170 managed files reported zero drift. The profile
|
|
149
|
+
policy failed acceptance: it activates 50 skills per agent despite Loadout's
|
|
150
|
+
recommended limit of 30; every selected package retained blocking static-risk
|
|
151
|
+
findings (79 total); only five of eight sources have an asserted SPDX license;
|
|
152
|
+
and one coarse prompt approves all package findings. Make Power a broad managed
|
|
153
|
+
library with no more than 30 project-relevant skills active per agent, preserve
|
|
154
|
+
per-package approval and rejection choices, persist and display excluded-unit
|
|
155
|
+
quarantine evidence, and make the health headline reflect blocking risk and
|
|
156
|
+
capacity failures instead of reporting the overall installation as healthy.
|
|
157
|
+
Add a release test proving Power preserves unmanaged skills, never exceeds its
|
|
158
|
+
active cap, reports every excluded unit and retained finding, and restores its
|
|
159
|
+
explicit setup snapshot without residue. The founder's published-package run
|
|
160
|
+
has now demonstrated that final rollback property successfully.
|
|
161
|
+
- [ ] `P18-25 [LUNA+TERRA]` Make health output honest and focused for empty or unmanaged
|
|
162
|
+
profiles. After the successful Power rollback, `loadout health --explain` led
|
|
163
|
+
with `Loadout health: healthy` and then reported 28/100 critical evidence for
|
|
164
|
+
Claude Code, 0/100 unknown for Codex, and similarly verbose sections for every
|
|
165
|
+
detected agent. Use an explicit `not configured`/`no managed packages` overall
|
|
166
|
+
state instead of healthy, keep the default summary concise, and let users scope
|
|
167
|
+
detailed evidence to selected agents while preserving the full explanation on
|
|
168
|
+
request. Add empty-state and mixed detected-agent CLI regression tests.
|
|
169
|
+
- [ ] `P18-26 [LUNA+TERRA]` Make Maximum's preview understandable without weakening
|
|
170
|
+
its safe defaults. The founder run proved the disabled-library contract, but
|
|
171
|
+
printed 50 unit-level quarantine blocks, 19 expected MCP/runtime deferrals, two
|
|
172
|
+
`No SKILL.md` preparation failures, and one coarse 28-package risk approval in
|
|
173
|
+
the default path. The subsequent scan reported 11 repository failures without
|
|
174
|
+
naming them. Default to a concise grouped summary with exact counts, severity,
|
|
175
|
+
source, and next commands; retain complete findings behind an explicit details
|
|
176
|
+
or JSON view. Classify no-skill MCP/runtime records as explicit setup rather
|
|
177
|
+
than failed skill preparation, distinguish high-confidence findings from likely
|
|
178
|
+
documentation/binary/metadata false positives, persist every excluded unit, and
|
|
179
|
+
name every failed repository with a short reason. Add snapshot tests for both
|
|
180
|
+
concise and detailed Maximum output.
|
|
181
|
+
- [x] `P18-27 [SOL+TERRA, LUNA copy review]` Correct project-aware activation safety
|
|
182
|
+
and relevance using
|
|
183
|
+
`docs/superpowers/specs/2026-07-20-project-activation-safety-design.md`.
|
|
184
|
+
Founder preview proved that activation currently ignores 12 unmanaged Claude
|
|
185
|
+
skills when calculating `--limit 30`, treats rollback-restored empty directories
|
|
186
|
+
as occupied for both agents, and proposes a generic, redundant 30-skill set from
|
|
187
|
+
only JavaScript/TypeScript and Playwright signals. Implement the approved shared
|
|
188
|
+
empty-target predicate, per-agent total active capacity, bounded Node CLI/npm/
|
|
189
|
+
Vitest/MCP project signals, diverse evidence-threshold selection, integration-
|
|
190
|
+
type labels, atomic apply revalidation, and the specified regression suite.
|
|
191
|
+
Implementation is complete on `codex/project-activation-safety`: the full local
|
|
192
|
+
release gate passes, including 114 unit-test files with 605 passing tests and
|
|
193
|
+
one intentional skip; the disposable two-agent CLI journey proves 12 unmanaged
|
|
194
|
+
Claude skills, distinct Claude/Codex budgets, empty rollback residue, one atomic
|
|
195
|
+
apply, and explicit rollback; the packed artifact is 468.0 kB with 145 files;
|
|
196
|
+
and the fresh scan benchmark passed at 1,583.5 ms p95 across seven real CLI
|
|
197
|
+
runs. Do
|
|
198
|
+
not resume real-profile activation until this branch is reviewed, integrated,
|
|
199
|
+
versioned, and published as the corrected release.
|
|
200
|
+
|
|
110
201
|
### Release 0.3 lifecycle hardening
|
|
111
202
|
|
|
112
203
|
- [x] `P18-09 [SOL+TERRA]` Add preview-first complete uninstall with modified-file
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
</p>
|
|
27
27
|
|
|
28
28
|
> [!IMPORTANT]
|
|
29
|
-
> Installation is version-pinned so the code you test matches these docs. The commands below target `loadout-ai@0.4.
|
|
29
|
+
> Installation is version-pinned so the code you test matches these docs. The commands below target `loadout-ai@0.4.1`; review the preview before every apply.
|
|
30
30
|
|
|
31
31
|
## How it works
|
|
32
32
|
|
|
@@ -76,7 +76,7 @@ Skills, plugins, MCP servers, and agent settings tend to accumulate one experime
|
|
|
76
76
|
You need Node.js 20 or newer and Git.
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
|
-
npm install --global loadout-ai@0.4.
|
|
79
|
+
npm install --global loadout-ai@0.4.1
|
|
80
80
|
loadout --version
|
|
81
81
|
loadout guide
|
|
82
82
|
```
|
package/dist/src/cli.js
CHANGED
|
@@ -265,7 +265,7 @@ async function runSetup(options) {
|
|
|
265
265
|
reader?.close();
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
|
-
const LOADOUT_VERSION = "0.4.
|
|
268
|
+
const LOADOUT_VERSION = "0.4.1";
|
|
269
269
|
function durableSchedulerLauncher() {
|
|
270
270
|
return [
|
|
271
271
|
join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
|
|
@@ -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,
|
package/dist/src/core/install.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { cp, lstat,
|
|
2
|
+
import { cp, lstat, rm } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, posix, relative, win32, } from "node:path";
|
|
4
4
|
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
5
5
|
import { planAdapterSkillInstall } from "./adapters.js";
|
|
6
6
|
import { applySkillPlan, detectInstallConflicts } from "./skills.js";
|
|
7
7
|
import { activationLibraryPath, installStatePath, recordInstall, recordInstallBatch, recordLibraryInstallBatch, readInstallState, hashDirectory, writeInstallState, } from "./state.js";
|
|
8
8
|
import { runMutationTransaction } from "./transaction.js";
|
|
9
|
+
import { inspectTargetOccupancy } from "./target-occupancy.js";
|
|
9
10
|
export function installedAgents(agents, requested) {
|
|
10
11
|
const available = agents.filter((agent) => agent.installed);
|
|
11
12
|
if (!requested || requested.length === 0)
|
|
@@ -116,42 +117,8 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
|
|
|
116
117
|
for (const target of [
|
|
117
118
|
...new Set(plans.flatMap((plan) => plan.files.map((file) => file.target))),
|
|
118
119
|
])
|
|
119
|
-
|
|
120
|
-
const info = await lstat(target);
|
|
121
|
-
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
122
|
-
const queue = [target];
|
|
123
|
-
let entriesChecked = 0;
|
|
124
|
-
let empty = true;
|
|
125
|
-
while (queue.length && empty) {
|
|
126
|
-
const directory = queue.pop();
|
|
127
|
-
for (const entry of await readdir(directory, {
|
|
128
|
-
withFileTypes: true,
|
|
129
|
-
})) {
|
|
130
|
-
entriesChecked += 1;
|
|
131
|
-
if (entriesChecked > 10_000) {
|
|
132
|
-
empty = false;
|
|
133
|
-
break;
|
|
134
|
-
}
|
|
135
|
-
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
136
|
-
queue.push(join(directory, entry.name));
|
|
137
|
-
else {
|
|
138
|
-
empty = false;
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (empty)
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
120
|
+
if ((await inspectTargetOccupancy(target)).occupied)
|
|
146
121
|
occupied.push(target);
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
if (!error ||
|
|
150
|
-
typeof error !== "object" ||
|
|
151
|
-
!("code" in error) ||
|
|
152
|
-
error.code !== "ENOENT")
|
|
153
|
-
throw error;
|
|
154
|
-
}
|
|
155
122
|
if (occupied.length && options.allowManagedReplacement) {
|
|
156
123
|
const state = await readInstallState();
|
|
157
124
|
const allowed = new Map();
|