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,178 @@
1
+ // KnoSky bundle engine (KSV2-R4) — intent-manifest builder with fail-closed secret scan.
2
+ // Pure Node stdlib + internal imports only. ESM. No new deps.
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync, realpathSync } from 'node:fs';
5
+ import { join, relative, isAbsolute, sep } from 'node:path';
6
+ import { findSecrets } from './contract.mjs';
7
+ import { makeIntentManifest, validateIntentManifest } from './schema.mjs';
8
+ import { extractLedgerSeq } from './freshness.mjs';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Internal helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Return true when `ref` is a safe, repo-relative path (not absolute, no "..").
16
+ * @param {string|undefined} ref
17
+ * @returns {boolean}
18
+ */
19
+ function isSafeRef(ref) {
20
+ if (!ref || typeof ref !== 'string') return false;
21
+ if (ref.startsWith('/')) return false;
22
+ // Windows drive: C:\ or C:/
23
+ if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
24
+ if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Resolve `ref` under `root` and read it — following symlinks via realpathSync,
30
+ * then verifying the REAL resolved path still lands inside `root` before the
31
+ * file is ever opened. `isSafeRef` above only validates the path STRING; it
32
+ * cannot see that a same-named file on disk is actually a symlink pointing
33
+ * outside root. This is the actual read boundary (belt-and-suspenders on top
34
+ * of fs-indexer's own symlink exclusion at index time — this is a separate
35
+ * read path with its own live filesystem access, so it re-checks independently).
36
+ *
37
+ * Never throws. Distinguishes "nothing to scan by design" (no root given —
38
+ * the caller explicitly opted out of content access) from "root given but the
39
+ * file could not be verified safe/readable" (fail-closed: caller must treat
40
+ * this as unscannable, NEVER as clean).
41
+ *
42
+ * @param {string|undefined} root
43
+ * @param {string} ref
44
+ * @returns {{ buf: Buffer } | { noRoot: true } | { unsafe: true }}
45
+ */
46
+ function safeRead(root, ref) {
47
+ if (!root) return { noRoot: true };
48
+ const full = join(root, ref);
49
+ let real, rootReal;
50
+ try {
51
+ real = realpathSync(full);
52
+ rootReal = realpathSync(root);
53
+ } catch {
54
+ return { unsafe: true }; // missing / broken symlink / permission error — cannot verify
55
+ }
56
+ const rel = relative(rootReal, real);
57
+ if (rel === sep || rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) {
58
+ return { unsafe: true }; // real path escapes root (symlink pointed outside) — refuse
59
+ }
60
+ try {
61
+ return { buf: readFileSync(real) };
62
+ } catch {
63
+ return { unsafe: true };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Compute the hex SHA-256 of a file. Returns "" when unreadable/unsafe/no-root.
69
+ * @param {string|undefined} root Repo root directory, or undefined.
70
+ * @param {string} ref Repo-relative path.
71
+ * @returns {string}
72
+ */
73
+ function sha256OfFile(root, ref) {
74
+ const r = safeRead(root, ref);
75
+ if (!r.buf) return '';
76
+ return createHash('sha256').update(r.buf).digest('hex');
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // kcBundle — main export
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Build an intent-manifest for the given node ids.
85
+ *
86
+ * @param {object} ctx — retrieve context {city:{nodes}, byId:Map}
87
+ * @param {string[]} ids — node ids to include
88
+ * @param {object} [opts]
89
+ * @param {string} [opts.root] — repo root; used to read files for sha256 + secret scan
90
+ * @param {string|null} [opts.expiry] — ISO-8601 expiry timestamp or null
91
+ * @returns {object} intent-manifest (passes validateIntentManifest)
92
+ */
93
+ export function kcBundle(ctx, ids, { root, expiry = null } = {}) {
94
+ // Normalise ids to an array of strings
95
+ const idList = Array.isArray(ids) ? ids.map(String) : [];
96
+
97
+ // Build the set of included ids (only those with safe refs)
98
+ const includedSet = new Set();
99
+ const refByid = new Map(); // id -> safe ref string
100
+
101
+ for (const id of idList) {
102
+ const node = ctx.byId.get(id);
103
+ if (!node) continue; // missing node — skip
104
+ const ref = node.provenance && node.provenance.ref;
105
+ if (!isSafeRef(ref)) continue; // absolute or ".." — skip
106
+ includedSet.add(id);
107
+ refByid.set(id, ref);
108
+ }
109
+
110
+ // Build paths[] with sha256
111
+ const paths = [];
112
+ for (const id of idList) {
113
+ if (!includedSet.has(id)) continue;
114
+ const ref = refByid.get(id);
115
+ const sha256 = sha256OfFile(root, ref);
116
+ paths.push({ path: ref, sha256 });
117
+ }
118
+
119
+ // Build edges[] — out-edges within the bundled set
120
+ const edges = [];
121
+ for (const id of idList) {
122
+ if (!includedSet.has(id)) continue;
123
+ const node = ctx.byId.get(id);
124
+ const links = Array.isArray(node.links) ? node.links : [];
125
+ for (const target of links) {
126
+ if (includedSet.has(target)) {
127
+ edges.push({ from: id, to: target });
128
+ }
129
+ }
130
+ }
131
+
132
+ // Secret scan — FAIL-CLOSED. Three outcomes per included file:
133
+ // noRoot → caller opted out of content access entirely; nothing to scan by
134
+ // design (sha256 is also "" in this mode — a deliberate no-read mode,
135
+ // not a failure).
136
+ // unsafe → root WAS given but the file could not be verified safe-and-readable
137
+ // (missing, permission error, or a symlink resolving outside root).
138
+ // We can no longer prove it's secret-free, so we must NOT call it
139
+ // clean — treat it as blocked (the previous behavior silently
140
+ // skipped these and returned "clean", a fail-OPEN bug).
141
+ // text → scanned normally.
142
+ let totalMatches = 0;
143
+ let blocked = false;
144
+ let noRootMode = false;
145
+
146
+ for (const id of idList) {
147
+ if (!includedSet.has(id)) continue;
148
+ const ref = refByid.get(id);
149
+ const r = safeRead(root, ref);
150
+ if (r.noRoot) { noRootMode = true; continue; }
151
+ if (r.unsafe) { blocked = true; continue; }
152
+ const hits = findSecrets(r.buf.toString('utf8'));
153
+ if (hits.length > 0) {
154
+ blocked = true;
155
+ for (const [, count] of hits) totalMatches += count;
156
+ }
157
+ }
158
+ void noRootMode; // no-root is intentionally a no-op above; named for readability
159
+
160
+ const secret_scan = blocked
161
+ ? { status: 'blocked', count: totalMatches }
162
+ : { status: 'clean', count: 0 };
163
+
164
+ // Ledger-anchored freshness (SAT-444): propagate the monotone commit count
165
+ // from the city envelope into the manifest so consumers can apply the V13
166
+ // high-water-mark guard without needing the original city object.
167
+ const ledger_seq = extractLedgerSeq(ctx.city);
168
+
169
+ const manifest = makeIntentManifest({ paths, edges, expiry, secret_scan, ledger_seq });
170
+
171
+ // Invariant: manifest MUST pass validateIntentManifest
172
+ const validation = validateIntentManifest(manifest);
173
+ if (!validation.ok) {
174
+ throw new Error('kcBundle produced an invalid intent-manifest: ' + validation.errors.join('; '));
175
+ }
176
+
177
+ return manifest;
178
+ }
package/core/churn.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  // Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
2
2
  // No commit messages, diffs, hunks, authors, or line-level churn retained.
3
- import { execSync } from 'node:child_process';
3
+ // execFileSync + an args array (not execSync + a shell string) — no argument here is
4
+ // externally controlled today, but this matches the args-array-only discipline used by
5
+ // every other git invocation in this codebase (ci.mjs) and removes the shell entirely,
6
+ // so a future edit that adds a dynamic path/ref here can't reintroduce an injection class.
7
+ import { execFileSync } from 'node:child_process';
4
8
 
5
9
  export function gitChurn(root) {
6
10
  const counts = {}, last = {};
7
11
  try {
8
- const out = execSync('git log --since=90.days.ago --name-only --pretty=format:%ct -- .', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
12
+ const out = execFileSync('git', ['log', '--since=90.days.ago', '--name-only', '--pretty=format:%ct', '--', '.'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
9
13
  let ts = 0;
10
14
  for (const raw of out.split(/\r?\n/)) {
11
15
  const line = raw.trim();
package/core/ci.mjs ADDED
@@ -0,0 +1,268 @@
1
+ // KnoSky CI artifact generator (KSV2-CI1) — PR-GPS advisory artifact.
2
+ // Pure Node stdlib + node:child_process (git, args-array only) + internal imports.
3
+ // HARD RULE: exit 0 by default. Only --fail-on-secret can cause exit 1, and only
4
+ // when secrets are found in the emitted artifacts. Never throws, never breaks builds.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { load } from './retrieve.mjs';
7
+ import { kcRoute } from './route.mjs';
8
+ import { findSecrets } from './contract.mjs';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Path-safety guard (mirrors the one in route.mjs)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function isSafePath(p) {
15
+ if (!p || typeof p !== 'string') return false;
16
+ if (p.startsWith('/')) return false;
17
+ if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
18
+ if (p.split(/[/\\]/).some(seg => seg === '..')) return false;
19
+ return true;
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Git helper — returns changed file list or [] on any error (advisory, never throws)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ // A conservative git-ref allowlist: SHAs, branch/tag names, and relative refs
27
+ // (HEAD, HEAD~1, HEAD^). Must NOT start with '-' — that's the belt-and-suspenders
28
+ // layer that stops a ref string looking like a CLI flag (e.g. "--output=...")
29
+ // from ever being handed to git, even before --end-of-options runs.
30
+ function isSafeGitRef(ref) {
31
+ if (!ref || typeof ref !== 'string') return false;
32
+ if (ref.length > 200) return false;
33
+ return /^[A-Za-z0-9][A-Za-z0-9._/\-~^:]*$/.test(ref);
34
+ }
35
+
36
+ function gitChangedFiles(root, base, head) {
37
+ // Refuse to shell out at all if either ref doesn't look like a ref (layer 1).
38
+ if (!isSafeGitRef(base) || !isSafeGitRef(head)) return [];
39
+ try {
40
+ // --end-of-options (layer 2, git >= 2.24) tells git's option parser that
41
+ // everything after is a revision, never an option — closes the residual
42
+ // "ref value that happens to start with '-'" injection even though
43
+ // execFileSync's args-array already rules out shell injection.
44
+ const output = execFileSync(
45
+ 'git',
46
+ ['-C', root, 'diff', '--name-only', '--end-of-options', base + '...' + head],
47
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
48
+ );
49
+ return output.split('\n').map(l => l.trim()).filter(Boolean);
50
+ } catch (_) {
51
+ // Any git error → advisory empty set, never break the build
52
+ return [];
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // summaryMd builder
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function buildSummaryMd(generatedAt, base, head, routes) {
61
+ const lines = [];
62
+ lines.push('## KnoSky PR-GPS — Advisory Navigation Report');
63
+ lines.push('');
64
+ lines.push('> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.');
65
+ lines.push('');
66
+ lines.push(`- Generated: ${generatedAt}`);
67
+ if (base) lines.push(`- Base: \`${base}\``);
68
+ if (head) lines.push(`- Head: \`${head}\``);
69
+ lines.push('');
70
+
71
+ if (routes.length === 0) {
72
+ lines.push('_No changed files detected in this PR._');
73
+ } else {
74
+ for (const { file, route: routeDoc } of routes) {
75
+ lines.push(`### \`${file}\``);
76
+ lines.push('');
77
+ const topWaypoints = (routeDoc.route || []).slice(0, 5);
78
+ if (topWaypoints.length === 0) {
79
+ lines.push('_No route waypoints found in the index for this file._');
80
+ } else {
81
+ lines.push('**Route waypoints:**');
82
+ lines.push('');
83
+ for (const wp of topWaypoints) {
84
+ const path = typeof wp === 'string' ? wp : (wp && wp.path) || '';
85
+ const reason = (wp && wp.reason) || '';
86
+ lines.push(`- \`${path}\`${reason ? ' — ' + reason : ''}`);
87
+ }
88
+ }
89
+ const confidence = typeof routeDoc.confidence === 'number'
90
+ ? Math.round(routeDoc.confidence * 100) + '%'
91
+ : 'n/a';
92
+ lines.push('');
93
+ lines.push(`**Confidence:** ${confidence}`);
94
+ const caveats = (routeDoc.caveats || []).filter(c =>
95
+ c.includes('recently changed') || c.includes('stale') || c.includes('coverage'),
96
+ );
97
+ if (caveats.length > 0) {
98
+ lines.push('');
99
+ lines.push('**Caveats:**');
100
+ for (const c of caveats) lines.push(`- ${c}`);
101
+ }
102
+ lines.push('');
103
+ }
104
+ }
105
+
106
+ lines.push('---');
107
+ lines.push('_This report is advisory. KnoSky reads metadata only — it does not read code meaning._');
108
+ return lines.join('\n');
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // knoskyCi — main export
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Generate the KnoSky PR-GPS advisory artifacts.
117
+ *
118
+ * @param {object} [opts]
119
+ * @param {string} [opts.root='.'] Repo root for git commands.
120
+ * @param {string} [opts.base] Base ref for git diff.
121
+ * @param {string} [opts.head] Head ref for git diff.
122
+ * @param {string} [opts.cityPath] Path to city-data.json (city index).
123
+ * @param {boolean} [opts.failOnSecret=false] Exit 1 only if secrets found in artifacts.
124
+ * @param {string[]} [opts.changedFiles] Inject changed files directly (test seam — skips git).
125
+ * @returns {{ exitCode: number, summaryMd: string, routeJson: object, safetyJson: object }}
126
+ */
127
+ export async function knoskyCi({
128
+ root = '.',
129
+ base,
130
+ head,
131
+ cityPath,
132
+ failOnSecret = false,
133
+ changedFiles,
134
+ } = {}) {
135
+ const generatedAt = new Date().toISOString();
136
+
137
+ // 1. Resolve changed files
138
+ let files;
139
+ if (Array.isArray(changedFiles)) {
140
+ // Test seam — use injected list directly, no git needed
141
+ files = changedFiles;
142
+ } else {
143
+ // Resolve base/head from args, then environment, then fallback
144
+ const resolvedBase = base || process.env.GITHUB_BASE_REF || 'HEAD~1';
145
+ const resolvedHead = head || process.env.GITHUB_SHA || 'HEAD';
146
+ files = gitChangedFiles(root, resolvedBase, resolvedHead);
147
+ }
148
+
149
+ const resolvedBase = base || process.env.GITHUB_BASE_REF || null;
150
+ const resolvedHead = head || process.env.GITHUB_SHA || null;
151
+
152
+ // 2. Load the city index — if absent/unreadable, produce advisory summary and exit 0
153
+ let ctx;
154
+ if (!cityPath) {
155
+ const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
156
+ return {
157
+ exitCode: 0,
158
+ summaryMd,
159
+ routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
160
+ safetyJson: buildSafetyJson(generatedAt, 0),
161
+ };
162
+ }
163
+
164
+ try {
165
+ ctx = load(cityPath);
166
+ } catch (_) {
167
+ const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
168
+ return {
169
+ exitCode: 0,
170
+ summaryMd,
171
+ routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
172
+ safetyJson: buildSafetyJson(generatedAt, 0),
173
+ };
174
+ }
175
+
176
+ // 3. For each changed file, call kcRoute — metadata only, no file bodies
177
+ const routes = [];
178
+ for (const relpath of files) {
179
+ // Safety: skip any path that escapes the repo
180
+ if (!isSafePath(relpath)) continue;
181
+ let routeDoc;
182
+ try {
183
+ routeDoc = kcRoute(ctx, 'file:' + relpath);
184
+ } catch (_) {
185
+ // Route errors are advisory — skip this file, never break
186
+ continue;
187
+ }
188
+ // Drop any route entries with unsafe paths (defense in depth)
189
+ if (routeDoc && Array.isArray(routeDoc.route)) {
190
+ routeDoc.route = routeDoc.route.filter(wp => {
191
+ const p = typeof wp === 'string' ? wp : (wp && wp.path);
192
+ return !p || isSafePath(p);
193
+ });
194
+ }
195
+ if (routeDoc && Array.isArray(routeDoc.alternates)) {
196
+ routeDoc.alternates = routeDoc.alternates.filter(wp => {
197
+ const p = typeof wp === 'string' ? wp : (wp && wp.path);
198
+ return !p || isSafePath(p);
199
+ });
200
+ }
201
+ routes.push({ file: relpath, route: routeDoc });
202
+ }
203
+
204
+ // 4. Build routeJson
205
+ const routeJson = buildRouteJson(generatedAt, resolvedBase, resolvedHead, routes);
206
+
207
+ // 5. Build summaryMd
208
+ const summaryMd = buildSummaryMd(generatedAt, resolvedBase, resolvedHead, routes);
209
+
210
+ // 6. Scan ONLY the emitted artifacts for secrets (defense in depth)
211
+ const artifactText = summaryMd + '\n' + JSON.stringify(routeJson);
212
+ const secretHits = findSecrets(artifactText);
213
+ const secretsFound = secretHits.reduce((n, [, count]) => n + count, 0);
214
+
215
+ // 7. Build safetyJson
216
+ const safetyJson = buildSafetyJson(generatedAt, secretsFound);
217
+
218
+ // 8. exitCode: only failOnSecret+secrets_found > 0 can be non-zero
219
+ const exitCode = (failOnSecret && secretsFound > 0) ? 1 : 0;
220
+
221
+ return { exitCode, summaryMd, routeJson, safetyJson };
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Artifact builders
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function buildNoIndexSummary(generatedAt, base, head) {
229
+ const lines = [
230
+ '## KnoSky PR-GPS — Advisory Navigation Report',
231
+ '',
232
+ '> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.',
233
+ '',
234
+ `- Generated: ${generatedAt}`,
235
+ ];
236
+ if (base) lines.push(`- Base: \`${base}\``);
237
+ if (head) lines.push(`- Head: \`${head}\``);
238
+ lines.push('');
239
+ lines.push('_No index available — run `knosky <path>` to build the city index first._');
240
+ lines.push('');
241
+ lines.push('---');
242
+ lines.push('_This report is advisory. KnoSky reads metadata only._');
243
+ return lines.join('\n');
244
+ }
245
+
246
+ function buildRouteJson(generatedAt, base, head, routes) {
247
+ return {
248
+ knosky_protocol: '1.0',
249
+ artifact_type: 'pr-route',
250
+ advisory: true,
251
+ generated_at: generatedAt,
252
+ base: base || null,
253
+ head: head || null,
254
+ routes: routes.map(({ file, route }) => ({ file, route })),
255
+ };
256
+ }
257
+
258
+ function buildSafetyJson(generatedAt, secretsFound) {
259
+ return {
260
+ knosky_protocol: '1.0',
261
+ artifact_type: 'safety-report',
262
+ advisory: true,
263
+ generated_at: generatedAt,
264
+ absolute_paths: false,
265
+ secrets_found: secretsFound,
266
+ redaction: 'metadata-only; no file bodies; no absolute paths',
267
+ };
268
+ }
@@ -0,0 +1,189 @@
1
+ // KnoSky naive-vs-guided agent comparison protocol (SAT-458).
2
+ // Defines the artifact schema and validators for a single comparison run:
3
+ // tokens (in + out), tool-calls, time-to-relevant-file, correctness
4
+ // for both a naive agent and an identical task run with KnoSky guidance.
5
+ // Pure Node stdlib, ESM — no new deps.
6
+
7
+ import { PROTOCOL_VERSION } from './schema.mjs';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Artifact type constant
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** @type {string} */
14
+ export const COMPARISON_ARTIFACT_TYPE = 'comparison-run';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Internal helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Return true when `p` is a relative-safe file path:
22
+ * — non-empty string
23
+ * — does not start with `/` (Unix absolute) or Windows drive letter
24
+ * — contains no `..` path segment
25
+ * @param {string} p
26
+ * @returns {boolean}
27
+ */
28
+ function isRelativeSafePath(p) {
29
+ if (!p || typeof p !== 'string') return false;
30
+ if (p.startsWith('/')) return false;
31
+ if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
32
+ if (p.split(/[/\\]/).some(s => s === '..')) return false;
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * Validate one metric side object (either `naive` or `guided`).
38
+ * Returns an array of violation strings; empty means valid.
39
+ *
40
+ * @param {unknown} side
41
+ * @param {string} label — "naive" or "guided"
42
+ * @returns {string[]}
43
+ */
44
+ function validateSide(side, label) {
45
+ const errors = [];
46
+
47
+ if (!side || typeof side !== 'object') {
48
+ errors.push(`${label} must be an object`);
49
+ return errors; // can't go further
50
+ }
51
+
52
+ // tokens_in: non-negative integer
53
+ if (!Number.isInteger(side.tokens_in) || side.tokens_in < 0) {
54
+ errors.push(`${label}.tokens_in must be a non-negative integer, got: ${JSON.stringify(side.tokens_in)}`);
55
+ }
56
+
57
+ // tokens_out: non-negative integer
58
+ if (!Number.isInteger(side.tokens_out) || side.tokens_out < 0) {
59
+ errors.push(`${label}.tokens_out must be a non-negative integer, got: ${JSON.stringify(side.tokens_out)}`);
60
+ }
61
+
62
+ // tool_calls: non-negative integer
63
+ if (!Number.isInteger(side.tool_calls) || side.tool_calls < 0) {
64
+ errors.push(`${label}.tool_calls must be a non-negative integer, got: ${JSON.stringify(side.tool_calls)}`);
65
+ }
66
+
67
+ // time_to_relevant_file_ms: null (never found) or non-negative number
68
+ const ttrf = side.time_to_relevant_file_ms;
69
+ if (ttrf !== null && !(typeof ttrf === 'number' && ttrf >= 0)) {
70
+ errors.push(
71
+ `${label}.time_to_relevant_file_ms must be null or a non-negative number, got: ${JSON.stringify(ttrf)}`,
72
+ );
73
+ }
74
+
75
+ // correct: boolean
76
+ if (typeof side.correct !== 'boolean') {
77
+ errors.push(`${label}.correct must be a boolean, got: ${JSON.stringify(side.correct)}`);
78
+ }
79
+
80
+ return errors;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // makeComparisonRun
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Construct a KnoSky `comparison-run` artifact envelope.
89
+ *
90
+ * @param {object} opts
91
+ * @param {string} opts.task_id Short identifier for this task.
92
+ * @param {string} opts.task_description Human-readable description of the task.
93
+ * @param {string[]} opts.target_files Relative paths to the "relevant" files for this task.
94
+ * @param {object} opts.naive Metrics for the naive agent (no KnoSky guidance).
95
+ * @param {number} opts.naive.tokens_in Input tokens consumed.
96
+ * @param {number} opts.naive.tokens_out Output tokens produced.
97
+ * @param {number} opts.naive.tool_calls Total tool/function calls made.
98
+ * @param {number|null} opts.naive.time_to_relevant_file_ms ms until first relevant-file hit, or null.
99
+ * @param {boolean} opts.naive.correct Whether the agent arrived at the correct answer.
100
+ * @param {object} opts.guided Same metric shape for the KnoSky-guided agent.
101
+ * @returns {object}
102
+ */
103
+ export function makeComparisonRun({
104
+ task_id,
105
+ task_description,
106
+ target_files = [],
107
+ naive,
108
+ guided,
109
+ } = {}) {
110
+ return {
111
+ knosky_protocol: PROTOCOL_VERSION,
112
+ artifact_type: COMPARISON_ARTIFACT_TYPE,
113
+ advisory: true,
114
+ generated_at: new Date().toISOString(),
115
+ task_id,
116
+ task_description,
117
+ target_files,
118
+ naive,
119
+ guided,
120
+ };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // validateComparisonRun
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Validate a KnoSky `comparison-run` document.
129
+ * Collects every violation; `ok` is true only when `errors` is empty.
130
+ *
131
+ * @param {object} doc
132
+ * @returns {{ ok: boolean, errors: string[] }}
133
+ */
134
+ export function validateComparisonRun(doc) {
135
+ const errors = [];
136
+
137
+ if (!doc || typeof doc !== 'object') {
138
+ return { ok: false, errors: ['doc must be an object'] };
139
+ }
140
+
141
+ if (doc.knosky_protocol !== PROTOCOL_VERSION) {
142
+ errors.push(
143
+ `knosky_protocol must be "${PROTOCOL_VERSION}", got: ${JSON.stringify(doc.knosky_protocol)}`,
144
+ );
145
+ }
146
+
147
+ if (doc.artifact_type !== COMPARISON_ARTIFACT_TYPE) {
148
+ errors.push(
149
+ `artifact_type must be "${COMPARISON_ARTIFACT_TYPE}", got: ${JSON.stringify(doc.artifact_type)}`,
150
+ );
151
+ }
152
+
153
+ if (doc.advisory !== true) {
154
+ errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
155
+ }
156
+
157
+ // task_id: non-empty string
158
+ if (typeof doc.task_id !== 'string' || doc.task_id.length === 0) {
159
+ errors.push(`task_id must be a non-empty string, got: ${JSON.stringify(doc.task_id)}`);
160
+ }
161
+
162
+ // task_description: non-empty string
163
+ if (typeof doc.task_description !== 'string' || doc.task_description.length === 0) {
164
+ errors.push(
165
+ `task_description must be a non-empty string, got: ${JSON.stringify(doc.task_description)}`,
166
+ );
167
+ }
168
+
169
+ // target_files: non-empty array of relative-safe path strings
170
+ if (!Array.isArray(doc.target_files) || doc.target_files.length === 0) {
171
+ errors.push('target_files must be a non-empty array');
172
+ } else {
173
+ for (let i = 0; i < doc.target_files.length; i++) {
174
+ const p = doc.target_files[i];
175
+ if (typeof p !== 'string' || p.length === 0) {
176
+ errors.push(`target_files[${i}] must be a non-empty string`);
177
+ } else if (!isRelativeSafePath(p)) {
178
+ errors.push(`target_files[${i}] must be a relative path with no ".." segments: ${JSON.stringify(p)}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ // naive and guided sides
184
+ for (const side of ['naive', 'guided']) {
185
+ errors.push(...validateSide(doc[side], side));
186
+ }
187
+
188
+ return { ok: errors.length === 0, errors };
189
+ }