@panaversity/ksor 0.0.39 → 0.0.41

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 (108) hide show
  1. package/CHANGELOG.md +965 -0
  2. package/README.md +29 -9
  3. package/dist/checker/check-main.mjs +14049 -0
  4. package/dist/cli.mjs +11528 -5213
  5. package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
  6. package/dist/gateway.d.mts +52 -13
  7. package/dist/gateway.mjs +2 -2
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
  11. package/docs/authorization.md +8 -6
  12. package/docs/deploying.md +36 -25
  13. package/docs/index.md +26 -13
  14. package/docs/ingesting.md +70 -22
  15. package/docs/tool-surface.md +69 -16
  16. package/package.json +4 -3
  17. package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
  18. package/schema/schema.sql +77 -14
  19. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
  20. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
  21. package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
  22. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
  23. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
  24. package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
  25. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
  26. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
  27. package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
  28. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
  29. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
  30. package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
  31. package/templates/scaffold/.github/workflows/validate.yml +9 -1
  32. package/templates/scaffold/.ksor/governance.yaml +17 -0
  33. package/templates/scaffold/AGENTS.md +293 -119
  34. package/templates/scaffold/Dockerfile +5 -1
  35. package/templates/scaffold/README.md +160 -42
  36. package/templates/scaffold/env.example +37 -6
  37. package/templates/scaffold/gitignore +14 -6
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +11 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
  42. package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
  43. package/templates/scaffold/knowledge/surfaces/index.md +4 -20
  44. package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
  45. package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
  46. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
  47. package/templates/scaffold/package.json +3 -4
  48. package/templates/scaffold/pnpm-lock.yaml +3 -0
  49. package/templates/scaffold/system/gateways/content.ts +13 -0
  50. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  51. package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
  52. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
  53. package/templates/scaffold/system/site/app/global.css +198 -6
  54. package/templates/scaffold/system/site/app/layout.tsx +8 -3
  55. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
  56. package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
  57. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
  58. package/templates/scaffold/system/site/components/code-block.tsx +87 -0
  59. package/templates/scaffold/system/site/components/embed.tsx +276 -0
  60. package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
  61. package/templates/scaffold/system/site/components/governance.tsx +219 -87
  62. package/templates/scaffold/system/site/components/mdx.tsx +10 -2
  63. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  64. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  65. package/templates/scaffold/system/site/components/record-views.tsx +6 -17
  66. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  67. package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
  68. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  69. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  70. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  71. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  72. package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
  73. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  74. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  75. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  76. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  77. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  78. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  79. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  80. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  81. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  82. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  83. package/templates/scaffold/system/site/lib/source.ts +256 -186
  84. package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
  85. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  86. package/templates/scaffold/system/site/package.json +1 -0
  87. package/templates/scaffold/system/site/record/actor.ts +23 -0
  88. package/templates/scaffold/system/site/record/check.ts +571 -0
  89. package/templates/scaffold/system/site/record/citations.ts +312 -0
  90. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  91. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  92. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  93. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  94. package/templates/scaffold/system/site/record/index.ts +103 -0
  95. package/templates/scaffold/system/site/record/instance.ts +257 -0
  96. package/templates/scaffold/system/site/record/instant.ts +43 -0
  97. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  98. package/templates/scaffold/system/site/record/load.ts +129 -0
  99. package/templates/scaffold/system/site/record/lock.ts +306 -0
  100. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  101. package/templates/scaffold/system/site/record/policy.ts +414 -0
  102. package/templates/scaffold/system/site/record/profile.ts +535 -0
  103. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  104. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  105. package/templates/scaffold/system/site/source.config.ts +109 -23
  106. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  107. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  108. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -262,7 +262,7 @@ html > body[data-scroll-locked] {
262
262
  /* The caution colour is a TOKEN, declared as a light/dark pair on the root
263
263
  rather than inside the one class that first used it. Every surface that has
264
264
  to say "the record withdrew this" now spends the same colour — the banner
265
- over a superseded document, and the chip that marks it in the sidebar, the
265
+ over a deprecated document, and the chip that marks it in the sidebar, the
266
266
  listings, the front door and search. Declared in both themes deliberately:
267
267
  a colour written only on `:root` leaks into dark, because `:root` and
268
268
  `.dark` have equal specificity and whichever comes later wins. */
@@ -489,10 +489,10 @@ html > body[data-scroll-locked] {
489
489
  }
490
490
 
491
491
  /* A withdrawn document's status chip, wherever one renders. A `draft` chip and
492
- a `superseded` chip were pixel-identical — the same hairline border and the
492
+ a `deprecated` chip were pixel-identical — the same hairline border and the
493
493
  same muted text — so the two statuses that mean the most different things
494
494
  looked the same at the moment a reader picks between two documents. The
495
- colour is additive, never the whole signal: the word "superseded" is beside
495
+ colour is additive, never the whole signal: the word "deprecated" is beside
496
496
  it in every one of these places (GOV.UK's rule — never rely on colour alone).
497
497
  Unlayered, so it beats the Tailwind utility that sets the chip's border and
498
498
  text colour; the banner class above has always worked this way. */
@@ -519,8 +519,16 @@ html > body[data-scroll-locked] {
519
519
  }
520
520
 
521
521
  /* …and in search too, where a reader is choosing between results. The chip is
522
- drawn by `content`, so the tone has to be set on the same pseudo-element. */
523
- [data-ksor-status="superseded"]::after {
522
+ drawn by `content`, so the tone has to be set on the same pseudo-element.
523
+
524
+ found live 2026-08-25: this selector still read `superseded`, the PRE-PROFILE
525
+ status word. Under the profile the withdrawn state is `deprecated` (record
526
+ spec §2.2), so it matched nothing and a withdrawn document's search row wore
527
+ the ordinary grey chip — on the one surface whose snippet quotes the
528
+ withdrawn figure, which is why the rule exists. The value here is
529
+ `lifecycleBadge`'s, not `status`'s; the two agree on this word and the badge
530
+ is what the map carries. */
531
+ [data-ksor-status="deprecated"]::after {
524
532
  border-color: color-mix(in oklab, var(--ksor-caution) 45%, transparent);
525
533
  color: var(--ksor-caution);
526
534
  }
@@ -678,13 +686,53 @@ html > body[data-scroll-locked] {
678
686
  font-weight: 500;
679
687
  letter-spacing: 0.14em;
680
688
  text-transform: uppercase;
681
- color: var(--color-fd-muted-foreground);
689
+ /* The record's own accent, at reading strength. The labels were grey on
690
+ near-white and the head did not read as a head at all — on a three-column
691
+ table the eye had to work out which row was the label. */
692
+ color: color-mix(in oklab, var(--color-fd-primary) 72%, var(--color-fd-foreground));
693
+ background: color-mix(in oklab, var(--color-fd-primary) 7%, var(--color-fd-muted));
694
+ }
695
+
696
+ /* The head is a band, so it is ruled off from the body rather than floating
697
+ above it — and the rule is the accent's, not the hairline's, so the two read
698
+ as one object. */
699
+ #nd-page .prose thead {
700
+ border-bottom: 2px solid color-mix(in oklab, var(--color-fd-primary) 28%, var(--color-fd-border));
701
+ }
702
+
703
+ .dark #nd-page .prose thead th {
704
+ /* Against near-black the same 7% is almost nothing, and the same 72% ink is
705
+ too bright to sit under body text. */
706
+ color: color-mix(in oklab, var(--color-fd-primary) 60%, var(--color-fd-foreground));
707
+ background: color-mix(in oklab, var(--color-fd-primary) 12%, var(--color-fd-muted));
682
708
  }
