@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/CHANGELOG.md +266 -0
- package/dist/cli.mjs +583 -91
- package/docs/index.md +3 -2
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +36 -26
- package/templates/scaffold/README.md +1 -1
- package/templates/scaffold/env.example +6 -5
- package/templates/scaffold/instance.md +11 -1
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +5 -2
- package/templates/scaffold/system/site/lib/order-rule.ts +107 -0
- package/templates/scaffold/system/site/lib/page-order.ts +93 -0
- package/templates/scaffold/system/site/lib/shared.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +9 -48
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +19 -2
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.
|
|
49
|
-
frontmatter key — never `meta.json` or `sidebar_position
|
|
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.
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
- **
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
261
|
-
|
|
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`,
|
|
332
|
-
link
|
|
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
|
|
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
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
+
removeStage(stageDir);
|
|
472
489
|
refuseVisibilityWithoutAudiences(recordDir);
|
|
473
490
|
return RECORD_DIR;
|
|
474
491
|
}
|