@markstanden/opencode-docs-plugin 0.10.0 → 0.12.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
 
@@ -1400,6 +1460,8 @@ export function slugify(text: string): string {
1400
1460
  export type AddOutcome =
1401
1461
  | {
1402
1462
  action: "write";
1463
+ /** The mutation mode the write follows. */
1464
+ mode: AddMode;
1403
1465
  /** Root-relative target. */
1404
1466
  path: string;
1405
1467
  /** The serialised file contents. */
@@ -1439,6 +1501,41 @@ function blankRequiredField(metadata: DocMetadata): string | null {
1439
1501
  return null;
1440
1502
  }
1441
1503
 
1504
+ /**
1505
+ * The mutation mode of an `add` request, guarded for direct core callers:
1506
+ * the typed schema requires `mode`, but nothing stops a JS caller bypassing
1507
+ * it, so the core refuses rather than guessing.
1508
+ * @param add The typed request, as delivered.
1509
+ * @returns The mode, or the refusal reason.
1510
+ */
1511
+ function modeOf(add: DocsAdd): AddMode | { reason: string } {
1512
+ if (add.mode === "create" || add.mode === "replace") {
1513
+ return add.mode;
1514
+ }
1515
+ return {
1516
+ reason:
1517
+ 'mode is required and must be "create" (write a new doc) or ' +
1518
+ '"replace" (wholesale-replace the doc at path).',
1519
+ };
1520
+ }
1521
+
1522
+ /**
1523
+ * Whether a create's deliberate near-twin override is present: a non-blank,
1524
+ * non-empty explanation. It bypasses only the semantic near-twin guard, never
1525
+ * the structural checks or the target state.
1526
+ * @param add The typed request.
1527
+ * @returns True when the override may be used in this mode.
1528
+ */
1529
+ function overrideGiven(add: DocsAdd): boolean {
1530
+ if (add.mode !== "create") {
1531
+ return false;
1532
+ }
1533
+ return (
1534
+ typeof add.allowNearTwinReason === "string" &&
1535
+ add.allowNearTwinReason.trim() !== ""
1536
+ );
1537
+ }
1538
+
1442
1539
  /**
1443
1540
  * Validate an explicit `add` path: relative, inside the lake root, naming a
1444
1541
  * `.md` file.
@@ -1468,8 +1565,9 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
1468
1565
 
1469
1566
  /**
1470
1567
  * 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.
1568
+ * An explicit path must stay inside the lake root and name a `.md` file; a
1569
+ * create with no path derives `<Area>/<slug>.md` from the H1; a replace has
1570
+ * no path to derive — its `path` is required.
1473
1571
  * @param add The typed request.
1474
1572
  * @returns The target path, or the refusal reason.
1475
1573
  */
@@ -1478,6 +1576,11 @@ function targetOf(add: DocsAdd): { path: string } | { reason: string } {
1478
1576
  if (given !== "") {
1479
1577
  return explicitTarget(given);
1480
1578
  }
1579
+ if (add.mode === "replace") {
1580
+ return {
1581
+ reason: "replace requires the path of the existing doc to replace.",
1582
+ };
1583
+ }
1481
1584
  const slug = slugify(h1Of(add.body) ?? "");
1482
1585
  if (slug === "") {
1483
1586
  return { reason: "The H1 does not yield a usable file name." };
@@ -1546,8 +1649,10 @@ export function nearTwins(
1546
1649
  }
1547
1650
 
1548
1651
  /**
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.
1652
+ * The near-twin refusal for a create, or null when the write may proceed.
1653
+ * Every create reconciles — derived and explicit paths alike — unless the
1654
+ * caller supplies a non-blank {@link DocsAdd.allowNearTwinReason}; replace is
1655
+ * the deliberate update contract and has no create guard.
1551
1656
  * @param add The typed request.
1552
1657
  * @param entries The current docs.
1553
1658
  * @returns The refusal, or null.
@@ -1556,7 +1661,7 @@ function nearTwinRefusal(
1556
1661
  add: DocsAdd,
1557
1662
  entries: readonly DocEntry[],
1558
1663
  ): AddOutcome | null {
1559
- if ((add.path?.trim() ?? "") !== "") {
1664
+ if (add.mode !== "create" || overrideGiven(add)) {
1560
1665
  return null;
1561
1666
  }
1562
1667
  const nearTwinDocs = nearTwins(
@@ -1571,23 +1676,123 @@ function nearTwinRefusal(
1571
1676
  action: "refuse",
1572
1677
  reason:
1573
1678
  `${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" +
1679
+ 'this doc. Pass its path with mode:"replace" to replace it, ' +
1680
+ "or a non-blank allowNearTwinReason to write a deliberate " +
1681
+ "distinct doc:\n\n" +
1576
1682
  render(nearTwinDocs),
1577
1683
  candidates: nearTwinDocs,
1578
1684
  };
1579
1685
  }
1580
1686
 
1687
+ /**
1688
+ * The refusal for a misused `allowNearTwinReason`, or null. Replace has no
1689
+ * near-twin guard, so the field is meaningless there; a create must explain
1690
+ * the override in non-blank prose.
1691
+ * @param add The typed request.
1692
+ * @returns The refusal reason, or null when the field is acceptable.
1693
+ */
1694
+ function overrideRefusal(add: DocsAdd): string | null {
1695
+ if (add.allowNearTwinReason === undefined) {
1696
+ return null;
1697
+ }
1698
+ if (add.mode === "replace") {
1699
+ return (
1700
+ "allowNearTwinReason is create-only; replace has no " +
1701
+ "near-twin guard to bypass."
1702
+ );
1703
+ }
1704
+ if (
1705
+ typeof add.allowNearTwinReason !== "string" ||
1706
+ add.allowNearTwinReason.trim() === ""
1707
+ ) {
1708
+ return (
1709
+ "allowNearTwinReason must be a non-blank explanation of why " +
1710
+ "this doc is distinct from its near-twin."
1711
+ );
1712
+ }
1713
+ return null;
1714
+ }
1715
+
1716
+ /**
1717
+ * The refusal for a misused `expectedRevision`, or null. Replace requires the
1718
+ * revision token of the doc being replaced — its stale-write guard; create
1719
+ * carries a not-exists precondition instead and rejects the field outright.
1720
+ * @param add The typed request.
1721
+ * @returns The refusal reason, or null when the field is acceptable.
1722
+ */
1723
+ function revisionRefusal(add: DocsAdd): string | null {
1724
+ if (add.mode === "create") {
1725
+ return add.expectedRevision === undefined
1726
+ ? null
1727
+ : "expectedRevision is replace-only; a create's precondition is " +
1728
+ "that the target does not exist.";
1729
+ }
1730
+ const given = add.expectedRevision?.trim() ?? "";
1731
+ if (given === "") {
1732
+ return (
1733
+ "replace requires expectedRevision — the revision returned with " +
1734
+ "your last read or write of the doc, so a stale whole-doc " +
1735
+ "replacement refuses instead of overwriting a newer edit."
1736
+ );
1737
+ }
1738
+ if (!REVISION_PATTERN.test(given)) {
1739
+ return (
1740
+ "expectedRevision must be the SHA-256 hex digest returned with " +
1741
+ "the doc you read; reread it and resubmit."
1742
+ );
1743
+ }
1744
+ return null;
1745
+ }
1746
+
1747
+ /**
1748
+ * The refusal for DTO content a knowledge doc may not carry: blank required
1749
+ * metadata, an `Area` off the root, a missing `H1`, no claim section, or an
1750
+ * identity too weak to reconcile.
1751
+ * @param add The typed request.
1752
+ * @returns The refusal reason, or null when the content is acceptable.
1753
+ */
1754
+ function contentRefusal(add: DocsAdd): string | null {
1755
+ const blank = blankRequiredField(add.metadata);
1756
+ if (blank !== null) {
1757
+ return `${blank} is required and must not be blank.`;
1758
+ }
1759
+ if (/[/\\]|\.\./.test(add.metadata.Area)) {
1760
+ return "Area must be a single folder name under the lake root.";
1761
+ }
1762
+ if (h1Of(add.body) === undefined) {
1763
+ return "The body must carry an H1 (`# Title`).";
1764
+ }
1765
+ // The same rule `check` applies, enforced before anything touches disk: a
1766
+ // doc that `check` would flag on the next run should never be written.
1767
+ if (!hasClaimSection(add.body)) {
1768
+ return (
1769
+ "the body carries no claim section; add an H2/H3 headed Facts, " +
1770
+ "Inferred, Tested, Failed approaches or Open questions."
1771
+ );
1772
+ }
1773
+ if (!hasDistinctiveIdentity(reconcileQuery(add))) {
1774
+ return (
1775
+ `the H1 and Keywords together carry no term of MIN_TERM_LENGTH ` +
1776
+ `(${MIN_TERM_LENGTH}) or longer; add a distinctive term so ` +
1777
+ "reconcile can tell this doc apart."
1778
+ );
1779
+ }
1780
+ return null;
1781
+ }
1782
+
1581
1783
  /**
1582
1784
  * Validate an `add` request and prepare it for writing — pure, no fs.
1583
1785
  *
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.
1786
+ * Trust boundary: the caller is a model. `mode` is required: `create` derives
1787
+ * the target from `Area` and the H1 unless an explicit `path` is given, and
1788
+ * reconciles against same-area near-twins either way; `replace` requires the
1789
+ * path of an existing doc. Blank required metadata, a body without an H1, a
1790
+ * path or `Area` escaping the lake root, a non-`.md` target and a title that
1791
+ * yields no slug are all refused. A same-area near-twin — a doc matching every
1792
+ * reconcile query term ({@link reconcileQuery}) — refuses a create unless a
1793
+ * non-blank `allowNearTwinReason` explains the deliberate distinct doc.
1794
+ * Target existence is the caller seam's to verify: creates must not overwrite
1795
+ * and replaces must exist.
1591
1796
  * @param add The typed request.
1592
1797
  * @param entries The current docs, for reconcile and neighbours.
1593
1798
  * @param options Soft-limit configuration.
@@ -1605,30 +1810,21 @@ export function prepareAdd(
1605
1810
  candidates: [],
1606
1811
  });
1607
1812
 
1608
- const blank = blankRequiredField(add.metadata);
1609
- if (blank !== null) {
1610
- return refuse(`${blank} is required and must not be blank.`);
1813
+ const mode = modeOf(add);
1814
+ if (typeof mode !== "string") {
1815
+ return refuse(mode.reason);
1611
1816
  }
1612
- if (/[/\\]|\.\./.test(add.metadata.Area)) {
1613
- return refuse("Area must be a single folder name under the lake root.");
1817
+ const override = overrideRefusal(add);
1818
+ if (override !== null) {
1819
+ return refuse(override);
1614
1820
  }
1615
- if (h1Of(add.body) === undefined) {
1616
- return refuse("The body must carry an H1 (`# Title`).");
1617
- }
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
- );
1821
+ const revision = revisionRefusal(add);
1822
+ if (revision !== null) {
1823
+ return refuse(revision);
1625
1824
  }
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
- );
1825
+ const content = contentRefusal(add);
1826
+ if (content !== null) {
1827
+ return refuse(content);
1632
1828
  }
1633
1829
  const target = targetOf(add);
1634
1830
  if ("reason" in target) {
@@ -1647,6 +1843,7 @@ export function prepareAdd(
1647
1843
  .slice(0, MAX_RESULTS);
1648
1844
  return {
1649
1845
  action: "write",
1846
+ mode,
1650
1847
  path: target.path,
1651
1848
  contents: serializeDoc(add.metadata, add.body),
1652
1849
  neighbours,
@@ -2181,12 +2378,13 @@ export const RECORD_NUDGE =
2181
2378
  "behaviour), add one `Record with docs add:` block per durable topic and " +
2182
2379
  "scope — coalesce related findings rather than one block per observation, " +
2183
2380
  "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.";
2381
+ "its scope (Repository/Personal/Organisation — config repo, machine or " +
2382
+ "global), `Area`, `H1` title and `Keywords`, and keeps what was learned, " +
2383
+ "why it matters, the evidence or status, any unresolved uncertainty and " +
2384
+ "the intended scope, so the next agent can reconcile before writing and " +
2385
+ "confirm the destination fits. Reference a finding already recorded by " +
2386
+ "path rather than restating it, and assume persistence only where the " +
2387
+ "session confirms it.";
2190
2388
 
2191
2389
  /**
2192
2390
  * Resolve the plugin's `nudge` option: unset means on; a boolean passes
@@ -2219,6 +2417,13 @@ export interface LakeSpec {
2219
2417
  profile: LakeProfile;
2220
2418
  /** Whether `add` may write to it. */
2221
2419
  writable: boolean;
2420
+ /**
2421
+ * What belongs in this particular destination, as configured. Absent lakes
2422
+ * fall back to the class guidance ({@link classGuidance}); it is
2423
+ * descriptive data shown in help and refusal candidate lists, never
2424
+ * indexed for search.
2425
+ */
2426
+ description?: string;
2222
2427
  }
2223
2428
 
2224
2429
  /**
@@ -2245,6 +2450,21 @@ export interface Lake extends LakeSpec {
2245
2450
  * existing root; throws when the root is absent rather than creating it.
2246
2451
  */
2247
2452
  write: (path: string, contents: string) => void;
2453
+ /**
2454
+ * The guarded publication route for real lakes: lock, re-verify, and
2455
+ * publish the whole document — a no-overwrite create, or an atomic
2456
+ * whole-file replacement against `expectedRevision`. Legacy closure lakes
2457
+ * omit it and fall back to the plain `write` seam.
2458
+ */
2459
+ publish?: (
2460
+ path: string,
2461
+ contents: string,
2462
+ options: {
2463
+ mode: AddMode;
2464
+ expectedRevision?: string | undefined;
2465
+ label: string;
2466
+ },
2467
+ ) => { revision: string } | { reason: string };
2248
2468
  /**
2249
2469
  * Legacy seam notes only. Real lakes compute their environment notes at
2250
2470
  * call time — a root that appears, disappears or gains a git checkout is
@@ -2315,17 +2535,713 @@ function safeLakeTarget(root: string, path: string): string | null {
2315
2535
  return target;
2316
2536
  }
2317
2537
 
2538
+ /**
2539
+ * A held target lock. Release it on every ordinary exit; a lock left by an
2540
+ * interrupted process is never stolen automatically — the refusal names its
2541
+ * owner and the recovery is manual.
2542
+ */
2543
+ interface TargetLock {
2544
+ /** Removes the lock file; best effort. */
2545
+ release: () => void;
2546
+ }
2547
+
2548
+ /**
2549
+ * The physical identity of a target: its deepest existing ancestor, real-pathed,
2550
+ * plus the exact remaining segments. Two configured roots that reach the same
2551
+ * tree by different routes resolve to one prefix and one tail, so their locks
2552
+ * and identities cannot silently diverge. The key is stable across the
2553
+ * target-or-parent-creation transition: real-pathing the existing prefix and
2554
+ * joining the missing tail yields the same absolute path before and after the
2555
+ * missing segments appear.
2556
+ * @param target The absolute target path.
2557
+ * @returns The existing real prefix and the missing tail.
2558
+ */
2559
+ function physicalRouteOf(target: string): {
2560
+ realPrefix: string;
2561
+ tail: readonly string[];
2562
+ } {
2563
+ const tail: string[] = [];
2564
+ let head = target;
2565
+ while (!existsSync(head)) {
2566
+ tail.unshift(basename(head));
2567
+ const parent = dirname(head);
2568
+ if (parent === head) {
2569
+ break;
2570
+ }
2571
+ head = parent;
2572
+ }
2573
+ return { realPrefix: realpathSync(head), tail };
2574
+ }
2575
+
2576
+ /**
2577
+ * The canonical on-disk identity of a target: its resolved existing prefix
2578
+ * plus the missing tail joined. Every configured route to the same file
2579
+ * produces this one string.
2580
+ * @param target The absolute target path.
2581
+ * @returns The real-path key.
2582
+ */
2583
+ export function physicalTargetKey(target: string): string {
2584
+ const { realPrefix, tail } = physicalRouteOf(target);
2585
+ return join(realPrefix, ...tail);
2586
+ }
2587
+
2588
+ /**
2589
+ * The lock directory for a target: a hidden `.docs-lock` inside the deepest
2590
+ * existing ancestor that can hold it — the target's own directory once the
2591
+ * target exists, its existing parent otherwise. Siting the lock beside the
2592
+ * physical target keeps all configured routes (and separately configured
2593
+ * processes) on one directory: aliasing roots resolve the same real prefix,
2594
+ * and nothing about the lock location depends on the current runtime's lake
2595
+ * list. Search and `check` skip dot-directories at every level, so the lock
2596
+ * directory is never mistaken for content.
2597
+ * @param target The absolute target path.
2598
+ * @returns The lock directory path.
2599
+ */
2600
+ export function targetLockDirectory(target: string): string {
2601
+ const { realPrefix, tail } = physicalRouteOf(target);
2602
+ // The existing prefix is real-pathed already: for an existing target it
2603
+ // is the file itself (lock beside it), for a missing target it is the
2604
+ // deepest existing ancestor directory.
2605
+ return join(
2606
+ tail.length === 0 ? dirname(realPrefix) : realPrefix,
2607
+ ".docs-lock",
2608
+ );
2609
+ }
2610
+
2611
+ /**
2612
+ * The lock file for a target: keyed by the target's canonical physical key and
2613
+ * sited beside it (see {@link targetLockDirectory}). Exported for diagnostics
2614
+ * and tests: refusals name the manual recovery path, and tests must consult
2615
+ * the real protocol's location rather than re-deriving it.
2616
+ * @param target The absolute target path.
2617
+ * @returns The lock file path.
2618
+ */
2619
+ export function targetLockPath(target: string): string {
2620
+ return join(
2621
+ targetLockDirectory(target),
2622
+ `${createHash("sha256")
2623
+ .update(physicalTargetKey(target), "utf8")
2624
+ .digest("hex")}.lock`,
2625
+ );
2626
+ }
2627
+
2628
+ /**
2629
+ * Prepare the lock directory without touching anything but what is ours: an
2630
+ * existing directory is kept, any other object (including a symlink) refuses
2631
+ * without being read, followed, truncated or removed, and a missing directory
2632
+ * is created one level inside the already-existing ancestor. Bounded — one
2633
+ * creation attempt, then a post-EEXIST re-stat; no spin, no cleanup of
2634
+ * foreign objects.
2635
+ * @param lockDir The computed lock directory.
2636
+ * @returns undefined when the directory is usable, or the refusal reason.
2637
+ */
2638
+ function ensureLockDirectory(lockDir: string): { error: string } | undefined {
2639
+ try {
2640
+ const stats = lstatSync(lockDir);
2641
+ if (stats.isDirectory()) {
2642
+ return undefined;
2643
+ }
2644
+ return {
2645
+ error:
2646
+ `the lock directory ${lockDir} is occupied by a ` +
2647
+ `${stats.isSymbolicLink() ? "symlink" : "non-directory"}; ` +
2648
+ "nothing was written. Remove or repair it deliberately.",
2649
+ };
2650
+ } catch {
2651
+ // Absent — create it, contended creation tolerated below.
2652
+ }
2653
+ try {
2654
+ mkdirSync(lockDir);
2655
+ return undefined;
2656
+ } catch (error) {
2657
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
2658
+ return {
2659
+ error: `could not create the lock directory ${lockDir}: ${String(error)}.`,
2660
+ };
2661
+ }
2662
+ try {
2663
+ return lstatSync(lockDir).isDirectory()
2664
+ ? undefined
2665
+ : {
2666
+ error: `the lock directory ${lockDir} appeared and is not a directory.`,
2667
+ };
2668
+ } catch {
2669
+ return {
2670
+ error: `the lock directory ${lockDir} vanished while locking.`,
2671
+ };
2672
+ }
2673
+ }
2674
+ }
2675
+
2676
+ /**
2677
+ * Acquire a target's publication lock with an exclusive create. A held lock
2678
+ * refuses busy with its recorded owner; it is never stolen, however old —
2679
+ * an interrupted process is recovered by a human confirming the owner is
2680
+ * gone and removing the file.
2681
+ * @param target The absolute target path.
2682
+ * @returns The held lock, or the busy reason.
2683
+ */
2684
+ function acquireTargetLock(target: string): TargetLock | { busy: string } {
2685
+ const lockPath = targetLockPath(target);
2686
+ const problem = ensureLockDirectory(dirname(lockPath));
2687
+ if (problem !== undefined) {
2688
+ return { busy: problem.error };
2689
+ }
2690
+ try {
2691
+ const fd = openSync(lockPath, "wx");
2692
+ try {
2693
+ writeFullBytes(
2694
+ fd,
2695
+ JSON.stringify({
2696
+ pid: process.pid,
2697
+ acquiredAt: new Date().toISOString(),
2698
+ }),
2699
+ );
2700
+ } finally {
2701
+ closeSync(fd);
2702
+ }
2703
+ return {
2704
+ release: (): void => {
2705
+ try {
2706
+ unlinkSync(lockPath);
2707
+ } catch {
2708
+ // Best effort: a later refusal names the surviving lock.
2709
+ }
2710
+ },
2711
+ };
2712
+ } catch (error) {
2713
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
2714
+ throw error;
2715
+ }
2716
+ let owner = "details unreadable";
2717
+ try {
2718
+ const raw = readFileSync(lockPath, "utf8").trim();
2719
+ if (raw !== "") {
2720
+ owner = raw;
2721
+ }
2722
+ } catch {
2723
+ // Keep the fallback text.
2724
+ }
2725
+ return {
2726
+ busy:
2727
+ `another writer holds the target lock (${owner}); retry ` +
2728
+ `later, or after confirming that process is gone remove ` +
2729
+ `${lockPath}.`,
2730
+ };
2731
+ }
2732
+ }
2733
+
2734
+ /**
2735
+ * Write the whole byte sequence through an open descriptor. A short write is
2736
+ * looped — regular files rarely short-write, but nothing here reports a
2737
+ * revision for bytes it did not fully store: a mid-loop failure propagates
2738
+ * and the publication never renames.
2739
+ * @param fd The descriptor to write through, positioned by the OS.
2740
+ * @param contents The full serialised document.
2741
+ * @param write The single write primitive; injected for short-write tests.
2742
+ */
2743
+ export function writeFullBytes(
2744
+ fd: number,
2745
+ contents: string,
2746
+ write: typeof writeSync = writeSync,
2747
+ ): void {
2748
+ const buffer = Buffer.from(contents, "utf8");
2749
+ let offset = 0;
2750
+ while (offset < buffer.length) {
2751
+ const written = write(fd, buffer, offset, buffer.length - offset);
2752
+ if (typeof written !== "number" || written <= 0) {
2753
+ throw new Error(
2754
+ `write stalled after ${offset} of ${buffer.length} bytes`,
2755
+ );
2756
+ }
2757
+ offset += written;
2758
+ }
2759
+ }
2760
+
2761
+ /** A temp file this operation exclusively created and owns. */
2762
+ interface OwnedTemp {
2763
+ /** The exclusive-create descriptor; all writes go through it. */
2764
+ fd: number;
2765
+ /** The temp's path, for the atomic rename. */
2766
+ path: string;
2767
+ }
2768
+
2769
+ /**
2770
+ * The exclusive temp-name selection seam: create one new regular file at a
2771
+ * name this operation owns, or throw `EEXIST` when a foreign object already
2772
+ * occupies the candidate. It is never reopened by pathname for writing — the
2773
+ * returned descriptor is the only write route — and a pre-existing symlink,
2774
+ * regular file or directory is never followed, truncated or removed.
2775
+ */
2776
+ export type TempFactory = (target: string) => OwnedTemp;
2777
+
2778
+ /** The attempts made on distinct exclusive temp names before giving up. */
2779
+ export const MAX_TEMP_ATTEMPTS = 8;
2780
+
2781
+ /** The module-level temp-file counter: unique publication temps per process. */
2782
+ let publishTempCounter = 0;
2783
+
2784
+ /**
2785
+ * The default temp selection: `${target}.${pid}.${counter}.tmp` created
2786
+ * exclusively. A pre-existing object at a candidate name — including a
2787
+ * symlink seeded in advance — makes the create fail without touching it, and
2788
+ * the retry loop below moves to the next fresh name. The counter never
2789
+ * repeats within a process, so ordinary contention cannot exhaust the bound.
2790
+ * @param target The absolute target path.
2791
+ * @returns The owned temp descriptor and path.
2792
+ */
2793
+ const defaultTempFactory: TempFactory = (target) => {
2794
+ const path = `${target}.${process.pid}.${publishTempCounter++}.tmp`;
2795
+ const fd = openSync(path, "wx");
2796
+ return { fd, path };
2797
+ };
2798
+
2799
+ /**
2800
+ * The publication seams: the narrow, injectable primitives the guarded
2801
+ * publication uses, exercised by default against the real filesystem. Tests
2802
+ * inject them to make collisions and mid-publication failures deterministic;
2803
+ * production code never reopens a claimed temp, and every seam default
2804
+ * satisfies the exclusive-create and full-write contracts.
2805
+ */
2806
+ export interface PublicationSeams {
2807
+ /** Exclusive temp selection; defaults to {@link defaultTempFactory}. */
2808
+ temp?: TempFactory;
2809
+ /** The full-write primitive; defaults to {@link writeFullBytes}. */
2810
+ writeFull?: (fd: number, contents: string) => void;
2811
+ /** The atomic rename; defaults to `renameSync`. */
2812
+ rename?: (temp: string, target: string) => void;
2813
+ /** Stage observation for coordination and failure injection. */
2814
+ onStage?: (stage: PublicationStage) => void;
2815
+ }
2816
+
2817
+ /** The observations a guarded publication announces. */
2818
+ export type PublicationStage =
2819
+ | "locked"
2820
+ | "verified"
2821
+ | /** create only: the target is exclusively open, bytes not yet written */ "targetOpen"
2822
+ | "published";
2823
+
2824
+ /**
2825
+ * The bounded retry over exclusive temp names: each attempt is a fresh name
2826
+ * the factory must exclusively create; an `EEXIST` retry is bounded and never
2827
+ * touches the foreign object it collided with.
2828
+ * @param make The temp seam in use.
2829
+ * @param target The absolute target path, for the candidate names.
2830
+ * @returns An owned temp, or null when every attempt collided.
2831
+ */
2832
+ function acquireTemp(make: TempFactory, target: string): OwnedTemp | null {
2833
+ for (let remaining = MAX_TEMP_ATTEMPTS; remaining > 0; remaining -= 1) {
2834
+ try {
2835
+ return { ...make(target) };
2836
+ } catch (error) {
2837
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
2838
+ throw error;
2839
+ }
2840
+ }
2841
+ }
2842
+ return null;
2843
+ }
2844
+
2845
+ /**
2846
+ * Verify the replace contract while the target lock is held: the target must
2847
+ * be a regular file (a vanished target is a conflict, never an implicit
2848
+ * create) and its current bytes must match the caller's token, so two
2849
+ * differently routed replace calls verify the same physical bytes under the
2850
+ * same lock.
2851
+ * @param target The absolute, guarded target path.
2852
+ * @param options Publication options.
2853
+ * @param options.expectedRevision The replace's stale-write token.
2854
+ * @param options.label The qualified target label, for refusals.
2855
+ * @returns null when the write may proceed, else the refusal reason.
2856
+ */
2857
+ function verifyUnderLock(
2858
+ target: string,
2859
+ options: { expectedRevision?: string | undefined; label: string },
2860
+ ): string | null {
2861
+ let present: boolean;
2862
+ try {
2863
+ present = lstatSync(target).isFile();
2864
+ } catch {
2865
+ present = false;
2866
+ }
2867
+ if (!present) {
2868
+ return (
2869
+ `${REFUSE} no doc at ${options.label} — replace never ` +
2870
+ 'creates; pass mode:"create" for a new doc.'
2871
+ );
2872
+ }
2873
+ const found = revisionOf(readFileSync(target, "utf8"));
2874
+ if (found !== options.expectedRevision) {
2875
+ const short = (value: string | undefined): string =>
2876
+ (value ?? "none").slice(0, 12);
2877
+ return (
2878
+ `${REFUSE} ${options.label} changed since you read it ` +
2879
+ `(expected ${short(options.expectedRevision)}, found ` +
2880
+ `${short(found)}). Read it again, reconcile, and ` +
2881
+ "resubmit the replacement deliberately."
2882
+ );
2883
+ }
2884
+ return null;
2885
+ }
2886
+
2887
+ /**
2888
+ * The refusal for a caught publication error, or null when the error is not
2889
+ * one the caller should see as a refusal: a losing create's EEXIST, or an
2890
+ * unwritable lake's EACCES/EPERM. Everything else propagates.
2891
+ * @param label The qualified target label, for refusals.
2892
+ * @param mode The mutation mode the write follows.
2893
+ * @param error The caught error.
2894
+ * @returns The refusal reason, or null to rethrow.
2895
+ */
2896
+ function publicationErrorRefusal(
2897
+ label: string,
2898
+ mode: AddMode,
2899
+ error: unknown,
2900
+ ): string | null {
2901
+ const code = (error as NodeJS.ErrnoException).code;
2902
+ if (mode === "create" && code === "EEXIST") {
2903
+ return (
2904
+ `${REFUSE} ${label} already exists — read it and pass ` +
2905
+ 'mode:"replace" to overwrite it deliberately.'
2906
+ );
2907
+ }
2908
+ if (code === "EACCES" || code === "EPERM") {
2909
+ return (
2910
+ `${REFUSE} could not publish ${label}: ${String(error)}. ` +
2911
+ "The lake or its directories are not writable by this process."
2912
+ );
2913
+ }
2914
+ return null;
2915
+ }
2916
+
2917
+ /**
2918
+ * The publication state/ownership sketch (one replace call):
2919
+ *
2920
+ * acquire lock exclusive create beside the physical target
2921
+ * onStage locked
2922
+ * verify current bytes re-read under the lock (stale refusal)
2923
+ * onStage verified
2924
+ * create temp exclusive create beside the target; owned fd retained
2925
+ * write full through the fd only; short writes loop or fail
2926
+ * rename atomic temp → target; the file published is ours
2927
+ * onStage published
2928
+ * release lock
2929
+ * cleanup duties: on any ordinary failure, unlink ONLY the temp this
2930
+ * operation created; the lock release in the outer finally; never a
2931
+ * foreign temp, lock or document path.
2932
+ *
2933
+ * The guarantee covers cooperating plugin writers; a manual editor can still
2934
+ * race any filesystem operation, and a local attacker able to swap
2935
+ * directories between individual syscalls is outside this threat model
2936
+ * (documented, not fought with privileged machinery).
2937
+ * @param spec The lake spec.
2938
+ * @param target The absolute, guarded target path.
2939
+ * @param contents The serialised file contents.
2940
+ * @param options Publication options.
2941
+ * @param options.mode The mutation mode.
2942
+ * @param options.expectedRevision The replace's stale-write token.
2943
+ * @param options.label The qualified target label, for refusals.
2944
+ * @param options.seams The injectable publication primitives.
2945
+ * @returns The new revision, or the refusal reason.
2946
+ */
2947
+ /** The resources one guarded publication has claimed so far. */
2948
+ interface PublicationLedger {
2949
+ /** The temp this operation exclusively created, while unpublished. */
2950
+ owned: OwnedTemp | null;
2951
+ /** Whether the submitted bytes are durably at the target. */
2952
+ published: boolean;
2953
+ /** Whether the target file is this operation's own `wx` create. */
2954
+ createdTarget: boolean;
2955
+ }
2956
+
2957
+ /**
2958
+ * The guarded create: exclusive open, full write through the descriptor, no
2959
+ * overwrite. Sets the ledger on the way through; throws on failure so the
2960
+ * caller's cleanup owns the partial file.
2961
+ * @param target The absolute, guarded target path.
2962
+ * @param contents The serialised file contents.
2963
+ * @param ledger The claimed resources, updated in place.
2964
+ * @param writeFull The full-write primitive.
2965
+ * @param onStage Stage observation.
2966
+ */
2967
+ function publishCreate(
2968
+ target: string,
2969
+ contents: string,
2970
+ ledger: PublicationLedger,
2971
+ writeFull: (fd: number, contents: string) => void,
2972
+ onStage: (stage: PublicationStage) => void,
2973
+ ): void {
2974
+ mkdirSync(dirname(target), { recursive: true });
2975
+ const fd = openSync(target, "wx");
2976
+ // From here the file at the target is this operation's own exclusive
2977
+ // create: a failure owns and removes it, never a rival's `EEXIST` loss.
2978
+ ledger.createdTarget = true;
2979
+ // Deliberately observable window: a create's guarantees are exclusive
2980
+ // naming and eventually-complete bytes, not the replacement path's
2981
+ // all-or-nothing swap — "targetOpen" marks the readers-see-it-first state
2982
+ // for coordination and tests.
2983
+ onStage("targetOpen");
2984
+ try {
2985
+ writeFull(fd, contents);
2986
+ } finally {
2987
+ closeSync(fd);
2988
+ }
2989
+ // The whole sequence succeeded: the no-overwrite create contract, now
2990
+ // with a full-write guarantee.
2991
+ ledger.published = true;
2992
+ onStage("published");
2993
+ }
2994
+
2995
+ /**
2996
+ * The guarded replace under the held lock: re-verify the token, exclusively
2997
+ * create a fresh temp, write the whole sequence through its descriptor and
2998
+ * swap atomically.
2999
+ * @param target The absolute, guarded target path.
3000
+ * @param contents The serialised file contents.
3001
+ * @param ledger The claimed resources, updated in place.
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
+ * @param seams The publication seams.
3006
+ * @returns null when the bytes are published, else the refusal reason.
3007
+ */
3008
+ function publishReplace(
3009
+ target: string,
3010
+ contents: string,
3011
+ ledger: PublicationLedger,
3012
+ options: { expectedRevision?: string | undefined; label: string },
3013
+ seams: PublicationSeams,
3014
+ ): string | null {
3015
+ const refusal = verifyUnderLock(target, options);
3016
+ if (refusal !== null) {
3017
+ return refusal;
3018
+ }
3019
+ seams.onStage?.("verified");
3020
+ const temp = acquireTemp(seams.temp ?? defaultTempFactory, target);
3021
+ if (temp === null) {
3022
+ return (
3023
+ `${REFUSE} could not publish ${options.label}: every ` +
3024
+ "candidate temp name was occupied by an object this " +
3025
+ "operation does not own; nothing was changed."
3026
+ );
3027
+ }
3028
+ ledger.owned = temp;
3029
+ try {
3030
+ (seams.writeFull ?? writeFullBytes)(temp.fd, contents);
3031
+ } finally {
3032
+ closeSync(temp.fd);
3033
+ }
3034
+ (seams.rename ?? renameSync)(temp.path, target);
3035
+ // From here the temp path no longer holds our bytes.
3036
+ ledger.owned = null;
3037
+ ledger.published = true;
3038
+ seams.onStage?.("published");
3039
+ return null;
3040
+ }
3041
+
3042
+ /**
3043
+ * The publication state/ownership sketch (one call):
3044
+ *
3045
+ * acquire lock exclusive create beside the physical target
3046
+ * onStage locked
3047
+ * verify replace: current bytes re-read under the lock
3048
+ * onStage verified
3049
+ * open target create: `wx` open, this operation's file, no bytes yet
3050
+ * onStage targetOpen (create only — readers can observe the empty file)
3051
+ * write full through the fd only; short writes loop or fail
3052
+ * rename replace: atomic temp → target; the file published is ours
3053
+ * onStage published
3054
+ * release lock
3055
+ * cleanup duties: on any ordinary failure, unlink ONLY the temp this
3056
+ * operation created (and its own partial create target); the lock release
3057
+ * in the outer finally; never a foreign temp, lock or document path.
3058
+ *
3059
+ * The guarantee covers cooperating plugin writers; a manual editor can still
3060
+ * race any filesystem operation, and a local attacker able to swap
3061
+ * directories between individual syscalls is outside this threat model
3062
+ * (documented, not fought with privileged machinery).
3063
+ * @param target The absolute, guarded target path. (The lake's identity is
3064
+ * implied by the guard.)
3065
+ * @param contents The serialised file contents.
3066
+ * @param options Publication options.
3067
+ * @param options.mode The mutation mode.
3068
+ * @param options.expectedRevision The replace's stale-write token.
3069
+ * @param options.label The qualified target label, for refusals.
3070
+ * @param options.seams The injectable publication primitives.
3071
+ * @returns The new revision, or the refusal reason.
3072
+ */
3073
+ function publishTarget(
3074
+ target: string,
3075
+ contents: string,
3076
+ options: {
3077
+ mode: AddMode;
3078
+ expectedRevision?: string | undefined;
3079
+ label: string;
3080
+ seams?: PublicationSeams;
3081
+ },
3082
+ ): { revision: string } | { reason: string } {
3083
+ const seams = options.seams ?? {};
3084
+ const lock = acquireTargetLock(target);
3085
+ if ("busy" in lock) {
3086
+ return { reason: `${REFUSE} ${lock.busy}` };
3087
+ }
3088
+ // The resource ledger: only the temp this operation exclusively created
3089
+ // is ever unlinked (and only while unpublished), and only a target file
3090
+ // this operation created itself is removed on a create failure — never a
3091
+ // rival's `EEXIST` loss.
3092
+ const ledger: PublicationLedger = {
3093
+ owned: null,
3094
+ published: false,
3095
+ createdTarget: false,
3096
+ };
3097
+ const onStage = (stage: PublicationStage): void => {
3098
+ seams.onStage?.(stage);
3099
+ };
3100
+ try {
3101
+ onStage("locked");
3102
+ if (options.mode === "create") {
3103
+ publishCreate(
3104
+ target,
3105
+ contents,
3106
+ ledger,
3107
+ seams.writeFull ?? writeFullBytes,
3108
+ onStage,
3109
+ );
3110
+ } else {
3111
+ const refusal = publishReplace(
3112
+ target,
3113
+ contents,
3114
+ ledger,
3115
+ options,
3116
+ seams,
3117
+ );
3118
+ if (refusal !== null) {
3119
+ return { reason: refusal };
3120
+ }
3121
+ }
3122
+ return { revision: revisionOf(contents) };
3123
+ } catch (error) {
3124
+ const handled = publicationFailure(target, ledger, options, error);
3125
+ if (handled !== null) {
3126
+ return { reason: handled };
3127
+ }
3128
+ throw error;
3129
+ } finally {
3130
+ if (ledger.owned !== null) {
3131
+ try {
3132
+ unlinkSync(ledger.owned.path);
3133
+ } catch {
3134
+ // Owned temp may already be gone with its rename.
3135
+ }
3136
+ }
3137
+ lock.release();
3138
+ }
3139
+ }
3140
+
3141
+ /**
3142
+ * Handle one caught publication error: remove the partial file this
3143
+ * operation created itself, translate the mapped refusal codes, and leave
3144
+ * everything else for the caller to rethrow.
3145
+ * @param target The absolute, guarded target path.
3146
+ * @param ledger The claimed resources.
3147
+ * @param options Publication options.
3148
+ * @param options.label The qualified target label, for refusals.
3149
+ * @param options.mode The mutation mode the write follows.
3150
+ * @param error The caught error.
3151
+ * @returns The refusal reason, or null to rethrow.
3152
+ */
3153
+ function publicationFailure(
3154
+ target: string,
3155
+ ledger: PublicationLedger,
3156
+ options: { label: string; mode: AddMode },
3157
+ error: unknown,
3158
+ ): string | null {
3159
+ if (ledger.createdTarget && !ledger.published) {
3160
+ // A create that opened `wx` then failed owns its own partial file;
3161
+ // remove it, and never the rival's.
3162
+ try {
3163
+ unlinkSync(target);
3164
+ } catch {
3165
+ // Best effort; the failure already reports itself.
3166
+ }
3167
+ }
3168
+ return publicationErrorRefusal(options.label, options.mode, error);
3169
+ }
3170
+
3171
+ /** One other configured lake, for publishing's physical containment checks. */
3172
+ export interface Containment {
3173
+ /** The other lake's identity and writability. */
3174
+ lake: { name: string; root: string; writable: boolean };
3175
+ /** The other lake's real (symlink-resolved) root. */
3176
+ realRoot: string;
3177
+ }
3178
+
3179
+ /**
3180
+ * The refusal for a public write whose physical target reaches a nested
3181
+ * lake's tree through the selected lake's route, or null. The owner is the
3182
+ * deepest enclosing configured lake: addressing content physically inside
3183
+ * another lake's root through an outer alias would mutate a subtree the outer
3184
+ * lake never indexes, and a read-only owner's tree must not become writable
3185
+ * by reaching it through a writable outer lake. Naming the correct owner
3186
+ * leaves the caller one explicit re-address away.
3187
+ * @param spec The selected lake being published through.
3188
+ * @param target The absolute, guarded target path.
3189
+ * @param others The other configured lakes' real roots.
3190
+ * @returns The refusal reason, or null when the selected lake owns the tree.
3191
+ */
3192
+ function containmentRefusal(
3193
+ spec: LakeSpec,
3194
+ target: string,
3195
+ others: readonly Containment[],
3196
+ ): string | null {
3197
+ const own = realPathOf(spec.root);
3198
+ const key = physicalTargetKey(target);
3199
+ let deepest: Containment | undefined;
3200
+ for (const other of others) {
3201
+ if (other.realRoot === own) {
3202
+ continue;
3203
+ }
3204
+ if (
3205
+ isUnder(other.realRoot, own) &&
3206
+ isUnder(key, other.realRoot) &&
3207
+ (deepest === undefined || isUnder(other.realRoot, deepest.realRoot))
3208
+ ) {
3209
+ deepest = other;
3210
+ }
3211
+ }
3212
+ if (deepest === undefined) {
3213
+ return null;
3214
+ }
3215
+ const access = deepest.lake.writable
3216
+ ? `address it through lake:'${deepest.lake.name}' so it lands in the tree that indexes that address`
3217
+ : `its owner lake '${deepest.lake.name}' is read-only, so this content cannot be written`;
3218
+ return (
3219
+ `${REFUSE} the addressed file physically resolves inside lake ` +
3220
+ `'${deepest.lake.name}' (${deepest.lake.root}; ` +
3221
+ `${deepest.lake.writable ? "writable" : "read-only"}) — ${access}.`
3222
+ );
3223
+ }
3224
+
2318
3225
  /**
2319
3226
  * Attach the filesystem seams to a lake spec.
2320
3227
  * @param spec The configured lake.
2321
3228
  * @param options Runtime options.
2322
3229
  * @param options.skip Absolute roots of other lakes, so a nested lake is not
2323
3230
  * collected twice.
3231
+ * @param options.containment The other lakes' real roots, for the publish
3232
+ * containment guard; omitted by direct callers that carry one lake only.
3233
+ * @param options.seams Injectable publication primitives; the real
3234
+ * filesystem defaults are used when absent, and tests inject them to make
3235
+ * collisions and failures deterministic.
2324
3236
  * @returns The lake the tool serves.
2325
3237
  */
