@markstanden/opencode-docs-plugin 0.2.0 → 0.3.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,16 @@
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
12
  matches rather than a dead end; `add` writes knowledge through a typed DTO;
12
- `check` validates the tree against the convention.
13
+ `check` validates the tree against the convention. Nested project
14
+ `README.md` files are indexed too — `.gitignore` rules decide what is
15
+ skipped — but only for search: `add` and `check` never touch them.
13
16
  - `/docs` (command) — the same core, driven from the prompt:
14
17
  `search <text>` (bare text searches too), `add <text>`, `check`, `help`.
15
18
  - The plugin also registers the `docs` skill, so the convention travels with
@@ -17,6 +20,53 @@ configured docs root's `*.md` files (the `root` plugin option; default
17
20
 
18
21
  Reading a matched doc stays OpenCode's built-in `read`.
19
22
 
23
+ ## Installation
24
+
25
+ From npm:
26
+
27
+ ```sh
28
+ opencode plugin add @markstanden/opencode-docs-plugin
29
+ ```
30
+
31
+ or as a `plugins` entry in `opencode.json(c)` (global, project or `.opencode/`):
32
+
33
+ ```jsonc
34
+ {
35
+ "$schema": "https://opencode.ai/config.json",
36
+ "plugins": ["@markstanden/opencode-docs-plugin"],
37
+ }
38
+ ```
39
+
40
+ Unpinned entries update on the next server start or `opencode plugin update`;
41
+ an exact version (e.g. `@0.2.0`) stays pinned.
42
+
43
+ ## Configuration
44
+
45
+ The docs root defaults to `<location>/docs`. Set the `root` option with the
46
+ object form — `~` expands against the home directory, so a global config can
47
+ point every project at one shared tree:
48
+
49
+ ```jsonc
50
+ {
51
+ "$schema": "https://opencode.ai/config.json",
52
+ "plugins": [
53
+ {
54
+ "package": "@markstanden/opencode-docs-plugin",
55
+ "options": {
56
+ "root": "~/doclake",
57
+ },
58
+ },
59
+ ],
60
+ }
61
+ ```
62
+
63
+ Relative paths resolve against the location's directory; absolute paths pass
64
+ through. A blank `root` value counts as unset and uses the default; a
65
+ non-string value fails at startup. The root directory itself need not exist in
66
+ advance — it is created on the first `add`, and a search or `check` over a
67
+ still-empty tree simply reports nothing. Restart OpenCode after editing the
68
+ config — it isn't hot-reloaded.
69
+
20
70
  ## Status
21
71
 
22
72
  Standalone extract of the docs tooling, under active development.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@markstanden/opencode-docs-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
@@ -36,6 +36,13 @@ and vendored mirrors (`requirements/`, `wiki/`). `Area` comes from the path,
36
36
  `date` from git. A doc marked `Status: superseded` carries a `(superseded)`
37
37
  marker in results and ranks below an active doc on a tie.
38
38
 
39
+ Search also covers nested `README.md` files in the project tree — the map lives
40
+ beside the code. They are indexed as they stand (H1 title, headings, code
41
+ spans) and are **search-only**: `add` and `check` never touch them.
42
+ `.gitignore` decides which directories are skipped; outside git, dependency and
43
+ build directories are skipped by name. The lake root is skipped when it sits
44
+ inside the project, since those READMEs are already knowledge docs.
45
+
39
46
  ## Read it
40
47
 
41
48
  - Consult matching docs before a broad grep: they are the curated knowledge.
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import type { Info } 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
 
@@ -595,6 +596,7 @@ export function explainRanking(): string {
595
596
  `- 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
597
  `- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
597
598
  `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
599
+ "- 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
600
  `- 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
601
  '- 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
602
  ].join("\n");
@@ -738,6 +740,132 @@ export function collectDocs(
738
740
  );
739
741
  }
740
742
 
