@panaversity/ksor 0.0.20 → 0.0.21

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 (42) hide show
  1. package/CHANGELOG.md +472 -0
  2. package/dist/cli.mjs +71 -19
  3. package/package.json +3 -3
  4. package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
  5. package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
  6. package/templates/scaffold/AGENTS.md +52 -4
  7. package/templates/scaffold/instance.md +28 -20
  8. package/templates/scaffold/knowledge/governance-ladder.md +36 -0
  9. package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
  10. package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
  11. package/templates/scaffold/knowledge/surfaces/index.md +21 -0
  12. package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
  13. package/templates/scaffold/pnpm-lock.yaml +1198 -228
  14. package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
  15. package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
  16. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
  17. package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
  18. package/templates/scaffold/system/site/app/global.css +552 -9
  19. package/templates/scaffold/system/site/app/layout.tsx +23 -4
  20. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
  21. package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
  22. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
  23. package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
  24. package/templates/scaffold/system/site/components/governance.tsx +262 -0
  25. package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
  26. package/templates/scaffold/system/site/components/record-index.tsx +120 -0
  27. package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
  28. package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
  29. package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
  30. package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
  31. package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
  32. package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
  33. package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
  34. package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
  35. package/templates/scaffold/system/site/components.json +25 -0
  36. package/templates/scaffold/system/site/lib/governance.ts +432 -0
  37. package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
  38. package/templates/scaffold/system/site/lib/shared.ts +38 -0
  39. package/templates/scaffold/system/site/lib/source.ts +221 -5
  40. package/templates/scaffold/system/site/lib/utils.ts +6 -0
  41. package/templates/scaffold/system/site/package.json +9 -3
  42. package/templates/scaffold/knowledge/example.md +0 -23
@@ -1,8 +1,18 @@
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";
4
5
  import type { Node, Root } from "fumadocs-core/page-tree";
5
6
 
7
+ 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";
6
16
  import { orderValue } from "./order-rule";
7
17
  import { sortNodes } from "./page-order";
8
18
 
@@ -10,7 +20,20 @@ import { sortNodes } from "./page-order";
10
20
  export const source = loader({
11
21
  baseUrl: "/docs",
12
22
  source: docs.toFumadocsSource(),
13
- plugins: [lucideIconsPlugin()],
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
+ ],
14
37
  });
15
38
 
16
39
  export type KnowledgePage = (typeof source)["$inferPage"];
