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.
- package/README.md +18 -3
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +371 -66
- package/dist/bin/launcher.js +16 -1
- package/dist/src/agent-integration.js +82 -16
- package/dist/src/artifact-refresh.js +2 -1
- package/dist/src/cli-args.js +19 -1
- package/dist/src/cli-model.js +28 -2
- package/dist/src/codeflow-replay.js +2 -1
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-constraints.js +2 -1
- package/dist/src/competitive-runner.js +4 -4
- package/dist/src/context-export.js +3 -2
- package/dist/src/context.js +1 -1
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1160 -133
- package/dist/src/doctor.js +3 -1
- package/dist/src/engine/ann-hnsw.js +2 -12
- package/dist/src/engine/file-walker.js +8 -2
- package/dist/src/engine/git-history.js +12 -12
- package/dist/src/engine/index.js +1174 -313
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +28 -13
- package/dist/src/engine/source-policy.js +16 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/execution-profile.js +15 -10
- package/dist/src/failure-diagnosis.js +7 -1
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/index-activity.js +2 -1
- package/dist/src/init.js +86 -45
- package/dist/src/lifecycle-health.js +41 -9
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +2 -1
- package/dist/src/output-telemetry.js +16 -11
- package/dist/src/progressive-evidence.js +30 -26
- package/dist/src/pure-compression-cli.js +4 -3
- package/dist/src/relationship-adapters.js +15 -8
- package/dist/src/release-preflight.js +13 -10
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repository-init-process.js +13 -9
- package/dist/src/repository-management.js +34 -4
- package/dist/src/response-budget.js +8 -6
- package/dist/src/server.js +80 -35
- package/dist/src/structural-fast-path.js +16 -10
- package/dist/src/structural-snapshot.js +6 -2
- package/dist/src/system-config.js +25 -2
- package/dist/src/tools/knodin-tools.js +142 -31
- package/dist/src/update-ceremony.js +9 -5
- package/dist/src/update-trust.js +5 -4
- package/dist/src/visualization.js +372 -19
- package/dist/src/worktree-lifecycle.js +5 -2
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/DIAGNOSTICS.md +46 -11
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -2
- package/docs/MCP.md +59 -8
- package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +5 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/releases/0.5.1.md +4 -4
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +17 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +1 -1
- 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* ${
|
|
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 $
|
|
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 $
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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(
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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()])]
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
351
|
+
printf '%s\n' "$EVENT_ARG"
|
|
346
352
|
done
|
|
347
|
-
printf 'sequence=%s
|
|
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
|
|
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
|
|
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
|
|
397
|
-
printf 'Pending event retained at %s
|
|
398
|
-
printf 'See .knodin/indexer.log for details
|
|
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
|
-
|
|
454
|
-
|
|
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} "$@"${
|
|
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"}}
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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)]
|
|
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
|
|
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
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|