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