loadout-ai 0.7.0 → 0.9.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 (108) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/README.md +160 -315
  3. package/SECURITY.md +21 -1
  4. package/catalog/discovered.json +31269 -26003
  5. package/catalog/packages.json +4 -4
  6. package/dist/src/cli.js +13 -0
  7. package/dist/src/commands/agents.js +3 -1
  8. package/dist/src/commands/catalog-candidate.js +150 -0
  9. package/dist/src/commands/catalog-workflows.js +147 -0
  10. package/dist/src/commands/catalog.js +9 -367
  11. package/dist/src/commands/coordinate.js +586 -0
  12. package/dist/src/commands/coordination-discussions.js +194 -0
  13. package/dist/src/commands/coordination-sessions.js +197 -0
  14. package/dist/src/commands/inventory.js +4 -2
  15. package/dist/src/core/agents/agent-inspection.js +26 -4
  16. package/dist/src/core/{routing → agents}/model-config.js +7 -2
  17. package/dist/src/core/catalog/catalog.js +1 -0
  18. package/dist/src/core/catalog/registry.js +44 -10
  19. package/dist/src/core/catalog/safety.js +36 -7
  20. package/dist/src/core/coordination/adapters/claude-code.js +154 -0
  21. package/dist/src/core/coordination/adapters/codex.js +137 -0
  22. package/dist/src/core/coordination/adapters/types.js +64 -0
  23. package/dist/src/core/coordination/auth.js +59 -0
  24. package/dist/src/core/coordination/bridge-lease.js +79 -0
  25. package/dist/src/core/coordination/conflict-preview.js +167 -0
  26. package/dist/src/core/coordination/contract-diff.js +106 -0
  27. package/dist/src/core/coordination/coordinator.js +552 -0
  28. package/dist/src/core/coordination/crash-recovery.js +169 -0
  29. package/dist/src/core/coordination/daemon.js +667 -0
  30. package/dist/src/core/coordination/discussion.js +340 -0
  31. package/dist/src/core/coordination/events.js +161 -0
  32. package/dist/src/core/coordination/http-api.js +118 -0
  33. package/dist/src/core/coordination/interrupt-policy.js +89 -0
  34. package/dist/src/core/coordination/lock.js +110 -0
  35. package/dist/src/core/coordination/mcp-server.js +424 -0
  36. package/dist/src/core/coordination/redaction.js +85 -0
  37. package/dist/src/core/coordination/replay.js +188 -0
  38. package/dist/src/core/coordination/retention.js +128 -0
  39. package/dist/src/core/coordination/runtime.js +21 -0
  40. package/dist/src/core/coordination/session-manager.js +366 -0
  41. package/dist/src/core/coordination/watcher.js +128 -0
  42. package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
  43. package/dist/src/core/{routing → delegation}/handoff.js +130 -63
  44. package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
  45. package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
  46. package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
  47. package/dist/src/core/discovery/candidate-intelligence.js +3 -214
  48. package/dist/src/core/discovery/community.js +12 -4
  49. package/dist/src/core/discovery/github-discovery.js +6 -2
  50. package/dist/src/core/discovery/private-discovery.js +6 -2
  51. package/dist/src/core/install/reconcile.js +1 -1
  52. package/dist/src/core/install/source.js +21 -7
  53. package/dist/src/core/install/uninstall.js +39 -3
  54. package/dist/src/core/reporting/cli-guide.js +10 -6
  55. package/dist/src/core/reporting/completion.js +61 -109
  56. package/dist/src/core/reporting/doctor.js +4 -6
  57. package/dist/src/core/runtime/bounded-json.js +65 -0
  58. package/dist/src/core/runtime/github.js +30 -16
  59. package/dist/src/core/runtime/mcp-recipes.js +1 -1
  60. package/dist/src/core/workspace/active-policy.js +1 -1
  61. package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
  62. package/docs/CATALOG.md +1 -1
  63. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
  64. package/docs/DEMO_SCRIPT.md +19 -23
  65. package/docs/DISCOVERED.md +251 -250
  66. package/docs/FEATURE_TEST_MATRIX.md +10 -263
  67. package/docs/GITHUB_AUTHORIZATION.md +5 -0
  68. package/docs/LIVE_COLLABORATION.md +234 -0
  69. package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
  70. package/docs/REFERENCE.md +163 -0
  71. package/docs/RELEASE_REVIEW.md +1 -2
  72. package/docs/TESTING.md +17 -1
  73. package/docs/USER_TEST_GUIDE.md +138 -3
  74. package/docs/assets/loadout-discover-activate.webp +0 -0
  75. package/docs/assets/loadout-handoff-coordinate.webp +0 -0
  76. package/docs/assets/loadout-social-preview.png +0 -0
  77. package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
  78. package/docs/decisions/002-local-daemon-authentication.md +32 -0
  79. package/docs/decisions/003-bounded-agent-discussions.md +91 -0
  80. package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
  81. package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
  82. package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
  83. package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
  84. package/package.json +18 -7
  85. package/skills/loadout-handoff/SKILL.md +238 -0
  86. package/MASTER_PLAN.md +0 -2207
  87. package/dist/src/core/routing/route.js +0 -539
  88. package/docs/ACTIVE_SET.md +0 -53
  89. package/docs/COMPATIBILITY_POLICY.md +0 -22
  90. package/docs/CONVERSION_AND_SANDBOX.md +0 -27
  91. package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
  92. package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
  93. package/docs/PROVIDER_CONFIGURATION.md +0 -45
  94. package/docs/README_RESEARCH.md +0 -36
  95. package/docs/REPOSITORY_STABILIZATION.md +0 -190
  96. package/docs/SAFE_UPDATE_DEMO.md +0 -25
  97. package/docs/SCHEMA_DECISIONS.md +0 -25
  98. package/docs/SUBMISSION_COPY.md +0 -90
  99. package/docs/TEAM_POLICY.md +0 -18
  100. package/docs/assets/loadout-workflow.png +0 -0
  101. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
  102. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
  103. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
  104. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
  105. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
  106. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
  107. package/skills/loadout-router/SKILL.md +0 -120
  108. /package/dist/src/core/{routing → agents}/credentials.js +0 -0
