knosky 0.4.1 → 0.6.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/CHANGELOG.md +35 -0
- package/README.md +25 -1
- package/SECURITY.md +14 -0
- package/action/post-comment.mjs +89 -0
- package/action.yml +62 -0
- package/bin/knosky.mjs +39 -0
- package/core/benchmark-results.mjs +225 -0
- package/core/bundle.mjs +178 -0
- package/core/churn.mjs +6 -2
- package/core/ci.mjs +268 -0
- package/core/comparison.mjs +189 -0
- package/core/config.mjs +189 -0
- package/core/constants.mjs +13 -0
- package/core/cross-repo.mjs +111 -0
- package/core/destination.mjs +161 -0
- package/core/escalate.mjs +68 -0
- package/core/freshness.mjs +194 -0
- package/core/fs-indexer.mjs +10 -1
- package/core/key-store.mjs +348 -0
- package/core/ledger.mjs +141 -0
- package/core/lod.mjs +155 -0
- package/core/multi-model-benchmark.mjs +405 -0
- package/core/onboarding.mjs +223 -0
- package/core/overlays.mjs +45 -0
- package/core/pr-comment.mjs +198 -0
- package/core/protocol-spec.mjs +460 -0
- package/core/route.mjs +304 -0
- package/core/schema.mjs +275 -0
- package/mcp/server.mjs +34 -0
- package/package.json +3 -1
- package/renderer/city.template.html +824 -715
package/core/route.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/core/schema.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// KnoSky protocol artifact schemas and validators (SAT-427 / KSV2-P2).
|
|
2
|
+
// Covers: route.json and intent-manifest.json envelope shapes.
|
|
3
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Protocol version constant
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** @type {string} */
|
|
10
|
+
export const PROTOCOL_VERSION = '1.0';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Internal helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return true if `str` is an absolute path (Unix or Windows).
|
|
18
|
+
* @param {string} str
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
function isAbsolutePath(str) {
|
|
22
|
+
if (str.startsWith('/')) return true;
|
|
23
|
+
// Windows: C:\ or C:/
|
|
24
|
+
if (/^[A-Za-z]:[\\\/]/.test(str)) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return true if `str` contains a `..` path segment.
|
|
30
|
+
* Splits on both `/` and `\`.
|
|
31
|
+
* @param {string} str
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function hasDotDotSegment(str) {
|
|
35
|
+
return str.split(/[/\\]/).some(seg => seg === '..');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the path string from a route/manifest entry.
|
|
40
|
+
* An entry is either a plain string or an object with a `path` field.
|
|
41
|
+
* Returns null if the entry carries no recognisable path.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} entry
|
|
44
|
+
* @returns {string|null}
|
|
45
|
+
*/
|
|
46
|
+
function extractPathString(entry) {
|
|
47
|
+
if (typeof entry === 'string') return entry;
|
|
48
|
+
if (entry !== null && typeof entry === 'object' && typeof entry.path === 'string') {
|
|
49
|
+
return entry.path;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate the absolute-path invariant over an array of entries.
|
|
56
|
+
* Pushes error messages into `errors` for each violation found.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown[]} entries Array of string or { path } objects.
|
|
59
|
+
* @param {string} fieldName Label for error messages.
|
|
60
|
+
* @param {string[]} errors Accumulator array.
|
|
61
|
+
*/
|
|
62
|
+
function checkAbsolutePaths(entries, fieldName, errors) {
|
|
63
|
+
for (let i = 0; i < entries.length; i++) {
|
|
64
|
+
const p = extractPathString(entries[i]);
|
|
65
|
+
if (p === null) continue; // non-path entry — skip
|
|
66
|
+
if (isAbsolutePath(p)) {
|
|
67
|
+
errors.push(`${fieldName}[${i}] must not be an absolute path: ${JSON.stringify(p)}`);
|
|
68
|
+
}
|
|
69
|
+
if (hasDotDotSegment(p)) {
|
|
70
|
+
errors.push(`${fieldName}[${i}] must not contain a ".." path segment: ${JSON.stringify(p)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// makeRouteDoc
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Construct a KnoSky `route` artifact envelope.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} opts
|
|
83
|
+
* @param {string} opts.destination Navigation target.
|
|
84
|
+
* @param {unknown[]} [opts.route] Ordered waypoints.
|
|
85
|
+
* @param {unknown[]} [opts.alternates] Alternate routes.
|
|
86
|
+
* @param {string[]} [opts.caveats] Advisory notes.
|
|
87
|
+
* @param {number} [opts.confidence] Confidence score [0..1].
|
|
88
|
+
* @param {string|null} [opts.source_rev] VCS revision that produced this doc.
|
|
89
|
+
* @param {number|null} [opts.ledger_seq] Ledger sequence number (SAT-444) —
|
|
90
|
+
* monotone commit count from the city that produced this route doc.
|
|
91
|
+
* null means the city predates ledger-anchored freshness.
|
|
92
|
+
* @returns {object}
|
|
93
|
+
*/
|
|
94
|
+
export function makeRouteDoc({
|
|
95
|
+
destination,
|
|
96
|
+
route = [],
|
|
97
|
+
alternates = [],
|
|
98
|
+
caveats = [],
|
|
99
|
+
confidence = 0,
|
|
100
|
+
source_rev = null,
|
|
101
|
+
ledger_seq = null,
|
|
102
|
+
} = {}) {
|
|
103
|
+
return {
|
|
104
|
+
knosky_protocol: PROTOCOL_VERSION,
|
|
105
|
+
artifact_type: 'route',
|
|
106
|
+
advisory: true,
|
|
107
|
+
generated_at: new Date().toISOString(),
|
|
108
|
+
source_rev,
|
|
109
|
+
ledger_seq,
|
|
110
|
+
destination,
|
|
111
|
+
route,
|
|
112
|
+
alternates,
|
|
113
|
+
caveats,
|
|
114
|
+
confidence,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// validateRouteDoc
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate a KnoSky `route` document.
|
|
124
|
+
* Collects every violation; `ok` is true only when `errors` is empty.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} doc
|
|
127
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
128
|
+
*/
|
|
129
|
+
export function validateRouteDoc(doc) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
|
|
132
|
+
if (doc.knosky_protocol !== '1.0') {
|
|
133
|
+
errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(doc.knosky_protocol)}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (doc.artifact_type !== 'route') {
|
|
137
|
+
errors.push(`artifact_type must be "route", got: ${JSON.stringify(doc.artifact_type)}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (doc.advisory !== true) {
|
|
141
|
+
errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const field of ['route', 'alternates', 'caveats']) {
|
|
145
|
+
if (!Array.isArray(doc[field])) {
|
|
146
|
+
errors.push(`${field} must be an array, got: ${JSON.stringify(doc[field])}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Number.isFinite rejects NaN and ±Infinity; the range check then enforces [0, 1].
|
|
151
|
+
if (!Number.isFinite(doc.confidence) || doc.confidence < 0 || doc.confidence > 1) {
|
|
152
|
+
errors.push(`confidence must be a number in [0, 1], got: ${JSON.stringify(doc.confidence)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ledger_seq: must be null or a non-negative integer (SAT-444)
|
|
156
|
+
if (doc.ledger_seq !== undefined && doc.ledger_seq !== null) {
|
|
157
|
+
if (typeof doc.ledger_seq !== 'number' || !Number.isInteger(doc.ledger_seq) || doc.ledger_seq < 0) {
|
|
158
|
+
errors.push(`ledger_seq must be null or a non-negative integer, got: ${JSON.stringify(doc.ledger_seq)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Absolute-path invariant applies to route[] and alternates[]
|
|
163
|
+
if (Array.isArray(doc.route)) {
|
|
164
|
+
checkAbsolutePaths(doc.route, 'route', errors);
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(doc.alternates)) {
|
|
167
|
+
checkAbsolutePaths(doc.alternates, 'alternates', errors);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { ok: errors.length === 0, errors };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// makeIntentManifest
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Construct a KnoSky `intent-manifest` artifact envelope.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} opts
|
|
181
|
+
* @param {Array<{ path: string, sha256: string }>} [opts.paths] Files covered.
|
|
182
|
+
* @param {unknown[]} [opts.edges] Dependency edges.
|
|
183
|
+
* @param {string|null} [opts.expiry] ISO-8601 expiry timestamp or null.
|
|
184
|
+
* @param {object} opts.secret_scan REQUIRED secret-scan result object.
|
|
185
|
+
* @param {number|null} [opts.ledger_seq] Ledger sequence number (SAT-444) —
|
|
186
|
+
* monotone commit count from the city that produced this manifest.
|
|
187
|
+
* null means the city predates ledger-anchored freshness.
|
|
188
|
+
* @returns {object}
|
|
189
|
+
*/
|
|
190
|
+
export function makeIntentManifest({
|
|
191
|
+
paths = [],
|
|
192
|
+
edges = [],
|
|
193
|
+
expiry = null,
|
|
194
|
+
secret_scan,
|
|
195
|
+
ledger_seq = null,
|
|
196
|
+
} = {}) {
|
|
197
|
+
return {
|
|
198
|
+
knosky_protocol: PROTOCOL_VERSION,
|
|
199
|
+
artifact_type: 'intent-manifest',
|
|
200
|
+
advisory: true,
|
|
201
|
+
generated_at: new Date().toISOString(),
|
|
202
|
+
paths,
|
|
203
|
+
edges,
|
|
204
|
+
expiry,
|
|
205
|
+
ledger_seq,
|
|
206
|
+
secret_scan,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// validateIntentManifest
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validate a KnoSky `intent-manifest` document.
|
|
216
|
+
* Collects every violation; `ok` is true only when `errors` is empty.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} doc
|
|
219
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
220
|
+
*/
|
|
221
|
+
export function validateIntentManifest(doc) {
|
|
222
|
+
const errors = [];
|
|
223
|
+
|
|
224
|
+
if (doc.knosky_protocol !== '1.0') {
|
|
225
|
+
errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(doc.knosky_protocol)}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (doc.artifact_type !== 'intent-manifest') {
|
|
229
|
+
errors.push(`artifact_type must be "intent-manifest", got: ${JSON.stringify(doc.artifact_type)}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (doc.advisory !== true) {
|
|
233
|
+
errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// paths must be an array of { path: string (non-empty), sha256: string }
|
|
237
|
+
if (!Array.isArray(doc.paths)) {
|
|
238
|
+
errors.push(`paths must be an array, got: ${JSON.stringify(doc.paths)}`);
|
|
239
|
+
} else {
|
|
240
|
+
for (let i = 0; i < doc.paths.length; i++) {
|
|
241
|
+
const entry = doc.paths[i];
|
|
242
|
+
if (!entry || typeof entry !== 'object') {
|
|
243
|
+
errors.push(`paths[${i}] must be an object`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
247
|
+
errors.push(`paths[${i}].path must be a non-empty string, got: ${JSON.stringify(entry.path)}`);
|
|
248
|
+
}
|
|
249
|
+
if (typeof entry.sha256 !== 'string') {
|
|
250
|
+
errors.push(`paths[${i}].sha256 must be a string, got: ${JSON.stringify(entry.sha256)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Absolute-path invariant on paths[].path
|
|
255
|
+
checkAbsolutePaths(doc.paths, 'paths', errors);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ledger_seq: must be null or a non-negative integer (SAT-444)
|
|
259
|
+
if (doc.ledger_seq !== undefined && doc.ledger_seq !== null) {
|
|
260
|
+
if (typeof doc.ledger_seq !== 'number' || !Number.isInteger(doc.ledger_seq) || doc.ledger_seq < 0) {
|
|
261
|
+
errors.push(`ledger_seq must be null or a non-negative integer, got: ${JSON.stringify(doc.ledger_seq)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// secret_scan must be present and have a valid status
|
|
266
|
+
if (!doc.secret_scan || typeof doc.secret_scan !== 'object') {
|
|
267
|
+
errors.push('secret_scan is required and must be an object');
|
|
268
|
+
} else if (doc.secret_scan.status !== 'clean' && doc.secret_scan.status !== 'blocked') {
|
|
269
|
+
errors.push(
|
|
270
|
+
`secret_scan.status must be "clean" or "blocked", got: ${JSON.stringify(doc.secret_scan.status)}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { ok: errors.length === 0, errors };
|
|
275
|
+
}
|
package/mcp/server.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { load, search, getNode, listCategories, getProvenance, getRelated } from "../core/retrieve.mjs";
|
|
7
|
+
import { kcRoute } from "../core/route.mjs";
|
|
7
8
|
|
|
8
9
|
const CITY = process.env.KC_CITY || process.argv[2];
|
|
9
10
|
if (!CITY) { console.error("Knowledge City MCP: set KC_CITY (or pass a path) to a city-data.v2.json"); process.exit(1); }
|
|
@@ -70,5 +71,38 @@ server.registerTool("kc_related", {
|
|
|
70
71
|
return { content: [{ type: "text", text: lines.join(NL) }], structuredContent: r };
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
server.registerTool("kc_route", {
|
|
75
|
+
title: "Route to a destination (agent GPS)",
|
|
76
|
+
description: "ADVISORY, metadata-only route through the repo towards a destination. Returns where-to-look-first (ranked waypoints), alternates, related tests, related docs, caveats, and a confidence score. Does NOT read or analyse code — structural navigation only, local, no network. Verify findings before acting.",
|
|
77
|
+
inputSchema: {
|
|
78
|
+
destination: z.string().max(400).describe("navigation target — e.g. file:src/auth.js, folder:src/auth, or keywords"),
|
|
79
|
+
limit: z.number().int().min(1).max(20).optional().describe("max route entries (default 8, max 20)"),
|
|
80
|
+
},
|
|
81
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
82
|
+
}, async ({ destination, limit }) => {
|
|
83
|
+
const doc = kcRoute(ctx, String(destination).slice(0, 400), { limit: limit ? Math.min(Math.max(1, limit), 20) : 8 });
|
|
84
|
+
const lines = [
|
|
85
|
+
`Route to: ${doc.destination} (confidence ${(doc.confidence * 100).toFixed(0)}%)`,
|
|
86
|
+
'',
|
|
87
|
+
'Route:',
|
|
88
|
+
...(doc.route.map((e, i) => ` ${i + 1}. ${e.path} [${e.reason}]`)),
|
|
89
|
+
];
|
|
90
|
+
if (doc.alternates && doc.alternates.length > 0) {
|
|
91
|
+
lines.push('', 'Alternates:');
|
|
92
|
+
for (const e of doc.alternates) lines.push(` - ${e.path} [${e.reason}]`);
|
|
93
|
+
}
|
|
94
|
+
if (doc.tests && doc.tests.length > 0) {
|
|
95
|
+
lines.push('', 'Tests: ' + doc.tests.map(e => e.path).join(', '));
|
|
96
|
+
}
|
|
97
|
+
if (doc.docs && doc.docs.length > 0) {
|
|
98
|
+
lines.push('', 'Docs: ' + doc.docs.map(e => e.path).join(', '));
|
|
99
|
+
}
|
|
100
|
+
if (doc.caveats && doc.caveats.length > 0) {
|
|
101
|
+
lines.push('', 'Caveats:');
|
|
102
|
+
for (const c of doc.caveats) lines.push(` ⚠ ${c}`);
|
|
103
|
+
}
|
|
104
|
+
return { content: [{ type: "text", text: lines.join('\n') }], structuredContent: doc };
|
|
105
|
+
});
|
|
106
|
+
|
|
73
107
|
await server.connect(new StdioServerTransport());
|
|
74
108
|
console.error(`knowledge-city MCP ready — ${ctx.city.node_count} nodes, ${(ctx.city.categories||[]).length} categories`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knosky",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Turn any repo or docs folder into an explorable city, plus a local MCP that grounds your AI in your own source with citations. Local-first, free, $0 tokens.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"bin",
|
|
11
11
|
"core",
|
|
12
12
|
"renderer",
|
|
13
|
+
"action",
|
|
14
|
+
"action.yml",
|
|
13
15
|
"mcp/server.mjs",
|
|
14
16
|
"README.md",
|
|
15
17
|
"LICENSE.md",
|