@docsworth/cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +238 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +458 -0
  5. package/dist/core/acl.d.ts +37 -0
  6. package/dist/core/acl.js +136 -0
  7. package/dist/core/adopt.d.ts +68 -0
  8. package/dist/core/adopt.js +665 -0
  9. package/dist/core/audit-log.d.ts +133 -0
  10. package/dist/core/audit-log.js +290 -0
  11. package/dist/core/blocks.d.ts +35 -0
  12. package/dist/core/blocks.js +417 -0
  13. package/dist/core/codeowners.d.ts +62 -0
  14. package/dist/core/codeowners.js +133 -0
  15. package/dist/core/config.d.ts +101 -0
  16. package/dist/core/config.js +175 -0
  17. package/dist/core/coverage.d.ts +25 -0
  18. package/dist/core/coverage.js +41 -0
  19. package/dist/core/duration.d.ts +20 -0
  20. package/dist/core/duration.js +60 -0
  21. package/dist/core/edits.d.ts +13 -0
  22. package/dist/core/edits.js +20 -0
  23. package/dist/core/errors.d.ts +11 -0
  24. package/dist/core/errors.js +13 -0
  25. package/dist/core/frontmatter.d.ts +29 -0
  26. package/dist/core/frontmatter.js +49 -0
  27. package/dist/core/gate.d.ts +17 -0
  28. package/dist/core/gate.js +25 -0
  29. package/dist/core/gfm.d.ts +13 -0
  30. package/dist/core/gfm.js +244 -0
  31. package/dist/core/github.d.ts +152 -0
  32. package/dist/core/github.js +234 -0
  33. package/dist/core/host.d.ts +125 -0
  34. package/dist/core/host.js +22 -0
  35. package/dist/core/identity.d.ts +39 -0
  36. package/dist/core/identity.js +127 -0
  37. package/dist/core/init.d.ts +25 -0
  38. package/dist/core/init.js +86 -0
  39. package/dist/core/links.d.ts +82 -0
  40. package/dist/core/links.js +345 -0
  41. package/dist/core/lint.d.ts +16 -0
  42. package/dist/core/lint.js +149 -0
  43. package/dist/core/mcp/audit-transport.d.ts +16 -0
  44. package/dist/core/mcp/audit-transport.js +238 -0
  45. package/dist/core/mcp/server.d.ts +19 -0
  46. package/dist/core/mcp/server.js +216 -0
  47. package/dist/core/mcp/text.d.ts +14 -0
  48. package/dist/core/mcp/text.js +43 -0
  49. package/dist/core/mirror-view.d.ts +68 -0
  50. package/dist/core/mirror-view.js +367 -0
  51. package/dist/core/new-page.d.ts +52 -0
  52. package/dist/core/new-page.js +49 -0
  53. package/dist/core/policy.d.ts +10 -0
  54. package/dist/core/policy.js +30 -0
  55. package/dist/core/polish.d.ts +42 -0
  56. package/dist/core/polish.js +1000 -0
  57. package/dist/core/printable.d.ts +4 -0
  58. package/dist/core/printable.js +16 -0
  59. package/dist/core/references.d.ts +87 -0
  60. package/dist/core/references.js +1744 -0
  61. package/dist/core/repo.d.ts +46 -0
  62. package/dist/core/repo.js +118 -0
  63. package/dist/core/schema.d.ts +50 -0
  64. package/dist/core/schema.js +51 -0
  65. package/dist/core/search.d.ts +32 -0
  66. package/dist/core/search.js +132 -0
  67. package/dist/core/serve/auth.d.ts +23 -0
  68. package/dist/core/serve/auth.js +38 -0
  69. package/dist/core/serve/server.d.ts +21 -0
  70. package/dist/core/serve/server.js +138 -0
  71. package/dist/core/serve/sessions.d.ts +40 -0
  72. package/dist/core/serve/sessions.js +203 -0
  73. package/dist/core/serve/token.d.ts +10 -0
  74. package/dist/core/serve/token.js +96 -0
  75. package/dist/core/service.d.ts +473 -0
  76. package/dist/core/service.js +973 -0
  77. package/dist/core/sla.d.ts +16 -0
  78. package/dist/core/sla.js +20 -0
  79. package/dist/core/stale.d.ts +16 -0
  80. package/dist/core/stale.js +24 -0
  81. package/dist/core/tool-registry.d.ts +31 -0
  82. package/dist/core/tool-registry.js +80 -0
  83. package/dist/core/url.d.ts +4 -0
  84. package/dist/core/url.js +31 -0
  85. package/dist/core/verify-pages.d.ts +19 -0
  86. package/dist/core/verify-pages.js +110 -0
  87. package/dist/core/version.d.ts +22 -0
  88. package/dist/core/version.js +32 -0
  89. package/dist/core/writes.d.ts +58 -0
  90. package/dist/core/writes.js +113 -0
  91. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  92. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  93. package/dist/web/index.html +14 -0
  94. package/docs/format-spec.md +349 -0
  95. package/package.json +75 -4
  96. package/plugin/.claude-plugin/plugin.json +8 -0
  97. package/plugin/.mcp.json +8 -0
  98. package/plugin/bin/docsworth-mcp.mjs +46 -0
  99. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  100. package/plugin/skills/polish-page/SKILL.md +64 -0
  101. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  102. package/plugin/skills/verify-page/SKILL.md +46 -0
  103. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,973 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { appendFileSync, mkdirSync, statSync, watch } from "node:fs";
