@markstanden/opencode-docs-plugin 0.2.1 → 0.4.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
@@ -12,6 +12,13 @@ area: `<area>/<name>.md` (the root is the plugin's `root` option; default
12
12
  are `plans/` (scratch, gitignored, deleted when the work lands),
13
13
  `requirements/` and `wiki/` (vendored mirrors).
14
14
 
15
+ Search also covers `README.md` files nested in the project tree, so an agent
16
+ finds the map beside the code. They are indexed as they stand (H1 title,
17
+ headings and code spans) and are **search-only**: they are never written by
18
+ `add`, validated by `check`, or eligible as near-twins. `.gitignore` decides
19
+ which directories are skipped; outside git, dependency and build directories
20
+ are skipped by name.
21
+
15
22
  ## Front-matter
16
23
 
17
24
  Every doc opens with a `---` front-matter block. The fields, in canonical order:
package/README.md CHANGED
@@ -3,13 +3,20 @@
3
3
  An [OpenCode](https://opencode.ai) V2 plugin that gives an agent a knowledge
4
4
  tree it can actually use: a `docs` tool and a `/docs` command over the
5
5
  configured docs root's `*.md` files (the `root` plugin option; default
6
- `<location>/docs`), plus the writing convention those docs follow.
6
+ `<location>/docs`), plus nested `README.md` files in the project tree, and the
7
+ writing convention those docs follow.
7
8
 
8
9
  - `docs` (tool) — blank search lists the 10 most recently changed docs
9
10
  (`path — Area — date — Summary`); a search ranks docs over their
10
11
  front-matter, path and body identifiers, falling back to the closest
11
- matches rather than a dead end; `add` writes knowledge through a typed DTO;
12
- `check` validates the tree against the convention.
12
+ matches rather than a dead end; `read` returns a matched doc verbatim
13
+ (front-matter and body), so find-then-read is one call; `add` writes
14
+ knowledge through a typed DTO; `check` validates the tree against the
15
+ convention. Nested project `README.md` files are indexed too —
16
+ `.gitignore` rules decide what is skipped — but only for search: `add`
17
+ and `check` never touch them. A Code Mode twin, `docs_search`, registers
18
+ alongside it, so `execute` reaches the same lake as
19
+ `tools.docs.docs_search(…)`.
13
20
  - `/docs` (command) — the same core, driven from the prompt:
14
21
  `search <text>` (bare text searches too), `add <text>`, `check`, `help`.
15
22
  - The plugin also registers the `docs` skill, so the convention travels with
