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