knodin 0.12.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +57 -17
- package/dist/src/backup-retention.js +20 -6
- package/dist/src/doctor.js +59 -6
- package/dist/src/engine/index.js +12 -2
- package/dist/src/init.js +91 -13
- package/dist/src/lifecycle-health.js +62 -2
- package/dist/src/node-runtime.js +96 -0
- package/docs/releases/0.12.2.md +88 -0
- package/package.json +2 -1
package/dist/bin/cli.js
CHANGED
|
@@ -195,8 +195,15 @@ function formatLifecycleLine(lifecycle, isMirror) {
|
|
|
195
195
|
return "";
|
|
196
196
|
if (isMirror)
|
|
197
197
|
return "Hooks: not applicable; a mirror is refreshed explicitly, not by Git events.\n";
|
|
198
|
-
if (lifecycle.status === "healthy")
|
|
199
|
-
|
|
198
|
+
if (lifecycle.status === "healthy") {
|
|
199
|
+
// A silent self-heal would leave the user unable to explain why the hook's
|
|
200
|
+
// interpreter changed; a degraded report for a fixed condition would cry
|
|
201
|
+
// wolf. Say what happened, once, on the healthy line.
|
|
202
|
+
const heal = lifecycle.interpreterSelfHeal;
|
|
203
|
+
return heal
|
|
204
|
+
? `Hooks: installed and executable; background indexer interpreter self-healed (${heal.from} -> ${heal.to}).\n`
|
|
205
|
+
: "Hooks: installed and executable.\n";
|
|
206
|
+
}
|
|
200
207
|
const issue = lifecycle.issues[0] ?? "refresh capability is not verified";
|
|
201
208
|
return `Lifecycle refresh: ${lifecycle.status}; ${issue}. Run \`knodin repair --lifecycle\`.\n`;
|
|
202
209
|
}
|
|
@@ -449,24 +456,34 @@ function formatStatusHuman(result) {
|
|
|
449
456
|
// led with an arbitrary source filename, so an absent graph read as damage
|
|
450
457
|
// proportional to repository size. `missing.files` still carries the repair
|
|
451
458
|
// worklist — only the count and the headline shown to a human change here.
|
|
452
|
-
const
|
|
453
|
-
? result.missing.records
|
|
454
|
-
: result.missing.files
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
+
const issueItems = result.coverage.countsUnknown
|
|
460
|
+
? result.missing.records
|
|
461
|
+
: [...result.missing.files, ...result.missing.records];
|
|
462
|
+
const outstanding = issueItems.length;
|
|
463
|
+
// One issue keeps the established single-line shape; more are listed bounded
|
|
464
|
+
// rather than hidden behind "First issue" — a report that names one problem
|
|
465
|
+
// out of several sends the user to fix it and then surprises them with the
|
|
466
|
+
// rest, one status invocation at a time (KNODIN-38).
|
|
467
|
+
const shownIssues = issueItems.slice(0, 5);
|
|
468
|
+
const hiddenIssues = outstanding - shownIssues.length;
|
|
469
|
+
const detail = shownIssues.length === 0
|
|
470
|
+
? ""
|
|
471
|
+
: shownIssues.length === 1
|
|
472
|
+
? ` First issue: ${shownIssues[0]}.`
|
|
473
|
+
: ` Issues: ${shownIssues.join("; ")}${hiddenIssues > 0 ? `; +${hiddenIssues} more (see --json)` : ""}.`;
|
|
459
474
|
// A linked worktree with no database is the one case where `repair` is both
|
|
460
475
|
// the wrong first step and the expensive one: `init` seeds from an indexed
|
|
461
476
|
// sibling and reconciles only what differs, while `repair` builds from
|
|
462
477
|
// scratch. The engine has already worked out that this is that case, so
|
|
463
478
|
// defer to the step it wrote rather than recomputing the judgement here.
|
|
464
479
|
const worktreeStep = result.repairSteps?.find((step) => step.includes("per-worktree"));
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
480
|
+
// Lifecycle-only damage takes `repair --lifecycle`, not `init`: the graph is
|
|
481
|
+
// healthy, and the engine's own repairSteps already say so — prescribing
|
|
482
|
+
// `init` here contradicted the lifecycle line printed directly beneath it.
|
|
483
|
+
const lifecycleOnly = result.lifecycle?.status === "degraded" &&
|
|
484
|
+
(result.coverage.countsUnknown || result.missing.files.length === 0) &&
|
|
485
|
+
result.missing.records.every((record) => result.lifecycle?.issues.includes(record));
|
|
486
|
+
const repairCommand = worktreeStep ?? (lifecycleOnly ? "Run `knodin repair --lifecycle`." : "Run `knodin repair`.");
|
|
470
487
|
return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
|
|
471
488
|
}
|
|
472
489
|
function humanLabel(key) {
|
|
@@ -1867,13 +1884,20 @@ async function main() {
|
|
|
1867
1884
|
if (client !== undefined &&
|
|
1868
1885
|
!["claude", "codex", "gemini", "copilot", "antigravity"].includes(client))
|
|
1869
1886
|
throw new Error("knodin doctor: --client must be claude, codex, gemini, copilot, or antigravity");
|
|
1870
|
-
const unsupported = rest.filter((argument, index) => argument !== "--client" && rest[index - 1] !== "--client");
|
|
1887
|
+
const unsupported = rest.filter((argument, index) => argument !== "--client" && argument !== "--deep" && rest[index - 1] !== "--client");
|
|
1871
1888
|
if (unsupported.length > 0)
|
|
1872
1889
|
throw new Error(`knodin doctor: unknown option ${unsupported[0]}`);
|
|
1873
1890
|
const diagnosis = await diagnoseInstallation(repo, {
|
|
1874
1891
|
currentVersion: KNODIN_VERSION,
|
|
1875
1892
|
runtimeCommand: [...runtimeCommand, "serve"],
|
|
1876
|
-
|
|
1893
|
+
// Adaptive, like `status`: the persisted-audit path runs the same
|
|
1894
|
+
// drift probe, so a stale graph still reports stale, while a forced
|
|
1895
|
+
// deep audit on a very large repository cost doctor two full stat
|
|
1896
|
+
// sweeps every run (KNODIN-39). `--deep` keeps the exact audit
|
|
1897
|
+
// available on demand.
|
|
1898
|
+
graph: await engine.status(repo, {
|
|
1899
|
+
audit: rest.includes("--deep") ? "deep" : "adaptive",
|
|
1900
|
+
}),
|
|
1877
1901
|
client,
|
|
1878
1902
|
});
|
|
1879
1903
|
const manager = diagnosis.manager.name;
|
|
@@ -1884,7 +1908,23 @@ async function main() {
|
|
|
1884
1908
|
: "unknown",
|
|
1885
1909
|
env: process.env,
|
|
1886
1910
|
});
|
|
1887
|
-
|
|
1911
|
+
// One MCP probe backs every client row; the human listing printed the
|
|
1912
|
+
// identical initialize/toolsList block five times, reading as five
|
|
1913
|
+
// independent server checks (KNODIN-40). JSON keeps the per-client
|
|
1914
|
+
// shape for compatibility.
|
|
1915
|
+
const agents = diagnosis.agents;
|
|
1916
|
+
result = jsonOutput
|
|
1917
|
+
? diagnosis
|
|
1918
|
+
: {
|
|
1919
|
+
...diagnosis,
|
|
1920
|
+
agents: {
|
|
1921
|
+
...agents,
|
|
1922
|
+
clients: agents.clients?.map((clientRow) => ({
|
|
1923
|
+
...clientRow,
|
|
1924
|
+
server: "shared MCP probe; see the mcp section",
|
|
1925
|
+
})),
|
|
1926
|
+
},
|
|
1927
|
+
};
|
|
1888
1928
|
break;
|
|
1889
1929
|
}
|
|
1890
1930
|
case "system": {
|
|
@@ -355,12 +355,19 @@ function parseRetentionReceipt(value) {
|
|
|
355
355
|
}
|
|
356
356
|
function readInstalledRetention(options) {
|
|
357
357
|
const locations = homes(options);
|
|
358
|
+
const receiptPath = path.join(locations.stateRoot, "receipt.json");
|
|
358
359
|
const policy = parseRetentionPolicy(readJson(locations.policy));
|
|
359
|
-
const receipt = parseRetentionReceipt(readJson(
|
|
360
|
+
const receipt = parseRetentionReceipt(readJson(receiptPath));
|
|
361
|
+
// A machine where NEITHER file exists never had retention installed; that is
|
|
362
|
+
// a clean state, not a broken one, and reporting "missing-or-invalid" for it
|
|
363
|
+
// taught every fresh doctor run to cry attention-required (KNODIN-40). Any
|
|
364
|
+
// on-disk trace — even an unparseable file — is installation evidence and
|
|
365
|
+
// keeps the strict issue reporting.
|
|
366
|
+
const installEvidence = fs.existsSync(locations.policy) || fs.existsSync(receiptPath);
|
|
360
367
|
const issues = [];
|
|
361
|
-
if (!policy)
|
|
368
|
+
if (installEvidence && !policy)
|
|
362
369
|
issues.push("policy-missing-or-invalid");
|
|
363
|
-
if (!receipt)
|
|
370
|
+
if (installEvidence && !receipt)
|
|
364
371
|
issues.push("receipt-missing-or-invalid");
|
|
365
372
|
if (policy && receipt && digest(`${JSON.stringify(policy, null, 2)}\n`) !== receipt.policyDigest)
|
|
366
373
|
issues.push("policy-receipt-mismatch");
|
|
@@ -373,7 +380,7 @@ function readInstalledRetention(options) {
|
|
|
373
380
|
issues.push(`scheduler-missing: ${artifact.path}`);
|
|
374
381
|
}
|
|
375
382
|
}
|
|
376
|
-
return { policy, receipt, issues };
|
|
383
|
+
return { policy, receipt, issues, installEvidence };
|
|
377
384
|
}
|
|
378
385
|
function commandRunner(command, args) {
|
|
379
386
|
const result = childProcess.spawnSync(command, args, { encoding: "utf8" });
|
|
@@ -571,11 +578,18 @@ export function installBackupRetention(roots, options = {}) {
|
|
|
571
578
|
}
|
|
572
579
|
export function retentionStatus(options = {}) {
|
|
573
580
|
const locations = homes(options);
|
|
574
|
-
const { policy, receipt, issues } = readInstalledRetention(options);
|
|
581
|
+
const { policy, receipt, issues, installEvidence } = readInstalledRetention(options);
|
|
575
582
|
const lastRun = readJson(path.join(locations.stateRoot, "last-run.json"));
|
|
576
583
|
return {
|
|
577
584
|
schemaVersion: 1,
|
|
578
|
-
|
|
585
|
+
// Keyed on on-disk evidence rather than parse success: a never-installed
|
|
586
|
+
// machine is "not-installed" with no issues, while an installed-but-broken
|
|
587
|
+
// state (either file present, parseable or not) keeps attention-required.
|
|
588
|
+
status: !installEvidence && issues.length === 0
|
|
589
|
+
? "not-installed"
|
|
590
|
+
: issues.length === 0
|
|
591
|
+
? "healthy"
|
|
592
|
+
: "attention-required",
|
|
579
593
|
installed: Boolean(policy && receipt),
|
|
580
594
|
policy,
|
|
581
595
|
receipt,
|
package/dist/src/doctor.js
CHANGED
|
@@ -171,15 +171,68 @@ function managedConfigurationRemediation(repo, run) {
|
|
|
171
171
|
paths.push(relative);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
// One `ls-files` and one `diff` for every managed path together: the previous
|
|
175
|
+
// per-path pair spawned ~2 git processes per entry, and each `git diff` must
|
|
176
|
+
// load and refresh the repository index — seconds apiece on a 900k-file
|
|
177
|
+
// checkout, for identical answers (KNODIN-39).
|
|
178
|
+
const trackedResult = run("git", ["-C", repo, "ls-files", "-z", "--", ...paths]);
|
|
179
|
+
const trackedFiles = trackedResult.status === 0 ? trackedResult.stdout.split("\0").filter(Boolean) : [];
|
|
180
|
+
// A managed DIRECTORY (a receipted skill dir) never appears in ls-files
|
|
181
|
+
// output itself — only files under it do — so membership alone would read
|
|
182
|
+
// every tracked skill dir as untracked, matching neither the old
|
|
183
|
+
// `--error-unmatch` semantics nor reality (review on KNODIN-39). The output
|
|
184
|
+
// is already limited to the managed pathspecs, so the prefix scan is tiny.
|
|
185
|
+
const isTrackedManaged = (relative) => trackedFiles.includes(relative) || trackedFiles.some((file) => file.startsWith(`${relative}/`));
|
|
186
|
+
const trackedManaged = paths.filter(isTrackedManaged);
|
|
187
|
+
// Ignored paths need no "add to .git/info/exclude" advice: the instruction
|
|
188
|
+
// previously fired for every present, untracked, knodin-looking file whether
|
|
189
|
+
// or not the exclude entry already existed (KNODIN-40). One batched
|
|
190
|
+
// check-ignore answers for all of them; a non-zero status just means none
|
|
191
|
+
// are ignored.
|
|
192
|
+
const ignoredPaths = new Set();
|
|
193
|
+
const untrackedPresent = paths.filter((relative) => !isTrackedManaged(relative) && fs.existsSync(path.join(repo, relative)));
|
|
194
|
+
if (untrackedPresent.length > 0) {
|
|
195
|
+
const checkIgnore = run("git", ["-C", repo, "check-ignore", "-z", "--stdin"], {
|
|
196
|
+
input: `${untrackedPresent.join("\0")}\0`,
|
|
197
|
+
});
|
|
198
|
+
for (const relative of checkIgnore.stdout.split("\0").filter(Boolean))
|
|
199
|
+
ignoredPaths.add(relative);
|
|
200
|
+
}
|
|
201
|
+
// Guarding on trackedManaged (not the raw ls-files output) matters: with no
|
|
202
|
+
// tracked managed paths, `git diff --` with an empty pathspec list would
|
|
203
|
+
// diff the entire repository (review on KNODIN-39).
|
|
204
|
+
const diffSections = [];
|
|
205
|
+
if (trackedManaged.length > 0) {
|
|
206
|
+
const batchedDiff = run("git", [
|
|
207
|
+
"-C",
|
|
208
|
+
repo,
|
|
209
|
+
"diff",
|
|
210
|
+
"--no-ext-diff",
|
|
211
|
+
"--unified=3",
|
|
212
|
+
"--",
|
|
213
|
+
...trackedManaged,
|
|
214
|
+
]);
|
|
215
|
+
if (batchedDiff.status === 0 && batchedDiff.stdout) {
|
|
216
|
+
for (const section of batchedDiff.stdout.split(/^(?=diff --git )/m)) {
|
|
217
|
+
const header = /^diff --git a\/(.+) b\//.exec(section);
|
|
218
|
+
if (header?.[1])
|
|
219
|
+
diffSections.push({ file: header[1], section });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// A managed directory owns every section for files beneath it; a managed
|
|
224
|
+
// file owns only its own. This mirrors what the old per-path `git diff -- p`
|
|
225
|
+
// returned for each shape.
|
|
226
|
+
const diffFor = (relative) => diffSections
|
|
227
|
+
.filter(({ file }) => file === relative || file.startsWith(`${relative}/`))
|
|
228
|
+
.map(({ section }) => section)
|
|
229
|
+
.join("");
|
|
174
230
|
return paths
|
|
175
231
|
.map((relative) => {
|
|
176
232
|
const absolute = path.join(repo, relative);
|
|
177
233
|
const present = fs.existsSync(absolute);
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
const diffResult = tracked
|
|
181
|
-
? run("git", ["-C", repo, "diff", "--no-ext-diff", "--unified=3", "--", relative])
|
|
182
|
-
: { status: 0, stdout: "", stderr: "" };
|
|
234
|
+
const tracked = isTrackedManaged(relative);
|
|
235
|
+
const diffResult = { status: 0, stdout: diffFor(relative), stderr: "" };
|
|
183
236
|
const receipted = fs.existsSync(path.join(absolute, ".knodin-managed.json"));
|
|
184
237
|
let entryOwned = false;
|
|
185
238
|
if (present && !receipted) {
|
|
@@ -195,7 +248,7 @@ function managedConfigurationRemediation(repo, run) {
|
|
|
195
248
|
instructions.push(`Review the diff, then restore manually with: git restore --source=HEAD -- ${relative}`);
|
|
196
249
|
if (!tracked && receipted)
|
|
197
250
|
instructions.push(`Review the Knodin receipt, then remove manually if unwanted: ${relative}`);
|
|
198
|
-
if (!tracked && present && (receipted || entryOwned))
|
|
251
|
+
if (!tracked && present && (receipted || entryOwned) && !ignoredPaths.has(relative))
|
|
199
252
|
instructions.push(`To retain it locally without committing, add ${relative} to .git/info/exclude manually.`);
|
|
200
253
|
return {
|
|
201
254
|
path: relative,
|
package/dist/src/engine/index.js
CHANGED
|
@@ -8227,7 +8227,7 @@ const freshnessChecks = new Map();
|
|
|
8227
8227
|
* Work counters for the guard. Tests assert its cost in *invocations*, which is
|
|
8228
8228
|
* deterministic, rather than in wall-clock milliseconds, which is flaky.
|
|
8229
8229
|
*/
|
|
8230
|
-
const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0 };
|
|
8230
|
+
const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0, probeOverflows: 0 };
|
|
8231
8231
|
/**
|
|
8232
8232
|
* Paths the live watcher for `repoPath` currently has registered, flattened to
|
|
8233
8233
|
* repo-relative form. Empty when no watcher is running (test mode, or after
|
|
@@ -8298,6 +8298,7 @@ export function resetFreshnessStats() {
|
|
|
8298
8298
|
freshnessStats.probes = 0;
|
|
8299
8299
|
freshnessStats.reconciles = 0;
|
|
8300
8300
|
freshnessStats.cacheHits = 0;
|
|
8301
|
+
freshnessStats.probeOverflows = 0;
|
|
8301
8302
|
freshnessProbes.clear();
|
|
8302
8303
|
}
|
|
8303
8304
|
/**
|
|
@@ -8316,10 +8317,19 @@ function gitWorkTreeProbe(repoPath) {
|
|
|
8316
8317
|
cwd: repoPath,
|
|
8317
8318
|
encoding: "utf-8",
|
|
8318
8319
|
stdio: ["pipe", "pipe", "ignore"],
|
|
8320
|
+
// Node's default 1 MB maxBuffer holds only a few thousand dirty
|
|
8321
|
+
// porcelain lines; a large dirty tree then threw here and silently
|
|
8322
|
+
// demoted the probe to the bounded mtime fallback (KNODIN-39).
|
|
8323
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
8319
8324
|
})
|
|
8320
8325
|
.split("\n");
|
|
8321
8326
|
}
|
|
8322
|
-
catch {
|
|
8327
|
+
catch (error) {
|
|
8328
|
+
// An overflow is a degradation worth attributing — the fallback path is
|
|
8329
|
+
// bounded and can only answer "unknown" — so it is counted where the perf
|
|
8330
|
+
// harness and tests can see it, unlike a plain not-a-git-repo failure.
|
|
8331
|
+
if (error.code === "ENOBUFS")
|
|
8332
|
+
freshnessStats.probeOverflows += 1;
|
|
8323
8333
|
return null;
|
|
8324
8334
|
}
|
|
8325
8335
|
const head = lines[0]?.trim();
|
package/dist/src/init.js
CHANGED
|
@@ -7,7 +7,7 @@ import { compareBytes } from "./compare.js";
|
|
|
7
7
|
import { isIndexableSourcePath } from "./engine/source-policy.js";
|
|
8
8
|
import { lookupMirror } from "./engine/state-paths.js";
|
|
9
9
|
import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
|
|
10
|
-
import {
|
|
10
|
+
import { stableInterpreterCandidates } from "./node-runtime.js";
|
|
11
11
|
import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
|
|
12
12
|
import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
|
|
13
13
|
import { registerInitializedWorktree } from "./worktree-lifecycle.js";
|
|
@@ -434,18 +434,33 @@ function backgroundScript(command) {
|
|
|
434
434
|
// silently switch which runtime executes, which is its own bug.
|
|
435
435
|
const [interpreter, ...rest] = command;
|
|
436
436
|
const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
|
|
437
|
-
// Recorded through `
|
|
437
|
+
// Recorded through `stableInterpreterCandidates` so a Homebrew interpreter is
|
|
438
438
|
// written as its version-stable `opt` path rather than the versioned Cellar
|
|
439
|
-
// path Node reports
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
439
|
+
// path Node reports (EASFDC-8497), and so the script carries an ordered list
|
|
440
|
+
// of stable runtimes (version-manager shims, Homebrew opt paths) that engage
|
|
441
|
+
// only once the recorded path is dead. Version-manager installs (mise, nvm,
|
|
442
|
+
// fnm, asdf, volta) delete their versioned directories on upgrade exactly
|
|
443
|
+
// like Homebrew's Cellar, and no eager rewrite exists for them — the shim
|
|
444
|
+
// resolves per-directory configuration, so promoting it while the recorded
|
|
445
|
+
// path still works could silently switch runtimes (KNODIN-38).
|
|
446
|
+
const resolution = stableInterpreterCandidates(interpreter ?? process.execPath);
|
|
447
|
+
const preferred = shellQuote(resolution.preferred);
|
|
448
|
+
const fallbackBlock = resolution.fallbacks.length > 0
|
|
449
|
+
? String.raw `if [ ! -x "$KNODIN_NODE" ]; then
|
|
450
|
+
for KNODIN_NODE_CANDIDATE in ${resolution.fallbacks.map(shellQuote).join(" ")}; do
|
|
451
|
+
if [ -x "$KNODIN_NODE_CANDIDATE" ]; then
|
|
452
|
+
KNODIN_NODE="$KNODIN_NODE_CANDIDATE"
|
|
453
|
+
break
|
|
454
|
+
fi
|
|
455
|
+
done
|
|
456
|
+
fi
|
|
457
|
+
`
|
|
458
|
+
: "";
|
|
444
459
|
return String.raw `#!/bin/sh
|
|
445
460
|
# knodin packaged background refresh. Generated by knodin init.
|
|
446
461
|
set -u
|
|
447
462
|
KNODIN_NODE=${preferred}
|
|
448
|
-
if [ ! -x "$KNODIN_NODE" ]; then
|
|
463
|
+
${fallbackBlock}if [ ! -x "$KNODIN_NODE" ]; then
|
|
449
464
|
KNODIN_NODE="$(command -v node 2>/dev/null || true)"
|
|
450
465
|
# No interpreter at all: fall through to the recorded path so the failure is
|
|
451
466
|
# the existing loud "not found", not a silent no-op.
|
|
@@ -954,12 +969,34 @@ function withoutManagedExcludeBlock(content) {
|
|
|
954
969
|
after += 1;
|
|
955
970
|
return `${content.slice(0, start > 0 ? start - 1 : 0)}\n${content.slice(after)}`;
|
|
956
971
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
972
|
+
/** The entries currently inside the knodin-owned exclude block, verbatim. */
|
|
973
|
+
function managedExcludeBlockEntries(content) {
|
|
974
|
+
let start = content.indexOf(EXCLUDE_BLOCK_START);
|
|
975
|
+
while (start > 0 && content[start - 1] !== "\n")
|
|
976
|
+
start = content.indexOf(EXCLUDE_BLOCK_START, start + 1);
|
|
977
|
+
if (start < 0)
|
|
962
978
|
return [];
|
|
979
|
+
const end = content.indexOf(EXCLUDE_BLOCK_END, start + EXCLUDE_BLOCK_START.length);
|
|
980
|
+
if (end < 0)
|
|
981
|
+
return [];
|
|
982
|
+
return content
|
|
983
|
+
.slice(start + EXCLUDE_BLOCK_START.length, end)
|
|
984
|
+
.split(/\r?\n/)
|
|
985
|
+
.map((line) => line.trim())
|
|
986
|
+
.filter(Boolean);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* MERGES into the managed block rather than rewriting it from `candidates`
|
|
990
|
+
* alone. Rewriting was the SalesforceCI regression (KNODIN-40): init calls this
|
|
991
|
+
* up to twice per run with different candidate lists, so the hook-manager pass
|
|
992
|
+
* erased the agent-config entries written moments earlier, and a later bare
|
|
993
|
+
* `knodin init` shrank the block to `.knodin/` — leaving knodin-owned files as
|
|
994
|
+
* untracked dirt in `git status`. Entries already in the block survive as long
|
|
995
|
+
* as they remain untracked; only a tracked path (or an explicit
|
|
996
|
+
* `removeManagedExcludes`, which team and cli-only transitions call first) can
|
|
997
|
+
* remove one.
|
|
998
|
+
*/
|
|
999
|
+
async function excludeUntrackedPaths(repo, candidates) {
|
|
963
1000
|
const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
|
|
964
1001
|
const excludePath = path.isAbsolute(rawExcludePath)
|
|
965
1002
|
? rawExcludePath
|
|
@@ -972,6 +1009,11 @@ async function excludeUntrackedPaths(repo, candidates) {
|
|
|
972
1009
|
if (error.code !== "ENOENT")
|
|
973
1010
|
throw error;
|
|
974
1011
|
}
|
|
1012
|
+
const safe = [...new Set([...managedExcludeBlockEntries(existing), ...candidates])]
|
|
1013
|
+
.filter((candidate) => !isTracked(repo, candidate))
|
|
1014
|
+
.sort(compareBytes);
|
|
1015
|
+
if (safe.length === 0)
|
|
1016
|
+
return [];
|
|
975
1017
|
const startMarker = "# knodin:start";
|
|
976
1018
|
const endMarker = "# knodin:end";
|
|
977
1019
|
const outside = withoutManagedExcludeBlock(existing);
|
|
@@ -983,6 +1025,40 @@ async function excludeUntrackedPaths(repo, candidates) {
|
|
|
983
1025
|
await fs.promises.writeFile(excludePath, `${prefix}${block}`, "utf-8");
|
|
984
1026
|
return owned;
|
|
985
1027
|
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Personal-scope files whose exclude entry is worth restoring during repair.
|
|
1030
|
+
* `.mcp.json` is absent deliberately: personal scope removes it rather than
|
|
1031
|
+
* writing it, and `.vscode/mcp.json` only exists when copilot was configured.
|
|
1032
|
+
*/
|
|
1033
|
+
const PERSONAL_AGENT_CONFIG_PATHS = [
|
|
1034
|
+
".codex/config.toml",
|
|
1035
|
+
".gemini/settings.json",
|
|
1036
|
+
".agents/mcp_config.json",
|
|
1037
|
+
".vscode/mcp.json",
|
|
1038
|
+
];
|
|
1039
|
+
/**
|
|
1040
|
+
* Restore managed exclude entries a whole-block rewrite erased (KNODIN-40).
|
|
1041
|
+
* Candidates are limited to files that exist, mention knodin, and belong to a
|
|
1042
|
+
* personal-scope integration — repair must never start excluding files it
|
|
1043
|
+
* cannot show it owns. The merge inside `excludeUntrackedPaths` keeps whatever
|
|
1044
|
+
* else the block already carries (a hook manager's local config, for one).
|
|
1045
|
+
*/
|
|
1046
|
+
async function reconcileManagedExcludes(repo) {
|
|
1047
|
+
if (readRepositoryIntegrationConfig(repo)?.scope !== "personal")
|
|
1048
|
+
return [];
|
|
1049
|
+
const candidates = [
|
|
1050
|
+
...(isTracked(repo, ".knodin") ? [] : [".knodin/"]),
|
|
1051
|
+
...PERSONAL_AGENT_CONFIG_PATHS.filter((relative) => {
|
|
1052
|
+
try {
|
|
1053
|
+
return fs.readFileSync(path.join(repo, relative), "utf-8").includes("knodin");
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
}),
|
|
1059
|
+
];
|
|
1060
|
+
return candidates.length > 0 ? excludeUntrackedPaths(repo, candidates) : [];
|
|
1061
|
+
}
|
|
986
1062
|
async function removeManagedExcludes(repo) {
|
|
987
1063
|
const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
|
|
988
1064
|
const excludePath = path.isAbsolute(rawExcludePath)
|
|
@@ -1526,12 +1602,14 @@ export async function repairLifecycleRouting(repoPath, options) {
|
|
|
1526
1602
|
}
|
|
1527
1603
|
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
|
|
1528
1604
|
await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
|
|
1605
|
+
const excluded = await reconcileManagedExcludes(resolvedRepo);
|
|
1529
1606
|
return {
|
|
1530
1607
|
backgroundIndexer: ".knodin/hooks/background-index.sh",
|
|
1531
1608
|
hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
|
|
1532
1609
|
gitHooks: [...HOOK_NAMES],
|
|
1533
1610
|
hookManagerIntegration,
|
|
1534
1611
|
lifecycleRefresh,
|
|
1612
|
+
excluded,
|
|
1535
1613
|
configurationChanges: [],
|
|
1536
1614
|
};
|
|
1537
1615
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { compareBytes } from "./compare.js";
|
|
5
5
|
import { isMirror, resolveDbPath } from "./engine/state-paths.js";
|
|
6
6
|
import { inspectLefthookIntegration, isActiveLefthookHook } from "./hook-manager-integration.js";
|
|
7
|
+
import { resolveHealthyInterpreter } from "./node-runtime.js";
|
|
7
8
|
const MANAGED_MARKER = "KNODIN MANAGED HOOK";
|
|
8
9
|
const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
|
|
9
10
|
const HOOK_FAILURE_FILE = "last-refresh-failure";
|
|
@@ -204,6 +205,44 @@ function pinnedInterpreterWithoutFallback(backgroundScriptPath) {
|
|
|
204
205
|
return null;
|
|
205
206
|
return /'(\/[^']*\/bin\/node)'/.exec(contents)?.[1] ?? null;
|
|
206
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Rewrite a dead recorded interpreter to a proven-healthy stable one, in place.
|
|
210
|
+
*
|
|
211
|
+
* Detection without repair made this a recurring chore: every `brew upgrade
|
|
212
|
+
* node` (or mise/nvm upgrade) killed the recorded path, health reported
|
|
213
|
+
* degraded, and the user ran `repair --lifecycle` by hand — which re-recorded
|
|
214
|
+
* the repairing process's own versioned path and planted the next failure
|
|
215
|
+
* (KNODIN-38). The fix is narrow by design: only the quoted interpreter path
|
|
216
|
+
* changes, the replacement must exist AND report a supported Node version
|
|
217
|
+
* (`resolveHealthyInterpreter` executes it), and the write is atomic
|
|
218
|
+
* (tmp+rename, mode preserved) so a hook running concurrently keeps the copy
|
|
219
|
+
* it already read.
|
|
220
|
+
*
|
|
221
|
+
* Returns null — leaving the degraded report exactly as it was — when no
|
|
222
|
+
* replacement can be proven healthy, when the script cannot be written, or
|
|
223
|
+
* when the replacement contains a single quote the recorded format cannot
|
|
224
|
+
* carry. Degraded-but-honest beats healed-but-guessed.
|
|
225
|
+
*/
|
|
226
|
+
export function selfHealRecordedInterpreter(backgroundScriptPath, recorded, resolve = resolveHealthyInterpreter) {
|
|
227
|
+
const replacement = resolve(recorded);
|
|
228
|
+
if (!replacement || replacement.includes("'"))
|
|
229
|
+
return null;
|
|
230
|
+
const temporary = `${backgroundScriptPath}.self-heal.${process.pid}`;
|
|
231
|
+
try {
|
|
232
|
+
const contents = fs.readFileSync(backgroundScriptPath, "utf8");
|
|
233
|
+
const updated = contents.split(`'${recorded}'`).join(`'${replacement}'`);
|
|
234
|
+
if (updated === contents)
|
|
235
|
+
return null;
|
|
236
|
+
const mode = fs.statSync(backgroundScriptPath).mode & 0o777;
|
|
237
|
+
fs.writeFileSync(temporary, updated, { mode });
|
|
238
|
+
fs.renameSync(temporary, backgroundScriptPath);
|
|
239
|
+
return { from: recorded, to: replacement };
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
fs.rmSync(temporary, { force: true });
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
207
246
|
function readRefreshFailure(repo) {
|
|
208
247
|
const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
|
|
209
248
|
if (!fs.existsSync(failurePath))
|
|
@@ -216,7 +255,15 @@ function readRefreshFailure(repo) {
|
|
|
216
255
|
return "last background refresh failed";
|
|
217
256
|
}
|
|
218
257
|
}
|
|
219
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* Proof that committed Git activity can still reach knodin's indexer.
|
|
260
|
+
*
|
|
261
|
+
* Read-only, with one deliberate exception: a recorded interpreter that no
|
|
262
|
+
* longer exists is rewritten in place when a proven-healthy replacement
|
|
263
|
+
* resolves (`selfHealRecordedInterpreter`), because reporting that condition
|
|
264
|
+
* without fixing it made every Node upgrade a manual `repair --lifecycle`
|
|
265
|
+
* chore (KNODIN-38).
|
|
266
|
+
*/
|
|
220
267
|
export function inspectLifecycleHealth(repoPath) {
|
|
221
268
|
const repo = path.resolve(repoPath);
|
|
222
269
|
// A mirror deliberately has no lifecycle hooks: its working tree is replaced
|
|
@@ -240,6 +287,7 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
240
287
|
lastError: null,
|
|
241
288
|
hookManager: null,
|
|
242
289
|
routing: "unavailable",
|
|
290
|
+
interpreterSelfHeal: null,
|
|
243
291
|
};
|
|
244
292
|
}
|
|
245
293
|
if (!fs.existsSync(resolveDbPath(repo))) {
|
|
@@ -257,6 +305,7 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
257
305
|
lastError: null,
|
|
258
306
|
hookManager: null,
|
|
259
307
|
routing: "unavailable",
|
|
308
|
+
interpreterSelfHeal: null,
|
|
260
309
|
};
|
|
261
310
|
}
|
|
262
311
|
let hooksDirectory;
|
|
@@ -278,6 +327,7 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
278
327
|
lastError: null,
|
|
279
328
|
hookManager: null,
|
|
280
329
|
routing: "unavailable",
|
|
330
|
+
interpreterSelfHeal: null,
|
|
281
331
|
};
|
|
282
332
|
}
|
|
283
333
|
const { activeHooks, missingHooks, hookManager, routing } = inspectManagedHooks(repo, hooksDirectory);
|
|
@@ -286,7 +336,16 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
286
336
|
const issues = missingHooks.map((hook) => `${hook} no longer routes through knodin's active Git hook path`);
|
|
287
337
|
if (!backgroundReady)
|
|
288
338
|
issues.push("background indexer is missing or not executable");
|
|
289
|
-
|
|
339
|
+
// A dead recorded interpreter is repaired here, not just reported: the
|
|
340
|
+
// replacement must prove itself healthy, and when nothing does the original
|
|
341
|
+
// degraded report stands untouched (KNODIN-38).
|
|
342
|
+
let staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
|
|
343
|
+
let interpreterSelfHeal = null;
|
|
344
|
+
if (staleInterpreter) {
|
|
345
|
+
interpreterSelfHeal = selfHealRecordedInterpreter(background, staleInterpreter);
|
|
346
|
+
if (interpreterSelfHeal)
|
|
347
|
+
staleInterpreter = null;
|
|
348
|
+
}
|
|
290
349
|
if (staleInterpreter)
|
|
291
350
|
issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin repair --lifecycle\` to rewrite the hook`);
|
|
292
351
|
// Reported even while that interpreter still exists. The failure is latent
|
|
@@ -315,6 +374,7 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
315
374
|
lastError,
|
|
316
375
|
hookManager,
|
|
317
376
|
routing,
|
|
377
|
+
interpreterSelfHeal,
|
|
318
378
|
};
|
|
319
379
|
}
|
|
320
380
|
/** Merge graph and refresh-path health without reporting absent evidence as healthy. */
|
package/dist/src/node-runtime.js
CHANGED
|
@@ -188,3 +188,99 @@ export function stableInterpreterPath(executable, exists = (candidate) => fs.exi
|
|
|
188
188
|
const candidate = `${prefix}/opt/${formula}/${remainder}`;
|
|
189
189
|
return exists(candidate) ? candidate : executable;
|
|
190
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* The version manager's stable shim for a versioned install path, when the
|
|
193
|
+
* manager has one. mise, asdf, and volta shims survive `<manager> upgrade`
|
|
194
|
+
* because the manager repoints them; the versioned install directory the
|
|
195
|
+
* recorded `execPath` names does not. nvm has no on-disk shim (it is a shell
|
|
196
|
+
* function), so an nvm path yields nothing here.
|
|
197
|
+
*/
|
|
198
|
+
function versionManagerShim(executable) {
|
|
199
|
+
const mise = /^(.*)\/installs\/node\/[^/]+\/bin\/node$/.exec(executable);
|
|
200
|
+
if (mise)
|
|
201
|
+
return `${mise[1]}/shims/node`;
|
|
202
|
+
const asdf = /^(.*)\/installs\/nodejs\/[^/]+\/bin\/node$/.exec(executable);
|
|
203
|
+
if (asdf)
|
|
204
|
+
return `${asdf[1]}/shims/node`;
|
|
205
|
+
const volta = /^(.*)\/tools\/image\/node\/[^/]+\/bin\/node$/.exec(executable);
|
|
206
|
+
if (volta)
|
|
207
|
+
return `${volta[1]}/bin/node`;
|
|
208
|
+
const fnm = /^(.*)\/node-versions\/[^/]+\/installation\/bin\/node$/.exec(executable);
|
|
209
|
+
if (fnm)
|
|
210
|
+
return `${fnm[1]}/aliases/default/bin/node`;
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A version-stable interpreter plus an ordered fallback list for anything that
|
|
215
|
+
* records an interpreter path for later (the background refresh hook).
|
|
216
|
+
*
|
|
217
|
+
* The preferred path stays EXACT except for the Homebrew Cellar→opt rewrite,
|
|
218
|
+
* which is the same binary by construction. A version-manager shim is
|
|
219
|
+
* deliberately NOT promoted to preferred while the recorded path still exists:
|
|
220
|
+
* shims resolve per-directory configuration, so an eager rewrite could
|
|
221
|
+
* silently switch which runtime executes. Shims belong in the fallback list,
|
|
222
|
+
* where they only engage once the recorded path is dead — at which point any
|
|
223
|
+
* working stable runtime beats a vanished one.
|
|
224
|
+
*/
|
|
225
|
+
export function stableInterpreterCandidates(executable, options = {}) {
|
|
226
|
+
const exists = options.exists ?? ((candidate) => fs.existsSync(candidate));
|
|
227
|
+
const env = options.env ?? process.env;
|
|
228
|
+
const home = options.homeDirectory ?? os.homedir();
|
|
229
|
+
const preferred = stableInterpreterPath(executable, exists);
|
|
230
|
+
const absolute = (value) => value && path.isAbsolute(value) ? value : undefined;
|
|
231
|
+
const miseRoot = absolute(env.MISE_DATA_DIR) ?? path.join(home, ".local", "share", "mise");
|
|
232
|
+
const asdfRoot = absolute(env.ASDF_DATA_DIR) ?? path.join(home, ".asdf");
|
|
233
|
+
const voltaRoot = absolute(env.VOLTA_HOME) ?? path.join(home, ".volta");
|
|
234
|
+
const fnmRoot = absolute(env.FNM_DIR);
|
|
235
|
+
// KNODIN_NODE_RUNTIME leads: it is the user's explicit override, and
|
|
236
|
+
// `runtimeCandidates` above treats it as highest priority — the derived shim
|
|
237
|
+
// must not outrank it once the recorded path is dead. A non-absolute value
|
|
238
|
+
// is skipped rather than thrown here (the launcher already throws for it at
|
|
239
|
+
// startup): this derivation runs inside status/doctor health checks, where
|
|
240
|
+
// a bad environment variable must degrade a fallback list, not the command.
|
|
241
|
+
const ordered = [
|
|
242
|
+
absolute(env.KNODIN_NODE_RUNTIME),
|
|
243
|
+
versionManagerShim(executable),
|
|
244
|
+
"/opt/homebrew/opt/node@24/bin/node",
|
|
245
|
+
"/opt/homebrew/opt/node/bin/node",
|
|
246
|
+
"/usr/local/opt/node@24/bin/node",
|
|
247
|
+
"/usr/local/opt/node/bin/node",
|
|
248
|
+
path.join(miseRoot, "shims", "node"),
|
|
249
|
+
path.join(home, ".mise", "shims", "node"),
|
|
250
|
+
path.join(asdfRoot, "shims", "node"),
|
|
251
|
+
path.join(voltaRoot, "bin", "node"),
|
|
252
|
+
fnmRoot ? path.join(fnmRoot, "aliases", "default", "bin", "node") : null,
|
|
253
|
+
];
|
|
254
|
+
const fallbacks = [];
|
|
255
|
+
const seen = new Set([preferred, executable]);
|
|
256
|
+
for (const candidate of ordered) {
|
|
257
|
+
if (!candidate || seen.has(candidate) || !exists(candidate))
|
|
258
|
+
continue;
|
|
259
|
+
seen.add(candidate);
|
|
260
|
+
fallbacks.push(candidate);
|
|
261
|
+
if (fallbacks.length >= 4)
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
return { preferred, fallbacks };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A working replacement for a recorded interpreter that no longer exists, or
|
|
268
|
+
* null when none can be proven healthy. Candidates are the stable derivation
|
|
269
|
+
* of the dead path plus the stable fallback list; each is executed to confirm
|
|
270
|
+
* it actually reports a supported Node version — a shim can resolve to an
|
|
271
|
+
* unsupported runtime through per-directory configuration, and recording an
|
|
272
|
+
* unverified path would trade one dead interpreter for another.
|
|
273
|
+
*/
|
|
274
|
+
export function resolveHealthyInterpreter(deadExecutable, options = {}) {
|
|
275
|
+
const exists = options.exists ?? ((candidate) => fs.existsSync(candidate));
|
|
276
|
+
const probe = options.probe ?? probeNodeVersion;
|
|
277
|
+
const { preferred, fallbacks } = stableInterpreterCandidates(deadExecutable, options);
|
|
278
|
+
const candidates = preferred === deadExecutable ? fallbacks : [preferred, ...fallbacks];
|
|
279
|
+
for (const candidate of candidates) {
|
|
280
|
+
if (!exists(candidate))
|
|
281
|
+
continue;
|
|
282
|
+
if (isSupported(probe(candidate)))
|
|
283
|
+
return candidate;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# knodin 0.12.2
|
|
2
|
+
|
|
3
|
+
Three field reports from one very large Salesforce repository (903k files):
|
|
4
|
+
a lifecycle fault that came back after every Node upgrade, a doctor that took
|
|
5
|
+
48 seconds to say so, and a handful of reports that were wrong in ways that
|
|
6
|
+
taught people to ignore them. No schema change and no re-index.
|
|
7
|
+
|
|
8
|
+
## The interpreter that died on every `brew upgrade node` now heals itself
|
|
9
|
+
|
|
10
|
+
The background refresh hook records the absolute path of the Node interpreter
|
|
11
|
+
that wrote it. Node reports `process.execPath` as a fully resolved path, so a
|
|
12
|
+
stable `/opt/homebrew/bin/node` was recorded as a versioned Cellar path — and
|
|
13
|
+
mise, nvm, fnm, asdf, and volta versioned installs were recorded verbatim.
|
|
14
|
+
Every Node upgrade deleted that path, health reported "a Node interpreter that
|
|
15
|
+
no longer exists," and the prescribed `repair --lifecycle` re-recorded the
|
|
16
|
+
repairing process's own versioned path: the fix planted the next failure. This
|
|
17
|
+
loop had no exit, only a chore per upgrade per repository.
|
|
18
|
+
|
|
19
|
+
Three changes close it:
|
|
20
|
+
|
|
21
|
+
- **Recording is version-stable where that is safe.** The Homebrew Cellar path
|
|
22
|
+
is still rewritten to its durable `opt` form (the same binary by
|
|
23
|
+
construction). A version-manager path stays exact — a shim resolves
|
|
24
|
+
per-directory configuration, so promoting it while the recorded path still
|
|
25
|
+
works could silently switch which runtime executes.
|
|
26
|
+
- **The generated script carries an ordered fallback list** — the explicit
|
|
27
|
+
`KNODIN_NODE_RUNTIME` override first, then the recording manager's shim,
|
|
28
|
+
then known stable runtimes — so a single dead path never stops refresh.
|
|
29
|
+
- **Health repairs what it detects.** When the recorded interpreter is gone
|
|
30
|
+
and a replacement both exists and *proves itself* (it is executed and must
|
|
31
|
+
report a supported Node version), `status` and `doctor` rewrite the one
|
|
32
|
+
recorded line atomically and say so:
|
|
33
|
+
`background indexer interpreter self-healed (<old> -> <new>)`. When nothing
|
|
34
|
+
can be proven healthy, the degraded report stands untouched — degraded but
|
|
35
|
+
honest beats healed but guessed.
|
|
36
|
+
|
|
37
|
+
## `doctor` on a 903k-file repository: 48 seconds of redundant evidence
|
|
38
|
+
|
|
39
|
+
Doctor forced a full deep audit — two complete stat sweeps — on every run,
|
|
40
|
+
ignoring the persisted audit that `status` already trusts, and then spawned
|
|
41
|
+
two git processes per managed configuration path, each `git diff` reloading
|
|
42
|
+
the 903k-entry index. Doctor now audits adaptively exactly as `status` does
|
|
43
|
+
(the persisted-audit path runs the same drift probe, so a stale graph still
|
|
44
|
+
reports stale — accuracy was the constraint, and it is regression-tested),
|
|
45
|
+
with `doctor --deep` keeping the exact audit one flag away. The managed-path
|
|
46
|
+
inspection is one `git ls-files` and one `git diff` for everything, aware
|
|
47
|
+
that a managed *directory* is tracked through the files beneath it and never
|
|
48
|
+
runs a diff with an empty pathspec.
|
|
49
|
+
|
|
50
|
+
One silent accuracy hazard was found on the way: the working-tree probe used
|
|
51
|
+
Node's default 1 MB buffer for `git status --porcelain`, so a few thousand
|
|
52
|
+
dirty paths overflowed it and quietly demoted freshness checking to the
|
|
53
|
+
bounded mtime fallback. The buffer is now 64 MB and an overflow is counted
|
|
54
|
+
where tests and the perf harness can see it.
|
|
55
|
+
|
|
56
|
+
## Reports that cried wolf, and one that hid the pack
|
|
57
|
+
|
|
58
|
+
- **`.git/info/exclude` entries no longer vanish.** The managed exclude block
|
|
59
|
+
was rewritten from each caller's candidate list alone, so on a lefthook
|
|
60
|
+
repository the hook-manager pass erased the agent-config entries written
|
|
61
|
+
moments earlier, and a later bare `knodin init` shrank the block to
|
|
62
|
+
`.knodin/` — leaving knodin-owned files as permanent untracked dirt in
|
|
63
|
+
`git status`. The block now merges, and `repair --lifecycle` restores lost
|
|
64
|
+
entries for personal-scope repositories (only files that exist, mention
|
|
65
|
+
knodin, and match the receipt scope — repair never excludes what it cannot
|
|
66
|
+
show it owns).
|
|
67
|
+
- **A machine that never installed backup retention is `not-installed`, not
|
|
68
|
+
broken.** Doctor reported `attention-required` with
|
|
69
|
+
`policy-missing-or-invalid` and `receipt-missing-or-invalid` on machines
|
|
70
|
+
with neither file and zero backups. Absence of both files is a clean state;
|
|
71
|
+
any on-disk trace — even an unparseable file — still counts as installation
|
|
72
|
+
evidence and keeps the strict reporting.
|
|
73
|
+
- **Status names every issue, and the right remedy.** A lifecycle-only fault
|
|
74
|
+
used to print "Run `knodin init`." directly above a line that correctly said
|
|
75
|
+
`repair --lifecycle` — advice that contradicts itself teaches people to
|
|
76
|
+
read neither. The remediation now matches the fault, and status lists up to
|
|
77
|
+
five issues (`+N more`, full list under `--json`) instead of only the first.
|
|
78
|
+
- **Doctor prints its MCP probe once.** One probe backs every client row; the
|
|
79
|
+
human listing rendered it five times as if five servers had been tested.
|
|
80
|
+
JSON keeps the per-client shape for compatibility.
|
|
81
|
+
- **Doctor stops telling you to add exclude entries you already have.** The
|
|
82
|
+
"add to `.git/info/exclude` manually" instruction now consults
|
|
83
|
+
`git check-ignore` first.
|
|
84
|
+
|
|
85
|
+
Field verification for the recurring interpreter loop: run
|
|
86
|
+
`knodin repair --lifecycle` once with this version installed; the next Node
|
|
87
|
+
upgrade should self-heal on the first `status` instead of demanding another
|
|
88
|
+
repair.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"knodin": {
|
|
5
5
|
"compatibility": "compatible"
|
|
6
6
|
},
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"docs/releases/0.11.0.md",
|
|
67
67
|
"docs/releases/0.12.0.md",
|
|
68
68
|
"docs/releases/0.12.1.md",
|
|
69
|
+
"docs/releases/0.12.2.md",
|
|
69
70
|
"docs/releases/0.3.0.md",
|
|
70
71
|
"docs/releases/0.4.0.md",
|
|
71
72
|
"docs/releases/0.4.1.md",
|