loadout-ai 0.4.0 → 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.
@@ -15,6 +15,7 @@ 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
  ]);
20
21
  /** Additive machine-readable boundary for every rule-selected recommendation list. */
@@ -22,6 +23,45 @@ export const RECOMMENDATION_BOUNDARY = Object.freeze({
22
23
  selectionMethod: "deterministic-project-signal-rules",
23
24
  qualityEvidence: "not-established",
24
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
+ }
25
65
  export async function scanProject(root = process.cwd()) {
26
66
  const absolute = resolve(root);
27
67
  const entries = await readdir(absolute, { withFileTypes: true });
@@ -31,6 +71,8 @@ export async function scanProject(root = process.cwd()) {
31
71
  .sort();
32
72
  const languages = new Set();
33
73
  const frameworks = new Set();
74
+ const roles = new Set();
75
+ const tools = new Set();
34
76
  if (files.includes("package.json")) {
35
77
  languages.add("javascript/typescript");
36
78
  try {
@@ -44,8 +86,28 @@ export async function scanProject(root = process.cwd()) {
44
86
  frameworks.add("vue");
45
87
  if (deps.svelte)
46
88
  frameworks.add("svelte");
47
- if (deps.playwright || deps["@playwright/test"])
89
+ if (deps.playwright || deps["@playwright/test"]) {
48
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");
49
111
  }
50
112
  catch {
51
113
  /* malformed project metadata is reported through an empty framework set */
@@ -65,22 +127,37 @@ export async function scanProject(root = process.cwd()) {
65
127
  languages.add(".net");
66
128
  if (files.some((file) => file.startsWith("next.config")))
67
129
  frameworks.add("next.js");
68
- if (files.includes("playwright.config.ts"))
130
+ if (files.includes("playwright.config.ts")) {
69
131
  frameworks.add("playwright");
132
+ tools.add("playwright");
133
+ }
134
+ if (files.includes("SECURITY.md"))
135
+ roles.add("security");
70
136
  return {
71
137
  root: absolute,
72
138
  languages: [...languages],
73
139
  frameworks: [...frameworks],
140
+ roles: [...roles],
141
+ tools: [...tools],
74
142
  files,
75
143
  };
76
144
  }
77
145
  export function recommendPackages(signals, catalog) {
78
- const ids = new Set(catalog.map((pkg) => pkg.id));
146
+ const packages = new Map(catalog.map((pkg) => [pkg.id, pkg]));
79
147
  const result = [];
80
148
  const add = (packageId, reason, confidence) => {
81
- if (ids.has(packageId) &&
82
- !result.some((item) => item.packageId === packageId))
83
- result.push({ packageId, reason, confidence });
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
+ });
84
161
  };
85
162
  add("superpowers", "Useful engineering planning, testing, and review workflows for most repositories.", "high");
86
163
  add("context7", "Current library documentation helps agents avoid outdated APIs.", signals.languages.length ? "high" : "medium");
@@ -129,6 +206,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
129
206
  packageId: item.packageId,
130
207
  reason: item.reason,
131
208
  confidence: item.confidence,
209
+ kind: item.kind,
132
210
  ...(item.localOutcomeAdjustment !== undefined
133
211
  ? { localOutcomeAdjustment: item.localOutcomeAdjustment }
134
212
  : {}),
@@ -174,14 +252,22 @@ export function profileManifestPackages(profile, catalog) {
174
252
  export function formatRecommendations(signals, recommendations) {
175
253
  const lines = [
176
254
  `Project: ${basename(signals.root)}`,
177
- `Detected: ${[...signals.languages, ...signals.frameworks].join(", ") || "no known project signals"}`,
255
+ `Detected: ${formatDetectedSignals(signals) || "no known project signals"}`,
178
256
  "",
179
257
  "Rule-based project suggestions:",
180
258
  "Rules use detected project signals and catalog membership; they do not prove package quality.",
181
259
  ];
182
260
  if (!recommendations.length)
183
261
  lines.push(" No matching catalog packages found.");
184
- for (const item of recommendations)
185
- lines.push(` ${item.packageId} [${item.confidence}] — ${item.reason}`);
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
+ }
186
272
  return lines.join("\n");
187
273
  }
@@ -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
+ }
@@ -26,15 +26,19 @@ Loadout-managed skills from your own pre-existing skills. `health` checks local
26
26
  loadout catalog --json
27
27
  loadout candidate list --limit 10
28
28
  loadout recommend --project .
29
- loadout optimize --project .
29
+ loadout optimize --project . --agents codex,claude-code --limit 30
30
30
  loadout tool
31
31
  loadout tool graphify
32
32
  ```
33
33
 
34
34
  Run the project commands from the project you care about, or replace `.` with its
35
- absolute path. `optimize` is still a preview until `--yes` is supplied. `tool
36
- graphify` is also a preview; Graphify is a reviewed runtime tool and does not
37
- need an OpenAI or Anthropic API key for its code-only install.
35
+ absolute path. `recommend` labels ordinary skill libraries separately from MCP or
36
+ runtime integrations that require explicit setup. `optimize` is still a preview
37
+ until `--yes` is supplied. Its limit applies separately to every agent and includes
38
+ both Loadout-managed and pre-existing unmanaged skills, so Claude and Codex can
39
+ receive different numbers of additions. `tool graphify` is also a preview; Graphify
40
+ is a reviewed runtime tool and does not need an OpenAI or Anthropic API key for its
41
+ code-only install.
38
42
 
39
43
  ## 3. Open the optional dashboard
40
44
 
@@ -64,6 +68,10 @@ loadout setup --mode power --agents codex,claude-code
64
68
  loadout setup --mode maximum --agents codex,claude-code
65
69
  ```
66
70
 
71
+ Maximum stores reviewed copies in Loadout's disabled library; it does not expose the
72
+ whole catalog to each agent. Follow it with `loadout optimize --project .
73
+ --agents codex,claude-code --limit 30` to preview a compact project-aware working set.
74
+
67
75
  At the API-access question, choose `None` unless you separately pay for a
68
76
  provider API. A ChatGPT Plus or Claude Pro subscription is not an API key. Core
69
77
  skill profiles do not require one; credentialed MCP and runtime operations stay
@@ -161,7 +169,7 @@ cleanup deliberately deletes Loadout's snapshots, so it is the last lifecycle te
161
169
  ## Troubleshooting and recovery
162
170
 
163
171
  - **`loadout` is not found after installation:** confirm `npm install --global
164
- loadout-ai@0.4.0` completed, run `hash -r`, and confirm npm's global binary
172
+ loadout-ai@0.4.1` completed, run `hash -r`, and confirm npm's global binary
165
173
  directory is on `PATH`. For a source checkout, run `npm run build` and `npm link`.
166
174
  - **A preview asks for `--approve-risk`:** read the reported scripts, domains,
167
175
  credentials, binaries, or instruction findings. If you accept that specific plan,
@@ -172,6 +180,10 @@ loadout-ai@0.4.0` completed, run `hash -r`, and confirm npm's global binary
172
180
  legacy snapshot without post-mutation evidence. Run `loadout health --explain` and
173
181
  inspect the affected path before deciding whether an explicit force option is
174
182
  appropriate; do not delete the path merely to make the command pass.
183
+ - **Activation reports fewer additions for one agent:** this is expected when that
184
+ agent already has unmanaged or managed skills. `--limit` is a total per-agent
185
+ ceiling, not a request to add that many new skills. Recursively empty rollback
186
+ directories do not consume capacity and are safe for Loadout to reuse.
175
187
  - **A fetch, discovery, or update check fails:** retry only after checking network,
176
188
  proxy, DNS, and source-host access. Local inventory, library, health, rollback, and
177
189
  offline fixture tests remain separate; an unavailable live check is not a pass.