@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.
- package/CHANGELOG.md +65 -0
- package/dist/cli.mjs +67 -4
- package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/AGENTS.md +49 -0
- package/templates/scaffold/Dockerfile +10 -6
- package/templates/scaffold/README.md +3 -2
- package/templates/scaffold/dockerignore +5 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
- package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
- package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
- package/templates/scaffold/system/site/components/governance.tsx +17 -25
- package/templates/scaffold/system/site/components/record-views.tsx +241 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
- package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
- package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
- package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
- package/templates/scaffold/system/site/lib/attachments.ts +105 -0
- package/templates/scaffold/system/site/lib/deck.ts +59 -0
- package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
- package/templates/scaffold/system/site/lib/srs.ts +219 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
- package/templates/scaffold/system/site/source.config.ts +54 -1
- package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.28
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 24ec8c3: Each document page now says how long it takes to read.
|
|
8
|
+
|
|
9
|
+
The figure is counted when the site is built, from the document's own markdown,
|
|
10
|
+
so it is in the shipped HTML — a reader whose bundle failed, a crawler and an
|
|
11
|
+
agent parsing the page all get it. Fenced code and frontmatter are left out of
|
|
12
|
+
the count, so a short page carrying a long example is not reported as a
|
|
13
|
+
twenty-minute read.
|
|
14
|
+
|
|
15
|
+
Nothing to author: it is derived from the words already there. Where a document
|
|
16
|
+
has a summary, both tabs carry their own figure, so a reader can see what the
|
|
17
|
+
summary saves them before opening it.
|
|
18
|
+
|
|
19
|
+
- 6abcf1f: Summaries and flashcard decks, as governed attachments of a document.
|
|
20
|
+
|
|
21
|
+
A document in `knowledge/` may now carry two companions named after it —
|
|
22
|
+
`<doc>.summary.md` and `<doc>.flashcards.yaml`. The summary joins the record's
|
|
23
|
+
own words as a second tab; the deck renders at the end of the page, with spaced
|
|
24
|
+
review kept in the reader's browser and Shuffle / Guide / Download beneath it.
|
|
25
|
+
`ksor init` ships one of each so a fresh project shows the shape rather than
|
|
26
|
+
describing it.
|
|
27
|
+
|
|
28
|
+
An attachment is **part of its document, not a document**. It gets no URL, no
|
|
29
|
+
sidebar row, no `llms.txt` line, no markdown twin, no search entry — and no
|
|
30
|
+
stable id, so an agent can never cite it as a source in its own right. It takes
|
|
31
|
+
its `visibility:` and its takedown from its parent: restrict or withdraw the
|
|
32
|
+
document and its summary and deck go with it. An attachment declaring
|
|
33
|
+
frontmatter, or one whose document is missing, is refused by `pnpm check` and by
|
|
34
|
+
`pnpm build`.
|
|
35
|
+
|
|
36
|
+
**If your record already has `.summary.md` files and you serve over MCP, read
|
|
37
|
+
this.** They were previously ingested as ordinary documents, each with its own
|
|
38
|
+
id and its own governance defaults. They no longer are. After upgrading, run
|
|
39
|
+
`pnpm refresh`; if a takedown names one of those ids, `ksor serve` will refuse
|
|
40
|
+
to boot until the denial is pointed at the parent document or retired
|
|
41
|
+
deliberately. That refusal is the fix working — those rows governed a node that
|
|
42
|
+
should never have existed.
|
|
43
|
+
|
|
44
|
+
Review scheduling is a two-grade SM-2 variant (`ksor-sm2-v1`). It is not FSRS
|
|
45
|
+
and claims no retention target.
|
|
46
|
+
|
|
47
|
+
## 0.0.27
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- 6bc9d8f: The emitted `Dockerfile` names the files it copies instead of `COPY . ./`.
|
|
52
|
+
|
|
53
|
+
**A `.dockerignore` is not honoured by every build host.** Vercel's container
|
|
54
|
+
builder ignores it, so `COPY . ./` swept in `node_modules` and the built site —
|
|
55
|
+
about a gigabyte — and the deploy failed with the registry rejecting the push as
|
|
56
|
+
`PAYLOAD_TOO_LARGE`. The error arrives from the host and says nothing about the
|
|
57
|
+
file that caused it.
|
|
58
|
+
|
|
59
|
+
Naming what enters the image is the only form portable across build hosts. The
|
|
60
|
+
`.dockerignore` stays as defence in depth for builders that do respect it, but it
|
|
61
|
+
is no longer what bounds the image.
|
|
62
|
+
|
|
63
|
+
The risk of naming files is forgetting one — which is exactly how the registration
|
|
64
|
+
file came to be missing from the image two releases ago. That is covered: the
|
|
65
|
+
container acceptance job boots the built image and asserts it serves the tools the
|
|
66
|
+
registration names, so a forgotten file fails there rather than at a deploy.
|
|
67
|
+
|
|
3
68
|
## 0.0.26
|
|
4
69
|
|
|
5
70
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { n as resolveCommand, r as verbs, t as exitCodes } from "./src-pl4aOpVs.mjs";
|
|
3
|
-
import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-
|
|
3
|
+
import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-8lNruq9e-CuohjtoK.mjs";
|
|
4
4
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { InMemoryTransport, LATEST_PROTOCOL_VERSION, createMcpHandler } from "@modelcontextprotocol/server";
|
|
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
16
16
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
17
|
import { parseArgs } from "node:util";
|
|
18
18
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
19
|
-
//#region ../content-gateway/dist/main-
|
|
19
|
+
//#region ../content-gateway/dist/main-DiX1MNMX.mjs
|
|
20
20
|
/**
|
|
21
21
|
* The default registration — and the ORIGINAL of the file `ksor init` emits.
|
|
22
22
|
*
|
|
@@ -1552,7 +1552,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
1552
1552
|
throw lastError;
|
|
1553
1553
|
}
|
|
1554
1554
|
//#endregion
|
|
1555
|
-
//#region ../content/dist/commands-
|
|
1555
|
+
//#region ../content/dist/commands-DU1nqwZa.mjs
|
|
1556
1556
|
/**
|
|
1557
1557
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
1558
1558
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -4148,6 +4148,54 @@ function compareSiblings(a, b) {
|
|
|
4148
4148
|
return codePointCompare$1(a.tie, b.tie);
|
|
4149
4149
|
}
|
|
4150
4150
|
/**
|
|
4151
|
+
* What makes a file in the record an ATTACHMENT rather than a document.
|
|
4152
|
+
*
|
|
4153
|
+
* A document may carry study attachments named after it — `x.summary.md` and
|
|
4154
|
+
* `x.flashcards.yaml` belong to `x.md` in the same directory. An attachment is
|
|
4155
|
+
* PART OF its parent: no route, no sidebar entry, no llms.txt line, no stable
|
|
4156
|
+
* id, no MCP node, and its parent's governance rather than its own.
|
|
4157
|
+
*
|
|
4158
|
+
* This rule is duplicated by construction — the kernel's ingest decides what
|
|
4159
|
+
* becomes a node, the site's staging decides what is copied, the site's build
|
|
4160
|
+
* decides what is a page, and the record's checker decides what is well-formed.
|
|
4161
|
+
* Four readers of one rule is exactly the shape decision 18 names, so this file
|
|
4162
|
+
* is canonical and every other copy is asserted against it rather than trusted.
|
|
4163
|
+
*
|
|
4164
|
+
* A LEAF: no imports, so any of those four can take it without taking anything
|
|
4165
|
+
* else with it.
|
|
4166
|
+
*/
|
|
4167
|
+
/** The suffix that marks each kind, longest-match first. */
|
|
4168
|
+
const ATTACHMENT_SUFFIXES = [
|
|
4169
|
+
{
|
|
4170
|
+
suffix: ".summary.md",
|
|
4171
|
+
kind: "summary"
|
|
4172
|
+
},
|
|
4173
|
+
{
|
|
4174
|
+
suffix: ".summary.mdx",
|
|
4175
|
+
kind: "summary"
|
|
4176
|
+
},
|
|
4177
|
+
{
|
|
4178
|
+
suffix: ".flashcards.yaml",
|
|
4179
|
+
kind: "deck"
|
|
4180
|
+
}
|
|
4181
|
+
];
|
|
4182
|
+
/**
|
|
4183
|
+
* The attachment kind this file name carries, or null when it is not one.
|
|
4184
|
+
*
|
|
4185
|
+
* Matched on the whole base name, never on a path: `.summary.md` in a directory
|
|
4186
|
+
* called `summary` is not an attachment, and a file called exactly
|
|
4187
|
+
* `.summary.md` (a dotfile with no stem) has no parent to attach to and is not
|
|
4188
|
+
* one either — the same "a dotfile has no suffix" boundary ingest's isDoc uses.
|
|
4189
|
+
*/
|
|
4190
|
+
function attachmentKindOf(baseName) {
|
|
4191
|
+
for (const entry of ATTACHMENT_SUFFIXES) if (baseName.length > entry.suffix.length && baseName.endsWith(entry.suffix)) return entry.kind;
|
|
4192
|
+
return null;
|
|
4193
|
+
}
|
|
4194
|
+
/** True when this file name is an attachment of some document. */
|
|
4195
|
+
function isAttachment(baseName) {
|
|
4196
|
+
return attachmentKindOf(baseName) !== null;
|
|
4197
|
+
}
|
|
4198
|
+
/**
|
|
4151
4199
|
* The plain-tree corpus adapter — ANY folder of Markdown becomes a corpus.
|
|
4152
4200
|
* Converted from the oracle (sor-agentfactory @ b554f91,
|
|
4153
4201
|
* ingest/adapters/plain_tree.py); the kernel cannot tell this manifest from
|
|
@@ -4360,8 +4408,23 @@ function buildManifestFromTree(root, options) {
|
|
|
4360
4408
|
sources
|
|
4361
4409
|
};
|
|
4362
4410
|
}
|
|
4363
|
-
/**
|
|
4411
|
+
/**
|
|
4412
|
+
* Python `p.suffix in (".md", ".mdx")` parity: a dotfile named exactly ".md"
|
|
4413
|
+
* has NO suffix.
|
|
4414
|
+
*
|
|
4415
|
+
* ATTACHMENTS ARE NOT DOCUMENTS. `x.summary.md` ends in `.md`, so without the
|
|
4416
|
+
* attachment rule it ingested as a node of its own — `stable_id`
|
|
4417
|
+
* `knowledge/x.summary`, its own `content_nodes.visibility` coalescing to the
|
|
4418
|
+
* record default, and its own takedown state. That is one cause wearing four
|
|
4419
|
+
* costumes: the door served a summary the site hides; served an internal
|
|
4420
|
+
* parent's summary at the public tier; served a taken-down parent's summary
|
|
4421
|
+
* undenied (per-node denial matches a different id, and the subtree walk goes
|
|
4422
|
+
* through `parent_id`, which is the enclosing SECTION, not the sibling
|
|
4423
|
+
* document); and served an orphan the site refuses. An attachment belongs to
|
|
4424
|
+
* its parent, so it gets no id and is never independently citable.
|
|
4425
|
+
*/
|
|
4364
4426
|
function isDoc(name) {
|
|
4427
|
+
if (isAttachment(name)) return false;
|
|
4365
4428
|
const dot = name.lastIndexOf(".");
|
|
4366
4429
|
if (dot <= 0) return false;
|
|
4367
4430
|
const suffix = name.slice(dot);
|
|
@@ -5,7 +5,7 @@ import { z, z as z$1 } from "zod";
|
|
|
5
5
|
import pg from "pg";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
|
-
//#region ../content-gateway/dist/gateway-api-
|
|
8
|
+
//#region ../content-gateway/dist/gateway-api-8lNruq9e.mjs
|
|
9
9
|
/**
|
|
10
10
|
* The record's system prompt: instance.md's body, preserved beneath a framework
|
|
11
11
|
* floor. Lifted out of server.ts so the public gateway surface can re-export
|
package/dist/gateway.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-
|
|
1
|
+
import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-8lNruq9e-CuohjtoK.mjs";
|
|
2
2
|
export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
4
4
|
"description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abstention",
|
|
@@ -35,6 +35,35 @@ const REQUIRED_KEYS = ["title", "status"]; // level 0 — the ladder, not a gate
|
|
|
35
35
|
const STATUS_VALUES = new Set(["draft", "review", "approved", "superseded"]);
|
|
36
36
|
const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
|
|
37
37
|
|
|
38
|
+
// Study attachments: `x.summary.md` and `x.flashcards.yaml` belong to `x.md`.
|
|
39
|
+
// An attachment is PART OF its parent — no route, no stable id, no governance
|
|
40
|
+
// of its own — so it is neither a document nor an asset, and gets its own
|
|
41
|
+
// rules below. This mirrors packages/content/src/lib/attachment-rule.ts, which
|
|
42
|
+
// this dependency-free file cannot import; ATTACHMENT_CASES there is the table
|
|
43
|
+
// both are held to.
|
|
44
|
+
const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml"];
|
|
45
|
+
// One character off a real attachment, refused BY NAME: `.yml` reaches the
|
|
46
|
+
// site bundler's `Unknown file type` throw, which names the path and nothing
|
|
47
|
+
// about the rule.
|
|
48
|
+
const ATTACHMENT_NEAR_MISSES = [
|
|
49
|
+
[".flashcards.yml", ".flashcards.yaml"],
|
|
50
|
+
[".flashcards.json", ".flashcards.yaml"],
|
|
51
|
+
[".summary.markdown", ".summary.md"],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** The attachment suffix this name carries, or null. A dotfile has no stem. */
|
|
55
|
+
function attachmentSuffixOf(base) {
|
|
56
|
+
return ATTACHMENT_SUFFIXES.find((s) => base.length > s.length && base.endsWith(s)) ?? null;
|
|
57
|
+
}
|
|
58
|
+
function isAttachment(base) {
|
|
59
|
+
return attachmentSuffixOf(base) !== null;
|
|
60
|
+
}
|
|
61
|
+
/** The document an attachment belongs to: always `<stem>.md`. */
|
|
62
|
+
function parentDocumentOf(base) {
|
|
63
|
+
const suffix = attachmentSuffixOf(base);
|
|
64
|
+
return suffix === null ? null : `${base.slice(0, -suffix.length)}.md`;
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
// PNG integrity, dependency-free: signature + per-chunk CRC-32. A damaged
|
|
39
68
|
// image beside a document is a check-time problem with the file named, never
|
|
40
69
|
// a build-time 500 with no filename in it.
|
|
@@ -381,7 +410,9 @@ if (!existsSync(knowledgeDir)) {
|
|
|
381
410
|
}
|
|
382
411
|
const dirs = walkDirs(knowledgeDir);
|
|
383
412
|
const all = [...files, ...dirs];
|
|
384
|
-
|
|
413
|
+
// Attachments are never documents: they carry no frontmatter, own no route,
|
|
414
|
+
// and are checked by their own rules instead.
|
|
415
|
+
const mdFiles = files.filter((p) => p.endsWith(".md") && !isAttachment(path.basename(p)));
|
|
385
416
|
|
|
386
417
|
if (mdFiles.length === 0) {
|
|
387
418
|
problem(
|
|
@@ -446,7 +477,12 @@ if (!existsSync(knowledgeDir)) {
|
|
|
446
477
|
);
|
|
447
478
|
}
|
|
448
479
|
seenLower.set(lower, rel);
|
|
449
|
-
if (
|
|
480
|
+
if (
|
|
481
|
+
files.includes(p) &&
|
|
482
|
+
!p.endsWith(".md") &&
|
|
483
|
+
!isAttachment(base) &&
|
|
484
|
+
!ASSET_EXTENSIONS.has(path.extname(p))
|
|
485
|
+
) {
|
|
450
486
|
problem(
|
|
451
487
|
rel,
|
|
452
488
|
`unexpected file type "${path.extname(p) || base}"`,
|
|
@@ -486,6 +522,51 @@ if (!existsSync(knowledgeDir)) {
|
|
|
486
522
|
}
|
|
487
523
|
}
|
|
488
524
|
|
|
525
|
+
// Study attachments: bound to a parent, carrying no governance of their own.
|
|
526
|
+
for (const p of files) {
|
|
527
|
+
const base = path.basename(p);
|
|
528
|
+
const rel = path.relative(root, p);
|
|
529
|
+
|
|
530
|
+
const nearMiss = ATTACHMENT_NEAR_MISSES.find(
|
|
531
|
+
([wrong]) => base.length > wrong.length && base.endsWith(wrong),
|
|
532
|
+
);
|
|
533
|
+
if (nearMiss && !isAttachment(base)) {
|
|
534
|
+
problem(
|
|
535
|
+
rel,
|
|
536
|
+
`${nearMiss[0]} is not an attachment extension`,
|
|
537
|
+
"the site reads decks as YAML and accepts only .yaml — a near miss is not picked up as a deck, and fails the build naming the path but not the rule",
|
|
538
|
+
`rename it to ${base.slice(0, -nearMiss[0].length)}${nearMiss[1]}`,
|
|
539
|
+
);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!isAttachment(base)) continue;
|
|
544
|
+
|
|
545
|
+
const parent = parentDocumentOf(base);
|
|
546
|
+
if (parent && !existsSync(path.join(path.dirname(p), parent))) {
|
|
547
|
+
problem(
|
|
548
|
+
rel,
|
|
549
|
+
`attachment of ${parent}, which is not in the record`,
|
|
550
|
+
"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",
|
|
551
|
+
`add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (base.endsWith(".md") || base.endsWith(".mdx")) {
|
|
556
|
+
const text = readFileSync(p, "utf8")
|
|
557
|
+
.replace(/^\uFEFF/, "")
|
|
558
|
+
.replaceAll("\r\n", "\n");
|
|
559
|
+
if (text.startsWith("---\n")) {
|
|
560
|
+
problem(
|
|
561
|
+
rel,
|
|
562
|
+
"attachment declares frontmatter",
|
|
563
|
+
"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 (a visibility: on a summary of a restricted document is the shape that matters)",
|
|
564
|
+
`remove the frontmatter block; ${parent ?? "its parent"} is what carries the governance`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
489
570
|
// foo.md vs foo/index.md route collisions
|
|
490
571
|
for (const p of mdFiles) {
|
|
491
572
|
const sibling = p.replace(/\.md$/, "");
|
|
@@ -35,6 +35,35 @@ const REQUIRED_KEYS = ["title", "status"]; // level 0 — the ladder, not a gate
|
|
|
35
35
|
const STATUS_VALUES = new Set(["draft", "review", "approved", "superseded"]);
|
|
36
36
|
const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
|
|
37
37
|
|
|
38
|
+
// Study attachments: `x.summary.md` and `x.flashcards.yaml` belong to `x.md`.
|
|
39
|
+
// An attachment is PART OF its parent — no route, no stable id, no governance
|
|
40
|
+
// of its own — so it is neither a document nor an asset, and gets its own
|
|
41
|
+
// rules below. This mirrors packages/content/src/lib/attachment-rule.ts, which
|
|
42
|
+
// this dependency-free file cannot import; ATTACHMENT_CASES there is the table
|
|
43
|
+
// both are held to.
|
|
44
|
+
const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml"];
|
|
45
|
+
// One character off a real attachment, refused BY NAME: `.yml` reaches the
|
|
46
|
+
// site bundler's `Unknown file type` throw, which names the path and nothing
|
|
47
|
+
// about the rule.
|
|
48
|
+
const ATTACHMENT_NEAR_MISSES = [
|
|
49
|
+
[".flashcards.yml", ".flashcards.yaml"],
|
|
50
|
+
[".flashcards.json", ".flashcards.yaml"],
|
|
51
|
+
[".summary.markdown", ".summary.md"],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** The attachment suffix this name carries, or null. A dotfile has no stem. */
|
|
55
|
+
function attachmentSuffixOf(base) {
|
|
56
|
+
return ATTACHMENT_SUFFIXES.find((s) => base.length > s.length && base.endsWith(s)) ?? null;
|
|
57
|
+
}
|
|
58
|
+
function isAttachment(base) {
|
|
59
|
+
return attachmentSuffixOf(base) !== null;
|
|
60
|
+
}
|
|
61
|
+
/** The document an attachment belongs to: always `<stem>.md`. */
|
|
62
|
+
function parentDocumentOf(base) {
|
|
63
|
+
const suffix = attachmentSuffixOf(base);
|
|
64
|
+
return suffix === null ? null : `${base.slice(0, -suffix.length)}.md`;
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
// PNG integrity, dependency-free: signature + per-chunk CRC-32. A damaged
|
|
39
68
|
// image beside a document is a check-time problem with the file named, never
|
|
40
69
|
// a build-time 500 with no filename in it.
|
|
@@ -381,7 +410,9 @@ if (!existsSync(knowledgeDir)) {
|
|
|
381
410
|
}
|
|
382
411
|
const dirs = walkDirs(knowledgeDir);
|
|
383
412
|
const all = [...files, ...dirs];
|
|
384
|
-
|
|
413
|
+
// Attachments are never documents: they carry no frontmatter, own no route,
|
|
414
|
+
// and are checked by their own rules instead.
|
|
415
|
+
const mdFiles = files.filter((p) => p.endsWith(".md") && !isAttachment(path.basename(p)));
|
|
385
416
|
|
|
386
417
|
if (mdFiles.length === 0) {
|
|
387
418
|
problem(
|
|
@@ -446,7 +477,12 @@ if (!existsSync(knowledgeDir)) {
|
|
|
446
477
|
);
|
|
447
478
|
}
|
|
448
479
|
seenLower.set(lower, rel);
|
|
449
|
-
if (
|
|
480
|
+
if (
|
|
481
|
+
files.includes(p) &&
|
|
482
|
+
!p.endsWith(".md") &&
|
|
483
|
+
!isAttachment(base) &&
|
|
484
|
+
!ASSET_EXTENSIONS.has(path.extname(p))
|
|
485
|
+
) {
|
|
450
486
|
problem(
|
|
451
487
|
rel,
|
|
452
488
|
`unexpected file type "${path.extname(p) || base}"`,
|
|
@@ -486,6 +522,51 @@ if (!existsSync(knowledgeDir)) {
|
|
|
486
522
|
}
|
|
487
523
|
}
|
|
488
524
|
|
|
525
|
+
// Study attachments: bound to a parent, carrying no governance of their own.
|
|
526
|
+
for (const p of files) {
|
|
527
|
+
const base = path.basename(p);
|
|
528
|
+
const rel = path.relative(root, p);
|
|
529
|
+
|
|
530
|
+
const nearMiss = ATTACHMENT_NEAR_MISSES.find(
|
|
531
|
+
([wrong]) => base.length > wrong.length && base.endsWith(wrong),
|
|
532
|
+
);
|
|
533
|
+
if (nearMiss && !isAttachment(base)) {
|
|
534
|
+
problem(
|
|
535
|
+
rel,
|
|
536
|
+
`${nearMiss[0]} is not an attachment extension`,
|
|
537
|
+
"the site reads decks as YAML and accepts only .yaml — a near miss is not picked up as a deck, and fails the build naming the path but not the rule",
|
|
538
|
+
`rename it to ${base.slice(0, -nearMiss[0].length)}${nearMiss[1]}`,
|
|
539
|
+
);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!isAttachment(base)) continue;
|
|
544
|
+
|
|
545
|
+
const parent = parentDocumentOf(base);
|
|
546
|
+
if (parent && !existsSync(path.join(path.dirname(p), parent))) {
|
|
547
|
+
problem(
|
|
548
|
+
rel,
|
|
549
|
+
`attachment of ${parent}, which is not in the record`,
|
|
550
|
+
"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",
|
|
551
|
+
`add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (base.endsWith(".md") || base.endsWith(".mdx")) {
|
|
556
|
+
const text = readFileSync(p, "utf8")
|
|
557
|
+
.replace(/^\uFEFF/, "")
|
|
558
|
+
.replaceAll("\r\n", "\n");
|
|
559
|
+
if (text.startsWith("---\n")) {
|
|
560
|
+
problem(
|
|
561
|
+
rel,
|
|
562
|
+
"attachment declares frontmatter",
|
|
563
|
+
"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 (a visibility: on a summary of a restricted document is the shape that matters)",
|
|
564
|
+
`remove the frontmatter block; ${parent ?? "its parent"} is what carries the governance`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
489
570
|
// foo.md vs foo/index.md route collisions
|
|
490
571
|
for (const p of mdFiles) {
|
|
491
572
|
const sibling = p.replace(/\.md$/, "");
|
|
@@ -448,6 +448,12 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
|
|
|
448
448
|
publishes that as fact) and `superseded` (a legacy marker — prefer `status`)
|
|
449
449
|
are available. No other keys; never
|
|
450
450
|
`id:` or `name:` — the path is the identity.
|
|
451
|
+
- **Each page says how long it takes to read**, just above the body, counted from the
|
|
452
|
+
document's own words when the site is built. Fenced code and frontmatter do
|
|
453
|
+
not count toward it, so a short page carrying a long example is not reported
|
|
454
|
+
as a long read. Nothing to author — it is derived. A document with a summary
|
|
455
|
+
shows the figure on both tabs, so a reader can see at a glance how much the
|
|
456
|
+
summary saves them.
|
|
451
457
|
- **The governance keys are rendered, so they are worth filling in.** Each
|
|
452
458
|
page shows its owner and effective date under the title, lists every
|
|
453
459
|
`provenance` entry separately at the foot, and — for a superseded document —
|
|
@@ -500,6 +506,49 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
|
|
|
500
506
|
refused.
|
|
501
507
|
- Images and assets live in `knowledge/` beside the document that uses them,
|
|
502
508
|
referenced by relative links. A relative link must never leave `knowledge/`.
|
|
509
|
+
- **Study attachments.** A document may carry two optional companions named
|
|
510
|
+
after it, in the same folder: `<doc>.summary.md` (a short précis) and
|
|
511
|
+
`<doc>.flashcards.yaml` (a recall deck). The summary appears as a second tab
|
|
512
|
+
beside the document's own words; the deck appears at the END of that
|
|
513
|
+
document's page. Both appear nowhere else in the site.
|
|
514
|
+
|
|
515
|
+
An attachment is **part of its document**, not a document. It has no URL of
|
|
516
|
+
its own, no sidebar row, no line in `llms.txt`, and no identity an agent can
|
|
517
|
+
cite — so it carries **no frontmatter at all** (the checker refuses any), and
|
|
518
|
+
it takes its `visibility:` and any takedown from its parent. Restrict the
|
|
519
|
+
document and its summary and deck go with it; there is no way to publish a
|
|
520
|
+
summary more widely than the document it summarises. An attachment whose
|
|
521
|
+
document is missing is refused, by `pnpm check` and by `pnpm build` alike.
|
|
522
|
+
|
|
523
|
+
A deck is YAML, and the extension is exactly `.flashcards.yaml` — `.yml` is
|
|
524
|
+
refused by name:
|
|
525
|
+
|
|
526
|
+
```yaml
|
|
527
|
+
deck:
|
|
528
|
+
title: Expense approvals
|
|
529
|
+
description: Recall checks for the approvals policy.
|
|
530
|
+
cards:
|
|
531
|
+
- front: Who approves a purchase above the threshold?
|
|
532
|
+
back: A second approver, independent of the requester.
|
|
533
|
+
why: Optional — a prompt shown before the answer.
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
No `id:` anywhere, on the deck or a card: the path is the deck's identity,
|
|
537
|
+
and a card's identity is its own text. Edit a card and only that card's
|
|
538
|
+
review progress starts again; the rest is untouched.
|
|
539
|
+
|
|
540
|
+
**A card may only say what its document says.** The summary and the deck are
|
|
541
|
+
ways of rehearsing the record, never a second source — a card asserting
|
|
542
|
+
something its document does not is a claim nothing governs and no agent can
|
|
543
|
+
cite. Ask your coding agent to write them from a document and to check every
|
|
544
|
+
answer back against it.
|
|
545
|
+
|
|
546
|
+
Review scheduling uses a simple interval ladder (an SM-2 variant): a missed
|
|
547
|
+
card returns in about a minute, a recalled card's interval grows by roughly
|
|
548
|
+
2.5x each time. It is not FSRS and makes no retention guarantee. Progress is
|
|
549
|
+
kept in the reader's own browser, so it is per-person and per-device, and it
|
|
550
|
+
is not part of the record.
|
|
551
|
+
|
|
503
552
|
- Copy load-bearing values (numbers, thresholds, dates) exactly from their
|
|
504
553
|
source, and name the source in `provenance`.
|
|
505
554
|
|
|
@@ -22,12 +22,16 @@ WORKDIR /app
|
|
|
22
22
|
COPY package.json ./
|
|
23
23
|
RUN npm install --omit=dev --no-audit --no-fund
|
|
24
24
|
|
|
25
|
-
# The record's identity
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
|
|
25
|
+
# The record's identity and configuration, and this door's own MCP registration.
|
|
26
|
+
#
|
|
27
|
+
# NAMED, not `COPY . ./`. A .dockerignore is not honoured by every builder —
|
|
28
|
+
# Vercel's is not, and `COPY . ./` there swept in node_modules and the built
|
|
29
|
+
# site, producing a registry push rejected as PAYLOAD_TOO_LARGE (found live).
|
|
30
|
+
# Naming what enters the image is the only form that is portable across build
|
|
31
|
+
# hosts. The risk of naming things is forgetting one; that is covered by a test
|
|
32
|
+
# which boots the built image and asserts it serves the tools this file names.
|
|
33
|
+
COPY instance.md ./
|
|
34
|
+
COPY system/gateways/ ./system/gateways/
|
|
31
35
|
|
|
32
36
|
# Most container hosts inject PORT; 80 is a sane default when nothing does.
|
|
33
37
|
ENV PORT=80
|
|
@@ -5,8 +5,9 @@ project's people and AI agents operate from.
|
|
|
5
5
|
|
|
6
6
|
Two worlds live here:
|
|
7
7
|
|
|
8
|
-
- **`knowledge/` — the record.** Plain governed markdown
|
|
9
|
-
|
|
8
|
+
- **`knowledge/` — the record.** Plain governed markdown (plus the optional
|
|
9
|
+
study attachments a document may carry). Yours forever, readable anywhere,
|
|
10
|
+
portable without this repository's code.
|
|
10
11
|
- **`system/` — the system.** The site (and later, services) that serve the
|
|
11
12
|
record. Replaceable machinery.
|
|
12
13
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# DENY EVERYTHING, then allow exactly what the door needs.
|
|
2
2
|
#
|
|
3
|
+
# Defence in depth, NOT the bound. The Dockerfile names the files it copies,
|
|
4
|
+
# because a .dockerignore is not honoured by every build host — Vercel's is
|
|
5
|
+
# not. This file protects local `docker build` and every builder that does
|
|
6
|
+
# respect it; the Dockerfile protects the rest.
|
|
7
|
+
#
|
|
3
8
|
# The inverse — listing what to exclude — cannot bound the image, because it can
|
|
4
9
|
# only exclude what someone thought of. A build output, a cache, a backup
|
|
5
10
|
# directory or a vendored dependency in the project root all end up inside, and
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# A recall deck for what-is-a-ksor.md.
|
|
2
|
+
#
|
|
3
|
+
# Every card states only what its parent document states — a deck is a way of
|
|
4
|
+
# rehearsing the record, never a second source. Ask your coding agent to write
|
|
5
|
+
# one of these from a document, and to check each answer against it.
|
|
6
|
+
deck:
|
|
7
|
+
title: What a KSoR is
|
|
8
|
+
description: Recall checks for the record's own definition of itself.
|
|
9
|
+
cards:
|
|
10
|
+
- front: When a spreadsheet disagrees with the ledger, which one wins?
|
|
11
|
+
back: The ledger. A system of record is the copy that governs.
|
|
12
|
+
why: Which copy would your organization's agents trust today?
|
|
13
|
+
|
|
14
|
+
- front: A traditional system of record settles the state of a business. What does a KSoR settle?
|
|
15
|
+
back: What the organization knows and how it should operate — which policies apply, which thresholds are approved, what a term means here.
|
|
16
|
+
|
|
17
|
+
- front: What problem does a KSoR solve?
|
|
18
|
+
back: Scatter. Knowledge spread across wikis, decks, PDFs, prompts and someone's memory, with no authoritative answer to which knowledge an agent should trust.
|
|
19
|
+
|
|
20
|
+
- front: Why can an assistant not tell you which of its sentences were checked?
|
|
21
|
+
back: Because it answers from everything it has ever read. Nothing in that process distinguishes a checked claim from an unchecked one.
|
|
22
|
+
why: This is the gap the record exists to close.
|
|
23
|
+
|
|
24
|
+
- front: Does provenance prove that a document is correct?
|
|
25
|
+
back: No. Provenance proves who said what, and when. Whether the source is any good is a separate matter, and the record never claims otherwise.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
A Knowledge System of Record settles **which copy governs**. When a wiki page, a
|
|
2
|
+
slide or a model's memory disagrees with it, this record wins.
|
|
3
|
+
|
|
4
|
+
- A traditional system of record settles the _state_ of a business — the ledger
|
|
5
|
+
for transactions, the HRIS for employee records. A KSoR settles what the
|
|
6
|
+
organization _knows_ and how it should operate.
|
|
7
|
+
- The problem it solves is scatter: knowledge spread across wikis, decks, PDFs,
|
|
8
|
+
prompts and memory, with no authoritative answer to the question an agent has
|
|
9
|
+
to ask — which knowledge should I trust?
|
|
10
|
+
- An assistant cannot answer that question, because it answers from everything
|
|
11
|
+
it has ever read and cannot tell you which of its sentences were checked.
|
|
12
|
+
- Every answer here traces to a document that names who stands behind it and
|
|
13
|
+
when it took effect.
|
|
14
|
+
- It does **not** settle whether that document is right. Provenance proves who
|
|
15
|
+
said what and when; whether the source is any good is a separate matter.
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
import { predecessorsOf, readGovernance, resolveSuccessorUrl } from "@/lib/governance";
|
|
16
16
|
import { showGovernance } from "@/lib/shared";
|
|
17
17
|
import { RecordToc, TocItems } from "@/components/record-toc";
|
|
18
|
+
import { RecordViews } from "@/components/record-views";
|
|
19
|
+
import { Flashcards } from "@/components/flashcards";
|
|
20
|
+
import { StudyAids } from "@/components/study-aids";
|
|
21
|
+
import { deckFor, summaryFor } from "@/lib/attachments";
|
|
22
|
+
import { readingMinutes } from "@/lib/reading-time";
|
|
18
23
|
|
|
19
24
|
export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
|
|
20
25
|
const params = await props.params;
|
|
@@ -22,6 +27,18 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
|
|
|
22
27
|
if (!page) notFound();
|
|
23
28
|
|
|
24
29
|
const MDX = page.data.body;
|
|
30
|
+
// Attachments of THIS document, found by suffix on its own path. Null is the
|
|
31
|
+
// ordinary case, not an error.
|
|
32
|
+
const summary = summaryFor(page.path);
|
|
33
|
+
const Summary = summary?.body ?? null;
|
|
34
|
+
const deck = deckFor(page.path);
|
|
35
|
+
// Counted at BUILD time from the document's own markdown, so the figure is in
|
|
36
|
+
// the shipped HTML for a reader with a failed bundle, a crawler and an agent
|
|
37
|
+
// alike. The predecessor measured the rendered DOM after paint, which put it
|
|
38
|
+
// out of reach of all three.
|
|
39
|
+
const minutes = readingMinutes(await page.data.getText("processed"));
|
|
40
|
+
const summaryMinutes =
|
|
41
|
+
summary === null ? null : readingMinutes(await summary.getText("processed"));
|
|
25
42
|
// What the record says about this document. The page renders it; it never
|
|
26
43
|
// supplies it — an undeclared key shows nothing (specs/ksor/site-governance).
|
|
27
44
|
const governance = readGovernance(page.data, page.path);
|
|
@@ -112,14 +129,35 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
|
|
|
112
129
|
describes (measured, 2026-08-21). Short documents now end where their
|
|
113
130
|
text ends. */}
|
|
114
131
|
<DocsBody style={{ flexGrow: 0 }}>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
{/* The summary panel is built HERE, on the server, and handed to the
|
|
133
|
+
client tab strip as a prop — so it is in the shipped HTML whether or
|
|
134
|
+
not the bundle runs, which is what an agent parsing the page and a
|
|
135
|
+
reader with a failed bundle both depend on. Presence-driven: with no
|
|
136
|
+
summary, RecordViews renders the body alone and no tab strip exists
|
|
137
|
+
(specs/ksor/study-attachments C3, C20). */}
|
|
138
|
+
<RecordViews
|
|
139
|
+
documentMinutes={minutes}
|
|
140
|
+
summaryMinutes={summaryMinutes ?? undefined}
|
|
141
|
+
summary={
|
|
142
|
+
Summary === null ? null : (
|
|
143
|
+
<Summary components={getMDXComponents({ a: createRelativeLink(source, page) })} />
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
>
|
|
147
|
+
<MDX
|
|
148
|
+
components={getMDXComponents({
|
|
149
|
+
// relative links between documents in knowledge/ resolve to
|
|
150
|
+
// their rendered pages
|
|
151
|
+
a: createRelativeLink(source, page),
|
|
152
|
+
})}
|
|
153
|
+
/>
|
|
154
|
+
</RecordViews>
|
|
122
155
|
</DocsBody>
|
|
156
|
+
{/* What a reader DOES with this document once they have read it. One
|
|
157
|
+
region, so the quiz that will sit beside the deck is a child here and
|
|
158
|
+
not a new argument about where it goes. Renders nothing at all when
|
|
159
|
+
the document has no study aids. */}
|
|
160
|
+
<StudyAids>{deck === null ? null : <Flashcards deck={deck} />}</StudyAids>
|
|
123
161
|
{/* A folder's index page lists what the folder holds. Without it the
|
|
124
162
|
page ended at its own sentence and the documents below it were
|
|
125
163
|
reachable only from the sidebar (research/site-design.md F5). Empty
|