@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 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`. The site shell
44
- at `system/site/` is replaceable behind a four-clause surface contract; a
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.2",
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.1.0"
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.1.0"
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
- * has list items). Returns null when there is no block at all, and collects
100
- * every line that is neither `key: value`, a list item, an indented
101
- * continuation, nor blank those mean the block was never closed.
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 { keys, children, quoted, malformedQuote, duplicates, malformed, tightColons, tabIndents };
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))) checkLinkTarget(rel, p, target);
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(["format", "name", "ksor", "site"]);
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 — five questions that define what it is authoritative for, 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.
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.1.0"
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 five questions
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. Do not touch the frontmatter keys.
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.1.0"
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.1.0"
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