unknown-knowledge 2.1.0
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/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +167 -0
- package/cli/.gitkeep +0 -0
- package/cli/commands/init-copy.js +90 -0
- package/cli/commands/init.js +386 -0
- package/cli/init-copy.js +24 -0
- package/cli/init.js +24 -0
- package/cli/kit.manifest.yaml +256 -0
- package/cli/lib/copy-payload.js +334 -0
- package/cli/lib/generate-wrappers.js +162 -0
- package/package.json +36 -0
- package/payload/adapter-fixtures/README.md +53 -0
- package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
- package/payload/adapter-fixtures/html/sample.html +38 -0
- package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
- package/payload/adapter-fixtures/md/sample.md +32 -0
- package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
- package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
- package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
- package/payload/adapter-fixtures/txt/sample.txt +18 -0
- package/payload/docs/README.md +102 -0
- package/payload/docs/boundaries.md +60 -0
- package/payload/docs/ci-wiring.md +109 -0
- package/payload/docs/steward-guide.md +238 -0
- package/payload/engine/audit.js +34 -0
- package/payload/engine/commands/audit.js +289 -0
- package/payload/engine/commands/derive.js +334 -0
- package/payload/engine/commands/ingest.js +124 -0
- package/payload/engine/commands/log-entry.js +85 -0
- package/payload/engine/commands/phoenix.js +206 -0
- package/payload/engine/commands/preflight.js +530 -0
- package/payload/engine/commands/resolve.js +1678 -0
- package/payload/engine/commands/survey-map.js +351 -0
- package/payload/engine/commands/validate-values.js +315 -0
- package/payload/engine/commands/validate.js +1426 -0
- package/payload/engine/derive.js +34 -0
- package/payload/engine/ingest.js +34 -0
- package/payload/engine/lib/anchor-signatures.js +126 -0
- package/payload/engine/lib/boot.js +39 -0
- package/payload/engine/lib/call-numbers.js +133 -0
- package/payload/engine/lib/cli.js +147 -0
- package/payload/engine/lib/coverage.js +849 -0
- package/payload/engine/lib/decomposition.js +225 -0
- package/payload/engine/lib/derived.js +494 -0
- package/payload/engine/lib/engine-refusal.js +40 -0
- package/payload/engine/lib/exit-codes.js +14 -0
- package/payload/engine/lib/extractor-kinds.js +955 -0
- package/payload/engine/lib/format-adapters.js +802 -0
- package/payload/engine/lib/id-grammars.js +178 -0
- package/payload/engine/lib/iso-date.js +55 -0
- package/payload/engine/lib/kit-root.js +101 -0
- package/payload/engine/lib/load-stores.js +1624 -0
- package/payload/engine/lib/log-entry.js +196 -0
- package/payload/engine/lib/phoenix.js +628 -0
- package/payload/engine/lib/scoring.js +150 -0
- package/payload/engine/lib/suppressions.js +172 -0
- package/payload/engine/lib/time-verdicts.js +282 -0
- package/payload/engine/lib/usage-error.js +14 -0
- package/payload/engine/lib/validate-record.js +504 -0
- package/payload/engine/log-entry.js +34 -0
- package/payload/engine/phoenix.js +39 -0
- package/payload/engine/preflight.js +34 -0
- package/payload/engine/resolve.js +34 -0
- package/payload/engine/survey-map.js +34 -0
- package/payload/engine/validate-values.js +34 -0
- package/payload/engine/validate.js +34 -0
- package/payload/extractor-fixtures/.gitkeep +0 -0
- package/payload/extractor-fixtures/README.md +29 -0
- package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
- package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
- package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
- package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
- package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
- package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
- package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
- package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
- package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
- package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
- package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
- package/payload/hooks/pre-commit +37 -0
- package/payload/hooks/reverse-lookup +66 -0
- package/payload/package.json +3 -0
- package/payload/protocol/.gitkeep +0 -0
- package/payload/protocol/AGENTS.md +239 -0
- package/payload/protocol/derived-layer.md +174 -0
- package/payload/protocol/new-kind-pipeline.md +179 -0
- package/payload/protocol/registry-warrant.md +162 -0
- package/payload/protocol/skills/kb-build.md +303 -0
- package/payload/protocol/skills/knowledge-audit.md +183 -0
- package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
- package/payload/protocol/skills/knowledge-reflect.md +397 -0
- package/payload/schemas/catalog.schema.json +32 -0
- package/payload/schemas/decision-entry.schema.json +122 -0
- package/payload/schemas/finding.schema.json +77 -0
- package/payload/schemas/gap.schema.json +52 -0
- package/payload/schemas/graduation-categories.schema.json +64 -0
- package/payload/schemas/knowledge-leaf.schema.json +194 -0
- package/payload/schemas/miss.schema.json +45 -0
- package/payload/schemas/ontology-concept.schema.json +115 -0
- package/payload/schemas/phoenix-event.schema.json +76 -0
- package/payload/schemas/registry.schema.json +57 -0
- package/payload/schemas/rules.schema.json +14 -0
- package/payload/schemas/survey-scope.schema.json +23 -0
- package/payload/templates/decisions/_catalog.yaml +7 -0
- package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
- package/payload/templates/decisions/phoenix-event.yaml +74 -0
- package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
- package/payload/templates/decisions/registry-minting.yaml +58 -0
- package/payload/templates/decisions/trust-graduation.yaml +120 -0
- package/payload/templates/decisions/trust-revocation.yaml +106 -0
- package/payload/templates/knowledge/_catalog.yaml +9 -0
- package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
- package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
- package/payload/templates/knowledge/_registries/domains.yaml +43 -0
- package/payload/templates/knowledge/_registries/form.yaml +38 -0
- package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
- package/payload/templates/knowledge/_registries/operations.yaml +18 -0
- package/payload/templates/knowledge/_registries/stage.yaml +53 -0
- package/payload/templates/knowledge/_rules.yaml +6 -0
- package/payload/templates/new-kind/README.md +107 -0
- package/payload/templates/new-kind/descriptor.example.yaml +18 -0
- package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
- package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
- package/payload/templates/new-kind/fixture/sample.list +7 -0
- package/payload/templates/new-kind/parser.example.js +98 -0
- package/payload/templates/ontology/_catalog.yaml +6 -0
- package/payload/templates/ontology/_rules.yaml +6 -0
- package/payload/wrappers/cursor.mdc +15 -0
- package/payload/wrappers/pointer.md +10 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The derived layer (UCS-1158) — browse trees, call numbers, resolution index.
|
|
3
|
+
*
|
|
4
|
+
* Everything discovery-shaped is a PROJECTION of the flat store, regenerated on
|
|
5
|
+
* demand and thrown away without loss. That sentence is the whole design, and
|
|
6
|
+
* every decision below follows from taking it literally.
|
|
7
|
+
*
|
|
8
|
+
* WHY PLURAL TREES. The store's first life filed each leaf at one position in
|
|
9
|
+
* one tree, and that position was also its identity (§3.2). One tree means one
|
|
10
|
+
* privileged reading of the material, chosen at bootstrap by whoever wrote the
|
|
11
|
+
* spine, and permanent because moving a leaf broke every citation to it. The
|
|
12
|
+
* accession inversion (UCS-1147) removed the cost of being wrong, and this
|
|
13
|
+
* module spends the refund: the same flat store projects into as many trees as
|
|
14
|
+
* there are useful orderings, because a projection commits to nothing.
|
|
15
|
+
*
|
|
16
|
+
* The two shipped axes are not arbitrary. A steward asks "what do we know about
|
|
17
|
+
* design tokens?" and wants DOMAIN first, form second — subject matter is how a
|
|
18
|
+
* person holds a library in their head. An agent asks "what are the constraints
|
|
19
|
+
* here?" and wants FORM first: it is looking for a kind of thing to act on, and
|
|
20
|
+
* the domain narrows it afterwards. Those are two genuinely different reading
|
|
21
|
+
* orders over one set of facts, and before this ticket the store could serve
|
|
22
|
+
* only whichever one its spine happened to encode.
|
|
23
|
+
*
|
|
24
|
+
* WHY DELETING IT LOSES NOTHING. No derived artifact carries a fact that is not
|
|
25
|
+
* already in a leaf. There is no field an author edits here, no id minted here,
|
|
26
|
+
* nothing here that another surface reads back. `derived/` is a build output in
|
|
27
|
+
* the ordinary sense, and `regenerate` proves it: delete the directory, run the
|
|
28
|
+
* command, and the bytes are identical (tests/derived-round-trip). If that test
|
|
29
|
+
* ever fails, something started living in the derived layer that had no other
|
|
30
|
+
* home — which is the exact drift this design exists to prevent.
|
|
31
|
+
*
|
|
32
|
+
* WHY DEMOTION IS ANNOTATION, NEVER OMISSION. A stale or draft leaf appears in
|
|
33
|
+
* every tree it belongs in, wearing a visible marker. Hiding it would make the
|
|
34
|
+
* tree lie by omission — a browsing steward would conclude the store knows
|
|
35
|
+
* nothing about a subject when what is true is that what it knows has rotted,
|
|
36
|
+
* and those two are opposite calls to action. The demotion vocabulary is
|
|
37
|
+
* imported from the surfaces that already own it (`timeVerdict`,
|
|
38
|
+
* `isPrePromotionStatus`) rather than re-derived, so a leaf cannot read stale in
|
|
39
|
+
* a tree and trusted in the resolver.
|
|
40
|
+
*
|
|
41
|
+
* WHERE THE PROBABILISTIC LAYER LIVES, AND WHY IT IS HERE AND NOWHERE ELSE.
|
|
42
|
+
* The resolution index carries a declared SLOT for embedding recall
|
|
43
|
+
* (`RECALL_SLOT`) and a gate that refuses to let anything through it. The slot
|
|
44
|
+
* is empty by design and this ticket implements no recall — model selection and
|
|
45
|
+
* semantic search are out of scope per the spec. What the slot does is fix the
|
|
46
|
+
* architectural position so it cannot be occupied by accident somewhere worse:
|
|
47
|
+
* embedding recall belongs in the DERIVED index, is consulted only after the
|
|
48
|
+
* deterministic layer is exhausted, and produces PROPOSALS that cannot be cited
|
|
49
|
+
* or persisted without passing a human gate. A proposal that reached frontmatter
|
|
50
|
+
* would be a probabilistic guess wearing the same clothes as a cited fact, and
|
|
51
|
+
* no downstream reader could tell them apart. Keeping the slot in the disposable
|
|
52
|
+
* layer means the strongest possible guarantee: whatever the recall layer ever
|
|
53
|
+
* computes, deleting `derived/` deletes all of it, and the store is unchanged.
|
|
54
|
+
*/
|
|
55
|
+
import { compare } from './validate-record.js';
|
|
56
|
+
import {
|
|
57
|
+
DERIVED_DIR, isPrePromotionStatus, leafStage, recordId,
|
|
58
|
+
} from './load-stores.js';
|
|
59
|
+
import { timeVerdict, timeCheckStatus } from './time-verdicts.js';
|
|
60
|
+
import { synthesizeCallNumber } from './call-numbers.js';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The derived layer's home — one directory, clearly disposable.
|
|
64
|
+
*
|
|
65
|
+
* Sits INSIDE the knowledge store rather than at the repo root, because the
|
|
66
|
+
* projection is of this store's leaves and a sibling directory would imply it
|
|
67
|
+
* spans all three. `derived` rather than `generated` or `build`: the word names
|
|
68
|
+
* the relationship to the source (it is derived FROM the leaves) rather than
|
|
69
|
+
* the process that made it, which is what a reader deciding whether it is safe
|
|
70
|
+
* to delete needs to know.
|
|
71
|
+
*
|
|
72
|
+
* Declared in lib/load-stores.js and re-exported here, because the LOADER is
|
|
73
|
+
* the surface that must ignore it: browse trees are markdown under `knowledge/`
|
|
74
|
+
* and so are leaves, so nothing but the directory name distinguishes engine
|
|
75
|
+
* output from a record. One string, named on both sides.
|
|
76
|
+
*/
|
|
77
|
+
export { DERIVED_DIR };
|
|
78
|
+
|
|
79
|
+
/** The banner every generated artifact opens with — the disposability notice. */
|
|
80
|
+
export const DERIVED_BANNER = 'DERIVED — regenerated by `node payload/engine/derive.js --write`. Do not edit; do not cite. Deleting this directory loses nothing.';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The browse axes, in the order they generate.
|
|
84
|
+
*
|
|
85
|
+
* Each is a pure function from a leaf record to a facet path — the same shape
|
|
86
|
+
* the prototype's pivots use, because the prototype is the executable intent
|
|
87
|
+
* for this ticket. A missing facet contributes an `unclassified` segment rather
|
|
88
|
+
* than being skipped, so a leaf with no `form` files under `.../unclassified/`
|
|
89
|
+
* where a steward will see it, instead of silently flattening up a level into
|
|
90
|
+
* the domain node and looking correctly classified.
|
|
91
|
+
*
|
|
92
|
+
* Adding an axis is one row here. Nothing else in this module names an axis,
|
|
93
|
+
* and the command enumerates this table — so a third ordering is a data change,
|
|
94
|
+
* not a code change, which is the property that makes "plural" true rather than
|
|
95
|
+
* "two".
|
|
96
|
+
*
|
|
97
|
+
* @type {ReadonlyArray<{ key: string, label: string, path: (record: object) => string[] }>}
|
|
98
|
+
*/
|
|
99
|
+
export const AXES = Object.freeze([
|
|
100
|
+
Object.freeze({
|
|
101
|
+
key: 'domain-form',
|
|
102
|
+
label: 'domain → form',
|
|
103
|
+
audience: 'stewards — subject matter first, the way a person holds a library in their head',
|
|
104
|
+
path: (record) => [...domainSegments(record), formOf(record)],
|
|
105
|
+
}),
|
|
106
|
+
Object.freeze({
|
|
107
|
+
key: 'form-domain',
|
|
108
|
+
label: 'form → domain',
|
|
109
|
+
audience: 'agents — kind of knowledge first, narrowed by subject afterwards',
|
|
110
|
+
path: (record) => [formOf(record), ...domainSegments(record)],
|
|
111
|
+
}),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/** The marker a facet path segment carries when the leaf declares no value. */
|
|
115
|
+
export const UNCLASSIFIED = 'unclassified';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A leaf's governed domain path, split into segments.
|
|
119
|
+
*
|
|
120
|
+
* Reads `facets.domain` — the REGISTRY-GOVERNED subject path (UCS-1148) — and
|
|
121
|
+
* never the legacy top-level `domain` free string, which is ungoverned and on
|
|
122
|
+
* its way out. Reading the legacy field would make the trees project a
|
|
123
|
+
* vocabulary nothing checks, so a typo would mint a shelf.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} record a leaf's front-matter record
|
|
126
|
+
* @returns {string[]} the domain segments, or one `unclassified` segment
|
|
127
|
+
*/
|
|
128
|
+
export function domainSegments(record) {
|
|
129
|
+
const domain = record?.facets?.domain;
|
|
130
|
+
if (typeof domain !== 'string' || domain === '') return [UNCLASSIFIED];
|
|
131
|
+
const segments = domain.split('/').filter((s) => s !== '');
|
|
132
|
+
return segments.length ? segments : [UNCLASSIFIED];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A leaf's governed form, or the unclassified marker.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} record a leaf's front-matter record
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function formOf(record) {
|
|
142
|
+
const form = record?.facets?.form;
|
|
143
|
+
return typeof form === 'string' && form !== '' ? form : UNCLASSIFIED;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every demotion that applies to one leaf, with its reason.
|
|
148
|
+
*
|
|
149
|
+
* The SAME two predicates the resolver demotes on (`isPrePromotionStatus` for
|
|
150
|
+
* stage, `timeVerdict().stale` for time), read through the same accessors, and
|
|
151
|
+
* that sharing is the point rather than a convenience: a leaf marked stale in a
|
|
152
|
+
* browse tree and trusted in resolver output would be one record wearing two
|
|
153
|
+
* answers, and a reader would have no way to know which surface to believe.
|
|
154
|
+
* UCS-1150's module header commits to exactly one implementation and names this
|
|
155
|
+
* ticket as an inheritor; this is that inheritance.
|
|
156
|
+
*
|
|
157
|
+
* Returns the same `{reason, detail}` shape the resolver publishes, so a
|
|
158
|
+
* consumer reading both surfaces sees one vocabulary.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} record a leaf's front-matter record
|
|
161
|
+
* @param {string|null} today the injected date, or null
|
|
162
|
+
* @returns {Array<{reason: string, detail: string}>}
|
|
163
|
+
*/
|
|
164
|
+
export function demotionsFor(record, today) {
|
|
165
|
+
const stage = leafStage(record);
|
|
166
|
+
const time = timeVerdict(record, today);
|
|
167
|
+
return [
|
|
168
|
+
...(isPrePromotionStatus(stage)
|
|
169
|
+
? [{ reason: 'stage', detail: `stage "${stage}" is pre-promotion — no moderator has certified this leaf's citations (UCS-1149)` }]
|
|
170
|
+
: []),
|
|
171
|
+
...(time.stale ? [{ reason: 'time', detail: time.reason }] : []),
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Project one leaf into one axis — the row a tree prints.
|
|
177
|
+
*
|
|
178
|
+
* The call number is synthesized from the SAME path array the tree nests the
|
|
179
|
+
* leaf under, so the printed shelf label and the actual position cannot
|
|
180
|
+
* disagree. See lib/call-numbers.js for why that string is not an identity.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} entry a loader-indexed leaf entry
|
|
183
|
+
* @param {{ path: (record: object) => string[] }} axis
|
|
184
|
+
* @param {string|null} today the injected date, or null
|
|
185
|
+
* @returns {{ id: string, path: string[], callNumber: string, heading: string|null, file: string, stage: string|null, time: object, demoted: boolean, demotions: Array<object> }}
|
|
186
|
+
*/
|
|
187
|
+
export function projectLeaf(entry, axis, today) {
|
|
188
|
+
const record = entry.record;
|
|
189
|
+
const path = axis.path(record);
|
|
190
|
+
const id = recordId(entry);
|
|
191
|
+
const demotions = demotionsFor(record, today);
|
|
192
|
+
return {
|
|
193
|
+
id: typeof id === 'string' ? id : null,
|
|
194
|
+
path,
|
|
195
|
+
'call-number': synthesizeCallNumber(path, typeof id === 'string' ? id : null),
|
|
196
|
+
heading: typeof record?.heading === 'string' ? record.heading : null,
|
|
197
|
+
file: entry.file,
|
|
198
|
+
stage: leafStage(record),
|
|
199
|
+
time: timeVerdict(record, today),
|
|
200
|
+
// A single boolean answering a single question — does this leaf sort below
|
|
201
|
+
// the promoted ones — with WHICH demotions fired kept separately, so a leaf
|
|
202
|
+
// that is both draft and stale reports both rather than having one absorb
|
|
203
|
+
// the other. Identical to the resolver's contract (UCS-1150).
|
|
204
|
+
demoted: demotions.length > 0,
|
|
205
|
+
demotions,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build one browse tree from the flat leaf index.
|
|
211
|
+
*
|
|
212
|
+
* Nodes are sorted by name and leaves by accession, both through `compare` —
|
|
213
|
+
* the same collator every other surface stable-sorts with, so a tree's byte
|
|
214
|
+
* order does not depend on the order the filesystem happened to hand the loader
|
|
215
|
+
* its leaves. That is what makes the golden files diffable at all, and the
|
|
216
|
+
* reordered-twin fixture pins it.
|
|
217
|
+
*
|
|
218
|
+
* DEMOTED LEAVES SORT LAST WITHIN THEIR NODE, and that is the only way demotion
|
|
219
|
+
* changes the tree's shape. They are not moved to another node, not collapsed,
|
|
220
|
+
* and not omitted — a steward browsing `design-system/components` sees every leaf
|
|
221
|
+
* filed there, with the rotted ones at the bottom wearing their reason. Sorting
|
|
222
|
+
* them out of the way is a reading aid; hiding them would be a lie about what
|
|
223
|
+
* the store contains.
|
|
224
|
+
*
|
|
225
|
+
* @param {Iterable<object>} entries loader-indexed leaf entries
|
|
226
|
+
* @param {{ path: (record: object) => string[] }} axis
|
|
227
|
+
* @param {string|null} today the injected date, or null
|
|
228
|
+
* @returns {object} the tree root
|
|
229
|
+
*/
|
|
230
|
+
export function buildTree(entries, axis, today) {
|
|
231
|
+
const root = { name: null, children: new Map(), leaves: [] };
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
const projected = projectLeaf(entry, axis, today);
|
|
234
|
+
let node = root;
|
|
235
|
+
for (const segment of projected.path) {
|
|
236
|
+
if (!node.children.has(segment)) {
|
|
237
|
+
node.children.set(segment, { name: segment, children: new Map(), leaves: [] });
|
|
238
|
+
}
|
|
239
|
+
node = node.children.get(segment);
|
|
240
|
+
}
|
|
241
|
+
node.leaves.push(projected);
|
|
242
|
+
}
|
|
243
|
+
return sortNode(root);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Sort one node's children and leaves, recursively, into their published order.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} node
|
|
250
|
+
* @returns {object} the same node, sorted
|
|
251
|
+
*/
|
|
252
|
+
function sortNode(node) {
|
|
253
|
+
node.leaves.sort((a, b) => {
|
|
254
|
+
// Demoted last, then by accession. Two demoted leaves keep accession order
|
|
255
|
+
// between them rather than sorting by WHICH demotion fired: the demotion is
|
|
256
|
+
// a fact about the leaf, not a rank, and ordering by it would invent a
|
|
257
|
+
// precedence between "draft" and "stale" that nothing in the store declares.
|
|
258
|
+
if (a.demoted !== b.demoted) return a.demoted ? 1 : -1;
|
|
259
|
+
return compare(a.id, b.id);
|
|
260
|
+
});
|
|
261
|
+
const sorted = new Map([...node.children.entries()].sort((a, b) => compare(a[0], b[0])));
|
|
262
|
+
for (const child of sorted.values()) sortNode(child);
|
|
263
|
+
node.children = sorted;
|
|
264
|
+
return node;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Count the leaves at or under one node.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} node
|
|
271
|
+
* @returns {number}
|
|
272
|
+
*/
|
|
273
|
+
export function countLeaves(node) {
|
|
274
|
+
let n = node.leaves.length;
|
|
275
|
+
for (const child of node.children.values()) n += countLeaves(child);
|
|
276
|
+
return n;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Render one tree as the markdown artifact written to disk.
|
|
281
|
+
*
|
|
282
|
+
* Markdown rather than JSON because the audience for a browse tree is a person
|
|
283
|
+
* or an agent READING it — the same reason a catalog is browsable. The
|
|
284
|
+
* machine-shaped projection of the same facts is the resolution index, which is
|
|
285
|
+
* JSON; the two are generated from one model so they cannot disagree.
|
|
286
|
+
*
|
|
287
|
+
* No timestamps, anywhere. The banner and the axis are the only preamble, and
|
|
288
|
+
* every line below is a function of the store's content — which is what lets
|
|
289
|
+
* D-012 baseline diffing attribute a changed tree to a changed leaf rather than
|
|
290
|
+
* to the clock.
|
|
291
|
+
*
|
|
292
|
+
* @param {object} tree the sorted tree root
|
|
293
|
+
* @param {{ key: string, label: string, audience: string }} axis
|
|
294
|
+
* @param {string|null} today the injected date, or null
|
|
295
|
+
* @returns {string} the artifact's full text
|
|
296
|
+
*/
|
|
297
|
+
export function renderTree(tree, axis, today) {
|
|
298
|
+
const lines = [
|
|
299
|
+
`# Browse tree — ${axis.label}`,
|
|
300
|
+
'',
|
|
301
|
+
`> ${DERIVED_BANNER}`,
|
|
302
|
+
'',
|
|
303
|
+
`- axis: \`${axis.key}\` (${axis.label})`,
|
|
304
|
+
`- for: ${axis.audience}`,
|
|
305
|
+
`- leaves: ${countLeaves(tree)}`,
|
|
306
|
+
`- time verdicts: ${timeCheckStatus(today)}`,
|
|
307
|
+
'',
|
|
308
|
+
'Call numbers are synthesized display strings for this projection only. They',
|
|
309
|
+
'are NOT identities: cite the accession id (L-NNNNNN), never a call number.',
|
|
310
|
+
'',
|
|
311
|
+
];
|
|
312
|
+
renderNode(tree, 0, lines);
|
|
313
|
+
return `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Render one node and its subtree into `lines`.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} node
|
|
320
|
+
* @param {number} depth
|
|
321
|
+
* @param {string[]} lines
|
|
322
|
+
*/
|
|
323
|
+
function renderNode(node, depth, lines) {
|
|
324
|
+
const indent = ' '.repeat(depth);
|
|
325
|
+
for (const [name, child] of node.children) {
|
|
326
|
+
lines.push(`${indent}- **${name}/** (${countLeaves(child)})`);
|
|
327
|
+
renderNode(child, depth + 1, lines);
|
|
328
|
+
}
|
|
329
|
+
for (const leaf of node.leaves) {
|
|
330
|
+
// The demotion annotation is part of the leaf's LINE, not a separate
|
|
331
|
+
// section: a reader scanning the shelf must not have to cross-reference a
|
|
332
|
+
// footnote to learn that what they are looking at has rotted.
|
|
333
|
+
const marks = leaf.demotions.map((d) => `${d.reason}`).join(', ');
|
|
334
|
+
const suffix = leaf.demoted ? ` — **demoted** (${marks})` : '';
|
|
335
|
+
lines.push(`${indent}- \`${leaf['call-number']}\` ${leaf.heading ?? '(no heading)'}${suffix}`);
|
|
336
|
+
for (const demotion of leaf.demotions) {
|
|
337
|
+
lines.push(`${indent} - ${demotion.reason}: ${demotion.detail}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The declared position of embedding recall — a SLOT, deliberately empty.
|
|
344
|
+
*
|
|
345
|
+
* This ticket implements no recall and selects no model: that is out of scope by
|
|
346
|
+
* spec, and writing similarity code here would be the scope error the spec warns
|
|
347
|
+
* about. What ships is the ARCHITECTURE — where recall may live, when it may be
|
|
348
|
+
* consulted, and what it is forbidden to do — so that an implementation arriving
|
|
349
|
+
* later has exactly one legal shape and cannot quietly acquire a worse one.
|
|
350
|
+
*
|
|
351
|
+
* The three constraints are each load-bearing:
|
|
352
|
+
*
|
|
353
|
+
* `location: derived` — recall lives in the disposable layer and nowhere else.
|
|
354
|
+
* Deleting `derived/` must delete every probabilistic artifact the system has
|
|
355
|
+
* ever computed. An embedding cached in frontmatter would be a guess sitting
|
|
356
|
+
* in the same file as cited facts, indistinguishable to every later reader.
|
|
357
|
+
*
|
|
358
|
+
* `consulted: after-deterministic-exhausted` — recall runs only when the
|
|
359
|
+
* deterministic layer returns nothing. Consulting it earlier would let a
|
|
360
|
+
* similarity score outrank a declared edge, and the store's whole claim is
|
|
361
|
+
* that what resolves, resolves for a reason someone can point at.
|
|
362
|
+
*
|
|
363
|
+
* `output: proposals` with `citable: false` and `persistable: false` — recall
|
|
364
|
+
* may PROPOSE and may never persist or be cited. A proposal becomes knowledge
|
|
365
|
+
* only by passing a human gate, at which point it is an ordinary authored leaf
|
|
366
|
+
* with citations, and the probabilistic step is history rather than provenance.
|
|
367
|
+
*
|
|
368
|
+
* @type {Readonly<object>}
|
|
369
|
+
*/
|
|
370
|
+
export const RECALL_SLOT = Object.freeze({
|
|
371
|
+
status: 'declared-unimplemented',
|
|
372
|
+
location: DERIVED_DIR,
|
|
373
|
+
consulted: 'after-deterministic-exhausted',
|
|
374
|
+
output: 'proposals',
|
|
375
|
+
citable: false,
|
|
376
|
+
persistable: false,
|
|
377
|
+
gate: 'human-review — a proposal enters the store only as an authored leaf with citations (UCS-1158)',
|
|
378
|
+
'in-scope-here': 'the slot and the gate',
|
|
379
|
+
'out-of-scope-here': 'model selection and semantic-recall implementation (issue #49)',
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The gate every probabilistic proposal must pass, and never does automatically.
|
|
384
|
+
*
|
|
385
|
+
* Total by construction: it returns a refusal for EVERY proposal, because no
|
|
386
|
+
* proposal can be admitted without a human, and there is no argument this
|
|
387
|
+
* function could take that would represent one. An implementation of recall
|
|
388
|
+
* calls this and gets a refusal; the only way into the store is the ordinary
|
|
389
|
+
* authoring path, where a human writes a leaf with citations and a moderator
|
|
390
|
+
* promotes it.
|
|
391
|
+
*
|
|
392
|
+
* A function that always refuses may read as a placeholder. It is not: it is the
|
|
393
|
+
* seam that makes "nothing probabilistic persists without a gate" checkable by a
|
|
394
|
+
* test rather than promised by a comment, and it is what a future recall
|
|
395
|
+
* implementation must route through to be reviewable at all.
|
|
396
|
+
*
|
|
397
|
+
* @param {object} proposal a candidate produced by probabilistic recall
|
|
398
|
+
* @returns {{ admitted: false, reason: string, proposal: object }}
|
|
399
|
+
*/
|
|
400
|
+
export function gateProposal(proposal) {
|
|
401
|
+
return {
|
|
402
|
+
admitted: false,
|
|
403
|
+
reason: 'probabilistic proposals are never admitted automatically — they may be read by a human, '
|
|
404
|
+
+ 'who may author an ordinary cited leaf; nothing reaches the store without that gate (UCS-1158)',
|
|
405
|
+
proposal,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Build the resolution index — the machine-shaped projection of the same facts.
|
|
411
|
+
*
|
|
412
|
+
* One entry per leaf, carrying its identity, its position on every axis, the
|
|
413
|
+
* call number each axis synthesizes, and its demotion state. The trees are for
|
|
414
|
+
* reading; this is for joining, and both are generated from one pass over one
|
|
415
|
+
* model so a leaf cannot be filed one way in a tree and another way here.
|
|
416
|
+
*
|
|
417
|
+
* The index is keyed by ACCESSION and by nothing else. Keying it by call number
|
|
418
|
+
* would create a second resolvable name for a leaf — exactly the fusion the
|
|
419
|
+
* accession inversion removed — and a lookup table is the most tempting place
|
|
420
|
+
* in the system to reintroduce it.
|
|
421
|
+
*
|
|
422
|
+
* @param {Iterable<object>} entries loader-indexed leaf entries
|
|
423
|
+
* @param {string|null} today the injected date, or null
|
|
424
|
+
* @returns {object} the index payload
|
|
425
|
+
*/
|
|
426
|
+
export function buildIndex(entries, today) {
|
|
427
|
+
const leaves = [];
|
|
428
|
+
for (const entry of entries) {
|
|
429
|
+
const record = entry.record;
|
|
430
|
+
const id = recordId(entry);
|
|
431
|
+
const demotions = demotionsFor(record, today);
|
|
432
|
+
const positions = {};
|
|
433
|
+
for (const axis of AXES) {
|
|
434
|
+
const path = axis.path(record);
|
|
435
|
+
positions[axis.key] = {
|
|
436
|
+
path,
|
|
437
|
+
'call-number': synthesizeCallNumber(path, typeof id === 'string' ? id : null),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
leaves.push({
|
|
441
|
+
id: typeof id === 'string' ? id : null,
|
|
442
|
+
heading: typeof record?.heading === 'string' ? record.heading : null,
|
|
443
|
+
file: entry.file,
|
|
444
|
+
stage: leafStage(record),
|
|
445
|
+
time: timeVerdict(record, today),
|
|
446
|
+
demoted: demotions.length > 0,
|
|
447
|
+
demotions,
|
|
448
|
+
positions,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
leaves.sort((a, b) => compare(a.id, b.id));
|
|
452
|
+
return {
|
|
453
|
+
note: DERIVED_BANNER,
|
|
454
|
+
// Spelled out rather than left implicit, because the index is the artifact
|
|
455
|
+
// most likely to be read by something that wants to resolve through it.
|
|
456
|
+
'call-numbers': 'synthesized display strings, per axis — NOT identities and never citable; cite the accession id',
|
|
457
|
+
'time-check': timeCheckStatus(today),
|
|
458
|
+
axes: AXES.map((a) => ({ key: a.key, label: a.label, audience: a.audience })),
|
|
459
|
+
'recall-slot': RECALL_SLOT,
|
|
460
|
+
counts: {
|
|
461
|
+
leaves: leaves.length,
|
|
462
|
+
demoted: leaves.filter((l) => l.demoted).length,
|
|
463
|
+
},
|
|
464
|
+
leaves,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Every artifact the derived layer consists of, as `{path, text}` pairs.
|
|
470
|
+
*
|
|
471
|
+
* ONE function computes the whole layer, and both verbs run it: `--check`
|
|
472
|
+
* compares these bytes against what is on disk, `--write` writes them. That is
|
|
473
|
+
* what makes the check meaningful — a check that recomputed the artifacts a
|
|
474
|
+
* second way could pass while the writer produced something else.
|
|
475
|
+
*
|
|
476
|
+
* Paths are relative to the store root and always forward-slashed, so the
|
|
477
|
+
* manifest is identical on every platform.
|
|
478
|
+
*
|
|
479
|
+
* @param {Iterable<object>} entries loader-indexed leaf entries
|
|
480
|
+
* @param {string|null} today the injected date, or null
|
|
481
|
+
* @returns {Array<{path: string, text: string}>} sorted by path
|
|
482
|
+
*/
|
|
483
|
+
export function deriveArtifacts(entries, today) {
|
|
484
|
+
const materialized = [...entries];
|
|
485
|
+
const artifacts = AXES.map((axis) => ({
|
|
486
|
+
path: `knowledge/${DERIVED_DIR}/tree.${axis.key}.md`,
|
|
487
|
+
text: renderTree(buildTree(materialized, axis, today), axis, today),
|
|
488
|
+
}));
|
|
489
|
+
artifacts.push({
|
|
490
|
+
path: `knowledge/${DERIVED_DIR}/index.json`,
|
|
491
|
+
text: `${JSON.stringify(buildIndex(materialized, today), null, 2)}\n`,
|
|
492
|
+
});
|
|
493
|
+
return artifacts.sort((a, b) => compare(a.path, b.path));
|
|
494
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The difference between a refusal and a bug (UCS-949).
|
|
3
|
+
*
|
|
4
|
+
* A surface may catch an engine throw and print a clean, actionable message:
|
|
5
|
+
* "no git", "the store loader reported 3 error(s)", "illegal transition". These
|
|
6
|
+
* are ANTICIPATED conditions. The engine reached them on purpose, so a stack
|
|
7
|
+
* trace would be noise.
|
|
8
|
+
*
|
|
9
|
+
* A `TypeError` from a genuine bug is not that. It must reach `runCli`, which
|
|
10
|
+
* prints the stack — otherwise a crash hides behind a one-line message that
|
|
11
|
+
* reads exactly like a considered refusal, and nobody can debug it.
|
|
12
|
+
*
|
|
13
|
+
* Both still exit 2. This is about diagnosability, not the exit-code contract.
|
|
14
|
+
*
|
|
15
|
+
* The rule: an anticipated refusal is a plain `new Error(msg)` — which is what
|
|
16
|
+
* every refusal in the engine already throws — or an `EngineRefusal` subclass
|
|
17
|
+
* for the ones that carry more than a message. Everything else is a bug, or a
|
|
18
|
+
* `UsageError` that the harness alone gets to interpret.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** An anticipated, actionable engine refusal. Reported without a stack trace. */
|
|
22
|
+
export class EngineRefusal extends Error {
|
|
23
|
+
name = 'EngineRefusal';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Rethrow `error` unless it is an anticipated engine refusal.
|
|
28
|
+
*
|
|
29
|
+
* Call this first inside a surface's engine-failure catch, so the catch speaks
|
|
30
|
+
* only for the failures it can actually explain and everything else — a bug, a
|
|
31
|
+
* UsageError raised deep in the loader — travels on to the harness.
|
|
32
|
+
*
|
|
33
|
+
* @param {unknown} error
|
|
34
|
+
* @returns {void} when the error is a refusal the caller may report itself
|
|
35
|
+
* @throws {unknown} the original error, when it is not
|
|
36
|
+
*/
|
|
37
|
+
export function rethrowIfBug(error) {
|
|
38
|
+
const expected = error instanceof EngineRefusal || error?.constructor === Error;
|
|
39
|
+
if (!expected) throw error;
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine exit-code contract (PRD §5, D-011) — uniform across every engine
|
|
3
|
+
* module: validate, validate-values, audit, resolve, survey-map, preflight.
|
|
4
|
+
*
|
|
5
|
+
* 0 — clean
|
|
6
|
+
* 1 — findings / quarantines
|
|
7
|
+
* 2 — engine or environment failure. A check that never ran is a
|
|
8
|
+
* blocking defect, never a silent pass.
|
|
9
|
+
*/
|
|
10
|
+
export const EXIT_CODES = Object.freeze({
|
|
11
|
+
CLEAN: 0,
|
|
12
|
+
FINDINGS: 1,
|
|
13
|
+
FAILURE: 2,
|
|
14
|
+
});
|