@markstanden/opencode-docs-plugin 0.5.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 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.0",
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,60 @@ 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 and terms under `MIN_TERM_LENGTH` (3) are ignored.
25
+ `help` lists the lakes and ranking.
65
26
 
66
- ## Record it
27
+ Open the match with `read`: the root-relative path, or `lake:path` when several
28
+ lakes exist. A README result is `instructions:path` and opens with the built-in
29
+ `read`. Consult matching docs before a broad grep; a tie ranks repo,
30
+ instructions, global, machine, wiki. Excluded areas: `plans/`, `requirements/`
31
+ and `wiki/`.
67
32
 
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.
33
+ ## Record it
71
34
 
72
- Before writing:
35
+ Write to `<area>/<title>.md` under the right lake; `CONVENTION.md` is
36
+ authoritative.
73
37
 
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.
38
+ 1. **Read first, never duplicate.** Read the docs relevant to the task: the same
39
+ area, keyword hits on its entities, or links from touched docs.
40
+ 2. **Choose one class** from the table — the narrowest that holds the claim.
81
41
  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.
42
+ doc per topic; after a split, merge or move, repoint inbound references and
43
+ delete the emptied doc — no superseded stubs.
44
+
45
+ One artifact per topic and class, never one dump; the compaction nudge shapes
46
+ `Record with docs add:` blocks for this.
47
+
48
+ Record through the typed `add`: metadata (`Area`, `Keywords`, `Summary`) and a
49
+ 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.
50
+
51
+ A doc is current or deleted; no `Status` or `Supersedes`. A failed attempt is
52
+ valid knowledge when it records what was tried, why it failed, what is true now,
53
+ and what not to retry.
54
+
55
+ Lead with the actionable fact, keeping the reason, limits and evidence that
56
+ affect using it; drop chronology, repeated conclusions and empty sections. Keep
57
+ the service names, errors, paths and commands someone will search for. A
58
+ `Summary` decides whether to open the file.
59
+
60
+ Never record secrets, credentials, tokens or customer data — name the vault or
61
+ variable, never the value — and no personal data in GlobalContext or Wiki.
102
62
 
103
63
  ## Check it
104
64
 
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
65
+ `check` audits the lakes: front-matter, an `H1`, a claim section, no
66
+ hand-maintained `Date`, a distinctive identity, near-twins and broken relative
67
+ links. Obsolete fields, long docs and missing roots are **notes**, not findings;
68
+ wiki lakes are not validated. `add` enforces the structural rules at the write
69
+ boundary; cross-document audits stay with `check`. `maxDocLines` (default 200,
70
+ `0` disables) is a soft size signal, not a refusal.
71
+
72
+ Run the project's tests and typecheck before proposing a commit, fixing
73
+ failures rather than papering over them. Prepare the commit and **wait for
117
74
  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
  });
@@ -844,6 +844,8 @@ export interface LakeInfo {
844
844
  writable: boolean;
845
845
  /** Whether `check` validates it. */
846
846
  validated: boolean;
847
+ /** Whether the root is absent, so the lake contributes nothing yet. */
848
+ missing?: boolean;
847
849
  }
848
850
 
849
851
  /**
@@ -857,8 +859,9 @@ export function helpText(lakes: readonly LakeInfo[]): string {
857
859
  for (const lake of lakes) {
858
860
  const access = lake.writable ? "writable" : "read-only";
859
861
  const checked = lake.validated ? "checked" : "not checked";
862
+ const missing = lake.missing === true ? " — missing" : "";
860
863
  lines.push(
861
- `- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}`,
864
+ `- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}${missing}`,
862
865
  );
863
866
  }
864
867
  lines.push(
@@ -1319,7 +1322,11 @@ export type AddOutcome =
1319
1322
  path: string;
1320
1323
  /** The serialised file contents. */
1321
1324
  contents: string;
1322
- /** Same-area docs already on disk, shown alongside the write. */
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
+ */
1323
1330
  neighbours: readonly DocEntry[];
1324
1331
  /** Soft advisory lines (e.g. a long doc worth splitting). */
1325
1332
  notes: readonly string[];
