knodin 0.10.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/init.js CHANGED
@@ -478,8 +478,15 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
478
478
  if [ -f "$LOCK_DIR/pid" ]; then
479
479
  LOCK_PID="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
480
480
  if [ -n "$LOCK_PID" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
481
- rm -rf "$LOCK_DIR"
482
- mkdir "$LOCK_DIR" 2>/dev/null || exit 0
481
+ # Claim the stale lock by RENAMING it: "rm -rf then mkdir" leaves a window
482
+ # in which two processes that both saw the same dead pid can each succeed.
483
+ # Only one rename can win, because the loser's source no longer exists.
484
+ if mv "$LOCK_DIR" "$LOCK_DIR.stale.$$" 2>/dev/null; then
485
+ rm -rf "$LOCK_DIR.stale.$$"
486
+ mkdir "$LOCK_DIR" 2>/dev/null || exit 0
487
+ else
488
+ exit 0
489
+ fi
483
490
  else
484
491
  exit 0
485
492
  fi
@@ -488,7 +495,25 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
488
495
  fi
489
496
  fi
490
497
  printf '%s\n' "$$" > "$LOCK_DIR/pid"
491
- trap 'rm -rf "$LOCK_DIR"; rm -f "$FAILURE_TMP"' EXIT HUP INT TERM
498
+ # Release only a lock this process still owns. The previous handler removed
499
+ # $LOCK_DIR unconditionally, so a script that had already lost the lock deleted
500
+ # its SUCCESSOR's — and init sends SIGTERM here on every drain that outruns its
501
+ # 30s spawnSync timeout, which made that the common case rather than the rare
502
+ # one (KNODIN-23).
503
+ knodin_release_lock() {
504
+ if [ "$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" = "$$" ]; then
505
+ rm -rf "$LOCK_DIR"
506
+ fi
507
+ rm -f "$FAILURE_TMP"
508
+ }
509
+ trap 'knodin_release_lock' EXIT
510
+ # Each signal exits explicitly. POSIX sh defers a trap until the foreground
511
+ # command finishes and then RESUMES where it left off, so a handler that only
512
+ # cleaned up dropped the lock and kept draining — two processors at once, and
513
+ # Ctrl-C failed to cancel a foreground run at all.
514
+ trap 'knodin_release_lock; trap - EXIT; exit 129' HUP
515
+ trap 'knodin_release_lock; trap - EXIT; exit 130' INT
516
+ trap 'knodin_release_lock; trap - EXIT; exit 143' TERM
492
517
  LOG_PATH="$REPO_ROOT/.knodin/indexer.log"
493
518
  if [ -f "$LOG_PATH" ]; then
494
519
  LOG_BYTES="$(wc -c < "$LOG_PATH" 2>/dev/null | tr -d '[:space:]')"
@@ -1433,6 +1458,87 @@ export async function initializeRepository(repo, options) {
1433
1458
  lifecycleLease.release();
1434
1459
  }
1435
1460
  }
