@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,216 @@
1
+ /**
2
+ * Skill safety scrubbing.
3
+ *
4
+ * A distilled {@link SkillDraft} is untrusted text minted by an AI model from a
5
+ * just-completed session: it can leak the operator's secrets, embed destructive
6
+ * shell snippets, name outward-facing deploy verbs that belong only to the gated
7
+ * deployer, or hard-code the operator's home-directory paths. Every draft passes
8
+ * through {@link sanitizeSkillDraft} BEFORE it is ever written to disk.
9
+ *
10
+ * The scrubber is two-tiered:
11
+ *
12
+ * - **Auto-fix (non-fatal):** secrets are redacted in place and home-absolute
13
+ * paths are rewritten to a neutral `<repo>/...` placeholder. These are recorded
14
+ * as violations for the audit trail but do NOT fail the report — the cleaned
15
+ * draft is safe to persist.
16
+ * - **Fatal:** a dangerous shell command or a deploy verb cannot be safely
17
+ * rewritten, so the report comes back `ok: false`. The caller downgrades the
18
+ * draft to a `skip` rather than minting a hazardous skill.
19
+ *
20
+ * `skip` drafts (and any non-create/update action) pass straight through: there
21
+ * is nothing to write, so there is nothing to scrub.
22
+ */
23
+
24
+ import { findDangerousCommands, hasDeployVerb } from "../agents/guard-rules.ts";
25
+ import { containsSecret, sanitize } from "../logging/sanitizer.ts";
26
+ import type { SkillDraft } from "./types.ts";
27
+
28
+ /** Outcome of scrubbing a single draft. */
29
+ export interface SafetyReport {
30
+ /** True iff the draft is safe to write (no dangerous command, no deploy verb). */
31
+ ok: boolean;
32
+ /** The draft after secret redaction + path rewriting (the copy to persist). */
33
+ redactedDraft: SkillDraft;
34
+ /** Human-readable record of everything the scrubber found or fixed. */
35
+ violations: string[];
36
+ }
37
+
38
+ /**
39
+ * Matches a fenced code block whose info string is `bash` or `sh` (optionally
40
+ * with trailing words, e.g. ```` ```bash title=x ````). Capture group 1 is the
41
+ * block's interior. The `m` flag lets the fences sit on their own lines and `g`
42
+ * walks every block in the body.
43
+ */
44
+ const BASH_FENCE_RE = /```(?:bash|sh)\b[^\n]*\n([\s\S]*?)```/gim;
45
+
46
+ /**
47
+ * Matches a POSIX home-directory absolute path: `/Users/<name>/...` (macOS) or
48
+ * `/home/<name>/...` (Linux). Capture group 1 is everything AFTER the user's
49
+ * home root, which we graft onto the `<repo>` placeholder. The leading `(?<![\w])`
50
+ * keeps us from rewriting a path that is a substring of a longer token.
51
+ */
52
+ const HOME_PATH_RE = /(?<![\w/])\/(?:Users|home)\/[^/\s"'`]+((?:\/[^\s"'`]*)?)/g;
53
+
54
+ /** Neutral placeholder substituted for an operator's home-directory root. */
55
+ const REPO_PLACEHOLDER = "<repo>";
56
+
57
+ /**
58
+ * Extract the interiors of every ```bash / ```sh fenced code block in `body`.
59
+ *
60
+ * Returns one string per matched block (fences stripped, interior preserved).
61
+ * If the body contains no fenced bash/sh blocks the array is empty — callers
62
+ * fall back to scanning the whole body so an unfenced snippet is not missed.
63
+ */
64
+ export function extractBashBlocks(body: string): string[] {
65
+ const blocks: string[] = [];
66
+ // A fresh lastIndex per call: the regex is module-level and stateful with /g.
67
+ BASH_FENCE_RE.lastIndex = 0;
68
+ for (let m = BASH_FENCE_RE.exec(body); m !== null; m = BASH_FENCE_RE.exec(body)) {
69
+ const interior = m[1];
70
+ if (interior !== undefined) blocks.push(interior);
71
+ }
72
+ return blocks;
73
+ }
74
+
75
+ /**
76
+ * Redact a single string field via {@link sanitize}; if {@link containsSecret}
77
+ * flagged it, push a `secret redacted in <field>` violation. Returns the cleaned
78
+ * string (unchanged when nothing matched).
79
+ */
80
+ function redactField(
81
+ value: string | undefined,
82
+ field: string,
83
+ violations: string[],
84
+ ): string | undefined {
85
+ if (value === undefined) return undefined;
86
+ if (containsSecret(value)) {
87
+ violations.push(`secret redacted in ${field}`);
88
+ return sanitize(value);
89
+ }
90
+ return value;
91
+ }
92
+
93
+ /**
94
+ * Redact each element of a string-array field, tracking whether any element held
95
+ * a secret so a single `<field>` violation is recorded for the array as a whole.
96
+ */
97
+ function redactArrayField(
98
+ values: string[] | undefined,
99
+ field: string,
100
+ violations: string[],
101
+ ): string[] | undefined {
102
+ if (values === undefined) return undefined;
103
+ let found = false;
104
+ const cleaned = values.map((value) => {
105
+ if (containsSecret(value)) {
106
+ found = true;
107
+ return sanitize(value);
108
+ }
109
+ return value;
110
+ });
111
+ if (found) violations.push(`secret redacted in ${field}`);
112
+ return cleaned;
113
+ }
114
+
115
+ /**
116
+ * Rewrite home-directory absolute paths in `body` to `<repo>/...`.
117
+ *
118
+ * Records a single `rewrote absolute path` violation if any substitution was
119
+ * made. The trailing capture (everything past the home root) is preserved, so
120
+ * `/Users/alice/Projects/agentplate/src/x.ts` → `<repo>/Projects/agentplate/src/x.ts`.
121
+ */
122
+ function rewriteHomePaths(body: string, violations: string[]): string {
123
+ let rewrote = false;
124
+ HOME_PATH_RE.lastIndex = 0;
125
+ const result = body.replace(HOME_PATH_RE, (_match, tail: string) => {
126
+ rewrote = true;
127
+ return `${REPO_PLACEHOLDER}${tail}`;
128
+ });
129
+ if (rewrote) violations.push("rewrote absolute path");
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Scrub a skill draft of secrets, dangerous commands, deploy verbs, and
135
+ * home-absolute paths before it is written to disk.
136
+ *
137
+ * Steps (in order):
138
+ * 1. Non-create/update actions (notably `skip`) are inherently safe — return the
139
+ * draft unchanged with `ok: true`.
140
+ * 2. Redact secrets in `title`, `goal`, `body`, `whenToUse`, and `tags`. A hit
141
+ * records a non-fatal `secret redacted in <field>` violation but the cleaned
142
+ * copy proceeds.
143
+ * 3. Scan the body's ```bash/```sh blocks (falling back to the whole body) for
144
+ * dangerous commands — each hit is a FATAL `dangerous command: <hit>`
145
+ * violation. A deploy verb anywhere in the body is a FATAL
146
+ * `deploy verb in skill (reserved for deployer)` violation.
147
+ * 4. Rewrite home-absolute paths in the body to `<repo>/...` (non-fatal
148
+ * `rewrote absolute path`).
149
+ * 5. `ok` is true iff no dangerous-command and no deploy-verb violation fired.
150
+ * Secret redactions and path rewrites are auto-fixes and never fail the report.
151
+ */
152
+ export function sanitizeSkillDraft(draft: SkillDraft): SafetyReport {
153
+ // Step 1: skip (and any non-mutating action) is safe — nothing to write.
154
+ if (draft.action !== "create" && draft.action !== "update") {
155
+ return { ok: true, redactedDraft: draft, violations: [] };
156
+ }
157
+
158
+ const violations: string[] = [];
159
+
160
+ // Step 2: redact secrets across every free-text field. Arrays are scrubbed
161
+ // element-wise so a leaked key inside one tag/when-to-use entry is caught.
162
+ const title = redactField(draft.title, "title", violations);
163
+ const goal = redactField(draft.goal, "goal", violations);
164
+ const whenToUse = redactArrayField(draft.whenToUse, "whenToUse", violations);
165
+ const tags = redactArrayField(draft.tags, "tags", violations);
166
+ let body = redactField(draft.body, "body", violations);
167
+
168
+ // Steps 3-4 operate on the body. Track dangerous/deploy hits separately from
169
+ // the (non-fatal) redaction/rewrite violations so `ok` reflects only fatals.
170
+ let dangerous = false;
171
+ let deploy = false;
172
+
173
+ if (body !== undefined) {
174
+ // Step 3a: scan fenced bash/sh blocks; fall back to the whole body when the
175
+ // snippet was left unfenced so a raw `rm -rf /` is still caught.
176
+ const blocks = extractBashBlocks(body);
177
+ const scanTargets = blocks.length > 0 ? blocks : [body];
178
+ const seenHits = new Set<string>();
179
+ for (const target of scanTargets) {
180
+ for (const hit of findDangerousCommands(target)) {
181
+ dangerous = true;
182
+ // De-duplicate identical hits (same command in body + a block) so the
183
+ // violation list stays signal-dense.
184
+ if (!seenHits.has(hit)) {
185
+ seenHits.add(hit);
186
+ violations.push(`dangerous command: ${hit}`);
187
+ }
188
+ }
189
+ }
190
+
191
+ // Step 3b: deploy verbs are checked against the entire body (they may sit in
192
+ // prose, not just a code fence) and are reserved for the gated deployer.
193
+ if (hasDeployVerb(body)) {
194
+ deploy = true;
195
+ violations.push("deploy verb in skill (reserved for deployer)");
196
+ }
197
+
198
+ // Step 4: rewrite operator home-directory paths to a neutral placeholder.
199
+ body = rewriteHomePaths(body, violations);
200
+ }
201
+
202
+ // Rebuild the draft preserving optional-field semantics: a field that was
203
+ // absent stays absent (no spurious empty keys in the persisted frontmatter).
204
+ const redactedDraft: SkillDraft = { action: draft.action };
205
+ if (draft.targetSlug !== undefined) redactedDraft.targetSlug = draft.targetSlug;
206
+ if (title !== undefined) redactedDraft.title = title;
207
+ if (goal !== undefined) redactedDraft.goal = goal;
208
+ if (whenToUse !== undefined) redactedDraft.whenToUse = whenToUse;
209
+ if (draft.filePatterns !== undefined) redactedDraft.filePatterns = draft.filePatterns;
210
+ if (tags !== undefined) redactedDraft.tags = tags;
211
+ if (body !== undefined) redactedDraft.body = body;
212
+
213
+ // Step 5: only fatal categories fail the report.
214
+ const ok = !dangerous && !deploy;
215
+ return { ok, redactedDraft, violations };
216
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Skill store tests.
3
+ *
4
+ * Real implementations throughout: a real temp `.agentplate/` tree on disk and the
5
+ * real bun:sqlite FTS index (no mocks). Each test gets a fresh temp root so
6
+ * directory-per-skill state never leaks between cases.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { AGENTPLATE_DIR } from "../config.ts";
14
+ import { NotFoundError, ValidationError } from "../errors.ts";
15
+ import {
16
+ createSkillStore,
17
+ parseSkillMd,
18
+ type SkillStore,
19
+ serializeSkillMd,
20
+ slugify,
21
+ } from "./store.ts";
22
+ import type { Skill, SkillDraft, SkillOutcome, SkillProvenance } from "./types.ts";
23
+
24
+ const PROVENANCE: SkillProvenance = { taskId: "task-1", agent: "builder-a", commit: "abc123" };
25
+
26
+ function createDraft(overrides: Partial<SkillDraft> = {}): SkillDraft {
27
+ return {
28
+ action: "create",
29
+ title: "Add a CLI Subcommand",
30
+ goal: "Register a new subcommand on the Commander program",
31
+ whenToUse: ["adding a new ap command", "wiring an action handler"],
32
+ filePatterns: ["src/commands/*.ts", "src/index.ts"],
33
+ tags: ["cli", "commander"],
34
+ body: "## Steps\n\n1. Create src/commands/foo.ts\n2. Register it in index.ts\n",
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function outcome(
40
+ status: SkillOutcome["status"],
41
+ overrides: Partial<SkillOutcome> = {},
42
+ ): SkillOutcome {
43
+ return {
44
+ status,
45
+ agent: "builder-a",
46
+ taskId: "task-1",
47
+ gates: status,
48
+ ts: new Date().toISOString(),
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("slugify", () => {
54
+ test("lowercases and kebab-cases", () => {
55
+ expect(slugify("Add a CLI Subcommand")).toBe("add-a-cli-subcommand");
56
+ });
57
+
58
+ test("strips unsafe characters", () => {
59
+ expect(slugify("Fix: the @Thing! (now)")).toBe("fix-the-thing-now");
60
+ });
61
+
62
+ test("collapses repeated dashes and trims edges", () => {
63
+ expect(slugify(" --Hello___World-- ")).toBe("hello-world");
64
+ });
65
+
66
+ test("strips accents via normalization", () => {
67
+ expect(slugify("Café Déjà Vu")).toBe("cafe-deja-vu");
68
+ });
69
+
70
+ test("falls back to 'skill' when nothing survives", () => {
71
+ expect(slugify("!!!")).toBe("skill");
72
+ expect(slugify("")).toBe("skill");
73
+ });
74
+
75
+ test("is idempotent on already-slugged input", () => {
76
+ const s = slugify("Some Title Here");
77
+ expect(slugify(s)).toBe(s);
78
+ });
79
+ });
80
+
81
+ describe("parseSkillMd / serializeSkillMd", () => {
82
+ function sampleSkill(): Skill {
83
+ return {
84
+ id: "11111111-2222-3333-4444-555555555555",
85
+ slug: "add-a-cli-subcommand",
86
+ title: "Add a CLI Subcommand",
87
+ version: 3,
88
+ status: "active",
89
+ goal: "Register a new subcommand",
90
+ whenToUse: ["adding a command", "wiring a handler"],
91
+ filePatterns: ["src/commands/*.ts"],
92
+ tags: ["cli"],
93
+ created: "2026-05-31T10:00:00.000Z",
94
+ updatedAt: "2026-05-31T12:00:00.000Z",
95
+ distilledFrom: { taskId: "t-1", agent: "builder", commit: "deadbeef" },
96
+ relatesTo: ["another-skill"],
97
+ supersedes: [],
98
+ body: "## Steps\n\n1. Do the thing\n2. Verify it\n\n## Gotchas\n\n- Watch the colon",
99
+ confidence: 0.42,
100
+ appliedCount: 5,
101
+ successCount: 3.5,
102
+ lastOutcome: "partial",
103
+ };
104
+ }
105
+
106
+ test("round-trips a fully-populated skill", () => {
107
+ const original = sampleSkill();
108
+ const reparsed = parseSkillMd(serializeSkillMd(original));
109
+ expect(reparsed).toEqual(original);
110
+ });
111
+
112
+ test("serialized form has fenced frontmatter, a blank line, then the body", () => {
113
+ const text = serializeSkillMd(sampleSkill());
114
+ expect(text.startsWith("---\n")).toBe(true);
115
+ // Frontmatter fence closes, then exactly one blank line precedes the body.
116
+ expect(text).toContain("---\n\n## Steps");
117
+ // Body is present verbatim (including a line containing a colon).
118
+ expect(text).toContain("- Watch the colon");
119
+ });
120
+
121
+ test("frontmatter keys are emitted in canonical order", () => {
122
+ const text = serializeSkillMd(sampleSkill());
123
+ const idIdx = text.indexOf("id:");
124
+ const titleIdx = text.indexOf("title:");
125
+ const statusIdx = text.indexOf("status:");
126
+ const bodyIdx = text.indexOf("## Steps");
127
+ expect(idIdx).toBeGreaterThanOrEqual(0);
128
+ expect(idIdx).toBeLessThan(titleIdx);
129
+ expect(titleIdx).toBeLessThan(statusIdx);
130
+ expect(statusIdx).toBeLessThan(bodyIdx);
131
+ });
132
+
133
+ test("omits distilledFrom from frontmatter when absent", () => {
134
+ const skill = sampleSkill();
135
+ skill.distilledFrom = undefined;
136
+ const text = serializeSkillMd(skill);
137
+ expect(text).not.toContain("distilledFrom");
138
+ const reparsed = parseSkillMd(text);
139
+ expect(reparsed.distilledFrom).toBeUndefined();
140
+ });
141
+
142
+ test("tolerates missing optional arrays (defaults to [])", () => {
143
+ const text = ["---", "id: x1", "slug: minimal", "title: Minimal", "---", "", "Body here"].join(
144
+ "\n",
145
+ );
146
+ const skill = parseSkillMd(text);
147
+ expect(skill.whenToUse).toEqual([]);
148
+ expect(skill.filePatterns).toEqual([]);
149
+ expect(skill.tags).toEqual([]);
150
+ expect(skill.relatesTo).toEqual([]);
151
+ expect(skill.supersedes).toEqual([]);
152
+ expect(skill.body).toBe("Body here");
153
+ expect(skill.version).toBe(1);
154
+ expect(skill.status).toBe("active");
155
+ expect(skill.confidence).toBe(0);
156
+ expect(skill.lastOutcome).toBeNull();
157
+ });
158
+
159
+ test("a multi-paragraph body survives round-trip including trailing structure", () => {
160
+ const skill = sampleSkill();
161
+ skill.body = "Para one.\n\nPara two with a list:\n- a\n- b\n\nFinal line.";
162
+ const reparsed = parseSkillMd(serializeSkillMd(skill));
163
+ expect(reparsed.body).toBe(skill.body);
164
+ });
165
+
166
+ test("throws on non-mapping frontmatter", () => {
167
+ const text = "---\n- just\n- a\n- list\n---\n\nbody";
168
+ expect(() => parseSkillMd(text)).toThrow(ValidationError);
169
+ });
170
+ });
171
+
172
+ describe("createSkillStore", () => {
173
+ let root: string;
174
+ let store: SkillStore;
175
+
176
+ beforeEach(() => {
177
+ root = mkdtempSync(join(tmpdir(), "agentplate-skills-"));
178
+ store = createSkillStore(root);
179
+ });
180
+
181
+ afterEach(() => {
182
+ store.close();
183
+ rmSync(root, { recursive: true, force: true });
184
+ });
185
+
186
+ test("upsert(create) writes a skill directory and returns action 'created'", () => {
187
+ const { action, skill } = store.upsert(createDraft(), PROVENANCE);
188
+ expect(action).toBe("created");
189
+ expect(skill.slug).toBe("add-a-cli-subcommand");
190
+ expect(skill.version).toBe(1);
191
+ expect(skill.status).toBe("active");
192
+ expect(skill.confidence).toBe(0);
193
+ expect(skill.appliedCount).toBe(0);
194
+ expect(skill.successCount).toBe(0);
195
+ expect(skill.lastOutcome).toBeNull();
196
+ expect(skill.distilledFrom).toEqual(PROVENANCE);
197
+ expect(skill.created).not.toBe("");
198
+ expect(skill.updatedAt).toBe(skill.created);
199
+
200
+ // On-disk layout matches the contract.
201
+ const dir = join(root, AGENTPLATE_DIR, "skills", skill.slug);
202
+ expect(existsSync(join(dir, "skill.md"))).toBe(true);
203
+ });
204
+
205
+ test("get returns the stored skill and null for a missing slug", () => {
206
+ store.upsert(createDraft(), PROVENANCE);
207
+ const got = store.get("add-a-cli-subcommand");
208
+ expect(got).not.toBeNull();
209
+ expect(got?.title).toBe("Add a CLI Subcommand");
210
+ expect(got?.whenToUse).toEqual(["adding a new ap command", "wiring an action handler"]);
211
+ expect(store.get("does-not-exist")).toBeNull();
212
+ });
213
+
214
+ test("get reads back exactly what was written (frontmatter round-trip)", () => {
215
+ const { skill } = store.upsert(createDraft(), PROVENANCE);
216
+ const got = store.get(skill.slug);
217
+ expect(got).toEqual(skill);
218
+ });
219
+
220
+ test("list returns all skills and filters by status", () => {
221
+ store.upsert(createDraft({ title: "First Skill" }), PROVENANCE);
222
+ store.upsert(createDraft({ title: "Second Skill" }), PROVENANCE);
223
+ store.upsert(createDraft({ title: "Third Skill" }), PROVENANCE);
224
+
225
+ expect(store.list().length).toBe(3);
226
+
227
+ store.setStatus("second-skill", "deprecated");
228
+ const active = store.list({ status: "active" });
229
+ expect(active.map((s) => s.slug).sort()).toEqual(["first-skill", "third-skill"]);
230
+ const deprecated = store.list({ status: "deprecated" });
231
+ expect(deprecated.length).toBe(1);
232
+ expect(deprecated[0]?.slug).toBe("second-skill");
233
+ });
234
+
235
+ test("list on a fresh store is empty (no skills dir yet)", () => {
236
+ expect(store.list()).toEqual([]);
237
+ });
238
+
239
+ test("create then create same title yields a unique slug (no collision)", () => {
240
+ const a = store.upsert(createDraft({ title: "Same Name" }), PROVENANCE);
241
+ const b = store.upsert(createDraft({ title: "Same Name" }), PROVENANCE);
242
+ expect(a.skill.slug).toBe("same-name");
243
+ expect(b.skill.slug).toBe("same-name-2");
244
+ expect(store.list().length).toBe(2);
245
+ });
246
+
247
+ test("upsert(update) bumps version, applies fields, and preserves outcomes.jsonl", () => {
248
+ const { skill } = store.upsert(createDraft(), PROVENANCE);
249
+ const slug = skill.slug;
250
+
251
+ // Record some history first.
252
+ store.appendOutcome(slug, outcome("success"));
253
+ store.appendOutcome(slug, outcome("partial"));
254
+
255
+ const updateDraft: SkillDraft = {
256
+ action: "update",
257
+ targetSlug: slug,
258
+ title: "Add a CLI Subcommand (revised)",
259
+ goal: "Updated goal",
260
+ body: "## New body\n\nrevised steps",
261
+ };
262
+ const newProv: SkillProvenance = { taskId: "task-9", agent: "builder-b", commit: "999" };
263
+ const { action, skill: updated } = store.upsert(updateDraft, newProv);
264
+
265
+ expect(action).toBe("updated");
266
+ expect(updated.version).toBe(2);
267
+ expect(updated.title).toBe("Add a CLI Subcommand (revised)");
268
+ expect(updated.goal).toBe("Updated goal");
269
+ expect(updated.body).toBe("## New body\n\nrevised steps");
270
+ expect(updated.distilledFrom).toEqual(newProv);
271
+ // Fields not in the draft are preserved.
272
+ expect(updated.filePatterns).toEqual(skill.filePatterns);
273
+ expect(updated.created).toBe(skill.created);
274
+
275
+ // Outcomes were preserved, so derived counts survive the version bump.
276
+ expect(updated.appliedCount).toBe(2);
277
+ expect(updated.successCount).toBe(1.5);
278
+ expect(updated.lastOutcome).toBe("partial");
279
+
280
+ // The on-disk JSONL still has both lines.
281
+ const jsonl = readFileSync(
282
+ join(root, AGENTPLATE_DIR, "skills", slug, "outcomes.jsonl"),
283
+ "utf8",
284
+ ).trim();
285
+ expect(jsonl.split("\n").length).toBe(2);
286
+ });
287
+
288
+ test("upsert(update) on a missing target throws NotFoundError", () => {
289
+ expect(() => store.upsert({ action: "update", targetSlug: "nope" }, PROVENANCE)).toThrow(
290
+ NotFoundError,
291
+ );
292
+ });
293
+
294
+ test("upsert(update) without targetSlug throws ValidationError", () => {
295
+ expect(() => store.upsert({ action: "update" }, PROVENANCE)).toThrow(ValidationError);
296
+ });
297
+
298
+ test("upsert(skip) throws ValidationError (never writes)", () => {
299
+ expect(() => store.upsert({ action: "skip" }, PROVENANCE)).toThrow(ValidationError);
300
+ expect(store.list()).toEqual([]);
301
+ });
302
+
303
+ test("upsert(create) without a title throws ValidationError", () => {
304
+ expect(() => store.upsert({ action: "create" }, PROVENANCE)).toThrow(ValidationError);
305
+ expect(() => store.upsert({ action: "create", title: " " }, PROVENANCE)).toThrow(
306
+ ValidationError,
307
+ );
308
+ });
309
+
310
+ test("appendOutcome updates counts/confidence and persists to frontmatter", () => {
311
+ const { skill } = store.upsert(createDraft(), PROVENANCE);
312
+ const slug = skill.slug;
313
+
314
+ const afterFirst = store.appendOutcome(slug, outcome("success"));
315
+ expect(afterFirst.appliedCount).toBe(1);
316
+ expect(afterFirst.successCount).toBe(1);
317
+ expect(afterFirst.lastOutcome).toBe("success");
318
+ expect(afterFirst.confidence).toBeGreaterThan(0);
319
+ expect(afterFirst.confidence).toBeLessThanOrEqual(1);
320
+
321
+ const afterSecond = store.appendOutcome(slug, outcome("failure"));
322
+ expect(afterSecond.appliedCount).toBe(2);
323
+ expect(afterSecond.successCount).toBe(1);
324
+ expect(afterSecond.lastOutcome).toBe("failure");
325
+
326
+ const afterThird = store.appendOutcome(slug, outcome("partial"));
327
+ expect(afterThird.appliedCount).toBe(3);
328
+ expect(afterThird.successCount).toBe(1.5);
329
+ expect(afterThird.lastOutcome).toBe("partial");
330
+
331
+ // A fresh get() (re-parsing skill.md) sees the persisted derived fields.
332
+ const reloaded = store.get(slug);
333
+ expect(reloaded?.appliedCount).toBe(3);
334
+ expect(reloaded?.successCount).toBe(1.5);
335
+ expect(reloaded?.lastOutcome).toBe("partial");
336
+ expect(reloaded?.confidence).toBeCloseTo(afterThird.confidence, 10);
337
+
338
+ // And the raw frontmatter actually carries the numbers (not just runtime state).
339
+ const md = readFileSync(join(root, AGENTPLATE_DIR, "skills", slug, "skill.md"), "utf8");
340
+ expect(md).toContain("appliedCount: 3");
341
+ expect(md).toContain("successCount: 1.5");
342
+ expect(md).toContain("lastOutcome: partial");
343
+ });
344
+
345
+ test("confidence rises with more successes for the same applied count trend", () => {
346
+ const a = store.upsert(createDraft({ title: "Skill A" }), PROVENANCE).skill;
347
+ const b = store.upsert(createDraft({ title: "Skill B" }), PROVENANCE).skill;
348
+
349
+ // A: 5/5 successes. B: 1 success then 4 failures.
350
+ for (let i = 0; i < 5; i++) store.appendOutcome(a.slug, outcome("success"));
351
+ store.appendOutcome(b.slug, outcome("success"));
352
+ for (let i = 0; i < 4; i++) store.appendOutcome(b.slug, outcome("failure"));
353
+
354
+ const reA = store.get(a.slug);
355
+ const reB = store.get(b.slug);
356
+ expect(reA?.confidence ?? 0).toBeGreaterThan(reB?.confidence ?? 1);
357
+ });
358
+
359
+ test("appendOutcome on a missing skill throws NotFoundError", () => {
360
+ expect(() => store.appendOutcome("ghost", outcome("success"))).toThrow(NotFoundError);
361
+ });
362
+
363
+ test("setStatus rewrites frontmatter status and bumps updatedAt", () => {
364
+ const { skill } = store.upsert(createDraft(), PROVENANCE);
365
+ expect(skill.status).toBe("active");
366
+
367
+ store.setStatus(skill.slug, "quarantined");
368
+ const reloaded = store.get(skill.slug);
369
+ expect(reloaded?.status).toBe("quarantined");
370
+
371
+ const md = readFileSync(join(root, AGENTPLATE_DIR, "skills", skill.slug, "skill.md"), "utf8");
372
+ expect(md).toContain("status: quarantined");
373
+ });
374
+
375
+ test("setStatus on a missing skill throws NotFoundError", () => {
376
+ expect(() => store.setStatus("ghost", "deprecated")).toThrow(NotFoundError);
377
+ });
378
+
379
+ test("remove deletes the directory and drops it from list", () => {
380
+ const { skill } = store.upsert(createDraft(), PROVENANCE);
381
+ store.appendOutcome(skill.slug, outcome("success"));
382
+ expect(existsSync(join(root, AGENTPLATE_DIR, "skills", skill.slug))).toBe(true);
383
+
384
+ store.remove(skill.slug);
385
+ expect(existsSync(join(root, AGENTPLATE_DIR, "skills", skill.slug))).toBe(false);
386
+ expect(store.get(skill.slug)).toBeNull();
387
+ expect(store.list()).toEqual([]);
388
+ });
389
+
390
+ test("remove is a no-op for a non-existent slug", () => {
391
+ expect(() => store.remove("never-existed")).not.toThrow();
392
+ });
393
+
394
+ test("reindex returns the number of skills indexed", () => {
395
+ store.upsert(createDraft({ title: "One" }), PROVENANCE);
396
+ store.upsert(createDraft({ title: "Two" }), PROVENANCE);
397
+ store.upsert(createDraft({ title: "Three" }), PROVENANCE);
398
+
399
+ expect(store.reindex()).toBe(3);
400
+
401
+ // After removing one, a rebuild reflects the new count.
402
+ store.remove("two");
403
+ expect(store.reindex()).toBe(2);
404
+ });
405
+
406
+ test("reindex on an empty store returns 0", () => {
407
+ expect(store.reindex()).toBe(0);
408
+ });
409
+
410
+ test("a reindexed store survives reopen (index is rebuildable from files)", () => {
411
+ store.upsert(createDraft({ title: "Persistent Skill" }), PROVENANCE);
412
+ store.close();
413
+
414
+ // Reopen against the same root and rebuild from the on-disk skill.md files.
415
+ const reopened = createSkillStore(root);
416
+ try {
417
+ expect(reopened.reindex()).toBe(1);
418
+ expect(reopened.get("persistent-skill")?.title).toBe("Persistent Skill");
419
+ } finally {
420
+ reopened.close();
421
+ // Re-point the outer store so afterEach's close() is harmless.
422
+ store = createSkillStore(root);
423
+ }
424
+ });
425
+ });