@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
|
@@ -1,54 +1,87 @@
|
|
|
1
1
|
// What the record says about a document, projected for rendering.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// A concept carries the profile's governance (record spec §2) — status, type,
|
|
4
|
+
// `ksor.owner`, `sources`, `ksor.effective_from`, `stale_after`,
|
|
5
|
+
// `ksor.superseded_by`, the approval and the verifications — and this module
|
|
6
|
+
// is the one projection of those keys the pages and the agent surfaces share.
|
|
6
7
|
// Provenance is load-bearing: a reader has to be able to see who stands behind
|
|
7
8
|
// a document and where it came from, or the site is showing them prose while
|
|
8
9
|
// the agent surface answers with citations.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
// (packages/ksor/src/site-governance.test.ts) without a site install.
|
|
12
|
-
// needing the Fumadocs loader — resolving a successor
|
|
11
|
+
// Free of VALUE imports on purpose: this is the pure half, so it is unit-tested
|
|
12
|
+
// directly (packages/ksor/src/site-governance.test.ts) without a site install.
|
|
13
|
+
// Anything needing the Fumadocs loader — resolving a successor to its route —
|
|
13
14
|
// lives outside it.
|
|
14
15
|
//
|
|
15
|
-
//
|
|
16
|
+
// The one import is a TYPE, and it alone carries the `.js` that the package's
|
|
17
|
+
// Node-ESM program wants, while every other module here is extensionless
|
|
18
|
+
// because Turbopack resolves neither form onto a `.ts` file. `import type` is
|
|
19
|
+
// erased before any bundler sees it, so the two rules never meet on this line.
|
|
20
|
+
|
|
21
|
+
import type { LifecycleBadge } from "./lifecycle-rule.js";
|
|
22
|
+
|
|
23
|
+
export interface Source {
|
|
24
|
+
readonly id: string | null;
|
|
25
|
+
readonly title: string | null;
|
|
26
|
+
/** A URL, a bundle path, or a scope descriptor (OKF §5.1). */
|
|
27
|
+
readonly resource: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Act {
|
|
31
|
+
readonly by: string;
|
|
32
|
+
/** The instant as the record wrote it. */
|
|
33
|
+
readonly at: string;
|
|
34
|
+
}
|
|
16
35
|
|
|
17
36
|
export interface DocumentGovernance {
|
|
18
|
-
/** `draft` | `
|
|
37
|
+
/** `draft` | `stable` | `deprecated` (record spec §2.2). Null only when a document skipped it. */
|
|
19
38
|
readonly status: string | null;
|
|
20
|
-
|
|
39
|
+
readonly type: string | null;
|
|
40
|
+
/** Who stands behind this document: `ksor.owner`. */
|
|
21
41
|
readonly owner: string | null;
|
|
22
42
|
/** One entry per source — a citation must be able to point at exactly one of them. */
|
|
23
|
-
readonly
|
|
24
|
-
/**
|
|
25
|
-
readonly
|
|
26
|
-
|
|
43
|
+
readonly sources: readonly Source[];
|
|
44
|
+
/** `ksor.effective_from`, as the record wrote it. */
|
|
45
|
+
readonly effectiveFrom: string | null;
|
|
46
|
+
readonly staleAfter: string | null;
|
|
47
|
+
/** The successor's concept id, e.g. `policies/purchase-approval-v2`. */
|
|
27
48
|
readonly supersededBy: string | null;
|
|
49
|
+
readonly approval: Act | null;
|
|
50
|
+
readonly deprecated: Act | null;
|
|
51
|
+
readonly verified: readonly Act[];
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
/**
|
|
31
|
-
* A declared
|
|
55
|
+
* A declared scalar, or null. Blank and whitespace-only count as undeclared: a
|
|
32
56
|
* key an author started and left empty is not a governance fact.
|
|
33
57
|
*
|
|
34
|
-
* An unquoted
|
|
35
|
-
*
|
|
58
|
+
* An unquoted instant parses to a Date in the collection's YAML, so dates
|
|
59
|
+
* normalize to ISO here — rendering the object would print a locale- and
|
|
36
60
|
* timezone-dependent string into the record.
|
|
37
61
|
*/
|
|
38
62
|
function declared(value: unknown): string | null {
|
|
39
|
-
// A bare `effective: 2026` types as a NUMBER in YAML and used to disappear
|
|
40
|
-
// from the page entirely — the record declared it and the page said nothing.
|
|
41
|
-
// `pnpm check` refuses it now; showing what the author wrote is still the
|
|
42
|
-
// honest fallback for a record that skipped the checker.
|
|
43
63
|
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
|
|
44
64
|
if (value instanceof Date) {
|
|
45
|
-
return Number.isNaN(value.getTime()) ? null :
|
|
65
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
46
66
|
}
|
|
47
67
|
if (typeof value !== "string") return null;
|
|
48
68
|
const trimmed = value.trim();
|
|
49
69
|
return trimmed === "" ? null : trimmed;
|
|
50
70
|
}
|
|
51
71
|
|
|
72
|
+
function mapping(value: unknown): Record<string, unknown> {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
74
|
+
? (value as Record<string, unknown>)
|
|
75
|
+
: {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function act(value: unknown): Act | null {
|
|
79
|
+
const m = mapping(value);
|
|
80
|
+
const by = declared(m["by"]);
|
|
81
|
+
const at = declared(m["at"]);
|
|
82
|
+
return by === null || at === null ? null : { by, at };
|
|
83
|
+
}
|
|
84
|
+
|
|
52
85
|
/**
|
|
53
86
|
* The document's governance, exactly as it declares it.
|
|
54
87
|
*
|
|
@@ -63,93 +96,128 @@ export function readGovernance(data: unknown, where: string): DocumentGovernance
|
|
|
63
96
|
// frontmatter, which is whatever the author wrote, and a shell that crashes
|
|
64
97
|
// on a shape the checker would have named is worse than one that renders
|
|
65
98
|
// what it can.
|
|
66
|
-
const record
|
|
67
|
-
|
|
99
|
+
const record = mapping(data);
|
|
100
|
+
const ksor = mapping(record["ksor"]);
|
|
68
101
|
|
|
69
102
|
const status = declared(record["status"]);
|
|
70
|
-
const supersededBy = declared(
|
|
103
|
+
const supersededBy = declared(ksor["superseded_by"]);
|
|
71
104
|
|
|
72
|
-
// Defense in depth:
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
if (status === "superseded" && supersededBy === null) {
|
|
105
|
+
// Defense in depth: the checker refuses a deprecated concept with no
|
|
106
|
+
// attribution. Failing the build is the honest outcome — the alternative is
|
|
107
|
+
// serving a document that says it was withdrawn and cannot say who did it.
|
|
108
|
+
if (status === "deprecated" && act(ksor["deprecated"]) === null) {
|
|
77
109
|
throw new Error(
|
|
78
|
-
`${where} is status:
|
|
79
|
-
"
|
|
80
|
-
"
|
|
110
|
+
`${where} is status: deprecated with no ksor.deprecated — a document the record withdrew ` +
|
|
111
|
+
"must say who withdrew it and when, or the reader is told to stop trusting it by nobody. " +
|
|
112
|
+
"Add ksor.deprecated: { by, at } (pnpm check refuses this too).",
|
|
81
113
|
);
|
|
82
114
|
}
|
|
83
115
|
|
|
84
|
-
// A
|
|
85
|
-
// unexplained build failure hides the real message `pnpm check`
|
|
86
|
-
const raw: unknown = record["
|
|
87
|
-
const
|
|
88
|
-
? raw.
|
|
116
|
+
// A malformed sources list is a checker finding, not a crash: turning one
|
|
117
|
+
// into an unexplained build failure hides the real message `pnpm check` prints.
|
|
118
|
+
const raw: unknown = record["sources"];
|
|
119
|
+
const sources = Array.isArray(raw)
|
|
120
|
+
? raw.flatMap((entry): Source[] => {
|
|
121
|
+
const m = mapping(entry);
|
|
122
|
+
const resource = declared(m["resource"]);
|
|
123
|
+
return resource === null
|
|
124
|
+
? []
|
|
125
|
+
: [{ id: declared(m["id"]), title: declared(m["title"]), resource }];
|
|
126
|
+
})
|
|
89
127
|
: [];
|
|
128
|
+
// OKF §5.2: a bare `verified` mapping is a one-element list.
|
|
129
|
+
const verifiedRaw: unknown = record["verified"];
|
|
130
|
+
const verified = (Array.isArray(verifiedRaw) ? verifiedRaw : [verifiedRaw])
|
|
131
|
+
.map(act)
|
|
132
|
+
.filter((entry): entry is Act => entry !== null);
|
|
90
133
|
|
|
91
134
|
return {
|
|
92
135
|
status,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
type: declared(record["type"]),
|
|
137
|
+
owner: declared(ksor["owner"]),
|
|
138
|
+
sources,
|
|
139
|
+
effectiveFrom: declared(ksor["effective_from"]),
|
|
140
|
+
staleAfter: declared(record["stale_after"]),
|
|
96
141
|
supersededBy,
|
|
142
|
+
approval: act(ksor["approval"]),
|
|
143
|
+
deprecated: act(ksor["deprecated"]),
|
|
144
|
+
verified,
|
|
97
145
|
};
|
|
98
146
|
}
|
|
99
147
|
|
|
148
|
+
/** The three tiers, in the order OKF ranks them. */
|
|
149
|
+
export type TrustTier = "unverified" | "machine-confirmed" | "human-reviewed";
|
|
150
|
+
|
|
151
|
+
/** Trust tier derives from `verified` (record spec §2.3): none, machine only, or any human. */
|
|
152
|
+
export function trustTierOf(verified: readonly Act[]): TrustTier {
|
|
153
|
+
if (verified.length === 0) return "unverified";
|
|
154
|
+
return verified.some((v) => v.by.startsWith("human:")) ? "human-reviewed" : "machine-confirmed";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The tier, and the verification that put the document there. */
|
|
158
|
+
export interface TrustSignal {
|
|
159
|
+
readonly tier: TrustTier;
|
|
160
|
+
/** Null at `unverified`, where by definition nobody has said anything. */
|
|
161
|
+
readonly by: string | null;
|
|
162
|
+
readonly at: string | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The tier a page shows, WITH its verifier — "human-reviewed" alone is a claim
|
|
167
|
+
* with nobody behind it, and provenance is load-bearing.
|
|
168
|
+
*
|
|
169
|
+
* The verification named is the latest one OF THE KIND THAT SET THE TIER: the
|
|
170
|
+
* tier keys on the `human:` prefix (record spec §2.3), so a nightly machine
|
|
171
|
+
* pass running after a human review must not end up credited with the review.
|
|
172
|
+
* A tier is never inferred from anything but `verified`, and `unverified` is
|
|
173
|
+
* the honest state a stable, approved, unreviewed concept sits at — reported,
|
|
174
|
+
* not hidden (research/okf-native.md §1.1).
|
|
175
|
+
*/
|
|
176
|
+
export function trustSignal(verified: readonly Act[]): TrustSignal {
|
|
177
|
+
const tier = trustTierOf(verified);
|
|
178
|
+
if (tier === "unverified") return { tier, by: null, at: null };
|
|
179
|
+
const deciding =
|
|
180
|
+
tier === "human-reviewed" ? verified.filter((v) => v.by.startsWith("human:")) : verified;
|
|
181
|
+
// Latest by instant; an unparsable `at` (which the checker refuses) keeps
|
|
182
|
+
// declaration order rather than crashing the page it was handed.
|
|
183
|
+
let latest = deciding[0] as Act;
|
|
184
|
+
for (const entry of deciding.slice(1)) {
|
|
185
|
+
const a = Date.parse(entry.at);
|
|
186
|
+
const b = Date.parse(latest.at);
|
|
187
|
+
if (!Number.isNaN(a) && (Number.isNaN(b) || a >= b)) latest = entry;
|
|
188
|
+
}
|
|
189
|
+
return { tier, by: latest.by, at: latest.at };
|
|
190
|
+
}
|
|
191
|
+
|
|
100
192
|
/** One document as the loader reports it: its source path, and its route. */
|
|
101
193
|
export interface RecordPage {
|
|
102
|
-
/** Path under `knowledge/`, e.g. `legal.md` or `
|
|
194
|
+
/** Path under `knowledge/`, e.g. `legal.md` or `policies/terms.md`. */
|
|
103
195
|
readonly path: string;
|
|
104
196
|
/** The route it renders at, e.g. `/docs/legal`. */
|
|
105
197
|
readonly url: string;
|
|
106
198
|
}
|
|
107
199
|
|
|
108
|
-
/**
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
// separators included (the loader reports whatever the filesystem gave it).
|
|
112
|
-
const segments = from.replaceAll("\\", "/").split("/").slice(0, -1);
|
|
113
|
-
for (const part of relative.split("/")) {
|
|
114
|
-
if (part === "" || part === ".") continue;
|
|
115
|
-
if (part === "..") segments.pop();
|
|
116
|
-
else segments.push(part);
|
|
117
|
-
}
|
|
118
|
-
return segments.join("/");
|
|
200
|
+
/** The concept id of a page: its path without `.md`, forward slashes. */
|
|
201
|
+
export function conceptIdOfPath(pagePath: string): string {
|
|
202
|
+
return pagePath.replaceAll("\\", "/").replace(/\.mdx?$/, "");
|
|
119
203
|
}
|
|
120
204
|
|
|
121
205
|
/**
|
|
122
|
-
* The route a `superseded_by` pointer names, or null.
|
|
123
|
-
*
|
|
124
|
-
* Resolved against the document's SOURCE PATH, never against its route. A
|
|
125
|
-
* route cannot tell `knowledge/legal.md` from `knowledge/handbook/index.md` —
|
|
126
|
-
* both render at one path segment — yet `./terms.md` means a sibling in the
|
|
127
|
-
* first and a folder child in the second. Resolving on routes had to guess,
|
|
128
|
-
* and refused to link a record the checker calls well-formed (found live,
|
|
129
|
-
* 2026-08-20: `legal.md` pointing at `./terms.md` beside a `legal/terms.md`
|
|
130
|
-
* rendered the raw pointer instead of a link).
|
|
206
|
+
* The route a `ksor.superseded_by` pointer names, or null.
|
|
131
207
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
208
|
+
* The pointer is a CONCEPT ID — bundle-relative, `.md` optional (record spec
|
|
209
|
+
* §2, `ksor.superseded_by: policies/purchase-approval-v2`) — resolved against
|
|
210
|
+
* the pages in THIS build. Null means the successor is not here: legitimate
|
|
211
|
+
* for a per-viewer build, which stages a subset, and the caller then shows the
|
|
212
|
+
* pointer as text. A dead link on a deprecation notice is the worst outcome:
|
|
135
213
|
* it tells the reader to stop trusting the page and then strands them.
|
|
136
214
|
*/
|
|
137
|
-
export function resolveSuccessorUrl(
|
|
138
|
-
pointer
|
|
139
|
-
currentPath: string,
|
|
140
|
-
pages: readonly RecordPage[],
|
|
141
|
-
): string | null {
|
|
142
|
-
// Leaves the record: an absolute URL or a site-absolute path is not a
|
|
143
|
-
// pointer into knowledge/ at all.
|
|
144
|
-
if (pointer.includes("://") || pointer.startsWith("/")) return null;
|
|
145
|
-
|
|
215
|
+
export function resolveSuccessorUrl(pointer: string, pages: readonly RecordPage[]): string | null {
|
|
216
|
+
if (pointer.includes("://") || pointer.startsWith("/") || pointer.startsWith(".")) return null;
|
|
146
217
|
const [target = "", anchor] = pointer.split("#", 2);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const resolved = resolveFrom(currentPath, target);
|
|
150
|
-
const match = pages.find((page) => page.path.replaceAll("\\", "/") === resolved);
|
|
218
|
+
const id = target.replace(/\.md$/, "");
|
|
219
|
+
const match = pages.find((page) => conceptIdOfPath(page.path) === id);
|
|
151
220
|
if (match === undefined) return null;
|
|
152
|
-
|
|
153
221
|
return anchor === undefined ? match.url : `${match.url}#${anchor}`;
|
|
154
222
|
}
|
|
155
223
|
|
|
@@ -158,9 +226,7 @@ export function resolveSuccessorUrl(
|
|
|
158
226
|
*
|
|
159
227
|
* `2026-06-31` and `2026-13-45` both match a `\d{4}-\d{2}-\d{2}` shape, and
|
|
160
228
|
* stamping either into `<time datetime>` publishes a day that does not exist:
|
|
161
|
-
* invalid HTML, and a consumer parsing it gets July 1st.
|
|
162
|
-
* refuses those unquoted and offers QUOTING as the escape hatch — which is
|
|
163
|
-
* exactly how one reaches this function (found 2026-08-21).
|
|
229
|
+
* invalid HTML, and a consumer parsing it gets July 1st.
|
|
164
230
|
*/
|
|
165
231
|
export function isCalendarDate(value: string): boolean {
|
|
166
232
|
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
@@ -174,6 +240,11 @@ export function isCalendarDate(value: string): boolean {
|
|
|
174
240
|
);
|
|
175
241
|
}
|
|
176
242
|
|
|
243
|
+
/** The calendar day of an instant, for a reader: `2026-09-01T00:00:00Z` → `2026-09-01`. */
|
|
244
|
+
export function dayOf(instant: string): string {
|
|
245
|
+
return instant.split("T")[0] ?? instant;
|
|
246
|
+
}
|
|
247
|
+
|
|
177
248
|
/**
|
|
178
249
|
* Whether this record's site shows the governance it declares — the
|
|
179
250
|
* `site.governance` key in instance.md, default **on**:
|
|
@@ -181,118 +252,175 @@ export function isCalendarDate(value: string): boolean {
|
|
|
181
252
|
* site:
|
|
182
253
|
* governance: false
|
|
183
254
|
*
|
|
184
|
-
* The record often wants
|
|
185
|
-
*
|
|
255
|
+
* The record often wants owners and sources filled in for the agent surface
|
|
256
|
+
* and the audit trail while the published page stays plain. That is a
|
|
186
257
|
* publication choice, so it belongs to the instance, not to each document —
|
|
187
258
|
* per-document control is already the frontmatter itself (declare a key and it
|
|
188
259
|
* shows; leave it off and nothing does).
|
|
189
260
|
*
|
|
190
261
|
* Default on, and additive: every record written before this key existed keeps
|
|
191
|
-
* rendering exactly as it did. Turning it off never hides the
|
|
262
|
+
* rendering exactly as it did. Turning it off never hides the DEPRECATION
|
|
192
263
|
* notice — that is a correctness warning, not decoration, and a reader handed a
|
|
193
264
|
* replaced document with no word of its successor has been misled.
|
|
194
265
|
*
|
|
195
|
-
* Takes the frontmatter
|
|
196
|
-
* site binds it once in lib/shared.ts.
|
|
266
|
+
* Takes the parsed instance frontmatter (not a path) so it stays pure and
|
|
267
|
+
* testable; the site binds it once in lib/shared.ts. Real YAML now, so a flow
|
|
268
|
+
* mapping and a block read the same — the line scanner this replaced had to
|
|
269
|
+
* refuse the flow form because it could not read it.
|
|
197
270
|
*/
|
|
198
|
-
export function governanceVisible(
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
"not read as a group, so every key inside it is dropped without a word. Write it as an " +
|
|
211
|
-
"indented block:\n site:\n governance: false",
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
return true;
|
|
215
|
-
}
|
|
271
|
+
export function governanceVisible(instance: Readonly<Record<string, unknown>>): boolean {
|
|
272
|
+
const site = instance["site"];
|
|
273
|
+
if (typeof site !== "object" || site === null || Array.isArray(site)) return true;
|
|
274
|
+
const value = (site as Record<string, unknown>)["governance"];
|
|
275
|
+
if (value === undefined || value === null) return true;
|
|
276
|
+
if (typeof value === "boolean") return value;
|
|
277
|
+
throw new Error(
|
|
278
|
+
`instance.md site.governance is ${JSON.stringify(value)} — it must be true or false. ` +
|
|
279
|
+
"Defaulting silently would publish the governance you asked to hide, or hide what you " +
|
|
280
|
+
"asked to publish. Write `governance: false` to keep the pages plain, or remove the key.",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
216
283
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// The badge: what a human surface says beside a document the machine surfaces
|
|
286
|
+
// decline (record spec §2.5). ONE vocabulary for the page chip, the sidebar,
|
|
287
|
+
// the listings and the search results, so a reader picking between a document
|
|
288
|
+
// and its successor sees the same word everywhere.
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The chip text for a badge, or null for a document every surface admits.
|
|
292
|
+
*
|
|
293
|
+
* The words are record spec §2.5's own — "effective from …" and "past its
|
|
294
|
+
* review date" — because the reader who meets one on a sidebar row and again
|
|
295
|
+
* on the page must not have to work out that two phrasings mean one state.
|
|
296
|
+
* §2.5's ellipsis is the date, and `badgeText` fills it in where there is
|
|
297
|
+
* room; this is the same sentence with the value left off, never a second one.
|
|
298
|
+
*/
|
|
299
|
+
export function badgeLabel(badge: LifecycleBadge | null): string | null {
|
|
300
|
+
switch (badge) {
|
|
301
|
+
case null:
|
|
302
|
+
return null;
|
|
303
|
+
case "draft":
|
|
304
|
+
return "draft";
|
|
305
|
+
case "deprecated":
|
|
306
|
+
return "deprecated";
|
|
307
|
+
case "effective-from":
|
|
308
|
+
return "effective from";
|
|
309
|
+
case "stale":
|
|
310
|
+
return "past its review date";
|
|
240
311
|
}
|
|
241
|
-
return true;
|
|
242
312
|
}
|
|
243
313
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
314
|
+
/**
|
|
315
|
+
* The badge as a PAGE says it: §2.5's words with the ellipsis filled in from
|
|
316
|
+
* the document's own `ksor.effective_from`.
|
|
317
|
+
*
|
|
318
|
+
* Only `effective-from` carries a date, and only forwards: "past its review
|
|
319
|
+
* date" is about a day that has gone, and the day itself is already a fact in
|
|
320
|
+
* the strip beside it ("Review by"), so repeating it in the chip would say the
|
|
321
|
+
* same thing twice in one line.
|
|
322
|
+
*/
|
|
323
|
+
export function badgeText(
|
|
324
|
+
badge: LifecycleBadge | null,
|
|
325
|
+
effectiveFrom: string | null,
|
|
326
|
+
): string | null {
|
|
327
|
+
const label = badgeLabel(badge);
|
|
328
|
+
if (label === null) return null;
|
|
329
|
+
if (badge !== "effective-from" || effectiveFrom === null) return label;
|
|
330
|
+
return `${label} ${dayOf(effectiveFrom)}`;
|
|
331
|
+
}
|
|
258
332
|
|
|
259
|
-
/**
|
|
260
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Does the badge say anything the status chip does not?
|
|
335
|
+
*
|
|
336
|
+
* `draft` and `deprecated` are both a status word and a badge word, so a page
|
|
337
|
+
* that drew both would print one state twice. The two date states have no
|
|
338
|
+
* status word of their own — the document is `stable` and something about the
|
|
339
|
+
* calendar keeps it off the machine surfaces — and those are exactly the ones
|
|
340
|
+
* a reader cannot infer from the status alone.
|
|
341
|
+
*/
|
|
342
|
+
export function badgeAddsToStatus(badge: LifecycleBadge | null, status: string | null): boolean {
|
|
343
|
+
return badge !== null && badge !== status;
|
|
344
|
+
}
|
|
261
345
|
|
|
262
346
|
/**
|
|
263
|
-
* The
|
|
347
|
+
* The badge a PLAIN page still draws — the page of a record whose
|
|
348
|
+
* `site.governance` is off.
|
|
349
|
+
*
|
|
350
|
+
* That key turns off ATTRIBUTION: who owns this document, who approved it, who
|
|
351
|
+
* verified it, where it came from. A badge is not attribution. It is the record
|
|
352
|
+
* saying this document is not one the machine surfaces will serve today, which
|
|
353
|
+
* is the same class of thing as the deprecation notice the key already exempts
|
|
354
|
+
* ("a correctness warning, not decoration") — and the sidebar, the folder
|
|
355
|
+
* listings and the search dialog draw it whatever the key says. Gating it on
|
|
356
|
+
* the page alone put an "effective from 2027-01-01" chip on a document in three
|
|
357
|
+
* places and opened it as a current, in-force policy in the fourth, while the
|
|
358
|
+
* MCP door refused it outright (record spec §2.5). One record, one voice.
|
|
264
359
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* label that appears everywhere and always says the same thing trains people to
|
|
269
|
-
* skip it, including on the page where it mattered. An unrecognized state is
|
|
270
|
-
* passed through rather than swallowed: `pnpm check` holds status to a closed
|
|
271
|
-
* set, so reaching here with one means the record skipped the checker, and
|
|
272
|
-
* showing what it wrote beats hiding it.
|
|
360
|
+
* `deprecated` is the one state dropped, because a plain page still carries the
|
|
361
|
+
* DeprecatedNotice above its title: the same rule `badgeAddsToStatus` applies
|
|
362
|
+
* when the status chip is the thing already saying it.
|
|
273
363
|
*/
|
|
364
|
+
export function plainBadge(badge: LifecycleBadge | null): LifecycleBadge | null {
|
|
365
|
+
return badge === "deprecated" ? null : badge;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The three lifecycle states (record spec §2.2), and nothing else — a word the record does not define is not a status. */
|
|
369
|
+
const STATUSES: readonly string[] = ["draft", "stable", "deprecated"];
|
|
370
|
+
|
|
274
371
|
/**
|
|
275
|
-
* The
|
|
372
|
+
* The status chip every page carries — including a `stable` one, which is the
|
|
373
|
+
* one difference from the badge.
|
|
276
374
|
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
375
|
+
* A badge is rare on purpose: it marks the documents the machine surfaces
|
|
376
|
+
* decline. The status is not a caveat, it is the record's own word for where
|
|
377
|
+
* the document stands, and research/okf-native.md §1.1 has the page saying it
|
|
378
|
+
* out loud from day one — "the chips say `stable` with approver and date, and
|
|
379
|
+
* the badge says unverified". A reader who cannot see `stable` cannot tell a
|
|
380
|
+
* governed record from a site that simply never said.
|
|
283
381
|
*/
|
|
382
|
+
export function statusLabel(status: string | null): string | null {
|
|
383
|
+
return status !== null && STATUSES.includes(status) ? status : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The status chip's tone — the same rule the badge uses, so one state is one colour. */
|
|
284
387
|
export function statusTone(status: string | null): string {
|
|
285
|
-
return status === "
|
|
388
|
+
return status === "deprecated" ? "ksor-withdrawn" : "";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The tone class a badge wears. Only a WITHDRAWN document gets a colour:
|
|
393
|
+
* `draft` and the two date states are ordinary states of a live document;
|
|
394
|
+
* `deprecated` says the record has replaced it, and that is the one chip a
|
|
395
|
+
* reader must not mistake for the others at the moment of choosing.
|
|
396
|
+
*/
|
|
397
|
+
export function badgeTone(badge: LifecycleBadge | null): string {
|
|
398
|
+
return badge === "deprecated" ? "ksor-withdrawn" : "";
|
|
286
399
|
}
|
|
287
400
|
|
|
288
|
-
|
|
289
|
-
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// The AGENT surface's projection of the same record.
|
|
403
|
+
//
|
|
404
|
+
// The machine surfaces admit only stable, effective, unexpired concepts, so a
|
|
405
|
+
// consumer never meets a deprecated or draft document there — what it needs is
|
|
406
|
+
// the governance that makes the passage citable, and the stamps that connect
|
|
407
|
+
// it to one publication (R14). Deliberately NOT gated on `site.governance`:
|
|
408
|
+
// that key decides what the PAGES publish; the record keeps every key for the
|
|
409
|
+
// agent surface and the audit trail.
|
|
410
|
+
|
|
411
|
+
/** The stamps every machine artefact carries (build spec §3). */
|
|
412
|
+
export interface Stamps {
|
|
413
|
+
readonly build_id: string | null;
|
|
414
|
+
readonly source_commit: string | null;
|
|
415
|
+
readonly dirty: boolean;
|
|
416
|
+
readonly ksor_version: string | null;
|
|
417
|
+
readonly unstamped: boolean;
|
|
290
418
|
}
|
|
291
419
|
|
|
292
420
|
/**
|
|
293
421
|
* A YAML scalar a consumer can parse back to exactly what the record said. The
|
|
294
|
-
* shapes below are the ones
|
|
295
|
-
*
|
|
422
|
+
* shapes below are the ones a plain scalar cannot carry: unquoted, YAML reads
|
|
423
|
+
* them as something else.
|
|
296
424
|
*/
|
|
297
425
|
function yamlScalar(value: string): string {
|
|
298
426
|
const risky =
|
|
@@ -304,78 +432,64 @@ function yamlScalar(value: string): string {
|
|
|
304
432
|
return risky ? JSON.stringify(value) : value;
|
|
305
433
|
}
|
|
306
434
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
* resolution, including any base path), or null when it is not in this build —
|
|
317
|
-
* a per-audience build stages a subset. A missing successor never suppresses
|
|
318
|
-
* the SUPERSEDED marker: dropping the warning with the link would serve the
|
|
319
|
-
* withdrawn document looking clean, which is the whole defect.
|
|
320
|
-
*/
|
|
321
|
-
export function agentIndexSuffix(
|
|
322
|
-
governance: DocumentGovernance,
|
|
323
|
-
successorUrl: string | null,
|
|
324
|
-
): string {
|
|
325
|
-
const status = caveatStatus(governance.status);
|
|
326
|
-
if (status === null) return "";
|
|
327
|
-
const replaced =
|
|
328
|
-
status === "superseded" && successorUrl !== null ? `, replaced by ${successorUrl}` : "";
|
|
329
|
-
return ` — ${status.toUpperCase()}${replaced}`;
|
|
435
|
+
/** The stamp lines, as YAML keys — one spelling for every artefact. */
|
|
436
|
+
export function stampLines(stamps: Stamps): string[] {
|
|
437
|
+
if (stamps.unstamped) return ["build_id: null", "unstamped: true"];
|
|
438
|
+
const lines = [`build_id: ${yamlScalar(stamps.build_id ?? "")}`];
|
|
439
|
+
if (stamps.source_commit !== null)
|
|
440
|
+
lines.push(`source_commit: ${yamlScalar(stamps.source_commit)}`);
|
|
441
|
+
if (stamps.dirty) lines.push("dirty: true");
|
|
442
|
+
if (stamps.ksor_version !== null) lines.push(`ksor_version: ${yamlScalar(stamps.ksor_version)}`);
|
|
443
|
+
return lines;
|
|
330
444
|
}
|
|
331
445
|
|
|
332
446
|
/**
|
|
333
|
-
* The
|
|
334
|
-
*
|
|
335
|
-
*
|
|
447
|
+
* The frontmatter a document's markdown twin and its `llms-full.txt` block
|
|
448
|
+
* carry: the record's OWN frontmatter, intact, then the derived trust tier and
|
|
449
|
+
* the build's stamps (R14).
|
|
450
|
+
*
|
|
451
|
+
* `raw` is the concept's frontmatter exactly as the staged file holds it,
|
|
452
|
+
* between its fences and unparsed. Intact, because the twin is the record's
|
|
453
|
+
* bytes: an OKF consumer that fetches one must be able to parse the concept the
|
|
454
|
+
* profile describes, and record spec §2.7 keeps unknown keys for exactly that
|
|
455
|
+
* reason — a re-serialisation drops whatever this shell did not think to
|
|
456
|
+
* project.
|
|
336
457
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
458
|
+
* The projection this replaced flattened `ksor.owner` into a top-level `owner:`
|
|
459
|
+
* and `ksor.effective_from` into `effective_from:`. Both are keys record spec
|
|
460
|
+
* §2.7 refuses BY NAME as pre-profile leftovers, so every twin published a
|
|
461
|
+
* frontmatter the record's own checker would have rejected — the corpus
|
|
462
|
+
* describing itself in a grammar it forbids (found while implementing build
|
|
463
|
+
* spec §3's twin clause).
|
|
340
464
|
*
|
|
341
|
-
*
|
|
342
|
-
* document
|
|
465
|
+
* Two keys are ADDED rather than copied, and both are the build speaking about
|
|
466
|
+
* the document rather than the document speaking about itself: `trust_tier`,
|
|
467
|
+
* which record spec §2.3 derives from `verified` and no reader should have to
|
|
468
|
+
* re-derive, and the stamps, which are what connect these bytes to one
|
|
469
|
+
* publication.
|
|
343
470
|
*/
|
|
344
471
|
export function agentFrontmatter(
|
|
472
|
+
raw: string,
|
|
345
473
|
governance: DocumentGovernance,
|
|
346
|
-
|
|
474
|
+
stamps: Stamps,
|
|
347
475
|
): string {
|
|
348
|
-
const
|
|
349
|
-
const lines
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (effective !== null) lines.push(`effective: ${yamlScalar(effective)}`);
|
|
353
|
-
// The resolved route, never the raw `./successor.md` pointer: a consumer that
|
|
354
|
-
// never sees the record's file tree cannot follow one.
|
|
355
|
-
if (status === "superseded" && supersededBy !== null) {
|
|
356
|
-
lines.push(`superseded_by: ${yamlScalar(successorUrl ?? supersededBy)}`);
|
|
357
|
-
}
|
|
358
|
-
if (provenance.length > 0) {
|
|
359
|
-
lines.push("provenance:");
|
|
360
|
-
for (const entry of provenance) lines.push(` - ${yamlScalar(entry)}`);
|
|
361
|
-
}
|
|
362
|
-
return lines.length === 0 ? "" : `---\n${lines.join("\n")}\n---\n`;
|
|
476
|
+
const own = raw.replace(/\s+$/, "");
|
|
477
|
+
const lines = own === "" ? [] : [own];
|
|
478
|
+
lines.push(`trust_tier: ${trustTierOf(governance.verified)}`, ...stampLines(stamps));
|
|
479
|
+
return `---\n${lines.join("\n")}\n---\n`;
|
|
363
480
|
}
|
|
364
481
|
|
|
365
482
|
/**
|
|
366
|
-
* The href for a
|
|
367
|
-
*
|
|
483
|
+
* The href for a source that IS a URL, or null when it is a bundle path or a
|
|
484
|
+
* scope descriptor.
|
|
368
485
|
*
|
|
369
|
-
*
|
|
370
|
-
* linkifying a fragment inside "See https://x for the signed copy" would have
|
|
371
|
-
* to guess where the URL ends, and the citation is the entry, not the fragment.
|
|
372
|
-
* And only `http(s)` is accepted: `provenance` is AUTHORED content, so a
|
|
486
|
+
* Only `http(s)` is accepted: `resource` is AUTHORED content, so a
|
|
373
487
|
* `javascript:` or `data:` entry rendered into an href would let the record
|
|
374
488
|
* execute a script in the page that serves it. Other schemes (`mailto:`,
|
|
375
|
-
* `ftp:`) are refused as
|
|
489
|
+
* `ftp:`) are refused as links rather than widened without a reason.
|
|
376
490
|
*/
|
|
377
|
-
export function sourceHref(
|
|
378
|
-
const value =
|
|
491
|
+
export function sourceHref(resource: string): string | null {
|
|
492
|
+
const value = resource.trim();
|
|
379
493
|
if (value === "" || /\s/.test(value)) return null;
|
|
380
494
|
let url: URL;
|
|
381
495
|
try {
|
|
@@ -393,7 +507,7 @@ export function sourceHref(entry: string): string | null {
|
|
|
393
507
|
export interface SupersessionPointer {
|
|
394
508
|
/** Path under `knowledge/`, e.g. `policies/purchase-approval-2019.md`. */
|
|
395
509
|
readonly path: string;
|
|
396
|
-
/** Its `superseded_by
|
|
510
|
+
/** Its `ksor.superseded_by` value, or null. */
|
|
397
511
|
readonly supersededBy: string | null;
|
|
398
512
|
}
|
|
399
513
|
|
|
@@ -407,8 +521,8 @@ export interface SupersessionPointer {
|
|
|
407
521
|
* `Obsoleted by:` on the old).
|
|
408
522
|
*
|
|
409
523
|
* Derived, never declared — there is no new frontmatter key. Each pointer is
|
|
410
|
-
* resolved through the same
|
|
411
|
-
*
|
|
524
|
+
* resolved through the same rule the forward notice uses, so the two
|
|
525
|
+
* directions can never disagree about what points where.
|
|
412
526
|
*/
|
|
413
527
|
export function predecessorsOf(
|
|
414
528
|
currentUrl: string,
|
|
@@ -419,7 +533,7 @@ export function predecessorsOf(
|
|
|
419
533
|
const found: string[] = [];
|
|
420
534
|
for (const pointer of pointers) {
|
|
421
535
|
if (pointer.supersededBy === null) continue;
|
|
422
|
-
const resolved = resolveSuccessorUrl(pointer.supersededBy,
|
|
536
|
+
const resolved = resolveSuccessorUrl(pointer.supersededBy, pages);
|
|
423
537
|
if (resolved === null || resolved.split("#")[0] !== currentUrl) continue;
|
|
424
538
|
// A document that supersedes itself would otherwise render "Replaces: this
|
|
425
539
|
// page" on the page you are reading. `pnpm check` refuses it; this is the
|