@markstanden/opencode-docs-plugin 0.5.0 → 0.6.2

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 CHANGED
@@ -53,11 +53,11 @@ are skipped by name.
53
53
  Every knowledge doc opens with a `---` front-matter block. The fields, in
54
54
  canonical order:
55
55
 
56
- | Field | Required | Meaning |
57
- | ---------- | -------- | ----------------------------------------------------------------------- |
58
- | `Area` | yes | One top-level path segment; the doc's group. |
59
- | `Keywords` | yes | Comma-separated search terms; the second half of the doc's identity. |
60
- | `Summary` | yes | One self-contained sentence: enough to decide whether to open the file. |
56
+ | Field | Required | Meaning |
57
+ | ---------- | -------- | ----------------------------------------------------------------------------------------------------------- |
58
+ | `Area` | yes | One top-level path segment; the doc's group. |
59
+ | `Keywords` | yes | Comma-separated search terms; the second half of the doc's identity. |
60
+ | `Summary` | yes | One self-contained sentence that lets a reader decide whether to open the file — not a second introduction. |
61
61
 
62
62
  `Date` is **derived, never written**: the file's last git commit date, falling
63
63
  back to mtime. A hand-maintained `Date` is a finding, not a field.
@@ -79,6 +79,12 @@ git. Optional invisible metadata may be added later; it is not required.
79
79
  - At least one claim section as an `H2`/`H3` heading — `Facts`, `Inferred`,
80
80
  `Tested`, `Failed approaches`, `Open questions` — so a reader knows how much
81
81
  to rely on each claim. Group claims by section rather than mixing them.
82
+ - Include only the sections the doc actually needs. An empty heading is noise;
83
+ leave it out.
84
+ - Write so the next reader decides fast: lead with the actionable fact, include
85
+ its reason, limits and evidence where they affect its use, and omit discovery
86
+ chronology, repeated conclusions and empty sections. Under `Tested`, record
87
+ the check, its result and the conditions that mattered.
82
88
  - A failed attempt is a valid current doc or section: state what was tried, why
83
89
  it failed, what is true now, and what not to retry. Do not keep a doc whose
84
90
  claims are no longer true.
@@ -92,6 +98,11 @@ git. Optional invisible metadata may be added later; it is not required.
92
98
  `add` refuses a near-twin write and returns the candidates to reconcile
93
99
  against — edit before add, merge near-twins, split a bloated doc into
94
100
  per-topic files.
101
+ - The reconcile applies to a **create**: with `path` omitted, the target is
102
+ derived and the near-twin guard runs. An explicit `path` is the update
103
+ contract and skips it, which is how you replace a doc in place. Passing an
104
+ explicit _new_ path is therefore a deliberate override, not a protected
105
+ write.
95
106
  - Near-twins are per lake. The same topic at repo and global scope is
96
107
  legitimate; the narrower doc states its delta and links up.
97
108
 
@@ -129,7 +140,12 @@ knowledge when it records the outcome and the current truth.
129
140
 
130
141
  ## Adopting it
131
142
 
132
- Run `check` to audit an existing tree, and the gate's `defined comply` before
133
- proposing a commit. A missing lake root is a note, not an error: the plugin
134
- scans what is there and reports per-doc findings for what is not. Wiki lakes
135
- are read-only and are reported as not validated, never silently skipped.
143
+ Run `check` to audit an existing tree, and the project's own tests before
144
+ proposing a commit. `add` enforces the per-doc structural rules at the write
145
+ boundary — front-matter, `H1`, claim section, identity term floor, and the
146
+ near-twin guard on a create — while the cross-document audits (near-twin pairs
147
+ and relative links) stay with `check`. A missing lake root is a note, not an
148
+ error: the plugin scans what is there, search and help flag the absent path, and
149
+ `add` refuses rather than creating a tree at a mistyped root. Create the
150
+ directory deliberately. Wiki lakes are read-only and are reported as not
151
+ validated, never silently skipped.
package/README.md CHANGED
@@ -80,7 +80,7 @@ The simplest setup uses `root` — one repo lake:
80
80
  {
81
81
  "package": "@markstanden/opencode-docs-plugin",
82
82
  "options": {
83
- "root": "~/doclake",
83
+ "root": "./docs",
84
84
  },
85
85
  },
86
86
  ],
@@ -159,6 +159,12 @@ Lake paths never follow symlinks: a symlinked doc is skipped by search and
159
159
  `check`, refused by `read`, and refused by `add`. The configured `root` itself
160
160
  may be a symlink; the content beneath it must be real.
161
161
 
162
+ A missing lake root is flagged, never created. The lake stays dormant until the
163
+ directory exists: `search` appends a note naming the lake and path, `help` marks
164
+ it `— missing`, and `add` refuses with the path rather than bootstrapping a
165
+ tree. Create the directory deliberately. Area folders beneath an existing root
166
+ are still created on write.
167
+
162
168
  `maxDocLines` (default 200, `0` disables) is a **soft** note on a long doc, not a
163
169
  refusal. `perLakeQuota` (default 3, `0` disables) is the per-source result
164
170
  reservation.
@@ -195,7 +201,6 @@ Standalone extract of the docs tooling, under active development.
195
201
  npm install
196
202
  npm test # node --test
197
203
  npm run typecheck
