@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.
Files changed (91) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +186 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +409 -0
  5. package/dist/core/acl.d.ts +28 -0
  6. package/dist/core/acl.js +102 -0
  7. package/dist/core/audit-log.d.ts +133 -0
  8. package/dist/core/audit-log.js +290 -0
  9. package/dist/core/blocks.d.ts +35 -0
  10. package/dist/core/blocks.js +417 -0
  11. package/dist/core/codeowners.d.ts +62 -0
  12. package/dist/core/codeowners.js +133 -0
  13. package/dist/core/config.d.ts +96 -0
  14. package/dist/core/config.js +131 -0
  15. package/dist/core/coverage.d.ts +25 -0
  16. package/dist/core/coverage.js +41 -0
  17. package/dist/core/duration.d.ts +20 -0
  18. package/dist/core/duration.js +60 -0
  19. package/dist/core/errors.d.ts +11 -0
  20. package/dist/core/errors.js +13 -0
  21. package/dist/core/frontmatter.d.ts +17 -0
  22. package/dist/core/frontmatter.js +40 -0
  23. package/dist/core/gate.d.ts +17 -0
  24. package/dist/core/gate.js +25 -0
  25. package/dist/core/github.d.ts +152 -0
  26. package/dist/core/github.js +234 -0
  27. package/dist/core/host.d.ts +125 -0
  28. package/dist/core/host.js +22 -0
  29. package/dist/core/identity.d.ts +39 -0
  30. package/dist/core/identity.js +127 -0
  31. package/dist/core/init.d.ts +25 -0
  32. package/dist/core/init.js +86 -0
  33. package/dist/core/lint.d.ts +17 -0
  34. package/dist/core/lint.js +115 -0
  35. package/dist/core/mcp/audit-transport.d.ts +16 -0
  36. package/dist/core/mcp/audit-transport.js +238 -0
  37. package/dist/core/mcp/server.d.ts +19 -0
  38. package/dist/core/mcp/server.js +207 -0
  39. package/dist/core/mcp/text.d.ts +14 -0
  40. package/dist/core/mcp/text.js +43 -0
  41. package/dist/core/mirror-view.d.ts +68 -0
  42. package/dist/core/mirror-view.js +367 -0
  43. package/dist/core/new-page.d.ts +52 -0
  44. package/dist/core/new-page.js +49 -0
  45. package/dist/core/policy.d.ts +10 -0
  46. package/dist/core/policy.js +30 -0
  47. package/dist/core/polish.d.ts +42 -0
  48. package/dist/core/polish.js +1000 -0
  49. package/dist/core/references.d.ts +52 -0
  50. package/dist/core/references.js +1242 -0
  51. package/dist/core/repo.d.ts +42 -0
  52. package/dist/core/repo.js +116 -0
  53. package/dist/core/schema.d.ts +46 -0
  54. package/dist/core/schema.js +49 -0
  55. package/dist/core/search.d.ts +24 -0
  56. package/dist/core/search.js +98 -0
  57. package/dist/core/serve/auth.d.ts +23 -0
  58. package/dist/core/serve/auth.js +38 -0
  59. package/dist/core/serve/server.d.ts +22 -0
  60. package/dist/core/serve/server.js +138 -0
  61. package/dist/core/serve/sessions.d.ts +39 -0
  62. package/dist/core/serve/sessions.js +130 -0
  63. package/dist/core/serve/token.d.ts +10 -0
  64. package/dist/core/serve/token.js +96 -0
  65. package/dist/core/service.d.ts +375 -0
  66. package/dist/core/service.js +815 -0
  67. package/dist/core/sla.d.ts +16 -0
  68. package/dist/core/sla.js +20 -0
  69. package/dist/core/stale.d.ts +16 -0
  70. package/dist/core/stale.js +23 -0
  71. package/dist/core/tool-registry.d.ts +31 -0
  72. package/dist/core/tool-registry.js +77 -0
  73. package/dist/core/url.d.ts +4 -0
  74. package/dist/core/url.js +31 -0
  75. package/dist/core/version.d.ts +22 -0
  76. package/dist/core/version.js +32 -0
  77. package/dist/core/writes.d.ts +58 -0
  78. package/dist/core/writes.js +113 -0
  79. package/dist/web/assets/index-BglLMIyN.css +1 -0
  80. package/dist/web/assets/index-DugFPoRu.js +25 -0
  81. package/dist/web/index.html +14 -0
  82. package/docs/format-spec.md +311 -0
  83. package/package.json +75 -4
  84. package/plugin/.claude-plugin/plugin.json +8 -0
  85. package/plugin/.mcp.json +8 -0
  86. package/plugin/bin/docsworth-mcp.mjs +46 -0
  87. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  88. package/plugin/skills/polish-page/SKILL.md +64 -0
  89. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  90. package/plugin/skills/verify-page/SKILL.md +46 -0
  91. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,16 @@
