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 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 -> dashboard -> complete uninstall.
96
- The registry exposed versions only through `0.3.1` before this release attempt;
97
- local package metadata and a Git tag do not prove publication.
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.0`; review the preview before every apply.
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.0
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.0";
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 { scanProject } from "./recommend.js";
2
+ import { detectAgents } from "./paths.js";
3
+ import { formatDetectedSignals, scanProject } from "./recommend.js";
4
+ import { scanInstalledSkills } from "./skill-inventory.js";
3
5
  import { readInstallState } from "./state.js";
4
6
  import { outcomeAdjustment, projectTaskFamilies, readLocalOutcomes, } from "./outcomes.js";
5
7
  const FOUNDATION = new Set([
@@ -85,7 +87,77 @@ const SIGNAL_RULES = [
85
87
  pattern: /playwright|e2e|webapp-testing/,
86
88
  label: "Playwright project",
87
89
  },
90
+ {
91
+ signal: (project) => project.roles.includes("node-cli"),
92
+ pattern: /(?:^|-)cli(?:-|$)|command-line/,
93
+ label: "Node CLI",
94
+ },
95
+ {
96
+ signal: (project) => project.roles.includes("npm-package"),
97
+ pattern: /npm|package|publish/,
98
+ label: "npm package",
99
+ },
100
+ {
101
+ signal: (project) => project.roles.includes("release"),
102
+ pattern: /release|publish|package/,
103
+ label: "release automation",
104
+ },
105
+ {
106
+ signal: (project) => project.roles.includes("mcp"),
107
+ pattern: /mcp|model-context-protocol/,
108
+ label: "MCP tooling",
109
+ },
110
+ {
111
+ signal: (project) => project.roles.includes("security"),
112
+ pattern: /security|threat|secrets|owasp/,
113
+ label: "security policy",
114
+ },
115
+ {
116
+ signal: (project) => project.tools.includes("vitest"),
117
+ pattern: /vitest/,
118
+ label: "Vitest",
119
+ },
120
+ {
121
+ signal: (project) => project.tools.includes("commander"),
122
+ pattern: /(?:^|-)cli(?:-|$)|commander|command-line/,
123
+ label: "Commander CLI",
124
+ },
125
+ {
126
+ signal: (project) => project.tools.includes("zod"),
127
+ pattern: /zod|schema|validation/,
128
+ label: "Zod schemas",
129
+ },
88
130
  ];
131
+ const FAMILY_CAPS = {
132
+ "browser-testing": 3,
133
+ documentation: 2,
134
+ "code-review": 2,
135
+ architecture: 2,
136
+ planning: 3,
137
+ security: 3,
138
+ "language-tooling": 5,
139
+ testing: 3,
140
+ uncategorized: 3,
141
+ };
142
+ function candidateFamily(unitId) {
143
+ if (/playwright|e2e|webapp-testing|browser-testing/.test(unitId))
144
+ return "browser-testing";
145
+ if (/docs|documentation|readme/.test(unitId))
146
+ return "documentation";
147
+ if (/review|refactor/.test(unitId))
148
+ return "code-review";
149
+ if (/architect|api-design|openapi/.test(unitId))
150
+ return "architecture";
151
+ if (/brainstorm|planning|writing-plans/.test(unitId))
152
+ return "planning";
153
+ if (/security|threat|secrets|owasp/.test(unitId))
154
+ return "security";
155
+ if (/test|coverage|vitest|jest/.test(unitId))
156
+ return "testing";
157
+ if (/javascript|typescript|nodejs|npm|package|cli|zod|schema/.test(unitId))
158
+ return "language-tooling";
159
+ return "uncategorized";
160
+ }
89
161
  function selector(record) {
90
162
  if (!record.unitId)
91
163
  return undefined;
@@ -96,6 +168,10 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
96
168
  if (!id || !record.unitId)
97
169
  return undefined;
98
170
  const name = record.unitId.toLowerCase();
171
+ if (/(?:^|-)jest(?:-|$)/.test(name) &&
172
+ project.tools.includes("vitest") &&
173
+ !project.tools.includes("jest"))
174
+ return undefined;
99
175
  let score = 0;
100
176
  const reasons = [];
101
177
  if (pinned.has(id) || pinned.has(record.unitId)) {
@@ -136,25 +212,8 @@ function scoreCandidate(record, project, pinned, outcomes, taskFamilies) {
136
212
  reasons: [...new Set(reasons)],
137
213
  };
138
214
  }
139
- /** Deterministic, local-only active-set selection over the reviewed library. */
140
- export async function planProjectActivation(projectPath, options = {}) {
141
- const limit = options.limit ?? 40;
142
- if (!Number.isInteger(limit) || limit < 1 || limit > 200)
143
- throw new Error("--limit must be an integer from 1 to 200");
144
- const project = await scanProject(projectPath);
145
- const state = await readInstallState();
146
- const outcomes = await readLocalOutcomes();
147
- const taskFamilies = projectTaskFamilies(project);
148
- const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
149
- (!options.agents || options.agents.includes(record.agent)));
150
- const byAgent = new Map();
151
- for (const record of relevant)
152
- if (record.activationState === "active")
153
- byAgent.set(record.agent, (byAgent.get(record.agent) ?? 0) + 1);
154
- const activeBefore = Math.max(0, ...byAgent.values());
155
- const capacity = Math.max(0, limit - activeBefore);
156
- const pins = new Set(options.pins ?? []);
157
- const scored = relevant
215
+ function rankedCandidates(records, project, pins, outcomes, taskFamilies) {
216
+ const scored = records
158
217
  .filter((record) => record.activationState === "disabled" &&
159
218
  record.cacheState === "downloaded" &&
160
219
  record.reviewState === "reviewed")
@@ -165,19 +224,24 @@ export async function planProjectActivation(projectPath, options = {}) {
165
224
  (SOURCE_PRIORITY[left.packageId] ?? 0) ||
166
225
  left.unitId.localeCompare(right.unitId) ||
167
226
  left.packageId.localeCompare(right.packageId));
168
- // One capability name wins by evidence order; overlapping collections remain
169
- // in the library and can still be explicitly pinned by full selector.
170
227
  const unique = [];
171
228
  const claimedUnits = new Set();
229
+ const familyCounts = new Map();
172
230
  for (const item of scored) {
173
231
  const fullPin = pins.has(item.selector);
174
232
  if (!fullPin && claimedUnits.has(item.unitId))
175
233
  continue;
234
+ const family = candidateFamily(item.unitId);
235
+ if (!fullPin && (familyCounts.get(family) ?? 0) >= FAMILY_CAPS[family])
236
+ continue;
176
237
  claimedUnits.add(item.unitId);
238
+ familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
177
239
  unique.push(item);
178
240
  }
179
- const selected = unique.slice(0, capacity);
180
- const alternatives = selected.flatMap((chosen) => {
241
+ return unique;
242
+ }
243
+ function alternativesFor(selected, scored) {
244
+ return selected.flatMap((chosen) => {
181
245
  const deferred = scored
182
246
  .filter((candidate) => candidate.unitId === chosen.unitId &&
183
247
  candidate.selector !== chosen.selector)
@@ -186,7 +250,67 @@ export async function planProjectActivation(projectPath, options = {}) {
186
250
  ? [{ unitId: chosen.unitId, selected: chosen.selector, deferred }]
187
251
  : [];
188
252
  });
253
+ }
254
+ /** Deterministic, local-only active-set selection over the reviewed library. */
255
+ export async function planProjectActivation(projectPath, options = {}) {
256
+ const limit = options.limit ?? 40;
257
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
258
+ throw new Error("--limit must be an integer from 1 to 200");
259
+ const project = await scanProject(projectPath);
260
+ const state = await readInstallState();
261
+ const outcomes = await readLocalOutcomes();
262
+ const taskFamilies = projectTaskFamilies(project);
263
+ const relevant = (state.activations ?? []).filter((record) => record.installationState === "installed" &&
264
+ (!options.agents || options.agents.includes(record.agent)));
265
+ const requestedAgents = options.agents ?? [
266
+ ...new Set(relevant.map((record) => record.agent)),
267
+ ];
268
+ const detected = (await detectAgents()).filter((agent) => requestedAgents.includes(agent.id));
269
+ const inventory = await scanInstalledSkills(detected);
270
+ const pins = new Set(options.pins ?? []);
189
271
  const warnings = [];
272
+ const agentPlans = requestedAgents.map((agent) => {
273
+ const summary = inventory.agents.find((item) => item.agent === agent);
274
+ if (!summary)
275
+ throw new Error(`Could not scan active skills for requested agent '${agent}'`);
276
+ const capacity = Math.max(0, limit - summary.total);
277
+ const ranked = rankedCandidates(relevant.filter((record) => record.agent === agent), project, pins, outcomes, taskFamilies);
278
+ const selected = ranked.slice(0, capacity);
279
+ if (!capacity)
280
+ warnings.push(`${summary.displayName} has reached the active-set limit ${limit}; disable skills or raise --limit.`);
281
+ return {
282
+ agent,
283
+ displayName: summary.displayName,
284
+ activeBefore: summary.total,
285
+ managedBefore: summary.managed,
286
+ unmanagedBefore: summary.unmanaged,
287
+ capacity,
288
+ selected,
289
+ alternatives: alternativesFor(selected, ranked),
290
+ };
291
+ });
292
+ const activationPlans = await Promise.all(agentPlans
293
+ .filter((agentPlan) => agentPlan.selected.length)
294
+ .map((agentPlan) => planActivationChange("enable", agentPlan.selected.map((item) => item.selector), { agents: [agentPlan.agent] })));
295
+ const activation = activationPlans.length
296
+ ? {
297
+ action: "enable",
298
+ packages: [
299
+ ...new Set(activationPlans.flatMap((plan) => plan.packages)),
300
+ ],
301
+ requestedAgents,
302
+ changes: activationPlans.flatMap((plan) => plan.changes),
303
+ skipped: activationPlans.flatMap((plan) => plan.skipped),
304
+ blocked: activationPlans.some((plan) => plan.blocked),
305
+ warnings: activationPlans.flatMap((plan) => plan.warnings),
306
+ }
307
+ : undefined;
308
+ const selected = [
309
+ ...new Map(agentPlans
310
+ .flatMap((agentPlan) => agentPlan.selected)
311
+ .map((item) => [item.selector, item])).values(),
312
+ ];
313
+ const alternatives = agentPlans.flatMap((item) => item.alternatives);
190
314
  const known = new Set(relevant.flatMap((record) => {
191
315
  const id = selector(record);
192
316
  return id ? [id, record.unitId] : [];
@@ -194,17 +318,13 @@ export async function planProjectActivation(projectPath, options = {}) {
194
318
  const unknownPins = [...pins].filter((pin) => !known.has(pin));
195
319
  if (unknownPins.length)
196
320
  warnings.push(`Pinned skill(s) are not in the managed library: ${unknownPins.join(", ")}`);
197
- if (!capacity)
198
- warnings.push(`Active-set limit ${limit} is already reached; disable skills or raise --limit.`);
199
- const activation = selected.length
200
- ? await planActivationChange("enable", selected.map((item) => item.selector), { ...(options.agents ? { agents: options.agents } : {}) })
201
- : undefined;
202
321
  return {
203
322
  project,
204
323
  limit,
205
324
  ...(options.agents ? { agents: options.agents } : {}),
206
- activeBefore,
207
- capacity,
325
+ agentPlans,
326
+ activeBefore: Math.max(0, ...agentPlans.map((item) => item.activeBefore)),
327
+ capacity: Math.min(limit, ...agentPlans.map((item) => item.capacity)),
208
328
  selected,
209
329
  alternatives,
210
330
  ...(activation ? { activation } : {}),
@@ -214,17 +334,31 @@ export async function planProjectActivation(projectPath, options = {}) {
214
334
  export async function applyProjectActivation(plan) {
215
335
  if (!plan.activation || !plan.selected.length)
216
336
  throw new Error("No reviewed library skills were selected for activation");
217
- return applyActivationChange(plan.activation);
337
+ return applyActivationChange(plan.activation, {
338
+ preflight: async () => {
339
+ const requested = plan.agentPlans.map((item) => item.agent);
340
+ const detected = (await detectAgents()).filter((agent) => requested.includes(agent.id));
341
+ const inventory = await scanInstalledSkills(detected);
342
+ for (const agentPlan of plan.agentPlans) {
343
+ const current = inventory.agents.find((item) => item.agent === agentPlan.agent);
344
+ if (!current)
345
+ throw new Error(`Could not re-scan active skills for requested agent '${agentPlan.agent}'`);
346
+ if (current.total + agentPlan.selected.length > plan.limit)
347
+ throw new Error(`${agentPlan.displayName} active skill capacity changed after preview: ${current.total} active plus ${agentPlan.selected.length} proposed exceeds limit ${plan.limit}`);
348
+ }
349
+ },
350
+ });
218
351
  }
219
352
  export function formatProjectActivation(plan) {
220
- const detected = [...plan.project.languages, ...plan.project.frameworks];
221
353
  return [
222
354
  `Project: ${plan.project.root}`,
223
- `Detected: ${detected.join(", ") || "no known project signals"}`,
224
- `Active-set budget: ${plan.activeBefore}/${plan.limit} active; ${plan.capacity} slot(s) available`,
225
- `Proposed additions: ${plan.selected.length}`,
226
- ...plan.selected.map((item) => ` + ${item.selector} [${item.score}] — ${item.reasons.join(", ")}`),
227
- ...plan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
355
+ `Detected: ${formatDetectedSignals(plan.project) || "no known project signals"}`,
356
+ ...plan.agentPlans.flatMap((agentPlan) => [
357
+ `${agentPlan.displayName}: ${agentPlan.activeBefore} active (${agentPlan.managedBefore} managed, ${agentPlan.unmanagedBefore} unmanaged); ${agentPlan.capacity}/${plan.limit} slots available`,
358
+ `Proposed additions for ${agentPlan.displayName}: ${agentPlan.selected.length}`,
359
+ ...agentPlan.selected.map((item) => ` + ${item.selector} [${item.score}] ${item.reasons.join(", ")}`),
360
+ ...agentPlan.alternatives.map((item) => ` = ${item.unitId}: selected ${item.selected}; deferred equivalent source(s): ${item.deferred.join(", ")}`),
361
+ ]),
228
362
  ...(plan.activation
229
363
  ? ["", "Exact activation delta:", formatActivationPlan(plan.activation)]
230
364
  : []),
@@ -3,6 +3,7 @@ import { cp, lstat, mkdir, readFile, readdir, rm } from "node:fs/promises";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { agentSkillsDirectory, ensureDirectory } from "./paths.js";
5
5
  import { activationLibraryPath, installStatePath, readInstallState, writeInstallState, } from "./state.js";
6
+ import { inspectTargetOccupancy } from "./target-occupancy.js";
6
7
  import { runMutationTransaction } from "./transaction.js";
7
8
  /** Resolve a managed file to its active location or disabled library copy. */
8
9
  export function managedFileReadPath(packageId, filePath, activations) {
@@ -262,9 +263,12 @@ export async function planActivationChange(action, packageIds, options = {}) {
262
263
  const libraryActual = await hashTree(record.libraryPath);
263
264
  change.blockers.push(...exactTreeDifferences(record.libraryFiles, libraryActual).map((message) => `library copy: ${message}`));
264
265
  }
265
- const occupied = (await Promise.all(change.targets.map((target) => pathExists(target.activePath)))).filter(Boolean).length;
266
- if (occupied)
267
- change.blockers.push(`${occupied} active target(s) already exist and would be overwritten`);
266
+ const occupied = (await Promise.all(change.targets.map(async (target) => ({
267
+ target,
268
+ result: await inspectTargetOccupancy(target.activePath),
269
+ })))).filter((item) => item.result.occupied);
270
+ if (occupied.length)
271
+ change.blockers.push(`${occupied.length} active target(s) already contain protected content and would be overwritten; first target: ${occupied[0].target.activePath} (${occupied[0].result.reason})`);
268
272
  }
269
273
  changes.push(change);
270
274
  }
@@ -283,8 +287,9 @@ export async function planActivationChange(action, packageIds, options = {}) {
283
287
  function activationKey(record) {
284
288
  return `${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`;
285
289
  }
286
- export async function applyActivationChange(plan) {
290
+ export async function applyActivationChange(plan, options = {}) {
287
291
  const applied = await runMutationTransaction(async () => {
292
+ await options.preflight?.();
288
293
  const fresh = await planActivationChange(plan.action, plan.packages, {
289
294
  ...(plan.requestedAgents ? { agents: plan.requestedAgents } : {}),
290
295
  });
@@ -337,6 +342,10 @@ export async function applyActivationChange(plan) {
337
342
  else {
338
343
  for (const target of change.targets) {
339
344
  const source = join(change.libraryPath, target.libraryRelativePath);
345
+ const occupancy = await inspectTargetOccupancy(target.activePath);
346
+ if (occupancy.occupied)
347
+ throw new Error(`${change.packageId}/${change.agent}: activation target became occupied: ${target.activePath} (${occupancy.reason})`);
348
+ await rm(target.activePath, { recursive: true, force: true });
340
349
  await mkdir(dirname(target.activePath), { recursive: true });
341
350
  await cp(source, target.activePath, {
342
351
  recursive: true,
@@ -1,11 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
- import { cp, lstat, readdir, rm } from "node:fs/promises";
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
- try {
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();