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.
@@ -0,0 +1,405 @@
1
+ // KnoSky simultaneous multi-model benchmark harness (SAT-459).
2
+ //
3
+ // Defines the artifact schema and validators for a multi-model benchmark run:
4
+ // the same benchmark tasks from the comparison-run protocol (core/comparison.mjs)
5
+ // executed simultaneously across all supported model families in parallel — not
6
+ // sequentially — and collected into a single artifact for analysis.
7
+ //
8
+ // Paul's explicit instruction: "run simultaneous tests" -- every model family
9
+ // (Claude, GPT, Gemini, DeepSeek, Grok) receives the same tasks at the same
10
+ // time via Promise.all. This module owns the protocol shape only; it makes no
11
+ // network calls itself. A separate, not-yet-built tools/-layer helper (following
12
+ // the CI-only HTTP-dispatch pattern already used elsewhere in this repo's build
13
+ // tooling) is expected to supply the real dispatchFn at call time -- see D-176.
14
+ //
15
+ // Pure Node stdlib, ESM — no new deps.
16
+
17
+ import { PROTOCOL_VERSION } from './schema.mjs';
18
+ // NOTE (D-176, post-PR#50 review): this file does NOT import validateComparisonRun from
19
+ // core/comparison.mjs -- it defines and validates its own multi-model artifact shape and
20
+ // never delegates to the single-model comparison-run validator. An earlier version had an
21
+ // unused import of it (dead code, flagged by review); removed rather than left in.
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Artifact type constant
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** @type {string} */
28
+ export const MULTI_MODEL_ARTIFACT_TYPE = 'multi-model-benchmark';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Supported model families — the canonical list for simultaneous dispatch.
32
+ // Names must be stable identifiers (used as keys in per-model result maps).
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * The five model families that the simultaneous harness fans out to.
37
+ * Each entry is a { family, modelId } pair:
38
+ * family — short name used as a key in multi-model result artifacts
39
+ * modelId — the LiteLLM / routing model string for the dispatch layer
40
+ *
41
+ * @type {Array<{ family: string, modelId: string }>}
42
+ */
43
+ export const MODEL_FAMILIES = [
44
+ { family: 'claude', modelId: 'sathia-standard' },
45
+ { family: 'gpt', modelId: 'sathia-gpt' },
46
+ { family: 'gemini', modelId: 'sathia-gemini' },
47
+ { family: 'deepseek', modelId: 'sathia-deepseek' },
48
+ { family: 'grok', modelId: 'sathia-grok' },
49
+ ];
50
+
51
+ /**
52
+ * The set of valid family names — used in validation.
53
+ * @type {Set<string>}
54
+ */
55
+ const KNOWN_FAMILIES = new Set(MODEL_FAMILIES.map(m => m.family));
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Internal helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Return true when `p` is a relative-safe file path:
63
+ * — non-empty string
64
+ * — does not start with `/` (Unix absolute) or Windows drive letter
65
+ * — contains no `..` path segment
66
+ * @param {string} p
67
+ * @returns {boolean}
68
+ */
69
+ function isRelativeSafePath(p) {
70
+ if (!p || typeof p !== 'string') return false;
71
+ if (p.startsWith('/')) return false;
72
+ if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
73
+ if (p.split(/[/\\]/).some(s => s === '..')) return false;
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Validate one per-model result object.
79
+ * Returns an array of violation strings; empty means valid.
80
+ *
81
+ * @param {unknown} result
82
+ * @param {string} label — e.g. "results[claude]"
83
+ * @returns {string[]}
84
+ */
85
+ function validateModelResult(result, label) {
86
+ const errors = [];
87
+
88
+ if (!result || typeof result !== 'object') {
89
+ errors.push(`${label} must be an object`);
90
+ return errors;
91
+ }
92
+
93
+ // family: known non-empty string
94
+ if (typeof result.family !== 'string' || !KNOWN_FAMILIES.has(result.family)) {
95
+ errors.push(
96
+ `${label}.family must be one of [${[...KNOWN_FAMILIES].join(', ')}], ` +
97
+ `got: ${JSON.stringify(result.family)}`,
98
+ );
99
+ }
100
+
101
+ // model_id: non-empty string
102
+ if (typeof result.model_id !== 'string' || result.model_id.length === 0) {
103
+ errors.push(`${label}.model_id must be a non-empty string, got: ${JSON.stringify(result.model_id)}`);
104
+ }
105
+
106
+ // status: 'ok' | 'failed'
107
+ if (result.status !== 'ok' && result.status !== 'failed') {
108
+ errors.push(`${label}.status must be "ok" or "failed", got: ${JSON.stringify(result.status)}`);
109
+ }
110
+
111
+ // error: string or null
112
+ if (result.error !== null && typeof result.error !== 'string') {
113
+ errors.push(`${label}.error must be null or a string, got: ${JSON.stringify(result.error)}`);
114
+ }
115
+
116
+ // metrics: required when status === 'ok', must pass comparison-run side validation
117
+ if (result.status === 'ok') {
118
+ if (!result.metrics || typeof result.metrics !== 'object') {
119
+ errors.push(`${label}.metrics must be an object when status is "ok"`);
120
+ } else {
121
+ // metrics carries tokens_in / tokens_out / tool_calls / time_to_relevant_file_ms / correct
122
+ const m = result.metrics;
123
+ if (!Number.isInteger(m.tokens_in) || m.tokens_in < 0) {
124
+ errors.push(`${label}.metrics.tokens_in must be a non-negative integer, got: ${JSON.stringify(m.tokens_in)}`);
125
+ }
126
+ if (!Number.isInteger(m.tokens_out) || m.tokens_out < 0) {
127
+ errors.push(`${label}.metrics.tokens_out must be a non-negative integer, got: ${JSON.stringify(m.tokens_out)}`);
128
+ }
129
+ if (!Number.isInteger(m.tool_calls) || m.tool_calls < 0) {
130
+ errors.push(`${label}.metrics.tool_calls must be a non-negative integer, got: ${JSON.stringify(m.tool_calls)}`);
131
+ }
132
+ const ttrf = m.time_to_relevant_file_ms;
133
+ if (ttrf !== null && !(typeof ttrf === 'number' && ttrf >= 0)) {
134
+ errors.push(
135
+ `${label}.metrics.time_to_relevant_file_ms must be null or a non-negative number, ` +
136
+ `got: ${JSON.stringify(ttrf)}`,
137
+ );
138
+ }
139
+ if (typeof m.correct !== 'boolean') {
140
+ errors.push(`${label}.metrics.correct must be a boolean, got: ${JSON.stringify(m.correct)}`);
141
+ }
142
+ }
143
+ }
144
+
145
+ return errors;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // makeMultiModelRun
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Construct a KnoSky `multi-model-benchmark` artifact envelope.
154
+ *
155
+ * The artifact records:
156
+ * - the task that was run (same fields as comparison-run: id, description, target_files)
157
+ * - per-model results collected simultaneously via Promise.all dispatch
158
+ * - a summary: which models succeeded, total latency, fastest model
159
+ *
160
+ * @param {object} opts
161
+ * @param {string} opts.task_id Short identifier for this task.
162
+ * @param {string} opts.task_description Human-readable description of the task.
163
+ * @param {string[]} opts.target_files Relative paths to the "relevant" files.
164
+ * @param {object[]} opts.results Per-model result objects (one per family).
165
+ * @param {object} [opts.dispatch] Dispatch metadata: how models were invoked.
166
+ * @param {string} [opts.dispatch.mode] Always "parallel" — simultaneous Promise.all.
167
+ * @param {number} [opts.dispatch.started_at_ms] Wall-clock ms when the fan-out started.
168
+ * @param {number} [opts.dispatch.settled_at_ms] Wall-clock ms when all settled.
169
+ * @returns {object}
170
+ */
171
+ export function makeMultiModelRun({
172
+ task_id,
173
+ task_description,
174
+ target_files = [],
175
+ results = [],
176
+ dispatch = null,
177
+ } = {}) {
178
+ // Derive summary from results
179
+ const succeeded = results.filter(r => r && r.status === 'ok').map(r => r.family);
180
+ const failed = results.filter(r => r && r.status === 'failed').map(r => r.family);
181
+
182
+ // Fastest model = min time_to_relevant_file_ms among successful runs that found the file
183
+ let fastest_family = null;
184
+ let minTtrf = Infinity;
185
+ for (const r of results) {
186
+ if (r && r.status === 'ok' && r.metrics) {
187
+ const t = r.metrics.time_to_relevant_file_ms;
188
+ if (typeof t === 'number' && t >= 0 && t < minTtrf) {
189
+ minTtrf = t;
190
+ fastest_family = r.family;
191
+ }
192
+ }
193
+ }
194
+
195
+ return {
196
+ knosky_protocol: PROTOCOL_VERSION,
197
+ artifact_type: MULTI_MODEL_ARTIFACT_TYPE,
198
+ advisory: true,
199
+ generated_at: new Date().toISOString(),
200
+ task_id,
201
+ task_description,
202
+ target_files,
203
+ dispatch: dispatch || { mode: 'parallel' },
204
+ results,
205
+ summary: {
206
+ total: results.length,
207
+ succeeded: succeeded.length,
208
+ failed: failed.length,
209
+ succeeded_families: succeeded,
210
+ failed_families: failed,
211
+ fastest_family,
212
+ },
213
+ };
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // validateMultiModelRun
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Validate a KnoSky `multi-model-benchmark` document.
222
+ * Collects every violation; `ok` is true only when `errors` is empty.
223
+ *
224
+ * @param {object} doc
225
+ * @returns {{ ok: boolean, errors: string[] }}
226
+ */
227
+ export function validateMultiModelRun(doc) {
228
+ const errors = [];
229
+
230
+ if (!doc || typeof doc !== 'object') {
231
+ return { ok: false, errors: ['doc must be an object'] };
232
+ }
233
+
234
+ if (doc.knosky_protocol !== PROTOCOL_VERSION) {
235
+ errors.push(
236
+ `knosky_protocol must be "${PROTOCOL_VERSION}", got: ${JSON.stringify(doc.knosky_protocol)}`,
237
+ );
238
+ }
239
+
240
+ if (doc.artifact_type !== MULTI_MODEL_ARTIFACT_TYPE) {
241
+ errors.push(
242
+ `artifact_type must be "${MULTI_MODEL_ARTIFACT_TYPE}", ` +
243
+ `got: ${JSON.stringify(doc.artifact_type)}`,
244
+ );
245
+ }
246
+
247
+ if (doc.advisory !== true) {
248
+ errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
249
+ }
250
+
251
+ // task_id: non-empty string
252
+ if (typeof doc.task_id !== 'string' || doc.task_id.length === 0) {
253
+ errors.push(`task_id must be a non-empty string, got: ${JSON.stringify(doc.task_id)}`);
254
+ }
255
+
256
+ // task_description: non-empty string
257
+ if (typeof doc.task_description !== 'string' || doc.task_description.length === 0) {
258
+ errors.push(
259
+ `task_description must be a non-empty string, got: ${JSON.stringify(doc.task_description)}`,
260
+ );
261
+ }
262
+
263
+ // target_files: non-empty array of relative-safe path strings
264
+ if (!Array.isArray(doc.target_files) || doc.target_files.length === 0) {
265
+ errors.push('target_files must be a non-empty array');
266
+ } else {
267
+ for (let i = 0; i < doc.target_files.length; i++) {
268
+ const p = doc.target_files[i];
269
+ if (typeof p !== 'string' || p.length === 0) {
270
+ errors.push(`target_files[${i}] must be a non-empty string`);
271
+ } else if (!isRelativeSafePath(p)) {
272
+ errors.push(
273
+ `target_files[${i}] must be a relative path with no ".." segments: ${JSON.stringify(p)}`,
274
+ );
275
+ }
276
+ }
277
+ }
278
+
279
+ // dispatch: object with mode === 'parallel'
280
+ if (!doc.dispatch || typeof doc.dispatch !== 'object') {
281
+ errors.push('dispatch must be an object');
282
+ } else if (doc.dispatch.mode !== 'parallel') {
283
+ errors.push(
284
+ `dispatch.mode must be "parallel" (simultaneous fan-out), ` +
285
+ `got: ${JSON.stringify(doc.dispatch.mode)}`,
286
+ );
287
+ }
288
+
289
+ // results: non-empty array
290
+ if (!Array.isArray(doc.results) || doc.results.length === 0) {
291
+ errors.push('results must be a non-empty array');
292
+ } else {
293
+ for (let i = 0; i < doc.results.length; i++) {
294
+ errors.push(...validateModelResult(doc.results[i], `results[${i}]`));
295
+ }
296
+ }
297
+
298
+ // summary: structural check
299
+ if (!doc.summary || typeof doc.summary !== 'object') {
300
+ errors.push('summary must be an object');
301
+ } else {
302
+ const s = doc.summary;
303
+ if (!Number.isInteger(s.total) || s.total < 0) {
304
+ errors.push(`summary.total must be a non-negative integer, got: ${JSON.stringify(s.total)}`);
305
+ }
306
+ if (!Number.isInteger(s.succeeded) || s.succeeded < 0) {
307
+ errors.push(`summary.succeeded must be a non-negative integer, got: ${JSON.stringify(s.succeeded)}`);
308
+ }
309
+ if (!Number.isInteger(s.failed) || s.failed < 0) {
310
+ errors.push(`summary.failed must be a non-negative integer, got: ${JSON.stringify(s.failed)}`);
311
+ }
312
+ if (!Array.isArray(s.succeeded_families)) {
313
+ errors.push('summary.succeeded_families must be an array');
314
+ }
315
+ if (!Array.isArray(s.failed_families)) {
316
+ errors.push('summary.failed_families must be an array');
317
+ }
318
+ // fastest_family: null or a known family string
319
+ if (s.fastest_family !== null && typeof s.fastest_family !== 'string') {
320
+ errors.push(
321
+ `summary.fastest_family must be null or a string, got: ${JSON.stringify(s.fastest_family)}`,
322
+ );
323
+ }
324
+ }
325
+
326
+ return { ok: errors.length === 0, errors };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // runMultiModelBenchmark
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * Execute one benchmark task simultaneously across all model families.
335
+ *
336
+ * This is the harness entry point that implements Paul's "simultaneous tests"
337
+ * requirement: all models are dispatched in a single Promise.all fan-out so
338
+ * no model waits for another to finish.
339
+ *
340
+ * `dispatch` is an async function provided by the caller (typically the tools/
341
+ * layer, which owns the HTTPS + LiteLLM wiring). It receives a { family,
342
+ * modelId, task } descriptor and must resolve to a per-model result object:
343
+ *
344
+ * {
345
+ * family: string, // must match MODEL_FAMILIES[n].family
346
+ * model_id: string,
347
+ * status: 'ok' | 'failed',
348
+ * error: string | null, // non-null on failure
349
+ * metrics: { // present when status === 'ok'
350
+ * tokens_in: number,
351
+ * tokens_out: number,
352
+ * tool_calls: number,
353
+ * time_to_relevant_file_ms: number | null,
354
+ * correct: boolean,
355
+ * } | null,
356
+ * }
357
+ *
358
+ * Failed dispatches (rejected promise, uncaught throw) are caught and stored
359
+ * as `{ status: 'failed', error: <message> }` — one model failing never
360
+ * prevents the others from completing (Promise.allSettled semantics on top
361
+ * of the individual error handling).
362
+ *
363
+ * @param {object} task
364
+ * @param {string} task.task_id
365
+ * @param {string} task.task_description
366
+ * @param {string[]} task.target_files
367
+ * @param {Function} dispatchFn async (descriptor) => modelResult
368
+ * @param {Array<{ family: string, modelId: string }>} [models] Defaults to MODEL_FAMILIES.
369
+ * @returns {Promise<object>} A validated multi-model-benchmark artifact.
370
+ */
371
+ export async function runMultiModelBenchmark(task, dispatchFn, models = MODEL_FAMILIES) {
372
+ const startedAtMs = Date.now();
373
+
374
+ // Fan out simultaneously — Promise.all so all models start at the same time.
375
+ // Individual dispatch errors are caught per-slot so one failure cannot abort others.
376
+ const results = await Promise.all(
377
+ models.map(async ({ family, modelId }) => {
378
+ try {
379
+ return await dispatchFn({ family, modelId, task });
380
+ } catch (err) {
381
+ return {
382
+ family,
383
+ model_id: modelId,
384
+ status: 'failed',
385
+ error: err && err.message ? err.message : String(err),
386
+ metrics: null,
387
+ };
388
+ }
389
+ }),
390
+ );
391
+
392
+ const settledAtMs = Date.now();
393
+
394
+ return makeMultiModelRun({
395
+ task_id: task.task_id,
396
+ task_description: task.task_description,
397
+ target_files: task.target_files,
398
+ results,
399
+ dispatch: {
400
+ mode: 'parallel',
401
+ started_at_ms: startedAtMs,
402
+ settled_at_ms: settledAtMs,
403
+ },
404
+ });
405
+ }
@@ -0,0 +1,223 @@
1
+ // KnoSky generic onboarding contract (SAT-463).
2
+ // Model-agnostic system-prompt artifact: enumerates every available tool,
3
+ // hard usage rules, and starter examples — no model-specific formatting or IDs.
4
+ // Pure data + make/validate/render. No I/O, no external imports.
5
+
6
+ export const ONBOARDING_SCHEMA_VERSION = '1.0';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Tool catalogue — must stay in sync with mcp/server.mjs registrations.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** @type {Array<{ name: string, description: string, params: Record<string,string>, example: string }>} */
13
+ export const TOOL_DEFS = [
14
+ {
15
+ name: 'kc_search',
16
+ description:
17
+ 'Search the knowledge index by keywords. Returns ranked items (title, summary, category) ' +
18
+ 'each with a provenance citation (source path + revision) that links back to the live file. ' +
19
+ 'Use for "where does X live / what was decided about Y / how does this connect". ' +
20
+ 'Navigation, not full-text code search.',
21
+ params: {
22
+ query: 'keywords (string, max 500 chars)',
23
+ limit: 'max results (integer 1–50, default 10)',
24
+ category: 'restrict to a category id (optional)',
25
+ },
26
+ example: 'kc_search("authentication")',
27
+ },
28
+ {
29
+ name: 'kc_get_node',
30
+ description:
31
+ 'Fetch a single indexed item by id (title, summary, category, kind) with its provenance citation.',
32
+ params: { id: 'node id, e.g. fs:src/index.ts (string, max 400 chars)' },
33
+ example: 'kc_get_node("fs:src/auth.js")',
34
+ },
35
+ {
36
+ name: 'kc_list_categories',
37
+ description: 'List the knowledge categories (city districts) with item counts.',
38
+ params: {},
39
+ example: 'kc_list_categories()',
40
+ },
41
+ {
42
+ name: 'kc_get_provenance',
43
+ description:
44
+ 'Get the citation for an item: the live source ref + revision, plus its links to related items.',
45
+ params: { id: 'node id (string, max 400 chars)' },
46
+ example: 'kc_get_provenance("fs:src/auth.js")',
47
+ },
48
+ {
49
+ name: 'kc_related',
50
+ description:
51
+ 'How a file connects to others: which files it imports (out-edges), which import it (in-edges), ' +
52
+ 'and its recent-change (churn) signal. File-level structure with citations, not code analysis.',
53
+ params: { id: 'node id, e.g. fs:src/auth.js (string, max 400 chars)' },
54
+ example: 'kc_related("fs:src/auth.js")',
55
+ },
56
+ {
57
+ name: 'kc_route',
58
+ description:
59
+ 'Advisory, metadata-only route through the repo towards a destination. Returns ranked ' +
60
+ 'waypoints (where to look first), alternates, related tests, related docs, caveats, and a ' +
61
+ 'confidence score. Structural navigation only — does NOT read or analyse code meaning.',
62
+ params: {
63
+ destination:
64
+ 'navigation target — file:src/auth.js, folder:src/auth, or keywords (string, max 400 chars)',
65
+ limit: 'max route entries (integer 1–20, default 8)',
66
+ },
67
+ example: 'kc_route("file:src/auth.js")',
68
+ },
69
+ ];
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Hard usage rules — model-agnostic, applies to every deployment.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export const CONSTRAINTS = [
76
+ 'All results are advisory-only — verify before acting on any waypoint or route.',
77
+ 'KnoSky reads metadata and pointers only; it never reads or uploads full file bodies.',
78
+ 'All paths in results are repo-relative (never absolute). Do not construct absolute paths from them.',
79
+ 'Citations (provenance) reference the live file; the index may be stale — treat confidence scores accordingly.',
80
+ 'Secret and PII patterns are scrubbed from projections; do not assume scrubbing is exhaustive (see LIMITATIONS.md).',
81
+ 'kc_route is structural / file-level only — it does not understand code semantics.',
82
+ ];
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Starter prompts — illustrative, not exhaustive.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export const EXAMPLES = [
89
+ 'Using KnoSky, where does authentication live in this repo?',
90
+ 'Using KnoSky, what are the entry points of this project?',
91
+ 'Using KnoSky, which files should I read to understand billing?',
92
+ 'Using KnoSky, list the categories in this codebase.',
93
+ 'Using KnoSky, what connects to src/auth.js?',
94
+ ];
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // makeOnboardingDoc — construct a KnoSky `onboarding` artifact envelope.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Construct a KnoSky `onboarding` artifact envelope.
102
+ *
103
+ * @param {object} [opts]
104
+ * @param {string} [opts.generatedAt] ISO-8601 timestamp; defaults to now.
105
+ * @returns {object}
106
+ */
107
+ export function makeOnboardingDoc({ generatedAt } = {}) {
108
+ return {
109
+ knosky_protocol: ONBOARDING_SCHEMA_VERSION,
110
+ artifact_type: 'onboarding',
111
+ advisory: true,
112
+ generated_at: generatedAt || new Date().toISOString(),
113
+ tools: TOOL_DEFS.map(t => ({ ...t, params: { ...t.params } })),
114
+ constraints: [...CONSTRAINTS],
115
+ examples: [...EXAMPLES],
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // validateOnboardingDoc — collect all constraint violations; ok iff empty.
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Validate a KnoSky `onboarding` document.
125
+ * Collects every violation; `ok` is true only when `errors` is empty.
126
+ *
127
+ * @param {object} doc
128
+ * @returns {{ ok: boolean, errors: string[] }}
129
+ */
130
+ export function validateOnboardingDoc(doc) {
131
+ const errors = [];
132
+
133
+ if (!doc || typeof doc !== 'object') {
134
+ return { ok: false, errors: ['doc is not an object'] };
135
+ }
136
+
137
+ if (doc.knosky_protocol !== '1.0') {
138
+ errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(doc.knosky_protocol)}`);
139
+ }
140
+
141
+ if (doc.artifact_type !== 'onboarding') {
142
+ errors.push(`artifact_type must be "onboarding", got: ${JSON.stringify(doc.artifact_type)}`);
143
+ }
144
+
145
+ if (doc.advisory !== true) {
146
+ errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
147
+ }
148
+
149
+ if (!Array.isArray(doc.tools) || doc.tools.length === 0) {
150
+ errors.push('tools must be a non-empty array');
151
+ } else {
152
+ for (let i = 0; i < doc.tools.length; i++) {
153
+ const t = doc.tools[i];
154
+ if (!t || typeof t !== 'object') {
155
+ errors.push(`tools[${i}] must be an object`);
156
+ continue;
157
+ }
158
+ if (typeof t.name !== 'string' || t.name.length === 0) {
159
+ errors.push(`tools[${i}].name must be a non-empty string`);
160
+ }
161
+ if (typeof t.description !== 'string' || t.description.length === 0) {
162
+ errors.push(`tools[${i}].description must be a non-empty string`);
163
+ }
164
+ }
165
+ }
166
+
167
+ if (!Array.isArray(doc.constraints)) {
168
+ errors.push('constraints must be an array');
169
+ }
170
+
171
+ if (!Array.isArray(doc.examples)) {
172
+ errors.push('examples must be an array');
173
+ }
174
+
175
+ return { ok: errors.length === 0, errors };
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // renderOnboardingText — plain text suitable for any model system prompt.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Render a KnoSky `onboarding` document as plain text.
184
+ * Suitable for pasting directly into any model system prompt.
185
+ *
186
+ * @param {object} doc Validated onboarding document.
187
+ * @returns {string}
188
+ */
189
+ export function renderOnboardingText(doc) {
190
+ const lines = [];
191
+
192
+ lines.push('# KnoSky — onboarding');
193
+ lines.push('');
194
+ lines.push(
195
+ 'KnoSky is a local, offline knowledge city. It turns a repo or docs folder into a navigable ' +
196
+ 'index of pointers and projections. It reads metadata only — it never uploads code.',
197
+ );
198
+ lines.push('');
199
+
200
+ lines.push('## Available tools');
201
+ lines.push('');
202
+ for (const t of (doc.tools || [])) {
203
+ lines.push(`### ${t.name}`);
204
+ lines.push(t.description || '');
205
+ if (t.example) lines.push(`Example: ${t.example}`);
206
+ lines.push('');
207
+ }
208
+
209
+ lines.push('## Rules');
210
+ lines.push('');
211
+ for (const c of (doc.constraints || [])) {
212
+ lines.push(`- ${c}`);
213
+ }
214
+ lines.push('');
215
+
216
+ lines.push('## Starter prompts');
217
+ lines.push('');
218
+ for (const e of (doc.examples || [])) {
219
+ lines.push(`- ${e}`);
220
+ }
221
+
222
+ return lines.join('\n');
223
+ }
@@ -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
+ }