@panaversity/ksor 0.0.26 → 0.0.28

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.mjs +67 -4
  3. package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
  4. package/dist/gateway.mjs +1 -1
  5. package/package.json +1 -1
  6. package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
  7. package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
  8. package/templates/scaffold/AGENTS.md +49 -0
  9. package/templates/scaffold/Dockerfile +10 -6
  10. package/templates/scaffold/README.md +3 -2
  11. package/templates/scaffold/dockerignore +5 -0
  12. package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
  13. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
  14. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
  15. package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
  16. package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
  17. package/templates/scaffold/system/site/components/governance.tsx +17 -25
  18. package/templates/scaffold/system/site/components/record-views.tsx +241 -0
  19. package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
  20. package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
  21. package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
  22. package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
  23. package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
  24. package/templates/scaffold/system/site/lib/attachments.ts +105 -0
  25. package/templates/scaffold/system/site/lib/deck.ts +59 -0
  26. package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
  27. package/templates/scaffold/system/site/lib/srs.ts +219 -0
  28. package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
  29. package/templates/scaffold/system/site/source.config.ts +54 -1
  30. package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
@@ -11,6 +11,7 @@ import {
11
11
  } from "node:fs";
12
12
  import path from "node:path";
13
13
 
14
+ import { ATTACHMENT_SUFFIXES, isAttachment, parentDocumentOf } from "./attachment-rule";
14
15
  import { audienceModel, buildAudience, refuse, visibleInBuild } from "./audience";
15
16
  import { isDenied, recordPathFrom, stableIdFrom, type DenylistManifest } from "./denial-rule";
16
17
  import { appName, instanceFrontmatter } from "./shared";
@@ -316,11 +317,22 @@ function stableIdOf(recordDir: string, file: string, text: string): string {
316
317
  );
317
318
  }
318
319
 
320
+ /**
321
+ * The suffixes staging probes for — DERIVED from the shared rule, so a new
322
+ * attachment kind cannot be added there and forgotten here.
323
+ */
324
+ const ATTACHMENT_SUFFIXES_FOR_STAGE: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
325
+
319
326
  function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
320
327
  const documents: string[] = [];
321
328
  const assets = new Set<string>();
322
329
  let total = 0;
