@markstanden/opencode-docs-plugin 0.1.0 → 0.2.1

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
@@ -6,8 +6,9 @@ repository that adopts the plugin takes the convention with it.
6
6
 
7
7
  ## Where knowledge lives
8
8
 
9
- Durable knowledge is one Markdown file per topic under `docs/`, grouped by area:
10
- `docs/<area>/<name>.md`. The excluded areas — never scanned, never validated —
9
+ Durable knowledge is one Markdown file per topic under the docs root, grouped by
10
+ area: `<area>/<name>.md` (the root is the plugin's `root` option; default
11
+ `<location>/docs`). The excluded areas — never scanned, never validated —
11
12
  are `plans/` (scratch, gitignored, deleted when the work lands),
12
13
  `requirements/` and `wiki/` (vendored mirrors).
13
14
 
@@ -17,7 +18,7 @@ Every doc opens with a `---` front-matter block. The fields, in canonical order:
17
18
 
18
19
  | Field | Required | Meaning |
19
20
  | ------------ | -------- | ----------------------------------------------------------------------- |
20
- | `Area` | yes | One path segment under `docs/`; the doc's group. |
21
+ | `Area` | yes | One top-level path segment; the doc's group. |
21
22
  | `Keywords` | yes | Comma-separated search terms; the second half of the doc's identity. |
22
23
  | `Summary` | yes | One self-contained sentence: enough to decide whether to open the file. |
23
24
  | `Status` | no | `active` (the default) or `superseded`. |
@@ -55,5 +56,5 @@ tree.
55
56
  ## Adopting it
56
57
 
57
58
  Run `check` to audit an existing tree, and the gate's `defined comply` before
58
- proposing a commit. A missing `docs/` tree is not an error: the plugin scans
59
+ proposing a commit. A missing docs root is not an error: the plugin scans
59
60
  what is there and reports per-doc findings for what is not.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # opencode-docs-plugin
2
2
 
3
3
  An [OpenCode](https://opencode.ai) V2 plugin that gives an agent a knowledge
4
- tree it can actually use: a `docs` tool and a `/docs` command over a
5
- repository's `docs/**/*.md`, plus the writing convention those docs follow.
4
+ tree it can actually use: a `docs` tool and a `/docs` command over the
5
+ configured docs root's `*.md` files (the `root` plugin option; default
6
+ `<location>/docs`), plus the writing convention those docs follow.
6
7
 
7
8
  - `docs` (tool) — blank search lists the 10 most recently changed docs
8
9
  (`path — Area — date — Summary`); a search ranks docs over their
@@ -16,6 +17,49 @@ repository's `docs/**/*.md`, plus the writing convention those docs follow.
16
17
 
17
18
  Reading a matched doc stays OpenCode's built-in `read`.
18
19
 
20
+ ## Installation
21
+
22
+ From npm:
23
+
24
+ ```sh
25
+ opencode plugin add @markstanden/opencode-docs-plugin
26
+ ```
27
+
28
+ or as a `plugins` entry in `opencode.json(c)` (global, project or `.opencode/`):
29
+
30
+ ```jsonc
31
+ {
32
+ "$schema": "https://opencode.ai/config.json",
33
+ "plugins": ["@markstanden/opencode-docs-plugin"],
34
+ }
35
+ ```
36
+
37
+ Unpinned entries update on the next server start or `opencode plugin update`;
38
+ an exact version (e.g. `@0.2.0`) stays pinned.
39
+
40
+ ## Configuration
41
+
42
+ The docs root defaults to `<location>/docs`. Set the `root` option with the
43
+ object form — `~` expands against the home directory, so a global config can
44
+ point every project at one shared tree:
45
+
46
+ ```jsonc
47
+ {
48
+ "$schema": "https://opencode.ai/config.json",
49
+ "plugins": [
50
+ {
51
+ "package": "@markstanden/opencode-docs-plugin",
52
+ "options": {
53
+ "root": "~/doclake",
54
+ },
55
+ },
56
+ ],
57
+ }
58
+ ```
59
+
60
+ Relative paths resolve against the location's directory; absolute paths pass
61
+ through. Restart OpenCode after editing the config — it isn't hot-reloaded.
62
+
19
63
  ## Status
20
64
 
21
65
  Standalone extract of the docs tooling, under active development.