@@ -35,7 +58,13 @@ function orderOf(page: KnowledgePage): number {
35
58
  * state and mutating it would survive a hot reload.
36
59
  */
37
60
  export function getSortedPageTree(): Root {
38
- const orders = new Map(source.getPages().map((page) => [page.url, orderOf(page)] as const));
61
+ const pages = source.getPages();
62
+ const orders = new Map(pages.map((page) => [page.url, orderOf(page)] as const));
63
+ // The caveat status already rides the row: `statusBadgesPlugin` above put it
64
+ // there while the loader built the tree, so the reader sees it before the
65
+ // click rather than after (research/site-design.md F3). This function is
66
+ // left with the one thing the shell has no opinion about — the record's
67
+ // governed `order:`.
39
68
  const tree = source.getPageTree();
40
69
  return { ...tree, children: sortNodes(tree.children, orders, 0) };
41
70
  }
@@ -71,10 +100,197 @@ export function getSortedPages(): KnowledgePage[] {
71
100
  return [...ordered, ...remaining.values()];
72
101
  }
73
102
 
74
- export async function getLLMText(page: KnowledgePage): Promise<string> {
103
+ /**
104
+ * One document as the full-corpus file carries it: heading, then the record's
105
+ * own governance as frontmatter, then the body.
106
+ *
107
+ * The frontmatter is the point. Without it this file served a superseded
108
+ * document as clean prose, so a consumer ingesting the corpus answered from a
109
+ * withdrawn policy with nothing in the bytes to say so (research/site-design.md
110
+ * F1). `pages` resolves a successor pointer to the route a consumer can
111
+ * actually fetch.
112
+ */
113
+ export async function getLLMText(
114
+ page: KnowledgePage,
115
+ pages: readonly KnowledgePage[] = [],
116
+ ): Promise<string> {
75
117
  const processed = await page.data.getText("processed");
118
+ const governance = readGovernance(page.data, page.path);
119
+ const successor =
120
+ governance.supersededBy === null
121
+ ? null
122
+ : resolveSuccessorUrl(governance.supersededBy, page.path, pages);
123
+ const front = agentFrontmatter(governance, successor === null ? null : basePath + successor);
124
+
125
+ // found live 2026-08-21: the processed markdown arrives with its own leading
126
+ // blank lines, so every block opened with three of them — and adding the
127
+ // frontmatter above made it four. One blank line between each part, always.
128
+ return [`# ${page.data.title} (${basePath}${page.url})`, front.trimEnd(), processed.trimStart()]
129
+ .filter((part) => part !== "")
130
+ .join("\n\n");
131
+ }
132
+
133
+ /** One entry in a record listing — everything a reader needs to choose. */
134
+ export interface RecordEntry {
135
+ readonly url: string;
136
+ readonly title: string;
137
+ readonly description: string | null;
138
+ /** The document's status when it is a caveat, else null. */
139
+ readonly status: string | null;
140
+ /**
141
+ * Who stands behind it. Null when the record declares no owner — and null
142
+ * for every document when `site.governance` is off, because an owner is a
143
+ * governance fact and that key turns the pages plain.
144
+ */
145
+ readonly owner: string | null;
146
+ /** How many documents this entry holds below it; 0 for a leaf. */
147
+ readonly documents: number;
148
+ }
149
+
150
+ /**
151
+ * The record's entry for one page — what any listing needs.
152
+ *
153
+ * Exported because the front door leads with the document `Open the record`
154
+ * opens, which is the first page in governed order and may sit BELOW the top
155
+ * level, where `entriesUnder(null)` would never return it.
156
+ */
157
+ export function entryFor(page: KnowledgePage): RecordEntry {
158
+ const data: Record<string, unknown> = page.data as unknown as Record<string, unknown>;
159
+ const description = typeof data["description"] === "string" ? data["description"].trim() : "";
160
+ const status = typeof data["status"] === "string" ? data["status"].trim() : "";
161
+ return {
162
+ url: page.url,
163
+ title: page.data.title,
164
+ description: description === "" ? null : description,
165
+ status: caveatStatus(status === "" ? null : status),
166
+ owner: showGovernance ? readGovernance(page.data, page.path).owner : null,
167
+ documents: 0,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * How many documents a folder holds, its own index page excluded — the index
173
+ * IS the entry being counted, not something below it.
174
+ *
175
+ * By url in a set, not by adding lengths: whether a folder's index also
176
+ * appears among its children is the loader's business, and counting it twice
177
+ * would publish a number the record cannot support.
178
+ */
179
+ function countDocuments(folder: Extract<Node, { type: "folder" }>): number {
180
+ const urls = new Set<string>();
181
+ const walk = (nodes: readonly Node[]): void => {
182
+ for (const node of nodes) {
183
+ if (node.type === "page") urls.add(node.url);
184
+ else if (node.type === "folder") {
185
+ if (node.index) urls.add(node.index.url);
186
+ walk(node.children);
187
+ }
188
+ }
189
+ };
190
+ walk(folder.children);
191
+ if (folder.index) urls.delete(folder.index.url);
192
+ return urls.size;
193
+ }
194
+
195
+ /**
196
+ * The entries directly below a node of the record, in the governed reading
197
+ * order — or the top level when `url` is null.
198
+ *
199
+ * A folder's own index page is not listed under itself: it IS the page doing
200
+ * the listing. Without this, `/docs/policies` opened with a card pointing back
201
+ * at `/docs/policies`.
202
+ */
203
+ export function entriesUnder(url: string | null): RecordEntry[] {
204
+ const byUrl = new Map(getSortedPages().map((page) => [page.url, page] as const));
205
+ const nodes = url === null ? getSortedPageTree().children : childrenOfFolder(url);
206
+ const entries: RecordEntry[] = [];
207
+ for (const node of nodes) {
208
+ const target =
209
+ node.type === "page" ? node.url : node.type === "folder" ? node.index?.url : null;
210
+ if (target === undefined || target === null || target === url) continue;
211
+ const page = byUrl.get(target);
212
+ if (page === undefined) continue;
213
+ entries.push(
214
+ node.type === "folder"
215
+ ? { ...entryFor(page), documents: countDocuments(node) }
216
+ : entryFor(page),
217
+ );
218
+ }
219
+ return entries;
220
+ }
76
221
 
77
- return `# ${page.data.title} (${basePath}${page.url})
222
+ /**
223
+ * The record as `llms.txt` serves it: the instance name, then every document in
224
+ * the governed reading order, each carrying its governance when the governance
225
+ * is a caveat.
226
+ *
227
+ * Here rather than in the route, because the home page shows these same bytes
228
+ * to a reader. Two spellings of the record's index would be two indexes, and
229
+ * the one on the page would be the one nobody checked.
230
+ */
231
+ export function recordIndexText(): string {
232
+ const pages = getSortedPages();
233
+ const lines = pages.map((page) => {
234
+ const governance = readGovernance(page.data, page.path);
235
+ const successor =
236
+ governance.supersededBy === null
237
+ ? null
238
+ : resolveSuccessorUrl(governance.supersededBy, page.path, pages);
239
+ const link = `- [${page.data.title}](${basePath}${page.url})`;
240
+ const described = page.data.description ? `${link}: ${page.data.description}` : link;
241
+ // The successor's route is prefixed like every other URL here, so the line
242
+ // is usable as-is on a sub-path host.
243
+ return (
244
+ described + agentIndexSuffix(governance, successor === null ? null : basePath + successor)
245
+ );
246
+ });
247
+ return `# ${appName}\n\n${lines.join("\n")}\n`;
248
+ }
78
249
 
79
- ${processed}`;
250
+ /**
251
+ * The route of a document's markdown twin — `/docs/policies/terms` becomes
252
+ * `/md/policies/terms.md`, and the record's own index becomes `/md/index.md`.
253
+ *
254
+ * One rule, one place: the docs page derives the same address from its route
255
+ * params for `rel="alternate"`, and a second spelling of it here would be a
256
+ * broken link the day either changes.
257
+ */
258
+ export function markdownPath(url: string): string {
259
+ const slug = url.replace(/^\/docs\/?/, "").replace(/\/$/, "");
260
+ return `${basePath}/md/${slug === "" ? "index" : slug}.md`;
261
+ }
262
+
263
+ /** The children of the folder whose index page is at `url`, or []. */
264
+ function childrenOfFolder(url: string): Node[] {
265
+ const find = (nodes: readonly Node[]): Node[] | null => {
266
+ for (const node of nodes) {
267
+ if (node.type !== "folder") continue;
268
+ if (node.index?.url === url) return [...node.children];
269
+ const deeper = find(node.children);
270
+ if (deeper !== null) return deeper;
271
+ }
272
+ return null;
273
+ };
274
+ return find(getSortedPageTree().children) ?? [];
275
+ }
276
+
277
+ /**
278
+ * Every document's caveat status, keyed by route — the small map the search
279
+ * dialog needs on the client.
280
+ *
281
+ * Search was the last surface where a withdrawn document and the one that
282
+ * replaced it looked identical, and its snippet quotes the withdrawn figure
283
+ * (research/site-design.md F3). The dialog runs in the browser over a static
284
+ * index that has no field for status, so the map travels to it as a prop
285
+ * instead: a few dozen bytes per caveat document, and nothing at all for a
286
+ * record whose documents are all approved.
287
+ */
288
+ export function caveatStatusByUrl(): Record<string, string> {
289
+ const out: Record<string, string> = {};
290
+ for (const page of source.getPages()) {
291
+ const raw: unknown = (page.data as unknown as Record<string, unknown>)["status"];
292
+ const status = caveatStatus(typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null);
293
+ if (status !== null) out[page.url] = status;
294
+ }
295
+ return out;
80
296
  }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -2,18 +2,24 @@
2
2
  "name": "site",
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
+ "type": "module",
5
6
  "scripts": {
6
7
  "build": "next build",
7
8
  "dev": "next dev"
8
9
  },
9
10
  "dependencies": {
10
- "fumadocs-core": "16.10.3",
11
- "fumadocs-mdx": "15.0.13",
12
- "fumadocs-ui": "16.10.3",
11
+ "class-variance-authority": "0.7.1",
12
+ "clsx": "2.1.1",
13
+ "fumadocs-core": "16.14.5",
14
+ "fumadocs-mdx": "15.3.0",
15
+ "fumadocs-ui": "16.14.5",
13
16
  "lucide-react": "1.31.0",
14
17
  "next": "16.2.9",
18
+ "radix-ui": "1.6.7",
15
19
  "react": "19.2.8",
16
20
  "react-dom": "19.2.8",
21
+ "tailwind-merge": "3.6.0",
22
+ "tw-animate-css": "1.4.0",
17
23
  "zod": "4.4.3"
18
24
  },
19
25
  "devDependencies": {
@@ -1,23 +0,0 @@
1
- ---
2
- title: Your first governed document
3
- status: draft
4
- order: 1
5
- ---
6
-
7
- This file exists so the record is never empty: browse it with `pnpm dev`,
8
- then replace it with real knowledge.
9
-
10
- A governed document is plain markdown with a small frontmatter header. This
11
- one carries the two required keys — `title` and `status: draft`. As the
12
- knowledge matures, documents gain `owner` and `provenance` (who stands behind
13
- this, and which sources it came from), move to `status: approved`, and — when
14
- replaced — are marked `superseded`, never deleted.
15
-
16
- It also carries `order: 1`, which is how the record decides reading order: a
17
- document that declares `order` sorts ahead of every document that does not, so
18
- this one stays first in the sidebar, first in `llms.txt`, and the document the
19
- home page opens.
20
-
21
- Ask your coding agent to run the **intake interview** to define what this
22
- Knowledge System of Record is authoritative for, then start adding documents
23
- with the **add-sources** skill. `pnpm check` keeps every document honest.