knodin 0.6.0 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -3
- package/dist/bin/cli.js +168 -12
- package/dist/bin/launcher.js +11 -0
- package/dist/src/agent-integration.js +3 -1
- package/dist/src/cli-args.js +8 -1
- package/dist/src/cli-model.js +35 -1
- package/dist/src/codeflow-replay.js +80 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-manifest.js +106 -25
- package/dist/src/competitive-runner.js +37 -3
- package/dist/src/competitive-sandbox.js +1 -1
- package/dist/src/diagnostics.js +449 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +417 -70
- package/dist/src/engine/scip-import.js +408 -0
- package/dist/src/execution-profile.js +203 -0
- package/dist/src/failure-diagnosis.js +69 -10
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/init.js +319 -33
- package/dist/src/lifecycle-health.js +42 -4
- package/dist/src/output-telemetry.js +4 -0
- package/dist/src/progressive-evidence.js +473 -0
- package/dist/src/pure-compression-cli.js +101 -0
- package/dist/src/release-preflight.js +510 -0
- package/dist/src/repository-management.js +142 -0
- package/dist/src/response-budget.js +11 -1
- package/dist/src/server.js +22 -2
- package/dist/src/structural-fast-path.js +338 -0
- package/dist/src/structural-snapshot.js +33 -0
- package/dist/src/tools/knodin-tools.js +105 -14
- package/dist/src/update-ceremony.js +158 -0
- package/docs/CLI.md +22 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +31 -15
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DIAGNOSTICS.md +45 -0
- package/docs/DOCTOR-AND-UPDATES.md +5 -2
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/MCP.md +15 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +30 -0
- package/docs/SCIP-IMPORT.md +57 -0
- package/docs/SIGNED-UPDATES.md +5 -0
- package/docs/TELEMETRY.md +4 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/docs/releases/0.7.4.md +17 -0
- package/package.json +34 -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
|
-
?
|
|
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
|
-
?
|
|
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\`,
|
|
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
|
|
541
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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:
|
|
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
|
|
833
|
-
|
|
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
|
-
|
|
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() &&
|
|
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
|
|
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 {
|
|
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,
|