@@ -26,7 +70,6 @@ Standalone extract of the docs tooling, under active development.
26
70
  npm install
27
71
  npm test # node --test
28
72
  npm run typecheck
29
- npm run lint
30
73
  defined comply # the gate: bootstrap, repair and verify
31
74
  ```
32
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markstanden/opencode-docs-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over a repository's knowledge tree, with the writing convention it owns",
6
6
  "repository": {
@@ -22,8 +22,7 @@
22
22
  "scripts": {
23
23
  "test": "node --test 'tests/**/*.test.ts'",
24
24
  "test:watch": "node --test --watch 'tests/**/*.test.ts'",
25
- "typecheck": "tsc --noEmit",
26
- "lint": "eslint ."
25
+ "typecheck": "tsc --noEmit"
27
26
  },
28
27
  "dependencies": {
29
28
  "@opencode/plugin": "2.0.23",
@@ -32,10 +31,7 @@
32
31
  },
33
32
  "devDependencies": {
34
33
  "@types/node": "^26.6.4",
35
- "eslint": "^10.12.0",
36
- "eslint-config-prettier": "^10.1.8",
37
- "typescript": "^6.0.3",
38
- "typescript-eslint": "^8.71.0"
34
+ "typescript": "^6.0.3"
39
35
  },
40
36
  "license": "MIT"
41
37
  }
package/src/SKILL.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Docs
2
2
 
3
- The repository's durable knowledge lives under `docs/`. This skill covers both
3
+ Durable knowledge lives under the configured docs root (the plugin's `root`
4
+ option; default `<location>/docs`). This skill covers both
4
5
  halves of the loop: **find** (discover with the `docs` tool, read the match) and
5
6
  **record** (write or update a doc for the current task).
6
7
 
@@ -19,8 +20,7 @@ Call the `docs` tool:
19
20
  the closest matches under a `Closest matches` header rather than a dead end.
20
21
  A term must appear as a **substring** to match — a typo returns nothing, by
21
22
  design. Terms shorter than `MIN_TERM_LENGTH` (3) are ignored unless they are
22
- the whole query, so filler words cannot drag in the catalogue. The shared
23
- `docs/` path prefix itself is not searched; real path segments still are.
23
+ the whole query, so filler words cannot drag in the catalogue.
24
24
  - **every call** caps at `MAX_RESULTS` (10), preferring the best matches and
25
25
  then the most recent edits, and ends with `… and N more not shown.` when
26
26
  anything was cut.
@@ -31,7 +31,7 @@ Then open the match with the built-in `read` tool. The tool only discovers; it
31
31
  never returns a doc's body. `/docs [search]` runs the same search into the
32
32
  session (`/docs search <text>` is the explicit form).
33
33
 
34
- Every `docs/**/*.md` is covered except the excluded areas — scratch (`plans/`)
34
+ Every `*.md` under the root is covered except the excluded areas — scratch (`plans/`)
35
35
  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.
@@ -44,14 +44,15 @@ marker in results and ranks below an active doc on a tie.
44
44
 
45
45
  ## Record it
46
46
 
47
- Write durable findings and task documentation to `docs/<area>/<title>.md` so the
47
+ Write durable findings and task documentation to `<area>/<title>.md` under the
48
+ root so the
48
49
  next human or agent skips the rediscovery. The convention is this plugin's
49
50
  `CONVENTION.md`, restated here.
50
51
 
51
52
  Before writing:
52
53
 
53
- 1. **Read first, never duplicate.** List `docs/**` with the `docs` tool and read
54
- the docs relevant to the task: the same `docs/<area>/` neighbourhood, keyword
54
+ 1. **Read first, never duplicate.** List the tree with the `docs` tool and read
55
+ the docs relevant to the task: the same `<area>/` neighbourhood, keyword
55
56
  hits on the task's entities (service names, file paths, error strings, class
56
57
  names), or links from touched docs.
57
58
  2. **Reconcile, then add.** Edit before add; merge near-twins; split a bloated
@@ -62,12 +63,12 @@ Then record through the `docs` tool's typed `add`: metadata (`Area`, `Keywords`
62
63
  as a list, `Summary`; optionally `Status`/`Supersedes`) plus a body carrying an
63
64
  `H1` and the claim sections (`Facts`, `Inferred`, `Tested`, `Open questions`).
64
65
  The tool serialises the front-matter — never copy it — and derives
65
- the target (`docs/<Area>/<slug>.md`) when `path` is omitted; pass `path` to
66
+ the target (`<Area>/<slug>.md`) when `path` is omitted; pass `path` to
66
67
  update an existing doc, replacing its body wholesale. Its guardrails refuse
67
68
  blank metadata, path traversal, a body without an `H1`, and a same-area
68
69
  near-twin (returning the candidates to reconcile against). A plan for an
69
- in-flight effort is scratch, not a doc — `docs/plans/PLAN_<effort>.md`,
70
- gitignored and deleted when the work lands. `/docs add <text>` runs this record
70
+ in-flight effort is scratch, not a doc — a `plans/` area, excluded from the
71
+ index and deleted when the work lands. `/docs add <text>` runs this record
71
72
  loop for a fact supplied inline.
72
73
 
73
74
  ## Check it
package/src/index.ts CHANGED
@@ -14,15 +14,17 @@ import {
14
14
  statSync,
15
15
  writeFileSync,
16
16
  } from "node:fs";
17
- import { dirname, join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { dirname, isAbsolute, join } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { Schema } from "effect";
20
21
 
21
22
  /**
22
23
  * V2 docs plugin.
23
24
  *
24
- * Registers a model-callable `docs` tool so the agent can discover the repo's
25
- * knowledge tree mid-task: a blank search returns the most recently changed
25
+ * Registers a model-callable `docs` tool so the agent can discover the
26
+ * knowledge tree mid-task — the docs root is configurable (`root` option,
27
+ * default `<location>/docs`): a blank search returns the most recently changed
26
28
  * docs (`path — Area — date — Summary`), a search ranks the docs by
27
29
  * how well their front-matter, path and body identifiers match, falling back
28
30
  * to the closest matches rather than a dead end. `add` writes knowledge,
@@ -35,9 +37,9 @@ import { Schema } from "effect";
35
37
 
36
38
  /** One knowledge doc, as the tool sees it. */