683
709
 
684
710
  #nd-page .prose tbody tr {
685
711
  border-bottom: 1px solid var(--color-fd-border);
686
712
  }
687
713
 
714
+ /* And every other row carries a wash. A hairline alone is enough on a
715
+ two-column table; across three columns of wrapped prose the eye loses which
716
+ cells belong together, because the gap between rows and the gap between
717
+ lines INSIDE a cell are the same distance. The band is what holds a row
718
+ together over that distance.
719
+
720
+ Faint on purpose: it groups, it does not label. Anything stronger competes
721
+ with the head, which is the row that is meant to be read first. */
722
+ #nd-page .prose tbody tr:nth-child(even) {
723
+ background: var(--color-fd-background);
724
+ }
725
+
726
+ #nd-page .prose tbody tr:nth-child(odd) {
727
+ background: color-mix(in oklab, var(--color-fd-border) 14%, var(--color-fd-muted));
728
+ }
729
+
730
+ .dark #nd-page .prose tbody tr:nth-child(odd) {
731
+ /* A step of the same SIZE reads much stronger against near-black, so the
732
+ dark pair takes less of the border to land at the same apparent distance. */
733
+ background: color-mix(in oklab, var(--color-fd-border) 8%, var(--color-fd-muted));
734
+ }
735
+
688
736
  #nd-page .prose tbody tr:last-child {
