knodin 0.6.0 → 0.7.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 (48) hide show
  1. package/README.md +26 -3
  2. package/dist/bin/cli.js +168 -12
  3. package/dist/bin/launcher.js +11 -0
  4. package/dist/src/agent-integration.js +3 -1
  5. package/dist/src/cli-args.js +8 -1
  6. package/dist/src/cli-model.js +35 -1
  7. package/dist/src/codeflow-replay.js +80 -0
  8. package/dist/src/competitive-cold-mcp.js +40 -0
  9. package/dist/src/competitive-manifest.js +106 -25
  10. package/dist/src/competitive-runner.js +37 -3
  11. package/dist/src/competitive-sandbox.js +1 -1
  12. package/dist/src/diagnostics.js +449 -0
  13. package/dist/src/engine/git-history.js +289 -0
  14. package/dist/src/engine/index.js +417 -70
  15. package/dist/src/engine/scip-import.js +408 -0
  16. package/dist/src/execution-profile.js +203 -0
  17. package/dist/src/failure-diagnosis.js +69 -10
  18. package/dist/src/hook-manager-integration.js +156 -0
  19. package/dist/src/init.js +319 -33
  20. package/dist/src/lifecycle-health.js +42 -4
  21. package/dist/src/output-telemetry.js +4 -0
  22. package/dist/src/progressive-evidence.js +473 -0
  23. package/dist/src/pure-compression-cli.js +101 -0
  24. package/dist/src/release-preflight.js +510 -0
  25. package/dist/src/repository-management.js +142 -0
  26. package/dist/src/response-budget.js +11 -1
  27. package/dist/src/server.js +22 -2
  28. package/dist/src/structural-fast-path.js +303 -0
  29. package/dist/src/structural-snapshot.js +33 -0
  30. package/dist/src/tools/knodin-tools.js +105 -14
  31. package/dist/src/update-ceremony.js +158 -0
  32. package/docs/CLI.md +22 -0
  33. package/docs/COMMAND-OUTPUT-COMPRESSION.md +31 -15
  34. package/docs/CONTAINED-EXECUTION.md +77 -0
  35. package/docs/DIAGNOSTICS.md +45 -0
  36. package/docs/DOCTOR-AND-UPDATES.md +5 -2
  37. package/docs/GIT-HISTORY-REVIEW.md +39 -0
  38. package/docs/MCP.md +15 -0
  39. package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
  40. package/docs/REPOSITORIES-AND-WORKTREES.md +30 -0
  41. package/docs/SCIP-IMPORT.md +57 -0
  42. package/docs/SIGNED-UPDATES.md +5 -0
  43. package/docs/TELEMETRY.md +4 -0
  44. package/docs/releases/0.7.0.md +24 -0
  45. package/docs/releases/0.7.1.md +21 -0
  46. package/docs/releases/0.7.2.md +21 -0
  47. package/docs/releases/0.7.3.md +23 -0
  48. package/package.json +33 -2
package/dist/src/init.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import child_process from "node:child_process";
2
+ import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { configureProjectAgents, } from "./agent-integration.js";
5
6
  import { isIndexableSourcePath } from "./engine/source-policy.js";
7
+ import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
6
8
  import { registerInitializedWorktree } from "./worktree-lifecycle.js";
7
9
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
8
10
  const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
@@ -39,6 +41,203 @@ function runGit(repo, args, encoding = "utf-8") {
39
41
  stdio: ["ignore", "pipe", "ignore"],
40
42
  });
41
43
  }
