knodin 0.7.5 → 0.8.2

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.
Files changed (75) hide show
  1. package/README.md +18 -3
  2. package/benchmarks/competitors/SYNTHESIS.md +66 -0
  3. package/dist/bin/cli.js +371 -66
  4. package/dist/bin/launcher.js +16 -1
  5. package/dist/src/agent-integration.js +82 -16
  6. package/dist/src/artifact-refresh.js +2 -1
  7. package/dist/src/cli-args.js +19 -1
  8. package/dist/src/cli-model.js +28 -2
  9. package/dist/src/codeflow-replay.js +2 -1
  10. package/dist/src/compare.js +39 -0
  11. package/dist/src/competitive-constraints.js +2 -1
  12. package/dist/src/competitive-runner.js +4 -4
  13. package/dist/src/context-export.js +3 -2
  14. package/dist/src/context.js +1 -1
  15. package/dist/src/deterministic-random.js +34 -0
  16. package/dist/src/diagnostics-write-helper.js +473 -0
  17. package/dist/src/diagnostics.js +1160 -133
  18. package/dist/src/doctor.js +3 -1
  19. package/dist/src/engine/ann-hnsw.js +2 -12
  20. package/dist/src/engine/file-walker.js +8 -2
  21. package/dist/src/engine/git-history.js +12 -12
  22. package/dist/src/engine/index.js +1174 -313
  23. package/dist/src/engine/sarif-import.js +341 -0
  24. package/dist/src/engine/scip-import.js +28 -13
  25. package/dist/src/engine/source-policy.js +16 -0
  26. package/dist/src/engine/state-paths.js +175 -0
  27. package/dist/src/execution-profile.js +15 -10
  28. package/dist/src/failure-diagnosis.js +7 -1
  29. package/dist/src/graph-layout.js +173 -0
  30. package/dist/src/index-activity.js +2 -1
  31. package/dist/src/init.js +86 -45
  32. package/dist/src/lifecycle-health.js +41 -9
  33. package/dist/src/mcp-graph-worker.js +69 -0
  34. package/dist/src/mcp-reliability.js +154 -0
  35. package/dist/src/mcp-worker-supervisor.js +350 -0
  36. package/dist/src/mirror.js +290 -0
  37. package/dist/src/node-runtime.js +157 -0
  38. package/dist/src/output-compression.js +2 -1
  39. package/dist/src/output-telemetry.js +16 -11
  40. package/dist/src/progressive-evidence.js +30 -26
  41. package/dist/src/pure-compression-cli.js +4 -3
  42. package/dist/src/relationship-adapters.js +15 -8
  43. package/dist/src/release-preflight.js +13 -10
  44. package/dist/src/repair-lease.js +85 -0
  45. package/dist/src/repository-init-process.js +13 -9
  46. package/dist/src/repository-management.js +34 -4
  47. package/dist/src/response-budget.js +8 -6
  48. package/dist/src/server.js +80 -35
  49. package/dist/src/structural-fast-path.js +16 -10
  50. package/dist/src/structural-snapshot.js +6 -2
  51. package/dist/src/system-config.js +25 -2
  52. package/dist/src/tools/knodin-tools.js +142 -31
  53. package/dist/src/update-ceremony.js +9 -5
  54. package/dist/src/update-trust.js +5 -4
  55. package/dist/src/visualization.js +372 -19
  56. package/dist/src/worktree-lifecycle.js +5 -2
  57. package/docs/BEHAVIORAL-CONTRACT.md +72 -0
  58. package/docs/CLI.md +20 -1
  59. package/docs/COMPARISON.md +403 -0
  60. package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
  61. package/docs/DIAGNOSTICS.md +46 -11
  62. package/docs/HANDOFF.md +180 -0
  63. package/docs/INSTALLATION.md +21 -2
  64. package/docs/MCP.md +59 -8
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
  66. package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
  67. package/docs/SCIP-IMPORT.md +5 -0
  68. package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
  69. package/docs/releases/0.5.1.md +4 -4
  70. package/docs/releases/0.8.0.md +74 -0
  71. package/docs/releases/0.8.2.md +34 -0
  72. package/package.json +17 -4
  73. package/roadmap/competitive-roadmap.md +3801 -0
  74. package/schemas/release-attestation-v1.schema.json +1 -1
  75. package/schemas/support-bundle-v2.schema.json +212 -0