323
330
  for (const file of walkFiles(recordDir)) {
331
+ // An attachment is not a document: it is neither counted nor filtered on
332
+ // its own terms. It rides in below, with the parent that survived — which
333
+ // is the whole of governance inheritance, obtained by POSITION rather than
334
+ // by a second rule that could disagree with this one.
335
+ if (isAttachment(path.basename(file))) continue;
324
336
  if (!file.toLowerCase().endsWith(".md")) continue;
325
337
  total += 1;
326
338
  const text = readFileSync(file, "utf8");
@@ -332,6 +344,13 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
332
344
  if (isDenied(denied, stableIdOf(recordDir, file, text), recordPathOf(recordDir, file)))
333
345
  continue;
334
346
  documents.push(file);
347
+ // The parent survived BOTH filters, so its attachments may be published.
348
+ // Reached only here: a filtered or denied parent never gets this far, so
349
+ // there is no path on which an attachment is staged without its parent.
350
+ for (const suffix of ATTACHMENT_SUFFIXES_FOR_STAGE) {
351
+ const attachment = file.replace(/\.mdx?$/i, "") + suffix;
352
+ if (existsSync(attachment)) assets.add(attachment);
353
+ }
335
354
  // Body only: frontmatter carries no links in the record grammar, and
336
355
  // scanning it here while the other shell strips it staged different
337
356
  // asset sets from one record (review finding, 2026-08-18).
@@ -539,6 +558,53 @@ function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest
539
558
  * deleted or mistyped `audiences:` block would otherwise publish every
540
559
  * restricted document on a green build (vis-docusaurus, 2026-08-18).
541
560
  */
561
+ /**
562
+ * Attachments the record cannot publish, refused at the BUILD.
563
+ *
564
+ * Staging never depends on the checker having run, so both rules need a home
565
+ * here as well as in `pnpm check` — the checker is where they get a good
566
+ * message, this is where they are guaranteed.
567
+ *
568
+ * Runs on every path, including the level-0 fast path that stages nothing:
569
+ * an orphan is a governance hole whether or not this record declares
570
+ * audiences.
571
+ */
572
+ function assertAttachmentsWellFormed(recordDir: string): void {
573
+ for (const file of walkFiles(recordDir)) {
574
+ const base = path.basename(file);
575
+ if (!isAttachment(base)) continue;
576
+ const rel = path.relative(recordDir, file);
577
+
578
+ const parent = parentDocumentOf(base);
579
+ if (parent !== null && !existsSync(path.join(path.dirname(file), parent))) {
580
+ refuse(
581
+ "ksor-attachment-orphan",
582
+ `${rel} is an attachment of ${parent}, which is not in the record`,
583
+ "an attachment inherits its parent's governance — with no parent there is nothing to inherit, so it would be published under no tier and covered by no takedown",
584
+ `add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
585
+ );
586
+ }
587
+
588
+ // No frontmatter, at all. One rule kills the whole widening class:
589
+ // no `visibility:` claiming a tier the parent does not have, no `sor_id:`
590
+ // escaping the parent's takedown, no `status:`/`owner:` claiming
591
+ // governance a thing with no id cannot carry.
592
+ if (base.toLowerCase().endsWith(".md") || base.toLowerCase().endsWith(".mdx")) {
593
+ const text = readFileSync(file, "utf8")
594
+ .replace(/^\uFEFF/, "")
595
+ .replaceAll("\r\n", "\n");
596
+ if (text.startsWith("---\n")) {
597
+ refuse(
598
+ "ksor-attachment-frontmatter",
599
+ `${rel} declares frontmatter`,
600
+ "an attachment is part of its parent and carries none of its own governance — a key here would look like it governs something and would govern nothing",
601
+ `remove the frontmatter block from ${rel}; ${parent ?? "its parent"} is what carries the governance`,
602
+ );
603
+ }
604
+ }
605
+ }
606
+ }
607
+
542
608
  function refuseVisibilityWithoutAudiences(recordDir: string): void {
543
609
  for (const file of walkFiles(recordDir)) {
544
610
  if (!file.toLowerCase().endsWith(".md")) continue;
@@ -640,9 +706,11 @@ export function knowledgeSourceDir(): string {
640
706
  // taking a lock on every build.
641
707
  if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
642
708
  refuseVisibilityWithoutAudiences(recordDir);
709
+ assertAttachmentsWellFormed(recordDir);
643
710
  return RECORD_DIR;
644
711
  }
645
712
  if (audienceModel === null) refuseVisibilityWithoutAudiences(recordDir);
713
+ assertAttachmentsWellFormed(recordDir);
646
714
  fillStage(recordDir, stageDir, denied);
647
715
  watchRecord(recordDir, stageDir);
648
716
  return STAGE_DIR;
@@ -1,6 +1,7 @@
1
- import { defineConfig, defineDocs } from "fumadocs-mdx/config";
1
+ import { defineCollections, defineConfig, defineDocs } from "fumadocs-mdx/config";
2
2
  import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
3
3
  import { z } from "zod";
4
+ import { DeckSchema } from "./lib/deck";
4
5
  import { knowledgeSourceDir } from "./lib/stage-knowledge";
5
6
 
6
7
  // The record lives at <repo>/knowledge — two levels up from this site.
@@ -15,6 +16,15 @@ import { knowledgeSourceDir } from "./lib/stage-knowledge";
15
16
  export const docs = defineDocs({
16
17
  dir: knowledgeSourceDir(),
17
18
  docs: {
19
+ // ONE exclusion, and it is the whole of "an attachment is not a document".
20
+ // The route table, the sidebar, llms.txt, llms-full.txt, /md/, the search
21
+ // index and the caveat map ALL read `source`, and `source` reads exactly
22
+ // this collection — so subtracting attachments here subtracts them from
23
+ // every surface at once. Doing it per-surface instead is the failure mode
24
+ // research/visibility.md §4-§5 is cited for; pruning the page tree is not
25
+ // even sufficient, because getSortedPages() deliberately re-adds what the
26
+ // tree dropped and the search index never consults the tree.
27
+ files: ["**/*.md", "**/*.mdx", "!**/*.summary.md", "!**/*.summary.mdx"],
18
28
  schema: pageSchema
19
29
  .extend({
20
30
  status: z.string().optional(),
@@ -30,10 +40,53 @@ export const docs = defineDocs({
30
40
  },
31
41
  },
32
42
  meta: {
43
+ // PINNED, and not optional. The default meta glob is `**/*.{yaml,json}`,
44
+ // which swallows every `<doc>.flashcards.yaml` in the record and fails the
45
+ // build with a zod error naming neither the file's purpose nor the rule
46
+ // (verified against the real record: the default glob returned the deck).
47
+ files: ["**/meta.{json,yaml}"],
33
48
  schema: metaSchema,
34
49
  },
35
50
  });
36
51
 
52
+ /**
53
+ * A document's summary, rendered on the document's own page.
54
+ *
55
+ * Its own collection rather than a page: it goes through the SAME MDX pipeline
56
+ * as the record — the same prose voice, code handling and heading anchors — but
57
+ * is never handed to `loader()`, so it has no route and appears on no agent
58
+ * surface. The parent's own bytes are untouched by its presence.
59
+ */
60
+ export const summaries = defineCollections({
61
+ type: "doc",
62
+ dir: knowledgeSourceDir(),
63
+ files: ["**/*.summary.md"],
64
+ postprocess: {
65
+ // So the page can count the summary's words for its reading time without
66
+ // reading the file again. `getText("raw")` would go back to disk, and it
67
+ // resolves against the working directory rather than the collection's dir
68
+ // — which fails the export outright (found live: ENOENT on
69
+ // knowledge/<doc>.md during prerender).
70
+ includeProcessedMarkdown: true,
71
+ },
72
+ });
73
+
74
+ /**
75
+ * A document's recall deck.
76
+ *
77
+ * `type: "meta"` because fumadocs parses YAML for meta collections itself
78
+ * (dist/meta-BR_rkCyY.js: `.yaml` -> yaml.parse, `.json` -> JSON.parse, and
79
+ * anything else throws). That is why this needs no YAML parser and no new
80
+ * dependency — `yaml` is already fumadocs-mdx's own — and why the extension is
81
+ * exactly `.yaml`: `.yml` reaches that `throw` and names only the path.
82
+ */
83
+ export const decks = defineCollections({
84
+ type: "meta",
85
+ dir: knowledgeSourceDir(),
86
+ files: ["**/*.flashcards.yaml"],
87
+ schema: DeckSchema,
88
+ });
89
+
37
90
  export default defineConfig({
38
91
  mdxOptions: {
39
92
  // MDX options
@@ -1,70 +0,0 @@
1
- "use client";
2
-
3
- import { useCopyButton } from "fumadocs-ui/utils/use-copy-button";
4
- import { Check, Copy } from "lucide-react";
5
- import { useState, type ReactElement } from "react";
6
-
7
- /**
8
- * Hand this document to an agent: copy the governed markdown, verbatim.
9
- *
10
- * The record's second audience reads bytes, and until now a reader who wanted
11
- * to give an agent a document had to open its markdown twin and select the
12
- * page. This fetches that same twin — the one `/md/<path>.md` already serves,
13
- * so there is no second rendering of the document to drift — and puts it on the
14
- * clipboard.
15
- *
16
- * Fumadocs ships an `ai/page-actions` component that does this alongside "Open
17
- * in ChatGPT" and "Open in Claude". Those two are deliberately NOT taken: this
18
- * product's claim is that one corpus answers in ANY assistant because the
19
- * surface is an open standard, and hardcoding two vendors into every adopter's
20
- * page argues the opposite. What is taken is the shell's own `useCopyButton`,
21
- * which owns the copied-state timing — the only part worth not rewriting.
22
- *
23
- * It rests in the muted grey the rest of the row's metadata wears and takes the
24
- * accent only when it has actually copied. An accent at REST said "link" on a
25
- * row of facts and added to a page that was already too blue; an accent on the
26
- * state CHANGE is the one thing the accent is for.
27
- */
28
- export function CopyMarkdown({ href }: { href: string }): ReactElement {
29
- const [failed, setFailed] = useState(false);
30
- const [copied, onClick] = useCopyButton(async () => {
31
- setFailed(false);
32
- try {
33
- // `navigator.clipboard` exists only in a secure context, so a site served
34
- // over plain http on a LAN address has no clipboard at all. Saying so
35
- // beats a button that reports success it did not have.
36
- if (navigator.clipboard === undefined) throw new Error("no clipboard");
37
- const response = await fetch(href);
38
- if (!response.ok) throw new Error(`markdown twin returned ${response.status}`);
39
- await navigator.clipboard.writeText(await response.text());
40
- } catch {
41
- setFailed(true);
42
- }
43
- });
44
-
45
- return (
46
- <button
47
- type="button"
48
- onClick={onClick}
49
- // aria-live, because the label is the only feedback: a screen reader that
50
- // does not hear "Copied" is told nothing happened at all.
51
- aria-live="polite"
52
- // The hover tone is dropped while copied, not overridden: a `hover:`
53
- // variant is a class AND a pseudo-class, so it outranks a plain colour
54
- // class — and the pointer is by definition still over the button at the
55
- // moment it has just been clicked, so the success colour never showed
56
- // (measured 2026-08-22: lab(4.41), the foreground, where the accent
57
- // belonged).
58
- className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-[0.6875rem] tracking-[0.14em] uppercase transition-colors hover:bg-fd-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring ${
59
- copied ? "text-fd-primary" : "text-fd-muted-foreground hover:text-fd-foreground"
60
- }`}
61
- >
62
- {copied ? (
63
- <Check aria-hidden className="size-3.5" />
64
- ) : (
65
- <Copy aria-hidden className="size-3.5" />
66
- )}
67
- {failed ? "Copy failed" : copied ? "Copied" : "Copy"}
68
- </button>
69
- );
70
- }