@markstanden/opencode-docs-plugin 0.10.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/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Plugin, Skill } from "@opencode/plugin";
2
2
  import { AbsolutePath } from "@opencode/schema/schema";
3
+ import { createHash } from "node:crypto";
3
4
  import type {
4
5
  CommandDefinition,
5
6
  CommandInvocation,
@@ -7,15 +8,20 @@ import type {
7
8
  import type { Info, Result } from "@opencode/plugin/promise/tool";
8
9
  import { spawnSync } from "node:child_process";
9
10
  import {
11
+ closeSync,
10
12
  type Dirent,
11
13
  existsSync,
12
14
  lstatSync,
13
15
  mkdirSync,
16
+ openSync,
14
17
  readFileSync,
15
18
  readdirSync,
16
19
  realpathSync,
20
+ renameSync,
17
21
  statSync,
22
+ unlinkSync,
18
23
  writeFileSync,
24
+ writeSync,
19
25
  } from "node:fs";
20
26
  import { homedir } from "node:os";
21
27
  import {
@@ -50,6 +56,21 @@ import { Schema } from "effect";
50
56
  /** The doc classes the plugin serves. `instructions` is the implicit README source. */
51
57
  export type DocClass = "instructions" | "repo" | "machine" | "global" | "wiki";
52
58
 
59
+ /** The shape of a content revision: SHA-256, hex-encoded. */
60
+ export const REVISION_PATTERN = /^[0-9a-f]{64}$/;
61
+
62
+ /**
63
+ * A doc's content revision: the SHA-256 of the exact stored UTF-8 bytes,
64
+ * front-matter and newline bytes included. It represents the bytes, not an
65
+ * edit history — the same bytes hash to the same revision — and it is never
66
+ * persisted into the doc.
67
+ * @param raw The exact file contents.
68
+ * @returns The hex digest.
69
+ */
70
+ export function revisionOf(raw: string): string {
71
+ return createHash("sha256").update(raw, "utf8").digest("hex");
72
+ }
73
+
53
74
  /** The classes that can be a configured lake. */
54
75
  export type LakeClass = Exclude<DocClass, "instructions">;
55
76
 
@@ -89,21 +110,37 @@ export const DOCS_INPUT = Schema.Struct({
89
110
  search: Schema.optional(
90
111
  Schema.String.annotate({
91
112
  description:
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.",
113
+ "Case-insensitive search over title, path, keywords, headings, code spans, area, summary and wiki prose. Blank lists recent docs; a partial match returns the closest.",
93
114
  }),
94
115
  ),
95
116
  lake: Schema.optional(
96
117
  Schema.String.annotate({
97
118
  description:
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.",
119
+ "Optional lake name: search and check are limited to it; read and add target it. Omit to search or read the unique match; add writes the sole writable lake, refusing with candidates otherwise.",
99
120
  }),
100
121
  ),
101
122
  add: Schema.optional(
102
123
  Schema.Struct({
124
+ mode: Schema.Literals(["create", "replace"]).annotate({
125
+ description:
126
+ "Required: create (a new doc; never overwrites) or replace (wholesale-replaces the doc at path).",
127
+ }),
103
128
  path: Schema.optional(
104
129
  Schema.String.annotate({
105
130
  description:
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.",
131
+ "create: optional; derived from Area and the H1 when omitted. replace: required; the doc to replace.",
132
+ }),
133
+ ),
134
+ allowNearTwinReason: Schema.optional(
135
+ Schema.String.annotate({
136
+ description:
137
+ "create only: non-blank reason to write despite a same-area near-twin; bypasses only that guard.",
138
+ }),
139
+ ),
140
+ expectedRevision: Schema.optional(
141
+ Schema.String.annotate({
142
+ description:
143
+ "replace only: the revision token from your last read or write; stale or missing refuses.",
107
144
  }),
108
145
  ),
109
146
  metadata: Schema.Struct({
@@ -112,27 +149,27 @@ export const DOCS_INPUT = Schema.Struct({
112
149
  Summary: Schema.String,
113
150
  }).annotate({
114
151
  description:
115
- "Front-matter metadata; serialised, never copied. All three are required and must not be blank.",
152
+ "Front-matter metadata, serialised; all three required, never blank.",
116
153
  }),
117
154
  body: Schema.String.annotate({
118
155
  description:
119
- "Markdown body: an H1 title, plus at least one claim section as an H2/H3 — Facts, Inferred, Tested, Failed approaches, Open questions.",
156
+ "Markdown body: an H1 title plus a claim section as an H2/H3 — Facts, Inferred, Tested, Failed approaches, Open questions.",
120
157
  }),
121
158
  }).annotate({
122
159
  description:
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.",
160
+ "create: the target must not exist; explicit paths get the same checks as derived ones. replace: path names an existing doc whose bytes must match expectedRevision — never creates. Refused: blank metadata, traversal, a non-.md or symlinked target, no claim section, unreconciled near-twins. Wiki lakes are read-only.",
124
161
  }),
125
162
  ),
126
163
  check: Schema.optional(
127
164
  Schema.Boolean.annotate({
128
165
  description:
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. Only writable knowledge lakes are validated; read-only lakes, wiki lakes and READMEs are not, and skipped lakes are named.",
166
+ "Validate knowledge docs: front-matter, an H1, a claim section, no hand-maintained Date, a distinctive identity, near-twins, broken relative links, a soft length note. Only writable knowledge lakes are validated; skipped lakes are named.",
130
167
  }),
131
168
  ),
132
169
  read: Schema.optional(
133
170
  Schema.String.annotate({
134
171
  description:
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.",
172
+ "Root-relative path, or `lake:path`: the doc verbatim. Lake and wiki pages only; project READMEs use the built-in `read` tool. Knowledge reads end with a revision line, the replace token.",
136
173
  }),
137
174
  ),
138
175
  });
@@ -145,12 +182,16 @@ export const DOCS_INPUT = Schema.Struct({
145
182
  export type DocsInput = Schema.Schema.Type<typeof DOCS_INPUT>;
146
183
 
147
184
  /**
148
- * The `add` mode's typed DTO: the single writer's whole input. Pass `path` to
149
- * update (`docs/<area>/<name>.md`); omit it to create `docs/<Area>/<slug>.md`
150
- * from the H1.
185
+ * The `add` mode's typed DTO: the single writer's whole input. `mode` picks
186
+ * the contract: `create` derives `<Area>/<slug>.md` from the H1 unless an
187
+ * explicit `path` is given, and never overwrites; `replace` requires the
188
+ * `path` of an existing doc and replaces it wholesale.
151
189
  */
152
190
  export type DocsAdd = NonNullable<DocsInput["add"]>;
153
191
 
192
+ /** The two mutation modes of {@link DocsAdd}. */
193
+ export type AddMode = DocsAdd["mode"];
194
+
154
195
  /** The typed metadata of a knowledge doc, serialised to front-matter. */
155
196
  export type DocMetadata = DocsAdd["metadata"];
156
197
 
@@ -912,11 +953,15 @@ export interface LakeInfo {
912
953
  validated: boolean;
913
954
  /** Whether the root is absent, so the lake contributes nothing yet. */
914
955
  missing?: boolean;
956
+ /** What belongs there, as configured; absent lakes use the class guidance. */
957
+ description?: string;
915
958
  }
916
959
 
917
960
  /**
918
961
  * Describe the configured lakes, then the ranking. The agent learns where
919
- * knowledge lives without loading the full convention.
962
+ * knowledge lives without loading the full convention. Each line pairs the
963
+ * human scope label with its config value, and carries the lake's description
964
+ * (or the class guidance) so a write destination is chosen deliberately.
920
965
  * @param lakes The configured lakes.
921
966
  * @returns The help text.
922
967
  */
@@ -926,15 +971,30 @@ export function helpText(lakes: readonly LakeInfo[]): string {
926
971
  const access = lake.writable ? "writable" : "read-only";
927
972
  const checked = lake.validated ? "checked" : "not checked";
928
973
  const missing = lake.missing === true ? " — missing" : "";
974
+ const label =
975
+ lake.class in CLASS_LABELS
976
+ ? `${CLASS_LABELS[lake.class as LakeClass]} [${lake.class}]`
977
+ : lake.class;
978
+ const description =
979
+ lake.description ?? classGuidance(lake.class as LakeClass);
929
980
  lines.push(
930
- `- ${lake.name} (${lake.class}, ${access}, ${checked}): ${lake.root}${missing}`,
981
+ `- ${lake.name} (${label}, ${access}, ${checked}): ${lake.root}${missing}`,
931
982
  );
983
+ lines.push(` ${description}`);
932
984
  }
933
985
  lines.push(
934
986
  "- instructions (read-only, not checked): nested project README.md " +
935
987
  "files; results are prefixed `instructions:` and read with the " +
936
988
  "built-in read tool",
937
989
  );
990
+ lines.push(
991
+ "add writes to the sole writable knowledge lake when `lake` is " +
992
+ "omitted; with several, pass lake — one of the names above.",
993
+ );
994
+ lines.push(
995
+ "reads of knowledge docs end with a docs: revision line — pass its " +
996
+ "hash as expectedRevision when replacing that doc.",
997
+ );
938
998
  return `${lines.join("\n")}\n\n${explainRanking()}`;
939
999
  }
940
1000
 
@@ -1096,12 +1156,44 @@ function readDirectory(directory: string): Dirent[] {
1096
1156
  }
1097
1157
  }
1098
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
+
1099
1191
  /**
1100
1192
  * Whether a directory is outside the lake's collection rules.
1101
1193
  * @param item The directory entry.
1102
1194
  * @param directory Its parent directory.
1103
1195
  * @param root The lake root.
1104
- * @param excluded Top-level areas the profile excludes.
1196
+ * @param profile The lake's writing convention.
1105
1197
  * @param nested Other lake roots nested inside this one.
1106
1198
  * @returns True when the walk should not enter it.
1107
1199
  */
@@ -1109,12 +1201,9 @@ function skipDirectory(
1109
1201
  item: Dirent,
1110
1202
  directory: string,
1111
1203
  root: string,
1112
- excluded: readonly string[],
1204
+ profile: LakeProfile,
1113
1205
  nested: readonly string[],
1114
1206
  ): boolean {
1115
- if (item.name.startsWith(".") || item.name === "node_modules") {
1116
- return true;
1117
- }
1118
1207
  if (item.isSymbolicLink()) {
1119
1208
  return true;
1120
1209
  }
@@ -1122,7 +1211,12 @@ function skipDirectory(
1122
1211
  if (nested.some((other) => isUnder(path, other))) {
1123
1212
  return true;
1124
1213
  }
1125
- return directory === root && excluded.includes(item.name);
1214
+ return (
1215
+ uncollectedSegment(
1216
+ relative(root, path).split(sep).join("/"),
1217
+ profile,
1218
+ ) !== null
1219
+ );
1126
1220
  }
1127
1221
 
1128
1222
  /**
@@ -1163,14 +1257,12 @@ export function collectDocPaths(
1163
1257
  const nested = skip.filter(
1164
1258
  (other) => other !== root && isUnder(other, root),
1165
1259
  );
1166
- const excluded =
1167
- profile === "knowledge" ? (EXCLUDED_AREAS as readonly string[]) : [];
1168
1260
  const paths: string[] = [];
1169
1261
  const walk = (directory: string): void => {
1170
1262
  for (const item of readDirectory(directory)) {
1171
1263
  const path = join(directory, item.name);
1172
1264
  if (item.isDirectory()) {
1173
- if (!skipDirectory(item, directory, root, excluded, nested)) {
1265
+ if (!skipDirectory(item, directory, root, profile, nested)) {
1174
1266
  walk(path);
1175
1267
  }
1176
1268
  continue;
@@ -1178,7 +1270,11 @@ export function collectDocPaths(
1178
1270
  if (
1179
1271
  !item.isFile() ||
1180
1272
  item.isSymbolicLink() ||
1181
- !item.name.endsWith(".md")
1273
+ !item.name.endsWith(".md") ||
1274
+ uncollectedSegment(
1275
+ relative(root, path).split(sep).join("/"),
1276
+ profile,
1277
+ ) !== null
1182
1278
  ) {
1183
1279
  continue;
1184
1280
  }
@@ -1282,13 +1378,49 @@ function isReadme(name: string): boolean {
1282
1378
  return name.toLowerCase() === "readme.md";
1283
1379
  }
1284
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
+
1285
1410
  /**
1286
1411
  * The project's nested README paths, relative to `location`. Inside a git work
1287
1412
  * tree git supplies the list — tracked plus untracked, honouring `.gitignore` —
1288
1413
  * so dependency, build and scratch directories follow the project's own rules.
1289
1414
  * Outside one, a walk skips {@link README_EXCLUDED_DIRS} and dot-directories.
1290
- * Paths under `skip` (the lake root) are dropped: they are already lake docs.
1291
- * 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.
1292
1424
  * @param location The project directory the plugin loaded in.
1293
1425
  * @param skip Absolute roots whose READMEs are collected elsewhere.
1294
1426
  * @returns Project-relative README paths, ordered by path.
@@ -1299,13 +1431,54 @@ export function readmePaths(
1299
1431
  ): string[] {
1300
1432
  const candidates = gitReadmePaths(location) ?? walkedReadmePaths(location);
1301
1433
  return candidates
1302
- .filter((path) => existsSync(join(location, path)))
1434
+ .filter((path) => isContainedRegularFile(location, path))
1303
1435
  .filter(
1304
1436
  (path) => !skip.some((root) => isUnder(join(location, path), root)),
1305
1437
  )
1306
1438
  .sort(compareText);
1307
1439
  }
1308
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
+
1309
1482
  /**
1310
1483
  * README paths from git's index and untracked non-ignored files, or undefined
1311
1484
  * when `location` is not inside a git work tree.
@@ -1400,6 +1573,8 @@ export function slugify(text: string): string {
1400
1573
  export type AddOutcome =
1401
1574
  | {
1402
1575
  action: "write";
1576
+ /** The mutation mode the write follows. */
1577
+ mode: AddMode;
1403
1578
  /** Root-relative target. */
1404
1579
  path: string;
1405
1580
  /** The serialised file contents. */
@@ -1439,6 +1614,41 @@ function blankRequiredField(metadata: DocMetadata): string | null {
1439
1614
  return null;
1440
1615
  }
1441
1616
 
1617
+ /**
1618
+ * The mutation mode of an `add` request, guarded for direct core callers:
1619
+ * the typed schema requires `mode`, but nothing stops a JS caller bypassing
1620
+ * it, so the core refuses rather than guessing.
1621
+ * @param add The typed request, as delivered.
1622
+ * @returns The mode, or the refusal reason.
1623
+ */
1624
+ function modeOf(add: DocsAdd): AddMode | { reason: string } {
1625
+ if (add.mode === "create" || add.mode === "replace") {
1626
+ return add.mode;
1627
+ }
1628
+ return {
1629
+ reason:
1630
+ 'mode is required and must be "create" (write a new doc) or ' +
1631
+ '"replace" (wholesale-replace the doc at path).',
1632
+ };
1633
+ }
1634
+
1635
+ /**
1636
+ * Whether a create's deliberate near-twin override is present: a non-blank,
1637
+ * non-empty explanation. It bypasses only the semantic near-twin guard, never
1638
+ * the structural checks or the target state.
1639
+ * @param add The typed request.
1640
+ * @returns True when the override may be used in this mode.
1641
+ */
1642
+ function overrideGiven(add: DocsAdd): boolean {
1643
+ if (add.mode !== "create") {
1644
+ return false;
1645
+ }
1646
+ return (
1647
+ typeof add.allowNearTwinReason === "string" &&
1648
+ add.allowNearTwinReason.trim() !== ""
1649
+ );
1650
+ }
1651
+
1442
1652
  /**
1443
1653
  * Validate an explicit `add` path: relative, inside the lake root, naming a
1444
1654
  * `.md` file.
@@ -1468,8 +1678,9 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
1468
1678
 
1469
1679
  /**
1470
1680
  * The root-relative target path for an `add`, or the reason it is refused.
1471
- * An explicit path must stay inside the lake root and name a `.md` file; an
1472
- * omitted path derives `<Area>/<slug>.md` from the H1.
1681
+ * An explicit path must stay inside the lake root and name a `.md` file; a
1682
+ * create with no path derives `<Area>/<slug>.md` from the H1; a replace has
1683
+ * no path to derive — its `path` is required.
1473
1684
  * @param add The typed request.
1474
1685
  * @returns The target path, or the refusal reason.
1475
1686
  */
@@ -1478,6 +1689,11 @@ function targetOf(add: DocsAdd): { path: string } | { reason: string } {
1478
1689
  if (given !== "") {
1479
1690
  return explicitTarget(given);
1480
1691
  }
1692
+ if (add.mode === "replace") {
1693
+ return {
1694
+ reason: "replace requires the path of the existing doc to replace.",
1695
+ };
1696
+ }
1481
1697
  const slug = slugify(h1Of(add.body) ?? "");
1482
1698
  if (slug === "") {
1483
1699
  return { reason: "The H1 does not yield a usable file name." };
@@ -1546,8 +1762,10 @@ export function nearTwins(
1546
1762
  }
1547
1763
 
1548
1764
  /**
1549
- * The near-twin refusal for an `add`, or null when the write may proceed.
1550
- * An explicit path is the update contract and skips reconcile.
1765
+ * The near-twin refusal for a create, or null when the write may proceed.
1766
+ * Every create reconciles — derived and explicit paths alike — unless the
1767
+ * caller supplies a non-blank {@link DocsAdd.allowNearTwinReason}; replace is
1768
+ * the deliberate update contract and has no create guard.
1551
1769
  * @param add The typed request.
1552
1770
  * @param entries The current docs.
1553
1771
  * @returns The refusal, or null.
@@ -1556,7 +1774,7 @@ function nearTwinRefusal(
1556
1774
  add: DocsAdd,
1557
1775
  entries: readonly DocEntry[],
1558
1776
  ): AddOutcome | null {
1559
- if ((add.path?.trim() ?? "") !== "") {
1777
+ if (add.mode !== "create" || overrideGiven(add)) {
1560
1778
  return null;
1561
1779
  }
1562
1780
  const nearTwinDocs = nearTwins(
@@ -1571,33 +1789,170 @@ function nearTwinRefusal(
1571
1789
  action: "refuse",
1572
1790
  reason:
1573
1791
  `${REFUSE} ${add.metadata.Area} already holds a near-twin of ` +
1574
- "this doc. Pass its path to update it, or a different path " +
1575
- "to write anyway:\n\n" +
1792
+ 'this doc. Pass its path with mode:"replace" to replace it, ' +
1793
+ "or a non-blank allowNearTwinReason to write a deliberate " +
1794
+ "distinct doc:\n\n" +
1576
1795
  render(nearTwinDocs),
1577
1796
  candidates: nearTwinDocs,
1578
1797
  };
1579
1798
  }
1580
1799
 
1800
+ /**
1801
+ * The refusal for a misused `allowNearTwinReason`, or null. Replace has no
1802
+ * near-twin guard, so the field is meaningless there; a create must explain
1803
+ * the override in non-blank prose.
1804
+ * @param add The typed request.
1805
+ * @returns The refusal reason, or null when the field is acceptable.
1806
+ */
1807
+ function overrideRefusal(add: DocsAdd): string | null {
1808
+ if (add.allowNearTwinReason === undefined) {
1809
+ return null;
1810
+ }
1811
+ if (add.mode === "replace") {
1812
+ return (
1813
+ "allowNearTwinReason is create-only; replace has no " +
1814
+ "near-twin guard to bypass."
1815
+ );
1816
+ }
1817
+ if (
1818
+ typeof add.allowNearTwinReason !== "string" ||
1819
+ add.allowNearTwinReason.trim() === ""
1820
+ ) {
1821
+ return (
1822
+ "allowNearTwinReason must be a non-blank explanation of why " +
1823
+ "this doc is distinct from its near-twin."
1824
+ );
1825
+ }
1826
+ return null;
1827
+ }
1828
+
1829
+ /**
1830
+ * The refusal for a misused `expectedRevision`, or null. Replace requires the
1831
+ * revision token of the doc being replaced — its stale-write guard; create
1832
+ * carries a not-exists precondition instead and rejects the field outright.
1833
+ * @param add The typed request.
1834
+ * @returns The refusal reason, or null when the field is acceptable.
1835
+ */
1836
+ function revisionRefusal(add: DocsAdd): string | null {
1837
+ if (add.mode === "create") {
1838
+ return add.expectedRevision === undefined
1839
+ ? null
1840
+ : "expectedRevision is replace-only; a create's precondition is " +
1841
+ "that the target does not exist.";
1842
+ }
1843
+ const given = add.expectedRevision?.trim() ?? "";
1844
+ if (given === "") {
1845
+ return (
1846
+ "replace requires expectedRevision — the revision returned with " +
1847
+ "your last read or write of the doc, so a stale whole-doc " +
1848
+ "replacement refuses instead of overwriting a newer edit."
1849
+ );
1850
+ }
1851
+ if (!REVISION_PATTERN.test(given)) {
1852
+ return (
1853
+ "expectedRevision must be the SHA-256 hex digest returned with " +
1854
+ "the doc you read; reread it and resubmit."
1855
+ );
1856
+ }
1857
+ return null;
1858
+ }
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
+
1884
+ /**
1885
+ * The refusal for DTO content a knowledge doc may not carry: blank required
1886
+ * metadata, an `Area` off the root, a missing `H1`, no claim section, or an
1887
+ * identity too weak to reconcile.
1888
+ * @param add The typed request.
1889
+ * @returns The refusal reason, or null when the content is acceptable.
1890
+ */
1891
+ function contentRefusal(add: DocsAdd): string | null {
1892
+ const blank = blankRequiredField(add.metadata);
1893
+ if (blank !== null) {
1894
+ return `${blank} is required and must not be blank.`;
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
+ }
1905
+ if (/[/\\]|\.\./.test(add.metadata.Area)) {
1906
+ return "Area must be a single folder name under the lake root.";
1907
+ }
1908
+ if (h1Of(add.body) === undefined) {
1909
+ return "The body must carry an H1 (`# Title`).";
1910
+ }
1911
+ // The same rule `check` applies, enforced before anything touches disk: a
1912
+ // doc that `check` would flag on the next run should never be written.
1913
+ if (!hasClaimSection(add.body)) {
1914
+ return (
1915
+ "the body carries no claim section; add an H2/H3 headed Facts, " +
1916
+ "Inferred, Tested, Failed approaches or Open questions."
1917
+ );
1918
+ }
1919
+ if (!hasDistinctiveIdentity(reconcileQuery(add))) {
1920
+ return (
1921
+ `the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
1922
+ `(${MIN_TERM_LENGTH}) or longer; add a distinctive term so ` +
1923
+ "reconcile can tell this doc apart."
1924
+ );
1925
+ }
1926
+ return null;
1927
+ }
1928
+
1581
1929
  /**
1582
1930
  * Validate an `add` request and prepare it for writing — pure, no fs.
1583
1931
  *
1584
- * Trust boundary: the caller is a model. Blank required metadata, a body
1585
- * without an H1, a path or `Area` escaping the lake root, a non-`.md` target
1586
- * and a title that yields no slug are all refused. With `path` omitted the
1587
- * target is `<Area>/<slug>.md` and a same-area near-twin — a doc matching every
1588
- * reconcile query term ({@link reconcileQuery}) — refuses the write with the
1589
- * candidates attached; an explicit `path` is the update contract (wholesale
1590
- * replacement) and skips reconcile.
1932
+ * Trust boundary: the caller is a model. `mode` is required: `create` derives
1933
+ * the target from `Area` and the H1 unless an explicit `path` is given, and
1934
+ * reconciles against same-area near-twins either way; `replace` requires the
1935
+ * path of an existing doc. Blank required metadata, a body without an H1, a
1936
+ * path or `Area` escaping the lake root, a non-`.md` target and a title that
1937
+ * yields no slug are all refused. A same-area near-twin — a doc matching every
1938
+ * reconcile query term ({@link reconcileQuery}) — refuses a create unless a
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.
1942
+ * Target existence is the caller seam's to verify: creates must not overwrite
1943
+ * and replaces must exist.
1591
1944
  * @param add The typed request.
1592
1945
  * @param entries The current docs, for reconcile and neighbours.
1593
- * @param options Soft-limit configuration.
1946
+ * @param options Soft-limit configuration and the target lake's profile.
1594
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`.
1595
1950
  * @returns The prepared write, or the refusal.
1596
1951
  */
1597
1952
  export function prepareAdd(
1598
1953
  add: DocsAdd,
1599
1954
  entries: readonly DocEntry[],
1600
- options: { maxDocLines?: number } = {},
1955
+ options: { maxDocLines?: number; profile?: LakeProfile } = {},
1601
1956
  ): AddOutcome {
1602
1957
  const refuse = (reason: string): AddOutcome => ({
1603
1958
  action: "refuse",
@@ -1605,32 +1960,23 @@ export function prepareAdd(
1605
1960
  candidates: [],
1606
1961
  });
1607
1962
 
1608
- const blank = blankRequiredField(add.metadata);
1609
- if (blank !== null) {
1610
- return refuse(`${blank} is required and must not be blank.`);
1611
- }
1612
- if (/[/\\]|\.\./.test(add.metadata.Area)) {
1613
- return refuse("Area must be a single folder name under the lake root.");
1963
+ const mode = modeOf(add);
1964
+ if (typeof mode !== "string") {
1965
+ return refuse(mode.reason);
1614
1966
  }
1615
- if (h1Of(add.body) === undefined) {
1616
- return refuse("The body must carry an H1 (`# Title`).");
1967
+ const override = overrideRefusal(add);
1968
+ if (override !== null) {
1969
+ return refuse(override);
1617
1970
  }
1618
- // The same rule `check` applies, enforced before anything touches disk: a
1619
- // doc that `check` would flag on the next run should never be written.
1620
- if (!hasClaimSection(add.body)) {
1621
- return refuse(
1622
- "the body carries no claim section; add an H2/H3 headed Facts, " +
1623
- "Inferred, Tested, Failed approaches or Open questions.",
1624
- );
1971
+ const revision = revisionRefusal(add);
1972
+ if (revision !== null) {
1973
+ return refuse(revision);
1625
1974
  }
1626
- if (!hasDistinctiveIdentity(reconcileQuery(add))) {
1627
- return refuse(
1628
- `the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
1629
- `(${MIN_TERM_LENGTH}) or longer; add a distinctive term so ` +
1630
- "reconcile can tell this doc apart.",
1631
- );
1975
+ const content = contentRefusal(add);
1976
+ if (content !== null) {
1977
+ return refuse(content);
1632
1978
  }
1633
- const target = targetOf(add);
1979
+ const target = resolvedTargetOf(add, options.profile ?? "knowledge");
1634
1980
  if ("reason" in target) {
1635
1981
  return refuse(target.reason);
1636
1982
  }
@@ -1647,6 +1993,7 @@ export function prepareAdd(
1647
1993
  .slice(0, MAX_RESULTS);
1648
1994
  return {
1649
1995
  action: "write",
1996
+ mode,
1650
1997
  path: target.path,
1651
1998
  contents: serializeDoc(add.metadata, add.body),
1652
1999
  neighbours,
@@ -2181,12 +2528,13 @@ export const RECORD_NUDGE =
2181
2528
  "behaviour), add one `Record with docs add:` block per durable topic and " +
2182
2529
  "scope — coalesce related findings rather than one block per observation, " +
2183
2530
  "never one session dump, and never one mixed-scope doc. Each block names " +
2184
- "its class (repo, machine or global), `Area`, `H1` title and `Keywords`, and " +
2185
- "keeps what was learned, why it matters, the evidence or status, any " +
2186
- "unresolved uncertainty and the intended scope, so the next agent can " +
2187
- "reconcile before writing. Reference a finding already recorded by path " +
2188
- "rather than restating it, and assume persistence only where the session " +
2189
- "confirms it.";
2531
+ "its scope (Repository/Personal/Organisation — config repo, machine or " +
2532
+ "global), `Area`, `H1` title and `Keywords`, and keeps what was learned, " +
2533
+ "why it matters, the evidence or status, any unresolved uncertainty and " +
2534
+ "the intended scope, so the next agent can reconcile before writing and " +
2535
+ "confirm the destination fits. Reference a finding already recorded by " +
2536
+ "path rather than restating it, and assume persistence only where the " +
2537
+ "session confirms it.";
2190
2538
 
2191
2539
  /**
2192
2540
  * Resolve the plugin's `nudge` option: unset means on; a boolean passes
@@ -2219,6 +2567,13 @@ export interface LakeSpec {
2219
2567
  profile: LakeProfile;
2220
2568
  /** Whether `add` may write to it. */
2221
2569
  writable: boolean;
2570
+ /**
2571
+ * What belongs in this particular destination, as configured. Absent lakes
2572
+ * fall back to the class guidance ({@link classGuidance}); it is
2573
+ * descriptive data shown in help and refusal candidate lists, never
2574
+ * indexed for search.
2575
+ */
2576
+ description?: string;
2222
2577
  }
2223
2578
 
2224
2579
  /**
@@ -2245,6 +2600,21 @@ export interface Lake extends LakeSpec {
2245
2600
  * existing root; throws when the root is absent rather than creating it.
2246
2601
  */
2247
2602
  write: (path: string, contents: string) => void;
2603
+ /**
2604
+ * The guarded publication route for real lakes: lock, re-verify, and
2605
+ * publish the whole document — a no-overwrite create, or an atomic
2606
+ * whole-file replacement against `expectedRevision`. Legacy closure lakes
2607
+ * omit it and fall back to the plain `write` seam.
2608
+ */
2609
+ publish?: (
2610
+ path: string,
2611
+ contents: string,
2612
+ options: {
2613
+ mode: AddMode;
2614
+ expectedRevision?: string | undefined;
2615
+ label: string;
2616
+ },
2617
+ ) => { revision: string } | { reason: string };
2248
2618
  /**
2249
2619
  * Legacy seam notes only. Real lakes compute their environment notes at
2250
2620
  * call time — a root that appears, disappears or gains a git checkout is
@@ -2316,92 +2686,871 @@ function safeLakeTarget(root: string, path: string): string | null {
2316
2686
  }
2317
2687
 
2318
2688
  /**
2319
- * Attach the filesystem seams to a lake spec.
2320
- * @param spec The configured lake.
2321
- * @param options Runtime options.
2322
- * @param options.skip Absolute roots of other lakes, so a nested lake is not
2323
- * collected twice.
2324
- * @returns The lake the tool serves.
2689
+ * A held target lock. Release it on every ordinary exit; a lock left by an
2690
+ * interrupted process is never stolen automatically — the refusal names its
2691
+ * owner and the recovery is manual.
2325
2692
  */
2326
- export function createLake(
2327
- spec: LakeSpec,
2328
- options: { skip?: readonly string[] } = {},
2329
- ): Lake {
2330
- const guarded = (path: string): string => {
2331
- const target = safeLakeTarget(spec.root, path);
2332
- if (target === null) {
2333
- throw new UnsafePathError(path);
2693
+ interface TargetLock {
2694
+ /** Removes the lock file; best effort. */
2695
+ release: () => void;
2696
+ }
2697
+
2698
+ /**
2699
+ * The physical identity of a target: its deepest existing ancestor, real-pathed,
2700
+ * plus the exact remaining segments. Two configured roots that reach the same
2701
+ * tree by different routes resolve to one prefix and one tail, so their locks
2702
+ * and identities cannot silently diverge. The key is stable across the
2703
+ * target-or-parent-creation transition: real-pathing the existing prefix and
2704
+ * joining the missing tail yields the same absolute path before and after the
2705
+ * missing segments appear.
2706
+ * @param target The absolute target path.
2707
+ * @returns The existing real prefix and the missing tail.
2708
+ */
2709
+ function physicalRouteOf(target: string): {
2710
+ realPrefix: string;
2711
+ tail: readonly string[];
2712
+ } {
2713
+ const tail: string[] = [];
2714
+ let head = target;
2715
+ while (!existsSync(head)) {
2716
+ tail.unshift(basename(head));
2717
+ const parent = dirname(head);
2718
+ if (parent === head) {
2719
+ break;
2334
2720
  }
2335
- return target;
2336
- };
2337
- return {
2338
- ...spec,
2339
- linkCheck: true,
2340
- collect: () =>
2341
- collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
2342
- profile: spec.profile,
2343
- lake: spec.name,
2344
- class: spec.class,
2345
- skip: options.skip ?? [],
2346
- }),
2347
- read: (path) => readFileSync(guarded(path), "utf8"),
2348
- exists: (path) => {
2349
- const target = safeLakeTarget(spec.root, path);
2350
- return target !== null && existsSync(target);
2351
- },
2352
- write: (path, contents) => {
2353
- const target = guarded(path);
2354
- // The root is configured, never invented: a mistyped path must not
2355
- // bootstrap a lake. `add` pre-checks for a friendlier refusal.
2356
- if (!existsSync(spec.root)) {
2357
- throw new Error(
2358
- `lake '${spec.name}' root ${spec.root} does not exist; ` +
2359
- "create it first.",
2360
- );
2361
- }
2362
- mkdirSync(dirname(target), { recursive: true });
2363
- writeFileSync(target, contents);
2364
- },
2365
- };
2721
+ head = parent;
2722
+ }
2723
+ return { realPrefix: realpathSync(head), tail };
2366
2724
  }
2367
2725
 
2368
2726
  /**
2369
- * Build every configured lake, each aware of the others so a nested root is
2370
- * collected once. Nesting is decided on real paths, so a symlinked root cannot
2371
- * defeat containment: the skip paths handed to the walker are re-anchored
2372
- * under the collecting lake's configured root.
2373
- * @param specs The configured lakes.
2374
- * @returns The runtime lakes.
2727
+ * The canonical on-disk identity of a target: its resolved existing prefix
2728
+ * plus the missing tail joined. Every configured route to the same file
2729
+ * produces this one string.
2730
+ * @param target The absolute target path.
2731
+ * @returns The real-path key.
2375
2732
  */
2376
- export function createLakes(specs: readonly LakeSpec[]): Lake[] {
2377
- const realRoots = specs.map((spec) => realPathOf(spec.root));
2378
- return specs.map((spec, index) => {
2379
- const own = realRoots[index] ?? spec.root;
2380
- const skip = specs.flatMap((other, otherIndex) => {
2381
- if (otherIndex === index) {
2382
- return [];
2383
- }
2384
- const otherRoot = realRoots[otherIndex] ?? other.root;
2385
- return otherRoot !== own && isUnder(otherRoot, own)
2386
- ? [join(spec.root, relative(own, otherRoot))]
2387
- : [];
2388
- });
2389
- return createLake(spec, { skip });
2390
- });
2733
+ export function physicalTargetKey(target: string): string {
2734
+ const { realPrefix, tail } = physicalRouteOf(target);
2735
+ return join(realPrefix, ...tail);
2391
2736
  }
2392
2737
 
2393
- /** The lake classes the configuration accepts. */
2394
- export const LAKE_CLASSES = ["repo", "machine", "global", "wiki"] as const;
2738
+ /**
2739
+ * The lock directory for a target: a hidden `.docs-lock` inside the deepest
2740
+ * existing ancestor that can hold it — the target's own directory once the
2741
+ * target exists, its existing parent otherwise. Siting the lock beside the
2742
+ * physical target keeps all configured routes (and separately configured
2743
+ * processes) on one directory: aliasing roots resolve the same real prefix,
2744
+ * and nothing about the lock location depends on the current runtime's lake
2745
+ * list. Search and `check` skip dot-directories at every level, so the lock
2746
+ * directory is never mistaken for content.
2747
+ * @param target The absolute target path.
2748
+ * @returns The lock directory path.
2749
+ */
2750
+ export function targetLockDirectory(target: string): string {
2751
+ const { realPrefix, tail } = physicalRouteOf(target);
2752
+ // The existing prefix is real-pathed already: for an existing target it
2753
+ // is the file itself (lock beside it), for a missing target it is the
2754
+ // deepest existing ancestor directory.
2755
+ return join(
2756
+ tail.length === 0 ? dirname(realPrefix) : realPrefix,
2757
+ ".docs-lock",
2758
+ );
2759
+ }
2395
2760
 
2396
- /** The repo-local lake additions file, relative to the location. */
2397
- export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
2761
+ /**
2762
+ * The lock file for a target: keyed by the target's canonical physical key and
2763
+ * sited beside it (see {@link targetLockDirectory}). Exported for diagnostics
2764
+ * and tests: refusals name the manual recovery path, and tests must consult
2765
+ * the real protocol's location rather than re-deriving it.
2766
+ * @param target The absolute target path.
2767
+ * @returns The lock file path.
2768
+ */
2769
+ export function targetLockPath(target: string): string {
2770
+ return join(
2771
+ targetLockDirectory(target),
2772
+ `${createHash("sha256")
2773
+ .update(physicalTargetKey(target), "utf8")
2774
+ .digest("hex")}.lock`,
2775
+ );
2776
+ }
2398
2777
 
2399
- /** The plugin's options, as the host delivers them. */
2400
- export interface DocsPluginOptions {
2401
- /** The single repo-lake override; mutually exclusive with `lakes`. */
2778
+ /**
2779
+ * Prepare the lock directory without touching anything but what is ours: an
2780
+ * existing directory is kept, any other object (including a symlink) refuses
2781
+ * without being read, followed, truncated or removed, and a missing directory
2782
+ * is created one level inside the already-existing ancestor. Bounded — one
2783
+ * creation attempt, then a post-EEXIST re-stat; no spin, no cleanup of
2784
+ * foreign objects.
2785
+ * @param lockDir The computed lock directory.
2786
+ * @returns undefined when the directory is usable, or the refusal reason.
2787
+ */
2788
+ function ensureLockDirectory(lockDir: string): { error: string } | undefined {
2789
+ try {
2790
+ const stats = lstatSync(lockDir);
2791
+ if (stats.isDirectory()) {
2792
+ return undefined;
2793
+ }
2794
+ return {
2795
+ error:
2796
+ `the lock directory ${lockDir} is occupied by a ` +
2797
+ `${stats.isSymbolicLink() ? "symlink" : "non-directory"}; ` +
2798
+ "nothing was written. Remove or repair it deliberately.",
2799
+ };
2800
+ } catch {
2801
+ // Absent — create it, contended creation tolerated below.
2802
+ }
2803
+ try {
2804
+ mkdirSync(lockDir);
2805
+ return undefined;
2806
+ } catch (error) {
2807
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
2808
+ return {
2809
+ error: `could not create the lock directory ${lockDir}: ${String(error)}.`,
2810
+ };
2811
+ }
2812
+ try {
2813
+ return lstatSync(lockDir).isDirectory()
2814
+ ? undefined
2815
+ : {
2816
+ error: `the lock directory ${lockDir} appeared and is not a directory.`,
2817
+ };
2818
+ } catch {
2819
+ return {
2820
+ error: `the lock directory ${lockDir} vanished while locking.`,
2821
+ };
2822
+ }
2823
+ }
2824
+ }
2825
+
2826
+ /**
2827
+ * Acquire a target's publication lock with an exclusive create. A held lock
2828
+ * refuses busy with its recorded owner; it is never stolen, however old —
2829
+ * an interrupted process is recovered by a human confirming the owner is
2830
+ * gone and removing the file.
2831
+ * @param target The absolute target path.
2832
+ * @returns The held lock, or the busy reason.
2833
+ */
2834
+ function acquireTargetLock(target: string): TargetLock | { busy: string } {
2835
+ const lockPath = targetLockPath(target);
2836
+ const problem = ensureLockDirectory(dirname(lockPath));
2837
+ if (problem !== undefined) {
2838
+ return { busy: problem.error };
2839
+ }
2840
+ try {
2841
+ const fd = openSync(lockPath, "wx");
2842
+ try {
2843
+ writeFullBytes(
2844
+ fd,
2845
+ JSON.stringify({
2846
+ pid: process.pid,
2847
+ acquiredAt: new Date().toISOString(),
2848
+ }),
2849
+ );
2850
+ } finally {
2851
+ closeSync(fd);
2852
+ }
2853
+ return {
2854
+ release: (): void => {
2855
+ try {
2856
+ unlinkSync(lockPath);
2857
+ } catch {
2858
+ // Best effort: a later refusal names the surviving lock.
2859
+ }
2860
+ },
2861
+ };
2862
+ } catch (error) {
2863
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
2864
+ throw error;
2865
+ }
2866
+ let owner = "details unreadable";
2867
+ try {
2868
+ const raw = readFileSync(lockPath, "utf8").trim();
2869
+ if (raw !== "") {
2870
+ owner = raw;
2871
+ }
2872
+ } catch {
2873
+ // Keep the fallback text.
2874
+ }
2875
+ return {
2876
+ busy:
2877
+ `another writer holds the target lock (${owner}); retry ` +
2878
+ `later, or after confirming that process is gone remove ` +
2879
+ `${lockPath}.`,
2880
+ };
2881
+ }
2882
+ }
2883
+
2884
+ /**
2885
+ * Write the whole byte sequence through an open descriptor. A short write is
2886
+ * looped — regular files rarely short-write, but nothing here reports a
2887
+ * revision for bytes it did not fully store: a mid-loop failure propagates
2888
+ * and the publication never renames.
2889
+ * @param fd The descriptor to write through, positioned by the OS.
2890
+ * @param contents The full serialised document.
2891
+ * @param write The single write primitive; injected for short-write tests.
2892
+ */
2893
+ export function writeFullBytes(
2894
+ fd: number,
2895
+ contents: string,
2896
+ write: typeof writeSync = writeSync,
2897
+ ): void {
2898
+ const buffer = Buffer.from(contents, "utf8");
2899
+ let offset = 0;
2900
+ while (offset < buffer.length) {
2901
+ const written = write(fd, buffer, offset, buffer.length - offset);
2902
+ if (typeof written !== "number" || written <= 0) {
2903
+ throw new Error(
2904
+ `write stalled after ${offset} of ${buffer.length} bytes`,
2905
+ );
2906
+ }
2907
+ offset += written;
2908
+ }
2909
+ }
2910
+
2911
+ /** A temp file this operation exclusively created and owns. */
2912
+ interface OwnedTemp {
2913
+ /** The exclusive-create descriptor; all writes go through it. */
2914
+ fd: number;
2915
+ /** The temp's path, for the atomic rename. */
2916
+ path: string;
2917
+ }
2918
+
2919
+ /**
2920
+ * The exclusive temp-name selection seam: create one new regular file at a
2921
+ * name this operation owns, or throw `EEXIST` when a foreign object already
2922
+ * occupies the candidate. It is never reopened by pathname for writing — the
2923
+ * returned descriptor is the only write route — and a pre-existing symlink,
2924
+ * regular file or directory is never followed, truncated or removed.
2925
+ */
2926
+ export type TempFactory = (target: string) => OwnedTemp;
2927
+
2928
+ /** The attempts made on distinct exclusive temp names before giving up. */
2929
+ export const MAX_TEMP_ATTEMPTS = 8;
2930
+
2931
+ /** The module-level temp-file counter: unique publication temps per process. */
2932
+ let publishTempCounter = 0;
2933
+
2934
+ /**
2935
+ * The default temp selection: `${target}.${pid}.${counter}.tmp` created
2936
+ * exclusively. A pre-existing object at a candidate name — including a
2937
+ * symlink seeded in advance — makes the create fail without touching it, and
2938
+ * the retry loop below moves to the next fresh name. The counter never
2939
+ * repeats within a process, so ordinary contention cannot exhaust the bound.
2940
+ * @param target The absolute target path.
2941
+ * @returns The owned temp descriptor and path.
2942
+ */
2943
+ const defaultTempFactory: TempFactory = (target) => {
2944
+ const path = `${target}.${process.pid}.${publishTempCounter++}.tmp`;
2945
+ const fd = openSync(path, "wx");
2946
+ return { fd, path };
2947
+ };
2948
+
2949
+ /**
2950
+ * The publication seams: the narrow, injectable primitives the guarded
2951
+ * publication uses, exercised by default against the real filesystem. Tests
2952
+ * inject them to make collisions and mid-publication failures deterministic;
2953
+ * production code never reopens a claimed temp, and every seam default
2954
+ * satisfies the exclusive-create and full-write contracts.
2955
+ */
2956
+ export interface PublicationSeams {
2957
+ /** Exclusive temp selection; defaults to {@link defaultTempFactory}. */
2958
+ temp?: TempFactory;
2959
+ /** The full-write primitive; defaults to {@link writeFullBytes}. */
2960
+ writeFull?: (fd: number, contents: string) => void;
2961
+ /** The atomic rename; defaults to `renameSync`. */
2962
+ rename?: (temp: string, target: string) => void;
2963
+ /** Stage observation for coordination and failure injection. */
2964
+ onStage?: (stage: PublicationStage) => void;
2965
+ }
2966
+
2967
+ /** The observations a guarded publication announces. */
2968
+ export type PublicationStage =
2969
+ | "locked"
2970
+ | "verified"
2971
+ | /** create only: the target is exclusively open, bytes not yet written */ "targetOpen"
2972
+ | "published";
2973
+
2974
+ /**
2975
+ * The bounded retry over exclusive temp names: each attempt is a fresh name
2976
+ * the factory must exclusively create; an `EEXIST` retry is bounded and never
2977
+ * touches the foreign object it collided with.
2978
+ * @param make The temp seam in use.
2979
+ * @param target The absolute target path, for the candidate names.
2980
+ * @returns An owned temp, or null when every attempt collided.
2981
+ */
2982
+ function acquireTemp(make: TempFactory, target: string): OwnedTemp | null {
2983
+ for (let remaining = MAX_TEMP_ATTEMPTS; remaining > 0; remaining -= 1) {
2984
+ try {
2985
+ return { ...make(target) };
2986
+ } catch (error) {
2987
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
2988
+ throw error;
2989
+ }
2990
+ }
2991
+ }
2992
+ return null;
2993
+ }
2994
+
2995
+ /**
2996
+ * Verify the replace contract while the target lock is held: the target must
2997
+ * be a regular file (a vanished target is a conflict, never an implicit
2998
+ * create) and its current bytes must match the caller's token, so two
2999
+ * differently routed replace calls verify the same physical bytes under the
3000
+ * same lock.
3001
+ * @param target The absolute, guarded target path.
3002
+ * @param options Publication options.
3003
+ * @param options.expectedRevision The replace's stale-write token.
3004
+ * @param options.label The qualified target label, for refusals.
3005
+ * @returns null when the write may proceed, else the refusal reason.
3006
+ */
3007
+ function verifyUnderLock(
3008
+ target: string,
3009
+ options: { expectedRevision?: string | undefined; label: string },
3010
+ ): string | null {
3011
+ let present: boolean;
3012
+ try {
3013
+ present = lstatSync(target).isFile();
3014
+ } catch {
3015
+ present = false;
3016
+ }
3017
+ if (!present) {
3018
+ return (
3019
+ `${REFUSE} no doc at ${options.label} — replace never ` +
3020
+ 'creates; pass mode:"create" for a new doc.'
3021
+ );
3022
+ }
3023
+ const found = revisionOf(readFileSync(target, "utf8"));
3024
+ if (found !== options.expectedRevision) {
3025
+ const short = (value: string | undefined): string =>
3026
+ (value ?? "none").slice(0, 12);
3027
+ return (
3028
+ `${REFUSE} ${options.label} changed since you read it ` +
3029
+ `(expected ${short(options.expectedRevision)}, found ` +
3030
+ `${short(found)}). Read it again, reconcile, and ` +
3031
+ "resubmit the replacement deliberately."
3032
+ );
3033
+ }
3034
+ return null;
3035
+ }
3036
+
3037
+ /**
3038
+ * The refusal for a caught publication error, or null when the error is not
3039
+ * one the caller should see as a refusal: a losing create's EEXIST, or an
3040
+ * unwritable lake's EACCES/EPERM. Everything else propagates.
3041
+ * @param label The qualified target label, for refusals.
3042
+ * @param mode The mutation mode the write follows.
3043
+ * @param error The caught error.
3044
+ * @returns The refusal reason, or null to rethrow.
3045
+ */
3046
+ function publicationErrorRefusal(
3047
+ label: string,
3048
+ mode: AddMode,
3049
+ error: unknown,
3050
+ ): string | null {
3051
+ const code = (error as NodeJS.ErrnoException).code;
3052
+ if (mode === "create" && code === "EEXIST") {
3053
+ return (
3054
+ `${REFUSE} ${label} already exists — read it and pass ` +
3055
+ 'mode:"replace" to overwrite it deliberately.'
3056
+ );
3057
+ }
3058
+ if (code === "EACCES" || code === "EPERM") {
3059
+ return (
3060
+ `${REFUSE} could not publish ${label}: ${String(error)}. ` +
3061
+ "The lake or its directories are not writable by this process."
3062
+ );
3063
+ }
3064
+ return null;
3065
+ }
3066
+
3067
+ /**
3068
+ * The publication state/ownership sketch (one replace call):
3069
+ *
3070
+ * acquire lock exclusive create beside the physical target
3071
+ * onStage locked
3072
+ * verify current bytes re-read under the lock (stale refusal)
3073
+ * onStage verified
3074
+ * create temp exclusive create beside the target; owned fd retained
3075
+ * write full through the fd only; short writes loop or fail
3076
+ * rename atomic temp → target; the file published is ours
3077
+ * onStage published
3078
+ * release lock
3079
+ * cleanup duties: on any ordinary failure, unlink ONLY the temp this
3080
+ * operation created; the lock release in the outer finally; never a
3081
+ * foreign temp, lock or document path.
3082
+ *
3083
+ * The guarantee covers cooperating plugin writers; a manual editor can still
3084
+ * race any filesystem operation, and a local attacker able to swap
3085
+ * directories between individual syscalls is outside this threat model
3086
+ * (documented, not fought with privileged machinery).
3087
+ * @param spec The lake spec.
3088
+ * @param target The absolute, guarded target path.
3089
+ * @param contents The serialised file contents.
3090
+ * @param options Publication options.
3091
+ * @param options.mode The mutation mode.
3092
+ * @param options.expectedRevision The replace's stale-write token.
3093
+ * @param options.label The qualified target label, for refusals.
3094
+ * @param options.seams The injectable publication primitives.
3095
+ * @returns The new revision, or the refusal reason.
3096
+ */
3097
+ /** The resources one guarded publication has claimed so far. */
3098
+ interface PublicationLedger {
3099
+ /** The temp this operation exclusively created, while unpublished. */
3100
+ owned: OwnedTemp | null;
3101
+ /** Whether the submitted bytes are durably at the target. */
3102
+ published: boolean;
3103
+ /** Whether the target file is this operation's own `wx` create. */
3104
+ createdTarget: boolean;
3105
+ }
3106
+
3107
+ /**
3108
+ * The guarded create: exclusive open, full write through the descriptor, no
3109
+ * overwrite. Sets the ledger on the way through; throws on failure so the
3110
+ * caller's cleanup owns the partial file.
3111
+ * @param target The absolute, guarded target path.
3112
+ * @param contents The serialised file contents.
3113
+ * @param ledger The claimed resources, updated in place.
3114
+ * @param writeFull The full-write primitive.
3115
+ * @param onStage Stage observation.
3116
+ */
3117
+ function publishCreate(
3118
+ target: string,
3119
+ contents: string,
3120
+ ledger: PublicationLedger,
3121
+ writeFull: (fd: number, contents: string) => void,
3122
+ onStage: (stage: PublicationStage) => void,
3123
+ ): void {
3124
+ mkdirSync(dirname(target), { recursive: true });
3125
+ const fd = openSync(target, "wx");
3126
+ // From here the file at the target is this operation's own exclusive
3127
+ // create: a failure owns and removes it, never a rival's `EEXIST` loss.
3128
+ ledger.createdTarget = true;
3129
+ // Deliberately observable window: a create's guarantees are exclusive
3130
+ // naming and eventually-complete bytes, not the replacement path's
3131
+ // all-or-nothing swap — "targetOpen" marks the readers-see-it-first state
3132
+ // for coordination and tests.
3133
+ onStage("targetOpen");
3134
+ try {
3135
+ writeFull(fd, contents);
3136
+ } finally {
3137
+ closeSync(fd);
3138
+ }
3139
+ // The whole sequence succeeded: the no-overwrite create contract, now
3140
+ // with a full-write guarantee.
3141
+ ledger.published = true;
3142
+ onStage("published");
3143
+ }
3144
+
3145
+ /**
3146
+ * The guarded replace under the held lock: re-verify the token, exclusively
3147
+ * create a fresh temp, write the whole sequence through its descriptor and
3148
+ * swap atomically.
3149
+ * @param target The absolute, guarded target path.
3150
+ * @param contents The serialised file contents.
3151
+ * @param ledger The claimed resources, updated in place.
3152
+ * @param options Publication options.
3153
+ * @param options.expectedRevision The replace's stale-write token.
3154
+ * @param options.label The qualified target label, for refusals.
3155
+ * @param seams The publication seams.
3156
+ * @returns null when the bytes are published, else the refusal reason.
3157
+ */
3158
+ function publishReplace(
3159
+ target: string,
3160
+ contents: string,
3161
+ ledger: PublicationLedger,
3162
+ options: { expectedRevision?: string | undefined; label: string },
3163
+ seams: PublicationSeams,
3164
+ ): string | null {
3165
+ const refusal = verifyUnderLock(target, options);
3166
+ if (refusal !== null) {
3167
+ return refusal;
3168
+ }
3169
+ seams.onStage?.("verified");
3170
+ const temp = acquireTemp(seams.temp ?? defaultTempFactory, target);
3171
+ if (temp === null) {
3172
+ return (
3173
+ `${REFUSE} could not publish ${options.label}: every ` +
3174
+ "candidate temp name was occupied by an object this " +
3175
+ "operation does not own; nothing was changed."
3176
+ );
3177
+ }
3178
+ ledger.owned = temp;
3179
+ try {
3180
+ (seams.writeFull ?? writeFullBytes)(temp.fd, contents);
3181
+ } finally {
3182
+ closeSync(temp.fd);
3183
+ }
3184
+ (seams.rename ?? renameSync)(temp.path, target);
3185
+ // From here the temp path no longer holds our bytes.
3186
+ ledger.owned = null;
3187
+ ledger.published = true;
3188
+ seams.onStage?.("published");
3189
+ return null;
3190
+ }
3191
+
3192
+ /**
3193
+ * The publication state/ownership sketch (one call):
3194
+ *
3195
+ * acquire lock exclusive create beside the physical target
3196
+ * onStage locked
3197
+ * verify replace: current bytes re-read under the lock
3198
+ * onStage verified
3199
+ * open target create: `wx` open, this operation's file, no bytes yet
3200
+ * onStage targetOpen (create only — readers can observe the empty file)
3201
+ * write full through the fd only; short writes loop or fail
3202
+ * rename replace: atomic temp → target; the file published is ours
3203
+ * onStage published
3204
+ * release lock
3205
+ * cleanup duties: on any ordinary failure, unlink ONLY the temp this
3206
+ * operation created (and its own partial create target); the lock release
3207
+ * in the outer finally; never a foreign temp, lock or document path.
3208
+ *
3209
+ * The guarantee covers cooperating plugin writers; a manual editor can still
3210
+ * race any filesystem operation, and a local attacker able to swap
3211
+ * directories between individual syscalls is outside this threat model
3212
+ * (documented, not fought with privileged machinery).
3213
+ * @param target The absolute, guarded target path. (The lake's identity is
3214
+ * implied by the guard.)
3215
+ * @param contents The serialised file contents.
3216
+ * @param options Publication options.
3217
+ * @param options.mode The mutation mode.
3218
+ * @param options.expectedRevision The replace's stale-write token.
3219
+ * @param options.label The qualified target label, for refusals.
3220
+ * @param options.seams The injectable publication primitives.
3221
+ * @returns The new revision, or the refusal reason.
3222
+ */
3223
+ function publishTarget(
3224
+ target: string,
3225
+ contents: string,
3226
+ options: {
3227
+ mode: AddMode;
3228
+ expectedRevision?: string | undefined;
3229
+ label: string;
3230
+ seams?: PublicationSeams;
3231
+ },
3232
+ ): { revision: string } | { reason: string } {
3233
+ const seams = options.seams ?? {};
3234
+ const lock = acquireTargetLock(target);
3235
+ if ("busy" in lock) {
3236
+ return { reason: `${REFUSE} ${lock.busy}` };
3237
+ }
3238
+ // The resource ledger: only the temp this operation exclusively created
3239
+ // is ever unlinked (and only while unpublished), and only a target file
3240
+ // this operation created itself is removed on a create failure — never a
3241
+ // rival's `EEXIST` loss.
3242
+ const ledger: PublicationLedger = {
3243
+ owned: null,
3244
+ published: false,
3245
+ createdTarget: false,
3246
+ };
3247
+ const onStage = (stage: PublicationStage): void => {
3248
+ seams.onStage?.(stage);
3249
+ };
3250
+ try {
3251
+ onStage("locked");
3252
+ if (options.mode === "create") {
3253
+ publishCreate(
3254
+ target,
3255
+ contents,
3256
+ ledger,
3257
+ seams.writeFull ?? writeFullBytes,
3258
+ onStage,
3259
+ );
3260
+ } else {
3261
+ const refusal = publishReplace(
3262
+ target,
3263
+ contents,
3264
+ ledger,
3265
+ options,
3266
+ seams,
3267
+ );
3268
+ if (refusal !== null) {
3269
+ return { reason: refusal };
3270
+ }
3271
+ }
3272
+ return { revision: revisionOf(contents) };
3273
+ } catch (error) {
3274
+ const handled = publicationFailure(target, ledger, options, error);
3275
+ if (handled !== null) {
3276
+ return { reason: handled };
3277
+ }
3278
+ throw error;
3279
+ } finally {
3280
+ if (ledger.owned !== null) {
3281
+ try {
3282
+ unlinkSync(ledger.owned.path);
3283
+ } catch {
3284
+ // Owned temp may already be gone with its rename.
3285
+ }
3286
+ }
3287
+ lock.release();
3288
+ }
3289
+ }
3290
+
3291
+ /**
3292
+ * Handle one caught publication error: remove the partial file this
3293
+ * operation created itself, translate the mapped refusal codes, and leave
3294
+ * everything else for the caller to rethrow.
3295
+ * @param target The absolute, guarded target path.
3296
+ * @param ledger The claimed resources.
3297
+ * @param options Publication options.
3298
+ * @param options.label The qualified target label, for refusals.
3299
+ * @param options.mode The mutation mode the write follows.
3300
+ * @param error The caught error.
3301
+ * @returns The refusal reason, or null to rethrow.
3302
+ */
3303
+ function publicationFailure(
3304
+ target: string,
3305
+ ledger: PublicationLedger,
3306
+ options: { label: string; mode: AddMode },
3307
+ error: unknown,
3308
+ ): string | null {
3309
+ if (ledger.createdTarget && !ledger.published) {
3310
+ // A create that opened `wx` then failed owns its own partial file;
3311
+ // remove it, and never the rival's.
3312
+ try {
3313
+ unlinkSync(target);
3314
+ } catch {
3315
+ // Best effort; the failure already reports itself.
3316
+ }
3317
+ }
3318
+ return publicationErrorRefusal(options.label, options.mode, error);
3319
+ }
3320
+
3321
+ /** One other configured lake, for publishing's physical containment checks. */
3322
+ export interface Containment {
3323
+ /** The other lake's identity and writability. */
3324
+ lake: { name: string; root: string; writable: boolean };
3325
+ /** The other lake's real (symlink-resolved) root. */
3326
+ realRoot: string;
3327
+ }
3328
+
3329
+ /**
3330
+ * The refusal for a public write whose physical target reaches a nested
3331
+ * lake's tree through the selected lake's route, or null. The owner is the
3332
+ * deepest enclosing configured lake: addressing content physically inside
3333
+ * another lake's root through an outer alias would mutate a subtree the outer
3334
+ * lake never indexes, and a read-only owner's tree must not become writable
3335
+ * by reaching it through a writable outer lake. Naming the correct owner
3336
+ * leaves the caller one explicit re-address away.
3337
+ * @param spec The selected lake being published through.
3338
+ * @param target The absolute, guarded target path.
3339
+ * @param others The other configured lakes' real roots.
3340
+ * @returns The refusal reason, or null when the selected lake owns the tree.
3341
+ */
3342
+ function containmentRefusal(
3343
+ spec: LakeSpec,
3344
+ target: string,
3345
+ others: readonly Containment[],
3346
+ ): string | null {
3347
+ const own = realPathOf(spec.root);
3348
+ const key = physicalTargetKey(target);
3349
+ let deepest: Containment | undefined;
3350
+ for (const other of others) {
3351
+ if (other.realRoot === own) {
3352
+ continue;
3353
+ }
3354
+ if (
3355
+ isUnder(other.realRoot, own) &&
3356
+ isUnder(key, other.realRoot) &&
3357
+ (deepest === undefined || isUnder(other.realRoot, deepest.realRoot))
3358
+ ) {
3359
+ deepest = other;
3360
+ }
3361
+ }
3362
+ if (deepest === undefined) {
3363
+ return null;
3364
+ }
3365
+ const access = deepest.lake.writable
3366
+ ? `address it through lake:'${deepest.lake.name}' so it lands in the tree that indexes that address`
3367
+ : `its owner lake '${deepest.lake.name}' is read-only, so this content cannot be written`;
3368
+ return (
3369
+ `${REFUSE} the addressed file physically resolves inside lake ` +
3370
+ `'${deepest.lake.name}' (${deepest.lake.root}; ` +
3371
+ `${deepest.lake.writable ? "writable" : "read-only"}) — ${access}.`
3372
+ );
3373
+ }
3374
+
3375
+ /**
3376
+ * Attach the filesystem seams to a lake spec.
3377
+ * @param spec The configured lake.
3378
+ * @param options Runtime options.
3379
+ * @param options.skip Absolute roots of other lakes, so a nested lake is not
3380
+ * collected twice.
3381
+ * @param options.containment The other lakes' real roots, for the publish
3382
+ * containment guard; omitted by direct callers that carry one lake only.
3383
+ * @param options.seams Injectable publication primitives; the real
3384
+ * filesystem defaults are used when absent, and tests inject them to make
3385
+ * collisions and failures deterministic.
3386
+ * @returns The lake the tool serves.
3387
+ */
3388
+ export function createLake(
3389
+ spec: LakeSpec,
3390
+ options: {
3391
+ skip?: readonly string[];
3392
+ containment?: readonly Containment[];
3393
+ seams?: PublicationSeams;
3394
+ } = {},
3395
+ ): Lake {
3396
+ const guarded = (path: string): string => {
3397
+ const target = safeLakeTarget(spec.root, path);
3398
+ if (target === null) {
3399
+ throw new UnsafePathError(path);
3400
+ }
3401
+ return target;
3402
+ };
3403
+ return {
3404
+ ...spec,
3405
+ linkCheck: true,
3406
+ collect: () =>
3407
+ collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
3408
+ profile: spec.profile,
3409
+ lake: spec.name,
3410
+ class: spec.class,
3411
+ skip: options.skip ?? [],
3412
+ }),
3413
+ read: (path) => readFileSync(guarded(path), "utf8"),
3414
+ exists: (path) => {
3415
+ const target = safeLakeTarget(spec.root, path);
3416
+ return target !== null && existsSync(target);
3417
+ },
3418
+ write: (path, contents) => {
3419
+ const target = guarded(path);
3420
+ // The root is configured, never invented: a mistyped path must not
3421
+ // bootstrap a lake. `add` pre-checks for a friendlier refusal.
3422
+ if (!existsSync(spec.root)) {
3423
+ throw new Error(
3424
+ `lake '${spec.name}' root ${spec.root} does not exist; ` +
3425
+ "create it first.",
3426
+ );
3427
+ }
3428
+ mkdirSync(dirname(target), { recursive: true });
3429
+ writeFileSync(target, contents);
3430
+ },
3431
+ publish: (path, contents, publishOptions) => {
3432
+ const target = guarded(path);
3433
+ if (!existsSync(spec.root)) {
3434
+ return {
3435
+ reason: `lake '${spec.name}' root ${spec.root} does not exist; create it first.`,
3436
+ };
3437
+ }
3438
+ if (options.containment !== undefined) {
3439
+ const refusal = containmentRefusal(
3440
+ spec,
3441
+ target,
3442
+ options.containment,
3443
+ );
3444
+ if (refusal !== null) {
3445
+ return { reason: refusal };
3446
+ }
3447
+ }
3448
+ return publishTarget(target, contents, {
3449
+ ...publishOptions,
3450
+ ...(options.seams !== undefined
3451
+ ? { seams: options.seams }
3452
+ : {}),
3453
+ });
3454
+ },
3455
+ };
3456
+ }
3457
+
3458
+ /**
3459
+ * Build every configured lake, each aware of the others so a nested root is
3460
+ * collected once and never published through an outer alias. Nesting is
3461
+ * decided on real paths, so a symlinked root cannot defeat containment: the
3462
+ * skip paths handed to the walker are re-anchored under the collecting lake's
3463
+ * configured root.
3464
+ * @param specs The configured lakes.
3465
+ * @param options Runtime options.
3466
+ * @param options.seams The publication seams observed by every lake's
3467
+ * guarded publication.
3468
+ * @returns The runtime lakes.
3469
+ */
3470
+ export function createLakes(
3471
+ specs: readonly LakeSpec[],
3472
+ options: { seams?: PublicationSeams } = {},
3473
+ ): Lake[] {
3474
+ const realRoots = specs.map((spec) => realPathOf(spec.root));
3475
+ return specs.map((spec, index) => {
3476
+ const own = realRoots[index] ?? spec.root;
3477
+ const skip = specs.flatMap((other, otherIndex) => {
3478
+ if (otherIndex === index) {
3479
+ return [];
3480
+ }
3481
+ const otherRoot = realRoots[otherIndex] ?? other.root;
3482
+ return otherRoot !== own && isUnder(otherRoot, own)
3483
+ ? [join(spec.root, relative(own, otherRoot))]
3484
+ : [];
3485
+ });
3486
+ const containment: Containment[] = specs.flatMap(
3487
+ (other, otherIndex) => {
3488
+ if (otherIndex === index) {
3489
+ return [];
3490
+ }
3491
+ return [
3492
+ {
3493
+ lake: {
3494
+ name: other.name,
3495
+ root: other.root,
3496
+ writable: other.writable,
3497
+ },
3498
+ realRoot: realRoots[otherIndex] ?? other.root,
3499
+ },
3500
+ ];
3501
+ },
3502
+ );
3503
+ return createLake(spec, {
3504
+ skip,
3505
+ ...(containment.length > 0 ? { containment } : {}),
3506
+ ...(options.seams !== undefined ? { seams: options.seams } : {}),
3507
+ });
3508
+ });
3509
+ }
3510
+
3511
+ /** The lake classes the configuration accepts. */
3512
+ export const LAKE_CLASSES = ["repo", "machine", "global", "wiki"] as const;
3513
+
3514
+ /**
3515
+ * The human-facing scope label per class, shown beside the config value in
3516
+ * help and candidate lists so agents can connect the two. Configuration
3517
+ * values never change: `repo`, `machine`, `global`, `wiki`.
3518
+ */
3519
+ export const CLASS_LABELS: Readonly<Record<LakeClass, string>> = {
3520
+ repo: "Repository",
3521
+ machine: "Personal",
3522
+ global: "Organisation",
3523
+ wiki: "Wiki",
3524
+ };
3525
+
3526
+ /**
3527
+ * The ownership guidance for a class, used when a lake carries no configured
3528
+ * description. Generic by design: domain-specific ownership is never inferred
3529
+ * from a path or a name.
3530
+ * @param lakeClass The lake's class.
3531
+ * @returns The concise default guidance.
3532
+ */
3533
+ export function classGuidance(lakeClass: LakeClass): string {
3534
+ const guidance: Record<LakeClass, string> = {
3535
+ repo: "knowledge needed to understand or maintain this repository",
3536
+ machine: "knowledge tied to one person's devices, accounts and setup",
3537
+ global: "organisation-wide standards, services and integrations",
3538
+ wiki: "human-facing reference and narrative",
3539
+ };
3540
+ return guidance[lakeClass];
3541
+ }
3542
+
3543
+ /** The repo-local lake additions file, relative to the location. */
3544
+ export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
3545
+
3546
+ /** The plugin's options, as the host delivers them. */
3547
+ export interface DocsPluginOptions {
3548
+ /** The single repo-lake override; mutually exclusive with `lakes`. */
2402
3549
  root?: unknown;
2403
3550
  /** The `root` lake's name; defaults to the root folder name. Needs `root`. */
2404
3551
  name?: unknown;
3552
+ /** The `root` lake's description; what belongs there. Needs `root`. */
3553
+ description?: unknown;
2405
3554
  /** Base lake declarations, merged after `lakesFile` and the repo file. */
2406
3555
  lakes?: unknown;
2407
3556
  /** A JSON file of base lakes (`{ "lakes": [...] }`). */
@@ -2511,6 +3660,7 @@ interface RawLake {
2511
3660
  root?: unknown;
2512
3661
  name?: unknown;
2513
3662
  writable?: unknown;
3663
+ description?: unknown;
2514
3664
  }
2515
3665
 
2516
3666
  /** Narrow an unknown lake declaration to its raw shape. */
@@ -2629,6 +3779,27 @@ function lakeWritableOf(
2629
3779
  return writable;
2630
3780
  }
2631
3781
 
3782
+ /**
3783
+ * Resolve a lake description: trimmed when present, absent when blank. A
3784
+ * non-string fails loudly — a mistyped description must not become lake prose.
3785
+ * @param entry The raw declaration.
3786
+ * @param name The resolved lake name, for the error.
3787
+ * @returns The trimmed description, or undefined when absent.
3788
+ */
3789
+ function lakeDescriptionOf(entry: RawLake, name: string): string | undefined {
3790
+ if (entry.description === undefined) {
3791
+ return undefined;
3792
+ }
3793
+ if (typeof entry.description !== "string") {
3794
+ throw new Error(
3795
+ `docs plugin lake '${name}' description must be a string, ` +
3796
+ `got ${JSON.stringify(entry.description)}.`,
3797
+ );
3798
+ }
3799
+ const trimmed = entry.description.trim();
3800
+ return trimmed === "" ? undefined : trimmed;
3801
+ }
3802
+
2632
3803
  /**
2633
3804
  * Validate one raw lake declaration and resolve its defaults.
2634
3805
  * @param raw The raw entry from a config file or inline option.
@@ -2646,12 +3817,14 @@ function lakeSpecFrom(
2646
3817
  const lakeClass = lakeClassOf(entry, fromRepoFile);
2647
3818
  const root = lakeRootOf(entry, lakeClass, location, fromRepoFile);
2648
3819
  const name = lakeNameOf(entry, root, lakeClass);
3820
+ const description = lakeDescriptionOf(entry, name);
2649
3821
  return {
2650
3822
  name,
2651
3823
  class: lakeClass,
2652
3824
  root,
2653
3825
  profile: lakeClass === "wiki" ? "wiki" : "knowledge",
2654
3826
  writable: lakeWritableOf(entry, lakeClass, name),
3827
+ ...(description !== undefined ? { description } : {}),
2655
3828
  };
2656
3829
  }
2657
3830
 
@@ -2684,6 +3857,12 @@ function assertLakesOptions(options: DocsPluginOptions): void {
2684
3857
  "set 'name' inside a 'lakes' entry instead.",
2685
3858
  );
2686
3859
  }
3860
+ if (options.description !== undefined && options.root === undefined) {
3861
+ throw new Error(
3862
+ "docs plugin option 'description' describes the 'root' lake; " +
3863
+ "set 'description' inside a 'lakes' entry instead.",
3864
+ );
3865
+ }
2687
3866
  }
2688
3867
 
2689
3868
  /** Map raw lake entries from a file to specs. */
@@ -2760,7 +3939,12 @@ export function resolveLakes(
2760
3939
  if (options.root !== undefined) {
2761
3940
  specs.push(
2762
3941
  lakeSpecFrom(
2763
- { class: "repo", root: options.root, name: options.name },
3942
+ {
3943
+ class: "repo",
3944
+ root: options.root,
3945
+ name: options.name,
3946
+ description: options.description,
3947
+ },
2764
3948
  location,
2765
3949
  false,
2766
3950
  ),
@@ -2887,7 +4071,37 @@ function nestingDepth(lake: Lake, lakes: readonly Lake[]): number {
2887
4071
  }).length;
2888
4072
  }
2889
4073
 
2890
- /** The lake `add` writes to, or the refusal reason. */
4074
+ /**
4075
+ * One candidate line for a destination refusal: name, scope label beside the
4076
+ * config value, root, availability, and the configured description or the
4077
+ * class guidance — everything an agent needs to choose deliberately.
4078
+ * @param lake The candidate lake.
4079
+ * @returns The rendered line.
4080
+ */
4081
+ function candidateLine(lake: Lake): string {
4082
+ const label = `${CLASS_LABELS[lake.class]} [${lake.class}]`;
4083
+ const missing = lakeMissing(lake) ? " — missing root" : "";
4084
+ const description = lake.description ?? classGuidance(lake.class);
4085
+ return `- ${lake.name} (${label}): ${lake.root}${missing} — ${description}`;
4086
+ }
4087
+
4088
+ /**
4089
+ * The lake `add` writes to, or the refusal reason.
4090
+ *
4091
+ * An explicit name selects that exact lake or refuses — read-only, unknown
4092
+ * and (later, in `addDoc`) missing-root lakes never fall back elsewhere.
4093
+ * With `lake` omitted the write needs exactly one deliberate destination: the
4094
+ * sole configured writable knowledge lake. When two or more writable
4095
+ * knowledge lakes are configured the omission refuses with every candidate —
4096
+ * nesting, class, configuration order and current root existence change
4097
+ * nothing, because folder depth cannot infer the semantic owner of a claim.
4098
+ * Nesting still resolves the physical owner of an already named target (see
4099
+ * {@link uniqueHolder} and the publication containment guard); destination
4100
+ * choice and physical ownership are deliberately separate decisions.
4101
+ * @param lakes The configured lakes.
4102
+ * @param name The explicit lake selector, when given.
4103
+ * @returns The target lake, or the refusal reason.
4104
+ */
2891
4105
  function writableLake(
2892
4106
  lakes: readonly Lake[],
2893
4107
  name: string | undefined,
@@ -2904,34 +4118,26 @@ function writableLake(
2904
4118
  }
2905
4119
  return lake;
2906
4120
  }
2907
- // The narrowest writable knowledge lake wins: with nested roots the inner
2908
- // lake owns the file, so an outer read-only lake must not hijack the write.
2909
- const repos = lakes.filter(
2910
- (lake) => lake.class === "repo" && lake.writable,
4121
+ const pool = lakes.filter(
4122
+ (lake) => lake.profile === "knowledge" && lake.writable,
2911
4123
  );
2912
- const pool =
2913
- repos.length > 0
2914
- ? repos
2915
- : lakes.filter(
2916
- (lake) => lake.profile === "knowledge" && lake.writable,
2917
- );
2918
4124
  if (pool.length === 0) {
2919
4125
  return {
2920
4126
  reason:
2921
4127
  `${REFUSE} no writable knowledge lake is configured; ` +
2922
- "add a repo, machine, or global lake.",
4128
+ "wiki and read-only lakes are never written — add a repo, " +
4129
+ "machine or global lake.",
2923
4130
  };
2924
4131
  }
2925
- let target = pool[0] as Lake;
2926
- let depth = nestingDepth(target, lakes);
2927
- for (const candidate of pool.slice(1)) {
2928
- const candidateDepth = nestingDepth(candidate, lakes);
2929
- if (candidateDepth > depth) {
2930
- target = candidate;
2931
- depth = candidateDepth;
2932
- }
4132
+ if (pool.length > 1) {
4133
+ return {
4134
+ reason:
4135
+ `${REFUSE} ${pool.length} writable knowledge lakes could ` +
4136
+ "take this write; pass lake — one of:\n" +
4137
+ pool.map((lake) => candidateLine(lake)).join("\n"),
4138
+ };
2933
4139
  }
2934
- return target;
4140
+ return pool[0] as Lake;
2935
4141
  }
2936
4142
 
2937
4143
  /** Qualify a path with its lake when more than one lake is configured. */
@@ -3093,55 +4299,268 @@ function checkLakes(
3093
4299
  return { content: assembleCheck(reports, skipped), checked };
3094
4300
  }
3095
4301
 
3096
- /** Add a doc to the target lake. */
3097
- function addDoc(
3098
- runtime: Runtime,
3099
- add: DocsAdd,
4302
+ /**
4303
+ * The refusal for a legacy-seam write whose target is in the wrong state:
4304
+ * a create whose target exists (never an implicit overwrite), a replace whose
4305
+ * target vanished (never an implicit create), or a replace whose current
4306
+ * bytes no longer match the caller's `expectedRevision`. Legacy closure
4307
+ * seams cannot exclude other processes, so this route is in-process only.
4308
+ * @param target The lake the write lands in.
4309
+ * @param outcome The prepared write.
4310
+ * @param outcome.mode The mutation mode the write follows.
4311
+ * @param outcome.path The root-relative target path.
4312
+ * @param expected The caller's revision token, when replacing.
4313
+ * @param labelled The qualified target label, for refusals.
4314
+ * @returns The refusal, or null when the target state allows the write.
4315
+ */
4316
+ function legacyPublicationRefusal(
4317
+ target: Lake,
4318
+ outcome: { mode: AddMode; path: string },
4319
+ expected: string | undefined,
4320
+ labelled: string,
4321
+ ): string | null {
4322
+ const present = target.exists(outcome.path);
4323
+ if (outcome.mode === "create") {
4324
+ return present
4325
+ ? `${REFUSE} ${labelled} already exists — read it and pass ` +
4326
+ 'mode:"replace" to overwrite it deliberately.'
4327
+ : null;
4328
+ }
4329
+ if (!present) {
4330
+ return (
4331
+ `${REFUSE} no doc at ${labelled} — replace never creates; ` +
4332
+ 'pass mode:"create" for a new doc.'
4333
+ );
4334
+ }
4335
+ const found = revisionOf(target.read(outcome.path));
4336
+ return found === expected
4337
+ ? null
4338
+ : `${REFUSE} ${labelled} changed since you read it (expected ` +
4339
+ `${(expected ?? "none").slice(0, 12)}, found ` +
4340
+ `${found.slice(0, 12)}). Read it again, reconcile, and ` +
4341
+ "resubmit the replacement deliberately.";
4342
+ }
4343
+
4344
+ /**
4345
+ * The lake a write lands in, with its root verified present: the destination
4346
+ * refusal ({@link writableLake}) plus the dormant-root refusal in one step, so
4347
+ * a mistyped root never bootstraps a tree.
4348
+ * @param lakes The configured lakes.
4349
+ * @param lakeName The explicit lake selector, when given.
4350
+ * @returns The verified lake, or the refusal reason.
4351
+ */
4352
+ function writeTarget(
4353
+ lakes: readonly Lake[],
3100
4354
  lakeName: string | undefined,
3101
- ): { content: string; written: boolean } {
3102
- const target = writableLake(runtime.lakes, lakeName);
4355
+ ): Lake | { reason: string } {
4356
+ const target = writableLake(lakes, lakeName);
3103
4357
  if ("reason" in target) {
3104
- return { content: target.reason, written: false };
4358
+ return target;
3105
4359
  }
3106
- // A typo'd root must not bootstrap a new tree; the area directories below
3107
- // an existing root still create on write.
3108
4360
  if (target.root !== "" && !existsSync(target.root)) {
3109
4361
  return {
3110
- content:
4362
+ reason:
3111
4363
  `${REFUSE} lake '${target.name}' root ${target.root} does ` +
3112
4364
  "not exist — create it first.",
3113
- written: false,
3114
4365
  };
3115
4366
  }
3116
- const entries = target.collect();
3117
- const outcome = prepareAdd(add, entries, {
3118
- maxDocLines: runtime.maxDocLines,
3119
- });
3120
- if (outcome.action === "refuse") {
3121
- return { content: outcome.reason, written: false };
3122
- }
3123
- let updated: boolean;
4367
+ return target;
4368
+ }
4369
+
4370
+ /**
4371
+ * Publish a prepared write through a legacy lake's plain write seam,
4372
+ * translating the seam's path-safety error into a refusal.
4373
+ * @param target The lake the write lands in.
4374
+ * @param outcome The prepared write.
4375
+ * @param outcome.path The root-relative target path.
4376
+ * @param outcome.contents The serialised file contents.
4377
+ * @returns The refusal on an unsafe path, or null once written.
4378
+ */
4379
+ function publishLegacy(
4380
+ target: Lake,
4381
+ outcome: { path: string; contents: string },
4382
+ ): string | null {
3124
4383
  try {
3125
- updated = target.exists(outcome.path);
3126
4384
  target.write(outcome.path, outcome.contents);
3127
4385
  } catch (error) {
3128
4386
  if (error instanceof UnsafePathError) {
3129
- return {
3130
- content: `${REFUSE} ${error.message}`,
3131
- written: false,
3132
- };
4387
+ return `${REFUSE} ${error.message}`;
3133
4388
  }
3134
4389
  throw error;
3135
4390
  }
3136
- const head = `${address(target, outcome.path, runtime.lakes)} — ${
3137
- updated ? "updated" : "created"
3138
- }.`;
4391
+ return null;
4392
+ }
4393
+
4394
+ /**
4395
+ * Publish through a lake's guarded protocol seam, translating its path-safety
4396
+ * error into a refusal.
4397
+ * @param publish The lake's publication seam.
4398
+ * @param outcome The prepared write.
4399
+ * @param outcome.mode The mutation mode the write follows.
4400
+ * @param outcome.path The root-relative target path.
4401
+ * @param outcome.contents The serialised file contents.
4402
+ * @param expected The caller's revision token, when replacing.
4403
+ * @param label The qualified target label, for refusals.
4404
+ * @returns The new revision, or the refusal reason.
4405
+ */
4406
+ function guardedPublish(
4407
+ publish: NonNullable<Lake["publish"]>,
4408
+ outcome: { mode: AddMode; path: string; contents: string },
4409
+ expected: string | undefined,
4410
+ label: string,
4411
+ ): { revision: string } | { reason: string } {
4412
+ try {
4413
+ return publish(outcome.path, outcome.contents, {
4414
+ mode: outcome.mode,
4415
+ expectedRevision: expected,
4416
+ label,
4417
+ });
4418
+ } catch (error) {
4419
+ if (error instanceof UnsafePathError) {
4420
+ return { reason: `${REFUSE} ${error.message}` };
4421
+ }
4422
+ throw error;
4423
+ }
4424
+ }
4425
+
4426
+ /**
4427
+ * The one-line success report: the qualified target, the outcome, and an
4428
+ * override note when a near-twin was crossed deliberately.
4429
+ * @param outcome The prepared write.
4430
+ * @param outcome.mode The mutation mode the write follows.
4431
+ * @param label The qualified target label.
4432
+ * @param overridden Whether a near-twin override was used.
4433
+ * @returns The report head.
4434
+ */
4435
+ function successHead(
4436
+ outcome: { mode: AddMode },
4437
+ label: string,
4438
+ overridden: boolean,
4439
+ ): string {
4440
+ return (
4441
+ `${label} — ${outcome.mode === "replace" ? "replaced" : "created"}.` +
4442
+ (overridden ? " near-twin overridden." : "")
4443
+ );
4444
+ }
4445
+
4446
+ /**
4447
+ * The success metadata: the written flag, the resolved lake and path, and the
4448
+ * new content revision when the route produced one.
4449
+ * @param target The lake the write landed in.
4450
+ * @param outcome The prepared write.
4451
+ * @param outcome.path The root-relative target path.
4452
+ * @param revision The new revision, when the route produced one.
4453
+ * @returns The metadata object.
4454
+ */
4455
+ function successMetadata(
4456
+ target: Lake,
4457
+ outcome: { path: string },
4458
+ revision: string | undefined,
4459
+ ): Record<string, unknown> {
4460
+ return {
4461
+ written: true,
4462
+ lake: target.name,
4463
+ path: outcome.path,
4464
+ ...(revision !== undefined ? { revision } : {}),
4465
+ };
4466
+ }
4467
+
4468
+ /**
4469
+ * Publish a prepared write through the target lake's route: real lakes use
4470
+ * the guarded {@link publishTarget} protocol (lock, re-verify, atomic
4471
+ * publish); legacy closure lakes fall back to the plain seam with the same
4472
+ * contracts, in-process only.
4473
+ * @param target The verified destination lake.
4474
+ * @param outcome The prepared write.
4475
+ * @param outcome.mode The mutation mode the write follows.
4476
+ * @param outcome.path The root-relative target path.
4477
+ * @param outcome.contents The serialised file contents.
4478
+ * @param expected The caller's revision token, when replacing.
4479
+ * @param label The qualified target label, for refusals.
4480
+ * @returns The new revision, or the refusal reason.
4481
+ */
4482
+ function publishPrepared(
4483
+ target: Lake,
4484
+ outcome: {
4485
+ /** The mutation mode the write follows. */
4486
+ mode: AddMode;
4487
+ /** The root-relative target path. */
4488
+ path: string;
4489
+ /** The serialised file contents. */
4490
+ contents: string;
4491
+ },
4492
+ expected: string | undefined,
4493
+ label: string,
4494
+ ): { revision?: string; reason?: string } {
4495
+ if (target.publish !== undefined) {
4496
+ const result = guardedPublish(target.publish, outcome, expected, label);
4497
+ return "reason" in result
4498
+ ? { reason: result.reason }
4499
+ : { revision: result.revision };
4500
+ }
4501
+ const legacy = legacyPublicationRefusal(target, outcome, expected, label);
4502
+ if (legacy !== null) {
4503
+ return { reason: legacy };
4504
+ }
4505
+ const unsafe = publishLegacy(target, outcome);
4506
+ return unsafe === null ? {} : { reason: unsafe };
4507
+ }
4508
+
4509
+ /**
4510
+ * Add a doc to the target lake through its publication route: real lakes use
4511
+ * the guarded {@link publishTarget} protocol (lock, re-verify, atomic
4512
+ * publish); legacy closure lakes fall back to the plain seam with the same
4513
+ * contracts, in-process only.
4514
+ * @param runtime The resolved runtime.
4515
+ * @param add The typed request.
4516
+ * @param lakeName The explicit lake selector, when given.
4517
+ * @returns The result content, written flag and success metadata.
4518
+ */
4519
+ function addDoc(
4520
+ runtime: Runtime,
4521
+ add: DocsAdd,
4522
+ lakeName: string | undefined,
4523
+ ): {
4524
+ content: string;
4525
+ written: boolean;
4526
+ metadata?: Record<string, unknown>;
4527
+ } {
4528
+ const target = writeTarget(runtime.lakes, lakeName);
4529
+ if ("reason" in target) {
4530
+ return { content: target.reason, written: false };
4531
+ }
4532
+ const entries = target.collect();
4533
+ const outcome = prepareAdd(add, entries, {
4534
+ maxDocLines: runtime.maxDocLines,
4535
+ profile: target.profile,
4536
+ });
4537
+ if (outcome.action === "refuse") {
4538
+ return { content: outcome.reason, written: false };
4539
+ }
4540
+ const label = address(target, outcome.path, runtime.lakes);
4541
+ const expected = add.expectedRevision?.trim();
4542
+ const published = publishPrepared(target, outcome, expected, label);
4543
+ if (published.reason !== undefined) {
4544
+ return { content: published.reason, written: false };
4545
+ }
4546
+ const revision = published.revision;
4547
+ const head = successHead(
4548
+ outcome,
4549
+ label,
4550
+ outcome.mode === "create" && overrideGiven(add),
4551
+ );
3139
4552
  // The path and outcome are the answer; a list of same-area neighbours was
3140
4553
  // unsolicited context that grew with the area and was picked by collection
3141
4554
  // order, not relevance. Refusals still carry their candidates.
3142
4555
  const notes =
3143
4556
  outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
3144
- return { content: `${head}${notes}`, written: true };
4557
+ return {
4558
+ content:
4559
+ `${head}${notes}` +
4560
+ (revision !== undefined ? revisionNote(revision, true) : ""),
4561
+ written: true,
4562
+ metadata: successMetadata(target, outcome, revision),
4563
+ };
3145
4564
  }
3146
4565
 
3147
4566
  /** A parsed read request: the lake (when known) and the path. */
@@ -3222,7 +4641,13 @@ function readDoc(
3222
4641
  runtime: Runtime,
3223
4642
  given: string,
3224
4643
  lakeName: string | undefined,
3225
- ): { content: string; read: string | null } {
4644
+ ): {
4645
+ content: string;
4646
+ read: string | null;
4647
+ lake?: string;
4648
+ path?: string;
4649
+ revision?: string;
4650
+ } {
3226
4651
  const target = readTargetOf(runtime, given, lakeName);
3227
4652
  if (target.reason !== undefined) {
3228
4653
  return { content: target.reason, read: null };
@@ -3251,12 +4676,45 @@ function readDoc(
3251
4676
  const raw = selected.read(guarded.path);
3252
4677
  const { body } = parseFrontMatter(raw);
3253
4678
  const label = address(selected, guarded.path, runtime.lakes);
4679
+ const revision =
4680
+ selected.profile === "knowledge" ? revisionOf(raw) : undefined;
3254
4681
  return {
3255
- content: `${label} — ${titleOf(guarded.path, body)}\n\n${raw}`,
4682
+ content:
4683
+ `${label} — ${titleOf(guarded.path, body)}\n\n${raw}` +
4684
+ (revision !== undefined ? revisionNote(revision, false) : ""),
3256
4685
  read: label,
4686
+ lake: selected.name,
4687
+ path: guarded.path,
4688
+ // Wiki pages are read-only reference and promise no write-capable
4689
+ // revision workflow; knowledge docs carry their content hash.
4690
+ ...(revision !== undefined ? { revision } : {}),
3257
4691
  };
3258
4692
  }
3259
4693
 
4694
+ /**
4695
+ * The model-facing revision note: the one line that makes an optimistic-
4696
+ * concurrency token visible through every real host path, stored per-call
4697
+ * metadata kept alongside it for UI and programmatic callers. Host evidence
4698
+ * (opencode v2.2.5): a plugin tool's result reaches the model only through its
4699
+ * `content` — `Session.Tool.Success` persists metadata as optional UI data,
4700
+ * the model-facing lowering (`to-llm-message.ts` → `toolResult()`) serialises
4701
+ * only `content`, and the Code Mode twin's `resultValue()` drops `metadata`
4702
+ * for tools without an output schema. Content text is the one channel both
4703
+ * surfaces forward, so the token rides it. The note sits after an explicit
4704
+ * blank separator, never inside the verbatim document body.
4705
+ * @param revision The content's SHA-256 revision.
4706
+ * @param fresh False for a caller's read; true when a replacement just
4707
+ * published these bytes.
4708
+ * @returns The note text with its leading blank-line separator.
4709
+ */
4710
+ function revisionNote(revision: string, fresh: boolean): string {
4711
+ return (
4712
+ `\n\ndocs: revision ${revision} — ` +
4713
+ (fresh ? "the newly published bytes" : "these bytes") +
4714
+ "; cite it as expectedRevision when replacing (a stale token refuses)."
4715
+ );
4716
+ }
4717
+
3260
4718
  /** The lake help view shared by the tool and command. */
3261
4719
  function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
3262
4720
  return lakes.map((lake) => ({
@@ -3266,6 +4724,9 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
3266
4724
  writable: lake.writable,
3267
4725
  validated: lakeValidated(lake),
3268
4726
  missing: lakeMissing(lake),
4727
+ ...(lake.description !== undefined
4728
+ ? { description: lake.description }
4729
+ : {}),
3269
4730
  }));
3270
4731
  }
3271
4732
 
@@ -3308,7 +4769,7 @@ function executeRequest(runtime: Runtime, input: DocsInput): Result {
3308
4769
  const result = addDoc(runtime, request.add, request.lake);
3309
4770
  return {
3310
4771
  content: result.content,
3311
- metadata: { written: result.written },
4772
+ metadata: result.metadata ?? { written: result.written },
3312
4773
  };
3313
4774
  }
3314
4775
  if (request.verb === "check") {
@@ -3322,7 +4783,14 @@ function executeRequest(runtime: Runtime, input: DocsInput): Result {
3322
4783
  const result = readDoc(runtime, request.read, request.lake);
3323
4784
  return {
3324
4785
  content: result.content,
3325
- metadata: { read: result.read },
4786
+ metadata: {
4787
+ read: result.read,
4788
+ ...(result.lake !== undefined ? { lake: result.lake } : {}),
4789
+ ...(result.path !== undefined ? { path: result.path } : {}),
4790
+ ...(result.revision !== undefined
4791
+ ? { revision: result.revision }
4792
+ : {}),
4793
+ },
3326
4794
  };
3327
4795
  }
3328
4796
  if (request.verb === "help") {
@@ -3372,12 +4840,13 @@ export function createDocsTool(
3372
4840
  description: codemode
3373
4841
  ? TWIN_DESCRIPTION
3374
4842
  : "Search the configured knowledge lakes and nested project " +
3375
- "`README.md` files before a broad grep for how-to knowledge, " +
3376
- "decisions or known problems. A blank search lists recent docs; " +
3377
- "`help` shows the lakes and ranking. `read` returns a lake doc " +
3378
- "verbatim; project `instructions:` results open with the built-in " +
3379
- "read tool. `add` writes or replaces a knowledge doc; `check` " +
3380
- "audits convention compliance.",
4843
+ "`README.md` files before a broad grep for how-to knowledge. " +
4844
+ "A blank search lists recent docs; `help` shows the lakes and " +
4845
+ "ranking. `read` returns a lake doc verbatim plus its " +
4846
+ "revision token; `instructions:` results open with the " +
4847
+ "built-in read tool. `add` creates or replaces a knowledge " +
4848
+ "doc (replace needs that revision); `check` audits " +
4849
+ "convention compliance.",
3381
4850
  input: DOCS_INPUT,
3382
4851
  options: codemode
3383
4852
  ? { namespace: "docs", codemode: true }
@@ -3406,8 +4875,9 @@ function addPrompt(subject: string): string {
3406
4875
  }
3407
4876
  return (
3408
4877
  "Record this as durable knowledge in the docs lakes: call the `docs` " +
3409
- "tool's `add` with a typed DTO (metadata Area/Keywords/Summary, body starting " +
3410
- "with an H1) for:\n\n" +
4878
+ 'tool\'s `add` with a typed DTO — mode:"create" for a new doc, ' +
4879
+ "metadata (Area/Keywords/Summary) and a body starting with an H1 — " +
4880
+ "for:\n\n" +
3411
4881
  subject
3412
4882
  );
3413
4883
  }
@@ -3673,9 +5143,9 @@ export default Plugin.define({
3673
5143
  "Entry point to the configured knowledge lakes " +
3674
5144
  "(repo, machine, global, read-only wiki) and nested project " +
3675
5145
  "README.md files: find and read the right doc, then record " +
3676
- "focused knowledge at the narrowest true scope. Use before " +
3677
- "searching the codebase for how-to knowledge, or when a " +
3678
- "session learns something durable.",
5146
+ "focused knowledge at the narrowest justified scope. Use " +
5147
+ "before searching the codebase for how-to knowledge, or " +
5148
+ "when a session learns something durable.",
3679
5149
  path: AbsolutePath.make(fileURLToPath(skillURL)),
3680
5150
  content: skill,
3681
5151
  });