689
737
  border-bottom: 0;
690
738
  }
@@ -731,6 +779,117 @@ html > body[data-scroll-locked] {
731
779
  background: var(--color-fd-muted);
732
780
  }
733
781
 
782
+ /* A block with no language is not code — it is a passage to reproduce
783
+ verbatim: a prompt to type, a command to say, a message to paste. This
784
+ record has eight, and they arrived as walls of tight monospace, set for
785
+ scanning columns of code when what is in them is sentences.
786
+
787
+ The highlighter's own output is the selector, so nothing has to be authored
788
+ and nothing changes for the record: shiki gives every token in a HIGHLIGHTED
789
+ block an inline `--shiki-light`, and a plain one has none. `:has()` asks
790
+ that question directly.
791
+
792
+ Prose leading, because the lines are sentences; and the accent down the left
793
+ edge, which is what the rest of this site uses to mark structure — here it
794
+ says "reproduce this exactly", which is the one thing these blocks have in
795
+ common. */
796
+ #nd-page .prose figure.shiki:not(:has(code span[style*="--shiki-light"])) {
797
+ border-left: 3px solid color-mix(in oklab, var(--color-fd-primary) 55%, var(--color-fd-border));
798
+ }
799
+
800
+ #nd-page .prose figure.shiki:not(:has(code span[style*="--shiki-light"])) code {
801
+ line-height: 1.7;
802
+ }
803
+
804
+ /* Showing the whole of a long line, when the reader asks for it.
805
+ components/code-block.tsx puts the button on the block and sets the
806
+ attribute; this is what the attribute means.
807
+
808
+ `pre` is `w-max` in the shell, which sizes it to the longest line — that is
809
+ what makes wrapping impossible, so the width has to come back to the column
810
+ before `pre-wrap` can do anything. A wrapped line is then indented under its
811
+ own, the way a terminal marks a continuation, because otherwise a two-line
812
+ command reads as two commands.
813
+
814
+ Wrapping is NOT the default. It was, for one commit, and it is worse for the
815
+ blocks that do not need it: a yaml file rewrapped at the column reads as
816
+ though its indentation means something else. */
817
+ #nd-page .prose figure.shiki[data-wrapped] pre {
818
+ width: 100%;
819
+ min-width: 0;
820
+ }
821
+
822
+ #nd-page .prose figure.shiki[data-wrapped] code > * {
823
+ white-space: pre-wrap;
824
+ overflow-wrap: anywhere;
825
+ /* The hang, plus the gutter below — so a continuation is indented from the
826
+ text, not from the block's edge. */
827
+ padding-left: calc(1rem + 2ch);
828
+ text-indent: -2ch;
829
+ }
830
+
831
+ /* A block needs a gutter on both sides. Unwrapped, the shell gives one on the
832
+ left with `ps-*` and lets a long line run under the buttons on the right,
833
+ because the line can always be scrolled clear of them. Wrapped, it cannot:
834
+ the first line ends under the copy button with no way to move it, and the
835
+ left edge sits on the rule (seen live). So the wrapped state pays for its
836
+ own margins — a gutter left, and enough right for two 24px buttons. */
837
+ #nd-page .prose figure.shiki[data-wrapped] code {
838
+ padding-inline: 0 4rem;
839
+ }
840
+
841
+ #nd-page .prose figure.shiki[data-wrapped] .fd-scroll-container {
842
+ padding-inline-start: 0;
843
+ }
844
+
845
+ /* And a hairline where a scrollbar appears, in place of the platform's slab. */
846
+ #nd-page .prose figure.shiki .fd-scroll-container {
847
+ scrollbar-width: thin;
848
+ scrollbar-color: var(--color-fd-border) transparent;
849
+ }
850
+ /* A callout has to look like a callout. The same `bg-fd-card` trap as the code
851
+ block above: fumadocs paints the panel with `--card`, which in this palette
852
+ is all but the page colour, so a Note and a Warning both arrived as a white
853
+ box with a coloured hair down one edge — the kind was legible only in the
854
+ icon.
855
+
856
+ Fumadocs already puts the kind's own colour on the element as
857
+ `--callout-color`, so ONE rule tints every kind: a wash of that colour over
858
+ `--muted`, the token that means "a surface on the page". No per-kind
859
+ selector, and a kind added upstream is tinted without touching this.
860
+
861
+ 6% in light and 10% in dark, because the same fraction reads as a different
862
+ amount of colour against near-white and against near-black. Kept low on
863
+ purpose: this is the record's own accent discipline — the tint says which
864
+ kind, it does not compete with the prose. */
865
+ #nd-page .prose [style*="--callout-color"] {
866
+ background: color-mix(in oklab, var(--callout-color) 6%, var(--color-fd-muted));
867
+ border-color: color-mix(in oklab, var(--callout-color) 20%, var(--color-fd-border));
868
+ /* The shadow was doing the work the colour now does, and lifted the panel
869
+ off the page in a way nothing else on it does. */
870
+ box-shadow: none;
871
+
872
+ /* THE RULE DOWN THE LEFT EDGE — the same device the verbatim block uses, so
873
+ "this passage is set apart" looks the same everywhere it happens, whatever
874
+ kind of thing is set apart. Fumadocs draws a half-pixel bar INSIDE the
875
+ panel instead, which is too faint to read as a marker at all.
876
+
877
+ In the kind's own colour, which for a Note — much the commonest — is the
878
+ accent, so the standard case is the blue line. */
879
+ border-left: 3px solid color-mix(in oklab, var(--callout-color) 65%, var(--color-fd-border));
880
+ }
881
+
882
+ /* And the inner bar goes, now that the edge carries it. Two rules for the same
883
+ job, a pixel apart, read as a mistake. */
884
+ #nd-page .prose [style*="--callout-color"] > div[class*="w-0.5"] {
885
+ display: none;
886
+ }
887
+
888
+ .dark #nd-page .prose [style*="--callout-color"] {
889
+ background: color-mix(in oklab, var(--callout-color) 10%, var(--color-fd-muted));
890
+ border-color: color-mix(in oklab, var(--callout-color) 24%, var(--color-fd-border));
891
+ }
892
+
734
893
  /* Re-assert the shell's own reset, which the ramp above broke. The prose
735
894
  plugin zeroes the first block's top margin, but writes that rule as
736
895
  `.prose :where(> :first-child)`, and `:where()` contributes nothing to
@@ -743,6 +902,39 @@ html > body[data-scroll-locked] {
743
902
  margin-top: 0;
744
903
  }
745
904
 
905
+ /* A numbered list is a sequence, and its numbers should say so. The marker
906
+ arrived grey at regular weight, lighter than the text it counts, so a list
907
+ of six steps read as six paragraphs that happened to start with digits.
908
+
909
+ The accent, and only here: a number is structure, which is what this
910
+ record's accent is for. */
911
+ #nd-page .prose ol > li::marker {
912
+ color: var(--color-fd-primary);
913
+ font-weight: 600;
914
+ }
915
+
916
+ /* And the TERM a list item defines. `**Heartbeat:** a schedule that…` is the
917
+ commonest shape in this record — a term, then what it means — and the term
918
+ is the thing a reader scans for.
919
+
920
+ Only the term. Bold elsewhere in the sentence stays ink, because a page
921
+ where every emphasis is coloured has no emphasis: the accent stops meaning
922
+ "this is the one to find" and starts meaning "this is bold". */
923
+ #nd-page .prose li > strong:first-child,
924
+ #nd-page .prose li > p:first-child > strong:first-child {
925
+ color: var(--color-fd-primary);
926
+ }
927
+
928
+ /* The same rule, one level in. The document's first block is not a child of
929
+ `.prose` — it is inside the views wrapper, and inside a tab panel when the
930
+ document has a summary — so the reset above never reached it and the top of
931
+ every document sat 20px lower than the rule above it intended (measured
932
+ after the empty view strip was removed, which is what exposed it). */
933
+ #nd-page .prose > .ksor-views > :first-child,
934
+ #nd-page .prose > .ksor-views [role="tabpanel"] > :first-child {
935
+ margin-top: 0;
936
+ }
937
+
746
938
  /* ── The page pager ──────────────────────────────────────────────────────────
747
939
  Fumadocs lays the neighbours out as a grid that becomes two columns when both
748
940
  exist. With only one — the first or last document of the record — it stays a
@@ -2,7 +2,8 @@ import { RootProvider } from "fumadocs-ui/provider/next";
2
2
  import "./global.css";
3
3
  import type { Metadata } from "next";
4
4
  import { appTitle } from "@/lib/shared";
5
- import { basePath, caveatStatusByUrl } from "@/lib/source";
5
+ import { basePath, badgeByUrl } from "@/lib/source";
6
+ import { readStageManifest } from "@/lib/stage-manifest";
6
7
  import KsorSearchDialog from "@/components/search-dialog";
7
8
 
8
9
  // No next/font/google: it fetches the face from Google at BUILD time, so a
@@ -17,13 +18,17 @@ export const metadata: Metadata = {
17
18
  template: `%s | ${appTitle}`,
18
19
  },
19
20
  description: "The Knowledge System of Record for humans and AI agents.",
21
+ // A build that shows drafts (`KSOR_DRAFTS=show`) is a preview, and a static
22
+ // site's pages are open-web artefacts: it says so to every crawler rather
23
+ // than letting a draft be indexed under the record's name (build spec §3).
24
+ ...(readStageManifest().drafts === "shown" ? { robots: { index: false, follow: false } } : {}),
20
25
  };
21
26
 
22
27
  export default function Layout({ children }: LayoutProps<"/">) {
23
28
  return (
24
29
  <html lang="en" suppressHydrationWarning>
25
30
  <body className="flex flex-col min-h-screen">
26
- {/* Which documents carry a caveat status, for the search dialog — it
31
+ {/* Which documents carry a badge, for the search dialog — it
27
32
  runs in the browser over a static index that has no field for it.
28
33
  Delivered in the document rather than as a dialog prop because
29
34
  RootProvider types `options` against the SHIPPED dialog's props, and
@@ -34,7 +39,7 @@ export default function Layout({ children }: LayoutProps<"/">) {
34
39
  type="application/json"
35
40
  id="ksor-statuses"
36
41
  dangerouslySetInnerHTML={{
37
- __html: JSON.stringify(caveatStatusByUrl()).replaceAll("<", "\\u003c"),
42
+ __html: JSON.stringify(badgeByUrl()).replaceAll("<", "\\u003c"),
38
43
  }}
39
44
  />
40
45
  <RootProvider
@@ -1,12 +1,18 @@
1
- import { getLLMText, getSortedPages } from "@/lib/source";
1
+ import { getLLMText, getMachinePages } from "@/lib/source";
2
+ import { stampLines } from "@/lib/governance";
3
+ import { appName, appTitle } from "@/lib/shared";
4
+ import { readStageManifest } from "@/lib/stage-manifest";
2
5
 
3
6
  export const revalidate = false;
4
7
 
8
+ /**
9
+ * The whole machine-admitted record in one file, opened by the stamps that
10
+ * connect it to one publication (R14) — so a consumer holding a copy can say
11
+ * which build it came from — then every document with its own governance
12
+ * block, exactly as its twin serves it.
13
+ */
5
14
  export async function GET(): Promise<Response> {
6
- // The whole set is threaded through so each document's successor pointer
7
- // resolves to a route rather than the `./x.md` a consumer cannot follow.
8
- const pages = getSortedPages();
9
- const scanned = await Promise.all(pages.map((page) => getLLMText(page, pages)));
10
-
11
- return new Response(scanned.join("\n\n"));
15
+ const head = `---\ntitle: ${JSON.stringify(appTitle)}\nname: ${appName}\n${stampLines(readStageManifest().stamps).join("\n")}\n---`;
16
+ const scanned = getMachinePages().map((page) => getLLMText(page));
17
+ return new Response([head, ...scanned].join("\n\n"));
12
18
  }
