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