37
39
  export interface DocEntry {
38
- /** Repo-relative path, e.g. `docs/decisions/choose-a-queue.md`. */
40
+ /** Root-relative path, e.g. `decisions/choose-a-queue.md`. */
39
41
  path: string;
40
- /** Front-matter `Area`, else the first path segment under `docs/`. */
42
+ /** Front-matter `Area`, else the first path segment under the root. */
41
43
  area: string;
42
44
  /** Resolved at read time: last git commit, else file mtime. Never front-matter. */
43
45
  date: string;
@@ -70,7 +72,7 @@ export const DOCS_INPUT = Schema.Struct({
70
72
  path: Schema.optional(
71
73
  Schema.String.annotate({
72
74
  description:
73
- "Update target, e.g. `docs/guides/getting-started.md`. Omit to create `docs/<Area>/<slug>.md` from the H1.",
75
+ "Update target, e.g. `guides/getting-started.md` (relative to the docs root). Omit to create `<Area>/<slug>.md` from the H1.",
74
76
  }),
75
77
  ),
76
78
  metadata: Schema.Struct({
@@ -100,7 +102,7 @@ export const DOCS_INPUT = Schema.Struct({
100
102
  check: Schema.optional(
101
103
  Schema.Boolean.annotate({
102
104
  description:
103
- "Validate every doc under docs/ 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.",
105
+ "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.",
104
106
  }),
105
107
  ),
106
108
  });
@@ -122,7 +124,7 @@ export type DocsAdd = NonNullable<DocsInput["add"]>;
122
124
  /** The typed metadata of a knowledge doc, serialised to front-matter. */
123
125
  export type DocMetadata = DocsAdd["metadata"];
124
126
 
125
- /** Directories under `docs/` the tool ignores: scratch and vendored mirrors. */
127
+ /** Top-level directories under the root the tool ignores: scratch and mirrors. */
126
128
  export const EXCLUDED_AREAS = ["plans", "requirements", "wiki"] as const;
127
129
 
128
130
  /**
@@ -186,7 +188,7 @@ export function serializeDoc(metadata: DocMetadata, body: string): string {
186
188
  const text = Array.isArray(value) ? value.join(", ") : value;
187
189
  return [`${name}: ${text}`];
188
190
  });
189
- return `---\n${lines.join("\n")}\n---\n\n${body.replace(/\n+$/, "")}\n`;
191
+ return `---\n${lines.join("\n")}\n---\n\n${body.replace(/(?<!\n)\n+$/, "")}\n`;
190
192
  }
191
193
 
192
194
  /**
@@ -230,7 +232,7 @@ function isFence(line: string): boolean {
230
232
 
231
233
  /** Drop an ATX heading's trailing `#` decoration (and any trailing spaces). */
232
234
  function stripTrailingHashes(text: string): string {
233
- return text.replace(/[ #]+$/, "");
235
+ return text.replace(/(?<![ #])[ #]+$/, "");
234
236
  }
235
237
 
236
238
  /** The body's indexable pieces, from one fence-aware walk of the source. */
@@ -306,7 +308,7 @@ export function identifiersOf(body: string): string[] {
306
308
  export function toEntry(path: string, raw: string, date: string): DocEntry {
307
309
  const { meta, body } = parseFrontMatter(raw);
308
310
  const segments = path.split("/");
309
- const area = meta.Area ?? (segments.length > 2 ? (segments[1] ?? "") : "");
311
+ const area = meta.Area ?? (segments.length > 1 ? (segments[0] ?? "") : "");
310
312
  return {
311
313
  path,
312
314
  area,
@@ -347,18 +349,6 @@ function termsOf(query: string): string[] {
347
349
  return kept.length > 0 ? kept : terms;
348
350
  }
349
351
 
350
- /**
351
- * The searchable part of a doc's path: the shared `docs/` prefix is folder
352
- * scaffolding, not content, so it is dropped — otherwise the literal term
353
- * `docs` matches every doc through the path field alone. Display keeps the
354
- * full path; a real segment (`docs/docs.md`, `docs/decisions/…`) still hits.
355
- * @param path Repo-relative doc path.
356
- * @returns The path with its leading `docs/` segment removed.
357
- */
358
- function indexablePath(path: string): string {
359
- return path.startsWith("docs/") ? path.slice("docs/".length) : path;
360
- }
361
-
362
352
  /**
363
353
  * Whether a doc matches the query. A blank query matches everything; otherwise
364
354
  * every kept term (see {@link MIN_TERM_LENGTH}) must appear
@@ -373,7 +363,7 @@ export function matches(entry: DocEntry, query: string): boolean {
373
363
  return true;
374
364
  }
375
365
  const haystack = [
376
- indexablePath(entry.path),
366
+ entry.path,
377
367
  entry.area,
378
368
  entry.title,
379
369
  entry.summary,
@@ -405,7 +395,7 @@ const FIELDS: ReadonlyArray<
405
395
  readonly [weight: number, values: (entry: DocEntry) => readonly string[]]
406
396
  > = [
407
397
  [FIELD_WEIGHTS.title, (entry) => [entry.title]],
408
- [FIELD_WEIGHTS.path, (entry) => [indexablePath(entry.path)]],
398
+ [FIELD_WEIGHTS.path, (entry) => [entry.path]],
409
399
  [FIELD_WEIGHTS.keywords, (entry) => entry.keywords],
410
400
  [FIELD_WEIGHTS.identifiers, (entry) => entry.identifiers],
411
401
  [FIELD_WEIGHTS.area, (entry) => [entry.area]],
@@ -604,7 +594,7 @@ export function explainRanking(): string {
604
594
  "How docs search ranks:",
605
595
  `- 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}.`,
606
596
  `- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
607
- `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query; the shared \`docs/\` path prefix itself is not searched.`,
597
+ `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
608
598
  `- 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."`,
609
599
  '- 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."',
610
600
  ].join("\n");
@@ -644,8 +634,9 @@ export function resolveRequest(input: DocsInput): DocsRequest {
644
634
  * The newest commit date per path, in one `git log` pass over all paths.
645
635
  * `git log` lists commits newest first, so the first date seen for a path is
646
636
  * its latest edit; paths absent from history are absent from the map.
647
- * @param cwd Repository root.
648
- * @param paths Repo-relative paths.
637
+ * @param cwd Directory inside the docs root's work tree; git resolves the
638
+ * work-tree root itself.
639
+ * @param paths Root-relative paths.
649
640
  * @returns A `YYYY-MM-DD` per known path.
650
641
  */
651
642
  export function gitDates(
@@ -684,8 +675,8 @@ export function gitDates(
684
675
  /**
685
676
  * A date per path in one batch: git's newest commit date, else the file
686
677
  * mtime, else "". One git pass for the whole tree, however many docs.
687
- * @param cwd Repository root.
688
- * @param paths Repo-relative paths.
678
+ * @param cwd Directory inside the docs root's work tree.
679
+ * @param paths Root-relative paths.
689
680
  * @returns A date per path.
690
681
  */
691
682
  export function fallbackDates(
@@ -710,10 +701,11 @@ export function fallbackDates(
710
701
  }
711
702
 
712
703
  /**
713
- * Collect every knowledge doc under `docs/`, skipping scratch and mirrors.
714
- * @param root Repository root.
704
+ * Collect every knowledge doc under the docs root, skipping scratch and
705
+ * mirrors.
706
+ * @param root Docs root — the directory that is the knowledge tree.
715
707
  * @param resolve Resolves every doc path's date in one batched call.
716
- * @returns Entries ordered by path, or none when `docs/` is absent.
708
+ * @returns Entries ordered by path, or none when the root is absent.
717
709
  */
718
710
  export function collectDocs(
719
711
  root: string,
@@ -721,7 +713,7 @@ export function collectDocs(
721
713
  ): DocEntry[] {
722
714
  let files: string[];
723
715
  try {
724
- files = readdirSync(join(root, "docs"), {
716
+ files = readdirSync(root, {
725
717
  recursive: true,
726
718
  encoding: "utf8",
727
719
  });
@@ -735,8 +727,7 @@ export function collectDocs(
735
727
  (relativePath) =>
736
728
  !excluded.includes(relativePath.split("/")[0] ?? ""),
737
729
  )
738
- .sort()
739
- .map((relativePath) => `docs/${relativePath}`);
730
+ .sort();
740
731
  const dates = resolve(paths);
741
732
  return paths.map((path) =>
742
733
  toEntry(
@@ -757,14 +748,14 @@ export function slugify(text: string): string {
757
748
  return text
758
749
  .toLowerCase()
759
750
  .replace(/[^a-z0-9]+/g, "-")
760
- .replace(/^-+|-+$/g, "");
751
+ .replace(/^-+|(?<!-)-+$/g, "");
761
752
  }
762
753
 
763
754
  /** The outcome of preparing an `add`: either a write or a refusal. */
764
755
  export type AddOutcome =
765
756
  | {
766
757
  action: "write";
767
- /** Repo-relative target, always under `docs/`. */
758
+ /** Root-relative target. */
768
759
  path: string;
769
760
  /** The serialised file contents. */
770
761
  contents: string;
@@ -801,10 +792,10 @@ function blankRequiredField(metadata: DocMetadata): string | null {
801
792
  }
802
793
 
803
794
  /**
804
- * Validate an explicit `add` path: relative, inside `docs/`, naming a `.md`
805
- * file. A leading `docs/` is accepted and normalised away.
795
+ * Validate an explicit `add` path: relative, inside the docs root, naming a
796
+ * `.md` file.
806
797
  * @param given The trimmed, non-empty `path` the caller supplied.
807
- * @returns The repo-relative target path, or the refusal reason.
798
+ * @returns The root-relative target path, or the refusal reason.
808
799
  */
809
800
  function explicitTarget(given: string): { path: string } | { reason: string } {
810
801
  if (
@@ -813,23 +804,24 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
813
804
  given.includes("\\") ||
814
805
  given.includes(":")
815
806
  ) {
816
- return { reason: "path must be relative and stay inside docs/." };
807
+ return {
808
+ reason: "path must be relative and stay inside the docs root.",
809
+ };
817
810
  }
818
811
  if (!given.endsWith(".md")) {
819
812
  return { reason: "path must end in .md." };
820
813
  }
821
- const withoutPrefix = given.replace(/^docs\//, "");
822
- const stem = withoutPrefix.slice(0, -".md".length);
814
+ const stem = given.slice(0, -".md".length);
823
815
  if (stem === "" || stem.endsWith("/")) {
824
- return { reason: "path must name a .md file under docs/." };
816
+ return { reason: "path must name a .md file under the docs root." };
825
817
  }
826
- return { path: `docs/${withoutPrefix}` };
818
+ return { path: given };
827
819
  }
828
820
 
829
821
  /**
830
- * The repo-relative target path for an `add`, or the reason it is refused.
831
- * An explicit path must stay inside `docs/` and name a `.md` file; an omitted
832
- * path derives `docs/<Area>/<slug>.md` from the H1.
822
+ * The root-relative target path for an `add`, or the reason it is refused.
823
+ * An explicit path must stay inside the docs root and name a `.md` file; an
824
+ * omitted path derives `<Area>/<slug>.md` from the H1.
833
825
  * @param add The typed request.
834
826
  * @returns The target path, or the refusal reason.
835
827
  */
@@ -842,7 +834,7 @@ function targetOf(add: DocsAdd): { path: string } | { reason: string } {
842
834
  if (slug === "") {
843
835
  return { reason: "The H1 does not yield a usable file name." };
844
836
  }
845
- return { path: `docs/${add.metadata.Area}/${slug}.md` };
837
+ return { path: `${add.metadata.Area}/${slug}.md` };
846
838
  }
847
839
 
848
840
  /**
@@ -909,9 +901,9 @@ export function nearTwins(
909
901
  * Validate an `add` request and prepare it for writing — pure, no fs.
910
902
  *
911
903
  * Trust boundary: the caller is a model. Blank required metadata, a body
912
- * without an H1, a path or `Area` escaping `docs/`, a non-`.md` target and a
913
- * title that yields no slug are all refused. With `path` omitted the target is
914
- * `docs/<Area>/<slug>.md` and a same-area near-twin — a doc matching every
904
+ * without an H1, a path or `Area` escaping the docs root, a non-`.md` target
905
+ * and a title that yields no slug are all refused. With `path` omitted the
906
+ * target is `<Area>/<slug>.md` and a same-area near-twin — a doc matching every
915
907
  * reconcile query term ({@link reconcileQuery}) — refuses the write with the
916
908
  * candidates attached; an explicit `path` is the update contract (wholesale
917
909
  * replacement) and skips reconcile.
@@ -934,7 +926,7 @@ export function prepareAdd(
934
926
  return refuse(`${blank} is required and must not be blank.`);
935
927
  }
936
928
  if (/[/\\]|\.\./.test(add.metadata.Area)) {
937
- return refuse("Area must be a single folder name under docs/.");
929
+ return refuse("Area must be a single folder name under the docs root.");
938
930
  }
939
931
  if (h1Of(add.body) === undefined) {
940
932
  return refuse("The body must carry an H1 (`# Title`).");
@@ -1081,13 +1073,16 @@ function nearTwinPairs(
1081
1073
  * `Supersedes` that names no doc, then the {@link nearTwinPairs}. An empty
1082
1074
  * report means compliant.
1083
1075
  * @param entries The docs to check.
1084
- * @param read Resolves a repo-relative doc path to its raw contents;
1076
+ * @param read Resolves a root-relative doc path to its raw contents;
1085
1077
  * injected so the check is testable.
1078
+ * @param notes Environment notes (e.g. an unversioned root) appended to the
1079
+ * report; findings they describe are still reported per doc.
1086
1080
  * @returns The report text.
1087
1081
  */
1088
1082
  export function checkDocs(
1089
1083
  entries: readonly DocEntry[],
1090
1084
  read: (path: string) => string,
1085
+ notes: readonly string[] = [],
1091
1086
  ): string {
1092
1087
  const paths = new Set(entries.map((entry) => entry.path));
1093
1088
  const lines = entries.flatMap((entry) => {
@@ -1105,6 +1100,7 @@ export function checkDocs(
1105
1100
  "(merge, or repoint one).",
1106
1101
  );
1107
1102
  }
1103
+ lines.push(...notes);
1108
1104
  return lines.length === 0
1109
1105
  ? `All ${entries.length} docs comply.`
1110
1106
  : lines.join("\n");
@@ -1113,12 +1109,14 @@ export function checkDocs(
1113
1109
  export interface DocsToolDeps {
1114
1110
  /** Collects the docs to serve; injected so the tool is testable. */
1115
1111
  collect: () => DocEntry[];
1116
- /** Whether a repo-relative doc path exists; injected so writes are testable. */
1112
+ /** Whether a root-relative doc path exists; injected so writes are testable. */
1117
1113
  exists: (path: string) => boolean;
1118
- /** Writes a repo-relative doc path; injected so writes are testable. */
1114
+ /** Writes a root-relative doc path; injected so writes are testable. */
1119
1115
  write: (path: string, contents: string) => void;
1120
- /** Reads a repo-relative doc path's raw contents; injected so `check` is testable. */
1116
+ /** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
1121
1117
  read: (path: string) => string;
1118
+ /** Environment notes appended to the `check` report; injected for testability. */
1119
+ notes?: readonly string[];
1122
1120
  }
1123
1121
 
1124
1122
  /** Delivers a rendered result to a session. */
@@ -1130,8 +1128,10 @@ export type PromptSender = (
1130
1128
  export interface DocsCommandDeps {
1131
1129
  /** Collects the docs to serve; injected so the command is testable. */
1132
1130
  collect: () => DocEntry[];
1133
- /** Reads a repo-relative doc path's raw contents; injected so `check` is testable. */
1131
+ /** Reads a root-relative doc path's raw contents; injected so `check` is testable. */
1134
1132
  read: (path: string) => string;
1133
+ /** Environment notes appended to the `check` report; injected for testability. */
1134
+ notes?: readonly string[];
1135
1135
  /** Delivers the rendered result to the session. */
1136
1136
  prompt: PromptSender;
1137
1137
  }
@@ -1169,11 +1169,12 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1169
1169
  return {
1170
1170
  name: "docs",
1171
1171
  description:
1172
- "Discover the repository's knowledge docs under docs/. A blank search lists the " +
1172
+ "Discover the knowledge docs under the configured docs root " +
1173
+ "(default: the location's `docs/` directory). A blank search lists the " +
1173
1174
  MAX_RESULTS +
1174
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 " +
1175
1176
  MAX_RESULTS +
1176
- " 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. The shared `docs/` path prefix itself is not searched. Read a matched file with the read tool.",
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.",
1177
1178
  input: DOCS_INPUT,
1178
1179
  options: { codemode: false },
1179
1180
  async execute(input) {
@@ -1184,7 +1185,7 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1184
1185
  if (request.verb === "check") {
1185
1186
  const entries = deps.collect();
1186
1187
  return {
1187
- content: checkDocs(entries, deps.read),
1188
+ content: checkDocs(entries, deps.read, deps.notes ?? []),
1188
1189
  metadata: { checked: entries.length },
1189
1190
  };
1190
1191
  }
@@ -1223,8 +1224,8 @@ function addPrompt(subject: string): string {
1223
1224
  return "`/docs add` needs the text to record: `/docs add <fact>`.";
1224
1225
  }
1225
1226
  return (
1226
- "Record this as durable knowledge under docs/: call the `docs` tool's " +
1227
- "`add` with a typed DTO (metadata Area/Keywords/Summary, body starting " +
1227
+ "Record this as durable knowledge in the docs root: call the `docs` " +
1228
+ "tool's `add` with a typed DTO (metadata Area/Keywords/Summary, body starting " +
1228
1229
  "with an H1) for:\n\n" +
1229
1230
  subject
1230
1231
  );
@@ -1242,7 +1243,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1242
1243
  return {
1243
1244
  name: "docs",
1244
1245
  description:
1245
- "Search the repository's knowledge docs (docs/**/*.md), ranked; `add <text>` records new knowledge through the tool's typed `add`; `check` validates the convention; a blank search lists the " +
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 " +
1246
1247
  MAX_RESULTS +
1247
1248
  " most recently changed.",
1248
1249
  async execute({ sessionID, prompt }) {
@@ -1260,7 +1261,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1260
1261
  if (lower === CHECK_VERB) {
1261
1262
  await deps.prompt(
1262
1263
  sessionID,
1263
- checkDocs(deps.collect(), deps.read),
1264
+ checkDocs(deps.collect(), deps.read, deps.notes ?? []),
1264
1265
  );
1265
1266
  return;
1266
1267
  }
@@ -1274,10 +1275,75 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
1274
1275
  };
1275
1276
  }
1276
1277
 
1278
+ /**
1279
+ * Resolve the plugin's docs root from its `root` option. Unset → the
1280
+ * location's `docs/` directory; `~` or `~/…` expands against the home
1281
+ * directory; absolute paths pass through; anything else resolves against the
1282
+ * 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
1284
+ * must fail loudly, not silently fall back to the wrong tree.
1285
+ * @param configured The raw option value as the host delivers it.
1286
+ * @param locationDirectory The directory the plugin instance loaded in.
1287
+ * @returns The docs root.
1288
+ */
1289
+ export function resolveRoot(
1290
+ configured: unknown,
1291
+ locationDirectory: string,
1292
+ ): string {
1293
+ if (configured === undefined) {
1294
+ return join(locationDirectory, "docs");
1295
+ }
1296
+ if (typeof configured !== "string" || configured.trim() === "") {
1297
+ throw new Error(
1298
+ `docs plugin option 'root' must be a non-empty string path, ` +
1299
+ `got ${JSON.stringify(configured)}.`,
1300
+ );
1301
+ }
1302
+ const given = configured.trim();
1303
+ const expanded =
1304
+ given === "~" || given.startsWith("~/")
1305
+ ? join(homedir(), given.slice(1))
1306
+ : given;
1307
+ return isAbsolute(expanded) ? expanded : join(locationDirectory, expanded);
1308
+ }
1309
+
1310
+ /**
1311
+ * Probe whether the docs root sits inside a git work tree, as `check`-report
1312
+ * notes. An unversioned root still works — dates fall back to file mtimes and
1313
+ * the knowledge has no history — so it is surfaced, not refused.
1314
+ * @param root The docs root to probe.
1315
+ * @returns The note lines; empty when the root is version-controlled.
1316
+ */
1317
+ export function unversionedNote(root: string): readonly string[] {
1318
+ const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
1319
+ cwd: root,
1320
+ });
1321
+ if (probe.status === 0) {
1322
+ return [];
1323
+ }
1324
+ return [
1325
+ "note: the docs root is not inside a git work tree — dates fall " +
1326
+ "back to file mtimes and the knowledge has no history. Put the " +
1327
+ "root under version control to fix both.",
1328
+ ];
1329
+ }
1330
+
1331
+ /**
1332
+ * The docs plugin's options, as the host delivers them. The framework types
1333
+ * `ctx.options` as `Readonly<Record<string, any>>`, so the shape the plugin
1334
+ * reads is declared here.
1335
+ */
1336
+ interface DocsPluginOptions {
1337
+ /** The docs root override; defaults to `docs` beside the plugin. */
1338
+ root?: unknown;
1339
+ }
1340
+
1277
1341
  export default Plugin.define({
1278
1342
  id: "docs",
1279
1343
  async setup(ctx) {
1280
- const root = ctx.location.directory;
1344
+ const options = ctx.options as DocsPluginOptions | undefined;
1345
+ const root = resolveRoot(options?.root, ctx.location.directory);
1346
+ const notes = unversionedNote(root);
1281
1347
  const collect = () =>
1282
1348
  collectDocs(root, (paths) => fallbackDates(root, paths));
1283
1349
  const read = (path: string) => readFileSync(join(root, path), "utf8");
@@ -1292,6 +1358,7 @@ export default Plugin.define({
1292
1358
  writeFileSync(target, contents);
1293
1359
  },
1294
1360
  read,
1361
+ notes,
1295
1362
  }),
1296
1363
  );
1297
1364
  });
@@ -1300,6 +1367,7 @@ export default Plugin.define({
1300
1367
  createDocsCommand({
1301
1368
  collect,
1302
1369
  read,
1370
+ notes,
1303
1371
  prompt: (sessionID, text) =>
1304
1372
  ctx.session.prompt({ sessionID, text }),
1305
1373
  }),
@@ -1316,12 +1384,12 @@ export default Plugin.define({
1316
1384
  id: Skill.ID.make("docs"),
1317
1385
  name: Skill.Name.make("Docs"),
1318
1386
  description:
1319
- "The entry point to the repository's durable knowledge " +
1320
- "under docs/: find the right file with the docs tool, read " +
1321
- "it, and record plans and task documentation for the next " +
1322
- "human or agent. Use at the start of unfamiliar work, when " +
1323
- "hunting for prior decisions or traps, and when closing out " +
1324
- "a session.",
1387
+ "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.",
1325
1393
  path: AbsolutePath.make(fileURLToPath(skillURL)),
1326
1394
  content: skill,
1327
1395
  });