@@ -2,6 +2,32 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { writeFileAtomically } from "../install/atomic-file.js";
5
+ import { z } from "zod";
6
+ const handoffMessageSchema = z.object({
7
+ id: z.string().trim().min(1),
8
+ type: z.enum([
9
+ "task",
10
+ "handoff",
11
+ "question",
12
+ "done",
13
+ "status",
14
+ "error",
15
+ "cancel",
16
+ ]),
17
+ from: z.string().trim().min(1),
18
+ to: z.string().trim().min(1),
19
+ description: z.string().trim().min(1),
20
+ context: z.string().optional(),
21
+ timestamp: z.iso.datetime({ offset: true }),
22
+ resolves: z.string().trim().min(1).optional(),
23
+ });
24
+ function handoffValidationReason(error) {
25
+ return error.issues
26
+ .map((issue) => `${issue.path.join(".") || "message"}: ${issue.message}`)
27
+ .join("; ");
28
+ }
29
+ /** Message types that settle a task, so it stops appearing in an inbox. */
30
+ const TERMINAL_TYPES = new Set(["done", "error", "cancel"]);
5
31
  const HANDOFF_DIR = ".handoff";
6
32
  const MESSAGES_FILE = "messages.jsonl";
7
33
  const PROTOCOL_FILE = "PROTOCOL.md";