198
- defined comply # the gate: bootstrap, repair and verify
199
204
  ```
200
205
 
201
206
  Tests run the plugin source directly under Node's type-stripping (`noEmit`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markstanden/opencode-docs-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over configurable knowledge lakes (repo, machine, global and read-only wiki), nested README.md search, and the writing convention they share",
6
6
  "repository": {
package/src/SKILL.md CHANGED
@@ -1,13 +1,8 @@
1
1
  # Docs
2
2
 
3
- Durable knowledge lives in **lakes**. The plugin reads every configured lake
4
- and the project's nested `README.md` files. This skill covers both halves of the
5
- loop: **find** (search, then read) and **record** (write or update a focused
6
- doc at the narrowest true scope).
7
-
8
- Call the `docs` tool at top level. Inside `execute` (Code Mode) the same tool
9
- is reachable as the twin `tools.docs.docs_search(…)` — same verbs, same lakes —
10
- so batch it with the rest of the catalog there.
3
+ Durable knowledge lives in **lakes**: **find** it (search, then read), or
4
+ **record** one focused doc at the narrowest true scope. Call the `docs` tool;
5
+ inside `execute` it is `tools.docs.docs_search(…)`, same verbs.
11
6
 
12
7
  ## Classes
13
8
 
@@ -20,98 +15,62 @@ so batch it with the rest of the catalog there.
20
15
  | **Wiki** | human-readable wiki | narrative for humans | read-only |
21
16
 
22
17
  Record at the **narrowest scope where the whole claim is true**. Two scopes
23
- means two focused docs. Never one omnibus doc that is out of place.
18
+ means two docs; one omnibus doc is out of place everywhere.
24
19
 
25
20
  ## Find it
26
21
 
27
- Call the `docs` tool:
28
-
29
- - **blank `search`** — the most recently changed docs, one line per doc
30
- (`path — Area — date — Summary`).
31
- - **a `search`** — docs ranked over title, path, keywords, body headings and
32
- code spans, area, summary and wiki prose. When no doc contains every term it
33
- returns the closest matches under a `Closest matches` header rather than a
34
- dead end. A term must appear as a **substring** to match — a typo returns
35
- nothing, by design. Terms shorter than `MIN_TERM_LENGTH` (3) are ignored
36
- unless they are the whole query.
37
- - **every call** reserves up to `perLakeQuota` results per source (default 3),
38
- caps at `MAX_RESULTS` (10), and ends with `… and N more not shown.` when
39
- anything was cut. The reservation keeps a broad wiki from burying a focused
40
- repo doc.
41
- - **exactly `help`** — the configured lakes, their classes and writability,
42
- then the ranking rules.
43
-
44
- Then open the match with the tool's `read` verb — pass the root-relative path,
45
- or `lake:path` when several lakes are configured. A project README result is
46
- prefixed `instructions:`; read it with the built-in `read` tool instead.
47
- `/docs [search]` runs the same search into the session (`/docs search <text>` is
48
- the explicit form).
49
-
50
- Every `*.md` under a knowledge lake is covered except the excluded areas —
51
- scratch (`plans/`) and vendored mirrors (`requirements/`, `wiki/`). `Area` comes
52
- from the path, `date` from git. Wiki lakes are read-only; their pages are
53
- indexed as prose and tables, and their summaries are derived from the first
54
- paragraph. A README is search-only, rendered as `instructions:path`, and is
55
- never validated.
56
-
57
- ## Read it
58
-
59
- - Consult matching docs before a broad grep: they are the curated knowledge.
60
- - Class precedence on a tie is repo, instructions, global, machine, wiki. A
61
- non-primary lake is prefixed (`global:platform/regions.md`,
62
- `wiki:Integrations/Billing.md`).
63
- - A `Summary` is a self-contained sentence — enough to decide whether to open
64
- the file.
22
+ A **blank `search`** lists recent docs, one line each; a **`search`** ranks
23
+ them, falling back to `Closest matches` and matching terms as **substrings** —
24
+ so a typo finds nothing. Terms shorter than `MIN_TERM_LENGTH` (3) are dropped
25
+ when longer terms exist; otherwise all query terms are kept. `help` lists the
26
+ lakes and ranking.
65
27
 
66
- ## Record it
28
+ Open the match with `read`: the root-relative path, or `lake:path` when several
29
+ lakes exist. A README result is `instructions:path` and opens with the built-in
30
+ `read`. Consult matching docs before a broad grep; a tie ranks repo,
31
+ instructions, global, machine, wiki. Knowledge lakes exclude `plans/`,
32
+ `requirements/` and `wiki/` at the root; wiki lakes exclude only
33
+ dot-directories and `node_modules`.
67
34
 
68
- Write durable findings and task documentation to `<area>/<title>.md` under the
69
- right lake so the next human or agent skips the rediscovery. The convention is
70
- this plugin's `CONVENTION.md`, restated here.
35
+ ## Record it
71
36
 
72
- Before writing:
37
+ Write to `<area>/<title>.md` under the right lake; `CONVENTION.md` is
38
+ authoritative.
73
39
 
74
- 1. **Read first, never duplicate.** Search the tree and read the docs relevant
75
- to the task: the same `<area>/` neighbourhood, keyword hits on the task's
76
- entities (service names, file paths, error strings, class names), or links
77
- from touched docs.
78
- 2. **Choose one class.** Repo-specific goes to RepoContext. Personal setup goes
79
- to MachineContext. Cross-repo org knowledge goes to GlobalContext. How-to
80
- commands stay in the README. Human narrative goes to the wiki, read-only.
40
+ 1. **Read first, never duplicate.** Read the docs relevant to the task: the same
41
+ area, keyword hits on its entities, or links from touched docs.
42
+ 2. **Choose one class** from the table — the narrowest that holds the claim.
81
43
  3. **Reconcile, then add.** Edit before add; merge near-twins; split a bloated
82
- doc into per-topic files; after any split, merge or move, grep for inbound
83
- references and repoint them. Delete the emptied doc — no superseded stubs.
84
-
85
- One artifact per topic, one class per artifact: a session's findings land as
86
- several focused docs, never one session dump — a compaction nudge hands you
87
- `Record with docs add:` blocks already shaped for this.
88
-
89
- Then record through the `docs` tool's typed `add`: metadata (`Area`, `Keywords`
90
- as a list, `Summary`) plus a body carrying an `H1` and a claim section (`Facts`,
91
- `Inferred`, `Tested`, `Failed approaches`, `Open questions`). The tool
92
- serialises the front-matter — never copy it — and derives the target
93
- (`<Area>/<slug>.md`) when `path` is omitted; pass `path` to update an existing
94
- doc, replacing its body wholesale. Its guardrails refuse blank metadata, path
95
- traversal, a body without an `H1`, a read-only wiki lake, and a same-area
96
- near-twin (returning the candidates to reconcile against).
97
-
98
- A doc is current or deleted. There is no `Status` or `Supersedes` field. A
99
- failed attempt is valid knowledge when it records what was tried, why it failed,
100
- what is true now, and what not to retry. `check` reports the old fields as
101
- obsolete notes so a lake can be cleaned once.
44
+ doc per topic; after a split, merge or move, repoint inbound references and
45
+ delete the emptied doc — no superseded stubs.
46
+
47
+ One artifact per topic and class, never one dump; the compaction nudge shapes
48
+ `Record with docs add:` blocks for this.
49
+
50
+ Record through the typed `add`: metadata (`Area`, `Keywords`, `Summary`) and a
51
+ body with an `H1` and a claim section — Facts, Inferred, Tested, Failed approaches, Open questions. Omit `path` to create `<Area>/<slug>.md`; pass `path` to replace **wholesale**, so omitted body content is lost and the near-twin reconcile is skipped. Refused: blank metadata, traversal, a non-`.md` or symlinked target, no claim section, and — only when `path` is omitted — a same-area near-twin.
52
+
53
+ A doc is current or deleted; no `Status` or `Supersedes`. A failed attempt is
54
+ valid knowledge when it records what was tried, why it failed, what is true now,
55
+ and what not to retry.
56
+
57
+ Lead with the actionable fact, keeping the reason, limits and evidence that
58
+ affect using it; drop chronology, repeated conclusions and empty sections. Keep
59
+ the service names, errors, paths and commands someone will search for. A
60
+ `Summary` decides whether to open the file.
61
+
62
+ Never record secrets, credentials, tokens or customer data — name the vault or
63
+ variable, never the value — and no personal data in GlobalContext or Wiki.
102
64
 
103
65
  ## Check it
104
66
 
105
- To audit the knowledge lakes instead of writing them, call the tool's `check`
106
- (`/docs check` runs it into the session): it validates required front-matter, an
107
- `H1`, at least one claim section, no hand-maintained `Date`, an `H1`+`Keywords`
108
- identity with a term of `MIN_TERM_LENGTH` (3) or longer, same-area near-twins,
109
- and broken relative `.md` links. Obsolete fields, long docs and unversioned or
110
- missing roots are **notes**, not findings. Wiki lakes are read-only and are
111
- reported as not validated. `add` enforces the same structural rules at the
112
- trust boundary.
113
-
114
- Finally, run the repo's gate (`defined comply` where `.defined.json` exists,
115
- else what its README documents) before proposing a commit. Fix failures; never
116
- paper over a red gate with a docs commit. Prepare the commit and **wait for
67
+ `check` audits the lakes: front-matter, an `H1`, a claim section, no
68
+ hand-maintained `Date`, a distinctive identity, near-twins and broken relative
69
+ links. Obsolete fields, long docs and missing roots are **notes**, not findings;
70
+ wiki lakes are not validated. `add` enforces the structural rules at the write
71
+ boundary; cross-document audits stay with `check`. `maxDocLines` (default 200,
72
+ `0` disables) is a soft size signal, not a refusal.
73
+
74
+ Run the project's tests and typecheck before proposing a commit, fixing
75
+ failures rather than papering over them. Prepare the commit and **wait for
117
76
  explicit approval** — recording findings is not commit approval. Never push.
package/src/index.ts CHANGED
@@ -89,13 +89,13 @@ export const DOCS_INPUT = Schema.Struct({
89
89
  search: Schema.optional(
90
90
  Schema.String.annotate({
91
91
  description:
92
- "Case-insensitive search over title, path, keywords, body headings and code spans, area, summary and wiki prose. Omit or leave blank to list the most recently changed docs; a partial match returns the closest docs.",
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
93
  }),
94
94
  ),
95
95
  lake: Schema.optional(
96
96
  Schema.String.annotate({
97
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.",
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.",
99
99
  }),
100
100
  ),
101
101
  add: Schema.optional(
@@ -103,7 +103,7 @@ export const DOCS_INPUT = Schema.Struct({
103
103
  path: Schema.optional(
104
104
  Schema.String.annotate({
105
105
  description:
106
- "Update target, e.g. `guides/getting-started.md` (relative to the lake root). Omit to create `<Area>/<slug>.md` from the H1.",
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.",
107
107
  }),
108
108
  ),
109
109
  metadata: Schema.Struct({
@@ -112,27 +112,27 @@ export const DOCS_INPUT = Schema.Struct({
112
112
  Summary: Schema.String,
113
113
  }).annotate({
114
114
  description:
115
- "Front-matter metadata; serialised, never copied. All three fields are required and must not be blank.",
115
+ "Front-matter metadata; serialised, never copied. All three are required and must not be blank.",
116
116
  }),
117
117
  body: Schema.String.annotate({
118
118
  description:
119
- "Markdown body: an H1, then the claim sections (Facts / Inferred / Tested / Failed approaches / Open questions).",
119
+ "Markdown body: an H1 title, plus at least one claim section as an H2/H3 — Facts, Inferred, Tested, Failed approaches, Open questions.",
120
120
  }),
121
121
  }).annotate({
122
122
  description:
123
- "The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`. Blank required metadata, path traversal, a non-.md target, a symlinked target, and a same-area near-twin (when `path` is omitted) are all refused. Wiki lakes are read-only.",
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.",
124
124
  }),