@@ -58,7 +65,18 @@ point every project at one shared tree:
58
65
  ```
59
66
 
60
67
  Relative paths resolve against the location's directory; absolute paths pass
61
- through. Restart OpenCode after editing the config — it isn't hot-reloaded.
68
+ through. A blank `root` value counts as unset and uses the default; a
69
+ non-string value fails at startup. The root directory itself need not exist in
70
+ advance — it is created on the first `add`, and a search or `check` over a
71
+ still-empty tree simply reports nothing. Restart OpenCode after editing the
72
+ config — it isn't hot-reloaded.
73
+
74
+ The runtime nudges default on: a one-line pointer appended to the first
75
+ grep/glob miss of a session (suggesting a `docs search`), and a record-ready
76
+ block format injected into compaction summarisation, so durable findings are
77
+ shaped as separate focused docs — one artifact per topic, never a session
78
+ dump. Set `"nudge": false` to restore silence; a non-boolean value fails at
79
+ startup.
62
80
 
63
81
  ## Status
64
82
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@markstanden/opencode-docs-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
- "description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over a repository's knowledge tree, with the writing convention it owns",
5
+ "description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over a repository's knowledge tree and the project's nested README.md files (search only), with the writing convention it owns",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/markstanden/opencode-docs-plugin.git"
package/src/SKILL.md CHANGED
@@ -5,9 +5,9 @@ option; default `<location>/docs`). This skill covers both
5
5
  halves of the loop: **find** (discover with the `docs` tool, read the match) and
6
6
  **record** (write or update a doc for the current task).
7
7
 
8
- Call the `docs` tool directly at top level. It is a plugin-registered tool and
9
- is **not** reachable from the `execute` (Code Mode) catalog — do not try to
10
- batch it inside `execute`, and do not be misled by the unknown-tool suggestion.
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 lake —
10
+ so batch it with the rest of the catalog there.
11
11
 
12
12
  ## Find it
13
13
 
@@ -27,15 +27,24 @@ Call the `docs` tool:
27
27
  - **exactly `help`** — plain text explaining the weights, the hit qualities and
28
28
  the outcomes, instead of a search.
29
29
 
30
- Then open the match with the built-in `read` tool. The tool only discovers; it
31
- never returns a doc's body. `/docs [search]` runs the same search into the
32
- session (`/docs search <text>` is the explicit form).
30
+ Then open the match: either the built-in `read` tool (project files and
31
+ READMEs), or the tool's own `read` verb — pass the root-relative path and it
32
+ returns the doc verbatim, front-matter and body, in one call.
33
+ `/docs [search]` runs the same search into the session (`/docs search <text>`
34
+ is the explicit form).
33
35
 
34
36
  Every `*.md` under the root is covered except the excluded areas — scratch (`plans/`)
35
37
  and vendored mirrors (`requirements/`, `wiki/`). `Area` comes from the path,
36
38
  `date` from git. A doc marked `Status: superseded` carries a `(superseded)`
37
39
  marker in results and ranks below an active doc on a tie.
38
40
 
41
+ Search also covers nested `README.md` files in the project tree — the map lives
42
+ beside the code. They are indexed as they stand (H1 title, headings, code
43
+ spans) and are **search-only**: `add` and `check` never touch them.
44
+ `.gitignore` decides which directories are skipped; outside git, dependency and
45
+ build directories are skipped by name. The lake root is skipped when it sits
46
+ inside the project, since those READMEs are already knowledge docs.
47
+
39
48
  ## Read it
40
49
 
41
50
  - Consult matching docs before a broad grep: they are the curated knowledge.
@@ -59,6 +68,10 @@ Before writing:
59
68
  doc into per-topic files; after any split, merge or move, grep for inbound
60
69
  references and repoint them, leaving a stub if needed.
61
70
 
71
+ One artifact per topic: a session's findings land as several focused docs,
72
+ never one session dump — a compaction nudge hands you `Record with docs add:`
73
+ blocks already shaped for this.
74
+
62
75
  Then record through the `docs` tool's typed `add`: metadata (`Area`, `Keywords`
63
76
  as a list, `Summary`; optionally `Status`/`Supersedes`) plus a body carrying an
64
77
  `H1` and the claim sections (`Facts`, `Inferred`, `Tested`, `Open questions`).
package/src/index.ts CHANGED
@@ -4,9 +4,10 @@ import type {
4
4
  CommandDefinition,
5
5
  CommandInvocation,
6
6
  } from "@opencode/plugin/promise/command";
7
- import type { Info } from "@opencode/plugin/promise/tool";
7
+ import type { Info, Result } from "@opencode/plugin/promise/tool";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import {
10
+ type Dirent,
10
11
  existsSync,
11
12
  mkdirSync,
12
13
  readFileSync,
@@ -15,7 +16,7 @@ import {
15
16
  writeFileSync,
16
17
  } from "node:fs";
17
18
  import { homedir } from "node:os";
18
- import { dirname, isAbsolute, join } from "node:path";
19
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
  import { Schema } from "effect";
21
22
 
@@ -105,6 +106,12 @@ export const DOCS_INPUT = Schema.Struct({
105
106
  "Validate every doc under the docs root against the convention: required front-matter, an H1, claim sections, no hand-maintained Date, a distinctive H1+Keywords identity, a Supersedes naming a doc in the tree, and same-area near-twins. An empty report means compliant.",
106
107
  }),
107
108
  ),
109
+ read: Schema.optional(
110
+ Schema.String.annotate({
111
+ description:
112
+ "A root-relative doc path to return verbatim — front-matter and body — so find-then-read is one call. Lake docs only: project files and READMEs stay with the built-in `read` tool.",
113
+ }),
114
+ ),
108
115
  });
109
116
 
110
117
  /**
@@ -595,26 +602,29 @@ export function explainRanking(): string {
595
602
  `- Fields weighted: title ${FIELD_WEIGHTS.title} > path ${FIELD_WEIGHTS.path} > keywords ${FIELD_WEIGHTS.keywords} > identifiers ${FIELD_WEIGHTS.identifiers} > area ${FIELD_WEIGHTS.area} > summary ${FIELD_WEIGHTS.summary}.`,
596
603
  `- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