@@ -24,46 +50,30 @@ export async function initHandoff(projectRoot) {
24
50
  const dir = handoffDir(projectRoot);
25
51
  await mkdir(dir, { recursive: true });
26
52
  const protocol = [
27
- "# Handoff Protocol",
28
- "",
29
- "This directory enables asynchronous task handoff between AI coding agents.",
30
- "Generated by `loadout handoff init`. Compatible with the JSONL handoff convention.",
31
- "",
32
- "## Message types",
33
- "",
34
- "- **task**: assign work to another agent",
35
- "- **handoff**: transfer in-progress work with context",
36
- "- **question**: ask another agent for input",
37
- "- **done**: signal completion",
38
- "- **status**: progress update",
39
- "- **error**: report a failure",
40
- "- **cancel**: withdraw a task",
41
- "",
42
- "## Files",
53
+ "# Handoff",
43
54
  "",
44
- "- `messages.jsonl` — append-only message log (one JSON object per line)",
45
- "- `PROTOCOL.md` — this file",
55
+ "A shared task log so two AI coding agents can pass work between them.",
56
+ "Created by `loadout handoff`. Append-only JSONL; no server, no daemon.",
46
57
  "",
47
- "## Usage",
58
+ "## Using it",
48
59
  "",
49
60
  "```",
50
- "loadout handoff send codex 'write unit tests for auth module'",
51
- "loadout handoff send claude-code 'review the API changes' --context 'see PR #42'",
52
- "loadout handoff status",
53
- "loadout handoff done <message-id>",
61
+ "loadout handoff codex 'write unit tests for auth' --context 'see src/auth.ts'",
62
+ "loadout handoff codex # what is waiting for codex",
63
+ "loadout handoff # everything pending, both directions",
64
+ "loadout handoff --done <id> # finished",
54
65
  "```",
55
66
  "",
56
- "## Making agents read this",
67
+ "Sending sets this directory up on first use and adds a short block to",
68
+ "CLAUDE.md and AGENTS.md telling each agent to check its inbox at session",
69
+ "start. Only the text between the loadout:handoff markers is managed.",
57
70
  "",
58
- "A message log only helps if the other agent checks it. Run:",
71
+ "## Files",
59
72
  "",
60
- "```",
61
- "loadout handoff pickup --yes",
62
- "```",
73
+ "- `messages.jsonl` — the log, one JSON object per line",
74
+ "- `PROTOCOL.md` — this file",
63
75
  "",
64
- "That adds a managed block to CLAUDE.md and AGENTS.md telling each agent to",
65
- "run `loadout handoff inbox <agent>` at session start and act on what it finds.",
66
- "Only the block between the loadout:handoff markers is managed.",
76
+ "Commit both if you want the log shared across machines.",
67
77
  "",
68
78
  ].join("\n");
69
79
  await writeFileAtomically(join(dir, PROTOCOL_FILE), protocol);
@@ -78,66 +88,106 @@ export async function initHandoff(projectRoot) {
78
88
  }
79
89
  export async function sendHandoff(projectRoot, to, description, options = {}) {
80
90
  if (!(await isHandoffInitialized(projectRoot))) {
81
- throw new Error("Handoff not initialized. Run `loadout handoff init` first.");
91
+ throw new Error("Handoff is not set up here. Send a task and it will create itself: loadout handoff <agent> '<task>'");
82
92
  }
83
- const message = {
93
+ const candidate = {
84
94
  id: randomUUID().slice(0, 8),
85
95
  type: options.type ?? "task",
86
96
  from: options.from ?? "user",
87
97
  to,
88
98
  description,
89
99
  ...(options.context ? { context: options.context } : {}),
100
+ ...(options.resolves ? { resolves: options.resolves } : {}),
90
101
  timestamp: new Date().toISOString(),
91
102
  };
103
+ const parsed = handoffMessageSchema.safeParse(candidate);
104
+ if (!parsed.success)
105
+ throw new Error(`Invalid handoff message: ${handoffValidationReason(parsed.error)}`);
106
+ const message = parsed.data;
92
107
  const path = messagesPath(projectRoot);
93
108
  const line = JSON.stringify(message) + "\n";
94
109
  await writeFile(path, line, { flag: "a" });
95
110
  return message;
96
111
  }
97
112
  export async function markDone(projectRoot, messageId) {
98
- const messages = await readMessages(projectRoot);
99
- const original = messages.find((m) => m.id === messageId);
113
+ const state = await getHandoffState(projectRoot);
114
+ const original = state.messages.find((m) => m.id === messageId);
100
115
  if (!original)
101
116
  throw new Error(`Message '${messageId}' not found`);
102
- if (original.type === "done")
103
- throw new Error(`Message '${messageId}' is already done`);
117
+ if (original.type !== "task")
118
+ throw new Error(`Message '${messageId}' is not a task`);
119
+ if (state.done.some((message) => message.id === messageId))
120
+ throw new Error(`Message '${messageId}' is already settled`);
104
121
  return sendHandoff(projectRoot, original.from, `Completed: ${original.description}`, {
105
122
  from: original.to,
106
123
  type: "done",
107
- context: `Resolves ${messageId}`,
124
+ resolves: messageId,
108
125
  });
109
126
  }
110
- export async function readMessages(projectRoot) {
127
+ /**
128
+ * Parse the log one line at a time. A single truncated write previously made
129
+ * the whole inbox look empty, which is the worst possible failure for a queue:
130
+ * silent and total. Bad lines are collected and reported instead.
131
+ */
132
+ export async function readMessagesDetailed(projectRoot) {
111
133
  if (!(await isHandoffInitialized(projectRoot)))
112
- return [];
134
+ return { messages: [], corrupt: [] };
135
+ let content;
113
136
  try {
114
- const content = await readFile(messagesPath(projectRoot), "utf8");
115
- return content
116
- .split("\n")
117
- .filter(Boolean)
118
- .map((line) => JSON.parse(line));
137
+ content = await readFile(messagesPath(projectRoot), "utf8");
119
138
  }
120
139
  catch {
121
- return [];
140
+ return { messages: [], corrupt: [] };
122
141
  }
142
+ const messages = [];
143
+ const corrupt = [];
144
+ content.split("\n").forEach((raw, index) => {
145
+ if (!raw.trim())
146
+ return;
147
+ try {
148
+ const parsed = handoffMessageSchema.safeParse(JSON.parse(raw));
149
+ if (!parsed.success) {
150
+ throw new Error(handoffValidationReason(parsed.error));
151
+ }
152
+ messages.push(parsed.data);
153
+ }
154
+ catch (error) {
155
+ corrupt.push({
156
+ line: index + 1,
157
+ reason: error instanceof Error ? error.message : "unparseable",
158
+ });
159
+ }
160
+ });
161
+ return { messages, corrupt };
162
+ }
163
+ export async function readMessages(projectRoot) {
164
+ return (await readMessagesDetailed(projectRoot)).messages;
123
165
  }
124
166
  export async function getHandoffState(projectRoot) {
125
167
  const initialized = await isHandoffInitialized(projectRoot);
126
- const messages = initialized ? await readMessages(projectRoot) : [];
127
- const doneIds = new Set(messages
128
- .filter((m) => m.type === "done")
168
+ const { messages, corrupt } = initialized
169
+ ? await readMessagesDetailed(projectRoot)
170
+ : { messages: [], corrupt: [] };
171
+ // A task is settled by completion, failure, or withdrawal. Treating only
172
+ // `done` as terminal left failed tasks pending forever.
173
+ const resolvedIds = new Set(messages
174
+ .filter((m) => TERMINAL_TYPES.has(m.type))
129
175
  .flatMap((m) => {
176
+ if (m.resolves)
177
+ return [m.resolves];
178
+ // Logs written before `resolves` existed encode it in the context.
130
179
  const match = m.context?.match(/Resolves (\w+)/);
131
180
  return match ? [match[1]] : [];
132
181
  }));
133
- const pending = messages.filter((m) => m.type === "task" && !doneIds.has(m.id));
134
- const done = messages.filter((m) => m.type === "task" && doneIds.has(m.id));
182
+ const pending = messages.filter((m) => m.type === "task" && !resolvedIds.has(m.id));
183
+ const done = messages.filter((m) => m.type === "task" && resolvedIds.has(m.id));
135
184
  return {
136
185
  initialized,
137
186
  directory: handoffDir(projectRoot),
138
187
  messages,
139
188
  pending,
140
189
  done,
190
+ corrupt,
141
191
  };
142
192
  }
143
193
  // ---------------------------------------------------------------------------
@@ -150,7 +200,7 @@ export async function readInbox(projectRoot, agent) {
150
200
  }
151
201
  /**
152
202
  * Render an agent's inbox as instructions the agent itself can act on. This is
153
- * what `loadout handoff inbox` prints, and what the generated pickup block
203
+ * what `loadout handoff <agent>` prints, and what the generated pickup block
154
204
  * tells each agent to run, so the message log is consumed rather than merely
155
205
  * written.
156
206
  */
@@ -166,7 +216,7 @@ export function formatInbox(agent, messages) {
166
216
  lines.push(` ${m.description}`);
167
217
  if (m.context)
168
218
  lines.push(` context: ${m.context}`);
169
- lines.push(` when finished: loadout handoff done ${m.id}`);
219
+ lines.push(` when finished: loadout handoff --done ${m.id}`);
170
220
  lines.push("");
171
221
  }
172
222
  lines.push("Work these in order. Mark each done as you complete it so the sender sees progress.");
@@ -184,10 +234,10 @@ export function pickupBlock(agent) {
184
234
  `At the start of a session, and whenever you finish a task, run:`,
185
235
  "",
186
236
  "```bash",
187
- `loadout handoff inbox ${agent}`,
237
+ `loadout handoff ${agent}`,
188
238
  "```",
189
239
  "",
190
- "If it lists pending tasks, work them in order and run the `loadout handoff done`",
240
+ "If it lists pending tasks, work them in order and run the `loadout handoff --done`",
191
241
  "command it prints for each one. If it reports none, continue as normal.",
192
242
  "",
193
243
  PICKUP_END,
@@ -198,6 +248,10 @@ const AGENT_CONTEXT_FILES = {
198
248
  "claude-code": "CLAUDE.md",
199
249
  codex: "AGENTS.md",
200
250
  };
251
+ /** True when this agent has a context file Loadout knows how to write. */
252
+ export function isPickupTarget(agent) {
253
+ return agent in AGENT_CONTEXT_FILES;
254
+ }
201
255
  export function agentContextFile(agent) {
202
256
  const file = AGENT_CONTEXT_FILES[agent];
203
257
  if (!file)
@@ -225,12 +279,16 @@ export async function planPickup(projectRoot, agent) {
225
279
  const start = existing.indexOf(PICKUP_START);
226
280
  const end = existing.indexOf(PICKUP_END);
227
281
  const replacing = start !== -1 && end !== -1 && end > start;
282
+ // A block written by an older release still tells the agent to run commands
283
+ // that no longer exist, so a rewrite is a migration, not a no-op.
284
+ const stale = replacing &&
285
+ /loadout handoff (?:inbox|send|init|status|done)\b/.test(existing.slice(start, end));
228
286
  const content = replacing
229
287
  ? existing.slice(0, start) + block + existing.slice(end + PICKUP_END.length)
230
288
  : exists
231
289
  ? `${existing.replace(/\s*$/, "")}\n\n${block}\n`
232
290
  : `${block}\n`;
233
- return { path, agent, exists, replacing, content };
291
+ return { path, agent, exists, replacing, stale, content };
234
292
  }
235
293
  export async function applyPickup(plan) {
236
294
  await writeFileAtomically(plan.path, plan.content);
@@ -238,11 +296,13 @@ export async function applyPickup(plan) {
238
296
  export function formatPickupPlan(plans) {
239
297
  const lines = ["Handoff pickup instructions:", ""];
240
298
  for (const plan of plans) {
241
- const action = plan.replacing
242
- ? "refresh managed block in"
243
- : plan.exists
244
- ? "append managed block to"
245
- : "create";
299
+ const action = plan.stale
300
+ ? "migrate outdated block in"
301
+ : plan.replacing
302
+ ? "refresh managed block in"
303
+ : plan.exists
304
+ ? "append managed block to"
305
+ : "create";
246
306
  lines.push(` ${action} ${plan.path}`);
247
307
  }
248
308
  lines.push("", "This teaches each agent to check its handoff inbox at session start.", "Only the block between the loadout:handoff markers is managed; the rest of", "each file is preserved.");
@@ -250,9 +310,9 @@ export function formatPickupPlan(plans) {
250
310
  }
251
311
  export function formatHandoffStatus(state) {
252
312
  if (!state.initialized)
253
- return "Handoff not initialized. Run `loadout handoff init`.";
313
+ return "No handoff log here yet. Send a task and it creates itself: loadout handoff <agent> '<task>'";
254
314
  if (state.messages.length === 0)
255
- return "No handoff messages yet. Send one with `loadout handoff send <agent> <task>`.";
315
+ return "No handoff messages yet. Send one with `loadout handoff <agent> '<task>'`.";
256
316
  const lines = [];
257
317
  if (state.pending.length) {
258
318
  lines.push(`Pending (${state.pending.length}):`);
@@ -268,6 +328,13 @@ export function formatHandoffStatus(state) {
268
328
  lines.push(` ${m.id} ${m.from} → ${m.to} ${m.description}`);
269
329
  }
270
330
  }
331
+ if (state.corrupt.length) {
332
+ if (lines.length)
333
+ lines.push("");
334
+ lines.push(`Warning: ${state.corrupt.length} unreadable line(s) in the log — ${state.corrupt
335
+ .map((entry) => `line ${entry.line}`)
336
+ .join(", ")}.`, "The other messages are shown; repair or delete those lines to clear this.");
337
+ }
271
338
  const other = state.messages.filter((m) => m.type !== "task" && m.type !== "done");
272
339
  if (other.length) {
273
340
  if (lines.length)
@@ -0,0 +1,108 @@
1
+ import { join } from "node:path";
2
+ export function installabilityFor(components) {
3
+ if (components.some((component) => ["skill", "rule", "command", "agent"].includes(component)))
4
+ return "portable-components";
5
+ if (components.some((component) => component === "mcp" || component === "plugin"))
6
+ return "explicit-runtime-setup";
7
+ return "unsupported-source-shape";
8
+ }
9
+ export function componentsFor(inspection) {
10
+ const result = [];
11
+ if (inspection.counts.skills)
12
+ result.push("skill");
13
+ if (inspection.counts.rules)
14
+ result.push("rule");
15
+ if (inspection.counts.commands)
16
+ result.push("command");
17
+ if (inspection.counts.agents)
18
+ result.push("agent");
19
+ if (inspection.plugins.some((plugin) => !plugin.warnings.some((warning) => warning.startsWith("invalid plugin manifest"))))
20
+ result.push("plugin");
21
+ if (inspection.mcpServers.length)
22
+ result.push("mcp");
23
+ return result;
24
+ }
25
+ export function evidencePathsFor(inspection) {
26
+ return [
27
+ ...inspection.skills.map((item) => join(item.path, "SKILL.md")),
28
+ ...inspection.resources.map((item) => item.path),
29
+ ...inspection.plugins
30
+ .filter((item) => !item.warnings.some((warning) => warning.startsWith("invalid plugin manifest")))
31
+ .map((item) => item.path),
32
+ ...inspection.mcpServers.map((item) => item.path),
33
+ ]
34
+ .map((path) => path.split(/[\\/]/).join("/"))
35
+ .filter((path) => path !== "." && !path.startsWith("../"))
36
+ .filter((path, index, paths) => paths.indexOf(path) === index)
37
+ .sort();
38
+ }
39
+ const STOP_WORDS = new Set([
40
+ "the",
41
+ "and",
42
+ "for",
43
+ "with",
44
+ "from",
45
+ "that",
46
+ "this",
47
+ "your",
48
+ "into",
49
+ "agent",
50
+ "agents",
51
+ "skill",
52
+ "skills",
53
+ "mcp",
54
+ "server",
55
+ "servers",
56
+ "workflow",
57
+ "workflows",
58
+ "code",
59
+ "coding",
60
+ "developer",
61
+ "development",
62
+ "platform",
63
+ "context",
64
+ "open",
65
+ "source",
66
+ "using",
67
+ "across",
68
+ "tool",
69
+ "tools",
70
+ "github",
71
+ ]);
72
+ function tokens(...values) {
73
+ return new Set(values
74
+ .join(" ")
75
+ .toLowerCase()
76
+ .split(/[^a-z0-9]+/)
77
+ .filter((word) => word.length >= 3 && !STOP_WORDS.has(word)));
78
+ }
79
+ export function overlapFor(discovery, inspection, catalog) {
80
+ const candidateTokens = tokens(discovery.repository, discovery.description, discovery.topics.join(" "), ...inspection.skills.flatMap((item) => [item.name, item.description]), ...inspection.resources.map((item) => item.name), ...inspection.plugins.flatMap((item) => [item.name, item.description]), ...inspection.mcpServers.map((item) => item.name));
81
+ const candidateComponents = new Set(componentsFor(inspection));
82
+ return catalog
83
+ .map((pkg) => {
84
+ const packageTokens = tokens(pkg.id, pkg.displayName, pkg.description, pkg.category, pkg.topics?.join(" "));
85
+ const common = [...candidateTokens]
86
+ .filter((word) => packageTokens.has(word))
87
+ .sort();
88
+ // Containment is more useful than union similarity here: a large skill
89
+ // collection can overlap a focused package even when most collection
90
+ // tokens describe unrelated capabilities.
91
+ const smallerVocabulary = Math.min(candidateTokens.size, packageTokens.size);
92
+ const score = smallerVocabulary ? common.length / smallerVocabulary : 0;
93
+ const componentMatch = pkg.components?.some((component) => candidateComponents.has(component)) ?? false;
94
+ return {
95
+ packageId: pkg.id,
96
+ repository: pkg.repository,
97
+ score: Math.round(score * 1000) / 1000,
98
+ relationship: componentMatch && common.length >= 2 && score >= 0.2
99
+ ? "possible-overlap"
100
+ : "same-tooling-area",
101
+ evidence: common.slice(0, 8).map((word) => `shared term: ${word}`),
102
+ };
103
+ })
104
+ .filter((item) => item.evidence.length >= 1 && item.score >= 0.1)
105
+ .sort((left, right) => right.score - left.score ||
106
+ left.packageId.localeCompare(right.packageId))
107
+ .slice(0, 5);
108
+ }
@@ -0,0 +1,108 @@
1
+ import { componentsFor, evidencePathsFor, installabilityFor, } from "./candidate-intelligence-evidence.js";
2
+ export function isTextArray(value) {
3
+ return (Array.isArray(value) && value.every((item) => typeof item === "string"));
4
+ }
5
+ export function finiteNonnegative(value) {
6
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
7
+ }
8
+ export function validDate(value) {
9
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
10
+ }
11
+ export function validPersistedEvaluation(value) {
12
+ if (!value || typeof value !== "object" || Array.isArray(value))
13
+ return false;
14
+ const evaluation = value;
15
+ const categories = evaluation.categories;
16
+ return (evaluation.evaluatorVersion === 1 &&
17
+ typeof evaluation.uncertainty === "string" &&
18
+ Array.isArray(categories) &&
19
+ categories.length === 2 &&
20
+ new Set(categories.map((item) => item.category)).size === 2 &&
21
+ categories.every((item) => (item.category === "skills" || item.category === "mcp") &&
22
+ ["ready", "needs-review", "blocked", "not-applicable"].includes(item.status) &&
23
+ isTextArray(item.findings)));
24
+ }
25
+ function safeEvidencePath(value) {
26
+ return (typeof value === "string" &&
27
+ Boolean(value) &&
28
+ !value.startsWith("/") &&
29
+ !value.split(/[\\/]/).includes(".."));
30
+ }
31
+ export function validPersistedInspection(value) {
32
+ if (!value || typeof value !== "object" || Array.isArray(value))
33
+ return false;
34
+ const inspection = value;
35
+ const validCount = (count) => typeof count === "number" && Number.isSafeInteger(count) && count >= 0;
36
+ const validNamedPath = (item, type) => Boolean(item &&
37
+ typeof item === "object" &&
38
+ !Array.isArray(item) &&
39
+ item.type === type &&
40
+ typeof item.name === "string" &&
41
+ safeEvidencePath(item.path));
42
+ if (!Array.isArray(inspection.skills) ||
43
+ !inspection.skills.every((item) => validNamedPath(item, "skill") &&
44
+ (item.description === undefined ||
45
+ typeof item.description === "string")) ||
46
+ !Array.isArray(inspection.resources) ||
47
+ !inspection.resources.every((item) => ["rule", "command", "agent"].includes(item.type) &&
48
+ validNamedPath(item, item.type)) ||
49
+ !Array.isArray(inspection.plugins) ||
50
+ !inspection.plugins.every((item) => validNamedPath(item, "plugin") &&
51
+ (item.description === undefined ||
52
+ typeof item.description === "string") &&
53
+ (item.version === undefined || typeof item.version === "string") &&
54
+ (item.author === undefined || typeof item.author === "string") &&
55
+ Array.isArray(item.components) &&
56
+ item.components.every((component) => [
57
+ "skill",
58
+ "rule",
59
+ "command",
60
+ "agent",
61
+ "mcp",
62
+ "plugin",
63
+ "root",
64
+ ].includes(component)) &&
65
+ isTextArray(item.hookEvents) &&
66
+ isTextArray(item.mcpServers) &&
67
+ isTextArray(item.warnings)) ||
68
+ !Array.isArray(inspection.mcpServers) ||
69
+ !inspection.mcpServers.every((item) => validNamedPath(item, "mcp") &&
70
+ ["command", "url", "unknown"].includes(item.transport) &&
71
+ (item.command === undefined || typeof item.command === "string") &&
72
+ (item.url === undefined || typeof item.url === "string") &&
73
+ validCount(item.argumentCount) &&
74
+ validCount(item.environmentVariableCount) &&
75
+ isTextArray(item.warnings)) ||
76
+ !inspection.counts ||
77
+ !validCount(inspection.counts.skills) ||
78
+ !validCount(inspection.counts.rules) ||
79
+ !validCount(inspection.counts.commands) ||
80
+ !validCount(inspection.counts.agents) ||
81
+ !validCount(inspection.counts.plugins) ||
82
+ !validCount(inspection.counts.mcpServers) ||
83
+ !validCount(inspection.counts.manifests) ||
84
+ !isTextArray(inspection.warnings))
85
+ return false;
86
+ return (inspection.counts.skills === inspection.skills.length &&
87
+ inspection.counts.rules ===
88
+ inspection.resources.filter((item) => item.type === "rule").length &&
89
+ inspection.counts.commands ===
90
+ inspection.resources.filter((item) => item.type === "command").length &&
91
+ inspection.counts.agents ===
92
+ inspection.resources.filter((item) => item.type === "agent").length &&
93
+ inspection.counts.plugins === inspection.plugins.length &&
94
+ inspection.counts.mcpServers === inspection.mcpServers.length);
95
+ }
96
+ export function assertDossierEvidence(dossier) {
97
+ if (!validPersistedInspection(dossier.inspection))
98
+ throw new Error("Candidate dossier inspection evidence is invalid");
99
+ const inspection = { root: ".", ...dossier.inspection };
100
+ const components = componentsFor(inspection);
101
+ const evidencePaths = evidencePathsFor(inspection);
102
+ if (JSON.stringify(dossier.components) !== JSON.stringify(components))
103
+ throw new Error("Candidate dossier components do not match inspection evidence");
104
+ if (dossier.installability !== installabilityFor(components))
105
+ throw new Error("Candidate dossier installability does not match inspection evidence");
106
+ if (JSON.stringify(dossier.evidencePaths) !== JSON.stringify(evidencePaths))
107
+ throw new Error("Candidate dossier evidencePaths do not match inspection evidence");
108
+ }