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
|
@@ -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
|
+
}
|