@panaversity/ksor 0.0.2 → 0.0.3
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 +24 -0
- package/dist/cli.mjs +1 -0
- package/docs/index.md +6 -2
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +4 -1
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +8 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +218 -9
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +19 -4
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +4 -1
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +8 -1
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +218 -9
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +19 -4
- package/templates/scaffold/AGENTS.md +36 -7
- package/templates/scaffold/README.md +27 -0
- package/templates/scaffold/gitignore +3 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/docs/layout.tsx +2 -2
- package/templates/scaffold/system/site/components/footer-mark.tsx +22 -0
- package/templates/scaffold/system/site/lib/audience.ts +178 -0
- package/templates/scaffold/system/site/lib/shared.ts +14 -5
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +301 -0
- package/templates/scaffold/system/site/source.config.ts +7 -1
- package/templates/scaffold/vercel.json +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8e88899: The scaffold now answers Vercel's deploy interview: a shipped
|
|
8
|
+
`vercel.json` declares the repo root as the deploy directory (pinning
|
|
9
|
+
`system/site` omits the record — the interview's natural answer breaks
|
|
10
|
+
the build), the static export as the deliverable, and matching trailing
|
|
11
|
+
slashes. The README gains a Deploying section documenting what was
|
|
12
|
+
always true but never written down: the built site is a folder of files
|
|
13
|
+
with zero host-specific dependencies — Vercel, GitHub Pages, nginx, or
|
|
14
|
+
`python3 -m http.server` all serve it, with `KSOR_BASE_PATH` for
|
|
15
|
+
sub-path hosts.
|
|
16
|
+
- 113fddd: The record can now declare its audience. A governed `visibility:` key
|
|
17
|
+
(one value, orthogonal to `status:`) against an `audiences:` model in
|
|
18
|
+
instance.md; per-audience **staged** builds enforce it — a build below a
|
|
19
|
+
document's tier carries no trace of it: no page, no search entry, no
|
|
20
|
+
llms.txt line, no sidebar title, no asset bytes, and nothing about the
|
|
21
|
+
filter itself in the client bundle. Non-public builds name themselves.
|
|
22
|
+
Seven checker rules guard the model, including the cross-audience link
|
|
23
|
+
no single build can catch. Absent `audiences:`, nothing changes —
|
|
24
|
+
purely additive. Evidence and the measured build-time-vs-per-request
|
|
25
|
+
decision: the ksor repository's research/visibility.md and issue #10.
|
|
26
|
+
|
|
3
27
|
## 0.0.2
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -71,6 +71,7 @@ function isTextFile(file) {
|
|
|
71
71
|
*/
|
|
72
72
|
function materialize(templateDir, targetDir, stamps, created = []) {
|
|
73
73
|
for (const entry of readdirSync(templateDir, { withFileTypes: true })) {
|
|
74
|
+
if (entry.name === "node_modules") continue;
|
|
74
75
|
const from = path.join(templateDir, entry.name);
|
|
75
76
|
const to = path.join(targetDir, EMITTED_NAMES.get(entry.name) ?? entry.name);
|
|
76
77
|
if (entry.isDirectory()) {
|
package/docs/index.md
CHANGED
|
@@ -40,8 +40,12 @@ Read the scaffold's own `AGENTS.md` first — it is the working contract.
|
|
|
40
40
|
Knowledge lives in `knowledge/` and never inside the site; frontmatter uses
|
|
41
41
|
a closed key set (`title` + `status` required); `pnpm check` explains any
|
|
42
42
|
violation and how to fix it. Sidebar order is the governed `order:`
|
|
43
|
-
frontmatter key — never `meta.json` or `sidebar_position`.
|
|
44
|
-
|
|
43
|
+
frontmatter key — never `meta.json` or `sidebar_position`. If the
|
|
44
|
+
instance declares an `audiences:` model, documents may carry a
|
|
45
|
+
`visibility:` key and per-audience builds (`KSOR_AUDIENCE=<tier> pnpm
|
|
46
|
+
build`) stage only what that tier may see — publication, not authorship:
|
|
47
|
+
anyone who can clone reads everything. The site shell
|
|
48
|
+
at `system/site/` is replaceable behind a five-clause surface contract; a
|
|
45
49
|
Docusaurus conformance shell lives in the ksor repository under
|
|
46
50
|
`workbench/shells/docusaurus/` with its swap recipe.
|
|
47
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Knowledge System of Record — the authoritative, governed source of knowledge that humans and AI agents operate from. Name reserved; implementation in progress.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abstention",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: add-sources
|
|
3
3
|
description: Turn source material — documents, pages, pasted text, notes — into governed knowledge in knowledge/. Use when the owner shares material to add, says "add this to the knowledge base", or asks how to get existing content in. Not for editing the site.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.2.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Add sources
|
|
@@ -24,6 +24,9 @@ The rules that make it _governed_ rather than merely stored:
|
|
|
24
24
|
file names, systems, people, dates) whenever the owner can tell you.
|
|
25
25
|
Precision matters: "Finance policy manual §4.2, 2025 edition" governs;
|
|
26
26
|
"internal docs" does not.
|
|
27
|
+
- When `instance.md` declares `audiences:`, ask the owner which audience the
|
|
28
|
+
new material belongs to and write it as `visibility:` — never guess that
|
|
29
|
+
restricted material is public.
|
|
27
30
|
|
|
28
31
|
## Fidelity rules
|
|
29
32
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: format-checker
|
|
3
3
|
description: The record's format rules as a runnable check — frontmatter, filenames, links, structure. Use before handing off any change to knowledge/, when a check fails and you need to fix it, or when unsure whether a document is well-formed. Run with `pnpm check` (or node .agents/skills/format-checker/check.mjs).
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.2.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Format checker
|
|
@@ -25,6 +25,13 @@ enforces what AGENTS.md states in prose:
|
|
|
25
25
|
blocks are code, not links, and are ignored.
|
|
26
26
|
- `instance.md` exists, is `format: 1`, and carries only the keys the format
|
|
27
27
|
defines — an unknown key is named, never ignored.
|
|
28
|
+
- The audience model, when there is one: `audiences:` is ordered least- to
|
|
29
|
+
most-restricted with `public` first, no duplicates, and never without
|
|
30
|
+
`default_visibility:`; a document's `visibility:` names one of the declared
|
|
31
|
+
audiences; and no link or `superseded_by:` points from a wider audience at a
|
|
32
|
+
narrower one — the leak no single build can catch, since the build that
|
|
33
|
+
publishes the pointer has already dropped its target. A record that declares
|
|
34
|
+
no `audiences:` is checked exactly as it was before the key existed.
|
|
28
35
|
- `CLAUDE.md` stays a one-line pointer; `.agents/skills/` and
|
|
29
36
|
`.claude/skills/` hold the same files byte for byte **in both directions**
|
|
30
37
|
(a file only one tree carries is a rule nobody reviewed); the site contains
|
|
@@ -23,6 +23,7 @@ const ALLOWED_KEYS = new Set([
|
|
|
23
23
|
"title",
|
|
24
24
|
"description",
|
|
25
25
|
"status",
|
|
26
|
+
"visibility",
|
|
26
27
|
"owner",
|
|
27
28
|
"provenance",
|
|
28
29
|
"effective",
|
|
@@ -96,9 +97,10 @@ function unquote(value) {
|
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
99
|
* The frontmatter block, two levels deep (`ksor:` has children; `provenance:`
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
100
|
+
* and `audiences:` have list items, collected under the key above them).
|
|
101
|
+
* Returns null when there is no block at all, and collects every line that is
|
|
102
|
+
* neither `key: value`, a list item, an indented continuation, nor blank —
|
|
103
|
+
* those mean the block was never closed.
|
|
102
104
|
*/
|
|
103
105
|
function parseFrontmatter(text) {
|
|
104
106
|
// An editor's byte-order mark is invisible to the author; it must not be
|
|
@@ -108,6 +110,7 @@ function parseFrontmatter(text) {
|
|
|
108
110
|
if (!match) return null;
|
|
109
111
|
const keys = new Map();
|
|
110
112
|
const children = new Map();
|
|
113
|
+
const lists = new Map();
|
|
111
114
|
const quoted = new Set();
|
|
112
115
|
const malformedQuote = new Map();
|
|
113
116
|
const malformed = [];
|
|
@@ -139,6 +142,7 @@ function parseFrontmatter(text) {
|
|
|
139
142
|
}
|
|
140
143
|
keys.set(current, unquote(top[2]));
|
|
141
144
|
children.set(current, new Map());
|
|
145
|
+
lists.set(current, []);
|
|
142
146
|
continue;
|
|
143
147
|
}
|
|
144
148
|
const nested = /^[ \t]+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
@@ -146,10 +150,40 @@ function parseFrontmatter(text) {
|
|
|
146
150
|
children.get(current).set(nested[1], unquote(nested[2]));
|
|
147
151
|
continue;
|
|
148
152
|
}
|
|
153
|
+
// A list item belongs to the key above it: audiences: is a list of
|
|
154
|
+
// audiences, and the rules that read it need the entries, not their count.
|
|
155
|
+
const item = /^[ \t]*-[ \t]+(.*)$/.exec(line);
|
|
156
|
+
if (item && current !== null) {
|
|
157
|
+
// YAML ends a plain scalar at ` #` — the comment is not part of the
|
|
158
|
+
// entry, and reading it as one refuses the documents instead of the
|
|
159
|
+
// list (found live 2026-08-18: `- public # the default` made every
|
|
160
|
+
// public document's visibility undeclared).
|
|
161
|
+
const value = /^["']/.test(item[1]) ? item[1] : item[1].replace(/\s+#.*$/, "");
|
|
162
|
+
lists.get(current).push(unquote(value));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// A dash glued to its value (`-internal`) is a list item to nobody —
|
|
166
|
+
// the indented-continuation escape below swallowed it while the build
|
|
167
|
+
// scanners stopped reading the list there: one green record, two
|
|
168
|
+
// different audience lists (review finding, 2026-08-19).
|
|
169
|
+
if (/^[ \t]*-\S/.test(line)) {
|
|
170
|
+
malformed.push(line.trim());
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
149
173
|
if (/^[ \t]*-([ \t]|$)/.test(line) || /^[ \t]+\S/.test(line)) continue;
|
|
150
174
|
malformed.push(line.trim());
|
|
151
175
|
}
|
|
152
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
keys,
|
|
178
|
+
children,
|
|
179
|
+
lists,
|
|
180
|
+
quoted,
|
|
181
|
+
malformedQuote,
|
|
182
|
+
duplicates,
|
|
183
|
+
malformed,
|
|
184
|
+
tightColons,
|
|
185
|
+
tabIndents,
|
|
186
|
+
};
|
|
153
187
|
}
|
|
154
188
|
|
|
155
189
|
/**
|
|
@@ -218,13 +252,14 @@ function linkTargets(body) {
|
|
|
218
252
|
return raw.map((t) => (t.startsWith("<") && t.endsWith(">") ? t.slice(1, -1).trim() : t));
|
|
219
253
|
}
|
|
220
254
|
|
|
255
|
+
/** Reports a broken target; returns the record document it resolves to, if any. */
|
|
221
256
|
function checkLinkTarget(rel, docPath, target) {
|
|
222
257
|
// Anything with a URI scheme (https:, mailto:, tel:, ftp:, …) or a
|
|
223
258
|
// protocol-relative // host leaves the record on purpose — only relative
|
|
224
259
|
// paths are the record's own links (review finding 2026-08-18: tel: was
|
|
225
260
|
// reported as a dead file and //host as an escape).
|
|
226
|
-
if (target === "" || target.startsWith("#") || target.startsWith("//")) return;
|
|
227
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return;
|
|
261
|
+
if (target === "" || target.startsWith("#") || target.startsWith("//")) return null;
|
|
262
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return null;
|
|
228
263
|
const resolved = path.resolve(path.dirname(docPath), target.split("#")[0]);
|
|
229
264
|
if (!resolved.startsWith(knowledgeDir + path.sep) && resolved !== knowledgeDir) {
|
|
230
265
|
problem(
|
|
@@ -240,9 +275,46 @@ function checkLinkTarget(rel, docPath, target) {
|
|
|
240
275
|
"a record with dead internal links serves different truths by path",
|
|
241
276
|
"fix the path or remove the link",
|
|
242
277
|
);
|
|
278
|
+
} else if (resolved.endsWith(".md")) {
|
|
279
|
+
return resolved;
|
|
243
280
|
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// the audience model: who may read a document, declared in instance.md
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
const instanceMd = path.join(root, "instance.md");
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The declared audiences, ordered least- to most-restricted, or null when the
|
|
291
|
+
* record declares none — and then every visibility rule below stays inert, so
|
|
292
|
+
* a record without an audience model behaves exactly as it did before the key
|
|
293
|
+
* existed. Read before the record itself: who may read what is a property of
|
|
294
|
+
* the whole record, which no single document can answer.
|
|
295
|
+
*/
|
|
296
|
+
function readAudienceModel() {
|
|
297
|
+
if (!existsSync(instanceMd)) return null;
|
|
298
|
+
const fm = parseFrontmatter(readFileSync(instanceMd, "utf8"));
|
|
299
|
+
const audiences = fm?.lists.get("audiences") ?? [];
|
|
300
|
+
if (audiences.length === 0) return null;
|
|
301
|
+
return { audiences, defaultVisibility: scalarValue(fm, "default_visibility") ?? "" };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A plain scalar ends at ` #` — the rule the list items above already follow
|
|
306
|
+
* and both build scanners apply. The checker not applying it to values let
|
|
307
|
+
* `default_visibility: public # the default` build fine and fail `pnpm check`
|
|
308
|
+
* (review finding, 2026-08-19).
|
|
309
|
+
*/
|
|
310
|
+
function scalarValue(fm, key) {
|
|
311
|
+
const value = fm.keys.get(key);
|
|
312
|
+
if (value === undefined || fm.quoted.has(key)) return value;
|
|
313
|
+
return value.replace(/\s+#.*$/, "").trim();
|
|
244
314
|
}
|
|
245
315
|
|
|
316
|
+
const audienceModel = readAudienceModel();
|
|
317
|
+
|
|
246
318
|
if (!existsSync(knowledgeDir)) {
|
|
247
319
|
problem(
|
|
248
320
|
"knowledge/",
|
|
@@ -389,6 +461,8 @@ if (!existsSync(knowledgeDir)) {
|
|
|
389
461
|
}
|
|
390
462
|
|
|
391
463
|
// frontmatter + links per document
|
|
464
|
+
const visibilityByPath = new Map();
|
|
465
|
+
const crossings = [];
|
|
392
466
|
for (const p of mdFiles) {
|
|
393
467
|
const rel = path.relative(root, p);
|
|
394
468
|
const text = readFileSync(p, "utf8");
|
|
@@ -552,22 +626,97 @@ if (!existsSync(knowledgeDir)) {
|
|
|
552
626
|
"a replaced document must hand the reader its successor — a broken pointer dead-ends them on stale truth",
|
|
553
627
|
"fix the path (it resolves relative to this document), or write the successor first",
|
|
554
628
|
);
|
|
629
|
+
} else {
|
|
630
|
+
crossings.push({ kind: "superseded_by", rel, from: p, to: resolved, target: successor });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// visibility: one audience per document, from the set instance.md declares
|
|
634
|
+
const visibility = fm.keys.get("visibility");
|
|
635
|
+
const listed = fm.lists.get("visibility") ?? [];
|
|
636
|
+
// A flow list ([a, b]) is already named by the shape rule above.
|
|
637
|
+
const flowList = !fm.quoted.has("visibility") && /^\[.*\]$/.test(visibility ?? "");
|
|
638
|
+
if (listed.length > 0) {
|
|
639
|
+
problem(
|
|
640
|
+
rel,
|
|
641
|
+
"visibility is one value, not a list",
|
|
642
|
+
"a list makes every document a set-membership question, and set intersection is where access-control bugs live — one document belongs to exactly one audience",
|
|
643
|
+
`write a single audience: visibility: ${audienceModel?.audiences.at(-1) ?? "<audience>"}`,
|
|
644
|
+
);
|
|
645
|
+
} else if (visibility !== undefined && !flowList) {
|
|
646
|
+
if (audienceModel === null) {
|
|
647
|
+
problem(
|
|
648
|
+
rel,
|
|
649
|
+
`visibility: ${visibility} — the record declares no audience model`,
|
|
650
|
+
"who may read a document is governance, not a comment: with no audiences: in instance.md nothing constrains this value, and every surface publishes the document to everyone regardless",
|
|
651
|
+
"add audiences: to instance.md (ordered least- to most-restricted, public first) with default_visibility:, or remove the visibility: key",
|
|
652
|
+
);
|
|
653
|
+
} else if (!audienceModel.audiences.includes(visibility)) {
|
|
654
|
+
problem(
|
|
655
|
+
rel,
|
|
656
|
+
`visibility "${visibility}" is not a declared audience`,
|
|
657
|
+
"the audience set is closed in instance.md — a value outside it names a build that does not exist, so the document reaches either nobody or everybody",
|
|
658
|
+
`use one of: ${audienceModel.audiences.join(", ")} — or remove the key to take the default (${audienceModel.defaultVisibility})`,
|
|
659
|
+
);
|
|
555
660
|
}
|
|
556
661
|
}
|
|
662
|
+
if (audienceModel !== null) {
|
|
663
|
+
visibilityByPath.set(p, visibility ?? audienceModel.defaultVisibility);
|
|
664
|
+
}
|
|
557
665
|
}
|
|
558
666
|
// links: resolve, and never escape the record
|
|
559
|
-
for (const target of linkTargets(stripCode(text)))
|
|
667
|
+
for (const target of linkTargets(stripCode(text))) {
|
|
668
|
+
const to = checkLinkTarget(rel, p, target);
|
|
669
|
+
if (to !== null) crossings.push({ kind: "link", rel, from: p, to, target });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Pointers across audiences: the leak no single build can catch, because the
|
|
674
|
+
// build that publishes the pointer has already dropped its target and cannot
|
|
675
|
+
// know it ever existed. Only the whole record sees both ends.
|
|
676
|
+
if (audienceModel !== null) {
|
|
677
|
+
const audienceOf = (file) => visibilityByPath.get(file) ?? audienceModel.defaultVisibility;
|
|
678
|
+
const tier = (file) => audienceModel.audiences.indexOf(audienceOf(file));
|
|
679
|
+
for (const { kind, rel, from, to, target } of crossings) {
|
|
680
|
+
const here = tier(from);
|
|
681
|
+
const there = tier(to);
|
|
682
|
+
// An undeclared audience at either end is already reported; comparing
|
|
683
|
+
// against a tier that does not exist would invent a second problem.
|
|
684
|
+
if (here === -1 || there === -1 || there <= here) continue;
|
|
685
|
+
const relTo = path.relative(root, to);
|
|
686
|
+
const both = `${relTo} is ${audienceOf(to)}, this document is ${audienceOf(from)}`;
|
|
687
|
+
if (kind === "link") {
|
|
688
|
+
problem(
|
|
689
|
+
rel,
|
|
690
|
+
`link to a more restricted document: ${target} — ${both}`,
|
|
691
|
+
"the build that publishes this link has already dropped its target: the link text and URL ship to readers who cannot open them, naming a document they were never meant to know exists",
|
|
692
|
+
`raise this document to ${audienceOf(to)}, widen ${relTo} to ${audienceOf(from)}, or remove the link`,
|
|
693
|
+
);
|
|
694
|
+
} else {
|
|
695
|
+
problem(
|
|
696
|
+
rel,
|
|
697
|
+
`superseded_by points at a more restricted document: ${target} — ${both}`,
|
|
698
|
+
"it strands the very readers the supersession exists to redirect: they are told this document is replaced, by a successor their build does not contain",
|
|
699
|
+
`widen ${relTo} to ${audienceOf(from)}, raise this document to ${audienceOf(to)}, or supersede it with a document its readers can reach`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
560
703
|
}
|
|
561
704
|
}
|
|
562
705
|
|
|
563
706
|
// ---------------------------------------------------------------------------
|
|
564
707
|
// instance.md: the identity of this SoR — format 1, closed key set
|
|
565
708
|
// ---------------------------------------------------------------------------
|
|
566
|
-
const INSTANCE_KEYS = new Set([
|
|
709
|
+
const INSTANCE_KEYS = new Set([
|
|
710
|
+
"format",
|
|
711
|
+
"name",
|
|
712
|
+
"ksor",
|
|
713
|
+
"site",
|
|
714
|
+
"audiences",
|
|
715
|
+
"default_visibility",
|
|
716
|
+
]);
|
|
567
717
|
const INSTANCE_KSOR_KEYS = new Set(["requires", "scaffolded"]);
|
|
568
718
|
const INSTANCE_SITE_KEYS = new Set(["url"]);
|
|
569
719
|
|
|
570
|
-
const instanceMd = path.join(root, "instance.md");
|
|
571
720
|
if (!existsSync(instanceMd)) {
|
|
572
721
|
problem(
|
|
573
722
|
"instance.md",
|
|
@@ -673,6 +822,63 @@ if (!existsSync(instanceMd)) {
|
|
|
673
822
|
);
|
|
674
823
|
}
|
|
675
824
|
}
|
|
825
|
+
// the audience model: ordered, public first, and never without its default
|
|
826
|
+
const audiences = fm.lists.get("audiences") ?? [];
|
|
827
|
+
const defaultVisibility = scalarValue(fm, "default_visibility") ?? "";
|
|
828
|
+
if (fm.keys.has("audiences") && audiences.length === 0) {
|
|
829
|
+
const value = fm.keys.get("audiences");
|
|
830
|
+
problem(
|
|
831
|
+
"instance.md",
|
|
832
|
+
value === ""
|
|
833
|
+
? "audiences: declares no audiences"
|
|
834
|
+
: `audiences is a list, not a value: ${value}`,
|
|
835
|
+
"the audience list is the record's whole access model, ordered least- to most-restricted — with nothing in it, no document's visibility: can be answered",
|
|
836
|
+
"write it as list items:\n audiences:\n - public\n - internal",
|
|
837
|
+
);
|
|
838
|
+
} else if (audiences.length > 0) {
|
|
839
|
+
if (audiences[0] !== "public") {
|
|
840
|
+
problem(
|
|
841
|
+
"instance.md",
|
|
842
|
+
`audiences: does not start with public (it starts with "${audiences[0]}")`,
|
|
843
|
+
"the order is the restriction level, and that ordering is what makes an internal build mean public-and-internal with no further configuration — public is the least restricted tier by definition",
|
|
844
|
+
"list public first, then each narrower audience in turn",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
const seen = new Set();
|
|
848
|
+
for (const audience of audiences) {
|
|
849
|
+
if (seen.has(audience)) {
|
|
850
|
+
problem(
|
|
851
|
+
"instance.md",
|
|
852
|
+
`duplicate audience: ${audience}`,
|
|
853
|
+
"an audience's position in the list is its restriction level — named twice, it has two levels and neither can be trusted",
|
|
854
|
+
`keep one ${audience} entry`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
seen.add(audience);
|
|
858
|
+
}
|
|
859
|
+
if (defaultVisibility === "") {
|
|
860
|
+
problem(
|
|
861
|
+
"instance.md",
|
|
862
|
+
"audiences: without default_visibility:",
|
|
863
|
+
"there is no safe inference for a document that declares no audience: guessing the widest leaks the first document whose key is forgotten, guessing the narrowest hides the record from everyone it was written for",
|
|
864
|
+
`add default_visibility: — one of ${audiences.join(", ")} — the audience a document belongs to when it declares none`,
|
|
865
|
+
);
|
|
866
|
+
} else if (!audiences.includes(defaultVisibility)) {
|
|
867
|
+
problem(
|
|
868
|
+
"instance.md",
|
|
869
|
+
`default_visibility "${defaultVisibility}" is not one of the declared audiences`,
|
|
870
|
+
"every document without a visibility: key takes this value — a default outside the list puts most of the record in an audience that does not exist",
|
|
871
|
+
`use one of: ${audiences.join(", ")}`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
} else if (fm.keys.has("default_visibility")) {
|
|
875
|
+
problem(
|
|
876
|
+
"instance.md",
|
|
877
|
+
"default_visibility: without audiences:",
|
|
878
|
+
"a default audience with no audience list is a setting with nothing to select from — the owner believes the record has a visibility model while every surface publishes every document to everyone",
|
|
879
|
+
"add audiences: (ordered least- to most-restricted, public first), or remove default_visibility:",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
676
882
|
for (const [parent, allowed] of [
|
|
677
883
|
["ksor", INSTANCE_KSOR_KEYS],
|
|
678
884
|
["site", INSTANCE_SITE_KEYS],
|
|
@@ -757,6 +963,9 @@ if (existsSync(siteDir)) {
|
|
|
757
963
|
(p) =>
|
|
758
964
|
!p.includes(`${path.sep}.next${path.sep}`) &&
|
|
759
965
|
!p.includes(`${path.sep}.source${path.sep}`) &&
|
|
966
|
+
// The per-audience stage: generated copies of the record a build makes
|
|
967
|
+
// for one audience, never authored content (specs/ksor/visibility).
|
|
968
|
+
!p.includes(`${path.sep}.staged-knowledge${path.sep}`) &&
|
|
760
969
|
!p.includes(`${path.sep}out${path.sep}`),
|
|
761
970
|
)
|
|
762
971
|
.filter((p) => p.toLowerCase().endsWith(".md") || p.toLowerCase().endsWith(".mdx"));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: intake-interview
|
|
3
|
-
description: The first conversation with the owner of this Knowledge System of Record —
|
|
3
|
+
description: The first conversation with the owner of this Knowledge System of Record — six questions that define what it is authoritative for and who may read it, then write instance.md together. Use when the owner asks to set up, configure, or "get started with" this project, when instance.md still contains its scaffold placeholder text, or when the scope of the corpus is unclear.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.2.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Intake interview
|
|
@@ -12,7 +12,7 @@ prose will one day be the agent surface's system prompt. Do not draft it from
|
|
|
12
12
|
guesses — interview the owner, one question at a time, and write down what
|
|
13
13
|
they actually say.
|
|
14
14
|
|
|
15
|
-
## The
|
|
15
|
+
## The six questions
|
|
16
16
|
|
|
17
17
|
Ask these one at a time; follow up until each answer is concrete enough to
|
|
18
18
|
act on:
|
|
@@ -30,6 +30,18 @@ act on:
|
|
|
30
30
|
5. **Strictness** — "When the record doesn't cover a question, how firmly
|
|
31
31
|
should it decline? ('Not in this corpus' is a correct answer here —
|
|
32
32
|
confirm the owner wants that behavior and where they want it softened.)"
|
|
33
|
+
6. **Audiences** — "Does every reader of this record see every document? If
|
|
34
|
+
not, what are the audiences, from most public to most restricted?" A yes
|
|
35
|
+
is the common answer and the whole answer: write no `audiences:` key and
|
|
36
|
+
nothing about the project changes. A list means writing it into
|
|
37
|
+
`instance.md`'s frontmatter — ordered least- to most-restricted with
|
|
38
|
+
`public` first, plus `default_visibility:` naming the audience a document
|
|
39
|
+
takes when it says nothing (there is no safe guess, so the checker
|
|
40
|
+
requires it). Tell the owner what the key does and does not do:
|
|
41
|
+
documents carry `visibility:` and builds are made per audience, but
|
|
42
|
+
anyone who can clone the repository reads everything in it — if someone
|
|
43
|
+
must not read a document and can clone, that document belongs in a
|
|
44
|
+
different repository.
|
|
33
45
|
|
|
34
46
|
## Then write
|
|
35
47
|
|
|
@@ -37,7 +49,10 @@ act on:
|
|
|
37
49
|
record's **display title**, the human name every page will lead with
|
|
38
50
|
("Acme Operations Handbook", not the slug) — then the authority sentence,
|
|
39
51
|
boundary, audience, and strictness — plain prose, written for a reader
|
|
40
|
-
who must act on it.
|
|
52
|
+
who must act on it. Leave the frontmatter keys alone, with one exception:
|
|
53
|
+
an audience model from question 6 is written there as `audiences:` (a
|
|
54
|
+
list) and `default_visibility:`, and `pnpm check` will hold the record to
|
|
55
|
+
it from that moment on.
|
|
41
56
|
- Restart `pnpm dev` afterwards so the site picks the new title up, and
|
|
42
57
|
show the owner their name on the page.
|
|
43
58
|
- Offer to capture the source list from question 4 as the first real
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: add-sources
|
|
3
3
|
description: Turn source material — documents, pages, pasted text, notes — into governed knowledge in knowledge/. Use when the owner shares material to add, says "add this to the knowledge base", or asks how to get existing content in. Not for editing the site.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.2.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Add sources
|
|
@@ -24,6 +24,9 @@ The rules that make it _governed_ rather than merely stored:
|
|
|
24
24
|
file names, systems, people, dates) whenever the owner can tell you.
|
|
25
25
|
Precision matters: "Finance policy manual §4.2, 2025 edition" governs;
|
|
26
26
|
"internal docs" does not.
|
|
27
|
+
- When `instance.md` declares `audiences:`, ask the owner which audience the
|
|
28
|
+
new material belongs to and write it as `visibility:` — never guess that
|
|
29
|
+
restricted material is public.
|
|
27
30
|
|
|
28
31
|
## Fidelity rules
|
|
29
32
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: format-checker
|
|
3
3
|
description: The record's format rules as a runnable check — frontmatter, filenames, links, structure. Use before handing off any change to knowledge/, when a check fails and you need to fix it, or when unsure whether a document is well-formed. Run with `pnpm check` (or node .agents/skills/format-checker/check.mjs).
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.2.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Format checker
|
|
@@ -25,6 +25,13 @@ enforces what AGENTS.md states in prose:
|
|
|
25
25
|
blocks are code, not links, and are ignored.
|
|
26
26
|
- `instance.md` exists, is `format: 1`, and carries only the keys the format
|
|
27
27
|
defines — an unknown key is named, never ignored.
|
|
28
|
+
- The audience model, when there is one: `audiences:` is ordered least- to
|
|
29
|
+
most-restricted with `public` first, no duplicates, and never without
|
|
30
|
+
`default_visibility:`; a document's `visibility:` names one of the declared
|
|
31
|
+
audiences; and no link or `superseded_by:` points from a wider audience at a
|
|
32
|
+
narrower one — the leak no single build can catch, since the build that
|
|
33
|
+
publishes the pointer has already dropped its target. A record that declares
|
|
34
|
+
no `audiences:` is checked exactly as it was before the key existed.
|
|
28
35
|
- `CLAUDE.md` stays a one-line pointer; `.agents/skills/` and
|
|
29
36
|
`.claude/skills/` hold the same files byte for byte **in both directions**
|
|
30
37
|
(a file only one tree carries is a rule nobody reviewed); the site contains
|