@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
@@ -46,6 +46,9 @@ importers:
46
46
  tw-animate-css:
47
47
  specifier: 1.4.0
48
48
  version: 1.4.0
49
+ yaml:
50
+ specifier: 2.9.0
51
+ version: 2.9.0
49
52
  zod:
50
53
  specifier: 4.4.3
51
54
  version: 4.4.3
@@ -32,6 +32,7 @@ import {
32
32
  readHandler,
33
33
  SEARCH_OUTPUT,
34
34
  searchHandler,
35
+ TRUST_TIERS,
35
36
  z,
36
37
  type ServiceContext,
37
38
  } from "@panaversity/ksor/gateway";
@@ -75,6 +76,18 @@ export default function buildGateway(ctx: ServiceContext, version: string): McpS
75
76
  .max(MAX_SEARCH_K)
76
77
  .default(10)
77
78
  .describe(`Maximum passages to return (1–${MAX_SEARCH_K})`),
79
+ // The caller's trust floor. Keep it: dropping the parameter does not
80
+ // weaken the record — the handler still applies `unverified` and this
81
+ // deployment's own floor — but it takes away the only way a caller can
82
+ // ask to be answered ONLY from what a human reviewed, and the door says
83
+ // so at boot instead of failing quietly.
84
+ min_trust_tier: z
85
+ .enum(TRUST_TIERS)
86
+ .optional()
87
+ .describe(
88
+ "Lowest trust tier to answer from (default unverified). This deployment's own " +
89
+ "floor still applies: it can only be raised here, never lowered.",
90
+ ),
78
91
  }),
79
92
  outputSchema: SEARCH_OUTPUT,
80
93
  annotations: READ_ONLY,