2326
3238
  export function createLake(
2327
3239
  spec: LakeSpec,
2328
- options: { skip?: readonly string[] } = {},
3240
+ options: {
3241
+ skip?: readonly string[];
3242
+ containment?: readonly Containment[];
3243
+ seams?: PublicationSeams;
3244
+ } = {},
2329
3245
  ): Lake {
2330
3246
  const guarded = (path: string): string => {
2331
3247
  const target = safeLakeTarget(spec.root, path);
@@ -2362,18 +3278,49 @@ export function createLake(
2362
3278
  mkdirSync(dirname(target), { recursive: true });
2363
3279
  writeFileSync(target, contents);
2364
3280
  },
3281
+ publish: (path, contents, publishOptions) => {
3282
+ const target = guarded(path);
3283
+ if (!existsSync(spec.root)) {
3284
+ return {
3285
+ reason: `lake '${spec.name}' root ${spec.root} does not exist; create it first.`,
3286
+ };
3287
+ }
3288
+ if (options.containment !== undefined) {
3289
+ const refusal = containmentRefusal(
3290
+ spec,
3291
+ target,
3292
+ options.containment,
3293
+ );
3294
+ if (refusal !== null) {
3295
+ return { reason: refusal };
3296
+ }
3297
+ }
3298
+ return publishTarget(target, contents, {
3299
+ ...publishOptions,
3300
+ ...(options.seams !== undefined
3301
+ ? { seams: options.seams }
3302
+ : {}),
3303
+ });
3304
+ },
2365
3305
  };
2366
3306
  }
