loadout-ai 0.9.0 → 0.9.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +45 -50
  3. package/catalog/discovered.json +29156 -26656
  4. package/dist/src/commands/catalog-workflows.js +227 -9
  5. package/dist/src/commands/coordinate.js +148 -4
  6. package/dist/src/commands/coordination-discussions.js +71 -9
  7. package/dist/src/core/catalog/safety.js +46 -2
  8. package/dist/src/core/coordination/adapters/claude-code.js +15 -7
  9. package/dist/src/core/coordination/adapters/codex.js +29 -2
  10. package/dist/src/core/coordination/auto-contract.js +457 -0
  11. package/dist/src/core/coordination/coordinator.js +5 -4
  12. package/dist/src/core/coordination/daemon.js +6 -3
  13. package/dist/src/core/coordination/discussion-pipeline.js +313 -0
  14. package/dist/src/core/coordination/discussion.js +22 -2
  15. package/dist/src/core/coordination/git-ownership.js +217 -0
  16. package/dist/src/core/coordination/lock.js +34 -4
  17. package/dist/src/core/coordination/quick-start.js +200 -0
  18. package/dist/src/core/coordination/retention.js +67 -5
  19. package/dist/src/core/delegation/handoff-bundle.js +253 -0
  20. package/dist/src/core/delegation/handoff-templates.js +222 -0
  21. package/dist/src/core/delegation/handoff-verification.js +117 -0
  22. package/dist/src/core/delegation/handoff.js +218 -26
  23. package/dist/src/core/install/catalog-install.js +8 -2
  24. package/dist/src/core/install/snapshot.js +49 -6
  25. package/dist/src/core/install/source.js +8 -6
  26. package/dist/src/core/install/update.js +55 -1
  27. package/docs/DISCOVERED.md +249 -251
  28. package/docs/FEATURE_TEST_MATRIX.md +26 -11
  29. package/docs/LIVE_COLLABORATION.md +49 -0
  30. package/docs/REFERENCE.md +100 -0
  31. package/docs/USER_TEST_GUIDE.md +75 -2
  32. package/docs/evidence/coordination-provider-check-2026-09-05.md +33 -0
  33. package/docs/specs/HANDOFF_CONTEXT_BUNDLES.md +139 -0
  34. package/docs/specs/HANDOFF_VERIFICATION.md +83 -0
  35. package/docs/superpowers/plans/2026-09-04-handoff-context-bundles.md +109 -0
  36. package/docs/superpowers/plans/2026-09-04-handoff-verification.md +56 -0
  37. package/docs/superpowers/plans/2026-09-05-pre-release-hardening.md +175 -0
  38. package/docs/superpowers/plans/2026-09-05-public-readiness.md +20 -0
  39. package/package.json +3 -2
  40. package/skills/loadout-handoff/SKILL.md +68 -15
  41. package/docs/DEMO_SCRIPT.md +0 -152
@@ -260,7 +260,7 @@ export function formatPreparedCatalogInstall(prepared, options = {}) {
260
260
  if (directoriesPerAgent > DEFAULT_ACTIVE_SKILL_LIMIT)
261
261
  lines.push(`Capacity notice: about ${directoriesPerAgent} skill directories per agent exceeds Stable's ${DEFAULT_ACTIVE_SKILL_LIMIT}-skill bound.${prepared.selection.mode === "maximum" ? " Maximum stores them in the disabled library; optimize or activate a project-relevant working set." : prepared.selection.mode === "power" ? " Power is the explicit larger active mode; choose Stable or project optimization when lower context use matters." : " Use project-aware activation for a smaller working set."}`);
262
262
  if (failures.length)
263
- lines.push(`Preparation failures (installation will remain blocked): ${failures.map((item) => item.packageId).join(", ")}`);
263
+ lines.push(`Preparation failures (${prepared.selection.mode === "maximum" ? "will be skipped; remaining library can install" : "installation will remain blocked"}): ${failures.map((item) => item.packageId).join(", ")}`);
264
264
  if (quarantined.length)
265
265
  lines.push(`Quarantined invalid skill units: ${quarantined.length} (safe siblings remain available)`);
266
266
  if (explicit.length)
@@ -292,8 +292,14 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
292
292
  if (!prepared.entries.length)
293
293
  throw new Error("No reviewed skill packages could be prepared for installation");
294
294
  const failures = prepared.skipped.filter((item) => item.kind === "preparation-failed");
295
- if (failures.length)
295
+ if (failures.length && prepared.selection.mode !== "maximum") {
296
296
  throw new Error(`Setup is incomplete because reviewed packages failed to prepare: ${failures.map((item) => item.packageId).join(", ")}. Retry when GitHub is reachable; no partial loadout was installed.`);
297
+ }
298
+ if (failures.length) {
299
+ for (const fail of failures) {
300
+ console.error(`Warning: skipping ${fail.packageId} (preparation failed). The remaining library will install without it.`);
301
+ }
302
+ }
297
303
  const risky = prepared.entries.filter((entry) => entry.safety.approvalRequired);
298
304
  if (risky.length && !options.approveRisk)
299
305
  throw new Error(`Additional risk approval is required for: ${risky.map((entry) => entry.package.id).join(", ")}. Review the plan, then use --approve-risk.`);
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, writeFile, mkdir, readdir, rename, rm, lstat, } from "node:fs/promises";
2
+ import { readFile, writeFile, mkdir, readdir, rename, rm, lstat, chmod, } from "node:fs/promises";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { loadoutHome, ensureDirectory, userHome } from "../agents/paths.js";
5
5
  export async function createSnapshot(paths, options = {}) {
@@ -36,12 +36,18 @@ export async function createSnapshot(paths, options = {}) {
36
36
  existed: true,
37
37
  content: (await readFile(path)).toString("base64"),
38
38
  encoding: "base64",
39
+ mode: info.mode & 0o777,
39
40
  });
