loadout-ai 0.2.0 → 0.2.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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 - 2026-07-17
4
+
5
+ ### Fixed
6
+
7
+ - Make Stable and Power setup safe to rerun when Loadout already owns some target skills.
8
+ - Preserve unmanaged skills and refuse to replace managed skills that changed outside Loadout.
9
+ - Verify every replacement byte and keep mixed existing/new-agent installs rollback-safe.
10
+
3
11
  ## 0.2.0 - 2026-07-17
4
12
 
5
13
  ### Added
package/MASTER_PLAN.md CHANGED
@@ -1023,13 +1023,12 @@ universal or permanent truth.
1023
1023
  `NOASSERTION` records, before distribution.
1024
1024
  - [ ] `P12-31 [TERRA]` Publish `loadout-ai` to npm and run clean-machine package tests
1025
1025
  from outside the repository on macOS, Windows, Linux, and Node 20/22.
1026
- - Partial: `loadout-ai@0.1.2` was published publicly on 2026-07-16 while the GitHub
1027
- repository remained private. Registry metadata, `npx` version/help, and a real
1028
- read-only Stable preview passed outside the source directory. Patch `0.1.2`
1029
- adds Codex Desktop detection through its existing `~/.codex` home when the
1030
- standalone `codex` executable is absent from the user's shell PATH. CI contains an
1031
- opt-in OS/Node matrix and OIDC/provenance release workflow; clean independent-user
1032
- installs on Windows/macOS/Linux remain required.
1026
+ - Partial: `loadout-ai@0.2.0` was published publicly on 2026-07-17 while the GitHub
1027
+ repository remained private. Registry version/integrity, a clean external npm
1028
+ install, version/help, 50-record catalog coverage, and the real disposable
1029
+ Superpowers install/rollback demo pass on macOS. Hosted matrix evidence already
1030
+ covers Windows/macOS/Linux and Node 20/22; clean independent-user installs on
1031
+ Windows and Linux remain required.
1033
1032
  - [ ] `P12-32 [HUMAN]` Run moderated founder testing on the real Claude and Codex
1034
1033
  profiles with snapshots and explicit rollback checkpoints.
1035
1034
  - Partial 2026-07-16: the founder ran the published npm CLI against the real macOS
@@ -1170,7 +1169,7 @@ universal or permanent truth.
1170
1169
  1,000 on-disk skills at a 1.29-second p95 on macOS/Node 23.
1171
1170
  - [ ] `P15-06 [HUMAN]` Approve public repository visibility, complete the six
1172
1171
  `NOASSERTION` license decisions, authenticate npm, and publish `loadout-ai`.
1173
- - Partial 2026-07-16: npm authentication and the public `loadout-ai@0.1.2`
1172
+ - Partial 2026-07-17: npm authentication and the public `loadout-ai@0.2.0`
1174
1173
  publication succeeded. Repository visibility remains private at the owner's
1175
1174
  request, and the six root-level `NOASSERTION` decisions remain a human release
1176
1175
  gate; therefore this combined item is not ticked.
@@ -1601,6 +1600,10 @@ accident.
1601
1600
  - Repeat the safe no-key journey with Claude, then collect one Windows and one Linux
1602
1601
  external session. Convert every reproducible failure into a regression test before
1603
1602
  public-release claims.
1603
+ - Partial 2026-07-17: the registry and clean external macOS install are verified for
1604
+ `0.2.0`; the published CLI loads the complete command surface, reports all 50
1605
+ catalog records, and completes its disposable install/rollback demo. Real founder
1606
+ Stable/Power/Maximum and Claude-profile exercises remain deliberately pending.
1604
1607
  - [x] `P17-09 [TERRA implementation, LUNA copy review]` Make Power resilient and
1605
1608
  explain the product in beginner language before the `0.2.0` release.
