@panaversity/ksor 0.0.50 → 0.0.52

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.
@@ -4,6 +4,8 @@ import { DocumentActions } from "@/components/document-actions";
4
4
  import { Clock } from "lucide-react";
5
5
  import type { ReactElement } from "react";
6
6
 
7
+ import { displayActor } from "@/lib/actor-display";
8
+ import { peopleBook } from "@/lib/people";
7
9
  import {
8
10
  badgeAddsToStatus,
9
11
  badgeText,
@@ -257,7 +259,7 @@ export function GovernanceMeta({
257
259
  <Chip text={trust.tier} />
258
260
  {trust.by === null ? null : (
259
261
  <span className="font-normal text-fd-muted-foreground">
260
- {trust.by}
262
+ {displayActor(trust.by, peopleBook())}
261
263
  {trust.at === null ? null : <> · {day(trust.at)}</>}
262
264
  </span>
263
265
  )}
@@ -286,7 +288,7 @@ export function GovernanceMeta({
286
288
  Links keep full strength: `Replaces` points at the document this one
287
289
  superseded, and that is an action rather than a fact. */}
288
290
  <dl className="mt-2.5 flex flex-wrap items-baseline gap-x-8 gap-y-2.5 empty:mt-0 [&_a]:text-fd-foreground [&_dd]:font-normal [&_dd]:text-fd-muted-foreground">
289
- {owner === null ? null : <Fact label="Owner">{owner}</Fact>}
291
+ {owner === null ? null : <Fact label="Owner">{displayActor(owner, peopleBook())}</Fact>}
290
292
  {/* Who let this into the record. `ksor.approval` is what makes a `stable`
291
293
  document stable at all (record spec §2.2), so a page that showed the
292
294
  word and not the signature would be publishing the claim without its
@@ -294,7 +296,7 @@ export function GovernanceMeta({
294
296
  {approval === null ? null : (
295
297
  <Fact label="Approved">
296
298
  <>
297
- {approval.by} · {day(approval.at)}
299
+ {displayActor(approval.by, peopleBook())} · {day(approval.at)}
298
300
  </>
299
301
  </Fact>
300
302
  )}
@@ -307,7 +309,7 @@ export function GovernanceMeta({
307
309
  {deprecated === null ? null : (
308
310
  <Fact label="Withdrawn">
309
311
  <>
310
- {deprecated.by} · {day(deprecated.at)}
312
+ {displayActor(deprecated.by, peopleBook())} · {day(deprecated.at)}
311
313
  </>
312
314
  </Fact>
313
315
  )}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Turn a stored actor identifier into the string the page prints.
3
+ *
4
+ * KSoR stores actors as `human:<handle>`, `process:<id>`, `team:<id>` or
5
+ * `<producer>/<version>`. Those forms are right for machines — the checker
6
+ * parses them, `.ksor/governance.yaml` lists actors by them, and the whole
7
+ * authority model depends on them — but a public-facing page shouldn't lead
8
+ * with a slug. This module bridges the two.
9
+ *
10
+ * ONE-WAY RULE. If `.ksor/people.yaml` maps this exact identifier to a name, we
11
+ * print `Human: Bashir Aziz`. If it does not, we print the identifier unchanged
12
+ * (`human:bashiraziz`, `ksor-starter/0.0.47`). No convention-based splitting,
13
+ * no camelCase / kebab-case guessing, and no derivation of a handle from a
14
+ * name: an owner is the only source of a display name.
15
+ *
16
+ * A producer actor (`<producer>/<version>`) has no `<kind>:` prefix and no
17
+ * natural name to look up, so it passes through unchanged — which is correct:
18
+ * a tool that approved a document should be named plainly, not humanised.
19
+ *
20
+ * NO IMPORTS: a leaf, like `lifecycle-rule.ts`. The phone book is handed in by
21
+ * the caller rather than reached for, so the rule can be exercised without a
22
+ * record on disk — and so nothing pulls a filesystem read in behind it.
23
+ */
24
+
25
+ /** The prefixes KSoR's actor grammar defines, in the case the site prints. */
26
+ const KIND_LABELS: Record<string, string> = {
27
+ human: "Human",
28
+ process: "Process",
29
+ team: "Team",
30
+ };
31
+
32
+ /**
33
+ * The display form of an actor. An actor that does not appear in
34
+ * `.ksor/people.yaml` renders exactly as stored, so nothing regresses on a
35
+ * record that has declared no names — which is every record until an owner
36
+ * says otherwise.
37
+ */
38
+ export function displayActor(actor: string, people: ReadonlyMap<string, string>): string {
39
+ const colonAt = actor.indexOf(":");
40
+ if (colonAt === -1) {
41
+ // No `<kind>:` prefix — this is a producer like `ksor-starter/0.0.47`.
42
+ // Print it unchanged: humanising a tool's identifier would misread.
43
+ return actor;
44
+ }
45
+ // Looked up by the WHOLE identifier, not the bare handle: `human:ops` and
46
+ // `team:ops` are different actors, and a phone book keyed on `ops` would
47
+ // print one of them under the other's name.
48
+ const name = people.get(actor) ?? null;
49
+ if (name === null) return actor;
50
+ const kind = actor.slice(0, colonAt);
51
+ const kindLabel = KIND_LABELS[kind] ?? kind.charAt(0).toUpperCase() + kind.slice(1);
52
+ return `${kindLabel}: ${name}`;
53
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Natural names for the actors this record cites — the site's phone book.
3
+ *
4
+ * A MAP keyed by the actor as stored (`human:bashiraziz`, `team:legal-ops`),
5
+ * not a list of names a handle is derived from. The derivation was the defect:
6
+ * `name.replace(/\s+/g, "").toLowerCase()` can only ever match a handle that
7
+ * IS somebody's squashed full name, so `human:ciso`, `human:audit-lead` and
8
+ * `human:mjs` — most of the actors in a real record — had no expressible name
9
+ * at all. It also collided: "Bashir Aziz" and "Bashira Ziz" both derive
10
+ * `bashiraziz`, which would print one person's name on the other's governance
11
+ * act. A map has neither problem, and duplicate keys are refused by the parser
12
+ * rather than resolved by whichever came last.
13
+ *
14
+ * ONE-WAY. The identifier is what the record stores, cites and checks against
15
+ * the policy; this is only what a page prints. Nothing reads a name back into
16
+ * an actor, and no authority follows from appearing here — which is why this is
17
+ * a file of its own and not a block in `.ksor/governance.yaml`: that file is
18
+ * the root of authority, its key set is closed on purpose, and its digest is
19
+ * hashed into `build.lock.json`, so correcting the spelling of someone's name
20
+ * there would refuse the next site build as `ksor-lock-stale`.
21
+ *
22
+ * Read from the project root rather than the process's cwd: `next build` runs
23
+ * in `system/site`, so a cwd-relative path found nothing and the feature was
24
+ * inert in exactly the builds that publish.
25
+ *
26
+ * Read AT USE and memoised, not at module load. A module-load `readFileSync`
27
+ * makes importing this module a filesystem act — it runs wherever the module is
28
+ * pulled in, including from a test that wants nothing but the display rule, and
29
+ * it fixes the answer before anything has had a chance to say where the record
30
+ * is. That is the same defect the env-tuning knobs had (#149/#194), one file
31
+ * over.
32
+ */
33
+
34
+ import { readFileSync } from "node:fs";
35
+ import path from "node:path";
36
+
37
+ import { parseAllDocuments } from "yaml";
38
+
39
+ import { projectRoot } from "./shared";
40
+
41
+ const PEOPLE_YAML = path.join(projectRoot, ".ksor", "people.yaml");
42
+
43
+ function loadPeople(): ReadonlyMap<string, string> {
44
+ let text: string;
45
+ try {
46
+ text = readFileSync(PEOPLE_YAML, "utf8");
47
+ } catch {
48
+ // Optional: its absence means "no natural names declared".
49
+ return new Map();
50
+ }
51
+ try {
52
+ const docs = parseAllDocuments(text.replace(/^/, ""), {
53
+ schema: "core",
54
+ uniqueKeys: true,
55
+ logLevel: "silent",
56
+ });
57
+ const value: unknown = docs[0]?.toJS();
58
+ if (typeof value !== "object" || value === null) return new Map();
59
+ const table = (value as { people?: unknown }).people;
60
+ if (typeof table !== "object" || table === null || Array.isArray(table)) return new Map();
61
+ const out = new Map<string, string>();
62
+ for (const [actor, name] of Object.entries(table as Record<string, unknown>)) {
63
+ // A blank value is an entry someone started and left; printing "" would
64
+ // erase the identifier rather than replace it.
65
+ if (typeof name === "string" && name.trim() !== "") out.set(actor.trim(), name.trim());
66
+ }
67
+ return out;
68
+ } catch {
69
+ return new Map();
70
+ }
71
+ }
72
+
73
+ let cached: ReadonlyMap<string, string> | null = null;
74
+
75
+ /**
76
+ * What this record has declared. Memoised per process: the file is authored,
77
+ * not runtime state, and a static build renders many pages from one read.
78
+ */
79
+ export function peopleBook(): ReadonlyMap<string, string> {
80
+ cached ??= loadPeople();
81
+ return cached;
82
+ }
@@ -5,7 +5,7 @@
5
5
  * bundle-absolute (`/policies/x.md`, resolved against `knowledge/`) and
6
6
  * relative (`x.md`, `./x.md`, `../x.md`, against the document's own
7
7
  * directory), with `.md` optional in each. The shell resolves only the `./`
8
- * and `../` forms (fumadocs-core 16.14.5, `resolveHref` returns anything else
8
+ * and `../` forms (fumadocs-core 16.15.4, `resolveHref` returns anything else
9
9
  * untouched), so a bundle-absolute link left the record's frame entirely and a
10
10
  * bare `x.md` was resolved by the browser against the page's ROUTE rather than
11
11
  * its directory: both 404'd from every page, found live 2026-08-25 as prefetch
@@ -46,6 +46,19 @@ const config = {
46
46
  // Hosting under a sub-path (e.g. a GitHub Pages project site):
47
47
  // KSOR_BASE_PATH="/my-repo" pnpm build
48
48
  basePath: process.env.KSOR_BASE_PATH ?? "",
49
+ // Next >= 16.3 writes AGENTS.md and CLAUDE.md into the Next project root
50
+ // whenever `next dev` detects a coding agent (`agentRules`, default true).
51
+ // Here that root is system/site, and a markdown file there is refused by the
52
+ // record's own hygiene rule (`ksor-site-holds-content`): the site RENDERS the
53
+ // record, it never holds it, so content there silently forks it. Left on, an
54
+ // adopter's `pnpm dev` made their own `pnpm check` go red — reproduced by the
55
+ // scaffold walkthrough the hour Next 16.3.3 was pinned.
56
+ //
57
+ // The scaffold already answers what the feature is for: AGENTS.md at the repo
58
+ // root is the coding agent's first read, and one record must not speak with
59
+ // two voices about one thing. Turn it back on only if you also move those two
60
+ // files out of system/site.
61
+ agentRules: false,
49
62
  turbopack: {
50
63
  root: repoRoot,
51
64
  },
@@ -10,11 +10,11 @@
10
10
  "dependencies": {
11
11
  "class-variance-authority": "0.7.1",
12
12
  "clsx": "2.1.1",
13
- "fumadocs-core": "16.14.5",
14
- "fumadocs-mdx": "15.3.0",
15
- "fumadocs-ui": "16.14.5",
13
+ "fumadocs-core": "16.15.4",
14
+ "fumadocs-mdx": "15.4.0",
15
+ "fumadocs-ui": "16.15.4",
16
16
  "lucide-react": "1.31.0",
17
- "next": "16.2.9",
17
+ "next": "16.3.3",
18
18
  "radix-ui": "1.6.7",
19
19
  "react": "19.2.8",
20
20
  "react-dom": "19.2.8",
@@ -230,7 +230,7 @@ export default defineConfig({
230
230
  * apply to every group on the page and persist to the next visit. The
231
231
  * `Tabs` branch drops the attribute silently, so a reader with a
232
232
  * ten-section document would pick their tool ten times (verified against
233
- * fumadocs-core 16.14.5, remark-code-tab.js).
233
+ * fumadocs-core 16.15.4, remark-code-tab.js).
234
234
  */
235
235
  remarkPlugins: [[remarkCodeTab, { Tabs: "CodeBlockTabs" }]],
236
236
  },