125
125
  ),
126
126
  check: Schema.optional(
127
127
  Schema.Boolean.annotate({
128
128
  description:
129
- "Validate every knowledge doc against the convention: required front-matter, an H1, claim sections, no hand-maintained Date, a distinctive H1+Keywords identity, same-area near-twins, broken relative links, and a soft length note. Wiki lakes and READMEs are not validated.",
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.",
130
130
  }),
131
131
  ),
132
132
  read: Schema.optional(
133
133
  Schema.String.annotate({
134
134
  description:
135
- "A root-relative doc path, or `lake:path`, to return verbatim — front-matter and body — so find-then-read is one call. Lake docs and wiki pages only: project READMEs stay with the built-in `read` tool.",
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.",
136
136
  }),
137
137
  ),
138
138
  });
@@ -250,9 +250,77 @@ function splitKeywords(value: string): string[] {
250
250
  .filter((keyword) => keyword !== "");
251
251
  }
252
252
 
253
- /** Whether a line opens or closes a fenced code block. */
254
- function isFence(line: string): boolean {
255
- return line.trimStart().startsWith("```");
253
+ /**
254
+ * An open fenced code block: the opener's delimiter character and run length.
255
+ * The length and character are remembered so a shorter or different-character
256
+ * run inside the block cannot close it.
257
+ */
258
+ interface Fence {
259
+ readonly char: "`" | "~";
260
+ readonly length: number;
261
+ }
262
+
263
+ /**
264
+ * The supported Markdown fence subset: a backtick or tilde run of three or
265
+ * more, indented by at most three spaces. A backtick opener's info string may
266
+ * not contain a backtick. Four-space indentation is an indented code block, not
267
+ * a fence, and is not modelled; nor are tab-indented fences or the full
268
+ * CommonMark block grammar.
269
+ * @param line One source line.
270
+ * @returns The opened fence, or null when the line is not an opener.
271
+ */
272
+ function fenceOpener(line: string): Fence | null {
273
+ const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
274
+ if (match === null) {
275
+ return null;
276
+ }
277
+ const run = match[1] ?? "";
278
+ const info = line.slice(match[0].length);
279
+ const char = run[0] as Fence["char"];
280
+ if (char === "`" && info.includes("`")) {
281
+ return null;
282
+ }
283
+ return { char, length: run.length };
284
+ }
285
+
286
+ /**
287
+ * Whether a line closes `fence`: the same delimiter character, a run at least
288
+ * as long as the opener, and nothing after it but spaces or tabs.
289
+ * @param line One source line.
290
+ * @param fence The open fence.
291
+ * @returns True when the line closes the block.
292
+ */
293
+ function closesFence(line: string, fence: Fence): boolean {
294
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
295
+ if (match === null) {
296
+ return false;
297
+ }
298
+ const run = match[1] ?? "";
299
+ return run.startsWith(fence.char) && run.length >= fence.length;
300
+ }
301
+
302
+ /**
303
+ * The body's lines outside fenced code blocks, fence delimiters included in
304
+ * neither. An unclosed fence keeps every remaining line inside the block.
305
+ * @param body Doc body, front-matter already stripped.
306
+ * @returns Source lines with fenced blocks removed.
307
+ */
308
+ function outsideFenceLines(body: string): string[] {
309
+ const lines: string[] = [];
310
+ let fence: Fence | null = null;
311
+ for (const line of body.split("\n")) {
312
+ if (fence === null) {
313
+ const opener = fenceOpener(line);
314
+ if (opener !== null) {
315
+ fence = opener;
316
+ continue;
317
+ }
318
+ lines.push(line);
319
+ } else if (closesFence(line, fence)) {
320
+ fence = null;
321
+ }
322
+ }
323
+ return lines;
256
324
  }
257
325
 
258
326
  /** Drop an ATX heading's trailing `#` decoration (and any trailing spaces). */
@@ -279,19 +347,11 @@ interface BodyScan {
279
347
  function scanBody(body: string): BodyScan {
280
348
  const headings: string[] = [];
281
349
  const spans: string[] = [];
282
- let fenced = false;
283
- for (const line of body.split("\n")) {
284
- if (isFence(line)) {
285
- fenced = !fenced;
286
- continue;
287
- }
288
- if (fenced) {
289
- continue;
290
- }
350
+ for (const line of outsideFenceLines(body)) {
291
351
  const heading = /^(#{2,3})\s+/.exec(line);
292
352
  if (heading !== null) {
293
353
  const text = stripTrailingHashes(
294
- line.slice(heading[0].length).replace(/`/g, ""),
354
+ line.slice(heading[0].length).replaceAll("`", ""),
295
355
  ).trim();
296
356
  if (text !== "") {
297
357
  headings.push(text);
@@ -358,18 +418,7 @@ function plainProse(text: string): string {
358
418
 
359
419
  /** The body with fenced code blocks removed. */
360
420
  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");
421
+ return outsideFenceLines(body).join("\n");
373
422
  }
374
423
 
375
424
  /** Block prefixes that are never a wiki summary. */
@@ -752,6 +801,20 @@ export function rankDocs(
752
801
  };
753
802
  }
754
803
 
804
+ /** The provenance prefix for a result: instructions, non-primary lake, or none. */
805
+ function resultPrefix(
806
+ entry: DocEntry,
807
+ primaryLake: string | undefined,
808
+ ): string {
809
+ if (entry.class === "instructions") {
810
+ return "instructions";
811
+ }
812
+ if (entry.lake !== "" && entry.lake !== primaryLake) {
813
+ return entry.lake;
814
+ }
815
+ return "";
816
+ }
817
+
755
818
  /**
756
819
  * Render matched docs as `path — Area — date` lines with the summary indented
757
820
  * beneath; a non-primary lake is prefixed `lake:` so provenance is clear. A
@@ -782,12 +845,8 @@ export function render(
782
845
  );
783
846
  const meta = parts.join(" — ");
784
847
  const head = meta === "" ? entry.path : `${entry.path} — ${meta}`;
785
- const labelled =
786
- entry.class === "instructions"
787
- ? `instructions:${head}`
788
- : entry.lake !== "" && entry.lake !== options.primaryLake
789
- ? `${entry.lake}:${head}`
790
- : head;
848
+ const prefix = resultPrefix(entry, options.primaryLake);
849
+ const labelled = prefix === "" ? head : `${prefix}:${head}`;
791
850
  return entry.summary === ""
792
851
  ? labelled
793
852
  : `${labelled}\n ${entry.summary}`;
@@ -824,7 +883,7 @@ export function explainRanking(): string {
824
883
  "How docs search ranks:",
825
884
  `- 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}.`,
826
885
  `- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
827
- `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
886
+ `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are dropped when longer terms exist; otherwise all query terms are kept.`,
828
887
  "- 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
888
  `- 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
889
  "- Class precedence on a tie: repo > instructions > global > machine > wiki.",
@@ -844,6 +903,8 @@ export interface LakeInfo {
844
903
  writable: boolean;
845
904
  /** Whether `check` validates it. */
846
905
  validated: boolean;
906
+ /** Whether the root is absent, so the lake contributes nothing yet. */
907
+ missing?: boolean;
847
908
  }
848
909
 
849
910
  /**
@@ -857,8 +918,9 @@ export function helpText(lakes: readonly LakeInfo[]): string {
857
918
  for (const lake of lakes) {
858
919
  const access = lake.writable ? "writable" : "read-only";
859
920
  const checked = lake.validated ? "checked" : "not checked";
921
+ const missing = lake.missing === true ? " — missing" : "";
860
922
  lines.push(
861
- `- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}`,
923
+ `- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}${missing}`,
862
924
  );
863
925
  }
864
926
  lines.push(
@@ -942,7 +1004,7 @@ export function gitDates(
942
1004
  return dates;
943
1005
  }
944
1006
  const result = spawnSync(
945
- "git",
1007
+ "git", // NOSONAR: portable git lookup; cwd is trusted local config, not model input.
946
1008
  // `--relative` keeps the output paths root-relative to `cwd`; without
947
1009
  // it a lake rooted at `<repo>/docs` maps `docs/foo.md` while the
948
1010
  // requested paths are `foo.md`, silently falling back to mtimes.
@@ -1056,6 +1118,22 @@ function skipDirectory(
1056
1118
  return directory === root && excluded.includes(item.name);
1057
1119
  }
1058
1120
 
1121
+ /**
1122
+ * Code-unit string order, matching the default sort but stated explicitly.
1123
+ * @param a Left string.
1124
+ * @param b Right string.
1125
+ * @returns Negative, zero or positive in code-unit order.
1126
+ */
1127
+ function compareText(a: string, b: string): number {
1128
+ if (a < b) {
1129
+ return -1;
1130
+ }
1131
+ if (a > b) {
1132
+ return 1;
1133
+ }
1134
+ return 0;
1135
+ }
1136
+
1059
1137
  /**
1060
1138
  * Walk a lake root, returning its `.md` paths relative to the root. Dot
1061
1139
  * directories are skipped (which drops `.git` and `.attachments`), as are
@@ -1103,7 +1181,7 @@ export function collectDocPaths(
1103
1181
  if (existsSync(root)) {
1104
1182
  walk(root);
1105
1183
  }
1106
- return paths.sort();
1184
+ return paths.sort(compareText);
1107
1185
  }
1108
1186
 
1109
1187
  /**
@@ -1218,7 +1296,7 @@ export function readmePaths(
1218
1296
  .filter(
1219
1297
  (path) => !skip.some((root) => isUnder(join(location, path), root)),
1220
1298
  )
1221
- .sort();
1299
+ .sort(compareText);
1222
1300
  }
1223
1301
 
1224
1302
  /**
@@ -1227,7 +1305,7 @@ export function readmePaths(
1227
1305
  */
1228
1306
  function gitReadmePaths(location: string): string[] | undefined {
1229
1307
  const result = spawnSync(
1230
- "git",
1308
+ "git", // NOSONAR: portable git lookup; cwd is trusted local config, not model input.
1231
1309
  ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
1232
1310
  { cwd: location, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
1233
1311
  );
@@ -1319,7 +1397,11 @@ export type AddOutcome =
1319
1397
  path: string;
1320
1398
  /** The serialised file contents. */
1321
1399
  contents: string;
1322
- /** Same-area docs already on disk, shown alongside the write. */
1400
+ /**
1401
+ * Same-area docs already on disk. Not rendered in the tool result —
1402
+ * the success path names the path and outcome only — but retained for
1403
+ * programmatic callers that want the surrounding context.
1404
+ */
1323
1405
  neighbours: readonly DocEntry[];
1324
1406
  /** Soft advisory lines (e.g. a long doc worth splitting). */
1325
1407
  notes: readonly string[];
@@ -1526,6 +1608,14 @@ export function prepareAdd(
1526
1608
  if (h1Of(add.body) === undefined) {
1527
1609
  return refuse("The body must carry an H1 (`# Title`).");
1528
1610
  }
1611
+ // The same rule `check` applies, enforced before anything touches disk: a
1612
+ // doc that `check` would flag on the next run should never be written.
1613
+ if (!hasClaimSection(add.body)) {
1614
+ return refuse(
1615
+ "the body carries no claim section; add an H2/H3 headed Facts, " +
1616
+ "Inferred, Tested, Failed approaches or Open questions.",
1617
+ );
1618
+ }
1529
1619
  if (!hasDistinctiveIdentity(reconcileQuery(add))) {
1530
1620
  return refuse(
1531
1621
  `the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
@@ -1589,6 +1679,26 @@ export const CLAIM_SECTIONS = [
1589
1679
  "Open questions",
1590
1680
  ] as const;
1591
1681
 
1682
+ /**
1683
+ * Whether a body carries at least one recognisable claim section as an H2/H3
1684
+ * heading. This is the one rule {@link prepareAdd} and {@link validateDoc}
1685
+ * share, so `add` cannot accept a doc that `check` would immediately flag.
1686
+ *
1687
+ * It reads through {@link scanBody}, so a fenced code block or an inline code
1688
+ * span cannot masquerade as a section, and a `# H1` or `#### H4` does not
1689
+ * satisfy it.
1690
+ * @param body Markdown body, front-matter already stripped.
1691
+ * @returns True when a claim-section heading is present.
1692
+ */
1693
+ export function hasClaimSection(body: string): boolean {
1694
+ const claims = new Set(
1695
+ CLAIM_SECTIONS.map((section) => section.toLowerCase()),
1696
+ );
1697
+ return scanBody(body).headings.some((heading) =>
1698
+ claims.has(heading.toLowerCase()),
1699
+ );
1700
+ }
1701
+
1592
1702
  /**
1593
1703
  * The required front-matter fields that are blank in a raw doc: `Keywords` is
1594
1704
  * blank when it yields no terms (matching `add`'s typed rule in
@@ -1633,10 +1743,7 @@ export function validateDoc(path: string, raw: string): string[] {
1633
1743
  "hand-maintained `Date` — the date is derived by git, never written.",
1634
1744
  );
1635
1745
  }
1636
- const claims = CLAIM_SECTIONS.map((section) => section.toLowerCase());
1637
- if (
1638
- !scanBody(body).headings.some((h) => claims.includes(h.toLowerCase()))
1639
- ) {
1746
+ if (!hasClaimSection(body)) {
1640
1747
  findings.push(
1641
1748
  "no claim section (Facts / Inferred / Tested / Failed approaches / Open questions).",
1642
1749
  );
@@ -1668,7 +1775,7 @@ function nearTwinPairs(
1668
1775
  const query = identityQuery(doc.title, doc.keywords);
1669
1776
  const others = entries.filter((other) => other.path !== doc.path);
1670
1777
  for (const twin of nearTwins(query, doc.area, others)) {
1671
- const key = [doc.path, twin.path].sort().join("\n");
1778
+ const key = [doc.path, twin.path].sort(compareText).join("\n");
1672
1779
  if (!pairs.has(key)) {
1673
1780
  pairs.set(key, [doc, twin]);
1674
1781
  }
@@ -1702,26 +1809,47 @@ function resolveLink(from: string, target: string): string | null {
1702
1809
  return stack.join("/");
1703
1810
  }
1704
1811
 
1812
+ /** An inline link's destination and the index of its closing `)`. */
1813
+ interface InlineLink {
1814
+ readonly destination: string | undefined;
1815
+ readonly end: number;
1816
+ }
1817
+
1818
+ /**
1819
+ * Parse an inline `[text](target)` link starting at `start`.
1820
+ * @param line One source line.
1821
+ * @param start Index of the opening `[`.
1822
+ * @returns The destination and closing index, or null when the link is incomplete.
1823
+ */
1824
+ function inlineLinkAt(line: string, start: number): InlineLink | null {
1825
+ const close = line.indexOf("](", start + 1);
1826
+ if (close === -1) {
1827
+ return null;
1828
+ }
1829
+ const end = line.indexOf(")", close + 2);
1830
+ if (end === -1) {
1831
+ return null;
1832
+ }
1833
+ const destination = line
1834
+ .slice(close + 2, end)
1835
+ .trim()
1836
+ .split(/\s+/)[0];
1837
+ return { destination, end };
1838
+ }
1839
+
1705
1840
  /** Inline `[text](target)` destinations on one line; images are skipped. */
1706
1841
  function inlineTargets(line: string): string[] {
1707
1842
  const targets: string[] = [];
1708
1843
  let index = 0;
1709
1844
  while (index < line.length) {
1710
1845
  if (line[index] === "[" && line[index - 1] !== "!") {
1711
- const close = line.indexOf("](", index + 1);
1712
- if (close !== -1) {
1713
- const end = line.indexOf(")", close + 2);
1714
- if (end !== -1) {
1715
- const destination = line
1716
- .slice(close + 2, end)
1717
- .trim()
1718
- .split(/\s+/)[0];
1719
- if (destination !== undefined && destination !== "") {
1720
- targets.push(destination);
1721
- }
1722
- index = end + 1;
1723
- continue;
1846
+ const link = inlineLinkAt(line, index);
1847
+ if (link !== null) {
1848
+ if (link.destination !== undefined && link.destination !== "") {
1849
+ targets.push(link.destination);
1724
1850
  }
1851
+ index = link.end + 1;
1852
+ continue;
1725
1853
  }
1726
1854
  }
1727
1855
  index += 1;
@@ -1738,15 +1866,7 @@ function inlineTargets(line: string): string[] {
1738
1866
  */
1739
1867
  export function markdownTargets(body: string): string[] {
1740
1868
  const targets: string[] = [];
1741
- let fenced = false;
1742
- for (const line of body.split("\n")) {
1743
- if (isFence(line)) {
1744
- fenced = !fenced;
1745
- continue;
1746
- }
1747
- if (fenced) {
1748
- continue;
1749
- }
1869
+ for (const line of outsideFenceLines(body)) {
1750
1870
  targets.push(...inlineTargets(line));
1751
1871
  const reference = /^\s*\[[^\]]+\]:\s*(\S+)/.exec(line);
1752
1872
  if (reference?.[1] !== undefined) {
@@ -1932,7 +2052,8 @@ export function checkDocs(
1932
2052
  if (advisory.length === 0) {
1933
2053
  return head;
1934
2054
  }
1935
- return `${head}\n\nNotes:\n${advisory.map((note) => `- ${note}`).join("\n")}`;
2055
+ const noteLines = advisory.map((note) => `- ${note}`).join("\n");
2056
+ return `${head}\n\nNotes:\n${noteLines}`;
1936
2057
  }
1937
2058
 
1938
2059
  /** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
@@ -2048,13 +2169,17 @@ export function missNudgeFor(
2048
2169
  * never a session dump (the near-twin guard catches duplicates, not dumps).
2049
2170
  */
2050
2171
  export const RECORD_NUDGE =
2051
- "Before summarising: if this session established durable knowledge " +
2052
- "(decisions, traps, runbooks, verified behaviour), open the " +
2053
- "continuation with one `Record with docs add:` block per finding — " +
2172
+ "Before summarising, the active task comes first. If the session " +
2173
+ "established durable knowledge (decisions, traps, runbooks, verified " +
2174
+ "behaviour), add one `Record with docs add:` block per durable topic and " +
2175
+ "scope — coalesce related findings rather than one block per observation, " +
2054
2176
  "never one session dump, and never one mixed-scope doc. Each block names " +
2055
- "its own artifact's class (repo, machine, or global), `Area`, `H1` title " +
2056
- "and `Keywords`, plus a one-line what-and-why, so the next agent can " +
2057
- "persist each as a separate focused doc at the narrowest true scope.";
2177
+ "its class (repo, machine or global), `Area`, `H1` title and `Keywords`, and " +
2178
+ "keeps what was learned, why it matters, the evidence or status, any " +
2179
+ "unresolved uncertainty and the intended scope, so the next agent can " +
2180
+ "reconcile before writing. Reference a finding already recorded by path " +
2181
+ "rather than restating it, and assume persistence only where the session " +
2182
+ "confirms it.";
2058
2183
 
2059
2184
  /**
2060
2185
  * Resolve the plugin's `nudge` option: unset means on; a boolean passes
@@ -2097,7 +2222,10 @@ export interface Lake extends LakeSpec {
2097
2222
  read: (path: string) => string;
2098
2223
  /** Whether a root-relative path exists. */
2099
2224
  exists: (path: string) => boolean;
2100
- /** Writes a root-relative path, creating parent directories. */
2225
+ /**
2226
+ * Writes a root-relative path, creating parent directories beneath an
2227
+ * existing root; throws when the root is absent rather than creating it.
2228
+ */
2101
2229
  write: (path: string, contents: string) => void;
2102
2230
  /** Environment notes for `check` (missing root, unversioned root). */
2103
2231
  notes: readonly string[];
@@ -2186,7 +2314,7 @@ export function createLake(
2186
2314
  };
2187
2315
  return {
2188
2316
  ...spec,
2189
- notes: unversionedNote(spec.root),
2317
+ notes: unversionedNote(spec.root, spec.name),
2190
2318
  linkCheck: true,
2191
2319
  collect: () =>
2192
2320
  collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
@@ -2202,6 +2330,14 @@ export function createLake(
2202
2330
  },
2203
2331
  write: (path, contents) => {
2204
2332
  const target = guarded(path);
2333
+ // The root is configured, never invented: a mistyped path must not
2334
+ // bootstrap a lake. `add` pre-checks for a friendlier refusal.
2335
+ if (!existsSync(spec.root)) {
2336
+ throw new Error(
2337
+ `lake '${spec.name}' root ${spec.root} does not exist; ` +
2338
+ "create it first.",
2339
+ );
2340
+ }
2205
2341
  mkdirSync(dirname(target), { recursive: true });
2206
2342
  writeFileSync(target, contents);
2207
2343
  },
@@ -2314,13 +2450,20 @@ function readLakesJson(
2314
2450
  { cause: error },
2315
2451
  );
2316
2452
  }
2317
- const lakes = Array.isArray(parsed)
2318
- ? parsed
2319
- : typeof parsed === "object" && parsed !== null && "lakes" in parsed
2320
- ? (parsed as { lakes: unknown }).lakes
2321
- : undefined;
2453
+ let lakes: unknown;
2454
+ if (Array.isArray(parsed)) {
2455
+ lakes = parsed;
2456
+ } else if (
2457
+ typeof parsed === "object" &&
2458
+ parsed !== null &&
2459
+ "lakes" in parsed
2460
+ ) {
2461
+ lakes = (parsed as { lakes: unknown }).lakes;
2462
+ } else {
2463
+ lakes = undefined;
2464
+ }
2322
2465
  if (!Array.isArray(lakes)) {
2323
- throw new Error(
2466
+ throw new TypeError(
2324
2467
  `docs plugin ${label} must contain a "lakes" array: ${path}.`,
2325
2468
  );
2326
2469
  }
@@ -2390,7 +2533,7 @@ function lakeRootOf(
2390
2533
  return join(location, "docs");
2391
2534
  }
2392
2535
  if (typeof entry.root !== "string") {
2393
- throw new Error(
2536
+ throw new TypeError(
2394
2537
  `docs plugin ${lakeClass} lake root must be a string, ` +
2395
2538
  `got ${JSON.stringify(entry.root)}.`,
2396
2539
  );
@@ -2411,12 +2554,14 @@ function lakeNameOf(
2411
2554
  lakeClass: LakeClass,
2412
2555
  ): string {
2413
2556
  const derived = slugify(basename(root)) || lakeClass;
2414
- const name =
2415
- entry.name === undefined
2416
- ? derived
2417
- : typeof entry.name === "string"
2418
- ? entry.name.trim()
2419
- : "";
2557
+ let name: string;
2558
+ if (entry.name === undefined) {
2559
+ name = derived;
2560
+ } else if (typeof entry.name === "string") {
2561
+ name = entry.name.trim();
2562
+ } else {
2563
+ name = "";
2564
+ }
2420
2565
  if (!LAKE_NAME.test(name)) {
2421
2566
  throw new Error(
2422
2567
  `docs plugin lake name must match ${LAKE_NAME}, ` +
@@ -2443,8 +2588,7 @@ function lakeWritableOf(
2443
2588
  `got ${JSON.stringify(entry.writable)}.`,
2444
2589
  );
2445
2590
  }
2446
- const writable =
2447
- entry.writable === undefined ? lakeClass !== "wiki" : entry.writable;
2591
+ const writable = entry.writable ?? lakeClass !== "wiki";
2448
2592
  if (lakeClass === "wiki" && writable) {
2449
2593
  throw new Error(
2450
2594
  `docs plugin lake '${name}' is a wiki lake and cannot be writable.`,
@@ -2725,12 +2869,26 @@ function searchDocs(
2725
2869
  closest,
2726
2870
  omitted,
2727
2871
  } = rankDocs(entries, query, { perLakeQuota: runtime.perLakeQuota });
2872
+ const content = render(ranked, {
2873
+ closest,
2874
+ omitted,
2875
+ primaryLake: primaryLakeName(runtime.lakes) ?? "",
2876
+ });
2877
+ // A dormant lake contributes nothing, so without the note a typo'd root
2878
+ // looks like an empty lake rather than a broken path.
2879
+ const missing = runtime.lakes
2880
+ .filter(
2881
+ (lake) =>
2882
+ lake.root !== "" &&
2883
+ (lakeName === undefined || lakeName === lake.name) &&
2884
+ !existsSync(lake.root),
2885
+ )
2886
+ .map((lake) => missingRootNote(lake.root, lake.name));
2728
2887
  return {
2729
- content: render(ranked, {
2730
- closest,
2731
- omitted,
2732
- primaryLake: primaryLakeName(runtime.lakes) ?? "",
2733
- }),
2888
+ content:
2889
+ missing.length > 0
2890
+ ? `${content}\n\n${missing.join("\n")}`
2891
+ : content,
2734
2892
  count: ranked.length,
2735
2893
  omitted,
2736
2894
  };
@@ -2806,6 +2964,16 @@ function addDoc(
2806
2964
  if ("reason" in target) {
2807
2965
  return { content: target.reason, written: false };
2808
2966
  }
2967
+ // A typo'd root must not bootstrap a new tree; the area directories below
2968
+ // an existing root still create on write.
2969
+ if (target.root !== "" && !existsSync(target.root)) {
2970
+ return {
2971
+ content:
2972
+ `${REFUSE} lake '${target.name}' root ${target.root} does ` +
2973
+ "not exist — create it first.",
2974
+ written: false,
2975
+ };
2976
+ }
2809
2977
  const entries = target.collect();
2810
2978
  const outcome = prepareAdd(add, entries, {
2811
2979
  maxDocLines: runtime.maxDocLines,
@@ -2829,16 +2997,12 @@ function addDoc(
2829
2997
  const head = `${address(target, outcome.path, runtime.lakes)} — ${
2830
2998
  updated ? "updated" : "created"
2831
2999
  }.`;
2832
- const neighbours =
2833
- outcome.neighbours.length > 0
2834
- ? `\n\nNearby docs in the same area:\n\n${render(
2835
- outcome.neighbours,
2836
- { primaryLake: target.name },
2837
- )}`
2838
- : "";
3000
+ // The path and outcome are the answer; a list of same-area neighbours was
3001
+ // unsolicited context that grew with the area and was picked by collection
3002
+ // order, not relevance. Refusals still carry their candidates.
2839
3003
  const notes =
2840
3004
  outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
2841
- return { content: `${head}${neighbours}${notes}`, written: true };
3005
+ return { content: `${head}${notes}`, written: true };
2842
3006
  }
2843
3007
 
2844
3008
  /** A parsed read request: the lake (when known) and the path. */
@@ -2950,6 +3114,7 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
2950
3114
  root: lake.root,
2951
3115
  writable: lake.writable,
2952
3116
  validated: lake.profile === "knowledge",
3117
+ missing: lake.root !== "" && !existsSync(lake.root),
2953
3118
  }));
2954
3119
  }
2955
3120
 
@@ -2974,11 +3139,56 @@ export interface DocsCommandDeps extends RuntimeInput {
2974
3139
  * the full doctrine the native `docs` description carries.
2975
3140
  */
2976
3141
  const TWIN_DESCRIPTION =
2977
- "Search, read, add and check the configured knowledge lakes (plus nested " +
2978
- "project `README.md` files, search-only) from inside `execute` — the " +
2979
- "`docs` tool's Code Mode twin. A blank search lists the most recently " +
2980
- "changed docs; `read` returns a matched doc verbatim; `add` writes through " +
2981
- "a typed DTO; `check` validates the knowledge lakes.";
3142
+ "Search, read, add and check the configured knowledge lakes and nested " +
3143
+ "project `README.md` files (search-only) from inside `execute` — the `docs` " +
3144
+ "tool's Code Mode twin, same verbs and lakes. A blank search lists recent " +
3145
+ "docs; `read` returns a matched doc verbatim; `add` writes through a typed " +
3146
+ "DTO; `check` validates the lakes.";
3147
+
3148
+ /**
3149
+ * Run one resolved `docs` request against the runtime.
3150
+ * @param runtime The resolved lakes and seams.
3151
+ * @param input The typed tool input.
3152
+ * @returns The tool result.
3153
+ */
3154
+ function executeRequest(runtime: Runtime, input: DocsInput): Result {
3155
+ const request = resolveRequest(input);
3156
+ if (request.verb === "add") {
3157
+ const result = addDoc(runtime, request.add, request.lake);
3158
+ return {
3159
+ content: result.content,
3160
+ metadata: { written: result.written },
3161
+ };
3162
+ }
3163
+ if (request.verb === "check") {
3164
+ const result = checkLakes(runtime, request.lake);
3165
+ return {
3166
+ content: result.content,
3167
+ metadata: { checked: result.checked },
3168
+ };
3169
+ }
3170
+ if (request.verb === "read") {
3171
+ const result = readDoc(runtime, request.read, request.lake);
3172
+ return {
3173
+ content: result.content,
3174
+ metadata: { read: result.read },
3175
+ };
3176
+ }
3177
+ if (request.verb === "help") {
3178
+ return {
3179
+ content: helpText(lakeInfo(runtime.lakes)),
3180
+ metadata: { count: 0, omitted: 0 },
3181
+ };
3182
+ }
3183
+ const result = searchDocs(runtime, request.search, request.lake);
3184
+ return {
3185
+ content: result.content,
3186
+ metadata: {
3187
+ count: result.count,
3188
+ omitted: result.omitted,
3189
+ },
3190
+ };
3191
+ }
2982
3192
 
2983
3193
  /**
2984
3194
  * Build the `docs` tool from its dependencies.
@@ -3010,54 +3220,19 @@ export function createDocsTool(
3010
3220
  name,
3011
3221
  description: codemode
3012
3222
  ? TWIN_DESCRIPTION
3013
- : "Discover the configured knowledge lakes plus nested `README.md` " +
3014
- "files in the project tree. Prefer it over a broad grep when the " +
3015
- "question is how-to knowledge rather than where code lives. A blank search lists the " +
3016
- MAX_RESULTS +
3017
- " most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area, summary and wiki prose, falling back to the closest matches. Every source reserves a few results; every call caps at " +
3018
- MAX_RESULTS +
3019
- " and counts the rest. Search exactly `help` for the lakes and ranking. `read` returns a doc verbatim; pass a `lake` or a `lake:path`. `add` writes a new knowledge doc (or replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank fields, path traversal, non-.md targets, symlinked targets, read-only lakes and same-area near-twins are refused. `check` validates the knowledge lakes (front-matter, H1, claim sections, no hand-maintained Date, distinctive identity, near-twins, broken links, soft length note); wiki lakes are read-only and not validated. READMEs are search-only and render as `instructions:path`; read them with the built-in read tool.",
3223
+ : "Search the configured knowledge lakes and nested project " +
3224
+ "`README.md` files before a broad grep for how-to knowledge, " +
3225
+ "decisions or known problems. A blank search lists recent docs; " +
3226
+ "`help` shows the lakes and ranking. `read` returns a lake doc " +
3227
+ "verbatim; project `instructions:` results open with the built-in " +
3228
+ "read tool. `add` writes or replaces a knowledge doc; `check` " +
3229
+ "audits convention compliance.",
3020
3230
  input: DOCS_INPUT,
3021
3231
  options: codemode
3022
3232
  ? { namespace: "docs", codemode: true }
3023
3233
  : { codemode: false },
3024
- async execute(input) {
3025
- const request = resolveRequest(input);
3026
- if (request.verb === "add") {
3027
- const result = addDoc(runtime, request.add, request.lake);
3028
- return {
3029
- content: result.content,
3030
- metadata: { written: result.written },
3031
- };
3032
- }
3033
- if (request.verb === "check") {
3034
- const result = checkLakes(runtime, request.lake);
3035
- return {
3036
- content: result.content,
3037
- metadata: { checked: result.checked },
3038
- };
3039
- }
3040
- if (request.verb === "read") {
3041
- const result = readDoc(runtime, request.read, request.lake);
3042
- return {
3043
- content: result.content,
3044
- metadata: { read: result.read },
3045
- };
3046
- }
3047
- if (request.verb === "help") {
3048
- return {
3049
- content: helpText(lakeInfo(runtime.lakes)),
3050
- metadata: { count: 0, omitted: 0 },
3051
- };
3052
- }
3053
- const result = searchDocs(runtime, request.search, request.lake);
3054
- return {
3055
- content: result.content,
3056
- metadata: {
3057
- count: result.count,
3058
- omitted: result.omitted,
3059
- },
3060
- };
3234
+ execute(input) {
3235
+ return Promise.resolve(executeRequest(runtime, input));
3061
3236
  },
3062
3237
  };
3063
3238
  }
@@ -3099,9 +3274,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
3099
3274
  return {
3100
3275
  name: "docs",
3101
3276
  description:
3102
- "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 knowledge lakes; a blank search lists the " +
3103
- MAX_RESULTS +
3104
- " most recently changed.",
3277
+ "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.",
3105
3278
  async execute({ sessionID, prompt }) {
3106
3279
  const text = prompt.text.trim();
3107
3280
  if (isHelp(text)) {
@@ -3149,7 +3322,7 @@ export function resolveRoot(
3149
3322
  return join(locationDirectory, "docs");
3150
3323
  }
3151
3324
  if (typeof configured !== "string") {
3152
- throw new Error(
3325
+ throw new TypeError(
3153
3326
  `docs plugin option 'root' must be a non-empty string path, ` +
3154
3327
  `got ${JSON.stringify(configured)}.`,
3155
3328
  );
@@ -3166,22 +3339,45 @@ export function resolveRoot(
3166
3339
  }
3167
3340
 
3168
3341
  /**
3169
- * Probe whether the lake root sits inside a git work tree, as `check`-report
3170
- * notes. An unversioned root still works — dates fall back to file mtimes and
3171
- * the knowledge has no history — so it is surfaced, not refused.
3342
+ * The advisory note for a lake root that is absent. A missing root is a
3343
+ * configuration smell, not a failure: the lake stays dormant until the
3344
+ * directory exists, and `add` refuses rather than creating it.
3345
+ * @param root The resolved lake root.
3346
+ * @param lake The lake name, when known.
3347
+ * @returns The note line.
3348
+ */
3349
+ export function missingRootNote(root: string, lake?: string): string {
3350
+ const subject =
3351
+ lake === undefined
3352
+ ? `lake root ${root}`
3353
+ : `lake '${lake}' root ${root}`;
3354
+ return (
3355
+ `note: ${subject} does not exist — it is ignored until it does. ` +
3356
+ "A missing root is not an error."
3357
+ );
3358
+ }
3359
+
3360
+ /**
3361
+ * The environment notes for a lake root: missing, or unversioned. An absent
3362
+ * root is flagged but tolerated; an unversioned root still works — dates fall
3363
+ * back to file mtimes and the knowledge has no history — so it is surfaced,
3364
+ * not refused.
3172
3365
  * @param root The lake root to probe.
3173
- * @returns The note lines; empty when the root is version-controlled.
3366
+ * @param lake The lake name, when known.
3367
+ * @returns The note lines; empty when the root is present and version-controlled.
3174
3368
  */
3175
- export function unversionedNote(root: string): readonly string[] {
3369
+ export function unversionedNote(
3370
+ root: string,
3371
+ lake?: string,
3372
+ ): readonly string[] {
3176
3373
  if (!existsSync(root)) {
3177
- return [
3178
- "note: this lake root does not exist yet — it is ignored until " +
3179
- "it does. A missing root is not an error.",
3180
- ];
3374
+ return [missingRootNote(root, lake)];
3181
3375
  }
3182
- const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
3183
- cwd: root,
3184
- });
3376
+ const probe = spawnSync(
3377
+ "git", // NOSONAR: portable git lookup; cwd is trusted local config.
3378
+ ["rev-parse", "--show-toplevel"],
3379
+ { cwd: root },
3380
+ );
3185
3381
  if (probe.status === 0) {
3186
3382
  return [];
3187
3383
  }
@@ -3323,17 +3519,12 @@ export default Plugin.define({
3323
3519
  id: Skill.ID.make("docs"),
3324
3520
  name: Skill.Name.make("Docs"),
3325
3521
  description:
3326
- "The entry point to the configured knowledge lakes " +
3327
- "(repo, machine, global and read-only wiki), plus the " +
3328
- "project's nested README.md files (search only): find the " +
3329
- "right doc with the docs tool, read it, and record focused " +
3330
- "task knowledge for the next human or agent at the " +
3331
- "narrowest true scope. Use before searching the codebase " +
3332
- "for how-to knowledge, when a task names a service, flag " +
3333
- "or error string the lake may already explain, at the " +
3334
- "start of unfamiliar work, when hunting for prior " +
3335
- "decisions or traps, when a session learns something the " +
3336
- "next one will need, and when closing out a session.",
3522
+ "Entry point to the configured knowledge lakes " +
3523
+ "(repo, machine, global, read-only wiki) and nested project " +
3524
+ "README.md files: find and read the right doc, then record " +
3525
+ "focused knowledge at the narrowest true scope. Use before " +
3526
+ "searching the codebase for how-to knowledge, or when a " +
3527
+ "session learns something durable.",
3337
3528
  path: AbsolutePath.make(fileURLToPath(skillURL)),
3338
3529
  content: skill,
3339
3530
  });