@docsworth/cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +238 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +458 -0
  5. package/dist/core/acl.d.ts +37 -0
  6. package/dist/core/acl.js +136 -0
  7. package/dist/core/adopt.d.ts +68 -0
  8. package/dist/core/adopt.js +665 -0
  9. package/dist/core/audit-log.d.ts +133 -0
  10. package/dist/core/audit-log.js +290 -0
  11. package/dist/core/blocks.d.ts +35 -0
  12. package/dist/core/blocks.js +417 -0
  13. package/dist/core/codeowners.d.ts +62 -0
  14. package/dist/core/codeowners.js +133 -0
  15. package/dist/core/config.d.ts +101 -0
  16. package/dist/core/config.js +175 -0
  17. package/dist/core/coverage.d.ts +25 -0
  18. package/dist/core/coverage.js +41 -0
  19. package/dist/core/duration.d.ts +20 -0
  20. package/dist/core/duration.js +60 -0
  21. package/dist/core/edits.d.ts +13 -0
  22. package/dist/core/edits.js +20 -0
  23. package/dist/core/errors.d.ts +11 -0
  24. package/dist/core/errors.js +13 -0
  25. package/dist/core/frontmatter.d.ts +29 -0
  26. package/dist/core/frontmatter.js +49 -0
  27. package/dist/core/gate.d.ts +17 -0
  28. package/dist/core/gate.js +25 -0
  29. package/dist/core/gfm.d.ts +13 -0
  30. package/dist/core/gfm.js +244 -0
  31. package/dist/core/github.d.ts +152 -0
  32. package/dist/core/github.js +234 -0
  33. package/dist/core/host.d.ts +125 -0
  34. package/dist/core/host.js +22 -0
  35. package/dist/core/identity.d.ts +39 -0
  36. package/dist/core/identity.js +127 -0
  37. package/dist/core/init.d.ts +25 -0
  38. package/dist/core/init.js +86 -0
  39. package/dist/core/links.d.ts +82 -0
  40. package/dist/core/links.js +345 -0
  41. package/dist/core/lint.d.ts +16 -0
  42. package/dist/core/lint.js +149 -0
  43. package/dist/core/mcp/audit-transport.d.ts +16 -0
  44. package/dist/core/mcp/audit-transport.js +238 -0
  45. package/dist/core/mcp/server.d.ts +19 -0
  46. package/dist/core/mcp/server.js +216 -0
  47. package/dist/core/mcp/text.d.ts +14 -0
  48. package/dist/core/mcp/text.js +43 -0
  49. package/dist/core/mirror-view.d.ts +68 -0
  50. package/dist/core/mirror-view.js +367 -0
  51. package/dist/core/new-page.d.ts +52 -0
  52. package/dist/core/new-page.js +49 -0
  53. package/dist/core/policy.d.ts +10 -0
  54. package/dist/core/policy.js +30 -0
  55. package/dist/core/polish.d.ts +42 -0
  56. package/dist/core/polish.js +1000 -0
  57. package/dist/core/printable.d.ts +4 -0
  58. package/dist/core/printable.js +16 -0
  59. package/dist/core/references.d.ts +87 -0
  60. package/dist/core/references.js +1744 -0
  61. package/dist/core/repo.d.ts +46 -0
  62. package/dist/core/repo.js +118 -0
  63. package/dist/core/schema.d.ts +50 -0
  64. package/dist/core/schema.js +51 -0
  65. package/dist/core/search.d.ts +32 -0
  66. package/dist/core/search.js +132 -0
  67. package/dist/core/serve/auth.d.ts +23 -0
  68. package/dist/core/serve/auth.js +38 -0
  69. package/dist/core/serve/server.d.ts +21 -0
  70. package/dist/core/serve/server.js +138 -0
  71. package/dist/core/serve/sessions.d.ts +40 -0
  72. package/dist/core/serve/sessions.js +203 -0
  73. package/dist/core/serve/token.d.ts +10 -0
  74. package/dist/core/serve/token.js +96 -0
  75. package/dist/core/service.d.ts +473 -0
  76. package/dist/core/service.js +973 -0
  77. package/dist/core/sla.d.ts +16 -0
  78. package/dist/core/sla.js +20 -0
  79. package/dist/core/stale.d.ts +16 -0
  80. package/dist/core/stale.js +24 -0
  81. package/dist/core/tool-registry.d.ts +31 -0
  82. package/dist/core/tool-registry.js +80 -0
  83. package/dist/core/url.d.ts +4 -0
  84. package/dist/core/url.js +31 -0
  85. package/dist/core/verify-pages.d.ts +19 -0
  86. package/dist/core/verify-pages.js +110 -0
  87. package/dist/core/version.d.ts +22 -0
  88. package/dist/core/version.js +32 -0
  89. package/dist/core/writes.d.ts +58 -0
  90. package/dist/core/writes.js +113 -0
  91. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  92. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  93. package/dist/web/index.html +14 -0
  94. package/docs/format-spec.md +349 -0
  95. package/package.json +75 -4
  96. package/plugin/.claude-plugin/plugin.json +8 -0
  97. package/plugin/.mcp.json +8 -0
  98. package/plugin/bin/docsworth-mcp.mjs +46 -0
  99. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  100. package/plugin/skills/polish-page/SKILL.md +64 -0
  101. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  102. package/plugin/skills/verify-page/SKILL.md +46 -0
  103. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,101 @@
