@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,498 @@
1
+ /**
2
+ * AI distiller tests.
3
+ *
4
+ * Real implementations throughout: the prompt/parse halves are pure and tested
5
+ * directly; the orchestration is exercised end-to-end against a REAL temp git
6
+ * repo and the REAL skill store, with a FAKE runtime whose `buildPrintCommand`
7
+ * returns a `bash -lc cat <<EOF …` heredoc that prints a scripted draft. This
8
+ * drives `distillSkill` through a genuine one-shot subprocess call with no LLM.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import type { AgentRuntime, DirectSpawnOpts } from "../runtimes/types.ts";
16
+ import type { ResolvedModel } from "../types.ts";
17
+ import {
18
+ buildDistillerPrompt,
19
+ distillSkill,
20
+ extractFirstJsonObject,
21
+ parseDistillerOutput,
22
+ } from "./distiller.ts";
23
+ import { createSkillStore, type SkillStore } from "./store.ts";
24
+ import type { Skill } from "./types.ts";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Test helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Build a minimal valid Skill for prompt-rendering tests. */
31
+ function makeSkill(overrides: Partial<Skill> = {}): Skill {
32
+ return {
33
+ id: "id-1",
34
+ slug: "add-cli-subcommand",
35
+ title: "Add a CLI Subcommand",
36
+ version: 1,
37
+ status: "active",
38
+ goal: "Register a new subcommand on the program",
39
+ whenToUse: ["adding an ap command"],
40
+ filePatterns: ["src/commands/*.ts"],
41
+ tags: ["cli"],
42
+ created: "2026-01-01T00:00:00.000Z",
43
+ updatedAt: "2026-01-01T00:00:00.000Z",
44
+ relatesTo: [],
45
+ supersedes: [],
46
+ body: "## Steps\n1. do thing\n## Gotchas\n## Verification\n",
47
+ confidence: 0.5,
48
+ appliedCount: 2,
49
+ successCount: 2,
50
+ lastOutcome: "success",
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * A fake runtime whose one-shot `buildPrintCommand` emits a fixed string on
57
+ * stdout via a `cat` heredoc — a real subprocess, deterministic output, no LLM.
58
+ * The other methods satisfy the interface but are unused by the distiller.
59
+ */
60
+ function fakeRuntime(printPayload: string): AgentRuntime {
61
+ return {
62
+ id: "fake",
63
+ stability: "experimental",
64
+ instructionPath: "CLAUDE.md",
65
+ buildDirectSpawn(_opts: DirectSpawnOpts): string[] {
66
+ return ["true"];
67
+ },
68
+ buildEnv(_model: ResolvedModel): Record<string, string> {
69
+ return {};
70
+ },
71
+ buildPrintCommand(_prompt: string, _model?: string): string[] {
72
+ // Heredoc keeps the payload a single opaque chunk; 'EOF' (quoted) disables
73
+ // shell interpolation so JSON braces/quotes survive verbatim.
74
+ return ["bash", "-lc", `cat <<'AGENTPLATE_EOF'\n${printPayload}\nAGENTPLATE_EOF`];
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Run a git command in a repo, throwing on failure (test-only convenience). */
80
+ async function git(cwd: string, ...args: string[]): Promise<string> {
81
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
82
+ const stdout = await new Response(proc.stdout).text();
83
+ const exitCode = await proc.exited;
84
+ if (exitCode !== 0) {
85
+ const stderr = await new Response(proc.stderr).text();
86
+ throw new Error(`git ${args.join(" ")} failed: ${stderr}`);
87
+ }
88
+ return stdout.trim();
89
+ }
90
+
91
+ /**
92
+ * Create a real git repo in a temp dir with an initial commit, returning the
93
+ * worktree path and the base ref (the initial commit's sha) to diff against.
94
+ */
95
+ async function setupRepo(dir: string): Promise<string> {
96
+ await git(dir, "init", "-q");
97
+ await git(dir, "config", "user.email", "test@agentplate.dev");
98
+ await git(dir, "config", "user.name", "Agentplate Test");
99
+ writeFileSync(join(dir, "README.md"), "# base\n");
100
+ await git(dir, "add", ".");
101
+ await git(dir, "commit", "-q", "-m", "initial");
102
+ return git(dir, "rev-parse", "HEAD");
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // buildDistillerPrompt
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe("buildDistillerPrompt", () => {
110
+ test("embeds the diff, skip guidance, and the JSON contract", () => {
111
+ const prompt = buildDistillerPrompt({
112
+ diff: "diff --git a/x.ts b/x.ts\n+const SENTINEL_DIFF_TOKEN = 1;",
113
+ insightDigest: "x.ts | 1 +",
114
+ specText: "Implement the X feature",
115
+ appliedSkills: [],
116
+ });
117
+
118
+ // The diff is present verbatim.
119
+ expect(prompt).toContain("SENTINEL_DIFF_TOKEN");
120
+ // "skip" is taught as the usual right answer.
121
+ expect(prompt.toLowerCase()).toContain("skip");
122
+ expect(prompt).toMatch(/skip[\s\S]*right answer/i);
123
+ // The strict JSON output contract is stated.
124
+ expect(prompt).toContain("JSON");
125
+ expect(prompt).toContain('"action"');
126
+ // Body structure is specified.
127
+ expect(prompt).toContain("## Steps");
128
+ expect(prompt).toContain("## Gotchas");
129
+ expect(prompt).toContain("## Verification");
130
+ });
131
+
132
+ test("renders applied skills as update candidates", () => {
133
+ const prompt = buildDistillerPrompt({
134
+ diff: "some diff",
135
+ insightDigest: "",
136
+ specText: "",
137
+ appliedSkills: [makeSkill({ slug: "my-applied-skill" })],
138
+ });
139
+ expect(prompt).toContain("my-applied-skill");
140
+ expect(prompt).toContain("update");
141
+ });
142
+
143
+ test("handles empty inputs without throwing", () => {
144
+ const prompt = buildDistillerPrompt({
145
+ diff: "",
146
+ insightDigest: "",
147
+ specText: "",
148
+ appliedSkills: [],
149
+ });
150
+ expect(prompt).toContain("(empty diff)");
151
+ expect(prompt).toContain("(no spec provided)");
152
+ expect(prompt).toContain("(no skills were applied this session)");
153
+ });
154
+
155
+ test("truncates an enormous diff", () => {
156
+ const huge = "X".repeat(50_000);
157
+ const prompt = buildDistillerPrompt({
158
+ diff: huge,
159
+ insightDigest: "",
160
+ specText: "",
161
+ appliedSkills: [],
162
+ });
163
+ expect(prompt).toContain("[diff truncated]");
164
+ // The full 50k payload is not embedded.
165
+ expect(prompt.length).toBeLessThan(50_000);
166
+ });
167
+ });
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // extractFirstJsonObject
171
+ // ---------------------------------------------------------------------------
172
+
173
+ describe("extractFirstJsonObject", () => {
174
+ test("returns null when there is no object", () => {
175
+ expect(extractFirstJsonObject("no json here at all")).toBeNull();
176
+ expect(extractFirstJsonObject("")).toBeNull();
177
+ });
178
+
179
+ test("finds a bare object", () => {
180
+ expect(extractFirstJsonObject('prefix {"a":1} suffix')).toBe('{"a":1}');
181
+ });
182
+
183
+ test("respects braces inside string literals", () => {
184
+ const text = 'x {"body":"has a } brace inside"} y';
185
+ expect(extractFirstJsonObject(text)).toBe('{"body":"has a } brace inside"}');
186
+ });
187
+
188
+ test("handles nested objects and escaped quotes", () => {
189
+ const text = 'lead {"a":{"b":2},"s":"q\\"uote"} trail';
190
+ expect(extractFirstJsonObject(text)).toBe('{"a":{"b":2},"s":"q\\"uote"}');
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // parseDistillerOutput
196
+ // ---------------------------------------------------------------------------
197
+
198
+ describe("parseDistillerOutput", () => {
199
+ test("parses a bare JSON object", () => {
200
+ const draft = parseDistillerOutput('{"action":"skip"}');
201
+ expect(draft).not.toBeNull();
202
+ expect(draft?.action).toBe("skip");
203
+ });
204
+
205
+ test("parses a ```json fenced block with surrounding prose", () => {
206
+ const stdout = [
207
+ "Here is my decision after reviewing the diff:",
208
+ "```json",
209
+ JSON.stringify({
210
+ action: "create",
211
+ title: "Wire a New Subcommand",
212
+ goal: "Add a command",
213
+ whenToUse: ["adding a command"],
214
+ filePatterns: ["src/commands/*.ts"],
215
+ tags: ["cli"],
216
+ body: "## Steps\n1. x\n## Gotchas\n## Verification\n",
217
+ }),
218
+ "```",
219
+ "Hope that helps!",
220
+ ].join("\n");
221
+
222
+ const draft = parseDistillerOutput(stdout);
223
+ expect(draft).not.toBeNull();
224
+ expect(draft?.action).toBe("create");
225
+ expect(draft?.title).toBe("Wire a New Subcommand");
226
+ expect(draft?.whenToUse).toEqual(["adding a command"]);
227
+ expect(draft?.filePatterns).toEqual(["src/commands/*.ts"]);
228
+ expect(draft?.tags).toEqual(["cli"]);
229
+ expect(draft?.body).toContain("## Steps");
230
+ });
231
+
232
+ test("parses an update action with targetSlug", () => {
233
+ const draft = parseDistillerOutput(
234
+ '{"action":"update","targetSlug":"existing-skill","body":"## Steps\\nnew\\n"}',
235
+ );
236
+ expect(draft?.action).toBe("update");
237
+ expect(draft?.targetSlug).toBe("existing-skill");
238
+ });
239
+
240
+ test("returns null on garbage", () => {
241
+ expect(parseDistillerOutput("this is not json")).toBeNull();
242
+ expect(parseDistillerOutput("")).toBeNull();
243
+ expect(parseDistillerOutput("{ not valid json , }")).toBeNull();
244
+ });
245
+
246
+ test("returns null when action is missing", () => {
247
+ expect(parseDistillerOutput('{"title":"No Action Here"}')).toBeNull();
248
+ });
249
+
250
+ test("returns null when action is not in the union", () => {
251
+ expect(parseDistillerOutput('{"action":"delete"}')).toBeNull();
252
+ });
253
+
254
+ test("drops malformed optional fields rather than corrupting the draft", () => {
255
+ const draft = parseDistillerOutput(
256
+ '{"action":"create","title":"T","whenToUse":"not-an-array","tags":[1,2,"keep"]}',
257
+ );
258
+ expect(draft).not.toBeNull();
259
+ expect(draft?.title).toBe("T");
260
+ // A non-array whenToUse is dropped entirely.
261
+ expect(draft?.whenToUse).toBeUndefined();
262
+ // Non-string tag entries are filtered out.
263
+ expect(draft?.tags).toEqual(["keep"]);
264
+ });
265
+ });
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // distillSkill (end-to-end, real git + real store + fake runtime subprocess)
269
+ // ---------------------------------------------------------------------------
270
+
271
+ describe("distillSkill", () => {
272
+ let dir: string;
273
+ let store: SkillStore;
274
+
275
+ beforeEach(() => {
276
+ dir = mkdtempSync(join(tmpdir(), "agentplate-distiller-"));
277
+ });
278
+
279
+ afterEach(() => {
280
+ store?.close();
281
+ rmSync(dir, { recursive: true, force: true });
282
+ });
283
+
284
+ test("creates a skill from a committed diff via the fake runtime", async () => {
285
+ const baseRef = await setupRepo(dir);
286
+
287
+ // Make a real change and commit it so baseRef..HEAD is a non-empty diff.
288
+ writeFileSync(join(dir, "src.ts"), "export const answer = 42;\n");
289
+ await git(dir, "add", ".");
290
+ await git(dir, "commit", "-q", "-m", "add src");
291
+
292
+ const draftJson = JSON.stringify({
293
+ action: "create",
294
+ title: "Export a Constant",
295
+ goal: "Export a typed constant from a module",
296
+ whenToUse: ["adding a shared constant"],
297
+ filePatterns: ["src/**/*.ts"],
298
+ tags: ["typescript"],
299
+ body: "## Steps\n1. add `export const`\n## Gotchas\nnone\n## Verification\nrun `bun test`\n",
300
+ });
301
+
302
+ store = createSkillStore(dir);
303
+ const runtime = fakeRuntime(draftJson);
304
+
305
+ const result = await distillSkill({
306
+ store,
307
+ runtime,
308
+ root: dir,
309
+ worktreePath: dir,
310
+ baseRef,
311
+ taskId: "task-99",
312
+ agentName: "builder-x",
313
+ capability: "builder",
314
+ appliedSlugs: [],
315
+ });
316
+
317
+ expect(result.action).toBe("created");
318
+ expect(result.slug).toBe("export-a-constant");
319
+
320
+ // The skill directory + skill.md landed on disk.
321
+ const skillFile = join(dir, ".agentplate", "skills", "export-a-constant", "skill.md");
322
+ expect(existsSync(skillFile)).toBe(true);
323
+
324
+ // The persisted skill carries our provenance (agent + task + a real HEAD sha).
325
+ const persisted = store.get("export-a-constant");
326
+ expect(persisted).not.toBeNull();
327
+ expect(persisted?.title).toBe("Export a Constant");
328
+ expect(persisted?.distilledFrom?.agent).toBe("builder-x");
329
+ expect(persisted?.distilledFrom?.taskId).toBe("task-99");
330
+ expect(persisted?.distilledFrom?.commit).toMatch(/^[0-9a-f]{7,40}$/);
331
+ });
332
+
333
+ test("reads the task spec and feeds it to the model (spec is on disk)", async () => {
334
+ const baseRef = await setupRepo(dir);
335
+ writeFileSync(join(dir, "x.ts"), "export const x = 1;\n");
336
+ await git(dir, "add", ".");
337
+ await git(dir, "commit", "-q", "-m", "change");
338
+
339
+ // Write a spec under .agentplate/specs/<task-id>.md — the distiller should read it.
340
+ // Bun.write creates the parent directory as needed.
341
+ const specsDir = join(dir, ".agentplate", "specs");
342
+ await Bun.write(join(specsDir, "task-spec-1.md"), "Build the spec feature\n");
343
+
344
+ store = createSkillStore(dir);
345
+ // Echo back a skip so we don't mint anything — we only assert no throw + skip.
346
+ const result = await distillSkill({
347
+ store,
348
+ runtime: fakeRuntime('{"action":"skip"}'),
349
+ root: dir,
350
+ worktreePath: dir,
351
+ baseRef,
352
+ taskId: "task-spec-1",
353
+ agentName: "builder-x",
354
+ capability: "builder",
355
+ appliedSlugs: [],
356
+ });
357
+ expect(result.action).toBe("skipped");
358
+ });
359
+
360
+ test("skips when the diff is empty (nothing committed past base)", async () => {
361
+ const baseRef = await setupRepo(dir);
362
+ store = createSkillStore(dir);
363
+
364
+ const result = await distillSkill({
365
+ store,
366
+ runtime: fakeRuntime('{"action":"create","title":"Should Not Happen"}'),
367
+ root: dir,
368
+ worktreePath: dir,
369
+ baseRef,
370
+ taskId: null,
371
+ agentName: "builder-x",
372
+ capability: "builder",
373
+ appliedSlugs: [],
374
+ });
375
+
376
+ expect(result.action).toBe("skipped");
377
+ expect(result.slug).toBeUndefined();
378
+ expect(store.list()).toHaveLength(0);
379
+ });
380
+
381
+ test("skips when the model returns an explicit skip action", async () => {
382
+ const baseRef = await setupRepo(dir);
383
+ writeFileSync(join(dir, "f.ts"), "export const f = 1;\n");
384
+ await git(dir, "add", ".");
385
+ await git(dir, "commit", "-q", "-m", "trivial");
386
+
387
+ store = createSkillStore(dir);
388
+ const result = await distillSkill({
389
+ store,
390
+ runtime: fakeRuntime('Sure, here is my call:\n{"action":"skip"}\n'),
391
+ root: dir,
392
+ worktreePath: dir,
393
+ baseRef,
394
+ taskId: null,
395
+ agentName: "builder-x",
396
+ capability: "builder",
397
+ appliedSlugs: [],
398
+ });
399
+ expect(result.action).toBe("skipped");
400
+ expect(store.list()).toHaveLength(0);
401
+ });
402
+
403
+ test("downgrades a draft containing rm -rf to skipped (never persists it)", async () => {
404
+ const baseRef = await setupRepo(dir);
405
+ writeFileSync(join(dir, "danger.ts"), "export const d = 1;\n");
406
+ await git(dir, "add", ".");
407
+ await git(dir, "commit", "-q", "-m", "danger");
408
+
409
+ const dangerousDraft = JSON.stringify({
410
+ action: "create",
411
+ title: "Clean the Workspace",
412
+ goal: "Wipe build artifacts",
413
+ whenToUse: ["cleaning up"],
414
+ filePatterns: ["**/*"],
415
+ tags: ["cleanup"],
416
+ body: "## Steps\n```bash\nrm -rf ./dist\n```\n## Gotchas\n## Verification\n",
417
+ });
418
+
419
+ store = createSkillStore(dir);
420
+ const result = await distillSkill({
421
+ store,
422
+ runtime: fakeRuntime(dangerousDraft),
423
+ root: dir,
424
+ worktreePath: dir,
425
+ baseRef,
426
+ taskId: null,
427
+ agentName: "builder-x",
428
+ capability: "builder",
429
+ appliedSlugs: [],
430
+ });
431
+
432
+ // Fatal safety violation → skipped, and nothing written to disk.
433
+ expect(result.action).toBe("skipped");
434
+ expect(store.list()).toHaveLength(0);
435
+ expect(existsSync(join(dir, ".agentplate", "skills", "clean-the-workspace"))).toBe(false);
436
+ });
437
+
438
+ test("updates an existing applied skill when the model says update", async () => {
439
+ const baseRef = await setupRepo(dir);
440
+ writeFileSync(join(dir, "u.ts"), "export const u = 1;\n");
441
+ await git(dir, "add", ".");
442
+ await git(dir, "commit", "-q", "-m", "update work");
443
+
444
+ store = createSkillStore(dir);
445
+ // Seed an existing skill the model can target.
446
+ const seeded = store.upsert(
447
+ { action: "create", title: "Existing Skill", body: "## Steps\nold\n" },
448
+ { taskId: "t-0", agent: "seed", commit: "seed-sha" },
449
+ );
450
+ const targetSlug = seeded.skill.slug;
451
+
452
+ const updateDraft = JSON.stringify({
453
+ action: "update",
454
+ targetSlug,
455
+ body: "## Steps\nnew improved steps\n## Gotchas\n## Verification\n",
456
+ });
457
+
458
+ const result = await distillSkill({
459
+ store,
460
+ runtime: fakeRuntime(updateDraft),
461
+ root: dir,
462
+ worktreePath: dir,
463
+ baseRef,
464
+ taskId: "task-u",
465
+ agentName: "builder-x",
466
+ capability: "builder",
467
+ appliedSlugs: [targetSlug],
468
+ });
469
+
470
+ expect(result.action).toBe("updated");
471
+ expect(result.slug).toBe(targetSlug);
472
+ const updated = store.get(targetSlug);
473
+ expect(updated?.version).toBe(2);
474
+ expect(updated?.body).toContain("new improved steps");
475
+ });
476
+
477
+ test("skips on garbage model output (unparseable)", async () => {
478
+ const baseRef = await setupRepo(dir);
479
+ writeFileSync(join(dir, "g.ts"), "export const g = 1;\n");
480
+ await git(dir, "add", ".");
481
+ await git(dir, "commit", "-q", "-m", "garbage case");
482
+
483
+ store = createSkillStore(dir);
484
+ const result = await distillSkill({
485
+ store,
486
+ runtime: fakeRuntime("I could not decide, sorry — no JSON for you."),
487
+ root: dir,
488
+ worktreePath: dir,
489
+ baseRef,
490
+ taskId: null,
491
+ agentName: "builder-x",
492
+ capability: "builder",
493
+ appliedSlugs: [],
494
+ });
495
+ expect(result.action).toBe("skipped");
496
+ expect(store.list()).toHaveLength(0);
497
+ });
498
+ });