2367
3307
 
2368
3308
  /**
2369
3309
  * 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.
3310
+ * collected once and never published through an outer alias. Nesting is
3311
+ * decided on real paths, so a symlinked root cannot defeat containment: the
3312
+ * skip paths handed to the walker are re-anchored under the collecting lake's
3313
+ * configured root.
2373
3314
  * @param specs The configured lakes.
3315
+ * @param options Runtime options.
3316
+ * @param options.seams The publication seams observed by every lake's
3317
+ * guarded publication.
2374
3318
  * @returns The runtime lakes.
2375
3319
  */
2376
- export function createLakes(specs: readonly LakeSpec[]): Lake[] {
3320
+ export function createLakes(
3321
+ specs: readonly LakeSpec[],
3322
+ options: { seams?: PublicationSeams } = {},
3323
+ ): Lake[] {
2377
3324
  const realRoots = specs.map((spec) => realPathOf(spec.root));
2378
3325
  return specs.map((spec, index) => {
2379
3326
  const own = realRoots[index] ?? spec.root;
@@ -2386,13 +3333,63 @@ export function createLakes(specs: readonly LakeSpec[]): Lake[] {
2386
3333
  ? [join(spec.root, relative(own, otherRoot))]
2387
3334
  : [];
2388
3335
  });
2389
- return createLake(spec, { skip });
3336
+ const containment: Containment[] = specs.flatMap(
3337
+ (other, otherIndex) => {
3338
+ if (otherIndex === index) {
3339
+ return [];
3340
+ }
3341
+ return [
3342
+ {
3343
+ lake: {
3344
+ name: other.name,
3345
+ root: other.root,
3346
+ writable: other.writable,
3347
+ },
3348
+ realRoot: realRoots[otherIndex] ?? other.root,
3349
+ },
3350
+ ];
3351
+ },
3352
+ );
3353
+ return createLake(spec, {
3354
+ skip,
3355
+ ...(containment.length > 0 ? { containment } : {}),
3356
+ ...(options.seams !== undefined ? { seams: options.seams } : {}),
3357
+ });
2390
3358
  });
2391
3359
  }