40
41
  return;
41
42
  }
42
43
  if (!info.isDirectory())
43
44
  throw new Error(`Refusing unsupported snapshot target: ${path}`);
44
- snapshot.files.push({ path, existed: true, directory: true });
45
+ snapshot.files.push({
46
+ path,
47
+ existed: true,
48
+ directory: true,
49
+ mode: info.mode & 0o777,
50
+ });
45
51
  const entries = (await readdir(path, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
46
52
  for (const entry of entries) {
47
53
  const child = join(path, entry.name);
@@ -49,13 +55,16 @@ export async function createSnapshot(paths, options = {}) {
49
55
  throw new Error(`Refusing to snapshot symlink: ${child}`);
50
56
  if (entry.isDirectory())
51
57
  await capture(child);
52
- else if (entry.isFile())
58
+ else if (entry.isFile()) {
59
+ const childInfo = await lstat(child);
53
60
  snapshot.files.push({
54
61
  path: child,
55
62
  existed: true,
56
63
  content: (await readFile(child)).toString("base64"),
57
64
  encoding: "base64",
65
+ mode: childInfo.mode & 0o777,
58
66
  });
67
+ }
59
68
  else
60
69
  throw new Error(`Refusing unsupported snapshot target: ${child}`);
61
70
  }
@@ -74,12 +83,15 @@ export async function restoreSnapshot(snapshot, options = {}) {
74
83
  validateSnapshot(snapshot);
75
84
  if (options.requireUnchangedPostMutationState)
76
85
  await assertUnchangedPostMutationState(snapshot);
77
- for (const root of snapshot.roots)
86
+ for (const root of snapshot.roots) {
87
+ await makeTreeRemovable(root);
78
88
  await rm(root, { recursive: true, force: true });
89
+ }
79
90
  for (const directory of snapshot.files
80
91
  .filter((file) => file.existed && file.directory)
81
- .sort((a, b) => a.path.length - b.path.length))
92
+ .sort((a, b) => a.path.length - b.path.length)) {
82
93
  await mkdir(directory.path, { recursive: true });
94
+ }
83
95
  for (const file of snapshot.files) {
84
96
  if (!file.existed || file.directory)
85
97
  continue;
@@ -87,6 +99,32 @@ export async function restoreSnapshot(snapshot, options = {}) {
87
99
  await writeFile(file.path, file.encoding === "base64"
88
100
  ? Buffer.from(file.content ?? "", "base64")
89
101
  : (file.content ?? ""));
102
+ if (file.mode !== undefined)
103
+ await chmod(file.path, file.mode);
104
+ }
105
+ for (const directory of snapshot.files
106
+ .filter((file) => file.existed && file.directory)
107
+ .sort((a, b) => b.path.length - a.path.length)) {
108
+ if (directory.mode !== undefined)
109
+ await chmod(directory.path, directory.mode);
110
+ }
111
+ }
112
+ async function makeTreeRemovable(path) {
113
+ let info;
114
+ try {
115
+ info = await lstat(path);
116
+ }
117
+ catch (error) {
118
+ if (isFileError(error, "ENOENT"))
119
+ return;
120
+ throw error;
121
+ }
122
+ if (!info.isDirectory() || info.isSymbolicLink())
123
+ return;
124
+ await chmod(path, (info.mode & 0o777) | 0o700);
125
+ for (const entry of await readdir(path, { withFileTypes: true })) {
126
+ if (entry.isDirectory() && !entry.isSymbolicLink())
127
+ await makeTreeRemovable(join(path, entry.name));
90
128
  }
91
129
  }
92
130
  /** Attach the committed state used to make later user-requested rollback safe. */
@@ -240,7 +278,12 @@ function validateSnapshotFiles(files, roots, label) {
240
278
  typeof file.existed !== "boolean" ||
241
279
  (file.directory !== undefined && typeof file.directory !== "boolean") ||
242
280
  (file.content !== undefined && typeof file.content !== "string") ||
243
- (file.encoding !== undefined && file.encoding !== "base64"))
281
+ (file.encoding !== undefined && file.encoding !== "base64") ||
282
+ (file.mode !== undefined &&
283
+ (typeof file.mode !== "number" ||
284
+ !Number.isInteger(file.mode) ||
285
+ file.mode < 0 ||
286
+ file.mode > 0o777)))
244
287
  throw new Error(`${label} file ${index} is invalid`);
245
288
  const filePath = file.path;
246
289
  if (resolve(filePath) !== filePath)
@@ -17,7 +17,7 @@ export const REPOSITORY_FETCH_DEFAULTS = {
17
17
  maxBytes: 256 * 1024 * 1024,
18
18
  maxFiles: 20_000,
19
19
  };
20
- function withFetchDefaults(options) {
20
+ export function withFetchDefaults(options) {
21
21
  return {
22
22
  ...options,
23
23
  timeoutMs: options.timeoutMs ?? REPOSITORY_FETCH_DEFAULTS.timeoutMs,
@@ -320,24 +320,26 @@ export async function fetchGitSnapshot(input, options = {}) {
320
320
  // checked before fetching the way the GitHub path does. Enforce a bound
321
321
  // AFTER a shallow clone instead of refusing bounded requests, giving parity
322
322
  // with the catalog path and a default ceiling against an adversarial repo.
323
- const boundedOptions = {
323
+ const boundedOptions = withFetchDefaults({
324
324
  ...options,
325
325
  maxBytes: options.maxBytes ?? 128 * 1024 * 1024,
326
326
  maxFiles: options.maxFiles ?? 20_000,
327
- };
327
+ });
328
328
  const url = normalizeGitUrl(input);
329
329
  const temporary = await mkdtemp(join(tmpdir(), "loadout-git-"));
330
330
  try {
331
331
  const gitEnvironment = await isolatedGitEnvironment(loadoutHome());
332
- const refArgs = options.ref ? ["--branch", normalizeRef(options.ref)] : [];
332
+ const refArgs = boundedOptions.ref
333
+ ? ["--branch", normalizeRef(boundedOptions.ref)]
334
+ : [];
333
335
  await execFileAsync("git", ["clone", "--depth", "1", ...refArgs, "--", url, temporary], {
334
336
  maxBuffer: 10 * 1024 * 1024,
335
- timeout: options.timeoutMs,
337
+ timeout: boundedOptions.timeoutMs,
336
338
  env: {
337
339
  ...gitEnvironment,
338
340
  },
339
341
  });
340
- const { stdout } = await execFileAsync("git", ["-C", temporary, "rev-parse", "HEAD"], { timeout: options.timeoutMs, env: gitEnvironment });
342
+ const { stdout } = await execFileAsync("git", ["-C", temporary, "rev-parse", "HEAD"], { timeout: boundedOptions.timeoutMs, env: gitEnvironment });
341
343
  const commit = stdout.trim();
342
344
  if (!/^[0-9a-f]{40}$/i.test(commit))
343
345
  throw new Error("Git returned an invalid commit");
@@ -8,6 +8,7 @@ import { analyzeUpdateSafety } from "../catalog/safety.js";
8
8
  import { detectAgents, loadoutHome } from "../agents/paths.js";
9
9
  import { applySkillInstall, buildSkillPlan, installedAgents, } from "./install.js";
10
10
  import { discoverSkillDirectories, validateSkillDirectory, } from "../catalog/skills.js";
11
+ import { loadEffectiveCatalog } from "../catalog/catalog.js";
11
12
  function managedUnitIds(state, packageId) {
12
13
  return [
13
14
  ...new Set((state.activations ?? [])
@@ -84,6 +85,22 @@ async function analyzeManagedUpdate(oldRoot, newRoot, unitIds) {
84
85
  approvalRequired: safetyFindings.some((finding) => finding.severity === "blocking"),
85
86
  };
86
87
  }
88
+ /**
89
+ * Index catalog-pinned commits by repository for O(1) lookup during update.
90
+ * A commit matches if the catalog's source evidence references the same SHA.
91
+ */
92
+ function buildCatalogCommitIndex(catalog) {
93
+ const index = new Map();
94
+ for (const pkg of catalog) {
95
+ if (pkg.source?.commit && pkg.repository) {
96
+ const repository = pkg.repository.toLowerCase();
97
+ const commits = index.get(repository) ?? new Set();
98
+ commits.add(pkg.source.commit.toLowerCase());
99
+ index.set(repository, commits);
100
+ }
101
+ }
102
+ return index;
103
+ }
87
104
  /** Builds a read-only update plan from persisted installs and live GitHub snapshots. */
88
105
  export async function buildUpdatePlan(resolver, options = {}) {
89
106
  const state = await readInstallState();
@@ -91,6 +108,18 @@ export async function buildUpdatePlan(resolver, options = {}) {
91
108
  ? state.installs.filter((record) => record.packageId === options.packageId)
92
109
  : state.installs;
93
110
  const results = new Array(records.length);
111
+ // Load catalog commit pins so we can flag updates that diverge from
112
+ // the last reviewed snapshot. This is a read-only safety check — it
113
+ // does not prevent the update, but adds a blocking safety finding
114
+ // when the upstream HEAD has moved past the catalog-pinned commit.
115
+ let catalogIndex;
116
+ try {
117
+ const catalog = options.catalog ?? (await loadEffectiveCatalog());
118
+ catalogIndex = buildCatalogCommitIndex(catalog);
119
+ }
120
+ catch {
121
+ catalogIndex = new Map();
122
+ }
94
123
  const lightweightResolver = resolver ??
95
124
  options.resolveHead ??
96
125
  ((repository) => resolveRepositoryHead(repository, { timeoutMs: 30_000 }));
@@ -164,12 +193,36 @@ export async function buildUpdatePlan(resolver, options = {}) {
164
193
  throw new Error(`Resolved ${current.commit}, but fetched ${fetched.commit} for safety review`);
165
194
  currentPath = fetched.path;
166
195
  }
196
+ // Check whether the upstream HEAD matches the catalog-pinned
197
+ // commit. A mismatch means the repository has changed since the
198
+ // catalog was last reviewed — the content may be fine, but it
199
+ // hasn't been vetted, so flag it for human review.
200
+ let catalogDrift = false;
201
+ if (!same && record.repository) {
202
+ const pinnedCommits = catalogIndex.get(record.repository.toLowerCase());
203
+ if (pinnedCommits &&
204
+ !pinnedCommits.has(current.commit.toLowerCase())) {
205
+ catalogDrift = true;
206
+ }
207
+ }
167
208
  if (!same && currentPath) {
168
209
  const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
169
210
  const analysis = await analyzeManagedUpdate(oldPath, currentPath, managedUnitIds(state, record.packageId));
170
211
  diff = analysis.diff;
171
- safetyFindings = analysis.safetyFindings;
212
+ safetyFindings = analysis.safetyFindings ?? [];
172
213
  approvalRequired = analysis.approvalRequired;
214
+ if (catalogDrift) {
215
+ safetyFindings.push({
216
+ severity: "blocking",
217
+ category: "instruction",
218
+ message: "Upstream HEAD has moved past the catalog-reviewed commit. " +
219
+ "The new content has not been verified by the catalog maintainer. " +
220
+ "Review the diff carefully before approving.",
221
+ paths: [],
222
+ names: ["catalog-drift"],
223
+ });
224
+ approvalRequired = true;
225
+ }
173
226
  }
174
227
  return {
175
228
  ...base,
@@ -185,6 +238,7 @@ export async function buildUpdatePlan(resolver, options = {}) {
185
238
  ...(approvalRequired ? { approvalRequired: true } : {}),
186
239
  ...(safetyFindings?.length ? { safetyFindings } : {}),
187
240
  ...(diff ? { diff } : {}),
241
+ ...(catalogDrift ? { catalogDrift: true } : {}),
188
242
  };
189
243
  }
190
244
  catch (error) {