@panaversity/ksor 0.0.20 → 0.0.22
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 +498 -0
- package/dist/cli.mjs +71 -19
- package/package.json +3 -3
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/AGENTS.md +52 -4
- package/templates/scaffold/gitignore +3 -0
- package/templates/scaffold/instance.md +28 -20
- package/templates/scaffold/knowledge/governance-ladder.md +36 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
- package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
- package/templates/scaffold/knowledge/surfaces/index.md +21 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
- package/templates/scaffold/pnpm-lock.yaml +1198 -228
- package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
- package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
- package/templates/scaffold/system/site/app/global.css +552 -9
- package/templates/scaffold/system/site/app/layout.tsx +23 -4
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
- package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
- package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
- package/templates/scaffold/system/site/components/governance.tsx +262 -0
- package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
- package/templates/scaffold/system/site/components/record-index.tsx +120 -0
- package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
- package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
- package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
- package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
- package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
- package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
- package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
- package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
- package/templates/scaffold/system/site/components.json +25 -0
- package/templates/scaffold/system/site/lib/governance.ts +432 -0
- package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
- package/templates/scaffold/system/site/lib/shared.ts +38 -0
- package/templates/scaffold/system/site/lib/source.ts +221 -5
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +193 -40
- package/templates/scaffold/system/site/lib/utils.ts +6 -0
- package/templates/scaffold/system/site/package.json +9 -3
- 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: [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
copyFileSync,
|
|
3
|
+
existsSync,
|
|
3
4
|
mkdirSync,
|
|
4
5
|
readFileSync,
|
|
5
6
|
readdirSync,
|
|
6
7
|
rmSync,
|
|
7
8
|
statSync,
|
|
8
9
|
watch,
|
|
10
|
+
writeFileSync,
|
|
9
11
|
} from "node:fs";
|
|
10
12
|
import path from "node:path";
|
|
11
13
|
|
|
@@ -343,47 +345,191 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
|
|
|
343
345
|
return { files: [...documents, ...assets], documents: documents.length, total };
|
|
344
346
|
}
|
|
345
347
|
|
|
348
|
+
/** How often a waiter looks again. */
|
|
349
|
+
const LOCK_POLL_MS = 25;
|
|
350
|
+
/** How long a wait goes unexplained. A build that looks hung must say why. */
|
|
351
|
+
const LOCK_ANNOUNCE_MS = 10_000;
|
|
352
|
+
|
|
353
|
+
/** Synchronous, because everything on this path is: a bundler cannot await. */
|
|
354
|
+
function sleepSync(ms: number): void {
|
|
355
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isAlive(pid: number): boolean {
|
|
359
|
+
try {
|
|
360
|
+
process.kill(pid, 0);
|
|
361
|
+
return true;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// EPERM is a process that exists and is not ours to signal.
|
|
364
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Is this lock abandoned — stamped with a process that no longer exists?
|
|
370
|
+
*
|
|
371
|
+
* Blank is the one ambiguous read: the holder writes its pid in the same call
|
|
372
|
+
* that creates the file, so a blank lock is either a holder caught between the
|
|
373
|
+
* two (microseconds) or one that died there (forever). Looking twice tells
|
|
374
|
+
* them apart, and only the second look may break a lock.
|
|
375
|
+
*/
|
|
376
|
+
function lockIsAbandoned(lockFile: string): boolean {
|
|
377
|
+
for (const look of [0, 1]) {
|
|
378
|
+
let stamp: string;
|
|
379
|
+
try {
|
|
380
|
+
stamp = readFileSync(lockFile, "utf8").trim();
|
|
381
|
+
} catch {
|
|
382
|
+
// Released while we read it; the next acquire attempt takes it.
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
const pid = Number(stamp);
|
|
386
|
+
if (Number.isInteger(pid) && pid > 0) return !isAlive(pid);
|
|
387
|
+
if (look === 0) sleepSync(LOCK_POLL_MS * 2);
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Hold the stage lock for the duration of `work`: ONE evaluation writes the
|
|
394
|
+
* stage at a time, and this file says which.
|
|
395
|
+
*
|
|
396
|
+
* A build evaluates `source.config.ts` in more than one process — SEVEN of
|
|
397
|
+
* them staged the record in one measured `next build` of a scaffolded site
|
|
398
|
+
* (2026-08-23) — and staging was destructive on every evaluation: delete the
|
|
399
|
+
* whole stage, refill it. Two of those overlapping is not a rare interleaving,
|
|
400
|
+
* it is what seven of them do — six concurrent evaluations of a 150-document
|
|
401
|
+
* record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of `copyFileSync` (the reported one,
|
|
402
|
+
* issue #100), `ENOTEMPTY` out of `rmSync` *with* its retries already in
|
|
403
|
+
* place, and — 27 of the 48, the majority — no error at all: staging returned
|
|
404
|
+
* success and handed the build a stage a third of the record short.
|
|
405
|
+
*
|
|
406
|
+
* The silent shape is why this is a lock and not another retry. A crash fails
|
|
407
|
+
* a build; a short stage PUBLISHES one, with documents missing from /docs,
|
|
408
|
+
* llms.txt and the search index, and nothing anywhere saying so.
|
|
409
|
+
*
|
|
410
|
+
* `wx` is the whole primitive: create-if-absent, atomically, on every
|
|
411
|
+
* filesystem Node supports — and it stamps the holder's pid in the same call,
|
|
412
|
+
* so a waiter can tell a live holder from a killed one.
|
|
413
|
+
*
|
|
414
|
+
* Waiting on a LIVE holder is unbounded on purpose: it is another evaluation
|
|
415
|
+
* of the same build, staging the same bytes from the same record, and this
|
|
416
|
+
* build is not finished until it has. Unbounded is not silent, though — a wait
|
|
417
|
+
* long enough to look like a hang names what it is waiting for.
|
|
418
|
+
*/
|
|
419
|
+
function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
420
|
+
const lockFile = `${stageDir}.lock`;
|
|
421
|
+
let waited = 0;
|
|
422
|
+
let announced = false;
|
|
423
|
+
for (;;) {
|
|
424
|
+
try {
|
|
425
|
+
writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
426
|
+
break;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
429
|
+
if (lockIsAbandoned(lockFile)) {
|
|
430
|
+
rmSync(lockFile, { force: true });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
sleepSync(LOCK_POLL_MS);
|
|
434
|
+
waited += LOCK_POLL_MS;
|
|
435
|
+
if (waited >= LOCK_ANNOUNCE_MS && !announced) {
|
|
436
|
+
announced = true;
|
|
437
|
+
console.warn(
|
|
438
|
+
`[ksor] waiting on ${path.basename(lockFile)} — another evaluation of this build is ` +
|
|
439
|
+
"staging the record. Delete that file if no build is running.",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
return work();
|
|
446
|
+
} finally {
|
|
447
|
+
rmSync(lockFile, { force: true });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
346
451
|
/**
|
|
347
452
|
* Remove the stage, asking for the retries this exact failure needs.
|
|
348
453
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* the same bytes.
|
|
454
|
+
* Callers hold the stage lock, so no OTHER evaluation is writing here — but
|
|
455
|
+
* `force: true` suppresses ENOENT and does NOT retry anything, and Node
|
|
456
|
+
* retries EBUSY / EMFILE / ENFILE / ENOTEMPTY / EPERM only when `maxRetries`
|
|
457
|
+
* is set (it defaults to zero). Those are what a Windows indexer or an
|
|
458
|
+
* antivirus scanner holding a handle looks like — not ksor, and not something
|
|
459
|
+
* the lock can serialise. Losing that race is safe: the stage is a
|
|
460
|
+
* deterministic function of the record and the denylist, so redoing it
|
|
461
|
+
* produces the same bytes.
|
|
358
462
|
*/
|
|
359
463
|
function removeStage(stageDir: string): void {
|
|
360
464
|
rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
361
465
|
}
|
|
362
466
|
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
);
|
|
467
|
+
/**
|
|
468
|
+
* Does the stage already hold EXACTLY this plan, byte for byte?
|
|
469
|
+
*
|
|
470
|
+
* The wipe-and-refill is the destructive half of staging, and it is pure waste
|
|
471
|
+
* whenever the answer is yes — which is every evaluation after the first in
|
|
472
|
+
* one build, since the plan is a deterministic function of the record and the
|
|
473
|
+
* denylist. Skipping it is not an optimisation: while a wipe is running there
|
|
474
|
+
* is a window in which the stage is not the record, and an evaluation that has
|
|
475
|
+
* already returned is reading it. The lock stops two writers colliding; this
|
|
476
|
+
* stops the second writer existing at all.
|
|
477
|
+
*
|
|
478
|
+
* Bytes, not names and not timestamps: the alternative is serving a previous
|
|
479
|
+
* build's copy of a document that has since been edited.
|
|
480
|
+
*/
|
|
481
|
+
function stageHolds(recordDir: string, stageDir: string, plan: StagePlan): boolean {
|
|
482
|
+
let staged: string[];
|
|
483
|
+
try {
|
|
484
|
+
staged = walkFiles(stageDir);
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
381
487
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
488
|
+
if (staged.length !== plan.files.length) return false;
|
|
489
|
+
const expected = new Map(
|
|
490
|
+
plan.files.map((from) => [path.join(stageDir, path.relative(recordDir, from)), from]),
|
|
491
|
+
);
|
|
492
|
+
for (const file of staged) {
|
|
493
|
+
const from = expected.get(file);
|
|
494
|
+
if (from === undefined) return false;
|
|
495
|
+
if (!readFileSync(from).equals(readFileSync(file))) return false;
|
|
386
496
|
}
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Fill a clean stage with exactly the set this build may publish. */
|
|
501
|
+
function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
|
|
502
|
+
withStageLock(stageDir, () => {
|
|
503
|
+
let plan: StagePlan;
|
|
504
|
+
try {
|
|
505
|
+
plan = planStage(recordDir, denied);
|
|
506
|
+
// An empty record is its own problem, reported by the page that renders
|
|
507
|
+
// it; an empty AUDIENCE is a misconfiguration that would otherwise
|
|
508
|
+
// surface as "the record has no documents" against a record full of them.
|
|
509
|
+
if (plan.documents === 0 && plan.total > 0) {
|
|
510
|
+
refuse(
|
|
511
|
+
"ksor-audience-empty",
|
|
512
|
+
`no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
|
|
513
|
+
"a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this audience's slice of it is",
|
|
514
|
+
"build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
} catch (error) {
|
|
518
|
+
// No refusal may leave the previous, more permissive stage on disk: it
|
|
519
|
+
// hands the next careless build a filtered copy nothing governs (review
|
|
520
|
+
// finding, 2026-08-19). The removal used to lead this function, which is
|
|
521
|
+
// why nothing could ask whether the stage was already correct.
|
|
522
|
+
removeStage(stageDir);
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
if (stageHolds(recordDir, stageDir, plan)) return;
|
|
526
|
+
removeStage(stageDir);
|
|
527
|
+
for (const from of plan.files) {
|
|
528
|
+
const to = path.join(stageDir, path.relative(recordDir, from));
|
|
529
|
+
mkdirSync(path.dirname(to), { recursive: true });
|
|
530
|
+
copyFileSync(from, to);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
387
533
|
}
|
|
388
534
|
|
|
389
535
|
/**
|
|
@@ -421,13 +567,17 @@ function refuseVisibilityWithoutAudiences(recordDir: string): void {
|
|
|
421
567
|
* published build is always staged from scratch.
|
|
422
568
|
*/
|
|
423
569
|
function refreshStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
570
|
+
// Under the lock like every other write here: a save landing while another
|
|
571
|
+
// evaluation is refilling the stage is the same race from the other side.
|
|
572
|
+
withStageLock(stageDir, () => {
|
|
573
|
+
const permitted = new Set(planStage(recordDir, denied).files);
|
|
574
|
+
for (const staged of walkFiles(stageDir)) {
|
|
575
|
+
const from = path.join(recordDir, path.relative(stageDir, staged));
|
|
576
|
+
if (!permitted.has(from)) continue;
|
|
577
|
+
if (readFileSync(from).equals(readFileSync(staged))) continue;
|
|
578
|
+
copyFileSync(from, staged);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
431
581
|
}
|
|
432
582
|
|
|
433
583
|
let watching = false;
|
|
@@ -484,8 +634,11 @@ export function knowledgeSourceDir(): string {
|
|
|
484
634
|
// Nothing to filter — serve the record itself, the level-0 fast path.
|
|
485
635
|
// A stage left behind by an earlier model would be a filtered copy of the
|
|
486
636
|
// record nothing governs any more — removed before the refusal below can
|
|
487
|
-
// throw, so a refused build never leaves one behind either.
|
|
488
|
-
|
|
637
|
+
// throw, so a refused build never leaves one behind either. Under the lock,
|
|
638
|
+
// because two evaluations removing one tree is the `ENOTEMPTY` shape of the
|
|
639
|
+
// same race; the existence check keeps a record that never stages from
|
|
640
|
+
// taking a lock on every build.
|
|
641
|
+
if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
|
|
489
642
|
refuseVisibilityWithoutAudiences(recordDir);
|
|
490
643
|
return RECORD_DIR;
|
|
491
644
|
}
|
|
@@ -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
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"fumadocs-
|
|
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.
|