loadout-ai 0.5.4 → 0.5.5

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
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.5 - 2026-07-21
6
+
7
+ ### Changed
8
+
9
+ - Summarize update checks as active, disabled-library, current, and unavailable
10
+ records; hide repetitive current-package lines by default while retaining complete
11
+ JSON output and focused per-package review.
12
+ - Keep disabled Maximum-library copies pinned and explicitly state that newer
13
+ upstream commits neither change nor reactivate agent-visible skills.
14
+ - Treat catalog sources represented by adopted units as present in saved-profile
15
+ evaluation, and describe fully quarantined or unavailable profile sources without
16
+ calling them missing upgrades.
17
+
18
+ ### Fixed
19
+
20
+ - Deduplicate per-agent disabled update state and fetch a shared repository only once
21
+ when several adopted skill units track the same upstream source.
22
+ - Distinguish active updates from disabled-library updates in health output, including
23
+ separately reported network-check failures.
24
+ - Label partial catalog indexing accurately as sources containing quarantined units
25
+ instead of repository failures.
26
+
5
27
  ## 0.5.4 - 2026-07-21
6
28
 
7
29
  ### Added
package/MASTER_PLAN.md CHANGED
@@ -318,6 +318,16 @@ merged `codex/relatable-readme-hero` remote branch remains safe to delete after
318
318
  Humanizer and Turnstile update findings before any replacement, then finish
319
319
  read-only update/discovery, Graphify removal, complete uninstall, and clean
320
320
  reinstall acceptance.
321
+ - [x] `P18-45 [TERRA]` Correct the founder-discovered update-reporting defects after
322
+ the real `0.5.4` Maximum/reconciliation run. Update planning now deduplicates
323
+ repeated agent names and shared-repository fetches, separates active updates
324
+ from newer commits held for disabled library copies, compacts safety output,
325
+ recognizes adopted sources in profile state, and labels partial quarantine
326
+ evidence instead of calling it a repository failure. The real profile confirms
327
+ 39 active managed skills, 2,326 disabled copies, one Graphify runtime, zero
328
+ managed drift, and no active adopted-skill update. Release this correction
329
+ before the remaining Graphify removal, discovery, uninstall, and reinstall
330
+ acceptance steps.
321
331
 
322
332
  ### Release 0.3 lifecycle hardening
323
333
 
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.5.4`; 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.5.5`; 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.5.4
79
+ npm install --global loadout-ai@0.5.5
80
80
  loadout --version
81
81
  loadout guide
82
82
  ```
package/dist/src/cli.js CHANGED
@@ -264,7 +264,7 @@ async function runSetup(options) {
264
264
  reader?.close();
265
265
  }
266
266
  }
267
- const LOADOUT_VERSION = "0.5.4";
267
+ const LOADOUT_VERSION = "0.5.5";
268
268
  function durableSchedulerLauncher() {
269
269
  return [
270
270
  join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
@@ -103,15 +103,24 @@ export async function buildHealthReport(options = {}) {
103
103
  fix: "Review the target, then remove and reinstall the owning runtime tool.",
104
104
  });
105
105
  const available = updates.filter((update) => update.status === "update-available");
106
- if (available.length)
106
+ const disabledAvailable = available.filter((update) => Boolean(update.disabledAgents?.length));
107
+ const activeAvailable = available.filter((update) => !update.disabledAgents?.length);
108
+ if (activeAvailable.length)
107
109
  findings.push({
108
- level: available.some((update) => update.approvalRequired)
110
+ level: activeAvailable.some((update) => update.approvalRequired)
109
111
  ? "warning"
110
112
  : "info",
111
113
  code: "updates-available",
112
- message: `${available.length} package update(s) are available.`,
114
+ message: `${activeAvailable.length} active package update(s) are available.`,
113
115
  fix: "Run loadout update and review the safety findings.",
114
116
  });
117
+ if (disabledAvailable.length)
118
+ findings.push({
119
+ level: "info",
120
+ code: "disabled-library-updates-available",
121
+ message: `${disabledAvailable.length} disabled-library package(s) have newer upstream commits.`,
122
+ fix: "Nothing active changed; disabled skills stay pinned and are never reactivated automatically.",
123
+ });
115
124
  const errors = updates.filter((update) => update.status === "error");
116
125
  if (errors.length)
117
126
  findings.push({
@@ -150,6 +159,9 @@ export async function buildHealthReport(options = {}) {
150
159
  managedRuntimeTools: runtimeTools.length,
151
160
  updatesChecked: Boolean(options.updates || options.checkUpdates),
152
161
  updatesAvailable: available.length,
162
+ activeUpdatesAvailable: activeAvailable.length,
163
+ disabledUpdatesAvailable: disabledAvailable.length,
164
+ updateChecksFailed: errors.length,
153
165
  driftedFiles: drifted.length,
154
166
  driftedMcpServers,
155
167
  findings,
@@ -165,7 +177,7 @@ export function formatHealthReport(report) {
165
177
  : "✗";
166
178
  const lines = [
167
179
  `${icon} Loadout health: ${report.status === "not-configured" ? "not configured" : report.status === "library-only" ? "library ready (nothing active)" : report.status}`,
168
- `Packages: ${report.installedPackages} managed; skills: ${report.activeSkills ?? 0} active, ${report.disabledSkills ?? 0} disabled; MCP servers: ${report.managedMcpServers ?? 0}; runtime tools: ${report.managedRuntimeTools ?? 0}; ${report.updatesChecked ? `${report.updatesAvailable} update(s)` : "updates not checked (use --updates)"}; ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
180
+ `Packages: ${report.installedPackages} managed; skills: ${report.activeSkills ?? 0} active, ${report.disabledSkills ?? 0} disabled; MCP servers: ${report.managedMcpServers ?? 0}; runtime tools: ${report.managedRuntimeTools ?? 0}; ${report.updatesChecked ? `${report.activeUpdatesAvailable ?? report.updatesAvailable} active update(s), ${report.disabledUpdatesAvailable ?? 0} disabled-library update(s), ${report.updateChecksFailed ?? 0} check(s) unavailable` : "updates not checked (use --updates)"}; ${report.driftedFiles} drifted file(s), ${report.driftedMcpServers} drifted MCP server(s)`,
169
181
  ];