@@ -3,15 +3,20 @@ import { recordIndexText } from "@/lib/source";
3
3
  export const revalidate = false;
4
4
 
5
5
  // The agent-facing index of the record: this instance's name, then every
6
- // document in sidebar order, each link usable as-is on a sub-path host — and
7
- // each carrying its governance when the governance is a caveat.
6
+ // document a MACHINE surface may carry, in sidebar order, each link usable
7
+ // as-is on a sub-path host.
8
8
  //
9
- // Without that last part a withdrawn document and the one that replaced it are
10
- // two adjacent entries told apart only by whatever a human happened to type
11
- // into a title, and an agent picks either (research/site-design.md F1).
9
+ // There is no caveat marker any more, because there is nothing to mark: the
10
+ // §2.5 table admits only stable, effective, unexpired, undenied concepts here,
11
+ // so a draft, a deprecated document and one past its `stale_after` are not
12
+ // entries at all. That replaced marking with exclusion — the older worry was a
13
+ // withdrawn document and its replacement sitting adjacent, told apart only by
14
+ // whatever a human typed into a title (research/site-design.md F1), and an
15
+ // agent picking either.
12
16
  //
13
- // The bytes are built in lib/source (`recordIndexText`) because the home page
14
- // shows this same index to a reader one index, one spelling.
17
+ // The set is decided ONCE, by staging, and read back here: the bytes are built
18
+ // in lib/source (`recordIndexText`) because the home page shows this same index
19
+ // to a reader — one index, one spelling.
15
20
  export function GET(): Response {
16
21
  return new Response(recordIndexText());
17
22
  }
