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
@@ -1,7 +1,9 @@
1
1
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- const LEVELS = ["locate", "outline", "evidence", "expand"];
4
+ import { compareBytes } from "./compare.js";
5
+ import { resolveStateDir } from "./engine/state-paths.js";
6
+ const LEVELS = new Set(["locate", "outline", "evidence", "expand"]);
5
7
  const DEFAULT_BYTES = 65_536;
6
8
  const DEFAULT_TOKENS = 16_384;
7
9
  const DEFAULT_ITEMS = 100;
@@ -37,7 +39,7 @@ function normalizeFile(repo, file) {
37
39
  return { absolute, relative, root };
38
40
  }
39
41
  function handleSecret(root) {
40
- const stateDirectory = path.join(root, ".knodin");
42
+ const stateDirectory = resolveStateDir(root);
41
43
  if (fs.existsSync(stateDirectory) && fs.lstatSync(stateDirectory).isSymbolicLink())
42
44
  throw new Error("progressive evidence refuses a symlinked .knodin directory");
43
45
  fs.mkdirSync(stateDirectory, { recursive: true, mode: 0o700 });
@@ -96,7 +98,7 @@ function decodeHandle(value, secret) {
96
98
  (candidate.relatedOffset ?? -1) < 0 ||
97
99
  !Number.isSafeInteger(candidate.outlineOffset) ||
98
100
  (candidate.outlineOffset ?? -1) < 0 ||
99
- !LEVELS.includes(candidate.nextLevel))
101
+ !LEVELS.has(candidate.nextLevel))
100
102
  throw new Error("invalid continuation handle");
101
103
  return candidate;
102
104
  }
