@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 +5 -4
- package/README.md +46 -3
- package/package.json +3 -7
- package/src/SKILL.md +11 -10
- package/src/index.ts +141 -73
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
|
|
10
|
-
|
|
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
|
|
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
|
|
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
|
|
5
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
54
|
-
the docs relevant to the task: the same
|
|
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 (
|
|
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 — `
|
|
70
|
-
|
|
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 {
|
|
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
|
|
25
|
-
* knowledge tree mid-task
|
|
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
|
-
/**
|
|
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
|
|
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. `
|
|
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
|
|
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
|
-
/**
|
|
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(
|
|
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 >
|
|
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
|
-
|
|
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) => [
|
|
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
|
|
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
|
|
648
|
-
*
|
|
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
|
|
688
|
-
* @param 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
|
|
714
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
805
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
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:
|
|
818
|
+
return { path: given };
|
|
827
819
|
}
|
|
828
820
|
|
|
829
821
|
/**
|
|
830
|
-
* The
|
|
831
|
-
* An explicit path must stay inside
|
|
832
|
-
* path derives
|
|
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:
|
|
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
|
|
913
|
-
* title that yields no slug are all refused. With `path` omitted the
|
|
914
|
-
*
|
|
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
|
|
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
|
|
1112
|
+
/** Whether a root-relative doc path exists; injected so writes are testable. */
|
|
1117
1113
|
exists: (path: string) => boolean;
|
|
1118
|
-
/** Writes a
|
|
1114
|
+
/** Writes a root-relative doc path; injected so writes are testable. */
|
|
1119
1115
|
write: (path: string, contents: string) => void;
|
|
1120
|
-
/** Reads a
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1320
|
-
"
|
|
1321
|
-
"it, and record plans and task documentation
|
|
1322
|
-
"human or agent. Use at the start of
|
|
1323
|
-
"hunting for prior decisions or
|
|
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
|
});
|