@panaversity/ksor 0.0.9 → 0.0.11

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/docs/index.md CHANGED
@@ -45,8 +45,9 @@ instead of their training memory. The corpus grows with each implemented verb.
45
45
  Read the scaffold's own `AGENTS.md` first — it is the working contract.
46
46
  Knowledge lives in `knowledge/` and never inside the site; frontmatter uses
47
47
  a closed key set (`title` + `status` required); `pnpm check` explains any
48
- violation and how to fix it. Sidebar order is the governed `order:`
49
- frontmatter key — never `meta.json` or `sidebar_position`. If the
48
+ violation and how to fix it. Reading order is the governed `order:`
49
+ frontmatter key — never `meta.json` or `sidebar_position` and it drives
50
+ every surface: the sidebar, `llms.txt`, and the MCP `outline` tool. If the
50
51
  instance declares an `audiences:` model, documents may carry a
51
52
  `visibility:` key and per-audience builds (`KSOR_AUDIENCE=<tier> pnpm
52
53
  build`) stage only what that tier may see — publication, not authorship:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
5
5
  "keywords": [
6
6
  "abstention",
@@ -147,19 +147,20 @@ Stand it up in this order (each step's errors explain how to fix themselves):
147
147
  `knowledge/`**: an applied schema reports "already applied", an existing
148
148
  grant reports "already granted", and ingest builds a fresh generation.
149
149
 
150
- **`pnpm serve` is the only command this rung needs.** Run it the first
151
- time, run it after editing `knowledge/`, run it to bring the server back —
152
- it is always the right answer, so there is nothing to decide. Every step it
153
- chains reports the state it found rather than failing: an applied schema
154
- says "already applied", an existing grant says "already granted", and
155
- unchanged chunks carry forward by content hash, so a rerun on an untouched
156
- corpus makes **zero provider calls** (`embedded 0, carried N`).
157
-
158
- A rerun on an unchanged record costs **nothing at all**: ingest compares the
159
- corpus it just read against the generation already serving and, when they
160
- are identical at the same commit, consumes no generation and writes no rows
161
- ("unchanged generation N already serves this corpus"). Edit a document and
162
- the next run builds a generation for it, re-embedding only what changed.
150
+ **`pnpm refresh` after editing `knowledge/`; `pnpm serve` to bring the
151
+ server up.** Two commands, and the split is the point: serving must not
152
+ publish, or a restart, a crash-loop or an autoscaling event each republishes
153
+ your record. Everything is re-runnable and reports the state it found rather
154
+ than failing an applied schema says "already applied", an existing grant
155
+ says "already granted".
156
+
157
+ A refresh on an unchanged record costs **nothing at all**: ingest compares
158
+ the corpus it just read against the generation already serving and, when
159
+ they are identical at the same commit, consumes no generation and writes no
160
+ rows ("unchanged generation N already serves this corpus"). Edit a
161
+ document and the next refresh builds a generation for it, re-embedding only
162
+ what changed and carrying the rest forward by content hash so an ordinary
163
+ edit makes a handful of provider calls, not a corpus-worth.
163
164
 
164
165
  Generations do accumulate as you edit. Reap the superseded ones when you
165
166
  think of it, or on a schedule:
@@ -169,10 +170,10 @@ Stand it up in this order (each step's errors explain how to fix themselves):
169
170
  ```
170
171
 
171
172
  The individual verbs (`pnpm schema`, `pnpm grant`, `pnpm ingest`,
172
- `pnpm serve`) exist for pipelines and split duties a deploy step that
173
- ingests while a different process serves, or a DBA who holds the credentials
174
- that authorize ingest. Reach for them when something else runs the steps;
175
- not as a daily choice.
173
+ `pnpm gc`) are what `provision` and `refresh` are made of. Reach for them
174
+ when duties are split a deploy step that ingests while a different process
175
+ serves, or a DBA who holds the credentials that authorize ingest — not as a
176
+ daily choice.
176
177
 
177
178
  4. **Turn the abstention gate on — deliberately, once it serves.** This is the
178
179
  step that makes "not in this corpus" a real answer, and it is measured, never
@@ -234,11 +235,14 @@ Two things worth being deliberate about:
234
235
  - **Set `KSOR_SSO_ISSUER` when your SSO stamps a stable `iss`.** Audience is
235
236
  always enforced against `KSOR_JWT_ALLOWED_AUDIENCES`; naming the issuer adds
236
237
  one more check for the cost of one variable.
237
- - **Set `KSOR_JWKS_URL` unless your SSO is Better Auth.** The signing keys are
238
- fetched from `<KSOR_SSO_URL>/api/auth/jwks` by default, which is Better
239
- Auth's layout. Auth0, Okta, Entra, Keycloak and Cognito publish theirs
240
- elsewhere, and a wrong JWKS URL fails as a transient fetch error — the door
241
- boots clean and every request 503s with nothing naming the cause.
238
+ - **The signing keys are DISCOVERED; you rarely set `KSOR_JWKS_URL`.** The door
239
+ reads your SSO's own metadata document RFC 8414
240
+ (`/.well-known/oauth-authorization-server`), then OpenID Discovery
241
+ (`/.well-known/openid-configuration`) so Auth0, Okta, Entra, Keycloak,
242
+ Cognito, Google and Better Auth all work unmodified. The boot report's `keys`
243
+ line names which document answered and where the keys came from; set
244
+ `KSOR_JWKS_URL` only to override that, or when your SSO publishes no metadata
245
+ at all.
242
246
 
243
247
  ## Withdrawing a document — `ksor takedown`
244
248
 
@@ -257,8 +261,12 @@ pnpm exec ksor takedown --instance instance.md --revoke <stable-id>
257
261
  The stable id is what a search result reports as `provenance.stable_id` — for
258
262
  most documents that is `knowledge/<path-without-.md>`. `--subtree` withdraws a
259
263
  section and everything beneath it, including documents added later.
260
- `--actor NAME` names who performed the act in the ledger; it defaults to the
261
- operating user.
264
+ `--actor NAME` names who performed the act in the ledger, and a denial or a
265
+ revocation is REFUSED without it. There is no default: a name taken from the
266
+ environment reads like a person and is whatever the shell happened to be
267
+ (`runner` under CI, `root` in a container), which is worse than no name at all
268
+ in the one row that exists to record who did this. Read-only modes
269
+ (`--list`, `--ledger`, `--export`) need nothing.
262
270
 
263
271
  **The MCP door stops serving it immediately. The SITE stops at its next
264
272
  build** — the site reads a file, not the database, and `pnpm build` refreshes
@@ -328,8 +336,10 @@ Details in README → Deploying.
328
336
  takes the position that page declares.
329
337
  - Sidebar position is the governed `order:` key: documents that declare it come
330
338
  first, ascending; the rest follow in name order.
331
- - One order drives the sidebar, `llms.txt`, and the home page's first-document
332
- link set it once and every surface agrees.
339
+ - One order drives every surface — the sidebar, `llms.txt`, the home page's
340
+ first-document link, and the MCP `outline` tool an agent reads to decide what
341
+ to read first. Set it once and they agree. The door picks up a reorder at the
342
+ next `pnpm refresh`, which costs no embedding: only the ordering changed.
333
343
  - Never `meta.json` or `sidebar_position`: the checker refuses framework files
334
344
  in the record, which has to read the same without the site.
335
345
 
@@ -97,7 +97,7 @@ different coding agent's way of finding the same working contract.
97
97
  | `.gitattributes` | markdown is checked out byte-stable on every platform, so the same commit hashes the same everywhere. |
98
98
  | `.env.example` | the variables the served rung needs; copy to `.env` (gitignored) and fill in. |
99
99
  | `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history. |
100
- | `package.json` | the two surface commands — `pnpm dev` (the site) and `pnpm serve` (the agent surface: schema grant ingest serve) — plus `pnpm build` / `pnpm check`, the pinned `@panaversity/ksor` tool, and the pnpm version this project pins. |
100
+ | `package.json` | the surface commands — `pnpm dev` (the site) and `pnpm provision` / `pnpm refresh` / `pnpm serve` (the agent surface: set up once, publish, then serve) — plus `pnpm build` / `pnpm check`, the pinned `@panaversity/ksor` tool, and the pnpm version this project pins. |
101
101
  | `pnpm-workspace.yaml` | where the workspace looks for code (`system/site`, plus reserved `system/gateways/*` and `system/packages/*`), and the supply-chain policy for installs. |
102
102
  | `pnpm-lock.yaml` | the exact dependency versions — the reason two machines build the same site. |
103
103
 
@@ -20,11 +20,12 @@ GEMINI_API_KEY=
20
20
  # KSOR_SSO_URL=https://your-sso.example.com
21
21
  # KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
22
22
  # KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
23
- # The JWKS endpoint defaults to <KSOR_SSO_URL>/api/auth/jwks, which is Better
24
- # Auth's layout. EVERY other provider — Auth0, Okta, Entra, Keycloak, Cognito —
25
- # publishes its keys elsewhere, and a wrong JWKS URL fails as a TRANSIENT fetch
26
- # error, so every request 503s with nothing naming the cause. Say where the keys
27
- # are:
23
+ # The signing keys are DISCOVERED from your SSO's own metadata document —
24
+ # RFC 8414 (/.well-known/oauth-authorization-server), then OpenID Discovery
25
+ # (/.well-known/openid-configuration) so Auth0, Okta, Entra, Keycloak,
26
+ # Cognito and Google all work unmodified. The boot line says which document
27
+ # answered and where the keys came from.
28
+ # Set this only to override discovery, or when your SSO publishes no metadata:
28
29
  # KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
29
30
  # Serving a public bind with auth off additionally requires
30
31
  # KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1, which serves your whole record to anyone
@@ -6,7 +6,10 @@ ksor:
6
6
  scaffolded: "KSOR-STAMP-VERSION"
7
7
  # The served MCP rung needs ONE required block: the NAME of the environment
8
8
  # variable holding your Postgres DSN — never the DSN itself. Uncomment it, copy
9
- # .env.example to .env, then run `pnpm serve`. Nothing else here is required:
9
+ # .env.example to .env, then: `pnpm provision` once (schema + grant), then
10
+ # `pnpm refresh` to PUBLISH the record, then `pnpm serve`. Serving does not
11
+ # publish — that is deliberate, and skipping refresh serves nothing.
12
+ # Nothing else here is required:
10
13
  # `embedding:` already defaults to Gemini at 1536 dimensions, and leaving
11
14
  # `retrieval:` out starts you with the abstention gate off and honest about it
12
15
  # (turn it on afterwards with `ksor calibrate`, once the record is serving).
@@ -39,3 +42,10 @@ act on it.
39
42
  Ask your coding agent to run the **intake interview** (it knows how — see
40
43
  `.agents/skills/intake-interview/`), answer its questions, and let it write
41
44
  this document with you.
45
+
46
+ Until you do, `ksor serve` says so — at boot, and to every agent that connects:
47
+ the MCP surface replaces this template with a plain statement that the record's
48
+ scope is unstated, rather than passing authoring guidance to a runtime agent as
49
+ if it were instructions. Nothing breaks, and the record still answers with
50
+ citations; it just cannot tell an agent what it is authoritative FOR, which is
51
+ the one thing that makes an answer worth trusting.
@@ -1,4 +1,4 @@
1
- import { appName, mcpEndpoint, mcpNamespace, recordVersion } from "@/lib/shared";
1
+ import { appName, mcpEndpoint, mcpNamespace, recordDescription, recordVersion } from "@/lib/shared";
2
2
 
3
3
  /**
4
4
  * `/.well-known/mcp/server.json` — how an agent DISCOVERS this record's MCP
@@ -33,7 +33,10 @@ export function GET(): Response {
33
33
  {
34
34
  $schema: SCHEMA,
35
35
  name: `${mcpNamespace()}/${appName}`,
36
- description: `The ${appName} Knowledge System of Record: governed markdown served with citations and honest abstention.`,
36
+ // The record's OWN account of itself see recordDescription. A
37
+ // description identical in every ksor record cannot help an agent choose
38
+ // one, and a record with no scope yet says so instead of guessing.
39
+ description: recordDescription(),
37
40
  version: recordVersion(),
38
41
  // Absent until the owner declares where the server runs — an invented
39
42
  // URL is worse than none, because an agent would try it and conclude the
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Reading order — ONE rule, for the website and the MCP door alike.
3
+ *
4
+ * `order:` is the only ordering key an author may write: it is in the governed
5
+ * frontmatter set the format checker closes, and the checker's own remedy for a
6
+ * stray `meta.json` says so ("sidebar order is the `order` frontmatter key").
7
+ *
8
+ * The MCP door did not read it. The kernel's tree adapter was converted from
9
+ * the predecessor, where the ordering keys were Docusaurus's `position` /
10
+ * `sidebar_position` — neither of which a compliant record may declare, because
11
+ * the checker refuses them as unknown keys. So the two surfaces disagreed about
12
+ * the record's reading order for every corpus that ordered itself at all: the
13
+ * site honoured `order:` and the door fell back to filename order and called it
14
+ * the record's structure. On a curriculum, where reading order IS the content,
15
+ * an agent asking `outline` for "what do I read first" got the wrong answer
16
+ * (found live 2026-08-21, by an agent probing a real ingested record).
17
+ *
18
+ * That is decision 18's shape — one guarantee, two surfaces, two heads — so it
19
+ * gets decision 18's treatment: this file is the rule, `ORDER_CASES` is the
20
+ * decision table, and both surfaces are asserted against the same rows. The
21
+ * site cannot import the kernel, so this file is COPIED into the scaffold and
22
+ * the copy is asserted byte-identical rather than trusted.
23
+ *
24
+ * Four things the two surfaces disagreed about beyond the key name, each of
25
+ * which is a row in the table:
26
+ *
27
+ * - the unordered sentinel. The kernel used 10_000, a real number, so
28
+ * `order: 20000` sorted AFTER an unordered document in the door and BEFORE
29
+ * it on the site. Unordered is not a large order; it is the absence of one.
30
+ * - truncation. The kernel applied `Math.trunc`, collapsing 3.2 and 3.7 into
31
+ * one position and re-sorting them by name; the site kept both.
32
+ * - the tie key's extension. The kernel compared `example.md` against
33
+ * `example-two.md` — where `-` (45) sorts before `.` (46) — while the site
34
+ * compared the extensionless urls, where the shorter is a prefix and wins.
35
+ * Two ordinary filenames, two different orders.
36
+ * - case. The kernel lowercased the tie key and the site did not, so
37
+ * `apple.md` and `Banana.md` came out in opposite orders.
38
+ *
39
+ * No imports: a leaf, so it is testable in isolation and safe to copy.
40
+ */
41
+
42
+ /**
43
+ * A document that declares no usable `order:` sorts after every document that
44
+ * does. Infinity, not a large number — see above.
45
+ */
46
+ export const UNORDERED: number = Number.POSITIVE_INFINITY;
47
+
48
+ /**
49
+ * The `order:` frontmatter value as a sort key.
50
+ *
51
+ * A numeric string is accepted because YAML frontmatter is read by scanners
52
+ * here, not by a YAML library: `order: 3` and `order: "3"` both reach this as
53
+ * text on one surface and as a number on the other, and an author cannot be
54
+ * expected to know which. Anything that is not a finite number — a word, a
55
+ * boolean, an empty value — is NOT an order, and the document sorts unordered.
56
+ */
57
+ export function orderValue(raw: unknown): number {
58
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : UNORDERED;
59
+ if (typeof raw === "string") {
60
+ const trimmed = raw.trim();
61
+ if (trimmed === "") return UNORDERED;
62
+ const parsed = Number(trimmed);
63
+ return Number.isFinite(parsed) ? parsed : UNORDERED;
64
+ }
65
+ return UNORDERED;
66
+ }
67
+
68
+ /**
69
+ * The tie key for one sibling: its name with a MARKDOWN extension removed,
70
+ * case PRESERVED. The extension comes off because the site compares routes,
71
+ * which never carry one, and `.` sorting after `-` silently reversed ordinary
72
+ * pairs. Only `.md`/`.mdx` come off — a directory named `v1.2` keeps its dot,
73
+ * because the site's route keeps it too. Case is preserved because the site
74
+ * compares urls, and the url is what a reader sees.
75
+ */
76
+ export function tieKey(name: string): string {
77
+ return name.replace(/\.mdx?$/, "");
78
+ }
79
+
80
+ /** One sibling's place in its parent: what it declared, and how ties break. */
81
+ export interface Sibling {
82
+ readonly order: number;
83
+ readonly tie: string;
84
+ }
85
+
86
+ /**
87
+ * Compare by code point, not by locale or UTF-16 unit: reading order must be
88
+ * one bytewise truth on every machine, and `<` on strings compares UTF-16 units
89
+ * — which differ from code points on astral names.
90
+ */
91
+ export function codePointCompare(a: string, b: string): number {
92
+ const as = [...a];
93
+ const bs = [...b];
94
+ const n = Math.min(as.length, bs.length);
95
+ for (let i = 0; i < n; i += 1) {
96
+ const x = as[i]?.codePointAt(0) ?? 0;
97
+ const y = bs[i]?.codePointAt(0) ?? 0;
98
+ if (x !== y) return x < y ? -1 : 1;
99
+ }
100
+ return as.length === bs.length ? 0 : as.length < bs.length ? -1 : 1;
101
+ }
102
+
103
+ /** Declared order first; ties break on the tie key. Total, and stable-safe. */
104
+ export function compareSiblings(a: Sibling, b: Sibling): number {
105
+ if (a.order !== b.order) return a.order < b.order ? -1 : 1;
106
+ return codePointCompare(a.tie, b.tie);
107
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Turning the reading-order rule into a sorted page tree — the SITE's half of
3
+ * decision 18, and canonical here so it can be tested.
4
+ *
5
+ * It names no framework type — the page-tree node shape is declared
6
+ * structurally below — for two reasons: the file is byte-copied into the
7
+ * scaffold's site, where it runs against Fumadocs's `Node`; and a rule that can
8
+ * only be exercised inside a Next build is a rule nobody exercises. The kernel
9
+ * has no website — it carries this so the SAME ORDER_CASES rows can be run
10
+ * against the tree sort a reader actually sees.
11
+ *
12
+ * Structural, not nominal: any node shaped like this sorts, which is what makes
13
+ * one file serve both the real page tree and the table's fixtures.
14
+ */
15
+
16
+ import { compareSiblings, UNORDERED } from "./order-rule";
17
+
18
+ /** The members of a page-tree node that reading order depends on. */
19
+ export interface OrderNode {
20
+ readonly type: string;
21
+ /** A page's route. Absent on folders. */
22
+ readonly url?: string;
23
+ /** A folder's index document, when it has one. */
24
+ readonly index?: { readonly url: string } | undefined;
25
+ readonly children?: readonly OrderNode[] | undefined;
26
+ }
27
+
28
+ function nodeOrder(node: OrderNode, orders: ReadonlyMap<string, number>): number {
29
+ if (node.type === "page") return orders.get(node.url ?? "") ?? UNORDERED;
30
+ if (node.type === "folder" && node.index) return orders.get(node.index.url) ?? UNORDERED;
31
+ return UNORDERED;
32
+ }
33
+
34
+ // A url that identifies this node: its own, its index's, or its first
35
+ // descendant's — a folder with no index document has no url of its own.
36
+ function nodeUrl(node: OrderNode): string {
37
+ if (node.type === "page") return node.url ?? "";
38
+ if (node.type === "folder") {
39
+ if (node.index) return node.index.url;
40
+ for (const child of node.children ?? []) {
41
+ const url = nodeUrl(child);
42
+ if (url !== "") return url;
43
+ }
44
+ }
45
+ return "";
46
+ }
47
+
48
+ // The index of a top-level document's own segment: "/docs/x" splits to
49
+ // ["", "docs", "x"], so its segment is at 2 — the baseUrl's segment count.
50
+ const BASE_SEGMENTS = "/docs".split("/").length;
51
+
52
+ /**
53
+ * The tie key: the ONE path segment that distinguishes this node from its
54
+ * siblings — never the whole url.
55
+ *
56
+ * The tree adapter ties on the entry's own name, so a folder with no index
57
+ * document must tie on its folder name too. Its url comes from a descendant
58
+ * (`/docs/guides/first`), and comparing that whole url against a sibling
59
+ * `/docs/guides-x` puts them in the opposite order, because the separator `/`
60
+ * (47) sorts after `-` (45). Taking the segment at this depth is exactly the
61
+ * adapter's key.
62
+ */
63
+ function tieAt(node: OrderNode, depth: number): string {
64
+ return nodeUrl(node).split("/")[BASE_SEGMENTS + depth] ?? "";
65
+ }
66
+
67
+ /**
68
+ * Children of one parent, in reading order, recursively.
69
+ *
70
+ * Generic in the node so the caller keeps its own richer type: the site passes
71
+ * Fumadocs `Node`s and gets `Node`s back. The one cast is the rebuilt folder —
72
+ * spreading a node and replacing `children` produces a value TypeScript can no
73
+ * longer prove is the same subtype, though it is: every other member is copied
74
+ * verbatim. `depth` starts at 0 for the tree's own children.
75
+ */
76
+ export function sortNodes<T extends OrderNode>(
77
+ nodes: readonly T[],
78
+ orders: ReadonlyMap<string, number>,
79
+ depth: number,
80
+ ): T[] {
81
+ return nodes
82
+ .map((node) =>
83
+ node.type === "folder"
84
+ ? ({ ...node, children: sortNodes(node.children ?? [], orders, depth + 1) } as T)
85
+ : node,
86
+ )
87
+ .sort((a, b) =>
88
+ compareSiblings(
89
+ { order: nodeOrder(a, orders), tie: tieAt(a, depth) },
90
+ { order: nodeOrder(b, orders), tie: tieAt(b, depth) },
91
+ ),
92
+ );
93
+ }
@@ -59,6 +59,55 @@ function readInstanceTitle(): string {
59
59
 
60
60
  export const appTitle: string = readInstanceTitle();
61
61
 
62
+ /**
63
+ * How this record describes ITSELF, in one line — what an agent reads in a
64
+ * registry listing to decide whether this record can answer its question.
65
+ *
66
+ * It comes from the record's own prose (instance.md's first real paragraph,
67
+ * which the intake interview writes) because the alternative is what shipped
68
+ * before: one hard-coded sentence, byte-identical in every ksor record ever
69
+ * scaffolded, telling a discovering agent nothing that distinguishes this record
70
+ * from any other. "Discoverability determines whether agents find you at all" is
71
+ * a product principle, and a description that cannot discriminate is not
72
+ * discoverability (found live 2026-08-21).
73
+ *
74
+ * An UNDESCRIBED record says so rather than borrowing a confident sentence it
75
+ * has not earned — the same answer the MCP door already gives an agent that
76
+ * connects, so the two surfaces do not disagree about whether this record knows
77
+ * what it is. The marker is the template's own unfilled placeholder, matched on
78
+ * the WHOLE body: a scaffold's first paragraphs are authoring guidance, and
79
+ * reading one of those as the record's scope is worse than admitting there is
80
+ * none.
81
+ */
82
+ const TEMPLATE_MARKER = "_fill this in; it is";
83
+
84
+ function readInstanceScope(): string | null {
85
+ const text = readFileSync(findInstance(process.cwd()), "utf8");
86
+ const body = text.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, "");
87
+ if (body.includes(TEMPLATE_MARKER)) return null;
88
+ const afterHeading = body.replace(/^[\s\S]*?^#[ \t]+.+$/m, "");
89
+ for (const para of afterHeading.split(/\n[ \t]*\n/)) {
90
+ const one = para.trim().replace(/\s+/g, " ");
91
+ if (one === "" || one.startsWith("#") || one.startsWith("-") || one.startsWith(">")) continue;
92
+ const sentence = /^(.+?[.!?])(\s|$)/.exec(one)?.[1] ?? one;
93
+ return sentence.length > 300 ? `${sentence.slice(0, 297)}...` : sentence;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /** null until the owner has written one — never a guess. */
99
+ export const appScope: string | null = readInstanceScope();
100
+
101
+ /**
102
+ * The one-line description every discovery surface publishes. Built here so the
103
+ * registry document and anything else that needs one cannot drift apart.
104
+ */
105
+ export function recordDescription(): string {
106
+ return appScope === null
107
+ ? `${appTitle} — its owner has not yet described what this record covers.`
108
+ : `${appTitle} — ${appScope}`;
109
+ }
110
+
62
111
  /**
63
112
  * Where this record's MCP surface is published, if the owner has said.
64
113
  *
@@ -3,6 +3,9 @@ import { loader } from "fumadocs-core/source";
3
3
  import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons";
4
4
  import type { Node, Root } from "fumadocs-core/page-tree";
5
5
 
6
+ import { orderValue } from "./order-rule";
7
+ import { sortNodes } from "./page-order";
8
+
6
9
  // See https://fumadocs.dev/docs/headless/source-api for more info
7
10
  export const source = loader({
8
11
  baseUrl: "/docs",
@@ -18,54 +21,12 @@ export type KnowledgePage = (typeof source)["$inferPage"];
18
21
  // every rendered link.
19
22
  export const basePath: string = process.env.KSOR_BASE_PATH ?? "";
20
23
 
21
- // `order:` is a governed frontmatter key; a document without one, or with a
22
- // value that is not a number, sorts after every document that declares one.
24
+ // Reading order is ONE rule, shared with the MCP door byte-for-byte see
25
+ // ./order-rule.ts. The site cannot import the kernel, so the rule is copied and
26
+ // the copy is asserted; every case both surfaces must agree on is a row in the
27
+ // kernel's ORDER_CASES table, and this half is asserted against the same rows.
23
28
  function orderOf(page: KnowledgePage): number {
24
- const raw: unknown = page.data.order;
25
- const value = typeof raw === "string" ? Number(raw) : raw;
26
- return typeof value === "number" && Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
27
- }
28
-
29
- function nodeOrder(node: Node, orders: ReadonlyMap<string, number>): number {
30
- if (node.type === "page") return orders.get(node.url) ?? Number.POSITIVE_INFINITY;
31
- if (node.type === "folder" && node.index) {
32
- return orders.get(node.index.url) ?? Number.POSITIVE_INFINITY;
33
- }
34
- return Number.POSITIVE_INFINITY;
35
- }
36
-
37
- // The tie-break key: a page's url, a folder's index url or first page's url.
38
- // Ties break on it so unordered documents read in plain name order, folders
39
- // interleaved — the canonical reading order both shells implement (found
40
- // live 2026-08-18: the loader's own tie order grouped folders after loose
41
- // files, silently diverging from the Docusaurus shell on the same record).
42
- function nodeName(node: Node): string {
43
- if (node.type === "page") return node.url;
44
- if (node.type === "folder") {
45
- if (node.index) return node.index.url;
46
- for (const child of node.children) {
47
- const name = nodeName(child);
48
- if (name !== "") return name;
49
- }
50
- }
51
- return "";
52
- }
53
-
54
- function sortNodes(nodes: readonly Node[], orders: ReadonlyMap<string, number>): Node[] {
55
- return nodes
56
- .map((node) =>
57
- node.type === "folder" ? { ...node, children: sortNodes(node.children, orders) } : node,
58
- )
59
- .sort((a, b) => {
60
- const left = nodeOrder(a, orders);
61
- const right = nodeOrder(b, orders);
62
- if (left !== right) return left < right ? -1 : 1;
63
- const leftName = nodeName(a);
64
- const rightName = nodeName(b);
65
- // Codepoint comparison, not locale: reading order must be one bytewise
66
- // truth on every machine.
67
- return leftName < rightName ? -1 : leftName > rightName ? 1 : 0;
68
- });
29
+ return orderValue(page.data.order);
69
30
  }
70
31
 
71
32
  /**
@@ -76,7 +37,7 @@ function sortNodes(nodes: readonly Node[], orders: ReadonlyMap<string, number>):
76
37
  export function getSortedPageTree(): Root {
77
38
  const orders = new Map(source.getPages().map((page) => [page.url, orderOf(page)] as const));
78
39
  const tree = source.getPageTree();
79
- return { ...tree, children: sortNodes(tree.children, orders) };
40
+ return { ...tree, children: sortNodes(tree.children, orders, 0) };
80
41
  }
81
42
 
82
43
  function collectUrls(nodes: readonly Node[], urls: string[]): void {
@@ -343,13 +343,30 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
343
343
  return { files: [...documents, ...assets], documents: documents.length, total };
344
344
  }
345
345
 
346
+ /**
347
+ * Remove the stage, asking for the retries this exact failure needs.
348
+ *
349
+ * `force: true` suppresses ENOENT; it does NOT retry anything. Node retries
350
+ * EBUSY / EMFILE / ENFILE / ENOTEMPTY / EPERM only when `maxRetries` is set,
351
+ * and it defaults to zero. The build evaluates `source.config.ts` more than
352
+ * once when the bundler wants it in more than one place, so two runs can
353
+ * overlap: one removing the stage while the other is still copying into it.
354
+ * That surfaced as `ENOTEMPTY` out of `rmSync` and failed the whole site build
355
+ * (CI, 2026-08-21) — a race that is safe to lose, because the stage is a
356
+ * deterministic function of the record and the denylist, so redoing it produces
357
+ * the same bytes.
358
+ */
359
+ function removeStage(stageDir: string): void {
360
+ rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
361
+ }
362
+
346
363
  /** Fill a clean stage with exactly the set this build may publish. */
347
364
  function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
348
365
  // The old stage goes first, before any refusal can throw: a refused build
349
366
  // that leaves the previous, more permissive stage on disk hands the next
350
367
  // careless build a filtered copy nothing governs (review finding,
351
368
  // 2026-08-19).
352
- rmSync(stageDir, { recursive: true, force: true });
369
+ removeStage(stageDir);
353
370
  const plan = planStage(recordDir, denied);
354
371
  // An empty record is its own problem, reported by the page that renders it;
355
372
  // an empty AUDIENCE is a misconfiguration that would otherwise surface as
@@ -468,7 +485,7 @@ export function knowledgeSourceDir(): string {
468
485
  // A stage left behind by an earlier model would be a filtered copy of the
469
486
  // record nothing governs any more — removed before the refusal below can
470
487
  // throw, so a refused build never leaves one behind either.
471
- rmSync(stageDir, { recursive: true, force: true });
488
+ removeStage(stageDir);
472
489
  refuseVisibilityWithoutAudiences(recordDir);
473
490
  return RECORD_DIR;
474
491
  }