2392
3360
 
2393
3361
  /** The lake classes the configuration accepts. */
2394
3362
  export const LAKE_CLASSES = ["repo", "machine", "global", "wiki"] as const;
2395
3363
 
3364
+ /**
3365
+ * The human-facing scope label per class, shown beside the config value in
3366
+ * help and candidate lists so agents can connect the two. Configuration
3367
+ * values never change: `repo`, `machine`, `global`, `wiki`.
3368
+ */
3369
+ export const CLASS_LABELS: Readonly<Record<LakeClass, string>> = {
3370
+ repo: "Repository",
3371
+ machine: "Personal",
3372
+ global: "Organisation",
3373
+ wiki: "Wiki",
3374
+ };
3375
+
3376
+ /**
3377
+ * The ownership guidance for a class, used when a lake carries no configured
3378
+ * description. Generic by design: domain-specific ownership is never inferred
3379
+ * from a path or a name.
3380
+ * @param lakeClass The lake's class.
3381
+ * @returns The concise default guidance.
3382
+ */
3383
+ export function classGuidance(lakeClass: LakeClass): string {
3384
+ const guidance: Record<LakeClass, string> = {
3385
+ repo: "knowledge needed to understand or maintain this repository",
3386
+ machine: "knowledge tied to one person's devices, accounts and setup",
3387
+ global: "organisation-wide standards, services and integrations",
3388
+ wiki: "human-facing reference and narrative",
3389
+ };
3390
+ return guidance[lakeClass];
3391
+ }
3392
+
2396
3393
  /** The repo-local lake additions file, relative to the location. */