1606
1609
  - Power now quarantines a rejected selected skill while retaining safe selected
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.0";
265
+ const LOADOUT_VERSION = "0.2.1";
266
266
  function durableSchedulerLauncher() {
267
267
  return [
268
268
  join(dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx"),
@@ -230,5 +230,7 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
230
230
  throw new Error(`Additional risk approval is required for: ${risky.map((entry) => entry.package.id).join(", ")}. Review the plan, then use --approve-risk.`);
231
231
  return prepared.selection.mode === "maximum"
232
232
  ? applySkillLibraryBatch(prepared.entries)
233
- : applySkillInstallBatch(prepared.entries);
233
+ : applySkillInstallBatch(prepared.entries, [], {
234
+ replaceManagedTargets: true,
235
+ });
234
236
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { cp, lstat, rm } from "node:fs/promises";
3
- import { basename, dirname, join, posix, relative, win32 } from "node:path";
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";
@@ -17,6 +17,26 @@ export function installedAgents(agents, requested) {
17
17
  }
18
18
  return selected;
19
19
  }
20
+ function relativeHashes(root, files) {
21
+ return files
22
+ .filter((file) => {
23
+ const child = relative(root, file.path);
24
+ return child !== "" && !child.startsWith("..") && !isAbsolute(child);
25
+ })
26
+ .map((file) => ({
27
+ path: relative(root, file.path),
28
+ sha256: file.sha256,
29
+ }))
30
+ .sort((left, right) => left.path.localeCompare(right.path));
31
+ }
32
+ async function assertExactDirectoryCopy(source, target, label) {
33
+ const [expected, actual] = await Promise.all([
34
+ hashDirectory(source).then((files) => relativeHashes(source, files)),
35
+ hashDirectory(target).then((files) => relativeHashes(target, files)),
36
+ ]);
37
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
38
+ throw new Error(`${label} for ${target}`);
39
+ }
20
40
  async function assertActiveTargetsUnoccupied(plans, options = {}) {
21
41
  const occupied = [];
22
42
  for (const target of [
@@ -35,26 +55,40 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
35
55
  }
36
56
  if (occupied.length && options.allowManagedReplacement) {
37
57
  const state = await readInstallState();
38
- const allowed = new Set(plans.flatMap((plan) => {
58
+ const allowed = new Map();
59
+ for (const plan of plans) {
39
60
  const install = state.installs.find((record) => record.packageId === plan.packageId);
40
61
  const packageActivations = (state.activations ?? []).filter((record) => record.packageId === plan.packageId);
41
- const active = packageActivations.length === 0 ||
42
- packageActivations.some((record) => record.installationState === "installed" &&
43
- record.activationState === "active");
44
- if (!install || !active)
45
- return [];
62
+ if (!install)
63
+ continue;
64
+ const activeTargets = new Set(packageActivations
65
+ .filter((record) => record.installationState === "installed" &&
66
+ record.activationState === "active")
67
+ .flatMap((record) => record.targets.map((target) => target.activePath)));
46
68
  const recorded = install.files
47
69
  .filter((file) => basename(file.path) === "SKILL.md")
48
- .map((file) => dirname(file.path));
70
+ .map((file) => dirname(file.path))
71
+ .filter((target) => packageActivations.length === 0 || activeTargets.has(target));
49
72
  const legacy = packageActivations.length
50
73
  ? []
51
74
  : plan.files
52
75
  .map((file) => file.target)
53
76
  .filter((target) => basename(target) === plan.packageId);
54
- return [...recorded, ...legacy];
55
- }));
56
- if (occupied.every((target) => allowed.has(target)))
77
+ for (const target of [...recorded, ...legacy])
78
+ if (plan.files.some((file) => file.target === target))
79
+ allowed.set(target, install);
80
+ }
81
+ if (occupied.every((target) => allowed.has(target))) {
82
+ for (const target of occupied) {
83
+ 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}`);
89
+ }
57
90
  return;
91
+ }
58
92
  }
59
93
  if (occupied.length)
60
94
  throw new Error(`Installation refuses ${occupied.length} occupied skill target(s); scan, compare, or adopt them first. First target: ${occupied[0]}`);
@@ -106,20 +140,7 @@ export async function applySkillInstall(plan, metadata, options = {}) {
106
140
  await applySkillPlan(freshPlan);
107
141
  if (options.replaceManagedTargets)
108
142
  for (const file of freshPlan.files) {
109
- const expected = (await hashDirectory(file.source))
110
- .map((entry) => ({
111
- path: relative(file.source, entry.path),
112
- sha256: entry.sha256,
113
- }))
114
- .sort((left, right) => left.path.localeCompare(right.path));
115
- const actual = (await hashDirectory(file.target))
116
- .map((entry) => ({
117
- path: relative(file.target, entry.path),
118
- sha256: entry.sha256,
119
- }))
120
- .sort((left, right) => left.path.localeCompare(right.path));
121
- if (JSON.stringify(actual) !== JSON.stringify(expected))
122
- throw new Error(`Exact update copy verification failed for ${file.target}`);
143
+ await assertExactDirectoryCopy(file.source, file.target, "Exact update copy verification failed");
123
144
  }
124
145
  await recordInstall(freshPlan, snapshot.id, metadata);
125
146
  await options.verifyBeforeCommit?.(snapshot.id);
@@ -127,7 +148,7 @@ export async function applySkillInstall(plan, metadata, options = {}) {
127
148
  return applied.snapshotId;
128
149
  }
129
150
  /** Apply all selected packages as one filesystem transaction and one state update. */
130
- export async function applySkillInstallBatch(entries, extraSnapshotPaths = []) {
151
+ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], options = {}) {
131
152
  if (!entries.length)
132
153
  throw new Error("Installation batch is empty");
133
154
  const conflicts = detectInstallConflicts(entries.map((entry) => entry.plan));
@@ -135,7 +156,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = []) {
135
156
  if (blocking.length)
136
157
  throw new Error(`Installation blocked by conflicts: ${blocking.map((item) => item.message).join("; ")}`);
137
158
  const applied = await runMutationTransaction(async () => {
138
- await assertActiveTargetsUnoccupied(entries.map((entry) => entry.plan));
159
+ await assertActiveTargetsUnoccupied(entries.map((entry) => entry.plan), { allowManagedReplacement: options.replaceManagedTargets });
139
160
  for (const entry of entries) {
140
161
  entry.plan.conflicts = [
141
162
  ...(entry.plan.conflicts ?? []),
@@ -160,8 +181,17 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = []) {
160
181
  value: entries,
161
182
  };
162
183
  }, async (freshEntries, snapshot) => {
184
+ if (options.replaceManagedTargets)
185
+ for (const target of [
186
+ ...new Set(freshEntries.flatMap((entry) => entry.plan.files.map((file) => file.target))),
187
+ ])
188
+ await rm(target, { recursive: true, force: true });
163
189
  for (const entry of freshEntries)
164
190
  await applySkillPlan(entry.plan);
191
+ if (options.replaceManagedTargets)
192
+ for (const entry of freshEntries)
193
+ for (const file of entry.plan.files)
194
+ await assertExactDirectoryCopy(file.source, file.target, "Exact setup copy verification failed");
165
195
  await recordInstallBatch(freshEntries, snapshot.id);
166
196
  });
167
197
  return applied.snapshotId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadout-ai",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Universal upgrade manager for AI coding agents",