loadout-ai 0.1.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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,136 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { buildSkillPlan, applySkillInstall } from "./install.js";
5
+ import { loadEffectiveCatalog } from "./catalog.js";
6
+ import { agentSkillsDirectory } from "./paths.js";
7
+ import { fetchRepositorySnapshot } from "./source.js";
8
+ import { readInstallState } from "./state.js";
9
+ import { restoreSnapshot } from "./snapshot.js";
10
+ import { withMutationLock } from "./transaction.js";
11
+ const DEFAULT_REPOSITORY = "obra/superpowers";
12
+ const DEFAULT_PACKAGE_ID = "obra-superpowers";
13
+ function isWithin(root, path) {
14
+ const resolvedRoot = resolve(root);
15
+ const resolvedPath = resolve(path);
16
+ return (resolvedPath === resolvedRoot ||
17
+ resolvedPath.startsWith(`${resolvedRoot}${sep}`));
18
+ }
19
+ /**
20
+ * Run a real install and rollback inside a disposable profile. This function
21
+ * deliberately creates virtual agent targets instead of detecting local
22
+ * binaries: the demo proves the installer while never treating a user's real
23
+ * Codex, Claude, or other agent profile as a demo target.
24
+ */
25
+ export async function runIsolatedDemo(options = {}) {
26
+ if (options.source && options.repository)
27
+ throw new Error("Demo accepts either a local source or a repository, not both");
28
+ const profile = await mkdtemp(join(tmpdir(), "loadout-demo-"));
29
+ const demoLoadoutHome = join(profile, ".loadout");
30
+ const packageId = options.packageId ?? DEFAULT_PACKAGE_ID;
31
+ const agentIds = options.agents?.length
32
+ ? options.agents
33
+ : ["codex"];
34
+ const previousUserHome = process.env.LOADOUT_USER_HOME;
35
+ const previousLoadoutHome = process.env.LOADOUT_HOME;
36
+ let completed = false;
37
+ process.env.LOADOUT_USER_HOME = profile;
38
+ process.env.LOADOUT_HOME = demoLoadoutHome;
39
+ try {
40
+ const requestedRepository = options.repository ?? DEFAULT_REPOSITORY;
41
+ const catalog = options.source
42
+ ? []
43
+ : (options.catalog ?? (await loadEffectiveCatalog()));
44
+ const reviewedPackage = catalog.find((pkg) => pkg.repository.toLowerCase() === requestedRepository.toLowerCase() &&
45
+ Boolean(pkg.source?.commit));
46
+ if (!options.source && !options.repository && !reviewedPackage)
47
+ throw new Error(`The default demo repository ${DEFAULT_REPOSITORY} is missing its reviewed catalog commit`);
48
+ const fetched = options.source
49
+ ? undefined
50
+ : await (options.fetchSnapshot ?? fetchRepositorySnapshot)(requestedRepository, reviewedPackage?.source?.commit
51
+ ? { ref: reviewedPackage.source.commit }
52
+ : undefined);
53
+ if (reviewedPackage?.source?.commit &&
54
+ fetched.commit.toLowerCase() !==
55
+ reviewedPackage.source.commit.toLowerCase())
56
+ throw new Error(`Demo resolved ${fetched.commit}, expected reviewed commit ${reviewedPackage.source.commit}`);
57
+ const source = options.source ?? fetched.path;
58
+ const repository = fetched?.repository ?? `local source: ${resolve(source)}`;
59
+ const targets = agentIds.map((id) => ({
60
+ id,
61
+ displayName: `${id} (isolated demo target)`,
62
+ installed: true,
63
+ skillsDirectory: agentSkillsDirectory(id, profile),
64
+ }));
65
+ const plan = await buildSkillPlan(source, packageId, targets);
66
+ if (plan.files.some((file) => !isWithin(profile, file.target))) {
67
+ throw new Error("Demo safety check failed: a planned target escaped the isolated profile");
68
+ }
69
+ const snapshotId = await applySkillInstall(plan, fetched
70
+ ? { repository: fetched.repository, resolvedCommit: fetched.commit }
71
+ : undefined);
72
+ const state = await readInstallState();
73
+ const record = state.installs.find((item) => item.packageId === packageId && item.snapshotId === snapshotId);
74
+ if (!record)
75
+ throw new Error("Demo safety check failed: the isolated install was not recorded");
76
+ if (record.files.some((file) => !isWithin(profile, file.path))) {
77
+ throw new Error("Demo safety check failed: managed state escaped the isolated profile");
78
+ }
79
+ let rolledBack = false;
80
+ if (!options.keep) {
81
+ const snapshotPath = join(demoLoadoutHome, "snapshots", `${snapshotId}.json`);
82
+ const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
83
+ await withMutationLock(() => restoreSnapshot(snapshot));
84
+ rolledBack = true;
85
+ }
86
+ completed = true;
87
+ return {
88
+ profile,
89
+ loadoutHome: demoLoadoutHome,
90
+ packageId,
91
+ repository,
92
+ resolvedCommit: fetched?.commit,
93
+ reviewed: Boolean(reviewedPackage),
94
+ targetAgents: agentIds,
95
+ plannedFiles: plan.files.length,
96
+ installedFiles: record.files.length,
97
+ snapshotId,
98
+ rolledBack,
99
+ cleanedUp: !options.keep,
100
+ };
101
+ }
102
+ finally {
103
+ if (previousUserHome === undefined)
104
+ delete process.env.LOADOUT_USER_HOME;
105
+ else
106
+ process.env.LOADOUT_USER_HOME = previousUserHome;
107
+ if (previousLoadoutHome === undefined)
108
+ delete process.env.LOADOUT_HOME;
109
+ else
110
+ process.env.LOADOUT_HOME = previousLoadoutHome;
111
+ // The default mode has completed its rollback before removal. On an error
112
+ // we also remove the partial profile; source repositories are never deleted.
113
+ if (!options.keep || !completed)
114
+ await rm(profile, { recursive: true, force: true });
115
+ }
116
+ }
117
+ export function formatDemoResult(result) {
118
+ const source = result.resolvedCommit
119
+ ? `${result.repository} @ ${result.resolvedCommit}`
120
+ : result.repository;
121
+ const ending = result.rolledBack
122
+ ? "Rollback verified and the temporary profile was removed."
123
+ : `Isolated profile retained at ${result.profile}. Delete it when finished.`;
124
+ return [
125
+ "Loadout safe demo complete.",
126
+ `${result.reviewed ? "Reviewed source" : "Source"}: ${source}`,
127
+ `Virtual targets: ${result.targetAgents.join(", ")}`,
128
+ `Installed ${result.plannedFiles} planned skill directory(ies); tracking ${result.installedFiles} file(s).`,
129
+ `Snapshot: ${result.snapshotId}`,
130
+ ending,
131
+ ].join("\n");
132
+ }
133
+ export const demoDefaults = {
134
+ repository: DEFAULT_REPOSITORY,
135
+ packageId: DEFAULT_PACKAGE_ID,
136
+ };
@@ -0,0 +1,83 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { relative, resolve } from "node:path";
4
+ const MCP_NAMES = new Set([
5
+ "mcp.json",
6
+ ".mcp.json",
7
+ "claude_desktop_config.json",
8
+ ]);
9
+ function classify(path) {
10
+ const name = path.split("/").at(-1) ?? path;
11
+ if (name === "SKILL.md")
12
+ return "skill";
13
+ if (MCP_NAMES.has(name))
14
+ return "mcp";
15
+ if (/config\.(json|ya?ml)$/i.test(name))
16
+ return "config";
17
+ return undefined;
18
+ }
19
+ async function files(root) {
20
+ const result = new Map();
21
+ const base = resolve(root);
22
+ async function visit(directory, depth) {
23
+ if (depth > 8)
24
+ return;
25
+ let entries;
26
+ try {
27
+ entries = await readdir(directory, { withFileTypes: true });
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ for (const entry of entries) {
33
+ if (entry.name === ".git" || entry.name === "node_modules")
34
+ continue;
35
+ const absolute = resolve(directory, entry.name);
36
+ if (entry.isSymbolicLink())
37
+ continue;
38
+ if (entry.isDirectory()) {
39
+ await visit(absolute, depth + 1);
40
+ continue;
41
+ }
42
+ if (!entry.isFile())
43
+ continue;
44
+ const path = relative(base, absolute).split("\\").join("/");
45
+ if (!classify(path))
46
+ continue;
47
+ try {
48
+ const info = await stat(absolute);
49
+ if (info.size > 2_000_000)
50
+ continue;
51
+ const content = await readFile(absolute);
52
+ result.set(path, createHash("sha256").update(content).digest("hex"));
53
+ }
54
+ catch {
55
+ /* a concurrently removed file is simply omitted */
56
+ }
57
+ }
58
+ }
59
+ await visit(base, 0);
60
+ return result;
61
+ }
62
+ /** Compare two repository revisions using hashes only; never executes repository code. */
63
+ export async function diffRepositorySnapshots(oldPath, newPath) {
64
+ const [oldFiles, newFiles] = await Promise.all([
65
+ files(oldPath),
66
+ files(newPath),
67
+ ]);
68
+ const paths = [...new Set([...oldFiles.keys(), ...newFiles.keys()])].sort();
69
+ return paths.flatMap((path) => {
70
+ const oldHash = oldFiles.get(path);
71
+ const newHash = newFiles.get(path);
72
+ const kind = classify(path);
73
+ if (!kind)
74
+ return [];
75
+ if (!oldHash)
76
+ return [{ path, kind, status: "added" }];
77
+ if (!newHash)
78
+ return [{ path, kind, status: "removed" }];
79
+ if (oldHash !== newHash)
80
+ return [{ path, kind, status: "changed" }];
81
+ return [];
82
+ });
83
+ }
@@ -0,0 +1,165 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { writeFileAtomically } from "./atomic-file.js";
4
+ import { ensureDirectory } from "./paths.js";
5
+ export class ConnectorRequestError extends Error {
6
+ status;
7
+ retryAfterSeconds;
8
+ constructor(message, options = {}) {
9
+ super(message);
10
+ this.name = "ConnectorRequestError";
11
+ this.status = options.status;
12
+ this.retryAfterSeconds = options.retryAfterSeconds;
13
+ }
14
+ }
15
+ function nonnegativeInteger(value) {
16
+ if (!value || !/^\d+$/.test(value))
17
+ return undefined;
18
+ const parsed = Number(value);
19
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
20
+ }
21
+ export function rateLimitFromHeaders(headers) {
22
+ const limit = nonnegativeInteger(headers.get("x-ratelimit-limit"));
23
+ const remaining = nonnegativeInteger(headers.get("x-ratelimit-remaining"));
24
+ const resetAfterSeconds = nonnegativeInteger(headers.get("x-ratelimit-reset"));
25
+ return limit !== undefined ||
26
+ remaining !== undefined ||
27
+ resetAfterSeconds !== undefined
28
+ ? { limit, remaining, resetAfterSeconds }
29
+ : undefined;
30
+ }
31
+ function retryAfter(headers) {
32
+ const seconds = nonnegativeInteger(headers.get("retry-after"));
33
+ if (seconds !== undefined)
34
+ return seconds;
35
+ const date = headers.get("retry-after");
36
+ if (!date)
37
+ return undefined;
38
+ const parsed = Date.parse(date);
39
+ if (!Number.isFinite(parsed))
40
+ return undefined;
41
+ return Math.max(0, Math.ceil((parsed - Date.now()) / 1000));
42
+ }
43
+ /** Fetch one same-origin JSON page with a shared timeout and decoded body cap. */
44
+ export async function fetchBoundedJson(source, options = {}) {
45
+ const original = new URL(source);
46
+ if (original.protocol !== "https:" || original.username || original.password)
47
+ throw new ConnectorRequestError("Discovery endpoints must use credential-free HTTPS");
48
+ const signal = AbortSignal.timeout(Math.max(250, Math.min(options.timeoutMs ?? 10_000, 120_000)));
49
+ const maximum = Math.max(1_024, Math.min(options.maximumBytes ?? 5 * 1024 * 1024, 20 * 1024 * 1024));
50
+ const maximumRedirects = Math.max(0, Math.min(options.maximumRedirects ?? 3, 5));
51
+ let current = original;
52
+ let response;
53
+ for (let redirects = 0; redirects <= maximumRedirects; redirects++) {
54
+ try {
55
+ response = await (options.fetcher ?? fetch)(current, {
56
+ method: "GET",
57
+ headers: options.headers,
58
+ redirect: "manual",
59
+ signal,
60
+ });
61
+ }
62
+ catch (error) {
63
+ throw new ConnectorRequestError(`Discovery request failed: ${error instanceof Error ? error.message : String(error)}`);
64
+ }
65
+ if (response.status < 300 || response.status >= 400)
66
+ break;
67
+ const location = response.headers.get("location");
68
+ if (!location)
69
+ throw new ConnectorRequestError("Discovery redirect has no Location header", {
70
+ status: response.status,
71
+ });
72
+ if (redirects === maximumRedirects)
73
+ throw new ConnectorRequestError("Discovery request exceeded its redirect limit");
74
+ const next = new URL(location, current);
75
+ if (next.protocol !== "https:" ||
76
+ next.origin !== original.origin ||
77
+ next.username ||
78
+ next.password)
79
+ throw new ConnectorRequestError("Discovery request refused a cross-origin or non-HTTPS redirect");
80
+ current = next;
81
+ }
82
+ if (!response)
83
+ throw new ConnectorRequestError("Discovery request did not produce a response");
84
+ if (!response.ok)
85
+ throw new ConnectorRequestError(`Discovery request failed with HTTP ${response.status}`, {
86
+ status: response.status,
87
+ retryAfterSeconds: retryAfter(response.headers),
88
+ });
89
+ const declared = nonnegativeInteger(response.headers.get("content-length"));
90
+ if (declared !== undefined && declared > maximum)
91
+ throw new ConnectorRequestError(`Discovery response exceeds the ${maximum} byte limit`, { status: response.status });
92
+ if (!response.body)
93
+ throw new ConnectorRequestError("Discovery response has no body", {
94
+ status: response.status,
95
+ });
96
+ const reader = response.body.getReader();
97
+ const chunks = [];
98
+ let length = 0;
99
+ while (true) {
100
+ let chunk;
101
+ try {
102
+ chunk = await reader.read();
103
+ }
104
+ catch (error) {
105
+ throw new ConnectorRequestError(`Discovery response could not be read: ${error instanceof Error ? error.message : String(error)}`, { status: response.status });
106
+ }
107
+ if (chunk.done)
108
+ break;
109
+ length += chunk.value.byteLength;
110
+ if (length > maximum) {
111
+ await reader.cancel().catch(() => undefined);
112
+ throw new ConnectorRequestError(`Discovery response exceeds the ${maximum} byte limit`, { status: response.status });
113
+ }
114
+ chunks.push(chunk.value);
115
+ }
116
+ let value;
117
+ try {
118
+ value = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8"));
119
+ }
120
+ catch {
121
+ throw new ConnectorRequestError("Discovery response is not valid JSON", {
122
+ status: response.status,
123
+ });
124
+ }
125
+ return { value, headers: response.headers, url: current.toString() };
126
+ }
127
+ export async function readDiscoveryConnectorCache(path, source, validate, maximumRecords = 2_000) {
128
+ try {
129
+ const info = await stat(path);
130
+ if (!info.isFile() || info.size > 10 * 1024 * 1024)
131
+ return undefined;
132
+ const value = JSON.parse(await readFile(path, "utf8"));
133
+ if (!value || typeof value !== "object" || Array.isArray(value))
134
+ return undefined;
135
+ const cache = value;
136
+ if (cache.schemaVersion !== 1 ||
137
+ cache.source !== source ||
138
+ typeof cache.cachedAt !== "string" ||
139
+ !Number.isFinite(Date.parse(cache.cachedAt)) ||
140
+ !Array.isArray(cache.records) ||
141
+ cache.records.length > maximumRecords ||
142
+ !cache.records.every(validate))
143
+ return undefined;
144
+ return cache;
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ }
150
+ export async function writeDiscoveryConnectorCache(path, source, records, now = new Date()) {
151
+ await ensureDirectory(dirname(path));
152
+ await writeFileAtomically(path, `${JSON.stringify({
153
+ schemaVersion: 1,
154
+ source,
155
+ cachedAt: now.toISOString(),
156
+ records,
157
+ }, null, 2)}\n`);
158
+ }
159
+ /** Remove records already represented by another source without re-ranking. */
160
+ export function filterDiscoveryRecordsAgainstKeys(records, occupiedKeys) {
161
+ const occupied = new Set([...occupiedKeys].map((key) => key.trim().toLowerCase()).filter(Boolean));
162
+ return records.filter((record) => !occupied.has(record.identityKey.toLowerCase()) &&
163
+ (!record.repositoryKey ||
164
+ !occupied.has(record.repositoryKey.toLowerCase())));
165
+ }
@@ -0,0 +1,95 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { detectAgents, directoryExists, loadoutHome, userHome, } from "./paths.js";
4
+ import { inspectAgents } from "./agent-inspection.js";
5
+ async function writable(path) {
6
+ // A not-yet-created directory is healthy when any existing ancestor is
7
+ // writable; this handles first-run paths such as ~/.agents/skills.
8
+ let candidate = path;
9
+ while (true) {
10
+ try {
11
+ await access(candidate, constants.W_OK);
12
+ return true;
13
+ }
14
+ catch {
15
+ const parent = dirname(candidate);
16
+ if (parent === candidate)
17
+ return false;
18
+ candidate = parent;
19
+ }
20
+ }
21
+ }
22
+ export async function runDoctor() {
23
+ const home = userHome();
24
+ const stateHome = loadoutHome();
25
+ const loadoutHomeExists = await directoryExists(stateHome);
26
+ const loadoutHomeWritable = await writable(stateHome);
27
+ const agents = await detectAgents();
28
+ const inventories = await inspectAgents(agents);
29
+ const diagnosedAgents = await Promise.all(agents.map(async (agent, index) => {
30
+ const exists = await directoryExists(agent.skillsDirectory);
31
+ const canWrite = await writable(agent.skillsDirectory);
32
+ const issues = [];
33
+ if (!agent.installed)
34
+ issues.push(`Install or configure ${agent.displayName} to enable it.`);
35
+ if (!exists)
36
+ issues.push("Skills directory does not exist yet; Loadout will create it during install.");
37
+ if (!canWrite)
38
+ issues.push("Skills directory is not writable; check permissions or choose another home.");
39
+ return {
40
+ agent,
41
+ inventory: inventories[index],
42
+ skillsRootExists: exists,
43
+ writable: canWrite,
44
+ issues,
45
+ };
46
+ }));
47
+ const issues = [];
48
+ if (!loadoutHomeExists)
49
+ issues.push("Loadout state directory does not exist yet; it will be created on first install.");
50
+ if (!loadoutHomeWritable)
51
+ issues.push("Loadout state directory is not writable; check LOADOUT_HOME permissions.");
52
+ if (!diagnosedAgents.some(({ agent }) => agent.installed)) {
53
+ issues.push("No supported agent executable was detected on PATH.");
54
+ }
55
+ return {
56
+ platform: process.platform,
57
+ userHome: home,
58
+ loadoutHome: stateHome,
59
+ loadoutHomeExists,
60
+ loadoutHomeWritable,
61
+ agents: diagnosedAgents,
62
+ issues,
63
+ };
64
+ }
65
+ export function formatDoctorReport(report) {
66
+ const lines = [
67
+ "Loadout doctor",
68
+ `Platform: ${report.platform}`,
69
+ `User home: ${report.userHome}`,
70
+ `State home: ${report.loadoutHome}`,
71
+ ];
72
+ lines.push(`State status: ${report.loadoutHomeExists ? "present" : "not created"} (${report.loadoutHomeWritable ? "writable" : "not writable"})`, "", "Agents:");
73
+ for (const entry of report.agents) {
74
+ const status = entry.agent.installed ? "detected" : "not found";
75
+ lines.push(` ${entry.agent.installed ? "✓" : "○"} ${entry.agent.displayName}: ${status}`, ` skills: ${entry.agent.skillsDirectory} (${entry.skillsRootExists ? "present" : "will be created"}, ${entry.writable ? "writable" : "not writable"})`);
76
+ for (const component of entry.inventory.components) {
77
+ const detail = component.scanned
78
+ ? `${component.directoryExists ? `${component.entries.length} filesystem item(s)` : "not created"}${component.directory ? ` at ${component.directory}` : ""}`
79
+ : (component.note ?? "not inspected");
80
+ lines.push(` ${component.type}: ${component.compatibility}; ${detail}`);
81
+ }
82
+ for (const warning of entry.inventory.warnings)
83
+ lines.push(` ! ${warning}`);
84
+ for (const issue of entry.issues)
85
+ lines.push(` ! ${issue}`);
86
+ }
87
+ if (report.issues.length) {
88
+ lines.push("", "Recommendations:");
89
+ for (const issue of report.issues)
90
+ lines.push(` ! ${issue}`);
91
+ }
92
+ else
93
+ lines.push("", "No global blocking issues found.");
94
+ return lines.join("\n");
95
+ }