@markstanden/opencode-docs-plugin 0.5.0 → 0.6.2
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 +25 -9
- package/README.md +7 -2
- package/package.json +1 -1
- package/src/SKILL.md +50 -91
- package/src/index.ts +377 -186
package/CONVENTION.md
CHANGED
|
@@ -53,11 +53,11 @@ are skipped by name.
|
|
|
53
53
|
Every knowledge doc opens with a `---` front-matter block. The fields, in
|
|
54
54
|
canonical order:
|
|
55
55
|
|
|
56
|
-
| Field | Required | Meaning
|
|
57
|
-
| ---------- | -------- |
|
|
58
|
-
| `Area` | yes | One top-level path segment; the doc's group.
|
|
59
|
-
| `Keywords` | yes | Comma-separated search terms; the second half of the doc's identity.
|
|
60
|
-
| `Summary` | yes | One self-contained sentence
|
|
56
|
+
| Field | Required | Meaning |
|
|
57
|
+
| ---------- | -------- | ----------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| `Area` | yes | One top-level path segment; the doc's group. |
|
|
59
|
+
| `Keywords` | yes | Comma-separated search terms; the second half of the doc's identity. |
|
|
60
|
+
| `Summary` | yes | One self-contained sentence that lets a reader decide whether to open the file — not a second introduction. |
|
|
61
61
|
|
|
62
62
|
`Date` is **derived, never written**: the file's last git commit date, falling
|
|
63
63
|
back to mtime. A hand-maintained `Date` is a finding, not a field.
|
|
@@ -79,6 +79,12 @@ git. Optional invisible metadata may be added later; it is not required.
|
|
|
79
79
|
- At least one claim section as an `H2`/`H3` heading — `Facts`, `Inferred`,
|
|
80
80
|
`Tested`, `Failed approaches`, `Open questions` — so a reader knows how much
|
|
81
81
|
to rely on each claim. Group claims by section rather than mixing them.
|
|
82
|
+
- Include only the sections the doc actually needs. An empty heading is noise;
|
|
83
|
+
leave it out.
|
|
84
|
+
- Write so the next reader decides fast: lead with the actionable fact, include
|
|
85
|
+
its reason, limits and evidence where they affect its use, and omit discovery
|
|
86
|
+
chronology, repeated conclusions and empty sections. Under `Tested`, record
|
|
87
|
+
the check, its result and the conditions that mattered.
|
|
82
88
|
- A failed attempt is a valid current doc or section: state what was tried, why
|
|
83
89
|
it failed, what is true now, and what not to retry. Do not keep a doc whose
|
|
84
90
|
claims are no longer true.
|
|
@@ -92,6 +98,11 @@ git. Optional invisible metadata may be added later; it is not required.
|
|
|
92
98
|
`add` refuses a near-twin write and returns the candidates to reconcile
|
|
93
99
|
against — edit before add, merge near-twins, split a bloated doc into
|
|
94
100
|
per-topic files.
|
|
101
|
+
- The reconcile applies to a **create**: with `path` omitted, the target is
|
|
102
|
+
derived and the near-twin guard runs. An explicit `path` is the update
|
|
103
|
+
contract and skips it, which is how you replace a doc in place. Passing an
|
|
104
|
+
explicit _new_ path is therefore a deliberate override, not a protected
|
|
105
|
+
write.
|
|
95
106
|
- Near-twins are per lake. The same topic at repo and global scope is
|
|
96
107
|
legitimate; the narrower doc states its delta and links up.
|
|
97
108
|
|
|
@@ -129,7 +140,12 @@ knowledge when it records the outcome and the current truth.
|
|
|
129
140
|
|
|
130
141
|
## Adopting it
|
|
131
142
|
|
|
132
|
-
Run `check` to audit an existing tree, and the
|
|
133
|
-
proposing a commit.
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
Run `check` to audit an existing tree, and the project's own tests before
|
|
144
|
+
proposing a commit. `add` enforces the per-doc structural rules at the write
|
|
145
|
+
boundary — front-matter, `H1`, claim section, identity term floor, and the
|
|
146
|
+
near-twin guard on a create — while the cross-document audits (near-twin pairs
|
|
147
|
+
and relative links) stay with `check`. A missing lake root is a note, not an
|
|
148
|
+
error: the plugin scans what is there, search and help flag the absent path, and
|
|
149
|
+
`add` refuses rather than creating a tree at a mistyped root. Create the
|
|
150
|
+
directory deliberately. Wiki lakes are read-only and are reported as not
|
|
151
|
+
validated, never silently skipped.
|
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ The simplest setup uses `root` — one repo lake:
|
|
|
80
80
|
{
|
|
81
81
|
"package": "@markstanden/opencode-docs-plugin",
|
|
82
82
|
"options": {
|
|
83
|
-
"root": "
|
|
83
|
+
"root": "./docs",
|
|
84
84
|
},
|
|
85
85
|
},
|
|
86
86
|
],
|
|
@@ -159,6 +159,12 @@ Lake paths never follow symlinks: a symlinked doc is skipped by search and
|
|
|
159
159
|
`check`, refused by `read`, and refused by `add`. The configured `root` itself
|
|
160
160
|
may be a symlink; the content beneath it must be real.
|
|
161
161
|
|
|
162
|
+
A missing lake root is flagged, never created. The lake stays dormant until the
|
|
163
|
+
directory exists: `search` appends a note naming the lake and path, `help` marks
|
|
164
|
+
it `— missing`, and `add` refuses with the path rather than bootstrapping a
|
|
165
|
+
tree. Create the directory deliberately. Area folders beneath an existing root
|
|
166
|
+
are still created on write.
|
|
167
|
+
|
|
162
168
|
`maxDocLines` (default 200, `0` disables) is a **soft** note on a long doc, not a
|
|
163
169
|
refusal. `perLakeQuota` (default 3, `0` disables) is the per-source result
|
|
164
170
|
reservation.
|
|
@@ -195,7 +201,6 @@ Standalone extract of the docs tooling, under active development.
|
|
|
195
201
|
npm install
|
|
196
202
|
npm test # node --test
|
|
197
203
|
npm run typecheck
|
|
198
|
-
defined comply # the gate: bootstrap, repair and verify
|
|
199
204
|
```
|
|
200
205
|
|
|
201
206
|
Tests run the plugin source directly under Node's type-stripping (`noEmit`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markstanden/opencode-docs-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over configurable knowledge lakes (repo, machine, global and read-only wiki), nested README.md search, and the writing convention they share",
|
|
6
6
|
"repository": {
|
package/src/SKILL.md
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
# Docs
|
|
2
2
|
|
|
3
|
-
Durable knowledge lives in **lakes
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
doc at the narrowest true scope).
|
|
7
|
-
|
|
8
|
-
Call the `docs` tool at top level. Inside `execute` (Code Mode) the same tool
|
|
9
|
-
is reachable as the twin `tools.docs.docs_search(…)` — same verbs, same lakes —
|
|
10
|
-
so batch it with the rest of the catalog there.
|
|
3
|
+
Durable knowledge lives in **lakes**: **find** it (search, then read), or
|
|
4
|
+
**record** one focused doc at the narrowest true scope. Call the `docs` tool;
|
|
5
|
+
inside `execute` it is `tools.docs.docs_search(…)`, same verbs.
|
|
11
6
|
|
|
12
7
|
## Classes
|
|
13
8
|
|
|
@@ -20,98 +15,62 @@ so batch it with the rest of the catalog there.
|
|
|
20
15
|
| **Wiki** | human-readable wiki | narrative for humans | read-only |
|
|
21
16
|
|
|
22
17
|
Record at the **narrowest scope where the whole claim is true**. Two scopes
|
|
23
|
-
means two
|
|
18
|
+
means two docs; one omnibus doc is out of place everywhere.
|
|
24
19
|
|
|
25
20
|
## Find it
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
code spans, area, summary and wiki prose. When no doc contains every term it
|
|
33
|
-
returns the closest matches under a `Closest matches` header rather than a
|
|
34
|
-
dead end. A term must appear as a **substring** to match — a typo returns
|
|
35
|
-
nothing, by design. Terms shorter than `MIN_TERM_LENGTH` (3) are ignored
|
|
36
|
-
unless they are the whole query.
|
|
37
|
-
- **every call** reserves up to `perLakeQuota` results per source (default 3),
|
|
38
|
-
caps at `MAX_RESULTS` (10), and ends with `… and N more not shown.` when
|
|
39
|
-
anything was cut. The reservation keeps a broad wiki from burying a focused
|
|
40
|
-
repo doc.
|
|
41
|
-
- **exactly `help`** — the configured lakes, their classes and writability,
|
|
42
|
-
then the ranking rules.
|
|
43
|
-
|
|
44
|
-
Then open the match with the tool's `read` verb — pass the root-relative path,
|
|
45
|
-
or `lake:path` when several lakes are configured. A project README result is
|
|
46
|
-
prefixed `instructions:`; read it with the built-in `read` tool instead.
|
|
47
|
-
`/docs [search]` runs the same search into the session (`/docs search <text>` is
|
|
48
|
-
the explicit form).
|
|
49
|
-
|
|
50
|
-
Every `*.md` under a knowledge lake is covered except the excluded areas —
|
|
51
|
-
scratch (`plans/`) and vendored mirrors (`requirements/`, `wiki/`). `Area` comes
|
|
52
|
-
from the path, `date` from git. Wiki lakes are read-only; their pages are
|
|
53
|
-
indexed as prose and tables, and their summaries are derived from the first
|
|
54
|
-
paragraph. A README is search-only, rendered as `instructions:path`, and is
|
|
55
|
-
never validated.
|
|
56
|
-
|
|
57
|
-
## Read it
|
|
58
|
-
|
|
59
|
-
- Consult matching docs before a broad grep: they are the curated knowledge.
|
|
60
|
-
- Class precedence on a tie is repo, instructions, global, machine, wiki. A
|
|
61
|
-
non-primary lake is prefixed (`global:platform/regions.md`,
|
|
62
|
-
`wiki:Integrations/Billing.md`).
|
|
63
|
-
- A `Summary` is a self-contained sentence — enough to decide whether to open
|
|
64
|
-
the file.
|
|
22
|
+
A **blank `search`** lists recent docs, one line each; a **`search`** ranks
|
|
23
|
+
them, falling back to `Closest matches` and matching terms as **substrings** —
|
|
24
|
+
so a typo finds nothing. Terms shorter than `MIN_TERM_LENGTH` (3) are dropped
|
|
25
|
+
when longer terms exist; otherwise all query terms are kept. `help` lists the
|
|
26
|
+
lakes and ranking.
|
|
65
27
|
|
|
66
|
-
|
|
28
|
+
Open the match with `read`: the root-relative path, or `lake:path` when several
|
|
29
|
+
lakes exist. A README result is `instructions:path` and opens with the built-in
|
|
30
|
+
`read`. Consult matching docs before a broad grep; a tie ranks repo,
|
|
31
|
+
instructions, global, machine, wiki. Knowledge lakes exclude `plans/`,
|
|
32
|
+
`requirements/` and `wiki/` at the root; wiki lakes exclude only
|
|
33
|
+
dot-directories and `node_modules`.
|
|
67
34
|
|
|
68
|
-
|
|
69
|
-
right lake so the next human or agent skips the rediscovery. The convention is
|
|
70
|
-
this plugin's `CONVENTION.md`, restated here.
|
|
35
|
+
## Record it
|
|
71
36
|
|
|
72
|
-
|
|
37
|
+
Write to `<area>/<title>.md` under the right lake; `CONVENTION.md` is
|
|
38
|
+
authoritative.
|
|
73
39
|
|
|
74
|
-
1. **Read first, never duplicate.**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
from touched docs.
|
|
78
|
-
2. **Choose one class.** Repo-specific goes to RepoContext. Personal setup goes
|
|
79
|
-
to MachineContext. Cross-repo org knowledge goes to GlobalContext. How-to
|
|
80
|
-
commands stay in the README. Human narrative goes to the wiki, read-only.
|
|
40
|
+
1. **Read first, never duplicate.** Read the docs relevant to the task: the same
|
|
41
|
+
area, keyword hits on its entities, or links from touched docs.
|
|
42
|
+
2. **Choose one class** from the table — the narrowest that holds the claim.
|
|
81
43
|
3. **Reconcile, then add.** Edit before add; merge near-twins; split a bloated
|
|
82
|
-
doc
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
One artifact per topic
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
44
|
+
doc per topic; after a split, merge or move, repoint inbound references and
|
|
45
|
+
delete the emptied doc — no superseded stubs.
|
|
46
|
+
|
|
47
|
+
One artifact per topic and class, never one dump; the compaction nudge shapes
|
|
48
|
+
`Record with docs add:` blocks for this.
|
|
49
|
+
|
|
50
|
+
Record through the typed `add`: metadata (`Area`, `Keywords`, `Summary`) and a
|
|
51
|
+
body with an `H1` and a claim section — Facts, Inferred, Tested, Failed approaches, Open questions. Omit `path` to create `<Area>/<slug>.md`; pass `path` to replace **wholesale**, so omitted body content is lost and the near-twin reconcile is skipped. Refused: blank metadata, traversal, a non-`.md` or symlinked target, no claim section, and — only when `path` is omitted — a same-area near-twin.
|
|
52
|
+
|
|
53
|
+
A doc is current or deleted; no `Status` or `Supersedes`. A failed attempt is
|
|
54
|
+
valid knowledge when it records what was tried, why it failed, what is true now,
|
|
55
|
+
and what not to retry.
|
|
56
|
+
|
|
57
|
+
Lead with the actionable fact, keeping the reason, limits and evidence that
|
|
58
|
+
affect using it; drop chronology, repeated conclusions and empty sections. Keep
|
|
59
|
+
the service names, errors, paths and commands someone will search for. A
|
|
60
|
+
`Summary` decides whether to open the file.
|
|
61
|
+
|
|
62
|
+
Never record secrets, credentials, tokens or customer data — name the vault or
|
|
63
|
+
variable, never the value — and no personal data in GlobalContext or Wiki.
|
|
102
64
|
|
|
103
65
|
## Check it
|
|
104
66
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
Finally, run the repo's gate (`defined comply` where `.defined.json` exists,
|
|
115
|
-
else what its README documents) before proposing a commit. Fix failures; never
|
|
116
|
-
paper over a red gate with a docs commit. Prepare the commit and **wait for
|
|
67
|
+
`check` audits the lakes: front-matter, an `H1`, a claim section, no
|
|
68
|
+
hand-maintained `Date`, a distinctive identity, near-twins and broken relative
|
|
69
|
+
links. Obsolete fields, long docs and missing roots are **notes**, not findings;
|
|
70
|
+
wiki lakes are not validated. `add` enforces the structural rules at the write
|
|
71
|
+
boundary; cross-document audits stay with `check`. `maxDocLines` (default 200,
|
|
72
|
+
`0` disables) is a soft size signal, not a refusal.
|
|
73
|
+
|
|
74
|
+
Run the project's tests and typecheck before proposing a commit, fixing
|
|
75
|
+
failures rather than papering over them. Prepare the commit and **wait for
|
|
117
76
|
explicit approval** — recording findings is not commit approval. Never push.
|
package/src/index.ts
CHANGED
|
@@ -89,13 +89,13 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
89
89
|
search: Schema.optional(
|
|
90
90
|
Schema.String.annotate({
|
|
91
91
|
description:
|
|
92
|
-
"Case-insensitive search over title, path, keywords,
|
|
92
|
+
"Case-insensitive search over title, path, keywords, headings, code spans, area, summary and wiki prose. Blank lists the most recently changed docs; a partial match returns the closest.",
|
|
93
93
|
}),
|
|
94
94
|
),
|
|
95
95
|
lake: Schema.optional(
|
|
96
96
|
Schema.String.annotate({
|
|
97
97
|
description:
|
|
98
|
-
"Optional lake name
|
|
98
|
+
"Optional lake name: search and check are limited to it, read and add target it. Omit to search every lake, read the unique match, or add to the first writable repo lake.",
|
|
99
99
|
}),
|
|
100
100
|
),
|
|
101
101
|
add: Schema.optional(
|
|
@@ -103,7 +103,7 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
103
103
|
path: Schema.optional(
|
|
104
104
|
Schema.String.annotate({
|
|
105
105
|
description:
|
|
106
|
-
"Update target, e.g. `guides/getting-started.md
|
|
106
|
+
"Update target, e.g. `guides/getting-started.md`, relative to the lake root: replaces that doc wholesale, so omitted body content is lost, and skips the near-twin reconcile. Omit to create `<Area>/<slug>.md` from the H1.",
|
|
107
107
|
}),
|
|
108
108
|
),
|
|
109
109
|
metadata: Schema.Struct({
|
|
@@ -112,27 +112,27 @@ export const DOCS_INPUT = Schema.Struct({
|
|
|
112
112
|
Summary: Schema.String,
|
|
113
113
|
}).annotate({
|
|
114
114
|
description:
|
|
115
|
-
"Front-matter metadata; serialised, never copied. All three
|
|
115
|
+
"Front-matter metadata; serialised, never copied. All three are required and must not be blank.",
|
|
116
116
|
}),
|
|
117
117
|
body: Schema.String.annotate({
|
|
118
118
|
description:
|
|
119
|
-
"Markdown body: an H1,
|
|
119
|
+
"Markdown body: an H1 title, plus at least one claim section as an H2/H3 — Facts, Inferred, Tested, Failed approaches, Open questions.",
|
|
120
120
|
}),
|
|
121
121
|
}).annotate({
|
|
122
122
|
description:
|
|
123
|
-
"The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`.
|
|
123
|
+
"The only mutating mode: writes a new knowledge doc, or wholesale-replaces the doc at `path`. Refused: blank metadata, path traversal, a non-.md or symlinked target, no claim section, and a same-area near-twin when `path` is omitted. Wiki lakes are read-only.",
|
|
124
124
|
}),
|
|
125
125
|
),
|
|
126
126
|
check: Schema.optional(
|
|
127
127
|
Schema.Boolean.annotate({
|
|
128
128
|
description:
|
|
129
|
-
"Validate every knowledge doc
|
|
129
|
+
"Validate every knowledge doc: front-matter, an H1, a claim section, no hand-maintained Date, a distinctive H1+Keywords identity, near-twins, broken relative links, a soft length note. Wiki lakes and READMEs are not validated.",
|
|
130
130
|
}),
|
|
131
131
|
),
|
|
132
132
|
read: Schema.optional(
|
|
133
133
|
Schema.String.annotate({
|
|
134
134
|
description:
|
|
135
|
-
"A root-relative doc path, or `lake:path
|
|
135
|
+
"A root-relative doc path, or `lake:path`: returns the doc verbatim, front-matter and body, so find-then-read is one call. Lake docs and wiki pages only; project READMEs use the built-in `read` tool.",
|
|
136
136
|
}),
|
|
137
137
|
),
|
|
138
138
|
});
|
|
@@ -250,9 +250,77 @@ function splitKeywords(value: string): string[] {
|
|
|
250
250
|
.filter((keyword) => keyword !== "");
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
/**
|
|
254
|
+
* An open fenced code block: the opener's delimiter character and run length.
|
|
255
|
+
* The length and character are remembered so a shorter or different-character
|
|
256
|
+
* run inside the block cannot close it.
|
|
257
|
+
*/
|
|
258
|
+
interface Fence {
|
|
259
|
+
readonly char: "`" | "~";
|
|
260
|
+
readonly length: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The supported Markdown fence subset: a backtick or tilde run of three or
|
|
265
|
+
* more, indented by at most three spaces. A backtick opener's info string may
|
|
266
|
+
* not contain a backtick. Four-space indentation is an indented code block, not
|
|
267
|
+
* a fence, and is not modelled; nor are tab-indented fences or the full
|
|
268
|
+
* CommonMark block grammar.
|
|
269
|
+
* @param line One source line.
|
|
270
|
+
* @returns The opened fence, or null when the line is not an opener.
|
|
271
|
+
*/
|
|
272
|
+
function fenceOpener(line: string): Fence | null {
|
|
273
|
+
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
274
|
+
if (match === null) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const run = match[1] ?? "";
|
|
278
|
+
const info = line.slice(match[0].length);
|
|
279
|
+
const char = run[0] as Fence["char"];
|
|
280
|
+
if (char === "`" && info.includes("`")) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
return { char, length: run.length };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether a line closes `fence`: the same delimiter character, a run at least
|
|
288
|
+
* as long as the opener, and nothing after it but spaces or tabs.
|
|
289
|
+
* @param line One source line.
|
|
290
|
+
* @param fence The open fence.
|
|
291
|
+
* @returns True when the line closes the block.
|
|
292
|
+
*/
|
|
293
|
+
function closesFence(line: string, fence: Fence): boolean {
|
|
294
|
+
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
295
|
+
if (match === null) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
const run = match[1] ?? "";
|
|
299
|
+
return run.startsWith(fence.char) && run.length >= fence.length;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The body's lines outside fenced code blocks, fence delimiters included in
|
|
304
|
+
* neither. An unclosed fence keeps every remaining line inside the block.
|
|
305
|
+
* @param body Doc body, front-matter already stripped.
|
|
306
|
+
* @returns Source lines with fenced blocks removed.
|
|
307
|
+
*/
|
|
308
|
+
function outsideFenceLines(body: string): string[] {
|
|
309
|
+
const lines: string[] = [];
|
|
310
|
+
let fence: Fence | null = null;
|
|
311
|
+
for (const line of body.split("\n")) {
|
|
312
|
+
if (fence === null) {
|
|
313
|
+
const opener = fenceOpener(line);
|
|
314
|
+
if (opener !== null) {
|
|
315
|
+
fence = opener;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
lines.push(line);
|
|
319
|
+
} else if (closesFence(line, fence)) {
|
|
320
|
+
fence = null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return lines;
|
|
256
324
|
}
|
|
257
325
|
|
|
258
326
|
/** Drop an ATX heading's trailing `#` decoration (and any trailing spaces). */
|
|
@@ -279,19 +347,11 @@ interface BodyScan {
|
|
|
279
347
|
function scanBody(body: string): BodyScan {
|
|
280
348
|
const headings: string[] = [];
|
|
281
349
|
const spans: string[] = [];
|
|
282
|
-
|
|
283
|
-
for (const line of body.split("\n")) {
|
|
284
|
-
if (isFence(line)) {
|
|
285
|
-
fenced = !fenced;
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
if (fenced) {
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
350
|
+
for (const line of outsideFenceLines(body)) {
|
|
291
351
|
const heading = /^(#{2,3})\s+/.exec(line);
|
|
292
352
|
if (heading !== null) {
|
|
293
353
|
const text = stripTrailingHashes(
|
|
294
|
-
line.slice(heading[0].length).
|
|
354
|
+
line.slice(heading[0].length).replaceAll("`", ""),
|
|
295
355
|
).trim();
|
|
296
356
|
if (text !== "") {
|
|
297
357
|
headings.push(text);
|
|
@@ -358,18 +418,7 @@ function plainProse(text: string): string {
|
|
|
358
418
|
|
|
359
419
|
/** The body with fenced code blocks removed. */
|
|
360
420
|
function withoutFences(body: string): string {
|
|
361
|
-
|
|
362
|
-
let fenced = false;
|
|
363
|
-
for (const line of body.split("\n")) {
|
|
364
|
-
if (isFence(line)) {
|
|
365
|
-
fenced = !fenced;
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
if (!fenced) {
|
|
369
|
-
lines.push(line);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
return lines.join("\n");
|
|
421
|
+
return outsideFenceLines(body).join("\n");
|
|
373
422
|
}
|
|
374
423
|
|
|
375
424
|
/** Block prefixes that are never a wiki summary. */
|
|
@@ -752,6 +801,20 @@ export function rankDocs(
|
|
|
752
801
|
};
|
|
753
802
|
}
|
|
754
803
|
|
|
804
|
+
/** The provenance prefix for a result: instructions, non-primary lake, or none. */
|
|
805
|
+
function resultPrefix(
|
|
806
|
+
entry: DocEntry,
|
|
807
|
+
primaryLake: string | undefined,
|
|
808
|
+
): string {
|
|
809
|
+
if (entry.class === "instructions") {
|
|
810
|
+
return "instructions";
|
|
811
|
+
}
|
|
812
|
+
if (entry.lake !== "" && entry.lake !== primaryLake) {
|
|
813
|
+
return entry.lake;
|
|
814
|
+
}
|
|
815
|
+
return "";
|
|
816
|
+
}
|
|
817
|
+
|
|
755
818
|
/**
|
|
756
819
|
* Render matched docs as `path — Area — date` lines with the summary indented
|
|
757
820
|
* beneath; a non-primary lake is prefixed `lake:` so provenance is clear. A
|
|
@@ -782,12 +845,8 @@ export function render(
|
|
|
782
845
|
);
|
|
783
846
|
const meta = parts.join(" — ");
|
|
784
847
|
const head = meta === "" ? entry.path : `${entry.path} — ${meta}`;
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
? `instructions:${head}`
|
|
788
|
-
: entry.lake !== "" && entry.lake !== options.primaryLake
|
|
789
|
-
? `${entry.lake}:${head}`
|
|
790
|
-
: head;
|
|
848
|
+
const prefix = resultPrefix(entry, options.primaryLake);
|
|
849
|
+
const labelled = prefix === "" ? head : `${prefix}:${head}`;
|
|
791
850
|
return entry.summary === ""
|
|
792
851
|
? labelled
|
|
793
852
|
: `${labelled}\n ${entry.summary}`;
|
|
@@ -824,7 +883,7 @@ export function explainRanking(): string {
|
|
|
824
883
|
"How docs search ranks:",
|
|
825
884
|
`- 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} > wiki prose ${FIELD_WEIGHTS.text}.`,
|
|
826
885
|
`- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
|
|
827
|
-
`- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are
|
|
886
|
+
`- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are dropped when longer terms exist; otherwise all query terms are kept.`,
|
|
828
887
|
"- Search covers every configured lake and nested project `README.md` files, honouring `.gitignore` (dependency and build directories are skipped when the project is not in git). READMEs are searchable only: `add` and `check` ignore them, and their results are prefixed `instructions:` so they read with the built-in `read` tool.",
|
|
829
888
|
`- Each source reserves up to ${DEFAULT_PER_LAKE_QUOTA} results (configurable), then leftover slots fill by score. 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."`,
|
|
830
889
|
"- Class precedence on a tie: repo > instructions > global > machine > wiki.",
|
|
@@ -844,6 +903,8 @@ export interface LakeInfo {
|
|
|
844
903
|
writable: boolean;
|
|
845
904
|
/** Whether `check` validates it. */
|
|
846
905
|
validated: boolean;
|
|
906
|
+
/** Whether the root is absent, so the lake contributes nothing yet. */
|
|
907
|
+
missing?: boolean;
|
|
847
908
|
}
|
|
848
909
|
|
|
849
910
|
/**
|
|
@@ -857,8 +918,9 @@ export function helpText(lakes: readonly LakeInfo[]): string {
|
|
|
857
918
|
for (const lake of lakes) {
|
|
858
919
|
const access = lake.writable ? "writable" : "read-only";
|
|
859
920
|
const checked = lake.validated ? "checked" : "not checked";
|
|
921
|
+
const missing = lake.missing === true ? " — missing" : "";
|
|
860
922
|
lines.push(
|
|
861
|
-
`- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}`,
|
|
923
|
+
`- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}${missing}`,
|
|
862
924
|
);
|
|
863
925
|
}
|
|
864
926
|
lines.push(
|
|
@@ -942,7 +1004,7 @@ export function gitDates(
|
|
|
942
1004
|
return dates;
|
|
943
1005
|
}
|
|
944
1006
|
const result = spawnSync(
|
|
945
|
-
"git",
|
|
1007
|
+
"git", // NOSONAR: portable git lookup; cwd is trusted local config, not model input.
|
|
946
1008
|
// `--relative` keeps the output paths root-relative to `cwd`; without
|
|
947
1009
|
// it a lake rooted at `<repo>/docs` maps `docs/foo.md` while the
|
|
948
1010
|
// requested paths are `foo.md`, silently falling back to mtimes.
|
|
@@ -1056,6 +1118,22 @@ function skipDirectory(
|
|
|
1056
1118
|
return directory === root && excluded.includes(item.name);
|
|
1057
1119
|
}
|
|
1058
1120
|
|
|
1121
|
+
/**
|
|
1122
|
+
* Code-unit string order, matching the default sort but stated explicitly.
|
|
1123
|
+
* @param a Left string.
|
|
1124
|
+
* @param b Right string.
|
|
1125
|
+
* @returns Negative, zero or positive in code-unit order.
|
|
1126
|
+
*/
|
|
1127
|
+
function compareText(a: string, b: string): number {
|
|
1128
|
+
if (a < b) {
|
|
1129
|
+
return -1;
|
|
1130
|
+
}
|
|
1131
|
+
if (a > b) {
|
|
1132
|
+
return 1;
|
|
1133
|
+
}
|
|
1134
|
+
return 0;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1059
1137
|
/**
|
|
1060
1138
|
* Walk a lake root, returning its `.md` paths relative to the root. Dot
|
|
1061
1139
|
* directories are skipped (which drops `.git` and `.attachments`), as are
|
|
@@ -1103,7 +1181,7 @@ export function collectDocPaths(
|
|
|
1103
1181
|
if (existsSync(root)) {
|
|
1104
1182
|
walk(root);
|
|
1105
1183
|
}
|
|
1106
|
-
return paths.sort();
|
|
1184
|
+
return paths.sort(compareText);
|
|
1107
1185
|
}
|
|
1108
1186
|
|
|
1109
1187
|
/**
|
|
@@ -1218,7 +1296,7 @@ export function readmePaths(
|
|
|
1218
1296
|
.filter(
|
|
1219
1297
|
(path) => !skip.some((root) => isUnder(join(location, path), root)),
|
|
1220
1298
|
)
|
|
1221
|
-
.sort();
|
|
1299
|
+
.sort(compareText);
|
|
1222
1300
|
}
|
|
1223
1301
|
|
|
1224
1302
|
/**
|
|
@@ -1227,7 +1305,7 @@ export function readmePaths(
|
|
|
1227
1305
|
*/
|
|
1228
1306
|
function gitReadmePaths(location: string): string[] | undefined {
|
|
1229
1307
|
const result = spawnSync(
|
|
1230
|
-
"git",
|
|
1308
|
+
"git", // NOSONAR: portable git lookup; cwd is trusted local config, not model input.
|
|
1231
1309
|
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
1232
1310
|
{ cwd: location, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
|
1233
1311
|
);
|
|
@@ -1319,7 +1397,11 @@ export type AddOutcome =
|
|
|
1319
1397
|
path: string;
|
|
1320
1398
|
/** The serialised file contents. */
|
|
1321
1399
|
contents: string;
|
|
1322
|
-
/**
|
|
1400
|
+
/**
|
|
1401
|
+
* Same-area docs already on disk. Not rendered in the tool result —
|
|
1402
|
+
* the success path names the path and outcome only — but retained for
|
|
1403
|
+
* programmatic callers that want the surrounding context.
|
|
1404
|
+
*/
|
|
1323
1405
|
neighbours: readonly DocEntry[];
|
|
1324
1406
|
/** Soft advisory lines (e.g. a long doc worth splitting). */
|
|
1325
1407
|
notes: readonly string[];
|
|
@@ -1526,6 +1608,14 @@ export function prepareAdd(
|
|
|
1526
1608
|
if (h1Of(add.body) === undefined) {
|
|
1527
1609
|
return refuse("The body must carry an H1 (`# Title`).");
|
|
1528
1610
|
}
|
|
1611
|
+
// The same rule `check` applies, enforced before anything touches disk: a
|
|
1612
|
+
// doc that `check` would flag on the next run should never be written.
|
|
1613
|
+
if (!hasClaimSection(add.body)) {
|
|
1614
|
+
return refuse(
|
|
1615
|
+
"the body carries no claim section; add an H2/H3 headed Facts, " +
|
|
1616
|
+
"Inferred, Tested, Failed approaches or Open questions.",
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1529
1619
|
if (!hasDistinctiveIdentity(reconcileQuery(add))) {
|
|
1530
1620
|
return refuse(
|
|
1531
1621
|
`the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
|
|
@@ -1589,6 +1679,26 @@ export const CLAIM_SECTIONS = [
|
|
|
1589
1679
|
"Open questions",
|
|
1590
1680
|
] as const;
|
|
1591
1681
|
|
|
1682
|
+
/**
|
|
1683
|
+
* Whether a body carries at least one recognisable claim section as an H2/H3
|
|
1684
|
+
* heading. This is the one rule {@link prepareAdd} and {@link validateDoc}
|
|
1685
|
+
* share, so `add` cannot accept a doc that `check` would immediately flag.
|
|
1686
|
+
*
|
|
1687
|
+
* It reads through {@link scanBody}, so a fenced code block or an inline code
|
|
1688
|
+
* span cannot masquerade as a section, and a `# H1` or `#### H4` does not
|
|
1689
|
+
* satisfy it.
|
|
1690
|
+
* @param body Markdown body, front-matter already stripped.
|
|
1691
|
+
* @returns True when a claim-section heading is present.
|
|
1692
|
+
*/
|
|
1693
|
+
export function hasClaimSection(body: string): boolean {
|
|
1694
|
+
const claims = new Set(
|
|
1695
|
+
CLAIM_SECTIONS.map((section) => section.toLowerCase()),
|
|
1696
|
+
);
|
|
1697
|
+
return scanBody(body).headings.some((heading) =>
|
|
1698
|
+
claims.has(heading.toLowerCase()),
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1592
1702
|
/**
|
|
1593
1703
|
* The required front-matter fields that are blank in a raw doc: `Keywords` is
|
|
1594
1704
|
* blank when it yields no terms (matching `add`'s typed rule in
|
|
@@ -1633,10 +1743,7 @@ export function validateDoc(path: string, raw: string): string[] {
|
|
|
1633
1743
|
"hand-maintained `Date` — the date is derived by git, never written.",
|
|
1634
1744
|
);
|
|
1635
1745
|
}
|
|
1636
|
-
|
|
1637
|
-
if (
|
|
1638
|
-
!scanBody(body).headings.some((h) => claims.includes(h.toLowerCase()))
|
|
1639
|
-
) {
|
|
1746
|
+
if (!hasClaimSection(body)) {
|
|
1640
1747
|
findings.push(
|
|
1641
1748
|
"no claim section (Facts / Inferred / Tested / Failed approaches / Open questions).",
|
|
1642
1749
|
);
|
|
@@ -1668,7 +1775,7 @@ function nearTwinPairs(
|
|
|
1668
1775
|
const query = identityQuery(doc.title, doc.keywords);
|
|
1669
1776
|
const others = entries.filter((other) => other.path !== doc.path);
|
|
1670
1777
|
for (const twin of nearTwins(query, doc.area, others)) {
|
|
1671
|
-
const key = [doc.path, twin.path].sort().join("\n");
|
|
1778
|
+
const key = [doc.path, twin.path].sort(compareText).join("\n");
|
|
1672
1779
|
if (!pairs.has(key)) {
|
|
1673
1780
|
pairs.set(key, [doc, twin]);
|
|
1674
1781
|
}
|
|
@@ -1702,26 +1809,47 @@ function resolveLink(from: string, target: string): string | null {
|
|
|
1702
1809
|
return stack.join("/");
|
|
1703
1810
|
}
|
|
1704
1811
|
|
|
1812
|
+
/** An inline link's destination and the index of its closing `)`. */
|
|
1813
|
+
interface InlineLink {
|
|
1814
|
+
readonly destination: string | undefined;
|
|
1815
|
+
readonly end: number;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* Parse an inline `[text](target)` link starting at `start`.
|
|
1820
|
+
* @param line One source line.
|
|
1821
|
+
* @param start Index of the opening `[`.
|
|
1822
|
+
* @returns The destination and closing index, or null when the link is incomplete.
|
|
1823
|
+
*/
|
|
1824
|
+
function inlineLinkAt(line: string, start: number): InlineLink | null {
|
|
1825
|
+
const close = line.indexOf("](", start + 1);
|
|
1826
|
+
if (close === -1) {
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
const end = line.indexOf(")", close + 2);
|
|
1830
|
+
if (end === -1) {
|
|
1831
|
+
return null;
|
|
1832
|
+
}
|
|
1833
|
+
const destination = line
|
|
1834
|
+
.slice(close + 2, end)
|
|
1835
|
+
.trim()
|
|
1836
|
+
.split(/\s+/)[0];
|
|
1837
|
+
return { destination, end };
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1705
1840
|
/** Inline `[text](target)` destinations on one line; images are skipped. */
|
|
1706
1841
|
function inlineTargets(line: string): string[] {
|
|
1707
1842
|
const targets: string[] = [];
|
|
1708
1843
|
let index = 0;
|
|
1709
1844
|
while (index < line.length) {
|
|
1710
1845
|
if (line[index] === "[" && line[index - 1] !== "!") {
|
|
1711
|
-
const
|
|
1712
|
-
if (
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
const destination = line
|
|
1716
|
-
.slice(close + 2, end)
|
|
1717
|
-
.trim()
|
|
1718
|
-
.split(/\s+/)[0];
|
|
1719
|
-
if (destination !== undefined && destination !== "") {
|
|
1720
|
-
targets.push(destination);
|
|
1721
|
-
}
|
|
1722
|
-
index = end + 1;
|
|
1723
|
-
continue;
|
|
1846
|
+
const link = inlineLinkAt(line, index);
|
|
1847
|
+
if (link !== null) {
|
|
1848
|
+
if (link.destination !== undefined && link.destination !== "") {
|
|
1849
|
+
targets.push(link.destination);
|
|
1724
1850
|
}
|
|
1851
|
+
index = link.end + 1;
|
|
1852
|
+
continue;
|
|
1725
1853
|
}
|
|
1726
1854
|
}
|
|
1727
1855
|
index += 1;
|
|
@@ -1738,15 +1866,7 @@ function inlineTargets(line: string): string[] {
|
|
|
1738
1866
|
*/
|
|
1739
1867
|
export function markdownTargets(body: string): string[] {
|
|
1740
1868
|
const targets: string[] = [];
|
|
1741
|
-
|
|
1742
|
-
for (const line of body.split("\n")) {
|
|
1743
|
-
if (isFence(line)) {
|
|
1744
|
-
fenced = !fenced;
|
|
1745
|
-
continue;
|
|
1746
|
-
}
|
|
1747
|
-
if (fenced) {
|
|
1748
|
-
continue;
|
|
1749
|
-
}
|
|
1869
|
+
for (const line of outsideFenceLines(body)) {
|
|
1750
1870
|
targets.push(...inlineTargets(line));
|
|
1751
1871
|
const reference = /^\s*\[[^\]]+\]:\s*(\S+)/.exec(line);
|
|
1752
1872
|
if (reference?.[1] !== undefined) {
|
|
@@ -1932,7 +2052,8 @@ export function checkDocs(
|
|
|
1932
2052
|
if (advisory.length === 0) {
|
|
1933
2053
|
return head;
|
|
1934
2054
|
}
|
|
1935
|
-
|
|
2055
|
+
const noteLines = advisory.map((note) => `- ${note}`).join("\n");
|
|
2056
|
+
return `${head}\n\nNotes:\n${noteLines}`;
|
|
1936
2057
|
}
|
|
1937
2058
|
|
|
1938
2059
|
/** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
|
|
@@ -2048,13 +2169,17 @@ export function missNudgeFor(
|
|
|
2048
2169
|
* never a session dump (the near-twin guard catches duplicates, not dumps).
|
|
2049
2170
|
*/
|
|
2050
2171
|
export const RECORD_NUDGE =
|
|
2051
|
-
"Before summarising
|
|
2052
|
-
"(decisions, traps, runbooks, verified
|
|
2053
|
-
"
|
|
2172
|
+
"Before summarising, the active task comes first. If the session " +
|
|
2173
|
+
"established durable knowledge (decisions, traps, runbooks, verified " +
|
|
2174
|
+
"behaviour), add one `Record with docs add:` block per durable topic and " +
|
|
2175
|
+
"scope — coalesce related findings rather than one block per observation, " +
|
|
2054
2176
|
"never one session dump, and never one mixed-scope doc. Each block names " +
|
|
2055
|
-
"its
|
|
2056
|
-
"
|
|
2057
|
-
"
|
|
2177
|
+
"its class (repo, machine or global), `Area`, `H1` title and `Keywords`, and " +
|
|
2178
|
+
"keeps what was learned, why it matters, the evidence or status, any " +
|
|
2179
|
+
"unresolved uncertainty and the intended scope, so the next agent can " +
|
|
2180
|
+
"reconcile before writing. Reference a finding already recorded by path " +
|
|
2181
|
+
"rather than restating it, and assume persistence only where the session " +
|
|
2182
|
+
"confirms it.";
|
|
2058
2183
|
|
|
2059
2184
|
/**
|
|
2060
2185
|
* Resolve the plugin's `nudge` option: unset means on; a boolean passes
|
|
@@ -2097,7 +2222,10 @@ export interface Lake extends LakeSpec {
|
|
|
2097
2222
|
read: (path: string) => string;
|
|
2098
2223
|
/** Whether a root-relative path exists. */
|
|
2099
2224
|
exists: (path: string) => boolean;
|
|
2100
|
-
/**
|
|
2225
|
+
/**
|
|
2226
|
+
* Writes a root-relative path, creating parent directories beneath an
|
|
2227
|
+
* existing root; throws when the root is absent rather than creating it.
|
|
2228
|
+
*/
|
|
2101
2229
|
write: (path: string, contents: string) => void;
|
|
2102
2230
|
/** Environment notes for `check` (missing root, unversioned root). */
|
|
2103
2231
|
notes: readonly string[];
|
|
@@ -2186,7 +2314,7 @@ export function createLake(
|
|
|
2186
2314
|
};
|
|
2187
2315
|
return {
|
|
2188
2316
|
...spec,
|
|
2189
|
-
notes: unversionedNote(spec.root),
|
|
2317
|
+
notes: unversionedNote(spec.root, spec.name),
|
|
2190
2318
|
linkCheck: true,
|
|
2191
2319
|
collect: () =>
|
|
2192
2320
|
collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
|
|
@@ -2202,6 +2330,14 @@ export function createLake(
|
|
|
2202
2330
|
},
|
|
2203
2331
|
write: (path, contents) => {
|
|
2204
2332
|
const target = guarded(path);
|
|
2333
|
+
// The root is configured, never invented: a mistyped path must not
|
|
2334
|
+
// bootstrap a lake. `add` pre-checks for a friendlier refusal.
|
|
2335
|
+
if (!existsSync(spec.root)) {
|
|
2336
|
+
throw new Error(
|
|
2337
|
+
`lake '${spec.name}' root ${spec.root} does not exist; ` +
|
|
2338
|
+
"create it first.",
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2205
2341
|
mkdirSync(dirname(target), { recursive: true });
|
|
2206
2342
|
writeFileSync(target, contents);
|
|
2207
2343
|
},
|
|
@@ -2314,13 +2450,20 @@ function readLakesJson(
|
|
|
2314
2450
|
{ cause: error },
|
|
2315
2451
|
);
|
|
2316
2452
|
}
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2453
|
+
let lakes: unknown;
|
|
2454
|
+
if (Array.isArray(parsed)) {
|
|
2455
|
+
lakes = parsed;
|
|
2456
|
+
} else if (
|
|
2457
|
+
typeof parsed === "object" &&
|
|
2458
|
+
parsed !== null &&
|
|
2459
|
+
"lakes" in parsed
|
|
2460
|
+
) {
|
|
2461
|
+
lakes = (parsed as { lakes: unknown }).lakes;
|
|
2462
|
+
} else {
|
|
2463
|
+
lakes = undefined;
|
|
2464
|
+
}
|
|
2322
2465
|
if (!Array.isArray(lakes)) {
|
|
2323
|
-
throw new
|
|
2466
|
+
throw new TypeError(
|
|
2324
2467
|
`docs plugin ${label} must contain a "lakes" array: ${path}.`,
|
|
2325
2468
|
);
|
|
2326
2469
|
}
|
|
@@ -2390,7 +2533,7 @@ function lakeRootOf(
|
|
|
2390
2533
|
return join(location, "docs");
|
|
2391
2534
|
}
|
|
2392
2535
|
if (typeof entry.root !== "string") {
|
|
2393
|
-
throw new
|
|
2536
|
+
throw new TypeError(
|
|
2394
2537
|
`docs plugin ${lakeClass} lake root must be a string, ` +
|
|
2395
2538
|
`got ${JSON.stringify(entry.root)}.`,
|
|
2396
2539
|
);
|
|
@@ -2411,12 +2554,14 @@ function lakeNameOf(
|
|
|
2411
2554
|
lakeClass: LakeClass,
|
|
2412
2555
|
): string {
|
|
2413
2556
|
const derived = slugify(basename(root)) || lakeClass;
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2557
|
+
let name: string;
|
|
2558
|
+
if (entry.name === undefined) {
|
|
2559
|
+
name = derived;
|
|
2560
|
+
} else if (typeof entry.name === "string") {
|
|
2561
|
+
name = entry.name.trim();
|
|
2562
|
+
} else {
|
|
2563
|
+
name = "";
|
|
2564
|
+
}
|
|
2420
2565
|
if (!LAKE_NAME.test(name)) {
|
|
2421
2566
|
throw new Error(
|
|
2422
2567
|
`docs plugin lake name must match ${LAKE_NAME}, ` +
|
|
@@ -2443,8 +2588,7 @@ function lakeWritableOf(
|
|
|
2443
2588
|
`got ${JSON.stringify(entry.writable)}.`,
|
|
2444
2589
|
);
|
|
2445
2590
|
}
|
|
2446
|
-
const writable =
|
|
2447
|
-
entry.writable === undefined ? lakeClass !== "wiki" : entry.writable;
|
|
2591
|
+
const writable = entry.writable ?? lakeClass !== "wiki";
|
|
2448
2592
|
if (lakeClass === "wiki" && writable) {
|
|
2449
2593
|
throw new Error(
|
|
2450
2594
|
`docs plugin lake '${name}' is a wiki lake and cannot be writable.`,
|
|
@@ -2725,12 +2869,26 @@ function searchDocs(
|
|
|
2725
2869
|
closest,
|
|
2726
2870
|
omitted,
|
|
2727
2871
|
} = rankDocs(entries, query, { perLakeQuota: runtime.perLakeQuota });
|
|
2872
|
+
const content = render(ranked, {
|
|
2873
|
+
closest,
|
|
2874
|
+
omitted,
|
|
2875
|
+
primaryLake: primaryLakeName(runtime.lakes) ?? "",
|
|
2876
|
+
});
|
|
2877
|
+
// A dormant lake contributes nothing, so without the note a typo'd root
|
|
2878
|
+
// looks like an empty lake rather than a broken path.
|
|
2879
|
+
const missing = runtime.lakes
|
|
2880
|
+
.filter(
|
|
2881
|
+
(lake) =>
|
|
2882
|
+
lake.root !== "" &&
|
|
2883
|
+
(lakeName === undefined || lakeName === lake.name) &&
|
|
2884
|
+
!existsSync(lake.root),
|
|
2885
|
+
)
|
|
2886
|
+
.map((lake) => missingRootNote(lake.root, lake.name));
|
|
2728
2887
|
return {
|
|
2729
|
-
content:
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
}),
|
|
2888
|
+
content:
|
|
2889
|
+
missing.length > 0
|
|
2890
|
+
? `${content}\n\n${missing.join("\n")}`
|
|
2891
|
+
: content,
|
|
2734
2892
|
count: ranked.length,
|
|
2735
2893
|
omitted,
|
|
2736
2894
|
};
|
|
@@ -2806,6 +2964,16 @@ function addDoc(
|
|
|
2806
2964
|
if ("reason" in target) {
|
|
2807
2965
|
return { content: target.reason, written: false };
|
|
2808
2966
|
}
|
|
2967
|
+
// A typo'd root must not bootstrap a new tree; the area directories below
|
|
2968
|
+
// an existing root still create on write.
|
|
2969
|
+
if (target.root !== "" && !existsSync(target.root)) {
|
|
2970
|
+
return {
|
|
2971
|
+
content:
|
|
2972
|
+
`${REFUSE} lake '${target.name}' root ${target.root} does ` +
|
|
2973
|
+
"not exist — create it first.",
|
|
2974
|
+
written: false,
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2809
2977
|
const entries = target.collect();
|
|
2810
2978
|
const outcome = prepareAdd(add, entries, {
|
|
2811
2979
|
maxDocLines: runtime.maxDocLines,
|
|
@@ -2829,16 +2997,12 @@ function addDoc(
|
|
|
2829
2997
|
const head = `${address(target, outcome.path, runtime.lakes)} — ${
|
|
2830
2998
|
updated ? "updated" : "created"
|
|
2831
2999
|
}.`;
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
outcome.neighbours,
|
|
2836
|
-
{ primaryLake: target.name },
|
|
2837
|
-
)}`
|
|
2838
|
-
: "";
|
|
3000
|
+
// The path and outcome are the answer; a list of same-area neighbours was
|
|
3001
|
+
// unsolicited context that grew with the area and was picked by collection
|
|
3002
|
+
// order, not relevance. Refusals still carry their candidates.
|
|
2839
3003
|
const notes =
|
|
2840
3004
|
outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
|
|
2841
|
-
return { content: `${head}${
|
|
3005
|
+
return { content: `${head}${notes}`, written: true };
|
|
2842
3006
|
}
|
|
2843
3007
|
|
|
2844
3008
|
/** A parsed read request: the lake (when known) and the path. */
|
|
@@ -2950,6 +3114,7 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
|
|
|
2950
3114
|
root: lake.root,
|
|
2951
3115
|
writable: lake.writable,
|
|
2952
3116
|
validated: lake.profile === "knowledge",
|
|
3117
|
+
missing: lake.root !== "" && !existsSync(lake.root),
|
|
2953
3118
|
}));
|
|
2954
3119
|
}
|
|
2955
3120
|
|
|
@@ -2974,11 +3139,56 @@ export interface DocsCommandDeps extends RuntimeInput {
|
|
|
2974
3139
|
* the full doctrine the native `docs` description carries.
|
|
2975
3140
|
*/
|
|
2976
3141
|
const TWIN_DESCRIPTION =
|
|
2977
|
-
"Search, read, add and check the configured knowledge lakes
|
|
2978
|
-
"project `README.md` files
|
|
2979
|
-
"
|
|
2980
|
-
"
|
|
2981
|
-
"
|
|
3142
|
+
"Search, read, add and check the configured knowledge lakes and nested " +
|
|
3143
|
+
"project `README.md` files (search-only) from inside `execute` — the `docs` " +
|
|
3144
|
+
"tool's Code Mode twin, same verbs and lakes. A blank search lists recent " +
|
|
3145
|
+
"docs; `read` returns a matched doc verbatim; `add` writes through a typed " +
|
|
3146
|
+
"DTO; `check` validates the lakes.";
|
|
3147
|
+
|
|
3148
|
+
/**
|
|
3149
|
+
* Run one resolved `docs` request against the runtime.
|
|
3150
|
+
* @param runtime The resolved lakes and seams.
|
|
3151
|
+
* @param input The typed tool input.
|
|
3152
|
+
* @returns The tool result.
|
|
3153
|
+
*/
|
|
3154
|
+
function executeRequest(runtime: Runtime, input: DocsInput): Result {
|
|
3155
|
+
const request = resolveRequest(input);
|
|
3156
|
+
if (request.verb === "add") {
|
|
3157
|
+
const result = addDoc(runtime, request.add, request.lake);
|
|
3158
|
+
return {
|
|
3159
|
+
content: result.content,
|
|
3160
|
+
metadata: { written: result.written },
|
|
3161
|
+
};
|
|
3162
|
+
}
|
|
3163
|
+
if (request.verb === "check") {
|
|
3164
|
+
const result = checkLakes(runtime, request.lake);
|
|
3165
|
+
return {
|
|
3166
|
+
content: result.content,
|
|
3167
|
+
metadata: { checked: result.checked },
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
if (request.verb === "read") {
|
|
3171
|
+
const result = readDoc(runtime, request.read, request.lake);
|
|
3172
|
+
return {
|
|
3173
|
+
content: result.content,
|
|
3174
|
+
metadata: { read: result.read },
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
if (request.verb === "help") {
|
|
3178
|
+
return {
|
|
3179
|
+
content: helpText(lakeInfo(runtime.lakes)),
|
|
3180
|
+
metadata: { count: 0, omitted: 0 },
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
const result = searchDocs(runtime, request.search, request.lake);
|
|
3184
|
+
return {
|
|
3185
|
+
content: result.content,
|
|
3186
|
+
metadata: {
|
|
3187
|
+
count: result.count,
|
|
3188
|
+
omitted: result.omitted,
|
|
3189
|
+
},
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
2982
3192
|
|
|
2983
3193
|
/**
|
|
2984
3194
|
* Build the `docs` tool from its dependencies.
|
|
@@ -3010,54 +3220,19 @@ export function createDocsTool(
|
|
|
3010
3220
|
name,
|
|
3011
3221
|
description: codemode
|
|
3012
3222
|
? TWIN_DESCRIPTION
|
|
3013
|
-
: "
|
|
3014
|
-
"
|
|
3015
|
-
"
|
|
3016
|
-
|
|
3017
|
-
"
|
|
3018
|
-
|
|
3019
|
-
"
|
|
3223
|
+
: "Search the configured knowledge lakes and nested project " +
|
|
3224
|
+
"`README.md` files before a broad grep for how-to knowledge, " +
|
|
3225
|
+
"decisions or known problems. A blank search lists recent docs; " +
|
|
3226
|
+
"`help` shows the lakes and ranking. `read` returns a lake doc " +
|
|
3227
|
+
"verbatim; project `instructions:` results open with the built-in " +
|
|
3228
|
+
"read tool. `add` writes or replaces a knowledge doc; `check` " +
|
|
3229
|
+
"audits convention compliance.",
|
|
3020
3230
|
input: DOCS_INPUT,
|
|
3021
3231
|
options: codemode
|
|
3022
3232
|
? { namespace: "docs", codemode: true }
|
|
3023
3233
|
: { codemode: false },
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
if (request.verb === "add") {
|
|
3027
|
-
const result = addDoc(runtime, request.add, request.lake);
|
|
3028
|
-
return {
|
|
3029
|
-
content: result.content,
|
|
3030
|
-
metadata: { written: result.written },
|
|
3031
|
-
};
|
|
3032
|
-
}
|
|
3033
|
-
if (request.verb === "check") {
|
|
3034
|
-
const result = checkLakes(runtime, request.lake);
|
|
3035
|
-
return {
|
|
3036
|
-
content: result.content,
|
|
3037
|
-
metadata: { checked: result.checked },
|
|
3038
|
-
};
|
|
3039
|
-
}
|
|
3040
|
-
if (request.verb === "read") {
|
|
3041
|
-
const result = readDoc(runtime, request.read, request.lake);
|
|
3042
|
-
return {
|
|
3043
|
-
content: result.content,
|
|
3044
|
-
metadata: { read: result.read },
|
|
3045
|
-
};
|
|
3046
|
-
}
|
|
3047
|
-
if (request.verb === "help") {
|
|
3048
|
-
return {
|
|
3049
|
-
content: helpText(lakeInfo(runtime.lakes)),
|
|
3050
|
-
metadata: { count: 0, omitted: 0 },
|
|
3051
|
-
};
|
|
3052
|
-
}
|
|
3053
|
-
const result = searchDocs(runtime, request.search, request.lake);
|
|
3054
|
-
return {
|
|
3055
|
-
content: result.content,
|
|
3056
|
-
metadata: {
|
|
3057
|
-
count: result.count,
|
|
3058
|
-
omitted: result.omitted,
|
|
3059
|
-
},
|
|
3060
|
-
};
|
|
3234
|
+
execute(input) {
|
|
3235
|
+
return Promise.resolve(executeRequest(runtime, input));
|
|
3061
3236
|
},
|
|
3062
3237
|
};
|
|
3063
3238
|
}
|
|
@@ -3099,9 +3274,7 @@ export function createDocsCommand(deps: DocsCommandDeps): CommandDefinition {
|
|
|
3099
3274
|
return {
|
|
3100
3275
|
name: "docs",
|
|
3101
3276
|
description:
|
|
3102
|
-
"Search the configured knowledge lakes, ranked, plus nested project `README.md` files (search only); `add <text>` records new knowledge through the tool's typed `add`; `check` validates the
|
|
3103
|
-
MAX_RESULTS +
|
|
3104
|
-
" most recently changed.",
|
|
3277
|
+
"Search the configured knowledge lakes, ranked, plus nested project `README.md` files (search only); `add <text>` records new knowledge through the tool's typed `add`; `check` validates the lakes; a blank search lists recent docs.",
|
|
3105
3278
|
async execute({ sessionID, prompt }) {
|
|
3106
3279
|
const text = prompt.text.trim();
|
|
3107
3280
|
if (isHelp(text)) {
|
|
@@ -3149,7 +3322,7 @@ export function resolveRoot(
|
|
|
3149
3322
|
return join(locationDirectory, "docs");
|
|
3150
3323
|
}
|
|
3151
3324
|
if (typeof configured !== "string") {
|
|
3152
|
-
throw new
|
|
3325
|
+
throw new TypeError(
|
|
3153
3326
|
`docs plugin option 'root' must be a non-empty string path, ` +
|
|
3154
3327
|
`got ${JSON.stringify(configured)}.`,
|
|
3155
3328
|
);
|
|
@@ -3166,22 +3339,45 @@ export function resolveRoot(
|
|
|
3166
3339
|
}
|
|
3167
3340
|
|
|
3168
3341
|
/**
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
3171
|
-
*
|
|
3342
|
+
* The advisory note for a lake root that is absent. A missing root is a
|
|
3343
|
+
* configuration smell, not a failure: the lake stays dormant until the
|
|
3344
|
+
* directory exists, and `add` refuses rather than creating it.
|
|
3345
|
+
* @param root The resolved lake root.
|
|
3346
|
+
* @param lake The lake name, when known.
|
|
3347
|
+
* @returns The note line.
|
|
3348
|
+
*/
|
|
3349
|
+
export function missingRootNote(root: string, lake?: string): string {
|
|
3350
|
+
const subject =
|
|
3351
|
+
lake === undefined
|
|
3352
|
+
? `lake root ${root}`
|
|
3353
|
+
: `lake '${lake}' root ${root}`;
|
|
3354
|
+
return (
|
|
3355
|
+
`note: ${subject} does not exist — it is ignored until it does. ` +
|
|
3356
|
+
"A missing root is not an error."
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
/**
|
|
3361
|
+
* The environment notes for a lake root: missing, or unversioned. An absent
|
|
3362
|
+
* root is flagged but tolerated; an unversioned root still works — dates fall
|
|
3363
|
+
* back to file mtimes and the knowledge has no history — so it is surfaced,
|
|
3364
|
+
* not refused.
|
|
3172
3365
|
* @param root The lake root to probe.
|
|
3173
|
-
* @
|
|
3366
|
+
* @param lake The lake name, when known.
|
|
3367
|
+
* @returns The note lines; empty when the root is present and version-controlled.
|
|
3174
3368
|
*/
|
|
3175
|
-
export function unversionedNote(
|
|
3369
|
+
export function unversionedNote(
|
|
3370
|
+
root: string,
|
|
3371
|
+
lake?: string,
|
|
3372
|
+
): readonly string[] {
|
|
3176
3373
|
if (!existsSync(root)) {
|
|
3177
|
-
return [
|
|
3178
|
-
"note: this lake root does not exist yet — it is ignored until " +
|
|
3179
|
-
"it does. A missing root is not an error.",
|
|
3180
|
-
];
|
|
3374
|
+
return [missingRootNote(root, lake)];
|
|
3181
3375
|
}
|
|
3182
|
-
const probe = spawnSync(
|
|
3183
|
-
|
|
3184
|
-
|
|
3376
|
+
const probe = spawnSync(
|
|
3377
|
+
"git", // NOSONAR: portable git lookup; cwd is trusted local config.
|
|
3378
|
+
["rev-parse", "--show-toplevel"],
|
|
3379
|
+
{ cwd: root },
|
|
3380
|
+
);
|
|
3185
3381
|
if (probe.status === 0) {
|
|
3186
3382
|
return [];
|
|
3187
3383
|
}
|
|
@@ -3323,17 +3519,12 @@ export default Plugin.define({
|
|
|
3323
3519
|
id: Skill.ID.make("docs"),
|
|
3324
3520
|
name: Skill.Name.make("Docs"),
|
|
3325
3521
|
description:
|
|
3326
|
-
"
|
|
3327
|
-
"(repo, machine, global
|
|
3328
|
-
"
|
|
3329
|
-
"
|
|
3330
|
-
"
|
|
3331
|
-
"
|
|
3332
|
-
"for how-to knowledge, when a task names a service, flag " +
|
|
3333
|
-
"or error string the lake may already explain, at the " +
|
|
3334
|
-
"start of unfamiliar work, when hunting for prior " +
|
|
3335
|
-
"decisions or traps, when a session learns something the " +
|
|
3336
|
-
"next one will need, and when closing out a session.",
|
|
3522
|
+
"Entry point to the configured knowledge lakes " +
|
|
3523
|
+
"(repo, machine, global, read-only wiki) and nested project " +
|
|
3524
|
+
"README.md files: find and read the right doc, then record " +
|
|
3525
|
+
"focused knowledge at the narrowest true scope. Use before " +
|
|
3526
|
+
"searching the codebase for how-to knowledge, or when a " +
|
|
3527
|
+
"session learns something durable.",
|
|
3337
3528
|
path: AbsolutePath.make(fileURLToPath(skillURL)),
|
|
3338
3529
|
content: skill,
|
|
3339
3530
|
});
|