Corpus#
Check, publish, locate, and retrieve source-bound documentation.
@kontourai/corpus gives every repository the same three things from one manifest:
- A docs gate for CI.
corpus checkvalidates links, anchors, reachability, topics, redirects, local paths, v2 purpose and building-block declarations, an optional decision registry, and (on pull requests) freshness. - A GitHub Pages site.
corpus buildrendershttps://kontourai.github.io/<repo>/and checks every link it generated. - A docs bundle for agents.
buildalso writesdocs-bundle.json, andcorpus mcpserves every product's bundle from one MCP server.
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:
npm install --save-dev --save-exact @kontourai/corpus@<version>
npx --no-install corpus check
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.
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:
npx -y --package=https://codeload.github.com/kontourai/corpus/tar.gz/<sha> -- corpus check
Publishing the root package#
The root publish workflow publishes
only @kontourai/corpus. Dispatch publish-npm-corpus.yml on main with an
exact version. Before anything is packed,
scripts/root-release-preflight.mjs
requires that version to equal the root package.json version and to be the
first section of CHANGELOG.md, with no Unreleased section left. A release
pull request that sets the version and moves the unreleased entries under it
therefore lands first. Stable versions publish to latest and prereleases to
next, and the version must be absent from npm; the same
guard as the workspaces refuses an existing
version, a registry error other than E404, or an unconfirmed response.
The verification job, with contents: read, runs the tests and the dogfood
check, then npm run verify:pack. That packs the root once, checks the
tarball's file list against an allow-list (bin/, src/, schemas/,
README.md, LICENSE, and package.json), installs the tarball with
--ignore-scripts into a fresh project, and runs the installed corpus check,
corpus decisions check (including a rejected record), and the docs-kit
alias against a fixture corpus and against this repository. It then exports
that same tarball with its SHA-256, and also passes that SHA-256 to the publish
job as a job output, outside the artifact. The publish job, with
id-token: write in the npm-publish environment, checks the tarball twice.
Right after download it checks it against the artifact's SHA256SUMS and
against the job-output hash. Immediately before npm publish it checks it
against the job-output hash again. It then publishes that exact file with
--provenance --ignore-scripts, without repacking. Run
npm run verify:pack locally to exercise the same check; it fetches the
tarball's dependencies from the registry.
The root package has its own workflow because its stable releases and the
workspaces' prereleases have different versions and cadences, and because an
npm trusted publisher names one workflow file: the trusted publisher for
@kontourai/corpus names publish-npm-corpus.yml, and the workspace packages
name publish-npm.yml, so neither workflow can publish the other's packages.
Configure it for organization kontourai, repository corpus, environment
npm-publish, with direct npm publish allowed, after bootstrapping the name
the same way as the workspaces below. The first CI release publishes to
latest, which moves latest off any bootstrap placeholder.
Publishing the store workspaces#
The publish workflow publishes only
@kontourai/corpus-store and @kontourai/corpus-conformance. The root
@kontourai/corpus package has its own workflow, described
above. Dispatch publish-npm.yml
on main with an exact version matching both workspace manifests;
the initial candidate is 0.1.0-next.0. Release-please does not manage these
workspace versions yet.
The verification job builds Store, runs its contracts and packed-consumer checks,
and uploads the two tested npm pack tarballs with SHA-256 hashes, which it also
passes to the publish job as job outputs. A separate publish job checks each
tarball right after download against the artifact's SHA256SUMS and against
the job-output hashes, then against the job-output hash again immediately
before each npm publish, and publishes those same tarballs with provenance. Only the publish job has OIDC permission;
test and build steps use contents: read. Every prerelease
uses next; stable versions use latest. Both versions must be absent on npm
before either publish begins. An existing version, registry error other than
E404, or unconfirmed response stops publication. If one publish succeeds and
the other fails, the run cannot simply be repeated: investigate the failure
and prepare a new matching version pair. The registry cannot publish the pair
atomically.
An owner must first create both npm packages and configure GitHub Actions
trusted publishers for organization kontourai, repository corpus, workflow
publish-npm.yml, environment npm-publish, with direct npm publish allowed.
Bootstrap primarily with staged publishing, which creates a 0.0.0-stage
placeholder for a new name. Reject the bootstrap stages with 2FA before CI
publication so their reserved target versions are released; approving them
would consume the CI candidate. A separate bootstrap prerelease is a fallback.
Inspect dist-tags immediately after bootstrap: latest unset may not be
achievable if npm assigns it to the placeholder. The orchestrator can deprecate
that placeholder and direct users to @next; do not move or remove latest.
Configure the GitHub npm-publish environment
for main and owner approval before dispatch. CI uses Node 24, pinned npm
11.17.0, id-token: write, and no registry token or setup-node registry-url.
Exercise the executable publish guard and the root release preflight with fixture registry responses and manifests:
node --test test/npm-publish-guard.test.mjs test/root-release-preflight.test.mjs
Purpose and audience#
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 providing mutable file-backed records as a separate package. It works for any repository or product suite, including projects outside Kontour. Docs bundles remain immutable snapshots; they do not become mutable records.
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.
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.
The separate @kontourai/corpus-conformance 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.
Contents#
- Manifest reference
- CLI reference
- MCP server
- Adopting corpus in a repo
- Decision records
- Bundle and products.json formats
- What the generated site contains
- Known limits
- Contributing
Manifest reference#
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.
{
"schemaVersion": 2,
"purpose": "Make process and execution state visible to users.",
"builtOn": [],
"product": "flow",
"title": "Flow",
"tagline": "Process transparency for agentic work",
"repo": "kontourai/flow",
"landing": "README.md",
"documents": [
{ "source": "docs/getting-started.md", "section": "Start Here", "covers": ["src/cli/**"] }
],
"topics": [
{ "id": "workflows", "title": "Workflows", "source": "docs/concepts.md#workflows" }
],
"exclude": ["docs/plans/**"],
"branch": "main",
"selectionActions": { "primary": "ask" },
"redirects": {
"gates-and-route-back.html": "docs/gates.md#route-back",
"releasing.html": "https://example.com/releasing"
},
"allowLocalPaths": ["docs/install-paths.md"]
}
| Key | Required | Meaning |
|---|---|---|
schemaVersion |
yes | Exactly 2. A manifest with 1 is refused (exit 2). |
purpose |
yes | Non-empty sentence describing the promise to users. The exact sentence corpus init writes is refused by check. |
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). |
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. |
title |
yes | Display name. |
tagline |
no | One line shown in the site header and on the hub. |
repo |
yes | owner/name on GitHub. |
landing |
no | Markdown rendered as the site's index.html. Default README.md. |
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. |
topics |
no | Curated entry points for agents. Each has id (a lowercase slug), title, and source, which is either path or path#anchor. |
exclude |
no | Globs for docs/**/*.md files that are deliberately unpublished. |
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. |
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). |
selectionActions |
no | The selection toolbar. Its only key, primary (required in the object), is "ask" (the default) or "challenge-issue": the action that leads the toolbar. |
decisions |
no | The decision registry: { "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. |
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 skips them. No globs. |
The validation rules:
- Validation is strict. An unknown key at any level is refused.
- Every path is repository-relative, POSIX, and normalized. Absolute paths,
..segments, and symlinks that resolve outside the repository are refused. Every source must exist. - Documents are listed once. They may not repeat the landing page, and they may not match
exclude. - 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-mattertitle, else the file name without.md. Atitleon thedocumentsentry overrides all of them in the navigation, the page<title>, anddocs-bundle.json; it must be a non-empty string without leading or trailing whitespace. - A topic
sourcemust point at the landing page or at a listed document, so it is published. Its#anchormust be a heading outside blockquotes and lists (any level,#through######), slugged by github-slugger: lowercase, punctuation dropped, spaces become-, and repeated headings get-1,-2, and so on. - 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 simpletitle: value(optionally quoted) is read from it; there is no YAML parser. publish, when present, must be a boolean. It defaults totrue.- Each
allowLocalPathsentry must be a normalized repository path that is exactly the landing page or adocumentssource, listed once. A glob, or any other path, is a manifest error. coversholds globs of the code a document describes; the freshness gate uses them. A bare directory name such assrc/climeanssrc/cli/**. A glob that matches no tracked file (git ls-files; every file outside git) fails thecoversgate ofcheck, because it could never mark its document stale. It does not blockbuildor the MCP server.
Section names group the navigation in the order they first appear in documents.
Bundle-only documents#
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:
buildrenders no page for it, and it is left out of the navigation and the landing cards. It is still indocs-bundle.json, so the MCP server serves it, and topics may point at it.- Its bundle
urlishttps://github.com/<repo>/blob/<sha>/<path>at the built commit. - Its bundle
markdownhas 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 GitHubblobURL (treefor directories), an image or other embedded file (Markdown image, raw-HTMLsrc,srcset, orposter) becomeshttps://raw.githubusercontent.com/<repo>/<sha>/<path>so it resolves to the file itself, and a#fragmentalone gets the document's own blob URL. This covers Markdown links, images, reference definitions, and raw-HTMLhref,src,srcset, andposter. Code blocks and code spans are left as written. Topics taken from the document get the same rewritten Markdown, and theirurlis the blob URL plus#anchor. checkruns every gate on it: links, anchors, topics, covers, freshness, and paths. Reachability counts it as listed.- A published page that links to it gets its GitHub blob URL, as for any other repository file.
- A redirect cannot target it, because it has no page; that fails the
redirectsgate.
Redirects#
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.
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.
- A key is the old page's path relative to the site root, such as
gates-and-route-back.htmloradr/0001-x.html. It must end in.htmland must not start with/or contain..,#, or?. - GitHub Pages serves
x.htmlfor a request to/x, so a keyx.htmlalso redirects the extensionless URL/x. - A value is either a source path, optionally with
#anchor(docs/gates.md,docs/gates.md#route-back), or an absolutehttps://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#anchorfollows the rules of the links gate: a heading slug, or anidornamein raw HTML. - A source target becomes a relative URL from the stub's own directory, so
adr/0001-x.htmlpointing atdocs/gates.mdrefreshes to../docs/gates.html. Its canonical URL is absolute, under--base-url. - A key must not be a page corpus generates (
index.htmlor 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 failsbuildwith exit1, because assets are known only after rendering. - 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 theredirectsgate (exit1). - Redirects are not part of
docs-bundle.json.
CLI reference#
corpus <check|build|locate|mcp|hub|init> [options]
corpus decisions <check|index> [options]
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.
check#
corpus check [--root dir] [--base <git-ref>] [--advisory] [--only <gates>] [--json]
check runs the gates below and reports every failure, not only the first.
--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 (exit2). So is a selected gate that does not run, such asfreshnesswithout--base,decisionswithout adecisionsblock, oradr-freezewithout--baseanddecisions.adr: a lane that asks for one gate never passes by checking nothing. So is a flag that no selected gate reads:--baseunlessfreshnessoradr-freezeis selected, and--advisoryunlessfreshnessis, so a mistyped ref is never silently ignored. A gate that runs but has nothing to examine still passes:--only manifestruns only the manifest, and--only freshnesspasses in a repository whose documents declare nocovers.--jsonprints one JSON object on stdout instead of the text report. Exit codes are unchanged. Whenever--jsonappears amongcheck'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": "…" }(plusschemaVersion,tool,command,base,advisory, andonly), with the message also on stderr. That covers an unknown option, an option with a missing or blank value, a bad--only, a missingcorpus.json, and an unexpected internal error.--helpprints text, so it cannot be combined with--json; together they are refused the same way. A gate result is:{ "schemaVersion": 1, "tool": "corpus", "command": "check", "verdict": "fail", "exitCode": 1, "base": "origin/main", "advisory": false, "only": ["freshness"], "summary": { "failures": 1, "warnings": 0, "errors": 0, "staleDocuments": 1 }, "gates": [ { "name": "manifest", "status": "pass", "failures": [], "warnings": [] }, { "name": "freshness", "status": "fail", "note": "3 changed files since origin/main", "failures": ["docs/cli.md is stale: …"], "warnings": [] } ], "staleDocuments": [ { "source": "docs/cli.md", "kind": "docs-covers", "gate": "freshness", "severity": "failure", "changed": ["src/cli.js"] } ], "actions": [ { "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"] } ] }verdictispass,warn(exit0with warnings, as under--advisory), orfail(exit1or2;exitCodetells them apart). A gate'sstatusispass,warn,fail,skip(withskipped, the reason), orerror(witherror).summaryholds only numbers. Every stale document carries thekindof staleness, never a bare stale flag. Today the only kind isdocs-covers: a file matched by the document'scoverschanged and the document did not.changedlists every such file, andseverityiswarningunder--advisory.actionshas one entry per stale document, withtypeset to its kind. A broken link and a stale document therefore no longer look the same: both exit1, but only a stale document is listed instaleDocumentsandactions. The shape matches the external-tool artifact that a Veritas evidence check reads (verdict, numericsummary,actions), socorpus check --base origin/main --only freshness --jsoncan be bound as evidence.
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.
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.
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.
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.
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.
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.
- manifest:
corpus.jsonis valid, as described above. If it is invalid, the other gates are skipped and the exit code is2. - links: every Markdown link and image, and every raw-HTML
href,src,srcset, andposter(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 rawhref=""is allowed, andsrcsetis parsed per the HTML spec, sodata:URIs stay whole. Relative files must exist, with exact case. A#anchormust match a heading slug, or anidornameattribute in raw HTML, in the target.md. Externalhttp(s)links are not fetched. A leading/resolves from--root(which is the repository root unless you point--rootat a subdirectory). - reachability: every
docs/**/*.mdis listed indocuments(with or without"publish": false), is the landing page, or is matched byexclude. When the manifest declaresdecisions, every*.mddirectly indecisions.dir(the records and the index) also counts as reachable without being listed; the decisions gate checks them. They are published only if listed indocuments. - topics: every topic anchor resolves to a heading outside blockquotes and lists.
- redirects: every redirect target is published and its
#anchorresolves, and no key repeats or collides with a file corpus generates. - paths: no absolute home-directory path appears in the landing page or any listed document, bundle-only ones included:
/Users/<name>/,/home/<name>/, orC:\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 asfile:line. A path inside a URL such ashttps://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 inallowLocalPathswhen it needs real example paths. - purpose: the manifest's
purposeis not the exact placeholder sentence thatcorpus initwrites. This gate fails with exit1, not2, so a new adopter still sees every other gate. It compares the whole sentence, not aTODOsubstring. - covers: every
coversglob matches at least one tracked file. This gate fails with exit1. - decisions: only when the manifest declares
decisions. Every record indecisions.diris a valid decision record and the index is whatcorpus decisions indexwould write. The directory is read directly, whateverdocumentsandexcludesay. Fails with exit1. - adr-freeze: only when the manifest declares
decisions.adr. Without--baseit is reported as skipped. A file added underdecisions.adr.dirsince the merge base fails with exit1, except aREADME.mdorindex.mddirectly in it. See Numbered ADRs. - freshness: this gate runs only with
--base. It reads the changed files fromgit diff --name-only --no-renames <base>...HEAD, which diffs from the merge base and lists both paths of a renamed file. If a document'scoversglobs match a changed file, the document's ownsourcemust also be in the changed set. Otherwise the document is stale. With--advisory, stale documents are printed as warnings and the exit code is0. A base that does not resolve, or a directory that is not a git checkout, gives exit2, never a silent pass.
decisions#
corpus decisions check [--root dir] [--base <git-ref>]
corpus decisions index [--root dir]
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.
build#
corpus build [--root dir] [--out dist-pages] [--base-url https://kontourai.github.io/<product>/]
build runs check without the covers, adr-freeze, and freshness gates and refuses to build if it fails. It then replaces --out with:
index.html(the landing page), one page per published document (not bundle-only ones), anddocs-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> documentationwhen there is no tagline. Long inline text wraps to the document column; wide code blocks and tables scroll within it.- Links between published documents, rewritten to the generated pages, in Markdown and in raw HTML (
href,src,srcset). Links to other repository files go tohttps://github.com/<repo>/blob/<sha>/<path>, or totreefor directories. - 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. - 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 carrydata-src-linewith their source-file lines, and<main>carries the page's path, sha, and repository. Where the browser offers WebMCP, the script also registers in-page tools. See the generated site. - Sanitized HTML. Every product site shares the
kontourai.github.ioorigin, so rendered Markdown is reduced to GitHub-like output: noscriptorstyle, noon*attributes, and onlyhttp(s),mailto, and imagedata:URLs. - Mermaid diagrams. A fenced
```mermaidblock 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 SRIintegrityhash, withsecurityLevel: 'strict'andhtmlLabels: false(labels are plain SVG text, so diagram HTML never bypasses the sanitizer), and a theme that followsprefers-color-scheme. That script is part of the page template; ascriptin the Markdown is still stripped. See the generated site. - A redirect stub for each
redirectskey. See Redirects. .nojekyll, a.docs-kit-buildmarker, anddocs-bundle.json. The bundle is described in docs/bundle-format.md.
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.
locate#
corpus locate --root . --quote "a passage from the docs" [--source README.md]
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).
mcp#
corpus mcp [--repo dir | --workspace dir | --hub url | --bundle file...]
mcp starts a stdio MCP server. Give it at most one source; with none, it uses --hub https://kontourai.github.io/. See MCP server.
hub#
corpus hub [--products products.json] [--out dist-hub]
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:
| State | Meaning |
|---|---|
unpublished |
The bundle could not be fetched. |
behind |
The bundle's sha differs from the default branch HEAD. The card shows the bundle's age and the HEAD commit's. |
thin |
The bundle has fewer than 3 documents, not counting the landing page. |
current |
The bundle's sha equals the default branch HEAD. |
unknown |
The GitHub API could not be read (for example, it was rate limited), so current cannot be claimed. |
If more than one state applies, the first one in the table wins.
init#
corpus init [--root dir]
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.
MCP server#
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.
| Tool | Arguments | Returns |
|---|---|---|
list_products |
none | product, title, tagline, sha, builtAt, siteUrl, topicCount, docCount (not counting the landing page); failed products are listed as unavailable |
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 |
get_topic |
product, id |
the topic's Markdown section |
get_document |
product, source |
one whole document, including the landing page (landing: true) |
search |
query, product?, limit? (1-50, default 10) |
ranked matches over topics and documents, with snippet, product, source, url, sha; topic matches also carry parentId, tags, and summary when present |
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 |
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 &, <, >, ", ', and are decoded; other named entities (such as é) 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.
The server has four sources:
--repo dirbuilds 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 ofgit 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.--workspace dirdoes the same for every immediate subdirectory that containscorpus.json. A subdirectory with only a retireddocs-kit.jsonis 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 theproductits manifest declares (the directory name if the manifest does not parse). If two directories declare the same product, both are unavailable.--hub urlfetches<url>/products.jsonand 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.--bundle fileserves a prebuiltdocs-bundle.jsonexactly as written: fromcorpus 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 (exit2). Programs that embed the server get the same source fromstaticSource(bundles), passed tocreateDocsServer.
Claude Code:
claude mcp add kontour-docs -- npx -y --package=@kontourai/corpus@<version> -- corpus mcp
Codex (~/.codex/config.toml):
[mcp_servers.kontour-docs]
command = "npx"
args = ["-y", "--package=@kontourai/corpus@<version>", "--", "corpus", "mcp"]
To serve local checkouts while you write docs, add --workspace ~/dev/github/kontourai or --repo . after mcp.
Adopting corpus in a repo#
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.npm install --save-dev --save-exact @kontourai/corpus@<version>Scaffold and review the manifest. Replace the
purposeplaceholder (checkfails until you do), fillbuiltOn(or leave it empty), order the documents, name the sections, move internal docs toexclude, and addtopicsandcovers.npx --no-install corpus init npx --no-install corpus checkAdd the gate to CI. On pull requests,
fetch-depth: 0makes the base branch available for freshness. Drop--advisoryonce thecoversglobs are trusted.- uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: 24 - run: npm ci - run: npx --no-install corpus check - if: github.event_name == 'pull_request' run: npx --no-install corpus check --base origin/${{ github.base_ref }} --advisoryPublish Pages from
main. Set the repository's Pages source to GitHub Actions, then run:- run: npx --no-install corpus build --out dist-pages - uses: actions/upload-pages-artifact@v5 with: path: dist-pages - uses: actions/deploy-pages@v5Add the product to the hub's
products.json({"product": "<repo>", "repo": "kontourai/<repo>"}).
Known limits#
- External links are never fetched, so a dead
https://link passescheck. #fragmentmatching is case-sensitive.- Headings that contain images or code spans can slug slightly differently from github.com in edge cases.
- Links cannot point above
--root, and a leading/resolves from--root, not from the git top level. - Freshness is file-level: any edit to a covering document in the range counts as an update.
- 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.
- Mermaid diagrams need JavaScript and jsDelivr. Without them the page shows the diagram source as preformatted text.
- Raw-HTML
nameattributes count as anchors incheck, but the sanitizer keepsnameonly on<a>. Anameon another element passescheckand then fails the generated-site check inbuild. Elementids are not prefixed withuser-content-as they are on GitHub. - MCP
searchis plain term matching with no stemming or synonyms. - The hub calls the GitHub API without authentication, which allows 60 requests per hour per IP. The 10-minute
localStoragecache keeps normal use within that limit. - 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.
- "Tracked" means present in the git index. A local
buildpublishes tracked assets with their unstaged working-tree edits. CI builds from a clean checkout, so its output matches the commit. - Empty directories that someone adds by hand inside the output directory are removed on rebuild. Only files are checked against the
.docs-kit-buildmanifest. - 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. - 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, andC:\Users. - 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.
locatematches 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.- 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.
- The hub's install snippet shows
<sha>as a placeholder. It cannot know which corpus commit to recommend.
License#
Apache-2.0. See LICENSE.