@markstanden/opencode-docs-plugin 0.0.0-stage → 0.2.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.
package/src/index.ts ADDED
@@ -0,0 +1,1398 @@
1
+ import { Plugin, Skill } from "@opencode/plugin";
2
+ import { AbsolutePath } from "@opencode/schema/schema";
3
+ import type {
4
+ CommandDefinition,
5
+ CommandInvocation,
6
+ } from "@opencode/plugin/promise/command";
7
+ import type { Info } from "@opencode/plugin/promise/tool";
8
+ import { spawnSync } from "node:child_process";
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, isAbsolute, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { Schema } from "effect";
21
+
22
+ /**
23
+ * V2 docs plugin.
24
+ *
25
+ * Registers a model-callable `docs` tool so the agent can discover the
26
+ * knowledge tree mid-task — the docs root is configurable (`root` option,
27
+ * default `<location>/docs`): a blank search returns the most recently changed
28
+ * docs (`path — Area — date — Summary`), a search ranks the docs by
29
+ * how well their front-matter, path and body identifiers match, falling back
30
+ * to the closest matches rather than a dead end. `add` writes knowledge,
31
+ * `check` validates the convention. Reading a matched doc stays the built-in
32
+ * `read`.
33
+ *
34
+ * Everything below the default export is pure or dependency-injected so it can
35
+ * be exercised directly against Node's built-in test runner without OpenCode.
36
+ */
37
+
38
+ /** One knowledge doc, as the tool sees it. */
39
+ export interface DocEntry {
40
+ /** Root-relative path, e.g. `decisions/choose-a-queue.md`. */
41
+ path: string;
42
+ /** Front-matter `Area`, else the first path segment under the root. */
43
+ area: string;
44
+ /** Resolved at read time: last git commit, else file mtime. Never front-matter. */
45
+ date: string;
46
+ /** First `# ` heading in the body, else the file name. */
47
+ title: string;
48
+ /** Front-matter `Summary`, else an empty string. */
49
+ summary: string;
50
+ /** Front-matter `Status: superseded` — the doc is marked as replaced. */
51
+ superseded: boolean;
52
+ /** Front-matter `Keywords`, split on commas and trimmed. */
53
+ keywords: readonly string[];
54
+ /** Body H2/H3 headings plus inline `code` spans, trimmed and de-duplicated. */
55
+ identifiers: readonly string[];
56
+ }
57
+
58
+ /**
59
+ * The `docs` tool's input. The field annotation is a description the host turns
60
+ * into JSON Schema, so the model sees how to call the tool. The typed DTOs
61
+ * below derive from it, so the contract has one source.
62
+ */
63
+ export const DOCS_INPUT = Schema.Struct({
64
+ search: Schema.optional(
65
+ Schema.String.annotate({
66
+ description:
67
+ "Case-insensitive search over title, path, keywords, body headings and code spans, area and summary. Omit or leave blank to list the most recently changed docs; a partial match returns the closest docs.",
68
+ }),
69
+ ),
70
+ add: Schema.optional(
71
+ Schema.Struct({
72
+ path: Schema.optional(
73
+ Schema.String.annotate({
74
+ description:
75
+ "Update target, e.g. `guides/getting-started.md` (relative to the docs root). Omit to create `<Area>/<slug>.md` from the H1.",
76
+ }),
77
+ ),
78
+ metadata: Schema.Struct({
79
+ Area: Schema.String,
80
+ Keywords: Schema.Array(Schema.String),
81
+ Summary: Schema.String,
82
+ Status: Schema.optional(
83
+ Schema.Union([
84
+ Schema.Literal("active"),
85
+ Schema.Literal("superseded"),
86
+ ]),
87
+ ),
88
+ Supersedes: Schema.optional(Schema.String),
89
+ }).annotate({
90
+ description:
91
+ "Front-matter metadata; serialised, never copied. All three fields are required and must not be blank.",
92
+ }),
93
+ body: Schema.String.annotate({
94
+ description:
95
+ "Markdown body: an H1, then the claim sections (Facts / Inferred / Tested / Open questions).",
96
+ }),
97
+ }).annotate({
98
+ description:
99
+ "The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`. Blank required metadata, path traversal, a non-.md target, a body without an H1, and a same-area near-twin (when `path` is omitted) are all refused.",
100
+ }),
101
+ ),
102
+ check: Schema.optional(
103
+ Schema.Boolean.annotate({
104
+ description:
105
+ "Validate every doc under the docs root against the convention: required front-matter, an H1, claim sections, no hand-maintained Date, a distinctive H1+Keywords identity, a Supersedes naming a doc in the tree, and same-area near-twins. An empty report means compliant.",
106
+ }),
107
+ ),
108
+ });
109
+
110
+ /**
111
+ * The `docs` tool's input, derived from {@link DOCS_INPUT} so the schema is the
112
+ * one source and the implementation cannot drift from the model-facing
113
+ * contract.
114
+ */
115
+ export type DocsInput = Schema.Schema.Type<typeof DOCS_INPUT>;
116
+
117
+ /**
118
+ * The `add` mode's typed DTO: the single writer's whole input. Pass `path` to
119
+ * update (`docs/<area>/<name>.md`); omit it to create `docs/<Area>/<slug>.md`
120
+ * from the H1.
121
+ */
122
+ export type DocsAdd = NonNullable<DocsInput["add"]>;
123
+
124
+ /** The typed metadata of a knowledge doc, serialised to front-matter. */
125
+ export type DocMetadata = DocsAdd["metadata"];
126
+
127
+ /** Top-level directories under the root the tool ignores: scratch and mirrors. */
128
+ export const EXCLUDED_AREAS = ["plans", "requirements", "wiki"] as const;
129
+
130
+ /**
131
+ * Split a doc into its YAML front-matter key/values and body. Flat `Key: value`
132
+ * lines only — enough for the doc schema, so no YAML parser.
133
+ * @param raw Full file contents.
134
+ * @returns Parsed metadata and the body after the closing `---`.
135
+ */
136
+ export function parseFrontMatter(raw: string): {
137
+ meta: Record<string, string>;
138
+ body: string;
139
+ } {
140
+ if (!raw.startsWith("---\n")) {
141
+ return { meta: {}, body: raw };
142
+ }
143
+ const end = raw.indexOf("\n---", 4);
144
+ if (end === -1) {
145
+ return { meta: {}, body: raw };
146
+ }
147
+ const meta: Record<string, string> = {};
148
+ for (const line of raw.slice(4, end).split("\n")) {
149
+ const colon = line.indexOf(":");
150
+ if (colon === -1) {
151
+ continue;
152
+ }
153
+ const key = line.slice(0, colon).trim();
154
+ if (key !== "") {
155
+ meta[key] = line.slice(colon + 1).trim();
156
+ }
157
+ }
158
+ return { meta, body: raw.slice(end + 4).replace(/^\n+/, "") };
159
+ }
160
+
161
+ /**
162
+ * The front-matter fields in canonical order, and whether `add` requires
163
+ * them. The single source shared by serialisation ({@link serializeDoc},
164
+ * order) and the `check` validation to come (requiredness).
165
+ */
166
+ export const FRONT_MATTER_FIELDS = [
167
+ { name: "Area", required: true },
168
+ { name: "Keywords", required: true },
169
+ { name: "Summary", required: true },
170
+ { name: "Status", required: false },
171
+ { name: "Supersedes", required: false },
172
+ ] as const;
173
+
174
+ /**
175
+ * Serialise a doc: front-matter from the metadata in
176
+ * {@link FRONT_MATTER_FIELDS} order (the `Date` is derived by git, never
177
+ * written), then the body.
178
+ * @param metadata The typed metadata; `Keywords` joins into the comma form.
179
+ * @param body Markdown body; a single trailing newline is ensured.
180
+ * @returns The full file contents to write.
181
+ */
182
+ export function serializeDoc(metadata: DocMetadata, body: string): string {
183
+ const lines = FRONT_MATTER_FIELDS.flatMap(({ name }) => {
184
+ const value = metadata[name];
185
+ if (value === undefined) {
186
+ return [];
187
+ }
188
+ const text = Array.isArray(value) ? value.join(", ") : value;
189
+ return [`${name}: ${text}`];
190
+ });
191
+ return `---\n${lines.join("\n")}\n---\n\n${body.replace(/(?<!\n)\n+$/, "")}\n`;
192
+ }
193
+
194
+ /**
195
+ * The doc's H1 text: the first `# ` heading's body, trimmed. The one H1
196
+ * detector, shared by {@link titleOf}, the `add` guard and the `check` rule.
197
+ * @param body Doc body (front-matter already stripped).
198
+ * @returns The heading text, or undefined when the body carries no H1.
199
+ */
200
+ function h1Of(body: string): string | undefined {
201
+ const heading = body.split("\n").find((line) => line.startsWith("# "));
202
+ return heading?.slice(2).trim();
203
+ }
204
+
205
+ /**
206
+ * The doc's title: its {@link h1Of} heading, else the file name without `.md`.
207
+ * @param path Repo-relative doc path.
208
+ * @param body Doc body (front-matter already stripped).
209
+ * @returns Display title.
210
+ */
211
+ export function titleOf(path: string, body: string): string {
212
+ const heading = h1Of(body);
213
+ if (heading !== undefined) {
214
+ return heading;
215
+ }
216
+ const base = path.split("/").pop() ?? path;
217
+ return base.replace(/\.md$/, "");
218
+ }
219
+
220
+ /** Split a front-matter `Keywords` value into trimmed, non-empty terms. */
221
+ function splitKeywords(value: string): string[] {
222
+ return value
223
+ .split(",")
224
+ .map((keyword) => keyword.trim())
225
+ .filter((keyword) => keyword !== "");
226
+ }
227
+
228
+ /** Whether a line opens or closes a fenced code block. */
229
+ function isFence(line: string): boolean {
230
+ return line.trimStart().startsWith("```");
231
+ }
232
+
233
+ /** Drop an ATX heading's trailing `#` decoration (and any trailing spaces). */
234
+ function stripTrailingHashes(text: string): string {
235
+ return text.replace(/(?<![ #])[ #]+$/, "");
236
+ }
237
+
238
+ /** The body's indexable pieces, from one fence-aware walk of the source. */
239
+ interface BodyScan {
240
+ /** H2/H3 heading texts, trimmed, in first-seen order. */
241
+ readonly headings: string[];
242
+ /** Inline `` `code` `` span texts, trimmed, in first-seen order. */
243
+ readonly spans: string[];
244
+ }
245
+
246
+ /**
247
+ * One fence-aware walk of a doc body, yielding the H2/H3 heading texts and the
248
+ * inline `` `code` `` span texts — the one scanner behind search indexing
249
+ * ({@link identifiersOf}) and the `check` claim-section rule
250
+ * ({@link validateDoc}).
251
+ * @param body Doc body, front-matter already stripped.
252
+ * @returns The headings and code spans found outside fenced code blocks.
253
+ */
254
+ function scanBody(body: string): BodyScan {
255
+ const headings: string[] = [];
256
+ const spans: string[] = [];
257
+ let fenced = false;
258
+ for (const line of body.split("\n")) {
259
+ if (isFence(line)) {
260
+ fenced = !fenced;
261
+ continue;
262
+ }
263
+ if (fenced) {
264
+ continue;
265
+ }
266
+ const heading = /^(#{2,3})\s+/.exec(line);
267
+ if (heading !== null) {
268
+ const text = stripTrailingHashes(
269
+ line.slice(heading[0].length).replace(/`/g, ""),
270
+ ).trim();
271
+ if (text !== "") {
272
+ headings.push(text);
273
+ }
274
+ }
275
+ for (const span of line.matchAll(/`([^`]+)`/g)) {
276
+ const text = (span[1] ?? "").trim();
277
+ if (text !== "") {
278
+ spans.push(text);
279
+ }
280
+ }
281
+ }
282
+ return { headings, spans };
283
+ }
284
+
285
+ /**
286
+ * The body's indexable identifiers: H2/H3 heading texts plus inline `` `code` ``
287
+ * spans, trimmed and de-duplicated in first-seen order (headings first). Fenced
288
+ * code blocks and free prose are skipped — a fence merely repeats imports and
289
+ * usage, and long prose admits the noise the closest-match gate exists to keep
290
+ * out (2026-10-10).
291
+ * @param body Doc body, front-matter already stripped; both lists come from
292
+ * {@link scanBody}.
293
+ * @returns The identifiers to index.
294
+ */
295
+ export function identifiersOf(body: string): string[] {
296
+ const { headings, spans } = scanBody(body);
297
+ return [...new Set([...headings, ...spans])];
298
+ }
299
+
300
+ /**
301
+ * Build a doc entry from its path, raw contents and the date to show.
302
+ * @param path Repo-relative doc path.
303
+ * @param raw Full file contents.
304
+ * @param date The resolved date (last git commit, else file mtime). Front-matter
305
+ * `Date` is deliberately ignored — the date is derived, never hand-maintained.
306
+ * @returns The entry the tool ranks and renders.
307
+ */
308
+ export function toEntry(path: string, raw: string, date: string): DocEntry {
309
+ const { meta, body } = parseFrontMatter(raw);
310
+ const segments = path.split("/");
311
+ const area = meta.Area ?? (segments.length > 1 ? (segments[0] ?? "") : "");
312
+ return {
313
+ path,
314
+ area,
315
+ date,
316
+ title: titleOf(path, body),
317
+ summary: meta.Summary ?? "",
318
+ superseded: meta.Status?.trim().toLowerCase() === "superseded",
319
+ keywords: splitKeywords(meta.Keywords ?? ""),
320
+ identifiers: identifiersOf(body),
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Shortest query term that still counts as a match. Shorter terms are usually
326
+ * filler (`to`, `a`, `of`) that appear in nearly every Summary, dragging the
327
+ * whole catalogue into the closest-match fallback; dropping them keeps the
328
+ * fallback meaningful. A query made only of short terms is searched as typed.
329
+ * The single knob for `matches` and `score` alike — tune here.
330
+ */
331
+ export const MIN_TERM_LENGTH = 3;
332
+
333
+ /**
334
+ * The most docs any call returns — the blank catalogue, full matches and the
335
+ * closest-match fallback alike, with the cut reported as `omitted`. An agent
336
+ * needing the full inventory can list the tree itself. The single knob for the
337
+ * cap — tune here.
338
+ */
339
+ export const MAX_RESULTS = 10;
340
+
341
+ /** Split a raw query into lower-cased, non-empty terms, dropping filler. */
342
+ function termsOf(query: string): string[] {
343
+ const terms = query
344
+ .trim()
345
+ .toLowerCase()
346
+ .split(/\s+/)
347
+ .filter((term) => term !== "");
348
+ const kept = terms.filter((term) => term.length >= MIN_TERM_LENGTH);
349
+ return kept.length > 0 ? kept : terms;
350
+ }
351
+
352
+ /**
353
+ * Whether a doc matches the query. A blank query matches everything; otherwise
354
+ * every kept term (see {@link MIN_TERM_LENGTH}) must appear
355
+ * case-insensitively in the doc's searchable text.
356
+ * @param entry Doc to test.
357
+ * @param query Raw query text.
358
+ * @returns True when the doc should be listed.
359
+ */
360
+ export function matches(entry: DocEntry, query: string): boolean {
361
+ const terms = termsOf(query);
362
+ if (terms.length === 0) {
363
+ return true;
364
+ }
365
+ const haystack = [
366
+ entry.path,
367
+ entry.area,
368
+ entry.title,
369
+ entry.summary,
370
+ ...entry.keywords,
371
+ ...entry.identifiers,
372
+ ]
373
+ .join("\n")
374
+ .toLowerCase();
375
+ return terms.every((term) => haystack.includes(term));
376
+ }
377
+
378
+ /**
379
+ * Field weights, most significant first. Each term scores its best field hit
380
+ * as `weight × quality` (see {@link QUALITY}) — multiplicative, so an exact
381
+ * keyword (4×4=16) beats a substring title (6×2=12). The single source of the
382
+ * weights — dial here.
383
+ */
384
+ export const FIELD_WEIGHTS = {
385
+ title: 6,
386
+ path: 5,
387
+ keywords: 4,
388
+ identifiers: 3,
389
+ area: 2,
390
+ summary: 1,
391
+ } as const;
392
+
393
+ /** The weighted searchable fields, weights from {@link FIELD_WEIGHTS}. */
394
+ const FIELDS: ReadonlyArray<
395
+ readonly [weight: number, values: (entry: DocEntry) => readonly string[]]
396
+ > = [
397
+ [FIELD_WEIGHTS.title, (entry) => [entry.title]],
398
+ [FIELD_WEIGHTS.path, (entry) => [entry.path]],
399
+ [FIELD_WEIGHTS.keywords, (entry) => entry.keywords],
400
+ [FIELD_WEIGHTS.identifiers, (entry) => entry.identifiers],
401
+ [FIELD_WEIGHTS.area, (entry) => [entry.area]],
402
+ [FIELD_WEIGHTS.summary, (entry) => [entry.summary]],
403
+ ];
404
+
405
+ /**
406
+ * How well one term hits one field: exact beats prefix beats substring; `miss`
407
+ * is no hit. Deliberately no subsequence pass — a lone subsequence matched
408
+ * nearly every Summary, so the closest-match fallback dumped the catalogue
409
+ * (2026-10-10). The single source of the qualities — dial here.
410
+ */
411
+ export const QUALITY = {
412
+ exact: 4,
413
+ prefix: 3,
414
+ substring: 2,
415
+ miss: 0,
416
+ } as const;
417
+
418
+ function quality(term: string, text: string): number {
419
+ const haystack = text.toLowerCase();
420
+ if (haystack === term) {
421
+ return QUALITY.exact;
422
+ }
423
+ if (haystack.startsWith(term)) {
424
+ return QUALITY.prefix;
425
+ }
426
+ return haystack.includes(term) ? QUALITY.substring : QUALITY.miss;
427
+ }
428
+
429
+ /**
430
+ * Rank a doc against a query's terms: the sum over terms of the best
431
+ * weighted field hit. Higher is a better match; `0` means every term missed.
432
+ * @param entry Doc to score.
433
+ * @param terms Lower-cased query terms.
434
+ * @returns The doc's score.
435
+ */
436
+ export function score(entry: DocEntry, terms: readonly string[]): number {
437
+ let total = 0;
438
+ for (const term of terms) {
439
+ let best = 0;
440
+ for (const [weight, values] of FIELDS) {
441
+ for (const text of values(entry)) {
442
+ best = Math.max(best, weight * quality(term, text));
443
+ }
444
+ }
445
+ total += best;
446
+ }
447
+ return total;
448
+ }
449
+
450
+ /** The ranked search result: the docs to show, and whether they are a fallback. */
451
+ export interface RankedDocs {
452
+ /** Docs to show, best match first, at most {@link MAX_RESULTS} of them. */
453
+ entries: DocEntry[];
454
+ /** True when no doc had every term, so these are the closest matches. */
455
+ closest: boolean;
456
+ /** Docs cut by the {@link MAX_RESULTS} cap. */
457
+ omitted: number;
458
+ }
459
+
460
+ /**
461
+ * Most recently changed first, then the active doc of a same-date tie, then
462
+ * path ascending; an empty date is oldest.
463
+ */
464
+ function byRecency(a: DocEntry, b: DocEntry): number {
465
+ return (
466
+ b.date.localeCompare(a.date) ||
467
+ Number(a.superseded) - Number(b.superseded) ||
468
+ a.path.localeCompare(b.path)
469
+ );
470
+ }
471
+
472
+ /**
473
+ * Cut a ranked list to {@link MAX_RESULTS}, reporting what did not fit.
474
+ * @param entries Docs in their final display order.
475
+ * @returns The shown entries and the omitted count.
476
+ */
477
+ function capped(entries: DocEntry[]): Pick<RankedDocs, "entries" | "omitted"> {
478
+ return {
479
+ entries: entries.slice(0, MAX_RESULTS),
480
+ omitted: Math.max(0, entries.length - MAX_RESULTS),
481
+ };
482
+ }
483
+
484
+ /**
485
+ * Sort by score, best first, then the active doc of a same-score tie, then
486
+ * recency, then the stable path order. Each doc is scored once into the
487
+ * decorated list — the comparator sorts on precomputed points instead of
488
+ * re-scoring every comparison.
489
+ */
490
+ function byScore(
491
+ entries: readonly DocEntry[],
492
+ terms: readonly string[],
493
+ ): DocEntry[] {
494
+ return [...entries]
495
+ .map((doc) => ({ doc, points: score(doc, terms) }))
496
+ .sort(
497
+ (a, b) =>
498
+ b.points - a.points ||
499
+ Number(a.doc.superseded) - Number(b.doc.superseded) ||
500
+ b.doc.date.localeCompare(a.doc.date) ||
501
+ a.doc.path.localeCompare(b.doc.path),
502
+ )
503
+ .map(({ doc }) => doc);
504
+ }
505
+
506
+ /**
507
+ * Rank the docs for a query. A blank query returns the most recently changed
508
+ * docs as the catalogue; a query that some doc satisfies fully returns those
509
+ * docs ranked; otherwise it falls back to the docs that match any term as a
510
+ * substring, ranked, flagged `closest`. Every outcome caps at
511
+ * {@link MAX_RESULTS} and reports the cut as `omitted`.
512
+ * @param entries Docs to rank, already path-ordered.
513
+ * @param query Raw query text.
514
+ * @returns The docs to show, the fallback flag, and the omitted count.
515
+ */
516
+ export function rankDocs(
517
+ entries: readonly DocEntry[],
518
+ query: string,
519
+ ): RankedDocs {
520
+ const terms = termsOf(query);
521
+ if (terms.length === 0) {
522
+ return { ...capped([...entries].sort(byRecency)), closest: false };
523
+ }
524
+ const full = entries.filter((entry) => matches(entry, query));
525
+ if (full.length > 0) {
526
+ return { ...capped(byScore(full, terms)), closest: false };
527
+ }
528
+ const partial = entries.filter((entry) => score(entry, terms) > 0);
529
+ return {
530
+ ...capped(byScore(partial, terms)),
531
+ closest: partial.length > 0,
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Render matched docs as `path — Area — date` lines with the summary indented
537
+ * beneath; a superseded doc carries a `(superseded)` marker. A closest-match
538
+ * fallback set is labelled; a cap cut is counted in a trailing note; nothing
539
+ * matched is stated plainly.
540
+ * @param entries Docs to render, already ranked and capped.
541
+ * @param options Render options.
542
+ * @param options.closest True when the entries are a closest-match fallback.
543
+ * @param options.omitted Docs cut by the cap, shown as a trailing note.
544
+ * @returns The tool's text result.
545
+ */
546
+ export function render(
547
+ entries: readonly DocEntry[],
548
+ options: { closest?: boolean; omitted?: number } = {},
549
+ ): string {
550
+ if (entries.length === 0) {
551
+ return "No knowledge docs matched.";
552
+ }
553
+ const body = entries
554
+ .map((entry) => {
555
+ const parts = [entry.area, entry.date].filter(
556
+ (part) => part !== "",
557
+ );
558
+ if (entry.superseded) {
559
+ parts.push("(superseded)");
560
+ }
561
+ const meta = parts.join(" — ");
562
+ const head = meta === "" ? entry.path : `${entry.path} — ${meta}`;
563
+ return entry.summary === "" ? head : `${head}\n ${entry.summary}`;
564
+ })
565
+ .join("\n");
566
+ const headed = options.closest
567
+ ? `Closest matches (no doc had every term):\n\n${body}`
568
+ : body;
569
+ const omitted = options.omitted ?? 0;
570
+ return omitted > 0
571
+ ? `${headed}\n\n… and ${omitted} more not shown.`
572
+ : headed;
573
+ }
574
+
575
+ /**
576
+ * Whether a query asks for help rather than a search: exactly `help`, trimmed
577
+ * and case-insensitive. Accepted trade-off: a doc titled or keyworded `help`
578
+ * is not reachable by that lone term (the blank catalogue still lists it).
579
+ * @param query Raw query text.
580
+ * @returns True when the call should answer with {@link explainRanking}.
581
+ */
582
+ export function isHelp(query: string): boolean {
583
+ return query.trim().toLowerCase() === "help";
584
+ }
585
+
586
+ /**
587
+ * Explain the ranking in plain text: the field weights, the hit qualities,
588
+ * the knobs, the four call outcomes, and that the shared `docs/` prefix is
589
+ * not searched. Values come from the consts, so the text tracks the dials.
590
+ * @returns The help text.
591
+ */
592
+ export function explainRanking(): string {
593
+ return [
594
+ "How docs search ranks:",
595
+ `- Fields weighted: title ${FIELD_WEIGHTS.title} > path ${FIELD_WEIGHTS.path} > keywords ${FIELD_WEIGHTS.keywords} > identifiers ${FIELD_WEIGHTS.identifiers} > area ${FIELD_WEIGHTS.area} > summary ${FIELD_WEIGHTS.summary}.`,
596
+ `- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
597
+ `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
598
+ `- Every call returns at most MAX_RESULTS (${MAX_RESULTS}), best matches first, then the most recently edited; anything cut is counted as "… and N more not shown."`,
599
+ '- Outcomes: a blank query lists the most recently changed docs; a doc carrying every term ranks the full matches; otherwise the docs matching any term as a substring are listed under "Closest matches"; nothing hits is "No knowledge docs matched."',
600
+ ].join("\n");
601
+ }
602
+
603
+ /** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `help` > `search`). */
604
+ export type DocsRequest =
605
+ | { verb: "add"; add: DocsAdd }
606
+ | { verb: "check" }
607
+ | { verb: "help" }
608
+ | { verb: "search"; search: string };
609
+
610
+ /**
611
+ * Resolve a tool call to one verb. An explicit `add` is never downgraded; a
612
+ * `check` outranks a search; a lone `help` search explains the ranking;
613
+ * anything else searches.
614
+ * @param input The raw tool input.
615
+ * @param input.search The raw search text, when given.
616
+ * @param input.add The typed add DTO, when given.
617
+ * @param input.check The check flag, when given.
618
+ * @returns The single resolved request.
619
+ */
620
+ export function resolveRequest(input: DocsInput): DocsRequest {
621
+ if (input.add !== undefined) {
622
+ return { verb: "add", add: input.add };
623
+ }
624
+ if (input.check === true) {
625
+ return { verb: "check" };
626
+ }
627
+ if (isHelp(input.search ?? "")) {
628
+ return { verb: "help" };
629
+ }
630
+ return { verb: "search", search: input.search ?? "" };
631
+ }
632
+
633
+ /**
634
+ * The newest commit date per path, in one `git log` pass over all paths.
635
+ * `git log` lists commits newest first, so the first date seen for a path is
636
+ * its latest edit; paths absent from history are absent from the map.
637
+ * @param cwd Directory inside the docs root's work tree; git resolves the
638
+ * work-tree root itself.
639
+ * @param paths Root-relative paths.
640
+ * @returns A `YYYY-MM-DD` per known path.
641
+ */
642
+ export function gitDates(
643
+ cwd: string,
644
+ paths: readonly string[],
645
+ ): Map<string, string> {
646
+ const dates = new Map<string, string>();
647
+ if (paths.length === 0) {
648
+ return dates;
649
+ }
650
+ const result = spawnSync(
651
+ "git",
652
+ ["log", "--format=%cs", "--name-only", "--", ...paths],
653
+ // The default 1MB cap silently truncates a long history's output, which
654
+ // would drop every git date back to mtime — so give it room.
655
+ { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
656
+ );
657
+ if (result.status !== 0) {
658
+ return dates;
659
+ }
660
+ let committed = "";
661
+ for (const line of (result.stdout ?? "").split("\n")) {
662
+ const trimmed = line.trim();
663
+ if (trimmed === "") {
664
+ continue;
665
+ }
666
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
667
+ committed = trimmed;
668
+ } else if (committed !== "" && !dates.has(trimmed)) {
669
+ dates.set(trimmed, committed);
670
+ }
671
+ }
672
+ return dates;
673
+ }
674
+
675
+ /**
676
+ * A date per path in one batch: git's newest commit date, else the file
677
+ * mtime, else "". One git pass for the whole tree, however many docs.
678
+ * @param cwd Directory inside the docs root's work tree.
679
+ * @param paths Root-relative paths.
680
+ * @returns A date per path.
681
+ */
682
+ export function fallbackDates(
683
+ cwd: string,
684
+ paths: readonly string[],
685
+ ): Map<string, string> {
686
+ const dates = gitDates(cwd, paths);
687
+ for (const path of paths) {
688
+ if (dates.has(path)) {
689
+ continue;
690
+ }
691
+ try {
692
+ dates.set(
693
+ path,
694
+ statSync(join(cwd, path)).mtime.toISOString().slice(0, 10),
695
+ );
696
+ } catch {
697
+ dates.set(path, "");
698
+ }
699
+ }
700
+ return dates;
701
+ }
702
+
703
+ /**
704
+ * Collect every knowledge doc under the docs root, skipping scratch and
705
+ * mirrors.
706
+ * @param root Docs root — the directory that is the knowledge tree.
707
+ * @param resolve Resolves every doc path's date in one batched call.
708
+ * @returns Entries ordered by path, or none when the root is absent.
709
+ */
710
+ export function collectDocs(
711
+ root: string,
712
+ resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
713
+ ): DocEntry[] {
714
+ let files: string[];
715
+ try {
716
+ files = readdirSync(root, {
717
+ recursive: true,
718
+ encoding: "utf8",
719
+ });
720
+ } catch {
721
+ return [];
722
+ }
723
+ const excluded = EXCLUDED_AREAS as readonly string[];
724
+ const paths = files
725
+ .filter((relativePath) => relativePath.endsWith(".md"))
726
+ .filter(
727
+ (relativePath) =>
728
+ !excluded.includes(relativePath.split("/")[0] ?? ""),
729
+ )
730
+ .sort();
731
+ const dates = resolve(paths);
732
+ return paths.map((path) =>
733
+ toEntry(
734
+ path,
735
+ readFileSync(join(root, path), "utf8"),
736
+ dates.get(path) ?? "",
737
+ ),
738
+ );
739
+ }
740
+
741
+ /**
742
+ * Kebab-case a doc title into a file-name slug: lower-cased word characters,
743
+ * everything else collapsed to single dashes, dashes trimmed.
744
+ * @param text The H1 text.
745
+ * @returns The slug, empty when no word characters remain.
746
+ */
747
+ export function slugify(text: string): string {
748
+ return text
749
+ .toLowerCase()
750
+ .replace(/[^a-z0-9]+/g, "-")
751
+ .replace(/^-+|(?<!-)-+$/g, "");
752
+ }
753
+
754
+ /** The outcome of preparing an `add`: either a write or a refusal. */
755
+ export type AddOutcome =
756
+ | {
757
+ action: "write";
758
+ /** Root-relative target. */
759
+ path: string;
760
+ /** The serialised file contents. */
761
+ contents: string;
762
+ /** Same-area docs already on disk, shown alongside the write. */
763
+ neighbours: readonly DocEntry[];
764
+ }
765
+ | {
766
+ action: "refuse";
767
+ /** Why the write was refused; renders any candidates inline. */
768
+ reason: string;
769
+ /** Same-area near-twins, when the refusal is a reconcile hit. */
770
+ candidates: readonly DocEntry[];
771
+ };
772
+
773
+ /** The guardrails' refusal prefix — the DTO is the caller's to fix. */
774
+ const REFUSE = "Rejected:";
775
+
776
+ /** The first blank required front-matter field, else null. */
777
+ function blankRequiredField(metadata: DocMetadata): string | null {
778
+ for (const { name, required } of FRONT_MATTER_FIELDS) {
779
+ if (!required) {
780
+ continue;
781
+ }
782
+ const value = metadata[name];
783
+ const blank = Array.isArray(value)
784
+ ? value.length === 0 ||
785
+ value.some((keyword) => keyword.trim() === "")
786
+ : typeof value === "string" && value.trim() === "";
787
+ if (blank) {
788
+ return name;
789
+ }
790
+ }
791
+ return null;
792
+ }
793
+
794
+ /**
795
+ * Validate an explicit `add` path: relative, inside the docs root, naming a
796
+ * `.md` file.
797
+ * @param given The trimmed, non-empty `path` the caller supplied.
798
+ * @returns The root-relative target path, or the refusal reason.
799
+ */
800
+ function explicitTarget(given: string): { path: string } | { reason: string } {
801
+ if (
802
+ given.startsWith("/") ||
803
+ given.includes("..") ||
804
+ given.includes("\\") ||
805
+ given.includes(":")
806
+ ) {
807
+ return {
808
+ reason: "path must be relative and stay inside the docs root.",
809
+ };
810
+ }
811
+ if (!given.endsWith(".md")) {
812
+ return { reason: "path must end in .md." };
813
+ }
814
+ const stem = given.slice(0, -".md".length);
815
+ if (stem === "" || stem.endsWith("/")) {
816
+ return { reason: "path must name a .md file under the docs root." };
817
+ }
818
+ return { path: given };
819
+ }
820
+
821
+ /**
822
+ * The root-relative target path for an `add`, or the reason it is refused.
823
+ * An explicit path must stay inside the docs root and name a `.md` file; an
824
+ * omitted path derives `<Area>/<slug>.md` from the H1.
825
+ * @param add The typed request.
826
+ * @returns The target path, or the refusal reason.
827
+ */
828
+ function targetOf(add: DocsAdd): { path: string } | { reason: string } {
829
+ const given = add.path?.trim() ?? "";
830
+ if (given !== "") {
831
+ return explicitTarget(given);
832
+ }
833
+ const slug = slugify(h1Of(add.body) ?? "");
834
+ if (slug === "") {
835
+ return { reason: "The H1 does not yield a usable file name." };
836
+ }
837
+ return { path: `${add.metadata.Area}/${slug}.md` };
838
+ }
839
+
840
+ /**
841
+ * The identity terms of a doc: its H1 title and Keywords joined — the words
842
+ * that must all hit a same-area doc for the two to be near-twins. The single
843
+ * source shared by `add`'s {@link reconcileQuery} and `check`'s pairwise
844
+ * report.
845
+ * @param title The doc's H1 title.
846
+ * @param keywords The doc's Keywords.
847
+ * @returns The raw identity query.
848
+ */
849
+ function identityQuery(title: string, keywords: readonly string[]): string {
850
+ return [title, ...keywords].join(" ");
851
+ }
852
+
853
+ /**
854
+ * Whether an identity query carries a term long enough to identify a doc — at
855
+ * least one term of {@link MIN_TERM_LENGTH} or longer. The one
856
+ * distinctive-identity rule, shared by `add`'s guard and `check`'s finding;
857
+ * the two differ only in the message they raise.
858
+ * @param query A doc's raw identity query ({@link identityQuery}).
859
+ * @returns True when the identity is distinctive enough to search on.
860
+ */
861
+ function hasDistinctiveIdentity(query: string): boolean {
862
+ return termsOf(query).some((term) => term.length >= MIN_TERM_LENGTH);
863
+ }
864
+
865
+ /**
866
+ * The near-twin query for an `add`: the H1 and the Keywords joined — the new
867
+ * doc's own identity terms, searched against the existing tree. The Summary is
868
+ * deliberately excluded: its filler dragged whole areas into the candidate
869
+ * list (2026-10-10 dial-in). Dial-in seam: tests read this to show exactly
870
+ * which words vote.
871
+ * @param add The typed request.
872
+ * @returns The raw reconcile query.
873
+ */
874
+ export function reconcileQuery(add: DocsAdd): string {
875
+ return identityQuery(h1Of(add.body) ?? "", add.metadata.Keywords);
876
+ }
877
+
878
+ /**
879
+ * The one definition of a near-twin (2026-10-10 review): a same-area doc that
880
+ * matches **every** identity term ({@link identityQuery} via
881
+ * {@link matches}), ranked best first and capped at {@link MAX_RESULTS}.
882
+ * `add` refuses on a non-empty result and `check` reports it pairwise — the
883
+ * same rule, never re-derived.
884
+ * @param query The raw identity query of the doc being reconciled.
885
+ * @param area The area the twins must share.
886
+ * @param entries The docs to test, already excluding the doc itself.
887
+ * @returns The ranked near-twins.
888
+ */
889
+ export function nearTwins(
890
+ query: string,
891
+ area: string,
892
+ entries: readonly DocEntry[],
893
+ ): DocEntry[] {
894
+ return byScore(
895
+ entries.filter((doc) => doc.area === area && matches(doc, query)),
896
+ termsOf(query),
897
+ ).slice(0, MAX_RESULTS);
898
+ }
899
+
900
+ /**
901
+ * Validate an `add` request and prepare it for writing — pure, no fs.
902
+ *
903
+ * Trust boundary: the caller is a model. Blank required metadata, a body
904
+ * without an H1, a path or `Area` escaping the docs root, a non-`.md` target
905
+ * and a title that yields no slug are all refused. With `path` omitted the
906
+ * target is `<Area>/<slug>.md` and a same-area near-twin — a doc matching every
907
+ * reconcile query term ({@link reconcileQuery}) — refuses the write with the
908
+ * candidates attached; an explicit `path` is the update contract (wholesale
909
+ * replacement) and skips reconcile.
910
+ * @param add The typed request.
911
+ * @param entries The current docs, for reconcile and neighbours.
912
+ * @returns The prepared write, or the refusal.
913
+ */
914
+ export function prepareAdd(
915
+ add: DocsAdd,
916
+ entries: readonly DocEntry[],
917
+ ): AddOutcome {
918
+ const refuse = (reason: string): AddOutcome => ({
919
+ action: "refuse",
920
+ reason: `${REFUSE} ${reason}`,
921
+ candidates: [],
922
+ });
923
+
924
+ const blank = blankRequiredField(add.metadata);
925
+ if (blank !== null) {
926
+ return refuse(`${blank} is required and must not be blank.`);
927
+ }
928
+ if (/[/\\]|\.\./.test(add.metadata.Area)) {
929
+ return refuse("Area must be a single folder name under the docs root.");
930
+ }
931
+ if (h1Of(add.body) === undefined) {
932
+ return refuse("The body must carry an H1 (`# Title`).");
933
+ }
934
+ if (!hasDistinctiveIdentity(reconcileQuery(add))) {
935
+ return refuse(
936
+ `the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
937
+ `(${MIN_TERM_LENGTH}) or longer; add a distinctive term so ` +
938
+ "reconcile can tell this doc apart.",
939
+ );
940
+ }
941
+ const target = targetOf(add);
942
+ if ("reason" in target) {
943
+ return refuse(target.reason);
944
+ }
945
+
946
+ if ((add.path?.trim() ?? "") === "") {
947
+ const query = reconcileQuery(add);
948
+ const nearTwinDocs = nearTwins(query, add.metadata.Area, entries);
949
+ if (nearTwinDocs.length > 0) {
950
+ return {
951
+ action: "refuse",
952
+ reason:
953
+ `${REFUSE} ${add.metadata.Area} already holds a near-twin of ` +
954
+ "this doc. Pass its path to update it, or a different path " +
955
+ "to write anyway:\n\n" +
956
+ render(nearTwinDocs),
957
+ candidates: nearTwinDocs,
958
+ };
959
+ }
960
+ }
961
+
962
+ const neighbours = entries
963
+ .filter(
964
+ (doc) => doc.area === add.metadata.Area && doc.path !== target.path,
965
+ )
966
+ .slice(0, MAX_RESULTS);
967
+ return {
968
+ action: "write",
969
+ path: target.path,
970
+ contents: serializeDoc(add.metadata, add.body),
971
+ neighbours,
972
+ };
973
+ }
974
+
975
+ /** The claim sections a knowledge doc must carry at least one of. */
976
+ export const CLAIM_SECTIONS = [
977
+ "Facts",
978
+ "Inferred",
979
+ "Tested",
980
+ "Open questions",
981
+ ] as const;
982
+
983
+ /**
984
+ * The required front-matter fields that are blank in a raw doc: `Keywords` is
985
+ * blank when it yields no terms (matching `add`'s typed rule in
986
+ * {@link blankRequiredField} — `Keywords: , ,` is blank), the others when
987
+ * missing or whitespace.
988
+ * @param meta Parsed front-matter.
989
+ * @returns The blank required field names, in canonical order.
990
+ */
991
+ function blankMetaFields(meta: Record<string, string>): string[] {
992
+ const blank = (name: string, value: string | undefined): boolean =>
993
+ name === "Keywords"
994
+ ? splitKeywords(value ?? "").length === 0
995
+ : (value ?? "").trim() === "";
996
+ return FRONT_MATTER_FIELDS.filter(
997
+ ({ name, required }) => required && blank(name, meta[name]),
998
+ ).map(({ name }) => name);
999
+ }
1000
+
1001
+ /**
1002
+ * Validate one doc's raw contents against the writing convention — pure, no
1003
+ * fs. Findings: a missing H1; blank required front-matter
1004
+ * ({@link FRONT_MATTER_FIELDS}); a hand-maintained `Date` (derived by git,
1005
+ * never written); none of the {@link CLAIM_SECTIONS} as an H2/H3 heading; and
1006
+ * the short-identity guard — an H1 and Keywords that between them carry no
1007
+ * term at {@link MIN_TERM_LENGTH} or longer. Near-twins are pairwise across
1008
+ * docs, so they are reported by {@link checkDocs}, not here.
1009
+ * @param path Repo-relative doc path (title fallback when the H1 is missing).
1010
+ * @param raw Full file contents.
1011
+ * @returns The findings; empty means the doc complies.
1012
+ */
1013
+ export function validateDoc(path: string, raw: string): string[] {
1014
+ const { meta, body } = parseFrontMatter(raw);
1015
+ const findings: string[] = [];
1016
+ if (h1Of(body) === undefined) {
1017
+ findings.push("no H1 (`# Title`).");
1018
+ }
1019
+ for (const name of blankMetaFields(meta)) {
1020
+ findings.push(`${name} is required and must not be blank.`);
1021
+ }
1022
+ if (meta.Date !== undefined) {
1023
+ findings.push(
1024
+ "hand-maintained `Date` — the date is derived by git, never written.",
1025
+ );
1026
+ }
1027
+ const claims = CLAIM_SECTIONS.map((section) => section.toLowerCase());
1028
+ if (
1029
+ !scanBody(body).headings.some((h) => claims.includes(h.toLowerCase()))
1030
+ ) {
1031
+ findings.push(
1032
+ "no claim section (Facts / Inferred / Tested / Open questions).",
1033
+ );
1034
+ }
1035
+ const query = identityQuery(
1036
+ titleOf(path, body),
1037
+ splitKeywords(meta.Keywords ?? ""),
1038
+ );
1039
+ if (!hasDistinctiveIdentity(query)) {
1040
+ findings.push(
1041
+ `the H1 and Keywords carry no term of MIN_TERM_LENGTH ` +
1042
+ `(${MIN_TERM_LENGTH}) or longer.`,
1043
+ );
1044
+ }
1045
+ return findings;
1046
+ }
1047
+
1048
+ /**
1049
+ * Every near-twin pair in the tree, one entry per pair even when both
1050
+ * directions of the {@link nearTwins} rule fire.
1051
+ * @param entries The docs to test.
1052
+ * @returns Path-ordered `[doc, twin]` pairs.
1053
+ */
1054
+ function nearTwinPairs(
1055
+ entries: readonly DocEntry[],
1056
+ ): Array<[DocEntry, DocEntry]> {
1057
+ const pairs = new Map<string, [DocEntry, DocEntry]>();
1058
+ for (const doc of entries) {
1059
+ const query = identityQuery(doc.title, doc.keywords);
1060
+ const others = entries.filter((other) => other.path !== doc.path);
1061
+ for (const twin of nearTwins(query, doc.area, others)) {
1062
+ const key = [doc.path, twin.path].sort().join("\n");
1063
+ if (!pairs.has(key)) {
1064
+ pairs.set(key, [doc, twin]);
1065
+ }
1066
+ }
1067
+ }
1068
+ return [...pairs.values()];
1069
+ }
1070
+
1071
+ /**
1072
+ * Check the whole tree: per-doc {@link validateDoc} findings, a
1073
+ * `Supersedes` that names no doc, then the {@link nearTwinPairs}. An empty
1074
+ * report means compliant.
1075
+ * @param entries The docs to check.
1076
+ * @param read Resolves a root-relative doc path to its raw contents;
1077
+ * injected so the check is testable.
1078
+ * @param notes Environment notes (e.g. an unversioned root) appended to the
1079
+ * report; findings they describe are still reported per doc.
1080
+ * @returns The report text.
1081
+ */
1082
+ export function checkDocs(
1083
+ entries: readonly DocEntry[],
1084
+ read: (path: string) => string,
1085
+ notes: readonly string[] = [],
1086
+ ): string {
1087
+ const paths = new Set(entries.map((entry) => entry.path));
1088
+ const lines = entries.flatMap((entry) => {
1089
+ const raw = read(entry.path);
1090
+ const findings = validateDoc(entry.path, raw);
1091
+ const supersedes = parseFrontMatter(raw).meta.Supersedes?.trim() ?? "";
1092
+ if (supersedes !== "" && !paths.has(supersedes)) {
1093
+ findings.push(`Supersedes names no doc: ${supersedes}.`);
1094
+ }
1095
+ return findings.map((finding) => `${entry.path}: ${finding}`);
1096
+ });
1097
+ for (const [doc, twin] of nearTwinPairs(entries)) {
1098
+ lines.push(
1099
+ `${doc.path} and ${twin.path}: near-twins — reconcile ` +
1100
+ "(merge, or repoint one).",
1101
+ );
1102
+ }
1103
+ lines.push(...notes);
1104
+ return lines.length === 0
1105
+ ? `All ${entries.length} docs comply.`
1106
+ : lines.join("\n");
1107
+ }
1108
+
1109
+ export interface DocsToolDeps {
1110
+ /** Collects the docs to serve; injected so the tool is testable. */
1111
+ collect: () => DocEntry[];
1112
+ /** Whether a root-relative doc path exists; injected so writes are testable. */
1113
+ exists: (path: string) => boolean;
1114
+ /** Writes a root-relative doc path; injected so writes are testable. */
1115
+ write: (path: string, contents: string) => void;
1116
+ /** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
1117
+ read: (path: string) => string;
1118
+ /** Environment notes appended to the `check` report; injected for testability. */
1119
+ notes?: readonly string[];
1120
+ }
1121
+
1122
+ /** Delivers a rendered result to a session. */
1123
+ export type PromptSender = (
1124
+ sessionID: CommandInvocation["sessionID"],
1125
+ text: string,
1126
+ ) => Promise<unknown>;
1127
+
1128
+ export interface DocsCommandDeps {
1129
+ /** Collects the docs to serve; injected so the command is testable. */
1130
+ collect: () => DocEntry[];
1131
+ /** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
1132
+ read: (path: string) => string;
1133
+ /** Environment notes appended to the `check` report; injected for testability. */
1134
+ notes?: readonly string[];
1135
+ /** Delivers the rendered result to the session. */
1136
+ prompt: PromptSender;
1137
+ }
1138
+
1139
+ /**
1140
+ * Build the `docs` tool from its dependencies.
1141
+ *
1142
+ * Registered with `codemode: false` so the tool stays on the provider's native
1143
+ * tool list. That option is exclusive: a tool defaults into Code Mode
1144
+ * (`codemode` defaults true) and is dropped from the native list; only
1145
+ * `codemode: false` keeps it natively callable. This tool is called directly,
1146
+ * so it opts out.
1147
+ * @param deps Runtime seams used on each call: the docs to serve, and the
1148
+ * write seam {@link DocsToolDeps} for `add`.
1149
+ * @returns The tool the host registers.
1150
+ */
1151
+ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1152
+ const addDoc = (add: DocsAdd) => {
1153
+ const outcome = prepareAdd(add, deps.collect());
1154
+ if (outcome.action === "refuse") {
1155
+ return { content: outcome.reason, metadata: { written: false } };
1156
+ }
1157
+ const updated = deps.exists(outcome.path);
1158
+ deps.write(outcome.path, outcome.contents);
1159
+ const head = `${outcome.path} — ${updated ? "updated" : "created"}.`;
1160
+ const neighbours =
1161
+ outcome.neighbours.length > 0
1162
+ ? `\n\nNearby docs in the same area:\n\n${render(outcome.neighbours)}`
1163
+ : "";
1164
+ return {
1165
+ content: `${head}${neighbours}`,
1166
+ metadata: { written: true },
1167
+ };
1168
+ };
1169
+ return {
1170
+ name: "docs",
1171
+ description:
1172
+ "Discover the knowledge docs under the configured docs root " +
1173
+ "(default: the location's `docs/` directory). A blank search lists the " +
1174
+ MAX_RESULTS +
1175
+ " most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area and summary, falling back to the closest matches. Every call returns at most " +
1176
+ MAX_RESULTS +
1177
+ " results and counts the rest. Search exactly `help` explains the ranking. `add` writes a new knowledge doc (or wholesale-replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank required fields, path traversal, non-.md targets and same-area near-twins are refused. `check` validates every doc against the convention (required front-matter, H1, claim sections, no hand-maintained Date, distinctive H1+Keywords, a Supersedes naming a doc in the tree, near-twins) and reports findings; an empty report means compliant. Read a matched file with the read tool.",
1178
+ input: DOCS_INPUT,
1179
+ options: { codemode: false },
1180
+ async execute(input) {
1181
+ const request = resolveRequest(input);
1182
+ if (request.verb === "add") {
1183
+ return addDoc(request.add);
1184
+ }
1185
+ if (request.verb === "check") {
1186
+ const entries = deps.collect();
1187
+ return {
1188
+ content: checkDocs(entries, deps.read, deps.notes ?? []),
1189
+ metadata: { checked: entries.length },
1190
+ };
1191
+ }
1192
+ if (request.verb === "help") {
1193
+ return {
1194
+ content: explainRanking(),
1195
+ metadata: { count: 0, omitted: 0 },
1196
+ };
1197
+ }
1198
+ const { entries, closest, omitted } = rankDocs(
1199
+ deps.collect(),
1200
+ request.search,
1201
+ );
1202
+ return {
1203
+ content: render(entries, { closest, omitted }),
1204
+ metadata: { count: entries.length, omitted },
1205
+ };
1206
+ },
1207
+ };
1208
+ }
1209
+
1210
+ /** `/docs` verbs: `search <text>` is explicit; bare text searches too. */
1211
+ const SEARCH_VERB = "search";
1212
+ const ADD_VERB = "add";
1213
+ const CHECK_VERB = "check";
1214
+
1215
+ /**
1216
+ * The prompt delivered by `/docs add <text>`: ask the agent to call the tool's
1217
+ * `add` with a typed DTO rather than searching. An empty body is named as the
1218
+ * mistake it is.
1219
+ * @param subject Text after the `add` verb.
1220
+ * @returns The prompt to deliver to the session.
1221
+ */
1222
+ function addPrompt(subject: string): string {
1223
+ if (subject === "") {
1224
+ return "`/docs add` needs the text to record: `/docs add <fact>`.";
1225
+ }
1226
+ return (
1227
+ "Record this as durable knowledge in the docs root: call the `docs` " +
1228
+ "tool's `add` with a typed DTO (metadata Area/Keywords/Summary, body starting " +
1229
+ "with an H1) for:\n\n" +
1230
+ subject
1231
+ );
1232
+ }
1233
+
1234
+ /**
1235
+ * Build the `/docs` command over the same core as the tool: a search ranks the
1236
+ * catalogue (`search <text>`, or bare text), `add <text>` asks the agent to
1237
+ * write the knowledge through the tool's typed `add`, and `check` validates
1238
+ * the tree against the convention.
1239
+ * @param deps Runtime seams used on each call.
1240
+ * @returns The command the host registers.
1241
+ */
1242
+ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1243
+ return {
1244
+ name: "docs",
1245
+ description:
1246
+ "Search the knowledge docs under the configured docs root, ranked; `add <text>` records new knowledge through the tool's typed `add`; `check` validates the convention; a blank search lists the " +
1247
+ MAX_RESULTS +
1248
+ " most recently changed.",
1249
+ async execute({ sessionID, prompt }) {
1250
+ const text = prompt.text.trim();
1251
+ if (isHelp(text)) {
1252
+ await deps.prompt(sessionID, explainRanking());
1253
+ return;
1254
+ }
1255
+ const [verb, ...rest] = text.split(/\s+/);
1256
+ const lower = verb?.toLowerCase();
1257
+ if (lower === ADD_VERB) {
1258
+ await deps.prompt(sessionID, addPrompt(rest.join(" ")));
1259
+ return;
1260
+ }
1261
+ if (lower === CHECK_VERB) {
1262
+ await deps.prompt(
1263
+ sessionID,
1264
+ checkDocs(deps.collect(), deps.read, deps.notes ?? []),
1265
+ );
1266
+ return;
1267
+ }
1268
+ const query = lower === SEARCH_VERB ? rest.join(" ") : text;
1269
+ const { entries, closest, omitted } = rankDocs(
1270
+ deps.collect(),
1271
+ query,
1272
+ );
1273
+ await deps.prompt(sessionID, render(entries, { closest, omitted }));
1274
+ },
1275
+ };
1276
+ }
1277
+
1278
+ /**
1279
+ * Resolve the plugin's docs root from its `root` option. Unset → the
1280
+ * location's `docs/` directory; `~` or `~/…` expands against the home
1281
+ * directory; absolute paths pass through; anything else resolves against the
1282
+ * plugin location. The resolved value is the knowledge tree itself — no
1283
+ * segment is appended. A non-string or blank value throws: a mistyped option
1284
+ * must fail loudly, not silently fall back to the wrong tree.
1285
+ * @param configured The raw option value as the host delivers it.
1286
+ * @param locationDirectory The directory the plugin instance loaded in.
1287
+ * @returns The docs root.
1288
+ */
1289
+ export function resolveRoot(
1290
+ configured: unknown,
1291
+ locationDirectory: string,
1292
+ ): string {
1293
+ if (configured === undefined) {
1294
+ return join(locationDirectory, "docs");
1295
+ }
1296
+ if (typeof configured !== "string" || configured.trim() === "") {
1297
+ throw new Error(
1298
+ `docs plugin option 'root' must be a non-empty string path, ` +
1299
+ `got ${JSON.stringify(configured)}.`,
1300
+ );
1301
+ }
1302
+ const given = configured.trim();
1303
+ const expanded =
1304
+ given === "~" || given.startsWith("~/")
1305
+ ? join(homedir(), given.slice(1))
1306
+ : given;
1307
+ return isAbsolute(expanded) ? expanded : join(locationDirectory, expanded);
1308
+ }
1309
+
1310
+ /**
1311
+ * Probe whether the docs root sits inside a git work tree, as `check`-report
1312
+ * notes. An unversioned root still works — dates fall back to file mtimes and
1313
+ * the knowledge has no history — so it is surfaced, not refused.
1314
+ * @param root The docs root to probe.
1315
+ * @returns The note lines; empty when the root is version-controlled.
1316
+ */
1317
+ export function unversionedNote(root: string): readonly string[] {
1318
+ const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
1319
+ cwd: root,
1320
+ });
1321
+ if (probe.status === 0) {
1322
+ return [];
1323
+ }
1324
+ return [
1325
+ "note: the docs root is not inside a git work tree — dates fall " +
1326
+ "back to file mtimes and the knowledge has no history. Put the " +
1327
+ "root under version control to fix both.",
1328
+ ];
1329
+ }
1330
+
1331
+ /**
1332
+ * The docs plugin's options, as the host delivers them. The framework types
1333
+ * `ctx.options` as `Readonly<Record<string, any>>`, so the shape the plugin
1334
+ * reads is declared here.
1335
+ */
1336
+ interface DocsPluginOptions {
1337
+ /** The docs root override; defaults to `docs` beside the plugin. */
1338
+ root?: unknown;
1339
+ }
1340
+
1341
+ export default Plugin.define({
1342
+ id: "docs",
1343
+ async setup(ctx) {
1344
+ const options = ctx.options as DocsPluginOptions | undefined;
1345
+ const root = resolveRoot(options?.root, ctx.location.directory);
1346
+ const notes = unversionedNote(root);
1347
+ const collect = () =>
1348
+ collectDocs(root, (paths) => fallbackDates(root, paths));
1349
+ const read = (path: string) => readFileSync(join(root, path), "utf8");
1350
+ await ctx.tool.transform((editor) => {
1351
+ editor.add(
1352
+ createDocsTool({
1353
+ collect,
1354
+ exists: (path) => existsSync(join(root, path)),
1355
+ write: (path, contents) => {
1356
+ const target = join(root, path);
1357
+ mkdirSync(dirname(target), { recursive: true });
1358
+ writeFileSync(target, contents);
1359
+ },
1360
+ read,
1361
+ notes,
1362
+ }),
1363
+ );
1364
+ });
1365
+ await ctx.command.transform((editor) => {
1366
+ editor.add(
1367
+ createDocsCommand({
1368
+ collect,
1369
+ read,
1370
+ notes,
1371
+ prompt: (sessionID, text) =>
1372
+ ctx.session.prompt({ sessionID, text }),
1373
+ }),
1374
+ );
1375
+ });
1376
+ // The skill is the doctrine's runtime face. Its content ships next to
1377
+ // this module and is resolved through `import.meta.url`, so it works
1378
+ // whether the plugin is loaded from a local path or an installed
1379
+ // package.
1380
+ const skillURL = new URL("SKILL.md", import.meta.url);
1381
+ const skill = readFileSync(skillURL, "utf8");
1382
+ await ctx.skill.transform((editor) => {
1383
+ editor.add({
1384
+ id: Skill.ID.make("docs"),
1385
+ name: Skill.Name.make("Docs"),
1386
+ description:
1387
+ "The entry point to the knowledge docs under the " +
1388
+ "configured docs root: find the right file with the docs " +
1389
+ "tool, read it, and record plans and task documentation " +
1390
+ "for the next human or agent. Use at the start of " +
1391
+ "unfamiliar work, when hunting for prior decisions or " +
1392
+ "traps, and when closing out a session.",
1393
+ path: AbsolutePath.make(fileURLToPath(skillURL)),
1394
+ content: skill,
1395
+ });
1396
+ });
1397
+ },
1398
+ });