@markstanden/opencode-docs-plugin 0.4.0 → 0.6.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 +106 -22
- package/README.md +146 -34
- package/package.json +2 -2
- package/src/SKILL.md +61 -87
- package/src/index.ts +1942 -353
package/src/index.ts
CHANGED
|
@@ -9,14 +9,24 @@ import { spawnSync } from "node:child_process";
|
|
|
9
9
|
import {
|
|
10
10
|
type Dirent,
|
|
11
11
|
existsSync,
|
|
12
|
+
lstatSync,
|
|
12
13
|
mkdirSync,
|
|
13
14
|
readFileSync,
|
|
14
15
|
readdirSync,
|
|
16
|
+
realpathSync,
|
|
15
17
|
statSync,
|
|
16
18
|
writeFileSync,
|
|
17
19
|
} from "node:fs";
|
|
18
20
|
import { homedir } from "node:os";
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
basename,
|
|
23
|
+
dirname,
|
|
24
|
+
isAbsolute,
|
|
25
|
+
join,
|
|
26
|
+
normalize,
|
|
27
|
+
relative,
|
|
28
|
+
sep,
|
|
29
|
+
} from "node:path";
|
|
20
30
|
import { fileURLToPath } from "node:url";
|
|
21
31
|
import { Schema } from "effect";
|
|
22
32
|
|
|
@@ -24,36 +34,50 @@ import { Schema } from "effect";
|
|
|
24
34
|
* V2 docs plugin.
|
|
25
35
|
*
|
|
26
36
|
* Registers a model-callable `docs` tool so the agent can discover the
|
|
27
|
-
* knowledge
|
|
28
|
-
* default `<location>/docs`): a blank search
|
|
29
|
-
* docs (`path — Area — date — Summary`), a
|
|
30
|
-
* how well their front-matter, path and body
|
|
31
|
-
* to the closest matches rather than a dead
|
|
32
|
-
* `check` validates the
|
|
33
|
-
* `read
|
|
37
|
+
* knowledge lakes mid-task. The lakes are configurable (`lakes`, `lakesFile`,
|
|
38
|
+
* or the single-lake `root` sugar; default `<location>/docs`): a blank search
|
|
39
|
+
* returns the most recently changed docs (`path — Area — date — Summary`), a
|
|
40
|
+
* search ranks the docs by how well their front-matter, path and body
|
|
41
|
+
* identifiers match, falling back to the closest matches rather than a dead
|
|
42
|
+
* end. `add` writes knowledge, `check` validates the knowledge lakes. Reading
|
|
43
|
+
* a matched doc stays the built-in `read` for project files; the tool's own
|
|
44
|
+
* `read` serves lake docs and wiki pages.
|
|
34
45
|
*
|
|
35
46
|
* Everything below the default export is pure or dependency-injected so it can
|
|
36
47
|
* be exercised directly against Node's built-in test runner without OpenCode.
|
|
37
48
|
*/
|
|
38
49
|
|
|
50
|
+
/** The doc classes the plugin serves. `instructions` is the implicit README source. */
|
|
51
|
+
export type DocClass = "instructions" | "repo" | "machine" | "global" | "wiki";
|
|
52
|
+
|
|
53
|
+
/** The classes that can be a configured lake. */
|
|
54
|
+
export type LakeClass = Exclude<DocClass, "instructions">;
|
|
55
|
+
|
|
56
|
+
/** The two writing conventions a lake can use. */
|
|
57
|
+
export type LakeProfile = "knowledge" | "wiki";
|
|
58
|
+
|
|
39
59
|
/** One knowledge doc, as the tool sees it. */
|
|
40
60
|
export interface DocEntry {
|
|
41
61
|
/** Root-relative path, e.g. `decisions/choose-a-queue.md`. */
|
|
42
62
|
path: string;
|
|
63
|
+
/** The lake name; empty for a project README (the instructions source). */
|
|
64
|
+
lake: string;
|
|
65
|
+
/** The source class. */
|
|
66
|
+
class: DocClass;
|
|
43
67
|
/** Front-matter `Area`, else the first path segment under the root. */
|
|
44
68
|
area: string;
|
|
45
69
|
/** Resolved at read time: last git commit, else file mtime. Never front-matter. */
|
|
46
70
|
date: string;
|
|
47
71
|
/** First `# ` heading in the body, else the file name. */
|
|
48
72
|
title: string;
|
|
49
|
-
/** Front-matter `Summary`, else
|
|
73
|
+
/** Front-matter `Summary`, else the wiki's first prose paragraph, else "". */
|
|
50
74
|
summary: string;
|
|
51
|
-
/** Front-matter `Status: superseded` — the doc is marked as replaced. */
|
|
52
|
-
superseded: boolean;
|
|
53
75
|
/** Front-matter `Keywords`, split on commas and trimmed. */
|
|
54
76
|
keywords: readonly string[];
|
|
55
77
|
/** Body H2/H3 headings plus inline `code` spans, trimmed and de-duplicated. */
|
|
56
78
|
identifiers: readonly string[];
|
|
79
|
+
/** Wiki prose and table text; empty for knowledge docs and READMEs. */
|
|
80
|
+
text: string;
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
/**
|
|
@@ -65,7 +89,13 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
65
89
|
search: Schema.optional(
|
|
66
90
|
Schema.String.annotate({
|
|
67
91
|
description:
|
|
68
|
-
"Case-insensitive search over title, path, keywords,
|
|
92
|
+
"Case-insensitive search over title, path, keywords, headings, code spans, area, summary and wiki prose. Blank lists the most recently changed docs; a partial match returns the closest.",
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
lake: Schema.optional(
|
|
96
|
+
Schema.String.annotate({
|
|
97
|
+
description:
|
|
98
|
+
"Optional lake name: search and check are limited to it, read and add target it. Omit to search every lake, read the unique match, or add to the first writable repo lake.",
|
|
69
99
|
}),
|
|
70
100
|
),
|
|
71
101
|
add: Schema.optional(
|
|
@@ -73,43 +103,36 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
73
103
|
path: Schema.optional(
|
|
74
104
|
Schema.String.annotate({
|
|
75
105
|
description:
|
|
76
|
-
"Update target, e.g. `guides/getting-started.md
|
|
106
|
+
"Update target, e.g. `guides/getting-started.md`, relative to the lake root: replaces that doc wholesale, so omitted body content is lost, and skips the near-twin reconcile. Omit to create `<Area>/<slug>.md` from the H1.",
|
|
77
107
|
}),
|
|
78
108
|
),
|
|
79
109
|
metadata: Schema.Struct({
|
|
80
110
|
Area: Schema.String,
|
|
81
111
|
Keywords: Schema.Array(Schema.String),
|
|
82
112
|
Summary: Schema.String,
|
|
83
|
-
Status: Schema.optional(
|
|
84
|
-
Schema.Union([
|
|
85
|
-
Schema.Literal("active"),
|
|
86
|
-
Schema.Literal("superseded"),
|
|
87
|
-
]),
|
|
88
|
-
),
|
|
89
|
-
Supersedes: Schema.optional(Schema.String),
|
|
90
113
|
}).annotate({
|
|
91
114
|
description:
|
|
92
|
-
"Front-matter metadata; serialised, never copied. All three
|
|
115
|
+
"Front-matter metadata; serialised, never copied. All three are required and must not be blank.",
|
|
93
116
|
}),
|
|
94
117
|
body: Schema.String.annotate({
|
|
95
118
|
description:
|
|
96
|
-
"Markdown body: an H1,
|
|
119
|
+
"Markdown body: an H1 title, plus at least one claim section as an H2/H3 — Facts, Inferred, Tested, Failed approaches, Open questions.",
|
|
97
120
|
}),
|
|
98
121
|
}).annotate({
|
|
99
122
|
description:
|
|
100
|
-
"The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`.
|
|
123
|
+
"The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`. Refused: blank metadata, path traversal, a non-.md or symlinked target, no claim section, and a same-area near-twin when `path` is omitted. Wiki lakes are read-only.",
|
|
101
124
|
}),
|
|
102
125
|
),
|
|
103
126
|
check: Schema.optional(
|
|
104
127
|
Schema.Boolean.annotate({
|
|
105
128
|
description:
|
|
106
|
-
"Validate every doc
|
|
129
|
+
"Validate every knowledge doc: front-matter, an H1, a claim section, no hand-maintained Date, a distinctive H1+Keywords identity, near-twins, broken relative links, a soft length note. Wiki lakes and READMEs are not validated.",
|
|
107
130
|
}),
|
|
108
131
|
),
|
|
109
132
|
read: Schema.optional(
|
|
110
133
|
Schema.String.annotate({
|
|
111
134
|
description:
|
|
112
|
-
"A root-relative doc path
|
|
135
|
+
"A root-relative doc path, or `lake:path`: returns the doc verbatim, front-matter and body, so find-then-read is one call. Lake docs and wiki pages only; project READMEs use the built-in `read` tool.",
|
|
113
136
|
}),
|
|
114
137
|
),
|
|
115
138
|
});
|
|
@@ -174,8 +197,6 @@ export const FRONT_MATTER_FIELDS = [
|
|
|
174
197
|
{ name: "Area", required: true },
|
|
175
198
|
{ name: "Keywords", required: true },
|
|
176
199
|
{ name: "Summary", required: true },
|
|
177
|
-
{ name: "Status", required: false },
|
|
178
|
-
{ name: "Supersedes", required: false },
|
|
179
200
|
] as const;
|
|
180
201
|
|
|
181
202
|
/**
|
|
@@ -187,13 +208,10 @@ export const FRONT_MATTER_FIELDS = [
|
|
|
187
208
|
* @returns The full file contents to write.
|
|
188
209
|
*/
|
|
189
210
|
export function serializeDoc(metadata: DocMetadata, body: string): string {
|
|
190
|
-
const lines = FRONT_MATTER_FIELDS.
|
|
211
|
+
const lines = FRONT_MATTER_FIELDS.map(({ name }) => {
|
|
191
212
|
const value = metadata[name];
|
|
192
|
-
if (value === undefined) {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
195
213
|
const text = Array.isArray(value) ? value.join(", ") : value;
|
|
196
|
-
return
|
|
214
|
+
return `${name}: ${text}`;
|
|
197
215
|
});
|
|
198
216
|
return `---\n${lines.join("\n")}\n---\n\n${body.replace(/(?<!\n)\n+$/, "")}\n`;
|
|
199
217
|
}
|
|
@@ -304,27 +322,156 @@ export function identifiersOf(body: string): string[] {
|
|
|
304
322
|
return [...new Set([...headings, ...spans])];
|
|
305
323
|
}
|
|
306
324
|
|
|
325
|
+
/** The text inside inline links, dropping the URL; manual scan keeps it linear. */
|
|
326
|
+
function stripInlineLinks(text: string): string {
|
|
327
|
+
let result = "";
|
|
328
|
+
let index = 0;
|
|
329
|
+
while (index < text.length) {
|
|
330
|
+
if (text[index] === "[" && text[index - 1] !== "!") {
|
|
331
|
+
const close = text.indexOf("](", index + 1);
|
|
332
|
+
if (close !== -1) {
|
|
333
|
+
const end = text.indexOf(")", close + 2);
|
|
334
|
+
if (end !== -1) {
|
|
335
|
+
result += text.slice(index + 1, close);
|
|
336
|
+
index = end + 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
result += text[index];
|
|
342
|
+
index += 1;
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Strip inline Markdown from a prose fragment: links keep their text, code keeps its content. */
|
|
348
|
+
function plainProse(text: string): string {
|
|
349
|
+
return stripInlineLinks(text)
|
|
350
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
351
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
352
|
+
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1")
|
|
353
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
354
|
+
.replace(/(?<!_)_([^_]+)_(?!_)/g, "$1")
|
|
355
|
+
.replace(/\s+/g, " ")
|
|
356
|
+
.trim();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The body with fenced code blocks removed. */
|
|
360
|
+
function withoutFences(body: string): string {
|
|
361
|
+
const lines: string[] = [];
|
|
362
|
+
let fenced = false;
|
|
363
|
+
for (const line of body.split("\n")) {
|
|
364
|
+
if (isFence(line)) {
|
|
365
|
+
fenced = !fenced;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (!fenced) {
|
|
369
|
+
lines.push(line);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return lines.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Block prefixes that are never a wiki summary. */
|
|
376
|
+
const SUMMARY_SKIP_PREFIXES = ["#", "|", ">", "<", "---", ":::", "!["] as const;
|
|
377
|
+
|
|
378
|
+
/** Whether a blank-line-separated block is prose worth summarising. */
|
|
379
|
+
function isSummaryBlock(block: string): boolean {
|
|
380
|
+
const trimmed = block.trim();
|
|
381
|
+
if (trimmed === "" || trimmed.startsWith("[![")) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
if (SUMMARY_SKIP_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
if (/^([-*+]|\d+\.)\s/.test(trimmed)) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
return plainProse(trimmed) !== "";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The wiki summary: the first prose paragraph after the H1, capped. */
|
|
394
|
+
export function summaryOf(body: string): string {
|
|
395
|
+
const text = withoutFences(body);
|
|
396
|
+
const heading = text.indexOf("# ");
|
|
397
|
+
if (heading === -1) {
|
|
398
|
+
return "";
|
|
399
|
+
}
|
|
400
|
+
const lineEnd = text.indexOf("\n", heading);
|
|
401
|
+
const after = lineEnd === -1 ? "" : text.slice(lineEnd + 1);
|
|
402
|
+
for (const block of after.split(/\n\s*\n/)) {
|
|
403
|
+
if (isSummaryBlock(block)) {
|
|
404
|
+
return plainProse(block).slice(0, 240);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return "";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The indexable wiki prose: the whole body, front-matter already stripped. */
|
|
411
|
+
export function proseOf(body: string): string {
|
|
412
|
+
return body;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The doc's area: front-matter `Area`, else the first path segment. */
|
|
416
|
+
function areaOf(meta: Record<string, string>, path: string): string {
|
|
417
|
+
if (meta.Area !== undefined) {
|
|
418
|
+
return meta.Area;
|
|
419
|
+
}
|
|
420
|
+
const segments = path.split("/");
|
|
421
|
+
return segments.length > 1 ? (segments[0] ?? "") : "";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The doc's summary: front-matter, else a derived wiki paragraph, else "". */
|
|
425
|
+
function summaryFor(
|
|
426
|
+
meta: Record<string, string>,
|
|
427
|
+
body: string,
|
|
428
|
+
profile: LakeProfile,
|
|
429
|
+
): string {
|
|
430
|
+
if (meta.Summary !== undefined) {
|
|
431
|
+
return meta.Summary;
|
|
432
|
+
}
|
|
433
|
+
return profile === "wiki" ? summaryOf(body) : "";
|
|
434
|
+
}
|
|
435
|
+
|
|
307
436
|
/**
|
|
308
437
|
* Build a doc entry from its path, raw contents and the date to show.
|
|
309
438
|
* @param path Repo-relative doc path.
|
|
310
439
|
* @param raw Full file contents.
|
|
311
440
|
* @param date The resolved date (last git commit, else file mtime). Front-matter
|
|
312
441
|
* `Date` is deliberately ignored — the date is derived, never hand-maintained.
|
|
442
|
+
* @param options The source lake/class and the profile's indexing rules.
|
|
443
|
+
* @param options.lake The lake name stamped on the entry.
|
|
444
|
+
* @param options.class The source class stamped on the entry.
|
|
445
|
+
* @param options.profile The writing convention; `wiki` derives prose and summary.
|
|
313
446
|
* @returns The entry the tool ranks and renders.
|
|
314
447
|
*/
|
|
315
|
-
export function toEntry(
|
|
448
|
+
export function toEntry(
|
|
449
|
+
path: string,
|
|
450
|
+
raw: string,
|
|
451
|
+
date: string,
|
|
452
|
+
options: {
|
|
453
|
+
lake?: string;
|
|
454
|
+
class?: DocClass;
|
|
455
|
+
profile?: LakeProfile;
|
|
456
|
+
} = {},
|
|
457
|
+
): DocEntry {
|
|
458
|
+
const {
|
|
459
|
+
lake = "",
|
|
460
|
+
class: docClass = "repo",
|
|
461
|
+
profile = "knowledge",
|
|
462
|
+
} = options;
|
|
316
463
|
const { meta, body } = parseFrontMatter(raw);
|
|
317
|
-
const segments = path.split("/");
|
|
318
|
-
const area = meta.Area ?? (segments.length > 1 ? (segments[0] ?? "") : "");
|
|
319
464
|
return {
|
|
320
465
|
path,
|
|
321
|
-
|
|
466
|
+
lake,
|
|
467
|
+
class: docClass,
|
|
468
|
+
area: areaOf(meta, path),
|
|
322
469
|
date,
|
|
323
470
|
title: titleOf(path, body),
|
|
324
|
-
summary: meta
|
|
325
|
-
superseded: meta.Status?.trim().toLowerCase() === "superseded",
|
|
471
|
+
summary: summaryFor(meta, body, profile),
|
|
326
472
|
keywords: splitKeywords(meta.Keywords ?? ""),
|
|
327
473
|
identifiers: identifiersOf(body),
|
|
474
|
+
text: profile === "wiki" ? proseOf(body) : "",
|
|
328
475
|
};
|
|
329
476
|
}
|
|
330
477
|
|
|
@@ -374,6 +521,7 @@ export function matches(entry: DocEntry, query: string): boolean {
|
|
|
374
521
|
entry.area,
|
|
375
522
|
entry.title,
|
|
376
523
|
entry.summary,
|
|
524
|
+
entry.text,
|
|
377
525
|
...entry.keywords,
|
|
378
526
|
...entry.identifiers,
|
|
379
527
|
]
|
|
@@ -395,6 +543,7 @@ export const FIELD_WEIGHTS = {
|
|
|
395
543
|
identifiers: 3,
|
|
396
544
|
area: 2,
|
|
397
545
|
summary: 1,
|
|
546
|
+
text: 1,
|
|
398
547
|
} as const;
|
|
399
548
|
|
|
400
549
|
/** The weighted searchable fields, weights from {@link FIELD_WEIGHTS}. */
|
|
@@ -407,6 +556,7 @@ const FIELDS: ReadonlyArray<
|
|
|
407
556
|
[FIELD_WEIGHTS.identifiers, (entry) => entry.identifiers],
|
|
408
557
|
[FIELD_WEIGHTS.area, (entry) => [entry.area]],
|
|
409
558
|
[FIELD_WEIGHTS.summary, (entry) => [entry.summary]],
|
|
559
|
+
[FIELD_WEIGHTS.text, (entry) => [entry.text]],
|
|
410
560
|
];
|
|
411
561
|
|
|
412
562
|
/**
|
|
@@ -465,13 +615,37 @@ export interface RankedDocs {
|
|
|
465
615
|
}
|
|
466
616
|
|
|
467
617
|
/**
|
|
468
|
-
*
|
|
469
|
-
*
|
|
618
|
+
* Class precedence when scores tie. Narrow, local, vetted first.
|
|
619
|
+
* The single source of the order — dial here.
|
|
620
|
+
*/
|
|
621
|
+
export const CLASS_RANK: Readonly<Record<DocClass, number>> = {
|
|
622
|
+
repo: 0,
|
|
623
|
+
instructions: 1,
|
|
624
|
+
global: 2,
|
|
625
|
+
machine: 3,
|
|
626
|
+
wiki: 4,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* How many results each source reserves in the first pass, so a broad wiki
|
|
631
|
+
* cannot bury a focused repo doc. A reservation, not a cap: unused slots are
|
|
632
|
+
* filled by score. `0` disables reservation. The single knob — tune here.
|
|
633
|
+
*/
|
|
634
|
+
export const DEFAULT_PER_LAKE_QUOTA = 3;
|
|
635
|
+
|
|
636
|
+
/** The reservation key: a lake name, or the implicit instructions source. */
|
|
637
|
+
function sourceKey(entry: DocEntry): string {
|
|
638
|
+
return entry.lake === "" ? "instructions" : entry.lake;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Most recently changed first, then class precedence, then path ascending;
|
|
643
|
+
* an empty date is oldest.
|
|
470
644
|
*/
|
|
471
645
|
function byRecency(a: DocEntry, b: DocEntry): number {
|
|
472
646
|
return (
|
|
473
647
|
b.date.localeCompare(a.date) ||
|
|
474
|
-
|
|
648
|
+
CLASS_RANK[a.class] - CLASS_RANK[b.class] ||
|
|
475
649
|
a.path.localeCompare(b.path)
|
|
476
650
|
);
|
|
477
651
|
}
|
|
@@ -489,10 +663,41 @@ function capped(entries: DocEntry[]): Pick<RankedDocs, "entries" | "omitted"> {
|
|
|
489
663
|
}
|
|
490
664
|
|
|
491
665
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
666
|
+
* Fill the result cap with a per-source reservation first, then the best
|
|
667
|
+
* leftovers. This keeps every configured source visible without hard-capping a
|
|
668
|
+
* single-lake tree.
|
|
669
|
+
* @param entries Docs in ranked order.
|
|
670
|
+
* @param quota Results reserved per source; `0` disables reservation.
|
|
671
|
+
* @returns The shown entries and the omitted count.
|
|
672
|
+
*/
|
|
673
|
+
function reserved(
|
|
674
|
+
entries: DocEntry[],
|
|
675
|
+
quota: number,
|
|
676
|
+
): Pick<RankedDocs, "entries" | "omitted"> {
|
|
677
|
+
if (quota <= 0) {
|
|
678
|
+
return capped(entries);
|
|
679
|
+
}
|
|
680
|
+
const first: DocEntry[] = [];
|
|
681
|
+
const rest: DocEntry[] = [];
|
|
682
|
+
const counts = new Map<string, number>();
|
|
683
|
+
for (const entry of entries) {
|
|
684
|
+
const key = sourceKey(entry);
|
|
685
|
+
const seen = counts.get(key) ?? 0;
|
|
686
|
+
if (seen < quota) {
|
|
687
|
+
counts.set(key, seen + 1);
|
|
688
|
+
first.push(entry);
|
|
689
|
+
} else {
|
|
690
|
+
rest.push(entry);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return capped([...first, ...rest]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Sort by score, best first, then class precedence, then recency, then the
|
|
698
|
+
* stable path order. Each doc is scored once into the decorated list — the
|
|
699
|
+
* comparator sorts on precomputed points instead of re-scoring every
|
|
700
|
+
* comparison.
|
|
496
701
|
*/
|
|
497
702
|
function byScore(
|
|
498
703
|
entries: readonly DocEntry[],
|
|
@@ -503,7 +708,7 @@ function byScore(
|
|
|
503
708
|
.sort(
|
|
504
709
|
(a, b) =>
|
|
505
710
|
b.points - a.points ||
|
|
506
|
-
|
|
711
|
+
CLASS_RANK[a.doc.class] - CLASS_RANK[b.doc.class] ||
|
|
507
712
|
b.doc.date.localeCompare(a.doc.date) ||
|
|
508
713
|
a.doc.path.localeCompare(b.doc.path),
|
|
509
714
|
)
|
|
@@ -514,45 +719,58 @@ function byScore(
|
|
|
514
719
|
* Rank the docs for a query. A blank query returns the most recently changed
|
|
515
720
|
* docs as the catalogue; a query that some doc satisfies fully returns those
|
|
516
721
|
* docs ranked; otherwise it falls back to the docs that match any term as a
|
|
517
|
-
* substring, ranked, flagged `closest`. Every outcome
|
|
722
|
+
* substring, ranked, flagged `closest`. Every outcome reserves
|
|
723
|
+
* {@link DEFAULT_PER_LAKE_QUOTA} results per source, caps at
|
|
518
724
|
* {@link MAX_RESULTS} and reports the cut as `omitted`.
|
|
519
725
|
* @param entries Docs to rank, already path-ordered.
|
|
520
726
|
* @param query Raw query text.
|
|
727
|
+
* @param options Ranking options.
|
|
728
|
+
* @param options.perLakeQuota Results reserved per source; `0` disables.
|
|
521
729
|
* @returns The docs to show, the fallback flag, and the omitted count.
|
|
522
730
|
*/
|
|
523
731
|
export function rankDocs(
|
|
524
732
|
entries: readonly DocEntry[],
|
|
525
733
|
query: string,
|
|
734
|
+
options: { perLakeQuota?: number } = {},
|
|
526
735
|
): RankedDocs {
|
|
736
|
+
const quota = options.perLakeQuota ?? DEFAULT_PER_LAKE_QUOTA;
|
|
527
737
|
const terms = termsOf(query);
|
|
528
738
|
if (terms.length === 0) {
|
|
529
|
-
return {
|
|
739
|
+
return {
|
|
740
|
+
...reserved([...entries].sort(byRecency), quota),
|
|
741
|
+
closest: false,
|
|
742
|
+
};
|
|
530
743
|
}
|
|
531
744
|
const full = entries.filter((entry) => matches(entry, query));
|
|
532
745
|
if (full.length > 0) {
|
|
533
|
-
return { ...
|
|
746
|
+
return { ...reserved(byScore(full, terms), quota), closest: false };
|
|
534
747
|
}
|
|
535
748
|
const partial = entries.filter((entry) => score(entry, terms) > 0);
|
|
536
749
|
return {
|
|
537
|
-
...
|
|
750
|
+
...reserved(byScore(partial, terms), quota),
|
|
538
751
|
closest: partial.length > 0,
|
|
539
752
|
};
|
|
540
753
|
}
|
|
541
754
|
|
|
542
755
|
/**
|
|
543
756
|
* Render matched docs as `path — Area — date` lines with the summary indented
|
|
544
|
-
* beneath; a
|
|
545
|
-
* fallback set is labelled; a cap cut is counted in a trailing
|
|
546
|
-
* matched is stated plainly.
|
|
757
|
+
* beneath; a non-primary lake is prefixed `lake:` so provenance is clear. A
|
|
758
|
+
* closest-match fallback set is labelled; a cap cut is counted in a trailing
|
|
759
|
+
* note; nothing matched is stated plainly.
|
|
547
760
|
* @param entries Docs to render, already ranked and capped.
|
|
548
761
|
* @param options Render options.
|
|
549
762
|
* @param options.closest True when the entries are a closest-match fallback.
|
|
550
763
|
* @param options.omitted Docs cut by the cap, shown as a trailing note.
|
|
764
|
+
* @param options.primaryLake The lake rendered without a prefix (the local repo).
|
|
551
765
|
* @returns The tool's text result.
|
|
552
766
|
*/
|
|
553
767
|
export function render(
|
|
554
768
|
entries: readonly DocEntry[],
|
|
555
|
-
options: {
|
|
769
|
+
options: {
|
|
770
|
+
closest?: boolean;
|
|
771
|
+
omitted?: number;
|
|
772
|
+
primaryLake?: string;
|
|
773
|
+
} = {},
|
|
556
774
|
): string {
|
|
557
775
|
if (entries.length === 0) {
|
|
558
776
|
return "No knowledge docs matched.";
|
|
@@ -562,12 +780,17 @@ export function render(
|
|
|
562
780
|
const parts = [entry.area, entry.date].filter(
|
|
563
781
|
(part) => part !== "",
|
|
564
782
|
);
|
|
565
|
-
if (entry.superseded) {
|
|
566
|
-
parts.push("(superseded)");
|
|
567
|
-
}
|
|
568
783
|
const meta = parts.join(" — ");
|
|
569
784
|
const head = meta === "" ? entry.path : `${entry.path} — ${meta}`;
|
|
570
|
-
|
|
785
|
+
const labelled =
|
|
786
|
+
entry.class === "instructions"
|
|
787
|
+
? `instructions:${head}`
|
|
788
|
+
: entry.lake !== "" && entry.lake !== options.primaryLake
|
|
789
|
+
? `${entry.lake}:${head}`
|
|
790
|
+
: head;
|
|
791
|
+
return entry.summary === ""
|
|
792
|
+
? labelled
|
|
793
|
+
: `${labelled}\n ${entry.summary}`;
|
|
571
794
|
})
|
|
572
795
|
.join("\n");
|
|
573
796
|
const headed = options.closest
|
|
@@ -599,22 +822,77 @@ export function isHelp(query: string): boolean {
|
|
|
599
822
|
export function explainRanking(): string {
|
|
600
823
|
return [
|
|
601
824
|
"How docs search ranks:",
|
|
602
|
-
`- 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}.`,
|
|
825
|
+
`- 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} > wiki prose ${FIELD_WEIGHTS.text}.`,
|
|
603
826
|
`- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
|
|
604
827
|
`- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
|
|
605
|
-
"- Search
|
|
606
|
-
`- 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."`,
|
|
828
|
+
"- Search covers every configured lake and nested project `README.md` files, honouring `.gitignore` (dependency and build directories are skipped when the project is not in git). READMEs are searchable only: `add` and `check` ignore them, and their results are prefixed `instructions:` so they read with the built-in `read` tool.",
|
|
829
|
+
`- Each source reserves up to ${DEFAULT_PER_LAKE_QUOTA} results (configurable), then leftover slots fill by score. 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."`,
|
|
830
|
+
"- Class precedence on a tie: repo > instructions > global > machine > wiki.",
|
|
607
831
|
'- 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."',
|
|
608
832
|
].join("\n");
|
|
609
833
|
}
|
|
610
834
|
|
|
835
|
+
/** One configured lake, as the help text describes it. */
|
|
836
|
+
export interface LakeInfo {
|
|
837
|
+
/** The lake's addressing name. */
|
|
838
|
+
name: string;
|
|
839
|
+
/** The source class. */
|
|
840
|
+
class: DocClass;
|
|
841
|
+
/** The absolute lake root. */
|
|
842
|
+
root: string;
|
|
843
|
+
/** Whether `add` may write to it. */
|
|
844
|
+
writable: boolean;
|
|
845
|
+
/** Whether `check` validates it. */
|
|
846
|
+
validated: boolean;
|
|
847
|
+
/** Whether the root is absent, so the lake contributes nothing yet. */
|
|
848
|
+
missing?: boolean;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Describe the configured lakes, then the ranking. The agent learns where
|
|
853
|
+
* knowledge lives without loading the full convention.
|
|
854
|
+
* @param lakes The configured lakes.
|
|
855
|
+
* @returns The help text.
|
|
856
|
+
*/
|
|
857
|
+
export function helpText(lakes: readonly LakeInfo[]): string {
|
|
858
|
+
const lines = ["Configured lakes:"];
|
|
859
|
+
for (const lake of lakes) {
|
|
860
|
+
const access = lake.writable ? "writable" : "read-only";
|
|
861
|
+
const checked = lake.validated ? "checked" : "not checked";
|
|
862
|
+
const missing = lake.missing === true ? " — missing" : "";
|
|
863
|
+
lines.push(
|
|
864
|
+
`- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}${missing}`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
lines.push(
|
|
868
|
+
"- instructions (read-only, not checked): nested project README.md " +
|
|
869
|
+
"files; results are prefixed `instructions:` and read with the " +
|
|
870
|
+
"built-in read tool",
|
|
871
|
+
);
|
|
872
|
+
return `${lines.join("\n")}\n\n${explainRanking()}`;
|
|
873
|
+
}
|
|
874
|
+
|
|
611
875
|
/** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `read` > `help` > `search`). */
|
|
612
876
|
export type DocsRequest =
|
|
613
|
-
| { verb: "add"; add: DocsAdd }
|
|
614
|
-
| { verb: "check" }
|
|
615
|
-
| { verb: "read"; read: string }
|
|
877
|
+
| { verb: "add"; add: DocsAdd; lake?: string }
|
|
878
|
+
| { verb: "check"; lake?: string }
|
|
879
|
+
| { verb: "read"; read: string; lake?: string }
|
|
616
880
|
| { verb: "help" }
|
|
617
|
-
| { verb: "search"; search: string };
|
|
881
|
+
| { verb: "search"; search: string; lake?: string };
|
|
882
|
+
|
|
883
|
+
/** The trimmed lake selector, or undefined when unset. */
|
|
884
|
+
function lakeOf(input: DocsInput): string | undefined {
|
|
885
|
+
const lake = input.lake?.trim() ?? "";
|
|
886
|
+
return lake === "" ? undefined : lake;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Add a lake selector to a request when one was given. */
|
|
890
|
+
function requestWithLake<T extends object>(
|
|
891
|
+
request: T,
|
|
892
|
+
lake: string | undefined,
|
|
893
|
+
): T {
|
|
894
|
+
return lake === undefined ? request : ({ ...request, lake } as T);
|
|
895
|
+
}
|
|
618
896
|
|
|
619
897
|
/**
|
|
620
898
|
* Resolve a tool call to one verb. An explicit `add` is never downgraded; a
|
|
@@ -624,31 +902,36 @@ export type DocsRequest =
|
|
|
624
902
|
* @param input.search The raw search text, when given.
|
|
625
903
|
* @param input.add The typed add DTO, when given.
|
|
626
904
|
* @param input.check The check flag, when given.
|
|
627
|
-
* @param input.read The root-relative doc path to read, when given.
|
|
905
|
+
* @param input.read The root-relative doc path (or `lake:path`) to read, when given.
|
|
906
|
+
* @param input.lake The optional lake selector.
|
|
628
907
|
* @returns The single resolved request.
|
|
629
908
|
*/
|
|
630
909
|
export function resolveRequest(input: DocsInput): DocsRequest {
|
|
910
|
+
const lake = lakeOf(input);
|
|
631
911
|
if (input.add !== undefined) {
|
|
632
|
-
return { verb: "add", add: input.add };
|
|
912
|
+
return requestWithLake({ verb: "add", add: input.add }, lake);
|
|
633
913
|
}
|
|
634
914
|
if (input.check === true) {
|
|
635
|
-
return { verb: "check" };
|
|
915
|
+
return requestWithLake({ verb: "check" }, lake);
|
|
636
916
|
}
|
|
637
917
|
const read = input.read?.trim() ?? "";
|
|
638
918
|
if (read !== "") {
|
|
639
|
-
return { verb: "read", read };
|
|
919
|
+
return requestWithLake({ verb: "read", read }, lake);
|
|
640
920
|
}
|
|
641
921
|
if (isHelp(input.search ?? "")) {
|
|
642
922
|
return { verb: "help" };
|
|
643
923
|
}
|
|
644
|
-
return
|
|
924
|
+
return requestWithLake(
|
|
925
|
+
{ verb: "search", search: input.search ?? "" },
|
|
926
|
+
lake,
|
|
927
|
+
);
|
|
645
928
|
}
|
|
646
929
|
|
|
647
930
|
/**
|
|
648
931
|
* The newest commit date per path, in one `git log` pass over all paths.
|
|
649
932
|
* `git log` lists commits newest first, so the first date seen for a path is
|
|
650
933
|
* its latest edit; paths absent from history are absent from the map.
|
|
651
|
-
* @param cwd Directory inside the
|
|
934
|
+
* @param cwd Directory inside the lake root's work tree; git resolves the
|
|
652
935
|
* work-tree root itself.
|
|
653
936
|
* @param paths Root-relative paths.
|
|
654
937
|
* @returns A `YYYY-MM-DD` per known path.
|
|
@@ -663,7 +946,10 @@ export function gitDates(
|
|
|
663
946
|
}
|
|
664
947
|
const result = spawnSync(
|
|
665
948
|
"git",
|
|
666
|
-
|
|
949
|
+
// `--relative` keeps the output paths root-relative to `cwd`; without
|
|
950
|
+
// it a lake rooted at `<repo>/docs` maps `docs/foo.md` while the
|
|
951
|
+
// requested paths are `foo.md`, silently falling back to mtimes.
|
|
952
|
+
["log", "--relative", "--format=%cs", "--name-only", "--", ...paths],
|
|
667
953
|
// The default 1MB cap silently truncates a long history's output, which
|
|
668
954
|
// would drop every git date back to mtime — so give it room.
|
|
669
955
|
{ cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
|
@@ -689,7 +975,7 @@ export function gitDates(
|
|
|
689
975
|
/**
|
|
690
976
|
* A date per path in one batch: git's newest commit date, else the file
|
|
691
977
|
* mtime, else "". One git pass for the whole tree, however many docs.
|
|
692
|
-
* @param cwd Directory inside the
|
|
978
|
+
* @param cwd Directory inside the lake root's work tree.
|
|
693
979
|
* @param paths Root-relative paths.
|
|
694
980
|
* @returns A date per path.
|
|
695
981
|
*/
|
|
@@ -714,40 +1000,145 @@ export function fallbackDates(
|
|
|
714
1000
|
return dates;
|
|
715
1001
|
}
|
|
716
1002
|
|
|
1003
|
+
/** How a lake's tree is collected. */
|
|
1004
|
+
export interface CollectOptions {
|
|
1005
|
+
/** The writing convention: knowledge excludes scratch and mirrors; wiki does not. */
|
|
1006
|
+
profile?: LakeProfile;
|
|
1007
|
+
/** The lake name stamped on each entry. */
|
|
1008
|
+
lake?: string;
|
|
1009
|
+
/** The class stamped on each entry. */
|
|
1010
|
+
class?: DocClass;
|
|
1011
|
+
/** Absolute roots to skip (another lake nested inside this one). */
|
|
1012
|
+
skip?: readonly string[];
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Walk a lake root, returning its `.md` paths relative to the root. Dot
|
|
1017
|
+
* directories are skipped (which drops `.git` and `.attachments`), as are
|
|
1018
|
+
* symlinked directories and any other configured lake root nested inside.
|
|
1019
|
+
* Knowledge lakes also skip the scratch and mirror areas.
|
|
1020
|
+
* @param root The lake root.
|
|
1021
|
+
* @param options Collection rules.
|
|
1022
|
+
* @returns Root-relative paths, ordered by path.
|
|
1023
|
+
*/
|
|
1024
|
+
/** Read a directory, returning no entries when it is unreadable. */
|
|
1025
|
+
function readDirectory(directory: string): Dirent[] {
|
|
1026
|
+
try {
|
|
1027
|
+
return readdirSync(directory, { withFileTypes: true });
|
|
1028
|
+
} catch {
|
|
1029
|
+
return [];
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
717
1033
|
/**
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
* @param
|
|
1034
|
+
* Whether a directory is outside the lake's collection rules.
|
|
1035
|
+
* @param item The directory entry.
|
|
1036
|
+
* @param directory Its parent directory.
|
|
1037
|
+
* @param root The lake root.
|
|
1038
|
+
* @param excluded Top-level areas the profile excludes.
|
|
1039
|
+
* @param nested Other lake roots nested inside this one.
|
|
1040
|
+
* @returns True when the walk should not enter it.
|
|
1041
|
+
*/
|
|
1042
|
+
function skipDirectory(
|
|
1043
|
+
item: Dirent,
|
|
1044
|
+
directory: string,
|
|
1045
|
+
root: string,
|
|
1046
|
+
excluded: readonly string[],
|
|
1047
|
+
nested: readonly string[],
|
|
1048
|
+
): boolean {
|
|
1049
|
+
if (item.name.startsWith(".") || item.name === "node_modules") {
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
if (item.isSymbolicLink()) {
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
const path = join(directory, item.name);
|
|
1056
|
+
if (nested.some((other) => isUnder(path, other))) {
|
|
1057
|
+
return true;
|
|
1058
|
+
}
|
|
1059
|
+
return directory === root && excluded.includes(item.name);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Walk a lake root, returning its `.md` paths relative to the root. Dot
|
|
1064
|
+
* directories are skipped (which drops `.git` and `.attachments`), as are
|
|
1065
|
+
* symlinked directories, `node_modules` and any other configured lake root
|
|
1066
|
+
* nested inside. Knowledge lakes also skip the scratch and mirror areas.
|
|
1067
|
+
* @param root The lake root.
|
|
1068
|
+
* @param options Collection rules.
|
|
1069
|
+
* @param options.profile The writing convention.
|
|
1070
|
+
* @param options.lake The lake name (unused by the path walk).
|
|
1071
|
+
* @param options.class The source class (unused by the path walk).
|
|
1072
|
+
* @param options.skip Absolute roots of other lakes nested inside this one.
|
|
1073
|
+
* @returns Root-relative paths, ordered by path.
|
|
1074
|
+
*/
|
|
1075
|
+
export function collectDocPaths(
|
|
1076
|
+
root: string,
|
|
1077
|
+
options: CollectOptions = {},
|
|
1078
|
+
): string[] {
|
|
1079
|
+
const profile = options.profile ?? "knowledge";
|
|
1080
|
+
const skip = options.skip ?? [];
|
|
1081
|
+
const nested = skip.filter(
|
|
1082
|
+
(other) => other !== root && isUnder(other, root),
|
|
1083
|
+
);
|
|
1084
|
+
const excluded =
|
|
1085
|
+
profile === "knowledge" ? (EXCLUDED_AREAS as readonly string[]) : [];
|
|
1086
|
+
const paths: string[] = [];
|
|
1087
|
+
const walk = (directory: string): void => {
|
|
1088
|
+
for (const item of readDirectory(directory)) {
|
|
1089
|
+
const path = join(directory, item.name);
|
|
1090
|
+
if (item.isDirectory()) {
|
|
1091
|
+
if (!skipDirectory(item, directory, root, excluded, nested)) {
|
|
1092
|
+
walk(path);
|
|
1093
|
+
}
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (
|
|
1097
|
+
!item.isFile() ||
|
|
1098
|
+
item.isSymbolicLink() ||
|
|
1099
|
+
!item.name.endsWith(".md")
|
|
1100
|
+
) {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
paths.push(relative(root, path).split(sep).join("/"));
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
if (existsSync(root)) {
|
|
1107
|
+
walk(root);
|
|
1108
|
+
}
|
|
1109
|
+
return paths.sort();
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Collect every doc under a lake root, honouring the profile's exclusions and
|
|
1114
|
+
* the other lakes nested inside it.
|
|
1115
|
+
* @param root Lake root — the directory that is the tree.
|
|
721
1116
|
* @param resolve Resolves every doc path's date in one batched call.
|
|
1117
|
+
* @param options Collection rules.
|
|
1118
|
+
* @param options.profile The writing convention.
|
|
1119
|
+
* @param options.lake The lake name stamped on each entry.
|
|
1120
|
+
* @param options.class The source class stamped on each entry.
|
|
1121
|
+
* @param options.skip Absolute roots of other lakes nested inside this one.
|
|
722
1122
|
* @returns Entries ordered by path, or none when the root is absent.
|
|
723
1123
|
*/
|
|
724
1124
|
export function collectDocs(
|
|
725
1125
|
root: string,
|
|
726
1126
|
resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
|
|
1127
|
+
options: CollectOptions = {},
|
|
727
1128
|
): DocEntry[] {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
files = readdirSync(root, {
|
|
731
|
-
recursive: true,
|
|
732
|
-
encoding: "utf8",
|
|
733
|
-
});
|
|
734
|
-
} catch {
|
|
735
|
-
return [];
|
|
736
|
-
}
|
|
737
|
-
const excluded = EXCLUDED_AREAS as readonly string[];
|
|
738
|
-
const paths = files
|
|
739
|
-
.filter((relativePath) => relativePath.endsWith(".md"))
|
|
740
|
-
.filter(
|
|
741
|
-
(relativePath) =>
|
|
742
|
-
!excluded.includes(relativePath.split("/")[0] ?? ""),
|
|
743
|
-
)
|
|
744
|
-
.sort();
|
|
1129
|
+
const profile = options.profile ?? "knowledge";
|
|
1130
|
+
const paths = collectDocPaths(root, options);
|
|
745
1131
|
const dates = resolve(paths);
|
|
746
1132
|
return paths.map((path) =>
|
|
747
1133
|
toEntry(
|
|
748
1134
|
path,
|
|
749
1135
|
readFileSync(join(root, path), "utf8"),
|
|
750
1136
|
dates.get(path) ?? "",
|
|
1137
|
+
{
|
|
1138
|
+
lake: options.lake ?? "",
|
|
1139
|
+
class: options.class ?? (profile === "wiki" ? "wiki" : "repo"),
|
|
1140
|
+
profile,
|
|
1141
|
+
},
|
|
751
1142
|
),
|
|
752
1143
|
);
|
|
753
1144
|
}
|
|
@@ -780,6 +1171,30 @@ function isUnder(path: string, root: string): boolean {
|
|
|
780
1171
|
);
|
|
781
1172
|
}
|
|
782
1173
|
|
|
1174
|
+
/**
|
|
1175
|
+
* Resolve symlinks in the existing prefix of a path, appending any missing
|
|
1176
|
+
* suffix. Used to compare lake roots by their real filesystem location.
|
|
1177
|
+
* @param path The path to resolve.
|
|
1178
|
+
* @returns The real path, or a normalised path when it cannot be resolved.
|
|
1179
|
+
*/
|
|
1180
|
+
function realPathOf(path: string): string {
|
|
1181
|
+
let current = path;
|
|
1182
|
+
const missing: string[] = [];
|
|
1183
|
+
while (!existsSync(current)) {
|
|
1184
|
+
const parent = dirname(current);
|
|
1185
|
+
if (parent === current) {
|
|
1186
|
+
return normalize(path);
|
|
1187
|
+
}
|
|
1188
|
+
missing.unshift(basename(current));
|
|
1189
|
+
current = parent;
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
return join(realpathSync(current), ...missing);
|
|
1193
|
+
} catch {
|
|
1194
|
+
return normalize(path);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
783
1198
|
/** Whether a file name is a README in any case (`README.md`, `readme.md`). */
|
|
784
1199
|
function isReadme(name: string): boolean {
|
|
785
1200
|
return name.toLowerCase() === "readme.md";
|
|
@@ -860,12 +1275,16 @@ function walkedReadmePaths(location: string): string[] {
|
|
|
860
1275
|
* @param location The project directory the plugin loaded in.
|
|
861
1276
|
* @param resolve Resolves every path's date in one batched call.
|
|
862
1277
|
* @param skip Absolute roots whose READMEs are collected elsewhere.
|
|
1278
|
+
* @param options The source class/lake stamped on each entry.
|
|
1279
|
+
* @param options.class The source class; defaults to `instructions`.
|
|
1280
|
+
* @param options.lake The lake name; empty for the implicit instructions source.
|
|
863
1281
|
* @returns Entries ordered by path, or none when the project is unreadable.
|
|
864
1282
|
*/
|
|
865
1283
|
export function collectReadmes(
|
|
866
1284
|
location: string,
|
|
867
1285
|
resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
|
|
868
1286
|
skip: readonly string[] = [],
|
|
1287
|
+
options: { class?: DocClass; lake?: string } = {},
|
|
869
1288
|
): DocEntry[] {
|
|
870
1289
|
const paths = readmePaths(location, skip);
|
|
871
1290
|
const dates = resolve(paths);
|
|
@@ -874,6 +1293,10 @@ export function collectReadmes(
|
|
|
874
1293
|
path,
|
|
875
1294
|
readFileSync(join(location, path), "utf8"),
|
|
876
1295
|
dates.get(path) ?? "",
|
|
1296
|
+
{
|
|
1297
|
+
lake: options.lake ?? "",
|
|
1298
|
+
class: options.class ?? "instructions",
|
|
1299
|
+
},
|
|
877
1300
|
),
|
|
878
1301
|
);
|
|
879
1302
|
}
|
|
@@ -899,8 +1322,14 @@ export type AddOutcome =
|
|
|
899
1322
|
path: string;
|
|
900
1323
|
/** The serialised file contents. */
|
|
901
1324
|
contents: string;
|
|
902
|
-
/**
|
|
1325
|
+
/**
|
|
1326
|
+
* Same-area docs already on disk. Not rendered in the tool result —
|
|
1327
|
+
* the success path names the path and outcome only — but retained for
|
|
1328
|
+
* programmatic callers that want the surrounding context.
|
|
1329
|
+
*/
|
|
903
1330
|
neighbours: readonly DocEntry[];
|
|
1331
|
+
/** Soft advisory lines (e.g. a long doc worth splitting). */
|
|
1332
|
+
notes: readonly string[];
|
|
904
1333
|
}
|
|
905
1334
|
| {
|
|
906
1335
|
action: "refuse";
|
|
@@ -915,10 +1344,7 @@ const REFUSE = "Rejected:";
|
|
|
915
1344
|
|
|
916
1345
|
/** The first blank required front-matter field, else null. */
|
|
917
1346
|
function blankRequiredField(metadata: DocMetadata): string | null {
|
|
918
|
-
for (const { name
|
|
919
|
-
if (!required) {
|
|
920
|
-
continue;
|
|
921
|
-
}
|
|
1347
|
+
for (const { name } of FRONT_MATTER_FIELDS) {
|
|
922
1348
|
const value = metadata[name];
|
|
923
1349
|
const blank = Array.isArray(value)
|
|
924
1350
|
? value.length === 0 ||
|
|
@@ -932,7 +1358,7 @@ function blankRequiredField(metadata: DocMetadata): string | null {
|
|
|
932
1358
|
}
|
|
933
1359
|
|
|
934
1360
|
/**
|
|
935
|
-
* Validate an explicit `add` path: relative, inside the
|
|
1361
|
+
* Validate an explicit `add` path: relative, inside the lake root, naming a
|
|
936
1362
|
* `.md` file.
|
|
937
1363
|
* @param given The trimmed, non-empty `path` the caller supplied.
|
|
938
1364
|
* @returns The root-relative target path, or the refusal reason.
|
|
@@ -945,7 +1371,7 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
|
|
|
945
1371
|
given.includes(":")
|
|
946
1372
|
) {
|
|
947
1373
|
return {
|
|
948
|
-
reason: "path must be relative and stay inside the
|
|
1374
|
+
reason: "path must be relative and stay inside the lake root.",
|
|
949
1375
|
};
|
|
950
1376
|
}
|
|
951
1377
|
if (!given.endsWith(".md")) {
|
|
@@ -953,14 +1379,14 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
|
|
|
953
1379
|
}
|
|
954
1380
|
const stem = given.slice(0, -".md".length);
|
|
955
1381
|
if (stem === "" || stem.endsWith("/")) {
|
|
956
|
-
return { reason: "path must name a .md file under the
|
|
1382
|
+
return { reason: "path must name a .md file under the lake root." };
|
|
957
1383
|
}
|
|
958
1384
|
return { path: given };
|
|
959
1385
|
}
|
|
960
1386
|
|
|
961
1387
|
/**
|
|
962
1388
|
* The root-relative target path for an `add`, or the reason it is refused.
|
|
963
|
-
* An explicit path must stay inside the
|
|
1389
|
+
* An explicit path must stay inside the lake root and name a `.md` file; an
|
|
964
1390
|
* omitted path derives `<Area>/<slug>.md` from the H1.
|
|
965
1391
|
* @param add The typed request.
|
|
966
1392
|
* @returns The target path, or the refusal reason.
|
|
@@ -1037,11 +1463,44 @@ export function nearTwins(
|
|
|
1037
1463
|
).slice(0, MAX_RESULTS);
|
|
1038
1464
|
}
|
|
1039
1465
|
|
|
1466
|
+
/**
|
|
1467
|
+
* The near-twin refusal for an `add`, or null when the write may proceed.
|
|
1468
|
+
* An explicit path is the update contract and skips reconcile.
|
|
1469
|
+
* @param add The typed request.
|
|
1470
|
+
* @param entries The current docs.
|
|
1471
|
+
* @returns The refusal, or null.
|
|
1472
|
+
*/
|
|
1473
|
+
function nearTwinRefusal(
|
|
1474
|
+
add: DocsAdd,
|
|
1475
|
+
entries: readonly DocEntry[],
|
|
1476
|
+
): AddOutcome | null {
|
|
1477
|
+
if ((add.path?.trim() ?? "") !== "") {
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
const nearTwinDocs = nearTwins(
|
|
1481
|
+
reconcileQuery(add),
|
|
1482
|
+
add.metadata.Area,
|
|
1483
|
+
entries,
|
|
1484
|
+
);
|
|
1485
|
+
if (nearTwinDocs.length === 0) {
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
return {
|
|
1489
|
+
action: "refuse",
|
|
1490
|
+
reason:
|
|
1491
|
+
`${REFUSE} ${add.metadata.Area} already holds a near-twin of ` +
|
|
1492
|
+
"this doc. Pass its path to update it, or a different path " +
|
|
1493
|
+
"to write anyway:\n\n" +
|
|
1494
|
+
render(nearTwinDocs),
|
|
1495
|
+
candidates: nearTwinDocs,
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1040
1499
|
/**
|
|
1041
1500
|
* Validate an `add` request and prepare it for writing — pure, no fs.
|
|
1042
1501
|
*
|
|
1043
1502
|
* Trust boundary: the caller is a model. Blank required metadata, a body
|
|
1044
|
-
* without an H1, a path or `Area` escaping the
|
|
1503
|
+
* without an H1, a path or `Area` escaping the lake root, a non-`.md` target
|
|
1045
1504
|
* and a title that yields no slug are all refused. With `path` omitted the
|
|
1046
1505
|
* target is `<Area>/<slug>.md` and a same-area near-twin — a doc matching every
|
|
1047
1506
|
* reconcile query term ({@link reconcileQuery}) — refuses the write with the
|
|
@@ -1049,11 +1508,14 @@ export function nearTwins(
|
|
|
1049
1508
|
* replacement) and skips reconcile.
|
|
1050
1509
|
* @param add The typed request.
|
|
1051
1510
|
* @param entries The current docs, for reconcile and neighbours.
|
|
1511
|
+
* @param options Soft-limit configuration.
|
|
1512
|
+
* @param options.maxDocLines Note when the body exceeds this line count; `0` disables.
|
|
1052
1513
|
* @returns The prepared write, or the refusal.
|
|
1053
1514
|
*/
|
|
1054
1515
|
export function prepareAdd(
|
|
1055
1516
|
add: DocsAdd,
|
|
1056
1517
|
entries: readonly DocEntry[],
|
|
1518
|
+
options: { maxDocLines?: number } = {},
|
|
1057
1519
|
): AddOutcome {
|
|
1058
1520
|
const refuse = (reason: string): AddOutcome => ({
|
|
1059
1521
|
action: "refuse",
|
|
@@ -1066,11 +1528,19 @@ export function prepareAdd(
|
|
|
1066
1528
|
return refuse(`${blank} is required and must not be blank.`);
|
|
1067
1529
|
}
|
|
1068
1530
|
if (/[/\\]|\.\./.test(add.metadata.Area)) {
|
|
1069
|
-
return refuse("Area must be a single folder name under the
|
|
1531
|
+
return refuse("Area must be a single folder name under the lake root.");
|
|
1070
1532
|
}
|
|
1071
1533
|
if (h1Of(add.body) === undefined) {
|
|
1072
1534
|
return refuse("The body must carry an H1 (`# Title`).");
|
|
1073
1535
|
}
|
|
1536
|
+
// The same rule `check` applies, enforced before anything touches disk: a
|
|
1537
|
+
// doc that `check` would flag on the next run should never be written.
|
|
1538
|
+
if (!hasClaimSection(add.body)) {
|
|
1539
|
+
return refuse(
|
|
1540
|
+
"the body carries no claim section; add an H2/H3 headed Facts, " +
|
|
1541
|
+
"Inferred, Tested, Failed approaches or Open questions.",
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1074
1544
|
if (!hasDistinctiveIdentity(reconcileQuery(add))) {
|
|
1075
1545
|
return refuse(
|
|
1076
1546
|
`the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
|
|
@@ -1083,20 +1553,9 @@ export function prepareAdd(
|
|
|
1083
1553
|
return refuse(target.reason);
|
|
1084
1554
|
}
|
|
1085
1555
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
if (nearTwinDocs.length > 0) {
|
|
1090
|
-
return {
|
|
1091
|
-
action: "refuse",
|
|
1092
|
-
reason:
|
|
1093
|
-
`${REFUSE} ${add.metadata.Area} already holds a near-twin of ` +
|
|
1094
|
-
"this doc. Pass its path to update it, or a different path " +
|
|
1095
|
-
"to write anyway:\n\n" +
|
|
1096
|
-
render(nearTwinDocs),
|
|
1097
|
-
candidates: nearTwinDocs,
|
|
1098
|
-
};
|
|
1099
|
-
}
|
|
1556
|
+
const nearTwin = nearTwinRefusal(add, entries);
|
|
1557
|
+
if (nearTwin !== null) {
|
|
1558
|
+
return nearTwin;
|
|
1100
1559
|
}
|
|
1101
1560
|
|
|
1102
1561
|
const neighbours = entries
|
|
@@ -1109,17 +1568,60 @@ export function prepareAdd(
|
|
|
1109
1568
|
path: target.path,
|
|
1110
1569
|
contents: serializeDoc(add.metadata, add.body),
|
|
1111
1570
|
neighbours,
|
|
1571
|
+
notes: longDocNotes(add.body, options.maxDocLines),
|
|
1112
1572
|
};
|
|
1113
1573
|
}
|
|
1114
1574
|
|
|
1575
|
+
/** Default soft limit for a single doc's body, in lines. `0` disables the note. */
|
|
1576
|
+
export const DEFAULT_MAX_DOC_LINES = 200;
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* The soft length notes for a body: one note when it exceeds the configured
|
|
1580
|
+
* limit. A note only — the write always proceeds.
|
|
1581
|
+
* @param body Markdown body.
|
|
1582
|
+
* @param limit The configured line limit; `0` disables.
|
|
1583
|
+
* @returns Zero or one note.
|
|
1584
|
+
*/
|
|
1585
|
+
function longDocNotes(body: string, limit = DEFAULT_MAX_DOC_LINES): string[] {
|
|
1586
|
+
if (limit <= 0) {
|
|
1587
|
+
return [];
|
|
1588
|
+
}
|
|
1589
|
+
const lines = body.replace(/\n$/, "").split("\n").length;
|
|
1590
|
+
return lines > limit
|
|
1591
|
+
? [
|
|
1592
|
+
`note: ${lines} body lines exceeds maxDocLines (${limit}); ` +
|
|
1593
|
+
"consider splitting by topic or scope.",
|
|
1594
|
+
]
|
|
1595
|
+
: [];
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1115
1598
|
/** The claim sections a knowledge doc must carry at least one of. */
|
|
1116
1599
|
export const CLAIM_SECTIONS = [
|
|
1117
1600
|
"Facts",
|
|
1118
1601
|
"Inferred",
|
|
1119
1602
|
"Tested",
|
|
1603
|
+
"Failed approaches",
|
|
1120
1604
|
"Open questions",
|
|
1121
1605
|
] as const;
|
|
1122
1606
|
|
|
1607
|
+
/**
|
|
1608
|
+
* Whether a body carries at least one recognisable claim section as an H2/H3
|
|
1609
|
+
* heading. This is the one rule {@link prepareAdd} and {@link validateDoc}
|
|
1610
|
+
* share, so `add` cannot accept a doc that `check` would immediately flag.
|
|
1611
|
+
*
|
|
1612
|
+
* It reads through {@link scanBody}, so a fenced code block or an inline code
|
|
1613
|
+
* span cannot masquerade as a section, and a `# H1` or `#### H4` does not
|
|
1614
|
+
* satisfy it.
|
|
1615
|
+
* @param body Markdown body, front-matter already stripped.
|
|
1616
|
+
* @returns True when a claim-section heading is present.
|
|
1617
|
+
*/
|
|
1618
|
+
export function hasClaimSection(body: string): boolean {
|
|
1619
|
+
const claims = CLAIM_SECTIONS.map((section) => section.toLowerCase());
|
|
1620
|
+
return scanBody(body).headings.some((heading) =>
|
|
1621
|
+
claims.includes(heading.toLowerCase()),
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1123
1625
|
/**
|
|
1124
1626
|
* The required front-matter fields that are blank in a raw doc: `Keywords` is
|
|
1125
1627
|
* blank when it yields no terms (matching `add`'s typed rule in
|
|
@@ -1133,8 +1635,8 @@ function blankMetaFields(meta: Record<string, string>): string[] {
|
|
|
1133
1635
|
name === "Keywords"
|
|
1134
1636
|
? splitKeywords(value ?? "").length === 0
|
|
1135
1637
|
: (value ?? "").trim() === "";
|
|
1136
|
-
return FRONT_MATTER_FIELDS.filter(
|
|
1137
|
-
|
|
1638
|
+
return FRONT_MATTER_FIELDS.filter(({ name }) =>
|
|
1639
|
+
blank(name, meta[name]),
|
|
1138
1640
|
).map(({ name }) => name);
|
|
1139
1641
|
}
|
|
1140
1642
|
|
|
@@ -1164,12 +1666,9 @@ export function validateDoc(path: string, raw: string): string[] {
|
|
|
1164
1666
|
"hand-maintained `Date` — the date is derived by git, never written.",
|
|
1165
1667
|
);
|
|
1166
1668
|
}
|
|
1167
|
-
|
|
1168
|
-
if (
|
|
1169
|
-
!scanBody(body).headings.some((h) => claims.includes(h.toLowerCase()))
|
|
1170
|
-
) {
|
|
1669
|
+
if (!hasClaimSection(body)) {
|
|
1171
1670
|
findings.push(
|
|
1172
|
-
"no claim section (Facts / Inferred / Tested / Open questions).",
|
|
1671
|
+
"no claim section (Facts / Inferred / Tested / Failed approaches / Open questions).",
|
|
1173
1672
|
);
|
|
1174
1673
|
}
|
|
1175
1674
|
const query = identityQuery(
|
|
@@ -1209,86 +1708,306 @@ function nearTwinPairs(
|
|
|
1209
1708
|
}
|
|
1210
1709
|
|
|
1211
1710
|
/**
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
* @
|
|
1216
|
-
* @param read Resolves a root-relative doc path to its raw contents;
|
|
1217
|
-
* injected so the check is testable.
|
|
1218
|
-
* @param notes Environment notes (e.g. an unversioned root) appended to the
|
|
1219
|
-
* report; findings they describe are still reported per doc.
|
|
1220
|
-
* @returns The report text.
|
|
1711
|
+
* Resolve a relative Markdown link target against the linking doc's folder.
|
|
1712
|
+
* @param from The linking doc's root-relative path.
|
|
1713
|
+
* @param target The link target, anchors/query already stripped.
|
|
1714
|
+
* @returns The resolved root-relative path, or null when it escapes the root.
|
|
1221
1715
|
*/
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
)
|
|
1227
|
-
|
|
1228
|
-
const lines = entries.flatMap((entry) => {
|
|
1229
|
-
const raw = read(entry.path);
|
|
1230
|
-
const findings = validateDoc(entry.path, raw);
|
|
1231
|
-
const supersedes = parseFrontMatter(raw).meta.Supersedes?.trim() ?? "";
|
|
1232
|
-
if (supersedes !== "" && !paths.has(supersedes)) {
|
|
1233
|
-
findings.push(`Supersedes names no doc: ${supersedes}.`);
|
|
1716
|
+
function resolveLink(from: string, target: string): string | null {
|
|
1717
|
+
const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/")) : "";
|
|
1718
|
+
const stack = base === "" ? [] : base.split("/");
|
|
1719
|
+
for (const segment of target.split("/")) {
|
|
1720
|
+
if (segment === "" || segment === ".") {
|
|
1721
|
+
continue;
|
|
1234
1722
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1723
|
+
if (segment === "..") {
|
|
1724
|
+
if (stack.length === 0) {
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
stack.pop();
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
stack.push(segment);
|
|
1242
1731
|
}
|
|
1243
|
-
|
|
1244
|
-
return lines.length === 0
|
|
1245
|
-
? `All ${entries.length} docs comply.`
|
|
1246
|
-
: lines.join("\n");
|
|
1732
|
+
return stack.join("/");
|
|
1247
1733
|
}
|
|
1248
1734
|
|
|
1249
|
-
/**
|
|
1250
|
-
|
|
1735
|
+
/** Inline `[text](target)` destinations on one line; images are skipped. */
|
|
1736
|
+
function inlineTargets(line: string): string[] {
|
|
1737
|
+
const targets: string[] = [];
|
|
1738
|
+
let index = 0;
|
|
1739
|
+
while (index < line.length) {
|
|
1740
|
+
if (line[index] === "[" && line[index - 1] !== "!") {
|
|
1741
|
+
const close = line.indexOf("](", index + 1);
|
|
1742
|
+
if (close !== -1) {
|
|
1743
|
+
const end = line.indexOf(")", close + 2);
|
|
1744
|
+
if (end !== -1) {
|
|
1745
|
+
const destination = line
|
|
1746
|
+
.slice(close + 2, end)
|
|
1747
|
+
.trim()
|
|
1748
|
+
.split(/\s+/)[0];
|
|
1749
|
+
if (destination !== undefined && destination !== "") {
|
|
1750
|
+
targets.push(destination);
|
|
1751
|
+
}
|
|
1752
|
+
index = end + 1;
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
index += 1;
|
|
1758
|
+
}
|
|
1759
|
+
return targets;
|
|
1760
|
+
}
|
|
1251
1761
|
|
|
1252
1762
|
/**
|
|
1253
|
-
* The
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
* @
|
|
1763
|
+
* The local `.md` link targets in a body: inline links and reference
|
|
1764
|
+
* definitions, fence-aware. Images, URLs, anchors and `lake:` links are
|
|
1765
|
+
* dropped by the caller.
|
|
1766
|
+
* @param body Markdown body, front-matter stripped.
|
|
1767
|
+
* @returns Targets in first-seen order.
|
|
1257
1768
|
*/
|
|
1258
|
-
function
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
.
|
|
1769
|
+
export function markdownTargets(body: string): string[] {
|
|
1770
|
+
const targets: string[] = [];
|
|
1771
|
+
let fenced = false;
|
|
1772
|
+
for (const line of body.split("\n")) {
|
|
1773
|
+
if (isFence(line)) {
|
|
1774
|
+
fenced = !fenced;
|
|
1775
|
+
continue;
|
|
1776
|
+
}
|
|
1777
|
+
if (fenced) {
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
targets.push(...inlineTargets(line));
|
|
1781
|
+
const reference = /^\s*\[[^\]]+\]:\s*(\S+)/.exec(line);
|
|
1782
|
+
if (reference?.[1] !== undefined) {
|
|
1783
|
+
targets.push(reference[1]);
|
|
1784
|
+
}
|
|
1273
1785
|
}
|
|
1274
|
-
return
|
|
1786
|
+
return targets;
|
|
1275
1787
|
}
|
|
1276
1788
|
|
|
1277
|
-
/**
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
return trimmed === "" || /no (matches|files) found/i.test(trimmed);
|
|
1789
|
+
/** Whether a link target is external or otherwise outside the lake's concern. */
|
|
1790
|
+
function isExternalTarget(target: string): boolean {
|
|
1791
|
+
return (
|
|
1792
|
+
target === "" ||
|
|
1793
|
+
target.startsWith("#") ||
|
|
1794
|
+
target.startsWith("//") ||
|
|
1795
|
+
target.startsWith("/") ||
|
|
1796
|
+
target.startsWith("lake:") ||
|
|
1797
|
+
/^[a-z][a-z0-9+.-]*:/i.test(target)
|
|
1798
|
+
);
|
|
1288
1799
|
}
|
|
1289
1800
|
|
|
1290
|
-
/**
|
|
1291
|
-
|
|
1801
|
+
/**
|
|
1802
|
+
* The broken-link findings for one doc. Only relative `.md` targets that stay
|
|
1803
|
+
* inside the lake are checked; project links, URLs, anchors and images are
|
|
1804
|
+
* left alone.
|
|
1805
|
+
* @param path The doc's root-relative path.
|
|
1806
|
+
* @param raw Full file contents.
|
|
1807
|
+
* @param exists Whether a root-relative path exists in the lake.
|
|
1808
|
+
* @returns Findings for targets that do not resolve.
|
|
1809
|
+
*/
|
|
1810
|
+
export function linkFindings(
|
|
1811
|
+
path: string,
|
|
1812
|
+
raw: string,
|
|
1813
|
+
exists: (path: string) => boolean,
|
|
1814
|
+
): string[] {
|
|
1815
|
+
const { body } = parseFrontMatter(raw);
|
|
1816
|
+
const findings: string[] = [];
|
|
1817
|
+
const seen = new Set<string>();
|
|
1818
|
+
for (const target of markdownTargets(body)) {
|
|
1819
|
+
if (isExternalTarget(target)) {
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
const clean = target.split("#")[0]?.split("?")[0] ?? "";
|
|
1823
|
+
if (!clean.toLowerCase().endsWith(".md")) {
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
let decoded = clean;
|
|
1827
|
+
try {
|
|
1828
|
+
decoded = decodeURIComponent(clean);
|
|
1829
|
+
} catch {
|
|
1830
|
+
// Keep the raw target when it is not valid percent-encoding.
|
|
1831
|
+
}
|
|
1832
|
+
const resolved = resolveLink(path, decoded);
|
|
1833
|
+
if (resolved === null || seen.has(resolved)) {
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
seen.add(resolved);
|
|
1837
|
+
if (!exists(resolved)) {
|
|
1838
|
+
findings.push(`broken link to ${target} (no ${resolved}).`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
return findings;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* The soft notes for one doc: obsolete lifecycle fields and a long body. These
|
|
1846
|
+
* are advisory, not compliance failures.
|
|
1847
|
+
* @param path The doc's root-relative path.
|
|
1848
|
+
* @param raw Full file contents.
|
|
1849
|
+
* @param maxDocLines The body line limit; `0` disables the length note.
|
|
1850
|
+
* @returns The note lines, without the path prefix.
|
|
1851
|
+
*/
|
|
1852
|
+
export function docNotes(
|
|
1853
|
+
path: string,
|
|
1854
|
+
raw: string,
|
|
1855
|
+
maxDocLines = DEFAULT_MAX_DOC_LINES,
|
|
1856
|
+
): string[] {
|
|
1857
|
+
const { meta, body } = parseFrontMatter(raw);
|
|
1858
|
+
const notes: string[] = [];
|
|
1859
|
+
for (const field of ["Status", "Supersedes"] as const) {
|
|
1860
|
+
if (meta[field] !== undefined) {
|
|
1861
|
+
notes.push(
|
|
1862
|
+
`obsolete field \`${field}\` — the convention has no lifecycle ` +
|
|
1863
|
+
"fields; remove it or delete the doc.",
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
notes.push(
|
|
1868
|
+
...longDocNotes(body, maxDocLines).map((note) =>
|
|
1869
|
+
note.replace(/^note: /, ""),
|
|
1870
|
+
),
|
|
1871
|
+
);
|
|
1872
|
+
return notes;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/** One doc's findings and advisory notes for `check`. */
|
|
1876
|
+
interface DocCheck {
|
|
1877
|
+
/** Structural failures. */
|
|
1878
|
+
findings: string[];
|
|
1879
|
+
/** Advisory notes. */
|
|
1880
|
+
notes: string[];
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* Collect one doc's check findings and notes.
|
|
1885
|
+
* @param entry The doc to check.
|
|
1886
|
+
* @param read Resolves its raw contents.
|
|
1887
|
+
* @param options Check options.
|
|
1888
|
+
* @param options.maxDocLines The body line limit.
|
|
1889
|
+
* @param options.exists Resolves a root-relative path for link checking.
|
|
1890
|
+
* @returns The findings and notes, path-prefixed.
|
|
1891
|
+
*/
|
|
1892
|
+
function checkOne(
|
|
1893
|
+
entry: DocEntry,
|
|
1894
|
+
read: (path: string) => string,
|
|
1895
|
+
options: {
|
|
1896
|
+
maxDocLines?: number;
|
|
1897
|
+
exists?: (path: string) => boolean;
|
|
1898
|
+
},
|
|
1899
|
+
): DocCheck {
|
|
1900
|
+
const raw = read(entry.path);
|
|
1901
|
+
const findings = validateDoc(entry.path, raw).map(
|
|
1902
|
+
(finding) => `${entry.path}: ${finding}`,
|
|
1903
|
+
);
|
|
1904
|
+
if (options.exists !== undefined) {
|
|
1905
|
+
findings.push(
|
|
1906
|
+
...linkFindings(entry.path, raw, options.exists).map(
|
|
1907
|
+
(finding) => `${entry.path}: ${finding}`,
|
|
1908
|
+
),
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
const notes = docNotes(entry.path, raw, options.maxDocLines).map(
|
|
1912
|
+
(note) => `${entry.path}: ${note}`,
|
|
1913
|
+
);
|
|
1914
|
+
return { findings, notes };
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
/** The near-twin findings for a tree, one line per pair. */
|
|
1918
|
+
function nearTwinFindings(entries: readonly DocEntry[]): string[] {
|
|
1919
|
+
return nearTwinPairs(entries).map(
|
|
1920
|
+
([doc, twin]) =>
|
|
1921
|
+
`${doc.path} and ${twin.path}: near-twins — reconcile ` +
|
|
1922
|
+
"(merge, or repoint one).",
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Check the whole tree: per-doc {@link validateDoc} findings, broken relative
|
|
1928
|
+
* links, then the {@link nearTwinPairs}. Obsolete fields, long docs and
|
|
1929
|
+
* environment notes are advisory notes, not findings.
|
|
1930
|
+
* @param entries The docs to check.
|
|
1931
|
+
* @param read Resolves a root-relative doc path to its raw contents;
|
|
1932
|
+
* injected so the check is testable.
|
|
1933
|
+
* @param notes Environment notes (e.g. an unversioned root) appended to the
|
|
1934
|
+
* report; findings they describe are still reported per doc.
|
|
1935
|
+
* @param options Check options.
|
|
1936
|
+
* @param options.maxDocLines The body line limit; `0` disables the length note.
|
|
1937
|
+
* @param options.exists Resolves a root-relative path for link checking; when
|
|
1938
|
+
* omitted, links are not checked.
|
|
1939
|
+
* @returns The report text.
|
|
1940
|
+
*/
|
|
1941
|
+
export function checkDocs(
|
|
1942
|
+
entries: readonly DocEntry[],
|
|
1943
|
+
read: (path: string) => string,
|
|
1944
|
+
notes: readonly string[] = [],
|
|
1945
|
+
options: {
|
|
1946
|
+
maxDocLines?: number;
|
|
1947
|
+
exists?: (path: string) => boolean;
|
|
1948
|
+
} = {},
|
|
1949
|
+
): string {
|
|
1950
|
+
const findings: string[] = [];
|
|
1951
|
+
const advisory: string[] = [...notes];
|
|
1952
|
+
for (const entry of entries) {
|
|
1953
|
+
const report = checkOne(entry, read, options);
|
|
1954
|
+
findings.push(...report.findings);
|
|
1955
|
+
advisory.push(...report.notes);
|
|
1956
|
+
}
|
|
1957
|
+
findings.push(...nearTwinFindings(entries));
|
|
1958
|
+
const head =
|
|
1959
|
+
findings.length === 0
|
|
1960
|
+
? `All ${entries.length} docs comply.`
|
|
1961
|
+
: findings.join("\n");
|
|
1962
|
+
if (advisory.length === 0) {
|
|
1963
|
+
return head;
|
|
1964
|
+
}
|
|
1965
|
+
return `${head}\n\nNotes:\n${advisory.map((note) => `- ${note}`).join("\n")}`;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
|
|
1969
|
+
export const NUDGED_TOOLS = ["grep", "glob"] as const;
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* The text of a tool result's content: the string itself, or its text parts
|
|
1973
|
+
* joined. Non-text parts and foreign shapes contribute nothing.
|
|
1974
|
+
* @param content A `Tool.Result` content of any provenance.
|
|
1975
|
+
* @returns The searchable text, possibly empty.
|
|
1976
|
+
*/
|
|
1977
|
+
function contentText(content: unknown): string {
|
|
1978
|
+
if (typeof content === "string") {
|
|
1979
|
+
return content;
|
|
1980
|
+
}
|
|
1981
|
+
if (Array.isArray(content)) {
|
|
1982
|
+
return content
|
|
1983
|
+
.map((part) =>
|
|
1984
|
+
typeof part === "object" &&
|
|
1985
|
+
part !== null &&
|
|
1986
|
+
"text" in part &&
|
|
1987
|
+
typeof (part as { text: unknown }).text === "string"
|
|
1988
|
+
? (part as { text: string }).text
|
|
1989
|
+
: "",
|
|
1990
|
+
)
|
|
1991
|
+
.join("\n");
|
|
1992
|
+
}
|
|
1993
|
+
return "";
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Whether a grep/glob result reads as a search miss. Raw rg prints nothing
|
|
1998
|
+
* on a miss (exit 1, empty stdout) — "No matches found" and "No files found"
|
|
1999
|
+
* are the host wrapper's phrasings — so emptiness is the primary signal and
|
|
2000
|
+
* the phrases are a bonus that keeps working if the wrapper rewords.
|
|
2001
|
+
* @param content The tool result's content (string or content parts).
|
|
2002
|
+
* @returns True when the result is a miss.
|
|
2003
|
+
*/
|
|
2004
|
+
export function isSearchMiss(content: unknown): boolean {
|
|
2005
|
+
const trimmed = contentText(content).trim();
|
|
2006
|
+
return trimmed === "" || /no (matches|files) found/i.test(trimmed);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/** The grep/glob call's `pattern`, when the input carries a string one. */
|
|
2010
|
+
function patternOf(input: unknown): string {
|
|
1292
2011
|
if (typeof input === "object" && input !== null && "pattern" in input) {
|
|
1293
2012
|
const pattern = (input as { pattern: unknown }).pattern;
|
|
1294
2013
|
if (typeof pattern === "string") {
|
|
@@ -1310,7 +2029,7 @@ export function missNudge(pattern: string): string {
|
|
|
1310
2029
|
? "a knowledge question may already be answered there."
|
|
1311
2030
|
: `try \`docs search\` with terms from "${pattern}".`;
|
|
1312
2031
|
return (
|
|
1313
|
-
"Note: the `docs` tool also indexes the knowledge
|
|
2032
|
+
"Note: the `docs` tool also indexes the knowledge lakes and project " +
|
|
1314
2033
|
`READMEs before you move on — ${suggestion}`
|
|
1315
2034
|
);
|
|
1316
2035
|
}
|
|
@@ -1359,12 +2078,17 @@ export function missNudgeFor(
|
|
|
1359
2078
|
* never a session dump (the near-twin guard catches duplicates, not dumps).
|
|
1360
2079
|
*/
|
|
1361
2080
|
export const RECORD_NUDGE =
|
|
1362
|
-
"Before summarising
|
|
1363
|
-
"(decisions, traps, runbooks, verified
|
|
1364
|
-
"
|
|
1365
|
-
"
|
|
1366
|
-
"
|
|
1367
|
-
"
|
|
2081
|
+
"Before summarising, the active task comes first. If the session " +
|
|
2082
|
+
"established durable knowledge (decisions, traps, runbooks, verified " +
|
|
2083
|
+
"behaviour), add one `Record with docs add:` block per durable topic and " +
|
|
2084
|
+
"scope — coalesce related findings rather than one block per observation, " +
|
|
2085
|
+
"never one session dump, and never one mixed-scope doc. Each block names " +
|
|
2086
|
+
"its class (repo, machine or global), `Area`, `H1` title and `Keywords`, and " +
|
|
2087
|
+
"keeps what was learned, why it matters, the evidence or status, any " +
|
|
2088
|
+
"unresolved uncertainty and the intended scope, so the next agent can " +
|
|
2089
|
+
"reconcile before writing. Reference a finding already recorded by path " +
|
|
2090
|
+
"rather than restating it, and assume persistence only where the session " +
|
|
2091
|
+
"confirms it.";
|
|
1368
2092
|
|
|
1369
2093
|
/**
|
|
1370
2094
|
* Resolve the plugin's `nudge` option: unset means on; a boolean passes
|
|
@@ -1385,36 +2109,927 @@ export function resolveNudge(configured: unknown): boolean {
|
|
|
1385
2109
|
);
|
|
1386
2110
|
}
|
|
1387
2111
|
|
|
1388
|
-
|
|
1389
|
-
|
|
2112
|
+
/** A configured lake, before its runtime seams are attached. */
|
|
2113
|
+
export interface LakeSpec {
|
|
2114
|
+
/** The lake's addressing name, unique within the configuration. */
|
|
2115
|
+
name: string;
|
|
2116
|
+
/** The source class. */
|
|
2117
|
+
class: LakeClass;
|
|
2118
|
+
/** The absolute lake root. */
|
|
2119
|
+
root: string;
|
|
2120
|
+
/** The writing convention. */
|
|
2121
|
+
profile: LakeProfile;
|
|
2122
|
+
/** Whether `add` may write to it. */
|
|
2123
|
+
writable: boolean;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/** A lake with its runtime seams. */
|
|
2127
|
+
export interface Lake extends LakeSpec {
|
|
2128
|
+
/** Collects this lake's docs. */
|
|
1390
2129
|
collect: () => DocEntry[];
|
|
1391
|
-
/**
|
|
1392
|
-
|
|
1393
|
-
/** Whether a root-relative
|
|
2130
|
+
/** Reads a root-relative path. */
|
|
2131
|
+
read: (path: string) => string;
|
|
2132
|
+
/** Whether a root-relative path exists. */
|
|
1394
2133
|
exists: (path: string) => boolean;
|
|
1395
|
-
/**
|
|
2134
|
+
/**
|
|
2135
|
+
* Writes a root-relative path, creating parent directories beneath an
|
|
2136
|
+
* existing root; throws when the root is absent rather than creating it.
|
|
2137
|
+
*/
|
|
1396
2138
|
write: (path: string, contents: string) => void;
|
|
1397
|
-
/**
|
|
1398
|
-
|
|
1399
|
-
/**
|
|
2139
|
+
/** Environment notes for `check` (missing root, unversioned root). */
|
|
2140
|
+
notes: readonly string[];
|
|
2141
|
+
/** Whether `check` should verify relative links; false for the legacy seam. */
|
|
2142
|
+
linkCheck: boolean;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* A lake path that would leave its root or pass through a symlink. The lake
|
|
2147
|
+
* seams raise it so `add` can refuse the request rather than fail the call.
|
|
2148
|
+
*/
|
|
2149
|
+
class UnsafePathError extends Error {
|
|
2150
|
+
constructor(path: string) {
|
|
2151
|
+
super(`path '${path}' escapes the lake root or crosses a symlink.`);
|
|
2152
|
+
this.name = "UnsafePathError";
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/** Whether `path` is outside `root` or is the root itself. */
|
|
2157
|
+
function escapesRoot(root: string, path: string): boolean {
|
|
2158
|
+
const rest = relative(root, path);
|
|
2159
|
+
return (
|
|
2160
|
+
rest === "" ||
|
|
2161
|
+
rest === ".." ||
|
|
2162
|
+
rest.startsWith(`..${sep}`) ||
|
|
2163
|
+
isAbsolute(rest)
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/**
|
|
2168
|
+
* Resolve a root-relative path to an absolute target, refusing an escape and
|
|
2169
|
+
* any symlink in the path. Missing trailing segments are allowed, so a write
|
|
2170
|
+
* can create them beneath a real directory.
|
|
2171
|
+
*
|
|
2172
|
+
* ponytail: the check and the caller's file operation are separate syscalls,
|
|
2173
|
+
* so a local process that can swap a component for a symlink in between still
|
|
2174
|
+
* wins the race. The threat model is a symlink committed to the tree, not a
|
|
2175
|
+
* live local attacker; closing the gap needs per-component `O_NOFOLLOW` opens.
|
|
2176
|
+
* @param root The lake root.
|
|
2177
|
+
* @param path The root-relative path.
|
|
2178
|
+
* @returns The target, or null when the path escapes or crosses a symlink.
|
|
2179
|
+
*/
|
|
2180
|
+
function safeLakeTarget(root: string, path: string): string | null {
|
|
2181
|
+
const target = join(root, path);
|
|
2182
|
+
if (escapesRoot(root, target)) {
|
|
2183
|
+
return null;
|
|
2184
|
+
}
|
|
2185
|
+
let current = root;
|
|
2186
|
+
for (const segment of relative(root, target).split(sep)) {
|
|
2187
|
+
current = join(current, segment);
|
|
2188
|
+
try {
|
|
2189
|
+
const stats = lstatSync(current);
|
|
2190
|
+
if (
|
|
2191
|
+
stats.isSymbolicLink() ||
|
|
2192
|
+
(current !== target && !stats.isDirectory())
|
|
2193
|
+
) {
|
|
2194
|
+
return null;
|
|
2195
|
+
}
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
2198
|
+
? target
|
|
2199
|
+
: null;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
return target;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
/**
|
|
2206
|
+
* Attach the filesystem seams to a lake spec.
|
|
2207
|
+
* @param spec The configured lake.
|
|
2208
|
+
* @param options Runtime options.
|
|
2209
|
+
* @param options.skip Absolute roots of other lakes, so a nested lake is not
|
|
2210
|
+
* collected twice.
|
|
2211
|
+
* @returns The lake the tool serves.
|
|
2212
|
+
*/
|
|
2213
|
+
export function createLake(
|
|
2214
|
+
spec: LakeSpec,
|
|
2215
|
+
options: { skip?: readonly string[] } = {},
|
|
2216
|
+
): Lake {
|
|
2217
|
+
const guarded = (path: string): string => {
|
|
2218
|
+
const target = safeLakeTarget(spec.root, path);
|
|
2219
|
+
if (target === null) {
|
|
2220
|
+
throw new UnsafePathError(path);
|
|
2221
|
+
}
|
|
2222
|
+
return target;
|
|
2223
|
+
};
|
|
2224
|
+
return {
|
|
2225
|
+
...spec,
|
|
2226
|
+
notes: unversionedNote(spec.root, spec.name),
|
|
2227
|
+
linkCheck: true,
|
|
2228
|
+
collect: () =>
|
|
2229
|
+
collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
|
|
2230
|
+
profile: spec.profile,
|
|
2231
|
+
lake: spec.name,
|
|
2232
|
+
class: spec.class,
|
|
2233
|
+
skip: options.skip ?? [],
|
|
2234
|
+
}),
|
|
2235
|
+
read: (path) => readFileSync(guarded(path), "utf8"),
|
|
2236
|
+
exists: (path) => {
|
|
2237
|
+
const target = safeLakeTarget(spec.root, path);
|
|
2238
|
+
return target !== null && existsSync(target);
|
|
2239
|
+
},
|
|
2240
|
+
write: (path, contents) => {
|
|
2241
|
+
const target = guarded(path);
|
|
2242
|
+
// The root is configured, never invented: a mistyped path must not
|
|
2243
|
+
// bootstrap a lake. `add` pre-checks for a friendlier refusal.
|
|
2244
|
+
if (!existsSync(spec.root)) {
|
|
2245
|
+
throw new Error(
|
|
2246
|
+
`lake '${spec.name}' root ${spec.root} does not exist; ` +
|
|
2247
|
+
"create it first.",
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
2251
|
+
writeFileSync(target, contents);
|
|
2252
|
+
},
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* Build every configured lake, each aware of the others so a nested root is
|
|
2258
|
+
* collected once.
|
|
2259
|
+
* @param specs The configured lakes.
|
|
2260
|
+
* @returns The runtime lakes.
|
|
2261
|
+
*/
|
|
2262
|
+
export function createLakes(specs: readonly LakeSpec[]): Lake[] {
|
|
2263
|
+
return specs.map((spec) =>
|
|
2264
|
+
createLake(spec, {
|
|
2265
|
+
skip: specs
|
|
2266
|
+
.filter((other) => other !== spec)
|
|
2267
|
+
.map((other) => other.root),
|
|
2268
|
+
}),
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** The lake classes the configuration accepts. */
|
|
2273
|
+
export const LAKE_CLASSES = ["repo", "machine", "global", "wiki"] as const;
|
|
2274
|
+
|
|
2275
|
+
/** The repo-local lake additions file, relative to the location. */
|
|
2276
|
+
export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
|
|
2277
|
+
|
|
2278
|
+
/** The plugin's options, as the host delivers them. */
|
|
2279
|
+
export interface DocsPluginOptions {
|
|
2280
|
+
/** The single repo-lake override; mutually exclusive with `lakes`. */
|
|
2281
|
+
root?: unknown;
|
|
2282
|
+
/** Base lake declarations, merged after `lakesFile` and the repo file. */
|
|
2283
|
+
lakes?: unknown;
|
|
2284
|
+
/** A JSON file of base lakes (`{ "lakes": [...] }`). */
|
|
2285
|
+
lakesFile?: unknown;
|
|
2286
|
+
/** The runtime nudges' switch; defaults to on. */
|
|
2287
|
+
nudge?: unknown;
|
|
2288
|
+
/** The soft body-line limit; defaults to 200, `0` disables. */
|
|
2289
|
+
maxDocLines?: unknown;
|
|
2290
|
+
/** The per-source result reservation; defaults to 3, `0` disables. */
|
|
2291
|
+
perLakeQuota?: unknown;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* Resolve a non-negative integer option, or throw loudly.
|
|
2296
|
+
* @param value The raw option value.
|
|
2297
|
+
* @param name The option name, for the error.
|
|
2298
|
+
* @param fallback The value when unset.
|
|
2299
|
+
* @returns The validated number.
|
|
2300
|
+
*/
|
|
2301
|
+
export function resolveNonNegativeInt(
|
|
2302
|
+
value: unknown,
|
|
2303
|
+
name: string,
|
|
2304
|
+
fallback: number,
|
|
2305
|
+
): number {
|
|
2306
|
+
if (value === undefined) {
|
|
2307
|
+
return fallback;
|
|
2308
|
+
}
|
|
2309
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
|
|
2310
|
+
return value;
|
|
2311
|
+
}
|
|
2312
|
+
throw new Error(
|
|
2313
|
+
`docs plugin option '${name}' must be a non-negative integer, ` +
|
|
2314
|
+
`got ${JSON.stringify(value)}.`,
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/** Resolve the `lakesFile` option to an absolute path, or undefined. */
|
|
2319
|
+
function resolveLakesFilePath(
|
|
2320
|
+
configured: unknown,
|
|
2321
|
+
location: string,
|
|
2322
|
+
): string | undefined {
|
|
2323
|
+
if (configured === undefined) {
|
|
2324
|
+
return undefined;
|
|
2325
|
+
}
|
|
2326
|
+
if (typeof configured !== "string" || configured.trim() === "") {
|
|
2327
|
+
throw new Error(
|
|
2328
|
+
`docs plugin option 'lakesFile' must be a non-empty string path, ` +
|
|
2329
|
+
`got ${JSON.stringify(configured)}.`,
|
|
2330
|
+
);
|
|
2331
|
+
}
|
|
2332
|
+
return resolveRoot(configured, location);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/**
|
|
2336
|
+
* Read a lakes JSON file. A base file is required; the repo file is optional.
|
|
2337
|
+
* @param path Absolute file path.
|
|
2338
|
+
* @param label Human-readable source name, for errors.
|
|
2339
|
+
* @param required Whether a missing file is an error.
|
|
2340
|
+
* @returns The raw lake entries.
|
|
2341
|
+
*/
|
|
2342
|
+
function readLakesJson(
|
|
2343
|
+
path: string,
|
|
2344
|
+
label: string,
|
|
2345
|
+
required: boolean,
|
|
2346
|
+
): unknown[] {
|
|
2347
|
+
if (!existsSync(path)) {
|
|
2348
|
+
if (required) {
|
|
2349
|
+
throw new Error(`docs plugin ${label} not found: ${path}.`);
|
|
2350
|
+
}
|
|
2351
|
+
return [];
|
|
2352
|
+
}
|
|
2353
|
+
let parsed: unknown;
|
|
2354
|
+
try {
|
|
2355
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2356
|
+
} catch (error) {
|
|
2357
|
+
throw new Error(
|
|
2358
|
+
`docs plugin ${label} is not valid JSON (${path}): ${String(error)}`,
|
|
2359
|
+
{ cause: error },
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
const lakes = Array.isArray(parsed)
|
|
2363
|
+
? parsed
|
|
2364
|
+
: typeof parsed === "object" && parsed !== null && "lakes" in parsed
|
|
2365
|
+
? (parsed as { lakes: unknown }).lakes
|
|
2366
|
+
: undefined;
|
|
2367
|
+
if (!Array.isArray(lakes)) {
|
|
2368
|
+
throw new Error(
|
|
2369
|
+
`docs plugin ${label} must contain a "lakes" array: ${path}.`,
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
return lakes;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
/** A safe lake name: lower-case word characters and dashes. */
|
|
2376
|
+
const LAKE_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
2377
|
+
|
|
2378
|
+
/** The raw shape a lake declaration may take. */
|
|
2379
|
+
interface RawLake {
|
|
2380
|
+
class?: unknown;
|
|
2381
|
+
root?: unknown;
|
|
2382
|
+
name?: unknown;
|
|
2383
|
+
writable?: unknown;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** Narrow an unknown lake declaration to its raw shape. */
|
|
2387
|
+
function asLakeEntry(raw: unknown): RawLake {
|
|
2388
|
+
if (typeof raw !== "object" || raw === null) {
|
|
2389
|
+
throw new Error(
|
|
2390
|
+
`docs plugin lake entry must be an object, got ${JSON.stringify(raw)}.`,
|
|
2391
|
+
);
|
|
2392
|
+
}
|
|
2393
|
+
return raw as RawLake;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** Validate the lake class and the repo-file restriction. */
|
|
2397
|
+
function lakeClassOf(entry: RawLake, fromRepoFile: boolean): LakeClass {
|
|
2398
|
+
const given = entry.class ?? "repo";
|
|
2399
|
+
if (
|
|
2400
|
+
typeof given !== "string" ||
|
|
2401
|
+
!(LAKE_CLASSES as readonly string[]).includes(given)
|
|
2402
|
+
) {
|
|
2403
|
+
throw new Error(
|
|
2404
|
+
`docs plugin lake class must be one of ${LAKE_CLASSES.join(", ")}, ` +
|
|
2405
|
+
`got ${JSON.stringify(entry.class)}.`,
|
|
2406
|
+
);
|
|
2407
|
+
}
|
|
2408
|
+
const lakeClass = given as LakeClass;
|
|
2409
|
+
if (fromRepoFile && lakeClass !== "repo") {
|
|
2410
|
+
throw new Error(
|
|
2411
|
+
`docs plugin repo lake file may only declare "repo" lakes; ` +
|
|
2412
|
+
`got ${JSON.stringify(lakeClass)}.`,
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
return lakeClass;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/** Resolve and validate a lake root, applying the repo default. */
|
|
2419
|
+
function lakeRootOf(
|
|
2420
|
+
entry: RawLake,
|
|
2421
|
+
lakeClass: LakeClass,
|
|
2422
|
+
location: string,
|
|
2423
|
+
fromRepoFile: boolean,
|
|
2424
|
+
): string {
|
|
2425
|
+
const blank =
|
|
2426
|
+
entry.root === undefined ||
|
|
2427
|
+
(typeof entry.root === "string" && entry.root.trim() === "");
|
|
2428
|
+
if (blank && lakeClass !== "repo") {
|
|
2429
|
+
throw new Error(
|
|
2430
|
+
`docs plugin ${lakeClass} lake needs a root path, ` +
|
|
2431
|
+
`got ${JSON.stringify(entry.root)}.`,
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
if (blank) {
|
|
2435
|
+
return join(location, "docs");
|
|
2436
|
+
}
|
|
2437
|
+
if (typeof entry.root !== "string") {
|
|
2438
|
+
throw new Error(
|
|
2439
|
+
`docs plugin ${lakeClass} lake root must be a string, ` +
|
|
2440
|
+
`got ${JSON.stringify(entry.root)}.`,
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
const root = resolveRoot(entry.root, location);
|
|
2444
|
+
if (fromRepoFile && !isUnder(realPathOf(root), realPathOf(location))) {
|
|
2445
|
+
throw new Error(
|
|
2446
|
+
`docs plugin repo lake root must stay inside the location: ${root}.`,
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
return root;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/** Resolve and validate a lake name. */
|
|
2453
|
+
function lakeNameOf(
|
|
2454
|
+
entry: RawLake,
|
|
2455
|
+
root: string,
|
|
2456
|
+
lakeClass: LakeClass,
|
|
2457
|
+
): string {
|
|
2458
|
+
const derived = slugify(basename(root)) || lakeClass;
|
|
2459
|
+
const name =
|
|
2460
|
+
entry.name === undefined
|
|
2461
|
+
? derived
|
|
2462
|
+
: typeof entry.name === "string"
|
|
2463
|
+
? entry.name.trim()
|
|
2464
|
+
: "";
|
|
2465
|
+
if (!LAKE_NAME.test(name)) {
|
|
2466
|
+
throw new Error(
|
|
2467
|
+
`docs plugin lake name must match ${LAKE_NAME}, ` +
|
|
2468
|
+
`got ${JSON.stringify(entry.name ?? derived)}.`,
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
if (name === "instructions") {
|
|
2472
|
+
throw new Error(
|
|
2473
|
+
"docs plugin lake name 'instructions' is reserved for project READMEs.",
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
return name;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/** Resolve and validate a lake's writability. */
|
|
2480
|
+
function lakeWritableOf(
|
|
2481
|
+
entry: RawLake,
|
|
2482
|
+
lakeClass: LakeClass,
|
|
2483
|
+
name: string,
|
|
2484
|
+
): boolean {
|
|
2485
|
+
if (entry.writable !== undefined && typeof entry.writable !== "boolean") {
|
|
2486
|
+
throw new Error(
|
|
2487
|
+
`docs plugin lake '${name}' writable must be a boolean, ` +
|
|
2488
|
+
`got ${JSON.stringify(entry.writable)}.`,
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
const writable =
|
|
2492
|
+
entry.writable === undefined ? lakeClass !== "wiki" : entry.writable;
|
|
2493
|
+
if (lakeClass === "wiki" && writable) {
|
|
2494
|
+
throw new Error(
|
|
2495
|
+
`docs plugin lake '${name}' is a wiki lake and cannot be writable.`,
|
|
2496
|
+
);
|
|
2497
|
+
}
|
|
2498
|
+
return writable;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
/**
|
|
2502
|
+
* Validate one raw lake declaration and resolve its defaults.
|
|
2503
|
+
* @param raw The raw entry from a config file or inline option.
|
|
2504
|
+
* @param location The project directory the plugin loaded in.
|
|
2505
|
+
* @param fromRepoFile Whether the entry came from the repo-local file, which
|
|
2506
|
+
* may only add repo lakes rooted inside the location.
|
|
2507
|
+
* @returns The resolved lake spec.
|
|
2508
|
+
*/
|
|
2509
|
+
function lakeSpecFrom(
|
|
2510
|
+
raw: unknown,
|
|
2511
|
+
location: string,
|
|
2512
|
+
fromRepoFile: boolean,
|
|
2513
|
+
): LakeSpec {
|
|
2514
|
+
const entry = asLakeEntry(raw);
|
|
2515
|
+
const lakeClass = lakeClassOf(entry, fromRepoFile);
|
|
2516
|
+
const root = lakeRootOf(entry, lakeClass, location, fromRepoFile);
|
|
2517
|
+
const name = lakeNameOf(entry, root, lakeClass);
|
|
2518
|
+
return {
|
|
2519
|
+
name,
|
|
2520
|
+
class: lakeClass,
|
|
2521
|
+
root,
|
|
2522
|
+
profile: lakeClass === "wiki" ? "wiki" : "knowledge",
|
|
2523
|
+
writable: lakeWritableOf(entry, lakeClass, name),
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Resolve the plugin's lakes: the base `lakesFile`, the repo-local additions,
|
|
2529
|
+
* inline `lakes`, then `root` sugar. One implicit repo lake at
|
|
2530
|
+
* `<location>/docs` is added when no repo lake is configured, so a repo needs
|
|
2531
|
+
* no lake file unless its docs live elsewhere.
|
|
2532
|
+
* @param options The plugin options.
|
|
2533
|
+
* @param location The directory the plugin loaded in.
|
|
2534
|
+
* @returns The configured lake specs.
|
|
2535
|
+
*/
|
|
2536
|
+
/** Reject the ambiguous option combinations before reading any file. */
|
|
2537
|
+
function assertLakesOptions(options: DocsPluginOptions): void {
|
|
2538
|
+
if (options.lakes !== undefined && !Array.isArray(options.lakes)) {
|
|
2539
|
+
throw new Error(
|
|
2540
|
+
`docs plugin option 'lakes' must be an array, ` +
|
|
2541
|
+
`got ${JSON.stringify(options.lakes)}.`,
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
if (options.root !== undefined && options.lakes !== undefined) {
|
|
2545
|
+
throw new Error(
|
|
2546
|
+
"docs plugin options 'root' and 'lakes' are mutually exclusive; " +
|
|
2547
|
+
"use 'root' for one repo lake or 'lakes' for the full list.",
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
/** Map raw lake entries from a file to specs. */
|
|
2553
|
+
function specsFromFile(
|
|
2554
|
+
path: string,
|
|
2555
|
+
location: string,
|
|
2556
|
+
fromRepoFile: boolean,
|
|
2557
|
+
): LakeSpec[] {
|
|
2558
|
+
const label = fromRepoFile ? "repo lake file" : "lakesFile";
|
|
2559
|
+
return readLakesJson(path, label, !fromRepoFile).map((raw) =>
|
|
2560
|
+
lakeSpecFrom(raw, location, fromRepoFile),
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/** Map the inline `lakes` option to specs. */
|
|
2565
|
+
function specsFromInline(rawLakes: unknown, location: string): LakeSpec[] {
|
|
2566
|
+
return Array.isArray(rawLakes)
|
|
2567
|
+
? rawLakes.map((raw) => lakeSpecFrom(raw, location, false))
|
|
2568
|
+
: [];
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/** Throw when two lakes share a name. */
|
|
2572
|
+
function assertUniqueLakeNames(specs: readonly LakeSpec[]): void {
|
|
2573
|
+
const names = new Set<string>();
|
|
2574
|
+
for (const spec of specs) {
|
|
2575
|
+
if (names.has(spec.name)) {
|
|
2576
|
+
throw new Error(
|
|
2577
|
+
`docs plugin lake name '${spec.name}' is declared twice; ` +
|
|
2578
|
+
"give each lake a unique name.",
|
|
2579
|
+
);
|
|
2580
|
+
}
|
|
2581
|
+
names.add(spec.name);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/** Throw when two differently named lakes resolve to the same root. */
|
|
2586
|
+
function assertUniqueLakeRoots(specs: readonly LakeSpec[]): void {
|
|
2587
|
+
const roots = new Map<string, string>();
|
|
2588
|
+
for (const spec of specs) {
|
|
2589
|
+
const key = realPathOf(spec.root);
|
|
2590
|
+
const existing = roots.get(key);
|
|
2591
|
+
if (existing !== undefined) {
|
|
2592
|
+
throw new Error(
|
|
2593
|
+
`docs plugin lakes '${existing}' and '${spec.name}' share ` +
|
|
2594
|
+
`the root ${spec.root}; give each lake its own root.`,
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
roots.set(key, spec.name);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/**
|
|
2602
|
+
* Resolve the plugin's lakes: the base `lakesFile`, the repo-local additions,
|
|
2603
|
+
* inline `lakes`, then `root` sugar. One implicit repo lake at
|
|
2604
|
+
* `<location>/docs` is added when no repo lake is configured, so a repo needs
|
|
2605
|
+
* no lake file unless its docs live elsewhere.
|
|
2606
|
+
* @param options The plugin options.
|
|
2607
|
+
* @param location The directory the plugin loaded in.
|
|
2608
|
+
* @returns The configured lake specs.
|
|
2609
|
+
*/
|
|
2610
|
+
export function resolveLakes(
|
|
2611
|
+
options: DocsPluginOptions,
|
|
2612
|
+
location: string,
|
|
2613
|
+
): LakeSpec[] {
|
|
2614
|
+
assertLakesOptions(options);
|
|
2615
|
+
const basePath = resolveLakesFilePath(options.lakesFile, location);
|
|
2616
|
+
const specs: LakeSpec[] = [
|
|
2617
|
+
...(basePath === undefined
|
|
2618
|
+
? []
|
|
2619
|
+
: specsFromFile(basePath, location, false)),
|
|
2620
|
+
...specsFromFile(join(location, REPO_LAKES_FILE), location, true),
|
|
2621
|
+
...specsFromInline(options.lakes, location),
|
|
2622
|
+
];
|
|
2623
|
+
if (options.root !== undefined) {
|
|
2624
|
+
specs.push(
|
|
2625
|
+
lakeSpecFrom(
|
|
2626
|
+
{ class: "repo", root: options.root },
|
|
2627
|
+
location,
|
|
2628
|
+
false,
|
|
2629
|
+
),
|
|
2630
|
+
);
|
|
2631
|
+
}
|
|
2632
|
+
if (!specs.some((spec) => spec.class === "repo")) {
|
|
2633
|
+
specs.push(
|
|
2634
|
+
lakeSpecFrom(
|
|
2635
|
+
{ class: "repo", root: join(location, "docs") },
|
|
2636
|
+
location,
|
|
2637
|
+
false,
|
|
2638
|
+
),
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
assertUniqueLakeNames(specs);
|
|
2642
|
+
assertUniqueLakeRoots(specs);
|
|
2643
|
+
return specs;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/** Runtime deps shared by the tool and the command. */
|
|
2647
|
+
export interface RuntimeInput {
|
|
2648
|
+
/** The configured lakes; omit for the legacy single-lake seams. */
|
|
2649
|
+
lakes?: readonly Lake[];
|
|
2650
|
+
/** Collects the lake docs; legacy seam. */
|
|
2651
|
+
collect?: () => DocEntry[];
|
|
2652
|
+
/** Collects everything searchable — lake docs plus local READMEs. */
|
|
2653
|
+
search?: () => DocEntry[];
|
|
2654
|
+
/** Whether a root-relative doc path exists; legacy seam. */
|
|
2655
|
+
exists?: (path: string) => boolean;
|
|
2656
|
+
/** Writes a root-relative doc path; legacy seam. */
|
|
2657
|
+
write?: (path: string, contents: string) => void;
|
|
2658
|
+
/** Reads a root-relative doc path's raw contents; legacy seam. */
|
|
2659
|
+
read?: (path: string) => string;
|
|
2660
|
+
/** Environment notes appended to the `check` report; legacy seam. */
|
|
1400
2661
|
notes?: readonly string[];
|
|
2662
|
+
/** The soft body-line limit; defaults to {@link DEFAULT_MAX_DOC_LINES}. */
|
|
2663
|
+
maxDocLines?: number;
|
|
2664
|
+
/** The per-source result reservation; defaults to {@link DEFAULT_PER_LAKE_QUOTA}. */
|
|
2665
|
+
perLakeQuota?: number;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/** A resolved runtime: lakes plus the search and check knobs. */
|
|
2669
|
+
interface Runtime {
|
|
2670
|
+
lakes: readonly Lake[];
|
|
2671
|
+
search: () => DocEntry[];
|
|
2672
|
+
maxDocLines: number;
|
|
2673
|
+
perLakeQuota: number;
|
|
1401
2674
|
}
|
|
1402
2675
|
|
|
1403
|
-
/**
|
|
2676
|
+
/** The legacy single-lake closure wrapped as a {@link Lake}. */
|
|
2677
|
+
function legacyLake(deps: RuntimeInput): Lake {
|
|
2678
|
+
const collect = deps.collect ?? deps.search ?? (() => []);
|
|
2679
|
+
return {
|
|
2680
|
+
name: "docs",
|
|
2681
|
+
class: "repo",
|
|
2682
|
+
root: "",
|
|
2683
|
+
profile: "knowledge",
|
|
2684
|
+
writable: true,
|
|
2685
|
+
collect,
|
|
2686
|
+
read: deps.read ?? (() => ""),
|
|
2687
|
+
exists: deps.exists ?? (() => false),
|
|
2688
|
+
write: deps.write ?? (() => {}),
|
|
2689
|
+
notes: deps.notes ?? [],
|
|
2690
|
+
linkCheck: deps.exists !== undefined,
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/** Normalise the old closure seams and the lake seams into one runtime. */
|
|
2695
|
+
function runtimeOf(deps: RuntimeInput): Runtime {
|
|
2696
|
+
const lakes = deps.lakes ?? [legacyLake(deps)];
|
|
2697
|
+
return {
|
|
2698
|
+
lakes,
|
|
2699
|
+
search: deps.search ?? (() => lakes.flatMap((lake) => lake.collect())),
|
|
2700
|
+
maxDocLines: deps.maxDocLines ?? DEFAULT_MAX_DOC_LINES,
|
|
2701
|
+
perLakeQuota: deps.perLakeQuota ?? DEFAULT_PER_LAKE_QUOTA,
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/** The first repo lake, else the first lake; the one rendered without a prefix. */
|
|
2706
|
+
function primaryLakeName(lakes: readonly Lake[]): string | undefined {
|
|
2707
|
+
return (lakes.find((lake) => lake.class === "repo") ?? lakes[0])?.name;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/** Find a lake by name. */
|
|
2711
|
+
function findLake(lakes: readonly Lake[], name: string): Lake | undefined {
|
|
2712
|
+
return lakes.find((lake) => lake.name === name);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/** The lake `add` writes to, or the refusal reason. */
|
|
2716
|
+
function writableLake(
|
|
2717
|
+
lakes: readonly Lake[],
|
|
2718
|
+
name: string | undefined,
|
|
2719
|
+
): Lake | { reason: string } {
|
|
2720
|
+
if (name !== undefined) {
|
|
2721
|
+
const lake = findLake(lakes, name);
|
|
2722
|
+
if (lake === undefined) {
|
|
2723
|
+
return { reason: `${REFUSE} no lake named '${name}'.` };
|
|
2724
|
+
}
|
|
2725
|
+
if (!lake.writable) {
|
|
2726
|
+
return {
|
|
2727
|
+
reason: `${REFUSE} lake '${name}' is read-only (${lake.profile}).`,
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
return lake;
|
|
2731
|
+
}
|
|
2732
|
+
return (
|
|
2733
|
+
lakes.find((lake) => lake.class === "repo" && lake.writable) ??
|
|
2734
|
+
lakes.find((lake) => lake.profile === "knowledge" && lake.writable) ?? {
|
|
2735
|
+
reason:
|
|
2736
|
+
`${REFUSE} no writable knowledge lake is configured; ` +
|
|
2737
|
+
"add a repo, machine, or global lake.",
|
|
2738
|
+
}
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/** Qualify a path with its lake when more than one lake is configured. */
|
|
2743
|
+
function address(lake: Lake, path: string, lakes: readonly Lake[]): string {
|
|
2744
|
+
return lakes.length > 1 ? `${lake.name}:${path}` : path;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
/** Search every lake, optionally limited to one, and render the ranked result. */
|
|
2748
|
+
function searchDocs(
|
|
2749
|
+
runtime: Runtime,
|
|
2750
|
+
query: string,
|
|
2751
|
+
lakeName: string | undefined,
|
|
2752
|
+
): { content: string; count: number; omitted: number } {
|
|
2753
|
+
const all = runtime.search();
|
|
2754
|
+
let entries = all;
|
|
2755
|
+
if (lakeName !== undefined) {
|
|
2756
|
+
if (lakeName === "instructions") {
|
|
2757
|
+
entries = all.filter((entry) => entry.class === "instructions");
|
|
2758
|
+
} else if (findLake(runtime.lakes, lakeName) === undefined) {
|
|
2759
|
+
return {
|
|
2760
|
+
content: `No lake named '${lakeName}'.`,
|
|
2761
|
+
count: 0,
|
|
2762
|
+
omitted: 0,
|
|
2763
|
+
};
|
|
2764
|
+
} else {
|
|
2765
|
+
entries = all.filter((entry) => entry.lake === lakeName);
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
const {
|
|
2769
|
+
entries: ranked,
|
|
2770
|
+
closest,
|
|
2771
|
+
omitted,
|
|
2772
|
+
} = rankDocs(entries, query, { perLakeQuota: runtime.perLakeQuota });
|
|
2773
|
+
const content = render(ranked, {
|
|
2774
|
+
closest,
|
|
2775
|
+
omitted,
|
|
2776
|
+
primaryLake: primaryLakeName(runtime.lakes) ?? "",
|
|
2777
|
+
});
|
|
2778
|
+
// A dormant lake contributes nothing, so without the note a typo'd root
|
|
2779
|
+
// looks like an empty lake rather than a broken path.
|
|
2780
|
+
const missing = runtime.lakes
|
|
2781
|
+
.filter(
|
|
2782
|
+
(lake) =>
|
|
2783
|
+
lake.root !== "" &&
|
|
2784
|
+
(lakeName === undefined || lakeName === lake.name) &&
|
|
2785
|
+
!existsSync(lake.root),
|
|
2786
|
+
)
|
|
2787
|
+
.map((lake) => missingRootNote(lake.root, lake.name));
|
|
2788
|
+
return {
|
|
2789
|
+
content:
|
|
2790
|
+
missing.length > 0
|
|
2791
|
+
? `${content}\n\n${missing.join("\n")}`
|
|
2792
|
+
: content,
|
|
2793
|
+
count: ranked.length,
|
|
2794
|
+
omitted,
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/** Check the knowledge lakes, optionally limited to one. */
|
|
2799
|
+
function checkLakes(
|
|
2800
|
+
runtime: Runtime,
|
|
2801
|
+
lakeName: string | undefined,
|
|
2802
|
+
): { content: string; checked: number } {
|
|
2803
|
+
if (lakeName !== undefined) {
|
|
2804
|
+
const lake = findLake(runtime.lakes, lakeName);
|
|
2805
|
+
if (lake === undefined) {
|
|
2806
|
+
return { content: `No lake named '${lakeName}'.`, checked: 0 };
|
|
2807
|
+
}
|
|
2808
|
+
if (lake.profile === "wiki") {
|
|
2809
|
+
return {
|
|
2810
|
+
content:
|
|
2811
|
+
`Lake '${lake.name}' is a wiki lake — read-only reference, ` +
|
|
2812
|
+
"not validated.",
|
|
2813
|
+
checked: 0,
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
const entries = lake.collect();
|
|
2817
|
+
return {
|
|
2818
|
+
content: checkDocs(entries, lake.read, lake.notes, {
|
|
2819
|
+
maxDocLines: runtime.maxDocLines,
|
|
2820
|
+
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
2821
|
+
}),
|
|
2822
|
+
checked: entries.length,
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
const knowledge = runtime.lakes.filter(
|
|
2826
|
+
(lake) => lake.profile === "knowledge",
|
|
2827
|
+
);
|
|
2828
|
+
let checked = 0;
|
|
2829
|
+
const reports = knowledge.map((lake) => {
|
|
2830
|
+
const entries = lake.collect();
|
|
2831
|
+
checked += entries.length;
|
|
2832
|
+
const report = checkDocs(entries, lake.read, lake.notes, {
|
|
2833
|
+
maxDocLines: runtime.maxDocLines,
|
|
2834
|
+
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
2835
|
+
});
|
|
2836
|
+
return knowledge.length > 1 || runtime.lakes.length > 1
|
|
2837
|
+
? `${lake.name}:\n${report}`
|
|
2838
|
+
: report;
|
|
2839
|
+
});
|
|
2840
|
+
const wiki = runtime.lakes
|
|
2841
|
+
.filter((lake) => lake.profile === "wiki")
|
|
2842
|
+
.map((lake) => lake.name);
|
|
2843
|
+
if (wiki.length > 0) {
|
|
2844
|
+
reports.push(
|
|
2845
|
+
`note: wiki lakes are read-only reference and are not validated: ` +
|
|
2846
|
+
`${wiki.join(", ")}.`,
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
return {
|
|
2850
|
+
content:
|
|
2851
|
+
reports.length > 0
|
|
2852
|
+
? reports.join("\n\n")
|
|
2853
|
+
: "No knowledge lakes configured.",
|
|
2854
|
+
checked,
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/** Add a doc to the target lake. */
|
|
2859
|
+
function addDoc(
|
|
2860
|
+
runtime: Runtime,
|
|
2861
|
+
add: DocsAdd,
|
|
2862
|
+
lakeName: string | undefined,
|
|
2863
|
+
): { content: string; written: boolean } {
|
|
2864
|
+
const target = writableLake(runtime.lakes, lakeName);
|
|
2865
|
+
if ("reason" in target) {
|
|
2866
|
+
return { content: target.reason, written: false };
|
|
2867
|
+
}
|
|
2868
|
+
// A typo'd root must not bootstrap a new tree; the area directories below
|
|
2869
|
+
// an existing root still create on write.
|
|
2870
|
+
if (target.root !== "" && !existsSync(target.root)) {
|
|
2871
|
+
return {
|
|
2872
|
+
content:
|
|
2873
|
+
`${REFUSE} lake '${target.name}' root ${target.root} does ` +
|
|
2874
|
+
"not exist — create it first.",
|
|
2875
|
+
written: false,
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
const entries = target.collect();
|
|
2879
|
+
const outcome = prepareAdd(add, entries, {
|
|
2880
|
+
maxDocLines: runtime.maxDocLines,
|
|
2881
|
+
});
|
|
2882
|
+
if (outcome.action === "refuse") {
|
|
2883
|
+
return { content: outcome.reason, written: false };
|
|
2884
|
+
}
|
|
2885
|
+
let updated: boolean;
|
|
2886
|
+
try {
|
|
2887
|
+
updated = target.exists(outcome.path);
|
|
2888
|
+
target.write(outcome.path, outcome.contents);
|
|
2889
|
+
} catch (error) {
|
|
2890
|
+
if (error instanceof UnsafePathError) {
|
|
2891
|
+
return {
|
|
2892
|
+
content: `${REFUSE} ${error.message}`,
|
|
2893
|
+
written: false,
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
throw error;
|
|
2897
|
+
}
|
|
2898
|
+
const head = `${address(target, outcome.path, runtime.lakes)} — ${
|
|
2899
|
+
updated ? "updated" : "created"
|
|
2900
|
+
}.`;
|
|
2901
|
+
// The path and outcome are the answer; a list of same-area neighbours was
|
|
2902
|
+
// unsolicited context that grew with the area and was picked by collection
|
|
2903
|
+
// order, not relevance. Refusals still carry their candidates.
|
|
2904
|
+
const notes =
|
|
2905
|
+
outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
|
|
2906
|
+
return { content: `${head}${notes}`, written: true };
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/** A parsed read request: the lake (when known) and the path. */
|
|
2910
|
+
interface ReadTarget {
|
|
2911
|
+
lake?: Lake;
|
|
2912
|
+
path: string;
|
|
2913
|
+
reason?: string;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/** Split `lake:path` or an explicit lake selector into a target. */
|
|
2917
|
+
function readTargetOf(
|
|
2918
|
+
runtime: Runtime,
|
|
2919
|
+
given: string,
|
|
2920
|
+
lakeName: string | undefined,
|
|
2921
|
+
): ReadTarget {
|
|
2922
|
+
const path = given.trim();
|
|
2923
|
+
if (lakeName !== undefined) {
|
|
2924
|
+
const lake = findLake(runtime.lakes, lakeName);
|
|
2925
|
+
const prefix = `${lakeName}:`;
|
|
2926
|
+
const selectedPath = path.startsWith(prefix)
|
|
2927
|
+
? path.slice(prefix.length)
|
|
2928
|
+
: path;
|
|
2929
|
+
return lake === undefined
|
|
2930
|
+
? { path, reason: `${REFUSE} no lake named '${lakeName}'.` }
|
|
2931
|
+
: { lake, path: selectedPath };
|
|
2932
|
+
}
|
|
2933
|
+
const colon = path.indexOf(":");
|
|
2934
|
+
if (colon <= 0) {
|
|
2935
|
+
return { path };
|
|
2936
|
+
}
|
|
2937
|
+
if (path.startsWith("instructions:")) {
|
|
2938
|
+
return {
|
|
2939
|
+
path,
|
|
2940
|
+
reason:
|
|
2941
|
+
`${REFUSE} instructions: paths are project READMEs; ` +
|
|
2942
|
+
"use the built-in read tool.",
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
const lake = findLake(runtime.lakes, path.slice(0, colon));
|
|
2946
|
+
return lake === undefined
|
|
2947
|
+
? { path }
|
|
2948
|
+
: { lake, path: path.slice(colon + 1) };
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/** The unique lake holding a path, or the refusal reason. */
|
|
2952
|
+
function uniqueHolder(
|
|
2953
|
+
runtime: Runtime,
|
|
2954
|
+
path: string,
|
|
2955
|
+
): Lake | { reason: string } {
|
|
2956
|
+
const holders = runtime.lakes.filter((lake) => lake.exists(path));
|
|
2957
|
+
if (holders.length === 0) {
|
|
2958
|
+
return { reason: `No doc at ${path}.` };
|
|
2959
|
+
}
|
|
2960
|
+
if (holders.length > 1) {
|
|
2961
|
+
return {
|
|
2962
|
+
reason:
|
|
2963
|
+
`${REFUSE} ${path} exists in multiple lakes ` +
|
|
2964
|
+
`(${holders.map((lake) => lake.name).join(", ")}); pass lake.`,
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
return holders[0] as Lake;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
/** Read one doc, resolving a `lake:path` prefix or the unique match. */
|
|
2971
|
+
function readDoc(
|
|
2972
|
+
runtime: Runtime,
|
|
2973
|
+
given: string,
|
|
2974
|
+
lakeName: string | undefined,
|
|
2975
|
+
): { content: string; read: string | null } {
|
|
2976
|
+
const target = readTargetOf(runtime, given, lakeName);
|
|
2977
|
+
if (target.reason !== undefined) {
|
|
2978
|
+
return { content: target.reason, read: null };
|
|
2979
|
+
}
|
|
2980
|
+
const guarded = explicitTarget(target.path);
|
|
2981
|
+
if ("reason" in guarded) {
|
|
2982
|
+
return {
|
|
2983
|
+
content: `${REFUSE} ${guarded.reason}`,
|
|
2984
|
+
read: null,
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
const selected = target.lake ?? uniqueHolder(runtime, guarded.path);
|
|
2988
|
+
if ("reason" in selected) {
|
|
2989
|
+
return { content: selected.reason, read: null };
|
|
2990
|
+
}
|
|
2991
|
+
if (!selected.exists(guarded.path)) {
|
|
2992
|
+
return {
|
|
2993
|
+
content: `No doc at ${address(
|
|
2994
|
+
selected,
|
|
2995
|
+
guarded.path,
|
|
2996
|
+
runtime.lakes,
|
|
2997
|
+
)}.`,
|
|
2998
|
+
read: null,
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
const raw = selected.read(guarded.path);
|
|
3002
|
+
const { body } = parseFrontMatter(raw);
|
|
3003
|
+
const label = address(selected, guarded.path, runtime.lakes);
|
|
3004
|
+
return {
|
|
3005
|
+
content: `${label} — ${titleOf(guarded.path, body)}\n\n${raw}`,
|
|
3006
|
+
read: label,
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/** The lake help view shared by the tool and command. */
|
|
3011
|
+
function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
|
|
3012
|
+
return lakes.map((lake) => ({
|
|
3013
|
+
name: lake.name,
|
|
3014
|
+
class: lake.class,
|
|
3015
|
+
root: lake.root,
|
|
3016
|
+
writable: lake.writable,
|
|
3017
|
+
validated: lake.profile === "knowledge",
|
|
3018
|
+
missing: lake.root !== "" && !existsSync(lake.root),
|
|
3019
|
+
}));
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
/** Deliver a rendered result to a session. */
|
|
1404
3023
|
export type PromptSender = (
|
|
1405
3024
|
sessionID: CommandInvocation["sessionID"],
|
|
1406
3025
|
text: string,
|
|
1407
3026
|
) => Promise<unknown>;
|
|
1408
3027
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
/** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
|
|
1415
|
-
read: (path: string) => string;
|
|
1416
|
-
/** Environment notes appended to the `check` report; injected for testability. */
|
|
1417
|
-
notes?: readonly string[];
|
|
3028
|
+
/** Tool deps: lakes (or the legacy single-lake closures) plus knobs. */
|
|
3029
|
+
export type DocsToolDeps = RuntimeInput;
|
|
3030
|
+
|
|
3031
|
+
/** Command deps: tool deps plus the session prompt sender. */
|
|
3032
|
+
export interface DocsCommandDeps extends RuntimeInput {
|
|
1418
3033
|
/** Delivers the rendered result to the session. */
|
|
1419
3034
|
prompt: PromptSender;
|
|
1420
3035
|
}
|
|
@@ -1425,12 +3040,11 @@ export interface DocsCommandDeps {
|
|
|
1425
3040
|
* the full doctrine the native `docs` description carries.
|
|
1426
3041
|
*/
|
|
1427
3042
|
const TWIN_DESCRIPTION =
|
|
1428
|
-
"Search, read, add and check the knowledge
|
|
1429
|
-
"
|
|
1430
|
-
"
|
|
1431
|
-
"
|
|
1432
|
-
"
|
|
1433
|
-
"convention.";
|
|
3043
|
+
"Search, read, add and check the configured knowledge lakes and nested " +
|
|
3044
|
+
"project `README.md` files (search-only) from inside `execute` — the `docs` " +
|
|
3045
|
+
"tool's Code Mode twin, same verbs and lakes. A blank search lists recent " +
|
|
3046
|
+
"docs; `read` returns a matched doc verbatim; `add` writes through a typed " +
|
|
3047
|
+
"DTO; `check` validates the lakes.";
|
|
1434
3048
|
|
|
1435
3049
|
/**
|
|
1436
3050
|
* Build the `docs` tool from its dependencies.
|
|
@@ -1443,8 +3057,7 @@ const TWIN_DESCRIPTION =
|
|
|
1443
3057
|
* (the catalog renders `tools.<namespace>.<name>(…)`). Pass a distinct
|
|
1444
3058
|
* `name` and `codemode: true` to register a Code Mode twin of the same core;
|
|
1445
3059
|
* the registry is name-keyed, so the twin cannot also be called `docs`.
|
|
1446
|
-
* @param deps Runtime seams used on each call
|
|
1447
|
-
* write seam {@link DocsToolDeps} for `add`.
|
|
3060
|
+
* @param deps Runtime seams used on each call.
|
|
1448
3061
|
* @param options Registration shape.
|
|
1449
3062
|
* @param options.name The tool name (default `"docs"`); a Code Mode twin
|
|
1450
3063
|
* passes a distinct name — the registry is name-keyed, so two tools cannot
|
|
@@ -1458,58 +3071,18 @@ export function createDocsTool(
|
|
|
1458
3071
|
options: { name?: string; codemode?: boolean } = {},
|
|
1459
3072
|
): Info<typeof DOCS_INPUT> {
|
|
1460
3073
|
const { name = "docs", codemode = false } = options;
|
|
1461
|
-
const
|
|
1462
|
-
const outcome = prepareAdd(add, deps.collect());
|
|
1463
|
-
if (outcome.action === "refuse") {
|
|
1464
|
-
return { content: outcome.reason, metadata: { written: false } };
|
|
1465
|
-
}
|
|
1466
|
-
const updated = deps.exists(outcome.path);
|
|
1467
|
-
deps.write(outcome.path, outcome.contents);
|
|
1468
|
-
const head = `${outcome.path} — ${updated ? "updated" : "created"}.`;
|
|
1469
|
-
const neighbours =
|
|
1470
|
-
outcome.neighbours.length > 0
|
|
1471
|
-
? `\n\nNearby docs in the same area:\n\n${render(outcome.neighbours)}`
|
|
1472
|
-
: "";
|
|
1473
|
-
return {
|
|
1474
|
-
content: `${head}${neighbours}`,
|
|
1475
|
-
metadata: { written: true },
|
|
1476
|
-
};
|
|
1477
|
-
};
|
|
1478
|
-
const readDoc = (given: string) => {
|
|
1479
|
-
// The same guard an explicit `add` path passes: relative, inside the
|
|
1480
|
-
// root, a `.md` file — the read verb never widens the surface.
|
|
1481
|
-
const target = explicitTarget(given.trim());
|
|
1482
|
-
if ("reason" in target) {
|
|
1483
|
-
return {
|
|
1484
|
-
content: `${REFUSE} ${target.reason}`,
|
|
1485
|
-
metadata: { read: null },
|
|
1486
|
-
};
|
|
1487
|
-
}
|
|
1488
|
-
if (!deps.exists(target.path)) {
|
|
1489
|
-
return {
|
|
1490
|
-
content: `No doc at ${target.path}.`,
|
|
1491
|
-
metadata: { read: null },
|
|
1492
|
-
};
|
|
1493
|
-
}
|
|
1494
|
-
const raw = deps.read(target.path);
|
|
1495
|
-
const { body } = parseFrontMatter(raw);
|
|
1496
|
-
return {
|
|
1497
|
-
content: `${target.path} — ${titleOf(target.path, body)}\n\n${raw}`,
|
|
1498
|
-
metadata: { read: target.path },
|
|
1499
|
-
};
|
|
1500
|
-
};
|
|
3074
|
+
const runtime = runtimeOf(deps);
|
|
1501
3075
|
return {
|
|
1502
3076
|
name,
|
|
1503
3077
|
description: codemode
|
|
1504
3078
|
? TWIN_DESCRIPTION
|
|
1505
|
-
: "
|
|
1506
|
-
"
|
|
1507
|
-
"
|
|
1508
|
-
"
|
|
1509
|
-
|
|
1510
|
-
"
|
|
1511
|
-
|
|
1512
|
-
" results and counts the rest. Search exactly `help` explains the ranking. `read` returns a matched doc verbatim (front-matter and body). `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. Local READMEs are search-only: `add` and `check` ignore them. Read a matched project file with the built-in read tool.",
|
|
3079
|
+
: "Search the configured knowledge lakes and nested project " +
|
|
3080
|
+
"`README.md` files before a broad grep for how-to knowledge, " +
|
|
3081
|
+
"decisions or known problems. A blank search lists recent docs; " +
|
|
3082
|
+
"`help` shows the lakes and ranking. `read` returns a lake doc " +
|
|
3083
|
+
"verbatim; project `instructions:` results open with the built-in " +
|
|
3084
|
+
"read tool. `add` writes or replaces a knowledge doc; `check` " +
|
|
3085
|
+
"audits convention compliance.",
|
|
1513
3086
|
input: DOCS_INPUT,
|
|
1514
3087
|
options: codemode
|
|
1515
3088
|
? { namespace: "docs", codemode: true }
|
|
@@ -1517,31 +3090,39 @@ export function createDocsTool(
|
|
|
1517
3090
|
async execute(input) {
|
|
1518
3091
|
const request = resolveRequest(input);
|
|
1519
3092
|
if (request.verb === "add") {
|
|
1520
|
-
|
|
3093
|
+
const result = addDoc(runtime, request.add, request.lake);
|
|
3094
|
+
return {
|
|
3095
|
+
content: result.content,
|
|
3096
|
+
metadata: { written: result.written },
|
|
3097
|
+
};
|
|
1521
3098
|
}
|
|
1522
3099
|
if (request.verb === "check") {
|
|
1523
|
-
const
|
|
3100
|
+
const result = checkLakes(runtime, request.lake);
|
|
1524
3101
|
return {
|
|
1525
|
-
content:
|
|
1526
|
-
metadata: { checked:
|
|
3102
|
+
content: result.content,
|
|
3103
|
+
metadata: { checked: result.checked },
|
|
1527
3104
|
};
|
|
1528
3105
|
}
|
|
1529
3106
|
if (request.verb === "read") {
|
|
1530
|
-
|
|
3107
|
+
const result = readDoc(runtime, request.read, request.lake);
|
|
3108
|
+
return {
|
|
3109
|
+
content: result.content,
|
|
3110
|
+
metadata: { read: result.read },
|
|
3111
|
+
};
|
|
1531
3112
|
}
|
|
1532
3113
|
if (request.verb === "help") {
|
|
1533
3114
|
return {
|
|
1534
|
-
content:
|
|
3115
|
+
content: helpText(lakeInfo(runtime.lakes)),
|
|
1535
3116
|
metadata: { count: 0, omitted: 0 },
|
|
1536
3117
|
};
|
|
1537
3118
|
}
|
|
1538
|
-
const
|
|
1539
|
-
(deps.search ?? deps.collect)(),
|
|
1540
|
-
request.search,
|
|
1541
|
-
);
|
|
3119
|
+
const result = searchDocs(runtime, request.search, request.lake);
|
|
1542
3120
|
return {
|
|
1543
|
-
content:
|
|
1544
|
-
metadata: {
|
|
3121
|
+
content: result.content,
|
|
3122
|
+
metadata: {
|
|
3123
|
+
count: result.count,
|
|
3124
|
+
omitted: result.omitted,
|
|
3125
|
+
},
|
|
1545
3126
|
};
|
|
1546
3127
|
},
|
|
1547
3128
|
};
|
|
@@ -1564,7 +3145,7 @@ function addPrompt(subject: string): string {
|
|
|
1564
3145
|
return "`/docs add` needs the text to record: `/docs add <fact>`.";
|
|
1565
3146
|
}
|
|
1566
3147
|
return (
|
|
1567
|
-
"Record this as durable knowledge in the docs
|
|
3148
|
+
"Record this as durable knowledge in the docs lakes: call the `docs` " +
|
|
1568
3149
|
"tool's `add` with a typed DTO (metadata Area/Keywords/Summary, body starting " +
|
|
1569
3150
|
"with an H1) for:\n\n" +
|
|
1570
3151
|
subject
|
|
@@ -1575,21 +3156,20 @@ function addPrompt(subject: string): string {
|
|
|
1575
3156
|
* Build the `/docs` command over the same core as the tool: a search ranks the
|
|
1576
3157
|
* catalogue (`search <text>`, or bare text), `add <text>` asks the agent to
|
|
1577
3158
|
* write the knowledge through the tool's typed `add`, and `check` validates
|
|
1578
|
-
* the
|
|
3159
|
+
* the knowledge lakes.
|
|
1579
3160
|
* @param deps Runtime seams used on each call.
|
|
1580
3161
|
* @returns The command the host registers.
|
|
1581
3162
|
*/
|
|
1582
3163
|
export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
3164
|
+
const runtime = runtimeOf(deps);
|
|
1583
3165
|
return {
|
|
1584
3166
|
name: "docs",
|
|
1585
3167
|
description:
|
|
1586
|
-
"Search the
|
|
1587
|
-
MAX_RESULTS +
|
|
1588
|
-
" most recently changed.",
|
|
3168
|
+
"Search the configured knowledge lakes, ranked, plus nested project `README.md` files (search only); `add <text>` records new knowledge through the tool's typed `add`; `check` validates the lakes; a blank search lists recent docs.",
|
|
1589
3169
|
async execute({ sessionID, prompt }) {
|
|
1590
3170
|
const text = prompt.text.trim();
|
|
1591
3171
|
if (isHelp(text)) {
|
|
1592
|
-
await deps.prompt(sessionID,
|
|
3172
|
+
await deps.prompt(sessionID, helpText(lakeInfo(runtime.lakes)));
|
|
1593
3173
|
return;
|
|
1594
3174
|
}
|
|
1595
3175
|
const [verb, ...rest] = text.split(/\s+/);
|
|
@@ -1601,22 +3181,19 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
1601
3181
|
if (lower === CHECK_VERB) {
|
|
1602
3182
|
await deps.prompt(
|
|
1603
3183
|
sessionID,
|
|
1604
|
-
|
|
3184
|
+
checkLakes(runtime, undefined).content,
|
|
1605
3185
|
);
|
|
1606
3186
|
return;
|
|
1607
3187
|
}
|
|
1608
3188
|
const query = lower === SEARCH_VERB ? rest.join(" ") : text;
|
|
1609
|
-
const
|
|
1610
|
-
|
|
1611
|
-
query,
|
|
1612
|
-
);
|
|
1613
|
-
await deps.prompt(sessionID, render(entries, { closest, omitted }));
|
|
3189
|
+
const result = searchDocs(runtime, query, undefined);
|
|
3190
|
+
await deps.prompt(sessionID, result.content);
|
|
1614
3191
|
},
|
|
1615
3192
|
};
|
|
1616
3193
|
}
|
|
1617
3194
|
|
|
1618
3195
|
/**
|
|
1619
|
-
* Resolve
|
|
3196
|
+
* Resolve a lake root from its `root` option. Unset → the
|
|
1620
3197
|
* location's `docs/` directory — a blank string counts as unset, since it is
|
|
1621
3198
|
* almost always an empty environment variable or half-finished config rather
|
|
1622
3199
|
* than a deliberate path; `~` or `~/…` expands against the home
|
|
@@ -1626,7 +3203,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
1626
3203
|
* must fail loudly, not silently fall back to the wrong tree.
|
|
1627
3204
|
* @param configured The raw option value as the host delivers it.
|
|
1628
3205
|
* @param locationDirectory The directory the plugin instance loaded in.
|
|
1629
|
-
* @returns The
|
|
3206
|
+
* @returns The lake root.
|
|
1630
3207
|
*/
|
|
1631
3208
|
export function resolveRoot(
|
|
1632
3209
|
configured: unknown,
|
|
@@ -1653,13 +3230,40 @@ export function resolveRoot(
|
|
|
1653
3230
|
}
|
|
1654
3231
|
|
|
1655
3232
|
/**
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
* @param root The
|
|
1660
|
-
* @
|
|
3233
|
+
* The advisory note for a lake root that is absent. A missing root is a
|
|
3234
|
+
* configuration smell, not a failure: the lake stays dormant until the
|
|
3235
|
+
* directory exists, and `add` refuses rather than creating it.
|
|
3236
|
+
* @param root The resolved lake root.
|
|
3237
|
+
* @param lake The lake name, when known.
|
|
3238
|
+
* @returns The note line.
|
|
3239
|
+
*/
|
|
3240
|
+
export function missingRootNote(root: string, lake?: string): string {
|
|
3241
|
+
const subject =
|
|
3242
|
+
lake === undefined
|
|
3243
|
+
? `lake root ${root}`
|
|
3244
|
+
: `lake '${lake}' root ${root}`;
|
|
3245
|
+
return (
|
|
3246
|
+
`note: ${subject} does not exist — it is ignored until it does. ` +
|
|
3247
|
+
"A missing root is not an error."
|
|
3248
|
+
);
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
/**
|
|
3252
|
+
* The environment notes for a lake root: missing, or unversioned. An absent
|
|
3253
|
+
* root is flagged but tolerated; an unversioned root still works — dates fall
|
|
3254
|
+
* back to file mtimes and the knowledge has no history — so it is surfaced,
|
|
3255
|
+
* not refused.
|
|
3256
|
+
* @param root The lake root to probe.
|
|
3257
|
+
* @param lake The lake name, when known.
|
|
3258
|
+
* @returns The note lines; empty when the root is present and version-controlled.
|
|
1661
3259
|
*/
|
|
1662
|
-
export function unversionedNote(
|
|
3260
|
+
export function unversionedNote(
|
|
3261
|
+
root: string,
|
|
3262
|
+
lake?: string,
|
|
3263
|
+
): readonly string[] {
|
|
3264
|
+
if (!existsSync(root)) {
|
|
3265
|
+
return [missingRootNote(root, lake)];
|
|
3266
|
+
}
|
|
1663
3267
|
const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
1664
3268
|
cwd: root,
|
|
1665
3269
|
});
|
|
@@ -1667,24 +3271,12 @@ export function unversionedNote(root: string): readonly string[] {
|
|
|
1667
3271
|
return [];
|
|
1668
3272
|
}
|
|
1669
3273
|
return [
|
|
1670
|
-
"note: the
|
|
3274
|
+
"note: the lake root is not inside a git work tree — dates fall " +
|
|
1671
3275
|
"back to file mtimes and the knowledge has no history. Put the " +
|
|
1672
3276
|
"root under version control to fix both.",
|
|
1673
3277
|
];
|
|
1674
3278
|
}
|
|
1675
3279
|
|
|
1676
|
-
/**
|
|
1677
|
-
* The docs plugin's options, as the host delivers them. The framework types
|
|
1678
|
-
* `ctx.options` as `Readonly<Record<string, any>>`, so the shape the plugin
|
|
1679
|
-
* reads is declared here.
|
|
1680
|
-
*/
|
|
1681
|
-
interface DocsPluginOptions {
|
|
1682
|
-
/** The docs root override; defaults to `docs` beside the plugin. */
|
|
1683
|
-
root?: unknown;
|
|
1684
|
-
/** The runtime nudges' switch; defaults to on. */
|
|
1685
|
-
nudge?: unknown;
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
3280
|
/** The registered tool names that count as using the lake. */
|
|
1689
3281
|
const DOCS_TOOL_NAMES = new Set(["docs", "docs_search"]);
|
|
1690
3282
|
|
|
@@ -1707,36 +3299,38 @@ function appendLine(
|
|
|
1707
3299
|
export default Plugin.define({
|
|
1708
3300
|
id: "docs",
|
|
1709
3301
|
async setup(ctx) {
|
|
1710
|
-
const options = ctx.options as DocsPluginOptions
|
|
1711
|
-
const
|
|
1712
|
-
const
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
3302
|
+
const options = (ctx.options ?? {}) as DocsPluginOptions;
|
|
3303
|
+
const nudge = resolveNudge(options.nudge);
|
|
3304
|
+
const maxDocLines = resolveNonNegativeInt(
|
|
3305
|
+
options.maxDocLines,
|
|
3306
|
+
"maxDocLines",
|
|
3307
|
+
DEFAULT_MAX_DOC_LINES,
|
|
3308
|
+
);
|
|
3309
|
+
const perLakeQuota = resolveNonNegativeInt(
|
|
3310
|
+
options.perLakeQuota,
|
|
3311
|
+
"perLakeQuota",
|
|
3312
|
+
DEFAULT_PER_LAKE_QUOTA,
|
|
3313
|
+
);
|
|
3314
|
+
const lakes = createLakes(
|
|
3315
|
+
resolveLakes(options, ctx.location.directory),
|
|
3316
|
+
);
|
|
1716
3317
|
const search = () => [
|
|
1717
|
-
...collect(),
|
|
3318
|
+
...lakes.flatMap((lake) => lake.collect()),
|
|
1718
3319
|
...collectReadmes(
|
|
1719
3320
|
ctx.location.directory,
|
|
1720
3321
|
(paths) => fallbackDates(ctx.location.directory, paths),
|
|
1721
|
-
|
|
3322
|
+
lakes.map((lake) => lake.root),
|
|
1722
3323
|
),
|
|
1723
3324
|
];
|
|
1724
|
-
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
|
1725
3325
|
const toolDeps = {
|
|
1726
|
-
|
|
3326
|
+
lakes,
|
|
1727
3327
|
search,
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
const target = join(root, path);
|
|
1731
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
1732
|
-
writeFileSync(target, contents);
|
|
1733
|
-
},
|
|
1734
|
-
read,
|
|
1735
|
-
notes,
|
|
3328
|
+
maxDocLines,
|
|
3329
|
+
perLakeQuota,
|
|
1736
3330
|
};
|
|
1737
3331
|
// Two registrations of one core: the native `docs` for direct calls,
|
|
1738
3332
|
// and the Code Mode twin `docs_search` so `execute` reaches the same
|
|
1739
|
-
//
|
|
3333
|
+
// lakes as `tools.docs.docs_search(…)`. The registry is name-keyed, so
|
|
1740
3334
|
// the twin must carry its own name.
|
|
1741
3335
|
await ctx.tool.transform((editor) => {
|
|
1742
3336
|
editor.add(createDocsTool(toolDeps));
|
|
@@ -1750,12 +3344,12 @@ export default Plugin.define({
|
|
|
1750
3344
|
await ctx.command.transform((editor) => {
|
|
1751
3345
|
editor.add(
|
|
1752
3346
|
createDocsCommand({
|
|
1753
|
-
|
|
3347
|
+
lakes,
|
|
1754
3348
|
search,
|
|
1755
|
-
|
|
1756
|
-
|
|
3349
|
+
maxDocLines,
|
|
3350
|
+
perLakeQuota,
|
|
1757
3351
|
prompt: (sessionID, text) => {
|
|
1758
|
-
// Any `/docs` use counts as consulting the
|
|
3352
|
+
// Any `/docs` use counts as consulting the lakes.
|
|
1759
3353
|
nudged.add(sessionID);
|
|
1760
3354
|
return ctx.session.prompt({ sessionID, text });
|
|
1761
3355
|
},
|
|
@@ -1814,17 +3408,12 @@ export default Plugin.define({
|
|
|
1814
3408
|
id: Skill.ID.make("docs"),
|
|
1815
3409
|
name: Skill.Name.make("Docs"),
|
|
1816
3410
|
description:
|
|
1817
|
-
"
|
|
1818
|
-
"
|
|
1819
|
-
"README.md files
|
|
1820
|
-
"
|
|
1821
|
-
"
|
|
1822
|
-
"
|
|
1823
|
-
"task names a service, flag or error string the lake " +
|
|
1824
|
-
"may already explain, at the start of unfamiliar work, " +
|
|
1825
|
-
"when hunting for prior decisions or traps, when a " +
|
|
1826
|
-
"session learns something the next one will need, and " +
|
|
1827
|
-
"when closing out a session.",
|
|
3411
|
+
"Entry point to the configured knowledge lakes " +
|
|
3412
|
+
"(repo, machine, global, read-only wiki) and nested project " +
|
|
3413
|
+
"README.md files: find and read the right doc, then record " +
|
|
3414
|
+
"focused knowledge at the narrowest true scope. Use before " +
|
|
3415
|
+
"searching the codebase for how-to knowledge, or when a " +
|
|
3416
|
+
"session learns something durable.",
|
|
1828
3417
|
path: AbsolutePath.make(fileURLToPath(skillURL)),
|
|
1829
3418
|
content: skill,
|
|
1830
3419
|
});
|