@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.
Files changed (3) hide show
  1. package/README.md +33 -8
  2. package/package.json +1 -1
  3. 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–E16, each with a claim, evidence,
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). To evaluate the guidance behaviourally: run the cases through
421
- a model with the docs tools mounted, holding model, settings and tool
422
- configuration constant, and score first-choice accuracy, unnecessary
423
- splitting, inappropriate fallback writes and the tool calls needed to correct
424
- recording. No behavioural trials have been run with the current wording; a
425
- fixture that merely asserts expected answers in Node is not evidence a model
426
- follows the guidance.
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.12.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 excluded Top-level areas the profile excludes.
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
- excluded: readonly string[],
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 directory === root && excluded.includes(item.name);
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, excluded, nested)) {
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
- * Paths under `skip` (the lake root) are dropped: they are already lake docs.
1351
- * A tracked file deleted from disk is dropped too — git still lists it.
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) => existsSync(join(location, 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 = targetOf(add);
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 };