44
+ const MUTATION_AUDIT_MAX_ENTRIES = 2_048;
45
+ const MUTATION_AUDIT_MAX_FILE_BYTES = 8 * 1_024 * 1_024;
46
+ function boundedDirectoryEntries(directory) {
47
+ try {
48
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
49
+ if (entries.length > MUTATION_AUDIT_MAX_ENTRIES)
50
+ throw new Error(`${directory}: configure mutation audit exceeds ${MUTATION_AUDIT_MAX_ENTRIES} managed entries`);
51
+ return entries;
52
+ }
53
+ catch (error) {
54
+ if (error.code === "ENOENT")
55
+ return [];
56
+ throw error;
57
+ }
58
+ }
59
+ function configureMutationAuditTargets(repo) {
60
+ const commonRaw = runGit(repo, [
61
+ "rev-parse",
62
+ "--path-format=absolute",
63
+ "--git-common-dir",
64
+ ]).trim();
65
+ const common = path.isAbsolute(commonRaw) ? commonRaw : path.resolve(repo, commonRaw);
66
+ const excludeRaw = runGit(repo, [
67
+ "rev-parse",
68
+ "--path-format=absolute",
69
+ "--git-path",
70
+ "info/exclude",
71
+ ]).trim();
72
+ const exclude = path.isAbsolute(excludeRaw) ? excludeRaw : path.resolve(repo, excludeRaw);
73
+ const effectiveHooks = effectiveHooksDirectory(repo);
74
+ const targets = new Map();
75
+ const add = (target) => {
76
+ const key = path.resolve(target.absolute);
77
+ if (!targets.has(key))
78
+ targets.set(key, { ...target, absolute: key });
79
+ };
80
+ for (const relative of [
81
+ "AGENTS.md",
82
+ "GEMINI.md",
83
+ ".mcp.json",
84
+ ".codex",
85
+ ".codex/config.toml",
86
+ ".gemini",
87
+ ".gemini/settings.json",
88
+ ".agents",
89
+ ".agents/mcp_config.json",
90
+ ".gitignore",
91
+ ".claude",
92
+ ".claude/hooks",
93
+ ".claude/hooks/nudge.sh",
94
+ ".claude/settings.example.json",
95
+ ".knodin",
96
+ ".knodin/integration.json",
97
+ ".knodin/indexer.log",
98
+ ".knodin/indexer.log.1",
99
+ "lefthook-local.yml",
100
+ "lefthook-local.yaml",
101
+ ".lefthook-local.yml",
102
+ ".lefthook-local.yaml",
103
+ ".config/lefthook-local.yml",
104
+ ".config/lefthook-local.yaml",
105
+ ])
106
+ add({ absolute: path.join(repo, relative), label: relative });
107
+ for (const relative of [
108
+ ".knodin/hooks",
109
+ ".knodin/hooks/background-index.sh",
110
+ ".knodin/hooks/manager-dispatch.sh",
111
+ ".knodin/hooks/events",
112
+ ".knodin/hooks/last-refresh-event",
113
+ ".knodin/hooks/last-refresh-failure",
114
+ ".knodin/hooks/refresh.lock",
115
+ ".knodin/hooks/refresh.lock/pid",
116
+ ])
117
+ add({ absolute: path.join(repo, relative), label: relative });
118
+ for (const entry of boundedDirectoryEntries(path.join(repo, ".knodin", "hooks", "events")))
119
+ add({
120
+ absolute: path.join(repo, ".knodin", "hooks", "events", entry.name),
121
+ label: `.knodin/hooks/events/${entry.name}`,
122
+ });
123
+ add({ absolute: path.join(common, "config"), label: ".git/config" });
124
+ add({ absolute: exclude, label: ".git/info/exclude" });
125
+ add({ absolute: path.join(common, "knodin"), label: ".git/knodin" });
126
+ add({
127
+ absolute: path.join(common, "knodin", "worktrees.json"),
128
+ label: ".git/knodin/worktrees.json",
129
+ });
130
+ const defaultHooks = path.join(common, "hooks");
131
+ const managedHooks = path.join(common, "knodin-hooks");
132
+ add({ absolute: defaultHooks, label: ".git/hooks" });
133
+ add({ absolute: managedHooks, label: ".git/knodin-hooks" });
134
+ const effectiveLabel = path.resolve(effectiveHooks) === path.resolve(defaultHooks)
135
+ ? ".git/hooks"
136
+ : path.resolve(effectiveHooks) === path.resolve(managedHooks)
137
+ ? ".git/knodin-hooks"
138
+ : "effective-git-hooks";
139
+ add({ absolute: effectiveHooks, label: effectiveLabel });
140
+ for (const hookRoot of [
141
+ { absolute: defaultHooks, label: ".git/hooks" },
142
+ { absolute: managedHooks, label: ".git/knodin-hooks" },
143
+ { absolute: effectiveHooks, label: effectiveLabel },
144
+ ]) {
145
+ for (const entry of boundedDirectoryEntries(hookRoot.absolute))
146
+ add({
147
+ absolute: path.join(hookRoot.absolute, entry.name),
148
+ label: `${hookRoot.label}/${entry.name}`,
149
+ });
150
+ }
151
+ for (const hookName of HOOK_NAMES) {
152
+ add({ absolute: path.join(effectiveHooks, hookName), label: `${effectiveLabel}/${hookName}` });
153
+ add({
154
+ absolute: path.join(effectiveHooks, `${hookName}.knodin-original`),
155
+ label: `${effectiveLabel}/${hookName}.knodin-original`,
156
+ });
157
+ }
158
+ for (const hookRoot of [
159
+ { absolute: defaultHooks, label: ".git/hooks" },
160
+ { absolute: managedHooks, label: ".git/knodin-hooks" },
161
+ ]) {
162
+ for (const hookName of HOOK_NAMES) {
163
+ add({
164
+ absolute: path.join(hookRoot.absolute, hookName),
165
+ label: `${hookRoot.label}/${hookName}`,
166
+ });
167
+ add({
168
+ absolute: path.join(hookRoot.absolute, `${hookName}.knodin-original`),
169
+ label: `${hookRoot.label}/${hookName}.knodin-original`,
170
+ });
171
+ }
172
+ }
173
+ const configured = configuredHooksPath(repo);
174
+ const relativeConfigured = configured ? repoRelativeHooksPath(repo, configured) : null;
175
+ if (relativeConfigured && hasTrackedHooks(repo, relativeConfigured)) {
176
+ const configuredRoot = path.join(repo, relativeConfigured);
177
+ for (const entry of boundedDirectoryEntries(configuredRoot)) {
178
+ if (!entry.isFile() || HOOK_NAMES.includes(entry.name))
179
+ continue;
180
+ const source = path.join(configuredRoot, entry.name);
181
+ if ((fs.statSync(source).mode & 0o111) === 0)
182
+ continue;
183
+ add({
184
+ absolute: path.join(managedHooks, entry.name),
185
+ label: `.git/knodin-hooks/${entry.name}`,
186
+ });
187
+ }
188
+ }
189
+ if (targets.size > MUTATION_AUDIT_MAX_ENTRIES)
190
+ throw new Error(`configure mutation audit exceeds ${MUTATION_AUDIT_MAX_ENTRIES} managed paths`);
191
+ return [...targets.values()];
192
+ }
193
+ function fileSignature(filePath, allowOversized = false) {
194
+ try {
195
+ const stat = fs.lstatSync(filePath);
196
+ if (stat.isDirectory())
197
+ return `directory:${stat.mode & 0o777}`;
198
+ if (stat.isSymbolicLink()) {
199
+ const link = fs.readlinkSync(filePath);
200
+ const resolved = fs.realpathSync(filePath);
201
+ return `symlink:${stat.mode & 0o777}:${link}:${fileSignature(resolved, allowOversized)}`;
202
+ }
203
+ if (!stat.isFile())
204
+ throw new Error(`${filePath}: unsupported filesystem entry`);
205
+ if (stat.size > MUTATION_AUDIT_MAX_FILE_BYTES && !allowOversized)
206
+ throw new Error(`${filePath}: configure mutation audit refuses files larger than ${MUTATION_AUDIT_MAX_FILE_BYTES} bytes`);
207
+ if (stat.size > MUTATION_AUDIT_MAX_FILE_BYTES)
208
+ return `oversized-file:${stat.mode & 0o777}:${stat.size}`;
209
+ const digest = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
210
+ return `file:${stat.mode & 0o777}:${stat.size}:${digest}`;
211
+ }
212
+ catch (error) {
213
+ if (error.code === "ENOENT")
214
+ return null;
215
+ throw error;
216
+ }
217
+ }
218
+ function mutationSnapshot(targets, allowOversized = false) {
219
+ const snapshot = new Map();
220
+ for (const target of targets) {
221
+ const rootSignature = fileSignature(target.absolute, allowOversized);
222
+ if (rootSignature !== null)
223
+ snapshot.set(target.label, rootSignature);
224
+ }
225
+ return snapshot;
226
+ }
227
+ function filesystemChanges(before, after) {
228
+ return [...new Set([...before.keys(), ...after.keys()])].sort().flatMap((changedPath) => {
229
+ const prior = before.get(changedPath);
230
+ const current = after.get(changedPath);
231
+ if (prior === current)
232
+ return [];
233
+ return [
234
+ {
235
+ path: changedPath,
236
+ action: prior === undefined ? "created" : current === undefined ? "removed" : "modified",
237
+ },
238
+ ];
239
+ });
240
+ }
42
241
  function shellQuote(value) {
43
242
  return `'${value.replaceAll("'", `'\\''`)}'`;
44
243
  }
@@ -257,7 +456,7 @@ function managedHookScript(hookName, originalPath) {
257
456
  : shellQuote(originalPath)
258
457
  : "";
259
458
  const runOriginal = originalPath
260
- ? `${original} "$@"${hookName === "post-rewrite" ? ' < "$REWRITE_INPUT"' : ""} || ORIGINAL_STATUS=$?`
459
+ ? `KNODIN_WRAPPER_ACTIVE=1 ${original} "$@"${hookName === "post-rewrite" ? ' < "$REWRITE_INPUT"' : ""} || ORIGINAL_STATUS=$?`
261
460
  : ":";
262
461
  let trigger = "";
263
462
  if (hookName === "post-commit")
@@ -271,7 +470,7 @@ AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
271
470
  [ -n "$BEFORE" ] && [ -n "$AFTER" ] && trigger_knodin merge "$BEFORE" "$AFTER"`;
272
471
  }