2397
3394
  export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
2398
3395
 
@@ -2402,6 +3399,8 @@ export interface DocsPluginOptions {
2402
3399
  root?: unknown;
2403
3400
  /** The `root` lake's name; defaults to the root folder name. Needs `root`. */
2404
3401
  name?: unknown;
3402
+ /** The `root` lake's description; what belongs there. Needs `root`. */
3403
+ description?: unknown;
2405
3404
  /** Base lake declarations, merged after `lakesFile` and the repo file. */
2406
3405
  lakes?: unknown;
2407
3406
  /** A JSON file of base lakes (`{ "lakes": [...] }`). */
@@ -2511,6 +3510,7 @@ interface RawLake {
2511
3510
  root?: unknown;
2512
3511
  name?: unknown;
2513
3512
  writable?: unknown;
3513
+ description?: unknown;
2514
3514
  }
2515
3515
 
2516
3516
  /** Narrow an unknown lake declaration to its raw shape. */
@@ -2629,6 +3629,27 @@ function lakeWritableOf(
2629
3629
  return writable;
2630
3630
  }
2631
3631
 
3632
+ /**
3633
+ * Resolve a lake description: trimmed when present, absent when blank. A
3634
+ * non-string fails loudly — a mistyped description must not become lake prose.
3635
+ * @param entry The raw declaration.
3636
+ * @param name The resolved lake name, for the error.
3637
+ * @returns The trimmed description, or undefined when absent.
3638
+ */
3639
+ function lakeDescriptionOf(entry: RawLake, name: string): string | undefined {
3640
+ if (entry.description === undefined) {
3641
+ return undefined;
3642
+ }
3643
+ if (typeof entry.description !== "string") {
3644
+ throw new Error(
3645
+ `docs plugin lake '${name}' description must be a string, ` +
3646
+ `got ${JSON.stringify(entry.description)}.`,
3647
+ );
3648
+ }
3649
+ const trimmed = entry.description.trim();
3650
+ return trimmed === "" ? undefined : trimmed;
3651
+ }
3652
+
2632
3653
  /**
2633
3654
  * Validate one raw lake declaration and resolve its defaults.
2634
3655
  * @param raw The raw entry from a config file or inline option.
@@ -2646,12 +3667,14 @@ function lakeSpecFrom(
2646
3667
  const lakeClass = lakeClassOf(entry, fromRepoFile);
2647
3668
  const root = lakeRootOf(entry, lakeClass, location, fromRepoFile);
2648
3669
  const name = lakeNameOf(entry, root, lakeClass);
3670
+ const description = lakeDescriptionOf(entry, name);
2649
3671
  return {
2650
3672
  name,
2651
3673
  class: lakeClass,
2652
3674
  root,
2653
3675
  profile: lakeClass === "wiki" ? "wiki" : "knowledge",
2654
3676
  writable: lakeWritableOf(entry, lakeClass, name),
3677
+ ...(description !== undefined ? { description } : {}),
2655
3678
  };
2656
3679
  }
2657
3680
 
@@ -2684,6 +3707,12 @@ function assertLakesOptions(options: DocsPluginOptions): void {
2684
3707
  "set 'name' inside a 'lakes' entry instead.",
2685
3708
  );
2686
3709
  }
3710
+ if (options.description !== undefined && options.root === undefined) {
3711
+ throw new Error(
3712
+ "docs plugin option 'description' describes the 'root' lake; " +
3713
+ "set 'description' inside a 'lakes' entry instead.",
3714
+ );
3715
+ }
2687
3716
  }
2688
3717
 
2689
3718
  /** Map raw lake entries from a file to specs. */
@@ -2760,7 +3789,12 @@ export function resolveLakes(
2760
3789
  if (options.root !== undefined) {
2761
3790
  specs.push(
2762
3791
  lakeSpecFrom(
2763
- { class: "repo", root: options.root, name: options.name },
3792
+ {
3793
+ class: "repo",
3794
+ root: options.root,
3795
+ name: options.name,
3796
+ description: options.description,
3797
+ },
2764
3798
  location,
2765
3799
  false,
2766
3800
  ),
@@ -2887,7 +3921,37 @@ function nestingDepth(lake: Lake, lakes: readonly Lake[]): number {
2887
3921
  }).length;
2888
3922
  }
2889
3923
 
2890
- /** The lake `add` writes to, or the refusal reason. */
3924
+ /**
3925
+ * One candidate line for a destination refusal: name, scope label beside the
3926
+ * config value, root, availability, and the configured description or the
3927
+ * class guidance — everything an agent needs to choose deliberately.
3928
+ * @param lake The candidate lake.
3929
+ * @returns The rendered line.
3930
+ */
3931
+ function candidateLine(lake: Lake): string {
3932
+ const label = `${CLASS_LABELS[lake.class]} [${lake.class}]`;
3933
+ const missing = lakeMissing(lake) ? " — missing root" : "";
3934
+ const description = lake.description ?? classGuidance(lake.class);
3935
+ return `- ${lake.name} (${label}): ${lake.root}${missing} — ${description}`;
3936
+ }
3937
+
3938
+ /**
3939
+ * The lake `add` writes to, or the refusal reason.
3940
+ *
3941
+ * An explicit name selects that exact lake or refuses — read-only, unknown
3942
+ * and (later, in `addDoc`) missing-root lakes never fall back elsewhere.
3943
+ * With `lake` omitted the write needs exactly one deliberate destination: the
3944
+ * sole configured writable knowledge lake. When two or more writable
3945
+ * knowledge lakes are configured the omission refuses with every candidate —
3946
+ * nesting, class, configuration order and current root existence change
3947
+ * nothing, because folder depth cannot infer the semantic owner of a claim.
3948
+ * Nesting still resolves the physical owner of an already named target (see
3949
+ * {@link uniqueHolder} and the publication containment guard); destination
3950
+ * choice and physical ownership are deliberately separate decisions.
3951
+ * @param lakes The configured lakes.
3952
+ * @param name The explicit lake selector, when given.
3953
+ * @returns The target lake, or the refusal reason.
3954
+ */
2891
3955
  function writableLake(
2892
3956
  lakes: readonly Lake[],
2893
3957
  name: string | undefined,
@@ -2904,34 +3968,26 @@ function writableLake(
2904
3968
  }
2905
3969
  return lake;
2906
3970
  }
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,
3971
+ const pool = lakes.filter(
3972
+ (lake) => lake.profile === "knowledge" && lake.writable,
2911
3973
  );
2912
- const pool =
2913
- repos.length > 0
2914
- ? repos
2915
- : lakes.filter(
2916
- (lake) => lake.profile === "knowledge" && lake.writable,
2917
- );
2918
3974
  if (pool.length === 0) {
2919
3975
  return {
2920
3976
  reason:
2921
3977
  `${REFUSE} no writable knowledge lake is configured; ` +
2922
- "add a repo, machine, or global lake.",
3978
+ "wiki and read-only lakes are never written — add a repo, " +
3979
+ "machine or global lake.",
2923
3980
  };
2924
3981
  }
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
- }
3982
+ if (pool.length > 1) {
3983
+ return {
3984
+ reason:
3985
+ `${REFUSE} ${pool.length} writable knowledge lakes could ` +
3986
+ "take this write; pass lake — one of:\n" +
3987
+ pool.map((lake) => candidateLine(lake)).join("\n"),
3988
+ };
2933
3989
  }
2934
- return target;
3990
+ return pool[0] as Lake;
2935
3991
  }
