@panaversity/ksor 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +965 -0
  2. package/README.md +29 -9
  3. package/dist/checker/check-main.mjs +14049 -0
  4. package/dist/cli.mjs +11528 -5213
  5. package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
  6. package/dist/gateway.d.mts +52 -13
  7. package/dist/gateway.mjs +2 -2
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
  11. package/docs/authorization.md +8 -6
  12. package/docs/deploying.md +36 -25
  13. package/docs/index.md +26 -13
  14. package/docs/ingesting.md +70 -22
  15. package/docs/tool-surface.md +69 -16
  16. package/package.json +4 -3
  17. package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
  18. package/schema/schema.sql +77 -14
  19. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
  20. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
  21. package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
  22. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
  23. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
  24. package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
  25. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
  26. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
  27. package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
  28. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
  29. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
  30. package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
  31. package/templates/scaffold/.github/workflows/validate.yml +9 -1
  32. package/templates/scaffold/.ksor/governance.yaml +17 -0
  33. package/templates/scaffold/AGENTS.md +293 -119
  34. package/templates/scaffold/Dockerfile +5 -1
  35. package/templates/scaffold/README.md +160 -42
  36. package/templates/scaffold/env.example +37 -6
  37. package/templates/scaffold/gitignore +14 -6
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +11 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
  42. package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
  43. package/templates/scaffold/knowledge/surfaces/index.md +4 -20
  44. package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
  45. package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
  46. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
  47. package/templates/scaffold/package.json +3 -4
  48. package/templates/scaffold/pnpm-lock.yaml +3 -0
  49. package/templates/scaffold/system/gateways/content.ts +13 -0
  50. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  51. package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
  52. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
  53. package/templates/scaffold/system/site/app/global.css +198 -6
  54. package/templates/scaffold/system/site/app/layout.tsx +8 -3
  55. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
  56. package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
  57. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
  58. package/templates/scaffold/system/site/components/code-block.tsx +87 -0
  59. package/templates/scaffold/system/site/components/embed.tsx +276 -0
  60. package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
  61. package/templates/scaffold/system/site/components/governance.tsx +219 -87
  62. package/templates/scaffold/system/site/components/mdx.tsx +10 -2
  63. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  64. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  65. package/templates/scaffold/system/site/components/record-views.tsx +6 -17
  66. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  67. package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
  68. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  69. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  70. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  71. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  72. package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
  73. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  74. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  75. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  76. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  77. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  78. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  79. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  80. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  81. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  82. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  83. package/templates/scaffold/system/site/lib/source.ts +256 -186
  84. package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
  85. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  86. package/templates/scaffold/system/site/package.json +1 -0
  87. package/templates/scaffold/system/site/record/actor.ts +23 -0
  88. package/templates/scaffold/system/site/record/check.ts +571 -0
  89. package/templates/scaffold/system/site/record/citations.ts +312 -0
  90. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  91. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  92. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  93. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  94. package/templates/scaffold/system/site/record/index.ts +103 -0
  95. package/templates/scaffold/system/site/record/instance.ts +257 -0
  96. package/templates/scaffold/system/site/record/instant.ts +43 -0
  97. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  98. package/templates/scaffold/system/site/record/load.ts +129 -0
  99. package/templates/scaffold/system/site/record/lock.ts +306 -0
  100. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  101. package/templates/scaffold/system/site/record/policy.ts +414 -0
  102. package/templates/scaffold/system/site/record/profile.ts +535 -0
  103. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  104. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  105. package/templates/scaffold/system/site/source.config.ts +109 -23
  106. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  107. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  108. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -1,39 +1,28 @@
1
1
  import { docs } from "collections/server";
2
2
  import { loader } from "fumadocs-core/source";
3
3
  import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons";
4
- import { statusBadgesPlugin } from "fumadocs-core/source/plugins/status-badges";
5
4
  import type { Node, Root } from "fumadocs-core/page-tree";
5
+ import type { ReactNode } from "react";
6
6
 
