@markstanden/opencode-docs-plugin 0.9.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/CONVENTION.md +76 -42
- package/README.md +187 -38
- package/package.json +2 -2
- package/src/SKILL.md +60 -57
- package/src/index.ts +1549 -168
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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.
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
|
|
@@ -541,6 +582,13 @@ export const MIN_TERM_LENGTH = 3;
|
|
|
541
582
|
*/
|
|
542
583
|
export const MAX_RESULTS = 10;
|
|
543
584
|
|
|
585
|
+
/**
|
|
586
|
+
* The most characters a `check` report returns before it is truncated with an
|
|
587
|
+
* `and N more` tail. A finding-heavy run once returned 39,871 characters in one
|
|
588
|
+
* tool result; the cap keeps a report readable. The single knob for the cap.
|
|
589
|
+
*/
|
|
590
|
+
export const MAX_CHECK_CHARS = 8000;
|
|
591
|
+
|
|
544
592
|
/** Split a raw query into lower-cased, non-empty terms, dropping filler. */
|
|
545
593
|
function termsOf(query: string): string[] {
|
|
546
594
|
const terms = query
|
|
@@ -905,11 +953,15 @@ export interface LakeInfo {
|
|
|
905
953
|
validated: boolean;
|
|
906
954
|
/** Whether the root is absent, so the lake contributes nothing yet. */
|
|
907
955
|
missing?: boolean;
|
|
956
|
+
/** What belongs there, as configured; absent lakes use the class guidance. */
|
|
957
|
+
description?: string;
|
|
908
958
|
}
|
|
909
959
|
|
|
910
960
|
/**
|
|
911
961
|
* Describe the configured lakes, then the ranking. The agent learns where
|
|
912
|
-
* 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.
|
|
913
965
|
* @param lakes The configured lakes.
|
|
914
966
|
* @returns The help text.
|
|
915
967
|
*/
|
|
@@ -919,15 +971,30 @@ export function helpText(lakes: readonly LakeInfo[]): string {
|
|
|
919
971
|
const access = lake.writable ? "writable" : "read-only";
|
|
920
972
|
const checked = lake.validated ? "checked" : "not checked";
|
|
921
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);
|
|
922
980
|
lines.push(
|
|
923
|
-
`- ${lake.name} (${
|
|
981
|
+
`- ${lake.name} (${label}, ${access}, ${checked}): ${lake.root}${missing}`,
|
|
924
982
|
);
|
|
983
|
+
lines.push(` ${description}`);
|
|
925
984
|
}
|
|
926
985
|
lines.push(
|
|
927
986
|
"- instructions (read-only, not checked): nested project README.md " +
|
|
928
987
|
"files; results are prefixed `instructions:` and read with the " +
|
|
929
988
|
"built-in read tool",
|
|
930
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
|
+
);
|
|
931
998
|
return `${lines.join("\n")}\n\n${explainRanking()}`;
|
|
932
999
|
}
|
|
933
1000
|
|
|
@@ -1393,6 +1460,8 @@ export function slugify(text: string): string {
|
|
|
1393
1460
|
export type AddOutcome =
|
|
1394
1461
|
| {
|
|
1395
1462
|
action: "write";
|
|
1463
|
+
/** The mutation mode the write follows. */
|
|
1464
|
+
mode: AddMode;
|
|
1396
1465
|
/** Root-relative target. */
|
|
1397
1466
|
path: string;
|
|
1398
1467
|
/** The serialised file contents. */
|
|
@@ -1432,6 +1501,41 @@ function blankRequiredField(metadata: DocMetadata): string | null {
|
|
|
1432
1501
|
return null;
|
|
1433
1502
|
}
|
|
1434
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
|
+
|
|
1435
1539
|
/**
|
|
1436
1540
|
* Validate an explicit `add` path: relative, inside the lake root, naming a
|
|
1437
1541
|
* `.md` file.
|
|
@@ -1461,8 +1565,9 @@ function explicitTarget(given: string): { path: string } | { reason: string } {
|
|
|
1461
1565
|
|
|
1462
1566
|
/**
|
|
1463
1567
|
* The root-relative target path for an `add`, or the reason it is refused.
|
|
1464
|
-
* An explicit path must stay inside the lake root and name a `.md` file;
|
|
1465
|
-
*
|
|
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.
|
|
1466
1571
|
* @param add The typed request.
|
|
1467
1572
|
* @returns The target path, or the refusal reason.
|
|
1468
1573
|
*/
|
|
@@ -1471,6 +1576,11 @@ function targetOf(add: DocsAdd): { path: string } | { reason: string } {
|
|
|
1471
1576
|
if (given !== "") {
|
|
1472
1577
|
return explicitTarget(given);
|
|
1473
1578
|
}
|
|
1579
|
+
if (add.mode === "replace") {
|
|
1580
|
+
return {
|
|
1581
|
+
reason: "replace requires the path of the existing doc to replace.",
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1474
1584
|
const slug = slugify(h1Of(add.body) ?? "");
|
|
1475
1585
|
if (slug === "") {
|
|
1476
1586
|
return { reason: "The H1 does not yield a usable file name." };
|
|
@@ -1539,8 +1649,10 @@ export function nearTwins(
|
|
|
1539
1649
|
}
|
|
1540
1650
|
|
|
1541
1651
|
/**
|
|
1542
|
-
* The near-twin refusal for
|
|
1543
|
-
*
|
|
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.
|
|
1544
1656
|
* @param add The typed request.
|
|
1545
1657
|
* @param entries The current docs.
|
|
1546
1658
|
* @returns The refusal, or null.
|
|
@@ -1549,7 +1661,7 @@ function nearTwinRefusal(
|
|
|
1549
1661
|
add: DocsAdd,
|
|
1550
1662
|
entries: readonly DocEntry[],
|
|
1551
1663
|
): AddOutcome | null {
|
|
1552
|
-
if (
|
|
1664
|
+
if (add.mode !== "create" || overrideGiven(add)) {
|
|
1553
1665
|
return null;
|
|
1554
1666
|
}
|
|
1555
1667
|
const nearTwinDocs = nearTwins(
|
|
@@ -1564,23 +1676,123 @@ function nearTwinRefusal(
|
|
|
1564
1676
|
action: "refuse",
|
|
1565
1677
|
reason:
|
|
1566
1678
|
`${REFUSE} ${add.metadata.Area} already holds a near-twin of ` +
|
|
1567
|
-
|
|
1568
|
-
"to write
|
|
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" +
|
|
1569
1682
|
render(nearTwinDocs),
|
|
1570
1683
|
candidates: nearTwinDocs,
|
|
1571
1684
|
};
|
|
1572
1685
|
}
|
|
1573
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
|
+
|
|
1574
1783
|
/**
|
|
1575
1784
|
* Validate an `add` request and prepare it for writing — pure, no fs.
|
|
1576
1785
|
*
|
|
1577
|
-
* Trust boundary: the caller is a model.
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
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.
|
|
1584
1796
|
* @param add The typed request.
|
|
1585
1797
|
* @param entries The current docs, for reconcile and neighbours.
|
|
1586
1798
|
* @param options Soft-limit configuration.
|
|
@@ -1598,30 +1810,21 @@ export function prepareAdd(
|
|
|
1598
1810
|
candidates: [],
|
|
1599
1811
|
});
|
|
1600
1812
|
|
|
1601
|
-
const
|
|
1602
|
-
if (
|
|
1603
|
-
return refuse(
|
|
1604
|
-
}
|
|
1605
|
-
if (/[/\\]|\.\./.test(add.metadata.Area)) {
|
|
1606
|
-
return refuse("Area must be a single folder name under the lake root.");
|
|
1813
|
+
const mode = modeOf(add);
|
|
1814
|
+
if (typeof mode !== "string") {
|
|
1815
|
+
return refuse(mode.reason);
|
|
1607
1816
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1817
|
+
const override = overrideRefusal(add);
|
|
1818
|
+
if (override !== null) {
|
|
1819
|
+
return refuse(override);
|
|
1610
1820
|
}
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
return refuse(
|
|
1615
|
-
"the body carries no claim section; add an H2/H3 headed Facts, " +
|
|
1616
|
-
"Inferred, Tested, Failed approaches or Open questions.",
|
|
1617
|
-
);
|
|
1821
|
+
const revision = revisionRefusal(add);
|
|
1822
|
+
if (revision !== null) {
|
|
1823
|
+
return refuse(revision);
|
|
1618
1824
|
}
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
`(${MIN_TERM_LENGTH}) or longer; add a distinctive term so ` +
|
|
1623
|
-
"reconcile can tell this doc apart.",
|
|
1624
|
-
);
|
|
1825
|
+
const content = contentRefusal(add);
|
|
1826
|
+
if (content !== null) {
|
|
1827
|
+
return refuse(content);
|
|
1625
1828
|
}
|
|
1626
1829
|
const target = targetOf(add);
|
|
1627
1830
|
if ("reason" in target) {
|
|
@@ -1640,6 +1843,7 @@ export function prepareAdd(
|
|
|
1640
1843
|
.slice(0, MAX_RESULTS);
|
|
1641
1844
|
return {
|
|
1642
1845
|
action: "write",
|
|
1846
|
+
mode,
|
|
1643
1847
|
path: target.path,
|
|
1644
1848
|
contents: serializeDoc(add.metadata, add.body),
|
|
1645
1849
|
neighbours,
|
|
@@ -2174,12 +2378,13 @@ export const RECORD_NUDGE =
|
|
|
2174
2378
|
"behaviour), add one `Record with docs add:` block per durable topic and " +
|
|
2175
2379
|
"scope — coalesce related findings rather than one block per observation, " +
|
|
2176
2380
|
"never one session dump, and never one mixed-scope doc. Each block names " +
|
|
2177
|
-
"its
|
|
2178
|
-
"
|
|
2179
|
-
"
|
|
2180
|
-
"
|
|
2181
|
-
"
|
|
2182
|
-
"
|
|
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.";
|
|
2183
2388
|
|
|
2184
2389
|
/**
|
|
2185
2390
|
* Resolve the plugin's `nudge` option: unset means on; a boolean passes
|
|
@@ -2212,6 +2417,24 @@ export interface LakeSpec {
|
|
|
2212
2417
|
profile: LakeProfile;
|
|
2213
2418
|
/** Whether `add` may write to it. */
|
|
2214
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;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
/**
|
|
2430
|
+
* Whether `check` validates a lake: validation follows writability, because a
|
|
2431
|
+
* lake the plugin cannot write is out of its control. A wiki lake is never
|
|
2432
|
+
* validated. One rule, no per-lake override.
|
|
2433
|
+
* @param lake The configured lake.
|
|
2434
|
+
* @returns True when the lake's docs are validated.
|
|
2435
|
+
*/
|
|
2436
|
+
function lakeValidated(lake: LakeSpec): boolean {
|
|
2437
|
+
return lake.profile === "knowledge" && lake.writable;
|
|
2215
2438
|
}
|
|
2216
2439
|
|
|
2217
2440
|
/** A lake with its runtime seams. */
|
|
@@ -2227,6 +2450,21 @@ export interface Lake extends LakeSpec {
|
|
|
2227
2450
|
* existing root; throws when the root is absent rather than creating it.
|
|
2228
2451
|
*/
|
|
2229
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 };
|
|
2230
2468
|
/**
|
|
2231
2469
|
* Legacy seam notes only. Real lakes compute their environment notes at
|
|
2232
2470
|
* call time — a root that appears, disappears or gains a git checkout is
|
|
@@ -2297,17 +2535,713 @@ function safeLakeTarget(root: string, path: string): string | null {
|
|
|
2297
2535
|
return target;
|
|
2298
2536
|
}
|
|
2299
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
|
+
|
|
2300
3225
|
/**
|
|
2301
3226
|
* Attach the filesystem seams to a lake spec.
|
|
2302
3227
|
* @param spec The configured lake.
|
|
2303
3228
|
* @param options Runtime options.
|
|
2304
3229
|
* @param options.skip Absolute roots of other lakes, so a nested lake is not
|
|
2305
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.
|
|
2306
3236
|
* @returns The lake the tool serves.
|
|
2307
3237
|
*/
|
|
2308
3238
|
export function createLake(
|
|
2309
3239
|
spec: LakeSpec,
|
|
2310
|
-
options: {
|
|
3240
|
+
options: {
|
|
3241
|
+
skip?: readonly string[];
|
|
3242
|
+
containment?: readonly Containment[];
|
|
3243
|
+
seams?: PublicationSeams;
|
|
3244
|
+
} = {},
|
|
2311
3245
|
): Lake {
|
|
2312
3246
|
const guarded = (path: string): string => {
|
|
2313
3247
|
const target = safeLakeTarget(spec.root, path);
|
|
@@ -2344,18 +3278,49 @@ export function createLake(
|
|
|
2344
3278
|
mkdirSync(dirname(target), { recursive: true });
|
|
2345
3279
|
writeFileSync(target, contents);
|
|
2346
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
|
+
},
|
|
2347
3305
|
};
|
|
2348
3306
|
}
|
|
2349
3307
|
|
|
2350
3308
|
/**
|
|
2351
3309
|
* Build every configured lake, each aware of the others so a nested root is
|
|
2352
|
-
* collected once
|
|
2353
|
-
*
|
|
2354
|
-
* under the collecting lake's
|
|
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.
|
|
2355
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.
|
|
2356
3318
|
* @returns The runtime lakes.
|
|
2357
3319
|
*/
|
|
2358
|
-
export function createLakes(
|
|
3320
|
+
export function createLakes(
|
|
3321
|
+
specs: readonly LakeSpec[],
|
|
3322
|
+
options: { seams?: PublicationSeams } = {},
|
|
3323
|
+
): Lake[] {
|
|
2359
3324
|
const realRoots = specs.map((spec) => realPathOf(spec.root));
|
|
2360
3325
|
return specs.map((spec, index) => {
|
|
2361
3326
|
const own = realRoots[index] ?? spec.root;
|
|
@@ -2368,13 +3333,63 @@ export function createLakes(specs: readonly LakeSpec[]): Lake[] {
|
|
|
2368
3333
|
? [join(spec.root, relative(own, otherRoot))]
|
|
2369
3334
|
: [];
|
|
2370
3335
|
});
|
|
2371
|
-
|
|
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
|
+
});
|
|
2372
3358
|
});
|
|
2373
3359
|
}
|
|
2374
3360
|
|
|
2375
3361
|
/** The lake classes the configuration accepts. */
|
|
2376
3362
|
export const LAKE_CLASSES = ["repo", "machine", "global", "wiki"] as const;
|
|
2377
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
|
+
|
|
2378
3393
|
/** The repo-local lake additions file, relative to the location. */
|
|
2379
3394
|
export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
|
|
2380
3395
|
|
|
@@ -2384,6 +3399,8 @@ export interface DocsPluginOptions {
|
|
|
2384
3399
|
root?: unknown;
|
|
2385
3400
|
/** The `root` lake's name; defaults to the root folder name. Needs `root`. */
|
|
2386
3401
|
name?: unknown;
|
|
3402
|
+
/** The `root` lake's description; what belongs there. Needs `root`. */
|
|
3403
|
+
description?: unknown;
|
|
2387
3404
|
/** Base lake declarations, merged after `lakesFile` and the repo file. */
|
|
2388
3405
|
lakes?: unknown;
|
|
2389
3406
|
/** A JSON file of base lakes (`{ "lakes": [...] }`). */
|
|
@@ -2493,6 +3510,7 @@ interface RawLake {
|
|
|
2493
3510
|
root?: unknown;
|
|
2494
3511
|
name?: unknown;
|
|
2495
3512
|
writable?: unknown;
|
|
3513
|
+
description?: unknown;
|
|
2496
3514
|
}
|
|
2497
3515
|
|
|
2498
3516
|
/** Narrow an unknown lake declaration to its raw shape. */
|
|
@@ -2611,6 +3629,27 @@ function lakeWritableOf(
|
|
|
2611
3629
|
return writable;
|
|
2612
3630
|
}
|
|
2613
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
|
+
|
|
2614
3653
|
/**
|
|
2615
3654
|
* Validate one raw lake declaration and resolve its defaults.
|
|
2616
3655
|
* @param raw The raw entry from a config file or inline option.
|
|
@@ -2628,12 +3667,14 @@ function lakeSpecFrom(
|
|
|
2628
3667
|
const lakeClass = lakeClassOf(entry, fromRepoFile);
|
|
2629
3668
|
const root = lakeRootOf(entry, lakeClass, location, fromRepoFile);
|
|
2630
3669
|
const name = lakeNameOf(entry, root, lakeClass);
|
|
3670
|
+
const description = lakeDescriptionOf(entry, name);
|
|
2631
3671
|
return {
|
|
2632
3672
|
name,
|
|
2633
3673
|
class: lakeClass,
|
|
2634
3674
|
root,
|
|
2635
3675
|
profile: lakeClass === "wiki" ? "wiki" : "knowledge",
|
|
2636
3676
|
writable: lakeWritableOf(entry, lakeClass, name),
|
|
3677
|
+
...(description !== undefined ? { description } : {}),
|
|
2637
3678
|
};
|
|
2638
3679
|
}
|
|
2639
3680
|
|
|
@@ -2666,6 +3707,12 @@ function assertLakesOptions(options: DocsPluginOptions): void {
|
|
|
2666
3707
|
"set 'name' inside a 'lakes' entry instead.",
|
|
2667
3708
|
);
|
|
2668
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
|
+
}
|
|
2669
3716
|
}
|
|
2670
3717
|
|
|
2671
3718
|
/** Map raw lake entries from a file to specs. */
|
|
@@ -2742,7 +3789,12 @@ export function resolveLakes(
|
|
|
2742
3789
|
if (options.root !== undefined) {
|
|
2743
3790
|
specs.push(
|
|
2744
3791
|
lakeSpecFrom(
|
|
2745
|
-
{
|
|
3792
|
+
{
|
|
3793
|
+
class: "repo",
|
|
3794
|
+
root: options.root,
|
|
3795
|
+
name: options.name,
|
|
3796
|
+
description: options.description,
|
|
3797
|
+
},
|
|
2746
3798
|
location,
|
|
2747
3799
|
false,
|
|
2748
3800
|
),
|
|
@@ -2869,7 +3921,37 @@ function nestingDepth(lake: Lake, lakes: readonly Lake[]): number {
|
|
|
2869
3921
|
}).length;
|
|
2870
3922
|
}
|
|
2871
3923
|
|
|
2872
|
-
/**
|
|
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
|
+
*/
|
|
2873
3955
|
function writableLake(
|
|
2874
3956
|
lakes: readonly Lake[],
|
|
2875
3957
|
name: string | undefined,
|
|
@@ -2886,34 +3968,26 @@ function writableLake(
|
|
|
2886
3968
|
}
|
|
2887
3969
|
return lake;
|
|
2888
3970
|
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
const repos = lakes.filter(
|
|
2892
|
-
(lake) => lake.class === "repo" && lake.writable,
|
|
3971
|
+
const pool = lakes.filter(
|
|
3972
|
+
(lake) => lake.profile === "knowledge" && lake.writable,
|
|
2893
3973
|
);
|
|
2894
|
-
const pool =
|
|
2895
|
-
repos.length > 0
|
|
2896
|
-
? repos
|
|
2897
|
-
: lakes.filter(
|
|
2898
|
-
(lake) => lake.profile === "knowledge" && lake.writable,
|
|
2899
|
-
);
|
|
2900
3974
|
if (pool.length === 0) {
|
|
2901
3975
|
return {
|
|
2902
3976
|
reason:
|
|
2903
3977
|
`${REFUSE} no writable knowledge lake is configured; ` +
|
|
2904
|
-
"add a repo,
|
|
3978
|
+
"wiki and read-only lakes are never written — add a repo, " +
|
|
3979
|
+
"machine or global lake.",
|
|
2905
3980
|
};
|
|
2906
3981
|
}
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
}
|
|
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
|
+
};
|
|
2915
3989
|
}
|
|
2916
|
-
return
|
|
3990
|
+
return pool[0] as Lake;
|
|
2917
3991
|
}
|
|
2918
3992
|
|
|
2919
3993
|
/** Qualify a path with its lake when more than one lake is configured. */
|
|
@@ -2971,6 +4045,80 @@ function searchDocs(
|
|
|
2971
4045
|
};
|
|
2972
4046
|
}
|
|
2973
4047
|
|
|
4048
|
+
/** Why a lake is skipped by `check`, in one short line. */
|
|
4049
|
+
function skippedLakeLine(lake: LakeSpec): string {
|
|
4050
|
+
const why = lake.profile === "wiki" ? "wiki class, read-only" : "read-only";
|
|
4051
|
+
return `${lake.name}: ${why}, not validated.`;
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
/**
|
|
4055
|
+
* Truncate a check report at a line boundary, counting the lines cut and
|
|
4056
|
+
* pointing at the narrower report. A finding-heavy lake otherwise floods a
|
|
4057
|
+
* tool result.
|
|
4058
|
+
* @param content The full report text.
|
|
4059
|
+
* @returns The report, within {@link MAX_CHECK_CHARS}, or its capped form.
|
|
4060
|
+
*/
|
|
4061
|
+
function cappedCheck(content: string): string {
|
|
4062
|
+
if (content.length <= MAX_CHECK_CHARS) {
|
|
4063
|
+
return content;
|
|
4064
|
+
}
|
|
4065
|
+
const lines = content.split("\n");
|
|
4066
|
+
const kept: string[] = [];
|
|
4067
|
+
let length = 0;
|
|
4068
|
+
for (const line of lines) {
|
|
4069
|
+
const next = length + (kept.length > 0 ? 1 : 0) + line.length;
|
|
4070
|
+
if (next > MAX_CHECK_CHARS) {
|
|
4071
|
+
break;
|
|
4072
|
+
}
|
|
4073
|
+
kept.push(line);
|
|
4074
|
+
length = next;
|
|
4075
|
+
}
|
|
4076
|
+
const omitted = lines.length - kept.length;
|
|
4077
|
+
const head = kept.length > 0 ? `${kept.join("\n")}\n\n` : "";
|
|
4078
|
+
return (
|
|
4079
|
+
`${head}… and ${omitted} more lines not shown. ` +
|
|
4080
|
+
"Narrow it per lake with the `lake` selector."
|
|
4081
|
+
);
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
/** Run one lake's check report and count the docs looked at. */
|
|
4085
|
+
function lakeReport(
|
|
4086
|
+
runtime: Runtime,
|
|
4087
|
+
lake: Lake,
|
|
4088
|
+
): { report: string; checked: number } {
|
|
4089
|
+
const entries = lake.collect();
|
|
4090
|
+
return {
|
|
4091
|
+
report: checkDocs(entries, lake.read, lakeNotes(lake), {
|
|
4092
|
+
maxDocLines: runtime.maxDocLines,
|
|
4093
|
+
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
4094
|
+
}),
|
|
4095
|
+
checked: entries.length,
|
|
4096
|
+
};
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
/**
|
|
4100
|
+
* Join the validated lakes' reports and the skipped-lake lines. Only the
|
|
4101
|
+
* reports are capped: "no findings" must never read as "compliant" when a lake
|
|
4102
|
+
* was simply not looked at.
|
|
4103
|
+
* @param reports The validated lakes' reports, in configuration order.
|
|
4104
|
+
* @param skipped One-line reasons for each lake `check` did not validate.
|
|
4105
|
+
* @returns The check result text.
|
|
4106
|
+
*/
|
|
4107
|
+
function assembleCheck(
|
|
4108
|
+
reports: readonly string[],
|
|
4109
|
+
skipped: readonly string[],
|
|
4110
|
+
): string {
|
|
4111
|
+
const report = reports.length > 0 ? cappedCheck(reports.join("\n\n")) : "";
|
|
4112
|
+
const tail = skipped.join("\n");
|
|
4113
|
+
if (report === "" && tail === "") {
|
|
4114
|
+
return "No knowledge lakes configured.";
|
|
4115
|
+
}
|
|
4116
|
+
if (report === "" || tail === "") {
|
|
4117
|
+
return report || tail;
|
|
4118
|
+
}
|
|
4119
|
+
return `${report}\n\n${tail}`;
|
|
4120
|
+
}
|
|
4121
|
+
|
|
2974
4122
|
/** Check the knowledge lakes, optionally limited to one. */
|
|
2975
4123
|
function checkLakes(
|
|
2976
4124
|
runtime: Runtime,
|
|
@@ -2981,105 +4129,287 @@ function checkLakes(
|
|
|
2981
4129
|
if (lake === undefined) {
|
|
2982
4130
|
return { content: `No lake named '${lakeName}'.`, checked: 0 };
|
|
2983
4131
|
}
|
|
2984
|
-
if (lake
|
|
2985
|
-
return {
|
|
2986
|
-
content:
|
|
2987
|
-
`Lake '${lake.name}' is a wiki lake — read-only reference, ` +
|
|
2988
|
-
"not validated.",
|
|
2989
|
-
checked: 0,
|
|
2990
|
-
};
|
|
4132
|
+
if (!lakeValidated(lake)) {
|
|
4133
|
+
return { content: skippedLakeLine(lake), checked: 0 };
|
|
2991
4134
|
}
|
|
2992
|
-
const
|
|
2993
|
-
return {
|
|
2994
|
-
content: checkDocs(entries, lake.read, lakeNotes(lake), {
|
|
2995
|
-
maxDocLines: runtime.maxDocLines,
|
|
2996
|
-
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
2997
|
-
}),
|
|
2998
|
-
checked: entries.length,
|
|
2999
|
-
};
|
|
4135
|
+
const { report, checked } = lakeReport(runtime, lake);
|
|
4136
|
+
return { content: cappedCheck(report), checked };
|
|
3000
4137
|
}
|
|
3001
|
-
const knowledge = runtime.lakes.filter(
|
|
3002
|
-
(lake) => lake.profile === "knowledge",
|
|
3003
|
-
);
|
|
3004
4138
|
let checked = 0;
|
|
3005
|
-
const reports =
|
|
3006
|
-
const
|
|
3007
|
-
checked +=
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
});
|
|
3012
|
-
return knowledge.length > 1 || runtime.lakes.length > 1
|
|
3013
|
-
? `${lake.name}:\n${report}`
|
|
3014
|
-
: report;
|
|
4139
|
+
const reports = runtime.lakes.filter(lakeValidated).map((lake) => {
|
|
4140
|
+
const outcome = lakeReport(runtime, lake);
|
|
4141
|
+
checked += outcome.checked;
|
|
4142
|
+
return runtime.lakes.length > 1
|
|
4143
|
+
? `${lake.name}:\n${outcome.report}`
|
|
4144
|
+
: outcome.report;
|
|
3015
4145
|
});
|
|
3016
|
-
const
|
|
3017
|
-
.filter((lake) => lake
|
|
3018
|
-
.map(
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
4146
|
+
const skipped = runtime.lakes
|
|
4147
|
+
.filter((lake) => !lakeValidated(lake))
|
|
4148
|
+
.map(skippedLakeLine);
|
|
4149
|
+
return { content: assembleCheck(reports, skipped), checked };
|
|
4150
|
+
}
|
|
4151
|
+
|
|
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.'
|
|
3023
4183
|
);
|
|
3024
4184
|
}
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
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.";
|
|
3032
4192
|
}
|
|
3033
4193
|
|
|
3034
|
-
/**
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
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[],
|
|
3038
4204
|
lakeName: string | undefined,
|
|
3039
|
-
): {
|
|
3040
|
-
const target = writableLake(
|
|
4205
|
+
): Lake | { reason: string } {
|
|
4206
|
+
const target = writableLake(lakes, lakeName);
|
|
3041
4207
|
if ("reason" in target) {
|
|
3042
|
-
return
|
|
4208
|
+
return target;
|
|
3043
4209
|
}
|
|
3044
|
-
// A typo'd root must not bootstrap a new tree; the area directories below
|
|
3045
|
-
// an existing root still create on write.
|
|
3046
4210
|
if (target.root !== "" && !existsSync(target.root)) {
|
|
3047
4211
|
return {
|
|
3048
|
-
|
|
4212
|
+
reason:
|
|
3049
4213
|
`${REFUSE} lake '${target.name}' root ${target.root} does ` +
|
|
3050
4214
|
"not exist — create it first.",
|
|
3051
|
-
written: false,
|
|
3052
4215
|
};
|
|
3053
4216
|
}
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
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 {
|
|
3062
4233
|
try {
|
|
3063
|
-
updated = target.exists(outcome.path);
|
|
3064
4234
|
target.write(outcome.path, outcome.contents);
|
|
3065
4235
|
} catch (error) {
|
|
3066
4236
|
if (error instanceof UnsafePathError) {
|
|
3067
|
-
return {
|
|
3068
|
-
content: `${REFUSE} ${error.message}`,
|
|
3069
|
-
written: false,
|
|
3070
|
-
};
|
|
4237
|
+
return `${REFUSE} ${error.message}`;
|
|
3071
4238
|
}
|
|
3072
4239
|
throw error;
|
|
3073
4240
|
}
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
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}` };
|
|
4271
|
+
}
|
|
4272
|
+
throw error;
|
|
4273
|
+
}
|
|
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
|
+
);
|
|
3077
4401
|
// The path and outcome are the answer; a list of same-area neighbours was
|
|
3078
4402
|
// unsolicited context that grew with the area and was picked by collection
|
|
3079
4403
|
// order, not relevance. Refusals still carry their candidates.
|
|
3080
4404
|
const notes =
|
|
3081
4405
|
outcome.notes.length > 0 ? `\n\n${outcome.notes.join("\n")}` : "";
|
|
3082
|
-
return {
|
|
4406
|
+
return {
|
|
4407
|
+
content:
|
|
4408
|
+
`${head}${notes}` +
|
|
4409
|
+
(revision !== undefined ? revisionNote(revision, true) : ""),
|
|
4410
|
+
written: true,
|
|
4411
|
+
metadata: successMetadata(target, outcome, revision),
|
|
4412
|
+
};
|
|
3083
4413
|
}
|
|
3084
4414
|
|
|
3085
4415
|
/** A parsed read request: the lake (when known) and the path. */
|
|
@@ -3160,7 +4490,13 @@ function readDoc(
|
|
|
3160
4490
|
runtime: Runtime,
|
|
3161
4491
|
given: string,
|
|
3162
4492
|
lakeName: string | undefined,
|
|
3163
|
-
): {
|
|
4493
|
+
): {
|
|
4494
|
+
content: string;
|
|
4495
|
+
read: string | null;
|
|
4496
|
+
lake?: string;
|
|
4497
|
+
path?: string;
|
|
4498
|
+
revision?: string;
|
|
4499
|
+
} {
|
|
3164
4500
|
const target = readTargetOf(runtime, given, lakeName);
|
|
3165
4501
|
if (target.reason !== undefined) {
|
|
3166
4502
|
return { content: target.reason, read: null };
|
|
@@ -3189,12 +4525,45 @@ function readDoc(
|
|
|
3189
4525
|
const raw = selected.read(guarded.path);
|
|
3190
4526
|
const { body } = parseFrontMatter(raw);
|
|
3191
4527
|
const label = address(selected, guarded.path, runtime.lakes);
|
|
4528
|
+
const revision =
|
|
4529
|
+
selected.profile === "knowledge" ? revisionOf(raw) : undefined;
|
|
3192
4530
|
return {
|
|
3193
|
-
content:
|
|
4531
|
+
content:
|
|
4532
|
+
`${label} — ${titleOf(guarded.path, body)}\n\n${raw}` +
|
|
4533
|
+
(revision !== undefined ? revisionNote(revision, false) : ""),
|
|
3194
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 } : {}),
|
|
3195
4540
|
};
|
|
3196
4541
|
}
|
|
3197
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
|
+
|
|
3198
4567
|
/** The lake help view shared by the tool and command. */
|
|
3199
4568
|
function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
|
|
3200
4569
|
return lakes.map((lake) => ({
|
|
@@ -3202,8 +4571,11 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
|
|
|
3202
4571
|
class: lake.class,
|
|
3203
4572
|
root: lake.root,
|
|
3204
4573
|
writable: lake.writable,
|
|
3205
|
-
validated: lake
|
|
4574
|
+
validated: lakeValidated(lake),
|
|
3206
4575
|
missing: lakeMissing(lake),
|
|
4576
|
+
...(lake.description !== undefined
|
|
4577
|
+
? { description: lake.description }
|
|
4578
|
+
: {}),
|
|
3207
4579
|
}));
|
|
3208
4580
|
}
|
|
3209
4581
|
|
|
@@ -3246,7 +4618,7 @@ function executeRequest(runtime: Runtime, input: DocsInput): Result {
|
|
|
3246
4618
|
const result = addDoc(runtime, request.add, request.lake);
|
|
3247
4619
|
return {
|
|
3248
4620
|
content: result.content,
|
|
3249
|
-
metadata: { written: result.written },
|
|
4621
|
+
metadata: result.metadata ?? { written: result.written },
|
|
3250
4622
|
};
|
|
3251
4623
|
}
|
|
3252
4624
|
if (request.verb === "check") {
|
|
@@ -3260,7 +4632,14 @@ function executeRequest(runtime: Runtime, input: DocsInput): Result {
|
|
|
3260
4632
|
const result = readDoc(runtime, request.read, request.lake);
|
|
3261
4633
|
return {
|
|
3262
4634
|
content: result.content,
|
|
3263
|
-
metadata: {
|
|
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
|
+
},
|
|
3264
4643
|
};
|
|
3265
4644
|
}
|
|
3266
4645
|
if (request.verb === "help") {
|
|
@@ -3310,12 +4689,13 @@ export function createDocsTool(
|
|
|
3310
4689
|
description: codemode
|
|
3311
4690
|
? TWIN_DESCRIPTION
|
|
3312
4691
|
: "Search the configured knowledge lakes and nested project " +
|
|
3313
|
-
"`README.md` files before a broad grep for how-to knowledge
|
|
3314
|
-
"
|
|
3315
|
-
"
|
|
3316
|
-
"
|
|
3317
|
-
"read tool. `add`
|
|
3318
|
-
"audits
|
|
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.",
|
|
3319
4699
|
input: DOCS_INPUT,
|
|
3320
4700
|
options: codemode
|
|
3321
4701
|
? { namespace: "docs", codemode: true }
|
|
@@ -3344,8 +4724,9 @@ function addPrompt(subject: string): string {
|
|
|
3344
4724
|
}
|
|
3345
4725
|
return (
|
|
3346
4726
|
"Record this as durable knowledge in the docs lakes: call the `docs` " +
|
|
3347
|
-
|
|
3348
|
-
"with an H1
|
|
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" +
|
|
3349
4730
|
subject
|
|
3350
4731
|
);
|
|
3351
4732
|
}
|
|
@@ -3611,9 +4992,9 @@ export default Plugin.define({
|
|
|
3611
4992
|
"Entry point to the configured knowledge lakes " +
|
|
3612
4993
|
"(repo, machine, global, read-only wiki) and nested project " +
|
|
3613
4994
|
"README.md files: find and read the right doc, then record " +
|
|
3614
|
-
"focused knowledge at the narrowest
|
|
3615
|
-
"searching the codebase for how-to knowledge, or
|
|
3616
|
-
"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.",
|
|
3617
4998
|
path: AbsolutePath.make(fileURLToPath(skillURL)),
|
|
3618
4999
|
content: skill,
|
|
3619
5000
|
});
|