@@ -1,36 +1,38 @@
1
1
  import { notFound } from "next/navigation";
2
2
 
3
- import { getLLMText, getSortedPages, source } from "@/lib/source";
3
+ import { getLLMText, getMachinePages, rootIndexTwin, source } from "@/lib/source";
4
+ import { machineAdmits } from "@/lib/stage-manifest";
4
5
 
5
6
  export const revalidate = false;
6
7
 
7
8
  /**
8
- * Every document as markdown, at a stable address derived from its path:
9
- * `/docs/policies/purchase-approval` is also served at
10
- * `/md/policies/purchase-approval.md`.
9
+ * Every MACHINE-admitted document as markdown, at a stable address derived
10
+ * from its path: `/docs/policies/purchase-approval` is also served at
11
+ * `/md/policies/purchase-approval.md`, and the record's own index at
12
+ * `/md/index.md` — the one index with a twin (build spec §3).
11
13
  *
12
14
  * The record already IS markdown; without this an agent handed a document URL
13
15
  * had to scrape a React app to reach text the record holds verbatim
14
- * (research/site-design.md F2). The body carries the document's governance as
15
- * frontmatter, exactly as `llms-full.txt` does, so a consumer reading one
16
- * document knows its status, owner, sources and successor.
16
+ * (research/site-design.md F2). The body carries the document's governance and
17
+ * the build's stamps as frontmatter, exactly as `llms-full.txt` does.
17
18
  *
18
- * Why a `/md/` prefix rather than appending `.md` to the document's own URL,
19
- * which is the convention the field has settled on: under `output: "export"` a
20
- * Route Handler cannot share a route segment with a Page, and there is no
21
- * middleware to rewrite one onto the other. The prefix is the shape that
19
+ * A page the machine surfaces decline a draft, a deprecated, a not-yet-
20
+ * effective or a stale concept (record spec §2.5) — has no twin at all: the
21
+ * decision is staging's, read back here, never re-derived.
22
+ *
23
+ * Why a `/md/` prefix rather than appending `.md` to the document's own URL:
24
+ * under `output: "export"` a Route Handler cannot share a route segment with a
25
+ * Page, and there is no middleware to rewrite one onto the other. The prefix
22
26
  * survives a static host, and the page advertises it in a `rel="alternate"`
23
- * link so a consumer discovers it rather than guessing. Appending `.md` to the
24
- * canonical URL becomes possible the day a build emits these artifacts itself.
27
+ * link so a consumer discovers it rather than guessing.
25
28
  */
