knodin 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +590 -0
- package/dist/bin/cli.js +1704 -0
- package/dist/src/agent-integration.js +250 -0
- package/dist/src/artifact-refresh.js +81 -0
- package/dist/src/cli-args.js +267 -0
- package/dist/src/cli-model.js +324 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/competitive-constraints.js +20 -0
- package/dist/src/competitive-manifest.js +330 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +453 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +422 -0
- package/dist/src/context.js +102 -0
- package/dist/src/docs-sections.js +141 -0
- package/dist/src/doctor.js +380 -0
- package/dist/src/engine/ann-hnsw.js +271 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +43 -0
- package/dist/src/engine/index.js +13030 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/source-policy.js +69 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/failure-diagnosis.js +590 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/index-activity.js +125 -0
- package/dist/src/init-progress-worker.js +107 -0
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +985 -0
- package/dist/src/lifecycle-health.js +213 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/output-compression.js +629 -0
- package/dist/src/output-telemetry.js +359 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/relationship-adapters.js +370 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/repair-progress-worker.js +121 -0
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +173 -0
- package/dist/src/repository-management.js +1089 -0
- package/dist/src/response-budget.js +184 -0
- package/dist/src/server.js +53 -0
- package/dist/src/system-config.js +615 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1438 -0
- package/dist/src/tools/reckon-tools.js +5 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +503 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +162 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +231 -0
- package/docs/CLI.md +39 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
- package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
- package/docs/DOCTOR-AND-UPDATES.md +84 -0
- package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
- package/docs/INSTALLATION.md +208 -0
- package/docs/MCP.md +100 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
- package/docs/SIGNED-UPDATES.md +146 -0
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
- package/docs/TELEMETRY.md +42 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/package.json +110 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/tree-sitter-prisma.wasm +0 -0
- package/tree-sitter-sql.wasm +0 -0
- package/tree-sitter-xml.wasm +0 -0
package/dist/src/init.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import child_process from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { configureProjectAgents, } from "./agent-integration.js";
|
|
5
|
+
import { isIndexableSourcePath } from "./engine/source-policy.js";
|
|
6
|
+
import { registerInitializedWorktree } from "./worktree-lifecycle.js";
|
|
7
|
+
const MANAGED_MARKER = "KNODIN MANAGED HOOK";
|
|
8
|
+
const LEGACY_MANAGED_MARKER = "RECKON-GRAPH MANAGED HOOK";
|
|
9
|
+
const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
|
|
10
|
+
const HOOK_LOG_MAX_BYTES = 1_048_576;
|
|
11
|
+
const HOOK_FAILURE_FILE = "last-refresh-failure";
|
|
12
|
+
export class InitializationHealthError extends Error {
|
|
13
|
+
indexResult;
|
|
14
|
+
constructor(indexResult) {
|
|
15
|
+
const firstIssue = indexResult.verification.missing.files[0] ?? indexResult.verification.missing.records[0];
|
|
16
|
+
const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
|
|
17
|
+
super(`knodin init: lifecycle hooks were installed, but ${indexResult.verification.issueCount.toLocaleString()} graph issue(s) remain.${detail} Run \`knodin repair\`.`);
|
|
18
|
+
this.name = "InitializationHealthError";
|
|
19
|
+
this.indexResult = indexResult;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isIndexResult(value) {
|
|
23
|
+
if (typeof value !== "object" || value === null)
|
|
24
|
+
return false;
|
|
25
|
+
const candidate = value;
|
|
26
|
+
const verification = candidate.verification;
|
|
27
|
+
return (Array.isArray(candidate.indexed) &&
|
|
28
|
+
Array.isArray(candidate.unchanged) &&
|
|
29
|
+
typeof verification === "object" &&
|
|
30
|
+
verification !== null &&
|
|
31
|
+
typeof verification.status === "string" &&
|
|
32
|
+
typeof verification.issueCount === "number" &&
|
|
33
|
+
Array.isArray(verification.missing?.files) &&
|
|
34
|
+
Array.isArray(verification.missing?.records));
|
|
35
|
+
}
|
|
36
|
+
function runGit(repo, args, encoding = "utf-8") {
|
|
37
|
+
return child_process.execFileSync("git", args, {
|
|
38
|
+
cwd: repo,
|
|
39
|
+
encoding,
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function shellQuote(value) {
|
|
44
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
45
|
+
}
|
|
46
|
+
function nulPaths(repo, args) {
|
|
47
|
+
const separator = args.indexOf("--");
|
|
48
|
+
const nulArgs = separator < 0 ? [...args, "-z"] : [...args.slice(0, separator), "-z", ...args.slice(separator)];
|
|
49
|
+
const output = runGit(repo, nulArgs, "utf-8");
|
|
50
|
+
return output.split("\0").filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
function diffPaths(repo, before, after) {
|
|
53
|
+
if (!/^[0-9a-fA-F]{40,64}$/.test(before) || !/^[0-9a-fA-F]{40,64}$/.test(after)) {
|
|
54
|
+
throw new Error("knodin hook refresh: invalid Git object id");
|
|
55
|
+
}
|
|
56
|
+
return nulPaths(repo, ["diff", "--name-only", before, after, "--"]);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve changed paths from a Git lifecycle event and incrementally reconcile
|
|
60
|
+
* exactly the candidates accepted by the engine's full-repository policy.
|
|
61
|
+
*/
|
|
62
|
+
export async function refreshFromGitEvent(repo, event, index) {
|
|
63
|
+
const resolvedRepo = path.resolve(repo);
|
|
64
|
+
let changed = [];
|
|
65
|
+
let rewriteInputToRemove;
|
|
66
|
+
try {
|
|
67
|
+
if (event.kind === "commit") {
|
|
68
|
+
changed = nulPaths(resolvedRepo, [
|
|
69
|
+
"diff-tree",
|
|
70
|
+
"--root",
|
|
71
|
+
"--no-commit-id",
|
|
72
|
+
"--name-only",
|
|
73
|
+
"-r",
|
|
74
|
+
"HEAD",
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
else if (event.kind === "checkout" || event.kind === "merge") {
|
|
78
|
+
changed = diffPaths(resolvedRepo, event.before, event.after);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const hooksRoot = path.join(resolvedRepo, ".reckon", "hooks");
|
|
82
|
+
const inputPath = path.resolve(event.inputPath);
|
|
83
|
+
const relativeInput = path.relative(hooksRoot, inputPath);
|
|
84
|
+
if (relativeInput === "" ||
|
|
85
|
+
relativeInput === ".." ||
|
|
86
|
+
relativeInput.startsWith(`..${path.sep}`) ||
|
|
87
|
+
path.isAbsolute(relativeInput)) {
|
|
88
|
+
throw new Error("knodin hook refresh: rewrite input must be inside .reckon/hooks");
|
|
89
|
+
}
|
|
90
|
+
const [realHooksRoot, realInputPath, inputStat] = await Promise.all([
|
|
91
|
+
fs.promises.realpath(hooksRoot),
|
|
92
|
+
fs.promises.realpath(inputPath),
|
|
93
|
+
fs.promises.lstat(inputPath),
|
|
94
|
+
]);
|
|
95
|
+
const realRelativeInput = path.relative(realHooksRoot, realInputPath);
|
|
96
|
+
if (inputStat.isSymbolicLink() ||
|
|
97
|
+
!inputStat.isFile() ||
|
|
98
|
+
realRelativeInput === "" ||
|
|
99
|
+
realRelativeInput === ".." ||
|
|
100
|
+
realRelativeInput.startsWith(`..${path.sep}`) ||
|
|
101
|
+
path.isAbsolute(realRelativeInput)) {
|
|
102
|
+
throw new Error("knodin hook refresh: rewrite input must be a regular file inside .reckon/hooks");
|
|
103
|
+
}
|
|
104
|
+
rewriteInputToRemove = inputPath;
|
|
105
|
+
const pairs = (await fs.promises.readFile(inputPath, "utf-8"))
|
|
106
|
+
.split(/\r?\n/)
|
|
107
|
+
.map((line) => line.trim().split(/\s+/))
|
|
108
|
+
.filter((pair) => pair.length >= 2);
|
|
109
|
+
for (const [before, after] of pairs) {
|
|
110
|
+
changed.push(...diffPaths(resolvedRepo, before, after));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const eligible = [...new Set(changed.map((file) => file.replaceAll("\\", "/")))]
|
|
114
|
+
.filter(isIndexableSourcePath)
|
|
115
|
+
.sort();
|
|
116
|
+
if (eligible.length > 0)
|
|
117
|
+
await index(resolvedRepo, eligible);
|
|
118
|
+
return eligible;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
if (rewriteInputToRemove)
|
|
122
|
+
await fs.promises.rm(rewriteInputToRemove, { force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function backgroundScript(command) {
|
|
126
|
+
const invocation = command.map(shellQuote).join(" ");
|
|
127
|
+
return `#!/bin/sh
|
|
128
|
+
# knodin packaged background refresh. Generated by knodin init.
|
|
129
|
+
set -u
|
|
130
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
131
|
+
[ -n "$REPO_ROOT" ] || exit 0
|
|
132
|
+
cd "$REPO_ROOT" || exit 0
|
|
133
|
+
mkdir -p "$REPO_ROOT/.reckon"
|
|
134
|
+
LOCK_DIR="$REPO_ROOT/.reckon/hooks/refresh.lock"
|
|
135
|
+
EVENT_DIR="$REPO_ROOT/.reckon/hooks/events"
|
|
136
|
+
LAST_EVENT_PATH="$REPO_ROOT/.reckon/hooks/last-refresh-event"
|
|
137
|
+
FAILURE_PATH="$REPO_ROOT/.reckon/hooks/${HOOK_FAILURE_FILE}"
|
|
138
|
+
FAILURE_TMP="$FAILURE_PATH.$$"
|
|
139
|
+
mkdir -p "$EVENT_DIR"
|
|
140
|
+
if [ "$#" -gt 0 ]; then
|
|
141
|
+
EVENT_TMP="$EVENT_DIR/$(date +%s).$$.$PPID.compat.tmp"
|
|
142
|
+
EVENT_SEQUENCE="$(date +%s).$$.$PPID"
|
|
143
|
+
EVENT_WORKTREE="$REPO_ROOT"
|
|
144
|
+
EVENT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
145
|
+
{
|
|
146
|
+
for EVENT_ARG in "$@"; do
|
|
147
|
+
printf '%s\\n' "$EVENT_ARG"
|
|
148
|
+
done
|
|
149
|
+
printf 'sequence=%s\\nworktree=%s\\nat=%s\\n' "$EVENT_SEQUENCE" "$EVENT_WORKTREE" "$EVENT_AT"
|
|
150
|
+
} > "$EVENT_TMP"
|
|
151
|
+
mv "$EVENT_TMP" "$EVENT_TMP.event"
|
|
152
|
+
fi
|
|
153
|
+
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
154
|
+
if [ -f "$LOCK_DIR/pid" ]; then
|
|
155
|
+
LOCK_PID="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
|
|
156
|
+
if [ -n "$LOCK_PID" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
157
|
+
rm -rf "$LOCK_DIR"
|
|
158
|
+
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
|
|
159
|
+
else
|
|
160
|
+
exit 0
|
|
161
|
+
fi
|
|
162
|
+
else
|
|
163
|
+
exit 0
|
|
164
|
+
fi
|
|
165
|
+
fi
|
|
166
|
+
printf '%s\\n' "$$" > "$LOCK_DIR/pid"
|
|
167
|
+
trap 'rm -rf "$LOCK_DIR"; rm -f "$FAILURE_TMP"' EXIT HUP INT TERM
|
|
168
|
+
LOG_PATH="$REPO_ROOT/.reckon/indexer.log"
|
|
169
|
+
if [ -f "$LOG_PATH" ]; then
|
|
170
|
+
LOG_BYTES="$(wc -c < "$LOG_PATH" 2>/dev/null | tr -d '[:space:]')"
|
|
171
|
+
case "$LOG_BYTES" in ''|*[!0-9]*) LOG_BYTES=0 ;; esac
|
|
172
|
+
if [ "$LOG_BYTES" -ge ${HOOK_LOG_MAX_BYTES} ]; then
|
|
173
|
+
mv -f "$LOG_PATH" "$LOG_PATH.1"
|
|
174
|
+
fi
|
|
175
|
+
fi
|
|
176
|
+
while :; do
|
|
177
|
+
EVENT="$(find "$EVENT_DIR" -type f -name '*.event' -print 2>/dev/null | LC_ALL=C sort | head -n 1)"
|
|
178
|
+
[ -n "$EVENT" ] || break
|
|
179
|
+
KIND="$(sed -n '1p' "$EVENT")"
|
|
180
|
+
ARG1="$(sed -n '2p' "$EVENT")"
|
|
181
|
+
ARG2="$(sed -n '3p' "$EVENT")"
|
|
182
|
+
if [ "$KIND" = "commit" ]; then
|
|
183
|
+
${invocation} --repo "$REPO_ROOT" hook-refresh commit --json >> "$LOG_PATH" 2>&1
|
|
184
|
+
elif [ "$KIND" = "checkout" ] || [ "$KIND" = "merge" ]; then
|
|
185
|
+
${invocation} --repo "$REPO_ROOT" hook-refresh "$KIND" "$ARG1" "$ARG2" --json >> "$LOG_PATH" 2>&1
|
|
186
|
+
elif [ "$KIND" = "rewrite" ]; then
|
|
187
|
+
${invocation} --repo "$REPO_ROOT" hook-refresh rewrite "$ARG1" --json >> "$LOG_PATH" 2>&1
|
|
188
|
+
else
|
|
189
|
+
printf 'knodin background refresh: invalid queued event %s\\n' "$KIND" >> "$LOG_PATH"
|
|
190
|
+
false
|
|
191
|
+
fi
|
|
192
|
+
STATUS=$?
|
|
193
|
+
if [ "$STATUS" -eq 0 ]; then
|
|
194
|
+
cp "$EVENT" "$LAST_EVENT_PATH"
|
|
195
|
+
rm -f "$EVENT" "$FAILURE_PATH"
|
|
196
|
+
else
|
|
197
|
+
{
|
|
198
|
+
printf 'knodin background refresh failed with exit code %s.\\n' "$STATUS"
|
|
199
|
+
printf 'Pending event retained at %s.\\n' "$EVENT"
|
|
200
|
+
printf 'See .reckon/indexer.log for details.\\n'
|
|
201
|
+
} > "$FAILURE_TMP"
|
|
202
|
+
mv -f "$FAILURE_TMP" "$FAILURE_PATH"
|
|
203
|
+
exit "$STATUS"
|
|
204
|
+
fi
|
|
205
|
+
done
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
function queuedLifecycleEvents(repo) {
|
|
209
|
+
const eventDirectory = path.join(repo, ".reckon", "hooks", "events");
|
|
210
|
+
try {
|
|
211
|
+
return fs.readdirSync(eventDirectory).filter((entry) => entry.endsWith(".event"));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function refreshProcessorIsRunning(repo) {
|
|
218
|
+
try {
|
|
219
|
+
const pid = Number(fs.readFileSync(path.join(repo, ".reckon", "hooks", "refresh.lock", "pid"), "utf-8"));
|
|
220
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
221
|
+
return false;
|
|
222
|
+
process.kill(pid, 0);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function drainQueuedLifecycleEvents(repo, backgroundPath) {
|
|
230
|
+
const before = queuedLifecycleEvents(repo);
|
|
231
|
+
if (before.length === 0)
|
|
232
|
+
return { state: "fresh", queuedEvents: 0 };
|
|
233
|
+
const result = child_process.spawnSync(backgroundPath, [], {
|
|
234
|
+
cwd: repo,
|
|
235
|
+
encoding: "utf-8",
|
|
236
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
237
|
+
timeout: 30_000,
|
|
238
|
+
});
|
|
239
|
+
if (result.status !== 0) {
|
|
240
|
+
const detail = result.error?.message || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
|
241
|
+
throw new Error(`knodin init: queued lifecycle refresh could not be drained (${detail}); run ${backgroundPath} after resolving .reckon/hooks/last-refresh-failure`);
|
|
242
|
+
}
|
|
243
|
+
const remaining = queuedLifecycleEvents(repo);
|
|
244
|
+
if (remaining.length === 0)
|
|
245
|
+
return { state: "fresh", queuedEvents: 0 };
|
|
246
|
+
if (refreshProcessorIsRunning(repo)) {
|
|
247
|
+
return { state: "running", queuedEvents: remaining.length };
|
|
248
|
+
}
|
|
249
|
+
if (remaining.length > 0) {
|
|
250
|
+
throw new Error("knodin init: queued lifecycle events remain after refresh; run `knodin wait --fresh`, then retry initialization");
|
|
251
|
+
}
|
|
252
|
+
return { state: "fresh", queuedEvents: 0 };
|
|
253
|
+
}
|
|
254
|
+
function managedHookScript(hookName, originalPath) {
|
|
255
|
+
const original = originalPath
|
|
256
|
+
? originalPath.startsWith("repo:")
|
|
257
|
+
? `"$REPO_ROOT"/${shellQuote(originalPath.slice("repo:".length))}`
|
|
258
|
+
: shellQuote(originalPath)
|
|
259
|
+
: "";
|
|
260
|
+
const runOriginal = originalPath
|
|
261
|
+
? `${original} "$@"${hookName === "post-rewrite" ? ' < "$REWRITE_INPUT"' : ""} || ORIGINAL_STATUS=$?`
|
|
262
|
+
: ":";
|
|
263
|
+
let trigger = "";
|
|
264
|
+
if (hookName === "post-commit")
|
|
265
|
+
trigger = "trigger_knodin commit";
|
|
266
|
+
if (hookName === "post-checkout") {
|
|
267
|
+
trigger = `[ "\${3:-0}" = "1" ] && trigger_knodin checkout "\${1:-}" "\${2:-}"`;
|
|
268
|
+
}
|
|
269
|
+
if (hookName === "post-merge") {
|
|
270
|
+
trigger = `BEFORE="$(git rev-parse ORIG_HEAD 2>/dev/null || true)"
|
|
271
|
+
AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
|
|
272
|
+
[ -n "$BEFORE" ] && [ -n "$AFTER" ] && trigger_knodin merge "$BEFORE" "$AFTER"`;
|
|
273
|
+
}
|
|
274
|
+
const rewriteWithoutIndexer = originalPath
|
|
275
|
+
? `${original} "$@"
|
|
276
|
+
exit $?`
|
|
277
|
+
: "exit 0";
|
|
278
|
+
const rewriteSetup = hookName === "post-rewrite"
|
|
279
|
+
? `if [ ! -x "$BACKGROUND" ]; then
|
|
280
|
+
${rewriteWithoutIndexer}
|
|
281
|
+
fi
|
|
282
|
+
REWRITE_INPUT="$(mktemp "$REPO_ROOT/.reckon/hooks/rewrite-input.XXXXXX")" || exit 0
|
|
283
|
+
cat > "$REWRITE_INPUT"
|
|
284
|
+
`
|
|
285
|
+
: "";
|
|
286
|
+
if (hookName === "post-rewrite")
|
|
287
|
+
trigger = 'trigger_knodin rewrite "$REWRITE_INPUT"';
|
|
288
|
+
return `#!/bin/sh
|
|
289
|
+
# ${MANAGED_MARKER}: ${hookName}
|
|
290
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
291
|
+
[ -n "$REPO_ROOT" ] || exit 0
|
|
292
|
+
BACKGROUND="$REPO_ROOT/.reckon/hooks/background-index.sh"
|
|
293
|
+
ORIGINAL_STATUS=0
|
|
294
|
+
trigger_knodin() {
|
|
295
|
+
[ -x "$BACKGROUND" ] || return 0
|
|
296
|
+
EVENT_KIND="\${1:-}"
|
|
297
|
+
EVENT_BEFORE="\${2:-}"
|
|
298
|
+
EVENT_AFTER="\${3:-}"
|
|
299
|
+
if [ "$EVENT_KIND" = "commit" ]; then
|
|
300
|
+
EVENT_BEFORE="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
|
301
|
+
EVENT_AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
|
|
302
|
+
set -- commit "$EVENT_BEFORE" "$EVENT_AFTER"
|
|
303
|
+
elif [ "$EVENT_KIND" = "rewrite" ] && [ -f "\${2:-}" ]; then
|
|
304
|
+
EVENT_BEFORE="$(sed -n '1{s/ .*//;p;}' "$2")"
|
|
305
|
+
EVENT_AFTER="$(sed -n '1{s/^[^ ]* //;s/ .*//;p;}' "$2")"
|
|
306
|
+
fi
|
|
307
|
+
EVENT_DIR="$REPO_ROOT/.reckon/hooks/events"
|
|
308
|
+
mkdir -p "$EVENT_DIR" || return 0
|
|
309
|
+
EVENT_TMP="$EVENT_DIR/$(date +%s).$$.$PPID.tmp"
|
|
310
|
+
EVENT_PATH="$EVENT_TMP.event"
|
|
311
|
+
EVENT_SEQUENCE="$(date +%s).$$.$PPID"
|
|
312
|
+
EVENT_WORKTREE="$REPO_ROOT"
|
|
313
|
+
EVENT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
314
|
+
{
|
|
315
|
+
for EVENT_ARG in "$@"; do
|
|
316
|
+
printf '%s\\n' "$EVENT_ARG"
|
|
317
|
+
done
|
|
318
|
+
printf 'sequence=%s\\nworktree=%s\\nat=%s\\nbefore=%s\\nafter=%s\\n' \\
|
|
319
|
+
"$EVENT_SEQUENCE" "$EVENT_WORKTREE" "$EVENT_AT" "$EVENT_BEFORE" "$EVENT_AFTER"
|
|
320
|
+
} > "$EVENT_TMP" || return 0
|
|
321
|
+
mv "$EVENT_TMP" "$EVENT_PATH" || return 0
|
|
322
|
+
if [ "\${RECKON_HOOK_FOREGROUND:-0}" = "1" ]; then
|
|
323
|
+
"$BACKGROUND"
|
|
324
|
+
else
|
|
325
|
+
nohup "$BACKGROUND" >/dev/null 2>&1 &
|
|
326
|
+
fi
|
|
327
|
+
}
|
|
328
|
+
${rewriteSetup}${runOriginal}
|
|
329
|
+
${trigger}
|
|
330
|
+
exit "$ORIGINAL_STATUS"
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
333
|
+
async function preventLefthookAutoInstall(hookPath) {
|
|
334
|
+
if (!fs.existsSync(hookPath))
|
|
335
|
+
return;
|
|
336
|
+
const content = await fs.promises.readFile(hookPath, "utf-8");
|
|
337
|
+
if (!content.includes("call_lefthook()"))
|
|
338
|
+
return;
|
|
339
|
+
const invocation = content.match(/^call_lefthook run "([^"\r\n]+)" "\$@"$/m);
|
|
340
|
+
if (!invocation)
|
|
341
|
+
return;
|
|
342
|
+
await fs.promises.writeFile(hookPath, content.replace(invocation[0], `call_lefthook run "${invocation[1]}" --no-auto-install "$@"`), "utf-8");
|
|
343
|
+
}
|
|
344
|
+
async function installManagedHook(hooksDir, hookName, preservedOriginal = undefined) {
|
|
345
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
346
|
+
let originalPath = preservedOriginal ?? null;
|
|
347
|
+
if (preservedOriginal === undefined && fs.existsSync(hookPath)) {
|
|
348
|
+
const content = await fs.promises.readFile(hookPath, "utf-8");
|
|
349
|
+
if (content.startsWith(`#!/bin/sh\n# ${MANAGED_MARKER}: ${hookName}\n`) ||
|
|
350
|
+
content.startsWith(`#!/bin/sh\n# ${LEGACY_MANAGED_MARKER}: ${hookName}\n`)) {
|
|
351
|
+
const match = content.match(/^# knodin original: (.+)$/m);
|
|
352
|
+
originalPath = match?.[1] ?? null;
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
const backupPath = `${hookPath}.reckon-original`;
|
|
356
|
+
const mode = (await fs.promises.stat(hookPath)).mode;
|
|
357
|
+
await fs.promises.copyFile(hookPath, backupPath);
|
|
358
|
+
await fs.promises.rm(hookPath, { force: true });
|
|
359
|
+
// Older releases appended a new backup on every displacement. Remove
|
|
360
|
+
// only copies that are provably redundant: another knodin wrapper or
|
|
361
|
+
// byte-identical to the authoritative current hook backup.
|
|
362
|
+
const canonical = await fs.promises.readFile(backupPath, "utf-8");
|
|
363
|
+
for (let suffix = 1;; suffix++) {
|
|
364
|
+
const candidate = `${backupPath}.${suffix}`;
|
|
365
|
+
if (!fs.existsSync(candidate))
|
|
366
|
+
break;
|
|
367
|
+
const candidateContent = await fs.promises.readFile(candidate, "utf-8");
|
|
368
|
+
if (candidateContent === canonical ||
|
|
369
|
+
candidateContent.includes(MANAGED_MARKER) ||
|
|
370
|
+
candidateContent.includes(LEGACY_MANAGED_MARKER))
|
|
371
|
+
await fs.promises.rm(candidate, { force: true });
|
|
372
|
+
}
|
|
373
|
+
// Match Git's semantics: preserve inactive hook files as backups, but
|
|
374
|
+
// never begin executing one merely because knodin installed a wrapper.
|
|
375
|
+
if ((mode & 0o111) !== 0)
|
|
376
|
+
originalPath = backupPath;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (originalPath)
|
|
380
|
+
await preventLefthookAutoInstall(originalPath);
|
|
381
|
+
let content = managedHookScript(hookName, originalPath);
|
|
382
|
+
if (originalPath)
|
|
383
|
+
content = content.replace("\nREPO_ROOT=", `\n# knodin original: ${originalPath}\nREPO_ROOT=`);
|
|
384
|
+
await fs.promises.writeFile(hookPath, content, {
|
|
385
|
+
encoding: "utf-8",
|
|
386
|
+
mode: 0o755,
|
|
387
|
+
});
|
|
388
|
+
await fs.promises.chmod(hookPath, 0o755);
|
|
389
|
+
}
|
|
390
|
+
const ORIGINAL_HOOKS_CONFIG = "knodin.originalHooksPath";
|
|
391
|
+
const LEGACY_ORIGINAL_HOOKS_CONFIG = "reckon.originalHooksPath";
|
|
392
|
+
function effectiveHooksDirectory(repo) {
|
|
393
|
+
let raw;
|
|
394
|
+
try {
|
|
395
|
+
raw = runGit(repo, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]).trim();
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
raw = runGit(repo, ["rev-parse", "--git-path", "hooks"]).trim();
|
|
399
|
+
}
|
|
400
|
+
return path.isAbsolute(raw) ? raw : path.resolve(repo, raw);
|
|
401
|
+
}
|
|
402
|
+
function configuredHooksPath(repo) {
|
|
403
|
+
try {
|
|
404
|
+
return runGit(repo, ["config", "--local", "--get", ORIGINAL_HOOKS_CONFIG]).trim() || null;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
try {
|
|
408
|
+
return (runGit(repo, ["config", "--local", "--get", LEGACY_ORIGINAL_HOOKS_CONFIG]).trim() || null);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
try {
|
|
412
|
+
return runGit(repo, ["config", "--local", "--get", "core.hooksPath"]).trim() || null;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function repoRelativeHooksPath(repo, configured) {
|
|
421
|
+
const absolute = path.isAbsolute(configured) ? configured : path.resolve(repo, configured);
|
|
422
|
+
const relative = path.relative(repo, absolute).replaceAll("\\", "/");
|
|
423
|
+
if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative))
|
|
424
|
+
return null;
|
|
425
|
+
return relative;
|
|
426
|
+
}
|
|
427
|
+
function hasTrackedHooks(repo, relativeHooksDir) {
|
|
428
|
+
try {
|
|
429
|
+
return nulPaths(repo, ["ls-files", "--", relativeHooksDir]).length > 0;
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function passthroughHookScript(relativeHookPath) {
|
|
436
|
+
return `#!/bin/sh
|
|
437
|
+
# ${MANAGED_MARKER}: tracked-hook passthrough
|
|
438
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
439
|
+
[ -n "$REPO_ROOT" ] || exit 0
|
|
440
|
+
ORIGINAL="$REPO_ROOT"/${shellQuote(relativeHookPath)}
|
|
441
|
+
[ -x "$ORIGINAL" ] || exit 0
|
|
442
|
+
exec "$ORIGINAL" "$@"
|
|
443
|
+
`;
|
|
444
|
+
}
|
|
445
|
+
async function prepareHookInstallation(repo) {
|
|
446
|
+
const configured = configuredHooksPath(repo);
|
|
447
|
+
const relative = configured ? repoRelativeHooksPath(repo, configured) : null;
|
|
448
|
+
if (!configured || !relative || !hasTrackedHooks(repo, relative)) {
|
|
449
|
+
return { hooksDir: effectiveHooksDirectory(repo), trackedHooksDir: null };
|
|
450
|
+
}
|
|
451
|
+
const commonGitDirRaw = runGit(repo, ["rev-parse", "--git-common-dir"]).trim();
|
|
452
|
+
const commonGitDir = path.isAbsolute(commonGitDirRaw)
|
|
453
|
+
? commonGitDirRaw
|
|
454
|
+
: path.resolve(repo, commonGitDirRaw);
|
|
455
|
+
const hooksDir = path.join(commonGitDir, "knodin-hooks");
|
|
456
|
+
await fs.promises.mkdir(hooksDir, { recursive: true });
|
|
457
|
+
runGit(repo, ["config", "--local", ORIGINAL_HOOKS_CONFIG, configured]);
|
|
458
|
+
runGit(repo, ["config", "--local", "core.hooksPath", hooksDir]);
|
|
459
|
+
const sourceDir = path.join(repo, relative);
|
|
460
|
+
for (const entry of await fs.promises.readdir(sourceDir, {
|
|
461
|
+
withFileTypes: true,
|
|
462
|
+
})) {
|
|
463
|
+
if (!entry.isFile() || HOOK_NAMES.includes(entry.name))
|
|
464
|
+
continue;
|
|
465
|
+
const source = path.join(sourceDir, entry.name);
|
|
466
|
+
const stat = await fs.promises.stat(source);
|
|
467
|
+
if ((stat.mode & 0o111) === 0)
|
|
468
|
+
continue;
|
|
469
|
+
const target = path.join(hooksDir, entry.name);
|
|
470
|
+
await fs.promises.writeFile(target, passthroughHookScript(`${relative}/${entry.name}`), {
|
|
471
|
+
encoding: "utf-8",
|
|
472
|
+
mode: 0o755,
|
|
473
|
+
});
|
|
474
|
+
await fs.promises.chmod(target, 0o755);
|
|
475
|
+
}
|
|
476
|
+
return { hooksDir, trackedHooksDir: relative };
|
|
477
|
+
}
|
|
478
|
+
const NUDGE_SCRIPT = `#!/bin/sh
|
|
479
|
+
# Example Claude Code hook: suggest local graph context for broad manual searches.
|
|
480
|
+
read -r INPUT_PAYLOAD
|
|
481
|
+
case "$INPUT_PAYLOAD" in
|
|
482
|
+
*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." ;;
|
|
483
|
+
*) REASON="Proceeding with tool execution." ;;
|
|
484
|
+
esac
|
|
485
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\\n' "$REASON"
|
|
486
|
+
`;
|
|
487
|
+
export const SETTINGS_TEMPLATE = `{
|
|
488
|
+
"comment": "Template only: opt in to local knodin context nudges.",
|
|
489
|
+
"hooks": {
|
|
490
|
+
"PreToolUse": [{
|
|
491
|
+
"matcher": "Bash|Read|Glob",
|
|
492
|
+
"hooks": [{"type": "command", "command": "./.claude/hooks/nudge.sh", "timeout": 5}]
|
|
493
|
+
}]
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
`;
|
|
497
|
+
const RECKON_AGENT_INSTRUCTIONS = `<!-- knodin:start -->
|
|
498
|
+
## knodin — Local Code Intelligence
|
|
499
|
+
|
|
500
|
+
This repository is indexed locally by knodin. Start with \`knodin status\`; if it reports \`repair-needed\`, run \`knodin repair\` before relying on graph evidence.
|
|
501
|
+
|
|
502
|
+
- When the single connected knodin MCP tool is available, use it directly. For cold or unfamiliar work, call its \`context\` operation before broad Read/Grep/Glob traversal. Use shell commands only as a fallback when the gateway is unavailable.
|
|
503
|
+
- Before a meaningful edit, call the gateway's \`query\` operation with the \`impact\` pattern (CLI fallback: \`knodin query impact <symbol> --direction upstream\`) and report material risk to the user.
|
|
504
|
+
- Use gateway operations \`explain\`, \`review\`, and \`query\` for source-evidenced context. CLI fallbacks are \`knodin explain\`, \`knodin review --scope compare\`, and \`knodin rename\`.
|
|
505
|
+
- Direct reads are appropriate for files just authored in the current session; do not force a graph round trip when the agent already has fresh source context.
|
|
506
|
+
- Run \`knodin index <changed-files>\` while working when you need immediately fresh graph evidence. Managed Git hooks installed by \`knodin init\` refresh the graph after commits, checkouts, merges, and rewrites.
|
|
507
|
+
- Keep output truthful: local graph evidence can be stale or ambiguous; say so and repair/reindex instead of claiming certainty.
|
|
508
|
+
|
|
509
|
+
<!-- knodin:end -->
|
|
510
|
+
`;
|
|
511
|
+
async function assertSafeRepositoryFile(repo, relativePath) {
|
|
512
|
+
const target = path.resolve(repo, relativePath);
|
|
513
|
+
const relative = path.relative(repo, target);
|
|
514
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
515
|
+
throw new Error(`${relativePath}: managed file must stay inside the repository`);
|
|
516
|
+
let current = repo;
|
|
517
|
+
for (const part of relative.split(path.sep)) {
|
|
518
|
+
current = path.join(current, part);
|
|
519
|
+
try {
|
|
520
|
+
if ((await fs.promises.lstat(current)).isSymbolicLink())
|
|
521
|
+
throw new Error(`${relativePath}: refusing to follow a symbolic link`);
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
if (error.code === "ENOENT")
|
|
525
|
+
break;
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async function installManagedAgentInstructions(repo, fileName = "AGENTS.md") {
|
|
531
|
+
await assertSafeRepositoryFile(repo, fileName);
|
|
532
|
+
const instructionsPath = path.join(repo, fileName);
|
|
533
|
+
let existing = "";
|
|
534
|
+
try {
|
|
535
|
+
existing = await fs.promises.readFile(instructionsPath, "utf-8");
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
if (error.code !== "ENOENT")
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
const canonicalStart = "<!-- knodin:start -->";
|
|
542
|
+
const canonicalEnd = "<!-- knodin:end -->";
|
|
543
|
+
const legacyStart = "<!-- reckon-graph:start -->";
|
|
544
|
+
const legacyEnd = "<!-- reckon-graph:end -->";
|
|
545
|
+
const usesCanonical = existing.includes(canonicalStart);
|
|
546
|
+
const startMarker = usesCanonical ? canonicalStart : legacyStart;
|
|
547
|
+
const endMarker = usesCanonical ? canonicalEnd : legacyEnd;
|
|
548
|
+
const start = existing.indexOf(startMarker);
|
|
549
|
+
const end = existing.indexOf(endMarker);
|
|
550
|
+
const next = start >= 0 && end >= start
|
|
551
|
+
? `${existing.slice(0, start)}${RECKON_AGENT_INSTRUCTIONS}${existing.slice(end + endMarker.length).replace(/^\r?\n/, "")}`
|
|
552
|
+
: existing.length > 0
|
|
553
|
+
? `${existing.replace(/\s*$/, "")}\n\n${RECKON_AGENT_INSTRUCTIONS}`
|
|
554
|
+
: RECKON_AGENT_INSTRUCTIONS;
|
|
555
|
+
if (next !== existing)
|
|
556
|
+
await fs.promises.writeFile(instructionsPath, next, "utf-8");
|
|
557
|
+
}
|
|
558
|
+
async function hasManagedAgentInstructions(repo, fileName = "AGENTS.md") {
|
|
559
|
+
try {
|
|
560
|
+
return (await fs.promises.readFile(path.join(repo, fileName), "utf-8")).includes(RECKON_AGENT_INSTRUCTIONS);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function isTracked(repo, relativePath) {
|
|
567
|
+
try {
|
|
568
|
+
return nulPaths(repo, ["ls-files", "--", relativePath]).length > 0;
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async function excludeUntrackedPaths(repo, candidates) {
|
|
575
|
+
const safe = [...new Set(candidates)].filter((candidate) => !isTracked(repo, candidate)).sort();
|
|
576
|
+
if (safe.length === 0)
|
|
577
|
+
return [];
|
|
578
|
+
const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
|
|
579
|
+
const excludePath = path.isAbsolute(rawExcludePath)
|
|
580
|
+
? rawExcludePath
|
|
581
|
+
: path.resolve(repo, rawExcludePath);
|
|
582
|
+
let existing = "";
|
|
583
|
+
try {
|
|
584
|
+
existing = await fs.promises.readFile(excludePath, "utf-8");
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (error.code !== "ENOENT")
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
const startMarker = "# knodin:start";
|
|
591
|
+
const endMarker = "# knodin:end";
|
|
592
|
+
const outside = existing
|
|
593
|
+
.replace(/(?:^|\n)# knodin:start\n[\s\S]*?\n# knodin:end\n?/, "\n")
|
|
594
|
+
.replace(/(?:^|\n)# reckon-graph:start\n[\s\S]*?\n# reckon-graph:end\n?/, "\n");
|
|
595
|
+
const outsideLines = new Set(outside.split(/\r?\n/));
|
|
596
|
+
const owned = safe.filter((candidate) => !outsideLines.has(candidate));
|
|
597
|
+
const prefix = outside && !outside.endsWith("\n") ? `${outside}\n` : outside;
|
|
598
|
+
const block = owned.length > 0 ? `${startMarker}\n${owned.join("\n")}\n${endMarker}\n` : "";
|
|
599
|
+
await fs.promises.mkdir(path.dirname(excludePath), { recursive: true });
|
|
600
|
+
await fs.promises.writeFile(excludePath, `${prefix}${block}`, "utf-8");
|
|
601
|
+
return owned;
|
|
602
|
+
}
|
|
603
|
+
async function removeManagedExcludes(repo) {
|
|
604
|
+
const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
|
|
605
|
+
const excludePath = path.isAbsolute(rawExcludePath)
|
|
606
|
+
? rawExcludePath
|
|
607
|
+
: path.resolve(repo, rawExcludePath);
|
|
608
|
+
let existing;
|
|
609
|
+
try {
|
|
610
|
+
existing = await fs.promises.readFile(excludePath, "utf-8");
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (error.code === "ENOENT")
|
|
614
|
+
return;
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
const next = existing.replace(/(?:^|\n)# knodin:start\n[\s\S]*?\n# knodin:end\n?/, "\n");
|
|
618
|
+
await fs.promises.writeFile(excludePath, next, "utf-8");
|
|
619
|
+
}
|
|
620
|
+
async function removeManagedPersonalArtifacts(repo, allowTrackedTransition = false) {
|
|
621
|
+
for (const fileName of ["AGENTS.md", "GEMINI.md"]) {
|
|
622
|
+
const instructionsPath = path.join(repo, fileName);
|
|
623
|
+
const tracked = isTracked(repo, fileName);
|
|
624
|
+
try {
|
|
625
|
+
const current = await fs.promises.readFile(instructionsPath, "utf-8");
|
|
626
|
+
if (!tracked) {
|
|
627
|
+
const start = current.indexOf("<!-- knodin:start -->");
|
|
628
|
+
const end = current.indexOf("<!-- knodin:end -->");
|
|
629
|
+
if (start >= 0 && end >= start) {
|
|
630
|
+
const without = `${current.slice(0, start)}${current.slice(end + "<!-- knodin:end -->".length)}`
|
|
631
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
632
|
+
.trim();
|
|
633
|
+
if (without)
|
|
634
|
+
await fs.promises.writeFile(instructionsPath, `${without}\n`, "utf-8");
|
|
635
|
+
else
|
|
636
|
+
await fs.promises.rm(instructionsPath, { force: true });
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const head = runGit(repo, ["show", `HEAD:${fileName}`]);
|
|
641
|
+
const start = current.indexOf("<!-- knodin:start -->");
|
|
642
|
+
const end = current.indexOf("<!-- knodin:end -->");
|
|
643
|
+
if (allowTrackedTransition && start >= 0 && end >= start) {
|
|
644
|
+
const without = `${current.slice(0, start)}${current.slice(end + "<!-- knodin:end -->".length)}`
|
|
645
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
646
|
+
.trimEnd();
|
|
647
|
+
if (without)
|
|
648
|
+
await fs.promises.writeFile(instructionsPath, `${without}\n`, "utf-8");
|
|
649
|
+
else
|
|
650
|
+
await fs.promises.rm(instructionsPath, { force: true });
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (start >= 0 && end >= start && !head.includes("<!-- knodin:start -->")) {
|
|
654
|
+
const without = `${current.slice(0, start)}${current.slice(end + "<!-- knodin:end -->".length)}`
|
|
655
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
656
|
+
.trimEnd();
|
|
657
|
+
if (without === head.trimEnd())
|
|
658
|
+
await fs.promises.writeFile(instructionsPath, head);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
// A missing HEAD version or unrelated working-tree edits are ambiguous.
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const gitIgnorePath = path.join(repo, ".gitignore");
|
|
666
|
+
try {
|
|
667
|
+
const current = await fs.promises.readFile(gitIgnorePath, "utf-8");
|
|
668
|
+
const next = current
|
|
669
|
+
.replace(/(?:^|\n)# knodin:start\n\.reckon\/\n# knodin:end\n?/g, "\n")
|
|
670
|
+
.trim();
|
|
671
|
+
if (isTracked(repo, ".gitignore")) {
|
|
672
|
+
const head = runGit(repo, ["show", "HEAD:.gitignore"]);
|
|
673
|
+
if (allowTrackedTransition)
|
|
674
|
+
await fs.promises.writeFile(gitIgnorePath, next ? `${next}\n` : "");
|
|
675
|
+
else if (next === head.trim())
|
|
676
|
+
await fs.promises.writeFile(gitIgnorePath, head, "utf-8");
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
if (next)
|
|
680
|
+
await fs.promises.writeFile(gitIgnorePath, `${next}\n`, "utf-8");
|
|
681
|
+
else
|
|
682
|
+
await fs.promises.rm(gitIgnorePath, { force: true });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// Missing or user-modified files are intentionally preserved.
|
|
687
|
+
}
|
|
688
|
+
for (const relative of [".claude/hooks/nudge.sh", ".claude/settings.example.json"]) {
|
|
689
|
+
if (isTracked(repo, relative))
|
|
690
|
+
continue;
|
|
691
|
+
const target = path.join(repo, relative);
|
|
692
|
+
try {
|
|
693
|
+
const content = await fs.promises.readFile(target, "utf-8");
|
|
694
|
+
if ((relative.endsWith("nudge.sh") && content === NUDGE_SCRIPT) ||
|
|
695
|
+
(relative.endsWith("settings.example.json") && content === SETTINGS_TEMPLATE))
|
|
696
|
+
await fs.promises.rm(target, { force: true });
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
// Missing or user-modified files are intentionally preserved.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
export function readRepositoryIntegrationConfig(repo) {
|
|
704
|
+
try {
|
|
705
|
+
const value = JSON.parse(fs.readFileSync(path.join(repo, ".reckon", "integration.json"), "utf-8"));
|
|
706
|
+
if ((value.scope === "personal" || value.scope === "team" || value.scope === "cli-only") &&
|
|
707
|
+
Array.isArray(value.agents))
|
|
708
|
+
return { scope: value.scope, agents: value.agents };
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
// Missing or invalid local state is reported as unconfigured.
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
/** Detect repository-owned client configuration independently of knodin's local receipt. */
|
|
716
|
+
export function inspectRepositoryIntegrationStatus(repo) {
|
|
717
|
+
const receipt = readRepositoryIntegrationConfig(repo);
|
|
718
|
+
const candidates = [
|
|
719
|
+
{ agent: "claude", path: ".mcp.json", marker: '"knodin"' },
|
|
720
|
+
{ agent: "codex", path: ".codex/config.toml", marker: 'mcp_servers."knodin"' },
|
|
721
|
+
{ agent: "gemini", path: ".gemini/settings.json", marker: '"knodin"' },
|
|
722
|
+
{ agent: "antigravity", path: ".agents/mcp_config.json", marker: '"knodin"' },
|
|
723
|
+
];
|
|
724
|
+
const configurations = candidates.map(({ agent, path: relative, marker }) => {
|
|
725
|
+
try {
|
|
726
|
+
return {
|
|
727
|
+
agent,
|
|
728
|
+
path: relative,
|
|
729
|
+
present: fs.readFileSync(path.join(repo, relative), "utf-8").includes(marker),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
return { agent, path: relative, present: false };
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
const detected = configurations.filter(({ present }) => present).map(({ agent }) => agent);
|
|
737
|
+
const agents = [...new Set([...(receipt?.agents ?? []), ...detected])];
|
|
738
|
+
if (!receipt && agents.length === 0)
|
|
739
|
+
return null;
|
|
740
|
+
return {
|
|
741
|
+
scope: receipt?.scope ?? "repository-detected",
|
|
742
|
+
agents,
|
|
743
|
+
receiptPresent: receipt !== null,
|
|
744
|
+
configurations,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async function writeRepositoryIntegrationConfig(repo, config) {
|
|
748
|
+
await fs.promises.writeFile(path.join(repo, ".reckon", "integration.json"), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
749
|
+
}
|
|
750
|
+
async function ensureTeamGitIgnore(repo) {
|
|
751
|
+
if (isTracked(repo, ".reckon"))
|
|
752
|
+
return;
|
|
753
|
+
await assertSafeRepositoryFile(repo, ".gitignore");
|
|
754
|
+
const ignorePath = path.join(repo, ".gitignore");
|
|
755
|
+
let existing = "";
|
|
756
|
+
try {
|
|
757
|
+
existing = await fs.promises.readFile(ignorePath, "utf-8");
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
if (error.code !== "ENOENT")
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
if (existing.split(/\r?\n/).includes(".reckon/"))
|
|
764
|
+
return;
|
|
765
|
+
const prefix = existing && !existing.endsWith("\n") ? `${existing}\n` : existing;
|
|
766
|
+
await fs.promises.writeFile(ignorePath, `${prefix}# knodin:start\n.reckon/\n# knodin:end\n`, "utf-8");
|
|
767
|
+
}
|
|
768
|
+
export function detectTrackedTeamIntegration(repo) {
|
|
769
|
+
const checks = [
|
|
770
|
+
["AGENTS.md", "<!-- knodin:start -->"],
|
|
771
|
+
["GEMINI.md", "<!-- knodin:start -->"],
|
|
772
|
+
[".gitignore", "# knodin:start"],
|
|
773
|
+
[".mcp.json", '"knodin"'],
|
|
774
|
+
[".codex/config.toml", "# knodin:start"],
|
|
775
|
+
[".gemini/settings.json", '"knodin"'],
|
|
776
|
+
[".agents/mcp_config.json", '"knodin"'],
|
|
777
|
+
];
|
|
778
|
+
return checks.some(([relative, marker]) => {
|
|
779
|
+
if (!isTracked(repo, relative))
|
|
780
|
+
return false;
|
|
781
|
+
try {
|
|
782
|
+
return fs.readFileSync(path.join(repo, relative), "utf-8").includes(marker);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
/** Install lifecycle hooks and initialize the local graph in a real Git worktree. */
|
|
790
|
+
export async function initializeRepository(repo, options) {
|
|
791
|
+
const resolvedRepo = path.resolve(repo);
|
|
792
|
+
const scope = options.scope ?? "personal";
|
|
793
|
+
const priorIntegration = readRepositoryIntegrationConfig(resolvedRepo);
|
|
794
|
+
if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
|
|
795
|
+
throw new Error(`knodin init: not a Git worktree: ${resolvedRepo}`);
|
|
796
|
+
}
|
|
797
|
+
if (!path.isAbsolute(options.command[0])) {
|
|
798
|
+
throw new Error("knodin init: packaged runtime command must be an absolute path");
|
|
799
|
+
}
|
|
800
|
+
if (scope !== "team" &&
|
|
801
|
+
!options.allowTrackedTransition &&
|
|
802
|
+
detectTrackedTeamIntegration(resolvedRepo)) {
|
|
803
|
+
throw new Error("knodin init: tracked team integration is active; use `knodin configure --scope personal` or `--scope cli-only` for an explicit commit-ready transition");
|
|
804
|
+
}
|
|
805
|
+
const knodinHooksDir = path.join(resolvedRepo, ".reckon", "hooks");
|
|
806
|
+
const hookInstallation = await prepareHookInstallation(resolvedRepo);
|
|
807
|
+
const hooksDir = hookInstallation.hooksDir;
|
|
808
|
+
await Promise.all([
|
|
809
|
+
fs.promises.mkdir(knodinHooksDir, { recursive: true }),
|
|
810
|
+
fs.promises.mkdir(hooksDir, { recursive: true }),
|
|
811
|
+
]);
|
|
812
|
+
const backgroundPath = path.join(knodinHooksDir, "background-index.sh");
|
|
813
|
+
await fs.promises.writeFile(backgroundPath, backgroundScript(options.command), {
|
|
814
|
+
encoding: "utf-8",
|
|
815
|
+
mode: 0o755,
|
|
816
|
+
});
|
|
817
|
+
await fs.promises.chmod(backgroundPath, 0o755);
|
|
818
|
+
for (const hookName of HOOK_NAMES) {
|
|
819
|
+
let preservedOriginal;
|
|
820
|
+
if (hookInstallation.trackedHooksDir) {
|
|
821
|
+
const relativeHook = `${hookInstallation.trackedHooksDir}/${hookName}`;
|
|
822
|
+
const sourceHook = path.join(resolvedRepo, relativeHook);
|
|
823
|
+
try {
|
|
824
|
+
const stat = await fs.promises.stat(sourceHook);
|
|
825
|
+
preservedOriginal =
|
|
826
|
+
stat.isFile() && (stat.mode & 0o111) !== 0 ? `repo:${relativeHook}` : null;
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
preservedOriginal = null;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
await installManagedHook(hooksDir, hookName, preservedOriginal);
|
|
833
|
+
}
|
|
834
|
+
for (const entry of await fs.promises.readdir(hooksDir, {
|
|
835
|
+
withFileTypes: true,
|
|
836
|
+
})) {
|
|
837
|
+
if (entry.isFile())
|
|
838
|
+
await preventLefthookAutoInstall(path.join(hooksDir, entry.name));
|
|
839
|
+
}
|
|
840
|
+
const indexResult = await options.index(resolvedRepo, {
|
|
841
|
+
onProgress: options.onProgress,
|
|
842
|
+
});
|
|
843
|
+
if (isIndexResult(indexResult) && indexResult.verification.status !== "healthy") {
|
|
844
|
+
throw new InitializationHealthError(indexResult);
|
|
845
|
+
}
|
|
846
|
+
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
|
|
847
|
+
await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
|
|
848
|
+
if (scope === "team") {
|
|
849
|
+
await installManagedAgentInstructions(resolvedRepo, "AGENTS.md");
|
|
850
|
+
await installManagedAgentInstructions(resolvedRepo, "GEMINI.md");
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
await removeManagedPersonalArtifacts(resolvedRepo, options.allowTrackedTransition);
|
|
854
|
+
}
|
|
855
|
+
const requestedAgents = options.agents ?? [];
|
|
856
|
+
const projectPathByAgent = {
|
|
857
|
+
codex: ".codex/config.toml",
|
|
858
|
+
gemini: ".gemini/settings.json",
|
|
859
|
+
antigravity: ".agents/mcp_config.json",
|
|
860
|
+
};
|
|
861
|
+
const blockedAgents = [];
|
|
862
|
+
if (scope === "personal" &&
|
|
863
|
+
options.allowTrackedTransition &&
|
|
864
|
+
priorIntegration?.scope === "team") {
|
|
865
|
+
const cleanup = configureProjectAgents({
|
|
866
|
+
repo: resolvedRepo,
|
|
867
|
+
scope: "cli-only",
|
|
868
|
+
agents: ["claude", "codex", "gemini", "antigravity"],
|
|
869
|
+
run: options.runAgentCommand,
|
|
870
|
+
});
|
|
871
|
+
if (cleanup.failed.length > 0)
|
|
872
|
+
throw new Error(`knodin configure: could not remove tracked team integration: ${cleanup.failed[0]?.message}`);
|
|
873
|
+
}
|
|
874
|
+
const eligibleAgents = scope === "team"
|
|
875
|
+
? requestedAgents
|
|
876
|
+
: requestedAgents.filter((agent) => {
|
|
877
|
+
const projectPath = agent === "claude" ? ".mcp.json" : projectPathByAgent[agent];
|
|
878
|
+
if (!projectPath ||
|
|
879
|
+
!isTracked(resolvedRepo, projectPath) ||
|
|
880
|
+
(scope === "cli-only" && options.allowTrackedTransition))
|
|
881
|
+
return true;
|
|
882
|
+
if (options.allowTrackedTransition && priorIntegration?.scope === "team")
|
|
883
|
+
return false;
|
|
884
|
+
blockedAgents.push({
|
|
885
|
+
agent,
|
|
886
|
+
message: `${projectPath} is tracked; personal mode left it unchanged and did not exclude it`,
|
|
887
|
+
});
|
|
888
|
+
return false;
|
|
889
|
+
});
|
|
890
|
+
const agentIntegration = configureProjectAgents({
|
|
891
|
+
repo: resolvedRepo,
|
|
892
|
+
scope,
|
|
893
|
+
agents: eligibleAgents,
|
|
894
|
+
run: options.runAgentCommand,
|
|
895
|
+
});
|
|
896
|
+
agentIntegration.failed.push(...blockedAgents);
|
|
897
|
+
await writeRepositoryIntegrationConfig(resolvedRepo, {
|
|
898
|
+
scope,
|
|
899
|
+
agents: agentIntegration.configured,
|
|
900
|
+
});
|
|
901
|
+
if (scope === "team") {
|
|
902
|
+
await ensureTeamGitIgnore(resolvedRepo);
|
|
903
|
+
await removeManagedExcludes(resolvedRepo);
|
|
904
|
+
}
|
|
905
|
+
else if (scope === "cli-only") {
|
|
906
|
+
await removeManagedExcludes(resolvedRepo);
|
|
907
|
+
}
|
|
908
|
+
const excluded = scope === "team"
|
|
909
|
+
? []
|
|
910
|
+
: await excludeUntrackedPaths(resolvedRepo, [
|
|
911
|
+
...(isTracked(resolvedRepo, ".reckon") ? [] : [".reckon/"]),
|
|
912
|
+
...agentIntegration.projectFiles,
|
|
913
|
+
]);
|
|
914
|
+
registerInitializedWorktree(resolvedRepo);
|
|
915
|
+
return {
|
|
916
|
+
scope,
|
|
917
|
+
knodin: ".reckon",
|
|
918
|
+
database: ".reckon/db.sqlite",
|
|
919
|
+
backgroundIndexer: ".reckon/hooks/background-index.sh",
|
|
920
|
+
hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
|
|
921
|
+
gitHooks: [...HOOK_NAMES],
|
|
922
|
+
claudeSettings: null,
|
|
923
|
+
agentInstructions: scope === "team" ? "AGENTS.md" : null,
|
|
924
|
+
agentIntegration,
|
|
925
|
+
excluded,
|
|
926
|
+
lifecycleRefresh,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
async function matchesManagedFile(filePath, expected, executable) {
|
|
930
|
+
try {
|
|
931
|
+
const [content, stat] = await Promise.all([
|
|
932
|
+
fs.promises.readFile(filePath, "utf-8"),
|
|
933
|
+
fs.promises.stat(filePath),
|
|
934
|
+
]);
|
|
935
|
+
return stat.isFile() && content === expected && (!executable || (stat.mode & 0o111) !== 0);
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Read-only installation probe used by repository bootstrap. Existing user hooks may
|
|
943
|
+
* be referenced through knodin's wrapper, so hook currency is established by a
|
|
944
|
+
* single managed marker while generated files are compared byte-for-byte.
|
|
945
|
+
*/
|
|
946
|
+
export async function isRepositoryInitializationCurrent(repo, command, scope = "personal") {
|
|
947
|
+
const resolvedRepo = path.resolve(repo);
|
|
948
|
+
if (!path.isAbsolute(command[0]))
|
|
949
|
+
return false;
|
|
950
|
+
let hooksDir;
|
|
951
|
+
try {
|
|
952
|
+
if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true")
|
|
953
|
+
return false;
|
|
954
|
+
hooksDir = effectiveHooksDirectory(resolvedRepo);
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
const managedHooks = await Promise.all(HOOK_NAMES.map(async (hookName) => {
|
|
960
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
961
|
+
try {
|
|
962
|
+
const [content, stat] = await Promise.all([
|
|
963
|
+
fs.promises.readFile(hookPath, "utf-8"),
|
|
964
|
+
fs.promises.stat(hookPath),
|
|
965
|
+
]);
|
|
966
|
+
const originalPath = content.match(/^# knodin original: (.+)$/m)?.[1] ?? null;
|
|
967
|
+
let expected = managedHookScript(hookName, originalPath);
|
|
968
|
+
if (originalPath)
|
|
969
|
+
expected = expected.replace("\nREPO_ROOT=", `\n# knodin original: ${originalPath}\nREPO_ROOT=`);
|
|
970
|
+
return stat.isFile() && (stat.mode & 0o111) !== 0 && content === expected;
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
}));
|
|
976
|
+
if (managedHooks.some((current) => !current))
|
|
977
|
+
return false;
|
|
978
|
+
const sharedFilesCurrent = scope !== "team" ||
|
|
979
|
+
((await hasManagedAgentInstructions(resolvedRepo, "AGENTS.md")) &&
|
|
980
|
+
(await hasManagedAgentInstructions(resolvedRepo, "GEMINI.md")));
|
|
981
|
+
const integrationCurrent = readRepositoryIntegrationConfig(resolvedRepo)?.scope === scope;
|
|
982
|
+
return ((await matchesManagedFile(path.join(resolvedRepo, ".reckon", "hooks", "background-index.sh"), backgroundScript(command), true)) &&
|
|
983
|
+
sharedFilesCurrent &&
|
|
984
|
+
integrationCurrent);
|
|
985
|
+
}
|