273
472
  const rewriteWithoutIndexer = originalPath
274
- ? `${original} "$@"
473
+ ? `KNODIN_WRAPPER_ACTIVE=1 ${original} "$@"
275
474
  exit $?`
276
475
  : "exit 0";
277
476
  const rewriteSetup = hookName === "post-rewrite"
@@ -487,7 +686,7 @@ export const SETTINGS_TEMPLATE = `{
487
686
  const KNODIN_AGENT_INSTRUCTIONS = `<!-- knodin:start -->
488
687
  ## knodin — Local Code Intelligence
489
688
 
490
- This repository is indexed locally by knodin. Start with \`knodin status\`; if it reports \`repair-needed\`, run \`knodin repair\` before relying on graph evidence.
689
+ 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.
491
690
 
492
691
  - 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.
493
692
  - 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.
@@ -521,12 +720,14 @@ async function installManagedAgentInstructions(repo, fileName = "AGENTS.md") {
521
720
  await assertSafeRepositoryFile(repo, fileName);
522
721
  const instructionsPath = path.join(repo, fileName);
523
722
  let existing = "";
723
+ let existed = true;
524
724
  try {
525
725
  existing = await fs.promises.readFile(instructionsPath, "utf-8");
526
726
  }
527
727
  catch (error) {
528
728
  if (error.code !== "ENOENT")
529
729
  throw error;
730
+ existed = false;
530
731
  }
531
732
  const canonicalStart = "<!-- knodin:start -->";
532
733
  const canonicalEnd = "<!-- knodin:end -->";
@@ -537,8 +738,10 @@ async function installManagedAgentInstructions(repo, fileName = "AGENTS.md") {
537
738
  : existing.length > 0
538
739
  ? `${existing.trimEnd()}\n\n${KNODIN_AGENT_INSTRUCTIONS}`
539
740
  : KNODIN_AGENT_INSTRUCTIONS;
540
- if (next !== existing)
541
- await fs.promises.writeFile(instructionsPath, next, "utf-8");
741
+ if (next === existing)
742
+ return null;
743
+ await fs.promises.writeFile(instructionsPath, next, "utf-8");
744
+ return { path: fileName, action: existed ? "modified" : "created" };
542
745
  }
543
746
  async function hasManagedAgentInstructions(repo, fileName = "AGENTS.md") {
544
747
  try {
@@ -548,6 +751,19 @@ async function hasManagedAgentInstructions(repo, fileName = "AGENTS.md") {
548
751
  return false;
549
752
  }
550
753
  }
754
+ function removeManagedAgentBlock(content) {
755
+ const start = content.indexOf("<!-- knodin:start -->");
756
+ const end = content.indexOf("<!-- knodin:end -->", start);
757
+ if (start < 0 || end < start)
758
+ return null;
759
+ const prefix = content.slice(0, start);
760
+ const suffix = content.slice(end + "<!-- knodin:end -->".length);
761
+ // The canonical marker-only file owns its final line terminator as part of
762
+ // the generated block. Any other prefix/suffix bytes remain untouched.
763
+ if (prefix.length === 0 && /^\r?\n$/.test(suffix))
764
+ return "";
765
+ return `${prefix}${suffix}`;
766
+ }
551
767
  function isTracked(repo, relativePath) {
552
768
  try {
553
769
  return nulPaths(repo, ["ls-files", "--", relativePath]).length > 0;
@@ -601,38 +817,35 @@ async function removeManagedExcludes(repo) {
601
817
  await fs.promises.writeFile(excludePath, next, "utf-8");
602
818
  }
603
819
  async function removeManagedPersonalArtifacts(repo, allowTrackedTransition = false) {
820
+ const changes = [];
604
821
  for (const fileName of ["AGENTS.md", "GEMINI.md"]) {
605
822
  const instructionsPath = path.join(repo, fileName);
606
823
  const tracked = isTracked(repo, fileName);
607
824
  try {
825
+ await assertSafeRepositoryFile(repo, fileName);
608
826
  const current = await fs.promises.readFile(instructionsPath, "utf-8");
827
+ const start = current.indexOf("<!-- knodin:start -->");
828
+ const end = current.indexOf("<!-- knodin:end -->", start);
829
+ const without = removeManagedAgentBlock(current);
609
830
  if (!tracked) {
610
- const start = current.indexOf("<!-- knodin:start -->");
611
- const end = current.indexOf("<!-- knodin:end -->");
612
- if (start >= 0 && end >= start) {
613
- const without = `${current.slice(0, start)}${current.slice(end + "<!-- knodin:end -->".length)}`
614
- .replace(/\n{3,}/g, "\n\n")
615
- .trim();
616
- if (without)
617
- await fs.promises.writeFile(instructionsPath, `${without}\n`, "utf-8");
618
- else
831
+ if (without !== null) {
832
+ if (without.length > 0) {
833
+ await fs.promises.writeFile(instructionsPath, without, "utf-8");
834
+ changes.push({ path: fileName, action: "modified" });
835
+ }
836
+ else {
619
837
  await fs.promises.rm(instructionsPath, { force: true });
838
+ changes.push({ path: fileName, action: "removed" });
839
+ }
620
840
  }
621
841
  continue;
622
842
  }
623
- const head = runGit(repo, ["show", `HEAD:${fileName}`]);
624
- const start = current.indexOf("<!-- knodin:start -->");
625
- const end = current.indexOf("<!-- knodin:end -->");
626
- if (allowTrackedTransition && start >= 0 && end >= start) {
627
- const without = `${current.slice(0, start)}${current.slice(end + "<!-- knodin:end -->".length)}`
628
- .replace(/\n{3,}/g, "\n\n")
629
- .trimEnd();
630
- if (without)
631
- await fs.promises.writeFile(instructionsPath, `${without}\n`, "utf-8");
632
- else
633
- await fs.promises.rm(instructionsPath, { force: true });
843
+ if (allowTrackedTransition && without !== null) {
844
+ await fs.promises.writeFile(instructionsPath, without, "utf-8");
845
+ changes.push({ path: fileName, action: "modified" });
634
846
  continue;
635
847
  }
848
+ const head = runGit(repo, ["show", `HEAD:${fileName}`]);
636
849
  if (start >= 0 && end >= start && !head.includes("<!-- knodin:start -->")) {
637
850
  const without = `${current.slice(0, start)}${current.slice(end + "<!-- knodin:end -->".length)}`
638
851
  .replace(/\n{3,}/g, "\n\n")
@@ -682,6 +895,7 @@ async function removeManagedPersonalArtifacts(repo, allowTrackedTransition = fal
682
895
  // Missing or user-modified files are intentionally preserved.
683
896
  }
684
897
  }
898
+ return changes;
685
899
  }
686
900
  export function readRepositoryIntegrationConfig(repo) {
687
901
  try {
@@ -695,6 +909,22 @@ export function readRepositoryIntegrationConfig(repo) {
695
909
  }
696
910
  return null;
697
911
  }
912
+ function hasSafeManagedAgentBlock(repo, relativePath) {
913
+ try {
914
+ let current = repo;
915
+ for (const part of relativePath.split(path.sep)) {
916
+ current = path.join(current, part);
917
+ if (fs.lstatSync(current).isSymbolicLink())
918
+ return false;
919
+ }
920
+ const content = fs.readFileSync(path.join(repo, relativePath), "utf-8");
921
+ const start = content.indexOf("<!-- knodin:start -->");
922
+ return start >= 0 && content.indexOf("<!-- knodin:end -->", start) >= start;
923
+ }
924
+ catch {
925
+ return false;
926
+ }
927
+ }
698
928
  /** Detect repository-owned client configuration independently of knodin's local receipt. */
699
929
  export function inspectRepositoryIntegrationStatus(repo) {
700
930
  const receipt = readRepositoryIntegrationConfig(repo);
@@ -703,13 +933,27 @@ export function inspectRepositoryIntegrationStatus(repo) {
703
933
  { agent: "codex", path: ".codex/config.toml", marker: 'mcp_servers."knodin"' },
704
934
  { agent: "gemini", path: ".gemini/settings.json", marker: '"knodin"' },
705
935
  { agent: "antigravity", path: ".agents/mcp_config.json", marker: '"knodin"' },
936
+ {
937
+ agent: "codex",
938
+ path: "AGENTS.md",
939
+ marker: "<!-- knodin:start -->",
940
+ managedGuidance: true,
941
+ },
942
+ {
943
+ agent: "gemini",
944
+ path: "GEMINI.md",
945
+ marker: "<!-- knodin:start -->",
946
+ managedGuidance: true,
947
+ },
706
948
  ];
707
- const configurations = candidates.map(({ agent, path: relative, marker }) => {
949
+ const configurations = candidates.map(({ agent, path: relative, marker, managedGuidance }) => {
708
950
  try {
709
951
  return {
710
952
  agent,
711
953
  path: relative,
712
- present: fs.readFileSync(path.join(repo, relative), "utf-8").includes(marker),
954
+ present: managedGuidance
955
+ ? hasSafeManagedAgentBlock(repo, relative)
956
+ : fs.readFileSync(path.join(repo, relative), "utf-8").includes(marker),
713
957
  };
714
958
  }
715
959
  catch {
@@ -780,6 +1024,10 @@ export async function initializeRepository(repo, options) {
780
1024
  if (!path.isAbsolute(options.command[0])) {
781
1025
  throw new Error("knodin init: packaged runtime command must be an absolute path");
782
1026
  }
1027
+ const mutationTargets = options.auditConfigurationChanges
1028
+ ? configureMutationAuditTargets(resolvedRepo)
1029
+ : [];
1030
+ const beforeMutation = mutationSnapshot(mutationTargets);
783
1031
  if (scope !== "team" &&
784
1032
  !options.allowTrackedTransition &&
785
1033
  detectTrackedTeamIntegration(resolvedRepo)) {
@@ -798,6 +1046,7 @@ export async function initializeRepository(repo, options) {
798
1046
  mode: 0o755,
799
1047
  });
800
1048
  await fs.promises.chmod(backgroundPath, 0o755);
1049
+ const hookManagerIntegration = installHookManagerIntegration(resolvedRepo);
801
1050
  for (const hookName of HOOK_NAMES) {
802
1051
  let preservedOriginal;
803
1052
  if (hookInstallation.trackedHooksDir) {
@@ -828,12 +1077,16 @@ export async function initializeRepository(repo, options) {
828
1077
  }
829
1078
  const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
830
1079
  await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
1080
+ let agentInstructionChanges = [];
831
1081
  if (scope === "team") {
832
- await installManagedAgentInstructions(resolvedRepo, "AGENTS.md");
833
- await installManagedAgentInstructions(resolvedRepo, "GEMINI.md");
1082
+ const installed = await Promise.all([
1083
+ installManagedAgentInstructions(resolvedRepo, "AGENTS.md"),
1084
+ installManagedAgentInstructions(resolvedRepo, "GEMINI.md"),
1085
+ ]);
1086
+ agentInstructionChanges = installed.filter((change) => change !== null);
834
1087
  }
835
1088
  else {
836
- await removeManagedPersonalArtifacts(resolvedRepo, options.allowTrackedTransition);
1089
+ agentInstructionChanges = await removeManagedPersonalArtifacts(resolvedRepo, options.allowTrackedTransition);
837
1090
  }
838
1091
  const requestedAgents = options.agents ?? [];
839
1092
  const projectPathByAgent = {
@@ -842,6 +1095,7 @@ export async function initializeRepository(repo, options) {
842
1095
  antigravity: ".agents/mcp_config.json",
843
1096
  };
844
1097
  const blockedAgents = [];
1098
+ const externalConfigurationOutcomes = [];
845
1099
  if (scope === "personal" &&
846
1100
  options.allowTrackedTransition &&
847
1101
  priorIntegration?.scope === "team") {
@@ -853,6 +1107,11 @@ export async function initializeRepository(repo, options) {
853
1107
  });
854
1108
  if (cleanup.failed.length > 0)
855
1109
  throw new Error(`knodin configure: could not remove tracked team integration: ${cleanup.failed[0]?.message}`);
1110
+ if (cleanup.removed.includes("claude"))
1111
+ externalConfigurationOutcomes.push({
1112
+ system: "claude-local-mcp",
1113
+ state: "removal-attempted",
1114
+ });
856
1115
  }
857
1116
  const eligibleAgents = scope === "team"
858
1117
  ? requestedAgents
@@ -876,6 +1135,18 @@ export async function initializeRepository(repo, options) {
876
1135
  agents: eligibleAgents,
877
1136
  run: options.runAgentCommand,
878
1137
  });
1138
+ if (scope === "personal" && agentIntegration.configured.includes("claude"))
1139
+ externalConfigurationOutcomes.push({
1140
+ system: "claude-local-mcp",
1141
+ state: "configured-or-present",
1142
+ });
1143
+ if (scope === "cli-only" &&
1144
+ agentIntegration.removed.includes("claude") &&
1145
+ !externalConfigurationOutcomes.some(({ system, state }) => system === "claude-local-mcp" && state === "removal-attempted"))
1146
+ externalConfigurationOutcomes.push({
1147
+ system: "claude-local-mcp",
1148
+ state: "removal-attempted",
1149
+ });
879
1150
  agentIntegration.failed.push(...blockedAgents);
880
1151
  await writeRepositoryIntegrationConfig(resolvedRepo, {
881
1152
  scope,
@@ -888,13 +1159,20 @@ export async function initializeRepository(repo, options) {
888
1159
  else if (scope === "cli-only") {
889
1160
  await removeManagedExcludes(resolvedRepo);
890
1161
  }
891
- const excluded = scope === "team"
1162
+ let excluded = scope === "team"
892
1163
  ? []
893
1164
  : await excludeUntrackedPaths(resolvedRepo, [
894
1165
  ...(isTracked(resolvedRepo, ".knodin") ? [] : [".knodin/"]),
895
1166
  ...agentIntegration.projectFiles,
896
1167
  ]);
1168
+ if (hookManagerIntegration) {
1169
+ const managerExcludes = await excludeUntrackedPaths(resolvedRepo, [
1170
+ hookManagerIntegration.localConfig,
1171
+ ]);
1172
+ excluded = [...new Set([...excluded, ...managerExcludes])].sort();
1173
+ }
897
1174
  registerInitializedWorktree(resolvedRepo);
1175
+ const detectedFilesystemChanges = filesystemChanges(beforeMutation, mutationSnapshot(mutationTargets, true));
898
1176
  return {
899
1177
  scope,
900
1178
  knodin: ".knodin",
@@ -905,8 +1183,12 @@ export async function initializeRepository(repo, options) {
905
1183
  claudeSettings: null,
906
1184
  agentInstructions: scope === "team" ? "AGENTS.md" : null,
907
1185
  agentIntegration,
1186
+ agentInstructionChanges,
1187
+ filesystemChanges: detectedFilesystemChanges,
1188
+ externalConfigurationOutcomes,
908
1189
  excluded,
909
1190
  lifecycleRefresh,
1191
+ hookManagerIntegration,
910
1192
  };
911
1193
  }
912
1194
  async function matchesManagedFile(filePath, expected, executable) {
@@ -939,6 +1221,7 @@ export async function isRepositoryInitializationCurrent(repo, command, scope = "
939
1221
  catch {
940
1222
  return false;
941
1223
  }
1224
+ const lefthook = inspectLefthookIntegration(resolvedRepo);
942
1225
  const managedHooks = await Promise.all(HOOK_NAMES.map(async (hookName) => {
943
1226
  const hookPath = path.join(hooksDir, hookName);
944
1227
  try {
@@ -950,7 +1233,9 @@ export async function isRepositoryInitializationCurrent(repo, command, scope = "
950
1233
  let expected = managedHookScript(hookName, originalPath);
951
1234
  if (originalPath)
952
1235
  expected = expected.replace("\nREPO_ROOT=", `\n# knodin original: ${originalPath}\nREPO_ROOT=`);
953
- return stat.isFile() && (stat.mode & 0o111) !== 0 && content === expected;
1236
+ return (stat.isFile() &&
1237
+ (stat.mode & 0o111) !== 0 &&
1238
+ (content === expected || (lefthook.configured && isActiveLefthookHook(content))));
954
1239
  }
