@panaversity/ksor 0.0.57 → 0.0.59
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 +205 -0
- package/README.md +19 -15
- package/dist/checker/check-main.mjs +142 -136
- package/dist/cli.mjs +812 -212
- package/dist/{gateway-api-C0vL3oOK-D24n786A.mjs → gateway-api-CEsb-e8z-Ch6oMaq2.mjs} +74 -7
- package/dist/gateway.d.mts +2 -2
- package/dist/gateway.mjs +1 -1
- package/docs/building.md +157 -0
- package/docs/deploying.md +17 -5
- package/docs/index.md +7 -3
- package/docs/ingesting.md +36 -6
- package/docs/tool-surface.md +0 -3
- package/docs/upgrading.md +16 -2
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +140 -134
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +6 -5
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +140 -134
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +6 -5
- package/templates/scaffold/.ksor/people.yaml +2 -2
- package/templates/scaffold/AGENTS.md +23 -8
- package/templates/scaffold/README.md +5 -2
- package/templates/scaffold/env.example +3 -0
- package/templates/scaffold/instance.md +2 -3
- package/templates/scaffold/system/site/next-no-telemetry.mjs +27 -0
- package/templates/scaffold/system/site/package.json +2 -2
- package/templates/scaffold/system/site/record/change-control.ts +319 -0
- package/templates/scaffold/system/site/record/index.ts +8 -0
- package/templates/scaffold/system/site/record/lock.ts +37 -0
- package/templates/scaffold/system/site/record/refusal.ts +1 -0
|
@@ -423,7 +423,7 @@ governance block every hit now carries:
|
|
|
423
423
|
Three edits pay for themselves:
|
|
424
424
|
|
|
425
425
|
- **Delete a tool nothing calls.** Removing `outline` and `read` gives back
|
|
426
|
-
~2,
|
|
426
|
+
~2,310 tokens for the whole session.
|
|
427
427
|
- **Say what this record covers**, above `FLOOR.search`. It is how an agent with
|
|
428
428
|
several records attached picks yours; name the subject AND the boundary.
|
|
429
429
|
- **Set `k`** in the input schema — it is the lever on reply size.
|
|
@@ -497,9 +497,14 @@ runs over every entry in the ledger at `pnpm check`, `ksor build` and ingest, so
|
|
|
497
497
|
a line appended by hand in a pull request is refused exactly as the verb would
|
|
498
498
|
refuse it. The read-only modes
|
|
499
499
|
(`--list`, `--ledger`) need no actor — nobody is performing an act by looking.
|
|
500
|
-
They do not need a database either:
|
|
501
|
-
|
|
502
|
-
|
|
500
|
+
They do not need a database either: `--ledger` always reads the committed
|
|
501
|
+
`.ksor/takedowns.yaml`, which is the record of every act, and `--list` reads
|
|
502
|
+
the door's denylist rows when `KSOR_DB_URL` is set and otherwise the ledger's
|
|
503
|
+
denials, each labelled `not applied (no database)` because no row exists for
|
|
504
|
+
a door to refuse on. A denial itself, on this record with `KSOR_DB_URL` unset,
|
|
505
|
+
is refused by name (`ksor-takedown-dsn-missing`) unless `--file-only` records
|
|
506
|
+
the entry alone — `--apply` writes its row later, where the database is
|
|
507
|
+
reachable.
|
|
503
508
|
|
|
504
509
|
**The MCP door stops serving it immediately. The SITE stops at its next
|
|
505
510
|
build** — the site reads the committed ledger (`.ksor/takedowns.yaml`), not
|
|
@@ -516,7 +521,11 @@ ROOT, never `system/site/`), and every other host just serves the folder.
|
|
|
516
521
|
record checked, `build.lock.json` written (commit it: it is the provenance
|
|
517
522
|
every machine artefact stamps) — and a refusal stops the build before a byte
|
|
518
523
|
is written. `ksor build --strict` additionally refuses an uncommitted input
|
|
519
|
-
(`ksor-build-dirty`), which is the posture for a release.
|
|
524
|
+
(`ksor-build-dirty`), which is the posture for a release. `ksor build
|
|
525
|
+
--bundles` also writes one OKF bundle per viewer under
|
|
526
|
+
`.ksor/out/bundles/<viewer>/` (gitignored) — only what that viewer's machine
|
|
527
|
+
surfaces publish, readable by any OKF consumer with no ksor in the loop — for
|
|
528
|
+
handing the record to another system.
|
|
520
529
|
`KSOR_BASE_PATH=/repo pnpm build` targets sub-path hosting. With audiences
|
|
521
530
|
registered in `.ksor/governance.yaml`, plain `pnpm build` is the `public`
|
|
522
531
|
viewer; `KSOR_AUDIENCE=public,<audience> pnpm build` — a comma list, always
|
|
@@ -607,7 +616,14 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
|
|
|
607
616
|
audiences registered in `.ksor/governance.yaml`; never omitted, never
|
|
608
617
|
inferred). A `stable` document carries `generated: { by, at }` and
|
|
609
618
|
`ksor.approval: { by, at }` by an actor the policy authorises, with
|
|
610
|
-
`generated.at` no later than the approval
|
|
619
|
+
`generated.at` no later than the approval — and `generated.at` dates the
|
|
620
|
+
TEXT: edit a stable document's body and `ksor build` and `ksor ingest`
|
|
621
|
+
compare it against every committed version that was stable, and refuse
|
|
622
|
+
`ksor-generated-stale` until you move the stamp PAST the edit and re-approve
|
|
623
|
+
— leaving the stamp alone and backdating it are refused alike, because
|
|
624
|
+
neither advances it (a frontmatter-only edit, such as adding a `verified`
|
|
625
|
+
entry, is not a body change; `pnpm check` reads no document history, so it passes an
|
|
626
|
+
edit the build will refuse); a `deprecated` one carries
|
|
611
627
|
`ksor.deprecated: { by, at }` and usually `ksor.superseded_by: <id>`
|
|
612
628
|
(a stable document every reader of this one may read). Optional: `order`
|
|
613
629
|
(reading position), `sources` (`{ id, resource, title }`, cited in the body
|
|
@@ -899,8 +915,7 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
|
|
|
899
915
|
click is informed.
|
|
900
916
|
|
|
901
917
|
**You do not state a height.** A page carried in the record is measured, so
|
|
902
|
-
the frame is exactly as tall as what it holds
|
|
903
|
-
to the pixel. A number written into a document would be a number some other
|
|
918
|
+
the frame is exactly as tall as what it holds, to the pixel. A number written into a document would be a number some other
|
|
904
919
|
measure makes wrong.
|
|
905
920
|
|
|
906
921
|
**Carry the page in where you can.** A file named `<name>.sim.html`, sitting
|
|
@@ -259,7 +259,9 @@ Never copy a floor from another corpus.
|
|
|
259
259
|
It also prints its own caveat, and it is worth reading: the probes it writes are
|
|
260
260
|
derived from your passages, so they share vocabulary a real question will not.
|
|
261
261
|
The floor it reports is an upper bound on separation until you check it against
|
|
262
|
-
questions
|
|
262
|
+
questions a person wrote: in-corpus ones with `--queries-file`, and — because
|
|
263
|
+
the built-in out-of-corpus probes are all far-domain — questions just outside
|
|
264
|
+
your scope with `--ooc-file`.
|
|
263
265
|
|
|
264
266
|
A rerun on an unchanged record costs nothing: no new generation, no embedding,
|
|
265
267
|
no rows. Edit a document and the next run picks up exactly that change.
|
|
@@ -698,7 +700,7 @@ different coding agent's way of finding the same working contract.
|
|
|
698
700
|
| `.gemini/settings.json` | points Gemini CLI at `AGENTS.md`; Gemini does not read that filename on its own. |
|
|
699
701
|
| `.github/workflows/validate.yml` | your CI: runs the same checker on every pull request and push to main. |
|
|
700
702
|
| `.gitattributes` | markdown is checked out byte-stable on every platform, so the same commit hashes the same everywhere. |
|
|
701
|
-
| `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history — and negates
|
|
703
|
+
| `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history — and negates three paths inside `.ksor/`, because the policy, the ledger and the phone book ARE the record. |
|
|
702
704
|
|
|
703
705
|
`format-checker` deliberately contains a program, `check.mjs`, and not only
|
|
704
706
|
prose: rules that are only written down cannot refuse anything.
|
|
@@ -724,6 +726,7 @@ map rather than a substitute.
|
|
|
724
726
|
| the home page and `/llms.txt` are empty | every document is still a draft — correct, not broken | approve one and rebuild |
|
|
725
727
|
| `ksor-record-empty` | every document was deleted — a record is never empty, so nothing was written | add one document of your own (or restore one from git) before deleting the last starter |
|
|
726
728
|
| `ksor-approver-unauthorised` | a document is approved by an actor `.ksor/governance.yaml` no longer names — usually `human:you` after the interview | re-attribute the approval to your handle, or restore the actor to `approval_authorities` |
|
|
729
|
+
| `ksor-generated-stale` at `pnpm build` or `pnpm refresh` | a `stable` document's body changed since a commit where it was stable, and `generated.at` was not advanced past that commit's (left alone, or moved backward) — the stamp dates the text, and `pnpm check` reads no document history, so it passed | set `generated.at` to an instant after the edit and re-approve (`ksor.approval.at` may not precede it) |
|
|
727
730
|
| a new document never appears on the built site | drafts reach no built surface at all | publish it — `status: stable` plus both governance keys |
|
|
728
731
|
| an expired document still shows on the site but not through the door | the static build evaluated `stale_after` at build time | rebuild and redeploy; schedule a rebuild if you use it |
|
|
729
732
|
| Vercel: `no services are declared` | Root Directory was auto-filled with `system/site` | set it to `./` |
|
|
@@ -66,6 +66,9 @@ KSOR_AUTH=disabled-local
|
|
|
66
66
|
# NEXT_PUBLIC_KSOR_SSO_URL=https://your-sso.example.com
|
|
67
67
|
# NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID=
|
|
68
68
|
# NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI=https://your-site.example.com/auth/callback
|
|
69
|
+
#
|
|
70
|
+
# Next.js telemetry is already off: the site's build and dev scripts run next
|
|
71
|
+
# through system/site/next-no-telemetry.mjs, which sets NEXT_TELEMETRY_DISABLED=1.
|
|
69
72
|
|
|
70
73
|
# ── Production knobs ────────────────────────────────────────────────────────
|
|
71
74
|
# Unset is fine for a local run; each one matters once this serves for real.
|
|
@@ -41,9 +41,8 @@ record wins.
|
|
|
41
41
|
|
|
42
42
|
Everything above describes KSoR itself. It ships filled in so that a fresh
|
|
43
43
|
project has a real governed corpus on the first `pnpm dev` — five approved
|
|
44
|
-
documents, three of them inside a folder, and one carrying
|
|
45
|
-
|
|
46
|
-
shelf and a placeholder.
|
|
44
|
+
documents, three of them inside a folder, and one carrying a summary
|
|
45
|
+
companion — instead of an empty shelf and a placeholder.
|
|
47
46
|
The documents live in `knowledge/`; delete them as your own knowledge arrives.
|
|
48
47
|
|
|
49
48
|
Be deliberate about replacing it, because a starter that describes the wrong
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Next.js with its telemetry OFF, so nothing phones home.
|
|
3
|
+
*
|
|
4
|
+
* Next.js ships with anonymous usage telemetry switched on: `next build` and
|
|
5
|
+
* `next dev` each post to telemetry.nextjs.org unless NEXT_TELEMETRY_DISABLED
|
|
6
|
+
* is set in the process environment. This project promises that nothing
|
|
7
|
+
* phones home, so `build` and `dev` run next through here instead of directly.
|
|
8
|
+
*
|
|
9
|
+
* Why the environment and not next.config.mjs: the `next dev` parent process
|
|
10
|
+
* never loads the config, and still records a session event on exit. Why a
|
|
11
|
+
* file and not a `NEXT_TELEMETRY_DISABLED=1 next …` prefix in package.json:
|
|
12
|
+
* that prefix is POSIX shell syntax, which cmd.exe refuses on Windows. This
|
|
13
|
+
* is node:child_process and nothing else — no dependency, works everywhere
|
|
14
|
+
* node does — and every argument passes through to next unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const next = fileURLToPath(new URL("dist/bin/next", import.meta.resolve("next/package.json")));
|
|
20
|
+
|
|
21
|
+
const child = spawnSync(process.execPath, [next, ...process.argv.slice(2)], {
|
|
22
|
+
stdio: "inherit",
|
|
23
|
+
env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (child.error) throw child.error;
|
|
27
|
+
process.exit(child.status ?? 1);
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"build": "next build --webpack",
|
|
8
|
-
"dev": "next dev"
|
|
7
|
+
"build": "node next-no-telemetry.mjs build --webpack",
|
|
8
|
+
"dev": "node next-no-telemetry.mjs dev"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"class-variance-authority": "0.7.1",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KSP R23 — the first verification tooth in approval.
|
|
3
|
+
*
|
|
4
|
+
* A `stable` concept's `generated.at` dates its TEXT, and `ksor.approval.at`
|
|
5
|
+
* ratifies the text of that date. Until now the checker compared the two
|
|
6
|
+
* authored instants and nothing else (`ksor-generated-after-approval`), so
|
|
7
|
+
* whether an edit actually moved `generated.at` was the author's obligation:
|
|
8
|
+
* change a sentence, leave the stamp, and the approval that ratified the old
|
|
9
|
+
* sentence reads as ratifying the new one, with nothing red. This module
|
|
10
|
+
* compares the body in the working tree against every committed version of
|
|
11
|
+
* the same path that was `stable`, and refuses `ksor-generated-stale` when a
|
|
12
|
+
* body differs under a `generated.at` the tree has not ADVANCED past — equal,
|
|
13
|
+
* or moved backward, which changes the stamp without advancing it. Only the
|
|
14
|
+
* body: a frontmatter-only
|
|
15
|
+
* edit — a `verified` entry, a re-approval — is not a change to the text the
|
|
16
|
+
* stamp dates.
|
|
17
|
+
*
|
|
18
|
+
* What it does NOT verify, and says so: who reviewed anything. R22 and R25
|
|
19
|
+
* need an identity the platform can vouch for, and `approval.checked` stays
|
|
20
|
+
* `"policy"` (decision 21). This is the one rule in the record module that
|
|
21
|
+
* reads git, so it sits beside `git-ledger.ts` and is run by the two
|
|
22
|
+
* publishing verbs — `ksor build` and `ksor ingest` — each of which SAYS when
|
|
23
|
+
* history could not be read rather than passing a check that did not run. The
|
|
24
|
+
* emitted `check.mjs` does not run it: that is the format gate an agent runs
|
|
25
|
+
* after every edit, and a stamp is verified where the record is published.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
|
|
29
|
+
import { normalizeText, splitFrontmatter } from "./frontmatter";
|
|
30
|
+
import { git } from "./git-ledger";
|
|
31
|
+
import { parseInstant } from "./instant";
|
|
32
|
+
import type { Concept } from "./profile";
|
|
33
|
+
import type { Refusal } from "./refusal";
|
|
34
|
+
|
|
35
|
+
export interface CommittedVersion {
|
|
36
|
+
readonly sha: string;
|
|
37
|
+
/** The committer instant, ISO 8601 with an offset (`%cI`). */
|
|
38
|
+
readonly committedAt: string;
|
|
39
|
+
readonly text: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CommittedHistory {
|
|
43
|
+
/** False outside any repository, and false without a git binary. */
|
|
44
|
+
readonly repository: boolean;
|
|
45
|
+
/** Whether HEAD exists: a fresh `ksor init` is a repository with no commit. */
|
|
46
|
+
readonly born: boolean;
|
|
47
|
+
readonly shallow: boolean;
|
|
48
|
+
/** Record-relative path → its committed versions, newest first; null when history could not be read. */
|
|
49
|
+
readonly versions: ReadonlyMap<string, readonly CommittedVersion[]> | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The record's bundle, and the only pathspec the walk needs (relative to the cwd, which is the record root). */
|
|
53
|
+
const BUNDLE = "knowledge/";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Objects per `git cat-file --batch` call. One process per object was measured
|
|
57
|
+
* at ~53 ms a call on the machine this was built on against ~0.6 ms an object
|
|
58
|
+
* batched (2026-09-02), so a record with a few hundred committed versions is
|
|
59
|
+
* the difference between a build that finishes and one that does not. Chunked
|
|
60
|
+
* so the reply stays under the buffer ceiling `git-ledger.ts` explains.
|
|
61
|
+
*/
|
|
62
|
+
const BATCH = 256;
|
|
63
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every committed version of each path, read in three git calls plus one
|
|
67
|
+
* `cat-file --batch` per chunk. `--full-history`, for the reason the ledger
|
|
68
|
+
* walk gives: default simplification follows a merge through the parent it is
|
|
69
|
+
* TREESAME to, so a version that lived only on a branch never enters the set.
|
|
70
|
+
* `-m`, because without a diff-merges option a merge commit lists NO paths at
|
|
71
|
+
* all — verified against git 2.50 — and a conflict resolved by hand is a body
|
|
72
|
+
* that is in neither parent; the same commit then prints once per parent, so
|
|
73
|
+
* versions are keyed by (commit, path). That flag is held by the merge case in
|
|
74
|
+
* `build.integration.test.ts`'s KSP R23 describe: drop `-m` here and the build
|
|
75
|
+
* it expects to refuse exits 0 instead.
|
|
76
|
+
*/
|
|
77
|
+
export function committedVersions(root: string, paths: readonly string[]): CommittedHistory {
|
|
78
|
+
const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
79
|
+
if (inside === null || inside.trim() !== "true") {
|
|
80
|
+
return { repository: false, born: false, shallow: false, versions: null };
|
|
81
|
+
}
|
|
82
|
+
const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
|
|
83
|
+
const born = git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) !== null;
|
|
84
|
+
const versions = new Map<string, CommittedVersion[]>();
|
|
85
|
+
if (!born || paths.length === 0) return { repository: true, born, shallow, versions };
|
|
86
|
+
|
|
87
|
+
// `--name-only` prints paths from the REPOSITORY root, and the record may
|
|
88
|
+
// sit below it (`docs-sor/knowledge/x.md`) — the same two meanings of "path"
|
|
89
|
+
// `git-ledger.ts` records. The pathspec is cwd-relative; the names are not.
|
|
90
|
+
const prefix = (git(root, ["rev-parse", "--show-prefix"]) ?? "").trim();
|
|
91
|
+
const log = git(root, [
|
|
92
|
+
"log",
|
|
93
|
+
"--full-history",
|
|
94
|
+
"-m",
|
|
95
|
+
"--format=%x1e%H %cI",
|
|
96
|
+
"--name-only",
|
|
97
|
+
"--",
|
|
98
|
+
BUNDLE,
|
|
99
|
+
]);
|
|
100
|
+
if (log === null) return { repository: true, born, shallow, versions: null };
|
|
101
|
+
|
|
102
|
+
const wanted = new Set(paths);
|
|
103
|
+
const refs: { readonly sha: string; readonly committedAt: string; readonly path: string }[] = [];
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
for (const block of log.split("\x1e")) {
|
|
106
|
+
const [header = "", ...names] = block.split("\n");
|
|
107
|
+
const [sha = "", committedAt = ""] = header.trim().split(" ");
|
|
108
|
+
if (sha === "") continue;
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
// Names the profile admits are plain ASCII (`ksor-name-unportable`), so
|
|
111
|
+
// git never C-quotes one here; a quoted stray matches no wanted path.
|
|
112
|
+
const trimmed = name.trim();
|
|
113
|
+
if (trimmed === "" || !trimmed.startsWith(prefix)) continue;
|
|
114
|
+
const rel = trimmed.slice(prefix.length);
|
|
115
|
+
if (!wanted.has(rel) || seen.has(`${sha} ${rel}`)) continue;
|
|
116
|
+
seen.add(`${sha} ${rel}`);
|
|
117
|
+
refs.push({ sha, committedAt, path: rel });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const blobs = readBlobs(
|
|
121
|
+
root,
|
|
122
|
+
refs.map((r) => `${r.sha}:${prefix}${r.path}`),
|
|
123
|
+
);
|
|
124
|
+
if (blobs === null) return { repository: true, born, shallow, versions: null };
|
|
125
|
+
refs.forEach((ref, i) => {
|
|
126
|
+
const text = blobs[i];
|
|
127
|
+
// Absent in that commit: the commit DELETED the path (a rename's old name).
|
|
128
|
+
if (text === null || text === undefined) return;
|
|
129
|
+
const list = versions.get(ref.path) ?? [];
|
|
130
|
+
list.push({ sha: ref.sha, committedAt: ref.committedAt, text });
|
|
131
|
+
versions.set(ref.path, list);
|
|
132
|
+
});
|
|
133
|
+
return { repository: true, born, shallow, versions };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `<rev>:<path>` specs → text, in order; null for a spec that names nothing,
|
|
138
|
+
* and null for the WHOLE read when git failed — a version dropped in silence
|
|
139
|
+
* would read as verified.
|
|
140
|
+
*/
|
|
141
|
+
function readBlobs(root: string, specs: readonly string[]): readonly (string | null)[] | null {
|
|
142
|
+
const out: (string | null)[] = [];
|
|
143
|
+
for (let i = 0; i < specs.length; i += BATCH) {
|
|
144
|
+
const chunk = specs.slice(i, i + BATCH);
|
|
145
|
+
const r = spawnSync("git", ["cat-file", "--batch"], {
|
|
146
|
+
cwd: root,
|
|
147
|
+
input: `${chunk.join("\n")}\n`,
|
|
148
|
+
maxBuffer: MAX_BUFFER,
|
|
149
|
+
});
|
|
150
|
+
if (r.status !== 0) return null;
|
|
151
|
+
const buf: Buffer = r.stdout;
|
|
152
|
+
let at = 0;
|
|
153
|
+
for (let k = 0; k < chunk.length; k += 1) {
|
|
154
|
+
const nl = buf.indexOf(0x0a, at);
|
|
155
|
+
if (nl === -1) return null;
|
|
156
|
+
const header = buf.subarray(at, nl).toString("utf8");
|
|
157
|
+
at = nl + 1;
|
|
158
|
+
if (header.endsWith(" missing")) {
|
|
159
|
+
out.push(null);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const [, type, sizeText] = header.split(" ");
|
|
163
|
+
const size = Number(sizeText);
|
|
164
|
+
if (type !== "blob" || !Number.isInteger(size) || at + size > buf.length) return null;
|
|
165
|
+
out.push(buf.subarray(at, at + size).toString("utf8"));
|
|
166
|
+
at += size + 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface StableVersion {
|
|
173
|
+
readonly sha: string;
|
|
174
|
+
readonly committedAt: string;
|
|
175
|
+
readonly generatedAt: number;
|
|
176
|
+
/** The stamp as that version spelled it, so the refusal quotes the file rather than a re-rendering. */
|
|
177
|
+
readonly stamp: string;
|
|
178
|
+
readonly body: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Line endings are the checkout's and a trailing blank line is nobody's edit; everything else is the text. */
|
|
182
|
+
function comparable(body: string): string {
|
|
183
|
+
return normalizeText(body).trimEnd();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A committed version as a stable, stamped text — or null: a draft's body is
|
|
188
|
+
* free, and a version the profile cannot read (no fence, broken YAML, no
|
|
189
|
+
* `generated.at`) was never a stable version under the profile.
|
|
190
|
+
*/
|
|
191
|
+
function stableVersionOf(version: CommittedVersion, path: string): StableVersion | null {
|
|
192
|
+
const split = splitFrontmatter(version.text, path);
|
|
193
|
+
if (!split.ok || split.frontmatter === null || split.frontmatter["status"] !== "stable") {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const generated = split.frontmatter["generated"];
|
|
197
|
+
if (typeof generated !== "object" || generated === null) return null;
|
|
198
|
+
const at = (generated as Readonly<Record<string, unknown>>)["at"];
|
|
199
|
+
if (typeof at !== "string") return null;
|
|
200
|
+
const generatedAt = parseInstant(at);
|
|
201
|
+
if (generatedAt === null) return null;
|
|
202
|
+
return {
|
|
203
|
+
sha: version.sha,
|
|
204
|
+
committedAt: version.committedAt,
|
|
205
|
+
generatedAt,
|
|
206
|
+
stamp: at,
|
|
207
|
+
body: comparable(split.body),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The rule, pure: for every `stable` concept whose body differs from a
|
|
213
|
+
* committed version that was `stable`, the tree's `generated.at` must be
|
|
214
|
+
* strictly LATER than that version's. Equal is the defect this rule exists to
|
|
215
|
+
* catch — edit the sentence, leave the stamp — and EARLIER is the same defect
|
|
216
|
+
* with one more keystroke: backdating the stamp changes it without advancing
|
|
217
|
+
* it, so the approval that ratified the old text still post-dates the new one.
|
|
218
|
+
* Only strictly-later clears a version, which is exactly what the refusal's
|
|
219
|
+
* own `fix` prints. All of history, not only HEAD's version — an edit
|
|
220
|
+
* committed without a bump matches HEAD exactly, and it is the version BEHIND
|
|
221
|
+
* it that tells (the shape CI sees). A path with no committed stable version
|
|
222
|
+
* passes: stable for the first time, or renamed, since path is identity.
|
|
223
|
+
* Instants are compared as instants, so two spellings of one moment are one
|
|
224
|
+
* stamp.
|
|
225
|
+
*/
|
|
226
|
+
export function checkGeneratedStale(
|
|
227
|
+
concepts: readonly Concept[],
|
|
228
|
+
files: ReadonlyMap<string, string>,
|
|
229
|
+
versions: ReadonlyMap<string, readonly CommittedVersion[]>,
|
|
230
|
+
): Refusal[] {
|
|
231
|
+
const refusals: Refusal[] = [];
|
|
232
|
+
for (const concept of concepts) {
|
|
233
|
+
if (concept.status !== "stable" || concept.generatedAt === null) continue;
|
|
234
|
+
const text = files.get(concept.path);
|
|
235
|
+
if (text === undefined) continue;
|
|
236
|
+
const split = splitFrontmatter(text, concept.path);
|
|
237
|
+
if (!split.ok) continue;
|
|
238
|
+
const body = comparable(split.body);
|
|
239
|
+
for (const version of versions.get(concept.path) ?? []) {
|
|
240
|
+
const stable = stableVersionOf(version, concept.path);
|
|
241
|
+
if (stable === null || stable.generatedAt < concept.generatedAt || stable.body === body) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const authored = (concept.frontmatter["generated"] as { readonly at?: unknown } | undefined)
|
|
245
|
+
?.at;
|
|
246
|
+
const stamp =
|
|
247
|
+
typeof authored === "string" ? authored : new Date(concept.generatedAt).toISOString();
|
|
248
|
+
const under =
|
|
249
|
+
stable.generatedAt === concept.generatedAt
|
|
250
|
+
? `under the same \`generated.at\` (${stamp})`
|
|
251
|
+
: `under \`generated.at\` ${stable.stamp}, LATER than the ${stamp} this file now carries ` +
|
|
252
|
+
"(the stamp was moved backward, which changes it without advancing it)";
|
|
253
|
+
refusals.push({
|
|
254
|
+
slug: "ksor-generated-stale",
|
|
255
|
+
path: concept.path,
|
|
256
|
+
why:
|
|
257
|
+
`the body differs from the one committed at ${stable.sha.slice(0, 7)} (${stable.committedAt}), ` +
|
|
258
|
+
`where this concept was \`stable\` ${under} — that instant dates ` +
|
|
259
|
+
"the text, so an edit to a stable concept must advance it, or the approval that ratified the old " +
|
|
260
|
+
"text reads as ratifying the new one (KSP R23)",
|
|
261
|
+
fix:
|
|
262
|
+
"set `generated.at` to an instant after this edit, then re-approve: `ksor.approval.at` must not " +
|
|
263
|
+
"precede the new `generated.at` (`ksor-generated-after-approval`)",
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return refusals;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface ChangeControl {
|
|
272
|
+
readonly refusals: readonly Refusal[];
|
|
273
|
+
/**
|
|
274
|
+
* Why the check could not run, or ran short — printed beside the verdict,
|
|
275
|
+
* never swallowed. Null when every committed version was read.
|
|
276
|
+
*/
|
|
277
|
+
readonly notice: string | null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The two callers' one entry: read history for the stable concepts, judge, and say what could not be read. */
|
|
281
|
+
export function checkChangeControl(
|
|
282
|
+
root: string,
|
|
283
|
+
concepts: readonly Concept[],
|
|
284
|
+
files: ReadonlyMap<string, string>,
|
|
285
|
+
): ChangeControl {
|
|
286
|
+
const paths = concepts.filter((c) => c.status === "stable").map((c) => c.path);
|
|
287
|
+
const history = committedVersions(root, paths);
|
|
288
|
+
const refusals =
|
|
289
|
+
history.versions === null ? [] : checkGeneratedStale(concepts, files, history.versions);
|
|
290
|
+
return { refusals, notice: changeControlNotice(history) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Honest absence in the build's own idiom — the `source: unspecified` line
|
|
295
|
+
* already says the commit is unknown; this says the same of the check that
|
|
296
|
+
* needs one. A check that could not run is never a check that passed.
|
|
297
|
+
*/
|
|
298
|
+
function changeControlNotice(history: CommittedHistory): string | null {
|
|
299
|
+
const what =
|
|
300
|
+
"so whether a stable concept's body changed under its `generated.at` (KSP R23) was not checked";
|
|
301
|
+
if (!history.repository) {
|
|
302
|
+
return `change-control: not checked — knowledge/ is not in a git repository (or git is not installed), ${what}`;
|
|
303
|
+
}
|
|
304
|
+
if (!history.born) {
|
|
305
|
+
return `change-control: not checked — the repository has no commits yet, ${what}`;
|
|
306
|
+
}
|
|
307
|
+
if (history.versions === null) {
|
|
308
|
+
return `change-control: not checked — git could not read the history of knowledge/ (\`git log -- knowledge/\` or \`git cat-file --batch\` failed), ${what}`;
|
|
309
|
+
}
|
|
310
|
+
if (history.shallow) {
|
|
311
|
+
const read = [...history.versions.values()].reduce((n, list) => n + list.length, 0);
|
|
312
|
+
return (
|
|
313
|
+
`change-control: checked against the ${read} committed version(s) this shallow clone holds — a stable ` +
|
|
314
|
+
"version beyond the shallow boundary was not read; fetch full history (`git fetch --unshallow`; in CI, " +
|
|
315
|
+
"`fetch-depth: 0`) to check all of it"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
@@ -72,6 +72,14 @@ export {
|
|
|
72
72
|
type Drafts,
|
|
73
73
|
} from "./lock";
|
|
74
74
|
export { git, historicLedger, type HistoricLedger } from "./git-ledger";
|
|
75
|
+
export {
|
|
76
|
+
checkChangeControl,
|
|
77
|
+
checkGeneratedStale,
|
|
78
|
+
committedVersions,
|
|
79
|
+
type ChangeControl,
|
|
80
|
+
type CommittedHistory,
|
|
81
|
+
type CommittedVersion,
|
|
82
|
+
} from "./change-control";
|
|
75
83
|
export { generateIndexes, parseIndex, humanise, type IndexInput } from "./index-file";
|
|
76
84
|
export { actorKind, isIndividualActor } from "./actor";
|
|
77
85
|
export { checkFootnotes, linkTargets, resolveLink } from "./citations";
|
|
@@ -33,6 +33,9 @@ export type Drafts = "hidden" | "shown";
|
|
|
33
33
|
|
|
34
34
|
const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
|
|
35
35
|
const viewerList = z.array(z.string().min(1));
|
|
36
|
+
const bundleEntry = z
|
|
37
|
+
.object({ viewer: z.string().min(1), sha256: hex64, files: z.number().int().nonnegative() })
|
|
38
|
+
.strict();
|
|
36
39
|
|
|
37
40
|
const lockSchema = z
|
|
38
41
|
.object({
|
|
@@ -66,9 +69,22 @@ const lockSchema = z
|
|
|
66
69
|
companions: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
67
70
|
assets: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
68
71
|
indexes: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
72
|
+
bundles: z.array(bundleEntry),
|
|
69
73
|
})
|
|
70
74
|
.strict();
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* One OKF bundle, as `ksor build --bundles` writes it for a canonical viewer
|
|
78
|
+
* (build spec §1 step 4): the digest is sha256 over the JSON of the bundle's
|
|
79
|
+
* sorted `[path, sha256]` pairs, so a recipient holding only the directory can
|
|
80
|
+
* recompute it and find the publication it came from.
|
|
81
|
+
*/
|
|
82
|
+
export interface LockBundle {
|
|
83
|
+
readonly viewer: string;
|
|
84
|
+
readonly sha256: string;
|
|
85
|
+
readonly files: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
export interface LockDocument {
|
|
73
89
|
/** Bundle-relative, with `.md`. */
|
|
74
90
|
readonly path: string;
|
|
@@ -116,6 +132,24 @@ export interface Lock {
|
|
|
116
132
|
* stopped short of the file that lists what was published.
|
|
117
133
|
*/
|
|
118
134
|
readonly indexes: readonly { readonly path: string; readonly sha256: string }[];
|
|
135
|
+
/**
|
|
136
|
+
* One digest per canonical viewer, recorded on EVERY build and not only when
|
|
137
|
+
* `--bundles` wrote the directories: the bundle set is a function of what
|
|
138
|
+
* `build_id` already hashes, so the lock is the same lock either way, and a
|
|
139
|
+
* `pnpm build` on a host that never passes the flag records the same digests
|
|
140
|
+
* the owner's `--bundles` run did. Outside `build_id` for the same reason —
|
|
141
|
+
* the documents, their admitted sets, the companions, the assets and the
|
|
142
|
+
* instance title are already in it, so hashing the bundles again could not
|
|
143
|
+
* move it (build spec §2). Listed so a directory can be MATCHED to a
|
|
144
|
+
* publication, not to widen what the id covers. Required on read like every
|
|
145
|
+
* other field, and NOT read around: a lock an older ksor wrote lacks the key,
|
|
146
|
+
* so `ksor build` refuses it as `ksor-lock-invalid` and says to delete it —
|
|
147
|
+
* it does not regenerate one it cannot read, because the lock is also a
|
|
148
|
+
* takedown baseline and a lock nothing can read is a baseline that quietly
|
|
149
|
+
* holds nothing. `ksor migrate` offers that deletion, which is the migration
|
|
150
|
+
* decision 28 pairs the removal with.
|
|
151
|
+
*/
|
|
152
|
+
readonly bundles: readonly LockBundle[];
|
|
119
153
|
}
|
|
120
154
|
|
|
121
155
|
export type LockResult =
|
|
@@ -254,6 +288,8 @@ export interface LockInput {
|
|
|
254
288
|
/** Bundle-relative path → the §8 index text this build generated (`index.md`, `policies/index.md`). */
|
|
255
289
|
readonly indexes: readonly { readonly path: string; readonly text: string }[];
|
|
256
290
|
readonly denials: readonly Denial[];
|
|
291
|
+
/** The digest of each canonical viewer's bundle, in the order `canonicalViewers` lists them. */
|
|
292
|
+
readonly bundles: readonly LockBundle[];
|
|
257
293
|
}
|
|
258
294
|
|
|
259
295
|
export function composeLock(input: LockInput): Lock {
|
|
@@ -310,6 +346,7 @@ export function composeLock(input: LockInput): Lock {
|
|
|
310
346
|
companions,
|
|
311
347
|
assets,
|
|
312
348
|
indexes,
|
|
349
|
+
bundles: input.bundles.map((b) => ({ viewer: b.viewer, sha256: b.sha256, files: b.files })),
|
|
313
350
|
};
|
|
314
351
|
}
|
|
315
352
|
|