@@ -0,0 +1,290 @@
1
+ import childProcess from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { mirrorEntryPath, mirrorSourcePath, mirrorStagingRoot, mirrorStatePath, readMirrorRegistry, writeMirrorRegistry, } from "./engine/state-paths.js";
5
+ import { gitExecutable } from "./git-executable.js";
6
+ import { repositoryIdentity } from "./repository-management.js";
7
+ export class MirrorError extends Error {
8
+ kind;
9
+ constructor(kind, message) {
10
+ super(message);
11
+ this.name = "MirrorError";
12
+ this.kind = kind;
13
+ }
14
+ }
15
+ /** Marker dropped at a mirror's root so it cannot be mistaken for a checkout. */
16
+ const MIRROR_README = `This directory is a knodin mirror: a read-only shallow clone of a remote
17
+ repository, kept only so knodin can answer graph questions about it.
18
+
19
+ This is not a working copy.
20
+ Local edits here are DISCARDED on the next \`knodin remote refresh\`.
21
+ Clone the repository yourself if you intend to change it.
22
+ `;
23
+ /**
24
+ * Strips credentials from a clone URL so nothing secret reaches the registry.
25
+ *
26
+ * The password always goes. The username goes only for HTTP(S), where it is
27
+ * routinely a token or half of a credential pair.
28
+ *
29
+ * For other schemes the username is structural, not secret: `ssh://git@host/x`
30
+ * means the SSH account, and clearing it yields `ssh://host/x`, which is not the
31
+ * same URL and cannot be used to re-add the mirror. The registry value is shown
32
+ * by `knodin remote list`, so a user copying it back would get a URL that does
33
+ * not work — the listing has to stay actionable, not merely safe.
34
+ */
35
+ export function sanitizeCloneUrl(url) {
36
+ try {
37
+ const parsed = new URL(url);
38
+ parsed.password = "";
39
+ if (parsed.protocol === "http:" || parsed.protocol === "https:")
40
+ parsed.username = "";
41
+ return parsed.toString();
42
+ }
43
+ catch {
44
+ // scp-style (git@host:org/repo.git) carries no secret to strip.
45
+ return url;
46
+ }
47
+ }
48
+ /**
49
+ * Classifies a failed git invocation.
50
+ *
51
+ * GitHub and GitHub Enterprise deliberately answer 404 for a private repository
52
+ * the caller cannot see, so "missing" and "forbidden" are indistinguishable over
53
+ * the wire. They are therefore reported as one kind, and the message never
54
+ * claims the repository does not exist — that would turn an auth problem into a
55
+ * false statement about the remote.
56
+ */
57
+ export function classifyGitFailure(stderr, signal) {
58
+ if (signal)
59
+ return "interrupted";
60
+ const text = stderr.toLowerCase();
61
+ if (/no space left on device|disk quota|write error|out of memory/.test(text))
62
+ return "disk";
63
+ if (/could not resolve host|network is unreachable|connection timed out|failed to connect|temporary failure in name resolution|operation timed out/.test(text))
64
+ return "network";
65
+ if (/repository not found|not found|does not exist|authentication failed|permission denied|access denied|could not read username|terminal prompts disabled|403|401/.test(text))
66
+ return "not-found-or-forbidden";
67
+ return "unknown";
68
+ }
69
+ /**
70
+ * Removes embedded credentials from text before it is shown or logged.
71
+ *
72
+ * Git is necessarily invoked with the *raw* clone URL, and it echoes that URL
73
+ * back in its own error text — `fatal: repository 'https://user:token@host/x'
74
+ * not found`. Everything we surface interpolates that stderr, so a token in the
75
+ * URL would land in terminal output, and from there into scrollback, CI logs and
76
+ * support bundles. Only the registry copy was being sanitized.
77
+ *
78
+ * Two passes: an exact substitution of the raw URL when we have it, and a
79
+ * generic `//user:secret@` scrub for anything git spells differently (trailing
80
+ * `.git/`, percent-encoding, a redirect target we never passed in).
81
+ *
82
+ * The username class also excludes `:`, which is what makes this linear. An
83
+ * earlier form used `[^/@\s]*:[^/@\s]*@` and I argued it was safe because
84
+ * neither class can span the `@` that terminates them — true, and beside the
85
+ * point: neither class excluded `:` either, so a run of colons could be split
86
+ * between them in as many ways as there were colons. Measured on `//` followed
87
+ * by `a:` repeated, with no `@` to succeed on: 3.7 / 18.8 / 65.9 / 226.8 ms at
88
+ * 4k / 8k / 16k / 32k chars — quadratic. Excluding `:` from the first class
89
+ * fixes the split at the first colon; flat at 0.03 ms across the same inputs.
90
+ *
91
+ * Third time today that a regex I reasoned was linear was not, so: the guard on
92
+ * the credential-scrubbing path is itself measured, not argued.
93
+ */
94
+ const EMBEDDED_CREDENTIAL = /\/\/[^/@\s:]*:[^/@\s]*@/g;
95
+ /**
96
+ * Username-only userinfo on HTTP(S), e.g. `https://<token>@host/x`.
97
+ *
98
+ * Raised in review on #59: the pair form above misses a token passed as the
99
+ * username alone, which is a normal way to authenticate over HTTPS, so the
100
+ * scrub would have left it intact unless it happened to match `rawUrl` exactly.
101
+ *
102
+ * Restricted to `http:`/`https:` on purpose, and it is the same distinction
103
+ * `sanitizeCloneUrl` makes: over SSH the username is structural (`ssh://git@…`
104
+ * names the account), so blanket-redacting `//user@` would mangle SSH URLs in
105
+ * error text while protecting nothing. A *password* is secret under any scheme
106
+ * and is still caught by the pair pattern.
107
+ *
108
+ * Linear: the class excludes `/`, `@`, `:` and whitespace, so it cannot span
109
+ * either the `@` that terminates it or the `//` that begins the next attempt.
110
+ * Verified by measurement rather than by that argument — see the spec.
111
+ */
112
+ const HTTP_USERINFO = /(https?:\/\/)[^/@\s:]*@/gi;
113
+ export function redactSecrets(text, rawUrl) {
114
+ let out = text;
115
+ if (rawUrl?.includes("@"))
116
+ out = out.split(rawUrl).join(sanitizeCloneUrl(rawUrl));
117
+ out = out.replace(HTTP_USERINFO, "$1***@");
118
+ return out.replace(EMBEDDED_CREDENTIAL, "//***:***@");
119
+ }
120
+ /** Human-facing message for a failure kind, naming the identity actually used. */
121
+ function failureMessage(kind, url, stderr, rawUrl) {
122
+ const detail = redactSecrets(stderr, rawUrl).trim().split("\n").slice(-3).join(" ").slice(0, 400);
123
+ switch (kind) {
124
+ case "not-found-or-forbidden": {
125
+ // Git's own wording asserts non-existence ("repository ... does not
126
+ // exist", "Repository not found"), which is precisely the claim that
127
+ // cannot be supported: the server answers identically whether the
128
+ // repository is absent or merely invisible to these credentials.
129
+ // Passing that text through would launder a guess into a finding.
130
+ const neutral = detail
131
+ .replace(/does ?n[o']t exist/gi, "was not accessible")
132
+ .replace(/repository not found/gi, "repository not accessible");
133
+ return `knodin remote: ${url} was not found, or is not accessible with the credentials in use. Git reports both cases identically, so this is NOT evidence the repository is missing — retry with an account that has access (\`gh-as <account>\`) or an SSH remote. git reported: ${neutral}`;
134
+ }
135
+ case "network":
136
+ return `knodin remote: could not reach ${url}. ${detail}`;
137
+ case "disk":
138
+ return `knodin remote: ran out of disk while acquiring ${url}. ${detail}`;
139
+ case "interrupted":
140
+ return `knodin remote: acquiring ${url} was interrupted before it completed. ${detail}`;
141
+ default:
142
+ return `knodin remote: failed to acquire ${url}. ${detail}`;
143
+ }
144
+ }
145
+ /**
146
+ * Runs git, throwing a classified MirrorError on failure.
147
+ *
148
+ * `url` is the sanitized form used in messages. `rawUrl` is the credentialed
149
+ * form actually handed to git, supplied only so its secret can be scrubbed back
150
+ * out of git's own error text — never for display.
151
+ */
152
+ function runGit(args, url, cwd, rawUrl) {
153
+ const result = childProcess.spawnSync(gitExecutable(), args, {
154
+ cwd,
155
+ encoding: "utf8",
156
+ env: {
157
+ ...process.env,
158
+ // Never block on an interactive credential prompt: a hang is worse than
159
+ // a clean, classified failure.
160
+ GIT_TERMINAL_PROMPT: "0",
161
+ },
162
+ });
163
+ if (result.error)
164
+ throw new MirrorError("unknown", `${redactSecrets(result.error.message, rawUrl)} (${url})`);
165
+ if (result.status !== 0) {
166
+ const kind = classifyGitFailure(result.stderr ?? "", result.signal);
167
+ throw new MirrorError(kind, failureMessage(kind, url, result.stderr ?? "", rawUrl));
168
+ }
169
+ return result.stdout ?? "";
170
+ }
171
+ /** Recursively removes a directory, ignoring absence. */
172
+ function discard(target) {
173
+ fs.rmSync(target, { recursive: true, force: true });
174
+ }
175
+ /**
176
+ * Acquires a mirror of `url`.
177
+ *
178
+ * The clone lands in a staging directory and is renamed into place only once it
179
+ * has completed, so an interrupted acquisition can never leave a partial mirror
180
+ * that later reads as a complete one — the same failure shape as an empty graph
181
+ * that reads as "no results".
182
+ */
183
+ export function addMirror(url) {
184
+ const sanitized = sanitizeCloneUrl(url);
185
+ const staging = path.join(mirrorStagingRoot(), `pending-${process.pid}`);
186
+ fs.mkdirSync(mirrorStagingRoot(), { recursive: true });
187
+ discard(staging);
188
+ try {
189
+ // The raw `url` goes to git because it may carry the credentials that make
190
+ // the clone work; `sanitized` is what any message may show, and passing the
191
+ // raw form as the last argument lets its secret be scrubbed from git's own
192
+ // error text rather than echoed back to the terminal.
193
+ runGit(["clone", "--depth", "1", "--single-branch", url, staging], sanitized, undefined, url);
194
+ // Git records the clone URL verbatim in `.git/config`, so a token embedded
195
+ // in `url` would persist on disk inside the mirror — in a directory whose
196
+ // path `knodin remote list` prints — and be reused implicitly by every
197
+ // later `refresh`. Sanitizing the registry copy while leaving the real
198
+ // secret in the clone's config would be sanitization in name only.
199
+ //
200
+ // The cost is deliberate: `refresh` can no longer authenticate from a
201
+ // token baked into the URL, and falls back to whatever git would use
202
+ // anyway — an SSH key or a credential helper. When that is not configured,
203
+ // refresh fails with the existing not-found-or-forbidden message, which
204
+ // already points at `gh-as` and SSH remotes. An honest auth failure is
205
+ // better than a credential quietly living in ~/.knodin.
206
+ if (sanitized !== url)
207
+ runGit(["remote", "set-url", "origin", sanitized], sanitized, staging);
208
+ const identity = repositoryIdentity(staging);
209
+ const entry = mirrorEntryPath(identity);
210
+ const source = mirrorSourcePath(identity);
211
+ const existing = readMirrorRegistry().mirrors.find((m) => m.identity === identity);
212
+ if (existing && fs.existsSync(source)) {
213
+ discard(staging);
214
+ return { record: existing, alreadyPresent: true };
215
+ }
216
+ const sha = runGit(["rev-parse", "HEAD"], sanitized, staging).trim();
217
+ fs.mkdirSync(entry, { recursive: true });
218
+ fs.mkdirSync(mirrorStatePath(identity), { recursive: true });
219
+ discard(source);
220
+ fs.renameSync(staging, source);
221
+ fs.writeFileSync(path.join(entry, "README"), MIRROR_README);
222
+ const record = {
223
+ identity,
224
+ url: sanitized,
225
+ path: source,
226
+ fetchedAt: new Date().toISOString(),
227
+ sha,
228
+ readOnly: true,
229
+ };
230
+ const registry = readMirrorRegistry();
231
+ writeMirrorRegistry({
232
+ version: 1,
233
+ mirrors: [...registry.mirrors.filter((m) => m.identity !== identity), record],
234
+ });
235
+ return { record, alreadyPresent: false };
236
+ }
237
+ finally {
238
+ discard(staging);
239
+ }
240
+ }
241
+ /**
242
+ * All registered mirrors, newest fetch first.
243
+ *
244
+ * Byte order, not `localeCompare`. `fetchedAt` is ISO 8601, which sorts
245
+ * correctly lexicographically, and locale collation can treat punctuation as
246
+ * variable-weight — so the `-`, `:` and `T` separators are not guaranteed to be
247
+ * compared the same way on every host. Same reasoning as `comparePaths`: this
248
+ * output should not depend on the machine it ran on.
249
+ */
250
+ export function listMirrors() {
251
+ return [...readMirrorRegistry().mirrors].sort((left, right) => {
252
+ if (left.fetchedAt > right.fetchedAt)
253
+ return -1;
254
+ return left.fetchedAt < right.fetchedAt ? 1 : 0;
255
+ });
256
+ }
257
+ /** Removes a mirror's clone, its graph, and its registry entry. */
258
+ export function removeMirror(identity) {
259
+ const registry = readMirrorRegistry();
260
+ if (!registry.mirrors.some((mirror) => mirror.identity === identity))
261
+ return false;
262
+ discard(mirrorEntryPath(identity));
263
+ writeMirrorRegistry({
264
+ version: 1,
265
+ mirrors: registry.mirrors.filter((m) => m.identity !== identity),
266
+ });
267
+ return true;
268
+ }
269
+ /**
270
+ * Fetches the mirror's branch again and hard-resets the clone onto it. The graph
271
+ * is left alone; callers re-index. Returns the record with its new sha.
272
+ */
273
+ export function refreshMirror(identity) {
274
+ const registry = readMirrorRegistry();
275
+ const record = registry.mirrors.find((m) => m.identity === identity);
276
+ if (!record)
277
+ throw new MirrorError("unknown", `knodin remote: no mirror named ${identity}`);
278
+ const source = record.path;
279
+ if (!fs.existsSync(source))
280
+ throw new MirrorError("unknown", `knodin remote: mirror ${identity} is registered but its clone is missing; remove and re-add it`);
281
+ runGit(["fetch", "--depth", "1", "origin"], record.url, source);
282
+ runGit(["reset", "--hard", "FETCH_HEAD"], record.url, source);
283
+ const sha = runGit(["rev-parse", "HEAD"], record.url, source).trim();
284
+ const updated = { ...record, sha, fetchedAt: new Date().toISOString() };
285
+ writeMirrorRegistry({
286
+ version: 1,
287
+ mirrors: [...registry.mirrors.filter((m) => m.identity !== identity), updated],
288
+ });
289
+ return updated;
290
+ }
@@ -0,0 +1,157 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { compareBytesDescending } from "./compare.js";
6
+ const MINIMUM_NODE_MAJOR = 24;
7
+ function nodeMajor(version) {
8
+ const match = version?.trim().match(/^v?(\d+)(?:\.|$)/);
9
+ if (!match)
10
+ return undefined;
11
+ const major = Number(match[1]);
12
+ return Number.isSafeInteger(major) ? major : undefined;
13
+ }
14
+ function isSupported(version) {
15
+ return (nodeMajor(version) ?? 0) >= MINIMUM_NODE_MAJOR;
16
+ }
17
+ function sortVersionsDescending(left, right) {
18
+ const leftParts = left.replace(/^v/, "").split(".").map(Number);
19
+ const rightParts = right.replace(/^v/, "").split(".").map(Number);
20
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
21
+ const difference = (rightParts[index] ?? 0) - (leftParts[index] ?? 0);
22
+ if (difference !== 0)
23
+ return difference;
24
+ }
25
+ // Byte order: this tie-break decides which runtime the handoff selects, so it
26
+ // must not vary with the host locale. Descending, matching the numeric
27
+ // comparison above.
28
+ return compareBytesDescending(left, right);
29
+ }
30
+ function addPathCandidates(options, executableName, add) {
31
+ for (const directory of (options.env.PATH ?? "").split(options.pathApi.delimiter)) {
32
+ if (directory && options.pathApi.isAbsolute(directory))
33
+ add(options.pathApi.join(directory, executableName));
34
+ }
35
+ }
36
+ function defaultFnmRoot(options, appDataRoot) {
37
+ if (options.platform === "darwin")
38
+ return options.pathApi.join(options.homeDirectory, "Library", "Application Support", "fnm");
39
+ if (options.platform === "win32" && appDataRoot)
40
+ return options.pathApi.join(appDataRoot, "fnm");
41
+ return options.pathApi.join(options.homeDirectory, ".local", "share", "fnm");
42
+ }
43
+ function addPlatformCandidates(options, executableName, absoluteEnvRoot, add, addVersioned) {
44
+ if (options.platform === "darwin") {
45
+ add("/opt/homebrew/opt/node@24/bin/node");
46
+ add("/opt/homebrew/opt/node/bin/node");
47
+ add("/usr/local/opt/node@24/bin/node");
48
+ add("/usr/local/opt/node/bin/node");
49
+ }
50
+ if (options.platform === "win32") {
51
+ addVersioned(absoluteEnvRoot(options.env.NVM_HOME), []);
52
+ const programFilesRoot = absoluteEnvRoot(options.env.ProgramFiles);
53
+ add(programFilesRoot
54
+ ? options.pathApi.join(programFilesRoot, "nodejs", executableName)
55
+ : undefined);
56
+ }
57
+ }
58
+ function runtimeCandidates(options) {
59
+ const { env, homeDirectory, pathApi, platform } = options;
60
+ const executableName = platform === "win32" ? "node.exe" : "node";
61
+ const candidates = [];
62
+ const seen = new Set([options.currentExecutable]);
63
+ const add = (candidate) => {
64
+ if (!candidate || seen.has(candidate))
65
+ return;
66
+ seen.add(candidate);
67
+ candidates.push(candidate);
68
+ };
69
+ const addVersioned = (root, suffix) => {
70
+ if (!root || !pathApi.isAbsolute(root))
71
+ return;
72
+ for (const version of options.listDirectories(root).sort(sortVersionsDescending))
73
+ add(pathApi.join(root, version, ...suffix, executableName));
74
+ };
75
+ if (env.KNODIN_NODE_RUNTIME && !pathApi.isAbsolute(env.KNODIN_NODE_RUNTIME))
76
+ throw new Error("KNODIN_NODE_RUNTIME must be an absolute path to a Node.js executable.");
77
+ add(env.KNODIN_NODE_RUNTIME);
78
+ addPathCandidates(options, executableName, add);
79
+ const absoluteEnvRoot = (value) => value && pathApi.isAbsolute(value) ? value : undefined;
80
+ const miseRoot = absoluteEnvRoot(env.MISE_DATA_DIR) ?? pathApi.join(homeDirectory, ".local", "share", "mise");
81
+ const managerBinaryDirectory = platform === "win32" ? [] : ["bin"];
82
+ addVersioned(pathApi.join(miseRoot, "installs", "node"), managerBinaryDirectory);
83
+ addVersioned(pathApi.join(homeDirectory, ".mise", "installs", "node"), managerBinaryDirectory);
84
+ const nvmRoot = absoluteEnvRoot(env.NVM_DIR);
85
+ addVersioned(nvmRoot ? pathApi.join(nvmRoot, "versions", "node") : undefined, managerBinaryDirectory);
86
+ addVersioned(pathApi.join(homeDirectory, ".nvm", "versions", "node"), managerBinaryDirectory);
87
+ const appDataRoot = absoluteEnvRoot(env.APPDATA);
88
+ const fnmDefaultRoot = defaultFnmRoot(options, appDataRoot);
89
+ const fnmRoot = absoluteEnvRoot(env.FNM_DIR);
90
+ addVersioned(fnmRoot ? pathApi.join(fnmRoot, "node-versions") : undefined, platform === "win32" ? ["installation"] : ["installation", "bin"]);
91
+ addVersioned(pathApi.join(fnmDefaultRoot, "node-versions"), platform === "win32" ? ["installation"] : ["installation", "bin"]);
92
+ const asdfRoot = absoluteEnvRoot(env.ASDF_DATA_DIR) ?? pathApi.join(homeDirectory, ".asdf");
93
+ addVersioned(pathApi.join(asdfRoot, "installs", "nodejs"), managerBinaryDirectory);
94
+ const voltaRoot = absoluteEnvRoot(env.VOLTA_HOME) ?? pathApi.join(homeDirectory, ".volta");
95
+ addVersioned(pathApi.join(voltaRoot, "tools", "image", "node"), managerBinaryDirectory);
96
+ addPlatformCandidates(options, executableName, absoluteEnvRoot, add, addVersioned);
97
+ return candidates;
98
+ }
99
+ export function handoffToSupportedNodeRuntime(options) {
100
+ if (isSupported(options.currentVersion))
101
+ return { kind: "current" };
102
+ const explicitRuntime = options.env.KNODIN_NODE_RUNTIME;
103
+ if (explicitRuntime === options.currentExecutable)
104
+ throw new Error(`KNODIN_NODE_RUNTIME points to the active Node.js ${options.currentVersion} executable; Node.js 24 or newer is required.`);
105
+ for (const candidate of runtimeCandidates(options)) {
106
+ const version = options.probe(candidate);
107
+ if (!isSupported(version)) {
108
+ if (candidate === explicitRuntime) {
109
+ throw new Error(`KNODIN_NODE_RUNTIME points to ${candidate}, but it reports ${version ?? "no usable Node.js version"}; Node.js 24 or newer is required.`);
110
+ }
111
+ continue;
112
+ }
113
+ const result = options.spawn(candidate, options.launcherArguments, {
114
+ env: options.env,
115
+ stdio: "inherit",
116
+ });
117
+ if (result.error)
118
+ throw new Error(`Unable to launch Knodin with ${candidate}: ${result.error.message}`);
119
+ if (result.status === null && result.signal === null)
120
+ throw new Error(`Knodin's Node.js 24+ runtime at ${candidate} exited without a status.`);
121
+ return { kind: "relaunched", status: result.status, signal: result.signal };
122
+ }
123
+ throw new Error(`Knodin requires Node.js 24 or newer, but the active runtime is ${options.currentVersion}. Install Node.js 24+ with Homebrew or your version manager, or set KNODIN_NODE_RUNTIME=/absolute/path/to/node. Your project may remain pinned to Node.js 20.`);
124
+ }
125
+ function listDirectories(root) {
126
+ try {
127
+ return fs
128
+ .readdirSync(root, { withFileTypes: true })
129
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
130
+ .map((entry) => entry.name);
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ }
136
+ function probeNodeVersion(candidate) {
137
+ const result = spawnSync(candidate, ["-p", "process.versions.node"], {
138
+ encoding: "utf8",
139
+ stdio: ["ignore", "pipe", "ignore"],
140
+ timeout: 2_000,
141
+ });
142
+ return result.status === 0 ? result.stdout.trim() : undefined;
143
+ }
144
+ export function handoffCurrentProcessToSupportedNodeRuntime() {
145
+ return handoffToSupportedNodeRuntime({
146
+ currentExecutable: process.execPath,
147
+ currentVersion: process.versions.node,
148
+ env: process.env,
149
+ homeDirectory: os.homedir(),
150
+ platform: process.platform,
151
+ launcherArguments: process.argv.slice(1),
152
+ probe: probeNodeVersion,
153
+ spawn: (candidate, args, options) => spawnSync(candidate, args, options),
154
+ pathApi: path,
155
+ listDirectories,
156
+ });
157
+ }
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { resolveStateDir } from "./engine/state-paths.js";
4
5
  const DEFAULT_LINE_BUDGET = 200;
5
6
  const DEFAULT_BYTE_BUDGET = 16_384;
6
7
  const DEFAULT_INPUT_LIMIT = 16 * 1024 * 1024;
@@ -335,7 +336,7 @@ function percentReduction(input, output) {
335
336
  }
336
337
  function artifactDirectory(repo) {
337
338
  const resolvedRepo = fs.realpathSync(repo);
338
- const knodinDirectory = path.join(resolvedRepo, ".knodin");
339
+ const knodinDirectory = resolveStateDir(resolvedRepo);
339
340
  const outputDirectory = path.join(knodinDirectory, "output");
340
341
  for (const candidate of [knodinDirectory, outputDirectory]) {
341
342
  if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink())
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { encode } from "gpt-tokenizer/encoding/o200k_base";
5
+ import { compareBytes } from "./compare.js";
6
+ import { resolveDbPath } from "./engine/state-paths.js";
5
7
  export const TELEMETRY_TOKENIZER = "gpt-tokenizer@3.4.0:o200k_base";
6
8
  export const countOutputTokens = (value) => encode(value).length;
7
9
  export const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
@@ -56,11 +58,10 @@ function percent(baseline, optimized) {
56
58
  function baselineFiles(repo, operation, output) {
57
59
  if (!["query:file_summary", "query:batch_outline"].includes(operation))
58
60
  return [];
59
- const candidates = operation === "query:file_summary"
60
- ? [output.target]
61
- : Array.isArray(output.results)
62
- ? output.results.map((row) => row.file)
63
- : [];
61
+ const rows = Array.isArray(output.results)
62
+ ? output.results.map((row) => row.file)
63
+ : [];
64
+ const candidates = operation === "query:file_summary" ? [output.target] : rows;
64
65
  const root = fs.realpathSync(repo);
65
66
  const files = new Set();
66
67
  for (const candidate of candidates) {
@@ -79,7 +80,7 @@ function baselineFiles(repo, operation, output) {
79
80
  if (fs.statSync(real).isFile())
80
81
  files.add(real);
81
82
  }
82
- return [...files].sort();
83
+ return [...files].sort(compareBytes);
83
84
  }
84
85
  export function measureOutput(options) {
85
86
  const serialized = JSON.stringify(options.output);
@@ -104,7 +105,7 @@ export function measureOutput(options) {
104
105
  const availability = options.output.availability;
105
106
  const freshness = options.output.freshness;
106
107
  const fidelity = options.output.fidelity;
107
- const database = path.join(fs.realpathSync(options.repo), ".knodin", "db.sqlite");
108
+ const database = resolveDbPath(fs.realpathSync(options.repo));
108
109
  const latencyMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
109
110
  const outcome = (status !== undefined &&
110
111
  ["error", "failed", "unavailable", "blocked", "timeout"].includes(status)) ||
@@ -189,8 +190,10 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
189
190
  group.push(record);
190
191
  groups.set(record.operation, group);
191
192
  }
193
+ // Byte order throughout this report: the HTML is written to disk as an
194
+ // artifact, so its bytes must not depend on the host locale.
192
195
  const rows = [...groups]
193
- .sort(([left], [right]) => left.localeCompare(right))
196
+ .sort(([left], [right]) => compareBytes(left, right))
194
197
  .map(([operation, values]) => {
195
198
  const measured = values.filter(({ savings }) => savings !== null);
196
199
  const tokens = measured.reduce((sum, value) => sum + (value.savings?.tokens ?? 0), 0);
@@ -207,7 +210,7 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
207
210
  timeSeries.set(day, current);
208
211
  }
209
212
  const seriesRows = [...timeSeries]
210
- .sort(([left], [right]) => left.localeCompare(right))
213
+ .sort(([left], [right]) => compareBytes(left, right))
211
214
  .map(([day, value]) => `<tr><td>${escapeHtml(day)}</td><td>${value.calls}</td><td>${value.tokens}</td></tr>`)
212
215
  .join("");
213
216
  const repositories = new Map();
@@ -215,7 +218,7 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
215
218
  if (record.repositoryId)
216
219
  repositories.set(record.repositoryId, (repositories.get(record.repositoryId) ?? 0) + 1);
217
220
  const repositoryRows = [...repositories]
218
- .sort(([left], [right]) => left.localeCompare(right))
221
+ .sort(([left], [right]) => compareBytes(left, right))
219
222
  .map(([id, calls]) => `<tr><td>${escapeHtml(id)}</td><td>${calls}</td></tr>`)
220
223
  .join("");
221
224
  const html = `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>knodin telemetry</title><style>:root{color-scheme:light dark;--line:#d0d7de;--card:#f6f8fa}body{font:14px system-ui;margin:2rem;max-width:76rem}section{background:var(--card);padding:1rem;margin:1rem 0;border-radius:.5rem}table{border-collapse:collapse;width:100%}th,td{padding:.55rem;border-bottom:1px solid var(--line);text-align:left}@media(prefers-color-scheme:dark){:root{--line:#30363d;--card:#161b22}}</style><h1>knodin local telemetry</h1><p>Tokenizer: ${TELEMETRY_TOKENIZER}. Local, opt-in, metadata-only; source, raw output, command text, usernames, and paths are not persisted.</p><section><h2>Summary</h2><p>Total measured tokens avoided: ${summary.measuredTokensAvoided}</p><p>Calls: ${summary.calls}; successes: ${summary.successes}; errors: ${summary.errors}; measured baselines: ${summary.measuredBaselines}</p><p>Latency p50 / p95: ${summary.latencyP50Ms} / ${summary.latencyP95Ms} ms</p><p>Freshness failures: ${summary.freshnessFailures}; compression fidelity samples / p50: ${summary.compressionFidelitySamples} / ${summary.compressionFidelityP50}; indexing time: ${summary.indexingMs} ms; index storage: ${summary.indexStorageBytes} bytes</p></section><section><h2>Operation breakdown</h2><table><thead><tr><th>Operation</th><th>Calls</th><th>Errors</th><th>Measured baselines</th><th>Net tokens saved</th><th>p50 ms</th><th>p95 ms</th></tr></thead><tbody>${rows}</tbody></table></section><section><h2>Time series</h2><table><thead><tr><th>Day</th><th>Calls</th><th>Tokens avoided</th></tr></thead><tbody>${seriesRows}</tbody></table></section><section><h2>Repository breakdown</h2><table><thead><tr><th>Private repository id</th><th>Calls</th></tr></thead><tbody>${repositoryRows}</tbody></table></section><p>Use <code>knodin telemetry export</code> for the machine-readable evidence bundle and <code>knodin telemetry clear</code> to delete persisted records.</p>`;
@@ -315,7 +318,9 @@ export function telemetryStatus(repoPath, inputPath = DEFAULT_INPUT, retentionDa
315
318
  const dates = records
316
319
  .map(({ at }) => (typeof at === "string" ? at : null))
317
320
  .filter((value) => value !== null)
318
- .sort((left, right) => left.localeCompare(right));
321
+ // Byte order: `[0]` and `.at(-1)` below report the oldest and newest
322
+ // record, so this sort selects values rather than presenting them.
323
+ .sort(compareBytes);
319
324
  return {
320
325
  schemaVersion: 1,
321
326
  enabledByDefault: false,