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.
- package/CHANGELOG.md +97 -0
- package/README.md +45 -50
- package/catalog/discovered.json +29156 -26656
- package/dist/src/commands/catalog-workflows.js +227 -9
- package/dist/src/commands/coordinate.js +148 -4
- package/dist/src/commands/coordination-discussions.js +71 -9
- package/dist/src/core/catalog/safety.js +46 -2
- package/dist/src/core/coordination/adapters/claude-code.js +15 -7
- package/dist/src/core/coordination/adapters/codex.js +29 -2
- package/dist/src/core/coordination/auto-contract.js +457 -0
- package/dist/src/core/coordination/coordinator.js +5 -4
- package/dist/src/core/coordination/daemon.js +6 -3
- package/dist/src/core/coordination/discussion-pipeline.js +313 -0
- package/dist/src/core/coordination/discussion.js +22 -2
- package/dist/src/core/coordination/git-ownership.js +217 -0
- package/dist/src/core/coordination/lock.js +34 -4
- package/dist/src/core/coordination/quick-start.js +200 -0
- package/dist/src/core/coordination/retention.js +67 -5
- package/dist/src/core/delegation/handoff-bundle.js +253 -0
- package/dist/src/core/delegation/handoff-templates.js +222 -0
- package/dist/src/core/delegation/handoff-verification.js +117 -0
- package/dist/src/core/delegation/handoff.js +218 -26
- package/dist/src/core/install/catalog-install.js +8 -2
- package/dist/src/core/install/snapshot.js +49 -6
- package/dist/src/core/install/source.js +8 -6
- package/dist/src/core/install/update.js +55 -1
- package/docs/DISCOVERED.md +249 -251
- package/docs/FEATURE_TEST_MATRIX.md +26 -11
- package/docs/LIVE_COLLABORATION.md +49 -0
- package/docs/REFERENCE.md +100 -0
- package/docs/USER_TEST_GUIDE.md +75 -2
- package/docs/evidence/coordination-provider-check-2026-09-05.md +33 -0
- package/docs/specs/HANDOFF_CONTEXT_BUNDLES.md +139 -0
- package/docs/specs/HANDOFF_VERIFICATION.md +83 -0
- package/docs/superpowers/plans/2026-09-04-handoff-context-bundles.md +109 -0
- package/docs/superpowers/plans/2026-09-04-handoff-verification.md +56 -0
- package/docs/superpowers/plans/2026-09-05-pre-release-hardening.md +175 -0
- package/docs/superpowers/plans/2026-09-05-public-readiness.md +20 -0
- package/package.json +3 -2
- package/skills/loadout-handoff/SKILL.md +68 -15
- 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({
|
|
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 =
|
|
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:
|
|
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:
|
|
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) {
|