7
+ import { agentFrontmatter, badgeLabel, readGovernance, stampLines } from "./governance";
8
+ import { dirOfRoute, listingOf, readingOrder } from "./index-routes";
9
+ import type { LifecycleBadge } from "./lifecycle-rule";
10
+ import { appName, appTitle, appDescription, showGovernance } from "./shared";
7
11
  import {
8
- agentFrontmatter,
9
- agentIndexSuffix,
10
- caveatStatus,
11
- readGovernance,
12
- resolveSuccessorUrl,
13
- } from "./governance";
14
- import { appName, showGovernance } from "./shared";
15
- import { renderCaveatBadge } from "@/components/sidebar-status";
16
- import { orderValue } from "./order-rule";
17
- import { sortNodes } from "./page-order";
12
+ readStagedIndex,
13
+ readStageManifest,
14
+ stagedBody,
15
+ stagedFrontmatter,
16
+ stagePageOf,
17
+ } from "./stage-manifest";
18
+ import { renderBadge } from "@/components/sidebar-status";
19
+ import { generateIndexes, humanise, type IndexEntry } from "../record/index-file";
18
20
 
19
21
  // See https://fumadocs.dev/docs/headless/source-api for more info
20
22
  export const source = loader({
21
23
  baseUrl: "/docs",
22
24
  source: docs.toFumadocsSource(),
23
- plugins: [
24
- lucideIconsPlugin(),
25
- // The shell's own status plugin, which reads `status` from a document's
26
- // frontmatter and puts it on the tree node. This used to be a map of
27
- // statuses by url and a second walk over the tree that rewrote each row's
28
- // `name` — the plugin does the walk, so the record's own key reaches the
29
- // sidebar without us restating it.
30
- //
31
- // `renderBadge` returns null for anything that is not a caveat, which is
32
- // the one rule that is OURS: a reader already assumes a document in the
33
- // record is current, so `approved` shows nothing and the marker stays rare
34
- // enough to be noticed where it matters.
35
- statusBadgesPlugin({ renderBadge: (status) => renderCaveatBadge(status) }),
36
- ],
25
+ plugins: [lucideIconsPlugin()],
37
26
  });
38
27
 
39
28
  // The page tree's root is named "Docs" by default — fumadocs' fallback for a
@@ -52,88 +41,170 @@ export type KnowledgePage = (typeof source)["$inferPage"];
52
41
  // every rendered link.
53
42
  export const basePath: string = process.env.KSOR_BASE_PATH ?? "";
54
43
 