@@ -1526,6 +1533,14 @@ export function prepareAdd(
1526
1533
  if (h1Of(add.body) === undefined) {
1527
1534
  return refuse("The body must carry an H1 (`# Title`).");
1528
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
+ }
1529
1544
  if (!hasDistinctiveIdentity(reconcileQuery(add))) {
1530
1545
  return refuse(
1531
1546
  `the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
@@ -1589,6 +1604,24 @@ export const CLAIM_SECTIONS = [
1589
1604
  "Open questions",
1590
1605
  ] as const;
1591
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
+
1592
1625
  /**
1593
1626
  * The required front-matter fields that are blank in a raw doc: `Keywords` is
1594
1627
  * blank when it yields no terms (matching `add`'s typed rule in
@@ -1633,10 +1666,7 @@ export function validateDoc(path: string, raw: string): string[] {
1633
1666
  "hand-maintained `Date` — the date is derived by git, never written.",
1634
1667
  );
1635
1668
  }
1636
- const claims = CLAIM_SECTIONS.map((section) => section.toLowerCase());
1637
- if (
1638
- !scanBody(body).headings.some((h) => claims.includes(h.toLowerCase()))
1639
- ) {
1669
+ if (!hasClaimSection(body)) {
1640
1670
  findings.push(
1641
1671
  "no claim section (Facts / Inferred / Tested / Failed approaches / Open questions).",
1642
1672
  );
@@ -2048,13 +2078,17 @@ export function missNudgeFor(
2048
2078
  * never a session dump (the near-twin guard catches duplicates, not dumps).
2049
2079
  */
2050
2080
  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 — " +
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, " +
2054
2085
  "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.";
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.";
2058
2092
 
2059
2093
  /**
2060
2094
  * Resolve the plugin's `nudge` option: unset means on; a boolean passes
@@ -2097,7 +2131,10 @@ export interface Lake extends LakeSpec {
2097
2131
  read: (path: string) => string;
2098
2132
  /** Whether a root-relative path exists. */
2099
2133
  exists: (path: string) => boolean;
2100
- /** Writes a root-relative path, creating parent directories. */
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
+ */
2101
2138
  write: (path: string, contents: string) => void;
2102
2139
  /** Environment notes for `check` (missing root, unversioned root). */
2103
2140
  notes: readonly string[];
@@ -2186,7 +2223,7 @@ export function createLake(
2186
2223
  };
2187
2224
  return {
2188
2225
  ...spec,
2189
- notes: unversionedNote(spec.root),
2226
+ notes: unversionedNote(spec.root, spec.name),
2190
2227
  linkCheck: true,
2191
2228
  collect: () =>
2192
2229
  collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
@@ -2202,6 +2239,14 @@ export function createLake(
2202
2239
  },
2203
2240
  write: (path, contents) => {
2204
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
+ }
2205
2250
  mkdirSync(dirname(target), { recursive: true });
2206
2251
  writeFileSync(target, contents);
2207
2252
  },
@@ -2725,12 +2770,26 @@ function searchDocs(
2725
2770
  closest,
2726
2771
  omitted,
2727
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));
2728
2788
  return {
2729
- content: render(ranked, {
2730
- closest,
2731
- omitted,
2732
- primaryLake: primaryLakeName(runtime.lakes) ?? "",
2733
- }),
2789
+ content:
2790
+ missing.length > 0
2791
+ ? `${content}\n\n${missing.join("\n")}`
2792
+ : content,
2734
2793
  count: ranked.length,
2735
2794
  omitted,
2736
2795
  };
@@ -2806,6 +2865,16 @@ function addDoc(
2806
2865
  if ("reason" in target) {
2807
2866
  return { content: target.reason, written: false };
2808
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
+ }
2809
2878
  const entries = target.collect();
2810
2879
  const outcome = prepareAdd(add, entries, {
2811
2880
  maxDocLines: runtime.maxDocLines,
@@ -2829,16 +2898,12 @@ function addDoc(
2829
2898
  const head = `${address(target, outcome.path, runtime.lakes)} — ${
2830
2899
  updated ? "updated" : "created"
2831
2900
  }.`;
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
- : "";
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.
2839
2904
  const notes =
2840
2905
  outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
2841
- return { content: `${head}${neighbours}${notes}`, written: true };
2906
+ return { content: `${head}${notes}`, written: true };
2842
2907
  }
2843
2908
 
2844
2909
  /** A parsed read request: the lake (when known) and the path. */
@@ -2950,6 +3015,7 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
2950
3015
  root: lake.root,
2951
3016
  writable: lake.writable,
2952
3017
  validated: lake.profile === "knowledge",
3018
+ missing: lake.root !== "" && !existsSync(lake.root),
2953
3019
  }));
2954
3020
  }
2955
3021
 
@@ -2974,11 +3040,11 @@ export interface DocsCommandDeps extends RuntimeInput {
2974
3040
  * the full doctrine the native `docs` description carries.
2975
3041
  */
2976
3042
  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.";
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.";
2982
3048
 
2983
3049
  /**
2984
3050
  * Build the `docs` tool from its dependencies.
@@ -3010,13 +3076,13 @@ export function createDocsTool(
3010
3076
  name,
3011
3077
  description: codemode
3012
3078
  ? 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.",
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.",
3020
3086
  input: DOCS_INPUT,
3021
3087
  options: codemode
3022
3088
  ? { namespace: "docs", codemode: true }
@@ -3099,9 +3165,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
3099
3165
  return {
3100
3166
  name: "docs",
3101
3167
  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.",
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.",
3105
3169
  async execute({ sessionID, prompt }) {
3106
3170
  const text = prompt.text.trim();
3107
3171
  if (isHelp(text)) {
@@ -3166,18 +3230,39 @@ export function resolveRoot(
3166
3230
  }
3167
3231
 
3168
3232
  /**
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.
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.
3172
3256
  * @param root The lake root to probe.
3173
- * @returns The note lines; empty when the root is version-controlled.
3257
+ * @param lake The lake name, when known.
3258
+ * @returns The note lines; empty when the root is present and version-controlled.
3174
3259
  */
3175
- export function unversionedNote(root: string): readonly string[] {
3260
+ export function unversionedNote(
3261
+ root: string,
3262
+ lake?: string,
3263
+ ): readonly string[] {
3176
3264
  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
- ];
3265
+ return [missingRootNote(root, lake)];
3181
3266
  }
3182
3267
  const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
3183
3268
  cwd: root,
@@ -3323,17 +3408,12 @@ export default Plugin.define({
3323
3408
  id: Skill.ID.make("docs"),
3324
3409
  name: Skill.Name.make("Docs"),
3325
3410
  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.",
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.",
3337
3417
  path: AbsolutePath.make(fileURLToPath(skillURL)),
3338
3418
  content: skill,
3339
3419
  });