@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.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/README.md ADDED
@@ -0,0 +1,497 @@
1
+ # Corpus
2
+
3
+ **Check, publish, locate, and retrieve source-bound documentation.**
4
+
5
+ [![CI](https://github.com/kontourai/corpus/actions/workflows/ci.yml/badge.svg)](https://github.com/kontourai/corpus/actions/workflows/ci.yml)
6
+ [![Docs](https://img.shields.io/badge/docs-kontourai.github.io-blue)](https://kontourai.github.io/corpus/)
7
+ [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
8
+
9
+ [Documentation](https://kontourai.github.io/corpus/) · [Docs hub](https://kontourai.github.io/)
10
+
11
+ `@kontourai/corpus` gives every repository the same three things from one manifest:
12
+
13
+ - **A docs gate for CI.** `corpus check` validates links, anchors, reachability, topics, redirects, local paths, v2 purpose and building-block declarations, an optional [decision registry](docs/decisions.md), and (on pull requests) freshness.
14
+ - **A GitHub Pages site.** `corpus build` renders `https://kontourai.github.io/<repo>/` and checks every link it generated.
15
+ - **A docs bundle for agents.** `build` also writes `docs-bundle.json`, and `corpus mcp` serves every product's bundle from one MCP server.
16
+ - **Related content across repositories.** `corpus content impact` maps changed files to GitHub content targets; `corpus content check` detects drift in explicitly reviewed product inputs and website outputs. Local source-bound reviews can also justify unchanged covering prose in `corpus check --base`. See [related content](docs/related-content.md).
17
+
18
+ Content reviews can select a JSON Pointer within a shared metadata file, so an
19
+ unrelated product version change does not invalidate another product's review.
20
+
21
+ The docs CLI is plain ESM JavaScript for Node 22 or newer, with no build step. Corpus Store builds TypeScript only within `packages/store`. Install it from npm as an exact-pinned dev dependency, so your lockfile covers Corpus and every package it depends on:
22
+
23
+ ```sh
24
+ npm install --save-dev --save-exact @kontourai/corpus@<version>
25
+ npx --no-install corpus check
26
+ ```
27
+
28
+ Run it through a package script (`"docs:check": "corpus check"`) or `npx --no-install`, which use only the installed bin. A bare `npx corpus` would fetch the unrelated unscoped `corpus` package when Corpus is not installed. For a one-off run without installing, name the package and exact version: `npx -y --package=@kontourai/corpus@<version> -- corpus check`. That form resolves Corpus's own dependencies afresh on every run, with no lockfile behind them, so CI should use the dev dependency.
29
+
30
+ Until the first npm release, and for an unreleased commit, pin the commit's GitHub tarball by its full sha instead, either as the dev dependency spec or directly:
31
+
32
+ ```sh
33
+ npx -y --package=https://codeload.github.com/kontourai/corpus/tar.gz/<sha> -- corpus check
34
+ ```
35
+
36
+ ## Publishing the root package
37
+
38
+ The [root publish workflow](.github/workflows/publish-npm-corpus.yml) publishes
39
+ only `@kontourai/corpus`. Dispatch `publish-npm-corpus.yml` on `main` with an
40
+ exact `version`. Before anything is packed,
41
+ [`scripts/root-release-preflight.mjs`](scripts/root-release-preflight.mjs)
42
+ requires that version to equal the root `package.json` version and to be the
43
+ first section of `CHANGELOG.md`, with no `Unreleased` section left. A release
44
+ pull request that sets the version and moves the unreleased entries under it
45
+ therefore lands first. Corpus Store and Conformance changes are recorded in
46
+ [`packages/store/CHANGELOG.md`](packages/store/CHANGELOG.md) instead, so a root
47
+ release never claims them. Stable versions publish to `latest` and prereleases to
48
+ `next`, and the version must be absent from npm; the same
49
+ [guard](scripts/npm-publish-guard.mjs) as the workspaces refuses an existing
50
+ version, a registry error other than E404, or an unconfirmed response.
51
+
52
+ The verification job, with `contents: read`, runs the tests and the dogfood
53
+ check, then `npm run verify:pack`. That packs the root once, checks the
54
+ tarball's file list against an allow-list (`bin/`, `src/`, `schemas/`,
55
+ `README.md`, `LICENSE`, and `package.json`), installs the tarball with
56
+ `--ignore-scripts` into a fresh project, and runs the installed `corpus check`,
57
+ `corpus decisions check` (including a rejected record), and the `docs-kit`
58
+ alias against a fixture corpus and against this repository. It then exports
59
+ that same tarball with its SHA-256, and also passes that SHA-256 to the publish
60
+ job as a job output, outside the artifact. The publish job, with
61
+ `id-token: write` in the `npm-publish` environment, checks the tarball twice.
62
+ Right after download it checks it against the artifact's `SHA256SUMS` and
63
+ against the job-output hash. Immediately before `npm publish` it checks it
64
+ against the job-output hash again. It then publishes that exact file with
65
+ `--provenance --ignore-scripts`, without repacking. Run
66
+ `npm run verify:pack` locally to exercise the same check; it fetches the
67
+ tarball's dependencies from the registry.
68
+
69
+ The root package has its own workflow because its stable releases and the
70
+ workspaces' prereleases have different versions and cadences, and because an
71
+ npm trusted publisher names one workflow file: the trusted publisher for
72
+ `@kontourai/corpus` names `publish-npm-corpus.yml`, and the workspace packages
73
+ name `publish-npm.yml`, so neither workflow can publish the other's packages.
74
+ Configure it for organization `kontourai`, repository `corpus`, environment
75
+ `npm-publish`, with direct `npm publish` allowed, after bootstrapping the name
76
+ the same way as the workspaces below. The first CI release publishes to
77
+ `latest`, which moves `latest` off any bootstrap placeholder.
78
+
79
+ ## Publishing the store workspaces
80
+
81
+ The [publish workflow](.github/workflows/publish-npm.yml) publishes only
82
+ `@kontourai/corpus-store` and `@kontourai/corpus-conformance`. The root
83
+ `@kontourai/corpus` package has its own workflow, described
84
+ [above](#publishing-the-root-package). Dispatch `publish-npm.yml`
85
+ on `main` with an exact `version` matching both workspace manifests. Find the
86
+ current versions in [`packages/store/package.json`](packages/store/package.json)
87
+ and [`packages/conformance/package.json`](packages/conformance/package.json).
88
+ Release-please does not manage these workspace versions yet. Record their
89
+ changes in [`packages/store/CHANGELOG.md`](packages/store/CHANGELOG.md), not
90
+ the root changelog, whose first section must be the root release.
91
+
92
+ The verification job builds Store, runs its contracts and packed-consumer checks,
93
+ and uploads the two tested `npm pack` tarballs with SHA-256 hashes, which it also
94
+ passes to the publish job as job outputs. A separate publish job checks each
95
+ tarball right after download against the artifact's `SHA256SUMS` and against
96
+ the job-output hashes, then against the job-output hash again immediately
97
+ before each `npm publish`, and publishes those same tarballs with provenance. Only the publish job has OIDC permission;
98
+ test and build steps use `contents: read`. Every prerelease
99
+ uses `next`; stable versions use `latest`. Both versions must be absent on npm
100
+ before either publish begins. An existing version, registry error other than
101
+ E404, or unconfirmed response stops publication. If one publish succeeds and
102
+ the other fails, the run cannot simply be repeated: investigate the failure
103
+ and prepare a new matching version pair. The registry cannot publish the pair
104
+ atomically.
105
+
106
+ An owner must first create both npm packages and configure GitHub Actions
107
+ trusted publishers for organization `kontourai`, repository `corpus`, workflow
108
+ `publish-npm.yml`, environment `npm-publish`, with direct `npm publish` allowed.
109
+ Bootstrap primarily with staged publishing, which creates a `0.0.0-stage`
110
+ placeholder for a new name. Reject the bootstrap stages with 2FA before CI
111
+ publication so their reserved target versions are released; approving them
112
+ would consume the CI candidate. A separate bootstrap prerelease is a fallback.
113
+ Inspect dist-tags immediately after bootstrap: `latest` unset may not be
114
+ achievable if npm assigns it to the placeholder. The orchestrator can deprecate
115
+ that placeholder and direct users to `@next`; do not move or remove `latest`.
116
+ Configure the GitHub `npm-publish` environment
117
+ for `main` and owner approval before dispatch. CI uses Node 24, pinned npm
118
+ 11.17.0, `id-token: write`, and no registry token or `setup-node` `registry-url`.
119
+
120
+ Exercise the executable publish guard and the root release preflight with
121
+ fixture registry responses and manifests:
122
+
123
+ ```sh
124
+ node --test test/npm-publish-guard.test.mjs test/root-release-preflight.test.mjs
125
+ ```
126
+
127
+ ## Purpose and audience
128
+
129
+ Documentation stays bound to the source revision it describes, so people and agents can check, publish, retrieve, and cite it exactly. Corpus owns documentation checking, publishing, and retrieval today, with [Corpus Store](packages/store/README.md) providing mutable file-backed records as a separate package, including host-authorized reviewed confidence updates. It works for any repository or product suite, including projects outside Kontour. Docs bundles remain immutable snapshots; they do not become mutable records.
130
+
131
+ This repository dogfoods `corpus.json` schema v2 with the `corpus` product identity and publishes at `/corpus/`. Its `builtOn` list is empty: the docs CLI consumes third-party libraries, but no other Kontour product through a public contract.
132
+
133
+ The root package is a zero-install, plain ESM docs CLI. `corpus` and the legacy `docs-kit` bin run the same commands with identical output and exit codes. Generated `docs-kit.css`, `docs-kit.js`, and `.docs-kit-build` names remain compatible.
134
+
135
+ The separate [`@kontourai/corpus-conformance`](packages/conformance/README.md) workspace ships public Knowledge store vectors and an adapter-factory runner. Its expected outcomes come from the contract and owner decisions. The shared mutable store is a later package.
136
+
137
+ ## Contents
138
+
139
+ - [Manifest reference](#manifest-reference)
140
+ - [CLI reference](#cli-reference)
141
+ - [MCP server](#mcp-server)
142
+ - [Adopting corpus in a repo](#adopting-corpus-in-a-repo)
143
+ - [Decision records](docs/decisions.md)
144
+ - [Bundle and products.json formats](docs/bundle-format.md)
145
+ - [What the generated site contains](docs/site-output.md)
146
+ - [Known limits](#known-limits)
147
+ - [Contributing](AGENTS.md)
148
+
149
+ ## Manifest reference
150
+
151
+ Each repository has a schema v2 `corpus.json` at its root. Corpus no longer reads the retired v1 `docs-kit.json`. If a `docs-kit.json` is present, alone or next to `corpus.json`, `check`, `build`, `locate`, `init`, and `mcp --repo` stop with exit `2` and say what to do: rename it to `corpus.json`, set `"schemaVersion": 2`, and add `purpose` and `builtOn`, or delete the leftover when a `corpus.json` already exists.
152
+
153
+ ```json
154
+ {
155
+ "schemaVersion": 2,
156
+ "purpose": "Make process and execution state visible to users.",
157
+ "builtOn": [],
158
+ "product": "flow",
159
+ "title": "Flow",
160
+ "tagline": "Process transparency for agentic work",
161
+ "repo": "kontourai/flow",
162
+ "landing": "README.md",
163
+ "documents": [
164
+ { "source": "docs/getting-started.md", "section": "Start Here", "covers": ["src/cli/**"] }
165
+ ],
166
+ "topics": [
167
+ { "id": "workflows", "title": "Workflows", "source": "docs/concepts.md#workflows" }
168
+ ],
169
+ "exclude": ["docs/plans/**"],
170
+ "branch": "main",
171
+ "selectionActions": { "primary": "ask" },
172
+ "redirects": {
173
+ "gates-and-route-back.html": "docs/gates.md#route-back",
174
+ "releasing.html": "https://example.com/releasing"
175
+ },
176
+ "allowLocalPaths": ["docs/install-paths.md"]
177
+ }
178
+ ```
179
+
180
+ | Key | Required | Meaning |
181
+ | --- | --- | --- |
182
+ | `schemaVersion` | yes | Exactly `2`. A manifest with `1` is refused (exit `2`). |
183
+ | `purpose` | yes | Non-empty sentence describing the promise to users. The exact sentence `corpus init` writes is refused by `check`. |
184
+ | `builtOn` | yes | Public-contract building blocks: `{ "product": "id", "via": "package or CLI (or non-empty array)", "for": "use" }`; may be empty. Optional `"evidence": "cli"` and bare `bin` describe command evidence (see below). |
185
+ | `product` | yes | Lowercase slug. It must equal the repository name in `repo`, because the site lives at `https://kontourai.github.io/<product>/` and the hub fetches `/<product>/docs-bundle.json`. |
186
+ | `title` | yes | Display name. |
187
+ | `tagline` | no | One line shown in the site header and on the hub. |
188
+ | `repo` | yes | `owner/name` on GitHub. |
189
+ | `landing` | no | Markdown rendered as the site's `index.html`. Default `README.md`. |
190
+ | `documents` | yes | Documents, in navigation order. Each entry has `source` (a `.md` path), `section` (the navigation group) and, optionally, `title`, `covers`, and `publish`. See [Bundle-only documents](#bundle-only-documents). |
191
+ | `topics` | no | Curated entry points for agents. Each has `id` (a lowercase slug), `title`, and `source`, which is either `path` or `path#anchor`. |
192
+ | `exclude` | no | Globs for `docs/**/*.md` files that are deliberately unpublished. |
193
+ | `redirects` | no | Old page URLs that moved, mapped to their new place. Each key is a site-relative `.html` path; each value is a published source (`path` or `path#anchor`) or an absolute `https://` URL. See [Redirects](#redirects). |
194
+ | `branch` | no | The default branch, for the site's **Edit** links (`https://github.com/<repo>/edit/<branch>/<path>`). Default `main`. It must be a valid git branch name (no whitespace, `..`, `~^:?*[\\`, leading `-` or `/`, or `.lock` component). |
195
+ | `selectionActions` | no | The [selection toolbar](docs/site-output.md#selection-toolbar). Its only key, `primary` (required in the object), is `"ask"` (the default) or `"challenge-issue"`: the action that leads the toolbar. |
196
+ | `decisions` | no | The [decision registry](docs/decisions.md): `{ "dir": "docs/decisions", "index": "<dir>/index.md", "adr": { "dir": "docs/adr" } }`, every key optional (`{}` uses the defaults). Present, it turns on the `decisions` gate; `adr` also freezes that numbered ADR directory against additions. |
197
+ | `allowLocalPaths` | no | Exact paths of documents (the landing page or `documents` sources) that may contain absolute home-directory paths, such as deliberate examples. The [paths gate](#check) skips them. No globs. |
198
+ | `synonyms` | no | Search vocabulary for this product: groups of words that mean the same thing here, such as `[["run", "execution"]]`. Each group is an array of at least two distinct single words (letters and digits only). MCP [`search`](#mcp-server) lets a word in a group also find the others at half weight, for this product's bundle only, on top of Corpus's suite-wide list. `build` copies it into `docs-bundle.json`. |
199
+
200
+ The validation rules:
201
+
202
+ - Validation is strict. An unknown key at any level is refused.
203
+ - Every path is repository-relative, POSIX, and normalized. Absolute paths, `..` segments, and symlinks that resolve outside the repository are refused. Every source must exist.
204
+ - Documents are listed once. They may not repeat the landing page, and they may not match `exclude`.
205
+ - A document's title is its first Markdown H1 outside blockquotes and lists, else the text of the first raw-HTML `<h1>` that has text, else a front-matter `title`, else the file name without `.md`. A `title` on the `documents` entry overrides all of them in the navigation, the page `<title>`, and `docs-bundle.json`; it must be a non-empty string without leading or trailing whitespace.
206
+ - A topic `source` must point at the landing page or at a listed document, so it is published. Its `#anchor` must be a heading outside blockquotes and lists (any level, `#` through `######`), slugged by [github-slugger](https://github.com/Flet/github-slugger): lowercase, punctuation dropped, spaces become `-`, and repeated headings get `-1`, `-2`, and so on.
207
+ - A leading YAML front-matter block is stripped before checking, rendering, and bundling, and check failures still report the source file's line numbers. A block is front matter when line 1 is `---` (after a UTF-8 BOM, which is always dropped), a `---` or `...` line closes it within the first 2000 lines, and it holds at least one top-level key line (`key:` at column 0). Otherwise, such as a thematic break followed by prose and another `---`, the document is left as written. Only a simple `title: value` (optionally quoted) is read from it; there is no YAML parser.
208
+ - `publish`, when present, must be a boolean. It defaults to `true`.
209
+ - Each `allowLocalPaths` entry must be a normalized repository path that is exactly the landing page or a `documents` source, listed once. A glob, or any other path, is a manifest error.
210
+ - `covers` holds globs of the code a document describes; the [freshness gate](#check) uses them. A bare directory name such as `src/cli` means `src/cli/**`. A glob that matches no tracked file (`git ls-files`; every file outside git) fails the `covers` gate of `check`, because it could never mark its document stale. It does not block `build` or the MCP server.
211
+
212
+ Section names group the navigation in the order they first appear in `documents`.
213
+
214
+ ### Bundle-only documents
215
+
216
+ A `documents` entry with `"publish": false` is an agent-facing document that is not published as a web page, such as an internal manual. It is still a document in every other way:
217
+
218
+ - `build` renders no page for it, and it is left out of the navigation and the landing cards. It is still in `docs-bundle.json`, so the MCP server serves it, and topics may point at it.
219
+ - Its bundle `url` is `https://github.com/<repo>/blob/<sha>/<path>` at the built commit.
220
+ - Its bundle `markdown` has every relative link made absolute, so the text stands alone for an MCP reader: a link to a published document becomes the page URL under `--base-url`, a link to anything else becomes its GitHub `blob` URL (`tree` for directories), an image or other embedded file (Markdown image, raw-HTML `src`, `srcset`, or `poster`) becomes `https://raw.githubusercontent.com/<repo>/<sha>/<path>` so it resolves to the file itself, and a `#fragment` alone gets the document's own blob URL. This covers Markdown links, images, reference definitions, and raw-HTML `href`, `src`, `srcset`, and `poster`. Code blocks and code spans are left as written. Topics taken from the document get the same rewritten Markdown, and their `url` is the blob URL plus `#anchor`.
221
+ - `check` runs every gate on it: links, anchors, topics, covers, freshness, and paths. Reachability counts it as listed.
222
+ - A published page that links to it gets its GitHub blob URL, as for any other repository file.
223
+ - A redirect cannot target it, because it has no page; that fails the `redirects` gate.
224
+
225
+ ### Redirects
226
+
227
+ GitHub Pages cannot send HTTP redirects, so when a page URL changes (for example `/flow/gates-and-route-back.html` became `/flow/docs/gates-and-route-back.html` when a product moved to corpus), list the old path under `redirects` and `build` writes a stub page there. The stub refreshes to the target at once (`<meta http-equiv="refresh" content="0; url=...">`), names it with `<link rel="canonical">`, asks search engines not to index it (`<meta name="robots" content="noindex">`), and shows a plain link to it.
228
+
229
+ A meta refresh drops the URL fragment, so the stub also carries a small inline script, placed before the meta refresh, that redirects with `location.replace` and keeps the fragment: `old.html#section` lands on the target's `#section`. When the target names its own anchor (`docs/gates.md#route-back`), that anchor wins and the incoming fragment is dropped, because the manifest chose it and the old page's fragment may not exist on the new page. An incoming fragment applies only to a target without one, for source and `https://` targets alike. Without JavaScript, the meta refresh still redirects, without the fragment. The script is template output, not document content: the target is embedded as a JSON string with every `<`, U+2028, and U+2029 escaped, so it cannot close the script element.
230
+
231
+ - A key is the old page's path relative to the site root, such as `gates-and-route-back.html` or `adr/0001-x.html`. It must end in `.html` and must not start with `/` or contain `..`, `#`, or `?`.
232
+ - GitHub Pages serves `x.html` for a request to `/x`, so a key `x.html` also redirects the extensionless URL `/x`.
233
+ - A value is either a source path, optionally with `#anchor` (`docs/gates.md`, `docs/gates.md#route-back`), or an absolute `https://` URL, which the stub uses in its parsed, normalized form (`new URL(value).href`). A source must be the landing page or a listed document that is published (not `"publish": false`), and its `#anchor` follows the rules of the [links gate](#check): a heading slug, or an `id` or `name` in raw HTML.
234
+ - A source target becomes a relative URL from the stub's own directory, so `adr/0001-x.html` pointing at `docs/gates.md` refreshes to `../docs/gates.html`. Its canonical URL is absolute, under `--base-url`.
235
+ - A key must not be a page corpus generates (`index.html` or a document's `.html`), compared after Unicode NFC normalization and case folding, and must not need a directory where corpus writes a file, or the reverse. A key may appear only once in the file. A stub that would overwrite a copied image or media file fails `build` with exit `1`, because assets are known only after rendering.
236
+ - Malformed keys or values are manifest errors (exit `2`). A target that is missing, unpublished, or has an unresolved anchor, and a colliding or repeated key, fail the `redirects` gate (exit `1`).
237
+ - Redirects are not part of `docs-bundle.json`.
238
+
239
+ ## CLI reference
240
+
241
+ ```text
242
+ corpus <check|build|locate|mcp|hub|init> [options]
243
+ corpus decisions <check|index> [options]
244
+ ```
245
+
246
+ Exit codes: `0` ok, `1` a gate failed, `2` a usage or configuration error. An invalid manifest is a configuration error (`2`), and so is a git error.
247
+
248
+ ### check
249
+
250
+ ```sh
251
+ corpus check [--root dir] [--base <git-ref>] [--advisory] [--only <gates>] [--json]
252
+ ```
253
+
254
+ `check` runs the gates below and reports every failure, not only the first.
255
+
256
+ - `--only <gates>` runs only the named gates, comma-separated, using the names in the list below (`builtOn`, `adr-freeze`, and so on). The manifest always runs, because every other gate depends on it. An unknown name is a usage error (exit `2`). So is a selected gate that does not run, such as `freshness` without `--base`, `decisions` without a `decisions` block, or `adr-freeze` without `--base` and `decisions.adr`: a lane that asks for one gate never passes by checking nothing. So is a flag that no selected gate reads: `--base` unless `freshness` or `adr-freeze` is selected, and `--advisory` unless `freshness` is, so a mistyped ref is never silently ignored. A gate that runs but has nothing to examine still passes: `--only manifest` runs only the manifest, and `--only freshness` passes in a repository whose documents declare no `covers`.
257
+ - `--json` prints one JSON object on stdout instead of the text report. Exit codes are unchanged. Whenever `--json` appears among `check`'s arguments, stdout holds exactly one JSON object, whatever happens. A run that does not reach a gate result still prints `{ "verdict": "fail", "exitCode": 2, "error": "…" }` (plus `schemaVersion`, `tool`, `command`, `base`, `advisory`, and `only`), with the message also on stderr. That covers an unknown option, an option with a missing or blank value, a bad `--only`, a missing `corpus.json`, and an unexpected internal error. `--help` prints text, so it cannot be combined with `--json`; together they are refused the same way. A gate result is:
258
+
259
+ ```json
260
+ {
261
+ "schemaVersion": 1,
262
+ "tool": "corpus",
263
+ "command": "check",
264
+ "verdict": "fail",
265
+ "exitCode": 1,
266
+ "base": "origin/main",
267
+ "advisory": false,
268
+ "only": ["freshness"],
269
+ "summary": { "failures": 1, "warnings": 0, "errors": 0, "staleDocuments": 1 },
270
+ "gates": [
271
+ { "name": "manifest", "status": "pass", "failures": [], "warnings": [] },
272
+ { "name": "freshness", "status": "fail", "note": "3 changed files since origin/main", "failures": ["docs/cli.md is stale: …"], "warnings": [] }
273
+ ],
274
+ "staleDocuments": [
275
+ { "source": "docs/cli.md", "kind": "docs-covers", "gate": "freshness", "severity": "failure", "changed": ["src/cli.js"] }
276
+ ],
277
+ "actions": [
278
+ { "type": "docs-covers", "description": "docs/cli.md is stale: 1 covered file changed since origin/main but the document did not", "paths": ["docs/cli.md", "src/cli.js"] }
279
+ ]
280
+ }
281
+ ```
282
+
283
+ `verdict` is `pass`, `warn` (exit `0` with warnings, as under `--advisory`), or `fail` (exit `1` or `2`; `exitCode` tells them apart). A gate's `status` is `pass`, `warn`, `fail`, `skip` (with `skipped`, the reason), or `error` (with `error`). `summary` holds only numbers. Every stale document carries the `kind` of staleness, never a bare stale flag. Today the only kind is `docs-covers`: a file matched by the document's `covers` changed and the document did not. `changed` lists every such file, and `severity` is `warning` under `--advisory`. `actions` has one entry per stale document, with `type` set to its kind. A broken link and a stale document therefore no longer look the same: both exit `1`, but only a stale document is listed in `staleDocuments` and `actions`. The shape matches the external-tool artifact that a [Veritas](https://github.com/kontourai/veritas) evidence check reads (`verdict`, numeric `summary`, `actions`), so `corpus check --base origin/main --only freshness --json` can be bound as evidence.
284
+
285
+ For v2 manifests, the **builtOn** gate compares declarations with root and workspace `package.json` dependencies, peer dependencies, and optional dependencies. Dependencies are verified by their exact package keys in `via`. `via` accepts a string or a non-empty array, allowing `{ "product": "console", "via": ["@kontourai/console-core", "@kontourai/console-ui"], "for": "Console integration" }`. All names in an entry are declared to belong to that product. **Product ids and package-to-product ownership are declared by the owner and cannot be verified offline.** The gate does not derive product identity from a scope, package name, or GitHub repository name. Missing-package suggestions use `OWNER_PRODUCT_ID`, which the owner must replace.
286
+
287
+ Kontour dependency recognition uses npm's package-spec parser. Scoped `@kontourai/*` package keys count, except an `npm:` alias to a non-Kontour target does not. An alias such as `flow-alias: "npm:@kontourai/flow@^1"` counts, and **`via` must name the alias key (`flow-alias`), not the target package**. GitHub specs count when their owner is `kontourai` (case-insensitive): `github:kontourai/repo` (including `/subpath`), bare `kontourai/repo`, `git+https://`, `git+ssh://git@`, `git@github.com:`, `git://`, and `https://github.com/` forms. GitHub and `https://codeload.github.com/kontourai/repo/...` tarball URLs also count. Other owners/hosts and unscoped registry packages do not count. Workspace/link/patch protocols are not parsed as GitHub or npm aliases; a scoped package key still counts. This classification inspects specs without fetching or executing them.
288
+
289
+ Scoped dependencies whose keys match the root package name or any workspace member's package name are excluded: a repo's own packages are not building blocks. This uses `package.json` names, never a name prefix. A package or CLI named more than once in `via` fails, including repeats within one array. Each declared name is checked separately against dependencies or CLI evidence. Missing runtime declarations and phantom declarations exit `1` with a fix. Dev-only packages warn, whether declared or not; malformed package JSON or YAML evidence exits `2`. Workspaces support `package.json` directory globs (an array or `{ "packages": [...] }`) and a real YAML-parsed `pnpm-workspace.yaml` `packages` array, including indented/unindented block lists, flow arrays, and exclusions. An existing pnpm file takes precedence. A missing `packages` key, `packages: []`, a list of only `!` exclusions, or `packages: ['.']` means a single-package repo: only the root package is compared. `.` (or `./`) names the root package, which is always compared. A list with any other inclusion glob exits `2` if no workspace member package files resolve, even when it also lists `.`. A missing root `package.json` prints an explicit skip; v1 skips this gate.
290
+
291
+ A dependency-free declaration requires explicit `"evidence": "cli"` and a non-empty reason in `for`. Evidence is read from root/workspace package scripts, `.github/workflows/*.yml` / `*.yaml` `jobs.*.steps[*].run`, and composite `.github/actions/**/action.yml` / `action.yaml` `runs.steps[*].run` values. Nested `with.run`, step names, comments, quoted mentions in arguments, heredoc bodies (including quoted and tab-stripped delimiters), and non-composite actions do not count. Composite action files are scanned as invocation evidence even if the action is not referenced by a workflow; this does not prove reachability or execution.
292
+
293
+ Recognised shell forms are direct `<bin>`, `./node_modules/.bin/<bin>`, `npx [-y|--yes|--no-install|--] <bin>[@version]`, `npm exec [--] <bin>[@version]`, `pnpm exec [--]`, `pnpm dlx [--]`, `yarn exec [--]`, `yarn dlx [--]`, and `pnpm <bin>` / `yarn <bin>`. A version (`<bin>@1`) is accepted only after `npx`, `npm exec`, `pnpm exec`/`dlx`, and `yarn exec`/`dlx`; `pnpm <bin>@1`, `yarn <bin>@1`, and a direct `<bin>@1` are not evidence. Leading `VAR=value` assignments and shell separators/newlines are supported. Every `${{ … }}` expression is replaced with an unknown placeholder word before tokenisation, including quoted and unquoted expressions in scripts and steps. As in GitHub's own parser, an expression ends at the first `}}` outside a single-quoted string, so `${{ '}}' }}` is one expression. Scripts are tokenised one logical line at a time: physical lines joined by backslash continuations or by a quote still open at the line end, which is what the shell reads as one command line. A line the tokenizer cannot parse (a bad `${…}` substitution, or a `${{` that never closes) supplies no evidence, but other lines of the same script or step are still examined, and a heredoc started on it still hides its body. The line is not split at `;` or `&&`, because finding those needs the parsing that failed. When a CLI declaration then has no evidence, its failure says how many command lines were skipped as unparseable and names up to three as `<file> scripts.<name> line <n>` or `<file> jobs.<job>.steps[<i>].run line <n>`. A static executable with expression-valued arguments can qualify; an expression-valued executable cannot. Other wrapper flags, `npm run`, shell functions/control-flow syntax, dynamic command names, command substitutions, reusable workflow references, PowerShell and cmd.exe syntax are not interpreted. The check is textual POSIX-style command evidence, not proof that a command ran or succeeded.
294
+
295
+ If a scoped package's executable differs, use a bare `bin` string: `{ "product": "veritas", "via": "@kontourai/governance", "bin": "veritas", "for": "Check governance readiness", "evidence": "cli" }`. `bin` applies to every name in that entry for CLI evidence; it does not change package dependency matching. The check verifies the declared executable is invoked, not that a package actually publishes that executable.
296
+
297
+
298
+ 1. **manifest:** `corpus.json` is valid, as described above. If it is invalid, the other gates are skipped and the exit code is `2`.
299
+ 2. **links:** every Markdown link and image, and every raw-HTML `href`, `src`, `srcset`, and `poster` (double-quoted, single-quoted, or unquoted), in the landing page and the listed documents (bundle-only ones included) resolves. A reference to a file under `.git/`, through a symlink, or to an untracked or git-ignored file (such as `.env`), is an unpublishable reference and fails. Raw HTML is tokenized with htmlparser2, so HTML comments are ignored, an empty raw `href=""` is allowed, and `srcset` is parsed per the HTML spec, so `data:` URIs stay whole. Relative files must exist, with exact case. A `#anchor` must match a heading slug, or an `id` or `name` attribute in raw HTML, in the target `.md`. External `http(s)` links are not fetched. A leading `/` resolves from `--root` (which is the repository root unless you point `--root` at a subdirectory).
300
+ 3. **reachability:** every `docs/**/*.md` is listed in `documents` (with or without `"publish": false`), is the landing page, or is matched by `exclude`. When the manifest declares `decisions`, every `*.md` directly in `decisions.dir` (the records and the index) also counts as reachable without being listed; the decisions gate checks them. They are published only if listed in `documents`.
301
+ 4. **topics:** every topic anchor resolves to a heading outside blockquotes and lists.
302
+ 5. **redirects:** every [redirect](#redirects) target is published and its `#anchor` resolves, and no key repeats or collides with a file corpus generates.
303
+ 6. **paths:** no absolute home-directory path appears in the landing page or any listed document, bundle-only ones included: `/Users/<name>/`, `/home/<name>/`, or `C:\Users\<name>\` (any drive letter, either slash). The whole Markdown body is scanned as text, so prose, code, links, and raw HTML all count; front matter is not, because it is never published. Each hit is reported as `file:line`. A path inside a URL such as `https://host/home/x/` does not match, and neither does a placeholder name such as `<name>`, `$USER`, or `{user}`. Two non-personal directories are exempt: `/Users/Shared/` (the macOS shared folder) and `/home/runner/` (the GitHub Actions runner home). List a document in `allowLocalPaths` when it needs real example paths.
304
+ 7. **purpose:** the manifest's `purpose` is not the exact placeholder sentence that `corpus init` writes. This gate fails with exit `1`, not `2`, so a new adopter still sees every other gate. It compares the whole sentence, not a `TODO` substring.
305
+ 8. **covers:** every `covers` glob matches at least one tracked file. This gate fails with exit `1`.
306
+ 9. **decisions:** only when the manifest declares `decisions`. Every record in `decisions.dir` is a valid [decision record](docs/decisions.md#record-format) and the index is what `corpus decisions index` would write. The directory is read directly, whatever `documents` and `exclude` say. Fails with exit `1`.
307
+ 10. **adr-freeze:** only when the manifest declares `decisions.adr`. Without `--base` it is reported as skipped. A file added under `decisions.adr.dir` since the merge base fails with exit `1`, except a `README.md` or `index.md` directly in it. See [Numbered ADRs](docs/decisions.md#numbered-adrs).
308
+ 11. **freshness:** this gate runs only with `--base`. It reads the changed files from `git diff --name-only --no-renames <base>...HEAD`, which diffs from the merge base and lists both paths of a renamed file. If a document's `covers` globs match a changed file, the document's own `source` must also be in the changed set. Otherwise the document is stale. With `--advisory`, stale documents are printed as warnings and the exit code is `0`. A base that does not resolve, or a directory that is not a git checkout, gives exit `2`, never a silent pass.
309
+
310
+ ### decisions
311
+
312
+ ```sh
313
+ corpus decisions check [--root dir] [--base <git-ref>]
314
+ corpus decisions index [--root dir]
315
+ ```
316
+
317
+ `decisions check` runs only the manifest, decisions, and adr-freeze gates of `check`, with the same gate lines and exit codes, so a repository can keep it in a fast required lane. `decisions index` writes or refreshes the index file and nothing else; it refuses to replace a file that is not a generated index. It is the one command that accepts a `documents` entry for the index before the index exists, so a manifest can list it before the first run. Both exit `2` when the manifest has no `decisions` block. See [Decision records](docs/decisions.md#commands).
318
+
319
+ ### build
320
+
321
+ ```sh
322
+ corpus build [--root dir] [--out dist-pages] [--base-url https://kontourai.github.io/<product>/]
323
+ ```
324
+
325
+ `build` runs `check` without the covers, adr-freeze, and freshness gates and refuses to build if it fails. It then replaces `--out` with:
326
+
327
+ - `index.html` (the landing page), one page per published document (not [bundle-only](#bundle-only-documents) ones), and `docs-kit.css`. Every page has navigation grouped by section, a link to the hub at <https://kontourai.github.io/>, and a link to the repository. The landing page's `<title>` is `<title> — <tagline>`, or `<title> documentation` when there is no tagline. Long inline text wraps to the document column; wide code blocks and tables scroll within it.
328
+ - Links between published documents, rewritten to the generated pages, in Markdown and in raw HTML (`href`, `src`, `srcset`). Links to other repository files go to `https://github.com/<repo>/blob/<sha>/<path>`, or to `tree` for directories.
329
+ - Copies of the local images and embedded media (`src`, `srcset`, `poster`) that published documents reference. Inside git, only git-tracked files are copied, never anything under `.git/` or through a symlink, and each file is capped at 25 MB; a larger tracked file keeps its GitHub link. Assets are collected after sanitization, so a file referenced only by a stripped tag (`iframe`, `embed`, `script`, ...) is never copied.
330
+ - A selection toolbar on every page (`docs-kit.js`, a static template script): select text in the page content to copy an **Ask** or **Challenge** prompt for your agent with the source path, line range, and sha, open a prefilled GitHub issue, or open the source in GitHub's editor. Rendered blocks carry `data-src-line` with their source-file lines, and `<main>` carries the page's path, sha, and repository. Where the browser offers [WebMCP](docs/site-output.md#webmcp-tools), the script also registers in-page tools. See [the generated site](docs/site-output.md#selection-toolbar).
331
+ - Sanitized HTML. Every product site shares the `kontourai.github.io` origin, so rendered Markdown is reduced to GitHub-like output: no `script` or `style`, no `on*` attributes, and only `http(s)`, `mailto`, and image `data:` URLs.
332
+ - Mermaid diagrams. A fenced ` ```mermaid ` block becomes `<pre class="mermaid">` holding the escaped diagram source. Only pages with such a block load Mermaid, from jsDelivr, pinned to one version with an SRI `integrity` hash, with `securityLevel: 'strict'` and `htmlLabels: false` (labels are plain SVG text, so diagram HTML never bypasses the sanitizer), and a theme that follows `prefers-color-scheme`. That script is part of the page template; a `script` in the Markdown is still stripped. See [the generated site](docs/site-output.md#mermaid-diagrams).
333
+ - A redirect stub for each `redirects` key. See [Redirects](#redirects).
334
+ - `.nojekyll`, a `.docs-kit-build` marker, and `docs-bundle.json`. The bundle is described in [docs/bundle-format.md](docs/bundle-format.md#docs-bundlejson).
335
+
336
+ `build` then checks every internal `href` and `src` in the generated site, and the URL of each redirect stub's meta refresh, including `#fragments`, and exits `1` if any of them is broken. Each stub must also have exactly one redirect script whose target resolves and equals its meta refresh URL. `build` needs a git checkout because the bundle and the GitHub links carry `git rev-parse HEAD`. `--out` is resolved relative to the current directory and is deleted before each build. `build` refuses an `--out` that is the repository or a parent of it, is inside a `.git` directory, or contains git-tracked files. It also refuses any existing non-empty directory unless every file in it is listed in the `.docs-kit-build` manifest that a previous build wrote. This applies inside or outside git, whether or not the directory is ignored. A file added by hand blocks the rebuild, and the error names it. A new or empty directory is fine. A forged marker is trusted: corpus deletes whatever a marker lists.
337
+
338
+ ### locate
339
+
340
+ ```sh
341
+ corpus locate --root . --quote "a passage from the docs" [--source README.md]
342
+ ```
343
+
344
+ Returns JSON source locations using the same quote matcher as MCP. Exit `1` means the quote could not be located; missing options are usage errors (`2`).
345
+
346
+ ### mcp
347
+
348
+ ```sh
349
+ corpus mcp [--repo dir | --workspace dir | --hub url | --bundle file...]
350
+ ```
351
+
352
+ `mcp` starts a stdio MCP server. Give it at most one source; with none, it uses `--hub https://kontourai.github.io/`. See [MCP server](#mcp-server).
353
+
354
+ ### hub
355
+
356
+ ```sh
357
+ corpus hub [--products products.json] [--out dist-hub]
358
+ ```
359
+
360
+ `hub` builds the organization hub: a self-contained `index.html` and a copy of `products.json`. It validates `products.json` strictly. Serve the output at the origin root (`https://kontourai.github.io/`), because each product card loads `/<product>/docs-bundle.json` from the same origin. Each card also loads `https://api.github.com/repos/<repo>/commits/HEAD` (the default branch), which it caches in `localStorage` for 10 minutes. Each card shows one state:
361
+
362
+ | State | Meaning |
363
+ | --- | --- |
364
+ | `unpublished` | The bundle could not be fetched. |
365
+ | `behind` | The bundle's `sha` differs from the default branch HEAD. The card shows the bundle's age and the HEAD commit's. |
366
+ | `thin` | The bundle has fewer than 3 documents, not counting the landing page. |
367
+ | `current` | The bundle's `sha` equals the default branch HEAD. |
368
+ | `unknown` | The GitHub API could not be read (for example, it was rate limited), so `current` cannot be claimed. |
369
+
370
+ If more than one state applies, the first one in the table wins.
371
+
372
+ ### init
373
+
374
+ ```sh
375
+ corpus init [--root dir]
376
+ ```
377
+
378
+ `init` writes a schema v2 `corpus.json` with a `purpose` placeholder sentence and an empty `builtOn`. Until you replace the placeholder, `check` fails its `purpose` gate with exit `1` (every other gate still runs), and `build` refuses to build. It uses `README.md` as the landing page, lists every `docs/**/*.md`, and guesses each section from the first directory under `docs/`. It takes the product and repo from the `origin` remote, and the tagline from `package.json`. It never overwrites an existing `corpus.json`, and it refuses while a retired `docs-kit.json` is present.
379
+
380
+ ## MCP server
381
+
382
+ The server exposes six read-only tools. Every result carries the source commit `sha` of the bundle it came from, and `dirty: true` when a local checkout has uncommitted changes. A tool never throws: problems come back as results with `isError` set.
383
+
384
+ | Tool | Arguments | Returns |
385
+ | --- | --- | --- |
386
+ | `list_products` | none | product, title, tagline, sha, builtAt, siteUrl, topicCount, docCount (not counting the landing page); failed products are listed as `unavailable` |
387
+ | `list_topics` | `product?`, `parentId?` | topics with product, id, title, source, url, sha, and `parentId`, `tags`, and `summary` when the bundle has them; `parentId` lists only that topic's children, and an id that is no topic in the selected products is an error |
388
+ | `get_topic` | `product`, `id` | the topic's Markdown section |
389
+ | `get_document` | `product`, `source` | one whole document, including the landing page (`landing: true`) |
390
+ | `search` | `query`, `product?`, `limit?` (1-50, default 10) | ranked matches over topics, documents, and named table rows, with `kind` (`topic`, `document`, or `row`), snippet, product, source, url, sha, and score; topic matches also carry `id` and `parentId`, `tags`, and `summary` when present; row matches carry `name` and `headings` (see [Search ranking](#search-ranking)) |
391
+ | `locate` | `product`, `quote`, `source?` | where a quoted passage lives: `source`, `repoPath`, 1-based file `startLine` and `endLine`, sha, the enclosing `heading` and `anchor`, the page `url`, the surrounding `markdown` blocks, a GitHub `blobUrl` ending in `#Lstart-Lend`, `match`, `confidence`, and, when the quote matches more than one place, `ambiguous: true`, `matchCount`, and up to 10 `alternatives` |
392
+
393
+ `locate` is what the site's Ask and Challenge prompts tell an agent to call. It compares letters and digits only, lowercased and with accents removed (NFKD), so whitespace, punctuation, and Markdown formatting (emphasis, code marks, link destinations, HTML tags, list and table markers, fence lines) do not matter. Numeric entities and `&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, and `&nbsp;` are decoded; other named entities (such as `&eacute;`) are ignored, so a quote containing their character falls back to the surrounding words. Text inside code blocks and code spans is compared as the page shows it, so `<dir>` in a code sample still matches. `match` is `verbatim` (confidence 1) when the quote also appears as written, `normalized` (0.9) when it matched only after that normalization, or `head-and-tail` (0.5) when only its first and last four words matched within a nearby window, for example across an image. With more than one candidate, the confidence is divided by their number (minimum 0.01); verbatim candidates rank before normalized ones, each in document order, and the best is returned with up to 10 others as `alternatives` and the total as `matchCount`. No match, a quote with fewer than 3 letters or digits, or an unknown `source` is an error result. Line numbers are file lines, after front matter; a bundle built before 0.6.0 does not record front matter, so `locate` adds a `note` saying it assumes none.
394
+
395
+ ### Search ranking
396
+
397
+ `search` ranks with BM25 and needs no network: the bundles it searches are already in memory.
398
+
399
+ - **Units and passages.** A result is a topic, a document, or a table row. Topics and documents are cut into passages at their headings of levels 1 to 3, and a unit scores by its best passage, so a long page does not win just by containing every word somewhere. Text that a topic from the same file covers (the section under the topic's heading, at any level, or the whole file) is scored only as that topic, so one passage never takes two places. A document with no text is still found by its title and source.
400
+ - **Table rows.** A table row whose first cell contains a code span (also inside bold, emphasis, or a link), and that has at least one more cell, is a part of its own: its `name` is the first code span (a key, tool, command, or flag), its `title` the first cell as it reads, `headings` the headings above it, and its `url` the page anchored at the nearest one. Its snippet is the whole row. A row is found by its bare name (`allowLocalPaths`) or by the name's words (`allow local paths`). Rows are derived from each document's Markdown when the bundle is first searched, so every bundle has them, whichever Corpus version built it. Only tables outside lists and quotes count.
401
+ - **Fields.** Each passage is scored over its unit's title (weight 5), id, source and tags (3), summary (2), the headings it stands under (2), its inline code (2), and its text (1), as BM25F boosts. Document frequencies and average lengths are taken over every passage of the bundles searched, so scores compare across products.
402
+ - **Words.** Text is split at punctuation, `snake_case`, `kebab-case`, and camelCase humps; names written in one piece are also kept whole. Both sides are stemmed lightly (subscribe, subscribes, subscribed, and subscribing are one word) and a few short forms (`doc`, `repo`, `dir`, `env`) count as their long forms. Common question words (how, do, I, the, …) are dropped unless nothing else is left.
403
+ - **Synonyms.** A query word in a synonym group also finds the group's other words at half weight. Corpus has a suite-wide list (`SUITE_SYNONYMS` in `src/search.mjs`: schedule and cron, approval and permission, and so on), and a product adds its own groups with the manifest's `synonyms`.
404
+ - **Soft "and".** A passage's score is multiplied by the share of query words it has; a word it has only through a synonym counts half.
405
+
406
+ Equal scores rank a topic before a row before a document. The ranking is adapted from [pmndrs/pine](https://github.com/pmndrs/pine)'s manual search (MIT). `test/search-questions.mjs` holds questions in a newcomer's words with the part that must rank in the top 3, over four published bundles.
407
+
408
+ The server has four sources:
409
+
410
+ - `--repo dir` builds the bundle in memory from a checkout and writes nothing. It rebuilds whenever the checkout changes, so a long session sees current docs. The cache key is HEAD, a hash of `git status --porcelain`, and the mtime and size of the manifest and each published source. It needs a valid manifest and resolvable topics at startup; a later breakage is reported as unavailable.
411
+ - `--workspace dir` does the same for every immediate subdirectory that contains `corpus.json`. A subdirectory with only a retired `docs-kit.json` is skipped, with one note on stderr per server, so a stale checkout neither stops the workspace nor disappears silently; one with both files is reported as unavailable. It rescans the directory on every call and rebuilds only the products that changed. A subdirectory that fails is reported as unavailable under the `product` its manifest declares (the directory name if the manifest does not parse). If two directories declare the same product, both are unavailable.
412
+ - `--hub url` fetches `<url>/products.json` and then each `<url>/<product>/docs-bundle.json`. It keeps successful fetches in memory for 10 minutes and retries failed or malformed ones after 30 seconds. A product whose fetch fails, or whose bundle is malformed, is reported as unavailable; this is not fatal.
413
+ - `--bundle file` serves a prebuilt `docs-bundle.json` exactly as written: from `corpus build`, or from a host's own generator. Repeat it to serve more products. Each file is read and validated once at startup and held in memory; nothing is rebuilt, refetched, or reread, so results always carry the sha the bundle was built at. A malformed bundle, an unreadable file, or two bundles for the same product is a usage error (exit `2`). Programs that embed the server get the same source from `staticSource(bundles)`, passed to `createDocsServer`.
414
+
415
+ Claude Code:
416
+
417
+ ```sh
418
+ claude mcp add kontour-docs -- npx -y --package=@kontourai/corpus@<version> -- corpus mcp
419
+ ```
420
+
421
+ Codex (`~/.codex/config.toml`):
422
+
423
+ ```toml
424
+ [mcp_servers.kontour-docs]
425
+ command = "npx"
426
+ args = ["-y", "--package=@kontourai/corpus@<version>", "--", "corpus", "mcp"]
427
+ ```
428
+
429
+ To serve local checkouts while you write docs, add `--workspace ~/dev/github/kontourai` or `--repo .` after `mcp`.
430
+
431
+ ## Adopting corpus in a repo
432
+
433
+ 1. Add Corpus as an exact-pinned dev dependency and commit the lockfile, so CI installs the same Corpus and the same dependency tree every time. For an unreleased commit, use `https://codeload.github.com/kontourai/corpus/tar.gz/<sha>` (full sha) as the spec instead.
434
+
435
+ ```sh
436
+ npm install --save-dev --save-exact @kontourai/corpus@<version>
437
+ ```
438
+
439
+ 2. Scaffold and review the manifest. Replace the `purpose` placeholder (`check` fails until you do), fill `builtOn` (or leave it empty), order the documents, name the sections, move internal docs to `exclude`, and add `topics` and `covers`.
440
+
441
+ ```sh
442
+ npx --no-install corpus init
443
+ npx --no-install corpus check
444
+ ```
445
+
446
+ 3. Add the gate to CI. On pull requests, `fetch-depth: 0` makes the base branch available for freshness. Drop `--advisory` once the `covers` globs are trusted.
447
+
448
+ ```yaml
449
+ - uses: actions/checkout@v7
450
+ with:
451
+ fetch-depth: 0
452
+ - uses: actions/setup-node@v6
453
+ with:
454
+ node-version: 24
455
+ - run: npm ci
456
+ - run: npx --no-install corpus check
457
+ - if: github.event_name == 'pull_request'
458
+ run: npx --no-install corpus check --base origin/${{ github.base_ref }} --advisory
459
+ ```
460
+
461
+ 4. Publish Pages from `main`. Set the repository's Pages source to GitHub Actions, then run:
462
+
463
+ ```yaml
464
+ - run: npx --no-install corpus build --out dist-pages
465
+ - uses: actions/upload-pages-artifact@v5
466
+ with:
467
+ path: dist-pages
468
+ - uses: actions/deploy-pages@v5
469
+ ```
470
+
471
+ 5. Add the product to the hub's `products.json` (`{"product": "<repo>", "repo": "kontourai/<repo>"}`).
472
+
473
+ ## Known limits
474
+
475
+ - External links are never fetched, so a dead `https://` link passes `check`.
476
+ - `#fragment` matching is case-sensitive.
477
+ - Headings that contain images or code spans can slug slightly differently from github.com in edge cases.
478
+ - Links cannot point above `--root`, and a leading `/` resolves from `--root`, not from the git top level.
479
+ - Freshness is file-level: any edit to a covering document in the range counts as an update.
480
+ - Topic anchors must be headings outside blockquotes and lists. A heading inside a quote or a list item does not qualify, at any heading level.
481
+ - Mermaid diagrams need JavaScript and jsDelivr. Without them the page shows the diagram source as preformatted text.
482
+ - Raw-HTML `name` attributes count as anchors in `check`, but the sanitizer keeps `name` only on `<a>`. A `name` on another element passes `check` and then fails the generated-site check in `build`. Element `id`s are not prefixed with `user-content-` as they are on GitHub.
483
+ - MCP `search` matches words, stems, and listed synonyms, not meaning: a question whose words the docs never use, and that no synonym group joins to them, is not found. `test/search-questions.mjs` lists open questions of that kind.
484
+ - The hub calls the GitHub API without authentication, which allows 60 requests per hour per IP. The 10-minute `localStorage` cache keeps normal use within that limit.
485
+ - Any asset reference that goes through a symlink, as a file or as a directory in its path, is refused as unpublishable, whatever the link points at. Commit the real file instead.
486
+ - "Tracked" means present in the git index. A local `build` publishes tracked assets with their unstaged working-tree edits. CI builds from a clean checkout, so its output matches the commit.
487
+ - Empty directories that someone adds by hand inside the output directory are removed on rebuild. Only files are checked against the `.docs-kit-build` manifest.
488
+ - In a bundle-only document, a link destination written with backslash escapes (such as `[x](a\(b\).md)`) cannot be located in the source text, so it is left relative in the bundle. A topic's Markdown does not carry reference definitions from outside its section, so `[text][ref]` links in a topic stay unresolved.
489
+ - The paths gate matches home directories by shape. It does not catch a path relative to `~`, a percent-encoded path, or a home directory outside `/Users`, `/home`, and `C:\Users`.
490
+ - The selection toolbar's line ranges cover whole annotated blocks (a top-level paragraph, list item, table row, code block, or blockquote), not the selected characters. Raw HTML blocks have no line attribute, so a selection inside one carries only the page's path and sha.
491
+ - `locate` matches letters and digits, so it cannot tell apart passages that differ only in punctuation, and text that renders differently from its source (an image's alt text, a Mermaid diagram) needs the head-and-tail fallback.
492
+ - WebMCP is a Chromium-only draft whose API is still changing; the in-page tools follow the October 2026 draft and the earlier synchronous form, and may need updating as it evolves.
493
+ - The hub's install snippet shows `<sha>` as a placeholder. It cannot know which corpus commit to recommend.
494
+
495
+ ## License
496
+
497
+ Apache-2.0. See [LICENSE](LICENSE).
package/bin/corpus.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,3 +1,58 @@
1
- { "name": "@kontourai/corpus", "version": "0.0.0-bootstrap.0",
2
- "description": "Placeholder that created this package name. Install @kontourai/corpus@latest once 0.8.0 is out.",
3
- "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/kontourai/corpus.git" } }
1
+ {
2
+ "name": "@kontourai/corpus",
3
+ "version": "0.8.0",
4
+ "description": "Source-bound documentation: check, build, locate, and MCP retrieval.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kontourai/corpus.git"
10
+ },
11
+ "homepage": "https://kontourai.github.io/corpus/",
12
+ "bugs": "https://github.com/kontourai/corpus/issues",
13
+ "bin": {
14
+ "corpus": "bin/corpus.mjs",
15
+ "docs-kit": "bin/docs-kit.mjs"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.mjs",
19
+ "./content": "./src/content.mjs",
20
+ "./schemas/decision-record.schema.json": "./schemas/decision-record.schema.json",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "src/",
26
+ "schemas/",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test --test-concurrency=4 \"test/**/*.test.mjs\"",
35
+ "check": "node bin/docs-kit.mjs check",
36
+ "test:browser": "node --test --test-concurrency=1 \"test/browser/*.browser.mjs\"",
37
+ "verify:pack": "node scripts/verify-root-pack.mjs"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/server": "^2.3.1",
41
+ "github-slugger": "^2.0.0",
42
+ "htmlparser2": "12.0.0",
43
+ "marked": "^18.1.0",
44
+ "npm-package-arg": "^12.0.2",
45
+ "picomatch": "^4.0.7",
46
+ "sanitize-html": "^2.18.0",
47
+ "shell-quote": "^1.12.0",
48
+ "yaml": "^2.9.1",
49
+ "zod": "^4.6.5"
50
+ },
51
+ "devDependencies": {
52
+ "@modelcontextprotocol/client": "^2.3.1",
53
+ "playwright-core": "1.64.0"
54
+ },
55
+ "workspaces": [
56
+ "packages/*"
57
+ ]
58
+ }