@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.
- package/CHANGELOG.md +965 -0
- package/README.md +29 -9
- package/dist/checker/check-main.mjs +14049 -0
- package/dist/cli.mjs +11528 -5213
- package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
- package/dist/gateway.d.mts +52 -13
- package/dist/gateway.mjs +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
- package/docs/authorization.md +8 -6
- package/docs/deploying.md +36 -25
- package/docs/index.md +26 -13
- package/docs/ingesting.md +70 -22
- package/docs/tool-surface.md +69 -16
- package/package.json +4 -3
- package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
- package/schema/schema.sql +77 -14
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.github/workflows/validate.yml +9 -1
- package/templates/scaffold/.ksor/governance.yaml +17 -0
- package/templates/scaffold/AGENTS.md +293 -119
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +14 -6
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +11 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
- package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
- package/templates/scaffold/knowledge/surfaces/index.md +4 -20
- package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
- package/templates/scaffold/package.json +3 -4
- package/templates/scaffold/pnpm-lock.yaml +3 -0
- package/templates/scaffold/system/gateways/content.ts +13 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
- package/templates/scaffold/system/site/app/global.css +198 -6
- package/templates/scaffold/system/site/app/layout.tsx +8 -3
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
- package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
- package/templates/scaffold/system/site/components/code-block.tsx +87 -0
- package/templates/scaffold/system/site/components/embed.tsx +276 -0
- package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +219 -87
- package/templates/scaffold/system/site/components/mdx.tsx +10 -2
- package/templates/scaffold/system/site/components/record-index.tsx +5 -5
- package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
- package/templates/scaffold/system/site/components/record-views.tsx +6 -17
- package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
- package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
- package/templates/scaffold/system/site/lib/attachments.ts +0 -28
- package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
- package/templates/scaffold/system/site/lib/audience.ts +42 -146
- package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
- package/templates/scaffold/system/site/lib/governance.ts +339 -225
- package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
- package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
- package/templates/scaffold/system/site/lib/lock.ts +282 -0
- package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
- package/templates/scaffold/system/site/lib/record-href.ts +68 -0
- package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
- package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
- package/templates/scaffold/system/site/lib/shared.ts +67 -104
- package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +256 -186
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
- package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
- package/templates/scaffold/system/site/package.json +1 -0
- package/templates/scaffold/system/site/record/actor.ts +23 -0
- package/templates/scaffold/system/site/record/check.ts +571 -0
- package/templates/scaffold/system/site/record/citations.ts +312 -0
- package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
- package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
- package/templates/scaffold/system/site/record/hygiene.ts +320 -0
- package/templates/scaffold/system/site/record/index-file.ts +150 -0
- package/templates/scaffold/system/site/record/index.ts +103 -0
- package/templates/scaffold/system/site/record/instance.ts +257 -0
- package/templates/scaffold/system/site/record/instant.ts +43 -0
- package/templates/scaffold/system/site/record/ledger.ts +694 -0
- package/templates/scaffold/system/site/record/load.ts +129 -0
- package/templates/scaffold/system/site/record/lock.ts +306 -0
- package/templates/scaffold/system/site/record/near-miss.ts +37 -0
- package/templates/scaffold/system/site/record/policy.ts +414 -0
- package/templates/scaffold/system/site/record/profile.ts +535 -0
- package/templates/scaffold/system/site/record/refusal.ts +106 -0
- package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
- package/templates/scaffold/system/site/source.config.ts +109 -23
- package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
- package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
- package/templates/scaffold/system/site/lib/page-order.ts +0 -93
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hygiene rules the scaffold's hand-written checker carried and the
|
|
3
|
+
* profile does not state: portable names, what a file may be, attachment
|
|
4
|
+
* near-misses, image integrity, the instance's closed key set, and the shape
|
|
5
|
+
* of the project around the record. Ported here (record spec §6) so the
|
|
6
|
+
* emitted `check.mjs` is BUILT from one rule set instead of keeping a second
|
|
7
|
+
* one by hand — nothing the old checker refused may become accepted silently.
|
|
8
|
+
* Each rule keeps the scar that put it there.
|
|
9
|
+
*/
|
|
10
|
+
import { ATTACHMENT_SUFFIXES, nearMissOf } from "../lib/attachment-rule";
|
|
11
|
+
import { isSim, SIM_SUFFIX } from "../lib/sim-rule";
|
|
12
|
+
import type { Refusal } from "./refusal";
|
|
13
|
+
|
|
14
|
+
const KNOWLEDGE = "knowledge/";
|
|
15
|
+
const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
|
|
16
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
17
|
+
const COMPANION_SUFFIXES: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
|
|
18
|
+
/**
|
|
19
|
+
* The yaml companions, as the refusal's remedy names them. Derived, because a
|
|
20
|
+
* remedy that hand-lists the suffixes is one more copy of the rule to keep
|
|
21
|
+
* right — and a remedy naming three of five sends the author to fix a file
|
|
22
|
+
* that was already named correctly.
|
|
23
|
+
*/
|
|
24
|
+
const YAML_COMPANIONS: string = ATTACHMENT_SUFFIXES.filter((e) => e.suffix.endsWith(".yaml"))
|
|
25
|
+
.map((e) => `\`<doc>${e.suffix}\``)
|
|
26
|
+
.join(", ");
|
|
27
|
+
const RESERVED = new Set(["index.md", "log.md", "README.md"]);
|
|
28
|
+
|
|
29
|
+
export interface HygieneTree {
|
|
30
|
+
/** Record-relative paths of every `.md`/`.yaml` under `knowledge/`. */
|
|
31
|
+
readonly textPaths: readonly string[];
|
|
32
|
+
/** Record-relative paths of every other file under `knowledge/`, with its bytes. */
|
|
33
|
+
readonly assets: ReadonlyMap<string, Uint8Array>;
|
|
34
|
+
/** Record-relative directories under `knowledge/`. */
|
|
35
|
+
readonly dirs: readonly string[];
|
|
36
|
+
readonly symlinks: readonly string[];
|
|
37
|
+
/** Bundle-relative ids of the concepts the profile accepted. */
|
|
38
|
+
readonly conceptIds: ReadonlySet<string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function checkHygiene(tree: HygieneTree): Refusal[] {
|
|
42
|
+
const refusals: Refusal[] = [];
|
|
43
|
+
const documents = tree.textPaths.filter(
|
|
44
|
+
(p) =>
|
|
45
|
+
p.endsWith(".md") &&
|
|
46
|
+
!p.endsWith("/index.md") &&
|
|
47
|
+
!p.endsWith("/log.md") &&
|
|
48
|
+
!p.endsWith("/README.md") &&
|
|
49
|
+
!COMPANION_SUFFIXES.some((s) => p.endsWith(s)),
|
|
50
|
+
);
|
|
51
|
+
if (documents.length === 0) {
|
|
52
|
+
refusals.push({
|
|
53
|
+
slug: "ksor-record-empty",
|
|
54
|
+
path: KNOWLEDGE,
|
|
55
|
+
why: "the record has no concept — a KSoR is never empty; the site has nothing to render and the record stands behind nothing",
|
|
56
|
+
fix: "restore a document from git history, or add one: knowledge/<name>.md with the profile's frontmatter (record spec §2)",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
for (const path of tree.symlinks) {
|
|
60
|
+
refusals.push({
|
|
61
|
+
slug: "ksor-symlink",
|
|
62
|
+
path,
|
|
63
|
+
why: "the record must survive being copied anywhere — a symlink carries a machine-local path, and a dangling one is unreadable",
|
|
64
|
+
fix: "replace the link with the file it points at",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const everything = [...tree.textPaths, ...tree.assets.keys(), ...tree.dirs].sort();
|
|
69
|
+
const seenLower = new Map<string, string>();
|
|
70
|
+
for (const path of everything) {
|
|
71
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
72
|
+
// A reserved name (`README.md`) is refused as such; naming its case too would be two problems for one cause.
|
|
73
|
+
const unportable = RESERVED.has(base) ? null : nameProblem(base);
|
|
74
|
+
if (unportable !== null) {
|
|
75
|
+
refusals.push({
|
|
76
|
+
slug: "ksor-name-unportable",
|
|
77
|
+
path,
|
|
78
|
+
why: unportable,
|
|
79
|
+
fix: "use ascii lowercase letters, digits and hyphens; the title: key carries the document's real name in any language",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const lower = path.toLowerCase();
|
|
83
|
+
const earlier = seenLower.get(lower);
|
|
84
|
+
if (earlier !== undefined && earlier !== path) {
|
|
85
|
+
refusals.push({
|
|
86
|
+
slug: "ksor-name-collides",
|
|
87
|
+
path,
|
|
88
|
+
why: `collides with \`${earlier}\` on case-insensitive filesystems — two paths that are one file on macOS or Windows cannot both be the record`,
|
|
89
|
+
fix: "rename one of them",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
seenLower.set(lower, path);
|
|
93
|
+
}
|
|
94
|
+
const dirSet = new Set(tree.dirs);
|
|
95
|
+
for (const path of documents) {
|
|
96
|
+
const stem = path.slice(0, -".md".length);
|
|
97
|
+
if (dirSet.has(stem)) {
|
|
98
|
+
refusals.push({
|
|
99
|
+
slug: "ksor-name-collides",
|
|
100
|
+
path,
|
|
101
|
+
why: `\`${stem}/\` is a directory beside it — both map to the same route, so one identity has two documents`,
|
|
102
|
+
fix: "keep one: move the prose into the directory as a named concept, or rename the directory",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const typed = [...tree.textPaths.map((p) => [p, null] as const), ...tree.assets];
|
|
108
|
+
for (const [path, bytes] of typed) {
|
|
109
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
110
|
+
const near = nearMissOf(base);
|
|
111
|
+
if (near !== null) {
|
|
112
|
+
refusals.push({
|
|
113
|
+
slug: "ksor-attachment-near-miss",
|
|
114
|
+
path,
|
|
115
|
+
why: `\`${near.is}\` is not an attachment extension — the site reads decks as YAML and accepts only \`.yaml\`; a near miss is not picked up, and fails the build naming the path but not the rule`,
|
|
116
|
+
fix: `rename it to ${base.slice(0, -near.is.length)}${near.want}`,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const ext = base.includes(".") ? base.slice(base.lastIndexOf(".")) : "";
|
|
121
|
+
// A sim is admitted by its SUFFIX, never by its extension: `.html` in
|
|
122
|
+
// general stays refused, and only `<name>.sim.html` is a page the record
|
|
123
|
+
// carries (lib/sim-rule.ts).
|
|
124
|
+
const sim = isSim(base);
|
|
125
|
+
if (base.endsWith(".mdx")) {
|
|
126
|
+
refusals.push({
|
|
127
|
+
slug: "ksor-file-type",
|
|
128
|
+
path,
|
|
129
|
+
why: "MDX in the record — knowledge/ is CommonMark only; framework grammar breaks the walk-away promise",
|
|
130
|
+
fix: "convert to .md; components belong to the site, not the record",
|
|
131
|
+
});
|
|
132
|
+
} else if (base === "meta.json") {
|
|
133
|
+
refusals.push({
|
|
134
|
+
slug: "ksor-file-type",
|
|
135
|
+
path,
|
|
136
|
+
why: "a framework file in the record — knowledge/ is CommonMark only",
|
|
137
|
+
fix: "delete it — reading order is the `order` frontmatter key",
|
|
138
|
+
});
|
|
139
|
+
} else if (ext === ".yaml" && !COMPANION_SUFFIXES.some((s) => base.endsWith(s))) {
|
|
140
|
+
refusals.push({
|
|
141
|
+
slug: "ksor-file-type",
|
|
142
|
+
path,
|
|
143
|
+
why: "a YAML file that is no companion — the record holds concepts, their companions and images; other formats cannot be governed or rendered",
|
|
144
|
+
fix: `name it after its document (${YAML_COMPANIONS}) or move it out of knowledge/`,
|
|
145
|
+
});
|
|
146
|
+
} else if (bytes !== null && !sim && (ext === ".html" || ext === ".htm")) {
|
|
147
|
+
// Split out of the refusal below because `.html` is the one extension an
|
|
148
|
+
// author can get RIGHT and still have refused: a carried page is a real
|
|
149
|
+
// thing here, and the only thing separating it from a stray export is a
|
|
150
|
+
// marker nothing else would tell them about.
|
|
151
|
+
refusals.push({
|
|
152
|
+
slug: "ksor-file-type",
|
|
153
|
+
path,
|
|
154
|
+
why: `a page in the record that nothing can serve — a carried page is named \`<name>${SIM_SUFFIX}\`, and only that shape is published and framed`,
|
|
155
|
+
fix: `rename it to <name>${SIM_SUFFIX} and link it from its document as [label](<name>${SIM_SUFFIX} "embed"), or move it out of knowledge/`,
|
|
156
|
+
});
|
|
157
|
+
} else if (bytes !== null && !sim && !ASSET_EXTENSIONS.has(ext)) {
|
|
158
|
+
refusals.push({
|
|
159
|
+
slug: "ksor-file-type",
|
|
160
|
+
path,
|
|
161
|
+
why: `unexpected file type \`${ext || base}\` — the record holds markdown, images and carried pages; other formats cannot be governed or rendered`,
|
|
162
|
+
fix: "convert it to markdown (the add-sources skill does this) or move it out of knowledge/",
|
|
163
|
+
});
|
|
164
|
+
} else if (bytes !== null && ext === ".png") {
|
|
165
|
+
const broken = firstBrokenPngChunk(bytes);
|
|
166
|
+
if (broken !== null) {
|
|
167
|
+
refusals.push({
|
|
168
|
+
slug: "ksor-asset-corrupt",
|
|
169
|
+
path,
|
|
170
|
+
why: `corrupt PNG (${broken}) — a corrupt image can take the whole site down at build time with an error that never names this file (found live: one bad CRC 500'd every page)`,
|
|
171
|
+
fix: "re-export or re-download the image; the bytes on disk are damaged",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return refusals;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Why a base name is not a portable identity, or null when it is. */
|
|
180
|
+
function nameProblem(base: string): string | null {
|
|
181
|
+
if (/\s/.test(base))
|
|
182
|
+
return `"${base}" contains whitespace — spaces have to be escaped in every link`;
|
|
183
|
+
if (/[<>:"|?*]/.test(base) || /[. ]$/.test(base) || WINDOWS_RESERVED.test(base)) {
|
|
184
|
+
return `"${base}" is not a portable name — the path is the document's identity and its URL on every platform, and Windows rejects these characters, trailing dots and reserved device names outright`;
|
|
185
|
+
}
|
|
186
|
+
// A backslash is one character of a filename on Linux and the path SEPARATOR
|
|
187
|
+
// on Windows, so this is not a name that renders badly there — it is a name
|
|
188
|
+
// that cannot be checked out at all, and it takes the whole working tree
|
|
189
|
+
// down with it, not just this document. Split from the class above because
|
|
190
|
+
// the reason is different: those characters are rejected, this one is
|
|
191
|
+
// reinterpreted.
|
|
192
|
+
if (base.includes("\\")) {
|
|
193
|
+
return `"${base}" contains a backslash — it is a legal character in one Linux filename and the path separator on Windows, so a checkout there does not merely rename this document, it fails`;
|
|
194
|
+
}
|
|
195
|
+
if (/[A-Z]/.test(base)) {
|
|
196
|
+
return `"${base}" has uppercase — paths are identities; case-only differences collide on case-insensitive filesystems`;
|
|
197
|
+
}
|
|
198
|
+
// eslint-disable-next-line no-control-regex -- the point is the range
|
|
199
|
+
if (/[^\x20-\x7E]/.test(base)) {
|
|
200
|
+
return `"${base}" contains non-ASCII characters — site frameworks disagree on how to encode non-ASCII routes, so the same document gets a different address on each surface (found live: política.md exported two incompatible routes)`;
|
|
201
|
+
}
|
|
202
|
+
if (base.startsWith("_")) {
|
|
203
|
+
return `"${base}" is underscore-prefixed — site frameworks treat _files as hidden partials, and the record has no hidden documents`;
|
|
204
|
+
}
|
|
205
|
+
// The same rule as `_`, for the prefix that actually means hidden — and the
|
|
206
|
+
// one that was missing. VERIFIED: `knowledge/.secret.md` passed the whole
|
|
207
|
+
// checker with ZERO refusals and became a full concept with id `.secret`, so
|
|
208
|
+
// ingest gave it a node and the door served it. The site would not have had a
|
|
209
|
+
// route for it — its docs collection globs `**/*.md`, and picomatch 4.0.5
|
|
210
|
+
// does not match that against a dot-prefixed name (verified directly; that
|
|
211
|
+
// fumadocs' own walk passes no `dot: true` was NOT verified here). The rule
|
|
212
|
+
// does not rest on that half: the record has no hidden documents, which is
|
|
213
|
+
// what the `_` clause above says and what `.` means everywhere a directory is
|
|
214
|
+
// read. `..md` is the same rule reaching the degenerate name.
|
|
215
|
+
if (base.startsWith(".")) {
|
|
216
|
+
return `"${base}" is dot-prefixed, which is hidden on every platform that reads a directory — the record has no hidden documents, and one that is invisible to the site's file walk while the MCP door serves it publishes to a machine what it withholds from a person`;
|
|
217
|
+
}
|
|
218
|
+
// A path is also a URL, and `%` is what starts an escape in one. `50%-off.md`
|
|
219
|
+
// is a malformed escape that kills the site build with a bare URIError naming
|
|
220
|
+
// no file, and `50%20off.md` decodes to a DIFFERENT name — so the character
|
|
221
|
+
// that is supposed to make a name unambiguous in a URL is the one that gives
|
|
222
|
+
// this document two identities (found live, 2026-08-25).
|
|
223
|
+
if (base.includes("%")) {
|
|
224
|
+
return `"${base}" contains a percent sign — the path is the document's URL, where \`%\` opens an escape sequence, so this name means one thing on disk and another (or nothing at all) to anything that reads a route`;
|
|
225
|
+
}
|
|
226
|
+
if (/\(.*\)/.test(base)) {
|
|
227
|
+
return `"${base}" is parenthesized — renderers strip parenthesized segments from routes, giving one document two identities`;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const PNG_SIGNATURE: readonly number[] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
233
|
+
|
|
234
|
+
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
|
|
235
|
+
let c = n;
|
|
236
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
237
|
+
return c >>> 0;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
function crc32(bytes: Uint8Array, start: number, end: number): number {
|
|
241
|
+
let c = 0xffffffff;
|
|
242
|
+
for (let i = start; i < end; i += 1) {
|
|
243
|
+
c = ((CRC_TABLE[(c ^ (bytes[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8)) >>> 0;
|
|
244
|
+
}
|
|
245
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The first defect in a PNG file — signature and per-chunk CRC-32 — or null when every chunk checks out. */
|
|
249
|
+
export function firstBrokenPngChunk(bytes: Uint8Array): string | null {
|
|
250
|
+
if (bytes.length < 8 || PNG_SIGNATURE.some((b, i) => bytes[i] !== b)) return "bad signature";
|
|
251
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
252
|
+
let offset = 8;
|
|
253
|
+
while (offset + 12 <= bytes.length) {
|
|
254
|
+
const length = view.getUint32(offset);
|
|
255
|
+
const name = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
|
256
|
+
const dataEnd = offset + 8 + length;
|
|
257
|
+
if (dataEnd + 4 > bytes.length) return `truncated ${name} chunk`;
|
|
258
|
+
if (crc32(bytes, offset + 4, dataEnd) !== view.getUint32(dataEnd)) {
|
|
259
|
+
return `CRC error in ${name} chunk`;
|
|
260
|
+
}
|
|
261
|
+
if (name === "IEND") return null;
|
|
262
|
+
offset = dataEnd + 4;
|
|
263
|
+
}
|
|
264
|
+
return "missing IEND chunk";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface ScaffoldStructure {
|
|
268
|
+
/** `CLAUDE.md`'s text, or null when absent. */
|
|
269
|
+
readonly claudeMd: string | null;
|
|
270
|
+
/** Skill-relative path → content digest, under `.agents/skills`. */
|
|
271
|
+
readonly agentsSkills: ReadonlyMap<string, string>;
|
|
272
|
+
/** The same under `.claude/skills`. */
|
|
273
|
+
readonly claudeSkills: ReadonlyMap<string, string>;
|
|
274
|
+
/** Record-relative `.md`/`.mdx` files found inside `system/site` (build output excluded). */
|
|
275
|
+
readonly siteContentFiles: readonly string[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The project around the record: pointer intact, skill copies identical both ways, no content in the site. */
|
|
279
|
+
export function checkScaffoldStructure(shape: ScaffoldStructure): Refusal[] {
|
|
280
|
+
const refusals: Refusal[] = [];
|
|
281
|
+
if (shape.claudeMd === null || shape.claudeMd.trim() !== "@AGENTS.md") {
|
|
282
|
+
refusals.push({
|
|
283
|
+
slug: "ksor-pointer-changed",
|
|
284
|
+
path: "CLAUDE.md",
|
|
285
|
+
why: "AGENTS.md is the single contract; a pointer that grows content forks it",
|
|
286
|
+
fix: "restore CLAUDE.md to exactly one line: @AGENTS.md",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
for (const [rel, digest] of shape.agentsSkills) {
|
|
290
|
+
const twin = shape.claudeSkills.get(rel);
|
|
291
|
+
if (twin === digest) continue;
|
|
292
|
+
refusals.push({
|
|
293
|
+
slug: "ksor-skill-copy-diverged",
|
|
294
|
+
path: `.claude/skills/${rel}`,
|
|
295
|
+
why:
|
|
296
|
+
twin === undefined
|
|
297
|
+
? "the skill copy is missing — Claude Code reads .claude/skills, and a skill without its copy is invisible there"
|
|
298
|
+
: "the skill copy differs from the canonical .agents/skills version — two diverging copies means agents follow different rules by tool",
|
|
299
|
+
fix: `copy .agents/skills/${rel} over .claude/skills/${rel}`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
for (const rel of shape.claudeSkills.keys()) {
|
|
303
|
+
if (shape.agentsSkills.has(rel)) continue;
|
|
304
|
+
refusals.push({
|
|
305
|
+
slug: "ksor-skill-copy-diverged",
|
|
306
|
+
path: `.claude/skills/${rel}`,
|
|
307
|
+
why: "the file exists only under .claude/skills — .agents/skills is canonical, and anything only the copy carries is a rule that never went through review",
|
|
308
|
+
fix: `delete it, or add it to .agents/skills/${rel} and re-copy the tree`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
for (const path of shape.siteContentFiles) {
|
|
312
|
+
refusals.push({
|
|
313
|
+
slug: "ksor-site-holds-content",
|
|
314
|
+
path,
|
|
315
|
+
why: "the site renders the record; it never holds it — content here silently forks the record",
|
|
316
|
+
fix: "move the content to knowledge/ and delete this file",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return refusals.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
320
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated `index.md`, one per directory, in OKF §8 form (build spec §1
|
|
3
|
+
* step 1): a heading, then one bullet per child — concepts and folders in ONE
|
|
4
|
+
* reading order. Nothing here is authored — an index carries no governance, so
|
|
5
|
+
* anything written into one would be ungoverned knowledge on a served surface
|
|
6
|
+
* (research/okf-native.md §2 item 4). Every projection regenerates its indexes
|
|
7
|
+
* from the tree it was filtered to, so this function must be a pure function of
|
|
8
|
+
* that tree.
|
|
9
|
+
*
|
|
10
|
+
* The bullet order IS the site's reading order: `readingOrder` walks these
|
|
11
|
+
* bullets and every human surface ranks by that walk. So the order here and the
|
|
12
|
+
* order `ingest/adapters/plain-tree.ts` gives the MCP door's `outline` are one
|
|
13
|
+
* guarantee with two implementations — decision 18 — and both are asserted
|
|
14
|
+
* against `ORDER_CASES` through `lib/order-rule.ts`. They used to differ twice:
|
|
15
|
+
* folder bullets were all emitted AFTER the concept bullets (so nothing ever
|
|
16
|
+
* interleaved), and concept ties broke on the TITLE while the door broke them
|
|
17
|
+
* on the filename, which reordered every record that declares no `order:` at
|
|
18
|
+
* all. Both are gone; `compareSiblings` decides here as it does there.
|
|
19
|
+
*/
|
|
20
|
+
import { compareSiblings, folderOrder, tieKey, UNORDERED } from "../lib/order-rule";
|
|
21
|
+
|
|
22
|
+
export interface IndexConcept {
|
|
23
|
+
/** Bundle-relative id (path without `.md`). */
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
readonly order: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IndexInput {
|
|
31
|
+
/** The instance title — the root index's heading. */
|
|
32
|
+
readonly title: string;
|
|
33
|
+
readonly concepts: readonly IndexConcept[];
|
|
34
|
+
/** Every bundle-relative directory the walker found, empty ones included. */
|
|
35
|
+
readonly dirs: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One child of a directory, as the sort sees it: a concept bullet or a folder bullet. */
|
|
39
|
+
interface Child {
|
|
40
|
+
readonly order: number;
|
|
41
|
+
readonly tie: string;
|
|
42
|
+
readonly line: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Bundle-relative index path (`index.md`, `surfaces/index.md`) → bytes. Empty directories are absent. */
|
|
46
|
+
export function generateIndexes(input: IndexInput): Map<string, string> {
|
|
47
|
+
const byDir = new Map<string, IndexConcept[]>();
|
|
48
|
+
for (const c of input.concepts) {
|
|
49
|
+
const dir = dirOf(c.id);
|
|
50
|
+
byDir.set(dir, [...(byDir.get(dir) ?? []), c]);
|
|
51
|
+
}
|
|
52
|
+
// A directory earns an index when a concept lives anywhere beneath it.
|
|
53
|
+
const populated = new Set<string>([""]);
|
|
54
|
+
for (const dir of byDir.keys()) {
|
|
55
|
+
for (let d = dir; d !== ""; d = dirOf(d)) populated.add(d);
|
|
56
|
+
}
|
|
57
|
+
const dirs = new Set(input.dirs);
|
|
58
|
+
// The shape `folderOrder` folds over: one directory, the orders of the
|
|
59
|
+
// concepts sitting directly in it.
|
|
60
|
+
const ordersByDir: (readonly [string, readonly number[]])[] = [...byDir].map(
|
|
61
|
+
([d, cs]) => [d, cs.map(orderOf)] as const,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const out = new Map<string, string>();
|
|
65
|
+
for (const dir of populated) {
|
|
66
|
+
if (dir !== "" && !dirs.has(dir)) continue;
|
|
67
|
+
const children: Child[] = [];
|
|
68
|
+
for (const c of byDir.get(dir) ?? []) {
|
|
69
|
+
const name = baseOf(c.id);
|
|
70
|
+
children.push({
|
|
71
|
+
order: orderOf(c),
|
|
72
|
+
tie: tieKey(`${name}.md`),
|
|
73
|
+
line: `* [${c.title}](${name}.md) - ${c.description}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const d of populated) {
|
|
77
|
+
if (d === "" || dirOf(d) !== dir) continue;
|
|
78
|
+
const name = d.slice(dir === "" ? 0 : dir.length + 1);
|
|
79
|
+
children.push({
|
|
80
|
+
order: folderOrder(ordersByDir, d),
|
|
81
|
+
tie: tieKey(name),
|
|
82
|
+
line: `* [${humanise(name)}](${name}/)`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
children.sort(compareSiblings);
|
|
86
|
+
|
|
87
|
+
const lines = [
|
|
88
|
+
`# ${dir === "" ? input.title : humanise(dir.slice(dir.lastIndexOf("/") + 1))}`,
|
|
89
|
+
"",
|
|
90
|
+
...children.map((c) => c.line),
|
|
91
|
+
];
|
|
92
|
+
const body = `${lines.join("\n")}\n`;
|
|
93
|
+
out.set(
|
|
94
|
+
dir === "" ? "index.md" : `${dir}/index.md`,
|
|
95
|
+
dir === "" ? `${ROOT_FRONTMATTER}${body}` : body,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ROOT_FRONTMATTER = '---\nokf_version: "0.2"\n---\n\n';
|
|
102
|
+
|
|
103
|
+
/** `purchase-policies` → `Purchase policies`. */
|
|
104
|
+
export function humanise(name: string): string {
|
|
105
|
+
const words = name.replace(/[-_]+/g, " ").trim();
|
|
106
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function orderOf(c: IndexConcept): number {
|
|
110
|
+
return c.order ?? UNORDERED;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function dirOf(id: string): string {
|
|
114
|
+
const at = id.lastIndexOf("/");
|
|
115
|
+
return at === -1 ? "" : id.slice(0, at);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function baseOf(id: string): string {
|
|
119
|
+
return id.slice(id.lastIndexOf("/") + 1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface IndexEntry {
|
|
123
|
+
readonly heading: string;
|
|
124
|
+
readonly title: string;
|
|
125
|
+
readonly href: string;
|
|
126
|
+
readonly description: string | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The parse side of §8 — what any consumer, ours or a bare OKF reader, gets
|
|
131
|
+
* from an index: each bullet with the heading it sits under. Tolerates both
|
|
132
|
+
* bullet markers, an optional root frontmatter, and a missing description.
|
|
133
|
+
*/
|
|
134
|
+
export function parseIndex(text: string): IndexEntry[] {
|
|
135
|
+
const body = text.startsWith("---\n") ? text.slice(text.indexOf("\n---\n") + 5) : text;
|
|
136
|
+
const entries: IndexEntry[] = [];
|
|
137
|
+
let heading = "";
|
|
138
|
+
for (const line of body.split("\n")) {
|
|
139
|
+
const h = /^#\s+(.+?)\s*$/.exec(line);
|
|
140
|
+
if (h !== null) {
|
|
141
|
+
heading = h[1] ?? "";
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const b = /^[*-]\s+\[(.+)\]\(([^)\s]+)\)(?:\s+-\s+(.*))?\s*$/.exec(line);
|
|
145
|
+
if (b !== null) {
|
|
146
|
+
entries.push({ heading, title: b[1] ?? "", href: b[2] ?? "", description: b[3] ?? null });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record module as one surface (record spec §6; decision 26): the
|
|
3
|
+
* profile, the control files, the checker, the lock and the leaf rules — and
|
|
4
|
+
* nothing that touches a database or a provider. Published as the
|
|
5
|
+
* `@panaversity/ksor-content/record` subpath so the emitted checker bundles
|
|
6
|
+
* exactly this and not the kernel behind it (the first build that took the
|
|
7
|
+
* package root carried pg-pool into an adopter's `pnpm check`).
|
|
8
|
+
*/
|
|
9
|
+
export { splitFrontmatter, frontmatterText, normalizeText, type Split } from "./frontmatter";
|
|
10
|
+
export { parseInstant } from "./instant";
|
|
11
|
+
export {
|
|
12
|
+
REFUSAL_SLUGS,
|
|
13
|
+
sortRefusals,
|
|
14
|
+
formatRefusal,
|
|
15
|
+
type Refusal,
|
|
16
|
+
type RefusalSlug,
|
|
17
|
+
} from "./refusal";
|
|
18
|
+
export {
|
|
19
|
+
parseConcept,
|
|
20
|
+
conceptIdOf,
|
|
21
|
+
RESERVED_TYPES,
|
|
22
|
+
STATUSES,
|
|
23
|
+
LEGACY_KEYS,
|
|
24
|
+
TRUST_TIERS,
|
|
25
|
+
type Concept,
|
|
26
|
+
type ConceptResult,
|
|
27
|
+
type Status,
|
|
28
|
+
type TrustTier,
|
|
29
|
+
} from "./profile";
|
|
30
|
+
export {
|
|
31
|
+
parsePolicy,
|
|
32
|
+
resolveApprovers,
|
|
33
|
+
resolveOwner,
|
|
34
|
+
type Policy,
|
|
35
|
+
type PolicyResult,
|
|
36
|
+
} from "./policy";
|
|
37
|
+
export {
|
|
38
|
+
parseLedger,
|
|
39
|
+
inForce,
|
|
40
|
+
checkLedgerActors,
|
|
41
|
+
checkLedgerAgainstTree,
|
|
42
|
+
checkLedgerAppendOnly,
|
|
43
|
+
targetPresent,
|
|
44
|
+
expectedIn,
|
|
45
|
+
entryDigest,
|
|
46
|
+
ledgerDigests,
|
|
47
|
+
denies,
|
|
48
|
+
type Ledger,
|
|
49
|
+
type LedgerEntry,
|
|
50
|
+
type LedgerBaseline,
|
|
51
|
+
type LedgerBaselineEntry,
|
|
52
|
+
type Denial,
|
|
53
|
+
type Expected,
|
|
54
|
+
type Scope,
|
|
55
|
+
type TreeShape,
|
|
56
|
+
} from "./ledger";
|
|
57
|
+
export {
|
|
58
|
+
composeLock,
|
|
59
|
+
parseLock,
|
|
60
|
+
buildIdOf,
|
|
61
|
+
sha256Hex,
|
|
62
|
+
canonicalViewers,
|
|
63
|
+
admittedViewersOf,
|
|
64
|
+
OKF_PIN,
|
|
65
|
+
LOCK_FORMAT,
|
|
66
|
+
type Lock,
|
|
67
|
+
type LockDocument,
|
|
68
|
+
type LockInput,
|
|
69
|
+
type LockResult,
|
|
70
|
+
type BuildIdInputs,
|
|
71
|
+
type AdmissionConcept,
|
|
72
|
+
type Drafts,
|
|
73
|
+
} from "./lock";
|
|
74
|
+
export { git, historicLedger, type HistoricLedger } from "./git-ledger";
|
|
75
|
+
export { generateIndexes, parseIndex, humanise, type IndexInput } from "./index-file";
|
|
76
|
+
export { actorKind, isIndividualActor } from "./actor";
|
|
77
|
+
export { checkFootnotes, linkTargets, resolveLink } from "./citations";
|
|
78
|
+
export { checkRecord, type RecordFiles, type CheckOptions, type CheckResult } from "./check";
|
|
79
|
+
export { loadRecord, loadScaffoldStructure, resolveInstanceDir } from "./load";
|
|
80
|
+
// Its own line: the site's copy drops the `.js`, and one long export would then
|
|
81
|
+
// fit on a line the formatter collapses — drift the mirror test cannot allow.
|
|
82
|
+
export type { LoadedRecord } from "./load";
|
|
83
|
+
export {
|
|
84
|
+
checkHygiene,
|
|
85
|
+
checkScaffoldStructure,
|
|
86
|
+
firstBrokenPngChunk,
|
|
87
|
+
type HygieneTree,
|
|
88
|
+
type ScaffoldStructure,
|
|
89
|
+
} from "./hygiene";
|
|
90
|
+
export { overlaps, mayReach } from "../lib/audience-rule";
|
|
91
|
+
export {
|
|
92
|
+
admitsLifecycle,
|
|
93
|
+
lifecycleBadge,
|
|
94
|
+
type LifecycleBadge,
|
|
95
|
+
type LifecycleDoc,
|
|
96
|
+
type LifecycleStatus,
|
|
97
|
+
type Surface,
|
|
98
|
+
} from "../lib/lifecycle-rule";
|
|
99
|
+
// The decision TABLES are not re-exported here. This barrel is what the site
|
|
100
|
+
// copies and what the emitted checker bundles, and a table is a test fixture:
|
|
101
|
+
// shipping it would put the spec's rows in an adopter's site bundle. Tests
|
|
102
|
+
// import them from `../lib/*-conformance.js`, and the package index exports
|
|
103
|
+
// them for the suites in packages/ksor.
|