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
@@ -22,12 +22,15 @@ function sandboxLiteral(value) {
22
22
  }
23
23
  function macosPolicy(repo, _executable) {
24
24
  const protectedRoots = ["/Users", "/Volumes", "/Network", "/private/tmp", "/private/var/folders"];
25
+ const protectedSubpaths = protectedRoots
26
+ .map((item) => `(subpath ${sandboxLiteral(item)})`)
27
+ .join(" ");
25
28
  return [
26
29
  "(version 1)",
27
30
  "(allow default)",
28
31
  "(deny network*)",
29
32
  "(deny process-fork)",
30
- `(deny file-read* ${protectedRoots.map((item) => `(subpath ${sandboxLiteral(item)})`).join(" ")})`,
33
+ `(deny file-read* ${protectedSubpaths})`,
31
34
  "(deny file-write*)",
32
35
  `(allow file-read* file-write* (subpath ${sandboxLiteral(repo)}) (literal "/dev/null"))`,
33
36
  ].join(" ");
@@ -54,10 +57,10 @@ function unavailable(profile, warning) {
54
57
  }
55
58
  function validateProfile(profileName, value) {
56
59
  if (!value || typeof value !== "object")
57
- throw new Error(`execution profile ${profileName} is invalid`);
60
+ throw new Error(`execution profile $profileNameis invalid`);
58
61
  const profile = value;
59
62
  if (!path.isAbsolute(profile.executable ?? ""))
60
- throw new Error(`execution profile ${profileName}: executable must be absolute`);
63
+ throw new Error(`execution profile $profileName: executable must be absolute`);
61
64
  if (!Array.isArray(profile.args) ||
62
65
  profile.args.some((arg) => typeof arg !== "string" || arg.includes("\0")))
63
66
  throw new Error(`execution profile ${profileName}: args must be a NUL-free string array`);
@@ -161,13 +164,15 @@ export async function runExecutionProfile(options) {
161
164
  retain: true,
162
165
  });
163
166
  const finalReason = reason;
164
- const status = finalReason === "timeout"
165
- ? "timed-out"
166
- : finalReason === "output-limit"
167
- ? "output-limit"
168
- : closed.code === 0
169
- ? "completed"
170
- : "failed";
167
+ // A non-zero exit only reads as "failed" once the bounded-run reasons that
168
+ // preempt it (deadline, output cap) have been ruled out.
169
+ let status;
170
+ if (finalReason === "timeout")
171
+ status = "timed-out";
172
+ else if (finalReason === "output-limit")
173
+ status = "output-limit";
174
+ else
175
+ status = closed.code === 0 ? "completed" : "failed";
171
176
  const result = {
172
177
  schemaVersion: 1,
173
178
  status,
@@ -540,6 +540,12 @@ function diagnosisStatus(resolvedCount, unresolvedCount, inputComplete, contextT
540
540
  return "partial";
541
541
  return "resolved";
542
542
  }
543
+ /** No diagnostics means nothing was assessed; a partial status must not read as exact. */
544
+ function confidenceLevel(diagnosticCount, status) {
545
+ if (diagnosticCount === 0)
546
+ return "refused";
547
+ return status === "resolved" ? "exact" : "qualified";
548
+ }
543
549
  export async function diagnoseFailure(graph, repo, request) {
544
550
  const maxDiagnostics = integer("maxDiagnostics", request.maxDiagnostics, DEFAULT_DIAGNOSTICS, 1, MAX_DIAGNOSTICS);
545
551
  const diagnosticOffset = integer("diagnosticOffset", request.diagnosticOffset, 0, 0, 1_000_000);
@@ -620,7 +626,7 @@ export async function diagnoseFailure(graph, repo, request) {
620
626
  },
621
627
  freshness: health.freshness,
622
628
  confidence: {
623
- level: diagnostics.length === 0 ? "refused" : status === "resolved" ? "exact" : "qualified",
629
+ level: confidenceLevel(diagnostics.length, status),
624
630
  reasons: confidenceReasons,
625
631
  },
626
632
  omissions: {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Deterministic force-directed layout.
3
+ *
4
+ * Coordinates are computed here, at artifact-generation time, rather than by a
5
+ * script in the rendered page. That keeps a generated artifact reproducible:
6
+ * the same graph over the same evidence lays out identically, so its SHA-256 is
7
+ * stable and the output is snapshot-testable. It also means the artifact needs
8
+ * no JavaScript and no external library, which keeps it self-contained.
9
+ *
10
+ * The simulation is a bounded Fruchterman-Reingold: repulsion between every
11
+ * pair, attraction along every edge, and a linearly decaying temperature that
12
+ * caps per-iteration displacement. Iteration count is fixed rather than
13
+ * convergence-driven so that runtime is a function of graph size alone.
14
+ */
15
+ import { mulberry32, seedFromString } from "./deterministic-random.js";
16
+ /** Square viewport side, in user units, that coordinates are fitted into. */
17
+ const VIEWPORT = 1000;
18
+ /** Fixed iteration count. Bounded work; not a convergence guarantee. */
19
+ const ITERATIONS = 300;
20
+ /** Fraction of the viewport a node may move on the first iteration. */
21
+ const INITIAL_TEMPERATURE = 0.1;
22
+ /** Minimum separation used when two nodes land on the same point. */
23
+ const EPSILON = 0.01;
24
+ /**
25
+ * Lays out `nodes` connected by `edges` inside a square viewport.
26
+ *
27
+ * `seed` should be derived from repository-scoped evidence (a commit id, say)
28
+ * so that unchanged evidence reproduces an identical layout. Edges naming a
29
+ * node that is not in `nodes` are ignored rather than throwing: callers cap
30
+ * node and edge counts independently, and a dangling edge is a truncation
31
+ * artifact, not a caller error.
32
+ */
33
+ export function layoutGraph(nodes, edges, seed) {
34
+ if (nodes.length === 0)
35
+ return { positions: [], viewport: VIEWPORT };
36
+ if (nodes.length === 1) {
37
+ return {
38
+ positions: [{ id: nodes[0].id, x: VIEWPORT / 2, y: VIEWPORT / 2 }],
39
+ viewport: VIEWPORT,
40
+ };
41
+ }
42
+ const random = mulberry32(seedFromString(seed));
43
+ const index = new Map();
44
+ for (let position = 0; position < nodes.length; position++)
45
+ index.set(nodes[position].id, position);
46
+ // Seeded initial placement. A deterministic ring with a jittered radius
47
+ // avoids the degenerate all-collinear start that a pure grid produces.
48
+ const xs = new Float64Array(nodes.length);
49
+ const ys = new Float64Array(nodes.length);
50
+ for (let i = 0; i < nodes.length; i++) {
51
+ const angle = (2 * Math.PI * i) / nodes.length;
52
+ const radius = (VIEWPORT / 2) * (0.35 + 0.6 * random());
53
+ xs[i] = VIEWPORT / 2 + radius * Math.cos(angle);
54
+ ys[i] = VIEWPORT / 2 + radius * Math.sin(angle);
55
+ }
56
+ // Resolve edges to index pairs once; drop any endpoint we do not hold.
57
+ const links = [];
58
+ for (const edge of edges) {
59
+ const from = index.get(edge.from);
60
+ const to = index.get(edge.to);
61
+ if (from === undefined || to === undefined || from === to)
62
+ continue;
63
+ links.push([from, to]);
64
+ }
65
+ // Ideal edge length: spread nodes evenly over the viewport area.
66
+ const k = Math.sqrt((VIEWPORT * VIEWPORT) / nodes.length);
67
+ const dx = new Float64Array(nodes.length);
68
+ const dy = new Float64Array(nodes.length);
69
+ const state = { count: nodes.length, xs, ys, dx, dy };
70
+ for (let iteration = 0; iteration < ITERATIONS; iteration++) {
71
+ dx.fill(0);
72
+ dy.fill(0);
73
+ accumulateRepulsion(state, k);
74
+ accumulateAttraction(state, links, k);
75
+ // Linear cooling caps displacement and lets the layout settle.
76
+ applyDisplacement(state, VIEWPORT * INITIAL_TEMPERATURE * (1 - iteration / ITERATIONS));
77
+ }
78
+ return { positions: fitToViewport(nodes, xs, ys), viewport: VIEWPORT };
79
+ }
80
+ /**
81
+ * Repulsion between every pair. O(n^2): callers cap node count, and the cap is
82
+ * small enough that a Barnes-Hut tree would cost more than it saves.
83
+ */
84
+ function accumulateRepulsion({ count, xs, ys, dx, dy }, k) {
85
+ for (let i = 0; i < count; i++) {
86
+ for (let j = i + 1; j < count; j++) {
87
+ let deltaX = xs[i] - xs[j];
88
+ let deltaY = ys[i] - ys[j];
89
+ let distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
90
+ if (distance < EPSILON) {
91
+ // Coincident nodes: nudge along a deterministic direction so the
92
+ // pair separates instead of dividing by ~zero forever.
93
+ deltaX = EPSILON;
94
+ deltaY = 0;
95
+ distance = EPSILON;
96
+ }
97
+ const force = (k * k) / distance;
98
+ const unitX = (deltaX / distance) * force;
99
+ const unitY = (deltaY / distance) * force;
100
+ dx[i] += unitX;
101
+ dy[i] += unitY;
102
+ dx[j] -= unitX;
103
+ dy[j] -= unitY;
104
+ }
105
+ }
106
+ }
107
+ /** Attraction along every resolved edge, pulling its endpoints together. */
108
+ function accumulateAttraction({ xs, ys, dx, dy }, links, k) {
109
+ for (const [from, to] of links) {
110
+ const deltaX = xs[from] - xs[to];
111
+ const deltaY = ys[from] - ys[to];
112
+ const distance = Math.max(Math.sqrt(deltaX * deltaX + deltaY * deltaY), EPSILON);
113
+ const force = (distance * distance) / k;
114
+ const unitX = (deltaX / distance) * force;
115
+ const unitY = (deltaY / distance) * force;
116
+ dx[from] -= unitX;
117
+ dy[from] -= unitY;
118
+ dx[to] += unitX;
119
+ dy[to] += unitY;
120
+ }
121
+ }
122
+ /**
123
+ * Moves every node along its accumulated displacement, capped at `temperature`
124
+ * so late iterations refine rather than reshuffle.
125
+ */
126
+ function applyDisplacement({ count, xs, ys, dx, dy }, temperature) {
127
+ for (let i = 0; i < count; i++) {
128
+ const magnitude = Math.max(Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]), EPSILON);
129
+ const step = Math.min(magnitude, temperature);
130
+ xs[i] += (dx[i] / magnitude) * step;
131
+ ys[i] += (dy[i] / magnitude) * step;
132
+ }
133
+ }
134
+ /**
135
+ * Rescales raw simulation output into the viewport with a uniform margin,
136
+ * preserving aspect ratio so the drawing is not distorted. Coordinates are
137
+ * rounded to two decimals: enough precision to draw, few enough digits to keep
138
+ * the artifact small and its bytes stable.
139
+ */
140
+ function fitToViewport(nodes, xs, ys) {
141
+ const margin = VIEWPORT * 0.04;
142
+ let minX = Number.POSITIVE_INFINITY;
143
+ let maxX = Number.NEGATIVE_INFINITY;
144
+ let minY = Number.POSITIVE_INFINITY;
145
+ let maxY = Number.NEGATIVE_INFINITY;
146
+ for (let i = 0; i < nodes.length; i++) {
147
+ if (xs[i] < minX)
148
+ minX = xs[i];
149
+ if (xs[i] > maxX)
150
+ maxX = xs[i];
151
+ if (ys[i] < minY)
152
+ minY = ys[i];
153
+ if (ys[i] > maxY)
154
+ maxY = ys[i];
155
+ }
156
+ const spanX = maxX - minX;
157
+ const spanY = maxY - minY;
158
+ const usable = VIEWPORT - 2 * margin;
159
+ // One shared scale keeps the aspect ratio; a zero span means every node
160
+ // collapsed onto one axis, so that axis simply centres.
161
+ const scale = Math.min(spanX > EPSILON ? usable / spanX : Number.POSITIVE_INFINITY, spanY > EPSILON ? usable / spanY : Number.POSITIVE_INFINITY);
162
+ const effective = Number.isFinite(scale) ? scale : 1;
163
+ const offsetX = margin + (usable - spanX * effective) / 2;
164
+ const offsetY = margin + (usable - spanY * effective) / 2;
165
+ return nodes.map((node, i) => ({
166
+ id: node.id,
167
+ x: round2((xs[i] - minX) * effective + offsetX),
168
+ y: round2((ys[i] - minY) * effective + offsetY),
169
+ }));
170
+ }
171
+ function round2(value) {
172
+ return Math.round(value * 100) / 100;
173
+ }
@@ -1,8 +1,9 @@
1
1
  import child_process from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { resolveStateDir } from "./engine/state-paths.js";
4
5
  function activityPath(repo) {
5
- return path.join(path.resolve(repo), ".knodin", "index-activity.json");
6
+ return path.join(resolveStateDir(repo), "index-activity.json");
6
7
  }
7
8
  const ACTIVITY_CLOCK_SKEW_MS = 2_000;
8
9
  const MAX_ACTIVITY_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
package/dist/src/init.js CHANGED
@@ -3,7 +3,9 @@ import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { configureProjectAgents, } from "./agent-integration.js";
6
+ import { compareBytes } from "./compare.js";
6
7
  import { isIndexableSourcePath } from "./engine/source-policy.js";
8
+ import { lookupMirror } from "./engine/state-paths.js";
7
9
  import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
8
10
  import { registerInitializedWorktree } from "./worktree-lifecycle.js";
9
11
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
@@ -131,11 +133,12 @@ function configureMutationAuditTargets(repo) {
131
133
  const managedHooks = path.join(common, "knodin-hooks");
132
134
  add({ absolute: defaultHooks, label: ".git/hooks" });
133
135
  add({ absolute: managedHooks, label: ".git/knodin-hooks" });
134
- const effectiveLabel = path.resolve(effectiveHooks) === path.resolve(defaultHooks)
135
- ? ".git/hooks"
136
- : path.resolve(effectiveHooks) === path.resolve(managedHooks)
137
- ? ".git/knodin-hooks"
138
- : "effective-git-hooks";
136
+ const resolvedEffectiveHooks = path.resolve(effectiveHooks);
137
+ let effectiveLabel = "effective-git-hooks";
138
+ if (resolvedEffectiveHooks === path.resolve(defaultHooks))
139
+ effectiveLabel = ".git/hooks";
140
+ else if (resolvedEffectiveHooks === path.resolve(managedHooks))
141
+ effectiveLabel = ".git/knodin-hooks";
139
142
  add({ absolute: effectiveHooks, label: effectiveLabel });
140
143
  for (const hookRoot of [
141
144
  { absolute: defaultHooks, label: ".git/hooks" },
@@ -225,21 +228,24 @@ function mutationSnapshot(targets, allowOversized = false) {
225
228
  return snapshot;
226
229
  }
227
230
  function filesystemChanges(before, after) {
228
- return [...new Set([...before.keys(), ...after.keys()])].sort().flatMap((changedPath) => {
231
+ return [...new Set([...before.keys(), ...after.keys()])]
232
+ .sort(compareBytes)
233
+ .flatMap((changedPath) => {
229
234
  const prior = before.get(changedPath);
230
235
  const current = after.get(changedPath);
231
236
  if (prior === current)
232
237
  return [];
233
- return [
234
- {
235
- path: changedPath,
236
- action: prior === undefined ? "created" : current === undefined ? "removed" : "modified",
237
- },
238
- ];
238
+ let action = "modified";
239
+ if (prior === undefined)
240
+ action = "created";
241
+ else if (current === undefined)
242
+ action = "removed";
243
+ return [{ path: changedPath, action }];
239
244
  });
240
245
  }
246
+ const SHELL_QUOTE_ESCAPE = String.raw `'\''`;
241
247
  function shellQuote(value) {
242
- return `'${value.replaceAll("'", `'\\''`)}'`;
248
+ return `'${value.replaceAll("'", SHELL_QUOTE_ESCAPE)}'`;
243
249
  }
244
250
  function nulPaths(repo, args) {
245
251
  const separator = args.indexOf("--");
@@ -310,7 +316,7 @@ export async function refreshFromGitEvent(repo, event, index) {
310
316
  }
311
317
  const eligible = [...new Set(changed.map((file) => file.replaceAll("\\", "/")))]
312
318
  .filter(isIndexableSourcePath)
313
- .sort();
319
+ .sort(compareBytes);
314
320
  if (eligible.length > 0)
315
321
  await index(resolvedRepo, eligible);
316
322
  return eligible;
@@ -322,7 +328,7 @@ export async function refreshFromGitEvent(repo, event, index) {
322
328
  }
323
329
  function backgroundScript(command) {
324
330
  const invocation = command.map(shellQuote).join(" ");
325
- return `#!/bin/sh
331
+ return String.raw `#!/bin/sh
326
332
  # knodin packaged background refresh. Generated by knodin init.
327
333
  set -u
328
334
  REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
@@ -342,9 +348,9 @@ if [ "$#" -gt 0 ]; then
342
348
  EVENT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
343
349
  {
344
350
  for EVENT_ARG in "$@"; do
345
- printf '%s\\n' "$EVENT_ARG"
351
+ printf '%s\n' "$EVENT_ARG"
346
352
  done
347
- printf 'sequence=%s\\nworktree=%s\\nat=%s\\n' "$EVENT_SEQUENCE" "$EVENT_WORKTREE" "$EVENT_AT"
353
+ printf 'sequence=%s\nworktree=%s\nat=%s\n' "$EVENT_SEQUENCE" "$EVENT_WORKTREE" "$EVENT_AT"
348
354
  } > "$EVENT_TMP"
349
355
  mv "$EVENT_TMP" "$EVENT_TMP.event"
350
356
  fi
@@ -361,7 +367,7 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
361
367
  exit 0
362
368
  fi
363
369
  fi
364
- printf '%s\\n' "$$" > "$LOCK_DIR/pid"
370
+ printf '%s\n' "$$" > "$LOCK_DIR/pid"
365
371
  trap 'rm -rf "$LOCK_DIR"; rm -f "$FAILURE_TMP"' EXIT HUP INT TERM
366
372
  LOG_PATH="$REPO_ROOT/.knodin/indexer.log"
367
373
  if [ -f "$LOG_PATH" ]; then
@@ -384,7 +390,7 @@ while :; do
384
390
  elif [ "$KIND" = "rewrite" ]; then
385
391
  ${invocation} --repo "$REPO_ROOT" hook-refresh rewrite "$ARG1" --json >> "$LOG_PATH" 2>&1
386
392
  else
387
- printf 'knodin background refresh: invalid queued event %s\\n' "$KIND" >> "$LOG_PATH"
393
+ printf 'knodin background refresh: invalid queued event %s\n' "$KIND" >> "$LOG_PATH"
388
394
  false
389
395
  fi
390
396
  STATUS=$?
@@ -393,9 +399,9 @@ while :; do
393
399
  rm -f "$EVENT" "$FAILURE_PATH"
394
400
  else
395
401
  {
396
- printf 'knodin background refresh failed with exit code %s.\\n' "$STATUS"
397
- printf 'Pending event retained at %s.\\n' "$EVENT"
398
- printf 'See .knodin/indexer.log for details.\\n'
402
+ printf 'knodin background refresh failed with exit code %s.\n' "$STATUS"
403
+ printf 'Pending event retained at %s.\n' "$EVENT"
404
+ printf 'See .knodin/indexer.log for details.\n'
399
405
  } > "$FAILURE_TMP"
400
406
  mv -f "$FAILURE_TMP" "$FAILURE_PATH"
401
407
  exit "$STATUS"
@@ -450,13 +456,14 @@ function drainQueuedLifecycleEvents(repo, backgroundPath) {
450
456
  return { state: "fresh", queuedEvents: 0 };
451
457
  }
452
458
  function managedHookScript(hookName, originalPath) {
453
- const original = originalPath
454
- ? originalPath.startsWith("repo:")
459
+ let original = "";
460
+ if (originalPath)
461
+ original = originalPath.startsWith("repo:")
455
462
  ? `"$REPO_ROOT"/${shellQuote(originalPath.slice("repo:".length))}`
456
- : shellQuote(originalPath)
457
- : "";
463
+ : shellQuote(originalPath);
464
+ const rewriteRedirect = hookName === "post-rewrite" ? ' < "$REWRITE_INPUT"' : "";
458
465
  const runOriginal = originalPath
459
- ? `KNODIN_WRAPPER_ACTIVE=1 ${original} "$@"${hookName === "post-rewrite" ? ' < "$REWRITE_INPUT"' : ""} || ORIGINAL_STATUS=$?`
466
+ ? `KNODIN_WRAPPER_ACTIVE=1 ${original} "$@"${rewriteRedirect} || ORIGINAL_STATUS=$?`
460
467
  : ":";
461
468
  let trigger = "";
462
469
  if (hookName === "post-commit")
@@ -664,14 +671,14 @@ async function prepareHookInstallation(repo) {
664
671
  }
665
672
  return { hooksDir, trackedHooksDir: relative };
666
673
  }
667
- const NUDGE_SCRIPT = `#!/bin/sh
674
+ const NUDGE_SCRIPT = String.raw `#!/bin/sh
668
675
  # Example Claude Code hook: suggest local graph context for broad manual searches.
669
676
  read -r INPUT_PAYLOAD
670
677
  case "$INPUT_PAYLOAD" in
671
678
  *grep*|*find*|*Glob*|*ReadFile*) REASON="For cold or unfamiliar work, call the connected knodin MCP gateway's context or explain operation before broad traversal; use the CLI only as a fallback." ;;
672
679
  *) REASON="Proceeding with tool execution." ;;
673
680
  esac
674
- printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\\n' "$REASON"
681
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' "$REASON"
675
682
  `;
676
683
  export const SETTINGS_TEMPLATE = `{
677
684
  "comment": "Template only: opt in to local knodin context nudges.",
@@ -733,11 +740,11 @@ async function installManagedAgentInstructions(repo, fileName = "AGENTS.md") {
733
740
  const canonicalEnd = "<!-- knodin:end -->";
734
741
  const start = existing.indexOf(canonicalStart);
735
742
  const end = existing.indexOf(canonicalEnd);
736
- const next = start >= 0 && end >= start
737
- ? `${existing.slice(0, start)}${KNODIN_AGENT_INSTRUCTIONS}${existing.slice(end + canonicalEnd.length).replace(/^\r?\n/, "")}`
738
- : existing.length > 0
739
- ? `${existing.trimEnd()}\n\n${KNODIN_AGENT_INSTRUCTIONS}`
740
- : KNODIN_AGENT_INSTRUCTIONS;
743
+ let next = KNODIN_AGENT_INSTRUCTIONS;
744
+ if (start >= 0 && end >= start)
745
+ next = `${existing.slice(0, start)}${KNODIN_AGENT_INSTRUCTIONS}${existing.slice(end + canonicalEnd.length).replace(/^\r?\n/, "")}`;
746
+ else if (existing.length > 0)
747
+ next = `${existing.trimEnd()}\n\n${KNODIN_AGENT_INSTRUCTIONS}`;
741
748
  if (next === existing)
742
749
  return null;
743
750
  await fs.promises.writeFile(instructionsPath, next, "utf-8");
@@ -772,8 +779,32 @@ function isTracked(repo, relativePath) {
772
779
  return false;
773
780
  }
774
781
  }
782
+ const EXCLUDE_BLOCK_START = "# knodin:start\n";
783
+ const EXCLUDE_BLOCK_END = "\n# knodin:end";
784
+ /**
785
+ * Drop the first knodin-owned exclude block, replacing it with the single
786
+ * newline the block's leading line terminator contributed. A literal scan keeps
787
+ * this linear: a start marker with no following end marker can never be rescued
788
+ * by a later start marker, because every later body begins further right.
789
+ */
790
+ function withoutManagedExcludeBlock(content) {
791
+ let start = content.indexOf(EXCLUDE_BLOCK_START);
792
+ while (start > 0 && content[start - 1] !== "\n")
793
+ start = content.indexOf(EXCLUDE_BLOCK_START, start + 1);
794
+ if (start < 0)
795
+ return content;
796
+ const end = content.indexOf(EXCLUDE_BLOCK_END, start + EXCLUDE_BLOCK_START.length);
797
+ if (end < 0)
798
+ return content;
799
+ let after = end + EXCLUDE_BLOCK_END.length;
800
+ if (content[after] === "\n")
801
+ after += 1;
802
+ return `${content.slice(0, start > 0 ? start - 1 : 0)}\n${content.slice(after)}`;
803
+ }
775
804
  async function excludeUntrackedPaths(repo, candidates) {
776
- const safe = [...new Set(candidates)].filter((candidate) => !isTracked(repo, candidate)).sort();
805
+ const safe = [...new Set(candidates)]
806
+ .filter((candidate) => !isTracked(repo, candidate))
807
+ .sort(compareBytes);
777
808
  if (safe.length === 0)
778
809
  return [];
779
810
  const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
@@ -790,7 +821,7 @@ async function excludeUntrackedPaths(repo, candidates) {
790
821
  }
791
822
  const startMarker = "# knodin:start";
792
823
  const endMarker = "# knodin:end";
793
- const outside = existing.replace(/(?:^|\n)# knodin:start\n[\s\S]*?\n# knodin:end\n?/, "\n");
824
+ const outside = withoutManagedExcludeBlock(existing);
794
825
  const outsideLines = new Set(outside.split(/\r?\n/));
795
826
  const owned = safe.filter((candidate) => !outsideLines.has(candidate));
796
827
  const prefix = outside && !outside.endsWith("\n") ? `${outside}\n` : outside;
@@ -813,7 +844,7 @@ async function removeManagedExcludes(repo) {
813
844
  return;
814
845
  throw error;
815
846
  }
816
- const next = existing.replace(/(?:^|\n)# knodin:start\n[\s\S]*?\n# knodin:end\n?/, "\n");
847
+ const next = withoutManagedExcludeBlock(existing);
817
848
  await fs.promises.writeFile(excludePath, next, "utf-8");
818
849
  }
819
850
  async function removeManagedPersonalArtifacts(repo, allowTrackedTransition = false) {
@@ -871,12 +902,10 @@ async function removeManagedPersonalArtifacts(repo, allowTrackedTransition = fal
871
902
  else if (next === head.trim())
872
903
  await fs.promises.writeFile(gitIgnorePath, head, "utf-8");
873
904
  }
874
- else {
875
- if (next)
876
- await fs.promises.writeFile(gitIgnorePath, `${next}\n`, "utf-8");
877
- else
878
- await fs.promises.rm(gitIgnorePath, { force: true });
879
- }
905
+ else if (next)
906
+ await fs.promises.writeFile(gitIgnorePath, `${next}\n`, "utf-8");
907
+ else
908
+ await fs.promises.rm(gitIgnorePath, { force: true });
880
909
  }
881
910
  catch {
882
911
  // Missing or user-modified files are intentionally preserved.
@@ -1021,6 +1050,17 @@ export async function initializeRepository(repo, options) {
1021
1050
  if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
1022
1051
  throw new Error(`knodin init: not a Git worktree: ${resolvedRepo}`);
1023
1052
  }
1053
+ // A mirror is a shallow clone, so the worktree check above PASSES for one and
1054
+ // init would go on to write .gitignore and install lifecycle hooks into a tree
1055
+ // that is supposed to stay byte-identical to the remote. `knodin remote list`
1056
+ // prints the clone path, which makes cd-ing there and running init a natural
1057
+ // mistake rather than a contrived one. The next refresh hard-resets the clone
1058
+ // and silently discards whatever was installed, leaving hooks that point at
1059
+ // files which no longer exist.
1060
+ const mirror = lookupMirror(resolvedRepo);
1061
+ if (mirror) {
1062
+ throw new Error(`knodin init: ${resolvedRepo} is a read-only mirror of ${mirror.url}, not a checkout. Mirrors carry no lifecycle hooks by design — their graph is built by \`knodin remote add\`/\`refresh\` and stored outside the clone. Nothing to initialize here.`);
1063
+ }
1024
1064
  if (!path.isAbsolute(options.command[0])) {
1025
1065
  throw new Error("knodin init: packaged runtime command must be an absolute path");
1026
1066
  }
@@ -1092,6 +1132,7 @@ export async function initializeRepository(repo, options) {
1092
1132
  const projectPathByAgent = {
1093
1133
  codex: ".codex/config.toml",
1094
1134
  gemini: ".gemini/settings.json",
1135
+ copilot: ".vscode/mcp.json",
1095
1136
  antigravity: ".agents/mcp_config.json",
1096
1137
  };
1097
1138
  const blockedAgents = [];
@@ -1102,7 +1143,7 @@ export async function initializeRepository(repo, options) {
1102
1143
  const cleanup = configureProjectAgents({
1103
1144
  repo: resolvedRepo,
1104
1145
  scope: "cli-only",
1105
- agents: ["claude", "codex", "gemini", "antigravity"],
1146
+ agents: ["claude", "codex", "gemini", "copilot", "antigravity"],
1106
1147
  run: options.runAgentCommand,
1107
1148
  });
1108
1149
  if (cleanup.failed.length > 0)
@@ -1169,7 +1210,7 @@ export async function initializeRepository(repo, options) {
1169
1210
  const managerExcludes = await excludeUntrackedPaths(resolvedRepo, [
1170
1211
  hookManagerIntegration.localConfig,
1171
1212
  ]);
1172
- excluded = [...new Set([...excluded, ...managerExcludes])].sort();
1213
+ excluded = [...new Set([...excluded, ...managerExcludes])].sort(compareBytes);
1173
1214
  }
1174
1215
  registerInitializedWorktree(resolvedRepo);
1175
1216
  const detectedFilesystemChanges = filesystemChanges(beforeMutation, mutationSnapshot(mutationTargets, true));
@@ -1,6 +1,8 @@
1
1
  import childProcess from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { compareBytes } from "./compare.js";
5
+ import { isMirror, resolveDbPath } from "./engine/state-paths.js";
4
6
  import { inspectLefthookIntegration, isActiveLefthookHook } from "./hook-manager-integration.js";
5
7
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
6
8
  const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
@@ -44,7 +46,8 @@ function readPendingEvent(repo) {
44
46
  const next = fs
45
47
  .readdirSync(directory)
46
48
  .filter((entry) => entry.endsWith(".event"))
47
- .sort((left, right) => left.localeCompare(right))[0];
49
+ // Byte order: `[0]` picks which queued event is reported as pending.
50
+ .sort(compareBytes)[0];
48
51
  return next ? readEvent(path.join(directory, next)) : null;
49
52
  }
50
53
  catch {
@@ -90,6 +93,18 @@ function activeHooksDirectory(repo) {
90
93
  const output = childProcess.execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-path", "hooks"], { cwd: repo, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
91
94
  return output.trim();
92
95
  }
96
+ /**
97
+ * Classifies how lifecycle hooks reach knodin. Both routes present reads as
98
+ * "mixed" rather than picking a winner, because that combination is what a
99
+ * partially-migrated repository looks like and the caller must be able to say so.
100
+ */
101
+ function hookRouting(wrappers, managerNative) {
102
+ if (wrappers > 0 && managerNative > 0)
103
+ return "mixed";
104
+ if (wrappers > 0)
105
+ return "managed-wrapper";
106
+ return managerNative > 0 ? "manager-native" : "unavailable";
107
+ }
93
108
  function inspectManagedHooks(repo, hooksDirectory) {
94
109
  const activeHooks = [];
95
110
  const missingHooks = [];
@@ -118,13 +133,7 @@ function inspectManagedHooks(repo, hooksDirectory) {
118
133
  activeHooks,
119
134
  missingHooks,
120
135
  hookManager: lefthook.configured ? "lefthook" : null,
121
- routing: wrappers > 0 && managerNative > 0
122
- ? "mixed"
123
- : wrappers > 0
124
- ? "managed-wrapper"
125
- : managerNative > 0
126
- ? "manager-native"
127
- : "unavailable",
136
+ routing: hookRouting(wrappers, managerNative),
128
137
  };
129
138
  }
130
139
  function readRefreshFailure(repo) {
@@ -142,7 +151,30 @@ function readRefreshFailure(repo) {
142
151
  /** Read-only proof that committed Git activity can still reach knodin's indexer. */
143
152
  export function inspectLifecycleHealth(repoPath) {
144
153
  const repo = path.resolve(repoPath);
145
- if (!fs.existsSync(path.join(repo, ".knodin", "db.sqlite"))) {
154
+ // A mirror deliberately has no lifecycle hooks: its working tree is replaced
155
+ // wholesale on refresh and nothing of ours may live inside it. Judging it
156
+ // against hook health would report "degraded" and demand `knodin init` for
157
+ // every mirror, forever — a permanent false alarm that teaches callers to
158
+ // ignore genuine repair prompts. Its freshness comes from the recorded
159
+ // snapshot, not from Git events.
160
+ if (isMirror(repo)) {
161
+ return {
162
+ status: "healthy",
163
+ refreshCapable: false,
164
+ hooksDirectory: null,
165
+ activeHooks: [],
166
+ missingHooks: [],
167
+ issues: [],
168
+ queuedEvents: 0,
169
+ processor: { state: "idle", pid: null },
170
+ pendingEvent: null,
171
+ lastEvent: null,
172
+ lastError: null,
173
+ hookManager: null,
174
+ routing: "unavailable",
175
+ };
176
+ }
177
+ if (!fs.existsSync(resolveDbPath(repo))) {
146
178
  return {
147
179
  status: "unconfigured",
148
180
  refreshCapable: false,