1
+ export type SlaState = "fresh" | "due_soon" | "overdue" | "unverified";
2
+ export interface Sla {
3
+ state: SlaState;
4
+ /** YYYY-MM-DD, or null when the page has no valid verification date/period. */
5
+ due: string | null;
6
+ /** Negative once overdue. */
7
+ days_until_due: number | null;
8
+ }
9
+ /**
10
+ * Review-SLA state of a page, evaluated in a fixed order so states never overlap:
11
+ * invalid input → unverified; past the due day → overdue; within the warning window →
12
+ * due_soon; otherwise fresh. The due day itself is still within SLA.
13
+ */
14
+ export declare function computeSla(lastVerified: unknown, reviewEvery: unknown, now: Date, dueSoonDays: number): Sla;
15
+ /** Within SLA = may be cited (together with status: canonical, see gate.ts). */
16
+ export declare function withinSla(state: SlaState): boolean;
@@ -0,0 +1,20 @@
1
+ import { addDuration, daysBetween, formatDate, parseDate, parseDuration, utcToday } from "./duration.js";
2
+ /**
3
+ * Review-SLA state of a page, evaluated in a fixed order so states never overlap:
4
+ * invalid input → unverified; past the due day → overdue; within the warning window →
5
+ * due_soon; otherwise fresh. The due day itself is still within SLA.
6
+ */
7
+ export function computeSla(lastVerified, reviewEvery, now, dueSoonDays) {
8
+ const verified = parseDate(lastVerified);
9
+ const period = parseDuration(reviewEvery);
10
+ if (!verified || !period)
11
+ return { state: "unverified", due: null, days_until_due: null };
12
+ const due = addDuration(verified, period);
13
+ const daysLeft = daysBetween(utcToday(now), due);
14
+ const state = daysLeft < 0 ? "overdue" : daysLeft <= dueSoonDays ? "due_soon" : "fresh";
15
+ return { state, due: formatDate(due), days_until_due: daysLeft };
16
+ }
17
+ /** Within SLA = may be cited (together with status: canonical, see gate.ts). */
18
+ export function withinSla(state) {
19
+ return state === "fresh" || state === "due_soon";
20
+ }
@@ -0,0 +1,16 @@
1
+ import type { Page, Repo } from "./repo.js";
2
+ export interface StaleEntry {
3
+ id: string;
4
+ title: string;
5
+ path: string;
6
+ owner_team: string;
7
+ status: Page["status"];
8
+ reason: "overdue" | "unverified" | "unowned";
9
+ due: string | null;
10
+ detail: string;
11
+ }
12
+ /**
13
+ * Pages past their SLA or without a resolvable team owner (§4.2 `stale`). Deprecated
14
+ * pages are retired and never reported. Callers pass an ACL-filtered page list.
15
+ */
16
+ export declare function listStale(pages: Page[], repo: Repo, now: Date, ownerTeam?: string): StaleEntry[];
@@ -0,0 +1,23 @@
1
+ import { pageStatus } from "./gate.js";
2
+ /**
3
+ * Pages past their SLA or without a resolvable team owner (§4.2 `stale`). Deprecated
4
+ * pages are retired and never reported. Callers pass an ACL-filtered page list.
5
+ */
6
+ export function listStale(pages, repo, now, ownerTeam) {
7
+ const out = [];
8
+ for (const page of pages) {
9
+ if (page.status === "deprecated")
10
+ continue;
11
+ if (ownerTeam && page.owner_team !== ownerTeam)
12
+ continue;
13
+ const s = pageStatus(page, repo, now);
14
+ const base = { id: page.id, title: page.title, path: page.path, owner_team: page.owner_team, status: page.status, due: s.sla.due };
15
+ if (!s.owned)
16
+ out.push({ ...base, reason: "unowned", detail: s.ownership_problem ?? "unowned" });
17
+ else if (s.sla.state === "overdue")
18
+ out.push({ ...base, reason: "overdue", detail: `due ${s.sla.due}, ${-(s.sla.days_until_due ?? 0)} days ago` });
19
+ else if (s.sla.state === "unverified")
20
+ out.push({ ...base, reason: "unverified", detail: "no valid last_verified" });
21
+ }
22
+ return out.sort((a, b) => (a.due ?? "").localeCompare(b.due ?? ""));
23
+ }
@@ -0,0 +1,31 @@
1
+ import type { AuditEvent } from "./audit-log.js";
2
+ import { POLICY_TOOLS } from "./config.js";
3
+ /** A name `policy.tools` in .knowledge/config.yaml can key on. */
4
+ export type PolicyName = (typeof POLICY_TOOLS)[number];
5
+ type Structured = Record<string, any>;
6
+ type Returned = Pick<AuditEvent, "returned" | "excluded" | "pr_url">;
7
+ export interface ToolSpec {
8
+ readonly name: string;
9
+ /** `all`: every server (stdio and HTTP). `http`: only `docsworth serve` sessions. */
10
+ readonly surface: "all" | "http";
11
+ /** Has side effects: its intent is audited before it runs, and `policy.writes` turns it off. */
12
+ readonly write: boolean;
13
+ /** The `policy.tools` entry this tool answers to. */
14
+ readonly policy: PolicyName;
15
+ /** Arguments the audit log may record, each checked against FIELD_CHECKS. */
16
+ readonly auditFields: readonly string[];
17
+ /** Page ids and PR links in a successful response (the service already filtered it by ACL). */
18
+ returned(sc: Structured): Returned;
19
+ }
20
+ /**
21
+ * The shape each recordable argument must have to be recorded as given. A value that
22
+ * fails its check is recorded as "<invalid>", so a malformed call can't smuggle text into
23
+ * the log through, say, `path`.
24
+ */
25
+ export declare const FIELD_CHECKS: Readonly<Record<string, (v: unknown) => boolean>>;
26
+ export declare const TOOLS: readonly ToolSpec[];
27
+ /** The tool called `name`, or undefined for any name that isn't one. */
28
+ export declare function toolSpec(name: string): ToolSpec | undefined;
29
+ /** Names of the tools with side effects. */
30
+ export declare const WRITE_TOOLS: ReadonlySet<string>;
31
+ export {};
@@ -0,0 +1,77 @@
1
+ import { PAGE_TYPES } from "./schema.js";
2
+ const SLUGISH = /^[a-z0-9][a-z0-9_-]{0,63}$/;
3
+ /**
4
+ * The shape each recordable argument must have to be recorded as given. A value that
5
+ * fails its check is recorded as "<invalid>", so a malformed call can't smuggle text into
6
+ * the log through, say, `path`.
7
+ */
8
+ export const FIELD_CHECKS = {
9
+ // A page id, or a mirrored page's (`mirror:<source>:<space>:<native id>`, v0.2 L2).
10
+ id: (v) => typeof v === "string" && (/^[a-z0-9][a-z0-9-]{0,99}$/.test(v) || /^mirror:(?:confluence|notion):[A-Za-z0-9_.~-]{1,64}:[A-Za-z0-9_-]{1,64}$/.test(v)),
11
+ type: (v) => typeof v === "string" && PAGE_TYPES.includes(v),
12
+ owner_team: (v) => typeof v === "string" && SLUGISH.test(v),
13
+ applies_to: (v) => typeof v === "string" && /^[a-z0-9][a-z0-9:_./-]{0,99}$/i.test(v),
14
+ path: (v) => typeof v === "string" && /^[a-z0-9_./-]{0,200}$/i.test(v) && !v.includes(".."),
15
+ include_stale: (v) => typeof v === "boolean",
16
+ draft: (v) => typeof v === "boolean",
17
+ acl: (v) => typeof v === "string" && SLUGISH.test(v),
18
+ polish: (v) => typeof v === "boolean",
19
+ limit: (v) => typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1000,
20
+ };
21
+ const ids = (xs) => (Array.isArray(xs) ? xs.map((x) => String(x?.id)) : []);
22
+ const prUrl = (sc) => (typeof sc.pr_url === "string" ? { pr_url: sc.pr_url } : {});
23
+ export const TOOLS = [
24
+ {
25
+ name: "search",
26
+ surface: "all",
27
+ write: false,
28
+ policy: "search",
29
+ auditFields: ["type", "owner_team", "applies_to", "include_stale", "limit"],
30
+ // Mirrored pages were shown too (as context), so they count as returned.
31
+ returned: (sc) => ({ returned: [...ids(sc.results), ...ids(sc.mirrored)], excluded: ids(sc.excluded) }),
32
+ },
33
+ { name: "read", surface: "all", write: false, policy: "read", auditFields: ["id"], returned: (sc) => ({ returned: [String(sc.id)] }) },
34
+ {
35
+ name: "list",
36
+ surface: "all",
37
+ write: false,
38
+ policy: "list",
39
+ auditFields: ["path", "type"],
40
+ returned: (sc) => ({ returned: [...(sc.index ? [String(sc.index.id)] : []), ...ids(sc.pages)] }),
41
+ },
42
+ { name: "stale", surface: "all", write: false, policy: "stale", auditFields: ["owner_team"], returned: (sc) => ({ returned: ids(sc.pages) }) },
43
+ { name: "propose_edit", surface: "all", write: true, policy: "propose_edit", auditFields: ["id", "draft", "polish"], returned: prUrl },
44
+ { name: "verify", surface: "all", write: true, policy: "verify", auditFields: ["id"], returned: prUrl },
45
+ // The new page's id is the server's (random), so the intent can't name it: it is
46
+ // recorded from the successful response only.
47
+ {
48
+ name: "draft_page",
49
+ surface: "all",
50
+ write: true,
51
+ policy: "draft_page",
52
+ auditFields: ["type", "owner_team", "acl", "draft"],
53
+ returned: (sc) => ({ ...(typeof sc.id === "string" ? { returned: [sc.id] } : {}), ...prUrl(sc) }),
54
+ },
55
+ // The web app's views, on `docsworth serve` sessions only. They show page lists, so they
56
+ // answer to `list`: a policy that refuses `list` refuses them too.
57
+ { name: "ui_capabilities", surface: "http", write: false, policy: "list", auditFields: [], returned: () => ({}) },
58
+ { name: "ui_overview", surface: "http", write: false, policy: "list", auditFields: [], returned: (sc) => ({ returned: [...ids(sc.due_soon), ...ids(sc.stale)] }) },
59
+ { name: "ui_graph", surface: "http", write: false, policy: "list", auditFields: ["owner_team"], returned: (sc) => ({ returned: ids(sc.nodes) }) },
60
+ // A page for the app: `read`'s view plus the ids of the visible pages it links to.
61
+ {
62
+ name: "ui_page",
63
+ surface: "http",
64
+ write: false,
65
+ policy: "read",
66
+ auditFields: ["id"],
67
+ returned: (sc) => ({ returned: [String(sc.id), ...Object.values((sc.links ?? {}))] }),
68
+ },
69
+ ];
70
+ // A Map, not an object: a tool called `constructor` or `__proto__` must not look known.
71
+ const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
72
+ /** The tool called `name`, or undefined for any name that isn't one. */
73
+ export function toolSpec(name) {
74
+ return BY_NAME.get(name);
75
+ }
76
+ /** Names of the tools with side effects. */
77
+ export const WRITE_TOOLS = new Set(TOOLS.filter((t) => t.write).map((t) => t.name));
@@ -0,0 +1,4 @@
1
+ /** Why `raw` may not carry a credential, or null when it may. */
2
+ export declare function insecureUrl(raw: string): string | null;
3
+ /** A URL for an error message: scheme and host only, never a path, user or password. */
4
+ export declare function urlForError(raw: string): string;
@@ -0,0 +1,31 @@
1
+ // Every URL Docsworth sends a credential to must be https, except on loopback (tests and
2
+ // local fakes). Plain http would put a GitHub or Confluence token on the wire in clear.
3
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
4
+ /** Why `raw` may not carry a credential, or null when it may. */
5
+ export function insecureUrl(raw) {
6
+ let u;
7
+ try {
8
+ u = new URL(raw);
9
+ }
10
+ catch {
11
+ return "is not a valid URL";
12
+ }
13
+ // A password in the URL would be echoed by every error and log that names it.
14
+ if (u.username || u.password)
15
+ return "must not carry a user name or password; give credentials through their own setting";
16
+ if (u.protocol === "https:")
17
+ return null;
18
+ if (u.protocol === "http:" && LOOPBACK.has(u.hostname.replace(/\.$/, "")))
19
+ return null;
20
+ return "must use https:// (plain http is allowed only on loopback), since the token is sent to it";
21
+ }
22
+ /** A URL for an error message: scheme and host only, never a path, user or password. */
23
+ export function urlForError(raw) {
24
+ try {
25
+ const u = new URL(raw);
26
+ return `${u.protocol}//${u.host}`;
27
+ }
28
+ catch {
29
+ return "(not a URL)";
30
+ }
31
+ }
@@ -0,0 +1,22 @@
1
+ /** Format version of the knowledge repo contract in docs/format-spec.md. */
2
+ export declare const FORMAT_VERSION = 1;
3
+ export declare const PACKAGE_VERSION = "0.2.0";
4
+ /**
5
+ * The npm package Docsworth is published as, with its version range: what `docsworth init
6
+ * --ci` writes into a workflow, and what the plugin launcher runs without DOCSWORTH_BIN.
7
+ * The `@docsworth` scope is ours on npm (the org `docsworth`). Another publisher owns
8
+ * `agent-kb` (and `akb`), our old names, and nobody owns the bare `docsworth`, so nothing
9
+ * may fetch any of those. PUBLISHED_PACKAGE in plugin/bin/docsworth-mcp.mjs is kept equal
10
+ * by a test, and scripts/release-guard.ts refuses a release outside its range.
11
+ */
12
+ export declare const PUBLISHED_PACKAGE = "@docsworth/cli@^0.2";
13
+ /** The one package we publish (release checklist, decision 1): a release of any other name is refused. */
14
+ export declare const PACKAGE_NAME = "@docsworth/cli";
15
+ /**
16
+ * Names on npm that aren't ours, never to fetch or publish: other publishers own `agent-kb`
17
+ * and `akb`, and nobody owns the bare `docsworth` (2026-11-07), so whoever publishes it first
18
+ * decides what it runs.
19
+ */
20
+ export declare const UNOWNED_PACKAGES: string[];
21
+ /** Whether a spec is a plain registry package with a range, and not a name we don't own. */
22
+ export declare function ownedPackage(spec: string): boolean;
@@ -0,0 +1,32 @@
1
+ /** Format version of the knowledge repo contract in docs/format-spec.md. */
2
+ export const FORMAT_VERSION = 1;
3
+ export const PACKAGE_VERSION = "0.2.0";
4
+ /**
5
+ * The npm package Docsworth is published as, with its version range: what `docsworth init
6
+ * --ci` writes into a workflow, and what the plugin launcher runs without DOCSWORTH_BIN.
7
+ * The `@docsworth` scope is ours on npm (the org `docsworth`). Another publisher owns
8
+ * `agent-kb` (and `akb`), our old names, and nobody owns the bare `docsworth`, so nothing
9
+ * may fetch any of those. PUBLISHED_PACKAGE in plugin/bin/docsworth-mcp.mjs is kept equal
10
+ * by a test, and scripts/release-guard.ts refuses a release outside its range.
11
+ */
12
+ export const PUBLISHED_PACKAGE = "@docsworth/cli@^0.2";
13
+ /** The one package we publish (release checklist, decision 1): a release of any other name is refused. */
14
+ export const PACKAGE_NAME = "@docsworth/cli";
15
+ /**
16
+ * Names on npm that aren't ours, never to fetch or publish: other publishers own `agent-kb`
17
+ * and `akb`, and nobody owns the bare `docsworth` (2026-11-07), so whoever publishes it first
18
+ * decides what it runs.
19
+ */
20
+ export const UNOWNED_PACKAGES = ["agent-kb", "akb", "docsworth"];
21
+ /**
22
+ * A registry package and a semver range, nothing else: `@scope/name@^0.2`, `name@1.2.3`.
23
+ * No aliases (`x@npm:agent-kb`), URLs, git or file specs, tags, or shell characters
24
+ * (`*` included: unquoted, a repo's filenames could glob-expand it),
25
+ * since this goes unquoted into a workflow that runs with a token that can push.
26
+ */
27
+ const PACKAGE_SPEC = /^(@[a-z0-9][a-z0-9._-]*\/)?([a-z0-9][a-z0-9._-]*)@([\^~]?\d+(?:\.(?:\d+|x)){0,2}(?:-[0-9a-z.]+)?)$/;
28
+ /** Whether a spec is a plain registry package with a range, and not a name we don't own. */
29
+ export function ownedPackage(spec) {
30
+ const m = PACKAGE_SPEC.exec(spec);
31
+ return Boolean(m) && !UNOWNED_PACKAGES.includes(`${m[1] ?? ""}${m[2]}`);
32
+ }
@@ -0,0 +1,58 @@
1
+ import { type GitHubClient } from "./github.js";
2
+ export interface Edit {
3
+ old: string;
4
+ new: string;
5
+ }
6
+ export declare class WriteError extends Error {
7
+ }
8
+ /**
9
+ * Fields an agent may not change through propose_edit. last_verified goes through the
10
+ * owner-only `verify`; changing owner_team, id or acl through an agent PR would let a
11
+ * page change hands or widen its audience in a diff reviewers may skim.
12
+ */
13
+ export declare const PROTECTED_FIELDS: readonly ["id", "owner_team", "acl", "last_verified"];
14
+ /** Apply exact replacements in order; each `old` must occur exactly once. */
15
+ export declare function applyEdits(text: string, edits: Edit[]): string;
16
+ export declare function assertProtectedFieldsUnchanged(before: string, after: string): void;
17
+ /**
18
+ * Replace everything after the frontmatter block, keeping the block byte for byte. `read`
19
+ * returns a page's body without its frontmatter, so an agent edits the body and the
20
+ * frontmatter (owner, ACL, review date) never passes through it.
21
+ */
22
+ export declare function replaceBody(text: string, body: string): string;
23
+ /** Set last_verified in the frontmatter block, leaving every other byte alone. */
24
+ export declare function setLastVerified(text: string, date: string): string;
25
+ export interface PrRequest {
26
+ repo: string;
27
+ baseBranch: string;
28
+ verb: "propose" | "verify" | "draft";
29
+ pageId: string;
30
+ path: string;
31
+ transform: (current: string) => string;
32
+ commitMessage: string;
33
+ title: string;
34
+ /** A function when the body depends on what the transform found. */
35
+ body: string | (() => string);
36
+ draft?: boolean;
37
+ /** Injected for tests; defaults to now. */
38
+ timestamp?: Date;
39
+ /** Create a new file (promote): refuse if it already exists, PUT without a sha. */
40
+ create?: boolean;
41
+ }
42
+ export interface PrResult {
43
+ number: number;
44
+ url: string;
45
+ branch: string;
46
+ }
47
+ /**
48
+ * A fresh branch for one proposal. The second-resolution timestamp alone collided when
49
+ * two agents proposed on the same page in the same second (GitHub: "Reference already
50
+ * exists"), so a random suffix follows it.
51
+ */
52
+ export declare function branchName(verb: string, pageId: string, at: Date, nonce?: string): string;
53
+ /**
54
+ * The only write path: read the file at the default branch, transform it, create a new
55
+ * branch, commit to that branch (explicitly), open a PR. Nothing ever targets the
56
+ * default branch; if a step after branch creation fails, the branch is removed.
57
+ */
58
+ export declare function openPagePr(gh: GitHubClient, req: PrRequest): Promise<PrResult>;
@@ -0,0 +1,113 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { splitFrontmatter } from "./frontmatter.js";
3
+ import { decodeContent, GitHubError } from "./github.js";
4
+ export class WriteError extends Error {
5
+ }
6
+ /**
7
+ * Fields an agent may not change through propose_edit. last_verified goes through the
8
+ * owner-only `verify`; changing owner_team, id or acl through an agent PR would let a
9
+ * page change hands or widen its audience in a diff reviewers may skim.
10
+ */
11
+ export const PROTECTED_FIELDS = ["id", "owner_team", "acl", "last_verified"];
12
+ /** Apply exact replacements in order; each `old` must occur exactly once. */
13
+ export function applyEdits(text, edits) {
14
+ if (edits.length === 0)
15
+ throw new WriteError("no edits given");
16
+ return edits.reduce((acc, e, i) => {
17
+ if (!e.old)
18
+ throw new WriteError(`edit ${i + 1}: \`old\` is empty`);
19
+ const first = acc.indexOf(e.old);
20
+ if (first === -1)
21
+ throw new WriteError(`edit ${i + 1}: \`old\` text not found in the current page`);
22
+ if (acc.indexOf(e.old, first + 1) !== -1)
23
+ throw new WriteError(`edit ${i + 1}: \`old\` text matches more than once; include more context`);
24
+ return acc.slice(0, first) + e.new + acc.slice(first + e.old.length);
25
+ }, text);
26
+ }
27
+ export function assertProtectedFieldsUnchanged(before, after) {
28
+ const a = splitFrontmatter(before).data ?? {};
29
+ const b = splitFrontmatter(after).data;
30
+ if (!b)
31
+ throw new WriteError("the edited page no longer has valid frontmatter");
32
+ for (const f of PROTECTED_FIELDS) {
33
+ if (JSON.stringify(a[f]) !== JSON.stringify(b[f])) {
34
+ const hint = f === "last_verified" ? "; owners bump it with the verify tool" : "";
35
+ throw new WriteError(`propose_edit may not change \`${f}\`${hint}`);
36
+ }
37
+ }
38
+ }
39
+ /**
40
+ * Replace everything after the frontmatter block, keeping the block byte for byte. `read`
41
+ * returns a page's body without its frontmatter, so an agent edits the body and the
42
+ * frontmatter (owner, ACL, review date) never passes through it.
43
+ */
44
+ export function replaceBody(text, body) {
45
+ const m = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
46
+ if (!m)
47
+ throw new WriteError("page has no frontmatter block");
48
+ // The body as given. Only a block that ends the file needs a line break before it.
49
+ return m[0] + (m[0].endsWith("\n") || body === "" ? "" : "\n") + body;
50
+ }
51
+ /** Set last_verified in the frontmatter block, leaving every other byte alone. */
52
+ export function setLastVerified(text, date) {
53
+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/.exec(text);
54
+ if (!m)
55
+ throw new WriteError("page has no frontmatter block");
56
+ const fm = /^last_verified:.*$/m.test(m[2])
57
+ ? m[2].replace(/^last_verified:.*$/m, `last_verified: ${date}`)
58
+ : `${m[2]}\nlast_verified: ${date}`;
59
+ return m[1] + fm + m[3] + text.slice(m[0].length);
60
+ }
61
+ /**
62
+ * A fresh branch for one proposal. The second-resolution timestamp alone collided when
63
+ * two agents proposed on the same page in the same second (GitHub: "Reference already
64
+ * exists"), so a random suffix follows it.
65
+ */
66
+ export function branchName(verb, pageId, at, nonce = randomBytes(3).toString("hex")) {
67
+ const ts = at.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "");
68
+ return `docsworth/${verb}/${pageId}-${ts}-${nonce}`;
69
+ }
70
+ /**
71
+ * The only write path: read the file at the default branch, transform it, create a new
72
+ * branch, commit to that branch (explicitly), open a PR. Nothing ever targets the
73
+ * default branch; if a step after branch creation fails, the branch is removed.
74
+ */
75
+ export async function openPagePr(gh, req) {
76
+ const branch = branchName(req.verb, req.pageId, req.timestamp ?? new Date());
77
+ if (branch === req.baseBranch || !branch.startsWith("docsworth/"))
78
+ throw new WriteError(`refusing to write to ${branch}`);
79
+ let file;
80
+ if (req.create) {
81
+ const exists = await gh.getFile(req.repo, req.path, req.baseBranch).then(() => true, (err) => (err instanceof GitHubError && err.status === 404 ? false : Promise.reject(err)));
82
+ if (exists)
83
+ throw new WriteError(`${req.path} already exists on ${req.baseBranch}`);
84
+ file = { sha: undefined, before: "" };
85
+ }
86
+ else {
87
+ const current = await gh.getFile(req.repo, req.path, req.baseBranch);
88
+ file = { sha: current.sha, before: decodeContent(current) };
89
+ }
90
+ const after = req.transform(file.before);
91
+ if (after === file.before)
92
+ throw new WriteError("the change is empty");
93
+ const baseSha = await gh.getBranchSha(req.repo, req.baseBranch);
94
+ await gh.createBranch(req.repo, branch, baseSha);
95
+ try {
96
+ await gh.putFile(req.repo, req.path, { message: req.commitMessage, content: after, sha: file.sha, branch });
97
+ const pr = await gh.createPull(req.repo, { title: req.title, head: branch, base: req.baseBranch, body: typeof req.body === "function" ? req.body() : req.body, draft: req.draft });
98
+ return { number: pr.number, url: pr.html_url, branch };
99
+ }
100
+ catch (err) {
101
+ // The PR may exist even though its response was lost (timeout, reset): never delete
102
+ // the head branch of a live PR. Only clean up when GitHub confirms there is none.
103
+ const existing = await gh.findPull(req.repo, branch, req.baseBranch).catch(() => undefined);
104
+ if (existing)
105
+ return { number: existing.number, url: existing.html_url, branch };
106
+ if (existing === undefined)
107
+ throw new WriteError(`${err.message}; branch ${branch} was kept because its PR state is unknown`);
108
+ const cleanup = await gh.deleteBranch(req.repo, branch).then(() => null, (e) => e);
109
+ if (cleanup)
110
+ throw new WriteError(`${err.message}; also failed to delete branch ${branch}: ${cleanup.message}`);
111
+ throw err;
112
+ }
113
+ }
@@ -0,0 +1 @@
1
+ :root,:host{--dw-bg:#fbfbfa;--dw-surface:#fff;--dw-fg:#1d1d1b;--dw-muted:#6b6b66;--dw-line:#e4e3de;--dw-track:#ecebe6;--dw-accent:#2b5fa8;--dw-good:#2f7d4f;--dw-warn:#b7791f;--dw-bad:#b4442c;--dw-code:#f2f1ed;--dw-radius:8px;--dw-font:15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;--dw-mono:13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}@media (prefers-color-scheme:dark){:root,:host{--dw-bg:#161615;--dw-surface:#1d1d1c;--dw-fg:#ecebe6;--dw-muted:#9a9993;--dw-line:#2c2c2a;--dw-track:#2a2a28;--dw-accent:#7aa7e8;--dw-good:#58b07c;--dw-warn:#d9a441;--dw-bad:#e0735a;--dw-code:#242422;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}}*{box-sizing:border-box}.dw{background:var(--dw-bg);color:var(--dw-fg);font:var(--dw-font)}.dw a{color:var(--dw-accent);text-decoration:none}.dw a:hover{text-decoration:underline}.dw code{font:var(--dw-mono);background:var(--dw-code);border-radius:4px;padding:0 4px}.dw h1{margin:0 0 4px;font-size:24px;line-height:1.25}.dw h2{margin:28px 0 10px;font-size:17px}.dw-muted{color:var(--dw-muted)}.dw-num{font-variant-numeric:tabular-nums}.dw-header{border-bottom:1px solid var(--dw-line);background:var(--dw-surface);flex-wrap:wrap;align-items:center;gap:20px;padding:12px 20px;display:flex}.dw-brand{letter-spacing:-.01em;font-weight:650;color:var(--dw-fg)!important}.dw-nav{flex-wrap:wrap;gap:4px;display:flex}.dw-nav a{color:var(--dw-muted);border-radius:6px;padding:6px 10px}.dw-nav a[aria-current=page]{color:var(--dw-fg);background:var(--dw-track)}.dw-who{color:var(--dw-muted);align-items:center;gap:8px;margin-left:auto;font-size:13px;display:flex}.dw-main{max-width:1040px;margin:0 auto;padding:28px 20px 64px}.dw-sub{color:var(--dw-muted);margin:0 0 20px}.dw-tiles{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;display:grid}.dw-tile{border:1px solid var(--dw-line);border-radius:var(--dw-radius);background:var(--dw-surface);padding:14px 16px}.dw-tile b{font-variant-numeric:tabular-nums;font-size:28px;display:block}.dw-tile span{color:var(--dw-muted);font-size:13px}.dw-table{border-collapse:collapse;width:100%}.dw-table th,.dw-table td{text-align:left;border-bottom:1px solid var(--dw-line);vertical-align:middle;padding:8px 6px}.dw-table thead th{text-transform:uppercase;letter-spacing:.04em;color:var(--dw-muted);font-size:12px;font-weight:600}.dw-scroll{overflow-x:auto}.dw-bar{background:var(--dw-track);border-radius:4px;max-width:240px;height:8px;margin-bottom:4px;overflow:hidden}.dw-bar>span{height:100%;display:block}.dw-good{background:var(--dw-good)}.dw-warn{background:var(--dw-warn)}.dw-bad{background:var(--dw-bad)}.dw-badge{border:1px solid var(--dw-line);color:var(--dw-muted);white-space:nowrap;border-radius:999px;padding:0 7px;font-size:12px;line-height:18px;display:inline-block}.dw-badge.ok{color:var(--dw-good);border-color:currentColor}.dw-badge.warn{color:var(--dw-warn);border-color:currentColor}.dw-badge.bad{color:var(--dw-bad);border-color:currentColor}.dw-meta{color:var(--dw-muted);flex-wrap:wrap;align-items:center;gap:8px;font-size:13px;display:flex}.dw-search{gap:8px;margin-bottom:20px;display:flex}.dw-search input{flex:1}.dw-results{gap:10px;margin:0;padding:0;list-style:none;display:grid}.dw-result{border:1px solid var(--dw-line);border-radius:var(--dw-radius);background:var(--dw-surface);padding:12px 14px}.dw-result h3{margin:0 0 4px;font-size:16px}.dw-result p{color:var(--dw-muted);margin:6px 0 0;font-size:14px}.dw-excluded{border-left:3px solid var(--dw-warn);background:var(--dw-surface);margin:6px 0;padding:8px 12px}.dw-page{max-width:760px}.dw-prose{line-height:1.65}.dw-prose pre{font:var(--dw-mono);background:var(--dw-code);border-radius:var(--dw-radius);padding:12px 14px;overflow-x:auto}.dw-prose pre code{background:0 0;padding:0}.dw-prose h1{margin-top:24px;font-size:22px}.dw-prose h2{margin-top:24px}.dw-note{border:1px solid var(--dw-line);border-left:3px solid var(--dw-warn);background:var(--dw-surface);border-radius:6px;margin:12px 0;padding:10px 12px;font-size:14px}.dw-error{border-left-color:var(--dw-bad)}.dw input,.dw select,.dw textarea,.dw button{font:inherit;color:inherit}.dw input,.dw select,.dw textarea{background:var(--dw-surface);border:1px solid var(--dw-line);border-radius:6px;width:100%;padding:8px 10px}.dw textarea{font:var(--dw-mono);resize:vertical;min-height:220px}.dw button{background:var(--dw-fg);color:var(--dw-bg);cursor:pointer;border:0;border-radius:6px;padding:8px 14px;font-weight:550}.dw button.secondary{color:var(--dw-fg);border:1px solid var(--dw-line);background:0 0}.dw button:disabled{opacity:.45;cursor:not-allowed}.dw-form{gap:14px;max-width:760px;display:grid}.dw-form label{gap:6px;font-size:14px;font-weight:550;display:grid}.dw-form .dw-row{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;display:grid}.dw-preview{font:var(--dw-mono);white-space:pre-wrap;background:var(--dw-code);border-radius:var(--dw-radius);max-height:260px;padding:12px 14px;overflow:auto}.dw-graph{border:1px solid var(--dw-line);border-radius:var(--dw-radius);background:var(--dw-surface);width:100%;height:560px;display:block}.dw-graph line{stroke:var(--dw-line);stroke-width:1.2px}.dw-graph circle{stroke:var(--dw-surface);stroke-width:1.5px;cursor:pointer}.dw-graph circle.dim{opacity:.35}.dw-graph text{fill:var(--dw-muted);pointer-events:none;font-size:11px}.dw-legend{color:var(--dw-muted);flex-wrap:wrap;gap:14px;margin:10px 0;font-size:13px;display:flex}.dw-swatch{vertical-align:-1px;border-radius:50%;width:10px;height:10px;margin-right:6px;display:inline-block}