2936
3992
 
2937
3993
  /** Qualify a path with its lake when more than one lake is configured. */
@@ -3093,55 +4149,267 @@ function checkLakes(
3093
4149
  return { content: assembleCheck(reports, skipped), checked };
3094
4150
  }
3095
4151
 
3096
- /** Add a doc to the target lake. */
3097
- function addDoc(
3098
- runtime: Runtime,
3099
- add: DocsAdd,
4152
+ /**
4153
+ * The refusal for a legacy-seam write whose target is in the wrong state:
4154
+ * a create whose target exists (never an implicit overwrite), a replace whose
4155
+ * target vanished (never an implicit create), or a replace whose current
4156
+ * bytes no longer match the caller's `expectedRevision`. Legacy closure
4157
+ * seams cannot exclude other processes, so this route is in-process only.
4158
+ * @param target The lake the write lands in.
4159
+ * @param outcome The prepared write.
4160
+ * @param outcome.mode The mutation mode the write follows.
4161
+ * @param outcome.path The root-relative target path.
4162
+ * @param expected The caller's revision token, when replacing.
4163
+ * @param labelled The qualified target label, for refusals.
4164
+ * @returns The refusal, or null when the target state allows the write.
4165
+ */
4166
+ function legacyPublicationRefusal(
4167
+ target: Lake,
4168
+ outcome: { mode: AddMode; path: string },
4169
+ expected: string | undefined,
4170
+ labelled: string,
4171
+ ): string | null {
4172
+ const present = target.exists(outcome.path);
4173
+ if (outcome.mode === "create") {
4174
+ return present
4175
+ ? `${REFUSE} ${labelled} already exists — read it and pass ` +
4176
+ 'mode:"replace" to overwrite it deliberately.'
4177
+ : null;
4178
+ }
4179
+ if (!present) {
4180
+ return (
4181
+ `${REFUSE} no doc at ${labelled} — replace never creates; ` +
4182
+ 'pass mode:"create" for a new doc.'
4183
+ );
4184
+ }
4185
+ const found = revisionOf(target.read(outcome.path));
4186
+ return found === expected
4187
+ ? null
4188
+ : `${REFUSE} ${labelled} changed since you read it (expected ` +
4189
+ `${(expected ?? "none").slice(0, 12)}, found ` +
4190
+ `${found.slice(0, 12)}). Read it again, reconcile, and ` +
4191
+ "resubmit the replacement deliberately.";
4192
+ }
4193
+
4194
+ /**
4195
+ * The lake a write lands in, with its root verified present: the destination
4196
+ * refusal ({@link writableLake}) plus the dormant-root refusal in one step, so
4197
+ * a mistyped root never bootstraps a tree.
4198
+ * @param lakes The configured lakes.
4199
+ * @param lakeName The explicit lake selector, when given.
4200
+ * @returns The verified lake, or the refusal reason.
4201
+ */
4202
+ function writeTarget(
4203
+ lakes: readonly Lake[],
3100
4204
  lakeName: string | undefined,
3101
- ): { content: string; written: boolean } {
3102
- const target = writableLake(runtime.lakes, lakeName);
4205
+ ): Lake | { reason: string } {
4206
+ const target = writableLake(lakes, lakeName);
3103
4207
  if ("reason" in target) {
3104
- return { content: target.reason, written: false };
4208
+ return target;
3105
4209
  }
3106
- // A typo'd root must not bootstrap a new tree; the area directories below
3107
- // an existing root still create on write.
3108
4210
  if (target.root !== "" && !existsSync(target.root)) {
3109
4211
  return {
3110
- content:
4212
+ reason:
3111
4213
  `${REFUSE} lake '${target.name}' root ${target.root} does ` +
3112
4214
  "not exist — create it first.",
3113
- written: false,
3114
4215
  };
3115
4216
  }
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;
4217
+ return target;
4218
+ }
4219
+
4220
+ /**
4221
+ * Publish a prepared write through a legacy lake's plain write seam,
4222
+ * translating the seam's path-safety error into a refusal.
4223
+ * @param target The lake the write lands in.
4224
+ * @param outcome The prepared write.
4225
+ * @param outcome.path The root-relative target path.
4226
+ * @param outcome.contents The serialised file contents.
4227
+ * @returns The refusal on an unsafe path, or null once written.
4228
+ */
4229
+ function publishLegacy(
4230
+ target: Lake,
4231
+ outcome: { path: string; contents: string },
4232
+ ): string | null {
3124
4233
  try {
3125
- updated = target.exists(outcome.path);
3126
4234
  target.write(outcome.path, outcome.contents);
3127
4235
  } catch (error) {
3128
4236
  if (error instanceof UnsafePathError) {
3129
- return {
3130
- content: `${REFUSE} ${error.message}`,
3131
- written: false,
3132
- };
4237
+ return `${REFUSE} ${error.message}`;
4238
+ }
4239
+ throw error;
4240
+ }
4241
+ return null;
4242
+ }
4243
+
4244
+ /**
4245
+ * Publish through a lake's guarded protocol seam, translating its path-safety
4246
+ * error into a refusal.
4247
+ * @param publish The lake's publication seam.
4248
+ * @param outcome The prepared write.
4249
+ * @param outcome.mode The mutation mode the write follows.
4250
+ * @param outcome.path The root-relative target path.
4251
+ * @param outcome.contents The serialised file contents.
4252
+ * @param expected The caller's revision token, when replacing.
4253
+ * @param label The qualified target label, for refusals.
4254
+ * @returns The new revision, or the refusal reason.
4255
+ */
4256
+ function guardedPublish(
4257
+ publish: NonNullable<Lake["publish"]>,
4258
+ outcome: { mode: AddMode; path: string; contents: string },
4259
+ expected: string | undefined,
4260
+ label: string,
4261
+ ): { revision: string } | { reason: string } {
4262
+ try {
4263
+ return publish(outcome.path, outcome.contents, {
4264
+ mode: outcome.mode,
4265
+ expectedRevision: expected,
4266
+ label,
4267
+ });
4268
+ } catch (error) {
4269
+ if (error instanceof UnsafePathError) {
4270
+ return { reason: `${REFUSE} ${error.message}` };
3133
4271
  }
3134
4272
  throw error;
3135
4273
  }
3136
- const head = `${address(target, outcome.path, runtime.lakes)} — ${
3137
- updated ? "updated" : "created"
3138
- }.`;
4274
+ }
4275
+
4276
+ /**
4277
+ * The one-line success report: the qualified target, the outcome, and an
4278
+ * override note when a near-twin was crossed deliberately.
4279
+ * @param outcome The prepared write.
4280
+ * @param outcome.mode The mutation mode the write follows.
4281
+ * @param label The qualified target label.
4282
+ * @param overridden Whether a near-twin override was used.
4283
+ * @returns The report head.
4284
+ */
4285
+ function successHead(
4286
+ outcome: { mode: AddMode },
4287
+ label: string,
4288
+ overridden: boolean,
4289
+ ): string {
4290
+ return (
4291
+ `${label} — ${outcome.mode === "replace" ? "replaced" : "created"}.` +
4292
+ (overridden ? " near-twin overridden." : "")
4293
+ );
4294
+ }
4295
+
4296
+ /**
4297
+ * The success metadata: the written flag, the resolved lake and path, and the
4298
+ * new content revision when the route produced one.
4299
+ * @param target The lake the write landed in.
4300
+ * @param outcome The prepared write.
4301
+ * @param outcome.path The root-relative target path.
4302
+ * @param revision The new revision, when the route produced one.
4303
+ * @returns The metadata object.
4304
+ */
4305
+ function successMetadata(
4306
+ target: Lake,
4307
+ outcome: { path: string },
4308
+ revision: string | undefined,
4309
+ ): Record<string, unknown> {
4310
+ return {
4311
+ written: true,
4312
+ lake: target.name,
4313
+ path: outcome.path,
4314
+ ...(revision !== undefined ? { revision } : {}),
4315
+ };
4316
+ }
4317
+
4318
+ /**
4319
+ * Publish a prepared write through the target lake's route: real lakes use
4320
+ * the guarded {@link publishTarget} protocol (lock, re-verify, atomic
4321
+ * publish); legacy closure lakes fall back to the plain seam with the same
4322
+ * contracts, in-process only.
4323
+ * @param target The verified destination lake.
4324
+ * @param outcome The prepared write.
4325
+ * @param outcome.mode The mutation mode the write follows.
4326
+ * @param outcome.path The root-relative target path.
4327
+ * @param outcome.contents The serialised file contents.
4328
+ * @param expected The caller's revision token, when replacing.
4329
+ * @param label The qualified target label, for refusals.
4330
+ * @returns The new revision, or the refusal reason.
4331
+ */
4332
+ function publishPrepared(
4333
+ target: Lake,
4334
+ outcome: {
4335
+ /** The mutation mode the write follows. */
4336
+ mode: AddMode;
4337
+ /** The root-relative target path. */
4338
+ path: string;
4339
+ /** The serialised file contents. */
4340
+ contents: string;
4341
+ },
4342
+ expected: string | undefined,
4343
+ label: string,
4344
+ ): { revision?: string; reason?: string } {
4345
+ if (target.publish !== undefined) {
4346
+ const result = guardedPublish(target.publish, outcome, expected, label);
4347
+ return "reason" in result
4348
+ ? { reason: result.reason }
4349
+ : { revision: result.revision };
4350
+ }
4351
+ const legacy = legacyPublicationRefusal(target, outcome, expected, label);
4352
+ if (legacy !== null) {
4353
+ return { reason: legacy };
4354
+ }
4355
+ const unsafe = publishLegacy(target, outcome);
4356
+ return unsafe === null ? {} : { reason: unsafe };
4357
+ }
4358
+
4359
+ /**
4360
+ * Add a doc to the target lake through its publication route: real lakes use
4361
+ * the guarded {@link publishTarget} protocol (lock, re-verify, atomic
4362
+ * publish); legacy closure lakes fall back to the plain seam with the same
4363
+ * contracts, in-process only.
4364
+ * @param runtime The resolved runtime.
4365
+ * @param add The typed request.
4366
+ * @param lakeName The explicit lake selector, when given.
4367
+ * @returns The result content, written flag and success metadata.
4368
+ */
4369
+ function addDoc(
4370
+ runtime: Runtime,
4371
+ add: DocsAdd,
4372
+ lakeName: string | undefined,
4373
+ ): {
4374
+ content: string;
4375
+ written: boolean;
4376
+ metadata?: Record<string, unknown>;
4377
+ } {
4378
+ const target = writeTarget(runtime.lakes, lakeName);
4379
+ if ("reason" in target) {
4380
+ return { content: target.reason, written: false };
4381
+ }
4382
+ const entries = target.collect();
4383
+ const outcome = prepareAdd(add, entries, {
4384
+ maxDocLines: runtime.maxDocLines,
4385
+ });
4386
+ if (outcome.action === "refuse") {
4387
+ return { content: outcome.reason, written: false };
4388
+ }
4389
+ const label = address(target, outcome.path, runtime.lakes);
4390
+ const expected = add.expectedRevision?.trim();
4391
+ const published = publishPrepared(target, outcome, expected, label);
4392
+ if (published.reason !== undefined) {
4393
+ return { content: published.reason, written: false };
4394
+ }
4395
+ const revision = published.revision;
4396
+ const head = successHead(
4397
+ outcome,
4398
+ label,
4399
+ outcome.mode === "create" && overrideGiven(add),
4400
+ );
3139
4401
  // The path and outcome are the answer; a list of same-area neighbours was
3140
4402
  // unsolicited context that grew with the area and was picked by collection
3141
4403
  // order, not relevance. Refusals still carry their candidates.
3142
4404
  const notes =
3143
4405
  outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
3144
- return { content: `${head}${notes}`, written: true };
4406
+ return {
4407
+ content:
4408
+ `${head}${notes}` +
4409
+ (revision !== undefined ? revisionNote(revision, true) : ""),
4410
+ written: true,
4411
+ metadata: successMetadata(target, outcome, revision),
4412
+ };
3145
4413
  }
3146
4414
 
3147
4415
  /** A parsed read request: the lake (when known) and the path. */
@@ -3222,7 +4490,13 @@ function readDoc(
3222
4490
  runtime: Runtime,
3223
4491
  given: string,
3224
4492
  lakeName: string | undefined,
3225
- ): { content: string; read: string | null } {
4493
+ ): {
4494
+ content: string;
4495
+ read: string | null;
4496
+ lake?: string;
4497
+ path?: string;
4498
+ revision?: string;
4499
+ } {
3226
4500
  const target = readTargetOf(runtime, given, lakeName);
3227
4501
  if (target.reason !== undefined) {
3228
4502
  return { content: target.reason, read: null };
@@ -3251,12 +4525,45 @@ function readDoc(
3251
4525
  const raw = selected.read(guarded.path);
3252
4526
  const { body } = parseFrontMatter(raw);
3253
4527
  const label = address(selected, guarded.path, runtime.lakes);
4528
+ const revision =
4529
+ selected.profile === "knowledge" ? revisionOf(raw) : undefined;
3254
4530
  return {
3255
- content: `${label} — ${titleOf(guarded.path, body)}\n\n${raw}`,
4531
+ content:
4532
+ `${label} — ${titleOf(guarded.path, body)}\n\n${raw}` +
4533
+ (revision !== undefined ? revisionNote(revision, false) : ""),
3256
4534
  read: label,
4535
+ lake: selected.name,
4536
+ path: guarded.path,
4537
+ // Wiki pages are read-only reference and promise no write-capable
4538
+ // revision workflow; knowledge docs carry their content hash.
4539
+ ...(revision !== undefined ? { revision } : {}),
3257
4540
  };
3258
4541
  }
3259
4542
 
4543
+ /**
4544
+ * The model-facing revision note: the one line that makes an optimistic-
4545
+ * concurrency token visible through every real host path, stored per-call
4546
+ * metadata kept alongside it for UI and programmatic callers. Host evidence
4547
+ * (opencode v2.2.5): a plugin tool's result reaches the model only through its
4548
+ * `content` — `Session.Tool.Success` persists metadata as optional UI data,
4549
+ * the model-facing lowering (`to-llm-message.ts` → `toolResult()`) serialises
4550
+ * only `content`, and the Code Mode twin's `resultValue()` drops `metadata`
4551
+ * for tools without an output schema. Content text is the one channel both
4552
+ * surfaces forward, so the token rides it. The note sits after an explicit
4553
+ * blank separator, never inside the verbatim document body.
4554
+ * @param revision The content's SHA-256 revision.
4555
+ * @param fresh False for a caller's read; true when a replacement just
4556
+ * published these bytes.
4557
+ * @returns The note text with its leading blank-line separator.
4558
+ */
4559
+ function revisionNote(revision: string, fresh: boolean): string {
4560
+ return (
4561
+ `\n\ndocs: revision ${revision} — ` +
4562
+ (fresh ? "the newly published bytes" : "these bytes") +
4563
+ "; cite it as expectedRevision when replacing (a stale token refuses)."
4564
+ );
4565
+ }
4566
+
3260
4567
  /** The lake help view shared by the tool and command. */
3261
4568
  function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
3262
4569
  return lakes.map((lake) => ({
@@ -3266,6 +4573,9 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
3266
4573
  writable: lake.writable,
3267
4574
  validated: lakeValidated(lake),
3268
4575
  missing: lakeMissing(lake),
4576
+ ...(lake.description !== undefined
4577
+ ? { description: lake.description }
4578
+ : {}),
3269
4579
  }));
3270
4580
  }
3271
4581
 
@@ -3308,7 +4618,7 @@ function executeRequest(runtime: Runtime, input: DocsInput): Result {
3308
4618
  const result = addDoc(runtime, request.add, request.lake);
3309
4619
  return {
3310
4620
  content: result.content,
3311
- metadata: { written: result.written },
4621
+ metadata: result.metadata ?? { written: result.written },
3312
4622
  };
3313
4623
  }
3314
4624
  if (request.verb === "check") {
@@ -3322,7 +4632,14 @@ function executeRequest(runtime: Runtime, input: DocsInput): Result {
3322
4632
  const result = readDoc(runtime, request.read, request.lake);
3323
4633
  return {
3324
4634
  content: result.content,
3325
- metadata: { read: result.read },
4635
+ metadata: {
4636
+ read: result.read,
4637
+ ...(result.lake !== undefined ? { lake: result.lake } : {}),
4638
+ ...(result.path !== undefined ? { path: result.path } : {}),
4639
+ ...(result.revision !== undefined
4640
+ ? { revision: result.revision }
4641
+ : {}),
4642
+ },
3326
4643
  };
3327
4644
  }
3328
4645
  if (request.verb === "help") {
@@ -3372,12 +4689,13 @@ export function createDocsTool(
3372
4689
  description: codemode
3373
4690
  ? TWIN_DESCRIPTION
3374
4691
  : "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.",
4692
+ "`README.md` files before a broad grep for how-to knowledge. " +
4693
+ "A blank search lists recent docs; `help` shows the lakes and " +
4694
+ "ranking. `read` returns a lake doc verbatim plus its " +
4695
+ "revision token; `instructions:` results open with the " +
4696
+ "built-in read tool. `add` creates or replaces a knowledge " +
4697
+ "doc (replace needs that revision); `check` audits " +
4698
+ "convention compliance.",
3381
4699
  input: DOCS_INPUT,
3382
4700
  options: codemode
3383
4701
  ? { namespace: "docs", codemode: true }
@@ -3406,8 +4724,9 @@ function addPrompt(subject: string): string {
3406
4724
  }
3407
4725
  return (
3408
4726
  "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" +
4727
+ 'tool\'s `add` with a typed DTO — mode:"create" for a new doc, ' +
4728
+ "metadata (Area/Keywords/Summary) and a body starting with an H1 — " +
4729
+ "for:\n\n" +
3411
4730
  subject
3412
4731
  );
3413
4732
  }
@@ -3673,9 +4992,9 @@ export default Plugin.define({
3673
4992
  "Entry point to the configured knowledge lakes " +
3674
4993
  "(repo, machine, global, read-only wiki) and nested project " +
3675
4994
  "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.",
4995
+ "focused knowledge at the narrowest justified scope. Use " +
4996
+ "before searching the codebase for how-to knowledge, or " +
4997
+ "when a session learns something durable.",
3679
4998
  path: AbsolutePath.make(fileURLToPath(skillURL)),
3680
4999
  content: skill,
3681
5000
  });