@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
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-dqpI-p1a.mjs";
|
|
3
|
-
import { A as parseViewer, B as
|
|
3
|
+
import { A as parseViewer, B as servingPolicy$1, C as contentPoolMin, D as outlineHandler, E as keyRingFromEnv, F as readHandler, G as withPgRetry$1, H as tallyHandlers, I as readPublished, K as withProbeDeadline$1, L as recordIsUndescribed, M as prewarmPool, N as providerKeyEnv$1, O as parseInstanceText$1, P as publishedGeneration, R as runProbe$1, S as contentPool$1, T as instancePathOf$1, U as tlsPosture, V as storedTextSearchConfig, W as validateViewer, _ as assertGovernanceServable$1, a as GovernanceGateError$1, b as checkEmbeddingSpace$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, g as TextSearchConfigMismatch, h as TRUST_TIERS$1, i as FLOOR, j as pooledEndpointFor, k as parseTrustFloor, l as MissingProviderKeyError$1, m as SchemaVersionError, n as ContentStoreError$1, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, q as z$1, r as EmbeddingSpaceMismatch$1, t as AudienceError$1, u as OUTLINE_OUTPUT, v as assertSchemaCompatible, w as embedQueryVlit, x as composeInstructions, y as buildShippedProvider$1, z as searchHandler } from "./gateway-api-CEsb-e8z-Ch6oMaq2.mjs";
|
|
4
4
|
import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { InMemoryTransport, LATEST_PROTOCOL_VERSION, createMcpHandler } from "@modelcontextprotocol/server";
|
|
@@ -14,10 +14,10 @@ import { serve } from "@hono/node-server";
|
|
|
14
14
|
import { Hono } from "hono";
|
|
15
15
|
import { bodyLimit } from "hono/body-limit";
|
|
16
16
|
import { Document, YAMLParseError, isCollection, isMap, isPair, isSeq, parseAllDocuments, parseDocument } from "yaml";
|
|
17
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
18
|
import { parseArgs } from "node:util";
|
|
18
19
|
import { readFile } from "node:fs/promises";
|
|
19
|
-
|
|
20
|
-
//#region ../content-gateway/dist/main-BoKhKcP0.mjs
|
|
20
|
+
//#region ../content-gateway/dist/main-BDFsC03h.mjs
|
|
21
21
|
/**
|
|
22
22
|
* The default registration — and the ORIGINAL of the file `ksor init` emits.
|
|
23
23
|
*
|
|
@@ -461,10 +461,22 @@ function currentActor() {
|
|
|
461
461
|
/**
|
|
462
462
|
* A required env var is missing — thrown with the operator message. Like
|
|
463
463
|
* AuthConfigError, the gateway maps this distinct type to a clean stderr line
|
|
464
|
-
* + exit
|
|
464
|
+
* + exit 3 (environment); it must never half-boot past it.
|
|
465
|
+
*
|
|
466
|
+
* `slug` is the stable first stderr line the gateway prints when the refusal
|
|
467
|
+
* names a rule (`bootErrorLines`). Optional, because most of these are one
|
|
468
|
+
* sentence about one variable and the sentence IS the name; a missing provider
|
|
469
|
+
* key is the one with a rule behind it, and it exited 3 with no line an agent
|
|
470
|
+
* could branch on while every exit-1 refusal had one (product principle 4;
|
|
471
|
+
* found live, 2026-09-02).
|
|
465
472
|
*/
|
|
466
473
|
var RequiredEnvError = class extends Error {
|
|
467
474
|
name = "RequiredEnvError";
|
|
475
|
+
slug;
|
|
476
|
+
constructor(message, slug = null) {
|
|
477
|
+
super(message);
|
|
478
|
+
this.slug = slug;
|
|
479
|
+
}
|
|
468
480
|
};
|
|
469
481
|
/**
|
|
470
482
|
* Host: KSOR_MCP_HOST, else 0.0.0.0 only when $PORT is set, else loopback.
|
|
@@ -527,12 +539,45 @@ function transportSecurityFromEnv(env = process.env) {
|
|
|
527
539
|
* the code's own terms and visibly crooked in the block (review finding 63).
|
|
528
540
|
*/
|
|
529
541
|
function bootLine(label, text) {
|
|
530
|
-
const padded = label.padEnd(
|
|
542
|
+
const padded = label.padEnd(12);
|
|
531
543
|
return ` ${padded === label ? `${label} ` : padded}${text}`;
|
|
532
544
|
}
|
|
533
545
|
function bootHeader(corpusId) {
|
|
534
546
|
return `ksor serve · ${corpusId}`;
|
|
535
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* What this door is SERVING — or that it is serving nothing.
|
|
550
|
+
*
|
|
551
|
+
* The block said db, audience, trust, auth, abstain, serving and came up green
|
|
552
|
+
* on a provisioned record nobody had ever ingested, which is exactly where
|
|
553
|
+
* `ksor init`'s own next steps leave an adopter who skipped the publish step.
|
|
554
|
+
* Every search then answered `reason: "unpublished"`, correctly, to an agent —
|
|
555
|
+
* and nothing had told the operator (found live, 2026-09-02). The absence is
|
|
556
|
+
* stated in capitals like the other two lines that decide trust, and the
|
|
557
|
+
* remedy travels with it.
|
|
558
|
+
*/
|
|
559
|
+
function generationPosture(published, refresh) {
|
|
560
|
+
if (published === null) return `NONE — nothing published; run ${refresh}`;
|
|
561
|
+
const { generation, nodes, sourceCommit } = published;
|
|
562
|
+
return `${generation} · ${nodes} ${nodes === 1 ? "node" : "nodes"} · source ${sourceCommit}`;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* The publish step, in the words of the manager that spawned this process.
|
|
566
|
+
*
|
|
567
|
+
* `npm_config_user_agent` is the same signal `ksor init` reads to emit the
|
|
568
|
+
* adopter's scaffold (#28): every manager sets it for the scripts it runs, so
|
|
569
|
+
* `npm run serve` is told `npm run refresh` rather than a pnpm spelling its
|
|
570
|
+
* scaffold does not carry. No manager in the loop — `ksor serve` typed
|
|
571
|
+
* directly, a container entrypoint — names the VERB, which is the name every
|
|
572
|
+
* runbook here uses for the individual step (AGENTS.md → Vocabulary).
|
|
573
|
+
*/
|
|
574
|
+
function refreshCommand(userAgent) {
|
|
575
|
+
const head = (userAgent ?? "").split("/")[0]?.trim();
|
|
576
|
+
if (head === "pnpm") return "pnpm refresh";
|
|
577
|
+
if (head === "npm") return "npm run refresh";
|
|
578
|
+
if (head === "bun") return "bun run refresh";
|
|
579
|
+
return "ksor ingest --flip";
|
|
580
|
+
}
|
|
536
581
|
function withoutSdkResponseModeWarning(body) {
|
|
537
582
|
const warn = console.warn;
|
|
538
583
|
console.warn = (...args) => {
|
|
@@ -1013,7 +1058,7 @@ async function compose(rawInstancePath, version) {
|
|
|
1013
1058
|
dim: instance.embeddingDim
|
|
1014
1059
|
});
|
|
1015
1060
|
} catch (error) {
|
|
1016
|
-
if (error instanceof MissingProviderKeyError$1) throw new RequiredEnvError(error.message);
|
|
1061
|
+
if (error instanceof MissingProviderKeyError$1) throw new RequiredEnvError(error.message, error.slug);
|
|
1017
1062
|
throw error;
|
|
1018
1063
|
}
|
|
1019
1064
|
console.error(bootHeader(instance.corpusId));
|
|
@@ -1026,6 +1071,8 @@ async function compose(rawInstancePath, version) {
|
|
|
1026
1071
|
const requestedViewer = parseViewer(process.env["KSOR_AUDIENCE"]);
|
|
1027
1072
|
let viewer = ["public"];
|
|
1028
1073
|
let spaceSkipReason = "not yet verified — boot checks have not passed";
|
|
1074
|
+
let published = null;
|
|
1075
|
+
const refreshHint = refreshCommand(process.env["npm_config_user_agent"]);
|
|
1029
1076
|
const bootChecks = async () => {
|
|
1030
1077
|
await assertSchemaCompatible(pool);
|
|
1031
1078
|
const stored = await storedTextSearchConfig(pool);
|
|
@@ -1034,6 +1081,8 @@ async function compose(rawInstancePath, version) {
|
|
|
1034
1081
|
const policy = await servingPolicy$1(pool, instance);
|
|
1035
1082
|
viewer = validateViewer(policy?.registry ?? [], requestedViewer);
|
|
1036
1083
|
console.error(bootLine("audience", viewer.join(",")));
|
|
1084
|
+
published = await publishedGeneration(pool, instance);
|
|
1085
|
+
console.error(bootLine("generation", generationPosture(published, refreshHint)));
|
|
1037
1086
|
const space = await checkEmbeddingSpace$1(pool, instance.tenantId, instance.embeddingModel, instance.embeddingDim);
|
|
1038
1087
|
if (space.storeUnreachable) throw new ContentStoreError$1(`embedding-space guard: ${space.reason ?? "unknown"}`);
|
|
1039
1088
|
spaceSkipReason = space.reason;
|
|
@@ -1048,6 +1097,7 @@ async function compose(rawInstancePath, version) {
|
|
|
1048
1097
|
console.error(`boot checks DEFERRED: content store unreachable${classSuffix(error)} — this instance reports NOT READY until schema AND governance both verify`);
|
|
1049
1098
|
console.error(` cause: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
1050
1099
|
console.error(bootLine("audience", `not resolved — requested ${requestedViewer.join(",")}; this door refuses every request until the boot checks pass`));
|
|
1100
|
+
console.error(bootLine("generation", "not resolved — read once the boot checks pass"));
|
|
1051
1101
|
let verified = false;
|
|
1052
1102
|
bootVerified = () => verified;
|
|
1053
1103
|
let inFlight = null;
|
|
@@ -1095,6 +1145,13 @@ async function compose(rawInstancePath, version) {
|
|
|
1095
1145
|
get spaceSkipReason() {
|
|
1096
1146
|
return spaceSkipReason;
|
|
1097
1147
|
},
|
|
1148
|
+
get published() {
|
|
1149
|
+
return published;
|
|
1150
|
+
},
|
|
1151
|
+
refreshHint,
|
|
1152
|
+
probePublished: async (client) => {
|
|
1153
|
+
published = await readPublished(client, instance);
|
|
1154
|
+
},
|
|
1098
1155
|
requestedViewer,
|
|
1099
1156
|
version,
|
|
1100
1157
|
verifyBoot,
|
|
@@ -1279,7 +1336,7 @@ async function runHttp(composition) {
|
|
|
1279
1336
|
}
|
|
1280
1337
|
const entry = {
|
|
1281
1338
|
settledAt: null,
|
|
1282
|
-
verdict: withProbeDeadline$1((verifyBoot === null ? Promise.resolve() : verifyBoot()).then(() => runProbe$1(pool, instance.tenantId, (client) =>
|
|
1339
|
+
verdict: withProbeDeadline$1((verifyBoot === null ? Promise.resolve() : verifyBoot()).then(() => runProbe$1(pool, instance.tenantId, (client) => composition.probePublished(client)))).then(() => ({ ok: true }), (error) => ({
|
|
1283
1340
|
ok: false,
|
|
1284
1341
|
error
|
|
1285
1342
|
}))
|
|
@@ -1325,6 +1382,7 @@ async function runHttp(composition) {
|
|
|
1325
1382
|
corpus_id: instance.corpusId,
|
|
1326
1383
|
store: verdict.ok ? "reachable" : notReadyReason(verdict.error),
|
|
1327
1384
|
boot_checks: bootVerified() ? "passed" : "NOT PASSED — every request is being refused until schema, governance, the audience list and the embedding space all verify; the reason is in this server's logs",
|
|
1385
|
+
generation: bootVerified() ? generationPosture(composition.published, composition.refreshHint) : "not resolved — boot checks have not passed",
|
|
1328
1386
|
abstain_gate: abstainPosture(instance.abstain.vectorFloor, instance.abstain.floorDigest),
|
|
1329
1387
|
embedding_space: composition.spaceSkipReason === null ? `${instance.embeddingModel}/d${instance.embeddingDim} ok` : `${instance.embeddingModel}/d${instance.embeddingDim} unverified (check skipped: ${composition.spaceSkipReason})`,
|
|
1330
1388
|
auth: auth.mode
|
|
@@ -1533,7 +1591,7 @@ async function main$1(version = GATEWAY_VERSION) {
|
|
|
1533
1591
|
}
|
|
1534
1592
|
}
|
|
1535
1593
|
//#endregion
|
|
1536
|
-
//#region ../content/dist/check-
|
|
1594
|
+
//#region ../content/dist/check-Nd8zkMvH.mjs
|
|
1537
1595
|
/**
|
|
1538
1596
|
* One reader for the control files beside the bundle (`.ksor/*.yaml`): the
|
|
1539
1597
|
* same parser posture as a concept's frontmatter — one document, unique keys,
|
|
@@ -3523,6 +3581,394 @@ function nearMissOf(baseName) {
|
|
|
3523
3581
|
return null;
|
|
3524
3582
|
}
|
|
3525
3583
|
/**
|
|
3584
|
+
* The takedown ledger's OTHER baseline: every entry any COMMITTED version of
|
|
3585
|
+
* `.ksor/takedowns.yaml` has ever carried.
|
|
3586
|
+
*
|
|
3587
|
+
* The committed lock is a baseline too, and a good one — it holds each entry's
|
|
3588
|
+
* digest, so an entry retargeted in place is caught. What it cannot do is prove
|
|
3589
|
+
* that an entry was never deleted, because the lock travels in the SAME change
|
|
3590
|
+
* as the ledger: delete the row, recompute `ledger_sha256`, empty
|
|
3591
|
+
* `ledger_entries`, and the two agree with each other about a denial that is
|
|
3592
|
+
* gone. Only history remembers.
|
|
3593
|
+
*
|
|
3594
|
+
* The baseline may be INCOMPLETE only if it SAYS so: every version history
|
|
3595
|
+
* holds is read, or `entries` comes back null and the caller reports that it
|
|
3596
|
+
* could not verify. A version silently skipped would contribute neither
|
|
3597
|
+
* digests nor ids while the answer still read "verified"
|
|
3598
|
+
* (`git-ledger.integration.test.ts`).
|
|
3599
|
+
*
|
|
3600
|
+
* This lives in the record module because THREE surfaces need the same answer —
|
|
3601
|
+
* `ksor build`, the emitted checker, and the site's stage (decision 19: a
|
|
3602
|
+
* surface that refuses must refuse on both surfaces). Plain `git log` / `git
|
|
3603
|
+
* show`, so nothing here needs installing.
|
|
3604
|
+
*/
|
|
3605
|
+
const LEDGER = ".ksor/takedowns.yaml";
|
|
3606
|
+
/**
|
|
3607
|
+
* `spawnSync` defaults to a 1 MB stdout buffer, and past it the child is KILLED
|
|
3608
|
+
* — `status` comes back null, so the query reads as a failure. A ledger with a
|
|
3609
|
+
* few thousand entries, or one entry carrying a long reason, clears 1 MB
|
|
3610
|
+
* easily, and the version was then dropped from the baseline while the caller
|
|
3611
|
+
* was still told history had been verified. The ceiling stays finite on
|
|
3612
|
+
* purpose: past it this returns null, which is a state the caller SAYS.
|
|
3613
|
+
*/
|
|
3614
|
+
const MAX_BUFFER$1 = 67108864;
|
|
3615
|
+
/** One git query, read-only. Null on any non-zero exit, including no git at all. */
|
|
3616
|
+
function git(root, args) {
|
|
3617
|
+
const r = spawnSync("git", [...args], {
|
|
3618
|
+
cwd: root,
|
|
3619
|
+
encoding: "utf8",
|
|
3620
|
+
maxBuffer: MAX_BUFFER$1
|
|
3621
|
+
});
|
|
3622
|
+
return r.status === 0 ? r.stdout : null;
|
|
3623
|
+
}
|
|
3624
|
+
function historicLedger(root) {
|
|
3625
|
+
const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
3626
|
+
if (inside === null || inside.trim() !== "true") return {
|
|
3627
|
+
repository: false,
|
|
3628
|
+
shallow: false,
|
|
3629
|
+
entries: null,
|
|
3630
|
+
unreadable: null
|
|
3631
|
+
};
|
|
3632
|
+
const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
|
|
3633
|
+
const born = git(root, [
|
|
3634
|
+
"rev-parse",
|
|
3635
|
+
"--verify",
|
|
3636
|
+
"--quiet",
|
|
3637
|
+
"HEAD"
|
|
3638
|
+
]) !== null;
|
|
3639
|
+
const entries = shallow ? null : born ? historicEntries(root) : [];
|
|
3640
|
+
return {
|
|
3641
|
+
repository: true,
|
|
3642
|
+
shallow,
|
|
3643
|
+
entries,
|
|
3644
|
+
unreadable: entries !== null ? null : shallow ? "shallow" : "unreadable"
|
|
3645
|
+
};
|
|
3646
|
+
}
|
|
3647
|
+
/**
|
|
3648
|
+
* Every id history has ever recorded, each with the text it carried the FIRST
|
|
3649
|
+
* time it was written. A version that parses contributes each entry's digest,
|
|
3650
|
+
* so an entry EDITED in place is caught, not only one deleted; a version that
|
|
3651
|
+
* no longer parses still contributes its ids, read permissively — the point
|
|
3652
|
+
* there is that an id once written never disappears.
|
|
3653
|
+
*
|
|
3654
|
+
* FIRST, not every: keying this by `id\tdigest` kept one baseline entry per
|
|
3655
|
+
* version an id ever had, so a tamper that was COMMITTED and then UNDONE left
|
|
3656
|
+
* two digests for one id, the restored entry matched only one of them, and
|
|
3657
|
+
* `ksor-ledger-amended` fired for good. The record became permanently
|
|
3658
|
+
* unbuildable — by a tamper that had already been put right — and the only
|
|
3659
|
+
* escape was rewriting git history, which is not a remedy a refusal may
|
|
3660
|
+
* demand (found in review, 2026-08-25).
|
|
3661
|
+
*
|
|
3662
|
+
* Taking the OLDEST is what makes the guarantee both enforceable and
|
|
3663
|
+
* escapable. It still refuses a committed tamper (the baseline is what the
|
|
3664
|
+
* entry said when it was written, so committing the edit does not launder
|
|
3665
|
+
* it), and the remedy it names — put the entry back — now actually clears
|
|
3666
|
+
* it. Taking the NEWEST would have done the opposite on both counts.
|
|
3667
|
+
*/
|
|
3668
|
+
function historicEntries(root) {
|
|
3669
|
+
const atRoot = `${(git(root, ["rev-parse", "--show-prefix"]) ?? "").trim()}${LEDGER}`;
|
|
3670
|
+
const commits = git(root, [
|
|
3671
|
+
"log",
|
|
3672
|
+
"--full-history",
|
|
3673
|
+
"--topo-order",
|
|
3674
|
+
"--format=%H",
|
|
3675
|
+
"--",
|
|
3676
|
+
LEDGER
|
|
3677
|
+
]);
|
|
3678
|
+
if (commits === null) return null;
|
|
3679
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3680
|
+
for (const sha of commits.split("\n").filter((s) => s !== "")) {
|
|
3681
|
+
const text = git(root, ["show", `${sha}:${atRoot}`]);
|
|
3682
|
+
if (text === null) {
|
|
3683
|
+
const listed = git(root, [
|
|
3684
|
+
"ls-tree",
|
|
3685
|
+
"--full-tree",
|
|
3686
|
+
"--name-only",
|
|
3687
|
+
sha,
|
|
3688
|
+
"--",
|
|
3689
|
+
atRoot
|
|
3690
|
+
]);
|
|
3691
|
+
if (listed !== null && listed.trim() === "") continue;
|
|
3692
|
+
return null;
|
|
3693
|
+
}
|
|
3694
|
+
const where = sha.slice(0, 7);
|
|
3695
|
+
const parsed = parseLedger(text, LEDGER);
|
|
3696
|
+
if (parsed.ok) {
|
|
3697
|
+
for (const entry of parsed.ledger.entries) seen.set(entry.id, {
|
|
3698
|
+
id: entry.id,
|
|
3699
|
+
digest: entryDigest(entry),
|
|
3700
|
+
entry,
|
|
3701
|
+
where
|
|
3702
|
+
});
|
|
3703
|
+
continue;
|
|
3704
|
+
}
|
|
3705
|
+
for (const m of text.matchAll(/^\s*(?:-\s+)?id:\s*["']?([^\s"']+)/gm)) {
|
|
3706
|
+
const id = m[1] ?? "";
|
|
3707
|
+
if (!seen.has(id)) seen.set(id, {
|
|
3708
|
+
id,
|
|
3709
|
+
digest: null,
|
|
3710
|
+
where
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
return [...seen.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
3715
|
+
}
|
|
3716
|
+
/**
|
|
3717
|
+
* KSP R23 — the first verification tooth in approval.
|
|
3718
|
+
*
|
|
3719
|
+
* A `stable` concept's `generated.at` dates its TEXT, and `ksor.approval.at`
|
|
3720
|
+
* ratifies the text of that date. Until now the checker compared the two
|
|
3721
|
+
* authored instants and nothing else (`ksor-generated-after-approval`), so
|
|
3722
|
+
* whether an edit actually moved `generated.at` was the author's obligation:
|
|
3723
|
+
* change a sentence, leave the stamp, and the approval that ratified the old
|
|
3724
|
+
* sentence reads as ratifying the new one, with nothing red. This module
|
|
3725
|
+
* compares the body in the working tree against every committed version of
|
|
3726
|
+
* the same path that was `stable`, and refuses `ksor-generated-stale` when a
|
|
3727
|
+
* body differs under a `generated.at` the tree has not ADVANCED past — equal,
|
|
3728
|
+
* or moved backward, which changes the stamp without advancing it. Only the
|
|
3729
|
+
* body: a frontmatter-only
|
|
3730
|
+
* edit — a `verified` entry, a re-approval — is not a change to the text the
|
|
3731
|
+
* stamp dates.
|
|
3732
|
+
*
|
|
3733
|
+
* What it does NOT verify, and says so: who reviewed anything. R22 and R25
|
|
3734
|
+
* need an identity the platform can vouch for, and `approval.checked` stays
|
|
3735
|
+
* `"policy"` (decision 21). This is the one rule in the record module that
|
|
3736
|
+
* reads git, so it sits beside `git-ledger.ts` and is run by the two
|
|
3737
|
+
* publishing verbs — `ksor build` and `ksor ingest` — each of which SAYS when
|
|
3738
|
+
* history could not be read rather than passing a check that did not run. The
|
|
3739
|
+
* emitted `check.mjs` does not run it: that is the format gate an agent runs
|
|
3740
|
+
* after every edit, and a stamp is verified where the record is published.
|
|
3741
|
+
*/
|
|
3742
|
+
/** The record's bundle, and the only pathspec the walk needs (relative to the cwd, which is the record root). */
|
|
3743
|
+
const BUNDLE$2 = "knowledge/";
|
|
3744
|
+
/**
|
|
3745
|
+
* Objects per `git cat-file --batch` call. One process per object was measured
|
|
3746
|
+
* at ~53 ms a call on the machine this was built on against ~0.6 ms an object
|
|
3747
|
+
* batched (2026-09-02), so a record with a few hundred committed versions is
|
|
3748
|
+
* the difference between a build that finishes and one that does not. Chunked
|
|
3749
|
+
* so the reply stays under the buffer ceiling `git-ledger.ts` explains.
|
|
3750
|
+
*/
|
|
3751
|
+
const BATCH = 256;
|
|
3752
|
+
const MAX_BUFFER = 67108864;
|
|
3753
|
+
/**
|
|
3754
|
+
* Every committed version of each path, read in three git calls plus one
|
|
3755
|
+
* `cat-file --batch` per chunk. `--full-history`, for the reason the ledger
|
|
3756
|
+
* walk gives: default simplification follows a merge through the parent it is
|
|
3757
|
+
* TREESAME to, so a version that lived only on a branch never enters the set.
|
|
3758
|
+
* `-m`, because without a diff-merges option a merge commit lists NO paths at
|
|
3759
|
+
* all — verified against git 2.50 — and a conflict resolved by hand is a body
|
|
3760
|
+
* that is in neither parent; the same commit then prints once per parent, so
|
|
3761
|
+
* versions are keyed by (commit, path). That flag is held by the merge case in
|
|
3762
|
+
* `build.integration.test.ts`'s KSP R23 describe: drop `-m` here and the build
|
|
3763
|
+
* it expects to refuse exits 0 instead.
|
|
3764
|
+
*/
|
|
3765
|
+
function committedVersions(root, paths) {
|
|
3766
|
+
const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
3767
|
+
if (inside === null || inside.trim() !== "true") return {
|
|
3768
|
+
repository: false,
|
|
3769
|
+
born: false,
|
|
3770
|
+
shallow: false,
|
|
3771
|
+
versions: null
|
|
3772
|
+
};
|
|
3773
|
+
const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
|
|
3774
|
+
const born = git(root, [
|
|
3775
|
+
"rev-parse",
|
|
3776
|
+
"--verify",
|
|
3777
|
+
"--quiet",
|
|
3778
|
+
"HEAD"
|
|
3779
|
+
]) !== null;
|
|
3780
|
+
const versions = /* @__PURE__ */ new Map();
|
|
3781
|
+
if (!born || paths.length === 0) return {
|
|
3782
|
+
repository: true,
|
|
3783
|
+
born,
|
|
3784
|
+
shallow,
|
|
3785
|
+
versions
|
|
3786
|
+
};
|
|
3787
|
+
const prefix = (git(root, ["rev-parse", "--show-prefix"]) ?? "").trim();
|
|
3788
|
+
const log = git(root, [
|
|
3789
|
+
"log",
|
|
3790
|
+
"--full-history",
|
|
3791
|
+
"-m",
|
|
3792
|
+
"--format=%x1e%H %cI",
|
|
3793
|
+
"--name-only",
|
|
3794
|
+
"--",
|
|
3795
|
+
BUNDLE$2
|
|
3796
|
+
]);
|
|
3797
|
+
if (log === null) return {
|
|
3798
|
+
repository: true,
|
|
3799
|
+
born,
|
|
3800
|
+
shallow,
|
|
3801
|
+
versions: null
|
|
3802
|
+
};
|
|
3803
|
+
const wanted = new Set(paths);
|
|
3804
|
+
const refs = [];
|
|
3805
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3806
|
+
for (const block of log.split("")) {
|
|
3807
|
+
const [header = "", ...names] = block.split("\n");
|
|
3808
|
+
const [sha = "", committedAt = ""] = header.trim().split(" ");
|
|
3809
|
+
if (sha === "") continue;
|
|
3810
|
+
for (const name of names) {
|
|
3811
|
+
const trimmed = name.trim();
|
|
3812
|
+
if (trimmed === "" || !trimmed.startsWith(prefix)) continue;
|
|
3813
|
+
const rel = trimmed.slice(prefix.length);
|
|
3814
|
+
if (!wanted.has(rel) || seen.has(`${sha} ${rel}`)) continue;
|
|
3815
|
+
seen.add(`${sha} ${rel}`);
|
|
3816
|
+
refs.push({
|
|
3817
|
+
sha,
|
|
3818
|
+
committedAt,
|
|
3819
|
+
path: rel
|
|
3820
|
+
});
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
const blobs = readBlobs(root, refs.map((r) => `${r.sha}:${prefix}${r.path}`));
|
|
3824
|
+
if (blobs === null) return {
|
|
3825
|
+
repository: true,
|
|
3826
|
+
born,
|
|
3827
|
+
shallow,
|
|
3828
|
+
versions: null
|
|
3829
|
+
};
|
|
3830
|
+
refs.forEach((ref, i) => {
|
|
3831
|
+
const text = blobs[i];
|
|
3832
|
+
if (text === null || text === void 0) return;
|
|
3833
|
+
const list = versions.get(ref.path) ?? [];
|
|
3834
|
+
list.push({
|
|
3835
|
+
sha: ref.sha,
|
|
3836
|
+
committedAt: ref.committedAt,
|
|
3837
|
+
text
|
|
3838
|
+
});
|
|
3839
|
+
versions.set(ref.path, list);
|
|
3840
|
+
});
|
|
3841
|
+
return {
|
|
3842
|
+
repository: true,
|
|
3843
|
+
born,
|
|
3844
|
+
shallow,
|
|
3845
|
+
versions
|
|
3846
|
+
};
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* `<rev>:<path>` specs → text, in order; null for a spec that names nothing,
|
|
3850
|
+
* and null for the WHOLE read when git failed — a version dropped in silence
|
|
3851
|
+
* would read as verified.
|
|
3852
|
+
*/
|
|
3853
|
+
function readBlobs(root, specs) {
|
|
3854
|
+
const out = [];
|
|
3855
|
+
for (let i = 0; i < specs.length; i += BATCH) {
|
|
3856
|
+
const chunk = specs.slice(i, i + BATCH);
|
|
3857
|
+
const r = spawnSync("git", ["cat-file", "--batch"], {
|
|
3858
|
+
cwd: root,
|
|
3859
|
+
input: `${chunk.join("\n")}\n`,
|
|
3860
|
+
maxBuffer: MAX_BUFFER
|
|
3861
|
+
});
|
|
3862
|
+
if (r.status !== 0) return null;
|
|
3863
|
+
const buf = r.stdout;
|
|
3864
|
+
let at = 0;
|
|
3865
|
+
for (let k = 0; k < chunk.length; k += 1) {
|
|
3866
|
+
const nl = buf.indexOf(10, at);
|
|
3867
|
+
if (nl === -1) return null;
|
|
3868
|
+
const header = buf.subarray(at, nl).toString("utf8");
|
|
3869
|
+
at = nl + 1;
|
|
3870
|
+
if (header.endsWith(" missing")) {
|
|
3871
|
+
out.push(null);
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
const [, type, sizeText] = header.split(" ");
|
|
3875
|
+
const size = Number(sizeText);
|
|
3876
|
+
if (type !== "blob" || !Number.isInteger(size) || at + size > buf.length) return null;
|
|
3877
|
+
out.push(buf.subarray(at, at + size).toString("utf8"));
|
|
3878
|
+
at += size + 1;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
return out;
|
|
3882
|
+
}
|
|
3883
|
+
/** Line endings are the checkout's and a trailing blank line is nobody's edit; everything else is the text. */
|
|
3884
|
+
function comparable(body) {
|
|
3885
|
+
return normalizeText(body).trimEnd();
|
|
3886
|
+
}
|
|
3887
|
+
/**
|
|
3888
|
+
* A committed version as a stable, stamped text — or null: a draft's body is
|
|
3889
|
+
* free, and a version the profile cannot read (no fence, broken YAML, no
|
|
3890
|
+
* `generated.at`) was never a stable version under the profile.
|
|
3891
|
+
*/
|
|
3892
|
+
function stableVersionOf(version, path) {
|
|
3893
|
+
const split = splitFrontmatter(version.text, path);
|
|
3894
|
+
if (!split.ok || split.frontmatter === null || split.frontmatter["status"] !== "stable") return null;
|
|
3895
|
+
const generated = split.frontmatter["generated"];
|
|
3896
|
+
if (typeof generated !== "object" || generated === null) return null;
|
|
3897
|
+
const at = generated["at"];
|
|
3898
|
+
if (typeof at !== "string") return null;
|
|
3899
|
+
const generatedAt = parseInstant(at);
|
|
3900
|
+
if (generatedAt === null) return null;
|
|
3901
|
+
return {
|
|
3902
|
+
sha: version.sha,
|
|
3903
|
+
committedAt: version.committedAt,
|
|
3904
|
+
generatedAt,
|
|
3905
|
+
stamp: at,
|
|
3906
|
+
body: comparable(split.body)
|
|
3907
|
+
};
|
|
3908
|
+
}
|
|
3909
|
+
/**
|
|
3910
|
+
* The rule, pure: for every `stable` concept whose body differs from a
|
|
3911
|
+
* committed version that was `stable`, the tree's `generated.at` must be
|
|
3912
|
+
* strictly LATER than that version's. Equal is the defect this rule exists to
|
|
3913
|
+
* catch — edit the sentence, leave the stamp — and EARLIER is the same defect
|
|
3914
|
+
* with one more keystroke: backdating the stamp changes it without advancing
|
|
3915
|
+
* it, so the approval that ratified the old text still post-dates the new one.
|
|
3916
|
+
* Only strictly-later clears a version, which is exactly what the refusal's
|
|
3917
|
+
* own `fix` prints. All of history, not only HEAD's version — an edit
|
|
3918
|
+
* committed without a bump matches HEAD exactly, and it is the version BEHIND
|
|
3919
|
+
* it that tells (the shape CI sees). A path with no committed stable version
|
|
3920
|
+
* passes: stable for the first time, or renamed, since path is identity.
|
|
3921
|
+
* Instants are compared as instants, so two spellings of one moment are one
|
|
3922
|
+
* stamp.
|
|
3923
|
+
*/
|
|
3924
|
+
function checkGeneratedStale(concepts, files, versions) {
|
|
3925
|
+
const refusals = [];
|
|
3926
|
+
for (const concept of concepts) {
|
|
3927
|
+
if (concept.status !== "stable" || concept.generatedAt === null) continue;
|
|
3928
|
+
const text = files.get(concept.path);
|
|
3929
|
+
if (text === void 0) continue;
|
|
3930
|
+
const split = splitFrontmatter(text, concept.path);
|
|
3931
|
+
if (!split.ok) continue;
|
|
3932
|
+
const body = comparable(split.body);
|
|
3933
|
+
for (const version of versions.get(concept.path) ?? []) {
|
|
3934
|
+
const stable = stableVersionOf(version, concept.path);
|
|
3935
|
+
if (stable === null || stable.generatedAt < concept.generatedAt || stable.body === body) continue;
|
|
3936
|
+
const authored = concept.frontmatter["generated"]?.at;
|
|
3937
|
+
const stamp = typeof authored === "string" ? authored : new Date(concept.generatedAt).toISOString();
|
|
3938
|
+
const under = stable.generatedAt === concept.generatedAt ? `under the same \`generated.at\` (${stamp})` : `under \`generated.at\` ${stable.stamp}, LATER than the ${stamp} this file now carries (the stamp was moved backward, which changes it without advancing it)`;
|
|
3939
|
+
refusals.push({
|
|
3940
|
+
slug: "ksor-generated-stale",
|
|
3941
|
+
path: concept.path,
|
|
3942
|
+
why: `the body differs from the one committed at ${stable.sha.slice(0, 7)} (${stable.committedAt}), where this concept was \`stable\` ${under} — that instant dates the text, so an edit to a stable concept must advance it, or the approval that ratified the old text reads as ratifying the new one (KSP R23)`,
|
|
3943
|
+
fix: "set `generated.at` to an instant after this edit, then re-approve: `ksor.approval.at` must not precede the new `generated.at` (`ksor-generated-after-approval`)"
|
|
3944
|
+
});
|
|
3945
|
+
break;
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
return refusals;
|
|
3949
|
+
}
|
|
3950
|
+
/** The two callers' one entry: read history for the stable concepts, judge, and say what could not be read. */
|
|
3951
|
+
function checkChangeControl(root, concepts, files) {
|
|
3952
|
+
const history = committedVersions(root, concepts.filter((c) => c.status === "stable").map((c) => c.path));
|
|
3953
|
+
return {
|
|
3954
|
+
refusals: history.versions === null ? [] : checkGeneratedStale(concepts, files, history.versions),
|
|
3955
|
+
notice: changeControlNotice(history)
|
|
3956
|
+
};
|
|
3957
|
+
}
|
|
3958
|
+
/**
|
|
3959
|
+
* Honest absence in the build's own idiom — the `source: unspecified` line
|
|
3960
|
+
* already says the commit is unknown; this says the same of the check that
|
|
3961
|
+
* needs one. A check that could not run is never a check that passed.
|
|
3962
|
+
*/
|
|
3963
|
+
function changeControlNotice(history) {
|
|
3964
|
+
const what = "so whether a stable concept's body changed under its `generated.at` (KSP R23) was not checked";
|
|
3965
|
+
if (!history.repository) return `change-control: not checked — knowledge/ is not in a git repository (or git is not installed), ${what}`;
|
|
3966
|
+
if (!history.born) return `change-control: not checked — the repository has no commits yet, ${what}`;
|
|
3967
|
+
if (history.versions === null) return `change-control: not checked — git could not read the history of knowledge/ (\`git log -- knowledge/\` or \`git cat-file --batch\` failed), ${what}`;
|
|
3968
|
+
if (history.shallow) return `change-control: checked against the ${[...history.versions.values()].reduce((n, list) => n + list.length, 0)} committed version(s) this shallow clone holds — a stable version beyond the shallow boundary was not read; fetch full history (\`git fetch --unshallow\`; in CI, \`fetch-depth: 0\`) to check all of it`;
|
|
3969
|
+
return null;
|
|
3970
|
+
}
|
|
3971
|
+
/**
|
|
3526
3972
|
* The audience rule, alone, with no imports and no side effects.
|
|
3527
3973
|
*
|
|
3528
3974
|
* CANONICAL COPY: `packages/content/src/lib/audience-rule.ts`. The scaffold's
|
|
@@ -3841,7 +4287,7 @@ function isSim(baseName) {
|
|
|
3841
4287
|
* one by hand — nothing the old checker refused may become accepted silently.
|
|
3842
4288
|
* Each rule keeps the scar that put it there.
|
|
3843
4289
|
*/
|
|
3844
|
-
const KNOWLEDGE$1 = "knowledge/";
|
|
4290
|
+
const KNOWLEDGE$1$1 = "knowledge/";
|
|
3845
4291
|
const ASSET_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
3846
4292
|
".png",
|
|
3847
4293
|
".jpg",
|
|
@@ -3869,7 +4315,7 @@ function checkHygiene(tree) {
|
|
|
3869
4315
|
const documents = tree.textPaths.filter((p) => p.endsWith(".md") && !p.endsWith("/index.md") && !p.endsWith("/log.md") && !p.endsWith("/README.md") && !COMPANION_SUFFIXES.some((s) => p.endsWith(s)));
|
|
3870
4316
|
if (documents.length === 0) refusals.push({
|
|
3871
4317
|
slug: "ksor-record-empty",
|
|
3872
|
-
path: KNOWLEDGE$1,
|
|
4318
|
+
path: KNOWLEDGE$1$1,
|
|
3873
4319
|
why: "the record has no concept — a KSoR is never empty; the site has nothing to render and the record stands behind nothing",
|
|
3874
4320
|
fix: "restore a document from git history, or add one: knowledge/<name>.md with the profile's frontmatter (record spec §2)"
|
|
3875
4321
|
});
|
|
@@ -4710,7 +5156,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
4710
5156
|
throw lastError;
|
|
4711
5157
|
}
|
|
4712
5158
|
//#endregion
|
|
4713
|
-
//#region ../content/dist/commands-
|
|
5159
|
+
//#region ../content/dist/commands-DIKiyZBQ.mjs
|
|
4714
5160
|
/**
|
|
4715
5161
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
4716
5162
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -6308,6 +6754,15 @@ var OpenAiEmbeddingProvider = class {
|
|
|
6308
6754
|
* directly, so it is not ported.)
|
|
6309
6755
|
*/
|
|
6310
6756
|
/**
|
|
6757
|
+
* The stable first stderr line for a missing provider key, on BOTH planes:
|
|
6758
|
+
* `ksor serve` (through the gateway's `bootErrorLines`) and `ksor ingest` /
|
|
6759
|
+
* `ksor calibrate` (the write plane's `fail`). Exit 3 either way — the key is
|
|
6760
|
+
* the operator's environment — but an exit code is not a name, and this was
|
|
6761
|
+
* the one refusal in the first-hour path that printed its sentence with no
|
|
6762
|
+
* slug above it (found live, 2026-09-02).
|
|
6763
|
+
*/
|
|
6764
|
+
const PROVIDER_KEY_MISSING = "ksor-provider-key-missing";
|
|
6765
|
+
/**
|
|
6311
6766
|
* A key-needing provider was built without an API key. A TYPED error (mirrors
|
|
6312
6767
|
* EmbeddingSpaceMismatch) so a composition root classifies the missing-key case
|
|
6313
6768
|
* by TYPE, not by string-matching this message — the exact prose-coupling scar
|
|
@@ -6315,6 +6770,7 @@ var OpenAiEmbeddingProvider = class {
|
|
|
6315
6770
|
* exit-code mapping no longer breaks when it is reworded.
|
|
6316
6771
|
*/
|
|
6317
6772
|
var MissingProviderKeyError = class extends Error {
|
|
6773
|
+
slug = PROVIDER_KEY_MISSING;
|
|
6318
6774
|
providerName;
|
|
6319
6775
|
keyEnv;
|
|
6320
6776
|
/**
|
|
@@ -6366,12 +6822,6 @@ function entryFor(name) {
|
|
|
6366
6822
|
if (entry === void 0) throw new Error(`unknown embedding provider ${JSON.stringify(name)} — registered: ${Object.keys(PROVIDERS).sort().join(", ")}`);
|
|
6367
6823
|
return entry;
|
|
6368
6824
|
}
|
|
6369
|
-
/** Whether the named provider's factory needs an API key. Unknown name → the
|
|
6370
|
-
* same loud error as building it, so a composition root can ask this FIRST
|
|
6371
|
-
* and still fail on a typo. */
|
|
6372
|
-
function providerNeedsApiKey(name) {
|
|
6373
|
-
return entryFor(name).needsApiKey;
|
|
6374
|
-
}
|
|
6375
6825
|
/**
|
|
6376
6826
|
* The environment variable this provider's key comes from, or null when it
|
|
6377
6827
|
* needs none. Unknown name → the same loud error as building it.
|
|
@@ -6880,6 +7330,31 @@ function parseQueriesFile(text) {
|
|
|
6880
7330
|
return normalizeQueries(queries);
|
|
6881
7331
|
}
|
|
6882
7332
|
/**
|
|
7333
|
+
* Where the out-of-corpus probes came from, and WHAT they are — decided once.
|
|
7334
|
+
*
|
|
7335
|
+
* These were two expressions and they disagreed. The label tested
|
|
7336
|
+
* `=== undefined` while the probes fell back through `?? BUILT_IN_OOC`, and the
|
|
7337
|
+
* CLI passes `null` when `--ooc-file` is absent (`commands.ts`) — so every
|
|
7338
|
+
* `ksor calibrate` without `--ooc-file` scored against the twenty BUILT-IN
|
|
7339
|
+
* far-domain probes and reported `ooc_source: "provided"`. `BUILT_IN_OOC_CAVEAT`
|
|
7340
|
+
* is gated on that field, and it is the one line that exists to stop an operator
|
|
7341
|
+
* pasting a floor blessed by far-domain probes — so it never printed on the path
|
|
7342
|
+
* that needs it most. Found reviewing PR #259, whose tutorial's own pasted
|
|
7343
|
+
* output is the evidence: a run with no `--ooc-file`, and no caveat above it.
|
|
7344
|
+
*
|
|
7345
|
+
* Returning both together is the fix rather than correcting one test: a source
|
|
7346
|
+
* label and the probes it labels cannot drift when one expression yields both.
|
|
7347
|
+
*/
|
|
7348
|
+
function resolveOoc(probes) {
|
|
7349
|
+
return probes === void 0 || probes === null ? {
|
|
7350
|
+
source: "built-in",
|
|
7351
|
+
probes: BUILT_IN_OOC
|
|
7352
|
+
} : {
|
|
7353
|
+
source: "provided",
|
|
7354
|
+
probes
|
|
7355
|
+
};
|
|
7356
|
+
}
|
|
7357
|
+
/**
|
|
6883
7358
|
* The scope every calibration statement binds: the widest viewer, the lowest
|
|
6884
7359
|
* trust floor, and (through the predicate's default) lifecycle at now(). The
|
|
6885
7360
|
* floor must be measured on the set the door serves — a draft, a not-yet-
|
|
@@ -6966,8 +7441,9 @@ async function runCalibration(pool, options) {
|
|
|
6966
7441
|
}
|
|
6967
7442
|
inQueries = normalizeQueries(synthesized);
|
|
6968
7443
|
}
|
|
6969
|
-
const
|
|
6970
|
-
const
|
|
7444
|
+
const resolved = resolveOoc(options.oocProbes);
|
|
7445
|
+
const oocSource = resolved.source;
|
|
7446
|
+
const ooc = normalizeQueries(resolved.probes);
|
|
6971
7447
|
return buildReport([...await scoreQueries(pool, scope, gucs, options.provider, inQueries, true), ...await scoreQueries(pool, scope, gucs, options.provider, ooc, false)], {
|
|
6972
7448
|
generation: counted.measured,
|
|
6973
7449
|
pinned: generation !== null,
|
|
@@ -7636,21 +8112,6 @@ async function reap(client, opts) {
|
|
|
7636
8112
|
for (;;) if (!(await client.query("DELETE FROM content_nodes n WHERE n.tenant_id = $1 AND n.generation = $2 AND NOT EXISTS (SELECT 1 FROM content_nodes ch WHERE ch.parent_id = n.node_id AND ch.tenant_id = n.tenant_id AND ch.generation = n.generation)", [opts.tenantId, opts.generation])).rowCount) break;
|
|
7637
8113
|
await client.query("UPDATE ingestion_runs SET state = 'reaped' WHERE tenant_id = $1 AND generation = $2", [opts.tenantId, opts.generation]);
|
|
7638
8114
|
}
|
|
7639
|
-
async function readLedger(pool, instance, limit) {
|
|
7640
|
-
return runAuditRead(pool, instance.tenantId, async (client) => {
|
|
7641
|
-
return (await client.query("SELECT action, actor, generation, detail, created_at FROM retrieval_log WHERE tenant_id = $1 AND corpus_id = $2 ORDER BY created_at DESC, id DESC LIMIT $3", [
|
|
7642
|
-
instance.tenantId,
|
|
7643
|
-
instance.corpusId,
|
|
7644
|
-
limit
|
|
7645
|
-
])).rows.map((row) => ({
|
|
7646
|
-
action: String(row.action),
|
|
7647
|
-
actor: String(row.actor),
|
|
7648
|
-
generation: row.generation === null ? null : Number(row.generation),
|
|
7649
|
-
detail: row.detail ?? {},
|
|
7650
|
-
createdAt: row.created_at
|
|
7651
|
-
}));
|
|
7652
|
-
});
|
|
7653
|
-
}
|
|
7654
8115
|
async function listTakedowns(pool, instance) {
|
|
7655
8116
|
return runRead(pool, instance.tenantId, async (client) => {
|
|
7656
8117
|
return (await client.query("SELECT stable_id, scope, reason, created_at FROM takedown_denylist WHERE tenant_id = $1 AND corpus_id = $2 AND revoked_at IS NULL ORDER BY created_at, stable_id", [instance.tenantId, instance.corpusId])).rows.map((r) => ({
|
|
@@ -7685,10 +8146,10 @@ function ledgerDenials(ledger) {
|
|
|
7685
8146
|
* What `--ledger` prints: every entry the file records, newest first, in the
|
|
7686
8147
|
* shape the database trail uses — the act, who, and the detail.
|
|
7687
8148
|
*
|
|
7688
|
-
* NOT the
|
|
7689
|
-
*
|
|
7690
|
-
*
|
|
7691
|
-
* ids
|
|
8149
|
+
* NOT the database's §7 trail, which also records the APPLY of each entry to
|
|
8150
|
+
* the door and which no verb prints. The file's own history is what the flag
|
|
8151
|
+
* prints on every rung — it is the record of the act (record spec §5), it is
|
|
8152
|
+
* where `--revoke`'s entry ids live, and it never needs a DSN.
|
|
7692
8153
|
*/
|
|
7693
8154
|
function ledgerActs(ledger) {
|
|
7694
8155
|
return [...ledger.entries].map((e) => {
|
|
@@ -9097,7 +9558,7 @@ function formatRefusals(refusals) {
|
|
|
9097
9558
|
return refusals.map((r) => `${r.slug}: ${r.path}\n why: ${r.why}\n fix: ${r.fix}`).join("\n");
|
|
9098
9559
|
}
|
|
9099
9560
|
const LOCK_PATH = "build.lock.json";
|
|
9100
|
-
const KNOWLEDGE = "knowledge/";
|
|
9561
|
+
const KNOWLEDGE$1 = "knowledge/";
|
|
9101
9562
|
const NOT_A_CONCEPT = /(^|\/)(index|log|README)\.md$|\.summary\.md$/;
|
|
9102
9563
|
const INDEX = /(^|\/)index\.md$/;
|
|
9103
9564
|
const INSTANCE_PATH = "instance.md";
|
|
@@ -9126,7 +9587,7 @@ function sha256OfDocument(text) {
|
|
|
9126
9587
|
function conceptHashes(record) {
|
|
9127
9588
|
const out = /* @__PURE__ */ new Map();
|
|
9128
9589
|
for (const [path, text] of record.files) {
|
|
9129
|
-
if (!path.startsWith(KNOWLEDGE) || !path.endsWith(".md") || NOT_A_CONCEPT.test(path)) continue;
|
|
9590
|
+
if (!path.startsWith(KNOWLEDGE$1) || !path.endsWith(".md") || NOT_A_CONCEPT.test(path)) continue;
|
|
9130
9591
|
out.set(path.slice(10), sha256OfDocument(text));
|
|
9131
9592
|
}
|
|
9132
9593
|
return out;
|
|
@@ -9146,7 +9607,7 @@ function conceptHashes(record) {
|
|
|
9146
9607
|
function companionHashes(record) {
|
|
9147
9608
|
const out = /* @__PURE__ */ new Map();
|
|
9148
9609
|
for (const [path, text] of record.files) {
|
|
9149
|
-
if (!path.startsWith(KNOWLEDGE)) continue;
|
|
9610
|
+
if (!path.startsWith(KNOWLEDGE$1)) continue;
|
|
9150
9611
|
if (attachmentKindOf(path.slice(path.lastIndexOf("/") + 1)) === null) continue;
|
|
9151
9612
|
out.set(path.slice(10), sha256OfDocument(text));
|
|
9152
9613
|
}
|
|
@@ -9163,7 +9624,7 @@ function companionHashes(record) {
|
|
|
9163
9624
|
function indexHashes(record) {
|
|
9164
9625
|
const out = /* @__PURE__ */ new Map();
|
|
9165
9626
|
for (const [path, text] of record.files) {
|
|
9166
|
-
if (!path.startsWith(KNOWLEDGE) || !INDEX.test(path)) continue;
|
|
9627
|
+
if (!path.startsWith(KNOWLEDGE$1) || !INDEX.test(path)) continue;
|
|
9167
9628
|
out.set(path.slice(10), sha256OfDocument(text));
|
|
9168
9629
|
}
|
|
9169
9630
|
return out;
|
|
@@ -9179,7 +9640,7 @@ function indexHashes(record) {
|
|
|
9179
9640
|
function assetHashes(record) {
|
|
9180
9641
|
const out = /* @__PURE__ */ new Map();
|
|
9181
9642
|
for (const [path, bytes] of record.assets ?? /* @__PURE__ */ new Map()) {
|
|
9182
|
-
if (!path.startsWith(KNOWLEDGE)) continue;
|
|
9643
|
+
if (!path.startsWith(KNOWLEDGE$1)) continue;
|
|
9183
9644
|
out.set(path.slice(10), createHash("sha256").update(bytes).digest("hex"));
|
|
9184
9645
|
}
|
|
9185
9646
|
return out;
|
|
@@ -9731,7 +10192,9 @@ async function buildGeneration(pool, instance, options) {
|
|
|
9731
10192
|
accepted: true
|
|
9732
10193
|
}]
|
|
9733
10194
|
});
|
|
9734
|
-
|
|
10195
|
+
const change = checkChangeControl(root, check.concepts, record.files);
|
|
10196
|
+
if (check.refusals.length > 0 || change.refusals.length > 0 || check.policy === null) throw new RecordRefused([...check.refusals, ...change.refusals]);
|
|
10197
|
+
if (change.notice !== null) (options.onReport ?? log)(change.notice);
|
|
9735
10198
|
const policy = check.policy;
|
|
9736
10199
|
const lock = checkLock(lockText, record);
|
|
9737
10200
|
if (!lock.ok) throw new RecordRefused([lock.refusal]);
|
|
@@ -10062,9 +10525,10 @@ function renderDrift(report, window) {
|
|
|
10062
10525
|
* zero would drag every statistic here toward a number nobody measured, in the
|
|
10063
10526
|
* direction that makes a floor look safer than it is.
|
|
10064
10527
|
*
|
|
10065
|
-
* Scoped by CORPUS as well as tenant,
|
|
10066
|
-
* tenant serving two corpora
|
|
10067
|
-
*
|
|
10528
|
+
* Scoped by CORPUS as well as tenant, because every governance write records
|
|
10529
|
+
* `corpus_id`: a tenant serving two corpora — the shape the second record
|
|
10530
|
+
* prepares for — would otherwise measure one record's floor against the
|
|
10531
|
+
* other's traffic. Read through `runAuditRead` — the serving role
|
|
10068
10532
|
* has no SELECT on this table at all, deliberately, and widening that to read
|
|
10069
10533
|
* a monitor would trade an audit guarantee for a convenience.
|
|
10070
10534
|
*/
|
|
@@ -10285,7 +10749,8 @@ Usage:
|
|
|
10285
10749
|
--check reads the record's OWN logged searches instead and reports how
|
|
10286
10750
|
the declared floor is holding against them — no provider key, no
|
|
10287
10751
|
embedding call, no LLM. A monitor, never a gate: it says what to
|
|
10288
|
-
re-measure and always exits 0
|
|
10752
|
+
re-measure, and a verdict always exits 0 — the environment exits 3, as
|
|
10753
|
+
for every verb.
|
|
10289
10754
|
ksor grant --instance PATH [--revoke]
|
|
10290
10755
|
Authorize ingest for the instance's tenant (the row row-level security
|
|
10291
10756
|
requires), or withdraw it. Idempotent; reports the state it established.
|
|
@@ -10324,10 +10789,11 @@ Exit codes: 0 ok · 1 refused · 3 environment
|
|
|
10324
10789
|
function usageFor(command) {
|
|
10325
10790
|
const lines = USAGE.split("\n");
|
|
10326
10791
|
const isHeading = (l) => /^ {2}ksor \S/.test(l);
|
|
10327
|
-
const
|
|
10792
|
+
const verbOf = (l) => l.trimStart().split(/\s+/)[1];
|
|
10793
|
+
const start = lines.findIndex((l) => isHeading(l) && verbOf(l) === command);
|
|
10328
10794
|
if (start === -1) return USAGE;
|
|
10329
10795
|
const rest = lines.slice(start + 1);
|
|
10330
|
-
const end = rest.findIndex(isHeading);
|
|
10796
|
+
const end = rest.findIndex((l) => isHeading(l) && verbOf(l) !== command);
|
|
10331
10797
|
return `${[lines[start], ...end === -1 ? rest : rest.slice(0, end)].join("\n").replace(/\s+$/, "")}\n`;
|
|
10332
10798
|
}
|
|
10333
10799
|
function fail$1(code, message) {
|
|
@@ -10396,18 +10862,13 @@ function resolveDsn(instance) {
|
|
|
10396
10862
|
/** The ingest composition root's provider step (oracle cli.py:58-74). */
|
|
10397
10863
|
function composeProvider(instance) {
|
|
10398
10864
|
try {
|
|
10399
|
-
let apiKey = null;
|
|
10400
|
-
if (providerNeedsApiKey(instance.embeddingProvider)) {
|
|
10401
|
-
const keyEnv = providerKeyEnv(instance.embeddingProvider) ?? "";
|
|
10402
|
-
apiKey = process.env[keyEnv] || null;
|
|
10403
|
-
if (apiKey === null) return fail$1(ENVIRONMENT, `${keyEnv} is required (the instance's embedding provider ${JSON.stringify(instance.embeddingProvider)} needs a key)\n fix: export ${keyEnv}=... and rerun`);
|
|
10404
|
-
}
|
|
10405
10865
|
return buildShippedProvider(instance.embeddingProvider, {
|
|
10406
|
-
apiKey,
|
|
10866
|
+
apiKey: process.env[providerKeyEnv(instance.embeddingProvider) ?? ""] || null,
|
|
10407
10867
|
modelId: instance.embeddingModel,
|
|
10408
10868
|
dim: instance.embeddingDim
|
|
10409
10869
|
});
|
|
10410
10870
|
} catch (exc) {
|
|
10871
|
+
if (exc instanceof MissingProviderKeyError) return fail$1(ENVIRONMENT, `error: ${exc.slug}\n${exc.message}\n fix: export ${exc.keyEnv ?? "the key"}=... and rerun`);
|
|
10411
10872
|
return refuse$2("ksor-instance-format", `instance embedding.provider: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
10412
10873
|
}
|
|
10413
10874
|
}
|
|
@@ -10775,13 +11236,18 @@ function printLedger(rows) {
|
|
|
10775
11236
|
process.stdout.write(`${when}\t${r.action}\t${r.actor}\t${JSON.stringify(r.detail)}\n`);
|
|
10776
11237
|
}
|
|
10777
11238
|
}
|
|
10778
|
-
/**
|
|
10779
|
-
|
|
11239
|
+
/**
|
|
11240
|
+
* One denial in force per line: what, at which scope, why — and, when the
|
|
11241
|
+
* lines come from the FILE rather than the door's rows, a fourth column saying
|
|
11242
|
+
* so, because a ledger entry is the act and a row is its projection, and only
|
|
11243
|
+
* the row is what the door refuses on.
|
|
11244
|
+
*/
|
|
11245
|
+
function printDenials(rows, label = null) {
|
|
10780
11246
|
if (rows.length === 0) {
|
|
10781
11247
|
process.stdout.write("takedown: nothing is denied in this corpus\n");
|
|
10782
11248
|
return;
|
|
10783
11249
|
}
|
|
10784
|
-
for (const r of rows) process.stdout.write(`${r.stableId}\t${r.scope}\t${r.reason}\n`);
|
|
11250
|
+
for (const r of rows) process.stdout.write(`${r.stableId}\t${r.scope}\t${r.reason}${label === null ? "" : `\t${label}`}\n`);
|
|
10785
11251
|
}
|
|
10786
11252
|
async function takedownCommand(args) {
|
|
10787
11253
|
const { values, positionals } = parseArgs({
|
|
@@ -10856,15 +11322,24 @@ async function takedownCommand(args) {
|
|
|
10856
11322
|
const parsedLedger = parseLedger(existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : null, ".ksor/takedowns.yaml");
|
|
10857
11323
|
if (!parsedLedger.ok) return refuse$2(parsedLedger.refusals[0].slug, parsedLedger.refusals.map((r) => `${r.slug}: ${r.path}\n why: ${r.why}\n fix: ${r.fix}`).join("\n"));
|
|
10858
11324
|
if (mode.kind === "list" || mode.kind === "ledger" || mode.kind === "apply") {
|
|
11325
|
+
if (mode.kind === "ledger") {
|
|
11326
|
+
process.stdout.write("ledger: from .ksor/takedowns.yaml — the committed record of every governance act\n");
|
|
11327
|
+
printLedger(ledgerActs(parsedLedger.ledger));
|
|
11328
|
+
return 0;
|
|
11329
|
+
}
|
|
10859
11330
|
if (instance === null) {
|
|
10860
11331
|
if (mode.kind === "apply") {
|
|
10861
11332
|
process.stdout.write("takedown: instance.md declares no database, so there is nothing to apply — the ledger IS the record and the site reads it at its next build\n");
|
|
10862
11333
|
return 0;
|
|
10863
11334
|
}
|
|
10864
|
-
process.stdout.write(
|
|
11335
|
+
process.stdout.write("takedown: from .ksor/takedowns.yaml — instance.md declares no database, so the committed ledger is the whole state\n");
|
|
11336
|
+
printDenials(ledgerDenials(parsedLedger.ledger));
|
|
11337
|
+
return 0;
|
|
11338
|
+
}
|
|
11339
|
+
if (mode.kind === "list" && (process.env[instance.dsnEnv] ?? "") === "") {
|
|
11340
|
+
process.stdout.write(`takedown: from .ksor/takedowns.yaml — ${instance.dsnEnv} is unset, so no denylist row was read; each denial below is the ledger's word, not a row the door refuses on
|
|
10865
11341
|
`);
|
|
10866
|
-
|
|
10867
|
-
else printDenials(ledgerDenials(parsedLedger.ledger));
|
|
11342
|
+
printDenials(ledgerDenials(parsedLedger.ledger), "not applied (no database)");
|
|
10868
11343
|
return 0;
|
|
10869
11344
|
}
|
|
10870
11345
|
const dsn = resolveDsn(instance);
|
|
@@ -10875,10 +11350,6 @@ async function takedownCommand(args) {
|
|
|
10875
11350
|
for (const line of unmergedLines(applied.unmerged)) process.stderr.write(line + "\n");
|
|
10876
11351
|
return 0;
|
|
10877
11352
|
}
|
|
10878
|
-
if (mode.kind === "ledger") {
|
|
10879
|
-
printLedger(await withPool(dsn, (pool) => readLedger(pool, instance, 50)));
|
|
10880
|
-
return 0;
|
|
10881
|
-
}
|
|
10882
11353
|
printDenials(await withPool(dsn, (pool) => listTakedowns(pool, instance)));
|
|
10883
11354
|
return 0;
|
|
10884
11355
|
}
|
|
@@ -11039,7 +11510,7 @@ async function runContentCli(argv) {
|
|
|
11039
11510
|
}
|
|
11040
11511
|
}
|
|
11041
11512
|
//#endregion
|
|
11042
|
-
//#region ../content/dist/record-
|
|
11513
|
+
//#region ../content/dist/record-CKv4cZY8.mjs
|
|
11043
11514
|
/**
|
|
11044
11515
|
* May `surface` publish `doc` at instant `at`? `drafts` is the build's drafts switch,
|
|
11045
11516
|
* which admits drafts to HUMAN surfaces only.
|
|
@@ -11070,6 +11541,11 @@ const OKF_PIN = {
|
|
|
11070
11541
|
};
|
|
11071
11542
|
const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
|
|
11072
11543
|
const viewerList = z.array(z.string().min(1));
|
|
11544
|
+
const bundleEntry = z.object({
|
|
11545
|
+
viewer: z.string().min(1),
|
|
11546
|
+
sha256: hex64,
|
|
11547
|
+
files: z.number().int().nonnegative()
|
|
11548
|
+
}).strict();
|
|
11073
11549
|
const lockSchema = z.object({
|
|
11074
11550
|
format: z.literal(1),
|
|
11075
11551
|
build_id: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
@@ -11117,7 +11593,8 @@ const lockSchema = z.object({
|
|
|
11117
11593
|
indexes: z.array(z.object({
|
|
11118
11594
|
path: z.string().min(1),
|
|
11119
11595
|
sha256: hex64
|
|
11120
|
-
}).strict())
|
|
11596
|
+
}).strict()),
|
|
11597
|
+
bundles: z.array(bundleEntry)
|
|
11121
11598
|
}).strict();
|
|
11122
11599
|
function parseLock(text) {
|
|
11123
11600
|
let value;
|
|
@@ -11239,144 +11716,119 @@ function composeLock(input) {
|
|
|
11239
11716
|
documents,
|
|
11240
11717
|
companions,
|
|
11241
11718
|
assets,
|
|
11242
|
-
indexes
|
|
11719
|
+
indexes,
|
|
11720
|
+
bundles: input.bundles.map((b) => ({
|
|
11721
|
+
viewer: b.viewer,
|
|
11722
|
+
sha256: b.sha256,
|
|
11723
|
+
files: b.files
|
|
11724
|
+
}))
|
|
11243
11725
|
};
|
|
11244
11726
|
}
|
|
11245
11727
|
function compare(a, b) {
|
|
11246
11728
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
11247
11729
|
}
|
|
11248
|
-
|
|
11249
|
-
|
|
11250
|
-
|
|
11251
|
-
*
|
|
11252
|
-
*
|
|
11253
|
-
*
|
|
11254
|
-
*
|
|
11255
|
-
*
|
|
11256
|
-
*
|
|
11257
|
-
*
|
|
11258
|
-
*
|
|
11259
|
-
*
|
|
11260
|
-
*
|
|
11261
|
-
*
|
|
11262
|
-
*
|
|
11263
|
-
* (
|
|
11264
|
-
*
|
|
11265
|
-
*
|
|
11266
|
-
*
|
|
11267
|
-
*
|
|
11268
|
-
*
|
|
11730
|
+
//#endregion
|
|
11731
|
+
//#region src/build/bundles.ts
|
|
11732
|
+
/**
|
|
11733
|
+
* `ksor build --bundles` (build spec §1 step 4; KSP-001 Class E, R15): one
|
|
11734
|
+
* OKF bundle per canonical viewer, holding exactly what that viewer's MACHINE
|
|
11735
|
+
* surfaces publish. Pure — the CLI hands in the record the loader read and
|
|
11736
|
+
* the admission the lock decided, and writes what comes back.
|
|
11737
|
+
*
|
|
11738
|
+
* Selection is NOT re-derived here. A concept is in the bundle for viewer V
|
|
11739
|
+
* iff the lock admits it to V (`admittedViewersOf`: stable, effective,
|
|
11740
|
+
* unexpired, undenied, audience-overlapping) — the same predicate the site's
|
|
11741
|
+
* stage and the door's `admitted` set compose (decision 18). What this module
|
|
11742
|
+
* adds is what travels WITH a concept: its companions, the assets its body
|
|
11743
|
+
* references, and an index for every directory of the filtered tree —
|
|
11744
|
+
* regenerated, never the committed map, which lists every status and every
|
|
11745
|
+
* audience (decision 27's index clause).
|
|
11746
|
+
*
|
|
11747
|
+
* R5 for a directory someone will send somewhere: no byte of a concept the
|
|
11748
|
+
* viewer may not read. The AUDIENCE half is closed upstream — the checker
|
|
11749
|
+
* refuses a link, a supersession pointer or a companion body that reaches a
|
|
11750
|
+
* narrower audience (`ksor-link-widens`), and an asset positioned under a
|
|
11751
|
+
* narrower directory with it — so a body copied verbatim cannot name what its
|
|
11752
|
+
* own readers may not open. A link to a concept excluded for another reason
|
|
11753
|
+
* (a draft, one not yet effective, one past `stale_after`, one denied) is a
|
|
11754
|
+
* different case: R5 is about audience, the body stays verbatim rather than
|
|
11755
|
+
* being rewritten, and the link dangles — REPORTED per bundle rather than
|
|
11756
|
+
* silently shipped, which is what `dangling` is for.
|
|
11269
11757
|
*/
|
|
11270
|
-
const
|
|
11758
|
+
const KNOWLEDGE = "knowledge/";
|
|
11271
11759
|
/**
|
|
11272
|
-
*
|
|
11273
|
-
*
|
|
11274
|
-
*
|
|
11275
|
-
* easily, and the version was then dropped from the baseline while the caller
|
|
11276
|
-
* was still told history had been verified. The ceiling stays finite on
|
|
11277
|
-
* purpose: past it this returns null, which is a state the caller SAYS.
|
|
11760
|
+
* The companions of every concept, keyed by the parent's id — derived from the
|
|
11761
|
+
* canonical attachment rule, never from a suffix list of this module's own
|
|
11762
|
+
* (`build.integration.test.ts` refuses a hand copy anywhere in this package).
|
|
11278
11763
|
*/
|
|
11279
|
-
|
|
11280
|
-
|
|
11281
|
-
|
|
11282
|
-
|
|
11283
|
-
|
|
11284
|
-
|
|
11285
|
-
|
|
11286
|
-
|
|
11287
|
-
|
|
11764
|
+
function companionsByParent(files) {
|
|
11765
|
+
const out = /* @__PURE__ */ new Map();
|
|
11766
|
+
for (const file of files.keys()) {
|
|
11767
|
+
if (!file.startsWith(KNOWLEDGE)) continue;
|
|
11768
|
+
const rel = file.slice(10);
|
|
11769
|
+
const base = rel.slice(rel.lastIndexOf("/") + 1);
|
|
11770
|
+
if (attachmentKindOf(base) === null) continue;
|
|
11771
|
+
const parent = parentDocumentOf(base);
|
|
11772
|
+
if (parent === null) continue;
|
|
11773
|
+
const parentId = `${rel.slice(0, rel.length - base.length)}${parent}`.replace(/\.md$/, "");
|
|
11774
|
+
out.set(parentId, [...out.get(parentId) ?? [], rel]);
|
|
11775
|
+
}
|
|
11776
|
+
return out;
|
|
11288
11777
|
}
|
|
11289
|
-
function
|
|
11290
|
-
const
|
|
11291
|
-
|
|
11292
|
-
|
|
11293
|
-
|
|
11294
|
-
|
|
11295
|
-
|
|
11296
|
-
|
|
11297
|
-
|
|
11298
|
-
|
|
11299
|
-
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
|
|
11303
|
-
|
|
11304
|
-
|
|
11305
|
-
|
|
11306
|
-
|
|
11307
|
-
|
|
11308
|
-
|
|
11309
|
-
|
|
11310
|
-
|
|
11778
|
+
function planBundles(input) {
|
|
11779
|
+
const companions = companionsByParent(input.files);
|
|
11780
|
+
const conceptIds = new Set(input.concepts.map((c) => c.id));
|
|
11781
|
+
const dirs = input.dirs.filter((d) => d.startsWith(KNOWLEDGE)).map((d) => d.slice(10));
|
|
11782
|
+
return input.viewers.map((viewer) => {
|
|
11783
|
+
const files = /* @__PURE__ */ new Map();
|
|
11784
|
+
const admitted = input.concepts.filter((c) => c.admitted.includes(viewer));
|
|
11785
|
+
const held = new Set(admitted.map((c) => c.id));
|
|
11786
|
+
const dangling = [];
|
|
11787
|
+
for (const concept of admitted) {
|
|
11788
|
+
const bodies = [[`${concept.id}.md`, input.files.get(`${KNOWLEDGE}${concept.id}.md`) ?? ""], ...(companions.get(concept.id) ?? []).map((rel) => [rel, input.files.get(`${KNOWLEDGE}${rel}`) ?? ""])];
|
|
11789
|
+
for (const [rel, text] of bodies) {
|
|
11790
|
+
files.set(rel, Buffer.from(text, "utf8"));
|
|
11791
|
+
const split = splitFrontmatter(text, rel);
|
|
11792
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11793
|
+
for (const target of linkTargets(split.ok ? split.body : text)) {
|
|
11794
|
+
const id = resolveLink(concept.id, target);
|
|
11795
|
+
if (id === null || seen.has(id)) continue;
|
|
11796
|
+
seen.add(id);
|
|
11797
|
+
const asset = input.assets.get(`${KNOWLEDGE}${id}`);
|
|
11798
|
+
if (asset !== void 0) files.set(id, asset);
|
|
11799
|
+
else if (conceptIds.has(id) && !held.has(id)) dangling.push({
|
|
11800
|
+
from: rel,
|
|
11801
|
+
to: `${id}.md`
|
|
11802
|
+
});
|
|
11803
|
+
}
|
|
11804
|
+
}
|
|
11805
|
+
}
|
|
11806
|
+
const indexes = generateIndexes({
|
|
11807
|
+
title: input.title,
|
|
11808
|
+
concepts: admitted.map((c) => ({
|
|
11809
|
+
id: c.id,
|
|
11810
|
+
title: c.title,
|
|
11811
|
+
description: c.description,
|
|
11812
|
+
order: c.order
|
|
11813
|
+
})),
|
|
11814
|
+
dirs
|
|
11815
|
+
});
|
|
11816
|
+
for (const [rel, text] of indexes) files.set(rel, Buffer.from(text, "utf8"));
|
|
11817
|
+
return {
|
|
11818
|
+
viewer,
|
|
11819
|
+
files,
|
|
11820
|
+
dangling
|
|
11821
|
+
};
|
|
11822
|
+
});
|
|
11311
11823
|
}
|
|
11312
11824
|
/**
|
|
11313
|
-
*
|
|
11314
|
-
*
|
|
11315
|
-
*
|
|
11316
|
-
* no longer parses still contributes its ids, read permissively — the point
|
|
11317
|
-
* there is that an id once written never disappears.
|
|
11318
|
-
*
|
|
11319
|
-
* FIRST, not every: keying this by `id\tdigest` kept one baseline entry per
|
|
11320
|
-
* version an id ever had, so a tamper that was COMMITTED and then UNDONE left
|
|
11321
|
-
* two digests for one id, the restored entry matched only one of them, and
|
|
11322
|
-
* `ksor-ledger-amended` fired for good. The record became permanently
|
|
11323
|
-
* unbuildable — by a tamper that had already been put right — and the only
|
|
11324
|
-
* escape was rewriting git history, which is not a remedy a refusal may
|
|
11325
|
-
* demand (found in review, 2026-08-25).
|
|
11326
|
-
*
|
|
11327
|
-
* Taking the OLDEST is what makes the guarantee both enforceable and
|
|
11328
|
-
* escapable. It still refuses a committed tamper (the baseline is what the
|
|
11329
|
-
* entry said when it was written, so committing the edit does not launder
|
|
11330
|
-
* it), and the remedy it names — put the entry back — now actually clears
|
|
11331
|
-
* it. Taking the NEWEST would have done the opposite on both counts.
|
|
11825
|
+
* The digest `build.lock.json` records for a bundle: sha256 over the JSON of
|
|
11826
|
+
* its sorted `[path, sha256(bytes)]` pairs. Stated this plainly so a recipient
|
|
11827
|
+
* holding only the directory can recompute it and match it to a publication.
|
|
11332
11828
|
*/
|
|
11333
|
-
function
|
|
11334
|
-
const
|
|
11335
|
-
|
|
11336
|
-
"log",
|
|
11337
|
-
"--full-history",
|
|
11338
|
-
"--topo-order",
|
|
11339
|
-
"--format=%H",
|
|
11340
|
-
"--",
|
|
11341
|
-
LEDGER
|
|
11342
|
-
]);
|
|
11343
|
-
if (commits === null) return null;
|
|
11344
|
-
const seen = /* @__PURE__ */ new Map();
|
|
11345
|
-
for (const sha of commits.split("\n").filter((s) => s !== "")) {
|
|
11346
|
-
const text = git(root, ["show", `${sha}:${atRoot}`]);
|
|
11347
|
-
if (text === null) {
|
|
11348
|
-
const listed = git(root, [
|
|
11349
|
-
"ls-tree",
|
|
11350
|
-
"--full-tree",
|
|
11351
|
-
"--name-only",
|
|
11352
|
-
sha,
|
|
11353
|
-
"--",
|
|
11354
|
-
atRoot
|
|
11355
|
-
]);
|
|
11356
|
-
if (listed !== null && listed.trim() === "") continue;
|
|
11357
|
-
return null;
|
|
11358
|
-
}
|
|
11359
|
-
const where = sha.slice(0, 7);
|
|
11360
|
-
const parsed = parseLedger(text, LEDGER);
|
|
11361
|
-
if (parsed.ok) {
|
|
11362
|
-
for (const entry of parsed.ledger.entries) seen.set(entry.id, {
|
|
11363
|
-
id: entry.id,
|
|
11364
|
-
digest: entryDigest(entry),
|
|
11365
|
-
entry,
|
|
11366
|
-
where
|
|
11367
|
-
});
|
|
11368
|
-
continue;
|
|
11369
|
-
}
|
|
11370
|
-
for (const m of text.matchAll(/^\s*(?:-\s+)?id:\s*["']?([^\s"']+)/gm)) {
|
|
11371
|
-
const id = m[1] ?? "";
|
|
11372
|
-
if (!seen.has(id)) seen.set(id, {
|
|
11373
|
-
id,
|
|
11374
|
-
digest: null,
|
|
11375
|
-
where
|
|
11376
|
-
});
|
|
11377
|
-
}
|
|
11378
|
-
}
|
|
11379
|
-
return [...seen.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
11829
|
+
function bundleDigest(files) {
|
|
11830
|
+
const pairs = [...files].map(([rel, bytes]) => [rel, sha256Hex(bytes)]).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
11831
|
+
return sha256Hex(JSON.stringify(pairs));
|
|
11380
11832
|
}
|
|
11381
11833
|
//#endregion
|
|
11382
11834
|
//#region src/build/git.ts
|
|
@@ -11504,7 +11956,7 @@ const iso = (ms) => new Date(ms).toISOString();
|
|
|
11504
11956
|
* and `build.lock.json`. Database-free, network-free. A refusal leaves the
|
|
11505
11957
|
* tree exactly as it found it, with the slug on the first stderr line.
|
|
11506
11958
|
*/
|
|
11507
|
-
const BUILD_USAGE = `Usage: ksor build [--instance <path>] [--as-of <instant>] [--strict] [--allow-unverifiable-ledger]
|
|
11959
|
+
const BUILD_USAGE = `Usage: ksor build [--instance <path>] [--as-of <instant>] [--strict] [--allow-unverifiable-ledger] [--bundles]
|
|
11508
11960
|
|
|
11509
11961
|
Generates every knowledge/**/index.md in memory, runs the record checker, and
|
|
11510
11962
|
on green writes the indexes whose bytes changed plus build.lock.json — the
|
|
@@ -11519,8 +11971,35 @@ toolchain. A refusal (exit 1, slug first on stderr) writes nothing.
|
|
|
11519
11971
|
--allow-unverifiable-ledger
|
|
11520
11972
|
build on a shallow clone, where the ledger's history
|
|
11521
11973
|
cannot be checked for deleted entries
|
|
11522
|
-
--bundles
|
|
11974
|
+
--bundles also write one OKF bundle per viewer under
|
|
11975
|
+
.ksor/out/bundles/<viewer>/ — public, and [public, X]
|
|
11976
|
+
for each registered audience X — holding only what that
|
|
11977
|
+
viewer's machine surfaces publish, with its indexes
|
|
11978
|
+
regenerated for that tree; any OKF consumer reads it
|
|
11979
|
+
with no ksor in the loop. The lock beside them names
|
|
11980
|
+
the build. The directory is replaced on every run.
|
|
11523
11981
|
`;
|
|
11982
|
+
/** What `--bundles` writes, record-relative. Gitignored by the scaffold's `.ksor/*` rule. */
|
|
11983
|
+
const BUNDLES_DIR = ".ksor/out/bundles";
|
|
11984
|
+
const LOCK_NAME = "build.lock.json";
|
|
11985
|
+
/**
|
|
11986
|
+
* An audience identifier that can be a directory name. The policy admits any
|
|
11987
|
+
* non-empty string as a registry key, and every other surface uses one only as
|
|
11988
|
+
* a token — `--bundles` is the first to use it as a PATH, so `../x` written as
|
|
11989
|
+
* given would land outside the output directory. The first character must be a
|
|
11990
|
+
* letter or a digit, which is what stops `.`, `..`, a dotfile and a name a
|
|
11991
|
+
* shell reads as a flag; `-`, `_` and `.` are fine after it.
|
|
11992
|
+
*
|
|
11993
|
+
* The LAST character may not be a `.`. Win32 path normalization strips a
|
|
11994
|
+
* trailing dot from a path segment, so `internal.` and `internal` are two
|
|
11995
|
+
* viewers in the policy and ONE directory on Windows — the case rule's merge
|
|
11996
|
+
* exactly, and the case fold cannot see it. Refused on every platform, for the
|
|
11997
|
+
* reason that rule gives: a record must not build here and leak there. A `.`
|
|
11998
|
+
* INSIDE the name (`v1.2`) is untouched; nothing normalizes it away.
|
|
11999
|
+
*/
|
|
12000
|
+
const PATH_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/;
|
|
12001
|
+
/** {@link PATH_SEGMENT} in words, in ONE place, so the refusal and the docs cannot drift from the regex. */
|
|
12002
|
+
const PATH_SEGMENT_PROSE = "a letter or a digit first, then letters, digits, `-`, `_` and `.`, and never a `.` last";
|
|
11524
12003
|
function parseArgs$2(args) {
|
|
11525
12004
|
let instance = null;
|
|
11526
12005
|
let asOf = null;
|
|
@@ -11577,6 +12056,40 @@ function refuse$1(io, slug, why, fix) {
|
|
|
11577
12056
|
io.err(`error: ${slug}\n${why}\n fix: ${fix}\n`);
|
|
11578
12057
|
return exitCodes.refused;
|
|
11579
12058
|
}
|
|
12059
|
+
/**
|
|
12060
|
+
* What every canonical viewer name has to be for `.ksor/out/bundles/<name>/`
|
|
12061
|
+
* to hold what the lock says it holds.
|
|
12062
|
+
*
|
|
12063
|
+
* Run on EVERY build, not only under `--bundles`, because `bundles[]` is in
|
|
12064
|
+
* the lock on every build: a plain build that let `../escape` through would
|
|
12065
|
+
* commit a digest for a directory the tool refuses to write — provenance for
|
|
12066
|
+
* something that cannot exist (invariant: provenance is load-bearing). The
|
|
12067
|
+
* alternative considered was omitting unbuildable viewers from `bundles[]`;
|
|
12068
|
+
* it keeps the lock honest too, but leaves the record broken and the owner
|
|
12069
|
+
* unwarned until their first exchange, which is the weaker guarantee. Here
|
|
12070
|
+
* the owner learns at the build they already run.
|
|
12071
|
+
*/
|
|
12072
|
+
function viewerRefusal(viewers) {
|
|
12073
|
+
const list = (ids) => ids.map((v) => JSON.stringify(v)).join(", ");
|
|
12074
|
+
const unsafe = viewers.filter((v) => !PATH_SEGMENT.test(v) || v.toLowerCase() === LOCK_NAME);
|
|
12075
|
+
if (unsafe.length > 0) return {
|
|
12076
|
+
slug: "ksor-audience-identifier-invalid",
|
|
12077
|
+
why: `the audience identifier${unsafe.length === 1 ? "" : "s"} ${list(unsafe)} cannot name a bundle directory: --bundles writes each viewer's bundle to ${BUNDLES_DIR}/<identifier>/ beside a copy of ${LOCK_NAME}, so an identifier that is not a plain path segment would land somewhere else, one ending in \`.\` names a DIFFERENT directory on Windows (which strips a trailing dot from a path segment, merging it into the name without one), and one named ${LOCK_NAME} would collide with the lock. build.lock.json records that bundle's digest on EVERY build, flag or not, so this is refused here rather than only under --bundles`,
|
|
12078
|
+
fix: `name audiences in plain words (${PATH_SEGMENT_PROSE}) in .ksor/governance.yaml and in every \`ksor.audience\` list, then rebuild`
|
|
12079
|
+
};
|
|
12080
|
+
const byFold = /* @__PURE__ */ new Map();
|
|
12081
|
+
for (const v of viewers) {
|
|
12082
|
+
const fold = v.toLowerCase();
|
|
12083
|
+
byFold.set(fold, [...byFold.get(fold) ?? [], v]);
|
|
12084
|
+
}
|
|
12085
|
+
const collided = [...byFold.values()].filter((group) => group.length > 1);
|
|
12086
|
+
if (collided.length > 0) return {
|
|
12087
|
+
slug: "ksor-audience-identifier-collides",
|
|
12088
|
+
why: `${collided.map((g) => list(g)).join("; ")} differ only in case, so each set is several viewers naming ONE directory: --bundles writes ${BUNDLES_DIR}/<identifier>/ per viewer, and on a case-insensitive filesystem (macOS and Windows, by default) the later bundle merges into the earlier one — leaving a directory that holds concepts the viewer named on it may not read, and a digest in build.lock.json that no longer describes it. build.lock.json records each of those digests on EVERY build, flag or not, so this is refused here rather than only under --bundles`,
|
|
12089
|
+
fix: `give each audience in .ksor/governance.yaml a name that differs by more than case — \`public\` is reserved, casefolded too — and update every \`ksor.audience\` list that named the one you dropped, then rebuild`
|
|
12090
|
+
};
|
|
12091
|
+
return null;
|
|
12092
|
+
}
|
|
11580
12093
|
function runBuild(args, cwd, io, options) {
|
|
11581
12094
|
if (args.includes("--help") || args.includes("-h")) {
|
|
11582
12095
|
io.out(BUILD_USAGE);
|
|
@@ -11587,10 +12100,6 @@ function runBuild(args, cwd, io, options) {
|
|
|
11587
12100
|
io.err(`error: bad-args\n${parsed}\n${BUILD_USAGE}`);
|
|
11588
12101
|
return exitCodes.refused;
|
|
11589
12102
|
}
|
|
11590
|
-
if (parsed.bundles) {
|
|
11591
|
-
io.out(`ksor build --bundles: designed but not implemented in ${options.version}.\n`);
|
|
11592
|
-
return exitCodes.notImplemented;
|
|
11593
|
-
}
|
|
11594
12103
|
const start = parsed.instance === null ? cwd : existsSync(parsed.instance) && statSync(parsed.instance).isFile() ? path.dirname(path.resolve(cwd, parsed.instance)) : path.resolve(cwd, parsed.instance);
|
|
11595
12104
|
const root = resolveInstanceDir(start);
|
|
11596
12105
|
if (root === null) return refuse$1(io, "ksor-instance-missing", `no instance.md at or above ${start} — the record root is the directory holding it`, "run from inside the record, or pass --instance <path>");
|
|
@@ -11619,10 +12128,12 @@ function runBuild(args, cwd, io, options) {
|
|
|
11619
12128
|
mode: "build",
|
|
11620
12129
|
ledgerBaselines: baselines
|
|
11621
12130
|
});
|
|
11622
|
-
|
|
11623
|
-
|
|
11624
|
-
|
|
11625
|
-
|
|
12131
|
+
const change = checkChangeControl(root, result.concepts, record.files);
|
|
12132
|
+
const refusals = sortRefusals([...result.refusals, ...change.refusals]);
|
|
12133
|
+
if (refusals.length > 0) {
|
|
12134
|
+
io.err(`error: ${refusals[0]?.slug ?? "ksor-refused"}\n`);
|
|
12135
|
+
io.err(`ksor build: ${refusals.length} problem(s) — nothing written:\n\n`);
|
|
12136
|
+
for (const r of refusals) io.err(` ${formatRefusal(r)}\n\n`);
|
|
11626
12137
|
return exitCodes.refused;
|
|
11627
12138
|
}
|
|
11628
12139
|
const pendingIndexes = [...result.indexes].filter(([rel, text]) => record.files.get(rel) !== text).map(([rel]) => rel);
|
|
@@ -11631,18 +12142,38 @@ function runBuild(args, cwd, io, options) {
|
|
|
11631
12142
|
const ledgerText = record.files.get(".ksor/takedowns.yaml") ?? null;
|
|
11632
12143
|
const ledger = parseLedger(ledgerText, ".ksor/takedowns.yaml");
|
|
11633
12144
|
const denials = ledger.ok ? inForce(ledger.ledger) : [];
|
|
12145
|
+
const asOf = parsed.asOf ?? Date.now();
|
|
12146
|
+
const audiences = result.policy?.audiences ?? [];
|
|
12147
|
+
const viewers = canonicalViewers(audiences);
|
|
12148
|
+
const bad = viewerRefusal(Object.keys(viewers));
|
|
12149
|
+
if (bad !== null) return refuse$1(io, bad.slug, bad.why, bad.fix);
|
|
12150
|
+
const instance = parseInstanceDocument(record.files.get("instance.md") ?? "");
|
|
12151
|
+
const bundles = planBundles({
|
|
12152
|
+
title: instance.ok ? instance.instance.title : "Index",
|
|
12153
|
+
viewers: Object.keys(viewers),
|
|
12154
|
+
concepts: result.concepts.map((c) => ({
|
|
12155
|
+
id: c.id,
|
|
12156
|
+
title: c.title,
|
|
12157
|
+
description: c.description,
|
|
12158
|
+
order: c.order,
|
|
12159
|
+
admitted: admittedViewersOf(c, viewers, asOf, denials)
|
|
12160
|
+
})),
|
|
12161
|
+
files: record.files,
|
|
12162
|
+
assets: record.assets,
|
|
12163
|
+
dirs: record.dirs
|
|
12164
|
+
});
|
|
11634
12165
|
const lock = composeLock({
|
|
11635
12166
|
ksorVersion: options.version,
|
|
11636
12167
|
sourceCommit: facts.sourceCommit,
|
|
11637
12168
|
dirty: facts.dirty || pendingIndexes.length > 0 || staleIndexes.length > 0,
|
|
11638
|
-
asOf
|
|
12169
|
+
asOf,
|
|
11639
12170
|
drafts: options.drafts,
|
|
11640
12171
|
instanceText: record.files.get("instance.md") ?? "",
|
|
11641
12172
|
policyText: record.files.get(".ksor/governance.yaml") ?? "",
|
|
11642
12173
|
peopleText: record.files.get(".ksor/people.yaml") ?? null,
|
|
11643
12174
|
ledgerText,
|
|
11644
12175
|
ledgerEntries: result.ledgerEntries,
|
|
11645
|
-
audiences
|
|
12176
|
+
audiences,
|
|
11646
12177
|
concepts: result.concepts.map((c) => ({
|
|
11647
12178
|
id: c.id,
|
|
11648
12179
|
status: c.status,
|
|
@@ -11663,11 +12194,18 @@ function runBuild(args, cwd, io, options) {
|
|
|
11663
12194
|
path: rel.slice(10),
|
|
11664
12195
|
text
|
|
11665
12196
|
})),
|
|
11666
|
-
denials
|
|
12197
|
+
denials,
|
|
12198
|
+
bundles: bundles.map((b) => ({
|
|
12199
|
+
viewer: b.viewer,
|
|
12200
|
+
sha256: bundleDigest(b.files),
|
|
12201
|
+
files: b.files.size
|
|
12202
|
+
}))
|
|
11667
12203
|
});
|
|
12204
|
+
const lockText = `${JSON.stringify(lock, null, 2)}\n`;
|
|
11668
12205
|
for (const rel of pendingIndexes) writeFileSync(path.join(root, rel), result.indexes.get(rel));
|
|
11669
12206
|
for (const rel of staleIndexes) unlinkSync(path.join(root, rel));
|
|
11670
|
-
|
|
12207
|
+
if (parsed.bundles) writeBundles(root, bundles, lockText);
|
|
12208
|
+
writeFileSync(lockPath, lockText);
|
|
11671
12209
|
const admitted = lock.documents.filter((d) => d.admitted.length > 0).length;
|
|
11672
12210
|
const notice = lifecycleNotice(result.concepts.map((c) => ({
|
|
11673
12211
|
path: c.path.slice(10),
|
|
@@ -11678,9 +12216,42 @@ function runBuild(args, cwd, io, options) {
|
|
|
11678
12216
|
io.out(`ksor build: ${lock.documents.length} document(s), ${admitted} admitted to a machine surface at ${lock.as_of}\n${provenanceLine({
|
|
11679
12217
|
...facts,
|
|
11680
12218
|
dirty: lock.dirty
|
|
11681
|
-
}, root)}\n` + notice + `${pendingIndexes.map((w) => ` wrote ${w}\n`).join("")}${staleIndexes.map((r) => ` removed ${r} (its directory earns no index)\n`).join("")} wrote build.lock.json — build_id ${lock.build_id}\n`);
|
|
12219
|
+
}, root)}\n` + (change.notice === null ? "" : ` ${change.notice}\n`) + notice + `${pendingIndexes.map((w) => ` wrote ${w}\n`).join("")}${staleIndexes.map((r) => ` removed ${r} (its directory earns no index)\n`).join("")}` + (parsed.bundles ? bundlesReport(bundles, viewers) : "") + ` wrote build.lock.json — build_id ${lock.build_id}\n`);
|
|
11682
12220
|
return 0;
|
|
11683
12221
|
}
|
|
12222
|
+
/**
|
|
12223
|
+
* Replace `.ksor/out/bundles/` with exactly this build's bundles. Replaced,
|
|
12224
|
+
* not merged: a bundle an earlier build wrote for an audience the policy no
|
|
12225
|
+
* longer registers, or a file a document no longer admits, would otherwise sit
|
|
12226
|
+
* beside the fresh ones under the same directory — the sims leak of 2026-08-25
|
|
12227
|
+
* (`pruneSims`) in a directory that exists to be sent somewhere. The lock goes
|
|
12228
|
+
* beside them the way it sits beside `knowledge/` (KSP-001 4.1.2), so the
|
|
12229
|
+
* output travels with the provenance that names it.
|
|
12230
|
+
*/
|
|
12231
|
+
function writeBundles(root, bundles, lockText) {
|
|
12232
|
+
const out = path.join(root, BUNDLES_DIR);
|
|
12233
|
+
rmSync(out, {
|
|
12234
|
+
recursive: true,
|
|
12235
|
+
force: true
|
|
12236
|
+
});
|
|
12237
|
+
for (const bundle of bundles) for (const [rel, bytes] of bundle.files) {
|
|
12238
|
+
const to = path.join(out, bundle.viewer, rel);
|
|
12239
|
+
mkdirSync(path.dirname(to), { recursive: true });
|
|
12240
|
+
writeFileSync(to, bytes);
|
|
12241
|
+
}
|
|
12242
|
+
writeFileSync(path.join(out, LOCK_NAME), lockText);
|
|
12243
|
+
}
|
|
12244
|
+
/** One line per bundle written, and a line per link it carries to a concept it excludes. */
|
|
12245
|
+
function bundlesReport(bundles, viewers) {
|
|
12246
|
+
let text = "";
|
|
12247
|
+
for (const bundle of bundles) {
|
|
12248
|
+
const list = (viewers[bundle.viewer] ?? [bundle.viewer]).join(", ");
|
|
12249
|
+
text += ` wrote ${BUNDLES_DIR}/${bundle.viewer}/ — the OKF bundle for viewer [${list}], ${bundle.files.size} file(s)\n`;
|
|
12250
|
+
for (const link of bundle.dangling) text += ` ${link.from} links to ${link.to}, which this bundle excludes — the link dangles for its reader\n`;
|
|
12251
|
+
}
|
|
12252
|
+
text += ` wrote ${BUNDLES_DIR}/build.lock.json — a copy, so the bundles travel with the build that made them\n`;
|
|
12253
|
+
return text;
|
|
12254
|
+
}
|
|
11684
12255
|
//#endregion
|
|
11685
12256
|
//#region src/init/manager.ts
|
|
11686
12257
|
/**
|
|
@@ -12121,8 +12692,7 @@ function unifiedDiff(change) {
|
|
|
12121
12692
|
const before = lines(change.before);
|
|
12122
12693
|
const after = lines(change.after);
|
|
12123
12694
|
const header = `--- ${change.before === null ? "/dev/null" : `a/${change.path}`}\n+++ ${change.after === null ? "/dev/null" : `b/${change.path}`}\n`;
|
|
12124
|
-
if (change.generated === true) return header + `@@ generated @@ replaced wholesale: ${before.length} line(s) become ${after.length}. This file is built from ksor's own rules and is never hand-edited
|
|
12125
|
-
`;
|
|
12695
|
+
if (change.generated === true) return header + (change.after === null ? `@@ generated @@ deleted: ${before.length} line(s). ` : `@@ generated @@ replaced wholesale: ${before.length} line(s) become ${after.length}. `) + "This file is built from ksor's own rules and is never hand-edited.\n";
|
|
12126
12696
|
return header + hunks(before, after);
|
|
12127
12697
|
}
|
|
12128
12698
|
/** Every change as one text, in path order, with a heading a reader can scan. */
|
|
@@ -13142,6 +13712,8 @@ no instance.md at or above ${start} — the record root is the directory holding
|
|
|
13142
13712
|
changes.push(...checkerChanges(root, options.templatesDir));
|
|
13143
13713
|
const manifest = manifestChange(root);
|
|
13144
13714
|
if (manifest !== null) changes.push(manifest);
|
|
13715
|
+
const staleLock = staleLockChange(root);
|
|
13716
|
+
if (staleLock !== null) changes.push(staleLock);
|
|
13145
13717
|
if (parsed.writeSite) changes.push(...siteChanges(root, options.templatesDir, {
|
|
13146
13718
|
name: identity.ok ? identity.name : path.basename(root),
|
|
13147
13719
|
version: options.version
|
|
@@ -13392,6 +13964,34 @@ function checkerChanges(root, templatesDir) {
|
|
|
13392
13964
|
return out;
|
|
13393
13965
|
}
|
|
13394
13966
|
/**
|
|
13967
|
+
* A committed `build.lock.json` this ksor cannot read. The lock gains keys as
|
|
13968
|
+
* the record's surfaces grow, and `ksor build` REFUSES one that is missing any
|
|
13969
|
+
* of them rather than regenerating it — a lock nothing can read is a takedown
|
|
13970
|
+
* baseline that quietly holds nothing (record spec §4), which is the state
|
|
13971
|
+
* `ksor-lock-invalid` exists to stop. So the refusal is right and the upgrade
|
|
13972
|
+
* still has to get past it: `pnpm build` is `ksor build && <site build>`, the
|
|
13973
|
+
* deploy command under decision 29, so an adopter who took a new ksor would
|
|
13974
|
+
* find their build and their deploy red on a file only a human could delete.
|
|
13975
|
+
*
|
|
13976
|
+
* Decision 28 permits removing a shape only when the removal is paired with a
|
|
13977
|
+
* MIGRATION, and this is the pairing: migrate offers exactly the deletion the
|
|
13978
|
+
* refusal asks for, so `docs/upgrading.md`'s four steps carry it. Deleted and
|
|
13979
|
+
* never rewritten — only a build can compute a lock, and inventing one here
|
|
13980
|
+
* would forge the provenance the file exists to hold.
|
|
13981
|
+
*/
|
|
13982
|
+
function staleLockChange(root) {
|
|
13983
|
+
const abs = path.join(root, "build.lock.json");
|
|
13984
|
+
if (!existsSync(abs)) return null;
|
|
13985
|
+
const before = readFileSync(abs, "utf8");
|
|
13986
|
+
if (parseLock(before).ok) return null;
|
|
13987
|
+
return {
|
|
13988
|
+
path: "build.lock.json",
|
|
13989
|
+
before,
|
|
13990
|
+
after: null,
|
|
13991
|
+
generated: true
|
|
13992
|
+
};
|
|
13993
|
+
}
|
|
13994
|
+
/**
|
|
13395
13995
|
* The root scripts this release breaks. `export-denylist` ran
|
|
13396
13996
|
* `ksor takedown --export`, a flag the committed ledger retired, and the
|
|
13397
13997
|
* scaffold's own `build` calls it first — so the adopter's build died on
|