1461
+ /**
1462
+ * Rewrite lifecycle routing — the managed Git hooks and the background refresh
1463
+ * script — without reading or writing a single line of agent configuration.
1464
+ *
1465
+ * This exists because a team-integrated repository whose hooks had died had no
1466
+ * safe exit (KNODIN-31). `repair` fixes graph rows and leaves the dead indexer
1467
+ * dead, so the schema drifts again; `init` rewrites tracked `AGENTS.md` /
1468
+ * `GEMINI.md` / `.mcp.json`, which is churn every teammate diffs on a branch
1469
+ * that has nothing to do with it; and `configure --scope personal` strips the
1470
+ * shared block for everyone else on that branch. The one clean option,
1471
+ * `init --scope personal`, is precisely the one refused in that state.
1472
+ *
1473
+ * So fault and remedy are matched in scope: the fault is in the hooks'
1474
+ * contents, and this rewrites the hooks' contents. It deliberately does not
1475
+ * index, does not touch graph rows, and never enters the `configureIntegration`
1476
+ * block — a broken hook should not be able to change a scope preference. Run
1477
+ * plain `knodin repair` alongside it when graph content is also damaged.
1478
+ */
1479
+ export async function repairLifecycleRouting(repoPath, options) {
1480
+ const resolvedRepo = path.resolve(repoPath);
1481
+ if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
1482
+ throw new Error(`knodin repair --lifecycle: not a Git worktree: ${resolvedRepo}`);
1483
+ }
1484
+ const mirror = lookupMirror(resolvedRepo);
1485
+ if (mirror) {
1486
+ throw new Error(`knodin repair --lifecycle: ${resolvedRepo} is a read-only mirror of ${mirror.url}. Mirrors carry no lifecycle hooks by design, so there is no routing here to repair.`);
1487
+ }
1488
+ if (!path.isAbsolute(options.command[0])) {
1489
+ throw new Error("knodin repair --lifecycle: packaged runtime command must be an absolute path");
1490
+ }
1491
+ const lifecycleLease = acquireRepairLease(resolvedRepo, "init");
1492
+ try {
1493
+ const knodinHooksDir = path.join(resolvedRepo, ".knodin", "hooks");
1494
+ const hookInstallation = await prepareHookInstallation(resolvedRepo);
1495
+ const hooksDir = hookInstallation.hooksDir;
1496
+ await Promise.all([
1497
+ fs.promises.mkdir(knodinHooksDir, { recursive: true }),
1498
+ fs.promises.mkdir(hooksDir, { recursive: true }),
1499
+ ]);
1500
+ const backgroundPath = path.join(knodinHooksDir, "background-index.sh");
1501
+ await fs.promises.writeFile(backgroundPath, backgroundScript(options.command), {
1502
+ encoding: "utf-8",
1503
+ mode: 0o755,
1504
+ });
1505
+ await fs.promises.chmod(backgroundPath, 0o755);
1506
+ const hookManagerIntegration = installHookManagerIntegration(resolvedRepo);
1507
+ for (const hookName of HOOK_NAMES) {
1508
+ let preservedOriginal;
1509
+ if (hookInstallation.trackedHooksDir) {
1510
+ const relativeHook = `${hookInstallation.trackedHooksDir}/${hookName}`;
1511
+ const sourceHook = path.join(resolvedRepo, relativeHook);
1512
+ try {
1513
+ const stat = await fs.promises.stat(sourceHook);
1514
+ preservedOriginal =
1515
+ stat.isFile() && (stat.mode & 0o111) !== 0 ? `repo:${relativeHook}` : null;
1516
+ }
1517
+ catch {
1518
+ preservedOriginal = null;
1519
+ }
1520
+ }
1521
+ await installManagedHook(hooksDir, hookName, preservedOriginal);
1522
+ }
1523
+ for (const entry of await fs.promises.readdir(hooksDir, { withFileTypes: true })) {
1524
+ if (entry.isFile())
1525
+ await preventLefthookAutoInstall(path.join(hooksDir, entry.name));
1526
+ }
1527
+ const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
1528
+ await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
1529
+ return {
1530
+ backgroundIndexer: ".knodin/hooks/background-index.sh",
1531
+ hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
1532
+ gitHooks: [...HOOK_NAMES],
1533
+ hookManagerIntegration,
1534
+ lifecycleRefresh,
1535
+ configurationChanges: [],
1536
+ };
1537
+ }
1538
+ finally {
1539
+ lifecycleLease.release();
1540
+ }
1541
+ }
1436
1542
  async function matchesManagedFile(filePath, expected, executable) {
1437
1543
  try {
1438
1544
  const [content, stat] = await Promise.all([
@@ -169,6 +169,41 @@ function recordedInterpreterIfMissing(backgroundScriptPath) {
169
169
  // wrong and the next environment without that fallback breaks silently.
170
170
  return recorded;
171
171
  }
172
+ /**
173
+ * The interpreter a pre-fallback generated script pins, when it pins one.
174
+ *
175
+ * Scripts written before the `KNODIN_NODE` fallback (EASFDC-8497) invoke an
176
+ * absolute interpreter path with nothing to fall back to. Homebrew deletes its
177
+ * Cellar directory on upgrade, so `.../Cellar/node/<version>/bin/node`
178
+ * evaporates and every background refresh afterwards dies with exit 127 — in
179
+ * the background, where the only trace is a log file nobody reads. The graph
180
+ * then drifts until someone happens to run `status`. Observed across three
181
+ * repositories on one machine from a single `brew upgrade` (KNODIN-32), so the
182
+ * blast radius is every checkout initialized before the fix, not one.
183
+ *
184
+ * `recordedInterpreterIfMissing` cannot see this: it keys off a `KNODIN_NODE=`
185
+ * line, and these scripts have none, so the most dangerous shape reads as
186
+ * healthy. Detection is the absence of that line in a script that is otherwise
187
+ * recognisably ours.
188
+ *
189
+ * Returns null when the script is current, unreadable, or shaped unfamiliarly.
190
+ * A script we cannot parse is not evidence of a pinned interpreter, and
191
+ * guessing would invent a failure rather than report one.
192
+ */
193
+ function pinnedInterpreterWithoutFallback(backgroundScriptPath) {
194
+ let contents;
195
+ try {
196
+ contents = fs.readFileSync(backgroundScriptPath, "utf8");
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ if (/^KNODIN_NODE=/m.test(contents))
202
+ return null;
203
+ if (!contents.includes("hook-refresh"))
204
+ return null;
205
+ return /'(\/[^']*\/bin\/node)'/.exec(contents)?.[1] ?? null;
206
+ }
172
207
  function readRefreshFailure(repo) {
173
208
  const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
174
209
  if (!fs.existsSync(failurePath))
@@ -253,7 +288,15 @@ export function inspectLifecycleHealth(repoPath) {
253
288
  issues.push("background indexer is missing or not executable");
254
289
  const staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
255
290
  if (staleInterpreter)
256
- issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin init\` to rewrite the hook`);
291
+ issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin repair --lifecycle\` to rewrite the hook`);
292
+ // Reported even while that interpreter still exists. The failure is latent
293
+ // by construction — the script works right up until the next `brew upgrade`
294
+ // deletes the path out from under it, and then fails silently in the
295
+ // background. Waiting for the breakage would mean reporting it only after
296
+ // the graph had already drifted (KNODIN-32).
297
+ const pinnedInterpreter = backgroundReady && !staleInterpreter ? pinnedInterpreterWithoutFallback(background) : null;
298
+ if (pinnedInterpreter)
299
+ issues.push(`background indexer predates the interpreter fallback and pins ${pinnedInterpreter} with no alternative; a Node upgrade will break refresh silently — run \`knodin repair --lifecycle\` to rewrite the hook`);
257
300
  const lastError = readRefreshFailure(repo);
258
301
  if (lastError)
259
302
  issues.push(`${lastError} See .knodin/indexer.log for details`);
@@ -299,7 +342,7 @@ export function attachLifecycleHealth(repo, graph) {
299
342
  },
300
343
  repairSteps: [
301
344
  ...graph.repairSteps,
302
- "Run `knodin init` to restore managed lifecycle hooks.",
345
+ "Run `knodin repair --lifecycle` to restore managed lifecycle hooks without touching agent configuration.",
303
346
  "Run `knodin status` again to verify graph and refresh-path health.",
304
347
  ],
305
348
  lifecycle,
@@ -312,7 +355,7 @@ export function attachRepairLifecycle(repo, result) {
312
355
  ...result,
313
356
  lifecycle,
314
357
  nextSteps: lifecycle.status === "degraded"
315
- ? ["Run `knodin init` to restore lifecycle routing, then `knodin status`."]
358
+ ? ["Run `knodin repair --lifecycle` to restore lifecycle routing, then `knodin status`."]
316
359
  : [],
317
360
  };
318
361
  }
@@ -214,9 +214,17 @@ export class RepositoryWorker extends EventEmitter {
214
214
  if (signal.aborted)
215
215
  throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker dispatch");
216
216
  if (!this.child) {
217
+ // Prune HERE as well as in `exited`. Once this gate throws, no child is
218
+ // spawned, so no exit event ever fires to prune the array — filtering
219
+ // only on exit turned the 60s rate window into a permanent per-repository
220
+ // latch that outlived whatever caused the crashes, and the cached
221
+ // RepositoryWorker is never evicted, so only a gateway restart cleared
222
+ // it (KNODIN-17).
223
+ const startedAt = Date.now();
224
+ this.restartTimes = this.restartTimes.filter((at) => startedAt - at < RESTART_WINDOW_MS);
217
225
  if (this.restartTimes.length >= MAX_RESTARTS)
218
- throw codedError("KNODIN_RESTART_LIMIT", "graph worker restart limit reached");
219
- this.restartTimes.push(Date.now());
226
+ throw codedError("KNODIN_RESTART_LIMIT", `graph worker restarted ${MAX_RESTARTS} times within ${RESTART_WINDOW_MS / 1000}s; it will be retried once that window elapses`);
227
+ this.restartTimes.push(startedAt);
220
228
  }
221
229
  const startup = this.start();
222
230
  const remainingForStartup = Math.max(1, deadlineAt - Date.now());
@@ -23,6 +23,7 @@ export function parseRepairCliArgs(args) {
23
23
  let json = false;
24
24
  let jsonl = false;
25
25
  let plan = false;
26
+ let lifecycle = false;
26
27
  const seen = new Set();
27
28
  const markSeen = (flag) => {
28
29
  if (seen.has(flag))
@@ -42,6 +43,11 @@ export function parseRepairCliArgs(args) {
42
43
  plan = true;
43
44
  continue;
44
45
  }
46
+ if (argument === "--lifecycle") {
47
+ markSeen(argument);
48
+ lifecycle = true;
49
+ continue;
50
+ }
45
51
  if (argument === "--json" || argument === "--jsonl") {
46
52
  markSeen(argument);
47
53
  if (argument === "--json")
@@ -86,12 +92,18 @@ export function parseRepairCliArgs(args) {
86
92
  }
87
93
  if (json && jsonl)
88
94
  throw new Error("knodin repair: --json and --jsonl are mutually exclusive");
95
+ // `--plan` explains what a graph repair would do; a lifecycle repair does not
96
+ // touch the graph, so there is nothing for it to explain. Refusing beats
97
+ // printing a graph plan for a run that would not carry it out.
98
+ if (plan && lifecycle)
99
+ throw new Error("knodin repair: --plan and --lifecycle are mutually exclusive");
89
100
  return {
90
101
  progress,
91
102
  progressExplicit,
92
103
  progressIntervalMs,
93
104
  output: jsonl ? "jsonl" : json ? "json" : "human",
94
105
  plan,
106
+ lifecycle,
95
107
  };
96
108
  }
97
109
  export function createRepairPlan(health) {
@@ -169,6 +169,55 @@ function directFile(repo, filePath) {
169
169
  symbols: directSymbols(filePath, content),
170
170
  };
171
171
  }
172
+ function directoryOf(filePath) {
173
+ const slash = filePath.lastIndexOf("/");
174
+ return slash === -1 ? "." : filePath.slice(0, slash);
175
+ }
176
+ function extensionOf(name) {
177
+ const dot = name.lastIndexOf(".");
178
+ return dot > 0 ? name.slice(dot) : null;
179
+ }
180
+ /**
181
+ * True when every directory the aggregate covers holds only files the snapshot
182
+ * already knows about.
183
+ *
184
+ * The indexable extensions are taken from the snapshot's own file list rather
185
+ * than hardcoded, so this stays calibrated to whatever the walker actually
186
+ * indexed. Membership is tested against the WHOLE snapshot, not the filtered
187
+ * subset, so a sibling that is indexed but outside the caller's target does not
188
+ * read as an omission.
189
+ */
190
+ function snapshotCoversDirectories(repo, snapshot, covered) {
191
+ const indexed = new Set((snapshot?.files ?? []).map((file) => file.path));
192
+ if (indexed.size === 0)
193
+ return false;
194
+ const extensions = new Set();
195
+ for (const known of indexed) {
196
+ const extension = extensionOf(known.slice(known.lastIndexOf("/") + 1));
197
+ if (extension)
198
+ extensions.add(extension);
199
+ }
200
+ for (const directory of new Set(covered.map((file) => directoryOf(file.path)))) {
201
+ let entries;
202
+ try {
203
+ entries = fs.readdirSync(path.resolve(repo, directory), { withFileTypes: true });
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ for (const entry of entries) {
209
+ if (!entry.isFile())
210
+ continue;
211
+ const extension = extensionOf(entry.name);
212
+ if (!extension || !extensions.has(extension))
213
+ continue;
214
+ const relative = directory === "." ? entry.name : `${directory}/${entry.name}`;
215
+ if (!indexed.has(relative))
216
+ return false;
217
+ }
218
+ }
219
+ return true;
220
+ }
172
221
  export function runStructuralFastPath(repo, pattern, target, limit = 100) {
173
222
  const snapshot = readStructuralSnapshot(repo);
174
223
  if (!snapshot && pattern !== "file_summary")
@@ -195,7 +244,7 @@ export function runStructuralFastPath(repo, pattern, target, limit = 100) {
195
244
  let fingerprint;
196
245
  let fresh = false;
197
246
  if (pattern === "project_overview") {
198
- fresh = matching.every((file) => {
247
+ const unchanged = matching.every((file) => {
199
248
  try {
200
249
  const stat = fs.statSync(path.resolve(repo, file.path));
201
250
  return stat.size === file.sizeBytes && stat.mtimeMs === file.mtimeMs;
@@ -204,6 +253,20 @@ export function runStructuralFastPath(repo, pattern, target, limit = 100) {
204
253
  return false;
205
254
  }
206
255
  });
256
+ // A size+mtime sweep can only ever prove that the files the snapshot
257
+ // ALREADY knows about are unchanged. A file added since the snapshot is not
258
+ // in that set, so it was never checked, never counted, and never mentioned
259
+ // — while the overview still reported itself "fresh" (KNODIN-25). So also
260
+ // read back the directories being aggregated and look for an indexable
261
+ // sibling the snapshot has never seen.
262
+ //
263
+ // Known bound: this sees additions to directories the snapshot already
264
+ // covers, which is where source files are overwhelmingly added. A brand new
265
+ // top-level directory is still missed, because recognising one would mean
266
+ // re-deriving the walker's prune rules here and re-walking the tree, which
267
+ // is the cost this fast path exists to avoid. `upgrade` already points at
268
+ // the full `architecture_overview` query for callers that need certainty.
269
+ fresh = unchanged && snapshotCoversDirectories(repo, snapshot, matching);
207
270
  const directories = new Map();
208
271
  for (const file of matching) {
209
272
  const directory = file.path.includes("/") ? file.path.split("/", 1)[0] : ".";
@@ -70,10 +70,8 @@ export async function closeKnodinToolEngine() {
70
70
  if (initializedEngine)
71
71
  await initializedEngine.close();
72
72
  initializedEngine = null;
73
- compactReadyRepos.clear();
74
73
  }
75
74
  const localTelemetry = [];
76
- const compactReadyRepos = new Set();
77
75
  let cachedSchemaTokens;
78
76
  function gatewaySchemaTokens() {
79
77
  cachedSchemaTokens ??= countOutputTokens(JSON.stringify(buildDocumentedKnodinTools()[0]?.inputSchema ?? {}));
@@ -82,15 +80,24 @@ function gatewaySchemaTokens() {
82
80
  function inspectGatewayGraphHealth(repo) {
83
81
  return inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })));
84
82
  }
83
+ /**
84
+ * Probe on EVERY compact call, exactly as the non-compact path does.
85
+ *
86
+ * This used to latch a repository as ready after one successful probe and never
87
+ * look again for the life of the process. Nothing invalidated that latch —
88
+ * `closeKnodinToolEngine` clears it but has no production caller — so once
89
+ * `.knodin/` was deleted or re-inited from another terminal, a long-lived MCP
90
+ * server kept answering compact queries against a database the open path had
91
+ * silently recreated as an empty schema (`CREATE TABLE IF NOT EXISTS`). The
92
+ * result was "no matches": indistinguishable from a true negative, and the exact
93
+ * confident-but-wrong answer this product exists to avoid (KNODIN-18).
94
+ *
95
+ * The probe is cheap — `engine.status(..., { audit: "cached" })` — so the latch
96
+ * was buying very little in exchange for that failure mode.
97
+ */
85
98
  async function compactGraphUnavailable(repo) {
86
- const key = nodePath.resolve(repo);
87
- if (compactReadyRepos.has(key))
88
- return null;
89
99
  const health = await inspectGatewayGraphHealth(repo);
90
- if (!health.available)
91
- return health;
92
- compactReadyRepos.add(key);
93
- return null;
100
+ return health.available ? null : health;
94
101
  }
95
102
  /** Runs `fn`, converting any thrown error into a structured `{ error }` result
96
103
  * instead of letting it escape into the stdio transport. Extracted from the
@@ -1045,6 +1052,22 @@ function validateKnodinArgs(args) {
1045
1052
  if (args.client !== undefined &&
1046
1053
  !["claude", "codex", "gemini", "copilot", "antigravity"].includes(args.client))
1047
1054
  throw new Error("knodin: invalid client");
1055
+ // `auditRange`/`auditBase`/`auditHead` select a range of pull requests for
1056
+ // `prs`, and nothing else consumes them. `review` used to accept them and
1057
+ // silently review HEAD~1..HEAD instead, returning `base: "HEAD~1"` under a
1058
+ // full `staleness: fresh` envelope — so it did not merely fail to answer the
1059
+ // question asked, it confidently answered a different one, and a caller who
1060
+ // did not inspect `base` would read the result as authoritative for their
1061
+ // range (KNODIN-27). An unused-parameter error is always better than that.
1062
+ //
1063
+ // The review equivalent is `diffScope: "compare"` with `from`/`toRevision`,
1064
+ // which is what the CLI's `--scope compare --from X --to Y` maps to.
1065
+ const auditOnly = ["auditRange", "auditBase", "auditHead"].filter((name) => args[name] !== undefined);
1066
+ if (auditOnly.length > 0 && args.operation !== "prs")
1067
+ throw new Error(`knodin ${args.operation}: ${auditOnly.join(", ")} ${auditOnly.length === 1 ? "is" : "are"} accepted only by \`prs\`` +
1068
+ (args.operation === "review"
1069
+ ? '; to review a revision range pass diffScope: "compare" with `from` and `toRevision`'
1070
+ : ""));
1048
1071
  }
1049
1072
  function compactExplainSource(result) {
1050
1073
  const source = result.source
@@ -0,0 +1,227 @@
1
+ # knodin 0.11.0
2
+
3
+ Nine defects from one adversarial review of `main`, plus the two things that
4
+ review's own fixes exposed. The theme running through most of them is the
5
+ failure this tool exists to prevent: an answer that is wrong and looks fine.
6
+
7
+ ## A compact answer could report "no matches" from a graph that was gone
8
+
9
+ `compactGraphUnavailable` cached a repository as ready after one successful
10
+ probe and never looked again for the life of the process. Nothing invalidated
11
+ that latch — `closeKnodinToolEngine` clears it but has no production caller — so
12
+ in a long-lived MCP server, deleting or re-initialising `.knodin` from another
13
+ terminal left compact operations answering from a database the open path had
14
+ silently recreated as an empty schema. `CREATE TABLE IF NOT EXISTS` does not
15
+ error; it just produces a graph with nothing in it.
16
+
17
+ The result was "no matches", which is indistinguishable from a true negative.
18
+ The non-compact path had it right all along: it re-probes before **and** after
19
+ every query. Compact now does the same. The probe is
20
+ `engine.status(..., { audit: "cached" })`, so the latch was buying very little
21
+ in exchange for that failure mode.
22
+
23
+ Two other things pointed the same direction and are fixed with it. The compact
24
+ renderer defaulted a **missing** staleness to `fresh`, the opposite of the
25
+ engine's own `stalenessFor()` default of `unknown` — and because search stamps
26
+ staleness per row while `SearchPage` carries no page-level field, a zero-row
27
+ page had nothing to read it from. An empty compact search on a stale or
28
+ unprobed graph therefore rendered as `fresh 0/0`: an authoritative-looking
29
+ negative assembled entirely from absent evidence. Missing evidence is now
30
+ `unknown`.
31
+
32
+ And the CLI's `project_overview` fast path reported `fresh` on the strength of a
33
+ size-and-mtime sweep over the files the snapshot **already knew about**. A file
34
+ added since the snapshot is not in that set, so it was never checked, never
35
+ counted, and never mentioned — while the overview still called itself fresh. It
36
+ now also reads back the directories it aggregates and looks for an indexable
37
+ sibling the snapshot has never seen, with the indexable extensions derived from
38
+ the snapshot's own file list rather than hardcoded.
39
+
40
+ That last check has a bound worth stating: it sees additions to directories the
41
+ snapshot already covers, which is where source files are overwhelmingly added,
42
+ but a brand-new top-level directory is still missed. Recognising one would mean
43
+ re-deriving the walker's prune rules and re-walking the tree, which is the cost
44
+ this fast path exists to avoid. The result's `upgrade` pointer already routes a
45
+ caller who needs certainty to the full `architecture_overview` query.
46
+
47
+ ## The MCP worker restart limit was a permanent latch
48
+
49
+ `MAX_RESTARTS` is meant to be a rate — three crashes inside a sixty-second
50
+ window. It was not. `restartTimes` was pruned only in the child-exit handler,
51
+ and once the gate threw `KNODIN_RESTART_LIMIT` no child was ever spawned, so no
52
+ exit event could ever fire to prune it. The window never elapsed.
53
+
54
+ Three transient crashes — an OOM under load, a full disk, a corrupt database
55
+ later repaired — therefore killed that repository for the life of the gateway
56
+ process. The `RepositoryWorker` is cached per repository and never evicted, so
57
+ nothing short of restarting the whole server cleared it, and the error message
58
+ never said so. The gate now prunes on the way in, and names the window.
59
+
60
+ ## One crash killed a parse-pool lane for the whole run
61
+
62
+ `PoolWorkerHandle` has no listener removal, so a replaced worker's listeners
63
+ stay live — and they closed over the *slot*, whose `.handle` is by then the
64
+ replacement. A crashed worker's inevitable late `exit` re-entered
65
+ `onSlotFailure` and terminated its own replacement, cancelling the one
66
+ documented retry and leaving the lane dead. On a single-worker pool the whole
67
+ run degraded to sequential inline parsing; if the replacement's response landed
68
+ first, the late exit instead killed an unrelated in-flight file.
69
+
70
+ Listeners are now bound to the handle they were registered on, mirroring the
71
+ `pending.child !== child` guard the MCP supervisor already carried.
72
+
73
+ Worth recording how this survived review: `FakeWorker` in `parse-pool.spec.ts`
74
+ never emitted `exit` — not after an error, not on `terminate()`. The pool never
75
+ saw the second event, so the bug had no way to appear. With realistic process
76
+ semantics restored, removing the new guard fails the new test **and** the
77
+ pre-existing "retries a crashed task once" test. That retry path was dead code
78
+ against real workers, and had been asserted as working.
79
+
80
+ ## Secrets spelled the way secrets are actually spelled
81
+
82
+ The shared redaction pattern began with `\b`, which cannot match between `_` and
83
+ a letter. So it caught a bare `password=` and let through
84
+ `AWS_SECRET_ACCESS_KEY=`, `DB_PASSWORD=`, `NPM_TOKEN=`, and every `*_SECRET=` —
85
+ the dominant real-world spelling, and the one that appears in a `.env` line
86
+ quoted into an error message or a CI log dump. Both disclosure paths, the
87
+ diagnostics bundle and the compressed output artifact, drew on that one pattern,
88
+ so both leaked.
89
+
90
+ The keyword now takes a `[\w-]*` prefix and a `[_-]`-led suffix, so the whole
91
+ identifier matches and the name is preserved while the value is not. The suffix
92
+ is `[_-]`-led rather than a bare `[\w-]*` deliberately: knodin echoes source
93
+ into both artifacts, and an unanchored tail redacted the value of a plain
94
+ `const tokenCount = 5`.
95
+
96
+ Not added, deliberately: a standalone value-shape pattern for AWS secret keys.
97
+ Those are `[A-Za-z0-9/+]{40}`, which also matches every 40-character git SHA and
98
+ any similar-length base64 blob. A context-free pattern would redact commit
99
+ hashes throughout diagnostics, and every realistic carrier of that value — an
100
+ environment variable, a credentials file — is name-prefixed and now covered.
101
+
102
+ ## A sealed artifact could serve source for a checkout it does not describe
103
+
104
+ Sealed artifacts are designed to be shared across machines, which makes an
105
+ artifact untrusted input. Its attestation was read with a bare
106
+ `JSON.parse(...) as SealAttestation` — an unchecked cast — and the
107
+ self-declared `repository.path` was registered into a process-global map that
108
+ every engine source read consults **before** the filesystem. A crafted artifact
109
+ naming a victim's real checkout path could therefore serve its own bytes to a
110
+ concurrent live query on that repository.
111
+
112
+ The attestation is now validated as a whole shape, and a database that
113
+ misrepresents itself raises a typed error rather than being quietly downgraded
114
+ to "not a sealed artifact". The attested path is refused on **content
115
+ disagreement** rather than on mere existence: sealing and then querying on the
116
+ same checkout is a real flow that an existence check would break, so instead
117
+ every embedded file that also exists under the attested directory is compared
118
+ against the hash recorded at seal time. Files absent there are not disagreement
119
+ — a removed checkout is exactly what an artifact is for.
120
+
121
+ Identity and `sealedCommit` are deliberately **not** used for this. Both are
122
+ attacker-supplied strings, and an attacker targeting a specific victim already
123
+ knows their `owner/name`. Content hashes are the only fingerprint in the
124
+ attestation an attacker cannot satisfy while still doing harm: to poison a live
125
+ checkout the embedded bytes must differ from the real files, which is precisely
126
+ what trips the gate.
127
+
128
+ Registrations are also refcounted now. Two artifacts attesting the same path
129
+ used to have the first `close()` unregister the other's source, leaving it
130
+ reading `""`.
131
+
132
+ ## A repo-wide text search held the whole working tree in memory
133
+
134
+ `searchRepoText` read every readable file's full content into one array before
135
+ searching any of it, with no size cap, and the binary NUL-sniff happened *after*
136
+ the read rather than before. The walk deliberately skips the source-extension
137
+ filter, so lockfiles, NDJSON dumps, fixtures and generated JSON all came in
138
+ whole. On a tree carrying gigabytes of non-pruned text, one call drove RSS to
139
+ the total text size. The response budget could not help: it applies to a result
140
+ object that by then already exists.
141
+
142
+ Files are now stat-checked and skipped above 4 MiB — reported through the same
143
+ `uncovered` list, never dropped — and searched one at a time, so at most one
144
+ file's content is live. Matches are capped at 5,000 with a new `truncated` flag,
145
+ documented as meaning the remaining files were **not** searched, so an absent
146
+ file proves nothing.
147
+
148
+ ## The background refresh script deleted locks it did not own
149
+
150
+ The generated `background-index.sh` trapped `HUP`/`INT`/`TERM` with a handler
151
+ that removed the lock directory and did not exit. POSIX `sh` defers a trap until
152
+ the foreground command finishes and then resumes where it left off — so when
153
+ `init` sent `SIGTERM` at its thirty-second drain timeout, the script freed the
154
+ lock and **kept draining**. The next commit's instance acquired the now-free
155
+ lock, and the first script's eventual `EXIT` trap deleted its successor's lock
156
+ unconditionally, because that `rm -rf` had no ownership check. Stale-lock
157
+ takeover was also `rm -rf` followed by `mkdir`, which two processes observing
158
+ the same dead pid could both complete.
159
+
160
+ Release is now guarded on the recorded pid, each signal exits explicitly, and
161
+ takeover claims the stale lock by renaming it, which only one racer can win. The
162
+ inner repair lease kept this away from database corruption; what it produced was
163
+ spurious failure markers, retained events, and `Ctrl-C` failing to cancel a
164
+ foreground run.
165
+
166
+ ## Each commit is now keyed to its own Jira
167
+
168
+ `scripts/jira-work-guard.sh` required every non-merge commit to carry the branch
169
+ name's **first** Jira key, which made a branch equivalent to exactly one issue.
170
+ Work spanning several tickets then had two options and both lose something: land
171
+ it as one commit labelled with a ticket covering a fraction of it, or split it
172
+ into one branch per ticket and multiply CI runs for a single coherent change.
173
+ This release hit exactly that — nine fixes from one review.
174
+
175
+ A branch is a container for work. It still has to be Jira-backed, but the
176
+ per-issue claim now belongs to each commit, which is the more durable record
177
+ anyway: branch names are deleted after merge and commit subjects are not.
178
+ `pre-push` also names the offending commit's short SHA now, because a push
179
+ carries many commits and "commit message has no JIRA key" left the author
180
+ bisecting their own branch.
181
+
182
+ The trade-off is real and worth naming: this drops the check that caught a
183
+ commit keyed to an unrelated project — a typo, or a message copied from another
184
+ branch. Restoring it would mean validating keys against Jira, which this guard
185
+ deliberately does not do. It is a local hook with no network.
186
+
187
+ ## Timing budgets in the test suite were measuring the scheduler
188
+
189
+ Found because this release's own two new spec files made the suite fail, twice,
190
+ on two different tests that had nothing to do with the changes.
191
+
192
+ Eleven specs independently hard-coded a fifteen-second budget for a spawned
193
+ child process. Shard 12 also holds `behavior-contract.spec.ts`, which runs
194
+ CPU-bound for about 380 seconds — 85% of that shard's wall time. A child spawned
195
+ beside it is starved, not slow: a CLI spawn costs about 0.4s idle and a progress
196
+ worker about 1.7s, so fifteen seconds looks like a 40x margin right up until
197
+ contention eats it whole.
198
+
199
+ Which spec pays is decided by where vitest queues its file, and that moves
200
+ whenever any spec file is added anywhere in the repository. Measured on an
201
+ uninstrumented `npm test`, same host, identical shard membership: the progress
202
+ worker started in 1.7s queued at t=28s, and blew the whole fifteen seconds
203
+ queued at t=408s. The shard's total wall time was unchanged (454s against 448s
204
+ before the change) and `behavior-contract` was in fact 8s faster, so this was
205
+ never added load — separately confirmed by measuring CLI spawn cost directly at
206
+ 367–468ms against 374–571ms before.
207
+
208
+ `SUBPROCESS_BUDGET_MS` now lives once, in the support helper those specs already
209
+ import, carrying that measurement. It is unconditional: two specs had already
210
+ been widened only under `KNODIN_COVERAGE_SHARD`, on the theory that
211
+ instrumentation causes the contention. It does not — the sixteen concurrent
212
+ shards do, in uninstrumented runs too — and gating on that is why this kept
213
+ being rediscovered one file at a time. The budget is an upper bound, not a
214
+ sleep, so it costs nothing in the passing case and no assertion is weakened.
215
+
216
+ One thing this does not fix, recorded so it is not rediscovered: a single spec
217
+ at ~380 seconds sets the critical path for the entire gate and is what starves
218
+ everything scheduled beside it. Widening budgets treats the symptom.
219
+
220
+ ## Verification
221
+
222
+ `npm test` (sixteen shards, 2,088 tests), `npm run lint`, `npm run typecheck`,
223
+ `npm run check:hygiene`, and `npm run test:pack-install` pass. Every fix above
224
+ is pinned by a test, including two new specs —
225
+ `background-refresh-lock.spec.ts` and `compact-graph-availability.spec.ts` — and
226
+ new cases in the credential-parity, supervisor, parse-pool, structural-fast-path,
227
+ sealed-open, text-matches and jira-work-guard specs.