1
+ import { z } from "zod";
2
+ import { UserError } from "./errors.js";
3
+ export declare const CONFIG_PATH = ".knowledge/config.yaml";
4
+ /** The MCP tools a policy can name (kept here so config doesn't import the server). */
5
+ export declare const POLICY_TOOLS: readonly ["search", "read", "list", "stale", "propose_edit", "verify", "draft_page"];
6
+ declare const MirrorSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
7
+ kind: z.ZodLiteral<"confluence">;
8
+ name: z.ZodDefault<z.ZodString>;
9
+ base_url: z.ZodString;
10
+ spaces: z.ZodArray<z.ZodObject<{
11
+ key: z.ZodString;
12
+ acl: z.ZodDefault<z.ZodString>;
13
+ }, z.core.$strip>>;
14
+ }, z.core.$strip>, z.ZodObject<{
15
+ kind: z.ZodLiteral<"notion">;
16
+ name: z.ZodDefault<z.ZodString>;
17
+ root_page_ids: z.ZodDefault<z.ZodArray<z.ZodString>>;
18
+ acl: z.ZodDefault<z.ZodString>;
19
+ }, z.core.$strip>], "kind">;
20
+ export declare const ConfigSchema: z.ZodObject<{
21
+ format: z.ZodDefault<z.ZodLiteral<1>>;
22
+ knowledge_root: z.ZodDefault<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
23
+ static_dirs: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
24
+ site_url: z.ZodOptional<z.ZodString>;
25
+ github: z.ZodObject<{
26
+ org: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
27
+ repo: z.ZodOptional<z.ZodString>;
28
+ default_branch: z.ZodOptional<z.ZodString>;
29
+ api_url: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strip>;
31
+ sla: z.ZodDefault<z.ZodObject<{
32
+ due_soon_days: z.ZodDefault<z.ZodNumber>;
33
+ }, z.core.$strip>>;
34
+ usage_log: z.ZodDefault<z.ZodBoolean>;
35
+ audit_log: z.ZodDefault<z.ZodObject<{
36
+ enabled: z.ZodDefault<z.ZodBoolean>;
37
+ dir: z.ZodOptional<z.ZodString>;
38
+ include_queries: z.ZodDefault<z.ZodBoolean>;
39
+ required: z.ZodDefault<z.ZodBoolean>;
40
+ retention_days: z.ZodDefault<z.ZodNumber>;
41
+ }, z.core.$strict>>;
42
+ skills: z.ZodDefault<z.ZodObject<{
43
+ out_dir: z.ZodDefault<z.ZodString>;
44
+ }, z.core.$strip>>;
45
+ frontmatter: z.ZodDefault<z.ZodObject<{
46
+ extra_fields: z.ZodDefault<z.ZodArray<z.ZodString>>;
47
+ }, z.core.$strict>>;
48
+ mirror: z.ZodDefault<z.ZodObject<{
49
+ out_dir: z.ZodDefault<z.ZodString>;
50
+ sources: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
51
+ kind: z.ZodLiteral<"confluence">;
52
+ name: z.ZodDefault<z.ZodString>;
53
+ base_url: z.ZodString;
54
+ spaces: z.ZodArray<z.ZodObject<{
55
+ key: z.ZodString;
56
+ acl: z.ZodDefault<z.ZodString>;
57
+ }, z.core.$strip>>;
58
+ }, z.core.$strip>, z.ZodObject<{
59
+ kind: z.ZodLiteral<"notion">;
60
+ name: z.ZodDefault<z.ZodString>;
61
+ root_page_ids: z.ZodDefault<z.ZodArray<z.ZodString>>;
62
+ acl: z.ZodDefault<z.ZodString>;
63
+ }, z.core.$strip>], "kind">>>;
64
+ serve: z.ZodDefault<z.ZodBoolean>;
65
+ }, z.core.$strip>>;
66
+ policy: z.ZodDefault<z.ZodObject<{
67
+ admins: z.ZodOptional<z.ZodString>;
68
+ tools: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
69
+ draft_page: "draft_page";
70
+ list: "list";
71
+ propose_edit: "propose_edit";
72
+ read: "read";
73
+ search: "search";
74
+ stale: "stale";
75
+ verify: "verify";
76
+ }> & z.core.$partial, z.ZodObject<{
77
+ teams: z.ZodArray<z.ZodString>;
78
+ }, z.core.$strict>>>;
79
+ writes: z.ZodDefault<z.ZodEnum<{
80
+ disabled: "disabled";
81
+ enabled: "enabled";
82
+ }>>;
83
+ require_human_approval: z.ZodDefault<z.ZodBoolean>;
84
+ include_stale: z.ZodDefault<z.ZodEnum<{
85
+ allowed: "allowed";
86
+ denied: "denied";
87
+ }>>;
88
+ identity_cache_seconds: z.ZodDefault<z.ZodNumber>;
89
+ }, z.core.$strict>>;
90
+ digest: z.ZodDefault<z.ZodObject<{
91
+ slack_webhook_env: z.ZodDefault<z.ZodString>;
92
+ team_webhooks: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
93
+ }, z.core.$strip>>;
94
+ audit_http: z.ZodOptional<z.ZodUnknown>;
95
+ }, z.core.$strict>;
96
+ export type Config = z.infer<typeof ConfigSchema>;
97
+ export type MirrorSourceConfig = z.infer<typeof MirrorSourceSchema>;
98
+ export declare class ConfigError extends UserError {
99
+ }
100
+ export declare function loadConfig(repoRoot: string): Config;
101
+ export {};
@@ -0,0 +1,175 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, posix } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { z } from "zod";
5
+ import { UserError } from "./errors.js";
6
+ import { insecureUrl } from "./url.js";
7
+ export const CONFIG_PATH = ".knowledge/config.yaml";
8
+ /** The MCP tools a policy can name (kept here so config doesn't import the server). */
9
+ export const POLICY_TOOLS = ["search", "read", "list", "stale", "propose_edit", "verify", "draft_page"];
10
+ const TeamSlug = z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, "a lowercase team slug like `platform`");
11
+ const MirrorSourceSchema = z.discriminatedUnion("kind", [
12
+ z.object({
13
+ kind: z.literal("confluence"),
14
+ name: z.string().default("confluence"),
15
+ base_url: z.string().url().superRefine((u, ctx) => {
16
+ const why = insecureUrl(u);
17
+ if (why)
18
+ ctx.addIssue({ code: "custom", message: `base_url ${why}` });
19
+ }),
20
+ spaces: z.array(z.object({ key: z.string().min(1), acl: z.string().default("internal") })).min(1),
21
+ }),
22
+ z.object({
23
+ kind: z.literal("notion"),
24
+ name: z.string().default("notion"),
25
+ root_page_ids: z.array(z.string()).default([]),
26
+ acl: z.string().default("internal"),
27
+ }),
28
+ ]);
29
+ /**
30
+ * A directory in the repo, in one spelling: `knowledge/`, `./knowledge` and `knowledge//` are
31
+ * `knowledge`, since paths join to it and a trailing slash would double. It's named relative to
32
+ * the repo, which redaction and CODEOWNERS paths are built on: the repo itself (`.`, or `/`,
33
+ * which a join makes the same), a directory above it, an absolute path and a Windows separator
34
+ * are refused. So are git's own directory (a ref named `x.md` is a file adopt would write) and
35
+ * Docsworth's state.
36
+ */
37
+ function repoDir(what) {
38
+ return z
39
+ .string()
40
+ .transform((s) => posix.normalize(s).replace(/(.)\/+$/, "$1"))
41
+ .refine((s) => s !== "." && s !== ".." && !s.startsWith("../") && !s.startsWith("/") && !/^[A-Za-z]:/.test(s) && !s.includes("\\"), `${what} is a directory in the repo, named relative to it: not the repo itself (.), not above it (..), not absolute; such as \`knowledge\``)
42
+ .refine((s) => !s.split("/").some((seg) => seg.toLowerCase() === ".git") && !/^\.knowledge(\/|$)/i.test(s), `${what} can't be in git's own directory (.git) or Docsworth's (.knowledge)`);
43
+ }
44
+ export const ConfigSchema = z.object({
45
+ format: z.literal(1).default(1),
46
+ knowledge_root: repoDir("the knowledge root").default("knowledge"),
47
+ /**
48
+ * Directories a docs site serves at its root (Docusaurus's `static/`): lint reads a
49
+ * root-relative attachment link (`/img/x.png`) from them too.
50
+ */
51
+ static_dirs: z.array(repoDir("a static directory")).default([]),
52
+ /**
53
+ * Where the team's docs site serves the pages, as `https://docs.example.com/docs/`.
54
+ * Redaction reads a link to a URL under it as the route it names.
55
+ */
56
+ site_url: z
57
+ .string()
58
+ .url()
59
+ .superRefine((u, ctx) => {
60
+ // `.url()` has reported a value that isn't one; this runs all the same.
61
+ const at = URL.canParse(u) ? new URL(u) : undefined;
62
+ if (!at)
63
+ return;
64
+ if (at.protocol !== "https:" && at.protocol !== "http:")
65
+ ctx.addIssue({ code: "custom", message: "site_url must be an http or https URL" });
66
+ else if (at.username || at.password || at.search || at.hash)
67
+ ctx.addIssue({ code: "custom", message: "site_url is an origin and a path, with no credentials, query or fragment" });
68
+ })
69
+ .optional(),
70
+ github: z.object({
71
+ /** Team owners resolve to @<org>/<team>. Required even for local-only repos. */
72
+ org: z.string().min(1).transform((s) => s.toLowerCase()),
73
+ /** owner/name. When set, GitHub is the ACL authority and a token is required. */
74
+ repo: z
75
+ .string()
76
+ .regex(/^[\w.-]+\/[\w.-]+$/, "owner/name")
77
+ .optional(),
78
+ default_branch: z.string().optional(),
79
+ /** REST API base for GitHub Enterprise Server, e.g. https://ghe.example.com/api/v3. */
80
+ api_url: z
81
+ .string()
82
+ .url()
83
+ .superRefine((u, ctx) => {
84
+ const why = insecureUrl(u);
85
+ if (why)
86
+ ctx.addIssue({ code: "custom", message: `github.api_url ${why}` });
87
+ })
88
+ .optional(),
89
+ }),
90
+ sla: z.object({ due_soon_days: z.number().int().min(0).default(14) }).default({ due_soon_days: 14 }),
91
+ /** Append page-id-only read events to .knowledge/usage.jsonl (feeds the digest). */
92
+ usage_log: z.boolean().default(false),
93
+ /** The attributed audit log (src/core/audit-log.ts). Off unless enabled. */
94
+ audit_log: z
95
+ .object({
96
+ enabled: z.boolean().default(false),
97
+ /** Default: a state directory outside the checkout. Inside it, git must ignore it. */
98
+ dir: z.string().min(1).optional(),
99
+ /** Record search query text. Off: queries can carry sensitive text. */
100
+ include_queries: z.boolean().default(false),
101
+ /** Fail a tool call (closed) when its event can't be written. */
102
+ required: z.boolean().default(false),
103
+ retention_days: z.number().int().min(1).default(365),
104
+ })
105
+ .strict() // `requred: true` must fail, not quietly make the log optional
106
+ // "Required" with nothing enabled would quietly mean no audit log at all.
107
+ .refine((a) => !a.required || a.enabled, { message: "audit_log.required needs audit_log.enabled: true (otherwise nothing is audited)" })
108
+ .default({ enabled: false, include_queries: false, required: false, retention_days: 365 }),
109
+ skills: z.object({ out_dir: z.string().default("skills") }).default({ out_dir: "skills" }),
110
+ /** Keys a site generator keeps in frontmatter (Docusaurus's `sidebar_position`, say): lint doesn't warn on them. */
111
+ frontmatter: z
112
+ .object({ extra_fields: z.array(z.string().min(1)).default([]) })
113
+ // `extra_field:` must fail, not quietly leave every key warned on.
114
+ .strict()
115
+ .default({ extra_fields: [] }),
116
+ mirror: z
117
+ .object({
118
+ out_dir: z.string().default(".knowledge/mirror"),
119
+ sources: z.array(MirrorSourceSchema).default([]),
120
+ /** Serve mirrored pages to agents as non-citable context (v0.2 L2). Off by default. */
121
+ serve: z.boolean().default(false),
122
+ })
123
+ .default({ out_dir: ".knowledge/mirror", sources: [], serve: false }),
124
+ /** Org policy and roles (src/core/policy.ts). Every default keeps v0.1 behaviour. */
125
+ policy: z
126
+ .object({
127
+ /** The team that must own .knowledge/config.yaml and CODEOWNERS in CODEOWNERS (lint). */
128
+ admins: TeamSlug.optional(),
129
+ /** Per-tool allowlists. An empty list disables the tool for everyone. */
130
+ tools: z.partialRecord(z.enum(POLICY_TOOLS), z.object({ teams: z.array(TeamSlug) }).strict()).default({}),
131
+ /** `disabled`: propose_edit and verify are not offered at all. */
132
+ writes: z.enum(["enabled", "disabled"]).default("enabled"),
133
+ /** Mark write tools so Claude Code asks a human on every call (defence in depth). */
134
+ require_human_approval: z.boolean().default(false),
135
+ /** `denied`: search never returns gated pages inline, even with include_stale. */
136
+ include_stale: z.enum(["allowed", "denied"]).default("allowed"),
137
+ /** How long team membership is cached for reads: the deprovisioning window. */
138
+ identity_cache_seconds: z.number().int().min(30).max(3600).default(300),
139
+ })
140
+ // A typo here must be an error, not a silently ignored key: `write: disabled`
141
+ // would otherwise leave writes on.
142
+ .strict()
143
+ .default({ tools: {}, writes: "enabled", require_human_approval: false, include_stale: "allowed", identity_cache_seconds: 300 }),
144
+ digest: z
145
+ .object({
146
+ /** Env var holding the shared Slack incoming-webhook URL. */
147
+ slack_webhook_env: z.string().default("SLACK_WEBHOOK_URL"),
148
+ /**
149
+ * Per-team webhook env vars. Each MUST point at that team's own private channel:
150
+ * restricted page titles are posted there by name. Shared channels go through
151
+ * slack_webhook_env, where restricted pages are only counted.
152
+ */
153
+ team_webhooks: z.record(z.string(), z.string()).default({}),
154
+ })
155
+ .default({ slack_webhook_env: "SLACK_WEBHOOK_URL", team_webhooks: {} }),
156
+ /** Pro: the audit collector. Docsworth pro parses it; core only reserves the key. */
157
+ audit_http: z.unknown().optional(),
158
+ })
159
+ // Every top-level key is known, so a misspelled block (`polciy:`, `audit_lgo:`) is an
160
+ // error rather than a silently dropped one that would leave writes on or auditing off.
161
+ .strict();
162
+ export class ConfigError extends UserError {
163
+ }
164
+ export function loadConfig(repoRoot) {
165
+ const abs = join(repoRoot, CONFIG_PATH);
166
+ if (!existsSync(abs))
167
+ throw new ConfigError(`no ${CONFIG_PATH} in ${repoRoot} (run \`docsworth init\`)`);
168
+ const raw = parseYaml(readFileSync(abs, "utf8"), { schema: "core" });
169
+ const result = ConfigSchema.safeParse(raw ?? {});
170
+ if (!result.success) {
171
+ const detail = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
172
+ throw new ConfigError(`invalid ${CONFIG_PATH}: ${detail}`);
173
+ }
174
+ return result.data;
175
+ }
@@ -0,0 +1,25 @@
1
+ import type { Repo } from "./repo.js";
2
+ export interface CoverageRow {
3
+ team: string;
4
+ pages: number;
5
+ owned: number;
6
+ owned_pct: number;
7
+ /** Pages subject to review: everything except deprecated pages. */
8
+ reviewable: number;
9
+ within_sla: number;
10
+ within_sla_pct: number;
11
+ }
12
+ export interface Coverage {
13
+ as_of: string;
14
+ total: CoverageRow;
15
+ teams: CoverageRow[];
16
+ }
17
+ /** Team bucket for pages whose frontmatter is too broken to name an owner. */
18
+ export declare const UNASSIGNED = "(unassigned)";
19
+ /**
20
+ * Ownership coverage (§4.3): % of pages team-owned and % verified within SLA, per team.
21
+ * Invalid pages count as neither owned nor verified, so a broken page lowers coverage
22
+ * instead of disappearing from it.
23
+ */
24
+ export declare function computeCoverage(repo: Repo, now: Date): Coverage;
25
+ export declare function formatCoverage(c: Coverage): string;
@@ -0,0 +1,41 @@
1
+ import { pageStatus } from "./gate.js";
2
+ import { withinSla } from "./sla.js";
3
+ /** Team bucket for pages whose frontmatter is too broken to name an owner. */
4
+ export const UNASSIGNED = "(unassigned)";
5
+ const pct = (n, d) => (d === 0 ? 100 : Math.round((n / d) * 1000) / 10);
6
+ function row(team, items) {
7
+ const owned = items.filter((i) => i.owned).length;
8
+ const reviewable = items.filter((i) => i.reviewable).length;
9
+ const within = items.filter((i) => i.reviewable && i.within).length;
10
+ return {
11
+ team,
12
+ pages: items.length,
13
+ owned,
14
+ owned_pct: pct(owned, items.length),
15
+ reviewable,
16
+ within_sla: within,
17
+ within_sla_pct: pct(within, reviewable),
18
+ };
19
+ }
20
+ /**
21
+ * Ownership coverage (§4.3): % of pages team-owned and % verified within SLA, per team.
22
+ * Invalid pages count as neither owned nor verified, so a broken page lowers coverage
23
+ * instead of disappearing from it.
24
+ */
25
+ export function computeCoverage(repo, now) {
26
+ const byTeam = new Map();
27
+ const add = (team, item) => byTeam.set(team, [...(byTeam.get(team) ?? []), item]);
28
+ for (const page of repo.pages) {
29
+ const s = pageStatus(page, repo, now);
30
+ add(page.owner_team, { owned: s.owned, reviewable: page.status !== "deprecated", within: withinSla(s.sla.state) });
31
+ }
32
+ for (const _ of repo.invalid)
33
+ add(UNASSIGNED, { owned: false, reviewable: true, within: false });
34
+ const teams = [...byTeam.entries()].map(([team, items]) => row(team, items)).sort((a, b) => a.team.localeCompare(b.team));
35
+ return { as_of: now.toISOString().slice(0, 10), total: row("total", [...byTeam.values()].flat()), teams };
36
+ }
37
+ export function formatCoverage(c) {
38
+ const header = "team pages owned within SLA";
39
+ const fmt = (r) => `${r.team.padEnd(15)} ${String(r.pages).padStart(5)} ${`${r.owned}/${r.pages}`.padStart(6)} ${`${r.owned_pct}%`.padStart(6)} ${`${r.within_sla}/${r.reviewable}`.padStart(6)} ${`${r.within_sla_pct}%`.padStart(6)}`;
40
+ return [`Ownership coverage as of ${c.as_of}`, header, ...c.teams.map(fmt), fmt(c.total)].join("\n");
41
+ }
@@ -0,0 +1,20 @@
1
+ export type DurationUnit = "d" | "w" | "m" | "y";
2
+ export interface Duration {
3
+ n: number;
4
+ unit: DurationUnit;
5
+ }
6
+ export declare const DURATION_RE: RegExp;
7
+ export declare function parseDuration(s: unknown): Duration | null;
8
+ /** Parse `YYYY-MM-DD` to UTC midnight; rejects impossible dates like 2026-02-30. */
9
+ export declare function parseDate(s: unknown): Date | null;
10
+ export declare function formatDate(d: Date): string;
11
+ /** The UTC calendar day containing `now`. */
12
+ export declare function utcToday(now: Date): Date;
13
+ /**
14
+ * Add a duration. Days and weeks are fixed; months and years are calendar units
15
+ * clamped to the end of the target month (Jan 31 + 1m = Feb 28/29).
16
+ */
17
+ export declare function addDuration(date: Date, dur: Duration): Date;
18
+ /** Go back by a duration, clamping the same way (Mar 31 − 1m = Feb 28/29). */
19
+ export declare function subtractDuration(date: Date, dur: Duration): Date;
20
+ export declare function daysBetween(from: Date, to: Date): number;
@@ -0,0 +1,60 @@
1
+ // Date-only calendar arithmetic for review SLAs.
2
+ //
3
+ // All dates are UTC midnight `Date`s produced by `parseDate`, so a page verified on
4
+ // 2026-10-15 is due on the same calendar day for every reader regardless of timezone.
5
+ export const DURATION_RE = /^([1-9]\d{0,3})([dwmy])$/;
6
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
7
+ const DAY_MS = 86_400_000;
8
+ export function parseDuration(s) {
9
+ if (typeof s !== "string")
10
+ return null;
11
+ const m = DURATION_RE.exec(s.trim());
12
+ return m ? { n: Number(m[1]), unit: m[2] } : null;
13
+ }
14
+ /** Parse `YYYY-MM-DD` to UTC midnight; rejects impossible dates like 2026-02-30. */
15
+ export function parseDate(s) {
16
+ if (typeof s !== "string")
17
+ return null;
18
+ const m = DATE_RE.exec(s.trim());
19
+ if (!m)
20
+ return null;
21
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
22
+ const date = new Date(Date.UTC(y, mo - 1, d));
23
+ const roundTrips = date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d;
24
+ return roundTrips ? date : null;
25
+ }
26
+ export function formatDate(d) {
27
+ return d.toISOString().slice(0, 10);
28
+ }
29
+ /** The UTC calendar day containing `now`. */
30
+ export function utcToday(now) {
31
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
32
+ }
33
+ /**
34
+ * Add a duration. Days and weeks are fixed; months and years are calendar units
35
+ * clamped to the end of the target month (Jan 31 + 1m = Feb 28/29).
36
+ */
37
+ export function addDuration(date, dur) {
38
+ if (dur.unit === "d" || dur.unit === "w") {
39
+ return new Date(date.getTime() + dur.n * (dur.unit === "w" ? 7 : 1) * DAY_MS);
40
+ }
41
+ const months = dur.unit === "y" ? dur.n * 12 : dur.n;
42
+ const y = date.getUTCFullYear();
43
+ const targetMonth = date.getUTCMonth() + months;
44
+ const lastDayOfTarget = new Date(Date.UTC(y, targetMonth + 1, 0)).getUTCDate();
45
+ return new Date(Date.UTC(y, targetMonth, Math.min(date.getUTCDate(), lastDayOfTarget)));
46
+ }
47
+ /** Go back by a duration, clamping the same way (Mar 31 − 1m = Feb 28/29). */
48
+ export function subtractDuration(date, dur) {
49
+ if (dur.unit === "d" || dur.unit === "w") {
50
+ return new Date(date.getTime() - dur.n * (dur.unit === "w" ? 7 : 1) * DAY_MS);
51
+ }
52
+ const months = dur.unit === "y" ? dur.n * 12 : dur.n;
53
+ const y = date.getUTCFullYear();
54
+ const targetMonth = date.getUTCMonth() - months;
55
+ const lastDayOfTarget = new Date(Date.UTC(y, targetMonth + 1, 0)).getUTCDate();
56
+ return new Date(Date.UTC(y, targetMonth, Math.min(date.getUTCDate(), lastDayOfTarget)));
57
+ }
58
+ export function daysBetween(from, to) {
59
+ return Math.round((to.getTime() - from.getTime()) / DAY_MS);
60
+ }
@@ -0,0 +1,13 @@
1
+ export interface Edit {
2
+ /** Start offset in the original text. */
3
+ start: number;
4
+ /** End offset (exclusive) in the original text. */
5
+ end: number;
6
+ insert: string;
7
+ }
8
+ /**
9
+ * `text` with each edit applied at its original offsets, rebuilt in one pass after sorting the
10
+ * edits (O(E log E) plus the text, not once per edit). Edits must not overlap; ones at the same
11
+ * offset apply in the order given.
12
+ */
13
+ export declare function applyEdits(text: string, edits: readonly Edit[]): string;
@@ -0,0 +1,20 @@
1
+ // Replacing ranges of a text by their original offsets: every rewrite `adopt` and `verify`
2
+ // make goes through here, so one edit never shifts another's place.
3
+ /**
4
+ * `text` with each edit applied at its original offsets, rebuilt in one pass after sorting the
5
+ * edits (O(E log E) plus the text, not once per edit). Edits must not overlap; ones at the same
6
+ * offset apply in the order given.
7
+ */
8
+ export function applyEdits(text, edits) {
9
+ const sorted = edits.map((e, i) => ({ e, i })).sort((a, b) => a.e.start - b.e.start || a.e.end - b.e.end || a.i - b.i);
10
+ const parts = [];
11
+ let at = 0;
12
+ for (const { e } of sorted) {
13
+ if (e.start < at)
14
+ throw new Error(`edits overlap at offset ${e.start}`);
15
+ parts.push(text.slice(at, e.start), e.insert);
16
+ at = e.end;
17
+ }
18
+ parts.push(text.slice(at));
19
+ return parts.join("");
20
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * An error the person running the CLI can act on: it prints as its message alone and exits
3
+ * 2, rather than as a stack. It's recognized by a brand, not by its class, so the CLI tells
4
+ * Docsworth pro's errors apart without importing pro: pro is another package, and its errors
5
+ * may extend another copy of this class.
6
+ */
7
+ export declare class UserError extends Error {
8
+ readonly userError: true;
9
+ }
10
+ /** Whether `err` is a UserError, from this copy of core or any other. */
11
+ export declare function isUserError(err: unknown): err is Error;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * An error the person running the CLI can act on: it prints as its message alone and exits
3
+ * 2, rather than as a stack. It's recognized by a brand, not by its class, so the CLI tells
4
+ * Docsworth pro's errors apart without importing pro: pro is another package, and its errors
5
+ * may extend another copy of this class.
6
+ */
7
+ export class UserError extends Error {
8
+ userError = true;
9
+ }
10
+ /** Whether `err` is a UserError, from this copy of core or any other. */
11
+ export function isUserError(err) {
12
+ return err instanceof Error && err.userError === true;
13
+ }
@@ -0,0 +1,29 @@
1
+ export interface SplitDoc {
2
+ /** Parsed frontmatter mapping, or null when absent/unparseable (see `error`). */
3
+ data: Record<string, unknown> | null;
4
+ body: string;
5
+ error?: string;
6
+ }
7
+ export declare const FRONTMATTER_MAX: number;
8
+ /**
9
+ * Split `---` frontmatter from a markdown file and parse it as YAML.
10
+ *
11
+ * The YAML "core" schema is used on purpose: under YAML 1.1 rules `2026-10-15` would
12
+ * become a Date and `review_every: 90d` could be coerced oddly. Here every scalar that
13
+ * isn't a plain number/bool/null stays a string, so dates round-trip exactly.
14
+ */
15
+ export declare function splitFrontmatter(text: string): SplitDoc;
16
+ export interface FrontmatterSpan {
17
+ /** Where the YAML starts: just after the opening `---` line. */
18
+ start: number;
19
+ /** Where it ends, before the closing `---` line's own line break. */
20
+ end: number;
21
+ /** Where the body starts, after the closing `---` line. */
22
+ bodyStart: number;
23
+ /** The opening line's line ending. */
24
+ eol: "\n" | "\r\n";
25
+ }
26
+ /** Where a page's frontmatter sits in `text`, by offset; null without a terminated block. */
27
+ export declare function frontmatterSpan(text: string): FrontmatterSpan | null;
28
+ /** Serialize frontmatter + body back to a markdown file (used by writes and promote). */
29
+ export declare function joinFrontmatter(yaml: string, body: string): string;
@@ -0,0 +1,49 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ const OPEN = /^---\r?\n/;
3
+ export const FRONTMATTER_MAX = 16 * 1024;
4
+ const CLOSE = /\r?\n---[ \t]*(\r?\n|$)/;
5
+ /**
6
+ * Split `---` frontmatter from a markdown file and parse it as YAML.
7
+ *
8
+ * The YAML "core" schema is used on purpose: under YAML 1.1 rules `2026-10-15` would
9
+ * become a Date and `review_every: 90d` could be coerced oddly. Here every scalar that
10
+ * isn't a plain number/bool/null stays a string, so dates round-trip exactly.
11
+ */
12
+ export function splitFrontmatter(text) {
13
+ if (!OPEN.test(text))
14
+ return { data: null, body: text, error: "missing frontmatter block" };
15
+ const span = frontmatterSpan(text);
16
+ if (!span)
17
+ return { data: null, body: text, error: "unterminated frontmatter block" };
18
+ const yamlSrc = text.slice(span.start, span.end);
19
+ const body = text.slice(span.bodyStart);
20
+ // A page's frontmatter is a few hundred bytes; YAML parsing slows past linear on
21
+ // hostile input (64 KB of `}&`: 650 ms, paid on every reload).
22
+ if (Buffer.byteLength(yamlSrc, "utf8") > FRONTMATTER_MAX)
23
+ return { data: null, body, error: `frontmatter is over ${FRONTMATTER_MAX / 1024} KB` };
24
+ try {
25
+ const data = parseYaml(yamlSrc, { schema: "core" });
26
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
27
+ return { data: null, body, error: "frontmatter is not a mapping" };
28
+ }
29
+ return { data: data, body };
30
+ }
31
+ catch (err) {
32
+ return { data: null, body, error: `invalid YAML: ${err.message.split("\n")[0]}` };
33
+ }
34
+ }
35
+ /** Where a page's frontmatter sits in `text`, by offset; null without a terminated block. */
36
+ export function frontmatterSpan(text) {
37
+ const open = OPEN.exec(text);
38
+ if (!open)
39
+ return null;
40
+ const start = open[0].length;
41
+ const close = CLOSE.exec(text.slice(start));
42
+ if (!close)
43
+ return null;
44
+ return { start, end: start + close.index, bodyStart: start + close.index + close[0].length, eol: open[0].length === 5 ? "\r\n" : "\n" };
45
+ }
46
+ /** Serialize frontmatter + body back to a markdown file (used by writes and promote). */
47
+ export function joinFrontmatter(yaml, body) {
48
+ return `---\n${yaml.trimEnd()}\n---\n${body.startsWith("\n") ? body : `\n${body}`}`;
49
+ }
@@ -0,0 +1,17 @@
1
+ import type { Page, Repo } from "./repo.js";
2
+ import { type Sla } from "./sla.js";
3
+ export interface PageStatus {
4
+ sla: Sla;
5
+ /** CODEOWNERS resolves to exactly one team of the org, and it equals owner_team. */
6
+ owned: boolean;
7
+ ownership_problem?: string;
8
+ /** May an agent cite this page? canonical + within SLA + owned. Not configurable. */
9
+ citable: boolean;
10
+ /** Why it is not citable, phrased for an agent's answer ("excluded: …"). */
11
+ gate_reason?: string;
12
+ }
13
+ /**
14
+ * The citation gate. Hard-coded on purpose (spec R11): no config may widen it, because
15
+ * a gate a team can switch off is the "rebuild Confluence with vectors" failure mode.
16
+ */
17
+ export declare function pageStatus(page: Page, repo: Repo, now: Date): PageStatus;
@@ -0,0 +1,25 @@
1
+ import { describeOwnershipProblem, resolveTeamOwner } from "./codeowners.js";
2
+ import { computeSla, withinSla } from "./sla.js";
3
+ /**
4
+ * The citation gate. Hard-coded on purpose (spec R11): no config may widen it, because
5
+ * a gate a team can switch off is the "rebuild Confluence with vectors" failure mode.
6
+ */
7
+ export function pageStatus(page, repo, now) {
8
+ const sla = computeSla(page.last_verified, page.review_every, now, repo.config.sla.due_soon_days);
9
+ const own = resolveTeamOwner(repo.codeowners, page.path, repo.config.github.org);
10
+ const owned = own.ok && own.team === page.owner_team.toLowerCase();
11
+ const ownership_problem = own.ok
12
+ ? owned
13
+ ? undefined
14
+ : `owner_team is \`${page.owner_team}\` but CODEOWNERS assigns @${repo.config.github.org}/${own.team}`
15
+ : describeOwnershipProblem(own.problem, repo.config.github.org);
16
+ let gate_reason;
17
+ if (page.status !== "canonical")
18
+ gate_reason = `status is ${page.status}`;
19
+ else if (!withinSla(sla.state))
20
+ gate_reason =
21
+ sla.state === "overdue" ? `past review date (due ${sla.due}), owned by ${page.owner_team}` : "never verified";
22
+ else if (!owned)
23
+ gate_reason = `ownership unresolved: ${ownership_problem}`;
24
+ return { sla, owned, ownership_problem, citable: gate_reason === undefined, gate_reason };
25
+ }
@@ -0,0 +1,13 @@
1
+ import { Node } from "commonmark";
2
+ /**
3
+ * commonmark.js's tree for `markdown`, with GFM tables as tables (see above). The placeholder
4
+ * is never a link or an image, whatever a page writes around it: a definition for it, a
5
+ * `(target)` or `[label]` right after the link redaction replaced, or a link around it. A
6
+ * link or image labelled "restricted page" becomes the placeholder's text, and one whose
7
+ * label holds the placeholder becomes its label's text.
8
+ */
9
+ export declare function parseGfm(markdown: string): Node;
10
+ /** The text and code inside `node`: a link's or image's label, as the reader sees it. */
11
+ export declare function textIn(node: Node): string;
12
+ /** A table cell (`th` or `td`) in a tree from `parseGfm`; its children are the cell's content. */
13
+ export declare function isTableCell(node: Node): boolean;