@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,684 @@
1
+ /**
2
+ * Skill store — the source of truth plus a derived FTS index.
3
+ *
4
+ * A skill lives on disk as a directory `<root>/.agentplate/skills/<slug>/` holding:
5
+ * - `skill.md` — YAML frontmatter (every {@link Skill} field except `body`)
6
+ * + a blank line + the markdown body. This is authoritative.
7
+ * - `outcomes.jsonl` — append-only log of {@link SkillOutcome}s, one JSON per line.
8
+ *
9
+ * A gitignored SQLite FTS5 index (`<root>/.agentplate/skills.db`) accelerates
10
+ * retrieval and is fully rebuildable from the `skill.md` files via {@link reindex}.
11
+ *
12
+ * Derived fields (`confidence`, `appliedCount`, `successCount`, `lastOutcome`)
13
+ * ARE persisted into the frontmatter for cheap reads, but are always recomputed
14
+ * from `outcomes.jsonl` whenever an outcome is appended — they are never trusted
15
+ * as hand-edited input. The recompute is INLINED here (a weighted-count +
16
+ * Wilson lower bound) rather than imported from the sibling feedback module, to
17
+ * keep the store free of a cross-dependency on a module built in parallel.
18
+ *
19
+ * Storage style mirrors the rest of Agentplate: js-yaml for human-editable YAML
20
+ * (as in config.ts / secrets.ts / identity.ts), bun:sqlite via openDatabase for
21
+ * the WAL-mode index, and crypto.randomUUID for ids.
22
+ */
23
+
24
+ import type { Database } from "bun:sqlite";
25
+ import {
26
+ appendFileSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ readdirSync,
30
+ readFileSync,
31
+ rmSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { join } from "node:path";
35
+ import yaml from "js-yaml";
36
+ import { AGENTPLATE_DIR } from "../config.ts";
37
+ import { openDatabase } from "../db/sqlite.ts";
38
+ import { NotFoundError, ValidationError } from "../errors.ts";
39
+ import type { OutcomeStatus } from "../types.ts";
40
+ import type { Skill, SkillDraft, SkillOutcome, SkillProvenance, SkillStatus } from "./types.ts";
41
+
42
+ /** Name of the per-skill source-of-truth file. */
43
+ const SKILL_FILE = "skill.md";
44
+ /** Name of the per-skill append-only outcome log. */
45
+ const OUTCOMES_FILE = "outcomes.jsonl";
46
+ /** Name of the derived (gitignored) FTS index. */
47
+ const INDEX_FILE = "skills.db";
48
+
49
+ /**
50
+ * Canonical frontmatter key order. Serialization walks this list so `skill.md`
51
+ * is byte-stable regardless of object construction order, which keeps git diffs
52
+ * small and round-trips deterministic.
53
+ */
54
+ const FRONTMATTER_KEYS = [
55
+ "id",
56
+ "slug",
57
+ "title",
58
+ "version",
59
+ "status",
60
+ "goal",
61
+ "whenToUse",
62
+ "filePatterns",
63
+ "tags",
64
+ "created",
65
+ "updatedAt",
66
+ "distilledFrom",
67
+ "relatesTo",
68
+ "supersedes",
69
+ "confidence",
70
+ "appliedCount",
71
+ "successCount",
72
+ "lastOutcome",
73
+ ] as const;
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Pure helpers (unit-tested directly)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Lowercase, kebab-case slug. Non-alphanumeric runs become single dashes;
81
+ * leading/trailing dashes are trimmed. An input that reduces to nothing yields
82
+ * the literal `"skill"` so a directory name always exists.
83
+ */
84
+ export function slugify(title: string): string {
85
+ const slug = title
86
+ .toLowerCase()
87
+ .normalize("NFKD")
88
+ // Strip combining marks left by normalization (e.g. accents).
89
+ .replace(/[̀-ͯ]/g, "")
90
+ // Any run of non [a-z0-9] becomes a single dash.
91
+ .replace(/[^a-z0-9]+/g, "-")
92
+ // Collapse repeats and trim edges.
93
+ .replace(/-+/g, "-")
94
+ .replace(/^-|-$/g, "");
95
+ return slug === "" ? "skill" : slug;
96
+ }
97
+
98
+ /** Coerce an unknown parsed value into a `string[]`, dropping non-strings. */
99
+ function toStringArray(value: unknown): string[] {
100
+ if (!Array.isArray(value)) return [];
101
+ const out: string[] = [];
102
+ for (const item of value) {
103
+ if (typeof item === "string") out.push(item);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Coerce an unknown parsed value into `SkillProvenance`, or `undefined`. */
109
+ function toProvenance(value: unknown): SkillProvenance | undefined {
110
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
111
+ const obj = value as Record<string, unknown>;
112
+ return {
113
+ taskId: typeof obj.taskId === "string" ? obj.taskId : null,
114
+ agent: typeof obj.agent === "string" ? obj.agent : "",
115
+ commit: typeof obj.commit === "string" ? obj.commit : null,
116
+ };
117
+ }
118
+
119
+ /** Narrow an unknown to a {@link SkillStatus}, defaulting to `"active"`. */
120
+ function toStatus(value: unknown): SkillStatus {
121
+ return value === "deprecated" || value === "quarantined" ? value : "active";
122
+ }
123
+
124
+ /** Narrow an unknown to an {@link OutcomeStatus} or `null`. */
125
+ function toOutcomeStatus(value: unknown): OutcomeStatus | null {
126
+ return value === "success" || value === "partial" || value === "failure" ? value : null;
127
+ }
128
+
129
+ /** Coerce an unknown to a finite number, falling back to `fallback`. */
130
+ function toNumber(value: unknown, fallback: number): number {
131
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
132
+ }
133
+
134
+ /**
135
+ * Parse a `skill.md` document into a {@link Skill}.
136
+ *
137
+ * Splits the leading `---`-fenced YAML frontmatter from the markdown body that
138
+ * follows it. Every Skill field except `body` comes from the frontmatter; the
139
+ * remainder (after the closing fence and one optional blank line) is the body.
140
+ * Optional arrays default to `[]` and missing scalars to safe defaults so a
141
+ * partially-written or older file never throws.
142
+ */
143
+ export function parseSkillMd(text: string): Skill {
144
+ const { frontmatter, body } = splitFrontmatter(text);
145
+
146
+ const parsed = yaml.load(frontmatter);
147
+ if (
148
+ parsed === null ||
149
+ parsed === undefined ||
150
+ typeof parsed !== "object" ||
151
+ Array.isArray(parsed)
152
+ ) {
153
+ throw new ValidationError("skill.md frontmatter must be a YAML mapping");
154
+ }
155
+ const fm = parsed as Record<string, unknown>;
156
+
157
+ return {
158
+ id: typeof fm.id === "string" && fm.id !== "" ? fm.id : crypto.randomUUID(),
159
+ slug: typeof fm.slug === "string" ? fm.slug : "",
160
+ title: typeof fm.title === "string" ? fm.title : "",
161
+ version: toNumber(fm.version, 1),
162
+ status: toStatus(fm.status),
163
+ goal: typeof fm.goal === "string" ? fm.goal : "",
164
+ whenToUse: toStringArray(fm.whenToUse),
165
+ filePatterns: toStringArray(fm.filePatterns),
166
+ tags: toStringArray(fm.tags),
167
+ created: typeof fm.created === "string" ? fm.created : "",
168
+ updatedAt: typeof fm.updatedAt === "string" ? fm.updatedAt : "",
169
+ distilledFrom: toProvenance(fm.distilledFrom),
170
+ relatesTo: toStringArray(fm.relatesTo),
171
+ supersedes: toStringArray(fm.supersedes),
172
+ body,
173
+ confidence: toNumber(fm.confidence, 0),
174
+ appliedCount: toNumber(fm.appliedCount, 0),
175
+ successCount: toNumber(fm.successCount, 0),
176
+ lastOutcome: toOutcomeStatus(fm.lastOutcome),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Split a `skill.md` into its raw frontmatter YAML and markdown body.
182
+ *
183
+ * Recognizes a leading `---` fence (optionally preceded by whitespace/BOM) and
184
+ * the next line that is exactly `---`. If no fences are present the whole
185
+ * document is treated as the body with empty frontmatter, so plain markdown
186
+ * still parses (yielding a defaults-only Skill).
187
+ */
188
+ function splitFrontmatter(text: string): { frontmatter: string; body: string } {
189
+ // Normalize CRLF so the fence regex and line slicing behave identically on
190
+ // Windows-authored files.
191
+ const normalized = text.replace(/\r\n/g, "\n").replace(/^/, "");
192
+ const lines = normalized.split("\n");
193
+
194
+ // The first non-empty line must be the opening fence.
195
+ let start = 0;
196
+ while (start < lines.length && lines[start]?.trim() === "") start++;
197
+ if (lines[start]?.trim() !== "---") {
198
+ return { frontmatter: "", body: normalized };
199
+ }
200
+
201
+ // Find the closing fence.
202
+ let end = -1;
203
+ for (let i = start + 1; i < lines.length; i++) {
204
+ if (lines[i]?.trim() === "---") {
205
+ end = i;
206
+ break;
207
+ }
208
+ }
209
+ if (end === -1) {
210
+ // Unterminated fence: treat everything after the opener as frontmatter and
211
+ // leave no body, rather than swallowing the document into the body.
212
+ return { frontmatter: lines.slice(start + 1).join("\n"), body: "" };
213
+ }
214
+
215
+ const frontmatter = lines.slice(start + 1, end).join("\n");
216
+ // Body is everything after the closing fence. Strip exactly the separators
217
+ // serializeSkillMd writes so a serialize → parse round-trip is the identity:
218
+ // one leading blank line (the fence/body separator) and the single trailing
219
+ // newline that terminates the file.
220
+ let bodyLines = lines.slice(end + 1);
221
+ if (bodyLines[0] === "") bodyLines = bodyLines.slice(1);
222
+ if (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === "") {
223
+ bodyLines = bodyLines.slice(0, -1);
224
+ }
225
+ return { frontmatter, body: bodyLines.join("\n") };
226
+ }
227
+
228
+ /**
229
+ * Serialize a {@link Skill} into a `skill.md` document: a deterministic
230
+ * `---`-fenced YAML frontmatter (stable key order, omitting only `body`),
231
+ * a single blank line, then the markdown body. Round-trips with
232
+ * {@link parseSkillMd}.
233
+ */
234
+ export function serializeSkillMd(skill: Skill): string {
235
+ const record = skill as unknown as Record<string, unknown>;
236
+
237
+ // Build the mapping in canonical key order so js-yaml (sortKeys:false) emits
238
+ // a byte-stable document. Optional `distilledFrom` is omitted when absent so
239
+ // the file does not carry a `null` for a field the type marks optional.
240
+ const ordered: Record<string, unknown> = {};
241
+ for (const key of FRONTMATTER_KEYS) {
242
+ if (
243
+ key === "distilledFrom" &&
244
+ (skill.distilledFrom === undefined || skill.distilledFrom === null)
245
+ ) {
246
+ continue;
247
+ }
248
+ ordered[key] = record[key];
249
+ }
250
+
251
+ const frontmatter = yaml.dump(ordered, { indent: 2, lineWidth: 100, sortKeys: false }).trimEnd();
252
+ // Body is preserved verbatim; ensure a single trailing newline on the file.
253
+ const body = skill.body.replace(/\s+$/, "");
254
+ return `---\n${frontmatter}\n---\n\n${body}\n`;
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Derived-field recompute (inlined; mirrors feedback.computeConfidence)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * Per-status success weight used when scoring an outcome line:
263
+ * a full success counts 1, a partial 0.5, a failure 0.
264
+ */
265
+ function outcomeWeight(status: OutcomeStatus): number {
266
+ if (status === "success") return 1;
267
+ if (status === "partial") return 0.5;
268
+ return 0;
269
+ }
270
+
271
+ /**
272
+ * Wilson score interval lower bound for a binomial proportion at ~95%
273
+ * confidence (z = 1.96). `successes` may be fractional (weighted partials).
274
+ * Returns 0 when there are no samples — an unproven skill earns no confidence.
275
+ *
276
+ * Inlined here (rather than importing feedback.ts, which is built in parallel)
277
+ * to avoid a cross-module dependency. The formula is the standard Wilson lower
278
+ * bound; the sibling feedback module is expected to compute the same value.
279
+ */
280
+ function wilsonLowerBound(successes: number, n: number): number {
281
+ if (n <= 0) return 0;
282
+ const z = 1.96;
283
+ const phat = successes / n;
284
+ const z2 = z * z;
285
+ const denom = 1 + z2 / n;
286
+ const center = phat + z2 / (2 * n);
287
+ const margin = z * Math.sqrt((phat * (1 - phat) + z2 / (4 * n)) / n);
288
+ const lower = (center - margin) / denom;
289
+ // Clamp to [0,1]; weighting can push phat into ranges where the raw bound
290
+ // drifts a hair outside the interval.
291
+ return lower < 0 ? 0 : lower > 1 ? 1 : lower;
292
+ }
293
+
294
+ /** Derived stats recomputed from a skill's full outcome history. */
295
+ interface DerivedStats {
296
+ appliedCount: number;
297
+ successCount: number;
298
+ confidence: number;
299
+ lastOutcome: OutcomeStatus | null;
300
+ }
301
+
302
+ /**
303
+ * Fold an ordered list of outcomes into derived stats:
304
+ * - `appliedCount` = number of outcomes (lines),
305
+ * - `successCount` = weighted sum (success 1 / partial 0.5 / failure 0),
306
+ * - `confidence` = Wilson lower bound of the weighted successes over the count,
307
+ * - `lastOutcome` = status of the final outcome (null when there are none).
308
+ */
309
+ function deriveStats(outcomes: SkillOutcome[]): DerivedStats {
310
+ let successCount = 0;
311
+ for (const outcome of outcomes) {
312
+ successCount += outcomeWeight(outcome.status);
313
+ }
314
+ const appliedCount = outcomes.length;
315
+ const last = outcomes[outcomes.length - 1];
316
+ return {
317
+ appliedCount,
318
+ successCount,
319
+ confidence: wilsonLowerBound(successCount, appliedCount),
320
+ lastOutcome: last ? last.status : null,
321
+ };
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Store
326
+ // ---------------------------------------------------------------------------
327
+
328
+ /** Filters for {@link SkillStore.list}. */
329
+ export interface ListSkillsOptions {
330
+ status?: SkillStatus;
331
+ }
332
+
333
+ /** Result of an {@link SkillStore.upsert}. */
334
+ export interface UpsertResult {
335
+ action: "created" | "updated";
336
+ skill: Skill;
337
+ }
338
+
339
+ /** Public surface returned by {@link createSkillStore}. */
340
+ export interface SkillStore {
341
+ list(opts?: ListSkillsOptions): Skill[];
342
+ get(slug: string): Skill | null;
343
+ upsert(draft: SkillDraft, provenance: SkillProvenance): UpsertResult;
344
+ setStatus(slug: string, status: SkillStatus): void;
345
+ appendOutcome(slug: string, outcome: SkillOutcome): Skill;
346
+ remove(slug: string): void;
347
+ reindex(): number;
348
+ close(): void;
349
+ }
350
+
351
+ /** FTS row shape as stored in the derived index. */
352
+ interface IndexRow {
353
+ slug: string;
354
+ title: string;
355
+ goal: string;
356
+ tags: string;
357
+ when_to_use: string;
358
+ file_patterns: string;
359
+ status: string;
360
+ confidence: number;
361
+ updated_at: string;
362
+ }
363
+
364
+ /**
365
+ * Create a skill store rooted at a project directory.
366
+ *
367
+ * `<root>/.agentplate/skills/` holds one directory per skill; `<root>/.agentplate/
368
+ * skills.db` is the derived FTS index (created on open, rebuildable via
369
+ * {@link SkillStore.reindex}). The skills directory is created lazily on first
370
+ * write so opening a store on a fresh project is side-effect free beyond the
371
+ * (gitignored) index file.
372
+ */
373
+ export function createSkillStore(root: string): SkillStore {
374
+ const agentplateDir = join(root, AGENTPLATE_DIR);
375
+ const skillsDir = join(agentplateDir, "skills");
376
+ const indexPath = join(agentplateDir, INDEX_FILE);
377
+
378
+ // The index lives under .agentplate/, which must exist before SQLite can create
379
+ // the file. Creating it here is safe and idempotent.
380
+ mkdirSync(agentplateDir, { recursive: true });
381
+ const db: Database = openDatabase(indexPath);
382
+ initIndex(db);
383
+
384
+ /** Absolute path to a skill's directory. */
385
+ function dirOf(slug: string): string {
386
+ return join(skillsDir, slug);
387
+ }
388
+ /** Absolute path to a skill's `skill.md`. */
389
+ function skillFileOf(slug: string): string {
390
+ return join(dirOf(slug), SKILL_FILE);
391
+ }
392
+ /** Absolute path to a skill's `outcomes.jsonl`. */
393
+ function outcomesFileOf(slug: string): string {
394
+ return join(dirOf(slug), OUTCOMES_FILE);
395
+ }
396
+
397
+ /** Read + parse a skill by slug, or null if its `skill.md` is absent. */
398
+ function read(slug: string): Skill | null {
399
+ const file = skillFileOf(slug);
400
+ if (!existsSync(file)) return null;
401
+ const skill = parseSkillMd(readFileSync(file, "utf8"));
402
+ // The directory name is authoritative for the slug — trust it over a stale
403
+ // frontmatter value (e.g. if a directory was renamed by hand).
404
+ skill.slug = slug;
405
+ return skill;
406
+ }
407
+
408
+ /**
409
+ * Write a skill's `skill.md` (creating its directory) and refresh the index row.
410
+ *
411
+ * The body is normalized in place to exactly the form that lands on disk
412
+ * (trailing whitespace trimmed by {@link serializeSkillMd}). Mutating the
413
+ * passed object here makes it the single normalization point, so the `Skill`
414
+ * a caller returns is identical to what a later {@link parseSkillMd} reads
415
+ * back — `upsert(...).skill` equals `get(slug)`.
416
+ */
417
+ function write(skill: Skill): void {
418
+ skill.body = skill.body.replace(/\s+$/, "");
419
+ mkdirSync(dirOf(skill.slug), { recursive: true });
420
+ writeFileSync(skillFileOf(skill.slug), serializeSkillMd(skill), "utf8");
421
+ upsertIndexRow(db, skill);
422
+ }
423
+
424
+ /** Read every outcome line for a skill (skipping malformed lines). */
425
+ function readOutcomes(slug: string): SkillOutcome[] {
426
+ const file = outcomesFileOf(slug);
427
+ if (!existsSync(file)) return [];
428
+ const out: SkillOutcome[] = [];
429
+ for (const line of readFileSync(file, "utf8").split("\n")) {
430
+ const trimmed = line.trim();
431
+ if (trimmed === "") continue;
432
+ let parsed: unknown;
433
+ try {
434
+ parsed = JSON.parse(trimmed);
435
+ } catch {
436
+ // A corrupt line should not poison the whole history.
437
+ continue;
438
+ }
439
+ const outcome = coerceOutcome(parsed);
440
+ if (outcome !== null) out.push(outcome);
441
+ }
442
+ return out;
443
+ }
444
+
445
+ /** List every skill on disk, newest-updated first, optionally status-filtered. */
446
+ function list(opts?: ListSkillsOptions): Skill[] {
447
+ if (!existsSync(skillsDir)) return [];
448
+ const skills: Skill[] = [];
449
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
450
+ if (!entry.isDirectory()) continue;
451
+ const skill = read(entry.name);
452
+ if (skill === null) continue;
453
+ if (opts?.status !== undefined && skill.status !== opts.status) continue;
454
+ skills.push(skill);
455
+ }
456
+ // Deterministic order: most-recently-updated first, slug as a stable tiebreak.
457
+ skills.sort((a, b) => {
458
+ if (a.updatedAt !== b.updatedAt) return a.updatedAt < b.updatedAt ? 1 : -1;
459
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
460
+ });
461
+ return skills;
462
+ }
463
+
464
+ function get(slug: string): Skill | null {
465
+ return read(slug);
466
+ }
467
+
468
+ function upsert(draft: SkillDraft, provenance: SkillProvenance): UpsertResult {
469
+ // `skip` drafts must never reach storage. The caller is expected to guard
470
+ // this, but we reject defensively so a stray skip can't mint/mutate a skill.
471
+ if (draft.action === "skip") {
472
+ throw new ValidationError("Cannot upsert a SkillDraft with action 'skip'");
473
+ }
474
+
475
+ const now = new Date().toISOString();
476
+
477
+ // --- UPDATE path: bump an existing skill, preserving its outcome log. ---
478
+ if (draft.action === "update") {
479
+ const targetSlug = draft.targetSlug;
480
+ if (targetSlug === undefined || targetSlug === "") {
481
+ throw new ValidationError("SkillDraft action 'update' requires targetSlug");
482
+ }
483
+ const existing = read(targetSlug);
484
+ if (existing === null) {
485
+ throw new NotFoundError(`Cannot update skill: "${targetSlug}" not found`);
486
+ }
487
+
488
+ // Recompute derived fields from the preserved outcome log so they stay
489
+ // consistent across the version bump (outcomes.jsonl is left untouched).
490
+ const stats = deriveStats(readOutcomes(targetSlug));
491
+
492
+ const updated: Skill = {
493
+ ...existing,
494
+ title: draft.title ?? existing.title,
495
+ goal: draft.goal ?? existing.goal,
496
+ whenToUse: draft.whenToUse ?? existing.whenToUse,
497
+ filePatterns: draft.filePatterns ?? existing.filePatterns,
498
+ tags: draft.tags ?? existing.tags,
499
+ body: draft.body ?? existing.body,
500
+ version: existing.version + 1,
501
+ updatedAt: now,
502
+ distilledFrom: provenance,
503
+ ...stats,
504
+ };
505
+ write(updated);
506
+ return { action: "updated", skill: updated };
507
+ }
508
+
509
+ // --- CREATE path: mint a fresh skill directory from the title slug. ---
510
+ if (draft.title === undefined || draft.title.trim() === "") {
511
+ throw new ValidationError("SkillDraft action 'create' requires a non-empty title");
512
+ }
513
+ const slug = uniqueSlug(slugify(draft.title));
514
+
515
+ const created: Skill = {
516
+ id: crypto.randomUUID(),
517
+ slug,
518
+ title: draft.title,
519
+ version: 1,
520
+ status: "active",
521
+ goal: draft.goal ?? "",
522
+ whenToUse: draft.whenToUse ?? [],
523
+ filePatterns: draft.filePatterns ?? [],
524
+ tags: draft.tags ?? [],
525
+ created: now,
526
+ updatedAt: now,
527
+ distilledFrom: provenance,
528
+ relatesTo: [],
529
+ supersedes: [],
530
+ body: draft.body ?? "",
531
+ confidence: 0,
532
+ appliedCount: 0,
533
+ successCount: 0,
534
+ lastOutcome: null,
535
+ };
536
+ write(created);
537
+ return { action: "created", skill: created };
538
+ }
539
+
540
+ /**
541
+ * Resolve a free slug, appending `-2`, `-3`, … if the base is taken so two
542
+ * skills with the same title don't collide on one directory.
543
+ */
544
+ function uniqueSlug(base: string): string {
545
+ if (!existsSync(dirOf(base))) return base;
546
+ for (let n = 2; ; n++) {
547
+ const candidate = `${base}-${n}`;
548
+ if (!existsSync(dirOf(candidate))) return candidate;
549
+ }
550
+ }
551
+
552
+ function setStatus(slug: string, status: SkillStatus): void {
553
+ const skill = read(slug);
554
+ if (skill === null) {
555
+ throw new NotFoundError(`Cannot set status: skill "${slug}" not found`);
556
+ }
557
+ skill.status = status;
558
+ skill.updatedAt = new Date().toISOString();
559
+ write(skill);
560
+ }
561
+
562
+ function appendOutcome(slug: string, outcome: SkillOutcome): Skill {
563
+ const skill = read(slug);
564
+ if (skill === null) {
565
+ throw new NotFoundError(`Cannot append outcome: skill "${slug}" not found`);
566
+ }
567
+
568
+ // Append first (the JSONL log is the durable record), then recompute the
569
+ // cached derived fields in skill.md from the full, post-append history.
570
+ appendFileSync(outcomesFileOf(slug), `${JSON.stringify(outcome)}\n`, "utf8");
571
+
572
+ const stats = deriveStats(readOutcomes(slug));
573
+ const updated: Skill = { ...skill, ...stats };
574
+ write(updated);
575
+ return updated;
576
+ }
577
+
578
+ function remove(slug: string): void {
579
+ const dir = dirOf(slug);
580
+ if (existsSync(dir)) {
581
+ rmSync(dir, { recursive: true, force: true });
582
+ }
583
+ db.query("DELETE FROM skills_fts WHERE slug = $slug").run({ $slug: slug });
584
+ }
585
+
586
+ function reindex(): number {
587
+ // Rebuild the FTS table from scratch so deletions/renames on disk are
588
+ // reflected and the index can't drift from the source of truth.
589
+ db.exec("DELETE FROM skills_fts");
590
+ let count = 0;
591
+ for (const skill of list()) {
592
+ upsertIndexRow(db, skill);
593
+ count++;
594
+ }
595
+ return count;
596
+ }
597
+
598
+ function close(): void {
599
+ db.close();
600
+ }
601
+
602
+ return { list, get, upsert, setStatus, appendOutcome, remove, reindex, close };
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Outcome coercion
607
+ // ---------------------------------------------------------------------------
608
+
609
+ /** Narrow an unknown parsed JSONL value into a {@link SkillOutcome}, or null. */
610
+ function coerceOutcome(value: unknown): SkillOutcome | null {
611
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
612
+ const obj = value as Record<string, unknown>;
613
+ const status = toOutcomeStatus(obj.status);
614
+ // An outcome without a valid status carries no information — drop it.
615
+ if (status === null) return null;
616
+ const outcome: SkillOutcome = {
617
+ status,
618
+ agent: typeof obj.agent === "string" ? obj.agent : "",
619
+ taskId: typeof obj.taskId === "string" ? obj.taskId : null,
620
+ gates: toOutcomeStatus(obj.gates),
621
+ ts: typeof obj.ts === "string" ? obj.ts : "",
622
+ };
623
+ if (typeof obj.note === "string") outcome.note = obj.note;
624
+ return outcome;
625
+ }
626
+
627
+ // ---------------------------------------------------------------------------
628
+ // FTS index
629
+ // ---------------------------------------------------------------------------
630
+
631
+ /**
632
+ * Create the FTS5 virtual table if it is absent. `slug` is stored UNINDEXED so
633
+ * it round-trips exactly (it is a row key, not a search term); the remaining
634
+ * text columns are full-text searchable. `confidence`/`updated_at` ride along as
635
+ * unindexed columns so retrieval can rank/filter without a second table.
636
+ */
637
+ function initIndex(db: Database): void {
638
+ db.exec(`
639
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
640
+ slug UNINDEXED,
641
+ title,
642
+ goal,
643
+ tags,
644
+ when_to_use,
645
+ file_patterns,
646
+ status UNINDEXED,
647
+ confidence UNINDEXED,
648
+ updated_at UNINDEXED
649
+ )
650
+ `);
651
+ }
652
+
653
+ /** Replace (delete + insert) a skill's row in the FTS index. */
654
+ function upsertIndexRow(db: Database, skill: Skill): void {
655
+ // FTS5 has no UPSERT; delete the prior row (if any) then insert the current.
656
+ db.query("DELETE FROM skills_fts WHERE slug = $slug").run({ $slug: skill.slug });
657
+ const row: IndexRow = {
658
+ slug: skill.slug,
659
+ title: skill.title,
660
+ goal: skill.goal,
661
+ tags: skill.tags.join(" "),
662
+ when_to_use: skill.whenToUse.join(" "),
663
+ file_patterns: skill.filePatterns.join(" "),
664
+ status: skill.status,
665
+ confidence: skill.confidence,
666
+ updated_at: skill.updatedAt,
667
+ };
668
+ db.query(
669
+ `INSERT INTO skills_fts
670
+ (slug, title, goal, tags, when_to_use, file_patterns, status, confidence, updated_at)
671
+ VALUES
672
+ ($slug, $title, $goal, $tags, $when_to_use, $file_patterns, $status, $confidence, $updated_at)`,
673
+ ).run({
674
+ $slug: row.slug,
675
+ $title: row.title,
676
+ $goal: row.goal,
677
+ $tags: row.tags,
678
+ $when_to_use: row.when_to_use,
679
+ $file_patterns: row.file_patterns,
680
+ $status: row.status,
681
+ $confidence: row.confidence,
682
+ $updated_at: row.updated_at,
683
+ });
684
+ }