loadout-ai 0.3.1 → 0.4.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 +67 -0
- package/MASTER_PLAN.md +177 -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/active-policy.js +172 -38
- package/dist/src/core/active-set.js +13 -4
- 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 +11 -11
- 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 +104 -12
- 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/target-occupancy.js +50 -0
- 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 +42 -4
- 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/plans/2026-07-20-project-activation-safety.md +469 -0
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -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
|
@@ -15,8 +15,53 @@ const SIGNAL_FILES = new Set([
|
|
|
15
15
|
"next.config.mjs",
|
|
16
16
|
"vite.config.ts",
|
|
17
17
|
"playwright.config.ts",
|
|
18
|
+
"SECURITY.md",
|
|
18
19
|
".git",
|
|
19
20
|
]);
|
|
21
|
+
/** Additive machine-readable boundary for every rule-selected recommendation list. */
|
|
22
|
+
export const RECOMMENDATION_BOUNDARY = Object.freeze({
|
|
23
|
+
selectionMethod: "deterministic-project-signal-rules",
|
|
24
|
+
qualityEvidence: "not-established",
|
|
25
|
+
});
|
|
26
|
+
const SIGNAL_LABELS = {
|
|
27
|
+
"javascript/typescript": "TypeScript",
|
|
28
|
+
playwright: "Playwright",
|
|
29
|
+
"node-cli": "Node CLI",
|
|
30
|
+
"npm-package": "npm package",
|
|
31
|
+
release: "release automation",
|
|
32
|
+
mcp: "MCP tooling",
|
|
33
|
+
security: "security policy",
|
|
34
|
+
commander: "Commander",
|
|
35
|
+
zod: "Zod",
|
|
36
|
+
vitest: "Vitest",
|
|
37
|
+
jest: "Jest",
|
|
38
|
+
};
|
|
39
|
+
const SIGNAL_DISPLAY_ORDER = [
|
|
40
|
+
"javascript/typescript",
|
|
41
|
+
"playwright",
|
|
42
|
+
"node-cli",
|
|
43
|
+
"npm-package",
|
|
44
|
+
"release",
|
|
45
|
+
"mcp",
|
|
46
|
+
"security",
|
|
47
|
+
"commander",
|
|
48
|
+
"zod",
|
|
49
|
+
"vitest",
|
|
50
|
+
"jest",
|
|
51
|
+
];
|
|
52
|
+
export function formatDetectedSignals(signals) {
|
|
53
|
+
const values = new Set([
|
|
54
|
+
...signals.languages,
|
|
55
|
+
...signals.frameworks,
|
|
56
|
+
...signals.roles,
|
|
57
|
+
...signals.tools,
|
|
58
|
+
]);
|
|
59
|
+
const ordered = [
|
|
60
|
+
...SIGNAL_DISPLAY_ORDER.filter((value) => values.delete(value)),
|
|
61
|
+
...values,
|
|
62
|
+
];
|
|
63
|
+
return ordered.map((value) => SIGNAL_LABELS[value] ?? value).join(", ");
|
|
64
|
+
}
|
|
20
65
|
export async function scanProject(root = process.cwd()) {
|
|
21
66
|
const absolute = resolve(root);
|
|
22
67
|
const entries = await readdir(absolute, { withFileTypes: true });
|
|
@@ -26,6 +71,8 @@ export async function scanProject(root = process.cwd()) {
|
|
|
26
71
|
.sort();
|
|
27
72
|
const languages = new Set();
|
|
28
73
|
const frameworks = new Set();
|
|
74
|
+
const roles = new Set();
|
|
75
|
+
const tools = new Set();
|
|
29
76
|
if (files.includes("package.json")) {
|
|
30
77
|
languages.add("javascript/typescript");
|
|
31
78
|
try {
|
|
@@ -39,8 +86,28 @@ export async function scanProject(root = process.cwd()) {
|
|
|
39
86
|
frameworks.add("vue");
|
|
40
87
|
if (deps.svelte)
|
|
41
88
|
frameworks.add("svelte");
|
|
42
|
-
if (deps.playwright || deps["@playwright/test"])
|
|
89
|
+
if (deps.playwright || deps["@playwright/test"]) {
|
|
43
90
|
frameworks.add("playwright");
|
|
91
|
+
tools.add("playwright");
|
|
92
|
+
}
|
|
93
|
+
if (deps.vitest)
|
|
94
|
+
tools.add("vitest");
|
|
95
|
+
if (deps.jest)
|
|
96
|
+
tools.add("jest");
|
|
97
|
+
if (deps.commander)
|
|
98
|
+
tools.add("commander");
|
|
99
|
+
if (deps.zod)
|
|
100
|
+
tools.add("zod");
|
|
101
|
+
if (pkg.bin)
|
|
102
|
+
roles.add("node-cli");
|
|
103
|
+
if (pkg.publishConfig || pkg.private === false)
|
|
104
|
+
roles.add("npm-package");
|
|
105
|
+
const scripts = pkg.scripts ?? {};
|
|
106
|
+
if (scripts.prepack ||
|
|
107
|
+
Object.keys(scripts).some((name) => /(?:package|release)/.test(name)))
|
|
108
|
+
roles.add("release");
|
|
109
|
+
if ((pkg.keywords ?? []).some((keyword) => /(?:^|-)mcp(?:-|$)/i.test(keyword)))
|
|
110
|
+
roles.add("mcp");
|
|
44
111
|
}
|
|
45
112
|
catch {
|
|
46
113
|
/* malformed project metadata is reported through an empty framework set */
|
|
@@ -60,22 +127,37 @@ export async function scanProject(root = process.cwd()) {
|
|
|
60
127
|
languages.add(".net");
|
|
61
128
|
if (files.some((file) => file.startsWith("next.config")))
|
|
62
129
|
frameworks.add("next.js");
|
|
63
|
-
if (files.includes("playwright.config.ts"))
|
|
130
|
+
if (files.includes("playwright.config.ts")) {
|
|
64
131
|
frameworks.add("playwright");
|
|
132
|
+
tools.add("playwright");
|
|
133
|
+
}
|
|
134
|
+
if (files.includes("SECURITY.md"))
|
|
135
|
+
roles.add("security");
|
|
65
136
|
return {
|
|
66
137
|
root: absolute,
|
|
67
138
|
languages: [...languages],
|
|
68
139
|
frameworks: [...frameworks],
|
|
140
|
+
roles: [...roles],
|
|
141
|
+
tools: [...tools],
|
|
69
142
|
files,
|
|
70
143
|
};
|
|
71
144
|
}
|
|
72
145
|
export function recommendPackages(signals, catalog) {
|
|
73
|
-
const
|
|
146
|
+
const packages = new Map(catalog.map((pkg) => [pkg.id, pkg]));
|
|
74
147
|
const result = [];
|
|
75
148
|
const add = (packageId, reason, confidence) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
result.push({
|
|
149
|
+
const pkg = packages.get(packageId);
|
|
150
|
+
if (pkg && !result.some((item) => item.packageId === packageId))
|
|
151
|
+
result.push({
|
|
152
|
+
packageId,
|
|
153
|
+
reason,
|
|
154
|
+
confidence,
|
|
155
|
+
kind: pkg.components?.includes("skill")
|
|
156
|
+
? "skill-library"
|
|
157
|
+
: pkg.components?.some((component) => component === "mcp" || component === "plugin")
|
|
158
|
+
? "mcp-runtime"
|
|
159
|
+
: "unavailable",
|
|
160
|
+
});
|
|
79
161
|
};
|
|
80
162
|
add("superpowers", "Useful engineering planning, testing, and review workflows for most repositories.", "high");
|
|
81
163
|
add("context7", "Current library documentation helps agents avoid outdated APIs.", signals.languages.length ? "high" : "medium");
|
|
@@ -124,6 +206,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
|
|
|
124
206
|
packageId: item.packageId,
|
|
125
207
|
reason: item.reason,
|
|
126
208
|
confidence: item.confidence,
|
|
209
|
+
kind: item.kind,
|
|
127
210
|
...(item.localOutcomeAdjustment !== undefined
|
|
128
211
|
? { localOutcomeAdjustment: item.localOutcomeAdjustment }
|
|
129
212
|
: {}),
|
|
@@ -132,7 +215,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
|
|
|
132
215
|
}
|
|
133
216
|
export const TESTED_PROFILES = {
|
|
134
217
|
stable: {
|
|
135
|
-
description: "
|
|
218
|
+
description: "Loadout policy selection: 30 skills from four pinned, SPDX-identified sources with no extra static-risk approvals.",
|
|
136
219
|
packages: [...STABLE_BOOST_PACKAGE_IDS],
|
|
137
220
|
},
|
|
138
221
|
web: {
|
|
@@ -144,7 +227,7 @@ export const TESTED_PROFILES = {
|
|
|
144
227
|
packages: ["superpowers", "context7", "github-mcp-server"],
|
|
145
228
|
},
|
|
146
229
|
maximum: {
|
|
147
|
-
description: "Broad
|
|
230
|
+
description: "Broad inspected toolkit; always review MCP permissions before applying.",
|
|
148
231
|
packages: [
|
|
149
232
|
"superpowers",
|
|
150
233
|
"context7",
|
|
@@ -169,13 +252,22 @@ export function profileManifestPackages(profile, catalog) {
|
|
|
169
252
|
export function formatRecommendations(signals, recommendations) {
|
|
170
253
|
const lines = [
|
|
171
254
|
`Project: ${basename(signals.root)}`,
|
|
172
|
-
`Detected: ${
|
|
255
|
+
`Detected: ${formatDetectedSignals(signals) || "no known project signals"}`,
|
|
173
256
|
"",
|
|
174
|
-
"
|
|
257
|
+
"Rule-based project suggestions:",
|
|
258
|
+
"Rules use detected project signals and catalog membership; they do not prove package quality.",
|
|
175
259
|
];
|
|
176
260
|
if (!recommendations.length)
|
|
177
261
|
lines.push(" No matching catalog packages found.");
|
|
178
|
-
|
|
179
|
-
|
|
262
|
+
const kindLabels = {
|
|
263
|
+
"skill-library": "skill library",
|
|
264
|
+
"mcp-runtime": "MCP/runtime setup",
|
|
265
|
+
unavailable: "unavailable",
|
|
266
|
+
};
|
|
267
|
+
for (const item of recommendations) {
|
|
268
|
+
lines.push(` ${item.packageId} [${item.confidence}, ${kindLabels[item.kind]}] — ${item.reason}`);
|
|
269
|
+
if (item.kind === "mcp-runtime")
|
|
270
|
+
lines.push(" Explicit setup only; preview credentials and permissions before enabling it.");
|
|
271
|
+
}
|
|
180
272
|
return lines.join("\n");
|
|
181
273
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Inspect a prospective skill target without following symlinks or executing
|
|
5
|
+
* any content. Missing paths and recursively empty directories are safe to
|
|
6
|
+
* replace; every uncertain state fails closed.
|
|
7
|
+
*/
|
|
8
|
+
export async function inspectTargetOccupancy(path, maximumEntries = 10_000) {
|
|
9
|
+
let root;
|
|
10
|
+
try {
|
|
11
|
+
root = await lstat(path);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error &&
|
|
15
|
+
typeof error === "object" &&
|
|
16
|
+
"code" in error &&
|
|
17
|
+
error.code === "ENOENT")
|
|
18
|
+
return { occupied: false };
|
|
19
|
+
return { occupied: true, reason: "unreadable" };
|
|
20
|
+
}
|
|
21
|
+
if (root.isSymbolicLink())
|
|
22
|
+
return { occupied: true, reason: "symlink" };
|
|
23
|
+
if (!root.isDirectory())
|
|
24
|
+
return { occupied: true, reason: "unsupported" };
|
|
25
|
+
const queue = [path];
|
|
26
|
+
let inspected = 0;
|
|
27
|
+
while (queue.length) {
|
|
28
|
+
const directory = queue.pop();
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { occupied: true, reason: "unreadable" };
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
inspected += 1;
|
|
38
|
+
if (inspected > maximumEntries)
|
|
39
|
+
return { occupied: true, reason: "inspection-limit" };
|
|
40
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
41
|
+
queue.push(join(directory, entry.name));
|
|
42
|
+
else
|
|
43
|
+
return {
|
|
44
|
+
occupied: true,
|
|
45
|
+
reason: entry.isSymbolicLink() ? "symlink" : "content",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { occupied: false };
|
|
50
|
+
}
|
|
@@ -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
|