@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,130 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { actorOf } from "../audit-log.js";
|
|
6
|
+
import { auditTransport } from "../mcp/audit-transport.js";
|
|
7
|
+
import { createMcpServer } from "../mcp/server.js";
|
|
8
|
+
import { KnowledgeService, WATCH_SETTLE_MS } from "../service.js";
|
|
9
|
+
export const MAX_SESSIONS = 16;
|
|
10
|
+
export const SESSION_IDLE_MS = 30 * 60_000;
|
|
11
|
+
function json(res, status, body) {
|
|
12
|
+
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
13
|
+
}
|
|
14
|
+
export class SessionTable {
|
|
15
|
+
opts;
|
|
16
|
+
sessions = new Map();
|
|
17
|
+
/** Sessions being set up: counted against the cap before they have an id. */
|
|
18
|
+
starting = 0;
|
|
19
|
+
watcher = null;
|
|
20
|
+
sweeper;
|
|
21
|
+
max;
|
|
22
|
+
idleMs;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.opts = opts;
|
|
25
|
+
this.max = opts.maxSessions ?? MAX_SESSIONS;
|
|
26
|
+
this.idleMs = opts.idleMs ?? SESSION_IDLE_MS;
|
|
27
|
+
// One watcher for every session: a change marks each service dirty, and each reloads
|
|
28
|
+
// lazily on its next call, never in the middle of a write that is still landing.
|
|
29
|
+
// It watches pages only. The config and CODEOWNERS (the policy, admins, owners) sit
|
|
30
|
+
// outside the knowledge root, and every call already stats them: KnowledgeService's
|
|
31
|
+
// current() reloads when inputsStamp() changes. So a tightened policy applies to an
|
|
32
|
+
// open session's next call (test/serve.test.ts, "applies a tightened policy").
|
|
33
|
+
this.watcher = watch(join(opts.root, opts.knowledgeRoot), { recursive: true }, () => this.invalidate());
|
|
34
|
+
const settle = setTimeout(() => this.invalidate(), WATCH_SETTLE_MS); // see KnowledgeService.watch
|
|
35
|
+
settle.unref();
|
|
36
|
+
this.sweeper = setInterval(() => void this.sweep(), Math.min(60_000, this.idleMs));
|
|
37
|
+
this.sweeper.unref();
|
|
38
|
+
}
|
|
39
|
+
get size() {
|
|
40
|
+
return this.sessions.size;
|
|
41
|
+
}
|
|
42
|
+
/** The services of live sessions (tests). */
|
|
43
|
+
services() {
|
|
44
|
+
return [...this.sessions.values()].map((s) => s.service);
|
|
45
|
+
}
|
|
46
|
+
invalidate() {
|
|
47
|
+
for (const s of this.sessions.values())
|
|
48
|
+
s.service.invalidate();
|
|
49
|
+
}
|
|
50
|
+
async handle(req, res) {
|
|
51
|
+
const sid = req.headers["mcp-session-id"];
|
|
52
|
+
if (sid !== undefined) {
|
|
53
|
+
const session = typeof sid === "string" ? this.sessions.get(sid) : undefined;
|
|
54
|
+
if (!session)
|
|
55
|
+
return json(res, 404, { error: { code: "not_found", message: "no such session; it may have expired: start a new one" } });
|
|
56
|
+
session.active++;
|
|
57
|
+
try {
|
|
58
|
+
return await session.http.handleRequest(req, res);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
session.active--;
|
|
62
|
+
session.lastUsed = Date.now();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (req.method !== "POST")
|
|
66
|
+
return json(res, 400, { error: { code: "invalid", message: "start a session with an initialize request" } });
|
|
67
|
+
// Checked and counted before the first await, so concurrent starts can't pass the cap.
|
|
68
|
+
if (this.sessions.size + this.starting >= this.max)
|
|
69
|
+
return json(res, 503, { error: { code: "unavailable", message: `this server already has ${this.max} sessions open; close a tab or wait for idle ones to expire` } });
|
|
70
|
+
this.starting++;
|
|
71
|
+
try {
|
|
72
|
+
const session = await this.open();
|
|
73
|
+
try {
|
|
74
|
+
await session.http.handleRequest(req, res);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
// Busy from birth: the session joins the table while this request is still
|
|
78
|
+
// being answered, so a sweep in between must not take it.
|
|
79
|
+
session.active--;
|
|
80
|
+
session.lastUsed = Date.now();
|
|
81
|
+
}
|
|
82
|
+
// A first request that wasn't an initialize gets the SDK's error and no session.
|
|
83
|
+
if (!session.http.sessionId)
|
|
84
|
+
await session.server.close();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
this.starting--;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async open() {
|
|
91
|
+
const o = this.opts;
|
|
92
|
+
const service = new KnowledgeService(o.root, o.identity, o.now);
|
|
93
|
+
const http = new StreamableHTTPServerTransport({
|
|
94
|
+
sessionIdGenerator: randomUUID,
|
|
95
|
+
enableJsonResponse: true,
|
|
96
|
+
onsessioninitialized: (id) => void this.sessions.set(id, session),
|
|
97
|
+
});
|
|
98
|
+
const transport = o.audit
|
|
99
|
+
? auditTransport(http, o.audit, () => o.identity.get(), {
|
|
100
|
+
includeQueries: o.includeQueries,
|
|
101
|
+
initialActor: actorOf(o.who),
|
|
102
|
+
known: () => service.knownValues(),
|
|
103
|
+
snapshot: () => service.snapshot(),
|
|
104
|
+
log: o.log,
|
|
105
|
+
})
|
|
106
|
+
: http;
|
|
107
|
+
// On the wrapper, not on `http`: the audit transport takes over the inner onclose.
|
|
108
|
+
// Set before connect, which chains the server's own handler after this one.
|
|
109
|
+
transport.onclose = () => {
|
|
110
|
+
if (http.sessionId)
|
|
111
|
+
this.sessions.delete(http.sessionId);
|
|
112
|
+
service.close();
|
|
113
|
+
};
|
|
114
|
+
const server = createMcpServer(service, { who: o.who, whoami: () => o.identity.get(), surface: "http" });
|
|
115
|
+
const session = { http, server, service, lastUsed: Date.now(), active: 1 };
|
|
116
|
+
await server.connect(transport);
|
|
117
|
+
return session;
|
|
118
|
+
}
|
|
119
|
+
/** Close sessions idle longer than the limit, never one with a request in flight. */
|
|
120
|
+
async sweep(now = Date.now()) {
|
|
121
|
+
const idle = [...this.sessions.values()].filter((s) => s.active === 0 && now - s.lastUsed > this.idleMs);
|
|
122
|
+
await Promise.all(idle.map((s) => s.server.close()));
|
|
123
|
+
}
|
|
124
|
+
async close() {
|
|
125
|
+
clearInterval(this.sweeper);
|
|
126
|
+
this.watcher?.close();
|
|
127
|
+
this.watcher = null;
|
|
128
|
+
await Promise.all([...this.sessions.values()].map((s) => s.server.close()));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { UserError } from "../errors.js";
|
|
2
|
+
export declare const TOKEN_FILE = "serve-token";
|
|
3
|
+
export declare class TokenError extends UserError {
|
|
4
|
+
}
|
|
5
|
+
/** The serve token kept in `dir`, created on first use, or replaced with `rotate`. */
|
|
6
|
+
export declare function loadToken(dir: string, opts?: {
|
|
7
|
+
rotate?: boolean;
|
|
8
|
+
}): string;
|
|
9
|
+
/** Constant-time comparison of a presented token with the expected one. */
|
|
10
|
+
export declare function tokenMatches(expected: string, presented: string | undefined): boolean;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { UserError } from "../errors.js";
|
|
5
|
+
// The bearer token `docsworth serve` checks on every /mcp request. It persists in this
|
|
6
|
+
// user's state directory (outside the repo, never committable), so a paired browser
|
|
7
|
+
// extension keeps working across restarts; `--rotate-token` replaces it. The file is
|
|
8
|
+
// handled like the audit log's: a 0700 directory that isn't a symlink, a 0600 file opened
|
|
9
|
+
// without following links, and writes that are atomic.
|
|
10
|
+
export const TOKEN_FILE = "serve-token";
|
|
11
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/; // 32 random bytes, base64url
|
|
12
|
+
export class TokenError extends UserError {
|
|
13
|
+
}
|
|
14
|
+
function ensureDir(dir) {
|
|
15
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
16
|
+
const st = lstatSync(dir);
|
|
17
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
18
|
+
throw new TokenError(`${dir} is a symlink or not a directory; refusing to keep the serve token there`);
|
|
19
|
+
if ((st.mode & 0o077) !== 0)
|
|
20
|
+
chmodSync(dir, 0o700);
|
|
21
|
+
}
|
|
22
|
+
/** The token in `file`, or null if there is none yet. */
|
|
23
|
+
function readToken(file) {
|
|
24
|
+
let fd;
|
|
25
|
+
try {
|
|
26
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const code = err.code;
|
|
30
|
+
if (code === "ENOENT")
|
|
31
|
+
return null;
|
|
32
|
+
if (code === "ELOOP")
|
|
33
|
+
throw new TokenError(`${file} is a symlink; refusing to read the serve token through it`);
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const st = fstatSync(fd);
|
|
38
|
+
if (!st.isFile())
|
|
39
|
+
throw new TokenError(`${file} is not a regular file; refusing to use it as the serve token`);
|
|
40
|
+
// A token others could read is a token others have: refuse it rather than quietly fix it.
|
|
41
|
+
if ((st.mode & 0o077) !== 0)
|
|
42
|
+
throw new TokenError(`${file} can be read by other users; run \`docsworth serve --rotate-token\` to replace it`);
|
|
43
|
+
const token = readFileSync(fd, "utf8").trim();
|
|
44
|
+
if (!TOKEN_SHAPE.test(token))
|
|
45
|
+
throw new TokenError(`${file} doesn't hold a serve token; run \`docsworth serve --rotate-token\``);
|
|
46
|
+
return token;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Write a new token to `file` through a fresh 0600 file in the same directory. When the
|
|
54
|
+
* token is first created, the new file is linked into place, so of two servers starting
|
|
55
|
+
* at once only one token wins and both read it; a rotation renames over the old one.
|
|
56
|
+
*/
|
|
57
|
+
function writeToken(dir, file, replace) {
|
|
58
|
+
const tmp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(4).toString("hex")}`);
|
|
59
|
+
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
60
|
+
try {
|
|
61
|
+
writeSync(fd, `${randomBytes(32).toString("base64url")}\n`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
if (replace)
|
|
67
|
+
return renameSync(tmp, file); // replaces a symlink itself, never its target
|
|
68
|
+
try {
|
|
69
|
+
linkSync(tmp, file);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err.code !== "EEXIST")
|
|
73
|
+
throw err; // another start won
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
unlinkSync(tmp);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The serve token kept in `dir`, created on first use, or replaced with `rotate`. */
|
|
80
|
+
export function loadToken(dir, opts = {}) {
|
|
81
|
+
ensureDir(dir);
|
|
82
|
+
const file = join(dir, TOKEN_FILE);
|
|
83
|
+
if (opts.rotate)
|
|
84
|
+
writeToken(dir, file, true);
|
|
85
|
+
else if (readToken(file) === null)
|
|
86
|
+
writeToken(dir, file, false);
|
|
87
|
+
return readToken(file);
|
|
88
|
+
}
|
|
89
|
+
/** Constant-time comparison of a presented token with the expected one. */
|
|
90
|
+
export function tokenMatches(expected, presented) {
|
|
91
|
+
if (typeof presented !== "string")
|
|
92
|
+
return false;
|
|
93
|
+
const a = Buffer.from(expected);
|
|
94
|
+
const b = Buffer.from(presented);
|
|
95
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
96
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { type Identity } from "./acl.js";
|
|
2
|
+
import { type NewPageType } from "./new-page.js";
|
|
3
|
+
import { type Policy } from "./policy.js";
|
|
4
|
+
import type { GitHubClient } from "./github.js";
|
|
5
|
+
import { type Page } from "./repo.js";
|
|
6
|
+
import { type PageType } from "./schema.js";
|
|
7
|
+
import { type StaleEntry } from "./stale.js";
|
|
8
|
+
import { type Edit } from "./writes.js";
|
|
9
|
+
export type ToolErrorCode = "not_found" | "forbidden" | "invalid" | "unavailable";
|
|
10
|
+
export declare class ToolError extends Error {
|
|
11
|
+
readonly code: ToolErrorCode;
|
|
12
|
+
constructor(code: ToolErrorCode, message: string);
|
|
13
|
+
}
|
|
14
|
+
/** What the service needs from identity resolution (IdentityProvider, or a test fake). */
|
|
15
|
+
export interface IdentitySource {
|
|
16
|
+
get(): Promise<Identity>;
|
|
17
|
+
fresh(): Promise<Identity>;
|
|
18
|
+
readonly github: GitHubClient | null;
|
|
19
|
+
}
|
|
20
|
+
export interface PageSummary {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
path: string;
|
|
24
|
+
type: PageType;
|
|
25
|
+
status: Page["status"];
|
|
26
|
+
owner_team: string;
|
|
27
|
+
last_verified: string;
|
|
28
|
+
due: string | null;
|
|
29
|
+
sla_state: string;
|
|
30
|
+
citable: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface SearchResult extends PageSummary {
|
|
33
|
+
snippet: string;
|
|
34
|
+
score: number;
|
|
35
|
+
/** Present only with include_stale: why this page is not citable. */
|
|
36
|
+
gate?: string;
|
|
37
|
+
}
|
|
38
|
+
/** A mirrored wiki page that matched: context for the agent, never a citation. */
|
|
39
|
+
export interface MirroredHit {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
source: string;
|
|
43
|
+
source_url: string;
|
|
44
|
+
owner_team: string | null;
|
|
45
|
+
last_edited: string;
|
|
46
|
+
citable: false;
|
|
47
|
+
snippet: string;
|
|
48
|
+
}
|
|
49
|
+
export interface Excluded {
|
|
50
|
+
id: string;
|
|
51
|
+
title: string;
|
|
52
|
+
owner_team: string;
|
|
53
|
+
reason: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The six knowledge tools as plain functions. The MCP server is a thin wrapper, and the
|
|
57
|
+
* ACL matrix test exercises this class directly, so there is one code path to audit.
|
|
58
|
+
*
|
|
59
|
+
* Invariant: every page a method touches comes from `visiblePages(repo, identity)`.
|
|
60
|
+
* Unknown ids and ids the caller cannot see raise the same not_found error.
|
|
61
|
+
*/
|
|
62
|
+
/** How long after starting a watcher to reload once anyway. A write 500 ms after a restart
|
|
63
|
+
* was always reported (20 of 20); this leaves room on a loaded machine. */
|
|
64
|
+
export declare const WATCH_SETTLE_MS = 2000;
|
|
65
|
+
/** A draft_page body's limit, and how many fresh names it tries before giving up. */
|
|
66
|
+
export declare const DRAFT_MAX_CHARS: number;
|
|
67
|
+
export declare const DRAFT_NAME_ATTEMPTS = 3;
|
|
68
|
+
/** At most this many mirrored pages per search: context, not a second result list. */
|
|
69
|
+
export declare const MIRROR_LIMIT = 3;
|
|
70
|
+
export declare class KnowledgeService {
|
|
71
|
+
private readonly root;
|
|
72
|
+
private readonly identity;
|
|
73
|
+
private readonly now;
|
|
74
|
+
private repo;
|
|
75
|
+
private index;
|
|
76
|
+
private dirty;
|
|
77
|
+
/** mtimes of the non-page inputs (config, CODEOWNERS) the loaded repo was built from. */
|
|
78
|
+
private inputs;
|
|
79
|
+
private watcher;
|
|
80
|
+
private watchedDir;
|
|
81
|
+
private settle;
|
|
82
|
+
private defaultBranch;
|
|
83
|
+
/** Mirrored pages, when mirror.serve is on; one search index per audience (views). */
|
|
84
|
+
private mirror;
|
|
85
|
+
private readonly mirrorViews;
|
|
86
|
+
/** Team ACLs whose content this session has seen (write-path taint check). */
|
|
87
|
+
private readonly tainted;
|
|
88
|
+
/** The random end of a drafted page's id and file name (replaced in tests). */
|
|
89
|
+
private draftSuffix;
|
|
90
|
+
constructor(root: string, identity: IdentitySource, now?: () => Date);
|
|
91
|
+
reload(): void;
|
|
92
|
+
/** Config and CODEOWNERS live outside the watched knowledge root; stat them per call. */
|
|
93
|
+
private inputsStamp;
|
|
94
|
+
/**
|
|
95
|
+
* The mirrored pages `who` may see, each with links to the ones they can't see
|
|
96
|
+
* redacted (and to repo pages they can't see: a wiki page can link to one by its URL),
|
|
97
|
+
* and a search index over exactly that view: ranking, snippets and counts never draw on
|
|
98
|
+
* a page outside it. One per audience (the caller's teams), rebuilt on reload.
|
|
99
|
+
*/
|
|
100
|
+
private mirrorView;
|
|
101
|
+
/** Reload on the next call. `docsworth serve` runs one watcher for all its sessions' services. */
|
|
102
|
+
invalidate(): void;
|
|
103
|
+
/** Mark the index dirty on any change under the knowledge root. */
|
|
104
|
+
watch(): void;
|
|
105
|
+
close(): void;
|
|
106
|
+
private current;
|
|
107
|
+
private snap;
|
|
108
|
+
/**
|
|
109
|
+
* The knowledge actually being served: the checkout's HEAD and whether the served files
|
|
110
|
+
* differ from it, plus a hash of the loaded config, ownership and pages. Refreshed after
|
|
111
|
+
* every reload, so an audit event names the snapshot its answer came from.
|
|
112
|
+
*/
|
|
113
|
+
snapshot(): {
|
|
114
|
+
commit: string | null;
|
|
115
|
+
dirty: boolean | null;
|
|
116
|
+
content_sha256: string;
|
|
117
|
+
};
|
|
118
|
+
/** Every page id, team and applies_to tag the repo uses: what an audited argument may record. */
|
|
119
|
+
knownValues(): {
|
|
120
|
+
ids: Set<string>;
|
|
121
|
+
teams: Set<string>;
|
|
122
|
+
tags: Set<string>;
|
|
123
|
+
};
|
|
124
|
+
/** Whether mirrored pages are served (mirror.serve). */
|
|
125
|
+
mirrorServed(): boolean;
|
|
126
|
+
/** The org policy in force now (config is re-read when it changes). */
|
|
127
|
+
policy(): Policy;
|
|
128
|
+
get org(): string;
|
|
129
|
+
/** Policy gate, checked on every call, before any page is resolved. */
|
|
130
|
+
private permit;
|
|
131
|
+
private summary;
|
|
132
|
+
private resolve;
|
|
133
|
+
/** Any restricted page represented in a response (content, snippet or just its title)
|
|
134
|
+
* taints the session for the write-path check. */
|
|
135
|
+
private taint;
|
|
136
|
+
private noteRead;
|
|
137
|
+
search(args: {
|
|
138
|
+
query: string;
|
|
139
|
+
type?: PageType;
|
|
140
|
+
owner_team?: string;
|
|
141
|
+
applies_to?: string;
|
|
142
|
+
include_stale?: boolean;
|
|
143
|
+
limit?: number;
|
|
144
|
+
}): Promise<{
|
|
145
|
+
results: SearchResult[];
|
|
146
|
+
excluded: Excluded[];
|
|
147
|
+
mirrored?: MirroredHit[];
|
|
148
|
+
note?: string;
|
|
149
|
+
}>;
|
|
150
|
+
read(args: {
|
|
151
|
+
id: string;
|
|
152
|
+
}): Promise<{
|
|
153
|
+
id: string;
|
|
154
|
+
title: string;
|
|
155
|
+
path: string;
|
|
156
|
+
type: PageType;
|
|
157
|
+
status: Page["status"];
|
|
158
|
+
owner_team: string;
|
|
159
|
+
last_verified: string;
|
|
160
|
+
due: string | null;
|
|
161
|
+
sla_state: string;
|
|
162
|
+
citable: boolean;
|
|
163
|
+
review_every: string;
|
|
164
|
+
acl: string;
|
|
165
|
+
supersedes: string[];
|
|
166
|
+
applies_to: string[];
|
|
167
|
+
flags: string[];
|
|
168
|
+
gate_reason?: string | undefined;
|
|
169
|
+
content: string;
|
|
170
|
+
} | {
|
|
171
|
+
id: string;
|
|
172
|
+
title: string;
|
|
173
|
+
source: "confluence" | "notion";
|
|
174
|
+
space: string;
|
|
175
|
+
source_url: string;
|
|
176
|
+
owner_team: string | null;
|
|
177
|
+
last_edited: string;
|
|
178
|
+
acl: string;
|
|
179
|
+
citable: boolean;
|
|
180
|
+
flags: string[];
|
|
181
|
+
gate_reason: string;
|
|
182
|
+
content: string;
|
|
183
|
+
}>;
|
|
184
|
+
/** read() for `tool`: the same view; only an agent's `read` goes in the usage log. */
|
|
185
|
+
private readAs;
|
|
186
|
+
/**
|
|
187
|
+
* `readAs`, with what it read: the page, the caller and the repo, from the one look at the
|
|
188
|
+
* repo after the only await. A caller that builds more of its answer from these (ui_page's
|
|
189
|
+
* links) answers from that same look, as `read` does: a reload after it can't mix in.
|
|
190
|
+
*/
|
|
191
|
+
private readWith;
|
|
192
|
+
/** A repo page's text as `who` may see it: links to pages, or mirrored pages, they can't see redacted. */
|
|
193
|
+
private pageText;
|
|
194
|
+
/** A mirrored page, as context: never citable. Hidden and unknown ids read the same. */
|
|
195
|
+
private readMirror;
|
|
196
|
+
list(args?: {
|
|
197
|
+
path?: string;
|
|
198
|
+
type?: PageType;
|
|
199
|
+
}): Promise<{
|
|
200
|
+
type: "adr" | "guide" | "policy" | "runbook" | "service";
|
|
201
|
+
pages: PageSummary[];
|
|
202
|
+
path?: undefined;
|
|
203
|
+
index?: undefined;
|
|
204
|
+
dirs?: undefined;
|
|
205
|
+
} | {
|
|
206
|
+
type?: undefined;
|
|
207
|
+
path: string;
|
|
208
|
+
index: PageSummary | null;
|
|
209
|
+
pages: PageSummary[];
|
|
210
|
+
dirs: string[];
|
|
211
|
+
}>;
|
|
212
|
+
stale(args?: {
|
|
213
|
+
owner_team?: string;
|
|
214
|
+
}): Promise<{
|
|
215
|
+
pages: StaleEntry[];
|
|
216
|
+
}>;
|
|
217
|
+
/** Who is served and whether writes would work, for the app's header and settings. */
|
|
218
|
+
uiCapabilities(opts?: {
|
|
219
|
+
pro?: string[];
|
|
220
|
+
}): Promise<{
|
|
221
|
+
mode: "github" | "local";
|
|
222
|
+
login: string | null;
|
|
223
|
+
org: string;
|
|
224
|
+
repo: string | null;
|
|
225
|
+
writes: boolean;
|
|
226
|
+
write_tools: string[];
|
|
227
|
+
teams: string[];
|
|
228
|
+
writes_off?: string | undefined;
|
|
229
|
+
pro: string[];
|
|
230
|
+
}>;
|
|
231
|
+
/** Counts over the caller's visible pages, by team, with what is due soon and what is stale. */
|
|
232
|
+
uiOverview(): Promise<{
|
|
233
|
+
as_of: string;
|
|
234
|
+
totals: {
|
|
235
|
+
pages: number;
|
|
236
|
+
citable: number;
|
|
237
|
+
overdue: number;
|
|
238
|
+
due_soon: number;
|
|
239
|
+
draft: number;
|
|
240
|
+
deprecated: number;
|
|
241
|
+
};
|
|
242
|
+
teams: {
|
|
243
|
+
pages: number;
|
|
244
|
+
citable: number;
|
|
245
|
+
overdue: number;
|
|
246
|
+
due_soon: number;
|
|
247
|
+
team: string;
|
|
248
|
+
}[];
|
|
249
|
+
due_soon: PageSummary[];
|
|
250
|
+
stale: StaleEntry[];
|
|
251
|
+
}>;
|
|
252
|
+
/**
|
|
253
|
+
* A page as `read` shows it, with `links`: the repo path and id of each visible page it
|
|
254
|
+
* links to, so the app can route those links. Taints the session with the page and those.
|
|
255
|
+
*/
|
|
256
|
+
uiPage(args: {
|
|
257
|
+
id: string;
|
|
258
|
+
}): Promise<{
|
|
259
|
+
id: string;
|
|
260
|
+
title: string;
|
|
261
|
+
source: "confluence" | "notion";
|
|
262
|
+
space: string;
|
|
263
|
+
source_url: string;
|
|
264
|
+
owner_team: string | null;
|
|
265
|
+
last_edited: string;
|
|
266
|
+
acl: string;
|
|
267
|
+
citable: boolean;
|
|
268
|
+
flags: string[];
|
|
269
|
+
gate_reason: string;
|
|
270
|
+
content: string;
|
|
271
|
+
links: {};
|
|
272
|
+
} | {
|
|
273
|
+
id: string;
|
|
274
|
+
title: string;
|
|
275
|
+
path: string;
|
|
276
|
+
type: PageType;
|
|
277
|
+
status: Page["status"];
|
|
278
|
+
owner_team: string;
|
|
279
|
+
last_verified: string;
|
|
280
|
+
due: string | null;
|
|
281
|
+
sla_state: string;
|
|
282
|
+
citable: boolean;
|
|
283
|
+
review_every: string;
|
|
284
|
+
acl: string;
|
|
285
|
+
supersedes: string[];
|
|
286
|
+
applies_to: string[];
|
|
287
|
+
flags: string[];
|
|
288
|
+
gate_reason?: string | undefined;
|
|
289
|
+
content: string;
|
|
290
|
+
links: {
|
|
291
|
+
[k: string]: string;
|
|
292
|
+
};
|
|
293
|
+
}>;
|
|
294
|
+
/**
|
|
295
|
+
* Visible pages and the links between them. A link to a page the caller can't see is no
|
|
296
|
+
* edge; `has_restricted_links` says a page has one, exactly as its `read` text shows a
|
|
297
|
+
* placeholder for it, so the graph reveals nothing `read` doesn't.
|
|
298
|
+
*/
|
|
299
|
+
uiGraph(args?: {
|
|
300
|
+
owner_team?: string;
|
|
301
|
+
}): Promise<{
|
|
302
|
+
nodes: {
|
|
303
|
+
id: string;
|
|
304
|
+
title: string;
|
|
305
|
+
path: string;
|
|
306
|
+
type: PageType;
|
|
307
|
+
status: Page["status"];
|
|
308
|
+
owner_team: string;
|
|
309
|
+
last_verified: string;
|
|
310
|
+
due: string | null;
|
|
311
|
+
sla_state: string;
|
|
312
|
+
citable: boolean;
|
|
313
|
+
has_restricted_links: boolean;
|
|
314
|
+
}[];
|
|
315
|
+
edges: {
|
|
316
|
+
from: string;
|
|
317
|
+
to: string;
|
|
318
|
+
}[];
|
|
319
|
+
}>;
|
|
320
|
+
/** What every write needs first: the policy, a live identity, a GitHub repo. */
|
|
321
|
+
private writeIdentity;
|
|
322
|
+
private pushContext;
|
|
323
|
+
/** Common preamble for writes to a page: live identity, visibility, a GitHub repo. */
|
|
324
|
+
private writeContext;
|
|
325
|
+
/** The same for a write that creates a page: no id to resolve. */
|
|
326
|
+
private newWriteContext;
|
|
327
|
+
/** The taint check: a session that saw team-only pages writes only into pages with that audience. */
|
|
328
|
+
private refuseLeak;
|
|
329
|
+
proposeEdit(args: {
|
|
330
|
+
id: string;
|
|
331
|
+
edits?: Edit[];
|
|
332
|
+
body?: string;
|
|
333
|
+
content?: string;
|
|
334
|
+
append?: string;
|
|
335
|
+
reason: string;
|
|
336
|
+
draft?: boolean;
|
|
337
|
+
polish?: boolean;
|
|
338
|
+
}): Promise<{
|
|
339
|
+
pr_url: string;
|
|
340
|
+
pr_number: number;
|
|
341
|
+
branch: string;
|
|
342
|
+
}>;
|
|
343
|
+
/**
|
|
344
|
+
* A new page, as a draft PR. The server picks its id and file name, each ending in 12
|
|
345
|
+
* random hex digits: a caller-chosen name could be aimed at a hidden page, and "already
|
|
346
|
+
* exists" would then say it is there. The title still sets the slug, so CODEOWNERS
|
|
347
|
+
* rules that match file names (`runbooks/payments-*`) still apply.
|
|
348
|
+
*/
|
|
349
|
+
draftPage(args: {
|
|
350
|
+
type: NewPageType;
|
|
351
|
+
title: string;
|
|
352
|
+
owner_team: string;
|
|
353
|
+
acl?: string;
|
|
354
|
+
dir?: string;
|
|
355
|
+
description?: string;
|
|
356
|
+
body: string;
|
|
357
|
+
source_url?: string;
|
|
358
|
+
reason: string;
|
|
359
|
+
draft?: boolean;
|
|
360
|
+
}): Promise<{
|
|
361
|
+
id: string;
|
|
362
|
+
path: string;
|
|
363
|
+
pr_url: string;
|
|
364
|
+
pr_number: number;
|
|
365
|
+
branch: string;
|
|
366
|
+
}>;
|
|
367
|
+
verify(args: {
|
|
368
|
+
id: string;
|
|
369
|
+
}): Promise<{
|
|
370
|
+
pr_url: string;
|
|
371
|
+
pr_number: number;
|
|
372
|
+
branch: string;
|
|
373
|
+
last_verified: string;
|
|
374
|
+
}>;
|
|
375
|
+
}
|