loadout-ai 0.2.1 → 0.2.3

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 - 2026-07-17
4
+
5
+ ### Fixed
6
+
7
+ - Validate large rollback snapshots in linear, constant-stack time so project-aware activation can safely snapshot large reviewed skills.
8
+ - Preserve strict malformed-base64 rejection without relying on a stack-intensive regular expression.
9
+ - Rewrite every generated Graphify top-level lookup, repair, and optional Gemini install to the reviewed hashed artifact instead of leaving unpinned package fallbacks.
10
+
11
+ ## 0.2.2 - 2026-07-17
12
+
13
+ ### Fixed
14
+
15
+ - Reconcile Stable and Power to their exact managed skill sets instead of leaving skills from the previous profile active.
16
+ - Preview every managed skill that profile setup will retire and reject an apply if managed state changed after the preview.
17
+ - Snapshot retired skills in the same transaction, preserve unmanaged skills, and refuse to retire locally changed managed content.
18
+
3
19
  ## 0.2.1 - 2026-07-17
4
20
 
5
21
  ### 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.1";
265
+ const LOADOUT_VERSION = "0.2.3";
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
  }
@@ -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
- const expected = relativeHashes(target, owner?.files ?? []);
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
  }
@@ -118,7 +118,14 @@ const generatedFilesSchema = z
118
118
  .object({
119
119
  fileExtension: z.string().regex(/^\.[A-Za-z0-9]+$/),
120
120
  from: nonControlTextSchema,
121
- to: nonControlTextSchema.refine((value) => !/[{}]/u.test(value.replaceAll("{artifactRequirement}", "")), "unknown rewrite template variable"),
121
+ to: nonControlTextSchema.refine((value) => {
122
+ const rendered = [
123
+ "{artifactRequirement}",
124
+ "{artifactUrl}",
125
+ "{artifactSha256}",
126
+ ].reduce((current, placeholder) => current.replaceAll(placeholder, ""), value);
127
+ return !/[{}]/u.test(rendered);
128
+ }, "unknown rewrite template variable"),
122
129
  mustEliminate: z.boolean(),
123
130
  })
124
131
  .strict()),
@@ -306,6 +313,8 @@ export function runtimeArtifactRequirement(recipe) {
306
313
  export function renderRuntimeRecipeValue(value, recipe) {
307
314
  return value
308
315
  .replaceAll("{artifactRequirement}", runtimeArtifactRequirement(recipe))
316
+ .replaceAll("{artifactUrl}", recipe.artifactUrl)
317
+ .replaceAll("{artifactSha256}", recipe.artifactSha256)
309
318
  .replaceAll("{version}", recipe.version);
310
319
  }
311
320
  /** Resolve reviewed path segments without ever accepting a recipe-owned root. */
@@ -162,6 +162,24 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
162
162
  to: "--from '{artifactRequirement}'",
163
163
  mustEliminate: true,
164
164
  },
165
+ {
166
+ fileExtension: ".md",
167
+ from: "install --upgrade graphifyy",
168
+ to: "install '{artifactRequirement}' --exclude-newer 2026-07-17T00:00:00Z",
169
+ mustEliminate: true,
170
+ },
171
+ {
172
+ fileExtension: ".md",
173
+ from: "pip install graphifyy",
174
+ to: "pip install '{artifactRequirement}'",
175
+ mustEliminate: true,
176
+ },
177
+ {
178
+ fileExtension: ".md",
179
+ from: "pip install 'graphifyy[gemini]'",
180
+ to: "pip install 'graphifyy[gemini] @ {artifactUrl}#sha256={artifactSha256}'",
181
+ mustEliminate: true,
182
+ },
165
183
  ],
166
184
  },
167
185
  guarantees: [
@@ -169,7 +187,7 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
169
187
  "exact top-level wheel URL and SHA-256; dependency uploads bounded by the reviewed cutoff",
170
188
  "no API keys or provider credentials inherited by installer subprocesses",
171
189
  "agent skill targets and isolated runtime are snapshotted for rollback/removal",
172
- "generated runtime lookup is rewritten to the same pinned Graphify artifact",
190
+ "generated top-level Graphify lookups and repair commands are rewritten to the same pinned artifact",
173
191
  ],
174
192
  }));
175
193
  export const REVIEWED_RUNTIME_TOOLS = [GRAPHIFY_RECIPE];
@@ -205,6 +205,19 @@ export function validateSnapshot(value) {
205
205
  return value;
206
206
  }
207
207
  function isCanonicalBase64(value) {
208
- return (value.length % 4 === 0 &&
209
- /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value));
208
+ if (value.length % 4 !== 0)
209
+ return false;
210
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
211
+ const contentLength = value.length - padding;
212
+ for (let index = 0; index < contentLength; index += 1) {
213
+ const code = value.charCodeAt(index);
214
+ const valid = (code >= 65 && code <= 90) ||
215
+ (code >= 97 && code <= 122) ||
216
+ (code >= 48 && code <= 57) ||
217
+ code === 43 ||
218
+ code === 47;
219
+ if (!valid)
220
+ return false;
221
+ }
222
+ return value.slice(contentLength) === "=".repeat(padding);
210
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadout-ai",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Universal upgrade manager for AI coding agents",