knosky 0.4.1 → 0.5.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.
@@ -0,0 +1,189 @@
1
+ // KnoSky protocol config schema, loader, and validator (SAT-426 / KSV2-P1).
2
+ // Pure Node stdlib, ESM — no third-party dependencies.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // DEFAULTS — the canonical safe config for every knosky project.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** @type {Readonly<KnoskyConfig>} */
11
+ export const DEFAULTS = Object.freeze({
12
+ knosky_protocol: '1.0',
13
+ telemetry: false,
14
+ absolute_paths: false,
15
+ fail_on_secret: true,
16
+ allow_excerpts: false,
17
+ max_excerpt_chars: 0,
18
+ categories: [],
19
+ ignore: [],
20
+ });
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // parseConfigYaml — minimal YAML-subset parser, no deps.
24
+ // Supports: `key: value` lines, booleans, integers, bare/quoted strings,
25
+ // block lists (`- item` lines following a `key:` line with no inline value).
26
+ // Ignores blank lines and # comments.
27
+ // Throws on any line it cannot interpret for a known key.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Parse a minimal YAML subset used by `.knosky/config.yml`.
32
+ *
33
+ * @param {string} text Raw file contents.
34
+ * @returns {Record<string, unknown>} Parsed key/value map.
35
+ */
36
+ export function parseConfigYaml(text) {
37
+ const result = {};
38
+ const lines = text.split(/\r?\n/);
39
+ let i = 0;
40
+
41
+ while (i < lines.length) {
42
+ const raw = lines[i];
43
+ // Strip inline comment and trim
44
+ const line = raw.replace(/#.*$/, '').trimEnd();
45
+ i++;
46
+
47
+ if (!line.trim()) continue; // blank / comment-only
48
+
49
+ // Must be a `key: [value]` line at the top level (no leading spaces)
50
+ const kvMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)/);
51
+ if (!kvMatch) {
52
+ throw new Error(`parseConfigYaml: unexpected line: ${JSON.stringify(raw)}`);
53
+ }
54
+ const key = kvMatch[1];
55
+ const rest = kvMatch[2].trim();
56
+
57
+ if (rest === '') {
58
+ // Possibly a block list — collect ` - item` lines that follow
59
+ const items = [];
60
+ while (i < lines.length) {
61
+ const next = lines[i].replace(/#.*$/, '');
62
+ if (/^\s*-\s/.test(next)) {
63
+ items.push(next.replace(/^\s*-\s*/, '').trim());
64
+ i++;
65
+ } else {
66
+ break;
67
+ }
68
+ }
69
+ result[key] = items;
70
+ } else {
71
+ result[key] = parseScalar(rest, key);
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Parse a scalar YAML value: boolean, integer, or string (bare or quoted).
80
+ *
81
+ * @param {string} raw The raw value string (already trimmed).
82
+ * @param {string} key The key name, used for error messages.
83
+ * @returns {boolean|number|string}
84
+ */
85
+ function parseScalar(raw, key) {
86
+ if (raw === 'true') return true;
87
+ if (raw === 'false') return false;
88
+
89
+ // Integer
90
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
91
+
92
+ // Double-quoted string
93
+ if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
94
+
95
+ // Single-quoted string
96
+ if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
97
+
98
+ // Bare string — must not contain characters that signal unhandled YAML
99
+ if (/^[A-Za-z0-9._\-/ ]+$/.test(raw)) return raw;
100
+
101
+ throw new Error(`parseConfigYaml: cannot parse value for key "${key}": ${JSON.stringify(raw)}`);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // loadConfig — read + parse `.knosky/config.yml`, merge over DEFAULTS.
106
+ // Fail-closed: throws on parse error; returns DEFAULTS copy if file absent.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Load `.knosky/config.yml` from `root` and merge it over {@link DEFAULTS}.
111
+ * Returns a plain object (not frozen). Throws if the file exists but is
112
+ * malformed.
113
+ *
114
+ * @param {string} root Absolute (or relative) project root.
115
+ * @returns {Record<string, unknown>} Merged config object.
116
+ */
117
+ export function loadConfig(root) {
118
+ const cfgPath = path.join(root, '.knosky', 'config.yml');
119
+
120
+ let text;
121
+ try {
122
+ text = fs.readFileSync(cfgPath, 'utf8');
123
+ } catch (err) {
124
+ if (err.code === 'ENOENT') {
125
+ // File absent — return a safe copy of defaults
126
+ return { ...DEFAULTS, categories: [], ignore: [] };
127
+ }
128
+ throw err; // permission error, etc — still fail-closed
129
+ }
130
+
131
+ // File exists: parse (throws on malformed — never silently fall back)
132
+ const parsed = parseConfigYaml(text);
133
+
134
+ // Deep-merge: parsed wins per-key; arrays are replaced, not concatenated.
135
+ return { ...DEFAULTS, categories: [], ignore: [], ...parsed };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // validateConfig — collect all constraint violations, return { ok, errors }.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ const BOOLEAN_KEYS = ['telemetry', 'absolute_paths', 'fail_on_secret', 'allow_excerpts'];
143
+
144
+ /**
145
+ * Validate a config object against the KnoSky protocol schema.
146
+ * Collects every violation; `ok` is true only when `errors` is empty.
147
+ *
148
+ * @param {Record<string, unknown>} cfg Config object (e.g. from {@link loadConfig}).
149
+ * @returns {{ ok: boolean, errors: string[] }}
150
+ */
151
+ export function validateConfig(cfg) {
152
+ const errors = [];
153
+ const knownKeys = new Set(Object.keys(DEFAULTS));
154
+
155
+ // knosky_protocol must be the string "1.0"
156
+ if (cfg.knosky_protocol !== '1.0') {
157
+ errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(cfg.knosky_protocol)}`);
158
+ }
159
+
160
+ // Boolean fields
161
+ for (const k of BOOLEAN_KEYS) {
162
+ if (typeof cfg[k] !== 'boolean') {
163
+ errors.push(`${k} must be a boolean, got: ${JSON.stringify(cfg[k])}`);
164
+ }
165
+ }
166
+
167
+ // max_excerpt_chars must be integer >= 0
168
+ const mec = cfg.max_excerpt_chars;
169
+ if (!Number.isInteger(mec) || mec < 0) {
170
+ errors.push(`max_excerpt_chars must be an integer >= 0, got: ${JSON.stringify(mec)}`);
171
+ }
172
+
173
+ // categories and ignore must be arrays of strings
174
+ for (const k of ['categories', 'ignore']) {
175
+ const val = cfg[k];
176
+ if (!Array.isArray(val) || val.some(v => typeof v !== 'string')) {
177
+ errors.push(`${k} must be an array of strings, got: ${JSON.stringify(val)}`);
178
+ }
179
+ }
180
+
181
+ // Unknown keys
182
+ for (const k of Object.keys(cfg)) {
183
+ if (!knownKeys.has(k)) {
184
+ errors.push(`unknown config key: ${k}`);
185
+ }
186
+ }
187
+
188
+ return { ok: errors.length === 0, errors };
189
+ }
@@ -0,0 +1,161 @@
1
+ // KnoSky destination parser (KSV2-R2) — extended prefix set for kc_route.
2
+ // Pure Node stdlib + internal imports only. ESM. No new deps.
3
+ import { search } from './retrieve.mjs';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Internal helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Resolve a single node by id "fs:<rest>" OR by provenance.ref === <rest>.
11
+ * Also accepts a fully-qualified id (already has "fs:" prefix).
12
+ * @param {object} ctx
13
+ * @param {string} idOrRef
14
+ * @returns {object|null}
15
+ */
16
+ function resolveOne(ctx, idOrRef) {
17
+ // Try constructing the canonical node id
18
+ const byConstructed = ctx.byId.get('fs:' + idOrRef);
19
+ if (byConstructed) return byConstructed;
20
+ // Try the raw string as-is (already fully qualified, e.g. "fs:src/a.js")
21
+ const byRaw = ctx.byId.get(idOrRef);
22
+ if (byRaw) return byRaw;
23
+ // Fallback: linear scan for provenance.ref match
24
+ return ctx.city.nodes.find(n => n.provenance && n.provenance.ref === idOrRef) || null;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // parseDestination — main export
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Resolve the destination string to an initial set of matching nodes.
33
+ * Returns { matched: Node[], matchStrength: 'direct'|'folder'|'district'|'edges'|'chain'|'keyword' }
34
+ *
35
+ * Supported prefixes (ALL metadata-only, no filesystem access):
36
+ * file:<idOrRef> — node by id "fs:<rest>" OR by provenance.ref === rest; 'direct'
37
+ * folder:<prefix> — nodes whose provenance.ref starts with prefix; 'folder'
38
+ * district:<name> — nodes whose category equals name (case-insensitive) or
39
+ * whose category label matches; 'district'
40
+ * importsOf:<idOrRef> — out-edge targets of that node (node.links through ctx.byId); 'edges'
41
+ * depChainTo:<idOrRef> — nodes that TRANSITIVELY import/reach the target via
42
+ * reverse-edge BFS; bounded (max depth 6, max 500 visited,
43
+ * visited-set prevents cycles); 'chain'
44
+ * <anything else> — keyword fallback via search(); 'keyword'
45
+ *
46
+ * @param {object} ctx — retrieve context {city:{nodes,categories}, byId:Map}
47
+ * @param {string} destination
48
+ * @param {number} limit — passed through to keyword search
49
+ * @returns {{ matched: object[], matchStrength: string }}
50
+ */
51
+ export function parseDestination(ctx, destination, limit) {
52
+ const dest = String(destination || '').trim();
53
+
54
+ // ------------------------------------------------------------------
55
+ // file:<idOrRef>
56
+ // ------------------------------------------------------------------
57
+ if (dest.startsWith('file:')) {
58
+ const rest = dest.slice('file:'.length);
59
+ const node = resolveOne(ctx, rest);
60
+ return { matched: node ? [node] : [], matchStrength: 'direct' };
61
+ }
62
+
63
+ // ------------------------------------------------------------------
64
+ // folder:<prefix>
65
+ // ------------------------------------------------------------------
66
+ if (dest.startsWith('folder:')) {
67
+ const rest = dest.slice('folder:'.length);
68
+ // Normalise: ensure trailing slash for prefix match (unless rest already ends with /)
69
+ const prefix = rest.endsWith('/') ? rest : rest + '/';
70
+ const matched = ctx.city.nodes.filter(n => {
71
+ const ref = n.provenance && n.provenance.ref;
72
+ return typeof ref === 'string' && (ref.startsWith(prefix) || ref === rest);
73
+ });
74
+ return { matched, matchStrength: 'folder' };
75
+ }
76
+
77
+ // ------------------------------------------------------------------
78
+ // district:<name>
79
+ // ------------------------------------------------------------------
80
+ if (dest.startsWith('district:')) {
81
+ const name = dest.slice('district:'.length).toLowerCase();
82
+ // Build label->id map from city.categories
83
+ const labelToId = new Map();
84
+ for (const cat of (ctx.city.categories || [])) {
85
+ if (cat.label) labelToId.set(String(cat.label).toLowerCase(), String(cat.id).toLowerCase());
86
+ }
87
+ const resolvedCatId = labelToId.get(name) || null;
88
+ const matched = ctx.city.nodes.filter(n => {
89
+ const cat = String(n.category || '').toLowerCase();
90
+ return cat === name || (resolvedCatId !== null && cat === resolvedCatId);
91
+ });
92
+ return { matched, matchStrength: 'district' };
93
+ }
94
+
95
+ // ------------------------------------------------------------------
96
+ // importsOf:<idOrRef>
97
+ // ------------------------------------------------------------------
98
+ if (dest.startsWith('importsOf:')) {
99
+ const rest = dest.slice('importsOf:'.length);
100
+ const node = resolveOne(ctx, rest);
101
+ if (!node) return { matched: [], matchStrength: 'edges' };
102
+ const matched = (node.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean);
103
+ return { matched, matchStrength: 'edges' };
104
+ }
105
+
106
+ // ------------------------------------------------------------------
107
+ // depChainTo:<idOrRef>
108
+ // Bounded reverse-edge BFS: finds all nodes that transitively import the target.
109
+ // Safety invariants:
110
+ // - visited Set prevents revisiting nodes (handles cycles A→B→A)
111
+ // - max depth 6 per BFS level
112
+ // - hard cap of 500 nodes visited (includes the target seed)
113
+ // ------------------------------------------------------------------
114
+ if (dest.startsWith('depChainTo:')) {
115
+ const rest = dest.slice('depChainTo:'.length);
116
+ const target = resolveOne(ctx, rest);
117
+ if (!target) return { matched: [], matchStrength: 'chain' };
118
+ const targetId = target.id;
119
+
120
+ // Build reverse-edge index: nodeId -> Set<callerId>
121
+ // (which nodes have this id in their links)
122
+ const importedBy = new Map();
123
+ for (const n of ctx.city.nodes) {
124
+ for (const link of (n.links || [])) {
125
+ if (!importedBy.has(link)) importedBy.set(link, new Set());
126
+ importedBy.get(link).add(n.id);
127
+ }
128
+ }
129
+
130
+ // BFS — each queue entry carries { id, depth }
131
+ const visited = new Set();
132
+ visited.add(targetId);
133
+ const queue = [{ id: targetId, depth: 0 }];
134
+
135
+ while (queue.length > 0 && visited.size < 500) {
136
+ const { id, depth } = queue.shift();
137
+ if (depth >= 6) continue;
138
+ const parents = importedBy.get(id);
139
+ if (!parents) continue;
140
+ for (const pid of parents) {
141
+ if (!visited.has(pid)) {
142
+ visited.add(pid);
143
+ queue.push({ id: pid, depth: depth + 1 });
144
+ if (visited.size >= 500) break;
145
+ }
146
+ }
147
+ }
148
+
149
+ // Return callers of target, not the target itself
150
+ visited.delete(targetId);
151
+ const matched = [...visited].map(id => ctx.byId.get(id)).filter(Boolean);
152
+ return { matched, matchStrength: 'chain' };
153
+ }
154
+
155
+ // ------------------------------------------------------------------
156
+ // no recognized prefix — keyword fallback
157
+ // ------------------------------------------------------------------
158
+ const hits = search(ctx, dest, { limit });
159
+ const matched = hits.map(h => ctx.byId.get(h.id)).filter(Boolean);
160
+ return { matched, matchStrength: 'keyword' };
161
+ }
@@ -11,7 +11,10 @@ import { gitChurn } from './churn.mjs';
11
11
  const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1]] : []).filter(Boolean));
12
12
  const ROOT = args.root && path.resolve(args.root);
13
13
  const OUT = args.out || null;
14
- const MAX = parseInt(args.max || '6000', 10);
14
+ // Validate --max: a non-numeric/zero/negative value must NOT silently disable the
15
+ // cap (NaN comparisons are always false, so `scanned > MAX` would never trip) —
16
+ // that's a resource-exhaustion opening on a hostile/malformed CI invocation.
17
+ const MAX = (() => { const n = parseInt(args.max || '6000', 10); return (Number.isFinite(n) && n > 0) ? n : 6000; })();
15
18
  const SHARE_SAFE = process.argv.includes('--share-safe');
16
19
  const INCLUDE_ABS = process.argv.includes('--include-absolute-root');
17
20
  const ALLOW_LEAKS = process.argv.includes('--allow-leaks');
@@ -0,0 +1,45 @@
1
+ // Operational-overlay metadata ingest (D-155): file-level ONLY.
2
+ // Reads existing local test/coverage artifacts under `root` — never executes tests.
3
+ // Returns a map { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }.
4
+ // Key: relpath is always forward-slash, relative to root, no leading './'.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ // Parse Istanbul coverage-summary.json. Returns partial overlay map.
9
+ function readIstanbul(root) {
10
+ const fp = path.join(root, 'coverage', 'coverage-summary.json');
11
+ let raw;
12
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { return {}; }
13
+ let json;
14
+ try { json = JSON.parse(raw); } catch { return {}; }
15
+ const out = {};
16
+ for (const [key, val] of Object.entries(json)) {
17
+ if (key === 'total') continue; // skip the aggregate row
18
+ if (!val || typeof val !== 'object') continue;
19
+ const pct = val.lines?.pct;
20
+ if (typeof pct !== 'number') continue;
21
+ const rel = key.replace(/\\/g, '/').replace(/^\.\//, '');
22
+ out[rel] = { coverage: Math.min(100, Math.max(0, pct)) };
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // Merge source into dest (dest wins on conflict).
28
+ function merge(dest, src) {
29
+ for (const [k, v] of Object.entries(src)) {
30
+ dest[k] = dest[k] ? { ...v, ...dest[k] } : v;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Read all recognised local test/coverage artifacts under `root` and return
36
+ * a file-level overlay map. Never executes tests or modifies files.
37
+ *
38
+ * @param {string} root Absolute or relative path to the project root.
39
+ * @returns {{ [relpath: string]: { coverage?: number, test?: 'pass'|'fail' } }}
40
+ */
41
+ export function readOverlays(root) {
42
+ const out = {};
43
+ merge(out, readIstanbul(root));
44
+ return out;
45
+ }
@@ -0,0 +1,198 @@
1
+ // KnoSky PR-comment renderer (KSV2-CI2) — claims-disciplined public Markdown.
2
+ // Consumes CI1 artifacts (routeJson + safetyJson) and renders the polished
3
+ // PR-GPS comment that knosky ci posts on a pull request.
4
+ //
5
+ // CLAIMS DISCIPLINE — BINDING (D-162 §8):
6
+ // USE: "advisory PR-GPS report" / "advisory route you can ignore"
7
+ // USE: "route cache / freshness"
8
+ // USE: "reads metadata only / never uploads your code"
9
+ // USE: "network-silent (verify with --verify-airgap)"
10
+ // USE: "open protocol / reference implementation"
11
+ // NEVER: CI gate / blocks the build / gates the build
12
+ // NEVER: learns / learns your repo
13
+ // NEVER: understands your code
14
+ // NEVER: zero data risk
15
+ // NEVER: official standard
16
+ // NEVER: air-gap guarantee
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Internal helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Format a confidence fraction [0..1] as a percent string, or 'n/a'.
24
+ * @param {unknown} val
25
+ * @returns {string}
26
+ */
27
+ function fmtConfidence(val) {
28
+ if (typeof val === 'number' && isFinite(val)) {
29
+ return Math.round(val * 100) + '%';
30
+ }
31
+ return 'n/a';
32
+ }
33
+
34
+ /**
35
+ * Extract path + reason from a waypoint entry (string | { path, reason }).
36
+ * @param {unknown} wp
37
+ * @returns {{ path: string, reason: string }}
38
+ */
39
+ function parsWaypoint(wp) {
40
+ if (typeof wp === 'string') return { path: wp, reason: '' };
41
+ const path = (wp && typeof wp.path === 'string') ? wp.path : '';
42
+ const reason = (wp && typeof wp.reason === 'string') ? wp.reason : '';
43
+ return { path, reason };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // renderPrComment — main export
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Render the KnoSky PR-GPS comment as a Markdown string.
52
+ *
53
+ * This is a DRAFT — the final public wording is Paul's critical gate.
54
+ *
55
+ * @param {object} opts
56
+ * @param {object} opts.routeJson CI1 route artifact ({ routes, base, head, advisory, … })
57
+ * @param {object} opts.safetyJson CI1 safety artifact ({ secrets_found, absolute_paths, … })
58
+ * @returns {string} Markdown comment body.
59
+ */
60
+ export function renderPrComment({ routeJson = {}, safetyJson = {} } = {}) {
61
+ const lines = [];
62
+
63
+ // -------------------------------------------------------------------------
64
+ // Header
65
+ // -------------------------------------------------------------------------
66
+ lines.push('## 🧭 KnoSky PR-GPS');
67
+ lines.push('');
68
+ lines.push(
69
+ '> **DRAFT — advisory PR-GPS report.** ' +
70
+ 'This is an advisory route you can ignore — it never gates or enforces a build outcome.',
71
+ );
72
+ lines.push('');
73
+
74
+ // -------------------------------------------------------------------------
75
+ // Per-file route sections
76
+ // -------------------------------------------------------------------------
77
+ const routes = Array.isArray(routeJson.routes) ? routeJson.routes : [];
78
+
79
+ if (routes.length === 0) {
80
+ lines.push('_No changed files detected in this PR — nothing to navigate._');
81
+ lines.push('');
82
+ } else {
83
+ for (const { file, route: routeDoc } of routes) {
84
+ lines.push(`### \`${file}\``);
85
+ lines.push('');
86
+
87
+ // Top ~5 waypoints
88
+ const waypoints = (Array.isArray(routeDoc && routeDoc.route) ? routeDoc.route : []).slice(0, 5);
89
+ if (waypoints.length === 0) {
90
+ lines.push('_No route waypoints found in the route cache for this file._');
91
+ } else {
92
+ lines.push('**Advisory route waypoints** _(top ' + waypoints.length + ', from route cache):_');
93
+ lines.push('');
94
+ for (const wp of waypoints) {
95
+ const { path, reason } = parsWaypoint(wp);
96
+ lines.push(`- \`${path}\`${reason ? ' — ' + reason : ''}`);
97
+ }
98
+ }
99
+ lines.push('');
100
+
101
+ // Confidence
102
+ const conf = fmtConfidence(routeDoc && routeDoc.confidence);
103
+ lines.push(`**Confidence:** ${conf}`);
104
+ lines.push('');
105
+
106
+ // Freshness / coverage caveats
107
+ const caveats = Array.isArray(routeDoc && routeDoc.caveats) ? routeDoc.caveats : [];
108
+ const freshnessOrCoverage = caveats.filter(c =>
109
+ /recently changed|stale|freshness|coverage/i.test(c),
110
+ );
111
+ if (freshnessOrCoverage.length > 0) {
112
+ lines.push('**Route freshness / coverage caveats:**');
113
+ lines.push('');
114
+ for (const c of freshnessOrCoverage) lines.push(`- ${c}`);
115
+ lines.push('');
116
+ }
117
+
118
+ // Related tests
119
+ const tests = Array.isArray(routeDoc && routeDoc.tests) ? routeDoc.tests : [];
120
+ if (tests.length > 0) {
121
+ lines.push('**Related tests:**');
122
+ lines.push('');
123
+ for (const t of tests) {
124
+ const p = typeof t === 'string' ? t : (t && t.path);
125
+ if (p) lines.push(`- \`${p}\``);
126
+ }
127
+ lines.push('');
128
+ }
129
+
130
+ // Related docs
131
+ const docs = Array.isArray(routeDoc && routeDoc.docs) ? routeDoc.docs : [];
132
+ if (docs.length > 0) {
133
+ lines.push('**Related docs:**');
134
+ lines.push('');
135
+ for (const d of docs) {
136
+ const p = typeof d === 'string' ? d : (d && d.path);
137
+ if (p) lines.push(`- \`${p}\``);
138
+ }
139
+ lines.push('');
140
+ }
141
+ }
142
+ }
143
+
144
+ // -------------------------------------------------------------------------
145
+ // Suggested reviewer / agent prompt block
146
+ // -------------------------------------------------------------------------
147
+ lines.push('---');
148
+ lines.push('');
149
+ lines.push('### 💬 Suggested reviewer / agent prompt');
150
+ lines.push('');
151
+
152
+ if (routes.length > 0) {
153
+ // Collect top waypoints across all changed files (unique, capped at 5)
154
+ const seen = new Set();
155
+ const topPaths = [];
156
+ for (const { route: routeDoc } of routes) {
157
+ const wps = Array.isArray(routeDoc && routeDoc.route) ? routeDoc.route : [];
158
+ for (const wp of wps.slice(0, 3)) {
159
+ const { path } = parsWaypoint(wp);
160
+ if (path && !seen.has(path)) {
161
+ seen.add(path);
162
+ topPaths.push(path);
163
+ if (topPaths.length >= 5) break;
164
+ }
165
+ }
166
+ if (topPaths.length >= 5) break;
167
+ }
168
+ const waypointList = topPaths.map(p => `\`${p}\``).join(', ');
169
+ lines.push(
170
+ `> Start your review at: ${waypointList || '_see routes above_'}. ` +
171
+ 'Advisory only — verify before acting.',
172
+ );
173
+ } else {
174
+ lines.push(
175
+ '> No route waypoints available for this PR. ' +
176
+ 'Advisory only — verify before acting.',
177
+ );
178
+ }
179
+
180
+ lines.push('');
181
+
182
+ // -------------------------------------------------------------------------
183
+ // Footer — safety / privacy
184
+ // -------------------------------------------------------------------------
185
+ lines.push('---');
186
+ lines.push('');
187
+ const secretsFound = (safetyJson && typeof safetyJson.secrets_found === 'number')
188
+ ? safetyJson.secrets_found
189
+ : 0;
190
+ lines.push(
191
+ '_KnoSky reads metadata only — it never uploads your code. ' +
192
+ `Secrets scan: **${secretsFound}** potential secret(s) found in emitted artifacts. ` +
193
+ 'network-silent (verify with --verify-airgap). ' +
194
+ 'open protocol / reference implementation._',
195
+ );
196
+
197
+ return lines.join('\n');
198
+ }