597
604
  `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
605
+ "- Search also covers nested `README.md` files in the project tree, honouring `.gitignore` (dependency and build directories are skipped when the project is not in git). They are searchable only: `add` and `check` ignore them.",
598
606
  `- Every call returns at most MAX_RESULTS (${MAX_RESULTS}), best matches first, then the most recently edited; anything cut is counted as "… and N more not shown."`,
599
607
  '- Outcomes: a blank query lists the most recently changed docs; a doc carrying every term ranks the full matches; otherwise the docs matching any term as a substring are listed under "Closest matches"; nothing hits is "No knowledge docs matched."',
600
608
  ].join("\n");
601
609
  }
602
610
 
603
- /** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `help` > `search`). */
611
+ /** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `read` > `help` > `search`). */
604
612
  export type DocsRequest =
605
613
  | { verb: "add"; add: DocsAdd }
606
614
  | { verb: "check" }
615
+ | { verb: "read"; read: string }
607
616
  | { verb: "help" }
608
617
  | { verb: "search"; search: string };
609
618
 
610
619
  /**
611
620
  * Resolve a tool call to one verb. An explicit `add` is never downgraded; a
612
- * `check` outranks a search; a lone `help` search explains the ranking;
613
- * anything else searches.
621
+ * `check` outranks a read; a non-blank `read` outranks a search; a lone `help`
622
+ * search explains the ranking; anything else searches.
614
623
  * @param input The raw tool input.
615
624
  * @param input.search The raw search text, when given.
616
625
  * @param input.add The typed add DTO, when given.
617
626
  * @param input.check The check flag, when given.
627
+ * @param input.read The root-relative doc path to read, when given.
618
628
  * @returns The single resolved request.
619
629
  */
620
630
  export function resolveRequest(input: DocsInput): DocsRequest {
@@ -624,6 +634,10 @@ export function resolveRequest(input: DocsInput): DocsRequest {
624
634
  if (input.check === true) {
625
635
  return { verb: "check" };
626
636
  }
637
+ const read = input.read?.trim() ?? "";
638
+ if (read !== "") {
639
+ return { verb: "read", read };
640
+ }
627
641
  if (isHelp(input.search ?? "")) {
628
642
  return { verb: "help" };
629
643
  }
@@ -738,6 +752,132 @@ export function collectDocs(
738
752
  );
739
753
  }
740
754
 
755
+ /**
756
+ * Directory names the fallback README walk never enters, for a project outside
757
+ * a git work tree (inside one, `.gitignore` decides; dot-directories are always
758
+ * skipped). Kept to dependencies and build output — the usual noise — rather
759
+ * than every convention going.
760
+ */
761
+ export const README_EXCLUDED_DIRS = [
762
+ "node_modules",
763
+ "dist",
764
+ "build",
765
+ "coverage",
766
+ "target",
767
+ "vendor",
768
+ "__pycache__",
769
+ ] as const;
770
+
771
+ /**
772
+ * Whether `path` is `root` itself or sits beneath it. Only an exact `..` path
773
+ * segment escapes the root — a `..`-prefixed name like `..drafts` does not.
774
+ */
775
+ function isUnder(path: string, root: string): boolean {
776
+ const rest = relative(root, path);
777
+ return (
778
+ rest === "" ||
779
+ (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
780
+ );
781
+ }
782
+
783
+ /** Whether a file name is a README in any case (`README.md`, `readme.md`). */
784
+ function isReadme(name: string): boolean {
785
+ return name.toLowerCase() === "readme.md";
786
+ }
787
+
788
+ /**
789
+ * The project's nested README paths, relative to `location`. Inside a git work
790
+ * tree git supplies the list — tracked plus untracked, honouring `.gitignore` —
791
+ * so dependency, build and scratch directories follow the project's own rules.
792
+ * Outside one, a walk skips {@link README_EXCLUDED_DIRS} and dot-directories.
793
+ * Paths under `skip` (the lake root) are dropped: they are already lake docs.
794
+ * A tracked file deleted from disk is dropped too — git still lists it.
795
+ * @param location The project directory the plugin loaded in.
796
+ * @param skip Absolute roots whose READMEs are collected elsewhere.
797
+ * @returns Project-relative README paths, ordered by path.
798
+ */
799
+ export function readmePaths(
800
+ location: string,
801
+ skip: readonly string[] = [],
802
+ ): string[] {
803
+ const candidates = gitReadmePaths(location) ?? walkedReadmePaths(location);
804
+ return candidates
805
+ .filter((path) => existsSync(join(location, path)))
806
+ .filter(
807
+ (path) => !skip.some((root) => isUnder(join(location, path), root)),
808
+ )
809
+ .sort();
810
+ }
811
+
812
+ /**
813
+ * README paths from git's index and untracked non-ignored files, or undefined
814
+ * when `location` is not inside a git work tree.
815
+ */
816
+ function gitReadmePaths(location: string): string[] | undefined {
817
+ const result = spawnSync(
818
+ "git",
819
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
820
+ { cwd: location, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
821
+ );
822
+ if (result.status !== 0) {
823
+ return undefined;
824
+ }
825
+ return (result.stdout ?? "")
826
+ .split("\0")
827
+ .filter((path) => path !== "" && isReadme(path.split("/").pop() ?? ""));
828
+ }
829
+
830
+ /** README paths from a directory walk, for a location outside a work tree. */
831
+ function walkedReadmePaths(location: string): string[] {
832
+ const paths: string[] = [];
833
+ const excluded = README_EXCLUDED_DIRS as readonly string[];
834
+ const walk = (directory: string): void => {
835
+ let entries: Dirent[];
836
+ try {
837
+ entries = readdirSync(directory, { withFileTypes: true });
838
+ } catch {
839
+ return;
840
+ }
841
+ for (const item of entries) {
842
+ const path = join(directory, item.name);
843
+ if (item.isDirectory()) {
844
+ if (item.name.startsWith(".") || excluded.includes(item.name)) {
845
+ continue;
846
+ }
847
+ walk(path);
848
+ } else if (item.isFile() && isReadme(item.name)) {
849
+ paths.push(relative(location, path));
850
+ }
851
+ }
852
+ };
853
+ walk(location);
854
+ return paths;
855
+ }
856
+
857
+ /**
858
+ * Collect the project's nested READMEs as search entries, with the same shape
859
+ * as lake docs: H1 title, path-derived area, no front-matter.
860
+ * @param location The project directory the plugin loaded in.
861
+ * @param resolve Resolves every path's date in one batched call.
862
+ * @param skip Absolute roots whose READMEs are collected elsewhere.
863
+ * @returns Entries ordered by path, or none when the project is unreadable.
864
+ */
865
+ export function collectReadmes(
866
+ location: string,
867
+ resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
868
+ skip: readonly string[] = [],
869
+ ): DocEntry[] {
870
+ const paths = readmePaths(location, skip);
871
+ const dates = resolve(paths);
872
+ return paths.map((path) =>
873
+ toEntry(
874
+ path,
875
+ readFileSync(join(location, path), "utf8"),
876
+ dates.get(path) ?? "",
877
+ ),
878
+ );
879
+ }
880
+
741
881
  /**
742
882
  * Kebab-case a doc title into a file-name slug: lower-cased word characters,
743
883
  * everything else collapsed to single dashes, dashes trimmed.
@@ -1106,9 +1246,150 @@ export function checkDocs(
1106
1246
  : lines.join("\n");
1107
1247
  }
1108
1248
 
1249
+ /** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
1250
+ export const NUDGED_TOOLS = ["grep", "glob"] as const;
1251
+
1252
+ /**
1253
+ * The text of a tool result's content: the string itself, or its text parts
1254
+ * joined. Non-text parts and foreign shapes contribute nothing.
1255
+ * @param content A `Tool.Result` content of any provenance.
1256
+ * @returns The searchable text, possibly empty.
1257
+ */
1258
+ function contentText(content: unknown): string {
1259
+ if (typeof content === "string") {
1260
+ return content;
1261
+ }
1262
+ if (Array.isArray(content)) {
1263
+ return content
1264
+ .map((part) =>
1265
+ typeof part === "object" &&
1266
+ part !== null &&
1267
+ "text" in part &&
1268
+ typeof (part as { text: unknown }).text === "string"
1269
+ ? (part as { text: string }).text
1270
+ : "",
1271
+ )
1272
+ .join("\n");
1273
+ }
1274
+ return "";
1275
+ }
1276
+
1277
+ /**
1278
+ * Whether a grep/glob result reads as a search miss. Raw rg prints nothing
1279
+ * on a miss (exit 1, empty stdout) — "No matches found" and "No files found"
1280
+ * are the host wrapper's phrasings — so emptiness is the primary signal and
1281
+ * the phrases are a bonus that keeps working if the wrapper rewords.
1282
+ * @param content The tool result's content (string or content parts).
1283
+ * @returns True when the result is a miss.
1284
+ */
1285
+ export function isSearchMiss(content: unknown): boolean {
1286
+ const trimmed = contentText(content).trim();
1287
+ return trimmed === "" || /no (matches|files) found/i.test(trimmed);
1288
+ }
1289
+
1290
+ /** The grep/glob call's `pattern`, when the input carries a string one. */
1291
+ function patternOf(input: unknown): string {
1292
+ if (typeof input === "object" && input !== null && "pattern" in input) {
1293
+ const pattern = (input as { pattern: unknown }).pattern;
1294
+ if (typeof pattern === "string") {
1295
+ return pattern.trim();
1296
+ }
1297
+ }
1298
+ return "";
1299
+ }
1300
+
1301
+ /**
1302
+ * The one-line pointer appended to a grep/glob miss: the docs tool indexes
1303
+ * the same knowledge, so echo the failed pattern when there is one.
1304
+ * @param pattern The failed call's pattern, possibly blank.
1305
+ * @returns The nudge line.
1306
+ */
1307
+ export function missNudge(pattern: string): string {
1308
+ const suggestion =
1309
+ pattern === ""
1310
+ ? "a knowledge question may already be answered there."
1311
+ : `try \`docs search\` with terms from "${pattern}".`;
1312
+ return (
1313
+ "Note: the `docs` tool also indexes the knowledge docs and project " +
1314
+ `READMEs before you move on — ${suggestion}`
1315
+ );
1316
+ }
1317
+
1318
+ /** One tool call, as the miss-nudge decision sees it. */
1319
+ export interface NudgeCall {
1320
+ /** The tool's registered name. */
1321
+ readonly tool: string;
1322
+ /** `"completed"` results only — an errored call is not a knowledge miss. */
1323
+ readonly status: "completed" | "error";
1324
+ /** The result's content (string or parts). */
1325
+ readonly content: unknown;
1326
+ /** The call's input, for the failed pattern. */
1327
+ readonly input: unknown;
1328
+ }
1329
+
1330
+ /**
1331
+ * The nudge line for a tool call, or null when the call deserves none: not a
1332
+ * watched tool, an errored call, a genuine hit, or a session that has already
1333
+ * been nudged (or already used the lake).
1334
+ * @param call The tool call to judge.
1335
+ * @param alreadyNudged Whether the session was nudged — or used the docs
1336
+ * tool — already.
1337
+ * @returns The line to append, or null.
1338
+ */
1339
+ export function missNudgeFor(
1340
+ call: NudgeCall,
1341
+ alreadyNudged: boolean,
1342
+ ): string | null {
1343
+ if (alreadyNudged || call.status !== "completed") {
1344
+ return null;
1345
+ }
1346
+ if (!(NUDGED_TOOLS as readonly string[]).includes(call.tool)) {
1347
+ return null;
1348
+ }
1349
+ if (!isSearchMiss(call.content)) {
1350
+ return null;
1351
+ }
1352
+ return missNudge(patternOf(call.input));
1353
+ }
1354
+
1355
+ /**
1356
+ * The system part injected into compaction requests: the summariser has no
1357
+ * tools, so it cannot record — instead it shapes each durable finding as its
1358
+ * own record-ready block for the continuing agent, one artifact per topic,
1359
+ * never a session dump (the near-twin guard catches duplicates, not dumps).
1360
+ */
1361
+ export const RECORD_NUDGE =
1362
+ "Before summarising: if this session established durable knowledge " +
1363
+ "(decisions, traps, runbooks, verified behaviour), open the " +
1364
+ "continuation with one `Record with docs add:` block per finding — " +
1365
+ "never one session dump. Each block names its own artifact's `Area`, " +
1366
+ "`H1` title and `Keywords`, plus a one-line what-and-why, so the next " +
1367
+ "agent can persist each as a separate focused doc.";
1368
+
1369
+ /**
1370
+ * Resolve the plugin's `nudge` option: unset means on; a boolean passes
1371
+ * through; anything else throws, matching `root`'s loud-failure precedent.
1372
+ * @param configured The raw option value as the host delivers it.
1373
+ * @returns Whether the runtime nudges are active.
1374
+ */
1375
+ export function resolveNudge(configured: unknown): boolean {
1376
+ if (configured === undefined) {
1377
+ return true;
1378
+ }
1379
+ if (typeof configured === "boolean") {
1380
+ return configured;
1381
+ }
1382
+ throw new Error(
1383
+ `docs plugin option 'nudge' must be a boolean, ` +
1384
+ `got ${JSON.stringify(configured)}.`,
1385
+ );
1386
+ }
1387
+
1109
1388
  export interface DocsToolDeps {
1110
- /** Collects the docs to serve; injected so the tool is testable. */
1389
+ /** Collects the lake docs; `add` and `check` use only these. */
1111
1390
  collect: () => DocEntry[];
1391
+ /** Collects everything searchable — lake docs plus local READMEs; defaults to `collect`. */
1392
+ search?: () => DocEntry[];
1112
1393
  /** Whether a root-relative doc path exists; injected so writes are testable. */
1113
1394
  exists: (path: string) => boolean;
1114
1395
  /** Writes a root-relative doc path; injected so writes are testable. */
@@ -1126,8 +1407,10 @@ export type PromptSender = (
1126
1407
  ) => Promise<unknown>;
1127
1408
 
1128
1409
  export interface DocsCommandDeps {
1129
- /** Collects the docs to serve; injected so the command is testable. */
1410
+ /** Collects the lake docs; `add` and `check` use only these. */
1130
1411
  collect: () => DocEntry[];
1412
+ /** Collects everything searchable — lake docs plus local READMEs; defaults to `collect`. */
1413
+ search?: () => DocEntry[];
1131
1414
  /** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
1132
1415
  read: (path: string) => string;
1133
1416
  /** Environment notes appended to the `check` report; injected for testability. */
@@ -1136,19 +1419,45 @@ export interface DocsCommandDeps {
1136
1419
  prompt: PromptSender;
1137
1420
  }
1138
1421
 
1422
+ /**
1423
+ * The twin's description: the Code Mode catalog is injected into every
1424
+ * `execute` prompt, so it pays its way in a short paragraph — the verbs, not
1425
+ * the full doctrine the native `docs` description carries.
1426
+ */
1427
+ const TWIN_DESCRIPTION =
1428
+ "Search, read, add and check the knowledge docs under the configured " +
1429
+ "docs root (plus nested project `README.md` files, search-only) from " +
1430
+ "inside `execute` — the `docs` tool's Code Mode twin. A blank search " +
1431
+ "lists the most recently changed docs; `read` returns a matched doc " +
1432
+ "verbatim; `add` writes through a typed DTO; `check` validates the " +
1433
+ "convention.";
1434
+
1139
1435
  /**
1140
1436
  * Build the `docs` tool from its dependencies.
1141
1437
  *
1142
- * Registered with `codemode: false` so the tool stays on the provider's native
1143
- * tool list. That option is exclusive: a tool defaults into Code Mode
1144
- * (`codemode` defaults true) and is dropped from the native list; only
1145
- * `codemode: false` keeps it natively callable. This tool is called directly,
1146
- * so it opts out.
1438
+ * By default the tool is registered with `codemode: false` so it stays on the
1439
+ * provider's native tool list. That option is exclusive: a tool defaults into
1440
+ * Code Mode (`codemode` defaults true) and is dropped from the native list;
1441
+ * only `codemode: false` keeps it natively callable — and `codemode: true`
1442
+ * with a `namespace` is how the host's own plugins expose tools to `execute`
1443
+ * (the catalog renders `tools.<namespace>.<name>(…)`). Pass a distinct
1444
+ * `name` and `codemode: true` to register a Code Mode twin of the same core;
1445
+ * the registry is name-keyed, so the twin cannot also be called `docs`.
1147
1446
  * @param deps Runtime seams used on each call: the docs to serve, and the
1148
1447
  * write seam {@link DocsToolDeps} for `add`.
1448
+ * @param options Registration shape.
1449
+ * @param options.name The tool name (default `"docs"`); a Code Mode twin
1450
+ * passes a distinct name — the registry is name-keyed, so two tools cannot
1451
+ * share one.
1452
+ * @param options.codemode True to register for the `execute` catalog under
1453
+ * the `docs` namespace (default `false` — the native tool list).
1149
1454
  * @returns The tool the host registers.
1150
1455
  */
1151
- export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1456
+ export function createDocsTool(
1457
+ deps: DocsToolDeps,
1458
+ options: { name?: string; codemode?: boolean } = {},
1459
+ ): Info<typeof DOCS_INPUT> {
1460
+ const { name = "docs", codemode = false } = options;
1152
1461
  const addDoc = (add: DocsAdd) => {
1153
1462
  const outcome = prepareAdd(add, deps.collect());
1154
1463
  if (outcome.action === "refuse") {
@@ -1166,17 +1475,45 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1166
1475
  metadata: { written: true },
1167
1476
  };
1168
1477
  };
1478
+ const readDoc = (given: string) => {
1479
+ // The same guard an explicit `add` path passes: relative, inside the
1480
+ // root, a `.md` file — the read verb never widens the surface.
1481
+ const target = explicitTarget(given.trim());
1482
+ if ("reason" in target) {
1483
+ return {
1484
+ content: `${REFUSE} ${target.reason}`,
1485
+ metadata: { read: null },
1486
+ };
1487
+ }
1488
+ if (!deps.exists(target.path)) {
1489
+ return {
1490
+ content: `No doc at ${target.path}.`,
1491
+ metadata: { read: null },
1492
+ };
1493
+ }
1494
+ const raw = deps.read(target.path);
1495
+ const { body } = parseFrontMatter(raw);
1496
+ return {
1497
+ content: `${target.path} — ${titleOf(target.path, body)}\n\n${raw}`,
1498
+ metadata: { read: target.path },
1499
+ };
1500
+ };
1169
1501
  return {
1170
- name: "docs",
1171
- description:
1172
- "Discover the knowledge docs under the configured docs root " +
1173
- "(default: the location's `docs/` directory). A blank search lists the " +
1174
- MAX_RESULTS +
1175
- " most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area and summary, falling back to the closest matches. Every call returns at most " +
1176
- MAX_RESULTS +
1177
- " results and counts the rest. Search exactly `help` explains the ranking. `add` writes a new knowledge doc (or wholesale-replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank required fields, path traversal, non-.md targets and same-area near-twins are refused. `check` validates every doc against the convention (required front-matter, H1, claim sections, no hand-maintained Date, distinctive H1+Keywords, a Supersedes naming a doc in the tree, near-twins) and reports findings; an empty report means compliant. Read a matched file with the read tool.",
1502
+ name,
1503
+ description: codemode
1504
+ ? TWIN_DESCRIPTION
1505
+ : "Discover the knowledge docs under the configured docs root " +
1506
+ "(default: the location's `docs/` directory), plus nested `README.md` " +
1507
+ "files in the project tree. Prefer it over a broad grep when the " +
1508
+ "question is how-to knowledge rather than where code lives. A blank search lists the " +
1509
+ MAX_RESULTS +
1510
+ " most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area and summary, falling back to the closest matches. Every call returns at most " +
1511
+ MAX_RESULTS +
1512
+ " results and counts the rest. Search exactly `help` explains the ranking. `read` returns a matched doc verbatim (front-matter and body). `add` writes a new knowledge doc (or wholesale-replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank required fields, path traversal, non-.md targets and same-area near-twins are refused. `check` validates every doc against the convention (required front-matter, H1, claim sections, no hand-maintained Date, distinctive H1+Keywords, a Supersedes naming a doc in the tree, near-twins) and reports findings; an empty report means compliant. Local READMEs are search-only: `add` and `check` ignore them. Read a matched project file with the built-in read tool.",
1178
1513
  input: DOCS_INPUT,
1179
- options: { codemode: false },
1514
+ options: codemode
1515
+ ? { namespace: "docs", codemode: true }
1516
+ : { codemode: false },
1180
1517
  async execute(input) {
1181
1518
  const request = resolveRequest(input);
1182
1519
  if (request.verb === "add") {
@@ -1189,6 +1526,9 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1189
1526
  metadata: { checked: entries.length },
1190
1527
  };
1191
1528
  }
1529
+ if (request.verb === "read") {
1530
+ return readDoc(request.read);
1531
+ }
1192
1532
  if (request.verb === "help") {
1193
1533
  return {
1194
1534
  content: explainRanking(),
@@ -1196,7 +1536,7 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1196
1536
  };
1197
1537
  }
1198
1538
  const { entries, closest, omitted } = rankDocs(
1199
- deps.collect(),
1539
+ (deps.search ?? deps.collect)(),
1200
1540
  request.search,
1201
1541
  );
1202
1542
  return {
@@ -1243,7 +1583,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1243
1583
  return {
1244
1584
  name: "docs",
1245
1585
  description:
1246
- "Search the knowledge docs under the configured docs root, ranked; `add <text>` records new knowledge through the tool's typed `add`; `check` validates the convention; a blank search lists the " +
1586
+ "Search the knowledge docs under the configured docs root, ranked, plus nested project `README.md` files (search only); `add <text>` records new knowledge through the tool's typed `add`; `check` validates the convention; a blank search lists the " +
1247
1587
  MAX_RESULTS +
1248
1588
  " most recently changed.",
1249
1589
  async execute({ sessionID, prompt }) {
@@ -1267,7 +1607,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1267
1607
  }
1268
1608
  const query = lower === SEARCH_VERB ? rest.join(" ") : text;
1269
1609
  const { entries, closest, omitted } = rankDocs(
1270
- deps.collect(),
1610
+ (deps.search ?? deps.collect)(),
1271
1611
  query,
1272
1612
  );
1273
1613
  await deps.prompt(sessionID, render(entries, { closest, omitted }));
@@ -1277,10 +1617,12 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1277
1617
 
1278
1618
  /**
1279
1619
  * Resolve the plugin's docs root from its `root` option. Unset → the
1280
- * location's `docs/` directory; `~` or `~/…` expands against the home
1620
+ * location's `docs/` directory — a blank string counts as unset, since it is
1621
+ * almost always an empty environment variable or half-finished config rather
1622
+ * than a deliberate path; `~` or `~/…` expands against the home
1281
1623
  * directory; absolute paths pass through; anything else resolves against the
1282
1624
  * plugin location. The resolved value is the knowledge tree itself — no
1283
- * segment is appended. A non-string or blank value throws: a mistyped option
1625
+ * segment is appended. A non-string value throws: a mistyped option
1284
1626
  * must fail loudly, not silently fall back to the wrong tree.
1285
1627
  * @param configured The raw option value as the host delivers it.
1286
1628
  * @param locationDirectory The directory the plugin instance loaded in.
@@ -1293,13 +1635,16 @@ export function resolveRoot(
1293
1635
  if (configured === undefined) {
1294
1636
  return join(locationDirectory, "docs");
1295
1637
  }
1296
- if (typeof configured !== "string" || configured.trim() === "") {
1638
+ if (typeof configured !== "string") {
1297
1639
  throw new Error(
1298
1640
  `docs plugin option 'root' must be a non-empty string path, ` +
1299
1641
  `got ${JSON.stringify(configured)}.`,
1300
1642
  );
1301
1643
  }
1302
1644
  const given = configured.trim();
1645
+ if (given === "") {
1646
+ return join(locationDirectory, "docs");
1647
+ }
1303
1648
  const expanded =
1304
1649
  given === "~" || given.startsWith("~/")
1305
1650
  ? join(homedir(), given.slice(1))
@@ -1336,6 +1681,27 @@ export function unversionedNote(root: string): readonly string[] {
1336
1681
  interface DocsPluginOptions {
1337
1682
  /** The docs root override; defaults to `docs` beside the plugin. */
1338
1683
  root?: unknown;
1684
+ /** The runtime nudges' switch; defaults to on. */
1685
+ nudge?: unknown;
1686
+ }
1687
+
1688
+ /** The registered tool names that count as using the lake. */
1689
+ const DOCS_TOOL_NAMES = new Set(["docs", "docs_search"]);
1690
+
1691
+ /**
1692
+ * Content with `line` appended: string concatenation, or one extra text part.
1693
+ * @param content The tool result's content, when it has one.
1694
+ * @param line The nudge line.
1695
+ * @returns The content to write back.
1696
+ */
1697
+ function appendLine(
1698
+ content: NonNullable<Result["content"]> | undefined,
1699
+ line: string,
1700
+ ): NonNullable<Result["content"]> {
1701
+ if (typeof content === "string" || content === undefined) {
1702
+ return `${content ?? ""}\n\n${line}`;
1703
+ }
1704
+ return [...content, { type: "text", text: line }];
1339
1705
  }
1340
1706
 
1341
1707
  export default Plugin.define({
@@ -1343,22 +1709,41 @@ export default Plugin.define({
1343
1709
  async setup(ctx) {
1344
1710
  const options = ctx.options as DocsPluginOptions | undefined;
1345
1711
  const root = resolveRoot(options?.root, ctx.location.directory);
1712
+ const nudge = resolveNudge(options?.nudge);
1346
1713
  const notes = unversionedNote(root);
1347
1714
  const collect = () =>
1348
1715
  collectDocs(root, (paths) => fallbackDates(root, paths));
1716
+ const search = () => [
1717
+ ...collect(),
1718
+ ...collectReadmes(
1719
+ ctx.location.directory,
1720
+ (paths) => fallbackDates(ctx.location.directory, paths),
1721
+ [root],
1722
+ ),
1723
+ ];
1349
1724
  const read = (path: string) => readFileSync(join(root, path), "utf8");
1725
+ const toolDeps = {
1726
+ collect,
1727
+ search,
1728
+ exists: (path: string) => existsSync(join(root, path)),
1729
+ write: (path: string, contents: string) => {
1730
+ const target = join(root, path);
1731
+ mkdirSync(dirname(target), { recursive: true });
1732
+ writeFileSync(target, contents);
1733
+ },
1734
+ read,
1735
+ notes,
1736
+ };
1737
+ // Two registrations of one core: the native `docs` for direct calls,
1738
+ // and the Code Mode twin `docs_search` so `execute` reaches the same
1739
+ // lake as `tools.docs.docs_search(…)`. The registry is name-keyed, so
1740
+ // the twin must carry its own name.
1350
1741
  await ctx.tool.transform((editor) => {
1742
+ editor.add(createDocsTool(toolDeps));
1351
1743
  editor.add(
1352
- createDocsTool({
1353
- collect,
1354
- exists: (path) => existsSync(join(root, path)),
1355
- write: (path, contents) => {
1356
- const target = join(root, path);
1357
- mkdirSync(dirname(target), { recursive: true });
1358
- writeFileSync(target, contents);
1359
- },
1360
- read,
1361
- notes,
1744
+ createDocsTool(toolDeps, {
1745
+ name: "docs_search",
1746
+ codemode: true,
1362
1747
  }),
1363
1748
  );
1364
1749
  });
@@ -1366,13 +1751,58 @@ export default Plugin.define({
1366
1751
  editor.add(
1367
1752
  createDocsCommand({
1368
1753
  collect,
1754
+ search,
1369
1755
  read,
1370
1756
  notes,
1371
- prompt: (sessionID, text) =>
1372
- ctx.session.prompt({ sessionID, text }),
1757
+ prompt: (sessionID, text) => {
1758
+ // Any `/docs` use counts as consulting the lake.
1759
+ nudged.add(sessionID);
1760
+ return ctx.session.prompt({ sessionID, text });
1761
+ },
1373
1762
  }),
1374
1763
  );
1375
1764
  });
1765
+ // The nudges (default on; `nudge: false` restores silence). The
1766
+ // miss-nudge fires once per session, at the first grep/glob miss, and
1767
+ // is fail-quiet by construction — it must never break the tool it
1768
+ // wraps. The record-nudge rides compaction: the summariser has no
1769
+ // tools, so it shapes record-ready blocks for the continuing agent.
1770
+ const nudged = new Set<string>();
1771
+ if (nudge) {
1772
+ await ctx.tool.hook("execute.after", (event) => {
1773
+ try {
1774
+ if (event.status !== "completed") {
1775
+ return;
1776
+ }
1777
+ if (DOCS_TOOL_NAMES.has(event.tool)) {
1778
+ nudged.add(event.sessionID);
1779
+ return;
1780
+ }
1781
+ const line = missNudgeFor(
1782
+ {
1783
+ tool: event.tool,
1784
+ status: event.status,
1785
+ content: event.result.content,
1786
+ input: event.input,
1787
+ },
1788
+ nudged.has(event.sessionID),
1789
+ );
1790
+ if (line === null) {
1791
+ return;
1792
+ }
1793
+ nudged.add(event.sessionID);
1794
+ event.result = {
1795
+ ...event.result,
1796
+ content: appendLine(event.result.content, line),
1797
+ };
1798
+ } catch {
1799
+ // Fail-quiet: a nudge must never break the wrapped tool.
1800
+ }
1801
+ });
1802
+ await ctx.session.hook("compaction", (compaction) => {
1803
+ compaction.system.push({ type: "text", text: RECORD_NUDGE });
1804
+ });
1805
+ }
1376
1806
  // The skill is the doctrine's runtime face. Its content ships next to
1377
1807
  // this module and is resolved through `import.meta.url`, so it works
1378
1808
  // whether the plugin is loaded from a local path or an installed
@@ -1385,11 +1815,16 @@ export default Plugin.define({
1385
1815
  name: Skill.Name.make("Docs"),
1386
1816
  description:
1387
1817
  "The entry point to the knowledge docs under the " +
1388
- "configured docs root: find the right file with the docs " +
1389
- "tool, read it, and record plans and task documentation " +
1390
- "for the next human or agent. Use at the start of " +
1391
- "unfamiliar work, when hunting for prior decisions or " +
1392
- "traps, and when closing out a session.",
1818
+ "configured docs root, plus the project's nested " +
1819
+ "README.md files (search only): find the right file with " +
1820
+ "the docs tool, read it, and record plans and task " +
1821
+ "documentation for the next human or agent. Use before " +
1822
+ "searching the codebase for how-to knowledge, when a " +
1823
+ "task names a service, flag or error string the lake " +
1824
+ "may already explain, at the start of unfamiliar work, " +
1825
+ "when hunting for prior decisions or traps, when a " +
1826
+ "session learns something the next one will need, and " +
1827
+ "when closing out a session.",
1393
1828
  path: AbsolutePath.make(fileURLToPath(skillURL)),
1394
1829
  content: skill,
1395
1830
  });