loadout-ai 0.2.3 → 0.3.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.
@@ -0,0 +1,101 @@
1
+ /** Plain-language entry points for people using the CLI, not maintaining it. */
2
+ export const BEGINNER_GUIDE = `
3
+ START HERE
4
+
5
+ 1. See what Loadout currently manages
6
+ loadout library
7
+
8
+ 2. Preview the recommended everyday setup
9
+ loadout setup --mode stable
10
+
11
+ 3. Find additions that fit the project in this folder
12
+ loadout recommend --project .
13
+ loadout optimize --project .
14
+
15
+ 4. Check for safer updates and new discoveries
16
+ loadout health
17
+ loadout alerts
18
+ loadout candidate list --limit 10
19
+
20
+ 5. If you want a visual view, start the private local dashboard
21
+ loadout dashboard
22
+
23
+ If you decide to install something, Loadout shows a preview first and creates a
24
+ snapshot before changing managed files. Recover with: loadout rollback
25
+
26
+ Nothing above changes your agents. For the full maintainer/tooling surface, run:
27
+ loadout advanced
28
+ `.trim();
29
+ export const ADVANCED_GUIDE = [
30
+ "ADVANCED COMMANDS",
31
+ "",
32
+ "These remain available, but are hidden from the first screen so daily use stays simple.",
33
+ "",
34
+ "Discovery and evidence: candidate, discover, review-queue, intelligence, compatibility, benchmark.",
35
+ "Packages and sharing: init, add, sync, lock, export, import, audit, create, pack, publish, registry-serve.",
36
+ "Integrations and safety: mcp-recipe, mcp-config, codex-mcp-config, credentials, models, sandbox-run, canary.",
37
+ "Automation and release: watch, schedule, unschedule, catalog-sign, catalog-verify, catalog-update, claims.",
38
+ "",
39
+ "Use `loadout <command> --help` for exact options. Every mutation-capable command previews first or requires --yes.",
40
+ ].join("\n");
41
+ /** Commands retained for specialist workflows but omitted from beginner help. */
42
+ export const HIDDEN_FROM_FIRST_SCREEN = new Set([
43
+ "init",
44
+ "lock",
45
+ "export",
46
+ "import",
47
+ "audit",
48
+ "create",
49
+ "pack",
50
+ "publish",
51
+ "registry-serve",
52
+ "search",
53
+ "report",
54
+ "outcomes",
55
+ "outcome",
56
+ "share",
57
+ "card",
58
+ "compare-loadouts",
59
+ "badge",
60
+ "claims",
61
+ "alert-ignore",
62
+ "alert-pin",
63
+ "alert-unpin",
64
+ "alert-pins",
65
+ "improve",
66
+ "improve-feedback",
67
+ "adopt",
68
+ "intelligence",
69
+ "compatibility",
70
+ "skill-audit",
71
+ "interop",
72
+ "benchmark",
73
+ "capabilities",
74
+ "candidate",
75
+ "catalog-update",
76
+ "discover",
77
+ "review-queue",
78
+ "review",
79
+ "credentials",
80
+ "models",
81
+ "keygen",
82
+ "catalog-sign",
83
+ "catalog-verify",
84
+ "completion",
85
+ "mcp-recipe",
86
+ "mcp",
87
+ "inspect",
88
+ "evaluate",
89
+ "head-to-head",
90
+ "watch",
91
+ "schedule",
92
+ "unschedule",
93
+ "sandbox-run",
94
+ "mcp-config",
95
+ "codex-mcp-config",
96
+ "plan",
97
+ "install",
98
+ "convert",
99
+ "canary",
100
+ "serve",
101
+ ]);
@@ -1,4 +1,6 @@
1
1
  const commands = [
2
+ "guide",
3
+ "advanced",
2
4
  "setup",
3
5
  "upgrade",
4
6
  "init",
@@ -83,6 +85,7 @@ const commands = [
83
85
  "outcome",
84
86
  "outcomes",
85
87
  "remove",
88
+ "uninstall",
86
89
  "dashboard",
87
90
  "serve",
88
91
  "completion",
@@ -16,6 +16,7 @@ export const REVIEWED_MCP_RECIPES = [
16
16
  command: "npx",
17
17
  args: ["-y", "@playwright/mcp@0.0.78"],
18
18
  environment: [],
19
+ modelApiProviders: [],
19
20
  fixedEnvironment: {},
20
21
  permissions: [
21
22
  "browser automation",
@@ -26,6 +27,25 @@ export const REVIEWED_MCP_RECIPES = [
26
27
  reviewedAt: "2026-07-16T00:00:00Z",
27
28
  artifact: "npm:@playwright/mcp@0.0.78#sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==",
28
29
  },
30
+ {
31
+ id: "chrome-devtools",
32
+ displayName: "Chrome DevTools MCP",
33
+ source: "https://github.com/ChromeDevTools/chrome-devtools-mcp/tree/f621d0052fd241dca76e7f615b20e9d320ba965f",
34
+ serverName: "chrome-devtools",
35
+ command: "npx",
36
+ args: ["-y", "chrome-devtools-mcp@1.6.0"],
37
+ environment: [],
38
+ modelApiProviders: [],
39
+ fixedEnvironment: {},
40
+ permissions: [
41
+ "control a local Chrome browser",
42
+ "inspect pages, network activity, performance, and console output",
43
+ ],
44
+ connection: "stdio",
45
+ reviewedCommit: "f621d0052fd241dca76e7f615b20e9d320ba965f",
46
+ reviewedAt: "2026-07-18T00:00:00Z",
47
+ artifact: "npm:chrome-devtools-mcp@1.6.0#sha512-VZX6f/OjQSYhy2BGGRs+y3LsrsAQAz/HwZCWKBLVyST/4r/3zjVEjjVW7gMCVbRDuspnVdcp5hQDPrQ5UFrdZw==",
48
+ },
29
49
  {
30
50
  id: "github-readonly",
31
51
  displayName: "GitHub MCP Server (read-only)",
@@ -43,6 +63,7 @@ export const REVIEWED_MCP_RECIPES = [
43
63
  "ghcr.io/github/github-mcp-server@sha256:7b1384cdd6d025c09256af2fb6cb79bc5e87aedc957c8826b5e50d8cb82f0be3",
44
64
  ],
45
65
  environment: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
66
+ modelApiProviders: [],
46
67
  fixedEnvironment: { GITHUB_READ_ONLY: "1" },
47
68
  permissions: ["read GitHub repositories, issues, pull requests, and users"],
48
69
  connection: "stdio",
@@ -0,0 +1,101 @@
1
+ import { resolveCatalogProfile } from "./profiles.js";
2
+ import { readInstallState, writeInstallState } from "./state.js";
3
+ export async function recordInstalledProfile(prepared) {
4
+ const profile = {
5
+ mode: prepared.selection.mode,
6
+ ...(prepared.selection.packageIds
7
+ ? { packageIds: [...prepared.selection.packageIds] }
8
+ : {}),
9
+ agents: prepared.agents.map((agent) => agent.id),
10
+ catalogPackages: prepared.resolution.packages
11
+ .filter((pkg) => pkg.components?.includes("skill"))
12
+ .map((pkg) => ({
13
+ packageId: pkg.id,
14
+ ...(pkg.source?.commit ? { reviewedCommit: pkg.source.commit } : {}),
15
+ })),
16
+ appliedAt: new Date().toISOString(),
17
+ };
18
+ const state = await readInstallState();
19
+ await writeInstallState({ ...state, profile });
20
+ return profile;
21
+ }
22
+ export function evaluateInstalledProfileState(state, catalog) {
23
+ const evaluatedAt = new Date().toISOString();
24
+ const boundary = "Checks the current signed/reviewed catalog. Newly discovered candidates stay recommendations until review evidence promotes them.";
25
+ if (!state.profile)
26
+ return {
27
+ installed: false,
28
+ expectedPackages: [],
29
+ missingPackages: [],
30
+ reviewedRevisionChanges: [],
31
+ needsRefresh: false,
32
+ evaluatedAt,
33
+ boundary,
34
+ };
35
+ const resolution = resolveCatalogProfile(catalog, {
36
+ mode: state.profile.mode,
37
+ ...(state.profile.packageIds
38
+ ? { packageIds: state.profile.packageIds }
39
+ : {}),
40
+ });
41
+ const expected = resolution.packages.filter((pkg) => pkg.components?.includes("skill"));
42
+ const installed = new Map(state.installs.map((record) => [record.packageId, record]));
43
+ const previousCatalog = new Map(state.profile.catalogPackages.map((record) => [record.packageId, record]));
44
+ const missingPackages = expected
45
+ .filter((pkg) => !installed.has(pkg.id))
46
+ .map((pkg) => pkg.id);
47
+ const reviewedRevisionChanges = expected.flatMap((pkg) => {
48
+ const previous = previousCatalog.get(pkg.id);
49
+ if (!previous ||
50
+ !pkg.source?.commit ||
51
+ previous.reviewedCommit?.toLowerCase() === pkg.source.commit.toLowerCase())
52
+ return [];
53
+ return [
54
+ {
55
+ packageId: pkg.id,
56
+ previousReviewedCommit: previous.reviewedCommit,
57
+ reviewedCommit: pkg.source.commit,
58
+ },
59
+ ];
60
+ });
61
+ const oldIds = state.profile.catalogPackages
62
+ .map((item) => item.packageId)
63
+ .sort();
64
+ const expectedPackages = expected.map((pkg) => pkg.id).sort();
65
+ const selectionChanged = oldIds.join("\0") !== expectedPackages.join("\0");
66
+ return {
67
+ installed: true,
68
+ mode: state.profile.mode,
69
+ appliedAt: state.profile.appliedAt,
70
+ expectedPackages,
71
+ missingPackages,
72
+ reviewedRevisionChanges,
73
+ needsRefresh: selectionChanged ||
74
+ missingPackages.length > 0 ||
75
+ reviewedRevisionChanges.length > 0,
76
+ evaluatedAt,
77
+ boundary,
78
+ };
79
+ }
80
+ export async function evaluateInstalledProfile(catalog) {
81
+ return evaluateInstalledProfileState(await readInstallState(), catalog);
82
+ }
83
+ export function formatInstalledProfileStatus(status) {
84
+ if (!status.installed)
85
+ return "PROFILE No saved Stable, Power, Maximum, or Custom profile yet.";
86
+ return [
87
+ `PROFILE ${status.mode?.toUpperCase()}: ${status.needsRefresh ? "reviewed changes available" : "current against reviewed catalog"}`,
88
+ `Expected repositories: ${status.expectedPackages.length}`,
89
+ ...(status.missingPackages.length
90
+ ? [`Missing: ${status.missingPackages.join(", ")}`]
91
+ : []),
92
+ ...(status.reviewedRevisionChanges.length
93
+ ? [
94
+ `Reviewed revisions changed: ${status.reviewedRevisionChanges
95
+ .map((item) => item.packageId)
96
+ .join(", ")}`,
97
+ ]
98
+ : []),
99
+ `Trust boundary: ${status.boundary}`,
100
+ ].join("\n");
101
+ }
@@ -4,10 +4,12 @@ import { forgetInstall, installStatePath, readInstallState } from "./state.js";
4
4
  import { writeMcpConfigPlan } from "./mcp.js";
5
5
  import { runMutationTransaction } from "./transaction.js";
6
6
  export async function planRemove(packageId) {
7
- const record = (await readInstallState()).installs.find((entry) => entry.packageId === packageId);
8
- if (!record)
7
+ const state = await readInstallState();
8
+ const record = state.installs.find((entry) => entry.packageId === packageId);
9
+ const trackedMcp = (state.mcpInstalls ?? []).filter((entry) => entry.packageId === packageId);
10
+ if (!record && !trackedMcp.length)
9
11
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
10
- const files = await Promise.all(record.files.map(async (file) => {
12
+ const files = await Promise.all((record?.files ?? []).map(async (file) => {
11
13
  try {
12
14
  const digest = createHash("sha256")
13
15
  .update(await readFile(file.path))
@@ -24,10 +26,7 @@ export async function planRemove(packageId) {
24
26
  }
25
27
  }));
26
28
  const modified = files.filter((file) => file.status === "modified");
27
- const state = await readInstallState();
28
- const mcpServers = await Promise.all((state.mcpInstalls ?? [])
29
- .filter((entry) => entry.packageId === packageId)
30
- .map(async (entry) => {
29
+ const mcpServers = await Promise.all(trackedMcp.map(async (entry) => {
31
30
  try {
32
31
  const config = JSON.parse(await readFile(entry.configPath, "utf8"));
33
32
  if (!config.mcpServers || !(entry.serverName in config.mcpServers))
@@ -217,6 +217,11 @@ async function readState(stateHome) {
217
217
  throw error;
218
218
  }
219
219
  }
220
+ /** Return runtime tools currently installed and owned by Loadout. */
221
+ export async function listInstalledRuntimeTools(stateHome = loadoutHome()) {
222
+ const state = await readState(stateHome);
223
+ return Object.keys(state.tools).sort();
224
+ }
220
225
  async function writeState(state, stateHome) {
221
226
  await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
222
227
  }
@@ -41,7 +41,7 @@ export function planNativeScheduler(action, options = {}) {
41
41
  const job = options.job ?? "updates";
42
42
  const nativeId = `loadout-daily-${job}`;
43
43
  const command = job === "updates"
44
- ? [...launcher, "watch", "--once", "--json"]
44
+ ? [...launcher, "update", "--json"]
45
45
  : [...launcher, "discover", "--source", "all", "--queue", "--json"];
46
46
  if (selectedPlatform === "darwin") {
47
47
  const label = `com.loadout.daily.${job}`;
@@ -259,6 +259,6 @@ export function formatNativeScheduler(plan) {
259
259
  `Command: ${plan.command.join(" ")}`,
260
260
  ...plan.files.map((file) => `File: ${file.path}`),
261
261
  ...plan.applyCommands.map((item) => `Native action: ${item.command} ${item.args.join(" ")}`),
262
- `Guarantee: the scheduled command is ${plan.command.slice(2).join(" ")}; it can only report updates or queue candidates and cannot apply changes.`,
262
+ `Guarantee: no --yes flag is scheduled; this job can only report updates or queue candidates and cannot apply changes.`,
263
263
  ].join("\n");
264
264
  }
@@ -298,12 +298,15 @@ export async function recordInstallTransaction(entries, mcpEntries, snapshotId)
298
298
  export async function forgetInstall(packageId) {
299
299
  const state = await readInstallState();
300
300
  const installs = state.installs.filter((entry) => entry.packageId !== packageId);
301
- if (installs.length === state.installs.length)
301
+ const mcpInstalls = (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId);
302
+ if (installs.length === state.installs.length &&
303
+ mcpInstalls.length === (state.mcpInstalls ?? []).length)
302
304
  throw new Error(`Package is not managed by Loadout: ${packageId}`);
303
305
  await writeInstallState({
304
306
  version: 1,
305
307
  installs,
306
- mcpInstalls: (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId),
308
+ ...(state.profile ? { profile: state.profile } : {}),
309
+ mcpInstalls,
307
310
  activations: (state.activations ?? []).map((entry) => entry.packageId === packageId
308
311
  ? {
309
312
  ...entry,
@@ -0,0 +1,109 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, rm, rmdir } from "node:fs/promises";
3
+ import { dirname, parse, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { loadoutHome } from "./paths.js";
6
+ import { applyRemove, planRemove } from "./remove.js";
7
+ import { applyRuntimeToolPlan, listInstalledRuntimeTools, planRuntimeTool, } from "./runtime-tools.js";
8
+ import { applyNativeSchedulerBundle, planNativeScheduler, } from "./scheduler.js";
9
+ import { readInstallState } from "./state.js";
10
+ export async function buildUninstallPlan(dependencies = {}) {
11
+ const stateHome = loadoutHome();
12
+ const state = await readInstallState();
13
+ const packageIds = [
14
+ ...new Set([
15
+ ...state.installs.map((install) => install.packageId),
16
+ ...(state.mcpInstalls ?? []).map((install) => install.packageId),
17
+ ]),
18
+ ];
19
+ const packages = await Promise.all(packageIds.map(planRemove));
20
+ const runtimeTools = await (dependencies.runtimeTools ?? listInstalledRuntimeTools)(stateHome);
21
+ const schedulers = dependencies.schedulerPlans?.() ??
22
+ ["updates", "discovery"].map((job) => planNativeScheduler("unschedule", { job }));
23
+ const warnings = packages.flatMap((entry) => entry.warnings);
24
+ if (runtimeTools.length)
25
+ warnings.push(`${runtimeTools.length} Loadout-managed runtime tool(s) will be restored to their pre-install snapshots.`);
26
+ warnings.push("Loadout's cache, disabled library, history, and rollback snapshots will be deleted. This final state cleanup cannot itself be rolled back.");
27
+ return {
28
+ stateHome,
29
+ packages,
30
+ runtimeTools,
31
+ schedulers,
32
+ disabledLibraryRecords: (state.activations ?? []).filter((entry) => entry.activationState === "disabled").length,
33
+ blocked: packages.some((entry) => entry.blocked),
34
+ warnings,
35
+ };
36
+ }
37
+ async function removeEmptyManagedDirectories(plans) {
38
+ const candidates = [
39
+ ...new Set(plans.flatMap((plan) => plan.files.map((file) => dirname(file.path)))),
40
+ ].sort((left, right) => right.length - left.length);
41
+ for (const directory of candidates) {
42
+ try {
43
+ if ((await readdir(directory)).length === 0)
44
+ await rmdir(directory);
45
+ }
46
+ catch {
47
+ // Missing and non-empty directories are both safe to leave alone.
48
+ }
49
+ }
50
+ }
51
+ function assertSafeStateHome(stateHome) {
52
+ const expected = resolve(loadoutHome());
53
+ const selected = resolve(stateHome);
54
+ const root = parse(selected).root;
55
+ if (selected !== expected || selected === root)
56
+ throw new Error(`Refusing unsafe Loadout state deletion target: ${selected}`);
57
+ }
58
+ export async function applyUninstall(plan, dependencies = {}, options = {}) {
59
+ assertSafeStateHome(plan.stateHome);
60
+ const fresh = await buildUninstallPlan(dependencies);
61
+ if (fresh.blocked && !options.force)
62
+ throw new Error(`Complete uninstall is blocked because managed files were modified. Review them, or re-run with --force. ${fresh.warnings.join(" ")}`);
63
+ for (const id of fresh.runtimeTools) {
64
+ const runtimePlan = await planRuntimeTool(id, {
65
+ action: "remove",
66
+ stateHome: fresh.stateHome,
67
+ });
68
+ await applyRuntimeToolPlan(runtimePlan, { approveRisk: true });
69
+ }
70
+ for (const packagePlan of fresh.packages)
71
+ await applyRemove(packagePlan, { force: options.force });
72
+ await removeEmptyManagedDirectories(fresh.packages);
73
+ if (fresh.schedulers.length)
74
+ await (dependencies.unschedule
75
+ ? dependencies.unschedule(fresh.schedulers)
76
+ : applyNativeSchedulerBundle(fresh.schedulers));
77
+ await rm(fresh.stateHome, { recursive: true, force: true });
78
+ return {
79
+ removedPackages: fresh.packages.length,
80
+ removedRuntimeTools: fresh.runtimeTools.length,
81
+ };
82
+ }
83
+ const execFileAsync = promisify(execFile);
84
+ /** Remove the globally installed npm launcher after managed data is gone. */
85
+ export async function uninstallGlobalCli() {
86
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
87
+ await execFileAsync(npm, ["uninstall", "--global", "loadout-ai"], {
88
+ windowsHide: true,
89
+ });
90
+ }
91
+ export function formatUninstallPlan(plan) {
92
+ return [
93
+ "Complete Loadout uninstall preview",
94
+ "",
95
+ `Managed packages: ${plan.packages.length}`,
96
+ `Managed runtime tools: ${plan.runtimeTools.length ? plan.runtimeTools.join(", ") : "none"}`,
97
+ `Disabled library records: ${plan.disabledLibraryRecords}`,
98
+ `Daily jobs to remove: ${plan.schedulers.map((item) => item.job).join(", ") || "none"}`,
99
+ `State and cache: ${plan.stateHome}`,
100
+ ...(plan.blocked
101
+ ? ["", "BLOCKED: managed files were changed outside Loadout."]
102
+ : []),
103
+ "",
104
+ ...plan.warnings.map((warning) => `Warning: ${warning}`),
105
+ "",
106
+ "Dry run only. Re-run with `loadout uninstall --yes` to remove Loadout-managed data.",
107
+ "Add `--remove-cli` to also uninstall the global npm command.",
108
+ ].join("\n");
109
+ }
@@ -85,66 +85,89 @@ async function analyzeManagedUpdate(oldRoot, newRoot, unitIds) {
85
85
  };
86
86
  }
87
87
  /** Builds a read-only update plan from persisted installs and live GitHub snapshots. */
88
- export async function buildUpdatePlan(resolver = async (repository) => fetchRepositorySnapshot(repository)) {
88
+ export async function buildUpdatePlan(resolver = async (repository) => fetchRepositorySnapshot(repository, { timeoutMs: 30_000 }), options = {}) {
89
89
  const state = await readInstallState();
90
- return Promise.all(state.installs.map(async (record) => {
91
- const disabledAgents = (state.activations ?? [])
92
- .filter((activation) => activation.packageId === record.packageId &&
93
- activation.installationState === "installed" &&
94
- activation.activationState === "disabled")
95
- .map((activation) => activation.agent);
96
- const base = {
97
- packageId: record.packageId,
98
- repository: record.repository,
99
- installedCommit: record.resolvedCommit,
100
- targetAgents: record.targetAgents,
101
- ...(disabledAgents.length ? { disabledAgents } : {}),
102
- };
103
- if (!record.repository || !record.resolvedCommit) {
104
- return {
105
- ...base,
106
- status: "untracked",
107
- action: "Reinstall from the original source to begin update tracking.",
108
- };
109
- }
110
- try {
111
- const current = await resolver(record.repository);
112
- const same = current.commit.toLowerCase() === record.resolvedCommit.toLowerCase();
113
- let diff;
114
- let safetyFindings;
115
- let approvalRequired = false;
116
- if (!same && current.path) {
117
- const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
118
- const analysis = await analyzeManagedUpdate(oldPath, current.path, managedUnitIds(state, record.packageId));
119
- diff = analysis.diff;
120
- safetyFindings = analysis.safetyFindings;
121
- approvalRequired = analysis.approvalRequired;
122
- }
123
- return {
124
- ...base,
125
- availableCommit: current.commit,
126
- status: same ? "up-to-date" : "update-available",
127
- action: same
128
- ? "No action required."
129
- : disabledAgents.length
130
- ? `Enable ${record.packageId} for ${disabledAgents.join(", ")} before applying an update; planning remains read-only.`
131
- : approvalRequired
132
- ? `Approval required: review safety warnings before updating ${record.packageId}.`
133
- : `Run loadout update --package ${record.packageId} after review.`,
134
- ...(approvalRequired ? { approvalRequired: true } : {}),
135
- ...(safetyFindings?.length ? { safetyFindings } : {}),
136
- ...(diff ? { diff } : {}),
137
- };
138
- }
139
- catch (error) {
140
- return {
141
- ...base,
142
- status: "error",
143
- action: "Retry when GitHub is reachable; the installed version was not changed.",
144
- error: error instanceof Error ? error.message : String(error),
145
- };
90
+ const records = options.packageId
91
+ ? state.installs.filter((record) => record.packageId === options.packageId)
92
+ : state.installs;
93
+ const results = new Array(records.length);
94
+ let cursor = 0;
95
+ let completed = 0;
96
+ const workers = Array.from({
97
+ length: Math.min(Math.max(1, options.concurrency ?? 4), Math.max(1, records.length)),
98
+ }, async () => {
99
+ while (cursor < records.length) {
100
+ const index = cursor++;
101
+ const record = records[index];
102
+ results[index] = await (async () => {
103
+ const disabledAgents = (state.activations ?? [])
104
+ .filter((activation) => activation.packageId === record.packageId &&
105
+ activation.installationState === "installed" &&
106
+ activation.activationState === "disabled")
107
+ .map((activation) => activation.agent);
108
+ const base = {
109
+ packageId: record.packageId,
110
+ repository: record.repository,
111
+ installedCommit: record.resolvedCommit,
112
+ targetAgents: record.targetAgents,
113
+ ...(disabledAgents.length ? { disabledAgents } : {}),
114
+ };
115
+ if (!record.repository || !record.resolvedCommit) {
116
+ return {
117
+ ...base,
118
+ status: "untracked",
119
+ action: "Reinstall from the original source to begin update tracking.",
120
+ };
121
+ }
122
+ try {
123
+ const current = await resolver(record.repository);
124
+ const same = current.commit.toLowerCase() ===
125
+ record.resolvedCommit.toLowerCase();
126
+ let diff;
127
+ let safetyFindings;
128
+ let approvalRequired = false;
129
+ if (!same && current.path) {
130
+ const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
131
+ const analysis = await analyzeManagedUpdate(oldPath, current.path, managedUnitIds(state, record.packageId));
132
+ diff = analysis.diff;
133
+ safetyFindings = analysis.safetyFindings;
134
+ approvalRequired = analysis.approvalRequired;
135
+ }
136
+ return {
137
+ ...base,
138
+ availableCommit: current.commit,
139
+ status: same ? "up-to-date" : "update-available",
140
+ action: same
141
+ ? "No action required."
142
+ : disabledAgents.length
143
+ ? `Enable ${record.packageId} for ${disabledAgents.join(", ")} before applying an update; planning remains read-only.`
144
+ : approvalRequired
145
+ ? `Approval required: review safety warnings before updating ${record.packageId}.`
146
+ : `Run loadout update --package ${record.packageId} --yes after review.`,
147
+ ...(approvalRequired ? { approvalRequired: true } : {}),
148
+ ...(safetyFindings?.length ? { safetyFindings } : {}),
149
+ ...(diff ? { diff } : {}),
150
+ };
151
+ }
152
+ catch (error) {
153
+ return {
154
+ ...base,
155
+ status: "error",
156
+ action: "Retry when GitHub is reachable; the installed version was not changed.",
157
+ error: error instanceof Error ? error.message : String(error),
158
+ };
159
+ }
160
+ })();
161
+ completed += 1;
162
+ options.onProgress?.({
163
+ completed,
164
+ total: records.length,
165
+ packageId: record.packageId,
166
+ });
146
167
  }
147
- }));
168
+ });
169
+ await Promise.all(workers);
170
+ return results;
148
171
  }
149
172
  export function formatUpdatePlan(plans) {
150
173
  if (plans.length === 0)
@@ -160,6 +183,12 @@ export function formatUpdatePlan(plans) {
160
183
  })
161
184
  .join("\n");
162
185
  }
186
+ /** Updates safe enough for an explicit whole-profile `update --yes` apply. */
187
+ export function selectSafeAutomaticUpdates(plans) {
188
+ return plans.filter((plan) => plan.status === "update-available" &&
189
+ !plan.approvalRequired &&
190
+ !plan.disabledAgents?.length);
191
+ }
163
192
  function quarantineRoot() {
164
193
  return join(loadoutHome(), "quarantine");
165
194
  }
@@ -304,6 +304,18 @@ export const installStateSchema = z
304
304
  installs: z.array(installRecordSchema),
305
305
  mcpInstalls: z.array(mcpInstallRecordSchema).default([]),
306
306
  activations: z.array(managedActivationRecordSchema).default([]),
307
+ profile: z
308
+ .object({
309
+ mode: z.enum(["stable", "power", "maximum", "custom"]),
310
+ packageIds: z.array(text).optional(),
311
+ agents: z.array(agentIdSchema),
312
+ catalogPackages: z.array(z.object({
313
+ packageId: text,
314
+ reviewedCommit: optionalText,
315
+ })),
316
+ appliedAt: text,
317
+ })
318
+ .optional(),
307
319
  })
308
320
  .passthrough();
309
321
  const lockedPackageSchema = z