loadout-ai 0.3.1 → 0.4.0
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 +42 -0
- package/MASTER_PLAN.md +86 -13
- package/README.md +157 -284
- package/dashboard/app.js +4 -4
- package/dashboard/index.html +4 -4
- package/dist/src/cli.js +27 -21
- package/dist/src/core/adapters.js +10 -0
- package/dist/src/core/adopt.js +165 -32
- package/dist/src/core/agent-health-score.js +2 -2
- package/dist/src/core/catalog-coverage.js +2 -1
- package/dist/src/core/catalog-install.js +8 -1
- package/dist/src/core/catalog-release.js +2 -1
- package/dist/src/core/conformance.js +74 -0
- package/dist/src/core/install.js +36 -3
- package/dist/src/core/profiles.js +9 -4
- package/dist/src/core/ranking.js +1 -1
- package/dist/src/core/readme-claims.js +10 -0
- package/dist/src/core/readme-facts.js +40 -0
- package/dist/src/core/recommend.js +9 -3
- package/dist/src/core/runtime-tools.js +5 -2
- package/dist/src/core/scheduler.js +2 -1
- package/dist/src/core/snapshot.js +58 -13
- package/dist/src/core/state.js +8 -1
- package/dist/src/core/transaction.js +2 -1
- package/dist/src/core/uninstall.js +26 -2
- package/dist/src/dashboard.js +5 -2
- package/dist/src/shared/schemas.js +57 -0
- package/docs/FEATURE_TEST_MATRIX.md +16 -0
- package/docs/README_RESEARCH.md +36 -0
- package/docs/RELEASE_REVIEW.md +31 -5
- package/docs/REPOSITORY_STABILIZATION.md +190 -0
- package/docs/TESTING.md +50 -0
- package/docs/USER_TEST_GUIDE.md +26 -0
- package/docs/assets/loadout-hero.svg +259 -0
- package/docs/assets/loadout-mark.svg +54 -0
- package/docs/evidence/live-checks-2026-07-19.json +22 -0
- package/docs/evidence/live-checks.schema.json +28 -0
- package/docs/evidence/readme-claims.json +286 -0
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
- package/package.json +8 -4
- package/SIMPLE_PLAN.md +0 -44
- package/docs/plans/2026-07-18-release-0.3.md +0 -42
- package/docs/superpowers/plans/2026-07-18-cli-ux-polish.md +0 -86
package/dist/src/core/install.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { cp, lstat, rm } from "node:fs/promises";
|
|
2
|
+
import { cp, lstat, readdir, rm } from "node:fs/promises";
|
|
3
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";
|
|
@@ -117,7 +117,32 @@ async function assertActiveTargetsUnoccupied(plans, options = {}) {
|
|
|
117
117
|
...new Set(plans.flatMap((plan) => plan.files.map((file) => file.target))),
|
|
118
118
|
])
|
|
119
119
|
try {
|
|
120
|
-
await lstat(target);
|
|
120
|
+
const info = await lstat(target);
|
|
121
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
122
|
+
const queue = [target];
|
|
123
|
+
let entriesChecked = 0;
|
|
124
|
+
let empty = true;
|
|
125
|
+
while (queue.length && empty) {
|
|
126
|
+
const directory = queue.pop();
|
|
127
|
+
for (const entry of await readdir(directory, {
|
|
128
|
+
withFileTypes: true,
|
|
129
|
+
})) {
|
|
130
|
+
entriesChecked += 1;
|
|
131
|
+
if (entriesChecked > 10_000) {
|
|
132
|
+
empty = false;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
136
|
+
queue.push(join(directory, entry.name));
|
|
137
|
+
else {
|
|
138
|
+
empty = false;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (empty)
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
121
146
|
occupied.push(target);
|
|
122
147
|
}
|
|
123
148
|
catch (error) {
|
|
@@ -202,6 +227,9 @@ export async function applySkillInstall(plan, metadata, options = {}) {
|
|
|
202
227
|
value: plan,
|
|
203
228
|
};
|
|
204
229
|
}, async (freshPlan, snapshot) => {
|
|
230
|
+
// Close the preview/apply race: an empty target may have become occupied
|
|
231
|
+
// after the first check but before the transaction snapshot completed.
|
|
232
|
+
await assertActiveTargetsUnoccupied([freshPlan], options);
|
|
205
233
|
if (options.replaceManagedTargets)
|
|
206
234
|
for (const target of [
|
|
207
235
|
...new Set(freshPlan.files.map((file) => file.target)),
|
|
@@ -264,6 +292,9 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
264
292
|
value: { entries, reconciliation },
|
|
265
293
|
};
|
|
266
294
|
}, async ({ entries: freshEntries, reconciliation }, snapshot) => {
|
|
295
|
+
// Re-check immediately before any removal or copy for the same reason as
|
|
296
|
+
// the single-package path above.
|
|
297
|
+
await assertActiveTargetsUnoccupied(freshEntries.map((entry) => entry.plan), { allowManagedReplacement: options.replaceManagedTargets });
|
|
267
298
|
for (const target of reconciliation.obsoleteTargets)
|
|
268
299
|
await rm(target, { recursive: true, force: true });
|
|
269
300
|
if (options.replaceManagedTargets)
|
|
@@ -279,6 +310,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
279
310
|
await assertExactDirectoryCopy(file.source, file.target, "Exact setup copy verification failed");
|
|
280
311
|
await recordInstallBatch(freshEntries, snapshot.id);
|
|
281
312
|
await recordManagedProfileReconciliation(reconciliation);
|
|
313
|
+
await options.afterRecord?.();
|
|
282
314
|
});
|
|
283
315
|
return applied.snapshotId;
|
|
284
316
|
}
|
|
@@ -286,7 +318,7 @@ export async function applySkillInstallBatch(entries, extraSnapshotPaths = [], o
|
|
|
286
318
|
* Download a batch into the reviewed library without exposing any skill to an
|
|
287
319
|
* agent yet. This is the safe destination for Maximum Library.
|
|
288
320
|
*/
|
|
289
|
-
export async function applySkillLibraryBatch(entries) {
|
|
321
|
+
export async function applySkillLibraryBatch(entries, options = {}) {
|
|
290
322
|
if (!entries.length)
|
|
291
323
|
throw new Error("Library batch is empty");
|
|
292
324
|
const conflicts = detectInstallConflicts(entries.map((entry) => entry.plan));
|
|
@@ -343,6 +375,7 @@ export async function applySkillLibraryBatch(entries) {
|
|
|
343
375
|
}
|
|
344
376
|
}
|
|
345
377
|
await recordLibraryInstallBatch(freshEntries, snapshot.id);
|
|
378
|
+
await options.afterRecord?.();
|
|
346
379
|
});
|
|
347
380
|
return applied.snapshotId;
|
|
348
381
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { compareCatalogPackages } from "./ranking.js";
|
|
2
2
|
/**
|
|
3
|
-
* Stable is Loadout's
|
|
3
|
+
* Stable is Loadout's bounded policy-selected daily driver: broad enough to improve normal
|
|
4
4
|
* engineering work immediately, but bounded at skill granularity and restricted
|
|
5
5
|
* to catalog sources with an identified SPDX license. Maximum remains the
|
|
6
6
|
* explicit broad-library mode.
|
|
@@ -45,7 +45,8 @@ export const STABLE_BOOST_PACKAGE_IDS = Object.freeze(Object.keys(STABLE_SKILL_A
|
|
|
45
45
|
/**
|
|
46
46
|
* Trust is deliberately separate from popularity and publisher tier. No
|
|
47
47
|
* bundled record is labelled human-reviewed or benchmarked until that evidence
|
|
48
|
-
* is actually stored; Stable is the policy-
|
|
48
|
+
* is actually stored; Stable is the policy-selected subset. The stored
|
|
49
|
+
* `recommended` value remains for schema compatibility.
|
|
49
50
|
*/
|
|
50
51
|
export function catalogTrustStage(pkg) {
|
|
51
52
|
if (STABLE_BOOST_PACKAGE_IDS.includes(pkg.id) &&
|
|
@@ -58,6 +59,10 @@ export function catalogTrustStage(pkg) {
|
|
|
58
59
|
return "inspected";
|
|
59
60
|
return "discovered";
|
|
60
61
|
}
|
|
62
|
+
/** Keep stored `recommended` values compatible while naming their evidence honestly. */
|
|
63
|
+
export function formatCatalogTrustStage(stage) {
|
|
64
|
+
return stage === "recommended" ? "policy-selected" : stage;
|
|
65
|
+
}
|
|
61
66
|
export function isStableSkillSelected(packageId, skillName, targetName) {
|
|
62
67
|
const selected = STABLE_SKILL_ALLOWLIST[packageId];
|
|
63
68
|
if (!selected)
|
|
@@ -233,7 +238,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
|
|
|
233
238
|
selected.delete(secondary.id);
|
|
234
239
|
deferred.push(secondary);
|
|
235
240
|
}
|
|
236
|
-
warnings.push(`Stable
|
|
241
|
+
warnings.push(`Loadout policy selection for Stable chose ${primary.displayName}; deferred ${members
|
|
237
242
|
.slice(1)
|
|
238
243
|
.map((pkg) => pkg.displayName)
|
|
239
244
|
.join(", ")} because they overlap in ${family.label}.`);
|
|
@@ -242,7 +247,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
|
|
|
242
247
|
warnings.push(`Custom selection retains the soft overlap in ${family.label}. Review ${names} before installation.`);
|
|
243
248
|
}
|
|
244
249
|
else {
|
|
245
|
-
warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. ${primary.displayName}
|
|
250
|
+
warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. Loadout policy orders ${primary.displayName} first; review each package before installation.`);
|
|
246
251
|
}
|
|
247
252
|
}
|
|
248
253
|
return {
|
package/dist/src/core/ranking.js
CHANGED
|
@@ -96,7 +96,7 @@ export function explainCatalogScore(pkg, now = new Date()) {
|
|
|
96
96
|
],
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
-
/** A deterministic ordering:
|
|
99
|
+
/** A deterministic policy ordering: declared tier, bounded evidence score, then id. */
|
|
100
100
|
export function compareCatalogPackages(a, b) {
|
|
101
101
|
return (TIER_ORDER[b.tier] - TIER_ORDER[a.tier] ||
|
|
102
102
|
explainCatalogScore(b).score - explainCatalogScore(a).score ||
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { formatSchemaError, readmeClaimManifestSchema, } from "../shared/schemas.js";
|
|
2
|
+
export { readmeClaimManifestSchema };
|
|
3
|
+
/** Parse the versioned index of evidence for material README statements. */
|
|
4
|
+
export function parseReadmeClaimManifest(value) {
|
|
5
|
+
const result = readmeClaimManifestSchema.safeParse(value);
|
|
6
|
+
if (!result.success) {
|
|
7
|
+
throw new Error(`README claim manifest is invalid: ${formatSchemaError(result.error)}`);
|
|
8
|
+
}
|
|
9
|
+
return result.data;
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { supportedAdapterNames } from "./adapters.js";
|
|
2
|
+
import { buildCatalogCoverage } from "./catalog-coverage.js";
|
|
3
|
+
function profileFacts(allowlist) {
|
|
4
|
+
return {
|
|
5
|
+
sources: Object.keys(allowlist).length,
|
|
6
|
+
skillDirectories: Object.values(allowlist).reduce((count, skills) => count + skills.length, 0),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Derive all changeable README facts from passed authoritative source data.
|
|
11
|
+
* This module intentionally neither reads nor parses README text.
|
|
12
|
+
*/
|
|
13
|
+
export function deriveReadmeFacts({ catalog, packageJson, agents, profiles, }) {
|
|
14
|
+
const coverage = buildCatalogCoverage(catalog);
|
|
15
|
+
return {
|
|
16
|
+
catalog: {
|
|
17
|
+
records: coverage.records,
|
|
18
|
+
categories: coverage.categoryCount,
|
|
19
|
+
components: coverage.components,
|
|
20
|
+
installShapes: coverage.installShapes,
|
|
21
|
+
assertedLicenses: coverage.assertedLicenses,
|
|
22
|
+
noAssertionLicenses: coverage.noAssertionLicenses,
|
|
23
|
+
},
|
|
24
|
+
profiles: {
|
|
25
|
+
stable: profileFacts(profiles.stable),
|
|
26
|
+
power: profileFacts(profiles.power),
|
|
27
|
+
},
|
|
28
|
+
agents: {
|
|
29
|
+
supportedNames: supportedAdapterNames(agents),
|
|
30
|
+
},
|
|
31
|
+
package: {
|
|
32
|
+
name: packageJson.name,
|
|
33
|
+
version: packageJson.version,
|
|
34
|
+
bin: { ...packageJson.bin },
|
|
35
|
+
},
|
|
36
|
+
runtime: {
|
|
37
|
+
node: packageJson.engines.node,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -17,6 +17,11 @@ const SIGNAL_FILES = new Set([
|
|
|
17
17
|
"playwright.config.ts",
|
|
18
18
|
".git",
|
|
19
19
|
]);
|
|
20
|
+
/** Additive machine-readable boundary for every rule-selected recommendation list. */
|
|
21
|
+
export const RECOMMENDATION_BOUNDARY = Object.freeze({
|
|
22
|
+
selectionMethod: "deterministic-project-signal-rules",
|
|
23
|
+
qualityEvidence: "not-established",
|
|
24
|
+
});
|
|
20
25
|
export async function scanProject(root = process.cwd()) {
|
|
21
26
|
const absolute = resolve(root);
|
|
22
27
|
const entries = await readdir(absolute, { withFileTypes: true });
|
|
@@ -132,7 +137,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
|
|
|
132
137
|
}
|
|
133
138
|
export const TESTED_PROFILES = {
|
|
134
139
|
stable: {
|
|
135
|
-
description: "
|
|
140
|
+
description: "Loadout policy selection: 30 skills from four pinned, SPDX-identified sources with no extra static-risk approvals.",
|
|
136
141
|
packages: [...STABLE_BOOST_PACKAGE_IDS],
|
|
137
142
|
},
|
|
138
143
|
web: {
|
|
@@ -144,7 +149,7 @@ export const TESTED_PROFILES = {
|
|
|
144
149
|
packages: ["superpowers", "context7", "github-mcp-server"],
|
|
145
150
|
},
|
|
146
151
|
maximum: {
|
|
147
|
-
description: "Broad
|
|
152
|
+
description: "Broad inspected toolkit; always review MCP permissions before applying.",
|
|
148
153
|
packages: [
|
|
149
154
|
"superpowers",
|
|
150
155
|
"context7",
|
|
@@ -171,7 +176,8 @@ export function formatRecommendations(signals, recommendations) {
|
|
|
171
176
|
`Project: ${basename(signals.root)}`,
|
|
172
177
|
`Detected: ${[...signals.languages, ...signals.frameworks].join(", ") || "no known project signals"}`,
|
|
173
178
|
"",
|
|
174
|
-
"
|
|
179
|
+
"Rule-based project suggestions:",
|
|
180
|
+
"Rules use detected project signals and catalog membership; they do not prove package quality.",
|
|
175
181
|
];
|
|
176
182
|
if (!recommendations.length)
|
|
177
183
|
lines.push(" No matching catalog packages found.");
|
|
@@ -5,7 +5,7 @@ import { promisify } from "node:util";
|
|
|
5
5
|
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
6
|
import { detectAgents, loadoutHome, userHome } from "./paths.js";
|
|
7
7
|
import { parseRuntimeToolRecipe, renderRuntimeRecipeValue, resolveRuntimeRecipePath, } from "./runtime-tool-recipe.js";
|
|
8
|
-
import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
|
|
8
|
+
import { createSnapshot, readSnapshot, recordSnapshotPostMutationState, restoreSnapshot, } from "./snapshot.js";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
function deepFreeze(value) {
|
|
11
11
|
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
@@ -399,7 +399,9 @@ export async function applyRuntimeToolPlan(plan, options) {
|
|
|
399
399
|
if (!installed)
|
|
400
400
|
throw new Error(`${plan.recipe.displayName} is not managed by Loadout`);
|
|
401
401
|
const snapshot = await readSnapshot(installed.snapshotId);
|
|
402
|
-
await restoreSnapshot(snapshot
|
|
402
|
+
await restoreSnapshot(snapshot, {
|
|
403
|
+
requireUnchangedPostMutationState: true,
|
|
404
|
+
});
|
|
403
405
|
delete state.tools[plan.recipe.id];
|
|
404
406
|
await writeState(state, plan.stateHome);
|
|
405
407
|
return { action: "remove", snapshotId: snapshot.id };
|
|
@@ -444,6 +446,7 @@ export async function applyRuntimeToolPlan(plan, options) {
|
|
|
444
446
|
agents: plan.agents.map((agent) => agent.id),
|
|
445
447
|
runtimeRoot: plan.runtimeRoot,
|
|
446
448
|
};
|
|
449
|
+
await recordSnapshotPostMutationState(snapshot);
|
|
447
450
|
await writeState(state, plan.stateHome);
|
|
448
451
|
return { action: "install", snapshotId: snapshot.id };
|
|
449
452
|
}
|
|
@@ -4,7 +4,7 @@ import { dirname, win32, join } from "node:path";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
6
|
import { ensureDirectory, loadoutHome, userHome } from "./paths.js";
|
|
7
|
-
import { createSnapshot } from "./snapshot.js";
|
|
7
|
+
import { createSnapshot, recordSnapshotPostMutationState } from "./snapshot.js";
|
|
8
8
|
import { beginTransaction, completeTransaction, markTransactionCommitting, recoverPendingTransactions, rollbackTransaction, } from "./transaction.js";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
function parseTime(value) {
|
|
@@ -238,6 +238,7 @@ export async function applyNativeSchedulerBundle(plans, runner = defaultRunner)
|
|
|
238
238
|
if (plans[0].action === "unschedule")
|
|
239
239
|
for (const file of plans.flatMap((plan) => plan.files))
|
|
240
240
|
await rm(file.path, { force: true });
|
|
241
|
+
await recordSnapshotPostMutationState(snapshot);
|
|
241
242
|
await completeTransaction(transaction);
|
|
242
243
|
}
|
|
243
244
|
catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile, writeFile, mkdir, readdir, rm, lstat, } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir, rename, rm, lstat, } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { loadoutHome, ensureDirectory, userHome } from "./paths.js";
|
|
5
5
|
export async function createSnapshot(paths, options = {}) {
|
|
@@ -41,7 +41,7 @@ export async function createSnapshot(paths, options = {}) {
|
|
|
41
41
|
if (!info.isDirectory())
|
|
42
42
|
throw new Error(`Refusing unsupported snapshot target: ${path}`);
|
|
43
43
|
snapshot.files.push({ path, existed: true, directory: true });
|
|
44
|
-
const entries = await readdir(path, { withFileTypes: true });
|
|
44
|
+
const entries = (await readdir(path, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
45
45
|
for (const entry of entries) {
|
|
46
46
|
const child = join(path, entry.name);
|
|
47
47
|
if (entry.isSymbolicLink())
|
|
@@ -55,6 +55,8 @@ export async function createSnapshot(paths, options = {}) {
|
|
|
55
55
|
content: (await readFile(child)).toString("base64"),
|
|
56
56
|
encoding: "base64",
|
|
57
57
|
});
|
|
58
|
+
else
|
|
59
|
+
throw new Error(`Refusing unsupported snapshot target: ${child}`);
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
for (const path of snapshot.roots)
|
|
@@ -67,8 +69,10 @@ export async function createSnapshot(paths, options = {}) {
|
|
|
67
69
|
}
|
|
68
70
|
return snapshot;
|
|
69
71
|
}
|
|
70
|
-
export async function restoreSnapshot(snapshot) {
|
|
72
|
+
export async function restoreSnapshot(snapshot, options = {}) {
|
|
71
73
|
validateSnapshot(snapshot);
|
|
74
|
+
if (options.requireUnchangedPostMutationState)
|
|
75
|
+
await assertUnchangedPostMutationState(snapshot);
|
|
72
76
|
for (const root of snapshot.roots)
|
|
73
77
|
await rm(root, { recursive: true, force: true });
|
|
74
78
|
for (const directory of snapshot.files
|
|
@@ -84,6 +88,39 @@ export async function restoreSnapshot(snapshot) {
|
|
|
84
88
|
: (file.content ?? ""));
|
|
85
89
|
}
|
|
86
90
|
}
|
|
91
|
+
/** Attach the committed state used to make later user-requested rollback safe. */
|
|
92
|
+
export async function recordSnapshotPostMutationState(snapshot) {
|
|
93
|
+
const postMutation = await createSnapshot(snapshot.roots, { persist: false });
|
|
94
|
+
snapshot.postMutationFiles = postMutation.files;
|
|
95
|
+
validateSnapshot(snapshot);
|
|
96
|
+
const directory = join(loadoutHome(), "snapshots");
|
|
97
|
+
await ensureDirectory(directory);
|
|
98
|
+
const target = join(directory, `${snapshot.id}.json`);
|
|
99
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
100
|
+
await writeFile(temporary, JSON.stringify(snapshot, null, 2), {
|
|
101
|
+
mode: 0o600,
|
|
102
|
+
flag: "wx",
|
|
103
|
+
});
|
|
104
|
+
await rename(temporary, target);
|
|
105
|
+
}
|
|
106
|
+
async function assertUnchangedPostMutationState(snapshot) {
|
|
107
|
+
if (!snapshot.postMutationFiles)
|
|
108
|
+
throw new Error("Explicit rollback refused: this legacy snapshot has no post-mutation evidence. Preserve current files and use a newer snapshot.");
|
|
109
|
+
let current;
|
|
110
|
+
try {
|
|
111
|
+
current = (await createSnapshot(snapshot.roots, { persist: false })).files;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw new Error(`Explicit rollback refused because the current filesystem cannot be verified: ${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
const expected = new Map(snapshot.postMutationFiles.map((file) => [file.path, file]));
|
|
117
|
+
const actual = new Map(current.map((file) => [file.path, file]));
|
|
118
|
+
const changed = [...new Set([...expected.keys(), ...actual.keys()])]
|
|
119
|
+
.filter((path) => JSON.stringify(expected.get(path)) !== JSON.stringify(actual.get(path)))
|
|
120
|
+
.sort();
|
|
121
|
+
if (changed.length)
|
|
122
|
+
throw new Error(`Explicit rollback refused because files changed after the snapshot: ${changed.slice(0, 10).join(", ")}. Preserve or review these changes before rollback.`);
|
|
123
|
+
}
|
|
87
124
|
export async function readSnapshot(id) {
|
|
88
125
|
if (!isSnapshotId(id))
|
|
89
126
|
throw new Error(`Invalid snapshot id: ${id}`);
|
|
@@ -167,42 +204,50 @@ export function validateSnapshot(value) {
|
|
|
167
204
|
if (roots.some((candidate, candidateIndex) => candidateIndex !== index && isInside(candidate, root)))
|
|
168
205
|
throw new Error("Snapshot roots must be unique and non-overlapping");
|
|
169
206
|
}
|
|
207
|
+
validateSnapshotFiles(value.files, roots, "Snapshot");
|
|
208
|
+
if (value.postMutationFiles !== undefined) {
|
|
209
|
+
if (!Array.isArray(value.postMutationFiles))
|
|
210
|
+
throw new Error("Snapshot post-mutation files are invalid");
|
|
211
|
+
validateSnapshotFiles(value.postMutationFiles, roots, "Snapshot post-mutation");
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
function validateSnapshotFiles(files, roots, label) {
|
|
170
216
|
const paths = new Set();
|
|
171
|
-
for (const [index, file] of
|
|
217
|
+
for (const [index, file] of files.entries()) {
|
|
172
218
|
if (!isRecord(file) ||
|
|
173
219
|
typeof file.path !== "string" ||
|
|
174
220
|
typeof file.existed !== "boolean" ||
|
|
175
221
|
(file.directory !== undefined && typeof file.directory !== "boolean") ||
|
|
176
222
|
(file.content !== undefined && typeof file.content !== "string") ||
|
|
177
223
|
(file.encoding !== undefined && file.encoding !== "base64"))
|
|
178
|
-
throw new Error(
|
|
224
|
+
throw new Error(`${label} file ${index} is invalid`);
|
|
179
225
|
const filePath = file.path;
|
|
180
226
|
if (resolve(filePath) !== filePath)
|
|
181
|
-
throw new Error(
|
|
227
|
+
throw new Error(`${label} file ${index} path must be absolute and normalized`);
|
|
182
228
|
if (paths.has(filePath))
|
|
183
|
-
throw new Error(
|
|
229
|
+
throw new Error(`${label} file ${index} duplicates another path`);
|
|
184
230
|
paths.add(filePath);
|
|
185
231
|
if (!roots.some((root) => isInside(root, filePath)))
|
|
186
|
-
throw new Error(
|
|
232
|
+
throw new Error(`${label} file ${index} escapes its declared roots`);
|
|
187
233
|
if (!file.existed) {
|
|
188
234
|
if (file.directory !== undefined ||
|
|
189
235
|
file.content !== undefined ||
|
|
190
236
|
file.encoding !== undefined)
|
|
191
|
-
throw new Error(`Missing
|
|
237
|
+
throw new Error(`Missing ${label.toLowerCase()} file ${index} must not contain data`);
|
|
192
238
|
}
|
|
193
239
|
else if (file.directory) {
|
|
194
240
|
if (file.content !== undefined || file.encoding !== undefined)
|
|
195
|
-
throw new Error(
|
|
241
|
+
throw new Error(`${label} directory ${index} must not contain bytes`);
|
|
196
242
|
}
|
|
197
243
|
else if (typeof file.content !== "string" ||
|
|
198
244
|
file.encoding !== "base64" ||
|
|
199
245
|
!isCanonicalBase64(file.content))
|
|
200
|
-
throw new Error(
|
|
246
|
+
throw new Error(`${label} file ${index} bytes are invalid`);
|
|
201
247
|
}
|
|
202
248
|
for (const [index, root] of roots.entries())
|
|
203
249
|
if (!paths.has(root))
|
|
204
|
-
throw new Error(
|
|
205
|
-
return value;
|
|
250
|
+
throw new Error(`${label} root ${index} has no matching file record`);
|
|
206
251
|
}
|
|
207
252
|
function isCanonicalBase64(value) {
|
|
208
253
|
if (value.length % 4 !== 0)
|
package/dist/src/core/state.js
CHANGED
|
@@ -123,7 +123,7 @@ export async function hashDirectory(root) {
|
|
|
123
123
|
await visit(root);
|
|
124
124
|
return files;
|
|
125
125
|
}
|
|
126
|
-
export async function recordInstall(plan, snapshotId, metadata = {}) {
|
|
126
|
+
export async function recordInstall(plan, snapshotId, metadata = {}, options = {}) {
|
|
127
127
|
const record = await createInstallRecord(plan, snapshotId, metadata);
|
|
128
128
|
const state = await readInstallState();
|
|
129
129
|
state.installs = [
|
|
@@ -131,6 +131,13 @@ export async function recordInstall(plan, snapshotId, metadata = {}) {
|
|
|
131
131
|
record,
|
|
132
132
|
];
|
|
133
133
|
state.activations = mergeActivationRecords(state.activations, activationRecordsForPlan(plan, metadata));
|
|
134
|
+
await options.verifyBeforeWrite?.();
|
|
135
|
+
if (options.expectedFiles) {
|
|
136
|
+
const actual = [...record.files].sort((left, right) => left.path.localeCompare(right.path));
|
|
137
|
+
const expected = [...options.expectedFiles].sort((left, right) => left.path.localeCompare(right.path));
|
|
138
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
139
|
+
throw new Error("Installed files changed before ownership could be recorded.");
|
|
140
|
+
}
|
|
134
141
|
await writeInstallState(state);
|
|
135
142
|
return record;
|
|
136
143
|
}
|
|
@@ -2,7 +2,7 @@ import { lstat, mkdir, readdir, readFile, rename, rm, writeFile, } from "node:fs
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
5
|
-
import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
|
|
5
|
+
import { createSnapshot, readSnapshot, recordSnapshotPostMutationState, restoreSnapshot, } from "./snapshot.js";
|
|
6
6
|
import { acquireFileLock, withFileLock, } from "./file-lock.js";
|
|
7
7
|
export const transactionRoot = () => join(loadoutHome(), "staging");
|
|
8
8
|
const transactionPreparingRoot = () => join(loadoutHome(), "staging-preparing");
|
|
@@ -136,6 +136,7 @@ export async function runMutationTransaction(prepare, mutate) {
|
|
|
136
136
|
});
|
|
137
137
|
await markTransactionCommitting(transaction);
|
|
138
138
|
const result = await mutate(prepared.value, snapshot);
|
|
139
|
+
await recordSnapshotPostMutationState(snapshot);
|
|
139
140
|
await completeTransaction(transaction);
|
|
140
141
|
return { snapshotId: snapshot.id, result };
|
|
141
142
|
}
|
|
@@ -40,8 +40,32 @@ async function removeEmptyManagedDirectories(plans) {
|
|
|
40
40
|
].sort((left, right) => right.length - left.length);
|
|
41
41
|
for (const directory of candidates) {
|
|
42
42
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const queue = [directory];
|
|
44
|
+
const visited = [directory];
|
|
45
|
+
let entriesChecked = 0;
|
|
46
|
+
let empty = true;
|
|
47
|
+
while (queue.length && empty) {
|
|
48
|
+
const current = queue.pop();
|
|
49
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
50
|
+
entriesChecked += 1;
|
|
51
|
+
if (entriesChecked > 10_000) {
|
|
52
|
+
empty = false;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
56
|
+
const child = resolve(current, entry.name);
|
|
57
|
+
queue.push(child);
|
|
58
|
+
visited.push(child);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
empty = false;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (empty)
|
|
67
|
+
for (const current of visited.sort((left, right) => right.length - left.length))
|
|
68
|
+
await rmdir(current);
|
|
45
69
|
}
|
|
46
70
|
catch {
|
|
47
71
|
// Missing and non-empty directories are both safe to leave alone.
|
package/dist/src/dashboard.js
CHANGED
|
@@ -8,7 +8,7 @@ import { inspectAgents } from "./core/agent-inspection.js";
|
|
|
8
8
|
import { loadEffectiveCatalog, rankCatalog } from "./core/catalog.js";
|
|
9
9
|
import { buildUpdatePlan } from "./core/update.js";
|
|
10
10
|
import { buildHealthReport } from "./core/health.js";
|
|
11
|
-
import { recommendPackages, scanProject, TESTED_PROFILES, } from "./core/recommend.js";
|
|
11
|
+
import { recommendPackages, RECOMMENDATION_BOUNDARY, scanProject, TESTED_PROFILES, } from "./core/recommend.js";
|
|
12
12
|
import { searchLocalRegistry } from "./core/registry.js";
|
|
13
13
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
14
|
import { applySyncPlan, buildSyncPlan } from "./core/sync.js";
|
|
@@ -318,6 +318,7 @@ async function route(request, response, context) {
|
|
|
318
318
|
const signals = await scanProject(process.cwd());
|
|
319
319
|
await sendJson(response, 200, {
|
|
320
320
|
signals,
|
|
321
|
+
recommendationBoundary: RECOMMENDATION_BOUNDARY,
|
|
321
322
|
recommendations: recommendPackages(signals, await loadEffectiveCatalog()),
|
|
322
323
|
});
|
|
323
324
|
return;
|
|
@@ -368,7 +369,9 @@ export function createDashboardServer(options = {}) {
|
|
|
368
369
|
buildSync: options.buildSync ?? buildSyncPlan,
|
|
369
370
|
applySync: options.applySync ?? applySyncPlan,
|
|
370
371
|
rollback: options.rollback ??
|
|
371
|
-
(async (snapshotId) => withMutationLock(async () => restoreSnapshot(await readSnapshot(snapshotId)
|
|
372
|
+
(async (snapshotId) => withMutationLock(async () => restoreSnapshot(await readSnapshot(snapshotId), {
|
|
373
|
+
requireUnchangedPostMutationState: true,
|
|
374
|
+
}))),
|
|
372
375
|
token: randomBytes(32).toString("hex"),
|
|
373
376
|
};
|
|
374
377
|
return createServer((request, response) => {
|
|
@@ -41,8 +41,26 @@ export const componentCompatibilitySchema = z.enum([
|
|
|
41
41
|
"unsupported",
|
|
42
42
|
]);
|
|
43
43
|
export const safetyRiskLevelSchema = z.enum(["safe", "review", "blocked"]);
|
|
44
|
+
export const readmeClaimEvidenceClassSchema = z.enum([
|
|
45
|
+
"structural",
|
|
46
|
+
"unit-verified",
|
|
47
|
+
"integration-verified",
|
|
48
|
+
"live-verified",
|
|
49
|
+
"platform-verified",
|
|
50
|
+
"human-reviewed",
|
|
51
|
+
"benchmarked",
|
|
52
|
+
"policy-selected",
|
|
53
|
+
]);
|
|
54
|
+
export const readmeClaimStatusSchema = z.enum([
|
|
55
|
+
"proven",
|
|
56
|
+
"bounded",
|
|
57
|
+
"unfulfilled",
|
|
58
|
+
]);
|
|
44
59
|
const text = z.string().trim().min(1, "must not be empty");
|
|
45
60
|
const optionalText = text.optional();
|
|
61
|
+
const readmeClaimIdSchema = z
|
|
62
|
+
.string()
|
|
63
|
+
.regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/, "must be a safe dotted identifier");
|
|
46
64
|
const sha256 = z.string().regex(/^[a-f0-9]{64}$/i, "must be a SHA-256 hash");
|
|
47
65
|
const gitSha = z.string().regex(/^[a-f0-9]{40}$/i, "must be a full Git SHA");
|
|
48
66
|
const repository = z
|
|
@@ -348,6 +366,45 @@ export const loadoutLockfileSchema = z
|
|
|
348
366
|
.optional(),
|
|
349
367
|
})
|
|
350
368
|
.passthrough();
|
|
369
|
+
export const readmeClaimSchema = z
|
|
370
|
+
.object({
|
|
371
|
+
id: readmeClaimIdSchema,
|
|
372
|
+
section: text,
|
|
373
|
+
summary: text,
|
|
374
|
+
evidenceClass: readmeClaimEvidenceClassSchema,
|
|
375
|
+
status: readmeClaimStatusSchema,
|
|
376
|
+
evidence: z.array(text),
|
|
377
|
+
externalPrerequisites: z.array(text).optional(),
|
|
378
|
+
})
|
|
379
|
+
.strict()
|
|
380
|
+
.superRefine((claim, context) => {
|
|
381
|
+
if (claim.status === "proven" && claim.evidence.length === 0) {
|
|
382
|
+
context.addIssue({
|
|
383
|
+
code: "custom",
|
|
384
|
+
path: ["evidence"],
|
|
385
|
+
message: "proven claims require at least one authoritative evidence reference",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
export const readmeClaimManifestSchema = z
|
|
390
|
+
.object({
|
|
391
|
+
schemaVersion: z.literal(1),
|
|
392
|
+
claims: z.array(readmeClaimSchema).min(1),
|
|
393
|
+
})
|
|
394
|
+
.strict()
|
|
395
|
+
.superRefine((manifest, context) => {
|
|
396
|
+
const seen = new Set();
|
|
397
|
+
for (const [index, claim] of manifest.claims.entries()) {
|
|
398
|
+
if (seen.has(claim.id)) {
|
|
399
|
+
context.addIssue({
|
|
400
|
+
code: "custom",
|
|
401
|
+
path: ["claims", index, "id"],
|
|
402
|
+
message: "must be unique",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
seen.add(claim.id);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
351
408
|
/** Compact, path-aware errors suitable for CLI and persisted-data diagnostics. */
|
|
352
409
|
export function formatSchemaError(error) {
|
|
353
410
|
return error.issues
|
|
@@ -121,8 +121,10 @@ reruns and diagnosis.
|
|
|
121
121
|
| `npm run format:check` | Repository formatting | Exit 0; no files changed. |
|
|
122
122
|
| `npm run lint` | TypeScript lint rules | Exit 0. |
|
|
123
123
|
| `npm run typecheck` | TypeScript contract | Exit 0. |
|
|
124
|
+
| `npm run check:evidence` | Catalog/discovery attribution, README claims, and release boundaries | Exit 0; no claim is silently promoted. |
|
|
124
125
|
| `npm test` | Unit, integration, native filesystem, safety, and regression suites | All tests pass. |
|
|
125
126
|
| `npm run test:e2e:cli` | Disposable scan → compare → optimize → apply → rollback journey | Prints a successful CLI product flow. |
|
|
127
|
+
| `npm run test:e2e:readme` | Isolated library/activation/manifest/card/rollback journey | Prints README product flow success. |
|
|
126
128
|
| `npm run test:package` | `npm pack`, install outside the checkout, packaged CLI install/rollback | Prints package smoke success. |
|
|
127
129
|
| `npm run test:performance` | Seven scans of 1,000 real on-disk skill directories | p95 remains below the enforced five-second budget. |
|
|
128
130
|
| `npm run test:e2e:dashboard` | Loopback dashboard first-run browser test | Playwright passes; no real profile is used. |
|
|
@@ -131,6 +133,20 @@ The dashboard test needs a Playwright browser. If the browser executable is abse
|
|
|
131
133
|
run `npx playwright install chromium` once; that download is not a Loadout product
|
|
132
134
|
side effect.
|
|
133
135
|
|
|
136
|
+
The focused regression contract for the v0.3.x profile lifecycle is:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npx vitest run tests/upgrade.test.ts tests/profile-state.test.ts \
|
|
140
|
+
tests/update.test.ts tests/uninstall.test.ts tests/install.test.ts \
|
|
141
|
+
tests/mcp-recipes.test.ts tests/cli-help.test.ts
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
It verifies preview/apply upgrade behavior, saved-profile refresh detection, safe
|
|
145
|
+
profile reconciliation, complete-uninstall drift protection, recursively empty target
|
|
146
|
+
recovery, and the separation between model-provider access declarations and service
|
|
147
|
+
credentials. These are disposable automated filesystem tests; they do not prove that
|
|
148
|
+
a native agent consumes the resulting files or that a host scheduler/keychain works.
|
|
149
|
+
|
|
134
150
|
## 2. Read-only inventory, ranking, and recommendation track (R; some N)
|
|
135
151
|
|
|
136
152
|
```bash
|