170
182
  for (const finding of report.findings)
171
183
  lines.push(`${finding.level === "ok" ? "✓" : finding.level === "error" ? "✗" : finding.level === "warning" ? "!" : "•"} ${finding.message}${finding.fix ? ` ${finding.fix}` : ""}`);
@@ -27,6 +27,7 @@ export function evaluateInstalledProfileState(state, catalog) {
27
27
  installed: false,
28
28
  expectedPackages: [],
29
29
  missingPackages: [],
30
+ newlyReviewedPackages: [],
30
31
  reviewedRevisionChanges: [],
31
32
  needsRefresh: false,
32
33
  evaluatedAt,
@@ -41,8 +42,16 @@ export function evaluateInstalledProfileState(state, catalog) {
41
42
  const expected = resolution.packages.filter((pkg) => pkg.components?.includes("skill"));
42
43
  const installed = new Map(state.installs.map((record) => [record.packageId, record]));
43
44
  const previousCatalog = new Map(state.profile.catalogPackages.map((record) => [record.packageId, record]));
44
- const missingPackages = expected
45
- .filter((pkg) => !installed.has(pkg.id))
45
+ const representedRepositories = new Set(state.installs
46
+ .map((record) => record.repository?.toLowerCase())
47
+ .filter((repository) => Boolean(repository)));
48
+ const missingPackages = state.profile.catalogPackages
49
+ .filter((item) => !installed.has(item.packageId))
50
+ .map((item) => item.packageId);
51
+ const newlyReviewedPackages = expected
52
+ .filter((pkg) => !previousCatalog.has(pkg.id) &&
53
+ !installed.has(pkg.id) &&
54
+ !representedRepositories.has(pkg.repository.toLowerCase()))
46
55
  .map((pkg) => pkg.id);
47
56
  const reviewedRevisionChanges = expected.flatMap((pkg) => {
48
57
  const previous = previousCatalog.get(pkg.id);
@@ -58,21 +67,16 @@ export function evaluateInstalledProfileState(state, catalog) {
58
67
  },
59
68
  ];
60
69
  });
61
- const oldIds = state.profile.catalogPackages
62
- .map((item) => item.packageId)
63
- .sort();
64
70
  const expectedPackages = expected.map((pkg) => pkg.id).sort();
65
- const selectionChanged = oldIds.join("\0") !== expectedPackages.join("\0");
66
71
  return {
67
72
  installed: true,
68
73
  mode: state.profile.mode,
69
74
  appliedAt: state.profile.appliedAt,
70
75
  expectedPackages,
71
76
  missingPackages,
77
+ newlyReviewedPackages,
72
78
  reviewedRevisionChanges,
73
- needsRefresh: selectionChanged ||
74
- missingPackages.length > 0 ||
75
- reviewedRevisionChanges.length > 0,
79
+ needsRefresh: newlyReviewedPackages.length > 0 || reviewedRevisionChanges.length > 0,
76
80
  evaluatedAt,
77
81
  boundary,
78
82
  };
@@ -87,7 +91,14 @@ export function formatInstalledProfileStatus(status) {
87
91
  `PROFILE ${status.mode?.toUpperCase()}: ${status.needsRefresh ? "reviewed changes available" : "current against reviewed catalog"}`,
88
92
  `Expected repositories: ${status.expectedPackages.length}`,
89
93
  ...(status.missingPackages.length
90
- ? [`Missing: ${status.missingPackages.join(", ")}`]
94
+ ? [
95
+ `Not installed from the saved profile (removed, quarantined, or unavailable): ${status.missingPackages.join(", ")}`,
96
+ ]
97
+ : []),
98
+ ...(status.newlyReviewedPackages.length
99
+ ? [
100
+ `New reviewed catalog sources to inspect: ${status.newlyReviewedPackages.join(", ")}`,
101
+ ]
91
102
  : []),
92
103
  ...(status.reviewedRevisionChanges.length
93
104
  ? [
@@ -292,8 +292,10 @@ export function enrichInventoryWithProvenance(report, index, source = "none") {
292
292
  }
293
293
  export function formatProvenanceSummary(report) {
294
294
  const value = report.provenance;
295
+ const quarantinedSources = value.failures.filter((failure) => /^\d+ skill unit\(s\) quarantined:/.test(failure.error)).length;
296
+ const preparationFailures = value.failures.length - quarantinedSources;
295
297
  return [
296
298
  `Provenance: ${value.exact} exact catalog match(es), ${value.managed} Loadout-managed, ${value.embedded} embedded-source candidate(s), ${value.nameCandidates} name-only candidate(s), ${value.unknown} unknown`,
297
- `Catalog skill index: ${value.indexSource}; ${value.indexedSkills} reviewed skill(s)${value.failures.length ? `; ${value.failures.length} repository failure(s)` : ""}`,
299
+ `Catalog skill index: ${value.indexSource}; ${value.indexedSkills} reviewed skill(s)${quarantinedSources ? `; ${quarantinedSources} source(s) contain quarantined units` : ""}${preparationFailures ? `; ${preparationFailures} source preparation failure(s)` : ""}`,
298
300
  ].join("\n");
299
301
  }
@@ -91,6 +91,15 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
91
91
  ? state.installs.filter((record) => record.packageId === options.packageId)
92
92
  : state.installs;
93
93
  const results = new Array(records.length);
94
+ const resolutions = new Map();
95
+ const resolveOnce = (repository) => {
96
+ const existing = resolutions.get(repository);
97
+ if (existing)
98
+ return existing;
99
+ const pending = resolver(repository);
100
+ resolutions.set(repository, pending);
101
+ return pending;
102
+ };
94
103
  let cursor = 0;
95
104
  let completed = 0;
96
105
  const workers = Array.from({
@@ -100,17 +109,21 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
100
109
  const index = cursor++;
101
110
  const record = records[index];
102
111
  results[index] = await (async () => {
103
- const disabledAgents = (state.activations ?? [])
104
- .filter((activation) => activation.packageId === record.packageId &&
112
+ const disabledActivations = (state.activations ?? []).filter((activation) => activation.packageId === record.packageId &&
105
113
  activation.installationState === "installed" &&
106
- activation.activationState === "disabled")
107
- .map((activation) => activation.agent);
114
+ activation.activationState === "disabled");
115
+ const disabledAgents = [
116
+ ...new Set(disabledActivations.map((activation) => activation.agent)),
117
+ ].sort();
108
118
  const base = {
109
119
  packageId: record.packageId,
110
120
  repository: record.repository,
111
121
  installedCommit: record.resolvedCommit,
112
122
  targetAgents: record.targetAgents,
113
123
  ...(disabledAgents.length ? { disabledAgents } : {}),
124
+ ...(disabledActivations.length
125
+ ? { disabledUnits: disabledActivations.length }
126
+ : {}),
114
127
  };
115
128
  if (!record.repository || !record.resolvedCommit) {
116
129
  return {
@@ -120,7 +133,7 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
120
133
  };
121
134
  }
122
135
  try {
123
- const current = await resolver(record.repository);
136
+ const current = await resolveOnce(record.repository);
124
137
  const same = current.commit.toLowerCase() ===
125
138
  record.resolvedCommit.toLowerCase();
126
139
  let diff;
@@ -140,7 +153,7 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
140
153
  action: same
141
154
  ? "No action required."
142
155
  : disabledAgents.length
143
- ? `Enable ${record.packageId} for ${disabledAgents.join(", ")} before applying an update; planning remains read-only.`
156
+ ? `A newer upstream commit exists, but ${disabledActivations.length} reviewed library ${disabledActivations.length === 1 ? "copy is" : "copies are"} disabled for ${disabledAgents.join(" and ")}. Nothing active changed; Loadout will not update or reactivate disabled skills automatically.`
144
157
  : approvalRequired
145
158
  ? `Approval required: review safety warnings before updating ${record.packageId}.`
146
159
  : `Run loadout update --package ${record.packageId} --yes after review.`,
@@ -172,16 +185,35 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
172
185
  export function formatUpdatePlan(plans) {
173
186
  if (plans.length === 0)
174
187
  return "No tracked installations found.";
175
- return plans
176
- .map((plan) => {
188
+ const available = plans.filter((plan) => plan.status === "update-available");
189
+ const activeAvailable = available.filter((plan) => !plan.disabledAgents?.length);
190
+ const disabledAvailable = available.filter((plan) => Boolean(plan.disabledAgents?.length));
191
+ const current = plans.filter((plan) => plan.status === "up-to-date");
192
+ const unavailable = plans.filter((plan) => plan.status === "error" || plan.status === "untracked");
193
+ const lines = [
194
+ `Update check: ${activeAvailable.length} active update(s), ${disabledAvailable.length} disabled-library update(s), ${current.length} current, ${unavailable.length} unavailable.`,
195
+ ];
196
+ const visible = plans.length === 1
197
+ ? plans
198
+ : plans.filter((plan) => plan.status !== "up-to-date");
199
+ lines.push(...visible.map((plan) => {
177
200
  const suffix = plan.error ? ` — ${plan.error}` : "";
178
201
  const repo = plan.repository ? ` (${plan.repository})` : "";
179
- const safety = plan.safetyFindings
180
- ?.map((finding) => ` [${finding.severity}] ${finding.message}${finding.names?.length ? ` (${finding.names.join(", ")})` : ""}`)
181
- .join("") ?? "";
202
+ const findingCounts = new Map();
203
+ for (const finding of plan.safetyFindings ?? []) {
204
+ const key = `${finding.severity} ${finding.category}`;
205
+ findingCounts.set(key, (findingCounts.get(key) ?? 0) + 1);
206
+ }
207
+ const safety = findingCounts.size
208
+ ? ` Safety review: ${[...findingCounts.entries()]
209
+ .map(([key, count]) => `${key}${count > 1 ? ` ×${count}` : ""}`)
210
+ .join(", ")}. Run loadout update --package ${plan.packageId} for the focused review.`
211
+ : "";
182
212
  return `${plan.status.toUpperCase()} ${plan.packageId}${repo}: ${plan.action}${safety}${suffix}`;
183
- })
184
- .join("\n");
213
+ }));
214
+ if (current.length && plans.length > 1)
215
+ lines.push(`Current packages hidden for readability (${current.length}); use --json for every record.`);
216
+ return lines.join("\n");
185
217
  }
186
218
  /** Updates safe enough for an explicit whole-profile `update --yes` apply. */
187
219
  export function selectSafeAutomaticUpdates(plans) {
@@ -236,11 +268,13 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
236
268
  const record = state.installs.find((item) => item.packageId === packageId);
237
269
  if (!record)
238
270
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
239
- const disabledAgents = (state.activations ?? [])
240
- .filter((activation) => activation.packageId === packageId &&
241
- activation.installationState === "installed" &&
242
- activation.activationState === "disabled")
243
- .map((activation) => activation.agent);
271
+ const disabledAgents = [
272
+ ...new Set((state.activations ?? [])
273
+ .filter((activation) => activation.packageId === packageId &&
274
+ activation.installationState === "installed" &&
275
+ activation.activationState === "disabled")
276
+ .map((activation) => activation.agent)),
277
+ ].sort();
244
278
  const expectedRecord = JSON.stringify(record);
245
279
  const expectedActivations = JSON.stringify((state.activations ?? [])
246
280
  .filter((activation) => activation.packageId === packageId)
@@ -211,7 +211,7 @@ cleanup deliberately deletes Loadout's snapshots, so it is the last lifecycle te
211
211
  ## Troubleshooting and recovery
212
212
 
213
213
  - **`loadout` is not found after installation:** confirm `npm install --global
214
- loadout-ai@0.5.4` completed, run `hash -r`, and confirm npm's global binary
214
+ loadout-ai@0.5.5` completed, run `hash -r`, and confirm npm's global binary
215
215
  directory is on `PATH`. For a source checkout, run `npm run build` and `npm link`.
216
216
  - **A preview asks for `--approve-risk`:** read the reported scripts, domains,
217
217
  credentials, binaries, or instruction findings. If you accept that specific plan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadout-ai",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Universal upgrade manager for AI coding agents",