loadout-ai 0.5.4 → 0.5.6
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 +39 -0
- package/MASTER_PLAN.md +18 -0
- package/README.md +2 -2
- package/dist/src/cli.js +3 -3
- package/dist/src/core/health.js +16 -4
- package/dist/src/core/profile-state.js +21 -10
- package/dist/src/core/provenance.js +3 -1
- package/dist/src/core/source.js +30 -0
- package/dist/src/core/update.js +81 -22
- package/docs/USER_TEST_GUIDE.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.6 - 2026-07-21
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Resolve each repository's default-branch commit with a lightweight Git metadata
|
|
10
|
+
request before downloading files, deduplicate shared sources, and reuse exact
|
|
11
|
+
cached changed revisions for safety review.
|
|
12
|
+
- Give changed repositories a bounded 120-second review window while keeping remote
|
|
13
|
+
commit checks at 30 seconds, so large reviewed sources do not produce permanent
|
|
14
|
+
false connectivity warnings under four-way concurrency.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Complete update checks for large sources such as Scientific Agent Skills and
|
|
19
|
+
Awesome Copilot without weakening static diff analysis, activating disabled skills,
|
|
20
|
+
or treating a timed-out clone as a GitHub outage.
|
|
21
|
+
|
|
22
|
+
## 0.5.5 - 2026-07-21
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Summarize update checks as active, disabled-library, current, and unavailable
|
|
27
|
+
records; hide repetitive current-package lines by default while retaining complete
|
|
28
|
+
JSON output and focused per-package review.
|
|
29
|
+
- Keep disabled Maximum-library copies pinned and explicitly state that newer
|
|
30
|
+
upstream commits neither change nor reactivate agent-visible skills.
|
|
31
|
+
- Treat catalog sources represented by adopted units as present in saved-profile
|
|
32
|
+
evaluation, and describe fully quarantined or unavailable profile sources without
|
|
33
|
+
calling them missing upgrades.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Deduplicate per-agent disabled update state and fetch a shared repository only once
|
|
38
|
+
when several adopted skill units track the same upstream source.
|
|
39
|
+
- Distinguish active updates from disabled-library updates in health output, including
|
|
40
|
+
separately reported network-check failures.
|
|
41
|
+
- Label partial catalog indexing accurately as sources containing quarantined units
|
|
42
|
+
instead of repository failures.
|
|
43
|
+
|
|
5
44
|
## 0.5.4 - 2026-07-21
|
|
6
45
|
|
|
7
46
|
### Added
|
package/MASTER_PLAN.md
CHANGED
|
@@ -318,6 +318,24 @@ 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.
|
|
331
|
+
- [x] `P18-46 [TERRA]` Eliminate the repeated large-repository timeout exposed by the
|
|
332
|
+
published `0.5.5` founder check. Resolve remote HEAD metadata before fetching
|
|
333
|
+
content, download only changed revisions that require static safety analysis,
|
|
334
|
+
reuse immutable cached snapshots, and allow those bounded reviews 120 seconds.
|
|
335
|
+
The real 40-record Maximum state now completes with 19 disabled-library
|
|
336
|
+
changes, 21 current packages, zero active updates, and zero unavailable checks;
|
|
337
|
+
both previously failing large repositories complete without changing or
|
|
338
|
+
activating any agent-visible skill.
|
|
321
339
|
|
|
322
340
|
### Release 0.3 lifecycle hardening
|
|
323
341
|
|
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.
|
|
29
|
+
> Installation is version-pinned so the code you test matches these docs. The commands below target `loadout-ai@0.5.6`; 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.
|
|
79
|
+
npm install --global loadout-ai@0.5.6
|
|
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.
|
|
267
|
+
const LOADOUT_VERSION = "0.5.6";
|
|
268
268
|
function durableSchedulerLauncher() {
|
|
269
269
|
return [
|
|
270
270
|
join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
|
|
@@ -909,7 +909,7 @@ program
|
|
|
909
909
|
.option("--agents <ids>", "limit explained scores to selected agent ids")
|
|
910
910
|
.action(async (options) => {
|
|
911
911
|
if (options.updates && !options.json)
|
|
912
|
-
console.error("Checking
|
|
912
|
+
console.error("Checking repository commits (4 at a time; changed sources may take up to 120s for safety review)…");
|
|
913
913
|
const report = await buildHealthReport({
|
|
914
914
|
updates: options.updates
|
|
915
915
|
? () => buildUpdatePlan(undefined, {
|
|
@@ -2857,7 +2857,7 @@ program
|
|
|
2857
2857
|
profile = await evaluateInstalledProfile(catalog);
|
|
2858
2858
|
}
|
|
2859
2859
|
if (!options.json)
|
|
2860
|
-
console.error("Checking
|
|
2860
|
+
console.error("Checking repository commits (4 at a time; changed sources may take up to 120s for safety review)…");
|
|
2861
2861
|
let plans = await buildUpdatePlan(undefined, {
|
|
2862
2862
|
packageId: options.package,
|
|
2863
2863
|
onProgress: options.json
|
package/dist/src/core/health.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
110
|
+
level: activeAvailable.some((update) => update.approvalRequired)
|
|
109
111
|
? "warning"
|
|
110
112
|
: "info",
|
|
111
113
|
code: "updates-available",
|
|
112
|
-
message: `${
|
|
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
|
|
45
|
-
.
|
|
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:
|
|
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
|
-
? [
|
|
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)${
|
|
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
|
}
|
package/dist/src/core/source.js
CHANGED
|
@@ -144,6 +144,36 @@ export function normalizeRepository(input) {
|
|
|
144
144
|
}
|
|
145
145
|
return value;
|
|
146
146
|
}
|
|
147
|
+
export function parseRepositoryHead(repository, output) {
|
|
148
|
+
const commit = output
|
|
149
|
+
.split(/\r?\n/)
|
|
150
|
+
.map((line) => line.split(/\s+/))
|
|
151
|
+
.find((parts) => parts[1] === "HEAD" && /^[0-9a-f]{40}$/i.test(parts[0]))?.[0];
|
|
152
|
+
if (!commit)
|
|
153
|
+
throw new Error(`Git returned an invalid default-branch HEAD for ${repository}`);
|
|
154
|
+
return commit;
|
|
155
|
+
}
|
|
156
|
+
/** Resolve the public default-branch commit without downloading repository files. */
|
|
157
|
+
export async function resolveRepositoryHead(input, options = {}) {
|
|
158
|
+
const repository = normalizeRepository(input);
|
|
159
|
+
const gitEnvironment = await isolatedGitEnvironment(loadoutHome());
|
|
160
|
+
const url = `https://github.com/${repository}.git`;
|
|
161
|
+
try {
|
|
162
|
+
const { stdout } = await execFileAsync("git", ["ls-remote", "--symref", "--", url, "HEAD"], {
|
|
163
|
+
maxBuffer: 1024 * 1024,
|
|
164
|
+
timeout: options.timeoutMs,
|
|
165
|
+
env: gitEnvironment,
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
repository,
|
|
169
|
+
commit: parseRepositoryHead(repository, stdout),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
throw new Error(`Could not resolve ${repository}: ${message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
147
177
|
function normalizeRef(ref) {
|
|
148
178
|
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) ||
|
|
149
179
|
ref.includes("..") ||
|
package/dist/src/core/update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
|
-
import { fetchRepositorySnapshot } from "./source.js";
|
|
3
|
+
import { fetchRepositorySnapshot, resolveRepositoryHead } from "./source.js";
|
|
4
4
|
import { repositoryCachePath } from "./source.js";
|
|
5
5
|
import { diffRepositorySnapshots } from "./diff.js";
|
|
6
6
|
import { hashDirectory, readInstallState } from "./state.js";
|
|
@@ -85,12 +85,39 @@ async function analyzeManagedUpdate(oldRoot, newRoot, unitIds) {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
/** Builds a read-only update plan from persisted installs and live GitHub snapshots. */
|
|
88
|
-
export async function buildUpdatePlan(resolver
|
|
88
|
+
export async function buildUpdatePlan(resolver, options = {}) {
|
|
89
89
|
const state = await readInstallState();
|
|
90
90
|
const records = options.packageId
|
|
91
91
|
? state.installs.filter((record) => record.packageId === options.packageId)
|
|
92
92
|
: state.installs;
|
|
93
93
|
const results = new Array(records.length);
|
|
94
|
+
const lightweightResolver = resolver ??
|
|
95
|
+
options.resolveHead ??
|
|
96
|
+
((repository) => resolveRepositoryHead(repository, { timeoutMs: 30_000 }));
|
|
97
|
+
const resolutions = new Map();
|
|
98
|
+
const resolveOnce = (repository) => {
|
|
99
|
+
const existing = resolutions.get(repository);
|
|
100
|
+
if (existing)
|
|
101
|
+
return existing;
|
|
102
|
+
const pending = lightweightResolver(repository);
|
|
103
|
+
resolutions.set(repository, pending);
|
|
104
|
+
return pending;
|
|
105
|
+
};
|
|
106
|
+
const changedSnapshots = new Map();
|
|
107
|
+
const fetchChangedOnce = (repository, commit) => {
|
|
108
|
+
const key = `${repository}\0${commit.toLowerCase()}`;
|
|
109
|
+
const existing = changedSnapshots.get(key);
|
|
110
|
+
if (existing)
|
|
111
|
+
return existing;
|
|
112
|
+
const pending = options.fetchChangedSnapshot
|
|
113
|
+
? options.fetchChangedSnapshot(repository)
|
|
114
|
+
: fetchRepositorySnapshot(repository, {
|
|
115
|
+
ref: commit,
|
|
116
|
+
timeoutMs: 120_000,
|
|
117
|
+
});
|
|
118
|
+
changedSnapshots.set(key, pending);
|
|
119
|
+
return pending;
|
|
120
|
+
};
|
|
94
121
|
let cursor = 0;
|
|
95
122
|
let completed = 0;
|
|
96
123
|
const workers = Array.from({
|
|
@@ -100,17 +127,21 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
|
|
|
100
127
|
const index = cursor++;
|
|
101
128
|
const record = records[index];
|
|
102
129
|
results[index] = await (async () => {
|
|
103
|
-
const
|
|
104
|
-
.filter((activation) => activation.packageId === record.packageId &&
|
|
130
|
+
const disabledActivations = (state.activations ?? []).filter((activation) => activation.packageId === record.packageId &&
|
|
105
131
|
activation.installationState === "installed" &&
|
|
106
|
-
activation.activationState === "disabled")
|
|
107
|
-
|
|
132
|
+
activation.activationState === "disabled");
|
|
133
|
+
const disabledAgents = [
|
|
134
|
+
...new Set(disabledActivations.map((activation) => activation.agent)),
|
|
135
|
+
].sort();
|
|
108
136
|
const base = {
|
|
109
137
|
packageId: record.packageId,
|
|
110
138
|
repository: record.repository,
|
|
111
139
|
installedCommit: record.resolvedCommit,
|
|
112
140
|
targetAgents: record.targetAgents,
|
|
113
141
|
...(disabledAgents.length ? { disabledAgents } : {}),
|
|
142
|
+
...(disabledActivations.length
|
|
143
|
+
? { disabledUnits: disabledActivations.length }
|
|
144
|
+
: {}),
|
|
114
145
|
};
|
|
115
146
|
if (!record.repository || !record.resolvedCommit) {
|
|
116
147
|
return {
|
|
@@ -120,15 +151,22 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
|
|
|
120
151
|
};
|
|
121
152
|
}
|
|
122
153
|
try {
|
|
123
|
-
const current = await
|
|
154
|
+
const current = await resolveOnce(record.repository);
|
|
124
155
|
const same = current.commit.toLowerCase() ===
|
|
125
156
|
record.resolvedCommit.toLowerCase();
|
|
126
157
|
let diff;
|
|
127
158
|
let safetyFindings;
|
|
128
159
|
let approvalRequired = false;
|
|
129
|
-
|
|
160
|
+
let currentPath = current.path;
|
|
161
|
+
if (!same && !currentPath && !resolver) {
|
|
162
|
+
const fetched = await fetchChangedOnce(record.repository, current.commit);
|
|
163
|
+
if (fetched.commit.toLowerCase() !== current.commit.toLowerCase())
|
|
164
|
+
throw new Error(`Resolved ${current.commit}, but fetched ${fetched.commit} for safety review`);
|
|
165
|
+
currentPath = fetched.path;
|
|
166
|
+
}
|
|
167
|
+
if (!same && currentPath) {
|
|
130
168
|
const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
|
|
131
|
-
const analysis = await analyzeManagedUpdate(oldPath,
|
|
169
|
+
const analysis = await analyzeManagedUpdate(oldPath, currentPath, managedUnitIds(state, record.packageId));
|
|
132
170
|
diff = analysis.diff;
|
|
133
171
|
safetyFindings = analysis.safetyFindings;
|
|
134
172
|
approvalRequired = analysis.approvalRequired;
|
|
@@ -140,7 +178,7 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
|
|
|
140
178
|
action: same
|
|
141
179
|
? "No action required."
|
|
142
180
|
: disabledAgents.length
|
|
143
|
-
? `
|
|
181
|
+
? `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
182
|
: approvalRequired
|
|
145
183
|
? `Approval required: review safety warnings before updating ${record.packageId}.`
|
|
146
184
|
: `Run loadout update --package ${record.packageId} --yes after review.`,
|
|
@@ -172,16 +210,35 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
|
|
|
172
210
|
export function formatUpdatePlan(plans) {
|
|
173
211
|
if (plans.length === 0)
|
|
174
212
|
return "No tracked installations found.";
|
|
175
|
-
|
|
176
|
-
|
|
213
|
+
const available = plans.filter((plan) => plan.status === "update-available");
|
|
214
|
+
const activeAvailable = available.filter((plan) => !plan.disabledAgents?.length);
|
|
215
|
+
const disabledAvailable = available.filter((plan) => Boolean(plan.disabledAgents?.length));
|
|
216
|
+
const current = plans.filter((plan) => plan.status === "up-to-date");
|
|
217
|
+
const unavailable = plans.filter((plan) => plan.status === "error" || plan.status === "untracked");
|
|
218
|
+
const lines = [
|
|
219
|
+
`Update check: ${activeAvailable.length} active update(s), ${disabledAvailable.length} disabled-library update(s), ${current.length} current, ${unavailable.length} unavailable.`,
|
|
220
|
+
];
|
|
221
|
+
const visible = plans.length === 1
|
|
222
|
+
? plans
|
|
223
|
+
: plans.filter((plan) => plan.status !== "up-to-date");
|
|
224
|
+
lines.push(...visible.map((plan) => {
|
|
177
225
|
const suffix = plan.error ? ` — ${plan.error}` : "";
|
|
178
226
|
const repo = plan.repository ? ` (${plan.repository})` : "";
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
.
|
|
227
|
+
const findingCounts = new Map();
|
|
228
|
+
for (const finding of plan.safetyFindings ?? []) {
|
|
229
|
+
const key = `${finding.severity} ${finding.category}`;
|
|
230
|
+
findingCounts.set(key, (findingCounts.get(key) ?? 0) + 1);
|
|
231
|
+
}
|
|
232
|
+
const safety = findingCounts.size
|
|
233
|
+
? ` Safety review: ${[...findingCounts.entries()]
|
|
234
|
+
.map(([key, count]) => `${key}${count > 1 ? ` ×${count}` : ""}`)
|
|
235
|
+
.join(", ")}. Run loadout update --package ${plan.packageId} for the focused review.`
|
|
236
|
+
: "";
|
|
182
237
|
return `${plan.status.toUpperCase()} ${plan.packageId}${repo}: ${plan.action}${safety}${suffix}`;
|
|
183
|
-
})
|
|
184
|
-
|
|
238
|
+
}));
|
|
239
|
+
if (current.length && plans.length > 1)
|
|
240
|
+
lines.push(`Current packages hidden for readability (${current.length}); use --json for every record.`);
|
|
241
|
+
return lines.join("\n");
|
|
185
242
|
}
|
|
186
243
|
/** Updates safe enough for an explicit whole-profile `update --yes` apply. */
|
|
187
244
|
export function selectSafeAutomaticUpdates(plans) {
|
|
@@ -236,11 +293,13 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
|
|
|
236
293
|
const record = state.installs.find((item) => item.packageId === packageId);
|
|
237
294
|
if (!record)
|
|
238
295
|
throw new Error(`Package is not managed by Loadout: ${packageId}`);
|
|
239
|
-
const disabledAgents =
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
296
|
+
const disabledAgents = [
|
|
297
|
+
...new Set((state.activations ?? [])
|
|
298
|
+
.filter((activation) => activation.packageId === packageId &&
|
|
299
|
+
activation.installationState === "installed" &&
|
|
300
|
+
activation.activationState === "disabled")
|
|
301
|
+
.map((activation) => activation.agent)),
|
|
302
|
+
].sort();
|
|
244
303
|
const expectedRecord = JSON.stringify(record);
|
|
245
304
|
const expectedActivations = JSON.stringify((state.activations ?? [])
|
|
246
305
|
.filter((activation) => activation.packageId === packageId)
|
package/docs/USER_TEST_GUIDE.md
CHANGED
|
@@ -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.
|
|
214
|
+
loadout-ai@0.5.6` 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,
|