knosky 0.6.3 → 0.7.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. package/ssot/tool-menu.json +130 -0
package/core/route.mjs CHANGED
@@ -1,304 +1,304 @@
1
- // KnoSky route engine (KSV2-R1/R2/R3) — structural destination -> ranked advisory route.
2
- // Pure Node stdlib + internal imports only. ESM. No new deps.
3
- import { getRelated } from './retrieve.mjs';
4
- import { makeRouteDoc, validateRouteDoc } from './schema.mjs';
5
- import { parseDestination } from './destination.mjs';
6
- import { extractLedgerSeq } from './freshness.mjs';
7
-
8
- // ---------------------------------------------------------------------------
9
- // Internal helpers
10
- // ---------------------------------------------------------------------------
11
-
12
- /**
13
- * Guard: a path from provenance.ref should never be absolute or contain "..".
14
- * Returns true when the path is safe to include in output.
15
- * @param {string|undefined} ref
16
- * @returns {boolean}
17
- */
18
- function isSafeRef(ref) {
19
- if (!ref || typeof ref !== 'string') return false;
20
- if (ref.startsWith('/')) return false;
21
- if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
22
- if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
23
- return true;
24
- }
25
-
26
- /**
27
- * Return the node's provenance.ref, or null if missing/unsafe.
28
- * @param {object} node
29
- * @returns {string|null}
30
- */
31
- function safeRef(node) {
32
- const ref = node.provenance && node.provenance.ref;
33
- return isSafeRef(ref) ? ref : null;
34
- }
35
-
36
- /**
37
- * Return true when node looks like a test file.
38
- * @param {object} node
39
- */
40
- function isTest(node) {
41
- const ref = safeRef(node) || '';
42
- const cat = String(node.category || '').toLowerCase();
43
- return (
44
- cat === 'test' ||
45
- /\/test\//.test(ref) ||
46
- /\.test\./.test(ref) ||
47
- /\.spec\./.test(ref) ||
48
- /\/test\//.test(node.id || '')
49
- );
50
- }
51
-
52
- /**
53
- * Return true when node looks like a docs file.
54
- * @param {object} node
55
- */
56
- function isDoc(node) {
57
- const ref = safeRef(node) || '';
58
- const cat = String(node.category || '').toLowerCase();
59
- return (
60
- cat === 'docs' ||
61
- cat === 'documentation' ||
62
- ref.endsWith('.md') ||
63
- ref.endsWith('.rst') ||
64
- ref.endsWith('.txt')
65
- );
66
- }
67
-
68
- /**
69
- * Tokenise a string into lowercase alphanumeric tokens, stripping any "word:" prefix.
70
- * @param {string} str
71
- * @returns {Set<string>}
72
- */
73
- function tokenise(str) {
74
- const stripped = String(str || '').replace(/^\w+:/, '');
75
- return new Set((stripped.toLowerCase().match(/[a-z0-9]+/g) || []));
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // kcRoute — main export
80
- // ---------------------------------------------------------------------------
81
-
82
- /**
83
- * Build a ranked, advisory, metadata-only route doc.
84
- *
85
- * @param {object} ctx — retrieve context from load()
86
- * @param {string} destination — navigation target string
87
- * @param {object} [opts]
88
- * @param {number} [opts.limit=8] — max route entries
89
- * @param {object} [opts.overlays] — optional overlay map from readOverlays():
90
- * { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }
91
- * Absence means coverage is unknown — the coverage-unknown caveat fires.
92
- * @returns {object} route doc (passes validateRouteDoc)
93
- */
94
- export function kcRoute(ctx, destination, { limit = 8, overlays } = {}) {
95
- const clampedLimit = Math.max(1, Math.min(20, limit));
96
- const { matched, matchStrength } = parseDestination(ctx, destination, clampedLimit);
97
-
98
- // Collect direct-match ids + categories for scoring
99
- const directIds = new Set(matched.map(n => n.id));
100
- const directCategories = new Set(
101
- matched.map(n => String(n.category || '').toLowerCase()).filter(Boolean),
102
- );
103
-
104
- // Destination keyword tokens (strip any "word:" prefix)
105
- const destTokens = tokenise(destination);
106
-
107
- // Expand to 1-hop neighbours via getRelated (imports + importedBy); de-dupe by id
108
- const candidateMap = new Map();
109
- for (const n of matched) {
110
- candidateMap.set(n.id, n);
111
- }
112
-
113
- for (const n of matched) {
114
- const rel = getRelated(ctx, n.id);
115
- if (!rel) continue;
116
- for (const imp of rel.imports.concat(rel.importedBy)) {
117
- if (!candidateMap.has(imp.id)) {
118
- const full = ctx.byId.get(imp.id);
119
- if (full) candidateMap.set(imp.id, full);
120
- }
121
- }
122
- }
123
-
124
- // Score candidates
125
- const caveats = [];
126
- const lowCoverageCaveats = [];
127
- const scored = [];
128
-
129
- for (const [id, node] of candidateMap) {
130
- let score = 0;
131
- const reasons = [];
132
-
133
- // Signal 1: destination match (+5)
134
- if (directIds.has(id)) {
135
- score += 5;
136
- reasons.push('destination');
137
- }
138
-
139
- // Signal 2: import proximity (+2)
140
- const rel = getRelated(ctx, id);
141
- if (rel) {
142
- const importsDirectMatch = rel.imports.some(m => directIds.has(m.id));
143
- const importedByDirectMatch = rel.importedBy.some(m => directIds.has(m.id));
144
- if (importsDirectMatch || importedByDirectMatch) {
145
- score += 2;
146
- reasons.push(importsDirectMatch ? 'imports destination' : 'imported by destination');
147
- }
148
- }
149
-
150
- // Signal 3: same district (+1.5) — shares category with a direct match but is not itself a direct match
151
- if (!directIds.has(id) && directCategories.size > 0) {
152
- const cat = String(node.category || '').toLowerCase();
153
- if (cat && directCategories.has(cat)) {
154
- score += 1.5;
155
- reasons.push('same district');
156
- }
157
- }
158
-
159
- // Signal 4: keyword overlap (+1, capped once)
160
- if (destTokens.size > 0) {
161
- const candidateText = [node.title || '', ...(node.headings || [])].join(' ');
162
- const candidateTokens = tokenise(candidateText);
163
- const hasOverlap = [...destTokens].some(t => candidateTokens.has(t));
164
- if (hasOverlap) {
165
- score += 1;
166
- reasons.push('name/heading match');
167
- }
168
- }
169
-
170
- // Signal 5: recently changed (+1)
171
- if (node.churn && typeof node.churn === 'object' && node.churn.c > 0) {
172
- score += 1;
173
- reasons.push('recently changed');
174
- } else if (typeof node.churn === 'number' && node.churn > 0) {
175
- score += 1;
176
- reasons.push('recently changed');
177
- }
178
-
179
- // Signal 6: low coverage (+0.5 if overlays provided and coverage < 50)
180
- if (overlays !== undefined && overlays !== null) {
181
- const ref = safeRef(node);
182
- if (ref) {
183
- const overlay = overlays[ref];
184
- if (overlay && typeof overlay.coverage === 'number') {
185
- if (overlay.coverage < 50) {
186
- score += 0.5;
187
- lowCoverageCaveats.push(
188
- 'low coverage: ' + ref + ' (' + Math.round(overlay.coverage) + '%) — extra review advised',
189
- );
190
- }
191
- // coverage >= 50: no change, no penalty
192
- }
193
- }
194
- }
195
-
196
- scored.push({ id, node, score, reason: reasons.join('; ') || 'neighbourhood match' });
197
- }
198
-
199
- // Sort: score desc, then id asc
200
- scored.sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
201
-
202
- // Build caveats
203
- // — churn caveat for any high-churn file in the top results
204
- const topCandidates = scored.slice(0, clampedLimit + 5); // check a bit beyond route
205
- for (const s of topCandidates) {
206
- const churn = s.node.churn;
207
- const hasChurn = (churn && typeof churn === 'object' && churn.c > 0) ||
208
- (typeof churn === 'number' && churn > 0);
209
- if (hasChurn) {
210
- caveats.push('recently changed: ' + (s.node.provenance && s.node.provenance.ref || s.id) + ' has high churn — verify before acting');
211
- break; // one caveat entry is sufficient
212
- }
213
- }
214
-
215
- // — low-coverage caveats (one per underperforming file, from signal 6)
216
- for (const c of lowCoverageCaveats) {
217
- caveats.push(c);
218
- }
219
-
220
- // — coverage unknown caveat: only when overlays were NOT provided
221
- if (overlays === undefined || overlays === null) {
222
- const hasCoverageGap = scored.some(s => s.node.coverage === undefined || s.node.coverage === null);
223
- if (hasCoverageGap) {
224
- caveats.push('coverage unknown: one or more nodes lack coverage overlay data');
225
- }
226
- }
227
-
228
- // — mandatory advisory caveat (always present)
229
- caveats.push('advisory route — verify before acting; KnoSky does not read code meaning');
230
-
231
- // — staleness caveat when source_rev is present
232
- const source_rev = ctx.city.source_rev || null;
233
- if (source_rev) {
234
- caveats.push('route is based on rev ' + source_rev + ' and may be stale');
235
- }
236
-
237
- // Ledger-anchored freshness (SAT-444): read the monotone commit count from the city.
238
- const ledger_seq = extractLedgerSeq(ctx.city);
239
-
240
- // Slice into route + alternates
241
- const routeEntries = scored.slice(0, clampedLimit);
242
- const alternateEntries = scored.slice(clampedLimit, clampedLimit + 5);
243
-
244
- function toEntry(s) {
245
- const path = safeRef(s.node) || s.id;
246
- return { path, id: s.id, reason: s.reason, score: s.score };
247
- }
248
-
249
- // Defense in depth: only isSafeRef() gates output (the previous `|| !path.startsWith('/')`
250
- // clause was a bug — any path lacking a leading '/' but containing '..' would pass through
251
- // it unfiltered; validateRouteDoc() catches that downstream today, but this filter should
252
- // not have been able to let it through in the first place).
253
- const route = routeEntries.map(toEntry).filter(e => isSafeRef(e.path));
254
- const alternates = alternateEntries.map(toEntry).filter(e => isSafeRef(e.path));
255
-
256
- // Confidence: direct -> ~0.95 ceiling; folder/district/edges/chain -> ~0.75; keyword -> ~0.6
257
- let confidence = 0;
258
- if (routeEntries.length > 0) {
259
- const topScore = routeEntries[0].score;
260
- if (matchStrength === 'direct') {
261
- // direct hit scores at least 5; normalise to ~0.95 max
262
- confidence = Math.min(0.95, topScore / 10);
263
- } else if (matchStrength === 'folder' || matchStrength === 'district' ||
264
- matchStrength === 'edges' || matchStrength === 'chain') {
265
- confidence = Math.min(0.75, topScore / 10);
266
- } else {
267
- // keyword — lower ceiling
268
- confidence = Math.min(0.6, topScore / 20);
269
- }
270
- confidence = Math.max(0, Math.min(1, confidence));
271
- }
272
-
273
- // Classify tests / docs from all candidates
274
- const tests = [];
275
- const docs = [];
276
- for (const [, node] of candidateMap) {
277
- const ref = safeRef(node);
278
- if (!ref) continue;
279
- if (isTest(node)) tests.push({ path: ref, id: node.id });
280
- else if (isDoc(node)) docs.push({ path: ref, id: node.id });
281
- }
282
-
283
- const doc = makeRouteDoc({
284
- destination,
285
- route,
286
- alternates,
287
- caveats,
288
- confidence,
289
- source_rev,
290
- ledger_seq,
291
- });
292
-
293
- // Attach tests + docs (extra fields beyond the base envelope — schema.validateRouteDoc does not reject them)
294
- doc.tests = tests;
295
- doc.docs = docs;
296
-
297
- // Invariant: doc MUST pass validateRouteDoc
298
- const validation = validateRouteDoc(doc);
299
- if (!validation.ok) {
300
- throw new Error('kcRoute produced an invalid route doc: ' + validation.errors.join('; '));
301
- }
302
-
303
- return doc;
304
- }
1
+ // KnoSky route engine (KSV2-R1/R2/R3) — structural destination -> ranked advisory route.
2
+ // Pure Node stdlib + internal imports only. ESM. No new deps.
3
+ import { getRelated } from './retrieve.mjs';
4
+ import { makeRouteDoc, validateRouteDoc } from './schema.mjs';
5
+ import { parseDestination } from './destination.mjs';
6
+ import { extractLedgerSeq } from './freshness.mjs';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Internal helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Guard: a path from provenance.ref should never be absolute or contain "..".
14
+ * Returns true when the path is safe to include in output.
15
+ * @param {string|undefined} ref
16
+ * @returns {boolean}
17
+ */
18
+ function isSafeRef(ref) {
19
+ if (!ref || typeof ref !== 'string') return false;
20
+ if (ref.startsWith('/')) return false;
21
+ if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
22
+ if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
23
+ return true;
24
+ }
25
+
26
+ /**
27
+ * Return the node's provenance.ref, or null if missing/unsafe.
28
+ * @param {object} node
29
+ * @returns {string|null}
30
+ */
31
+ function safeRef(node) {
32
+ const ref = node.provenance && node.provenance.ref;
33
+ return isSafeRef(ref) ? ref : null;
34
+ }
35
+
36
+ /**
37
+ * Return true when node looks like a test file.
38
+ * @param {object} node
39
+ */
40
+ function isTest(node) {
41
+ const ref = safeRef(node) || '';
42
+ const cat = String(node.category || '').toLowerCase();
43
+ return (
44
+ cat === 'test' ||
45
+ /\/test\//.test(ref) ||
46
+ /\.test\./.test(ref) ||
47
+ /\.spec\./.test(ref) ||
48
+ /\/test\//.test(node.id || '')
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Return true when node looks like a docs file.
54
+ * @param {object} node
55
+ */
56
+ function isDoc(node) {
57
+ const ref = safeRef(node) || '';
58
+ const cat = String(node.category || '').toLowerCase();
59
+ return (
60
+ cat === 'docs' ||
61
+ cat === 'documentation' ||
62
+ ref.endsWith('.md') ||
63
+ ref.endsWith('.rst') ||
64
+ ref.endsWith('.txt')
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Tokenise a string into lowercase alphanumeric tokens, stripping any "word:" prefix.
70
+ * @param {string} str
71
+ * @returns {Set<string>}
72
+ */
73
+ function tokenise(str) {
74
+ const stripped = String(str || '').replace(/^\w+:/, '');
75
+ return new Set((stripped.toLowerCase().match(/[a-z0-9]+/g) || []));
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // kcRoute — main export
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Build a ranked, advisory, metadata-only route doc.
84
+ *
85
+ * @param {object} ctx — retrieve context from load()
86
+ * @param {string} destination — navigation target string
87
+ * @param {object} [opts]
88
+ * @param {number} [opts.limit=8] — max route entries
89
+ * @param {object} [opts.overlays] — optional overlay map from readOverlays():
90
+ * { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }
91
+ * Absence means coverage is unknown — the coverage-unknown caveat fires.
92
+ * @returns {object} route doc (passes validateRouteDoc)
93
+ */
94
+ export function kcRoute(ctx, destination, { limit = 8, overlays } = {}) {
95
+ const clampedLimit = Math.max(1, Math.min(20, limit));
96
+ const { matched, matchStrength } = parseDestination(ctx, destination, clampedLimit);
97
+
98
+ // Collect direct-match ids + categories for scoring
99
+ const directIds = new Set(matched.map(n => n.id));
100
+ const directCategories = new Set(
101
+ matched.map(n => String(n.category || '').toLowerCase()).filter(Boolean),
102
+ );
103
+
104
+ // Destination keyword tokens (strip any "word:" prefix)
105
+ const destTokens = tokenise(destination);
106
+
107
+ // Expand to 1-hop neighbours via getRelated (imports + importedBy); de-dupe by id
108
+ const candidateMap = new Map();
109
+ for (const n of matched) {
110
+ candidateMap.set(n.id, n);
111
+ }
112
+
113
+ for (const n of matched) {
114
+ const rel = getRelated(ctx, n.id);
115
+ if (!rel) continue;
116
+ for (const imp of rel.imports.concat(rel.importedBy)) {
117
+ if (!candidateMap.has(imp.id)) {
118
+ const full = ctx.byId.get(imp.id);
119
+ if (full) candidateMap.set(imp.id, full);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Score candidates
125
+ const caveats = [];
126
+ const lowCoverageCaveats = [];
127
+ const scored = [];
128
+
129
+ for (const [id, node] of candidateMap) {
130
+ let score = 0;
131
+ const reasons = [];
132
+
133
+ // Signal 1: destination match (+5)
134
+ if (directIds.has(id)) {
135
+ score += 5;
136
+ reasons.push('destination');
137
+ }
138
+
139
+ // Signal 2: import proximity (+2)
140
+ const rel = getRelated(ctx, id);
141
+ if (rel) {
142
+ const importsDirectMatch = rel.imports.some(m => directIds.has(m.id));
143
+ const importedByDirectMatch = rel.importedBy.some(m => directIds.has(m.id));
144
+ if (importsDirectMatch || importedByDirectMatch) {
145
+ score += 2;
146
+ reasons.push(importsDirectMatch ? 'imports destination' : 'imported by destination');
147
+ }
148
+ }
149
+
150
+ // Signal 3: same district (+1.5) — shares category with a direct match but is not itself a direct match
151
+ if (!directIds.has(id) && directCategories.size > 0) {
152
+ const cat = String(node.category || '').toLowerCase();
153
+ if (cat && directCategories.has(cat)) {
154
+ score += 1.5;
155
+ reasons.push('same district');
156
+ }
157
+ }
158
+
159
+ // Signal 4: keyword overlap (+1, capped once)
160
+ if (destTokens.size > 0) {
161
+ const candidateText = [node.title || '', ...(node.headings || [])].join(' ');
162
+ const candidateTokens = tokenise(candidateText);
163
+ const hasOverlap = [...destTokens].some(t => candidateTokens.has(t));
164
+ if (hasOverlap) {
165
+ score += 1;
166
+ reasons.push('name/heading match');
167
+ }
168
+ }
169
+
170
+ // Signal 5: recently changed (+1)
171
+ if (node.churn && typeof node.churn === 'object' && node.churn.c > 0) {
172
+ score += 1;
173
+ reasons.push('recently changed');
174
+ } else if (typeof node.churn === 'number' && node.churn > 0) {
175
+ score += 1;
176
+ reasons.push('recently changed');
177
+ }
178
+
179
+ // Signal 6: low coverage (+0.5 if overlays provided and coverage < 50)
180
+ if (overlays !== undefined && overlays !== null) {
181
+ const ref = safeRef(node);
182
+ if (ref) {
183
+ const overlay = overlays[ref];
184
+ if (overlay && typeof overlay.coverage === 'number') {
185
+ if (overlay.coverage < 50) {
186
+ score += 0.5;
187
+ lowCoverageCaveats.push(
188
+ 'low coverage: ' + ref + ' (' + Math.round(overlay.coverage) + '%) — extra review advised',
189
+ );
190
+ }
191
+ // coverage >= 50: no change, no penalty
192
+ }
193
+ }
194
+ }
195
+
196
+ scored.push({ id, node, score, reason: reasons.join('; ') || 'neighbourhood match' });
197
+ }
198
+
199
+ // Sort: score desc, then id asc
200
+ scored.sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
201
+
202
+ // Build caveats
203
+ // — churn caveat for any high-churn file in the top results
204
+ const topCandidates = scored.slice(0, clampedLimit + 5); // check a bit beyond route
205
+ for (const s of topCandidates) {
206
+ const churn = s.node.churn;
207
+ const hasChurn = (churn && typeof churn === 'object' && churn.c > 0) ||
208
+ (typeof churn === 'number' && churn > 0);
209
+ if (hasChurn) {
210
+ caveats.push('recently changed: ' + (s.node.provenance && s.node.provenance.ref || s.id) + ' has high churn — verify before acting');
211
+ break; // one caveat entry is sufficient
212
+ }
213
+ }
214
+
215
+ // — low-coverage caveats (one per underperforming file, from signal 6)
216
+ for (const c of lowCoverageCaveats) {
217
+ caveats.push(c);
218
+ }
219
+
220
+ // — coverage unknown caveat: only when overlays were NOT provided
221
+ if (overlays === undefined || overlays === null) {
222
+ const hasCoverageGap = scored.some(s => s.node.coverage === undefined || s.node.coverage === null);
223
+ if (hasCoverageGap) {
224
+ caveats.push('coverage unknown: one or more nodes lack coverage overlay data');
225
+ }
226
+ }
227
+
228
+ // — mandatory advisory caveat (always present)
229
+ caveats.push('advisory route — verify before acting; KnoSky does not read code meaning');
230
+
231
+ // — staleness caveat when source_rev is present
232
+ const source_rev = ctx.city.source_rev || null;
233
+ if (source_rev) {
234
+ caveats.push('route is based on rev ' + source_rev + ' and may be stale');
235
+ }
236
+
237
+ // Ledger-anchored freshness (SAT-444): read the monotone commit count from the city.
238
+ const ledger_seq = extractLedgerSeq(ctx.city);
239
+
240
+ // Slice into route + alternates
241
+ const routeEntries = scored.slice(0, clampedLimit);
242
+ const alternateEntries = scored.slice(clampedLimit, clampedLimit + 5);
243
+
244
+ function toEntry(s) {
245
+ const path = safeRef(s.node) || s.id;
246
+ return { path, id: s.id, reason: s.reason, score: s.score };
247
+ }
248
+
249
+ // Defense in depth: only isSafeRef() gates output (the previous `|| !path.startsWith('/')`
250
+ // clause was a bug — any path lacking a leading '/' but containing '..' would pass through
251
+ // it unfiltered; validateRouteDoc() catches that downstream today, but this filter should
252
+ // not have been able to let it through in the first place).
253
+ const route = routeEntries.map(toEntry).filter(e => isSafeRef(e.path));
254
+ const alternates = alternateEntries.map(toEntry).filter(e => isSafeRef(e.path));
255
+
256
+ // Confidence: direct -> ~0.95 ceiling; folder/district/edges/chain -> ~0.75; keyword -> ~0.6
257
+ let confidence = 0;
258
+ if (routeEntries.length > 0) {
259
+ const topScore = routeEntries[0].score;
260
+ if (matchStrength === 'direct') {
261
+ // direct hit scores at least 5; normalise to ~0.95 max
262
+ confidence = Math.min(0.95, topScore / 10);
263
+ } else if (matchStrength === 'folder' || matchStrength === 'district' ||
264
+ matchStrength === 'edges' || matchStrength === 'chain') {
265
+ confidence = Math.min(0.75, topScore / 10);
266
+ } else {
267
+ // keyword — lower ceiling
268
+ confidence = Math.min(0.6, topScore / 20);
269
+ }
270
+ confidence = Math.max(0, Math.min(1, confidence));
271
+ }
272
+
273
+ // Classify tests / docs from all candidates
274
+ const tests = [];
275
+ const docs = [];
276
+ for (const [, node] of candidateMap) {
277
+ const ref = safeRef(node);
278
+ if (!ref) continue;
279
+ if (isTest(node)) tests.push({ path: ref, id: node.id });
280
+ else if (isDoc(node)) docs.push({ path: ref, id: node.id });
281
+ }
282
+
283
+ const doc = makeRouteDoc({
284
+ destination,
285
+ route,
286
+ alternates,
287
+ caveats,
288
+ confidence,
289
+ source_rev,
290
+ ledger_seq,
291
+ });
292
+
293
+ // Attach tests + docs (extra fields beyond the base envelope — schema.validateRouteDoc does not reject them)
294
+ doc.tests = tests;
295
+ doc.docs = docs;
296
+
297
+ // Invariant: doc MUST pass validateRouteDoc
298
+ const validation = validateRouteDoc(doc);
299
+ if (!validation.ok) {
300
+ throw new Error('kcRoute produced an invalid route doc: ' + validation.errors.join('; '));
301
+ }
302
+
303
+ return doc;
304
+ }