knodin 0.7.6 → 0.8.3

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 (134) hide show
  1. package/README.md +41 -12
  2. package/benchmarks/competitors/SYNTHESIS.md +66 -0
  3. package/dist/bin/cli.js +2181 -108
  4. package/dist/bin/launcher.js +25 -3
  5. package/dist/src/agent-integration.js +304 -0
  6. package/dist/src/artifact-refresh.js +82 -0
  7. package/dist/src/cli-args.js +292 -0
  8. package/dist/src/cli-model.js +384 -0
  9. package/dist/src/codeflow-replay.js +81 -0
  10. package/dist/src/compact-structural.js +96 -0
  11. package/dist/src/compare.js +39 -0
  12. package/dist/src/competitive-cold-mcp.js +40 -0
  13. package/dist/src/competitive-constraints.js +21 -0
  14. package/dist/src/competitive-manifest.js +411 -0
  15. package/dist/src/competitive-measurement.js +183 -0
  16. package/dist/src/competitive-runner.js +487 -0
  17. package/dist/src/competitive-sandbox.js +108 -0
  18. package/dist/src/context-export.js +423 -0
  19. package/dist/src/context.js +102 -0
  20. package/dist/src/deterministic-random.js +34 -0
  21. package/dist/src/diagnostics-write-helper.js +473 -0
  22. package/dist/src/diagnostics.js +1476 -0
  23. package/dist/src/docs-sections.js +142 -0
  24. package/dist/src/doctor.js +382 -0
  25. package/dist/src/engine/ann-hnsw.js +261 -0
  26. package/dist/src/engine/embeddings.js +193 -0
  27. package/dist/src/engine/file-walker.js +49 -0
  28. package/dist/src/engine/git-history.js +289 -0
  29. package/dist/src/engine/index.js +15094 -0
  30. package/dist/src/engine/perf.js +115 -0
  31. package/dist/src/engine/prune.js +112 -0
  32. package/dist/src/engine/sarif-import.js +341 -0
  33. package/dist/src/engine/scip-import.js +423 -0
  34. package/dist/src/engine/source-policy.js +85 -0
  35. package/dist/src/engine/sqlite.js +71 -0
  36. package/dist/src/engine/state-paths.js +175 -0
  37. package/dist/src/engine/symbol-delete.js +58 -0
  38. package/dist/src/execution-profile.js +208 -0
  39. package/dist/src/failure-diagnosis.js +655 -0
  40. package/dist/src/fleet.js +7 -0
  41. package/dist/src/git-executable.js +31 -0
  42. package/dist/src/graph-layout.js +173 -0
  43. package/dist/src/graph-query-health.js +115 -0
  44. package/dist/src/hook-manager-integration.js +156 -0
  45. package/dist/src/index-activity.js +126 -0
  46. package/dist/src/init-progress-worker.js +70 -2
  47. package/dist/src/init-progress.js +155 -0
  48. package/dist/src/init.js +1295 -0
  49. package/dist/src/lifecycle-health.js +282 -0
  50. package/dist/src/lsp-readonly.js +217 -0
  51. package/dist/src/mcp-graph-worker.js +69 -0
  52. package/dist/src/mcp-reliability.js +154 -0
  53. package/dist/src/mcp-worker-supervisor.js +350 -0
  54. package/dist/src/mirror.js +290 -0
  55. package/dist/src/node-runtime.js +157 -0
  56. package/dist/src/output-compression.js +630 -0
  57. package/dist/src/output-telemetry.js +368 -0
  58. package/dist/src/pr-triage.js +638 -0
  59. package/dist/src/progress-worker-runtime.js +46 -0
  60. package/dist/src/progressive-evidence.js +477 -0
  61. package/dist/src/pure-compression-cli.js +102 -0
  62. package/dist/src/relationship-adapters.js +377 -0
  63. package/dist/src/release-attestation.js +533 -0
  64. package/dist/src/release-preflight.js +513 -0
  65. package/dist/src/repair-lease.js +85 -0
  66. package/dist/src/repair-progress-worker.js +83 -2
  67. package/dist/src/repair-progress.js +262 -0
  68. package/dist/src/repository-init-process.js +177 -0
  69. package/dist/src/repository-management.js +1261 -0
  70. package/dist/src/resource-reachability.js +456 -0
  71. package/dist/src/response-budget.js +200 -0
  72. package/dist/src/server.js +217 -0
  73. package/dist/src/structural-fast-path.js +344 -0
  74. package/dist/src/structural-snapshot.js +37 -0
  75. package/dist/src/system-config.js +638 -0
  76. package/dist/src/terminal-help.js +83 -0
  77. package/dist/src/tools/knodin-tools.js +1645 -0
  78. package/dist/src/update-ceremony.js +162 -0
  79. package/dist/src/update-policy.js +944 -0
  80. package/dist/src/update-trust.js +504 -0
  81. package/dist/src/version.js +13 -0
  82. package/dist/src/visualization.js +515 -0
  83. package/dist/src/wait-for-fresh.js +98 -0
  84. package/dist/src/worktree-lifecycle.js +234 -0
  85. package/docs/BEHAVIORAL-CONTRACT.md +114 -0
  86. package/docs/CLI.md +30 -1
  87. package/docs/COMPARISON.md +413 -0
  88. package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
  89. package/docs/CONTAINED-EXECUTION.md +77 -0
  90. package/docs/DEMO.md +49 -0
  91. package/docs/DIAGNOSTICS.md +80 -0
  92. package/docs/GIT-HISTORY-REVIEW.md +39 -0
  93. package/docs/HANDOFF.md +180 -0
  94. package/docs/INSTALLATION.md +21 -18
  95. package/docs/MCP.md +64 -8
  96. package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
  97. package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
  98. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  99. package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
  100. package/docs/SCIP-IMPORT.md +62 -0
  101. package/docs/SIGNED-UPDATES.md +151 -0
  102. package/docs/TELEMETRY.md +46 -0
  103. package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
  104. package/docs/assets/knodin-favicon.svg +4 -0
  105. package/docs/releases/0.3.0.md +46 -0
  106. package/docs/releases/0.4.0.md +68 -0
  107. package/docs/releases/0.4.1.md +28 -0
  108. package/docs/releases/0.4.2.md +27 -0
  109. package/docs/releases/0.4.3.md +23 -0
  110. package/docs/releases/0.5.0.md +29 -0
  111. package/docs/releases/0.5.1.md +17 -0
  112. package/docs/releases/0.6.0.md +18 -0
  113. package/docs/releases/0.7.0.md +24 -0
  114. package/docs/releases/0.7.1.md +21 -0
  115. package/docs/releases/0.7.2.md +21 -0
  116. package/docs/releases/0.7.3.md +23 -0
  117. package/docs/releases/0.7.4.md +17 -0
  118. package/docs/releases/0.7.5.md +20 -0
  119. package/docs/releases/0.8.0.md +74 -0
  120. package/docs/releases/0.8.2.md +34 -0
  121. package/docs/releases/0.8.3.md +47 -0
  122. package/package.json +139 -4
  123. package/roadmap/competitive-roadmap.md +3896 -0
  124. package/schemas/release-attestation-v1.schema.json +210 -0
  125. package/schemas/support-bundle-v2.schema.json +212 -0
  126. package/dist/chunks/chunk-DMQAGX77.js +0 -654
  127. package/dist/chunks/chunk-F4Z3Z766.js +0 -4
  128. package/dist/chunks/chunk-SIJAQVSX.js +0 -3
  129. package/dist/chunks/chunk-X6M4HUUE.js +0 -2
  130. package/dist/chunks/chunk-YPRMY2LP.js +0 -8
  131. package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
  132. package/dist/chunks/server-7EDF4CBY.js +0 -14
  133. package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
  134. package/docs/releases/0.7.6.md +0 -25