4
+ import { dirname, join, posix } from "node:path";
5
+ import { canSee, redactLinks, RESTRICTED, visibleMirror, visiblePages } from "./acl.js";
6
+ import { stateDir } from "./audit-log.js";
7
+ import { redactHiddenLinks, redactMirror, servedMirror, stillOwned } from "./mirror-view.js";
8
+ import { CODEOWNERS_LOCATIONS } from "./codeowners.js";
9
+ import { CONFIG_PATH } from "./config.js";
10
+ import { formatDate, utcToday } from "./duration.js";
11
+ import { pageStatus } from "./gate.js";
12
+ import { polishChanges, polishSummary } from "./polish.js";
13
+ import { MIRRORED_NOTE, NOT_CITABLE_NOTE } from "./mcp/text.js";
14
+ import { buildNewPage, NEW_PAGE_PREFIX, slugify } from "./new-page.js";
15
+ import { toolRefusal } from "./policy.js";
16
+ import { WRITE_TOOLS } from "./tool-registry.js";
17
+ import { EXTERNAL, linkPath, pageLinks } from "./links.js";
18
+ import { extractLinks, loadRepo, resolveLink } from "./repo.js";
19
+ import { ACL_INTERNAL, PAGE_TYPES } from "./schema.js";
20
+ import { splitFrontmatter } from "./frontmatter.js";
21
+ import { plainText, SearchIndex, snippet } from "./search.js";
22
+ import { listStale } from "./stale.js";
23
+ import { applyEdits, assertProtectedFieldsUnchanged, openPagePr, replaceBody, setLastVerified, WriteError } from "./writes.js";
24
+ const count = (text, needle) => text.split(needle).length - 1;
25
+ export class ToolError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.code = code;
30
+ }
31
+ }
32
+ /**
33
+ * The six knowledge tools as plain functions. The MCP server is a thin wrapper, and the
34
+ * ACL matrix test exercises this class directly, so there is one code path to audit.
35
+ *
36
+ * Invariant: every page a method touches comes from `visiblePages(repo, identity)`.
37
+ * Unknown ids and ids the caller cannot see raise the same not_found error.
38
+ */
39
+ /** How long after starting a watcher to reload once anyway. A write 500 ms after a restart
40
+ * was always reported (20 of 20); this leaves room on a loaded machine. */
41
+ export const WATCH_SETTLE_MS = 2000;
42
+ /** A draft_page body's limit, and how many fresh names it tries before giving up. */
43
+ export const DRAFT_MAX_CHARS = 512 * 1024;
44
+ export const DRAFT_NAME_ATTEMPTS = 3;
45
+ /** At most this many mirrored pages per search: context, not a second result list. */
46
+ export const MIRROR_LIMIT = 3;
47
+ /** Stand-in identity for policy checks that don't depend on who is asking. */
48
+ const LOCAL_IDENTITY_FOR_POLICY = { mode: "local", login: null, teams: new Set(), repoAccess: true, canWrite: false };
49
+ // Where a mirrored page is read from when its links are resolved: outside the knowledge root.
50
+ const MIRROR_ORIGIN = ".knowledge/mirror";
51
+ /**
52
+ * Loads the repo and keeps it until something changes, for one service or, in `docsworth
53
+ * serve`, for every session's service: one copy of the pages and their index however many
54
+ * tabs are open (a 3,000-page repo took about 50 MB per copy). It owns the one file watcher.
55
+ * What belongs to a session (its taint, its cached default branch) stays on the service.
56
+ */
57
+ export class RepoLoader {
58
+ root;
59
+ loaded = null;
60
+ dirty = false;
61
+ /** watch() was asked for, and close() hasn't stopped it. */
62
+ watching = false;
63
+ watcher = null;
64
+ settle = null;
65
+ /** The directory being watched, or to be once it exists (tests read it). */
66
+ watchedDir = null;
67
+ /** How many times the repo was loaded (tests). */
68
+ loads = 0;
69
+ /** Called after each load: serve moves its idle sessions onto it, so no old load is kept. */
70
+ onLoad = null;
71
+ constructor(root) {
72
+ this.root = root;
73
+ }
74
+ /** The current load: the same object until a change is seen, then a new one, built once. */
75
+ current() {
76
+ if (!this.loaded || this.dirty || this.inputsStamp(this.loaded.repo) !== this.loaded.inputs)
77
+ this.load();
78
+ // A knowledge root that couldn't be watched (the config named one that didn't exist
79
+ // yet): try again on every call, and load it as soon as it can be watched.
80
+ if (this.watching && !this.watcher) {
81
+ this.follow(this.loaded.repo);
82
+ if (this.watcher)
83
+ this.load();
84
+ }
85
+ return this.loaded;
86
+ }
87
+ /** The newest load, without looking for changes. */
88
+ latest() {
89
+ return this.loaded;
90
+ }
91
+ /** Has a change been seen that the next call will load (tests). */
92
+ get pending() {
93
+ return this.dirty;
94
+ }
95
+ load() {
96
+ const repo = loadRepo(this.root);
97
+ // Index bodies with links to non-internal pages already redacted, so a team page's
98
+ // title in a public page's link text can't surface through ranking or snippets.
99
+ const publicView = { mode: "local", login: null, teams: new Set(), repoAccess: true, canWrite: false };
100
+ const mirror = repo.config.mirror.serve ? servedMirror(this.root, repo.config.mirror.out_dir) : [];
101
+ // Links to mirrored pages outside the public view are redacted from the index too.
102
+ const teamMirror = mirror.filter((m) => !canSee(m, publicView));
103
+ const index = new SearchIndex(repo.pages.map((p) => ({
104
+ title: p.title,
105
+ headings: p.headings,
106
+ description: p.description,
107
+ body: redactHiddenLinks(redactLinks(p.body, p.path, repo, publicView), teamMirror, RESTRICTED),
108
+ keywords: [p.id, ...(p.applies_to ?? [])],
109
+ })));
110
+ // The snapshot is recomputed on first use, so it always names what is being served.
111
+ this.loaded = { repo, index, mirror, mirrorViews: new Map(), inputs: this.inputsStamp(repo), snap: null };
112
+ this.dirty = false;
113
+ this.loads++;
114
+ this.onLoad?.();
115
+ // Config may have moved the knowledge root: follow it.
116
+ if (this.watching && this.watchedDir !== join(this.root, repo.config.knowledge_root))
117
+ this.follow(repo);
118
+ }
119
+ /** Config and CODEOWNERS live outside the watched knowledge root; stat them per call. */
120
+ inputsStamp(repo) {
121
+ const stamp = (abs) => {
122
+ try {
123
+ return statSync(abs).mtimeMs;
124
+ }
125
+ catch {
126
+ return 0;
127
+ }
128
+ };
129
+ // A mirror run rewrites its ownership records, so their directory's mtime changes.
130
+ const mirror = repo.config.mirror.serve ? [stamp(join(stateDir(this.root), "mirror"))] : [];
131
+ return [...[CONFIG_PATH, ...CODEOWNERS_LOCATIONS].map((rel) => stamp(join(this.root, rel))), ...mirror].join("|");
132
+ }
133
+ /** Load again on the next call. */
134
+ invalidate() {
135
+ this.dirty = true;
136
+ }
137
+ /** Mark the load stale on any change under the knowledge root. */
138
+ watch() {
139
+ if (this.watching)
140
+ return;
141
+ const repo = this.current().repo;
142
+ this.watching = true;
143
+ this.follow(repo);
144
+ }
145
+ /** Watch `repo`'s knowledge root, in place of whatever was watched before. */
146
+ follow(repo) {
147
+ this.stop();
148
+ this.watchedDir = join(this.root, repo.config.knowledge_root);
149
+ try {
150
+ this.watcher = watch(this.watchedDir, { recursive: true }, () => {
151
+ this.dirty = true;
152
+ });
153
+ }
154
+ catch {
155
+ return; // not there yet: current() tries again on every call
156
+ }
157
+ // macOS drops events for changes made while a new watch stream is still starting
158
+ // (3 of 20 writes made right after a restart were never reported). Reload once the
159
+ // stream has had time to start, so a page changed in that window is not served stale.
160
+ this.settle = setTimeout(() => (this.dirty = true), WATCH_SETTLE_MS);
161
+ this.settle.unref();
162
+ }
163
+ close() {
164
+ this.watching = false;
165
+ this.stop();
166
+ }
167
+ stop() {
168
+ this.watcher?.close();
169
+ this.watcher = null;
170
+ if (this.settle)
171
+ clearTimeout(this.settle);
172
+ this.settle = null;
173
+ }
174
+ }
175
+ export class KnowledgeService {
176
+ root;
177
+ identity;
178
+ now;
179
+ loader;
180
+ /** A loader this service made is its own to close; a shared one belongs to its owner. */
181
+ ownsLoader;
182
+ loaded;
183
+ repo;
184
+ index;
185
+ defaultBranch = null;
186
+ /** Mirrored pages, when mirror.serve is on; one search index per audience (views). */
187
+ mirror = [];
188
+ mirrorViews;
189
+ /** Team ACLs whose content this session has seen (write-path taint check). */
190
+ tainted = new Set();
191
+ /** The random end of a drafted page's id and file name (replaced in tests). */
192
+ draftSuffix = () => randomBytes(6).toString("hex");
193
+ /** `loader`: a load shared with other services (`docsworth serve`'s sessions); else its own. */
194
+ constructor(root, identity, now = () => new Date(), loader) {
195
+ this.root = root;
196
+ this.identity = identity;
197
+ this.now = now;
198
+ this.ownsLoader = !loader;
199
+ this.loader = loader ?? new RepoLoader(root);
200
+ this.adopt(this.loader.current());
201
+ }
202
+ /** Load the repo again now. */
203
+ reload() {
204
+ this.loader.invalidate();
205
+ this.current();
206
+ }
207
+ /**
208
+ * Point at the loader's newest load, letting go of an older one. Only while no call of
209
+ * this service is running: `docsworth serve` calls it for a session with no request in
210
+ * flight, so an idle tab doesn't keep a whole old copy of the repo alive.
211
+ */
212
+ release() {
213
+ const latest = this.loader.latest();
214
+ if (latest && latest !== this.loaded)
215
+ this.adopt(latest);
216
+ }
217
+ /** Take a new load, and drop what this service cached from the last one. */
218
+ adopt(loaded) {
219
+ this.loaded = loaded;
220
+ this.repo = loaded.repo;
221
+ this.index = loaded.index;
222
+ this.mirror = loaded.mirror;
223
+ this.mirrorViews = loaded.mirrorViews;
224
+ // Config may have changed: forget the cached branch.
225
+ this.defaultBranch = null;
226
+ }
227
+ /** The watched directory and a pending reload live on the loader (tests read them here). */
228
+ get watchedDir() {
229
+ return this.loader.watchedDir;
230
+ }
231
+ get dirty() {
232
+ return this.loader.pending;
233
+ }
234
+ /**
235
+ * The mirrored pages `who` may see, each with links to the ones they can't see
236
+ * redacted (and to repo pages they can't see: a wiki page can link to one by its URL),
237
+ * and a search index over exactly that view: ranking, snippets and counts never draw on
238
+ * a page outside it. One per audience (the caller's teams), rebuilt on reload.
239
+ */
240
+ mirrorView(who) {
241
+ const pages = visibleMirror(this.mirror, who);
242
+ const key = `${who.repoAccess}|${[...who.teams].map((t) => t.toLowerCase()).sort().join(",")}`;
243
+ let view = this.mirrorViews.get(key);
244
+ if (!view) {
245
+ const seen = new Set(pages);
246
+ const hidden = this.mirror.filter((m) => !seen.has(m));
247
+ // Read from outside the knowledge root, so only a URL or a full path names a repo page.
248
+ const redacted = pages.map((m) => ({ ...m, markdown: redactLinks(redactMirror(m, hidden, RESTRICTED), `${MIRROR_ORIGIN}/${m.id}.md`, this.repo, who) }));
249
+ view = { pages: redacted, origin: pages, hidden, index: new SearchIndex(redacted.map((m) => ({ title: m.title, body: m.markdown, keywords: [m.id] }))) };
250
+ this.mirrorViews.set(key, view);
251
+ }
252
+ return view;
253
+ }
254
+ /** Reload on the next call. */
255
+ invalidate() {
256
+ this.loader.invalidate();
257
+ }
258
+ /** Mark the index dirty on any change under the knowledge root. */
259
+ watch() {
260
+ this.loader.watch();
261
+ }
262
+ /** Stops this service's own watcher; a shared loader's is its owner's to stop. */
263
+ close() {
264
+ if (this.ownsLoader)
265
+ this.loader.close();
266
+ }
267
+ current() {
268
+ const loaded = this.loader.current();
269
+ if (loaded !== this.loaded)
270
+ this.adopt(loaded);
271
+ return this.repo;
272
+ }
273
+ /**
274
+ * The knowledge actually being served: the checkout's HEAD and whether the served files
275
+ * differ from it, plus a hash of the loaded config, ownership and pages. Refreshed after
276
+ * every reload, so an audit event names the snapshot its answer came from.
277
+ */
278
+ snapshot() {
279
+ const repo = this.current();
280
+ const loaded = this.loaded;
281
+ if (loaded.snap)
282
+ return loaded.snap;
283
+ const h = createHash("sha256");
284
+ h.update(JSON.stringify(repo.config));
285
+ h.update(JSON.stringify(repo.codeowners.rules.map((r) => [r.pattern, r.owners.map((o) => o.raw)])));
286
+ for (const p of [...repo.pages].sort((a, b) => a.path.localeCompare(b.path)))
287
+ h.update(`${p.path}\0${JSON.stringify(p)}\0`);
288
+ for (const m of loaded.mirror)
289
+ h.update(`${m.id}\0${JSON.stringify(m)}\0`);
290
+ const head = spawnSync("git", ["-C", this.root, "rev-parse", "HEAD"], { encoding: "utf8" });
291
+ const status = head.status === 0 ? spawnSync("git", ["-C", this.root, "--literal-pathspecs", "status", "--porcelain", "--", repo.config.knowledge_root, CONFIG_PATH, ...CODEOWNERS_LOCATIONS], { encoding: "utf8" }) : null;
292
+ loaded.snap = {
293
+ commit: head.status === 0 ? head.stdout.trim() : null,
294
+ dirty: status && status.status === 0 ? status.stdout.trim().length > 0 : null,
295
+ content_sha256: h.digest("hex"),
296
+ };
297
+ return loaded.snap;
298
+ }
299
+ /** Every page id, team and applies_to tag the repo uses: what an audited argument may record. */
300
+ knownValues() {
301
+ const repo = this.current();
302
+ return {
303
+ ids: new Set([...repo.pages.map((p) => p.id), ...this.mirror.map((m) => m.id)]),
304
+ teams: new Set([
305
+ ...repo.pages.flatMap((p) => [p.owner_team, p.acl]),
306
+ ...this.mirror.flatMap((m) => [m.acl, ...(m.owner_team ? [m.owner_team] : [])]),
307
+ // Teams CODEOWNERS names in this org: a new page's owner may have no page yet.
308
+ ...repo.codeowners.rules.flatMap((r) => r.owners.filter((o) => o.kind === "team" && o.org?.toLowerCase() === repo.config.github.org).map((o) => o.team.toLowerCase())),
309
+ ]),
310
+ tags: new Set(repo.pages.flatMap((p) => p.applies_to ?? [])),
311
+ };
312
+ }
313
+ /** Whether mirrored pages are served (mirror.serve). */
314
+ mirrorServed() {
315
+ return this.current().config.mirror.serve;
316
+ }
317
+ /** The org policy in force now (config is re-read when it changes). */
318
+ policy() {
319
+ return this.current().config.policy;
320
+ }
321
+ get org() {
322
+ return this.repo.config.github.org;
323
+ }
324
+ /** Policy gate, checked on every call, before any page is resolved. */
325
+ permit(tool, who) {
326
+ const why = toolRefusal(this.current().config.policy, this.repo.config.github.org, tool, who);
327
+ if (why)
328
+ throw new ToolError("forbidden", why);
329
+ }
330
+ summary(page) {
331
+ const s = pageStatus(page, this.repo, this.now());
332
+ return {
333
+ id: page.id,
334
+ title: page.title,
335
+ path: page.path,
336
+ type: page.type,
337
+ status: page.status,
338
+ owner_team: page.owner_team,
339
+ last_verified: page.last_verified,
340
+ due: s.sla.due,
341
+ sla_state: s.sla.state,
342
+ citable: s.citable,
343
+ };
344
+ }
345
+ resolve(id, who) {
346
+ const page = typeof id === "string" ? this.current().byId.get(id) : undefined;
347
+ if (!page || !canSee(page, who))
348
+ throw new ToolError("not_found", `no page with id \`${String(id)}\``);
349
+ return page;
350
+ }
351
+ /** Any restricted page represented in a response (content, snippet or just its title)
352
+ * taints the session for the write-path check. */
353
+ taint(pages) {
354
+ for (const p of pages)
355
+ if (p.acl !== ACL_INTERNAL)
356
+ this.tainted.add(p.acl);
357
+ }
358
+ noteRead(tool, pages) {
359
+ this.taint(pages);
360
+ if (!this.repo.config.usage_log || pages.length === 0)
361
+ return;
362
+ const file = join(this.root, ".knowledge/usage.jsonl");
363
+ try {
364
+ mkdirSync(dirname(file), { recursive: true });
365
+ // Page ids and the tool only: queries can carry sensitive text and are never logged.
366
+ appendFileSync(file, `${JSON.stringify({ ts: this.now().toISOString(), tool, ids: pages.map((p) => p.id) })}\n`);
367
+ }
368
+ catch {
369
+ /* usage logging is best-effort and must never fail a read */
370
+ }
371
+ }
372
+ async search(args) {
373
+ if (!args.query?.trim())
374
+ throw new ToolError("invalid", "query is empty");
375
+ const asText = args.snippets === "text";
376
+ // Cut from the redacted page either way: text only takes markup away. Each is at most 240
377
+ // characters around the query; a text window isn't the markdown one's, which may end
378
+ // inside markup (docs/security-model.md, "Redacted links").
379
+ const cut = (markdown) => snippet(asText ? plainText(markdown) : markdown, args.query);
380
+ const who = await this.identity.get();
381
+ this.permit("search", who);
382
+ const repo = this.current();
383
+ const includeStale = Boolean(args.include_stale) && repo.config.policy.include_stale !== "denied";
384
+ const limit = Math.min(Math.max(args.limit ?? 8, 1), 25);
385
+ const now = this.now();
386
+ const visible = new Set(visiblePages(repo, who));
387
+ const accept = (i) => {
388
+ const p = repo.pages[i];
389
+ return (visible.has(p) &&
390
+ (!args.type || p.type === args.type) &&
391
+ (!args.owner_team || p.owner_team === args.owner_team) &&
392
+ (!args.applies_to || (p.applies_to ?? []).includes(args.applies_to)));
393
+ };
394
+ const results = [];
395
+ const excluded = [];
396
+ for (const hit of this.index.search(args.query, limit * 3, accept)) {
397
+ const page = repo.pages[hit.index];
398
+ const status = pageStatus(page, repo, now);
399
+ if (!status.citable && !includeStale) {
400
+ if (excluded.length < 5)
401
+ excluded.push({ id: page.id, title: page.title, owner_team: page.owner_team, reason: status.gate_reason });
402
+ continue;
403
+ }
404
+ if (results.length >= limit)
405
+ continue;
406
+ results.push({
407
+ ...this.summary(page),
408
+ snippet: cut(this.pageText(page, who)),
409
+ score: hit.score,
410
+ ...(status.citable ? {} : { gate: status.gate_reason }),
411
+ });
412
+ }
413
+ // Mirrored pages, from this caller's view only, in a list of their own: never in
414
+ // `results`, never citable. Filters on page type and owner don't apply to them.
415
+ const mirrored = [];
416
+ if (this.mirror.length && !args.type && !args.applies_to) {
417
+ const view = this.mirrorView(who);
418
+ for (const hit of view.index.search(args.query, MIRROR_LIMIT * 3, (i) => !args.owner_team || view.pages[i].owner_team === args.owner_team)) {
419
+ if (mirrored.length >= MIRROR_LIMIT)
420
+ break;
421
+ // Served from memory, so checked against its file first: one edited or deleted
422
+ // since loading is dropped, and the mirror reloaded on the next call.
423
+ if (!stillOwned(view.origin[hit.index])) {
424
+ this.invalidate();
425
+ continue;
426
+ }
427
+ const m = view.pages[hit.index];
428
+ mirrored.push({ id: m.id, title: m.title, source: m.source, source_url: m.url, owner_team: m.owner_team, last_edited: m.last_edited, citable: false, snippet: cut(m.markdown) });
429
+ }
430
+ this.taint(mirrored.map((h) => view.pages.find((m) => m.id === h.id)));
431
+ }
432
+ this.noteRead("search", results.map((r) => repo.byId.get(r.id)));
433
+ this.taint(excluded.map((e) => repo.byId.get(e.id)));
434
+ const notes = [...(excluded.length ? [NOT_CITABLE_NOTE] : []), ...(mirrored.length ? [MIRRORED_NOTE] : [])];
435
+ return { results, excluded, ...(mirrored.length ? { mirrored } : {}), ...(notes.length ? { note: notes.join(" ") } : {}), ...(asText ? { snippets: "text" } : {}) };
436
+ }
437
+ async read(args) {
438
+ return this.readAs("read", args, true);
439
+ }
440
+ /** read() for `tool`: the same view; only an agent's `read` goes in the usage log. */
441
+ async readAs(tool, args, usage) {
442
+ return (await this.readWith(tool, args, usage)).shown;
443
+ }
444
+ /**
445
+ * `readAs`, with what it read: the page, the caller and the repo, from the one look at the
446
+ * repo after the only await. A caller that builds more of its answer from these (ui_page's
447
+ * links) answers from that same look, as `read` does: a reload after it can't mix in.
448
+ */
449
+ async readWith(tool, args, usage) {
450
+ const who = await this.identity.get();
451
+ this.permit(tool, who);
452
+ if (typeof args.id === "string" && args.id.startsWith("mirror:"))
453
+ return { kind: "mirror", shown: this.readMirror(args.id, who) };
454
+ const page = this.resolve(args.id, who);
455
+ const repo = this.repo;
456
+ const s = pageStatus(page, this.repo, this.now());
457
+ const flags = [
458
+ ...(s.sla.state === "overdue" ? ["past_sla"] : []),
459
+ ...(s.sla.state === "due_soon" ? ["due_soon"] : []),
460
+ ...(page.status !== "canonical" ? [page.status] : []),
461
+ ...(!s.owned ? ["unowned"] : []),
462
+ ];
463
+ if (usage)
464
+ this.noteRead("read", [page]);
465
+ else
466
+ this.taint([page]);
467
+ const shown = {
468
+ ...this.summary(page),
469
+ review_every: page.review_every,
470
+ acl: page.acl,
471
+ // Only ids the caller could look up: a hidden page's id would name it, and so would
472
+ // the id of a page that didn't load (its ACL can't be read). A mirrored page's id
473
+ // can't be here: `supersedes` holds canonical ids, which can't hold the `:` every
474
+ // mirror id has (schema.ts `ID`), so a page that names one doesn't load at all.
475
+ supersedes: (page.supersedes ?? []).filter((id) => {
476
+ const p = this.repo.byId.get(id);
477
+ return p ? canSee(p, who) : !this.repo.invalid.some((i) => i.id === id);
478
+ }),
479
+ applies_to: page.applies_to ?? [],
480
+ flags,
481
+ ...(s.citable ? {} : { gate_reason: s.gate_reason }),
482
+ content: this.pageText(page, who),
483
+ };
484
+ return { kind: "page", shown, page, who, repo };
485
+ }
486
+ /** A repo page's text as `who` may see it: links to pages, or mirrored pages, they can't see redacted. */
487
+ pageText(page, who, body = page.body) {
488
+ const text = redactLinks(body, page.path, this.repo, who);
489
+ return this.mirror.length ? redactHiddenLinks(text, this.mirrorView(who).hidden, RESTRICTED) : text;
490
+ }
491
+ /** A mirrored page, as context: never citable. Hidden and unknown ids read the same. */
492
+ readMirror(id, who) {
493
+ this.current();
494
+ const view = this.mirror.length ? this.mirrorView(who) : undefined;
495
+ const at = view ? view.pages.findIndex((p) => p.id === id) : -1;
496
+ const m = at >= 0 ? view.pages[at] : undefined;
497
+ // Changed since loading: don't serve it, and reload on the next call.
498
+ const changed = Boolean(m) && !stillOwned(view.origin[at]);
499
+ if (changed)
500
+ this.invalidate();
501
+ if (!m || changed)
502
+ throw new ToolError("not_found", `no page with id \`${id}\``);
503
+ this.taint([m]);
504
+ return {
505
+ id: m.id,
506
+ title: m.title,
507
+ source: m.source,
508
+ space: m.space,
509
+ source_url: m.url,
510
+ owner_team: m.owner_team,
511
+ last_edited: m.last_edited,
512
+ acl: m.acl,
513
+ citable: false,
514
+ flags: ["mirrored"],
515
+ gate_reason: `mirrored from ${m.source === "confluence" ? "Confluence" : "Notion"}: a copy of the old wiki, not a verified page`,
516
+ content: m.markdown,
517
+ };
518
+ }
519
+ async list(args = {}) {
520
+ const who = await this.identity.get();
521
+ this.permit("list", who);
522
+ const repo = this.current();
523
+ const visible = visiblePages(repo, who);
524
+ if (args.type) {
525
+ if (!PAGE_TYPES.includes(args.type))
526
+ throw new ToolError("invalid", `unknown type \`${args.type}\``);
527
+ const pages = visible.filter((p) => p.type === args.type);
528
+ this.taint(pages);
529
+ return { type: args.type, pages: pages.map((p) => this.summary(p)) };
530
+ }
531
+ const root = repo.config.knowledge_root;
532
+ const rel = posix.normalize((args.path ?? "").replace(/^\/+|\/+$/g, "") || ".");
533
+ if (rel.startsWith(".."))
534
+ throw new ToolError("invalid", "path must stay inside the knowledge root");
535
+ const dir = rel === "." ? root : `${root}/${rel}`;
536
+ const inDir = visible.filter((p) => posix.dirname(p.path) === dir);
537
+ // Only directories that contain at least one visible page, so a team-only
538
+ // directory's existence is not revealed.
539
+ const dirs = [
540
+ ...new Set(visible
541
+ .filter((p) => p.path.startsWith(`${dir}/`) && posix.dirname(p.path) !== dir)
542
+ .map((p) => p.path.slice(dir.length + 1).split("/")[0])),
543
+ ].sort();
544
+ const index = inDir.find((p) => posix.basename(p.path) === "index.md");
545
+ if (!index && inDir.length === 0 && dirs.length === 0)
546
+ throw new ToolError("not_found", `no directory \`${rel}\``);
547
+ this.taint(inDir);
548
+ return {
549
+ path: rel,
550
+ index: index ? this.summary(index) : null,
551
+ pages: inDir.filter((p) => p !== index).map((p) => this.summary(p)),
552
+ dirs: dirs.map((d) => (rel === "." ? d : `${rel}/${d}`)),
553
+ };
554
+ }
555
+ async stale(args = {}) {
556
+ const who = await this.identity.get();
557
+ this.permit("stale", who);
558
+ const repo = this.current();
559
+ const pages = listStale(visiblePages(repo, who), repo, this.now(), args.owner_team);
560
+ this.taint(pages.map((p) => repo.byId.get(p.id)));
561
+ return { pages };
562
+ }
563
+ // The web app's views (HTTP sessions only; tool-registry.ts). Each answers to the `list`
564
+ // policy, works over visiblePages like every read, and taints the session with exactly
565
+ // the restricted pages its response names, as `list` does.
566
+ /** Who is served and whether writes would work, for the app's header and settings. */
567
+ async uiCapabilities(opts = {}) {
568
+ const who = await this.identity.get();
569
+ this.permit("ui_capabilities", who);
570
+ const repo = this.current();
571
+ const config = repo.config;
572
+ // Owners the app can offer: teams of pages the caller sees, and the teams CODEOWNERS
573
+ // names (it is part of the repository). Names only, never a page: nothing is tainted.
574
+ const teams = [
575
+ ...new Set([
576
+ ...visiblePages(repo, who).map((p) => p.owner_team),
577
+ ...repo.codeowners.rules.flatMap((r) => r.owners.filter((o) => o.kind === "team" && o.org?.toLowerCase() === config.github.org).map((o) => o.team.toLowerCase())),
578
+ ]),
579
+ ].sort();
580
+ // The write tools the policy lets this identity call (each also needs what follows).
581
+ const allowed = [...WRITE_TOOLS].filter((t) => !toolRefusal(config.policy, config.github.org, t, who)).sort();
582
+ const writesOff = config.policy.writes === "disabled"
583
+ ? "writes are disabled by policy (policy.writes in .knowledge/config.yaml)"
584
+ : who.mode !== "github" || !config.github.repo
585
+ ? "writes need github.repo in .knowledge/config.yaml and a GitHub token"
586
+ : !who.canWrite
587
+ ? `your token has no push access to ${config.github.repo}`
588
+ : allowed.length === 0
589
+ ? "the policy allows you none of the write tools"
590
+ : null;
591
+ return {
592
+ mode: who.mode,
593
+ login: who.login,
594
+ org: config.github.org,
595
+ repo: config.github.repo ?? null,
596
+ writes: writesOff === null,
597
+ write_tools: writesOff === null ? allowed : [],
598
+ teams,
599
+ ...(writesOff ? { writes_off: writesOff } : {}),
600
+ pro: opts.pro ?? [],
601
+ };
602
+ }
603
+ /**
604
+ * Counts over the caller's visible pages, by team, with what is due soon and what is stale.
605
+ * `list_limit` keeps a big repo's overview short: the first of each list, the most pressing
606
+ * first, with `due_soon_total` and `stale_total`. Only the pages returned are named, so only
607
+ * those taint the session; the counts above the lists name no page.
608
+ */
609
+ async uiOverview(args = {}) {
610
+ const who = await this.identity.get();
611
+ this.permit("ui_overview", who);
612
+ const repo = this.current();
613
+ const visible = visiblePages(repo, who);
614
+ const pages = visible.map((p) => ({ page: p, s: this.summary(p) }));
615
+ // A deprecated page is retired, not up for review: like coverage and stale, the review
616
+ // counts leave it out, so they agree with the stale list below.
617
+ const sla = (xs, state) => xs.filter((x) => x.s.status !== "deprecated" && x.s.sla_state === state);
618
+ const tally = (xs) => ({
619
+ pages: xs.length,
620
+ citable: xs.filter((x) => x.s.citable).length,
621
+ overdue: sla(xs, "overdue").length,
622
+ due_soon: sla(xs, "due_soon").length,
623
+ });
624
+ const dueSoon = sla(pages, "due_soon").sort((a, b) => (a.s.due ?? "").localeCompare(b.s.due ?? ""));
625
+ const stale = listStale(visible, repo, this.now());
626
+ const first = (xs) => (args.list_limit === undefined ? xs : xs.slice(0, args.list_limit));
627
+ const dueShown = first(dueSoon);
628
+ const staleShown = first(stale);
629
+ this.taint([...dueShown.map((x) => x.page), ...staleShown.map((e) => repo.byId.get(e.id))]);
630
+ return {
631
+ as_of: formatDate(utcToday(this.now())),
632
+ totals: { ...tally(pages), draft: pages.filter((x) => x.s.status === "draft").length, deprecated: pages.filter((x) => x.s.status === "deprecated").length },
633
+ teams: [...new Set(pages.map((x) => x.s.owner_team))].sort().map((team) => ({ team, ...tally(pages.filter((x) => x.s.owner_team === team)) })),
634
+ due_soon: dueShown.map((x) => x.s),
635
+ stale: staleShown,
636
+ due_soon_total: dueSoon.length,
637
+ stale_total: stale.length,
638
+ // Files under the knowledge root that aren't valid pages, which nothing here serves: a
639
+ // count only, of every audience, never a path or a name (the owner's call, 2026-11-12;
640
+ // anyone this server answers can read the repository's files anyway). It's what
641
+ // loadRepo walked, so exactly what `docsworth lint` lists as frontmatter errors; like
642
+ // every page and lint since the first loader, it skips dot-directories (walkMarkdown).
643
+ skipped_files: repo.invalid.length,
644
+ };
645
+ }
646
+ /**
647
+ * A page as `read` shows it, with `links`: the repo path and id of each visible page it
648
+ * links to, so the app can route those links. Taints the session with the page and those.
649
+ */
650
+ async uiPage(args) {
651
+ const r = await this.readWith("ui_page", args, false);
652
+ if (r.kind === "mirror")
653
+ return { ...r.shown, links: {} }; // a mirrored page links to no repo page
654
+ // The links come from the look at the repo the read took, with its caller: the links in
655
+ // what the caller is shown, never a later look that a reload could have changed.
656
+ const { shown, page, who, repo } = r;
657
+ // The links in what the caller is shown: redaction can drop a whole line, and a link to
658
+ // a visible page on it with it, which then names nothing and taints nothing.
659
+ const linked = new Map();
660
+ // The loader's links, and CommonMark's (link text with brackets in it, say): every page
661
+ // either names is linked, and taints, as before.
662
+ const fromCommonMark = pageLinks(shown.content).filter((d) => d && !EXTERNAL.test(d)).map(linkPath);
663
+ for (const link of new Set([...extractLinks(shown.content), ...fromCommonMark])) {
664
+ const target = repo.byPath.get(resolveLink(page.path, link));
665
+ if (target && target !== page && canSee(target, who))
666
+ linked.set(target.path, target);
667
+ }
668
+ this.taint(linked.values());
669
+ return { ...shown, links: Object.fromEntries([...linked].map(([path, p]) => [path, p.id])) };
670
+ }
671
+ /**
672
+ * Visible pages and the links between them. A link to a page the caller can't see is no
673
+ * edge; `has_restricted_links` says a page has one, exactly as its `read` text shows a
674
+ * placeholder for it, so the graph reveals nothing `read` doesn't.
675
+ *
676
+ * `max_nodes` keeps a big repo drawable: past it, the most-linked pages (links between
677
+ * visible pages only; ties by id) and the links among them. `total` says how many it
678
+ * chose from, and `teams` how many visible pages each team has, a count that names no
679
+ * page. Only the returned pages are named, so only those taint the session.
680
+ */
681
+ async uiGraph(args = {}) {
682
+ const who = await this.identity.get();
683
+ this.permit("ui_graph", who);
684
+ const repo = this.current();
685
+ const all = visiblePages(repo, who);
686
+ // One team's pages, for its page in the app: only those are named, so only those taint.
687
+ const visible = all.filter((p) => !args.owner_team || p.owner_team === args.owner_team);
688
+ const linked = (among) => {
689
+ const edges = new Map();
690
+ for (const p of among)
691
+ for (const link of p.links) {
692
+ const target = repo.byPath.get(resolveLink(p.path, link));
693
+ if (target && target !== p && among.has(target))
694
+ edges.set(`${p.id}\0${target.id}`, { from: p, to: target });
695
+ }
696
+ return [...edges.values()];
697
+ };
698
+ let shown = visible;
699
+ let edges = linked(new Set(visible));
700
+ if (args.max_nodes !== undefined && visible.length > args.max_nodes) {
701
+ const degree = new Map();
702
+ for (const e of edges)
703
+ for (const p of [e.from, e.to])
704
+ degree.set(p, (degree.get(p) ?? 0) + 1);
705
+ shown = [...visible].sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0) || a.id.localeCompare(b.id)).slice(0, args.max_nodes);
706
+ const kept = new Set(shown);
707
+ edges = edges.filter((e) => kept.has(e.from) && kept.has(e.to));
708
+ }
709
+ const teams = new Map();
710
+ for (const p of all)
711
+ teams.set(p.owner_team, (teams.get(p.owner_team) ?? 0) + 1);
712
+ this.taint(shown);
713
+ return {
714
+ nodes: shown.map((p) => ({ ...this.summary(p), has_restricted_links: this.pageText(p, who).includes(RESTRICTED) })),
715
+ edges: edges.map((e) => ({ from: e.from.id, to: e.to.id })),
716
+ total: visible.length,
717
+ teams: [...teams].sort(([a], [b]) => a.localeCompare(b)).map(([team, pages]) => ({ team, pages })),
718
+ };
719
+ }
720
+ /** What every write needs first: the policy, a live identity, a GitHub repo. */
721
+ async writeIdentity(tool) {
722
+ // A global switch needs no identity: refuse before GitHub is asked anything.
723
+ if (this.current().config.policy.writes === "disabled")
724
+ this.permit(tool, LOCAL_IDENTITY_FOR_POLICY);
725
+ const who = await this.identity.fresh();
726
+ this.permit(tool, who);
727
+ const gh = this.identity.github;
728
+ const repoSlug = this.current().config.github.repo;
729
+ if (who.mode !== "github" || !gh || !repoSlug)
730
+ throw new ToolError("unavailable", "writes need github.repo in .knowledge/config.yaml and a GitHub token");
731
+ return { who, gh, repoSlug };
732
+ }
733
+ async pushContext(who, gh, repoSlug) {
734
+ if (!who.canWrite)
735
+ throw new ToolError("forbidden", `your token has no push access to ${repoSlug}`);
736
+ this.defaultBranch ??= this.repo.config.github.default_branch ?? (await gh.getRepo(repoSlug)).default_branch;
737
+ return this.defaultBranch;
738
+ }
739
+ /** Common preamble for writes to a page: live identity, visibility, a GitHub repo. */
740
+ async writeContext(tool, id) {
741
+ const { who, gh, repoSlug } = await this.writeIdentity(tool);
742
+ const page = this.resolve(id, who);
743
+ const base = await this.pushContext(who, gh, repoSlug);
744
+ return { who, gh, repoSlug, page, base };
745
+ }
746
+ /** The same for a write that creates a page: no id to resolve. */
747
+ async newWriteContext(tool) {
748
+ const { who, gh, repoSlug } = await this.writeIdentity(tool);
749
+ const base = await this.pushContext(who, gh, repoSlug);
750
+ return { who, gh, repoSlug, base };
751
+ }
752
+ /** The taint check: a session that saw team-only pages writes only into pages with that audience. */
753
+ refuseLeak(tool, acl, what) {
754
+ const leaking = [...this.tainted].filter((a) => a !== acl);
755
+ if (leaking.length)
756
+ throw new ToolError("forbidden", `this session read pages restricted to ${leaking.map((a) => `@${this.repo.config.github.org}/${a}`).join(", ")}; ${tool} can only ${what} (this ${tool === "draft_page" ? "one would be" : "page is"} \`${acl}\`)`);
757
+ }
758
+ async proposeEdit(args) {
759
+ if (!args.reason?.trim())
760
+ throw new ToolError("invalid", "a reason is required");
761
+ if ((args.edits ? 1 : 0) + (args.body !== undefined ? 1 : 0) + (args.content !== undefined ? 1 : 0) + (args.append !== undefined ? 1 : 0) !== 1)
762
+ throw new ToolError("invalid", "give exactly one of `edits`, `body`, `content` or `append`");
763
+ if (args.append !== undefined) {
764
+ if (!args.append.trim())
765
+ throw new ToolError("invalid", "`append` is empty");
766
+ if (args.polish)
767
+ throw new ToolError("invalid", "an `append` adds text, so it can't be a polish");
768
+ // In `read`, the placeholder stands where a link the caller can't see is; written
769
+ // into the page it would read as one.
770
+ if (args.append.includes(RESTRICTED))
771
+ throw new ToolError("invalid", `\`append\` contains \`${RESTRICTED}\`, which \`read\` shows in place of a link you can't see; leave it out`);
772
+ }
773
+ const { who, gh, repoSlug, page, base } = await this.writeContext("propose_edit", args.id);
774
+ // Taint check: once this session has seen a team-only page, refuse to write into a
775
+ // page with a different (broader) audience, which could carry that content out.
776
+ this.refuseLeak("propose_edit", page.acl, "target pages with the same audience");
777
+ let checked;
778
+ const pr = await openPagePr(gh, {
779
+ repo: repoSlug,
780
+ baseBranch: base,
781
+ verb: "propose",
782
+ pageId: page.id,
783
+ path: page.path,
784
+ transform: (current) => {
785
+ // A link the caller can't see shows in `read` as a placeholder. Checked before
786
+ // anything is applied, so neither a refusal nor whether an edit matched can tell
787
+ // the caller anything about it. On such a page only `edits` are taken: a whole
788
+ // new body or file written from the redacted view would overwrite or drop those
789
+ // links, and a polish refusal would name them.
790
+ const currentBody = splitFrontmatter(current).body;
791
+ // As `read` shows it: links to hidden repo pages and, when the mirror is served,
792
+ // to hidden mirrored pages.
793
+ // An append adds to the page as it is on the default branch: the caller's view of
794
+ // the rest, redacted or stale, never goes back into it.
795
+ if (args.append !== undefined)
796
+ return `${current.replace(/\s*$/, "")}\n\n${args.append.trim()}\n`;
797
+ const hidesLinks = this.pageText(page, who, currentBody) !== currentBody;
798
+ if (hidesLinks && (args.polish || !args.edits))
799
+ throw new ToolError("invalid", `this page links to pages you can't see (\`${RESTRICTED}\` in \`read\`), so ${args.polish ? "it can't be polished from your view" : "a whole new `body` or `content` written from your view would overwrite or drop those links"}; propose specific changes with \`edits\` instead`);
800
+ let next;
801
+ try {
802
+ next = args.edits ? applyEdits(current, args.edits) : args.body !== undefined ? replaceBody(current, args.body) : args.content;
803
+ assertProtectedFieldsUnchanged(current, next);
804
+ }
805
+ catch (err) {
806
+ throw new ToolError("invalid", err.message);
807
+ }
808
+ // An edit that writes the placeholder would leave it where a reader expects a link.
809
+ // Each edit's own text is checked: a total could balance a literal one removed.
810
+ if (hidesLinks && (args.edits.some((e) => e.new.includes(RESTRICTED)) || count(next, RESTRICTED) > count(current, RESTRICTED)))
811
+ throw new ToolError("invalid", `the proposal contains \`${RESTRICTED}\`, which \`read\` shows in place of a link you can't see; writing it would replace that link. Leave those parts out: use \`edits\` and change only the text around them`);
812
+ // A polish keeps the frontmatter byte for byte and every link target, so the page
813
+ // stays as valid as it was: lint's page checks read only those.
814
+ if (args.polish) {
815
+ const changes = polishChanges(current, next);
816
+ if (changes.length)
817
+ throw new ToolError("invalid", `a polish must not change what the page says, but this one ${changes.slice(0, 8).join("; ")}${changes.length > 8 ? `; and ${changes.length - 8} more` : ""}. Keep those as they are, or propose it as an ordinary edit`);
818
+ checked = polishSummary(current);
819
+ }
820
+ return next;
821
+ },
822
+ commitMessage: `docs(${page.id}): ${args.polish ? "polish" : args.append !== undefined ? "proposed addition" : "proposed edit"}\n\n${args.reason.trim()}`,
823
+ title: args.polish ? `polish(${page.id}): ${page.title}` : args.append !== undefined ? `Proposed addition: ${page.title}` : `Proposed edit: ${page.title}`,
824
+ body: () => [
825
+ `**Page:** \`${page.id}\` (${page.path}), owned by @${this.repo.config.github.org}/${page.owner_team}`,
826
+ "",
827
+ "**Reason given by the agent:**",
828
+ "",
829
+ args.reason.trim(),
830
+ ...(checked
831
+ ? [
832
+ "",
833
+ "**Polish guard:** " + checked + ".",
834
+ "The guard compares structure only. It can't tell when a reworded sentence now means something else, so please read the diff before approving.",
835
+ ]
836
+ : []),
837
+ "",
838
+ `_Opened by Docsworth \`propose_edit\` on behalf of @${who.login}. Nothing merges without owner review._`,
839
+ ].join("\n"),
840
+ draft: args.draft,
841
+ });
842
+ return { pr_url: pr.url, pr_number: pr.number, branch: pr.branch };
843
+ }
844
+ /**
845
+ * A new page, as a draft PR. The server picks its id and file name, each ending in 12
846
+ * random hex digits: a caller-chosen name could be aimed at a hidden page, and "already
847
+ * exists" would then say it is there. The title still sets the slug, so CODEOWNERS
848
+ * rules that match file names (`runbooks/payments-*`) still apply.
849
+ */
850
+ async draftPage(args) {
851
+ if (!args.reason?.trim())
852
+ throw new ToolError("invalid", "a reason is required");
853
+ const oneLine = (v, name, max) => {
854
+ if (v === undefined)
855
+ return;
856
+ if (!v.trim() || v.length > max || /[\u0000-\u001f\u007f\u2028\u2029]/.test(v))
857
+ throw new ToolError("invalid", `\`${name}\` must be one line of 1 to ${max} characters`);
858
+ };
859
+ oneLine(args.title, "title", 200);
860
+ oneLine(args.description, "description", 280);
861
+ if (!(args.type in NEW_PAGE_PREFIX))
862
+ throw new ToolError("invalid", "`type` must be runbook, service, guide or policy; ADRs are written by hand");
863
+ if (!args.body?.trim())
864
+ throw new ToolError("invalid", "`body` is empty");
865
+ if (args.body.length > DRAFT_MAX_CHARS)
866
+ throw new ToolError("invalid", `\`body\` is over ${DRAFT_MAX_CHARS / 1024} KB`);
867
+ let source;
868
+ if (args.source_url !== undefined) {
869
+ const url = URL.canParse(args.source_url) ? new URL(args.source_url) : null;
870
+ if (!url || !/^https?:$/.test(url.protocol) || url.href.length > 2000 || /[\s<>]/.test(url.href))
871
+ throw new ToolError("invalid", "`source_url` must be an http or https URL");
872
+ source = url.href;
873
+ }
874
+ const { who, gh, repoSlug, base } = await this.newWriteContext("draft_page");
875
+ const repo = this.current();
876
+ const acl = args.acl ?? ACL_INTERNAL;
877
+ if (acl !== ACL_INTERNAL && !who.teams.has(acl.toLowerCase()))
878
+ throw new ToolError("forbidden", `a new page's \`acl\` must be \`internal\` or a team you're on, not \`${acl}\``);
879
+ this.refuseLeak("draft_page", acl, "create pages with the same audience");
880
+ const today = formatDate(utcToday(this.now()));
881
+ const slug = slugify(args.title).slice(0, 40).replace(/-+$/, "") || "page";
882
+ const body = `${source ? `> Captured from <${source}> on ${today}. Review it before merging.\n\n` : ""}${args.body.replace(/\s*$/, "")}\n`;
883
+ // Where CODEOWNERS routes by file name (`runbooks/payments-*`), a name the directory's
884
+ // own rule gives to another team is tried again as `<owner_team>-<slug>-<hex>`. Still
885
+ // server-named, and the owner check in buildNewPage decides, as for the plain name.
886
+ const teamPrefix = slugify(args.owner_team);
887
+ for (let attempt = 0; attempt < DRAFT_NAME_ATTEMPTS; attempt++) {
888
+ const suffix = this.draftSuffix();
889
+ const build = (stem) => buildNewPage(repo, {
890
+ type: args.type,
891
+ title: args.title,
892
+ id: `${NEW_PAGE_PREFIX[args.type]}-${stem}`,
893
+ slug: stem,
894
+ ownerTeam: args.owner_team,
895
+ status: "draft",
896
+ dir: args.dir,
897
+ acl,
898
+ description: args.description,
899
+ body,
900
+ now: this.now(),
901
+ });
902
+ const plain = build(`${slug}-${suffix}`);
903
+ const mismatch = !plain.ok && plain.problem.kind === "owner_mismatch" ? plain.problem : null;
904
+ const built = mismatch ? build(`${teamPrefix}-${slug}-${suffix}`) : plain;
905
+ if (!built.ok) {
906
+ const p = built.problem;
907
+ // A name already in use: pick another, and never say which.
908
+ if (p.kind === "id_taken" || p.kind === "path_taken")
909
+ continue;
910
+ if (p.kind === "bad_dir")
911
+ throw new ToolError("invalid", `\`dir\` must be a relative path under ${repo.config.knowledge_root}/ without . or .. segments`);
912
+ if (p.kind === "owner_mismatch") {
913
+ // Name the directory the page would go in (a service gets its own directory under
914
+ // it), the team its rule gives it to, and the prefix that was tried there.
915
+ const { assigned, path } = mismatch ?? p;
916
+ const where = args.type === "service" && args.dir === undefined ? posix.dirname(posix.dirname(path)) : posix.dirname(path);
917
+ const org = repo.config.github.org;
918
+ throw new ToolError("invalid", `CODEOWNERS ${assigned ? `assigns @${org}/${assigned}` : "assigns no single team"} there (${where}/) and doesn't give @${org}/${args.owner_team} \`${where}/${teamPrefix}-*\`; give a \`dir\` that @${org}/${args.owner_team} owns`);
919
+ }
920
+ throw new ToolError("invalid", p.kind === "invalid" ? `the page would not lint: ${p.issues.join("; ")}` : "ADRs are written by hand");
921
+ }
922
+ try {
923
+ const pr = await openPagePr(gh, {
924
+ repo: repoSlug,
925
+ baseBranch: base,
926
+ verb: "draft",
927
+ pageId: built.id,
928
+ path: built.path,
929
+ create: true,
930
+ transform: () => built.content,
931
+ commitMessage: `docs(${built.id}): draft\n\n${args.reason.trim()}`,
932
+ title: `Draft: ${args.title}`,
933
+ body: [
934
+ `**New page:** \`${built.id}\` (${built.path}), owned by @${repo.config.github.org}/${args.owner_team}${acl === ACL_INTERNAL ? "" : `, visible to @${repo.config.github.org}/${acl}`}`,
935
+ "",
936
+ "**Reason given:**",
937
+ "",
938
+ args.reason.trim(),
939
+ ...(source ? ["", `**Source:** <${source}>`] : []),
940
+ "",
941
+ `_Opened by Docsworth \`draft_page\` on behalf of @${who.login}. The page is \`status: draft\`, so it can't be cited until its owners make it canonical. Rename its id and file before merging if you like._`,
942
+ ].join("\n"),
943
+ draft: args.draft ?? true,
944
+ });
945
+ return { id: built.id, path: built.path, pr_url: pr.url, pr_number: pr.number, branch: pr.branch };
946
+ }
947
+ catch (err) {
948
+ if (err instanceof WriteError && err.message === `${built.path} already exists on ${base}`)
949
+ continue;
950
+ throw err;
951
+ }
952
+ }
953
+ throw new ToolError("unavailable", "couldn't find a free name for the new page; try again");
954
+ }
955
+ async verify(args) {
956
+ const { who, gh, repoSlug, page, base } = await this.writeContext("verify", args.id);
957
+ if (!who.teams.has(page.owner_team.toLowerCase()))
958
+ throw new ToolError("forbidden", `only members of @${this.repo.config.github.org}/${page.owner_team} can verify \`${page.id}\``);
959
+ const today = formatDate(utcToday(this.now()));
960
+ const pr = await openPagePr(gh, {
961
+ repo: repoSlug,
962
+ baseBranch: base,
963
+ verb: "verify",
964
+ pageId: page.id,
965
+ path: page.path,
966
+ transform: (current) => setLastVerified(current, today),
967
+ commitMessage: `docs(${page.id}): verified ${today}`,
968
+ title: `Verify: ${page.title} (${today})`,
969
+ body: `@${who.login} (@${this.repo.config.github.org}/${page.owner_team}) verified \`${page.id}\` on ${today} via Docsworth \`verify\`.`,
970
+ });
971
+ return { pr_url: pr.url, pr_number: pr.number, branch: pr.branch, last_verified: today };
972
+ }
973
+ }