55
- // Reading order is ONE rule, shared with the MCP door byte-for-byte — see
56
- // ./order-rule.ts. The site cannot import the kernel, so the rule is copied and
57
- // the copy is asserted; every case both surfaces must agree on is a row in the
58
- // kernel's ORDER_CASES table, and this half is asserted against the same rows.
59
- function orderOf(page: KnowledgePage): number {
60
- return orderValue(page.data.order);
44
+ /** A page's bundle-relative path with forward slashes the manifest's key. */
45
+ function pathOf(page: KnowledgePage): string {
46
+ return page.path.replaceAll("\\", "/");
61
47
  }
62
48
 
63
49
  /**
64
- * The page tree with every folder's children ordered by `order:` frontmatter.
65
- * Rebuilt on each call from a fresh clone — the loader's own tree is shared
66
- * state and mutating it would survive a hot reload.
50
+ * Every directory this viewer's stage holds an index for, root first, with
51
+ * its parsed index walked from the root index's folder bullets, so a folder
52
+ * this viewer may not see is never even asked for.
67
53
  */
68
- export function getSortedPageTree(): Root {
69
- const pages = source.getPages();
70
- const orders = new Map(pages.map((page) => [page.url, orderOf(page)] as const));
71
- // The caveat status already rides the row: `statusBadgesPlugin` above put it
72
- // there while the loader built the tree, so the reader sees it before the
73
- // click rather than after (research/site-design.md F3). This function is
74
- // left with the one thing the shell has no opinion about — the record's
75
- // governed `order:`.
76
- const tree = source.getPageTree();
77
- return { ...tree, children: sortNodes(tree.children, orders, 0) };
54
+ function stagedIndexes(): Map<string, IndexEntry[]> {
55
+ const out = new Map<string, IndexEntry[]>();
56
+ const walk = (dir: string): void => {
57
+ const entries = readStagedIndex(dir);
58
+ if (entries === null) return;
59
+ out.set(dir, entries);
60
+ for (const item of listingOf(dir, entries)) if (item.kind === "folder") walk(item.path);
61
+ };
62
+ walk("");
63
+ return out;
78
64
  }
79
65
 
80
- function collectUrls(nodes: readonly Node[], urls: string[]): void {
66
+ /**
67
+ * Reading order is ONE rule — the index generator's (build spec §1: one bullet
68
+ * list, concepts and folders together, by `order:` then name) — and every
69
+ * surface takes it from the regenerated indexes rather than restating it. A
70
+ * route the indexes never listed sorts last, by url.
71
+ */
72
+ function positions(): Map<string, number> {
73
+ return new Map(readingOrder(stagedIndexes()).map((url, i) => [url, i] as const));
74
+ }
75
+
76
+ // The index of a top-level entry's own segment: "/docs/x" splits to
77
+ // ["", "docs", "x"], so its segment is at 2 — the baseUrl's segment count.
78
+ const BASE_SEGMENTS = "/docs".split("/").length;
79
+
80
+ /**
81
+ * A node's own route at `depth`: a page's url, or a folder's route derived
82
+ * from any descendant, TRUNCATED to this depth.
83
+ *
84
+ * The truncation is the whole point, not tidiness. A folder with no index
85
+ * document takes its url from a descendant (`/docs/guides/first`), and
86
+ * comparing that whole url against a sibling `/docs/guides-x` puts the two in
87
+ * the opposite order, because the separator `/` (47) sorts after `-` (45).
88
+ * Cutting to this depth compares `/docs/guides` against `/docs/guides-x`,
89
+ * which is what the reading-order rule means by a sibling name.
90
+ */
91
+ function routeAt(node: Node, depth: number): string {
92
+ if (node.type === "page") return node.url;
93
+ if (node.type === "folder") {
94
+ const descendant = node.index?.url ?? firstPageUrl(node.children);
95
+ if (descendant !== null) {
96
+ return descendant
97
+ .split("/")
98
+ .slice(0, BASE_SEGMENTS + depth + 1)
99
+ .join("/");
100
+ }
101
+ }
102
+ return "";
103
+ }
104
+
105
+ function firstPageUrl(nodes: readonly Node[]): string | null {
81
106
  for (const node of nodes) {
82
- if (node.type === "page") urls.push(node.url);
83
- else if (node.type === "folder") {
84
- if (node.index) urls.push(node.index.url);
85
- collectUrls(node.children, urls);
107
+ if (node.type === "page") return node.url;
108
+ if (node.type === "folder") {
109
+ const url = node.index?.url ?? firstPageUrl(node.children);
110
+ if (url !== null) return url;
86
111
  }
87
112
  }
113
+ return null;
114
+ }
115
+
116
+ function sortTree(
117
+ nodes: readonly Node[],
118
+ order: ReadonlyMap<string, number>,
119
+ depth: number,
120
+ ): Node[] {
121
+ const rank = (node: Node): number => order.get(routeAt(node, depth)) ?? Number.POSITIVE_INFINITY;
122
+ return nodes
123
+ .map((node): Node => {
124
+ if (node.type === "folder") {
125
+ const url = routeAt(node, depth);
126
+ // The folder's own page — the regenerated index rendered as a listing
127
+ // — so the sidebar row LINKS the folder rather than only toggling it.
128
+ const index: Node & { type: "page" } = { type: "page", name: node.name, url };
129
+ return { ...node, index, children: sortTree(node.children, order, depth + 1) };
130
+ }
131
+ if (node.type === "page") {
132
+ const badge = stagePageOf(pagePathByUrl().get(node.url) ?? "")?.badge ?? null;
133
+ return badge === null ? node : { ...node, name: withBadge(node.name, badge) };
134
+ }
135
+ return node;
136
+ })
137
+ .sort((a, b) => rank(a) - rank(b) || routeAt(a, depth).localeCompare(routeAt(b, depth)));
138
+ }
139
+
140
+ function withBadge(name: ReactNode, badge: LifecycleBadge): ReactNode {
141
+ return renderBadge(name, badge);
142
+ }
143
+
144
+ let urlToPath: Map<string, string> | null = null;
145
+ function pagePathByUrl(): Map<string, string> {
146
+ if (urlToPath === null) {
147
+ urlToPath = new Map(source.getPages().map((page) => [page.url, pathOf(page)] as const));
148
+ }
149
+ return urlToPath;
88
150
  }
89
151
 
90
152
  /**
91
- * Every page, in the order the sidebar shows them the one reading order the
92
- * site, llms.txt and llms-full.txt all serve.
153
+ * The page tree in reading order, every folder linking its own page and every
154
+ * page carrying its badge. Rebuilt on each call from a fresh clone — the
155
+ * loader's own tree is shared state and mutating it would survive a hot reload.
156
+ */
157
+ export function getSortedPageTree(): Root {
158
+ const tree = source.getPageTree();
159
+ return { ...tree, children: sortTree(tree.children, positions(), 0) };
160
+ }
161
+
162
+ /**
163
+ * Every page the human surfaces show, in the one reading order — the sidebar,
164
+ * the folder pages and the home page all walk this list.
93
165
  */
94
166
  export function getSortedPages(): KnowledgePage[] {
95
- const urls: string[] = [];
96
- collectUrls(getSortedPageTree().children, urls);
167
+ const order = positions();
168
+ return [...source.getPages()].sort(
169
+ (a, b) =>
170
+ (order.get(a.url) ?? Number.POSITIVE_INFINITY) -
171
+ (order.get(b.url) ?? Number.POSITIVE_INFINITY) || a.url.localeCompare(b.url),
172
+ );
173
+ }
97
174
 
98
- const remaining = new Map(source.getPages().map((page) => [page.url, page] as const));
99
- const ordered: KnowledgePage[] = [];
100
- for (const url of urls) {
101
- const page = remaining.get(url);
102
- if (page) {
103
- ordered.push(page);
104
- remaining.delete(url);
105
- }
106
- }
107
- // A page the tree never displayed is still part of the record.
108
- return [...ordered, ...remaining.values()];
175
+ /**
176
+ * The pages the MACHINE surfaces admit — `llms.txt`, `llms-full.txt`, the
177
+ * twins: stable, effective, unexpired at the build's `as_of`, decided ONCE by
178
+ * staging and read back from its manifest (record spec §2.5). A route cannot
179
+ * widen this; it can only read it.
180
+ */
181
+ export function getMachinePages(): KnowledgePage[] {
182
+ return getSortedPages().filter((page) => stagePageOf(pathOf(page))?.machine === true);
109
183
  }
110
184
 
111
185
  /**
112
- * One document as the full-corpus file carries it: heading, then the record's
113
- * own governance as frontmatter, then the body.
186
+ * One document as the full-corpus file and its twin carry it: heading, then the
187
+ * record's OWN frontmatter intact under the build's stamps, then the record's
188
+ * OWN body.
114
189
  *
115
- * The frontmatter is the point. Without it this file served a superseded
116
- * document as clean prose, so a consumer ingesting the corpus answered from a
117
- * withdrawn policy with nothing in the bytes to say so (research/site-design.md
118
- * F1). `pages` resolves a successor pointer to the route a consumer can
119
- * actually fetch.
190
+ * The frontmatter is the point without it a consumer ingesting the corpus had
191
+ * no way to tell a passage's status, owner or source, and nothing connecting it
192
+ * to the publication it came from (R14) and both halves are served intact so
193
+ * that what a consumer parses is the profile's grammar rather than this shell's
194
+ * rendering of it. Body from the STAGE, never from fumadocs' processed
195
+ * markdown: see `stagedBody`, and the door, which serves these same bytes.
120
196
  */
121
- export async function getLLMText(
122
- page: KnowledgePage,
123
- pages: readonly KnowledgePage[] = [],
124
- ): Promise<string> {
125
- const processed = await page.data.getText("processed");
126
- const governance = readGovernance(page.data, page.path);
127
- const successor =
128
- governance.supersededBy === null
129
- ? null
130
- : resolveSuccessorUrl(governance.supersededBy, page.path, pages);
131
- const front = agentFrontmatter(governance, successor === null ? null : basePath + successor);
132
-
133
- // found live 2026-08-21: the processed markdown arrives with its own leading
134
- // blank lines, so every block opened with three of them — and adding the
135
- // frontmatter above made it four. One blank line between each part, always.
136
- return [`# ${page.data.title} (${basePath}${page.url})`, front.trimEnd(), processed.trimStart()]
197
+ export function getLLMText(page: KnowledgePage): string {
198
+ const body = stagedBody(page.path);
199
+ const front = agentFrontmatter(
200
+ stagedFrontmatter(page.path),
201
+ readGovernance(page.data, page.path),
202
+ readStageManifest().stamps,
203
+ );
204
+ // found live 2026-08-21: the body arrives with its own leading blank lines,
205
+ // so every block opened with three of them — and adding the frontmatter above
206
+ // made it four. One blank line between each part, always.
207
+ return [`# ${page.data.title} (${basePath}${page.url})`, front.trimEnd(), body.trimStart()]
137
208
  .filter((part) => part !== "")
138
209
  .join("\n\n");
139
210
  }
@@ -143,8 +214,8 @@ export interface RecordEntry {
143
214
  readonly url: string;
144
215
  readonly title: string;
145
216
  readonly description: string | null;
146
- /** The document's status when it is a caveat, else null. */
147
- readonly status: string | null;
217
+ /** Why the machine surfaces decline it, when they do; else null. */
218
+ readonly badge: LifecycleBadge | null;
148
219
  /**
149
220
  * Who stands behind it. Null when the record declares no owner — and null
150
221
  * for every document when `site.governance` is off, because an owner is a
@@ -155,104 +226,118 @@ export interface RecordEntry {
155
226
  readonly documents: number;
156
227
  }
157
228
 
158
- /**
159
- * The record's entry for one page — what any listing needs.
160
- *
161
- * Exported because the front door leads with the document `Open the record`
162
- * opens, which is the first page in governed order and may sit BELOW the top
163
- * level, where `entriesUnder(null)` would never return it.
164
- */
229
+ /** The record's entry for one page — what any listing needs. */
165
230
  export function entryFor(page: KnowledgePage): RecordEntry {
166
- const data: Record<string, unknown> = page.data as unknown as Record<string, unknown>;
167
- const description = typeof data["description"] === "string" ? data["description"].trim() : "";
168
- const status = typeof data["status"] === "string" ? data["status"].trim() : "";
169
231
  return {
170
232
  url: page.url,
171
233
  title: page.data.title,
172
- description: description === "" ? null : description,
173
- status: caveatStatus(status === "" ? null : status),
234
+ description: page.data.description?.trim() || null,
235
+ badge: stagePageOf(pathOf(page))?.badge ?? null,
174
236
  owner: showGovernance ? readGovernance(page.data, page.path).owner : null,
175
237
  documents: 0,
176
238
  };
177
239
  }
178
240
 
241
+ /** How many documents the stage holds under a bundle-relative directory. */
242
+ function countUnder(dir: string): number {
243
+ const prefix = `${dir}/`;
244
+ return Object.keys(readStageManifest().pages).filter((p) => p.startsWith(prefix)).length;
245
+ }
246
+
179
247
  /**
180
- * How many documents a folder holds, its own index page excluded — the index
181
- * IS the entry being counted, not something below it.
182
- *
183
- * By url in a set, not by adding lengths: whether a folder's index also
184
- * appears among its children is the loader's business, and counting it twice
185
- * would publish a number the record cannot support.
248
+ * The entries directly below a directory of the record (`""` for the root),
249
+ * in the governed reading order the folder's regenerated `index.md`,
250
+ * rendered. A folder this viewer's stage does not hold lists nothing.
186
251
  */
187
- function countDocuments(folder: Extract<Node, { type: "folder" }>): number {
188
- const urls = new Set<string>();
189
- const walk = (nodes: readonly Node[]): void => {
190
- for (const node of nodes) {
191
- if (node.type === "page") urls.add(node.url);
192
- else if (node.type === "folder") {
193
- if (node.index) urls.add(node.index.url);
194
- walk(node.children);
195
- }
252
+ export function entriesUnder(dir: string): RecordEntry[] {
253
+ const byUrl = new Map(source.getPages().map((page) => [page.url, page] as const));
254
+ return listingOf(dir, readStagedIndex(dir) ?? []).flatMap((item): RecordEntry[] => {
255
+ if (item.kind === "folder") {
256
+ return [
257
+ {
258
+ url: item.url,
259
+ title: item.title,
260
+ description: null,
261
+ badge: null,
262
+ owner: null,
263
+ documents: countUnder(item.path),
264
+ },
265
+ ];
196
266
  }
197
- };
198
- walk(folder.children);
199
- if (folder.index) urls.delete(folder.index.url);
200
- return urls.size;
267
+ const page = byUrl.get(item.url);
268
+ return page === undefined ? [] : [entryFor(page)];
269
+ });
201
270
  }
202
271
 
203
- /**
204
- * The entries directly below a node of the record, in the governed reading
205
- * order or the top level when `url` is null.
206
- *
207
- * A folder's own index page is not listed under itself: it IS the page doing
208
- * the listing. Without this, `/docs/policies` opened with a card pointing back
209
- * at `/docs/policies`.
210
- */
211
- export function entriesUnder(url: string | null): RecordEntry[] {
212
- const byUrl = new Map(getSortedPages().map((page) => [page.url, page] as const));
213
- const nodes = url === null ? getSortedPageTree().children : childrenOfFolder(url);
214
- const entries: RecordEntry[] = [];
215
- for (const node of nodes) {
216
- const target =
217
- node.type === "page" ? node.url : node.type === "folder" ? node.index?.url : null;
218
- if (target === undefined || target === null || target === url) continue;
219
- const page = byUrl.get(target);
220
- if (page === undefined) continue;
221
- entries.push(
222
- node.type === "folder"
223
- ? { ...entryFor(page), documents: countDocuments(node) }
224
- : entryFor(page),
225
- );
226
- }
227
- return entries;
272
+ /** The heading of a directory's page: the index's own H1, or its humanised name. */
273
+ export function folderHeading(dir: string): string {
274
+ const entries = readStagedIndex(dir) ?? [];
275
+ return (
276
+ entries[0]?.heading || (dir === "" ? appTitle : humanise(dir.slice(dir.lastIndexOf("/") + 1)))
277
+ );
278
+ }
279
+
280
+ /** Every directory the stage holds an index for, as `/docs/...` slugs — the folder routes to export. */
281
+ export function folderSlugs(): string[][] {
282
+ return [...stagedIndexes().keys()].map((dir) => (dir === "" ? [] : dir.split("/")));
283
+ }
284
+
285
+ /** Is this route a folder page in this viewer's stage? Returns its directory. */
286
+ export function folderOfRoute(url: string): string | null {
287
+ const dir = dirOfRoute(url);
288
+ return dir !== null && readStagedIndex(dir) !== null ? dir : null;
289
+ }
290
+
291
+ /** The stamp block, as the text artefacts print it: one `key: value` bullet per stamp. */
292
+ function stampBullets(): string[] {
293
+ return [`- name: ${appName}`, ...stampLines(readStageManifest().stamps).map((l) => `- ${l}`)];
228
294
  }
229
295
 
230
296
  /**
231
- * The record as `llms.txt` serves it: the instance name, then every document in
232
- * the governed reading order, each carrying its governance when the governance
233
- * is a caveat.
297
+ * The record as `llms.txt` serves it: the display title, the record's own
298
+ * description, the stamps that connect this file to one publication, then
299
+ * every MACHINE-admitted document in reading order.
234
300
  *
235
- * Here rather than in the route, because the home page shows these same bytes
301
+ * Here rather than in the route, because the home page shows the same index
236
302
  * to a reader. Two spellings of the record's index would be two indexes, and
237
303
  * the one on the page would be the one nobody checked.
238
304
  */
239
305
  export function recordIndexText(): string {
240
- const pages = getSortedPages();
241
- const lines = pages.map((page) => {
242
- const governance = readGovernance(page.data, page.path);
243
- const successor =
244
- governance.supersededBy === null
245
- ? null
246
- : resolveSuccessorUrl(governance.supersededBy, page.path, pages);
306
+ const lines = getMachinePages().map((page) => {
247
307
  const link = `- [${page.data.title}](${basePath}${page.url})`;
248
- const described = page.data.description ? `${link}: ${page.data.description}` : link;
249
- // The successor's route is prefixed like every other URL here, so the line
250
- // is usable as-is on a sub-path host.
251
- return (
252
- described + agentIndexSuffix(governance, successor === null ? null : basePath + successor)
253
- );
308
+ return page.data.description ? `${link}: ${page.data.description}` : link;
309
+ });
310
+ const head = [`# ${appTitle}`, ""];
311
+ if (appDescription !== null) head.push(`> ${appDescription.replace(/\s+/g, " ").trim()}`, "");
312
+ return `${[...head, ...stampBullets(), "", "## Documents", "", ...lines].join("\n")}\n`;
313
+ }
314
+
315
+ /**
316
+ * The record-root twin, `/md/index.md`: the index REGENERATED over the machine
317
+ * set with the record's own generator (OKF §8 form), under the stamps. A
318
+ * folder's index has no twin — its page is the listing — so this is the one
319
+ * index a consumer can fetch.
320
+ */
321
+ export function rootIndexTwin(): string {
322
+ const pages = getMachinePages();
323
+ const dirs = new Set<string>();
324
+ for (const page of pages) {
325
+ const parts = pathOf(page).split("/").slice(0, -1);
326
+ for (let i = 1; i <= parts.length; i += 1) dirs.add(parts.slice(0, i).join("/"));
327
+ }
328
+ const generated = generateIndexes({
329
+ title: appTitle,
330
+ concepts: pages.map((page) => ({
331
+ id: pathOf(page).replace(/\.md$/, ""),
332
+ title: page.data.title,
333
+ description: page.data.description ?? "",
334
+ order: typeof page.data.order === "number" ? page.data.order : null,
335
+ })),
336
+ dirs: [...dirs],
254
337
  });
255
- return `# ${appName}\n\n${lines.join("\n")}\n`;
338
+ const root = generated.get("index.md") ?? "";
339
+ const body = root.replace(/^---\n[\s\S]*?\n---\n\n?/, "");
340
+ return `---\nokf_version: "0.2"\n${stampLines(readStageManifest().stamps).join("\n")}\n---\n\n${body}`;
256
341
  }
257
342
 
258
343
  /**
@@ -268,37 +353,22 @@ export function markdownPath(url: string): string {
268
353
  return `${basePath}/md/${slug === "" ? "index" : slug}.md`;
269
354
  }
270
355
 
271
- /** The children of the folder whose index page is at `url`, or []. */
272
- function childrenOfFolder(url: string): Node[] {
273
- const find = (nodes: readonly Node[]): Node[] | null => {
274
- for (const node of nodes) {
275
- if (node.type !== "folder") continue;
276
- if (node.index?.url === url) return [...node.children];
277
- const deeper = find(node.children);
278
- if (deeper !== null) return deeper;
279
- }
280
- return null;
281
- };
282
- return find(getSortedPageTree().children) ?? [];
283
- }
284
-
285
356
  /**
286
- * Every document's caveat status, keyed by route — the small map the search
287
- * dialog needs on the client.
357
+ * Every page's badge label, keyed by route — the small map the search dialog
358
+ * needs on the client.
288
359
  *
289
360
  * Search was the last surface where a withdrawn document and the one that
290
361
  * replaced it looked identical, and its snippet quotes the withdrawn figure
291
362
  * (research/site-design.md F3). The dialog runs in the browser over a static
292
- * index that has no field for status, so the map travels to it as a prop
293
- * instead: a few dozen bytes per caveat document, and nothing at all for a
294
- * record whose documents are all approved.
363
+ * index that has no field for it, so the map travels as a prop instead: a few
364
+ * dozen bytes per badged document, and nothing at all for a record whose
365
+ * documents are all current.
295
366
  */
296
- export function caveatStatusByUrl(): Record<string, string> {
367
+ export function badgeByUrl(): Record<string, string> {
297
368
  const out: Record<string, string> = {};
298
369
  for (const page of source.getPages()) {
299
- const raw: unknown = (page.data as unknown as Record<string, unknown>)["status"];
300
- const status = caveatStatus(typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null);
301
- if (status !== null) out[page.url] = status;
370
+ const label = badgeLabel(stagePageOf(pathOf(page))?.badge ?? null);
371
+ if (label !== null) out[page.url] = label;
302
372
  }
303
373
  return out;
304
374
  }