@@ -0,0 +1,1295 @@
1
+ import child_process from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { configureProjectAgents, } from "./agent-integration.js";
6
+ import { compareBytes } from "./compare.js";
7
+ import { isIndexableSourcePath } from "./engine/source-policy.js";
8
+ import { lookupMirror } from "./engine/state-paths.js";
9
+ import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
10
+ import { registerInitializedWorktree } from "./worktree-lifecycle.js";
11
+ const MANAGED_MARKER = "KNODIN MANAGED HOOK";
12
+ const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
13
+ const HOOK_LOG_MAX_BYTES = 1_048_576;
14
+ const HOOK_FAILURE_FILE = "last-refresh-failure";
15
+ export class InitializationHealthError extends Error {
16
+ indexResult;
17
+ constructor(indexResult) {
18
+ const firstIssue = indexResult.verification.missing.files[0] ?? indexResult.verification.missing.records[0];
19
+ const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
20
+ super(`knodin init: lifecycle hooks were installed, but ${indexResult.verification.issueCount.toLocaleString()} graph issue(s) remain.${detail} Run \`knodin repair\`.`);
21
+ this.name = "InitializationHealthError";
22
+ this.indexResult = indexResult;
23
+ }
24
+ }
25
+ function isIndexResult(value) {
26
+ if (typeof value !== "object" || value === null)
27
+ return false;
28
+ const candidate = value;
29
+ const verification = candidate.verification;
30
+ return (Array.isArray(candidate.indexed) &&
31
+ Array.isArray(candidate.unchanged) &&
32
+ typeof verification === "object" &&
33
+ verification !== null &&
34
+ typeof verification.status === "string" &&
35
+ typeof verification.issueCount === "number" &&
36
+ Array.isArray(verification.missing?.files) &&
37
+ Array.isArray(verification.missing?.records));
38
+ }
39
+ function runGit(repo, args, encoding = "utf-8") {
40
+ return child_process.execFileSync("git", args, {
41
+ cwd: repo,
42
+ encoding,
43
+ stdio: ["ignore", "pipe", "ignore"],
44
+ });
45
+ }
46
+ const MUTATION_AUDIT_MAX_ENTRIES = 2_048;
47
+ const MUTATION_AUDIT_MAX_FILE_BYTES = 8 * 1_024 * 1_024;
48
+ function boundedDirectoryEntries(directory) {
49
+ try {
50
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
51
+ if (entries.length > MUTATION_AUDIT_MAX_ENTRIES)
52
+ throw new Error(`${directory}: configure mutation audit exceeds ${MUTATION_AUDIT_MAX_ENTRIES} managed entries`);
53
+ return entries;
54
+ }
55
+ catch (error) {
56
+ if (error.code === "ENOENT")
57
+ return [];
58
+ throw error;
59
+ }
60
+ }
61
+ function configureMutationAuditTargets(repo) {
62
+ const commonRaw = runGit(repo, [
63
+ "rev-parse",
64
+ "--path-format=absolute",
65
+ "--git-common-dir",
66
+ ]).trim();
67
+ const common = path.isAbsolute(commonRaw) ? commonRaw : path.resolve(repo, commonRaw);
68
+ const excludeRaw = runGit(repo, [
69
+ "rev-parse",
70
+ "--path-format=absolute",
71
+ "--git-path",
72
+ "info/exclude",
73
+ ]).trim();
74
+ const exclude = path.isAbsolute(excludeRaw) ? excludeRaw : path.resolve(repo, excludeRaw);
75
+ const effectiveHooks = effectiveHooksDirectory(repo);
76
+ const targets = new Map();
77
+ const add = (target) => {
78
+ const key = path.resolve(target.absolute);
79
+ if (!targets.has(key))
80
+ targets.set(key, { ...target, absolute: key });
81
+ };
82
+ for (const relative of [
83
+ "AGENTS.md",
84
+ "GEMINI.md",
85
+ ".mcp.json",
86
+ ".codex",
87
+ ".codex/config.toml",
88
+ ".gemini",
89
+ ".gemini/settings.json",
90
+ ".agents",
91
+ ".agents/mcp_config.json",
92
+ ".gitignore",
93
+ ".claude",
94
+ ".claude/hooks",
95
+ ".claude/hooks/nudge.sh",
96
+ ".claude/settings.example.json",
97
+ ".knodin",
98
+ ".knodin/integration.json",
99
+ ".knodin/indexer.log",
100
+ ".knodin/indexer.log.1",
101
+ "lefthook-local.yml",
102
+ "lefthook-local.yaml",
103
+ ".lefthook-local.yml",
104
+ ".lefthook-local.yaml",
105
+ ".config/lefthook-local.yml",
106
+ ".config/lefthook-local.yaml",
107
+ ])
108
+ add({ absolute: path.join(repo, relative), label: relative });
109
+ for (const relative of [
110
+ ".knodin/hooks",
111
+ ".knodin/hooks/background-index.sh",
112
+ ".knodin/hooks/manager-dispatch.sh",
113
+ ".knodin/hooks/events",
114
+ ".knodin/hooks/last-refresh-event",
115
+ ".knodin/hooks/last-refresh-failure",
116
+ ".knodin/hooks/refresh.lock",
117
+ ".knodin/hooks/refresh.lock/pid",
118
+ ])
119
+ add({ absolute: path.join(repo, relative), label: relative });
120
+ for (const entry of boundedDirectoryEntries(path.join(repo, ".knodin", "hooks", "events")))
121
+ add({
122
+ absolute: path.join(repo, ".knodin", "hooks", "events", entry.name),
123
+ label: `.knodin/hooks/events/${entry.name}`,
124
+ });
125
+ add({ absolute: path.join(common, "config"), label: ".git/config" });
126
+ add({ absolute: exclude, label: ".git/info/exclude" });
127
+ add({ absolute: path.join(common, "knodin"), label: ".git/knodin" });
128
+ add({
129
+ absolute: path.join(common, "knodin", "worktrees.json"),
130
+ label: ".git/knodin/worktrees.json",
131
+ });
132
+ const defaultHooks = path.join(common, "hooks");
133
+ const managedHooks = path.join(common, "knodin-hooks");
134
+ add({ absolute: defaultHooks, label: ".git/hooks" });
135
+ add({ absolute: managedHooks, label: ".git/knodin-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";
142
+ add({ absolute: effectiveHooks, label: effectiveLabel });
143
+ for (const hookRoot of [
144
+ { absolute: defaultHooks, label: ".git/hooks" },
145
+ { absolute: managedHooks, label: ".git/knodin-hooks" },
146
+ { absolute: effectiveHooks, label: effectiveLabel },
147
+ ]) {
148
+ for (const entry of boundedDirectoryEntries(hookRoot.absolute))
149
+ add({
150
+ absolute: path.join(hookRoot.absolute, entry.name),
151
+ label: `${hookRoot.label}/${entry.name}`,
152
+ });
153
+ }
154
+ for (const hookName of HOOK_NAMES) {
155
+ add({ absolute: path.join(effectiveHooks, hookName), label: `${effectiveLabel}/${hookName}` });
156
+ add({
157
+ absolute: path.join(effectiveHooks, `${hookName}.knodin-original`),
158
+ label: `${effectiveLabel}/${hookName}.knodin-original`,
159
+ });
160
+ }
161
+ for (const hookRoot of [
162
+ { absolute: defaultHooks, label: ".git/hooks" },
163
+ { absolute: managedHooks, label: ".git/knodin-hooks" },
164
+ ]) {
165
+ for (const hookName of HOOK_NAMES) {
166
+ add({
167
+ absolute: path.join(hookRoot.absolute, hookName),
168
+ label: `${hookRoot.label}/${hookName}`,
169
+ });
170
+ add({
171
+ absolute: path.join(hookRoot.absolute, `${hookName}.knodin-original`),
172
+ label: `${hookRoot.label}/${hookName}.knodin-original`,
173
+ });
174
+ }
175
+ }
176
+ const configured = configuredHooksPath(repo);
177
+ const relativeConfigured = configured ? repoRelativeHooksPath(repo, configured) : null;
178
+ if (relativeConfigured && hasTrackedHooks(repo, relativeConfigured)) {
179
+ const configuredRoot = path.join(repo, relativeConfigured);
180
+ for (const entry of boundedDirectoryEntries(configuredRoot)) {
181
+ if (!entry.isFile() || HOOK_NAMES.includes(entry.name))
182
+ continue;
183
+ const source = path.join(configuredRoot, entry.name);
184
+ if ((fs.statSync(source).mode & 0o111) === 0)
185
+ continue;
186
+ add({
187
+ absolute: path.join(managedHooks, entry.name),
188
+ label: `.git/knodin-hooks/${entry.name}`,
189
+ });
190
+ }
191
+ }
192
+ if (targets.size > MUTATION_AUDIT_MAX_ENTRIES)
193
+ throw new Error(`configure mutation audit exceeds ${MUTATION_AUDIT_MAX_ENTRIES} managed paths`);
194
+ return [...targets.values()];
195
+ }
196
+ function fileSignature(filePath, allowOversized = false) {
197
+ try {
198
+ const stat = fs.lstatSync(filePath);
199
+ if (stat.isDirectory())
200
+ return `directory:${stat.mode & 0o777}`;
201
+ if (stat.isSymbolicLink()) {
202
+ const link = fs.readlinkSync(filePath);
203
+ const resolved = fs.realpathSync(filePath);
204
+ return `symlink:${stat.mode & 0o777}:${link}:${fileSignature(resolved, allowOversized)}`;
205
+ }
206
+ if (!stat.isFile())
207
+ throw new Error(`${filePath}: unsupported filesystem entry`);
208
+ if (stat.size > MUTATION_AUDIT_MAX_FILE_BYTES && !allowOversized)
209
+ throw new Error(`${filePath}: configure mutation audit refuses files larger than ${MUTATION_AUDIT_MAX_FILE_BYTES} bytes`);
210
+ if (stat.size > MUTATION_AUDIT_MAX_FILE_BYTES)
211
+ return `oversized-file:${stat.mode & 0o777}:${stat.size}`;
212
+ const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
213
+ return `file:${stat.mode & 0o777}:${stat.size}:${digest}`;
214
+ }
215
+ catch (error) {
216
+ if (error.code === "ENOENT")
217
+ return null;
218
+ throw error;
219
+ }
220
+ }
221
+ function mutationSnapshot(targets, allowOversized = false) {
222
+ const snapshot = new Map();
223
+ for (const target of targets) {
224
+ const rootSignature = fileSignature(target.absolute, allowOversized);
225
+ if (rootSignature !== null)
226
+ snapshot.set(target.label, rootSignature);
227
+ }
228
+ return snapshot;
229
+ }
230
+ function filesystemChanges(before, after) {
231
+ return [...new Set([...before.keys(), ...after.keys()])]
232
+ .sort(compareBytes)
233
+ .flatMap((changedPath) => {
234
+ const prior = before.get(changedPath);
235
+ const current = after.get(changedPath);
236
+ if (prior === current)
237
+ return [];
238
+ let action = "modified";
239
+ if (prior === undefined)
240
+ action = "created";
241
+ else if (current === undefined)
242
+ action = "removed";
243
+ return [{ path: changedPath, action }];
244
+ });
245
+ }
246
+ const SHELL_QUOTE_ESCAPE = String.raw `'\''`;
247
+ function shellQuote(value) {
248
+ return `'${value.replaceAll("'", SHELL_QUOTE_ESCAPE)}'`;
249
+ }
250
+ function nulPaths(repo, args) {
251
+ const separator = args.indexOf("--");
252
+ const nulArgs = separator < 0 ? [...args, "-z"] : [...args.slice(0, separator), "-z", ...args.slice(separator)];
253
+ const output = runGit(repo, nulArgs, "utf-8");
254
+ return output.split("\0").filter(Boolean);
255
+ }
256
+ function diffPaths(repo, before, after) {
257
+ if (!/^[0-9a-fA-F]{40,64}$/.test(before) || !/^[0-9a-fA-F]{40,64}$/.test(after)) {
258
+ throw new Error("knodin hook refresh: invalid Git object id");
259
+ }
260
+ return nulPaths(repo, ["diff", "--name-only", before, after, "--"]);
261
+ }
262
+ /**
263
+ * Resolve changed paths from a Git lifecycle event and incrementally reconcile
264
+ * exactly the candidates accepted by the engine's full-repository policy.
265
+ */
266
+ export async function refreshFromGitEvent(repo, event, index) {
267
+ const resolvedRepo = path.resolve(repo);
268
+ let changed = [];
269
+ let rewriteInputToRemove;
270
+ try {
271
+ if (event.kind === "commit") {
272
+ changed = nulPaths(resolvedRepo, [
273
+ "diff-tree",
274
+ "--root",
275
+ "--no-commit-id",
276
+ "--name-only",
277
+ "-r",
278
+ "HEAD",
279
+ ]);
280
+ }
281
+ else if (event.kind === "checkout" || event.kind === "merge") {
282
+ changed = diffPaths(resolvedRepo, event.before, event.after);
283
+ }
284
+ else {
285
+ const hooksRoot = path.join(resolvedRepo, ".knodin", "hooks");
286
+ const inputPath = path.resolve(event.inputPath);
287
+ const relativeInput = path.relative(hooksRoot, inputPath);
288
+ if (relativeInput === "" ||
289
+ relativeInput === ".." ||
290
+ relativeInput.startsWith(`..${path.sep}`) ||
291
+ path.isAbsolute(relativeInput)) {
292
+ throw new Error("knodin hook refresh: rewrite input must be inside .knodin/hooks");
293
+ }
294
+ const [realHooksRoot, realInputPath, inputStat] = await Promise.all([
295
+ fs.promises.realpath(hooksRoot),
296
+ fs.promises.realpath(inputPath),
297
+ fs.promises.lstat(inputPath),
298
+ ]);
299
+ const realRelativeInput = path.relative(realHooksRoot, realInputPath);
300
+ if (inputStat.isSymbolicLink() ||
301
+ !inputStat.isFile() ||
302
+ realRelativeInput === "" ||
303
+ realRelativeInput === ".." ||
304
+ realRelativeInput.startsWith(`..${path.sep}`) ||
305
+ path.isAbsolute(realRelativeInput)) {
306
+ throw new Error("knodin hook refresh: rewrite input must be a regular file inside .knodin/hooks");
307
+ }
308
+ rewriteInputToRemove = inputPath;
309
+ const pairs = (await fs.promises.readFile(inputPath, "utf-8"))
310
+ .split(/\r?\n/)
311
+ .map((line) => line.trim().split(/\s+/))
312
+ .filter((pair) => pair.length >= 2);
313
+ for (const [before, after] of pairs) {
314
+ changed.push(...diffPaths(resolvedRepo, before, after));
315
+ }
316
+ }
317
+ const eligible = [...new Set(changed.map((file) => file.replaceAll("\\", "/")))]
318
+ .filter(isIndexableSourcePath)
319
+ .sort(compareBytes);
320
+ if (eligible.length > 0)
321
+ await index(resolvedRepo, eligible);
322
+ return eligible;
323
+ }
324
+ finally {
325
+ if (rewriteInputToRemove)
326
+ await fs.promises.rm(rewriteInputToRemove, { force: true });
327
+ }
328
+ }
329
+ function backgroundScript(command) {
330
+ const invocation = command.map(shellQuote).join(" ");
331
+ return String.raw `#!/bin/sh
332
+ # knodin packaged background refresh. Generated by knodin init.
333
+ set -u
334
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
335
+ [ -n "$REPO_ROOT" ] || exit 0
336
+ cd "$REPO_ROOT" || exit 0
337
+ mkdir -p "$REPO_ROOT/.knodin"
338
+ LOCK_DIR="$REPO_ROOT/.knodin/hooks/refresh.lock"
339
+ EVENT_DIR="$REPO_ROOT/.knodin/hooks/events"
340
+ LAST_EVENT_PATH="$REPO_ROOT/.knodin/hooks/last-refresh-event"
341
+ FAILURE_PATH="$REPO_ROOT/.knodin/hooks/${HOOK_FAILURE_FILE}"
342
+ FAILURE_TMP="$FAILURE_PATH.$$"
343
+ mkdir -p "$EVENT_DIR"
344
+ if [ "$#" -gt 0 ]; then
345
+ EVENT_TMP="$EVENT_DIR/$(date +%s).$$.$PPID.compat.tmp"
346
+ EVENT_SEQUENCE="$(date +%s).$$.$PPID"
347
+ EVENT_WORKTREE="$REPO_ROOT"
348
+ EVENT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
349
+ {
350
+ for EVENT_ARG in "$@"; do
351
+ printf '%s\n' "$EVENT_ARG"
352
+ done
353
+ printf 'sequence=%s\nworktree=%s\nat=%s\n' "$EVENT_SEQUENCE" "$EVENT_WORKTREE" "$EVENT_AT"
354
+ } > "$EVENT_TMP"
355
+ mv "$EVENT_TMP" "$EVENT_TMP.event"
356
+ fi
357
+ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
358
+ if [ -f "$LOCK_DIR/pid" ]; then
359
+ LOCK_PID="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
360
+ if [ -n "$LOCK_PID" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
361
+ rm -rf "$LOCK_DIR"
362
+ mkdir "$LOCK_DIR" 2>/dev/null || exit 0
363
+ else
364
+ exit 0
365
+ fi
366
+ else
367
+ exit 0
368
+ fi
369
+ fi
370
+ printf '%s\n' "$$" > "$LOCK_DIR/pid"
371
+ trap 'rm -rf "$LOCK_DIR"; rm -f "$FAILURE_TMP"' EXIT HUP INT TERM
372
+ LOG_PATH="$REPO_ROOT/.knodin/indexer.log"
373
+ if [ -f "$LOG_PATH" ]; then
374
+ LOG_BYTES="$(wc -c < "$LOG_PATH" 2>/dev/null | tr -d '[:space:]')"
375
+ case "$LOG_BYTES" in ''|*[!0-9]*) LOG_BYTES=0 ;; esac
376
+ if [ "$LOG_BYTES" -ge ${HOOK_LOG_MAX_BYTES} ]; then
377
+ mv -f "$LOG_PATH" "$LOG_PATH.1"
378
+ fi
379
+ fi
380
+ while :; do
381
+ EVENT="$(find "$EVENT_DIR" -type f -name '*.event' -print 2>/dev/null | LC_ALL=C sort | head -n 1)"
382
+ [ -n "$EVENT" ] || break
383
+ KIND="$(sed -n '1p' "$EVENT")"
384
+ ARG1="$(sed -n '2p' "$EVENT")"
385
+ ARG2="$(sed -n '3p' "$EVENT")"
386
+ if [ "$KIND" = "commit" ]; then
387
+ ${invocation} --repo "$REPO_ROOT" hook-refresh commit --json >> "$LOG_PATH" 2>&1
388
+ elif [ "$KIND" = "checkout" ] || [ "$KIND" = "merge" ]; then
389
+ ${invocation} --repo "$REPO_ROOT" hook-refresh "$KIND" "$ARG1" "$ARG2" --json >> "$LOG_PATH" 2>&1
390
+ elif [ "$KIND" = "rewrite" ]; then
391
+ ${invocation} --repo "$REPO_ROOT" hook-refresh rewrite "$ARG1" --json >> "$LOG_PATH" 2>&1
392
+ else
393
+ printf 'knodin background refresh: invalid queued event %s\n' "$KIND" >> "$LOG_PATH"
394
+ false
395
+ fi
396
+ STATUS=$?
397
+ if [ "$STATUS" -eq 0 ]; then
398
+ cp "$EVENT" "$LAST_EVENT_PATH"
399
+ rm -f "$EVENT" "$FAILURE_PATH"
400
+ else
401
+ {
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'
405
+ } > "$FAILURE_TMP"
406
+ mv -f "$FAILURE_TMP" "$FAILURE_PATH"
407
+ exit "$STATUS"
408
+ fi
409
+ done
410
+ `;
411
+ }
412
+ function queuedLifecycleEvents(repo) {
413
+ const eventDirectory = path.join(repo, ".knodin", "hooks", "events");
414
+ try {
415
+ return fs.readdirSync(eventDirectory).filter((entry) => entry.endsWith(".event"));
416
+ }
417
+ catch {
418
+ return [];
419
+ }
420
+ }
421
+ function refreshProcessorIsRunning(repo) {
422
+ try {
423
+ const pid = Number(fs.readFileSync(path.join(repo, ".knodin", "hooks", "refresh.lock", "pid"), "utf-8"));
424
+ if (!Number.isInteger(pid) || pid <= 0)
425
+ return false;
426
+ process.kill(pid, 0);
427
+ return true;
428
+ }
429
+ catch {
430
+ return false;
431
+ }
432
+ }
433
+ function drainQueuedLifecycleEvents(repo, backgroundPath) {
434
+ const before = queuedLifecycleEvents(repo);
435
+ if (before.length === 0)
436
+ return { state: "fresh", queuedEvents: 0 };
437
+ const result = child_process.spawnSync(backgroundPath, [], {
438
+ cwd: repo,
439
+ encoding: "utf-8",
440
+ stdio: ["ignore", "pipe", "pipe"],
441
+ timeout: 30_000,
442
+ });
443
+ if (result.status !== 0) {
444
+ const detail = result.error?.message || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
445
+ throw new Error(`knodin init: queued lifecycle refresh could not be drained (${detail}); run ${backgroundPath} after resolving .knodin/hooks/last-refresh-failure`);
446
+ }
447
+ const remaining = queuedLifecycleEvents(repo);
448
+ if (remaining.length === 0)
449
+ return { state: "fresh", queuedEvents: 0 };
450
+ if (refreshProcessorIsRunning(repo)) {
451
+ return { state: "running", queuedEvents: remaining.length };
452
+ }
453
+ if (remaining.length > 0) {
454
+ throw new Error("knodin init: queued lifecycle events remain after refresh; run `knodin wait --fresh`, then retry initialization");
455
+ }
456
+ return { state: "fresh", queuedEvents: 0 };
457
+ }
458
+ function managedHookScript(hookName, originalPath) {
459
+ let original = "";
460
+ if (originalPath)
461
+ original = originalPath.startsWith("repo:")
462
+ ? `"$REPO_ROOT"/${shellQuote(originalPath.slice("repo:".length))}`
463
+ : shellQuote(originalPath);
464
+ const rewriteRedirect = hookName === "post-rewrite" ? ' < "$REWRITE_INPUT"' : "";
465
+ const runOriginal = originalPath
466
+ ? `KNODIN_WRAPPER_ACTIVE=1 ${original} "$@"${rewriteRedirect} || ORIGINAL_STATUS=$?`
467
+ : ":";
468
+ let trigger = "";
469
+ if (hookName === "post-commit")
470
+ trigger = "trigger_knodin commit";
471
+ if (hookName === "post-checkout") {
472
+ trigger = `[ "\${3:-0}" = "1" ] && trigger_knodin checkout "\${1:-}" "\${2:-}"`;
473
+ }
474
+ if (hookName === "post-merge") {
475
+ trigger = `BEFORE="$(git rev-parse ORIG_HEAD 2>/dev/null || true)"
476
+ AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
477
+ [ -n "$BEFORE" ] && [ -n "$AFTER" ] && trigger_knodin merge "$BEFORE" "$AFTER"`;
478
+ }
479
+ const rewriteWithoutIndexer = originalPath
480
+ ? `KNODIN_WRAPPER_ACTIVE=1 ${original} "$@"
481
+ exit $?`
482
+ : "exit 0";
483
+ const rewriteSetup = hookName === "post-rewrite"
484
+ ? `if [ ! -x "$BACKGROUND" ]; then
485
+ ${rewriteWithoutIndexer}
486
+ fi
487
+ REWRITE_INPUT="$(mktemp "$REPO_ROOT/.knodin/hooks/rewrite-input.XXXXXX")" || exit 0
488
+ cat > "$REWRITE_INPUT"
489
+ `
490
+ : "";
491
+ if (hookName === "post-rewrite")
492
+ trigger = 'trigger_knodin rewrite "$REWRITE_INPUT"';
493
+ return `#!/bin/sh
494
+ # ${MANAGED_MARKER}: ${hookName}
495
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
496
+ [ -n "$REPO_ROOT" ] || exit 0
497
+ BACKGROUND="$REPO_ROOT/.knodin/hooks/background-index.sh"
498
+ ORIGINAL_STATUS=0
499
+ trigger_knodin() {
500
+ [ -x "$BACKGROUND" ] || return 0
501
+ EVENT_KIND="\${1:-}"
502
+ EVENT_BEFORE="\${2:-}"
503
+ EVENT_AFTER="\${3:-}"
504
+ if [ "$EVENT_KIND" = "commit" ]; then
505
+ EVENT_BEFORE="$(git rev-parse HEAD^ 2>/dev/null || true)"
506
+ EVENT_AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
507
+ set -- commit "$EVENT_BEFORE" "$EVENT_AFTER"
508
+ elif [ "$EVENT_KIND" = "rewrite" ] && [ -f "\${2:-}" ]; then
509
+ EVENT_BEFORE="$(sed -n '1{s/ .*//;p;}' "$2")"
510
+ EVENT_AFTER="$(sed -n '1{s/^[^ ]* //;s/ .*//;p;}' "$2")"
511
+ fi
512
+ EVENT_DIR="$REPO_ROOT/.knodin/hooks/events"
513
+ mkdir -p "$EVENT_DIR" || return 0
514
+ EVENT_TMP="$EVENT_DIR/$(date +%s).$$.$PPID.tmp"
515
+ EVENT_PATH="$EVENT_TMP.event"
516
+ EVENT_SEQUENCE="$(date +%s).$$.$PPID"
517
+ EVENT_WORKTREE="$REPO_ROOT"
518
+ EVENT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
519
+ {
520
+ for EVENT_ARG in "$@"; do
521
+ printf '%s\\n' "$EVENT_ARG"
522
+ done
523
+ printf 'sequence=%s\\nworktree=%s\\nat=%s\\nbefore=%s\\nafter=%s\\n' \\
524
+ "$EVENT_SEQUENCE" "$EVENT_WORKTREE" "$EVENT_AT" "$EVENT_BEFORE" "$EVENT_AFTER"
525
+ } > "$EVENT_TMP" || return 0
526
+ mv "$EVENT_TMP" "$EVENT_PATH" || return 0
527
+ if [ "\${KNODIN_HOOK_FOREGROUND:-0}" = "1" ]; then
528
+ "$BACKGROUND"
529
+ else
530
+ nohup "$BACKGROUND" >/dev/null 2>&1 &
531
+ fi
532
+ }
533
+ ${rewriteSetup}${runOriginal}
534
+ ${trigger}
535
+ exit "$ORIGINAL_STATUS"
536
+ `;
537
+ }
538
+ async function preventLefthookAutoInstall(hookPath) {
539
+ if (!fs.existsSync(hookPath))
540
+ return;
541
+ const content = await fs.promises.readFile(hookPath, "utf-8");
542
+ if (!content.includes("call_lefthook()"))
543
+ return;
544
+ const invocation = content.match(/^call_lefthook run "([^"\r\n]+)" "\$@"$/m);
545
+ if (!invocation)
546
+ return;
547
+ await fs.promises.writeFile(hookPath, content.replace(invocation[0], `call_lefthook run "${invocation[1]}" --no-auto-install "$@"`), "utf-8");
548
+ }
549
+ async function installManagedHook(hooksDir, hookName, preservedOriginal = undefined) {
550
+ const hookPath = path.join(hooksDir, hookName);
551
+ let originalPath = preservedOriginal ?? null;
552
+ if (preservedOriginal === undefined && fs.existsSync(hookPath)) {
553
+ const content = await fs.promises.readFile(hookPath, "utf-8");
554
+ if (content.startsWith(`#!/bin/sh\n# ${MANAGED_MARKER}: ${hookName}\n`)) {
555
+ const match = content.match(/^# knodin original: (.+)$/m);
556
+ originalPath = match?.[1] ?? null;
557
+ }
558
+ else {
559
+ const backupPath = `${hookPath}.knodin-original`;
560
+ const mode = (await fs.promises.stat(hookPath)).mode;
561
+ await fs.promises.copyFile(hookPath, backupPath);
562
+ await fs.promises.rm(hookPath, { force: true });
563
+ // Older releases appended a new backup on every displacement. Remove
564
+ // only copies that are provably redundant: another knodin wrapper or
565
+ // byte-identical to the authoritative current hook backup.
566
+ const canonical = await fs.promises.readFile(backupPath, "utf-8");
567
+ for (let suffix = 1;; suffix++) {
568
+ const candidate = `${backupPath}.${suffix}`;
569
+ if (!fs.existsSync(candidate))
570
+ break;
571
+ const candidateContent = await fs.promises.readFile(candidate, "utf-8");
572
+ if (candidateContent === canonical || candidateContent.includes(MANAGED_MARKER))
573
+ await fs.promises.rm(candidate, { force: true });
574
+ }
575
+ // Match Git's semantics: preserve inactive hook files as backups, but
576
+ // never begin executing one merely because knodin installed a wrapper.
577
+ if ((mode & 0o111) !== 0)
578
+ originalPath = backupPath;
579
+ }
580
+ }
581
+ if (originalPath)
582
+ await preventLefthookAutoInstall(originalPath);
583
+ let content = managedHookScript(hookName, originalPath);
584
+ if (originalPath)
585
+ content = content.replace("\nREPO_ROOT=", `\n# knodin original: ${originalPath}\nREPO_ROOT=`);
586
+ await fs.promises.writeFile(hookPath, content, {
587
+ encoding: "utf-8",
588
+ mode: 0o755,
589
+ });
590
+ await fs.promises.chmod(hookPath, 0o755);
591
+ }
592
+ const ORIGINAL_HOOKS_CONFIG = "knodin.originalHooksPath";
593
+ function effectiveHooksDirectory(repo) {
594
+ let raw;
595
+ try {
596
+ raw = runGit(repo, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]).trim();
597
+ }
598
+ catch {
599
+ raw = runGit(repo, ["rev-parse", "--git-path", "hooks"]).trim();
600
+ }
601
+ return path.isAbsolute(raw) ? raw : path.resolve(repo, raw);
602
+ }
603
+ function configuredHooksPath(repo) {
604
+ try {
605
+ return runGit(repo, ["config", "--local", "--get", ORIGINAL_HOOKS_CONFIG]).trim() || null;
606
+ }
607
+ catch {
608
+ try {
609
+ return runGit(repo, ["config", "--local", "--get", "core.hooksPath"]).trim() || null;
610
+ }
611
+ catch {
612
+ return null;
613
+ }
614
+ }
615
+ }
616
+ function repoRelativeHooksPath(repo, configured) {
617
+ const absolute = path.isAbsolute(configured) ? configured : path.resolve(repo, configured);
618
+ const relative = path.relative(repo, absolute).replaceAll("\\", "/");
619
+ if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative))
620
+ return null;
621
+ return relative;
622
+ }
623
+ function hasTrackedHooks(repo, relativeHooksDir) {
624
+ try {
625
+ return nulPaths(repo, ["ls-files", "--", relativeHooksDir]).length > 0;
626
+ }
627
+ catch {
628
+ return false;
629
+ }
630
+ }
631
+ function passthroughHookScript(relativeHookPath) {
632
+ return `#!/bin/sh
633
+ # ${MANAGED_MARKER}: tracked-hook passthrough
634
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
635
+ [ -n "$REPO_ROOT" ] || exit 0
636
+ ORIGINAL="$REPO_ROOT"/${shellQuote(relativeHookPath)}
637
+ [ -x "$ORIGINAL" ] || exit 0
638
+ exec "$ORIGINAL" "$@"
639
+ `;
640
+ }
641
+ async function prepareHookInstallation(repo) {
642
+ const configured = configuredHooksPath(repo);
643
+ const relative = configured ? repoRelativeHooksPath(repo, configured) : null;
644
+ if (!configured || !relative || !hasTrackedHooks(repo, relative)) {
645
+ return { hooksDir: effectiveHooksDirectory(repo), trackedHooksDir: null };
646
+ }
647
+ const commonGitDirRaw = runGit(repo, ["rev-parse", "--git-common-dir"]).trim();
648
+ const commonGitDir = path.isAbsolute(commonGitDirRaw)
649
+ ? commonGitDirRaw
650
+ : path.resolve(repo, commonGitDirRaw);
651
+ const hooksDir = path.join(commonGitDir, "knodin-hooks");
652
+ await fs.promises.mkdir(hooksDir, { recursive: true });
653
+ runGit(repo, ["config", "--local", ORIGINAL_HOOKS_CONFIG, configured]);
654
+ runGit(repo, ["config", "--local", "core.hooksPath", hooksDir]);
655
+ const sourceDir = path.join(repo, relative);
656
+ for (const entry of await fs.promises.readdir(sourceDir, {
657
+ withFileTypes: true,
658
+ })) {
659
+ if (!entry.isFile() || HOOK_NAMES.includes(entry.name))
660
+ continue;
661
+ const source = path.join(sourceDir, entry.name);
662
+ const stat = await fs.promises.stat(source);
663
+ if ((stat.mode & 0o111) === 0)
664
+ continue;
665
+ const target = path.join(hooksDir, entry.name);
666
+ await fs.promises.writeFile(target, passthroughHookScript(`${relative}/${entry.name}`), {
667
+ encoding: "utf-8",
668
+ mode: 0o755,
669
+ });
670
+ await fs.promises.chmod(target, 0o755);
671
+ }
672
+ return { hooksDir, trackedHooksDir: relative };
673
+ }
674
+ const NUDGE_SCRIPT = String.raw `#!/bin/sh
675
+ # Example Claude Code hook: suggest local graph context for broad manual searches.
676
+ read -r INPUT_PAYLOAD
677
+ case "$INPUT_PAYLOAD" in
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." ;;
679
+ *) REASON="Proceeding with tool execution." ;;
680
+ esac
681
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' "$REASON"
682
+ `;
683
+ export const SETTINGS_TEMPLATE = `{
684
+ "comment": "Template only: opt in to local knodin context nudges.",
685
+ "hooks": {
686
+ "PreToolUse": [{
687
+ "matcher": "Bash|Read|Glob",
688
+ "hooks": [{"type": "command", "command": "./.claude/hooks/nudge.sh", "timeout": 5}]
689
+ }]
690
+ }
691
+ }
692
+ `;
693
+ const KNODIN_AGENT_INSTRUCTIONS = `<!-- knodin:start -->
694
+ ## knodin — Local Code Intelligence
695
+
696
+ This repository is indexed locally by knodin. Start with \`knodin status\`; if it reports \`repair-needed\`, follow its repair steps before relying on graph evidence. Use \`knodin repair\` for graph damage and \`knodin init\` for displaced lifecycle hooks.
697
+
698
+ - 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.
699
+ - 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.
700
+ - Use gateway operations \`explain\`, \`review\`, and \`query\` for source-evidenced context. CLI fallbacks are \`knodin explain\`, \`knodin review --scope compare\`, and \`knodin rename\`.
701
+ - 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.
702
+ - 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.
703
+ - Keep output truthful: local graph evidence can be stale or ambiguous; say so and repair/reindex instead of claiming certainty.
704
+
705
+ <!-- knodin:end -->
706
+ `;
707
+ async function assertSafeRepositoryFile(repo, relativePath) {
708
+ const target = path.resolve(repo, relativePath);
709
+ const relative = path.relative(repo, target);
710
+ if (relative.startsWith("..") || path.isAbsolute(relative))
711
+ throw new Error(`${relativePath}: managed file must stay inside the repository`);
712
+ let current = repo;
713
+ for (const part of relative.split(path.sep)) {
714
+ current = path.join(current, part);
715
+ try {
716
+ if ((await fs.promises.lstat(current)).isSymbolicLink())
717
+ throw new Error(`${relativePath}: refusing to follow a symbolic link`);
718
+ }
719
+ catch (error) {
720
+ if (error.code === "ENOENT")
721
+ break;
722
+ throw error;
723
+ }
724
+ }
725
+ }
726
+ async function installManagedAgentInstructions(repo, fileName = "AGENTS.md") {
727
+ await assertSafeRepositoryFile(repo, fileName);
728
+ const instructionsPath = path.join(repo, fileName);
729
+ let existing = "";
730
+ let existed = true;
731
+ try {
732
+ existing = await fs.promises.readFile(instructionsPath, "utf-8");
733
+ }
734
+ catch (error) {
735
+ if (error.code !== "ENOENT")
736
+ throw error;
737
+ existed = false;
738
+ }
739
+ const canonicalStart = "<!-- knodin:start -->";
740
+ const canonicalEnd = "<!-- knodin:end -->";
741
+ const start = existing.indexOf(canonicalStart);
742
+ const end = existing.indexOf(canonicalEnd);
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}`;
748
+ if (next === existing)
749
+ return null;
750
+ await fs.promises.writeFile(instructionsPath, next, "utf-8");
751
+ return { path: fileName, action: existed ? "modified" : "created" };
752
+ }
753
+ async function hasManagedAgentInstructions(repo, fileName = "AGENTS.md") {
754
+ try {
755
+ return (await fs.promises.readFile(path.join(repo, fileName), "utf-8")).includes(KNODIN_AGENT_INSTRUCTIONS);
756
+ }
757
+ catch {
758
+ return false;
759
+ }
760
+ }
761
+ function removeManagedAgentBlock(content) {
762
+ const start = content.indexOf("<!-- knodin:start -->");
763
+ const end = content.indexOf("<!-- knodin:end -->", start);
764
+ if (start < 0 || end < start)
765
+ return null;
766
+ const prefix = content.slice(0, start);
767
+ const suffix = content.slice(end + "<!-- knodin:end -->".length);
768
+ // The canonical marker-only file owns its final line terminator as part of
769
+ // the generated block. Any other prefix/suffix bytes remain untouched.
770
+ if (prefix.length === 0 && /^\r?\n$/.test(suffix))
771
+ return "";
772
+ return `${prefix}${suffix}`;
773
+ }
774
+ function isTracked(repo, relativePath) {
775
+ try {
776
+ return nulPaths(repo, ["ls-files", "--", relativePath]).length > 0;
777
+ }
778
+ catch {
779
+ return false;
780
+ }
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
+ }
804
+ async function excludeUntrackedPaths(repo, candidates) {
805
+ const safe = [...new Set(candidates)]
806
+ .filter((candidate) => !isTracked(repo, candidate))
807
+ .sort(compareBytes);
808
+ if (safe.length === 0)
809
+ return [];
810
+ const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
811
+ const excludePath = path.isAbsolute(rawExcludePath)
812
+ ? rawExcludePath
813
+ : path.resolve(repo, rawExcludePath);
814
+ let existing = "";
815
+ try {
816
+ existing = await fs.promises.readFile(excludePath, "utf-8");
817
+ }
818
+ catch (error) {
819
+ if (error.code !== "ENOENT")
820
+ throw error;
821
+ }
822
+ const startMarker = "# knodin:start";
823
+ const endMarker = "# knodin:end";
824
+ const outside = withoutManagedExcludeBlock(existing);
825
+ const outsideLines = new Set(outside.split(/\r?\n/));
826
+ const owned = safe.filter((candidate) => !outsideLines.has(candidate));
827
+ const prefix = outside && !outside.endsWith("\n") ? `${outside}\n` : outside;
828
+ const block = owned.length > 0 ? `${startMarker}\n${owned.join("\n")}\n${endMarker}\n` : "";
829
+ await fs.promises.mkdir(path.dirname(excludePath), { recursive: true });
830
+ await fs.promises.writeFile(excludePath, `${prefix}${block}`, "utf-8");
831
+ return owned;
832
+ }
833
+ async function removeManagedExcludes(repo) {
834
+ const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
835
+ const excludePath = path.isAbsolute(rawExcludePath)
836
+ ? rawExcludePath
837
+ : path.resolve(repo, rawExcludePath);
838
+ let existing;
839
+ try {
840
+ existing = await fs.promises.readFile(excludePath, "utf-8");
841
+ }
842
+ catch (error) {
843
+ if (error.code === "ENOENT")
844
+ return;
845
+ throw error;
846
+ }
847
+ const next = withoutManagedExcludeBlock(existing);
848
+ await fs.promises.writeFile(excludePath, next, "utf-8");
849
+ }
850
+ async function removeManagedPersonalArtifacts(repo, allowTrackedTransition = false) {
851
+ const changes = [];
852
+ for (const fileName of ["AGENTS.md", "GEMINI.md"]) {
853
+ const instructionsPath = path.join(repo, fileName);
854
+ const tracked = isTracked(repo, fileName);
855
+ try {
856
+ await assertSafeRepositoryFile(repo, fileName);
857
+ const current = await fs.promises.readFile(instructionsPath, "utf-8");
858
+ const start = current.indexOf("<!-- knodin:start -->");
859
+ const end = current.indexOf("<!-- knodin:end -->", start);
860
+ const without = removeManagedAgentBlock(current);
861
+ if (!tracked) {
862
+ if (without !== null) {
863
+ if (without.length > 0) {
864
+ await fs.promises.writeFile(instructionsPath, without, "utf-8");
865
+ changes.push({ path: fileName, action: "modified" });
866
+ }
867
+ else {
868
+ await fs.promises.rm(instructionsPath, { force: true });
869
+ changes.push({ path: fileName, action: "removed" });
870
+ }
871
+ }
872
+ continue;
873
+ }
874
+ if (allowTrackedTransition && without !== null) {
875
+ await fs.promises.writeFile(instructionsPath, without, "utf-8");
876
+ changes.push({ path: fileName, action: "modified" });
877
+ continue;
878
+ }
879
+ const head = runGit(repo, ["show", `HEAD:${fileName}`]);
880
+ if (start >= 0 && end >= start && !head.includes("<!-- knodin:start -->")) {
881
+ const without = `${current.slice(0, start)}${current.slice(end + "<!-- knodin:end -->".length)}`
882
+ .replace(/\n{3,}/g, "\n\n")
883
+ .trimEnd();
884
+ if (without === head.trimEnd())
885
+ await fs.promises.writeFile(instructionsPath, head);
886
+ }
887
+ }
888
+ catch {
889
+ // A missing HEAD version or unrelated working-tree edits are ambiguous.
890
+ }
891
+ }
892
+ const gitIgnorePath = path.join(repo, ".gitignore");
893
+ try {
894
+ const current = await fs.promises.readFile(gitIgnorePath, "utf-8");
895
+ const next = current
896
+ .replace(/(?:^|\n)# knodin:start\n\.knodin\/\n# knodin:end\n?/g, "\n")
897
+ .trim();
898
+ if (isTracked(repo, ".gitignore")) {
899
+ const head = runGit(repo, ["show", "HEAD:.gitignore"]);
900
+ if (allowTrackedTransition)
901
+ await fs.promises.writeFile(gitIgnorePath, next ? `${next}\n` : "");
902
+ else if (next === head.trim())
903
+ await fs.promises.writeFile(gitIgnorePath, head, "utf-8");
904
+ }
905
+ else if (next)
906
+ await fs.promises.writeFile(gitIgnorePath, `${next}\n`, "utf-8");
907
+ else
908
+ await fs.promises.rm(gitIgnorePath, { force: true });
909
+ }
910
+ catch {
911
+ // Missing or user-modified files are intentionally preserved.
912
+ }
913
+ for (const relative of [".claude/hooks/nudge.sh", ".claude/settings.example.json"]) {
914
+ if (isTracked(repo, relative))
915
+ continue;
916
+ const target = path.join(repo, relative);
917
+ try {
918
+ const content = await fs.promises.readFile(target, "utf-8");
919
+ if ((relative.endsWith("nudge.sh") && content === NUDGE_SCRIPT) ||
920
+ (relative.endsWith("settings.example.json") && content === SETTINGS_TEMPLATE))
921
+ await fs.promises.rm(target, { force: true });
922
+ }
923
+ catch {
924
+ // Missing or user-modified files are intentionally preserved.
925
+ }
926
+ }
927
+ return changes;
928
+ }
929
+ export function readRepositoryIntegrationConfig(repo) {
930
+ try {
931
+ const value = JSON.parse(fs.readFileSync(path.join(repo, ".knodin", "integration.json"), "utf-8"));
932
+ if ((value.scope === "personal" || value.scope === "team" || value.scope === "cli-only") &&
933
+ Array.isArray(value.agents))
934
+ return { scope: value.scope, agents: value.agents };
935
+ }
936
+ catch {
937
+ // Missing or invalid local state is reported as unconfigured.
938
+ }
939
+ return null;
940
+ }
941
+ function hasSafeManagedAgentBlock(repo, relativePath) {
942
+ try {
943
+ let current = repo;
944
+ for (const part of relativePath.split(path.sep)) {
945
+ current = path.join(current, part);
946
+ if (fs.lstatSync(current).isSymbolicLink())
947
+ return false;
948
+ }
949
+ const content = fs.readFileSync(path.join(repo, relativePath), "utf-8");
950
+ const start = content.indexOf("<!-- knodin:start -->");
951
+ return start >= 0 && content.indexOf("<!-- knodin:end -->", start) >= start;
952
+ }
953
+ catch {
954
+ return false;
955
+ }
956
+ }
957
+ /** Detect repository-owned client configuration independently of knodin's local receipt. */
958
+ export function inspectRepositoryIntegrationStatus(repo) {
959
+ const receipt = readRepositoryIntegrationConfig(repo);
960
+ const candidates = [
961
+ { agent: "claude", path: ".mcp.json", marker: '"knodin"' },
962
+ { agent: "codex", path: ".codex/config.toml", marker: 'mcp_servers."knodin"' },
963
+ { agent: "gemini", path: ".gemini/settings.json", marker: '"knodin"' },
964
+ { agent: "antigravity", path: ".agents/mcp_config.json", marker: '"knodin"' },
965
+ {
966
+ agent: "codex",
967
+ path: "AGENTS.md",
968
+ marker: "<!-- knodin:start -->",
969
+ managedGuidance: true,
970
+ },
971
+ {
972
+ agent: "gemini",
973
+ path: "GEMINI.md",
974
+ marker: "<!-- knodin:start -->",
975
+ managedGuidance: true,
976
+ },
977
+ ];
978
+ const configurations = candidates.map(({ agent, path: relative, marker, managedGuidance }) => {
979
+ try {
980
+ return {
981
+ agent,
982
+ path: relative,
983
+ present: managedGuidance
984
+ ? hasSafeManagedAgentBlock(repo, relative)
985
+ : fs.readFileSync(path.join(repo, relative), "utf-8").includes(marker),
986
+ };
987
+ }
988
+ catch {
989
+ return { agent, path: relative, present: false };
990
+ }
991
+ });
992
+ const detected = configurations.filter(({ present }) => present).map(({ agent }) => agent);
993
+ const agents = [...new Set([...(receipt?.agents ?? []), ...detected])];
994
+ if (!receipt && agents.length === 0)
995
+ return null;
996
+ return {
997
+ scope: receipt?.scope ?? "repository-detected",
998
+ agents,
999
+ receiptPresent: receipt !== null,
1000
+ configurations,
1001
+ };
1002
+ }
1003
+ async function writeRepositoryIntegrationConfig(repo, config) {
1004
+ await fs.promises.writeFile(path.join(repo, ".knodin", "integration.json"), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
1005
+ }
1006
+ async function ensureTeamGitIgnore(repo) {
1007
+ if (isTracked(repo, ".knodin"))
1008
+ return;
1009
+ await assertSafeRepositoryFile(repo, ".gitignore");
1010
+ const ignorePath = path.join(repo, ".gitignore");
1011
+ let existing = "";
1012
+ try {
1013
+ existing = await fs.promises.readFile(ignorePath, "utf-8");
1014
+ }
1015
+ catch (error) {
1016
+ if (error.code !== "ENOENT")
1017
+ throw error;
1018
+ }
1019
+ if (existing.split(/\r?\n/).includes(".knodin/"))
1020
+ return;
1021
+ const prefix = existing && !existing.endsWith("\n") ? `${existing}\n` : existing;
1022
+ await fs.promises.writeFile(ignorePath, `${prefix}# knodin:start\n.knodin/\n# knodin:end\n`, "utf-8");
1023
+ }
1024
+ export function detectTrackedTeamIntegration(repo) {
1025
+ const checks = [
1026
+ ["AGENTS.md", "<!-- knodin:start -->"],
1027
+ ["GEMINI.md", "<!-- knodin:start -->"],
1028
+ [".gitignore", "# knodin:start"],
1029
+ [".mcp.json", '"knodin"'],
1030
+ [".codex/config.toml", "# knodin:start"],
1031
+ [".gemini/settings.json", '"knodin"'],
1032
+ [".agents/mcp_config.json", '"knodin"'],
1033
+ ];
1034
+ return checks.some(([relative, marker]) => {
1035
+ if (!isTracked(repo, relative))
1036
+ return false;
1037
+ try {
1038
+ return fs.readFileSync(path.join(repo, relative), "utf-8").includes(marker);
1039
+ }
1040
+ catch {
1041
+ return false;
1042
+ }
1043
+ });
1044
+ }
1045
+ /** Install lifecycle hooks and initialize the local graph in a real Git worktree. */
1046
+ export async function initializeRepository(repo, options) {
1047
+ const resolvedRepo = path.resolve(repo);
1048
+ const scope = options.scope ?? "personal";
1049
+ const priorIntegration = readRepositoryIntegrationConfig(resolvedRepo);
1050
+ if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
1051
+ throw new Error(`knodin init: not a Git worktree: ${resolvedRepo}`);
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
+ }
1064
+ if (!path.isAbsolute(options.command[0])) {
1065
+ throw new Error("knodin init: packaged runtime command must be an absolute path");
1066
+ }
1067
+ const mutationTargets = options.auditConfigurationChanges
1068
+ ? configureMutationAuditTargets(resolvedRepo)
1069
+ : [];
1070
+ const beforeMutation = mutationSnapshot(mutationTargets);
1071
+ if (scope !== "team" &&
1072
+ !options.allowTrackedTransition &&
1073
+ detectTrackedTeamIntegration(resolvedRepo)) {
1074
+ 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");
1075
+ }
1076
+ const knodinHooksDir = path.join(resolvedRepo, ".knodin", "hooks");
1077
+ const hookInstallation = await prepareHookInstallation(resolvedRepo);
1078
+ const hooksDir = hookInstallation.hooksDir;
1079
+ await Promise.all([
1080
+ fs.promises.mkdir(knodinHooksDir, { recursive: true }),
1081
+ fs.promises.mkdir(hooksDir, { recursive: true }),
1082
+ ]);
1083
+ const backgroundPath = path.join(knodinHooksDir, "background-index.sh");
1084
+ await fs.promises.writeFile(backgroundPath, backgroundScript(options.command), {
1085
+ encoding: "utf-8",
1086
+ mode: 0o755,
1087
+ });
1088
+ await fs.promises.chmod(backgroundPath, 0o755);
1089
+ const hookManagerIntegration = installHookManagerIntegration(resolvedRepo);
1090
+ for (const hookName of HOOK_NAMES) {
1091
+ let preservedOriginal;
1092
+ if (hookInstallation.trackedHooksDir) {
1093
+ const relativeHook = `${hookInstallation.trackedHooksDir}/${hookName}`;
1094
+ const sourceHook = path.join(resolvedRepo, relativeHook);
1095
+ try {
1096
+ const stat = await fs.promises.stat(sourceHook);
1097
+ preservedOriginal =
1098
+ stat.isFile() && (stat.mode & 0o111) !== 0 ? `repo:${relativeHook}` : null;
1099
+ }
1100
+ catch {
1101
+ preservedOriginal = null;
1102
+ }
1103
+ }
1104
+ await installManagedHook(hooksDir, hookName, preservedOriginal);
1105
+ }
1106
+ for (const entry of await fs.promises.readdir(hooksDir, {
1107
+ withFileTypes: true,
1108
+ })) {
1109
+ if (entry.isFile())
1110
+ await preventLefthookAutoInstall(path.join(hooksDir, entry.name));
1111
+ }
1112
+ const indexResult = await options.index(resolvedRepo, {
1113
+ onProgress: options.onProgress,
1114
+ });
1115
+ if (isIndexResult(indexResult) && indexResult.verification.status !== "healthy") {
1116
+ throw new InitializationHealthError(indexResult);
1117
+ }
1118
+ const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
1119
+ await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
1120
+ let agentInstructionChanges = [];
1121
+ if (scope === "team") {
1122
+ const installed = await Promise.all([
1123
+ installManagedAgentInstructions(resolvedRepo, "AGENTS.md"),
1124
+ installManagedAgentInstructions(resolvedRepo, "GEMINI.md"),
1125
+ ]);
1126
+ agentInstructionChanges = installed.filter((change) => change !== null);
1127
+ }
1128
+ else {
1129
+ agentInstructionChanges = await removeManagedPersonalArtifacts(resolvedRepo, options.allowTrackedTransition);
1130
+ }
1131
+ const requestedAgents = options.agents ?? [];
1132
+ const projectPathByAgent = {
1133
+ codex: ".codex/config.toml",
1134
+ gemini: ".gemini/settings.json",
1135
+ copilot: ".vscode/mcp.json",
1136
+ antigravity: ".agents/mcp_config.json",
1137
+ };
1138
+ const blockedAgents = [];
1139
+ const externalConfigurationOutcomes = [];
1140
+ if (scope === "personal" &&
1141
+ options.allowTrackedTransition &&
1142
+ priorIntegration?.scope === "team") {
1143
+ const cleanup = configureProjectAgents({
1144
+ repo: resolvedRepo,
1145
+ scope: "cli-only",
1146
+ agents: ["claude", "codex", "gemini", "copilot", "antigravity"],
1147
+ run: options.runAgentCommand,
1148
+ });
1149
+ if (cleanup.failed.length > 0)
1150
+ throw new Error(`knodin configure: could not remove tracked team integration: ${cleanup.failed[0]?.message}`);
1151
+ if (cleanup.removed.includes("claude"))
1152
+ externalConfigurationOutcomes.push({
1153
+ system: "claude-local-mcp",
1154
+ state: "removal-attempted",
1155
+ });
1156
+ }
1157
+ const eligibleAgents = scope === "team"
1158
+ ? requestedAgents
1159
+ : requestedAgents.filter((agent) => {
1160
+ const projectPath = agent === "claude" ? ".mcp.json" : projectPathByAgent[agent];
1161
+ if (!projectPath ||
1162
+ !isTracked(resolvedRepo, projectPath) ||
1163
+ (scope === "cli-only" && options.allowTrackedTransition))
1164
+ return true;
1165
+ if (options.allowTrackedTransition && priorIntegration?.scope === "team")
1166
+ return false;
1167
+ blockedAgents.push({
1168
+ agent,
1169
+ message: `${projectPath} is tracked; personal mode left it unchanged and did not exclude it`,
1170
+ });
1171
+ return false;
1172
+ });
1173
+ const agentIntegration = configureProjectAgents({
1174
+ repo: resolvedRepo,
1175
+ scope,
1176
+ agents: eligibleAgents,
1177
+ run: options.runAgentCommand,
1178
+ });
1179
+ if (scope === "personal" && agentIntegration.configured.includes("claude"))
1180
+ externalConfigurationOutcomes.push({
1181
+ system: "claude-local-mcp",
1182
+ state: "configured-or-present",
1183
+ });
1184
+ if (scope === "cli-only" &&
1185
+ agentIntegration.removed.includes("claude") &&
1186
+ !externalConfigurationOutcomes.some(({ system, state }) => system === "claude-local-mcp" && state === "removal-attempted"))
1187
+ externalConfigurationOutcomes.push({
1188
+ system: "claude-local-mcp",
1189
+ state: "removal-attempted",
1190
+ });
1191
+ agentIntegration.failed.push(...blockedAgents);
1192
+ await writeRepositoryIntegrationConfig(resolvedRepo, {
1193
+ scope,
1194
+ agents: agentIntegration.configured,
1195
+ });
1196
+ if (scope === "team") {
1197
+ await ensureTeamGitIgnore(resolvedRepo);
1198
+ await removeManagedExcludes(resolvedRepo);
1199
+ }
1200
+ else if (scope === "cli-only") {
1201
+ await removeManagedExcludes(resolvedRepo);
1202
+ }
1203
+ let excluded = scope === "team"
1204
+ ? []
1205
+ : await excludeUntrackedPaths(resolvedRepo, [
1206
+ ...(isTracked(resolvedRepo, ".knodin") ? [] : [".knodin/"]),
1207
+ ...agentIntegration.projectFiles,
1208
+ ]);
1209
+ if (hookManagerIntegration) {
1210
+ const managerExcludes = await excludeUntrackedPaths(resolvedRepo, [
1211
+ hookManagerIntegration.localConfig,
1212
+ ]);
1213
+ excluded = [...new Set([...excluded, ...managerExcludes])].sort(compareBytes);
1214
+ }
1215
+ registerInitializedWorktree(resolvedRepo);
1216
+ const detectedFilesystemChanges = filesystemChanges(beforeMutation, mutationSnapshot(mutationTargets, true));
1217
+ return {
1218
+ scope,
1219
+ knodin: ".knodin",
1220
+ database: ".knodin/db.sqlite",
1221
+ backgroundIndexer: ".knodin/hooks/background-index.sh",
1222
+ hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
1223
+ gitHooks: [...HOOK_NAMES],
1224
+ claudeSettings: null,
1225
+ agentInstructions: scope === "team" ? "AGENTS.md" : null,
1226
+ agentIntegration,
1227
+ agentInstructionChanges,
1228
+ filesystemChanges: detectedFilesystemChanges,
1229
+ externalConfigurationOutcomes,
1230
+ excluded,
1231
+ lifecycleRefresh,
1232
+ hookManagerIntegration,
1233
+ };
1234
+ }
1235
+ async function matchesManagedFile(filePath, expected, executable) {
1236
+ try {
1237
+ const [content, stat] = await Promise.all([
1238
+ fs.promises.readFile(filePath, "utf-8"),
1239
+ fs.promises.stat(filePath),
1240
+ ]);
1241
+ return stat.isFile() && content === expected && (!executable || (stat.mode & 0o111) !== 0);
1242
+ }
1243
+ catch {
1244
+ return false;
1245
+ }
1246
+ }
1247
+ /**
1248
+ * Read-only installation probe used by repository bootstrap. Existing user hooks may
1249
+ * be referenced through knodin's wrapper, so hook currency is established by a
1250
+ * single managed marker while generated files are compared byte-for-byte.
1251
+ */
1252
+ export async function isRepositoryInitializationCurrent(repo, command, scope = "personal") {
1253
+ const resolvedRepo = path.resolve(repo);
1254
+ if (!path.isAbsolute(command[0]))
1255
+ return false;
1256
+ let hooksDir;
1257
+ try {
1258
+ if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true")
1259
+ return false;
1260
+ hooksDir = effectiveHooksDirectory(resolvedRepo);
1261
+ }
1262
+ catch {
1263
+ return false;
1264
+ }
1265
+ const lefthook = inspectLefthookIntegration(resolvedRepo);
1266
+ const managedHooks = await Promise.all(HOOK_NAMES.map(async (hookName) => {
1267
+ const hookPath = path.join(hooksDir, hookName);
1268
+ try {
1269
+ const [content, stat] = await Promise.all([
1270
+ fs.promises.readFile(hookPath, "utf-8"),
1271
+ fs.promises.stat(hookPath),
1272
+ ]);
1273
+ const originalPath = content.match(/^# knodin original: (.+)$/m)?.[1] ?? null;
1274
+ let expected = managedHookScript(hookName, originalPath);
1275
+ if (originalPath)
1276
+ expected = expected.replace("\nREPO_ROOT=", `\n# knodin original: ${originalPath}\nREPO_ROOT=`);
1277
+ return (stat.isFile() &&
1278
+ (stat.mode & 0o111) !== 0 &&
1279
+ (content === expected || (lefthook.configured && isActiveLefthookHook(content))));
1280
+ }
1281
+ catch {
1282
+ return false;
1283
+ }
1284
+ }));
1285
+ if (managedHooks.some((current) => !current))
1286
+ return false;
1287
+ const sharedFilesCurrent = scope !== "team" ||
1288
+ ((await hasManagedAgentInstructions(resolvedRepo, "AGENTS.md")) &&
1289
+ (await hasManagedAgentInstructions(resolvedRepo, "GEMINI.md")));
1290
+ const integrationCurrent = readRepositoryIntegrationConfig(resolvedRepo)?.scope === scope;
1291
+ return ((await matchesManagedFile(path.join(resolvedRepo, ".knodin", "hooks", "background-index.sh"), backgroundScript(command), true)) &&
1292
+ sharedFilesCurrent &&
1293
+ integrationCurrent &&
1294
+ (lefthook.localConfig === null || lefthook.configured));
1295
+ }