26
29
  export function generateStaticParams(): { slug: string[] }[] {
27
- return source.generateParams().map(({ slug }) => {
28
- const segments = slug ?? [];
29
- const last = segments.at(-1);
30
- return {
31
- slug: last === undefined ? ["index.md"] : [...segments.slice(0, -1), `${last}.md`],
32
- };
30
+ const twins = getMachinePages().map((page) => {
31
+ const segments = page.url.replace(/^\/docs\/?/, "").split("/");
32
+ const last = segments.at(-1) ?? "";
33
+ return { slug: [...segments.slice(0, -1), `${last}.md`] };
33
34
  });
35
+ return [{ slug: ["index.md"] }, ...twins];
34
36
  }
35
37
 
36
38
  export async function GET(
@@ -40,12 +42,11 @@ export async function GET(
40
42
  const { slug = [] } = await params;
41
43
  const last = slug.at(-1);
42
44
  if (last === undefined || !last.endsWith(".md")) notFound();
43
- const docSlug = last === "index.md" ? [] : [...slug.slice(0, -1), last.slice(0, -".md".length)];
45
+ const headers = { "content-type": "text/markdown; charset=utf-8" };
46
+ if (slug.length === 1 && last === "index.md") return new Response(rootIndexTwin(), { headers });
44
47
 
45
- const page = source.getPage(docSlug);
46
- if (!page) notFound();
48
+ const page = source.getPage([...slug.slice(0, -1), last.slice(0, -".md".length)]);
49
+ if (!page || !machineAdmits(page.path)) notFound();
47
50
 
48
- return new Response(await getLLMText(page, getSortedPages()), {
49
- headers: { "content-type": "text/markdown; charset=utf-8" },
50
- });
51
+ return new Response(getLLMText(page), { headers });
51
52
  }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock";
4
+ import { WrapText } from "lucide-react";
5
+ import { useCallback, useEffect, useRef, useState, type ReactElement } from "react";
6
+
7
+ /**
8
+ * A code block that can be asked to show the whole line.
9
+ *
10
+ * A record's commands are long — one in the fixture runs to 292 characters —
11
+ * and in a 672px column that is a scrollbar, with the left edge of every line
12
+ * going out of view as you drag it. Wrapping everything by default was tried
13
+ * and is worse for the blocks that do not need it: a yaml file rewrapped at
14
+ * the column reads as though its indentation means something else.
15
+ *
16
+ * So the reader decides, per block. The button appears ONLY on a block that
17
+ * actually overflows, which is measured after layout rather than guessed —
18
+ * most blocks fit, and a control that does nothing is worse than no control.
19
+ *
20
+ * The `Actions` slot is fumadocs' own, so the button sits with the copy button
21
+ * and inherits its placement rather than being positioned against it.
22
+ */
23
+ export function WrappableCodeBlock(props: React.ComponentProps<"pre">): ReactElement {
24
+ const figure = useRef<HTMLElement>(null);
25
+ const [overflows, setOverflows] = useState(false);
26
+ const [wrapped, setWrapped] = useState(false);
27
+
28
+ const measure = useCallback((): void => {
29
+ const viewport = figure.current?.querySelector<HTMLElement>(".fd-scroll-container");
30
+ if (!viewport) return;
31
+ // While wrapped there is nothing to overflow, so the question is only
32
+ // asked in the state that can answer it — otherwise turning wrapping on
33
+ // would remove the button that turns it off.
34
+ setOverflows((was) => (wrapped ? was : viewport.scrollWidth > viewport.clientWidth + 1));
35
+ }, [wrapped]);
36
+
37
+ useEffect(() => {
38
+ measure();
39
+ if (typeof ResizeObserver === "undefined") return;
40
+ const viewport = figure.current?.querySelector<HTMLElement>(".fd-scroll-container");
41
+ if (!viewport) return;
42
+ // The column changes width with the window, and with the table of contents
43
+ // appearing; a block that fits at one width overflows at another.
44
+ const observer = new ResizeObserver(measure);
45
+ observer.observe(viewport);
46
+ return () => observer.disconnect();
47
+ }, [measure]);
48
+
49
+ return (
50
+ <CodeBlock
51
+ ref={figure}
52
+ data-wrapped={wrapped ? "" : undefined}
53
+ Actions={({ className, children }) => (
54
+ // `children` IS the copy button — fumadocs hands the default actions
55
+ // in, and a slot that ignores them REPLACES the copy button instead of
56
+ // joining it (found live: the block lost copy entirely). The wrapper
57
+ // mirrors the default's own `empty:hidden`, so a block with neither
58
+ // button still renders nothing.
59
+ <div className={`flex items-center empty:hidden ${className ?? ""}`}>
60
+ {children}
61
+ {overflows ? (
62
+ <button
63
+ type="button"
64
+ aria-pressed={wrapped}
65
+ onClick={() => setWrapped((on) => !on)}
66
+ // The copy button's own metrics — measured, not guessed: 24x24
67
+ // with a 3.5 icon. Mine came out 30x30 and the pair read as two
68
+ // controls of different importance.
69
+ className="inline-flex size-6 items-center justify-center rounded-lg transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground [&_svg]:size-3.5"
70
+ // Said as what it does, not as what it is: the reader wants the
71
+ // rest of the line, not a setting.
72
+ title={wrapped ? "Show one line per line" : "Show the whole line"}
73
+ >
74
+ <WrapText aria-hidden />
75
+ <span className="sr-only">
76
+ {wrapped ? "Show one line per line" : "Show the whole line"}
77
+ </span>
78
+ </button>
79
+ ) : null}
80
+ </div>
81
+ )}
82
+ {...props}
83
+ >
84
+ <Pre>{props.children}</Pre>
85
+ </CodeBlock>
86
+ );
87
+ }