@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/CONVENTION.md +59 -0
- package/LICENSE +21 -0
- package/README.md +41 -2
- package/package.json +40 -5
- package/src/SKILL.md +86 -0
- package/src/index.ts +1330 -0
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
|
+
});
|