@@ -142,7 +144,9 @@ function findRelated(root, target, lines) {
142
144
  const visit = (directory) => {
143
145
  for (const entry of fs
144
146
  .readdirSync(directory, { withFileTypes: true })
145
- .sort((a, b) => a.name.localeCompare(b.name))) {
147
+ // Byte order: this walk order fixes the related-evidence sequence that
148
+ // the integrity-checked paging handle indexes into across calls.
149
+ .sort((a, b) => compareBytes(a.name, b.name))) {
146
150
  if ([".git", ".knodin", "node_modules"].includes(entry.name) || entry.isSymbolicLink())
147
151
  continue;
148
152
  const absolute = path.join(directory, entry.name);
@@ -154,7 +158,7 @@ function findRelated(root, target, lines) {
154
158
  if (!entry.isFile() || file === target || !/\.(?:[cm]?[jt]sx?|py|java|cs|go|rs)$/.test(file))
155
159
  continue;
156
160
  for (const [index, evidence] of fs.readFileSync(absolute, "utf8").split("\n").entries()) {
157
- if (!names.some((name) => new RegExp(`\\b${name}\\b`).test(evidence)))
161
+ if (!names.some((name) => new RegExp(String.raw `\b${name}\b`).test(evidence)))
158
162
  continue;
159
163
  results.push({
160
164
  file,
@@ -195,7 +199,7 @@ function omissionPath(request, handle, currentHash, contentBytes, relative) {
195
199
  * content and a complete byte-length assertion.
196
200
  */
197
201
  export function deliverProgressiveEvidence(request) {
198
- if (!LEVELS.includes(request.level))
202
+ if (!LEVELS.has(request.level))
199
203
  throw new Error("invalid progressive evidence level");
200
204
  const { absolute, relative, root } = normalizeFile(request.repo, request.file);
201
205
  const handleKey = handleSecret(root);
@@ -290,12 +294,12 @@ export function deliverProgressiveEvidence(request) {
290
294
  returned = [...returned, "source"];
291
295
  nextLine = last + 1;
292
296
  more = last < lines.length || request.level !== "expand";
293
- omitted =
294
- last < lines.length
295
- ? ["source-tail", "related"]
296
- : request.level === "expand"
297
- ? []
298
- : ["related"];
297
+ if (last < lines.length)
298
+ omitted = ["source-tail", "related"];
299
+ else if (request.level === "expand")
300
+ omitted = [];
301
+ else
302
+ omitted = ["related"];
299
303
  reason = last < lines.length ? "hard-item-budget" : "requested-level";
300
304
  }
301
305
  totalItems = lines.length;
@@ -318,15 +322,13 @@ export function deliverProgressiveEvidence(request) {
318
322
  totalItems = lines.length + allRelated.length;
319
323
  }
320
324
  }
321
- const nextLevel = request.level === "locate"
322
- ? "outline"
323
- : request.level === "outline"
324
- ? outlineOffset + (outline?.length ?? 0) < outlineTotal
325
- ? "outline"
326
- : "evidence"
327
- : more && nextLine <= lines.length
328
- ? request.level
329
- : "expand";
325
+ let nextLevel;
326
+ if (request.level === "locate")
327
+ nextLevel = "outline";
328
+ else if (request.level === "outline")
329
+ nextLevel = outlineOffset + (outline?.length ?? 0) < outlineTotal ? "outline" : "evidence";
330
+ else
331
+ nextLevel = more && nextLine <= lines.length ? request.level : "expand";
330
332
  const evidenceHandle = encodeHandle({
331
333
  v: 1,
332
334
  path: relative,
@@ -448,11 +450,13 @@ export function deliverProgressiveEvidence(request) {
448
450
  response.reason = "hard-byte-budget";
449
451
  if (!response.omitted.includes("source-tail"))
450
452
  response.omitted.unshift("source-tail");
451
- const resumeLine = response.sourceRange?.endLine
452
- ? response.sourceRange.endLine + 1
453
- : continuationMismatch
454
- ? 1
455
- : (request.startLine ?? pagingHandle?.nextLine ?? 1);
453
+ let resumeLine;
454
+ if (response.sourceRange?.endLine)
455
+ resumeLine = response.sourceRange.endLine + 1;
456
+ else if (continuationMismatch)
457
+ resumeLine = 1;
458
+ else
459
+ resumeLine = request.startLine ?? pagingHandle?.nextLine ?? 1;
456
460
  response.continuation = encodeHandle({
457
461
  v: 1,
458
462
  path: relative,
@@ -36,10 +36,11 @@ function human(result) {
36
36
  const artifact = result.artifact.retained
37
37
  ? ` Retained artifact: ${result.artifact.id} (${result.artifact.path}).`
38
38
  : " Raw retention disabled; omitted regions cannot be retrieved.";
39
- return `${result.content ? `${result.content}\n` : ""}---\n${fidelity} ${result.output.lines}/${result.input.lines} lines, ${result.output.bytes}/${result.input.bytes} bytes; exit=${result.exit.code ?? "unknown"}, signal=${result.exit.signal ?? "none"}.${artifact}\n`;
39
+ const body = result.content ? `${result.content}\n` : "";
40
+ return `${body}---\n${fidelity} ${result.output.lines}/${result.input.lines} lines, ${result.output.bytes}/${result.input.bytes} bytes; exit=${result.exit.code ?? "unknown"}, signal=${result.exit.signal ?? "none"}.${artifact}\n`;
40
41
  }
41
42
  /** Handle only pure compression creation. Artifact reads/diagnosis retain the full CLI contract. */
42
- export async function tryPureCompressionFastPath(argv, cwd = process.cwd()) {
43
+ export async function tryPureCompressionFastPath(argv, cwd) {
43
44
  if (argv[0] !== "compress")
44
45
  return false;
45
46
  const valueFlags = new Set([
@@ -74,7 +75,7 @@ export async function tryPureCompressionFastPath(argv, cwd = process.cwd()) {
74
75
  "cargo",
75
76
  ].includes(adapter))
76
77
  return false;
77
- const repo = path.resolve(valueAfter(argv, "--repo") ?? cwd);
78
+ const repo = path.resolve(valueAfter(argv, "--repo") ?? cwd ?? process.cwd());
78
79
  const maxInputBytes = boundedInteger(argv, "--max-input-bytes", 16 * 1024 * 1024, 1, 64 * 1024 * 1024);
79
80
  const request = {
80
81
  exitCode: valueAfter(argv, "--exit-code")
@@ -2,12 +2,13 @@ import childProcess from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { compareBytes } from "./compare.js";
5
6
  import { gitExecutable } from "./git-executable.js";
6
7
  const MAX_FILES = 5_000;
7
8
  const MAX_CONTRACT_BYTES = 2 * 1024 * 1024;
8
9
  export function trackedRepositoryFiles(repository) {
9
10
  try {
10
- return childProcess
11
+ return (childProcess
11
12
  .execFileSync(gitExecutable(), ["ls-files", "-z"], {
12
13
  cwd: repository,
13
14
  encoding: "utf-8",
@@ -15,8 +16,10 @@ export function trackedRepositoryFiles(repository) {
15
16
  })
16
17
  .split("\0")
17
18
  .filter(Boolean)
18
- .sort((left, right) => left.localeCompare(right))
19
- .slice(0, MAX_FILES);
19
+ // Byte order: this sort decides which files survive the MAX_FILES cap,
20
+ // so collation would change which files are scanned at all.
21
+ .sort(compareBytes)
22
+ .slice(0, MAX_FILES));
20
23
  }
21
24
  catch {
22
25
  return [];
@@ -41,7 +44,7 @@ function packageFindings(file, content) {
41
44
  const dependencies = manifest[section];
42
45
  if (typeof dependencies !== "object" || dependencies === null)
43
46
  continue;
44
- for (const name of Object.keys(dependencies).sort((left, right) => left.localeCompare(right))) {
47
+ for (const name of Object.keys(dependencies).sort(compareBytes)) {
45
48
  findings.push({
46
49
  type: "depends_on",
47
50
  target: `pkg:npm/${encodeURIComponent(name)}`,
@@ -279,7 +282,9 @@ export function extractRepositoryRelationships(repositoryId, repository, knownPa
279
282
  });
280
283
  }
281
284
  }
282
- return relationships.sort((left, right) => `${left.from}\0${left.type}\0${left.to}\0${left.evidence.location}`.localeCompare(`${right.from}\0${right.type}\0${right.to}\0${right.evidence.location}`));
285
+ // Byte order over a NUL-joined composite key: collation may ignore the NUL
286
+ // separators entirely, which blurs the field boundaries this key depends on.
287
+ return relationships.sort((left, right) => compareBytes(`${left.from}\0${left.type}\0${left.to}\0${left.evidence.location}`, `${right.from}\0${right.type}\0${right.to}\0${right.evidence.location}`));
283
288
  }
284
289
  /** Stream a file hash so duplicate detection never buffers an entire artifact. */
285
290
  export async function hashFileStream(file) {
@@ -294,7 +299,7 @@ export async function hashFileStream(file) {
294
299
  }
295
300
  function filesGroupedBySize(repositories) {
296
301
  const bySize = new Map();
297
- const orderedRepositories = [...repositories].sort((left, right) => left.id.localeCompare(right.id));
302
+ const orderedRepositories = [...repositories].sort((left, right) => compareBytes(left.id, right.id));
298
303
  for (const repository of orderedRepositories) {
299
304
  for (const file of trackedRepositoryFiles(repository.path)) {
300
305
  const absolute = path.join(repository.path, file);
@@ -327,7 +332,9 @@ function duplicateRelationships(matches) {
327
332
  const repositoryIds = new Set(matches.map(({ repositoryId }) => repositoryId));
328
333
  if (repositoryIds.size < 2)
329
334
  return [];
330
- const ordered = [...matches].sort((left, right) => `${left.repositoryId}\0${left.file}`.localeCompare(`${right.repositoryId}\0${right.file}`));
335
+ // Byte order: `ordered[0]` becomes the canonical side of every emitted
336
+ // duplicate_candidate, so collation would change the relationship direction.
337
+ const ordered = [...matches].sort((left, right) => compareBytes(`${left.repositoryId}\0${left.file}`, `${right.repositoryId}\0${right.file}`));
331
338
  const canonical = ordered[0];
332
339
  if (!canonical)
333
340
  return [];
@@ -366,5 +373,5 @@ export async function detectDuplicateCandidates(repositories) {
366
373
  relationships.push(...duplicateRelationships(matches));
367
374
  }
368
375
  }
369
- return relationships.sort((left, right) => `${left.from}\0${left.to}\0${left.evidence.location}`.localeCompare(`${right.from}\0${right.to}\0${right.evidence.location}`));
376
+ return relationships.sort((left, right) => compareBytes(`${left.from}\0${left.to}\0${left.evidence.location}`, `${right.from}\0${right.to}\0${right.evidence.location}`));
370
377
  }
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { createHash, createPublicKey } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { compareBytes } from "./compare.js";
5
6
  import { validateRootCeremonyManifest } from "./update-ceremony.js";
6
7
  import { canonicalizeUpdateMetadata, verifyUpdateRootChain, } from "./update-trust.js";
7
8
  export const RELEASE_PREFLIGHT_DEPENDENCIES = [
@@ -21,6 +22,7 @@ export const RELEASE_PREFLIGHT_REPOSITORY_COMMANDS = [
21
22
  "npm run test",
22
23
  "npm run test:pack-install",
23
24
  "npm run check:roadmap",
25
+ "npm run verify:c95",
24
26
  ];
25
27
  export const RELEASE_PREFLIGHT_REPLAY_COMMANDS = {
26
28
  competitive: [
@@ -65,8 +67,8 @@ function record(value, label) {
65
67
  return value;
66
68
  }
67
69
  function exactKeys(value, expected, label) {
68
- const keys = Object.keys(value).sort();
69
- const wanted = [...expected].sort();
70
+ const keys = Object.keys(value).sort(compareBytes);
71
+ const wanted = [...expected].sort(compareBytes);
70
72
  if (keys.length !== wanted.length || keys.some((key, index) => key !== wanted[index]))
71
73
  fail(`${label} must contain exactly: ${wanted.join(", ")}`);
72
74
  }
@@ -317,8 +319,8 @@ function requireTaggedBytes(git, commit, relativePath, bytes, label) {
317
319
  fail(`${label} bytes do not equal the exact source-commit blob`);
318
320
  }
319
321
  function verifyRoadmap(roadmap) {
320
- const activeLedger = roadmap.match(/### Track A — Active local-work ledger\n[\s\S]*?\n\| ID \| State \| Previous state \| Depends on \| Exit evidence \|\n\|---\|---\|---\|---\|---\|\n((?:\| C[^\n]+\n)*)\n### Track B — Parked external-evidence ledger/);
321
- const parkedLedger = roadmap.match(/### Track B — Parked external-evidence ledger\n[\s\S]*?\n\| ID \| State \| Previous state \| Depends on \| Exit evidence \|\n\|---\|---\|---\|---\|---\|\n((?:\| C[^\n]+\n)*)\n### Goal-loop ordering/);
322
+ const activeLedger = /### Track A — Active local-work ledger\n[\s\S]*?\n\| ID \| State \| Previous state \| Depends on \| Exit evidence \|\n\|---\|---\|---\|---\|---\|\n((?:\| C[^\n]+\n)*)\n### Track B — Parked external-evidence ledger/.exec(roadmap);
323
+ const parkedLedger = /### Track B — Parked external-evidence ledger\n[\s\S]*?\n\| ID \| State \| Previous state \| Depends on \| Exit evidence \|\n\|---\|---\|---\|---\|---\|\n((?:\| C[^\n]+\n)*)\n### Goal-loop ordering/.exec(roadmap);
322
324
  if (!activeLedger || !parkedLedger)
323
325
  fail("two-track roadmap ledger is missing or malformed");
324
326
  const activeIds = new Set([...activeLedger[1].matchAll(/^\| (C\d+) \|/gm)].map((match) => match[1]));
@@ -330,17 +332,17 @@ function verifyRoadmap(roadmap) {
330
332
  if (activeIds.has(dependency))
331
333
  fail(`${dependency} remains in Track A`);
332
334
  const header = dependency === "C75" ? "C69–C75" : dependency;
333
- const section = roadmap.match(new RegExp(`(^#{2,3} ${header} —[^\\n]*\\n[\\s\\S]*?)(?=^#{2,3} C(?:\\d+|\\d+–C\\d+) —|(?![\\s\\S]))`, "m"));
335
+ const section = new RegExp(String.raw `(^#{2,3} ${header} —[^\n]*\n[\s\S]*?)(?=^#{2,3} C(?:\d+|\d+–C\d+) —|(?![\s\S]))`, "m").exec(roadmap);
334
336
  if (!section)
335
337
  fail(`${dependency} has no authoritative item status`);
336
- const statusMatch = section[1].match(/^- Status: ([^\n]+)$/m);
338
+ const statusMatch = /^- Status: ([^\n]+)$/m.exec(section[1]);
337
339
  if (!statusMatch)
338
340
  fail(`${dependency} has no authoritative item status`);
339
341
  const status = statusMatch[1].trim();
340
342
  if (!TERMINAL_STATUS.test(status))
341
343
  fail(`${dependency} has nonterminal status ${JSON.stringify(status)}`);
342
344
  const evidenceLabel = dependency === "C75" ? "C75 Evidence" : "Evidence";
343
- const evidenceField = section[1].match(new RegExp(`^- ${evidenceLabel}: ([\\s\\S]*?)(?=\\n\\n|\\n- [A-Z][^\\n]*:|(?![\\s\\S]))`, "m"));
345
+ const evidenceField = new RegExp(String.raw `^- ${evidenceLabel}: ([\s\S]*?)(?=\n\n|\n- [A-Z][^\n]*:|(?![\s\S]))`, "m").exec(section[1]);
344
346
  if (!evidenceField)
345
347
  fail(`${dependency} has no explicit authoritative evidence field`);
346
348
  const paths = [...evidenceField[1].matchAll(/`([^`]+)`/g)].map((match) => match[1]);
@@ -396,7 +398,8 @@ function verifyProductionTrust(plan, evidence) {
396
398
  for (const role of ["root", "targets", "snapshot", "timestamp"]) {
397
399
  const rootRole = rootEnvelope.signed.roles[role];
398
400
  if (rootRole.threshold !== manifest.thresholds[role] ||
399
- [...rootRole.keyids].sort().join("\0") !== [...manifest.roleKeyIds[role]].sort().join("\0"))
401
+ [...rootRole.keyids].sort(compareBytes).join("\0") !==
402
+ [...manifest.roleKeyIds[role]].sort(compareBytes).join("\0"))
400
403
  fail(`production ceremony ${role} role does not match the signed root`);
401
404
  }
402
405
  for (const key of Object.values(rootEnvelope.signed.keys)) {
@@ -477,8 +480,8 @@ export function evaluateReleasePreflight(options) {
477
480
  const authoritative = roadmapEvidence.get(dependency) ?? [];
478
481
  if (declared.length !== authoritative.length ||
479
482
  [...declared]
480
- .sort()
481
- .some((candidate, index) => candidate !== [...authoritative].sort()[index]))
483
+ .sort(compareBytes)
484
+ .some((candidate, index) => candidate !== [...authoritative].sort(compareBytes)[index]))
482
485
  fail(`${dependency} evidence paths must equal its complete authoritative Evidence field`);
483
486
  for (const relativePath of plan.dependencyEvidence[dependency]) {
484
487
  if (uniquePaths.has(relativePath))
@@ -0,0 +1,85 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const MAX_LEASE_BYTES = 4_096;
5
+ function leasePath(repo) {
6
+ return path.join(path.resolve(repo), ".knodin", "repair.lock");
7
+ }
8
+ function processIsAlive(pid) {
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ function readLease(target) {
18
+ try {
19
+ const stat = fs.statSync(target);
20
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_LEASE_BYTES)
21
+ return null;
22
+ const value = JSON.parse(fs.readFileSync(target, "utf8"));
23
+ if (value.schemaVersion !== 1 ||
24
+ !Number.isInteger(value.pid) ||
25
+ (value.pid ?? 0) <= 0 ||
26
+ typeof value.startedAt !== "string" ||
27
+ !Number.isFinite(Date.parse(value.startedAt)) ||
28
+ typeof value.nonce !== "string" ||
29
+ !value.nonce)
30
+ return null;
31
+ return value;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function writeLease(target, record) {
38
+ fs.writeFileSync(target, `${JSON.stringify(record)}\n`, {
39
+ encoding: "utf8",
40
+ flag: "wx",
41
+ mode: 0o600,
42
+ });
43
+ }
44
+ function removeStaleLeaseOrThrow(target) {
45
+ const current = readLease(target);
46
+ if (current && processIsAlive(current.pid)) {
47
+ throw new Error(`knodin repair: another repair is already in progress (pid ${current.pid}, started ${current.startedAt}); wait for it to finish, then run \`knodin status\``);
48
+ }
49
+ try {
50
+ fs.unlinkSync(target);
51
+ }
52
+ catch (error) {
53
+ if (error.code !== "ENOENT")
54
+ throw error;
55
+ }
56
+ }
57
+ /** Atomically serialize repair post-processing across independent CLI/MCP processes. */
58
+ export function acquireRepairLease(repo) {
59
+ const target = leasePath(repo);
60
+ fs.mkdirSync(path.dirname(target), { recursive: true });
61
+ const record = {
62
+ schemaVersion: 1,
63
+ pid: process.pid,
64
+ startedAt: new Date().toISOString(),
65
+ nonce: crypto.randomUUID(),
66
+ };
67
+ for (let attempt = 0; attempt < 2; attempt += 1) {
68
+ try {
69
+ writeLease(target, record);
70
+ return {
71
+ release() {
72
+ const current = readLease(target);
73
+ if (current?.nonce === record.nonce)
74
+ fs.rmSync(target, { force: true });
75
+ },
76
+ };
77
+ }
78
+ catch (error) {
79
+ if (error.code !== "EEXIST")
80
+ throw error;
81
+ removeStaleLeaseOrThrow(target);
82
+ }
83
+ }
84
+ throw new Error("knodin repair: could not acquire the repository repair lease; retry shortly");
85
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import process from "node:process";
3
- export const DEFAULT_REPOSITORY_INIT_MEMORY_LIMIT_BYTES = 768 * 1024 * 1024;
3
+ export const MINIMUM_REPOSITORY_INIT_MEMORY_LIMIT_BYTES = 128 * 1024 * 1024;
4
4
  const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
5
5
  const TERMINATION_GRACE_MS = 1_000;
6
6
  const RSS_POLL_MS = 250;
@@ -50,8 +50,10 @@ function isWorkerMessage(value) {
50
50
  * continue.
51
51
  */
52
52
  export async function runRepositoryInitializationProcess(options) {
53
- const memoryLimitBytes = options.memoryLimitBytes ?? DEFAULT_REPOSITORY_INIT_MEMORY_LIMIT_BYTES;
54
- if (!Number.isSafeInteger(memoryLimitBytes) || memoryLimitBytes < 128 * 1024 * 1024)
53
+ const { memoryLimitBytes } = options;
54
+ if (memoryLimitBytes !== undefined &&
55
+ (!Number.isSafeInteger(memoryLimitBytes) ||
56
+ memoryLimitBytes < MINIMUM_REPOSITORY_INIT_MEMORY_LIMIT_BYTES))
55
57
  throw new Error("repository init memory limit must be an integer of at least 128 MiB");
56
58
  if (options.command.length === 0 || !options.command[0])
57
59
  throw new Error("repository init worker command is empty");
@@ -66,10 +68,7 @@ export async function runRepositoryInitializationProcess(options) {
66
68
  const child = spawn(options.command[0], [...options.command.slice(1), "__repository-init-worker", options.repository], {
67
69
  cwd: options.repository,
68
70
  detached: process.platform !== "win32",
69
- env: {
70
- ...process.env,
71
- KNODIN_INTERNAL_INIT_MEMORY_LIMIT_BYTES: String(memoryLimitBytes),
72
- },
71
+ env: process.env,
73
72
  stdio: ["ignore", "ignore", "pipe", "ipc"],
74
73
  });
75
74
  child.stderr?.on("data", (chunk) => {
@@ -77,7 +76,7 @@ export async function runRepositoryInitializationProcess(options) {
77
76
  });
78
77
  const observeRss = (rssBytes) => {
79
78
  peakRssBytes = Math.max(peakRssBytes, rssBytes);
80
- if (rssBytes <= memoryLimitBytes || exceeded)
79
+ if (memoryLimitBytes === undefined || rssBytes <= memoryLimitBytes || exceeded)
81
80
  return;
82
81
  exceeded = true;
83
82
  const pid = child.pid ?? 0;
@@ -155,7 +154,12 @@ export async function runRepositoryInitializationProcess(options) {
155
154
  return;
156
155
  }
157
156
  if (result) {
158
- resolve({ ...result, repository: options.repository, peakRssBytes, memoryLimitBytes });
157
+ resolve({
158
+ ...result,
159
+ repository: options.repository,
160
+ peakRssBytes,
161
+ ...(memoryLimitBytes === undefined ? {} : { memoryLimitBytes }),
162
+ });
159
163
  return;
160
164
  }
161
165
  const diagnostic = stderr.trim().replace(/\s+/g, " ");
@@ -2,6 +2,7 @@ import child_process from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { compareBytes } from "./compare.js";
5
6
  import { gitExecutable } from "./git-executable.js";
6
7
  import { initializeRepository, isRepositoryInitializationCurrent, readRepositoryIntegrationConfig, } from "./init.js";
7
8
  import { applyResponseBudget } from "./response-budget.js";
@@ -217,7 +218,10 @@ export async function discoverRepositories(roots, options = {}) {
217
218
  const mainRepositories = legacy.repositories.filter((repository) => (mainByWorktree.get(repository) ?? repository) === repository);
218
219
  const topLevelRepositories = mainRepositories
219
220
  .filter((repository) => !mainRepositories.some((candidate) => candidate !== repository && repository.startsWith(`${candidate}${path.sep}`)))
220
- .sort((left, right) => left.localeCompare(right));
221
+ // Byte order: `topLevelRepositories.find(...)` below decides whether a
222
+ // repository is classified main-worktree or unrelated, so the order picks
223
+ // the winner rather than only presenting it.
224
+ .sort(compareBytes);
221
225
  const repositories = legacy.repositories
222
226
  .map((repository) => {
223
227
  const mainWorktree = mainByWorktree.get(repository) ?? repository;
@@ -250,8 +254,11 @@ export async function discoverRepositories(roots, options = {}) {
250
254
  "nested-repository": 2,
251
255
  "unrelated-repository": 3,
252
256
  };
257
+ // Byte order: this listing is item- and byte-budgeted downstream and its
258
+ // record order is folded into the inventory cursor hash, so collation would
259
+ // change which repositories a page returns.
253
260
  repositories.sort((left, right) => classificationOrder[left.classification] - classificationOrder[right.classification] ||
254
- left.path.localeCompare(right.path));
261
+ compareBytes(left.path, right.path));
255
262
  return {
256
263
  repositories,
257
264
  issues: legacy.issues,
@@ -291,6 +298,10 @@ function parseRepositoryPrefixedOrPositional(argument, index, cwd, command, stat
291
298
  state.manifestPath = path.resolve(cwd, argument.slice("--manifest=".length));
292
299
  return index;
293
300
  }
301
+ if (argument.startsWith("--memory-limit-mib=")) {
302
+ state.memoryLimitBytes = repositoryMemoryLimitBytes(argument.slice("--memory-limit-mib=".length));
303
+ return index;
304
+ }
294
305
  if (argument.startsWith("--"))
295
306
  throw new Error(`knodin repos: unknown option ${argument}`);
296
307
  if (command === "search" && state.query === undefined)
@@ -343,6 +354,9 @@ function parseRepositoryOption(args, index, cwd, command, state) {
343
354
  case "--manifest":
344
355
  state.manifestPath = path.resolve(cwd, requiredArgument(args, index, argument));
345
356
  return index + 1;
357
+ case "--memory-limit-mib":
358
+ state.memoryLimitBytes = repositoryMemoryLimitBytes(requiredArgument(args, index, argument));
359
+ return index + 1;
346
360
  case "--depth":
347
361
  state.depth = Number(requiredArgument(args, index, argument));
348
362
  return index + 1;
@@ -387,6 +401,8 @@ export function parseRepositoryCommandArgs(args, cwd) {
387
401
  throw new Error(`knodin repos ${command}: --dry-run applies only to init`);
388
402
  if (state.manifestPath && command !== "init")
389
403
  throw new Error(`knodin repos ${command}: --manifest applies only to init`);
404
+ if (state.memoryLimitBytes !== undefined && command !== "init")
405
+ throw new Error(`knodin repos ${command}: --memory-limit-mib applies only to init`);
390
406
  if (state.signals && command !== "discover")
391
407
  throw new Error(`knodin repos ${command}: --signals applies only to discover`);
392
408
  if (command !== "search" &&
@@ -405,6 +421,15 @@ export function parseRepositoryCommandArgs(args, cwd) {
405
421
  throw new Error("knodin repos discover: --cursor applies only to search");
406
422
  return { command, ...state };
407
423
  }
424
+ export function repositoryMemoryLimitBytes(value) {
425
+ const mib = typeof value === "number" ? value : Number(value);
426
+ if (!Number.isSafeInteger(mib) || mib < 128)
427
+ throw new Error("knodin repos init: --memory-limit-mib requires an integer of at least 128");
428
+ const bytes = mib * 1024 * 1024;
429
+ if (!Number.isSafeInteger(bytes))
430
+ throw new Error("knodin repos init: --memory-limit-mib exceeds the supported integer range");
431
+ return bytes;
432
+ }
408
433
  /**
409
434
  * Find real Git worktree roots without following child symlinks. Every supplied
410
435
  * root is resolved through realpath, so aliases are accepted but cannot create
@@ -504,7 +529,10 @@ export async function discoverGitRepositoryPaths(roots, depth = DEFAULT_DEPTH, w
504
529
  emptyRoots.push(root);
505
530
  }
506
531
  const orderedRepositories = [...repositories];
507
- orderedRepositories.sort((left, right) => left.localeCompare(right));
532
+ // Byte order: this array is hashed into the repository initialization
533
+ // signature, which is persisted in the resume manifest and compared on a
534
+ // later run — a differing order rejects the manifest outright.
535
+ orderedRepositories.sort(compareBytes);
508
536
  const orderedIssues = [...issues];
509
537
  orderedIssues.sort((left, right) => left.path.localeCompare(right.path));
510
538
  const orderedEmptyRoots = [...emptyRoots];
@@ -624,7 +652,9 @@ const LANGUAGE_BY_EXTENSION = {
624
652
  ".tsx": "typescript",
625
653
  };
626
654
  function insertBoundedCandidate(candidates, file) {
627
- const insertion = candidates.findIndex((candidate) => candidate.localeCompare(file) > 0);
655
+ // Byte order: the list is capped at MAX_DOCUMENT_CANDIDATES and the tail is
656
+ // dropped, so this comparison decides which candidates survive.
657
+ const insertion = candidates.findIndex((candidate) => compareBytes(candidate, file) > 0);
628
658
  if (insertion === -1)
629
659
  candidates.push(file);
630
660
  else
@@ -1,3 +1,4 @@
1
+ import { compareBytes } from "./compare.js";
1
2
  const encoder = new TextEncoder();
2
3
  export const MIN_RESPONSE_BUDGET_BYTES = 256;
3
4
  const MAX_REPORTED_TOTAL_PATHS = 128;
@@ -61,17 +62,18 @@ export function applyResponseBudget(value, operation, request, defaults) {
61
62
  }
62
63
  const itemLimit = positiveInt(request.items, defaults.items);
63
64
  const payload = structuredClone(value);
64
- const root = Array.isArray(payload)
65
- ? { results: payload }
66
- : payload && typeof payload === "object"
67
- ? payload
68
- : { result: payload };
65
+ const objectRoot = payload && typeof payload === "object"
66
+ ? payload
67
+ : { result: payload };
68
+ const root = Array.isArray(payload) ? { results: payload } : objectRoot;
69
69
  const totals = {};
70
70
  let truncated = false;
71
71
  const initialArrays = collectArrays(root);
72
72
  const reportedTotals = [...initialArrays]
73
+ // Byte order: this tie-break decides which paths survive the slice below,
74
+ // and the reported totals are serialized into the response.
73
75
  .sort((left, right) => left.path.split("/").length - right.path.split("/").length ||
74
- left.path.localeCompare(right.path))
76
+ compareBytes(left.path, right.path))
75
77
  .slice(0, MAX_REPORTED_TOTAL_PATHS);
76
78
  for (const entry of reportedTotals)
77
79
  totals[entry.path] = entry.value.length;