@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
|
@@ -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
|
|
@@ -15,8 +15,11 @@ The record survives the system: `knowledge/` must stay readable and complete
|
|
|
15
15
|
even if `system/` is deleted. Dependency flows one way — the system reads the
|
|
16
16
|
record; the record never references the system.
|
|
17
17
|
|
|
18
|
-
`instance.md` carries a closed key set — `format`, `name`, `ksor`, `site
|
|
19
|
-
and
|
|
18
|
+
`instance.md` carries a closed key set — `format`, `name`, `ksor`, `site`,
|
|
19
|
+
and the optional pair `audiences` + `default_visibility` (the record's reader
|
|
20
|
+
audiences, ordered least- to most-restricted with `public` first, and the one
|
|
21
|
+
a document takes when it names none — declared together or not at all) — and
|
|
22
|
+
everything that matters about it is the prose below that frontmatter;
|
|
20
23
|
`pnpm check` names any other key rather than ignoring it.
|
|
21
24
|
|
|
22
25
|
## Critical rules
|
|
@@ -27,6 +30,8 @@ and everything that matters about it is the prose below that frontmatter;
|
|
|
27
30
|
2. **`knowledge/` is CommonMark `.md` only.** No `.mdx`, no `meta.json`, no
|
|
28
31
|
framework files. A document must read cleanly in any markdown viewer.
|
|
29
32
|
3. **Never edit generated files** — `system/site/.source/`, `.next/`, `out/`,
|
|
33
|
+
`system/site/.staged-knowledge/` (a build's per-audience copy of the
|
|
34
|
+
record — edit `knowledge/`, or the next build erases the change),
|
|
30
35
|
lockfiles by hand.
|
|
31
36
|
|
|
32
37
|
## Commands (run at the repo root)
|
|
@@ -38,6 +43,17 @@ pnpm build # static site into system/site/out/
|
|
|
38
43
|
pnpm check # the format checker — run before handing off any knowledge change
|
|
39
44
|
```
|
|
40
45
|
|
|
46
|
+
## Publishing
|
|
47
|
+
|
|
48
|
+
`pnpm build` emits a fully static site (`system/site/out/`) deployable to
|
|
49
|
+
any host — Vercel reads the shipped `vercel.json` (deploy from the repo
|
|
50
|
+
ROOT, never `system/site/`), and every other host just serves the folder.
|
|
51
|
+
`KSOR_BASE_PATH=/repo pnpm build` targets sub-path hosting. With
|
|
52
|
+
`audiences:` declared, plain `pnpm build` is always the public tier;
|
|
53
|
+
`KSOR_AUDIENCE=<audience> pnpm build` builds a wider tier that belongs
|
|
54
|
+
behind that audience's own access control, never on a public host.
|
|
55
|
+
Details in README → Deploying.
|
|
56
|
+
|
|
41
57
|
## Writing knowledge
|
|
42
58
|
|
|
43
59
|
- One document per file under `knowledge/`; the path is the document's
|
|
@@ -51,10 +67,23 @@ pnpm check # the format checker — run before handing off any knowledge c
|
|
|
51
67
|
- Frontmatter: `title` and `status` (`draft | review | approved | superseded`)
|
|
52
68
|
are required. `owner` and `provenance` (a list naming real sources) are
|
|
53
69
|
strongly encouraged — they become required as this project climbs the
|
|
54
|
-
governance ladder. `description`, `
|
|
55
|
-
(the date the document takes effect) and `superseded`
|
|
56
|
-
prefer `status`) are available. No other keys; never
|
|
57
|
-
path is the identity.
|
|
70
|
+
governance ladder. `description`, `visibility` (below), `order` (sidebar
|
|
71
|
+
position), `effective` (the date the document takes effect) and `superseded`
|
|
72
|
+
(a legacy marker — prefer `status`) are available. No other keys; never
|
|
73
|
+
`id:` or `name:` — the path is the identity.
|
|
74
|
+
- `visibility:` names the one audience a document belongs to — a single value
|
|
75
|
+
from `instance.md`'s `audiences:`, never a list, and orthogonal to `status:`
|
|
76
|
+
(an approved document can be restricted, and a draft is not hidden). Leave
|
|
77
|
+
it off and the document takes `default_visibility`. The key does nothing
|
|
78
|
+
until `instance.md` declares `audiences:`; once it does, `pnpm check`
|
|
79
|
+
refuses any link or `superseded_by:` pointing from a wider audience at a
|
|
80
|
+
narrower one — the leak no single build can catch, because the build that
|
|
81
|
+
publishes the link has already dropped its target.
|
|
82
|
+
|
|
83
|
+
**Publication, not authorship: anyone who can clone the repository reads
|
|
84
|
+
every document regardless of frontmatter; if someone must not read a
|
|
85
|
+
document and can clone, the answer is a second repository.**
|
|
86
|
+
|
|
58
87
|
- A replaced document is marked `status: superseded` with `superseded_by:`
|
|
59
88
|
pointing at its successor — superseded documents are never deleted.
|
|
60
89
|
- Images and assets live in `knowledge/` beside the document that uses them,
|
|
@@ -94,7 +123,7 @@ You own `system/site/` outright — these are the seams, cheapest first:
|
|
|
94
123
|
and the home-page mark are the same file.
|
|
95
124
|
- **Anything deeper** — edit the site like the Next.js app it is; the only
|
|
96
125
|
rule that survives customization is critical rule 1. The whole shell is
|
|
97
|
-
replaceable behind a
|
|
126
|
+
replaceable behind a five-clause contract (a themed Docusaurus shell with
|
|
98
127
|
a swap recipe lives in the ksor repository under `workbench/shells/`).
|
|
99
128
|
|
|
100
129
|
## What this project owns
|
|
@@ -55,6 +55,33 @@ and how to fix it.
|
|
|
55
55
|
Everything here is yours to change. The kit exists so that any coding agent can
|
|
56
56
|
operate this project without being taught it first.
|
|
57
57
|
|
|
58
|
+
## Deploying
|
|
59
|
+
|
|
60
|
+
The built site is a folder of files — 2 MB of HTML, JS and CSS with zero
|
|
61
|
+
host-specific dependencies. `pnpm build` writes it to `system/site/out/`,
|
|
62
|
+
and anything that can serve files can serve it.
|
|
63
|
+
|
|
64
|
+
- **Vercel** — connect the repository (or run `vercel`); the shipped
|
|
65
|
+
`vercel.json` answers the setup interview: deploy from the repo root
|
|
66
|
+
(never pin `system/site` as the root directory — the record lives
|
|
67
|
+
outside it), build with `pnpm build`, serve `system/site/out/`. If the
|
|
68
|
+
build image's pnpm predates the `packageManager` pin, set the
|
|
69
|
+
`ENABLE_EXPERIMENTAL_COREPACK=1` build environment variable.
|
|
70
|
+
- **GitHub Pages, nginx, S3, anything static** — run `pnpm build` and
|
|
71
|
+
upload `system/site/out/`. Hosted under a sub-path (like
|
|
72
|
+
`user.github.io/repo`)? Build with `KSOR_BASE_PATH=/repo pnpm build`.
|
|
73
|
+
- **Verify any deploy** the same way: the home page, one document page,
|
|
74
|
+
and `/llms.txt` all load; nothing else is required.
|
|
75
|
+
|
|
76
|
+
If `instance.md` declares `audiences:`, what you deploy is a **tier**.
|
|
77
|
+
Plain `pnpm build` always builds the public tier — safe for any host.
|
|
78
|
+
`KSOR_AUDIENCE=<audience> pnpm build` builds a wider tier for that
|
|
79
|
+
audience's own deployment, and that build carries an
|
|
80
|
+
"— not for publication" label because it must never reach a public host:
|
|
81
|
+
put it behind access control you already trust (VPN, SSO proxy,
|
|
82
|
+
authenticated host). The tiers govern what a build contains; where each
|
|
83
|
+
build may be served is yours to enforce.
|
|
84
|
+
|
|
58
85
|
## Ownership
|
|
59
86
|
|
|
60
87
|
Everything here is yours. The scaffold was generated by
|
|
@@ -5,6 +5,9 @@ node_modules/
|
|
|
5
5
|
system/site/.next/
|
|
6
6
|
system/site/.source/
|
|
7
7
|
system/site/out/
|
|
8
|
+
# the per-audience copy of the record a build stages — a filtered derivative,
|
|
9
|
+
# never a second record; committing it would publish what a build excluded
|
|
10
|
+
system/site/.staged-knowledge/
|
|
8
11
|
*.tsbuildinfo
|
|
9
12
|
|
|
10
13
|
# secrets never enter the record — system/ is their future home (serve)
|
|
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|
|
3
3
|
// The same file Next serves as the favicon (app/icon.png) — one mark, one
|
|
4
4
|
// asset. Replace it with your own and the tab icon changes with the page.
|
|
5
5
|
import mark from "@/app/icon.png";
|
|
6
|
-
import {
|
|
6
|
+
import { FooterMark } from "@/components/footer-mark";
|
|
7
7
|
import { appName, appTitle } from "@/lib/shared";
|
|
8
8
|
import { basePath, getSortedPages } from "@/lib/source";
|
|
9
9
|
|
|
@@ -75,7 +75,7 @@ export default function HomePage() {
|
|
|
75
75
|
|
|
76
76
|
<footer className="mx-auto w-full max-w-2xl px-6 pb-10">
|
|
77
77
|
<p className="border-t border-fd-border pt-6 text-xs">
|
|
78
|
-
<
|
|
78
|
+
<FooterMark />
|
|
79
79
|
</p>
|
|
80
80
|
</footer>
|
|
81
81
|
</main>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getSortedPageTree } from "@/lib/source";
|
|
2
2
|
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
|
3
3
|
import { baseOptions } from "@/lib/layout.shared";
|
|
4
|
-
import {
|
|
4
|
+
import { FooterMark } from "@/components/footer-mark";
|
|
5
5
|
|
|
6
6
|
export default function Layout({ children }: LayoutProps<"/docs">) {
|
|
7
7
|
return (
|
|
@@ -13,7 +13,7 @@ export default function Layout({ children }: LayoutProps<"/docs">) {
|
|
|
13
13
|
sidebar={{
|
|
14
14
|
footer: (
|
|
15
15
|
<p className="mt-3 text-xs">
|
|
16
|
-
<
|
|
16
|
+
<FooterMark />
|
|
17
17
|
</p>
|
|
18
18
|
),
|
|
19
19
|
}}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { BuiltWith } from "@/components/built-with";
|
|
3
|
+
import { audienceNotice } from "@/lib/audience";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The foot of the site chrome: who built it, and — on any build below the
|
|
7
|
+
* public tier — which audience that build was for, so a screenshot of an
|
|
8
|
+
* internal site names itself and a page that escapes carries its own
|
|
9
|
+
* warning.
|
|
10
|
+
*
|
|
11
|
+
* The public build renders the attribution ALONE, exactly as a site with no
|
|
12
|
+
* audience model does: the one build with nothing to disclose must not even
|
|
13
|
+
* carry the shape of a disclosure.
|
|
14
|
+
*/
|
|
15
|
+
export function FooterMark(): ReactElement {
|
|
16
|
+
if (audienceNotice === null) return <BuiltWith />;
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<BuiltWith /> · <span className="text-fd-muted-foreground">{audienceNotice}</span>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|