743
+ /**
744
+ * Directory names the fallback README walk never enters, for a project outside
745
+ * a git work tree (inside one, `.gitignore` decides; dot-directories are always
746
+ * skipped). Kept to dependencies and build output — the usual noise — rather
747
+ * than every convention going.
748
+ */
749
+ export const README_EXCLUDED_DIRS = [
750
+ "node_modules",
751
+ "dist",
752
+ "build",
753
+ "coverage",
754
+ "target",
755
+ "vendor",
756
+ "__pycache__",
757
+ ] as const;
758
+
759
+ /**
760
+ * Whether `path` is `root` itself or sits beneath it. Only an exact `..` path
761
+ * segment escapes the root — a `..`-prefixed name like `..drafts` does not.
762
+ */
763
+ function isUnder(path: string, root: string): boolean {
764
+ const rest = relative(root, path);
765
+ return (
766
+ rest === "" ||
767
+ (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
768
+ );
769
+ }
770
+
771
+ /** Whether a file name is a README in any case (`README.md`, `readme.md`). */
772
+ function isReadme(name: string): boolean {
773
+ return name.toLowerCase() === "readme.md";
774
+ }
775
+
776
+ /**
777
+ * The project's nested README paths, relative to `location`. Inside a git work
778
+ * tree git supplies the list — tracked plus untracked, honouring `.gitignore` —
779
+ * so dependency, build and scratch directories follow the project's own rules.
780
+ * Outside one, a walk skips {@link README_EXCLUDED_DIRS} and dot-directories.
781
+ * Paths under `skip` (the lake root) are dropped: they are already lake docs.
782
+ * A tracked file deleted from disk is dropped too — git still lists it.
783
+ * @param location The project directory the plugin loaded in.
784
+ * @param skip Absolute roots whose READMEs are collected elsewhere.
785
+ * @returns Project-relative README paths, ordered by path.
786
+ */
787
+ export function readmePaths(
788
+ location: string,
789
+ skip: readonly string[] = [],
790
+ ): string[] {
791
+ const candidates = gitReadmePaths(location) ?? walkedReadmePaths(location);
792
+ return candidates
793
+ .filter((path) => existsSync(join(location, path)))
794
+ .filter(
795
+ (path) => !skip.some((root) => isUnder(join(location, path), root)),
796
+ )
797
+ .sort();
798
+ }
799
+
800
+ /**
801
+ * README paths from git's index and untracked non-ignored files, or undefined
802
+ * when `location` is not inside a git work tree.
803
+ */
804
+ function gitReadmePaths(location: string): string[] | undefined {
805
+ const result = spawnSync(
806
+ "git",
807
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
808
+ { cwd: location, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
809
+ );
810
+ if (result.status !== 0) {
811
+ return undefined;
812
+ }
813
+ return (result.stdout ?? "")
814
+ .split("\0")
815
+ .filter((path) => path !== "" && isReadme(path.split("/").pop() ?? ""));
816
+ }
817
+
818
+ /** README paths from a directory walk, for a location outside a work tree. */
819
+ function walkedReadmePaths(location: string): string[] {
820
+ const paths: string[] = [];
821
+ const excluded = README_EXCLUDED_DIRS as readonly string[];
822
+ const walk = (directory: string): void => {
823
+ let entries: Dirent[];
824
+ try {
825
+ entries = readdirSync(directory, { withFileTypes: true });
826
+ } catch {
827
+ return;
828
+ }
829
+ for (const item of entries) {
830
+ const path = join(directory, item.name);
831
+ if (item.isDirectory()) {
832
+ if (item.name.startsWith(".") || excluded.includes(item.name)) {
833
+ continue;
834
+ }
835
+ walk(path);
836
+ } else if (item.isFile() && isReadme(item.name)) {
837
+ paths.push(relative(location, path));
838
+ }
839
+ }
840
+ };
841
+ walk(location);
842
+ return paths;
843
+ }
844
+
845
+ /**
846
+ * Collect the project's nested READMEs as search entries, with the same shape
847
+ * as lake docs: H1 title, path-derived area, no front-matter.
848
+ * @param location The project directory the plugin loaded in.
849
+ * @param resolve Resolves every path's date in one batched call.
850
+ * @param skip Absolute roots whose READMEs are collected elsewhere.
851
+ * @returns Entries ordered by path, or none when the project is unreadable.
852
+ */
853
+ export function collectReadmes(
854
+ location: string,
855
+ resolve: (paths: readonly string[]) => ReadonlyMap<string, string>,
856
+ skip: readonly string[] = [],
857
+ ): DocEntry[] {
858
+ const paths = readmePaths(location, skip);
859
+ const dates = resolve(paths);
860
+ return paths.map((path) =>
861
+ toEntry(
862
+ path,
863
+ readFileSync(join(location, path), "utf8"),
864
+ dates.get(path) ?? "",
865
+ ),
866
+ );
867
+ }
868
+
741
869
  /**
742
870
  * Kebab-case a doc title into a file-name slug: lower-cased word characters,
743
871
  * everything else collapsed to single dashes, dashes trimmed.
@@ -1107,8 +1235,10 @@ export function checkDocs(
1107
1235
  }
1108
1236
 
1109
1237
  export interface DocsToolDeps {
1110
- /** Collects the docs to serve; injected so the tool is testable. */
1238
+ /** Collects the lake docs; `add` and `check` use only these. */
1111
1239
  collect: () => DocEntry[];
1240
+ /** Collects everything searchable — lake docs plus local READMEs; defaults to `collect`. */
1241
+ search?: () => DocEntry[];
1112
1242
  /** Whether a root-relative doc path exists; injected so writes are testable. */
1113
1243
  exists: (path: string) => boolean;
1114
1244
  /** Writes a root-relative doc path; injected so writes are testable. */
@@ -1126,8 +1256,10 @@ export type PromptSender = (
1126
1256
  ) => Promise<unknown>;
1127
1257
 
1128
1258
  export interface DocsCommandDeps {
1129
- /** Collects the docs to serve; injected so the command is testable. */
1259
+ /** Collects the lake docs; `add` and `check` use only these. */
1130
1260
  collect: () => DocEntry[];
1261
+ /** Collects everything searchable — lake docs plus local READMEs; defaults to `collect`. */
1262
+ search?: () => DocEntry[];
1131
1263
  /** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
1132
1264
  read: (path: string) => string;
1133
1265
  /** Environment notes appended to the `check` report; injected for testability. */
@@ -1170,11 +1302,12 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1170
1302
  name: "docs",
1171
1303
  description:
1172
1304
  "Discover the knowledge docs under the configured docs root " +
1173
- "(default: the location's `docs/` directory). A blank search lists the " +
1305
+ "(default: the location's `docs/` directory), plus nested `README.md` " +
1306
+ "files in the project tree. A blank search lists the " +
1174
1307
  MAX_RESULTS +
1175
1308
  " 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
1309
  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.",
1310
+ " 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. Local READMEs are search-only: `add` and `check` ignore them. Read a matched file with the read tool.",
1178
1311
  input: DOCS_INPUT,
1179
1312
  options: { codemode: false },
1180
1313
  async execute(input) {
@@ -1196,7 +1329,7 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1196
1329
  };
1197
1330
  }
1198
1331
  const { entries, closest, omitted } = rankDocs(
1199
- deps.collect(),
1332
+ (deps.search ?? deps.collect)(),
1200
1333
  request.search,
1201
1334
  );
1202
1335
  return {
@@ -1243,7 +1376,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1243
1376
  return {
1244
1377
  name: "docs",
1245
1378
  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 " +
1379
+ "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
1380
  MAX_RESULTS +
1248
1381
  " most recently changed.",
1249
1382
  async execute({ sessionID, prompt }) {
@@ -1267,7 +1400,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1267
1400
  }
1268
1401
  const query = lower === SEARCH_VERB ? rest.join(" ") : text;
1269
1402
  const { entries, closest, omitted } = rankDocs(
1270
- deps.collect(),
1403
+ (deps.search ?? deps.collect)(),
1271
1404
  query,
1272
1405
  );
1273
1406
  await deps.prompt(sessionID, render(entries, { closest, omitted }));
@@ -1277,10 +1410,12 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1277
1410
 
1278
1411
  /**
1279
1412
  * Resolve the plugin's docs root from its `root` option. Unset → the
1280
- * location's `docs/` directory; `~` or `~/…` expands against the home
1413
+ * location's `docs/` directory — a blank string counts as unset, since it is
1414
+ * almost always an empty environment variable or half-finished config rather
1415
+ * than a deliberate path; `~` or `~/…` expands against the home
1281
1416
  * directory; absolute paths pass through; anything else resolves against the
1282
1417
  * 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
1418
+ * segment is appended. A non-string value throws: a mistyped option
1284
1419
  * must fail loudly, not silently fall back to the wrong tree.
1285
1420
  * @param configured The raw option value as the host delivers it.
1286
1421
  * @param locationDirectory The directory the plugin instance loaded in.
@@ -1293,13 +1428,16 @@ export function resolveRoot(
1293
1428
  if (configured === undefined) {
1294
1429
  return join(locationDirectory, "docs");
1295
1430
  }
1296
- if (typeof configured !== "string" || configured.trim() === "") {
1431
+ if (typeof configured !== "string") {
1297
1432
  throw new Error(
1298
1433
  `docs plugin option 'root' must be a non-empty string path, ` +
1299
1434
  `got ${JSON.stringify(configured)}.`,
1300
1435
  );
1301
1436
  }
1302
1437
  const given = configured.trim();
1438
+ if (given === "") {
1439
+ return join(locationDirectory, "docs");
1440
+ }
1303
1441
  const expanded =
1304
1442
  given === "~" || given.startsWith("~/")
1305
1443
  ? join(homedir(), given.slice(1))
@@ -1346,11 +1484,20 @@ export default Plugin.define({
1346
1484
  const notes = unversionedNote(root);
1347
1485
  const collect = () =>
1348
1486
  collectDocs(root, (paths) => fallbackDates(root, paths));
1487
+ const search = () => [
1488
+ ...collect(),
1489
+ ...collectReadmes(
1490
+ ctx.location.directory,
1491
+ (paths) => fallbackDates(ctx.location.directory, paths),
1492
+ [root],
1493
+ ),
1494
+ ];
1349
1495
  const read = (path: string) => readFileSync(join(root, path), "utf8");
1350
1496
  await ctx.tool.transform((editor) => {
1351
1497
  editor.add(
1352
1498
  createDocsTool({
1353
1499
  collect,
1500
+ search,
1354
1501
  exists: (path) => existsSync(join(root, path)),
1355
1502
  write: (path, contents) => {
1356
1503
  const target = join(root, path);
@@ -1366,6 +1513,7 @@ export default Plugin.define({
1366
1513
  editor.add(
1367
1514
  createDocsCommand({
1368
1515
  collect,
1516
+ search,
1369
1517
  read,
1370
1518
  notes,
1371
1519
  prompt: (sessionID, text) =>
@@ -1385,11 +1533,12 @@ export default Plugin.define({
1385
1533
  name: Skill.Name.make("Docs"),
1386
1534
  description:
1387
1535
  "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.",
1536
+ "configured docs root, plus the project's nested " +
1537
+ "README.md files (search only): find the right file with " +
1538
+ "the docs tool, read it, and record plans and task " +
1539
+ "documentation for the next human or agent. Use at the " +
1540
+ "start of unfamiliar work, when hunting for prior " +
1541
+ "decisions or traps, and when closing out a session.",
1393
1542
  path: AbsolutePath.make(fileURLToPath(skillURL)),
1394
1543
  content: skill,
1395
1544
  });