955
1240
  catch {
956
1241
  return false;
@@ -964,5 +1249,6 @@ export async function isRepositoryInitializationCurrent(repo, command, scope = "
964
1249
  const integrationCurrent = readRepositoryIntegrationConfig(resolvedRepo)?.scope === scope;
965
1250
  return ((await matchesManagedFile(path.join(resolvedRepo, ".knodin", "hooks", "background-index.sh"), backgroundScript(command), true)) &&
966
1251
  sharedFilesCurrent &&
967
- integrationCurrent);
1252
+ integrationCurrent &&
1253
+ (lefthook.localConfig === null || lefthook.configured));
968
1254
  }
@@ -1,6 +1,7 @@
1
1
  import childProcess from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { inspectLefthookIntegration, isActiveLefthookHook } from "./hook-manager-integration.js";
4
5
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
5
6
  const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
6
7
  const HOOK_FAILURE_FILE = "last-refresh-failure";
@@ -89,22 +90,42 @@ function activeHooksDirectory(repo) {
89
90
  const output = childProcess.execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-path", "hooks"], { cwd: repo, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
90
91
  return output.trim();
91
92
  }
92
- function inspectManagedHooks(hooksDirectory) {
93
+ function inspectManagedHooks(repo, hooksDirectory) {
93
94
  const activeHooks = [];
94
95
  const missingHooks = [];
96
+ let wrappers = 0;
97
+ let managerNative = 0;
98
+ const lefthook = inspectLefthookIntegration(repo);
95
99
  for (const hookName of HOOK_NAMES) {
96
100
  try {
97
101
  const hookPath = path.join(hooksDirectory, hookName);
98
102
  const content = fs.readFileSync(hookPath, "utf-8");
99
103
  const mode = fs.statSync(hookPath).mode;
100
- const active = (mode & 0o111) !== 0 && content.startsWith(`#!/bin/sh\n# ${MANAGED_MARKER}: ${hookName}\n`);
104
+ const wrapper = content.startsWith(`#!/bin/sh\n# ${MANAGED_MARKER}: ${hookName}\n`);
105
+ const native = lefthook.configured && isActiveLefthookHook(content);
106
+ const active = (mode & 0o111) !== 0 && (wrapper || native);
107
+ if (active && wrapper)
108
+ wrappers++;
109
+ else if (active && native)
110
+ managerNative++;
101
111
  (active ? activeHooks : missingHooks).push(hookName);
102
112
  }
103
113
  catch {
104
114
  missingHooks.push(hookName);
105
115
  }
106
116
  }
107
- return { activeHooks, missingHooks };
117
+ return {
118
+ activeHooks,
119
+ missingHooks,
120
+ hookManager: lefthook.configured ? "lefthook" : null,
121
+ routing: wrappers > 0 && managerNative > 0
122
+ ? "mixed"
123
+ : wrappers > 0
124
+ ? "managed-wrapper"
125
+ : managerNative > 0
126
+ ? "manager-native"
127
+ : "unavailable",
128
+ };
108
129
  }
109
130
  function readRefreshFailure(repo) {
110
131
  const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
@@ -134,6 +155,8 @@ export function inspectLifecycleHealth(repoPath) {
134
155
  pendingEvent: null,
135
156
  lastEvent: null,
136
157
  lastError: null,
158
+ hookManager: null,
159
+ routing: "unavailable",
137
160
  };
138
161
  }
139
162
  let hooksDirectory;
@@ -153,9 +176,11 @@ export function inspectLifecycleHealth(repoPath) {
153
176
  pendingEvent: readPendingEvent(repo),
154
177
  lastEvent: readLastEvent(repo),
155
178
  lastError: null,
179
+ hookManager: null,
180
+ routing: "unavailable",
156
181
  };
157
182
  }
