loadout-ai 0.2.1 → 0.2.2
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 +8 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/core/catalog-install.js +20 -1
- package/dist/src/core/install.js +98 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 - 2026-07-17
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Reconcile Stable and Power to their exact managed skill sets instead of leaving skills from the previous profile active.
|
|
8
|
+
- Preview every managed skill that profile setup will retire and reject an apply if managed state changed after the preview.
|
|
9
|
+
- Snapshot retired skills in the same transaction, preserve unmanaged skills, and refuse to retire locally changed managed content.
|
|
10
|
+
|
|
3
11
|
## 0.2.1 - 2026-07-17
|
|
4
12
|
|
|
5
13
|
### Fixed
|
package/dist/src/cli.js
CHANGED
|
@@ -262,7 +262,7 @@ async function runSetup(options) {
|
|
|
262
262
|
reader?.close();
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
|
-
const LOADOUT_VERSION = "0.2.
|
|
265
|
+
const LOADOUT_VERSION = "0.2.2";
|
|
266
266
|
function durableSchedulerLauncher() {
|
|
267
267
|
return [
|
|
268
268
|
join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadEffectiveCatalog } from "./catalog.js";
|
|
2
2
|
import { detectAgents } from "./paths.js";
|
|
3
3
|
import { isStableSkillSelected, isPowerSkillSelected, resolveCatalogProfile, } from "./profiles.js";
|
|
4
|
-
import { applySkillLibraryBatch, applySkillInstallBatch, buildSkillPlan, installedAgents, } from "./install.js";
|
|
4
|
+
import { applySkillLibraryBatch, applySkillInstallBatch, buildSkillPlan, installedAgents, planManagedProfileReconciliation, } from "./install.js";
|
|
5
5
|
import { fetchRepositorySnapshot, } from "./source.js";
|
|
6
6
|
import { analyzeInstallPlanSafety, } from "./safety.js";
|
|
7
7
|
import { formatModelApiAccess } from "./access.js";
|
|
@@ -175,6 +175,14 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
175
175
|
});
|
|
176
176
|
return false;
|
|
177
177
|
});
|
|
178
|
+
const reconciliation = selection.mode === "maximum"
|
|
179
|
+
? {
|
|
180
|
+
obsoleteActivationKeys: [],
|
|
181
|
+
obsoletePackageIds: [],
|
|
182
|
+
obsoleteTargets: [],
|
|
183
|
+
obsoleteUnits: [],
|
|
184
|
+
}
|
|
185
|
+
: await planManagedProfileReconciliation(usableEntries);
|
|
178
186
|
return {
|
|
179
187
|
selection,
|
|
180
188
|
resolution,
|
|
@@ -183,6 +191,7 @@ export async function prepareCatalogInstall(selection, options = {}) {
|
|
|
183
191
|
skipped,
|
|
184
192
|
collisions,
|
|
185
193
|
access: options.access ?? { modelApis: [] },
|
|
194
|
+
reconciliation,
|
|
186
195
|
};
|
|
187
196
|
}
|
|
188
197
|
export function formatPreparedCatalogInstall(prepared) {
|
|
@@ -194,6 +203,7 @@ export function formatPreparedCatalogInstall(prepared) {
|
|
|
194
203
|
const explicit = prepared.skipped.filter((item) => item.kind === "explicit-setup");
|
|
195
204
|
const failures = prepared.skipped.filter((item) => item.kind === "preparation-failed");
|
|
196
205
|
const quarantined = prepared.skipped.filter((item) => item.kind === "quarantined");
|
|
206
|
+
const retired = prepared.reconciliation?.obsoleteUnits ?? [];
|
|
197
207
|
const lines = [
|
|
198
208
|
`Loadout: ${prepared.selection.mode === "maximum" ? "Maximum Library" : prepared.selection.mode === "power" ? "Power Boost" : prepared.selection.mode === "stable" ? "Stable Boost" : "Custom"}`,
|
|
199
209
|
`Detected agents: ${prepared.agents.map((agent) => agent.displayName).join(", ")}`,
|
|
@@ -211,6 +221,11 @@ export function formatPreparedCatalogInstall(prepared) {
|
|
|
211
221
|
lines.push(`Quarantined invalid skill units: ${quarantined.length} (safe siblings remain available)`);
|
|
212
222
|
if (prepared.collisions.length)
|
|
213
223
|
lines.push(`Overlapping skill targets resolved: ${prepared.collisions.length} lower-ranked duplicate directories deferred`);
|
|
224
|
+
if (retired.length) {
|
|
225
|
+
lines.push(`Profile reconciliation: ${retired.length} active managed skill${retired.length === 1 ? "" : "s"} will be retired from the selected agents.`);
|
|
226
|
+
for (const item of retired)
|
|
227
|
+
lines.push(`Retire ${item.packageId}${item.unitId ? `/${item.unitId}` : ""} from ${item.agent}`);
|
|
228
|
+
}
|
|
214
229
|
if (risky.length)
|
|
215
230
|
lines.push(`Additional risk approval required: ${risky.map((entry) => entry.package.id).join(", ")}`);
|
|
216
231
|
for (const warning of prepared.resolution.warnings)
|
|
@@ -232,5 +247,9 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
|
|
|
232
247
|
? applySkillLibraryBatch(prepared.entries)
|
|
233
248
|
: applySkillInstallBatch(prepared.entries, [], {
|
|
234
249
|
replaceManagedTargets: true,
|
|
250
|
+
reconcileManagedTargets: true,
|
|
251
|
+
...(prepared.reconciliation
|
|
252
|
+
? { expectedReconciliation: prepared.reconciliation }
|
|
253
|
+
: {}),
|
|
235
254
|
});
|
|
236
255
|
}
|
package/dist/src/core/install.js
CHANGED
|
@@ -4,7 +4,7 @@ import { basename, dirname, isAbsolute, join, posix, relative, win32, } from "no
|
|
|
4
4
|
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
5
5
|
import { planAdapterSkillInstall } from "./adapters.js";
|
|
6
6
|
import { applySkillPlan, detectInstallConflicts } from "./skills.js";
|
|
7
|
-
import { activationLibraryPath, installStatePath, recordInstall, recordInstallBatch, recordLibraryInstallBatch, readInstallState, hashDirectory, } from "./state.js";
|
|
7
|
+
import { activationLibraryPath, installStatePath, recordInstall, recordInstallBatch, recordLibraryInstallBatch, readInstallState, hashDirectory, writeInstallState, } from "./state.js";
|
|
8
8
|
import { runMutationTransaction } from "./transaction.js";
|
|
9
9
|
export function installedAgents(agents, requested) {
|
|
10
10
|
const available = agents.filter((agent) => agent.installed);
|
|
@@ -19,16 +19,90 @@ export function installedAgents(agents, requested) {
|
|
|
19
19
|
}
|
|
20
20
|
function relativeHashes(root, files) {
|
|
21
21
|
return files
|
|
22
|
-
.filter((file) =>
|
|
23
|
-
const child = relative(root, file.path);
|
|
24
|
-
return child !== "" && !child.startsWith("..") && !isAbsolute(child);
|
|
25
|
-
})
|
|
22
|
+
.filter((file) => isInside(root, file.path))
|
|
26
23
|
.map((file) => ({
|
|
27
24
|
path: relative(root, file.path),
|
|
28
25
|
sha256: file.sha256,
|
|
29
26
|
}))
|
|
30
27
|
.sort((left, right) => left.path.localeCompare(right.path));
|
|
31
28
|
}
|
|
29
|
+
function isInside(root, path) {
|
|
30
|
+
const child = relative(root, path);
|
|
31
|
+
return child !== "" && !child.startsWith("..") && !isAbsolute(child);
|
|
32
|
+
}
|
|
33
|
+
function activationKey(record) {
|
|
34
|
+
return `${record.packageId}\0${record.agent}\0${record.unitId ?? ""}`;
|
|
35
|
+
}
|
|
36
|
+
async function assertManagedTargetUnchanged(target, owner) {
|
|
37
|
+
const expected = relativeHashes(target, owner?.files ?? []);
|
|
38
|
+
const actual = relativeHashes(target, await hashDirectory(target));
|
|
39
|
+
if (!expected.length || JSON.stringify(actual) !== JSON.stringify(expected))
|
|
40
|
+
throw new Error(`Installation refuses to replace drifted managed skill target: ${target}`);
|
|
41
|
+
}
|
|
42
|
+
export async function planManagedProfileReconciliation(entries) {
|
|
43
|
+
const state = await readInstallState();
|
|
44
|
+
const requestedAgents = new Set(entries.flatMap((entry) => entry.plan.targetAgents));
|
|
45
|
+
const desiredTargets = new Set(entries.flatMap((entry) => entry.plan.files.map((file) => file.target)));
|
|
46
|
+
const obsolete = [];
|
|
47
|
+
for (const activation of state.activations ?? []) {
|
|
48
|
+
if (!requestedAgents.has(activation.agent) ||
|
|
49
|
+
activation.installationState !== "installed" ||
|
|
50
|
+
activation.activationState !== "active")
|
|
51
|
+
continue;
|
|
52
|
+
const staleTargets = activation.targets.filter((target) => !desiredTargets.has(target.activePath));
|
|
53
|
+
if (!staleTargets.length)
|
|
54
|
+
continue;
|
|
55
|
+
if (staleTargets.length !== activation.targets.length)
|
|
56
|
+
throw new Error(`Profile reconciliation refuses a partially selected managed unit: ${activation.packageId}/${activation.unitId ?? "skill"}`);
|
|
57
|
+
const owner = state.installs.find((record) => record.packageId === activation.packageId);
|
|
58
|
+
for (const target of staleTargets)
|
|
59
|
+
await assertManagedTargetUnchanged(target.activePath, owner);
|
|
60
|
+
obsolete.push(activation);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
obsoleteActivationKeys: obsolete.map(activationKey),
|
|
64
|
+
obsoletePackageIds: [...new Set(obsolete.map((item) => item.packageId))],
|
|
65
|
+
obsoleteTargets: [
|
|
66
|
+
...new Set(obsolete.flatMap((item) => item.targets.map((target) => target.activePath))),
|
|
67
|
+
],
|
|
68
|
+
obsoleteUnits: obsolete.map((item) => ({
|
|
69
|
+
packageId: item.packageId,
|
|
70
|
+
agent: item.agent,
|
|
71
|
+
...(item.unitId ? { unitId: item.unitId } : {}),
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function reconciliationSignature(reconciliation) {
|
|
76
|
+
return JSON.stringify({
|
|
77
|
+
keys: [...reconciliation.obsoleteActivationKeys].sort(),
|
|
78
|
+
targets: [...reconciliation.obsoleteTargets].sort(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function recordManagedProfileReconciliation(reconciliation) {
|
|
82
|
+
if (!reconciliation.obsoleteActivationKeys.length)
|
|
83
|
+
return;
|
|
84
|
+
const obsoleteKeys = new Set(reconciliation.obsoleteActivationKeys);
|
|
85
|
+
const affectedPackages = new Set(reconciliation.obsoletePackageIds);
|
|
86
|
+
const state = await readInstallState();
|
|
87
|
+
state.activations = (state.activations ?? []).filter((record) => !obsoleteKeys.has(activationKey(record)));
|
|
88
|
+
state.installs = state.installs.flatMap((install) => {
|
|
89
|
+
if (!affectedPackages.has(install.packageId))
|
|
90
|
+
return [install];
|
|
91
|
+
const remaining = (state.activations ?? []).filter((record) => record.packageId === install.packageId &&
|
|
92
|
+
record.installationState === "installed");
|
|
93
|
+
if (!remaining.length)
|
|
94
|
+
return [];
|
|
95
|
+
const roots = remaining.flatMap((record) => record.targets.map((target) => target.activePath));
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
...install,
|
|
99
|
+
targetAgents: [...new Set(remaining.map((record) => record.agent))],
|
|
100
|
+
files: install.files.filter((file) => roots.some((root) => isInside(root, file.path))),
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
});
|
|
104
|
+
await writeInstallState(state);
|
|
105
|
+
}
|
|
32
106
|
async function assertExactDirectoryCopy(source, target, label) {
|
|
33
107
|
const [expected, actual] = await Promise.all([
|
|
34
108
|
hashDirectory(source).then((files) => relativeHashes(source, files)),
|
|
@@ -81,11 +155,7 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
|
|
|
81
155
|
if (occupied.every((target) => allowed.has(target))) {
|
|
82
156
|
for (const target of occupied) {
|
|
83
157
|
const owner = allowed.get(target);
|
|
84
|
-
|
|
85
|
-
const actual = relativeHashes(target, await hashDirectory(target));
|
|
86
|
-
if (!expected.length ||
|
|
87
|
-
JSON.stringify(actual) !== JSON.stringify(expected))
|
|
88
|
-
throw new Error(`Installation refuses to replace drifted managed skill target: ${target}`);
|
|
158
|
+
await assertManagedTargetUnchanged(target, owner);
|
|
89
159
|
}
|
|
90
160
|
return;
|
|
91
161
|
}
|
|
@@ -156,6 +226,18 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
156
226
|
if (blocking.length)
|
|
157
227
|
throw new Error(`Installation blocked by conflicts: ${blocking.map((item) => item.message).join("; ")}`);
|
|
158
228
|
const applied = await runMutationTransaction(async () => {
|
|
229
|
+
const reconciliation = options.reconcileManagedTargets
|
|
230
|
+
? await planManagedProfileReconciliation(entries)
|
|
231
|
+
: {
|
|
232
|
+
obsoleteActivationKeys: [],
|
|
233
|
+
obsoletePackageIds: [],
|
|
234
|
+
obsoleteTargets: [],
|
|
235
|
+
obsoleteUnits: [],
|
|
236
|
+
};
|
|
237
|
+
if (options.expectedReconciliation &&
|
|
238
|
+
reconciliationSignature(reconciliation) !==
|
|
239
|
+
reconciliationSignature(options.expectedReconciliation))
|
|
240
|
+
throw new Error("Profile reconciliation refused because managed state changed after preview; prepare the plan again.");
|
|
159
241
|
await assertActiveTargetsUnoccupied(entries.map((entry) => entry.plan), { allowManagedReplacement: options.replaceManagedTargets });
|
|
160
242
|
for (const entry of entries) {
|
|
161
243
|
entry.plan.conflicts = [
|
|
@@ -177,10 +259,13 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
177
259
|
...entries.flatMap((entry) => entry.plan.files.map((file) => file.target)),
|
|
178
260
|
installStatePath(),
|
|
179
261
|
...extraSnapshotPaths,
|
|
262
|
+
...reconciliation.obsoleteTargets,
|
|
180
263
|
],
|
|
181
|
-
value: entries,
|
|
264
|
+
value: { entries, reconciliation },
|
|
182
265
|
};
|
|
183
|
-
}, async (freshEntries, snapshot) => {
|
|
266
|
+
}, async ({ entries: freshEntries, reconciliation }, snapshot) => {
|
|
267
|
+
for (const target of reconciliation.obsoleteTargets)
|
|
268
|
+
await rm(target, { recursive: true, force: true });
|
|
184
269
|
if (options.replaceManagedTargets)
|
|
185
270
|
for (const target of [
|
|
186
271
|
...new Set(freshEntries.flatMap((entry) => entry.plan.files.map((file) => file.target))),
|
|
@@ -193,6 +278,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
193
278
|
for (const file of entry.plan.files)
|
|
194
279
|
await assertExactDirectoryCopy(file.source, file.target, "Exact setup copy verification failed");
|
|
195
280
|
await recordInstallBatch(freshEntries, snapshot.id);
|
|
281
|
+
await recordManagedProfileReconciliation(reconciliation);
|
|
196
282
|
});
|
|
197
283
|
return applied.snapshotId;
|
|
198
284
|
}
|