@agentplate/cli 1.0.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 (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Skill retrieval + overlay formatting tests.
3
+ *
4
+ * Everything under test is PURE (it takes already-loaded {@link Skill} objects
5
+ * and returns plain data + markdown), so these tests need no filesystem, SQLite,
6
+ * or subprocess — just hand-built skills. No mocks.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import {
11
+ globMatch,
12
+ renderSkillsOverlay,
13
+ type SelectOpts,
14
+ scoreSkill,
15
+ selectSkills,
16
+ } from "./retrieval.ts";
17
+ import type { Skill } from "./types.ts";
18
+
19
+ /** Build a Skill with sane defaults; override only what a test cares about. */
20
+ function makeSkill(overrides: Partial<Skill> = {}): Skill {
21
+ const now = new Date().toISOString();
22
+ return {
23
+ id: crypto.randomUUID(),
24
+ slug: "a-skill",
25
+ title: "A Skill",
26
+ version: 1,
27
+ status: "active",
28
+ goal: "Do a thing",
29
+ whenToUse: [],
30
+ filePatterns: [],
31
+ tags: [],
32
+ created: now,
33
+ updatedAt: now,
34
+ relatesTo: [],
35
+ supersedes: [],
36
+ body: "## Steps\n\n1. Step one\n2. Step two",
37
+ confidence: 0,
38
+ appliedCount: 0,
39
+ successCount: 0,
40
+ lastOutcome: null,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ function opts(overrides: Partial<SelectOpts> = {}): SelectOpts {
46
+ return {
47
+ fileScope: [],
48
+ taskText: "",
49
+ capability: "builder",
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // globMatch
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("globMatch", () => {
59
+ test("single * matches within a path segment but not across /", () => {
60
+ expect(globMatch("src/*.ts", "src/foo.ts")).toBe(true);
61
+ expect(globMatch("src/*.ts", "src/nested/foo.ts")).toBe(false);
62
+ });
63
+
64
+ test("** matches across path segments", () => {
65
+ expect(globMatch("src/**/*.ts", "src/a/b/foo.ts")).toBe(true);
66
+ expect(globMatch("src/**", "src/a/b/foo.ts")).toBe(true);
67
+ });
68
+
69
+ test("wildcarded patterns are anchored (no spurious prefix match)", () => {
70
+ expect(globMatch("src/*.ts", "lib/src/a.ts")).toBe(false);
71
+ });
72
+
73
+ test("a pattern with no wildcards is a substring fallback", () => {
74
+ expect(globMatch("commands", "src/commands/foo.ts")).toBe(true);
75
+ expect(globMatch("widgets", "src/commands/foo.ts")).toBe(false);
76
+ });
77
+
78
+ test("empty pattern never matches", () => {
79
+ expect(globMatch("", "anything")).toBe(false);
80
+ });
81
+
82
+ test("regex metacharacters in a literal are escaped", () => {
83
+ // The dot is a literal here, not 'any char'.
84
+ expect(globMatch("a.b*", "a.bcd")).toBe(true);
85
+ expect(globMatch("a.b*", "axbcd")).toBe(false);
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // scoreSkill
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe("scoreSkill", () => {
94
+ test("ranks a file-pattern-matching skill above an unrelated one", () => {
95
+ const matching = makeSkill({
96
+ slug: "matching",
97
+ filePatterns: ["src/commands/*.ts"],
98
+ });
99
+ const unrelated = makeSkill({
100
+ slug: "unrelated",
101
+ filePatterns: ["docs/**/*.md"],
102
+ });
103
+ const o = opts({ fileScope: ["src/commands/foo.ts"], taskText: "add a command" });
104
+
105
+ expect(scoreSkill(matching, o)).toBeGreaterThan(scoreSkill(unrelated, o));
106
+ });
107
+
108
+ test("ranks a lexically-matching skill above a lexically-irrelevant one", () => {
109
+ const relevant = makeSkill({
110
+ slug: "relevant",
111
+ title: "Add a CLI Subcommand",
112
+ goal: "Register a new subcommand on the program",
113
+ tags: ["cli", "commander"],
114
+ });
115
+ const irrelevant = makeSkill({
116
+ slug: "irrelevant",
117
+ title: "Tune Postgres Vacuum",
118
+ goal: "Adjust autovacuum thresholds",
119
+ tags: ["database", "postgres"],
120
+ });
121
+ const o = opts({ taskText: "add a new cli subcommand with commander" });
122
+
123
+ expect(scoreSkill(relevant, o)).toBeGreaterThan(scoreSkill(irrelevant, o));
124
+ });
125
+
126
+ test("a quarantined skill scores 0 even with a perfect file match", () => {
127
+ const skill = makeSkill({
128
+ status: "quarantined",
129
+ filePatterns: ["src/commands/*.ts"],
130
+ confidence: 1,
131
+ });
132
+ expect(scoreSkill(skill, opts({ fileScope: ["src/commands/foo.ts"] }))).toBe(0);
133
+ });
134
+
135
+ test("a deprecated skill scores 0", () => {
136
+ const skill = makeSkill({ status: "deprecated", filePatterns: ["src/*.ts"] });
137
+ expect(scoreSkill(skill, opts({ fileScope: ["src/a.ts"] }))).toBe(0);
138
+ });
139
+
140
+ test("higher confidence raises the score, all else equal", () => {
141
+ const base = { filePatterns: ["src/*.ts"], updatedAt: new Date().toISOString() };
142
+ const lowConf = makeSkill({ ...base, slug: "low", confidence: 0.1 });
143
+ const highConf = makeSkill({ ...base, slug: "high", confidence: 0.9 });
144
+ const o = opts({ fileScope: ["src/a.ts"], taskText: "edit source" });
145
+
146
+ expect(scoreSkill(highConf, o)).toBeGreaterThan(scoreSkill(lowConf, o));
147
+ });
148
+
149
+ test("a fresher skill outranks a stale one, all else equal", () => {
150
+ const fresh = makeSkill({ slug: "fresh", updatedAt: new Date().toISOString() });
151
+ const stale = makeSkill({
152
+ slug: "stale",
153
+ // ~2 years old → recency term decays toward 0.
154
+ updatedAt: new Date(Date.now() - 730 * 24 * 60 * 60 * 1000).toISOString(),
155
+ });
156
+ const o = opts({ taskText: "do a thing" });
157
+
158
+ expect(scoreSkill(fresh, o)).toBeGreaterThan(scoreSkill(stale, o));
159
+ });
160
+
161
+ test("score stays within [0, 1]", () => {
162
+ const skill = makeSkill({
163
+ filePatterns: ["src/commands/*.ts"],
164
+ title: "Add a CLI Subcommand",
165
+ goal: "Register a subcommand",
166
+ tags: ["cli"],
167
+ confidence: 1,
168
+ updatedAt: new Date().toISOString(),
169
+ });
170
+ const s = scoreSkill(
171
+ skill,
172
+ opts({ fileScope: ["src/commands/foo.ts"], taskText: "add a cli subcommand" }),
173
+ );
174
+ expect(s).toBeGreaterThan(0);
175
+ expect(s).toBeLessThanOrEqual(1);
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // selectSkills
181
+ // ---------------------------------------------------------------------------
182
+
183
+ describe("selectSkills", () => {
184
+ test("ranks full skills highest-score first and drops zero-score skills", () => {
185
+ const top = makeSkill({
186
+ slug: "top",
187
+ filePatterns: ["src/commands/*.ts"],
188
+ title: "Add a CLI Subcommand",
189
+ tags: ["cli"],
190
+ });
191
+ const mid = makeSkill({
192
+ slug: "mid",
193
+ title: "Add a CLI Subcommand",
194
+ tags: ["cli"],
195
+ });
196
+ const excluded = makeSkill({ slug: "excluded", status: "quarantined" });
197
+
198
+ const result = selectSkills(
199
+ [mid, top, excluded],
200
+ opts({ fileScope: ["src/commands/foo.ts"], taskText: "add a cli subcommand" }),
201
+ );
202
+
203
+ // 'top' (file + lexical) outranks 'mid' (lexical only); 'excluded' is gone.
204
+ const fullSlugs = result.full.map((r) => r.skill.slug);
205
+ expect(fullSlugs).toEqual(["top", "mid"]);
206
+ expect([...result.full, ...result.summarized].some((r) => r.skill.slug === "excluded")).toBe(
207
+ false,
208
+ );
209
+ });
210
+
211
+ test("respects maxFull: surplus skills spill into summarized", () => {
212
+ const skills = [0, 1, 2, 3, 4].map((i) =>
213
+ makeSkill({
214
+ slug: `s${i}`,
215
+ title: "Add a CLI Subcommand",
216
+ tags: ["cli"],
217
+ // Stagger confidence so ordering is deterministic and distinct.
218
+ confidence: (5 - i) / 5,
219
+ filePatterns: ["src/commands/*.ts"],
220
+ }),
221
+ );
222
+
223
+ const result = selectSkills(
224
+ skills,
225
+ opts({ fileScope: ["src/commands/foo.ts"], taskText: "add a cli subcommand", maxFull: 2 }),
226
+ );
227
+
228
+ expect(result.full.length).toBe(2);
229
+ expect(result.summarized.length).toBe(3);
230
+ // All five were ranked (none dropped).
231
+ expect(result.full.length + result.summarized.length).toBe(5);
232
+ });
233
+
234
+ test("a tiny budget pushes everything into summarized", () => {
235
+ const skills = [0, 1, 2].map((i) =>
236
+ makeSkill({
237
+ slug: `s${i}`,
238
+ title: "Add a CLI Subcommand",
239
+ tags: ["cli"],
240
+ body: "## Steps\n\n1. A long-ish body that easily exceeds a tiny budget.\n2. More.",
241
+ filePatterns: ["src/commands/*.ts"],
242
+ }),
243
+ );
244
+
245
+ const result = selectSkills(
246
+ skills,
247
+ opts({
248
+ fileScope: ["src/commands/foo.ts"],
249
+ taskText: "add a cli subcommand",
250
+ budgetChars: 5,
251
+ }),
252
+ );
253
+
254
+ expect(result.full.length).toBe(0);
255
+ expect(result.summarized.length).toBe(3);
256
+ // And the rendered block is the summaries (with show-hints), not full bodies.
257
+ expect(result.overlayMarkdown).toContain("agentplate skill show s0");
258
+ expect(result.overlayMarkdown).not.toContain("long-ish body");
259
+ });
260
+
261
+ test("a generous budget admits skills as full (under maxFull)", () => {
262
+ const skills = [0, 1].map((i) =>
263
+ makeSkill({
264
+ slug: `s${i}`,
265
+ title: "Add a CLI Subcommand",
266
+ tags: ["cli"],
267
+ filePatterns: ["src/commands/*.ts"],
268
+ }),
269
+ );
270
+ const result = selectSkills(
271
+ skills,
272
+ opts({
273
+ fileScope: ["src/commands/foo.ts"],
274
+ taskText: "add a cli subcommand",
275
+ budgetChars: 100_000,
276
+ }),
277
+ );
278
+ expect(result.full.length).toBe(2);
279
+ expect(result.summarized.length).toBe(0);
280
+ });
281
+
282
+ test("default budget/maxFull apply when omitted (small set fits as full)", () => {
283
+ const skills = [0, 1, 2].map((i) =>
284
+ makeSkill({
285
+ slug: `s${i}`,
286
+ title: "Add a CLI Subcommand",
287
+ tags: ["cli"],
288
+ filePatterns: ["src/commands/*.ts"],
289
+ }),
290
+ );
291
+ const result = selectSkills(
292
+ skills,
293
+ opts({ fileScope: ["src/commands/foo.ts"], taskText: "add a cli subcommand" }),
294
+ );
295
+ // Three short skills are well under the 6000-char / 4-full defaults.
296
+ expect(result.full.length).toBe(3);
297
+ expect(result.summarized.length).toBe(0);
298
+ });
299
+
300
+ test("overlayMarkdown carries the full skill's goal and the summarized skill's show-hint", () => {
301
+ const fullSkill = makeSkill({
302
+ slug: "full-one",
303
+ title: "Full One",
304
+ goal: "Wire the thing end to end",
305
+ tags: ["cli"],
306
+ body: "## Steps\n\n1. Do it",
307
+ filePatterns: ["src/commands/*.ts"],
308
+ });
309
+ const summarizedSkill = makeSkill({
310
+ slug: "summary-one",
311
+ title: "Summary One",
312
+ goal: "A lesser but relevant skill",
313
+ tags: ["cli"],
314
+ });
315
+
316
+ const result = selectSkills(
317
+ [fullSkill, summarizedSkill],
318
+ opts({
319
+ fileScope: ["src/commands/foo.ts"],
320
+ taskText: "add a cli command",
321
+ maxFull: 1,
322
+ }),
323
+ );
324
+
325
+ // The file-matching skill goes full; the lexical-only one is summarized.
326
+ expect(result.full.map((r) => r.skill.slug)).toEqual(["full-one"]);
327
+ expect(result.summarized.map((r) => r.skill.slug)).toEqual(["summary-one"]);
328
+
329
+ expect(result.overlayMarkdown).toContain("Wire the thing end to end");
330
+ expect(result.overlayMarkdown).toContain("agentplate skill show summary-one");
331
+ });
332
+
333
+ test("only excluded (score-0) skills yields the placeholder block and empty lists", () => {
334
+ // Quarantined/deprecated skills score exactly 0, so both lists end up empty
335
+ // even though the skills are otherwise strong matches — the realistic route
336
+ // to a "no applicable skills" overlay. (A merely-irrelevant active skill can
337
+ // still earn a microscopic recency score and would be summarized.)
338
+ const quarantined = makeSkill({
339
+ slug: "quarantined",
340
+ status: "quarantined",
341
+ filePatterns: ["src/widgets/*.tsx"],
342
+ title: "Render a React Widget",
343
+ confidence: 1,
344
+ });
345
+ const deprecated = makeSkill({
346
+ slug: "deprecated",
347
+ status: "deprecated",
348
+ filePatterns: ["src/widgets/*.tsx"],
349
+ title: "Render a React Widget",
350
+ });
351
+
352
+ const result = selectSkills(
353
+ [quarantined, deprecated],
354
+ opts({ fileScope: ["src/widgets/a.tsx"], taskText: "render a react widget" }),
355
+ );
356
+
357
+ expect(result.full).toEqual([]);
358
+ expect(result.summarized).toEqual([]);
359
+ expect(result.overlayMarkdown).toContain("## Applicable Skills");
360
+ expect(result.overlayMarkdown).toContain("(no applicable skills yet)");
361
+ });
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // renderSkillsOverlay
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("renderSkillsOverlay", () => {
369
+ test("empty input renders the heading + placeholder", () => {
370
+ const md = renderSkillsOverlay([], []);
371
+ expect(md).toContain("## Applicable Skills");
372
+ expect(md).toContain("(no applicable skills yet)");
373
+ });
374
+
375
+ test("a full skill renders goal, trimmed body, and a skill:<slug> tag", () => {
376
+ const skill = makeSkill({
377
+ slug: "wire-it",
378
+ title: "Wire It",
379
+ goal: "Connect A to B",
380
+ body: "\n\n## Steps\n\n1. Connect\n2. Verify\n\n",
381
+ });
382
+ const md = renderSkillsOverlay([skill], []);
383
+
384
+ expect(md).toContain("### Wire It");
385
+ expect(md).toContain("Goal: Connect A to B");
386
+ expect(md).toContain("## Steps");
387
+ expect(md).toContain("1. Connect");
388
+ expect(md).toContain("skill: wire-it");
389
+ // Body was trimmed: no leading blank lines bleeding into the header block.
390
+ expect(md).not.toContain("Wire It\n\n\n\n");
391
+ });
392
+
393
+ test("a summarized skill renders a single show-hint bullet with its goal", () => {
394
+ const skill = makeSkill({ slug: "lesser", title: "Lesser", goal: "A small win" });
395
+ const md = renderSkillsOverlay([], [skill]);
396
+
397
+ expect(md).toContain("- Lesser — A small win (run `agentplate skill show lesser`)");
398
+ expect(md).toContain("### Related skills");
399
+ });
400
+
401
+ test("a summarized skill with no goal omits the dash segment", () => {
402
+ const skill = makeSkill({ slug: "bare", title: "Bare", goal: "" });
403
+ const md = renderSkillsOverlay([], [skill]);
404
+
405
+ expect(md).toContain("- Bare (run `agentplate skill show bare`)");
406
+ expect(md).not.toContain("Bare — ");
407
+ });
408
+
409
+ test("mixed full + summarized renders both sections", () => {
410
+ const full = makeSkill({ slug: "full-x", title: "Full X", goal: "Full goal" });
411
+ const summary = makeSkill({ slug: "sum-y", title: "Sum Y", goal: "Sum goal" });
412
+ const md = renderSkillsOverlay([full], [summary]);
413
+
414
+ expect(md.startsWith("## Applicable Skills")).toBe(true);
415
+ expect(md).toContain("### Full X");
416
+ expect(md).toContain("skill: full-x");
417
+ expect(md).toContain("agentplate skill show sum-y");
418
+ // The full skill's content precedes the related-skills list.
419
+ expect(md.indexOf("### Full X")).toBeLessThan(md.indexOf("### Related skills"));
420
+ });
421
+ });