158
- const { activeHooks, missingHooks } = inspectManagedHooks(hooksDirectory);
183
+ const { activeHooks, missingHooks, hookManager, routing } = inspectManagedHooks(repo, hooksDirectory);
159
184
  const background = path.join(repo, ".knodin", "hooks", "background-index.sh");
160
185
  const backgroundReady = fs.existsSync(background) && (fs.statSync(background).mode & 0o111) !== 0;
161
186
  const issues = missingHooks.map((hook) => `${hook} no longer routes through knodin's active Git hook path`);
@@ -177,6 +202,8 @@ export function inspectLifecycleHealth(repoPath) {
177
202
  pendingEvent: readPendingEvent(repo),
178
203
  lastEvent: readLastEvent(repo),
179
204
  lastError,
205
+ hookManager,
206
+ routing,
180
207
  };
181
208
  }
182
209
  /** Merge graph and refresh-path health without reporting absent evidence as healthy. */
@@ -210,3 +237,14 @@ export function attachLifecycleHealth(repo, graph) {
210
237
  lifecycle,
211
238
  };
212
239
  }
240
+ /** Keep graph-repair success distinct from lifecycle integration health. */
241
+ export function attachRepairLifecycle(repo, result) {
242
+ const lifecycle = inspectLifecycleHealth(repo);
243
+ return {
244
+ ...result,
245
+ lifecycle,
246
+ nextSteps: lifecycle.status === "degraded"
247
+ ? ["Run `knodin init` to restore lifecycle routing, then `knodin status`."]
248
+ : [],
249
+ };
250
+ }
@@ -121,6 +121,7 @@ export function measureOutput(options) {
121
121
  at: new Date().toISOString(),
122
122
  repositoryId: repositoryIdentifier(options.repo),
123
123
  operation: options.operation,
124
+ ...(options.executionProfile ? { executionProfile: options.executionProfile } : {}),
124
125
  latencyMs,
125
126
  rssBytes: process.memoryUsage().rss,
126
127
  temperature: "cold",
@@ -231,6 +232,9 @@ function sanitizeTelemetryRecord(record) {
231
232
  at: record.at,
232
233
  repositoryId: record.repositoryId,
233
234
  operation: record.operation,
235
+ ...(record.executionProfile && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(record.executionProfile)
236
+ ? { executionProfile: record.executionProfile }
237
+ : {}),
234
238
  latencyMs: record.latencyMs,
235
239
  rssBytes: record.rssBytes,
236
240
  temperature: record.temperature,