@markstanden/opencode-docs-plugin 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -8
- package/package.json +1 -1
- package/src/index.ts +168 -17
package/README.md
CHANGED
|
@@ -407,6 +407,9 @@ npm test # node --test
|
|
|
407
407
|
npm run typecheck
|
|
408
408
|
```
|
|
409
409
|
|
|
410
|
+
Testing instructions, the behavioural placement trials and their container
|
|
411
|
+
live in [TESTING.md](TESTING.md).
|
|
412
|
+
|
|
410
413
|
Tests run the plugin source directly under Node's type-stripping (`noEmit`,
|
|
411
414
|
`erasableSyntaxOnly`), so the file OpenCode loads is the file under test.
|
|
412
415
|
|
|
@@ -415,15 +418,37 @@ ranking harness pins behaviour against — edit it directly. It holds invented
|
|
|
415
418
|
paths and summaries only; never snapshot a real docs tree into it.
|
|
416
419
|
|
|
417
420
|
`tests/fixtures/placement-cases.json` is a synthetic evaluation fixture for
|
|
418
|
-
the placement guidance (cases E01–
|
|
421
|
+
the placement guidance (cases E01–E18, each with a claim, evidence,
|
|
419
422
|
destinations, expected scope or action, acceptable rationale and tempting
|
|
420
|
-
wrong answer).
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
423
|
+
wrong answer). `tests/fixtures/placement-harness/harness.mts` runs the cases
|
|
424
|
+
behaviourally: `generate` materialises one prompt, one pristine fixture tree
|
|
425
|
+
and one out-of-band rubric per case (defaults `~/tmp/docs-placement/` for
|
|
426
|
+
prompts, rubrics and results, `~/test-dir/docs-placement/` for fixtures),
|
|
427
|
+
`run` copies one case into a stamped results directory and drives a headless
|
|
428
|
+
`opencode run` session there, `judge` scores each answer against the case
|
|
429
|
+
rubric with a second, plugin-free model call (`--judge-model` names a stronger
|
|
430
|
+
judge than the workhorse), and `export` bundles a sweep into one `dossier.md`
|
|
431
|
+
for deep review by an external model. Trials judge placement from the supplied
|
|
432
|
+
finding: a per-case run container sees only that case's sandbox, prompt, an
|
|
433
|
+
id-only case list and the result, and the requested shell/edit/read denials
|
|
434
|
+
are defence in depth rather than guarantees — each case's trail exposure is
|
|
435
|
+
classified and reported in the sweep summary.
|
|
436
|
+
`container-run.sh` runs the harness inside a Debian-slim trial container
|
|
437
|
+
(Node LTS, current `@opencode/cli`, read-only root filesystem) so trials see
|
|
438
|
+
no host filesystem at all; the harness can also run directly on the host.
|
|
439
|
+
Runs modify neither the repository nor the fixture trees. The first
|
|
440
|
+
behavioural sweep is recorded: sweep-1 (2026-11-07, plugin 0.12.0 at main
|
|
441
|
+
`f5a5882`), decision-only prompts with the `docs` skill in session, workhorse
|
|
442
|
+
`opencode-go/deepseek-v4.1-flash` in the trial container — 14/17 under the
|
|
443
|
+
workhorse judge, 17/17 under `zai-coding-plan/glm-5.3-flash`, with the three
|
|
444
|
+
disagreements resolved by manual review in favour of rewording the fixture
|
|
445
|
+
and rubric (E09, E06) rather than the guidance; see [TESTING.md](TESTING.md).
|
|
446
|
+
The principle stands: a fixture that merely asserts expected answers in Node
|
|
447
|
+
is not evidence a model follows the guidance. In this repository,
|
|
448
|
+
`/placement` (workflow in
|
|
449
|
+
`.opencode/skills/placement-trials/`) drives the harness: trials run on the
|
|
450
|
+
workhorse model, judging defaults to the current session model, and `export`
|
|
451
|
+
bundles a sweep for a stronger external reviewer.
|
|
427
452
|
|
|
428
453
|
## Licence
|
|
429
454
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markstanden/opencode-docs-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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/index.ts
CHANGED
|
@@ -1156,12 +1156,44 @@ function readDirectory(directory: string): Dirent[] {
|
|
|
1156
1156
|
}
|
|
1157
1157
|
}
|
|
1158
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* The one collection-visibility rule for a root-relative doc path: the
|
|
1161
|
+
* path segment that makes it invisible to collection, or null when the path
|
|
1162
|
+
* is collected. Knowledge lakes skip the scratch and mirror areas at the
|
|
1163
|
+
* top level; every profile skips dot-directories and `node_modules` at any
|
|
1164
|
+
* depth. Shared by the collection walk ({@link collectDocPaths}) and the
|
|
1165
|
+
* `add` target validation, so a write can never land where collection
|
|
1166
|
+
* refuses to look.
|
|
1167
|
+
* @param path Root-relative doc path, `/`-separated.
|
|
1168
|
+
* @param profile The lake's writing convention.
|
|
1169
|
+
* @returns The offending segment, or null when the path is collected.
|
|
1170
|
+
*/
|
|
1171
|
+
export function uncollectedSegment(
|
|
1172
|
+
path: string,
|
|
1173
|
+
profile: LakeProfile,
|
|
1174
|
+
): string | null {
|
|
1175
|
+
const segments = path.split("/");
|
|
1176
|
+
const top = segments[0] ?? "";
|
|
1177
|
+
if (
|
|
1178
|
+
profile === "knowledge" &&
|
|
1179
|
+
(EXCLUDED_AREAS as readonly string[]).includes(top)
|
|
1180
|
+
) {
|
|
1181
|
+
return top;
|
|
1182
|
+
}
|
|
1183
|
+
for (const segment of segments) {
|
|
1184
|
+
if (segment.startsWith(".") || segment === "node_modules") {
|
|
1185
|
+
return segment;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1159
1191
|
/**
|
|
1160
1192
|
* Whether a directory is outside the lake's collection rules.
|
|
1161
1193
|
* @param item The directory entry.
|
|
1162
1194
|
* @param directory Its parent directory.
|
|
1163
1195
|
* @param root The lake root.
|
|
1164
|
-
* @param
|
|
1196
|
+
* @param profile The lake's writing convention.
|
|
1165
1197
|
* @param nested Other lake roots nested inside this one.
|
|
1166
1198
|
* @returns True when the walk should not enter it.
|
|
1167
1199
|
*/
|
|
@@ -1169,12 +1201,9 @@ function skipDirectory(
|
|
|
1169
1201
|
item: Dirent,
|
|
1170
1202
|
directory: string,
|
|
1171
1203
|
root: string,
|
|
1172
|
-
|
|
1204
|
+
profile: LakeProfile,
|
|
1173
1205
|
nested: readonly string[],
|
|
1174
1206
|
): boolean {
|
|
1175
|
-
if (item.name.startsWith(".") || item.name === "node_modules") {
|
|
1176
|
-
return true;
|
|
1177
|
-
}
|
|
1178
1207
|
if (item.isSymbolicLink()) {
|
|
1179
1208
|
return true;
|
|
1180
1209
|
}
|
|
@@ -1182,7 +1211,12 @@ function skipDirectory(
|
|
|
1182
1211
|
if (nested.some((other) => isUnder(path, other))) {
|
|
1183
1212
|
return true;
|
|
1184
1213
|
}
|
|
1185
|
-
return
|
|
1214
|
+
return (
|
|
1215
|
+
uncollectedSegment(
|
|
1216
|
+
relative(root, path).split(sep).join("/"),
|
|
1217
|
+
profile,
|
|
1218
|
+
) !== null
|
|
1219
|
+
);
|
|
1186
1220
|
}
|
|
1187
1221
|
|
|
1188
1222
|
/**
|
|
@@ -1223,14 +1257,12 @@ export function collectDocPaths(
|
|
|
1223
1257
|
const nested = skip.filter(
|
|
1224
1258
|
(other) => other !== root && isUnder(other, root),
|
|
1225
1259
|
);
|
|
1226
|
-
const excluded =
|
|
1227
|
-
profile === "knowledge" ? (EXCLUDED_AREAS as readonly string[]) : [];
|
|
1228
1260
|
const paths: string[] = [];
|
|
1229
1261
|
const walk = (directory: string): void => {
|
|
1230
1262
|
for (const item of readDirectory(directory)) {
|
|
1231
1263
|
const path = join(directory, item.name);
|
|
1232
1264
|
if (item.isDirectory()) {
|
|
1233
|
-
if (!skipDirectory(item, directory, root,
|
|
1265
|
+
if (!skipDirectory(item, directory, root, profile, nested)) {
|
|
1234
1266
|
walk(path);
|
|
1235
1267
|
}
|
|
1236
1268
|
continue;
|
|
@@ -1238,7 +1270,11 @@ export function collectDocPaths(
|
|
|
1238
1270
|
if (
|
|
1239
1271
|
!item.isFile() ||
|
|
1240
1272
|
item.isSymbolicLink() ||
|
|
1241
|
-
!item.name.endsWith(".md")
|
|
1273
|
+
!item.name.endsWith(".md") ||
|
|
1274
|
+
uncollectedSegment(
|
|
1275
|
+
relative(root, path).split(sep).join("/"),
|
|
1276
|
+
profile,
|
|
1277
|
+
) !== null
|
|
1242
1278
|
) {
|
|
1243
1279
|
continue;
|
|
1244
1280
|
}
|
|
@@ -1342,13 +1378,49 @@ function isReadme(name: string): boolean {
|
|
|
1342
1378
|
return name.toLowerCase() === "readme.md";
|
|
1343
1379
|
}
|
|
1344
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* Resolve an `add` request's target path and apply the collection-visibility
|
|
1383
|
+
* rule: the resolved path (derived or explicit) must be a path the target
|
|
1384
|
+
* lake would actually collect, or the refusal explains why.
|
|
1385
|
+
* @param add The typed request.
|
|
1386
|
+
* @param profile The target lake's writing convention.
|
|
1387
|
+
* @returns The target path, or the refusal reason.
|
|
1388
|
+
*/
|
|
1389
|
+
function resolvedTargetOf(
|
|
1390
|
+
add: DocsAdd,
|
|
1391
|
+
profile: LakeProfile,
|
|
1392
|
+
): { path: string } | { reason: string } {
|
|
1393
|
+
const target = targetOf(add);
|
|
1394
|
+
if ("reason" in target) {
|
|
1395
|
+
return target;
|
|
1396
|
+
}
|
|
1397
|
+
const invisible = uncollectedSegment(target.path, profile);
|
|
1398
|
+
if (invisible !== null) {
|
|
1399
|
+
return {
|
|
1400
|
+
reason:
|
|
1401
|
+
`'${invisible}' in ${target.path} is never collected by a ` +
|
|
1402
|
+
`${profile} lake — the write would succeed yet stay ` +
|
|
1403
|
+
"invisible to search, read and check. Choose a visible " +
|
|
1404
|
+
"Area or path.",
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
return target;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1345
1410
|
/**
|
|
1346
1411
|
* The project's nested README paths, relative to `location`. Inside a git work
|
|
1347
1412
|
* tree git supplies the list — tracked plus untracked, honouring `.gitignore` —
|
|
1348
1413
|
* so dependency, build and scratch directories follow the project's own rules.
|
|
1349
1414
|
* Outside one, a walk skips {@link README_EXCLUDED_DIRS} and dot-directories.
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1415
|
+
* Both sources keep regular files only, and never follow a link: a leaf
|
|
1416
|
+
* symlink, a broken link, or a symlinked directory anywhere along the path
|
|
1417
|
+
* below `location` is not the project's own content, and following it could
|
|
1418
|
+
* index a file outside the tree — git can still list tracked paths whose
|
|
1419
|
+
* directory was replaced by a link. The project directory itself is the
|
|
1420
|
+
* trusted root: when it resolves through a symlink, containment is validated
|
|
1421
|
+
* from it downward, not above. Paths under `skip` (the lake root) are
|
|
1422
|
+
* dropped: they are already lake docs. A tracked file deleted from disk is
|
|
1423
|
+
* dropped too — git still lists it.
|
|
1352
1424
|
* @param location The project directory the plugin loaded in.
|
|
1353
1425
|
* @param skip Absolute roots whose READMEs are collected elsewhere.
|
|
1354
1426
|
* @returns Project-relative README paths, ordered by path.
|
|
@@ -1359,13 +1431,54 @@ export function readmePaths(
|
|
|
1359
1431
|
): string[] {
|
|
1360
1432
|
const candidates = gitReadmePaths(location) ?? walkedReadmePaths(location);
|
|
1361
1433
|
return candidates
|
|
1362
|
-
.filter((path) =>
|
|
1434
|
+
.filter((path) => isContainedRegularFile(location, path))
|
|
1363
1435
|
.filter(
|
|
1364
1436
|
(path) => !skip.some((root) => isUnder(join(location, path), root)),
|
|
1365
1437
|
)
|
|
1366
1438
|
.sort(compareText);
|
|
1367
1439
|
}
|
|
1368
1440
|
|
|
1441
|
+
/** Whether a path exists and is a regular file, never following a symlink. */
|
|
1442
|
+
function isRegularFile(path: string): boolean {
|
|
1443
|
+
try {
|
|
1444
|
+
return lstatSync(path).isFile();
|
|
1445
|
+
} catch {
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** Whether a path exists and is a real directory, never following a symlink. */
|
|
1451
|
+
function isRealDirectory(path: string): boolean {
|
|
1452
|
+
try {
|
|
1453
|
+
return lstatSync(path).isDirectory();
|
|
1454
|
+
} catch {
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Whether `relativePath` names a regular file whose every intermediate
|
|
1461
|
+
* component below `location` is a real directory, never a symlink — the
|
|
1462
|
+
* leaf check alone would follow a link planted mid-path into outside
|
|
1463
|
+
* content.
|
|
1464
|
+
* @param location The trusted project root; containment is validated below it.
|
|
1465
|
+
* @param relativePath A project-relative README candidate path.
|
|
1466
|
+
* @returns True only for a fully contained regular file.
|
|
1467
|
+
*/
|
|
1468
|
+
function isContainedRegularFile(
|
|
1469
|
+
location: string,
|
|
1470
|
+
relativePath: string,
|
|
1471
|
+
): boolean {
|
|
1472
|
+
const parts = relativePath.split("/");
|
|
1473
|
+
const leaf = parts.pop() ?? "";
|
|
1474
|
+
let current = location;
|
|
1475
|
+
for (const part of parts) {
|
|
1476
|
+
current = join(current, part);
|
|
1477
|
+
if (!isRealDirectory(current)) return false;
|
|
1478
|
+
}
|
|
1479
|
+
return isRegularFile(join(current, leaf));
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1369
1482
|
/**
|
|
1370
1483
|
* README paths from git's index and untracked non-ignored files, or undefined
|
|
1371
1484
|
* when `location` is not inside a git work tree.
|
|
@@ -1744,6 +1857,30 @@ function revisionRefusal(add: DocsAdd): string | null {
|
|
|
1744
1857
|
return null;
|
|
1745
1858
|
}
|
|
1746
1859
|
|
|
1860
|
+
/**
|
|
1861
|
+
* The first required metadata field whose value carries a line break, or
|
|
1862
|
+
* null. The front-matter serialisation is flat `Key: value` lines: a break
|
|
1863
|
+
* inside a value would emit a second line that parses as its own field (a
|
|
1864
|
+
* hand-maintained `Date`, a shadowed `Keywords`), so multiline values are
|
|
1865
|
+
* refused at the boundary rather than corrupted on disk.
|
|
1866
|
+
* @param metadata The typed request metadata.
|
|
1867
|
+
* @returns The offending field name, or null.
|
|
1868
|
+
*/
|
|
1869
|
+
function multilineMetadataField(metadata: DocMetadata): string | null {
|
|
1870
|
+
for (const { name } of FRONT_MATTER_FIELDS) {
|
|
1871
|
+
const value = metadata[name];
|
|
1872
|
+
const pieces = Array.isArray(value) ? value : [value];
|
|
1873
|
+
if (
|
|
1874
|
+
pieces.some(
|
|
1875
|
+
(piece) => typeof piece === "string" && /[\r\n]/.test(piece),
|
|
1876
|
+
)
|
|
1877
|
+
) {
|
|
1878
|
+
return name;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return null;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1747
1884
|
/**
|
|
1748
1885
|
* The refusal for DTO content a knowledge doc may not carry: blank required
|
|
1749
1886
|
* metadata, an `Area` off the root, a missing `H1`, no claim section, or an
|
|
@@ -1756,6 +1893,15 @@ function contentRefusal(add: DocsAdd): string | null {
|
|
|
1756
1893
|
if (blank !== null) {
|
|
1757
1894
|
return `${blank} is required and must not be blank.`;
|
|
1758
1895
|
}
|
|
1896
|
+
const multiline = multilineMetadataField(add.metadata);
|
|
1897
|
+
if (multiline !== null) {
|
|
1898
|
+
return (
|
|
1899
|
+
`${multiline} must be a single line: line breaks in serialized ` +
|
|
1900
|
+
"front-matter would corrupt the doc's metadata (a second line " +
|
|
1901
|
+
"becomes a field of its own). Keep each value one line; pass " +
|
|
1902
|
+
"Keywords as several elements, not one delimited string."
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1759
1905
|
if (/[/\\]|\.\./.test(add.metadata.Area)) {
|
|
1760
1906
|
return "Area must be a single folder name under the lake root.";
|
|
1761
1907
|
}
|
|
@@ -1790,19 +1936,23 @@ function contentRefusal(add: DocsAdd): string | null {
|
|
|
1790
1936
|
* path or `Area` escaping the lake root, a non-`.md` target and a title that
|
|
1791
1937
|
* yields no slug are all refused. A same-area near-twin — a doc matching every
|
|
1792
1938
|
* reconcile query term ({@link reconcileQuery}) — refuses a create unless a
|
|
1793
|
-
* non-blank `allowNearTwinReason` explains the deliberate distinct doc.
|
|
1939
|
+
* non-blank `allowNearTwinReason` explains the deliberate distinct doc. A
|
|
1940
|
+
* target the lake would never collect ({@link uncollectedSegment}) is
|
|
1941
|
+
* refused too: a successful write that search cannot see is a silent loss.
|
|
1794
1942
|
* Target existence is the caller seam's to verify: creates must not overwrite
|
|
1795
1943
|
* and replaces must exist.
|
|
1796
1944
|
* @param add The typed request.
|
|
1797
1945
|
* @param entries The current docs, for reconcile and neighbours.
|
|
1798
|
-
* @param options Soft-limit configuration.
|
|
1946
|
+
* @param options Soft-limit configuration and the target lake's profile.
|
|
1799
1947
|
* @param options.maxDocLines Note when the body exceeds this line count; `0` disables.
|
|
1948
|
+
* @param options.profile The target lake's writing convention, for the
|
|
1949
|
+
* collection-visibility rule; defaults to `knowledge`.
|
|
1800
1950
|
* @returns The prepared write, or the refusal.
|
|
1801
1951
|
*/
|
|
1802
1952
|
export function prepareAdd(
|
|
1803
1953
|
add: DocsAdd,
|
|
1804
1954
|
entries: readonly DocEntry[],
|
|
1805
|
-
options: { maxDocLines?: number } = {},
|
|
1955
|
+
options: { maxDocLines?: number; profile?: LakeProfile } = {},
|
|
1806
1956
|
): AddOutcome {
|
|
1807
1957
|
const refuse = (reason: string): AddOutcome => ({
|
|
1808
1958
|
action: "refuse",
|
|
@@ -1826,7 +1976,7 @@ export function prepareAdd(
|
|
|
1826
1976
|
if (content !== null) {
|
|
1827
1977
|
return refuse(content);
|
|
1828
1978
|
}
|
|
1829
|
-
const target =
|
|
1979
|
+
const target = resolvedTargetOf(add, options.profile ?? "knowledge");
|
|
1830
1980
|
if ("reason" in target) {
|
|
1831
1981
|
return refuse(target.reason);
|
|
1832
1982
|
}
|
|
@@ -4382,6 +4532,7 @@ function addDoc(
|
|
|
4382
4532
|
const entries = target.collect();
|
|
4383
4533
|
const outcome = prepareAdd(add, entries, {
|
|
4384
4534
|
maxDocLines: runtime.maxDocLines,
|
|
4535
|
+
profile: target.profile,
|
|
4385
4536
|
});
|
|
4386
4537
|
if (outcome.action === "refuse") {
|
|
4387
4538
|
return { content: outcome.reason, written: false };
|