@@ -42,8 +42,8 @@ export default function HomePage(): ReactElement {
42
42
  // entries standing behind it. The lead is looked up among the top-level
43
43
  // entries so a folder keeps the count of what it holds, and falls back to
44
44
  // building an entry directly — the first document in governed order can sit
45
- // BELOW the top level, where `entriesUnder(null)` would never return it.
46
- const entries = entriesUnder(null);
45
+ // BELOW the top level, where the root's own listing would never return it.
46
+ const entries = entriesUnder("");
47
47
  const lead = entries.find((entry) => entry.url === first.url) ?? entryFor(first);
48
48
  const behind = entries.filter((entry) => entry.url !== lead.url).slice(0, 3);
49
49
 
@@ -1,4 +1,5 @@
1
1
  import { appName, mcpEndpoint, mcpNamespace, recordDescription, recordVersion } from "@/lib/shared";
2
+ import { readStageManifest } from "@/lib/stage-manifest";
2
3
 
3
4
  /**
4
5
  * `/.well-known/mcp/server.json` — how an agent DISCOVERS this record's MCP
@@ -16,6 +17,10 @@ import { appName, mcpEndpoint, mcpNamespace, recordDescription, recordVersion }
16
17
  * `name:` has no slash, so it was rejected outright.
17
18
  * version REQUIRED. Absent, the document failed validation on its own.
18
19
  * capabilities NOT a field in the schema; it was invented here.
20
+ * _meta the schema's extension point, namespaced by reverse DNS —
21
+ * where the build's stamps go (build spec §3, R14), so a
22
+ * validating client still accepts the document and a consumer
23
+ * can connect it to the publication it describes.
19
24
  *
20
25
  * (Checked against the 2025-12-11 schema, round-6 review of #43, which built
21
26
  * the scaffold and validated the emitted file.)
@@ -27,11 +32,16 @@ export const dynamic = "force-static";
27
32
 
28
33
  const SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
29
34
 
35
+ /** The `_meta` key the stamps live under: a namespace this project controls. */
36
+ const META_KEY = "com.panaversity.ksor/build";
37
+
30
38
  export function GET(): Response {
31
39
  const endpoint = mcpEndpoint();
40
+ const { build_id, source_commit, dirty, ksor_version, unstamped } = readStageManifest().stamps;
32
41
  return Response.json(
33
42
  {
34
43
  $schema: SCHEMA,
44
+ _meta: { [META_KEY]: { build_id, source_commit, dirty, ksor_version, unstamped } },
35
45
  name: `${mcpNamespace()}/${appName}`,
36
46
  // The record's OWN account of itself — see recordDescription. A
37
47
  // description identical in every ksor record cannot help an agent choose
@@ -1,19 +1,31 @@
1
- import { entriesUnder, getSortedPages, markdownPath, source } from "@/lib/source";
1
+ import {
2
+ basePath,
3
+ entriesUnder,
4
+ folderHeading,
5
+ folderOfRoute,
6
+ folderSlugs,
7
+ getSortedPages,
8
+ markdownPath,
9
+ source,
10
+ } from "@/lib/source";
2
11
  import { RecordIndex } from "@/components/record-index";
3
12
  import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
4
13
  import { TOCPopover, TOCProvider } from "fumadocs-ui/layouts/docs/page/slots/toc";
5
14
  import { notFound } from "next/navigation";
6
15
  import { getMDXComponents } from "@/components/mdx";
7
16
  import type { Metadata } from "next";
8
- import { createRelativeLink } from "fumadocs-ui/mdx";
17
+ import type { ReactElement } from "react";
18
+ import { recordLink } from "@/lib/record-link";
9
19
  import {
20
+ DeprecatedNotice,
10
21
  GovernanceMeta,
22
+ LifecycleCaveat,
11
23
  Provenance,
12
- SupersededNotice,
13
24
  type Successor,
14
25
  } from "@/components/governance";
15
26
  import { predecessorsOf, readGovernance, resolveSuccessorUrl } from "@/lib/governance";
16
27
  import { showGovernance } from "@/lib/shared";
28
+ import { stagePageOf } from "@/lib/stage-manifest";
17
29
  import { RecordBreadcrumb } from "@/components/record-breadcrumb";
18
30
  import { RecordToc, TocItems } from "@/components/record-toc";
19
31
  import { RecordViews } from "@/components/record-views";
@@ -24,10 +36,53 @@ import { StudyAids } from "@/components/study-aids";
24
36
  import { deckFor, quizFor, slidesFor, summaryFor } from "@/lib/attachments";
25
37
  import { readingMinutes } from "@/lib/reading-time";
26
38
 
39
+ /** The route a slug names: `/docs`, or `/docs/a/b`. */
40
+ function routeOf(slug: readonly string[] | undefined): string {
41
+ return slug === undefined || slug.length === 0 ? "/docs" : `/docs/${slug.join("/")}`;
42
+ }
43
+
44
+ /**
45
+ * Every page reads `rel="describedby"`: the record describes itself in
46
+ * `llms.txt`, and a consumer that follows the link learns what this record is
47
+ * authoritative for before it reads a page of it (build spec §3).
48
+ */
49
+ function DescribedBy(): ReactElement {
50
+ return <link rel="describedby" href={`${basePath}/llms.txt`} type="text/markdown" />;
51
+ }
52
+
53
+ /**
54
+ * A directory's page: the regenerated `index.md` of this viewer's stage,
55
+ * rendered as a listing. A folder has no body and no governance of its own
56
+ * (record spec §1) — it is admitted iff a descendant is, and its page is the
57
+ * list of what it holds. Excluded from the docs collection, so it has no
58
+ * twin, no `llms.txt` line and no search entry.
59
+ */
60
+ function FolderPage({ dir }: { dir: string }): ReactElement {
61
+ return (
62
+ <TocItems items={[]}>
63
+ <DocsPage
64
+ toc={[]}
65
+ slots={{
66
+ breadcrumb: RecordBreadcrumb,
67
+ toc: { provider: TOCProvider, main: RecordToc, popover: TOCPopover },
68
+ }}
69
+ >
70
+ <DescribedBy />
71
+ <DocsTitle>{folderHeading(dir)}</DocsTitle>
72
+ <RecordIndex entries={entriesUnder(dir)} heading="In this section" />
73
+ </DocsPage>
74
+ </TocItems>
75
+ );
76
+ }
77
+
27
78
  export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
28
79
  const params = await props.params;
29
80
  const page = source.getPage(params.slug);
30
- if (!page) notFound();
81
+ if (!page) {
82
+ const dir = folderOfRoute(routeOf(params.slug));
83
+ if (dir === null) notFound();
84
+ return <FolderPage dir={dir} />;
85
+ }
31
86
 
32
87
  const MDX = page.data.body;
33
88
  // Attachments of THIS document, found by suffix on its own path. Null is the
@@ -39,40 +94,35 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
39
94
  const presentation = slidesFor(page.path);
40
95
  // Counted at BUILD time from the document's own markdown, so the figure is in
41
96
  // the shipped HTML for a reader with a failed bundle, a crawler and an agent
42
- // alike. The predecessor measured the rendered DOM after paint, which put it
43
- // out of reach of all three.
97
+ // alike.
44
98
  const minutes = readingMinutes(await page.data.getText("processed"));
45
99
  const summaryMinutes =
46
100
  summary === null ? null : readingMinutes(await summary.getText("processed"));
47
101
  // What the record says about this document. The page renders it; it never
48
- // supplies it — an undeclared key shows nothing (specs/ksor/site-governance).
102
+ // supplies it — an undeclared key shows nothing.
49
103
  const governance = readGovernance(page.data, page.path);
104
+ // What staging decided about it: the badge, and whether a twin exists.
105
+ const staged = stagePageOf(page.path);
106
+ const badge = staged?.badge ?? null;
107
+ const twin = staged?.machine === true ? markdownPath(page.url) : undefined;
50
108
 
109
+ const allPages = getSortedPages();
51
110
  let successor: Successor | null = null;
52
111
  // Gated on the STATUS, not on the pointer: a document the record calls
53
- // current must never be published under a "Superseded" banner, whatever
54
- // stale successor pointer it still carries (`pnpm check` refuses that
55
- // combination too this is the second lock on the same door).
56
- if (governance.status === "superseded" && governance.supersededBy !== null) {
57
- const pages = getSortedPages();
58
- // Against page.path, not page.url: a route cannot tell a file from a
59
- // folder index, and `./terms.md` means a different document in each.
60
- const href = resolveSuccessorUrl(governance.supersededBy, page.path, pages);
61
- // Name the successor by its title, not by its path: the notice is for a
112
+ // current must never be published under a "Deprecated" banner, whatever
113
+ // stale successor pointer it still carries.
114
+ if (governance.status === "deprecated" && governance.supersededBy !== null) {
115
+ const href = resolveSuccessorUrl(governance.supersededBy, allPages);
116
+ // Name the successor by its title, not by its id: the notice is for a
62
117
  // reader, and the pointer is only the fallback when the route did not
63
118
  // resolve — never a dead link.
64
- const target = href === null ? undefined : pages.find((c) => c.url === href.split("#")[0]);
119
+ const target = href === null ? undefined : allPages.find((c) => c.url === href.split("#")[0]);
65
120
  successor = { href, label: target?.data.title ?? governance.supersededBy };
66
121
  }
67
122
 
68
123
  // What this document replaced, derived by asking every document in the record
69
124
  // where its successor pointer lands (research/site-design.md F4). No new
70
125
  // frontmatter key: the record already says it, in the other direction.
71
- // The same address `generateMetadata` advertises, shown to a person as well:
72
- // the human surface handing an agent the record's own bytes is a better
73
- // demonstration of the product than any copy on the home page.
74
- const markdownUrl = markdownPath(page.url);
75
- const allPages = getSortedPages();
76
126
  const replaces = predecessorsOf(
77
127
  page.url,
78
128
  allPages,
@@ -91,16 +141,9 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
91
141
  toc={page.data.toc}
92
142
  full={page.data.full}
93
143
  // The rail is ours; the provider and the small-screen popover stay the
94
- // shell's. Its own rail marks a heading active when 90% of it is visible
95
- // anywhere in the viewport and then highlights whichever became active
96
- // last, which on this record's short-sectioned documents ran two to four
97
- // headings AHEAD of the reader. The observer's options are not
98
- // configurable and the observer is not exported, so the selection could
99
- // only be replaced — `slots.toc.main` is the seam for that.
100
- // The breadcrumb is ours for one reason, recorded in the component:
101
- // the shell's renders nothing at all on a top-level document, so the
102
- // block above the title came and went as a reader moved through the
103
- // record.
144
+ // shell's (its own rail ran two to four headings ahead of the reader on
145
+ // this record's short-sectioned documents). The breadcrumb is ours
146
+ // because the shell's renders nothing on a top-level document.
104
147
  slots={{
105
148
  breadcrumb: RecordBreadcrumb,
106
149
  toc: {
@@ -110,83 +153,68 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
110
153
  },
111
154
  }}
112
155
  // The table-of-contents column is HELD on every page, including the many
113
- // in a governed record that have no headings at all and render nothing
114
- // into it. Collapsing it for those documents (which this file did, from
115
- // 2026-08-21) bought a wider column at the price of a moving one: the
116
- // article is centred in whatever the column leaves, so the prose jumped
117
- // 134px sideways between a document with headings and one without
118
- // (measured at 1728px: text at x=446 against x=580). A reader clicking
119
- // through a record saw the page slide under them.
120
- //
121
- // Held, the grid is `0 | 268 | main | 268 | 0` — sidebar and rail the
122
- // same width, so the main column is centred in the viewport and the
123
- // reading measure capped in global.css sits centred inside it, in the
124
- // same place on every document. The rail is the natural home for the
125
- // governance facts this record already carries; until it holds them it
126
- // is quiet space on the side, which is what the earlier collapse was
127
- // really objecting to — the 900px measure beside it, since fixed.
156
+ // in a governed record that have no headings, so the prose sits in the
157
+ // same place on every document (measured 134px of drift without it).
128
158
  >
129
- {successor === null ? null : <SupersededNotice successor={successor} />}
159
+ <DescribedBy />
160
+ {governance.status === "deprecated" ? <DeprecatedNotice successor={successor} /> : null}
130
161
  <DocsTitle>{page.data.title}</DocsTitle>
131
162
  <DocsDescription>{page.data.description}</DocsDescription>
132
163
  {showGovernance ? (
133
- <GovernanceMeta governance={governance} replaces={replaces} markdownUrl={markdownUrl} />
134
- ) : null}
135
- {/* grow-0, against the shell's own `flex-1`: the article is a flex column
136
- stretched to the viewport, so the body inflated from ~150px of text to
137
- 402px and pushed Sources and everything after it to the bottom of the
138
- screen a governance block floating 400px below the document it
139
- describes (measured, 2026-08-21). Short documents now end where their
140
- text ends. */}
164
+ <GovernanceMeta
165
+ governance={governance}
166
+ badge={badge}
167
+ replaces={replaces}
168
+ markdownUrl={twin}
169
+ // Only when there is no view strip to carry it — with a summary,
170
+ // the number belongs to the view the reader picked, not to the
171
+ // document as a whole.
172
+ minutes={summary === null ? minutes : undefined}
173
+ />
174
+ ) : (
175
+ // …and with the strip off, the CAVEAT still shows. `site.governance`
176
+ // hides attribution, not the record's word on whether this document
177
+ // is in force — which the sidebar, the folder listing and the search
178
+ // result all keep saying about this same page (`plainBadge`).
179
+ <LifecycleCaveat badge={badge} effectiveFrom={governance.effectiveFrom} />
180
+ )}
181
+ {/* grow-0, against the shell's own `flex-1`: short documents end where
182
+ their text ends rather than pushing Sources to the bottom of the
183
+ screen (measured, 2026-08-21). */}
141
184
  <DocsBody style={{ flexGrow: 0 }}>
142
185
  {/* The summary panel is built HERE, on the server, and handed to the
143
186
  client tab strip as a prop — so it is in the shipped HTML whether or
144
- not the bundle runs, which is what an agent parsing the page and a
145
- reader with a failed bundle both depend on. Presence-driven: with no
146
- summary, RecordViews renders the body alone and no tab strip exists
147
- (specs/ksor/study-attachments C3, C20). */}
187
+ not the bundle runs. Presence-driven: with no summary, RecordViews
188
+ renders the body alone and no tab strip exists. */}
148
189
  <RecordViews
149
190
  documentMinutes={minutes}
150
191
  summaryMinutes={summaryMinutes ?? undefined}
151
192
  summary={
152
193
  Summary === null ? null : (
153
- <Summary components={getMDXComponents({ a: createRelativeLink(source, page) })} />
194
+ <Summary components={getMDXComponents({ a: recordLink(source, page) })} />
154
195
  )
155
196
  }
156
197
  >
157
198
  <MDX
158
199
  components={getMDXComponents({
159
- // relative links between documents in knowledge/ resolve to
160
- // their rendered pages
161
- a: createRelativeLink(source, page),
200
+ // links between documents in knowledge/, in both OKF forms,
201
+ // resolve to their rendered pages
202
+ a: recordLink(source, page),
162
203
  // The deck, rendered where the record's own shape puts it:
163
- // after the introduction, before the first section. The
164
- // rehype plugin marks the place on every document and this
165
- // decides whether there is anything to put there — so "does
166
- // this document have a teaching aid" stays one question,
167
- // answered by the attachment, not two.
204
+ // after the introduction, before the first section.
168
205
  TeachingAid: () =>
169
206
  presentation === null ? null : <Slides slides={presentation} />,
170
207
  })}
171
208
  />
172
209
  </RecordViews>
173
210
  </DocsBody>
174
- {/* What a reader DOES with this document once they have read it. One
175
- region, so the quiz that will sit beside the deck is a child here and
176
- not a new argument about where it goes. Renders nothing at all when
177
- the document has no study aids. */}
178
- {/* Recall first, then the check on it — and each renders only if the
179
- document carries one, so a page with just a quiz shows just a quiz. */}
211
+ {/* What a reader DOES with this document once they have read it.
212
+ Renders nothing at all when the document has no study aids. */}
180
213
  <StudyAids>
181
214
  {deck === null ? null : <Flashcards deck={deck} />}
182
215
  {quiz === null ? null : <Quiz quiz={quiz} />}
183
216
  </StudyAids>
184
- {/* A folder's index page lists what the folder holds. Without it the
185
- page ended at its own sentence and the documents below it were
186
- reachable only from the sidebar (research/site-design.md F5). Empty
187
- for a leaf document, which renders nothing. */}
188
- <RecordIndex entries={entriesUnder(page.url)} heading="In this section" />
189
- {showGovernance ? <Provenance entries={governance.provenance} /> : null}
217
+ {showGovernance ? <Provenance entries={governance.sources} /> : null}
190
218
  </DocsPage>
191
219
  </TocItems>
192
220
  );
@@ -194,29 +222,44 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
194
222
 
195
223
  export async function generateStaticParams() {
196
224
  const params = source.generateParams();
197
- if (params.length === 0) {
225
+ // Every directory this viewer's stage holds an index for is a page too —
226
+ // the root included, which is the record's own map.
227
+ const folders = folderSlugs().map((slug) => ({ slug }));
228
+ if (params.length === 0 && folders.length === 0) {
198
229
  // Without this, Next fails the empty-record build with an error that
199
230
  // names neither the record nor the rule (found live, 2026-08-18).
231
+ //
232
+ // NOT the same state as "this build publishes no document". The emitted
233
+ // starter is all drafts by design (R25), and build spec §4 acceptance 4
234
+ // requires it to BUILD and publish none of them — so a stage with the root
235
+ // index and no page is a record waiting for its first approval, and
236
+ // throwing here failed `pnpm build` on every freshly scaffolded project
237
+ // (found live 2026-08-25 through the scaffold e2e). What is left is the
238
+ // genuinely impossible state: no page AND no index, which means the stage
239
+ // itself is missing.
200
240
  throw new Error(
201
- "the record has no documents a KSoR is never empty; add one to knowledge/ or restore one from git history (pnpm check says the same).",
241
+ "the staged record holds neither a document nor a folder index the stage is missing or empty; run `ksor build` and check knowledge/ (`pnpm check` says the same).",
202
242
  );
203
243
  }
204
- return params;
244
+ return [...params, ...folders];
205
245
  }
206
246
 
207
247
  export async function generateMetadata(props: PageProps<"/docs/[[...slug]]">): Promise<Metadata> {
208
248
  const params = await props.params;
209
249
  const page = source.getPage(params.slug);
210
- if (!page) notFound();
211
-
212
- // The markdown twin, advertised rather than left to be guessed: a consumer
213
- // that follows `rel="alternate"` reaches the record's own bytes instead of
214
- // scraping this page (research/site-design.md F2).
215
- const markdownUrl = markdownPath(page.url);
250
+ if (!page) {
251
+ const dir = folderOfRoute(routeOf(params.slug));
252
+ if (dir === null) notFound();
253
+ return { title: folderHeading(dir) };
254
+ }
216
255
 
256
+ // The markdown twin, advertised rather than left to be guessed — and only
257
+ // where one exists: a page the machine surfaces decline has no twin, and
258
+ // advertising one would be a dead link (build spec §3).
259
+ const twin = stagePageOf(page.path)?.machine === true;
217
260
  return {
218
261
  title: page.data.title,
219
262
  description: page.data.description,
220
- alternates: { types: { "text/markdown": markdownUrl } },
263
+ ...(twin ? { alternates: { types: { "text/markdown": markdownPath(page.url) } } } : {}),
221
264
  };
222
265
  }