knodin 0.9.0 → 0.10.1

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.
@@ -16,8 +16,9 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../contex
16
16
  import { collectDiagnostics, persistDiagnosticsPreview } from "../diagnostics.js";
17
17
  import { getDocSection, listDocTopics } from "../docs-sections.js";
18
18
  import { diagnoseInstallation } from "../doctor.js";
19
- import { createEngine, REPO_WIDE_QUERY_PATTERNS, } from "../engine/index.js";
19
+ import { createEngine, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../engine/index.js";
20
20
  import { measurePerfPhaseSync } from "../engine/perf.js";
21
+ import { runSealedQuery } from "../engine/sealed-query.js";
21
22
  import { resolveDbPath } from "../engine/state-paths.js";
22
23
  import { runExecutionProfile } from "../execution-profile.js";
23
24
  import { diagnoseFailure, } from "../failure-diagnosis.js";
@@ -41,6 +42,7 @@ import { applySystemPlan, planSystemImport, planSystemLink, planSystemRelate, pl
41
42
  import { trustedUpdateStatus } from "../update-policy.js";
42
43
  import { waitForFresh } from "../wait-for-fresh.js";
43
44
  import { inspectWorktrees, reconcileWorktrees, removeManagedWorktree, } from "../worktree-lifecycle.js";
45
+ import { indexOrSeed } from "../worktree-seed.js";
44
46
  let initializedEngine = null;
45
47
  function gatewayEngine() {
46
48
  initializedEngine ??= createEngine();
@@ -200,8 +202,9 @@ function buildDocumentedKnodinTools() {
200
202
  "system",
201
203
  "telemetry",
202
204
  "diagnostics",
205
+ "sealed",
203
206
  ],
204
- description: "Which knodin capability to run. context: call this FIRST when starting any investigation and unsure which operation to reach for — one ultra-compact orientation (repo stats + top subsystems/hubs/flows + a risk score if there's a diff + a heuristic next-operation suggestion); the suggestion is only a hint and never blocks calling any operation directly. explain: use when orienting on a symbol/file or before editing it — returns edit-ready source + call paths + blast radius. review: use before writing a PR description or approving a diff — risk-scored context (changed symbols, affected flows, test gaps). map: use before a cross-cutting refactor or to understand subsystem boundaries — communities + hub/bridge nodes + confidence-tagged edges. search: use when you don't know the exact symbol name — hybrid semantic + keyword lookup over code symbols. query: use for a structured question about a known symbol — callers_of, tests_for, shortest_path, dead_code, rename_preview, flows, and more (see `pattern`). pack: create deterministic Markdown/JSON/XML source context under hard budgets, or bounded-read/exact-regex-grep a saved artifact. compress: reduce already-produced diagnostic text under exact line/content-byte budgets, preserve exit metadata and detected signals, retain private local drill-down data by default, and never label insufficient-fidelity output complete. execute: run one immutable repository-defined profile only when independently enabled globally and locally; no executable or argv is accepted from the caller, unsupported containment fails closed, and output is compressed then diagnosed. prs: use to triage open GitHub PRs (via your local authenticated `gh`) — per-PR status, CI, and blast radius sorted ready-small-impact first; pass `prNumber` for one PR's impacted files + community names. wiki: write a static markdown documentation site for the repository's logical subsystems to `.knodin/wiki/`. Generates an index plus one page per mapped community. Reuses the `map` output. Idempotent: unchanged pages are untouched on disk unless `force` is true. docs: call this to retrieve curated, focused markdown usage guidance directly over MCP. remote: list read-only mirrors of repositories that are reachable but not checked out locally, so you can run explain/query/map/search against one by passing its `path` as `repoPath`. Each mirror is a snapshot pinned at a commit; the remote is not watched. Acquiring, refreshing, and removing mirrors is CLI-only (`knodin remote add|refresh|remove`) because it clones another repository's source onto this machine and consumes disk nothing reclaims automatically — the user's decision to make, not an agent's.",
207
+ description: "Which knodin capability to run. context: call this FIRST when starting any investigation and unsure which operation to reach for — one ultra-compact orientation (repo stats + top subsystems/hubs/flows + a risk score if there's a diff + a heuristic next-operation suggestion); the suggestion is only a hint and never blocks calling any operation directly. explain: use when orienting on a symbol/file or before editing it — returns edit-ready source + call paths + blast radius. review: use before writing a PR description or approving a diff — risk-scored context (changed symbols, affected flows, test gaps). map: use before a cross-cutting refactor or to understand subsystem boundaries — communities + hub/bridge nodes + confidence-tagged edges. search: use when you don't know the exact symbol name — hybrid semantic + keyword lookup over code symbols. query: use for a structured question about a known symbol — callers_of, tests_for, shortest_path, dead_code, rename_preview, flows, and more (see `pattern`). pack: create deterministic Markdown/JSON/XML source context under hard budgets, or bounded-read/exact-regex-grep a saved artifact. compress: reduce already-produced diagnostic text under exact line/content-byte budgets, preserve exit metadata and detected signals, retain private local drill-down data by default, and never label insufficient-fidelity output complete. execute: run one immutable repository-defined profile only when independently enabled globally and locally; no executable or argv is accepted from the caller, unsupported containment fails closed, and output is compressed then diagnosed. prs: use to triage open GitHub PRs (via your local authenticated `gh`) — per-PR status, CI, and blast radius sorted ready-small-impact first; pass `prNumber` for one PR's impacted files + community names. wiki: write a static markdown documentation site for the repository's logical subsystems to `.knodin/wiki/`. Generates an index plus one page per mapped community. Reuses the `map` output. Idempotent: unchanged pages are untouched on disk unless `force` is true. docs: call this to retrieve curated, focused markdown usage guidance directly over MCP. remote: list read-only mirrors of repositories that are reachable but not checked out locally, so you can run explain/query/map/search against one by passing its `path` as `repoPath`. Each mirror is a snapshot pinned at a commit; the remote is not watched. Acquiring, refreshing, and removing mirrors is CLI-only (`knodin remote add|refresh|remove`) because it clones another repository's source onto this machine and consumes disk nothing reclaims automatically — the user's decision to make, not an agent's. sealed: query a sealed artifact (`artifactPath`) with NO checkout — source is embedded in it. Returns attested identity, commit, ref, age, coverage, and a `degraded` list of what it cannot do; pass `symbol` to explain. Describes the attested commit and no later one, so staleness is `unknown`, never `fresh`. Creating one is CLI-only (`knodin seal`).",
205
208
  },
206
209
  profile: {
207
210
  type: "string",
@@ -821,7 +824,7 @@ async function handleRepositoriesOperation(options) {
821
824
  const memoryLimitBytes = configuredRepositoryInitMemoryLimitBytes(systemConfig);
822
825
  const summary = await initializeRepositories(discoveryRoots, {
823
826
  command,
824
- index: (target) => engine.index(target),
827
+ index: indexOrSeed(engine, KNODIN_SCHEMA_VERSION),
825
828
  status: (target) => engine.status(target),
826
829
  depth,
827
830
  worktrees: linkedWorktrees,
@@ -1054,6 +1057,7 @@ function compactExplainSource(result) {
1054
1057
  identity: result.identity,
1055
1058
  symbol: result.symbol,
1056
1059
  source,
1060
+ sourceAvailability: result.sourceAvailability,
1057
1061
  staleness: result.staleness,
1058
1062
  };
1059
1063
  }
@@ -1595,6 +1599,15 @@ async function dispatchKnodinTool(args) {
1595
1599
  note: "Each mirror is a snapshot pinned at `snapshot`; the remote is not watched, so results describe that commit and no later one.",
1596
1600
  }, "remote:list");
1597
1601
  }
1602
+ case "sealed": {
1603
+ // Read-only, and safe over MCP for the same reason `remote:list` is:
1604
+ // it acquires nothing and writes nothing. It opens an artifact the
1605
+ // caller already has and answers from bytes embedded inside it.
1606
+ if (!artifactPath)
1607
+ throw new Error("knodin sealed requires `artifactPath`");
1608
+ const sealedResult = await runSealedQuery(artifactPath, symbol);
1609
+ return bounded(sealedResult, "sealed:query");
1610
+ }
1598
1611
  case "repositories": {
1599
1612
  if (!repositoryAction)
1600
1613
  throw new Error("knodin repositories requires `repositoryAction`");
@@ -203,19 +203,28 @@ export async function executeManagerUpdate(options) {
203
203
  lock.release();
204
204
  }
205
205
  }
206
- function installedRuntimeVersion(command, env) {
206
+ /** How long the `--version` probe may take. Overridable so tests are not bound to wall clock. */
207
+ export const DEFAULT_VERSION_PROBE_TIMEOUT_MS = 10_000;
208
+ function installedRuntimeVersion(command, env, timeoutMs = DEFAULT_VERSION_PROBE_TIMEOUT_MS) {
207
209
  const [executable, ...prefix] = command;
208
210
  if (!executable)
209
- return null;
211
+ return { kind: "unreadable", reason: "no runtime command" };
210
212
  const result = spawnSync(executable, [...prefix, "--version"], {
211
213
  encoding: "utf8",
212
214
  env,
213
215
  stdio: ["ignore", "pipe", "pipe"],
214
- timeout: 10_000,
216
+ timeout: timeoutMs,
215
217
  });
216
- return result.status === 0 && /^\d+\.\d+\.\d+/.test(result.stdout.trim())
217
- ? result.stdout.trim()
218
- : null;
218
+ if (result.error)
219
+ return { kind: "unreadable", reason: String(result.error) };
220
+ if (result.signal)
221
+ return { kind: "unreadable", reason: `killed by ${result.signal}` };
222
+ if (result.status !== 0)
223
+ return { kind: "unreadable", reason: `exited with status ${result.status}` };
224
+ const output = result.stdout?.trim() ?? "";
225
+ return /^\d+\.\d+\.\d+/.test(output)
226
+ ? { kind: "version", version: output }
227
+ : { kind: "unrecognized", output };
219
228
  }
220
229
  /** Resume an interrupted installation from its fsynced journal before ordinary dispatch. */
221
230
  export async function recoverInterruptedUpdate(options) {
@@ -248,16 +257,26 @@ export async function recoverInterruptedUpdate(options) {
248
257
  }));
249
258
  try {
250
259
  if (journal.state === "manager-applying") {
251
- const active = installedRuntimeVersion(options.runtimeCommand, options.env);
260
+ const probe = installedRuntimeVersion(options.runtimeCommand, options.env, options.versionProbeTimeoutMs);
261
+ const active = probe.kind === "version" ? probe.version : null;
262
+ // Say which of the three actually happened. "active version unknown"
263
+ // implied a value was read; a probe that timed out or could not be
264
+ // executed never got that far, and an operator reading this journal
265
+ // needs to know whether the install is wrong or merely unreadable.
266
+ const describe = () => {
267
+ if (probe.kind === "unreadable")
268
+ return `installed version could not be read: ${probe.reason}`;
269
+ if (probe.kind === "unrecognized")
270
+ return `installed runtime reported an unrecognized version ${JSON.stringify(probe.output)}`;
271
+ return `manager application was interrupted with active version ${probe.version}`;
272
+ };
252
273
  journal = transitionUpdateJournal(journal, active === journal.targetVersion
253
274
  ? "candidate-installed-unverified"
254
275
  : active === journal.previousVersion
255
276
  ? "rollback-installed-unverified"
256
277
  : "rollback-required", active && [journal.targetVersion, journal.previousVersion].includes(active)
257
278
  ? {}
258
- : {
259
- error: `manager application was interrupted with active version ${active ?? "unknown"}`,
260
- }, options.stateHome);
279
+ : { error: describe() }, options.stateHome);
261
280
  }
262
281
  if (["candidate-installed-unverified", "candidate-verifying"].includes(journal.state)) {
263
282
  journal = transitionUpdateJournal(journal, "candidate-verifying", {}, options.stateHome);
@@ -0,0 +1,172 @@
1
+ import childProcess from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { Database } from "./engine/sqlite.js";
5
+ import { resolveDbPath } from "./engine/state-paths.js";
6
+ import { deriveSharedOverlay } from "./shared-index/overlay.js";
7
+ import { commitRelation } from "./shared-index/selection.js";
8
+ import { KNODIN_VERSION } from "./version.js";
9
+ import { inspectWorktrees } from "./worktree-lifecycle.js";
10
+ const OPT_OUT_ENV = "KNODIN_DISABLE_WORKTREE_SEED";
11
+ function readHead(repoPath) {
12
+ try {
13
+ return childProcess
14
+ .execFileSync("git", ["rev-parse", "HEAD"], {
15
+ cwd: repoPath,
16
+ encoding: "utf8",
17
+ stdio: ["ignore", "pipe", "ignore"],
18
+ })
19
+ .trim();
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Opens `dbPath` read-only and confirms schema + build compatibility with this process. */
26
+ function siblingIsCompatible(dbPath, schemaVersion) {
27
+ let db = null;
28
+ try {
29
+ db = new Database(dbPath, { readonly: true });
30
+ const version = db.query("PRAGMA user_version").get()?.user_version ?? 0;
31
+ if (version !== schemaVersion)
32
+ return null;
33
+ const versionRow = db
34
+ .query("SELECT value FROM meta WHERE key = ?")
35
+ .get("knodinVersion");
36
+ if (!versionRow || versionRow.value !== KNODIN_VERSION)
37
+ return null;
38
+ const headRow = db
39
+ .query("SELECT value FROM meta WHERE key = ?")
40
+ .get("lastIndexedHead");
41
+ return headRow?.value || null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ finally {
47
+ db?.close();
48
+ }
49
+ }
50
+ /**
51
+ * Finds the cheapest-to-reconcile already-indexed sibling worktree of `repoPath`,
52
+ * or `null` when no eligible sibling exists (single-worktree repo, no healthy
53
+ * siblings, or none pass the schema/build compatibility gate).
54
+ */
55
+ export async function selectSeedSibling(repoPath, schemaVersion, status) {
56
+ const resolved = path.resolve(repoPath);
57
+ const head = readHead(resolved);
58
+ if (!head)
59
+ return null;
60
+ let worktrees;
61
+ try {
62
+ worktrees = (await inspectWorktrees(resolved, status)).worktrees;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ if (worktrees.length < 2)
68
+ return null;
69
+ const eligible = worktrees.filter((worktree) => path.resolve(worktree.path) !== resolved &&
70
+ worktree.graphInitialized &&
71
+ worktree.freshness !== "removed");
72
+ const ranked = eligible
73
+ .map((worktree) => {
74
+ const dbPath = resolveDbPath(worktree.path);
75
+ const indexedHead = siblingIsCompatible(dbPath, schemaVersion);
76
+ if (!indexedHead)
77
+ return null;
78
+ const relation = commitRelation(resolved, head, indexedHead);
79
+ const distance = relation ? relation.distance : Number.MAX_SAFE_INTEGER;
80
+ return { worktree, dbPath, indexedHead, distance };
81
+ })
82
+ .filter((entry) => entry !== null)
83
+ .sort((left, right) => left.distance - right.distance);
84
+ const best = ranked[0];
85
+ if (!best)
86
+ return null;
87
+ return {
88
+ siblingPath: best.worktree.path,
89
+ siblingDbPath: best.dbPath,
90
+ siblingIndexedHead: best.indexedHead,
91
+ };
92
+ }
93
+ /**
94
+ * Seeds a brand-new worktree's graph database from an already-indexed sibling's
95
+ * clean baseline (OS-level copy-on-write clone where supported) and reconciles it
96
+ * to this worktree's actual HEAD/working tree, instead of building from scratch.
97
+ */
98
+ export async function seedWorktreeIndex(repoPath, engine, schemaVersion) {
99
+ const resolved = path.resolve(repoPath);
100
+ const sibling = await selectSeedSibling(resolved, schemaVersion, (p) => engine.status(p));
101
+ if (!sibling)
102
+ return { seeded: false, reason: "no eligible sibling worktree" };
103
+ let candidate;
104
+ try {
105
+ candidate = await engine.createCandidate(resolved, {
106
+ sourceDatabasePath: sibling.siblingDbPath,
107
+ sourceLabel: sibling.siblingPath,
108
+ });
109
+ const overlay = deriveSharedOverlay(resolved, sibling.siblingIndexedHead);
110
+ const reconciliation = await engine.reconcileCandidate(candidate, resolved, overlay.changedPaths);
111
+ const audit = await engine.auditCandidate(candidate, { mode: "deep" });
112
+ if (!audit.healthy) {
113
+ return { seeded: false, reason: audit.issues.join(",") || "candidate audit failed" };
114
+ }
115
+ await engine.promoteCandidate(candidate);
116
+ const promoted = candidate;
117
+ candidate = undefined;
118
+ const health = await engine.status(resolved, { audit: "deep", ignoreActiveOperation: true });
119
+ return {
120
+ seeded: true,
121
+ reflinked: promoted.reflinked ?? false,
122
+ seededFrom: sibling.siblingPath,
123
+ reconciledPaths: reconciliation.reconciled,
124
+ status: health,
125
+ };
126
+ }
127
+ catch (error) {
128
+ return { seeded: false, reason: error instanceof Error ? error.message : "seed failed" };
129
+ }
130
+ finally {
131
+ if (candidate) {
132
+ try {
133
+ await engine.discardCandidate(candidate);
134
+ }
135
+ catch {
136
+ // Best-effort cleanup; a fresh candidate directory is harmless to leave for the next attempt.
137
+ }
138
+ }
139
+ }
140
+ }
141
+ function toIndexResult(status, reconciledPaths) {
142
+ return {
143
+ indexed: reconciledPaths,
144
+ unchanged: [],
145
+ verification: {
146
+ status: status.status,
147
+ issueCount: status.missing.files.length + status.missing.records.length,
148
+ missing: status.missing,
149
+ verifiedAt: status.verification.verifiedAt,
150
+ },
151
+ };
152
+ }
153
+ /**
154
+ * Drop-in replacement for `(repo, options) => engine.index(repo, undefined, false, options)`
155
+ * used by every `knodin init`-family command: for a brand-new worktree, tries to
156
+ * seed-and-reconcile from a sibling before falling back to a full from-scratch
157
+ * index. For a worktree that already has a database (repair/update path),
158
+ * behaves exactly like the wrapped full index call.
159
+ */
160
+ export function indexOrSeed(engine, schemaVersion) {
161
+ const fullIndex = (target, options) => engine.index(target, undefined, false, options);
162
+ return async (target, options) => {
163
+ if (fs.existsSync(resolveDbPath(target)))
164
+ return fullIndex(target, options);
165
+ if (process.env[OPT_OUT_ENV] === "1")
166
+ return fullIndex(target, options);
167
+ const seed = await seedWorktreeIndex(target, engine, schemaVersion);
168
+ if (seed.seeded)
169
+ return toIndexResult(seed.status, seed.reconciledPaths);
170
+ return fullIndex(target, options);
171
+ };
172
+ }
@@ -114,6 +114,15 @@ the signed output size and digest, rejects trailing compressed data, validates
114
114
  the SQLite header and internal commit/schema/fingerprint, reconciles final Git
115
115
  and filesystem truth, deep-audits, and only then promotes.
116
116
 
117
+ A brand-new worktree's distinct database may itself be populated by cloning an
118
+ already-indexed sibling worktree's baseline (an OS-level copy-on-write file
119
+ clone where the filesystem supports it, falling back to a plain copy
120
+ otherwise) instead of a from-scratch build — see `knodin init`'s seed-and-
121
+ reconcile path in `src/worktree-seed.ts`. That clone is reconciled to the new
122
+ worktree's actual state through this same overlay-diff machinery before
123
+ promotion, so the "distinct, writable" guarantee above is unchanged; only how
124
+ the initial bytes are produced differs.
125
+
117
126
  ## Operator surfaces
118
127
 
119
128
  - `knodin shared status` is network-free. `--probe` is the explicit S3/auth
@@ -0,0 +1,127 @@
1
+ # knodin 0.10.0
2
+
3
+ This release makes a full index roughly 2.8x faster, turns parallel parsing on
4
+ by default, adds sealed artifacts so a repository can be queried with no
5
+ checkout at all, and makes unreadable source distinguishable from source that
6
+ does not exist. It also makes `knodin init` fast on a brand-new git worktree by
7
+ seeding its graph from an already-indexed sibling.
8
+
9
+ ## Sealed artifacts: query a repository with no checkout
10
+
11
+ An ordinary `.knodin/db.sqlite` stores `filePath` plus line ranges and reads the
12
+ bytes off a live working tree, so it cannot answer anything on a machine that
13
+ has no clone. `knodin seal` embeds those bytes and stamps an attestation
14
+ describing exactly which tree they came from.
15
+
16
+ - `knodin seal --output <path.sqlite>` produces a portable artifact. Sealing is
17
+ a post-index transform, never index-time work: an ordinary user with a
18
+ checkout should not pay for source already on disk, so opting in is
19
+ structural rather than a matter of flag discipline. The live database is
20
+ copied, never mutated.
21
+ - `knodin sealed <artifact> [symbol]` inspects or queries an artifact, and the
22
+ same capability is available as a `sealed` operation on the MCP gateway. Both
23
+ are read-only; creating an artifact stays CLI-only because it writes to disk.
24
+ - The attestation carries schema version, knodin version, canonical
25
+ `owner/name` repository identity, the exact commit, **and which ref** was
26
+ sealed — a citation cannot be checked without knowing whether it came from
27
+ `main` or from whatever production is running.
28
+ - Refusals are machine-readable codes rather than prose, because a caller
29
+ branches on them: `database-missing`, `index-unhealthy`, `working-tree-dirty`,
30
+ `commit-divergent`, `no-commit`, `source-drift`.
31
+ - `source-drift` refuses the whole seal when a file changed between indexing
32
+ and sealing. Embedded bytes that disagree with recorded line ranges make
33
+ `explain` return the wrong function's body with full confidence and no
34
+ omission — a false positive that looks correct, which is worse than any
35
+ absence. Two checks run: `mtimeMs`/`size` against `index_state`, and a bounds
36
+ check that recorded line ranges fit the embedded bytes, which holds without
37
+ consulting a clock at all.
38
+ - **Known residual, stated rather than implied:** a same-size edit that also
39
+ preserves line count and lands inside the mtime comparison window is still
40
+ invisible. Closing it needs a content hash in `index_state`, which forces a
41
+ full reindex and is tracked separately.
42
+ - Uncovered content reads as absent, never as an empty string. An empty string
43
+ is indistinguishable from an empty file and downstream reads as "this symbol
44
+ has no body" rather than "this was not covered".
45
+ - Size: profiles are excluded by default under the named rule
46
+ `profiles-excluded` (58% of one Salesforce payload in 187 files, already
47
+ captured as graph edges); blobs are zstd-compressed **inside** the database,
48
+ since SQLite does not transparently compress and a consumer holds the
49
+ decompressed file resident; storage is content-addressed so duplicate files
50
+ cost one blob; and embeddings are stripped unless `--keep-embeddings` is
51
+ passed, with semantic search then reporting itself unavailable rather than
52
+ returning zero results.
53
+ - Measured on this repository: 832 files, 6.05 MB of source compressing to
54
+ 2.05 MB (2.95x), a 17 MB artifact, `integrity_check: ok`.
55
+ - Opening an artifact requires `mode: "sealed"`. Without it the engine
56
+ reconciles the graph against an empty mount, prunes every symbol, and answers
57
+ "not found" for content the artifact is carrying.
58
+
59
+ ## A full index is about 2.8x faster
60
+
61
+ - Scoping an FTS trigger to the columns it actually mirrors, and removing a
62
+ whole-body string build from symbol identity, took a full index of this
63
+ repository from 60.5s to 21.3s.
64
+ - Salesforce metadata is now symbolized by kind rather than as one
65
+ undifferentiated `salesforce-metadata` symbol carrying identical boilerplate,
66
+ so FTS and embeddings over it mean something for the first time.
67
+
68
+ ## Parallel parsing is on by default
69
+
70
+ - The parse pool now runs unless `KNODIN_DISABLE_PARSE_WORKERS=1` is set. Pool
71
+ size is derived from total memory and core count, and an unsuitable host
72
+ silently keeps the previous sequential behaviour rather than discovering the
73
+ problem partway through an index.
74
+ - Files that need a main-thread indexer are kept off the pool. `.page`,
75
+ `.component`, `.clp`, `.ws`, `.xml`, `.sql`, `.pkb`, `.pks` and `.prisma`
76
+ classify as generic but hand off to indexers that write to the database
77
+ themselves, and routing them to a worker made a Visualforce controller edge
78
+ resolve on some runs and not others.
79
+
80
+ ## Unreadable source is distinguishable from missing source
81
+
82
+ - Source reads report an availability state instead of collapsing every failure
83
+ into `""`. A file that is gone, a file that could not be read this time, and a
84
+ symbol that does not exist are now three different answers.
85
+ - `dead_code` no longer reports a symbol as dead because its file could not be
86
+ read, and a missing file no longer aborts an entire `map` or `explain`.
87
+
88
+ ## Faster `knodin init` on a new worktree
89
+
90
+ - A brand-new worktree's graph database is seeded from the cheapest-to-
91
+ reconcile already-indexed sibling worktree of the same repository, cloned
92
+ via an OS-level copy-on-write file clone where the filesystem supports it
93
+ (plain-copy fallback otherwise), then reconciled to the new worktree's
94
+ actual committed/staged/working/untracked state through the existing
95
+ candidate allocate/reconcile/audit/promote pipeline before atomic
96
+ promotion.
97
+ - Seeding is opportunistic: no eligible sibling, a schema/build-compatibility
98
+ mismatch, or any candidate-audit failure falls straight back to a full
99
+ from-scratch index, with no error surfaced to the caller. Every git
100
+ lifecycle operation (pull, reset, merge, rebase, checkout, stash, clean)
101
+ on a seeded worktree is handled by the same incremental reconciliation and
102
+ hook machinery as any other worktree.
103
+ - Measured on this repository (810 files, 5,086 symbols): `knodin init` on a
104
+ new worktree with an eligible sibling completed in roughly 5 seconds versus
105
+ 30-40 seconds for a from-scratch build.
106
+ - Fixes a latent bug in the candidate reconciliation path: a candidate
107
+ populated from a foreign source (a downloaded shared-index artifact, or now
108
+ another worktree's database) kept `index_state` mtimes from wherever that
109
+ source was built, so every file outside the explicit changed-path set could
110
+ trip the deep audit's drift check once source and target were genuinely
111
+ different checkouts. `reconcileCandidate` now refreshes that bookkeeping
112
+ (a stat, not a re-index) for known files outside the changed-path set,
113
+ which also benefits the existing shared-index restore path.
114
+ - Set `KNODIN_DISABLE_WORKTREE_SEED=1` to always force a full from-scratch
115
+ index.
116
+
117
+ No existing worktree's database is touched by that change; it only affects the
118
+ code path for a worktree whose `.knodin/db.sqlite` does not yet exist.
119
+
120
+ ## Also in this release
121
+
122
+ - Five benchmark runners no longer call `.replace("Z", "Z")`, a no-op that
123
+ CodeQL correctly flagged as `js/identity-replacement`. Output filenames are
124
+ byte-identical.
125
+ - Fixed an `update-executor` test that failed roughly one run in three: a
126
+ version probe raced a 10-second timeout, and the probe timeout is now
127
+ injectable so the suite does not depend on process-spawn latency.
@@ -0,0 +1,83 @@
1
+ # knodin 0.10.1
2
+
3
+ Three defects, none of which a test suite would have found on its own. Two were
4
+ found by upgrading knodin on a real machine: one only appears after Homebrew
5
+ moves the Node binary, the other only when a lifecycle event is already queued.
6
+ The third is a scheduling race that reported the same cause under two different
7
+ codes, and it surfaced as an unrelated pre-push failure after six clean runs.
8
+
9
+ ## Background refresh survives a moved Node binary
10
+
11
+ `knodin init` wrote the generated hook with a fully-resolved path to whichever
12
+ Node was running at the time. `process.execPath` is symlink-resolved, so a
13
+ stable `/opt/homebrew/bin/node` became a version-pinned
14
+ `/opt/homebrew/Cellar/node/<version>/bin/node`.
15
+
16
+ Homebrew deletes that directory on upgrade, so every hook written beforehand
17
+ died with exit 127 — silently, because refresh runs in the background. The
18
+ graph simply stopped keeping up, and only a `knodin status` run revealed it.
19
+ It affected every repository initialized before the upgrade, not one.
20
+
21
+ The hook now resolves its interpreter when it runs. The recorded path stays
22
+ preferred, so nothing changes while it exists; `PATH` is consulted only once it
23
+ does not. Falling back first would silently change which runtime executes,
24
+ which is its own bug.
25
+
26
+ Hooks generated before this release still carry the old absolute path. They
27
+ keep failing until regenerated with `knodin init`; the fix prevents recurrence
28
+ after the next Node upgrade rather than repairing them retroactively.
29
+
30
+ ## `knodin init` can drain its own lifecycle queue
31
+
32
+ `init` takes the lifecycle lease, then drains queued events by spawning the
33
+ background hook. The hook re-entered knodin, met the same lease, saw a live
34
+ pid — its own parent — and refused. `init` reported "queued lifecycle refresh
35
+ could not be drained (exit 1)" on every run, and `knodin status` then advised
36
+ running `init`, which reproduced the failure exactly.
37
+
38
+ Worse, the failed drain wrote a failure marker that `status` kept reporting, so
39
+ a transient condition looked permanent while the graph was in fact intact — in
40
+ the observed case 5,883 of 5,883 files indexed and 5,059 carrying symbols.
41
+
42
+ A lease holder now passes its nonce to processes it spawns, and a child whose
43
+ token matches the lease on disk is admitted re-entrantly. Re-entry returns a
44
+ no-op release, because releasing there would drop the lock while the parent
45
+ still believes it holds it.
46
+
47
+ The lease is not weakened. A missing token, or one that does not match the
48
+ lease on disk, still hits the existing contention check, so a genuinely
49
+ concurrent mutation from another process is refused exactly as before.
50
+
51
+ ## One abort cause, one code
52
+
53
+ Worker startup and queue admission are each a race between "ready" and
54
+ "aborted". Each arm had an abort listener rejecting with
55
+ `KNODIN_CLIENT_DISCONNECTED` and a follow-up `signal.aborted` re-check
56
+ rejecting with `KNODIN_REQUEST_CANCELLED`, so the same client disconnect
57
+ surfaced as either code depending on which arm settled first.
58
+
59
+ That is worse than a consistently wrong code. It was stable on an idle machine
60
+ and unstable under load, so it looked trustworthy everywhere anyone would have
61
+ checked it — it surfaced as a pre-push gate failure on an unrelated change
62
+ after six clean runs.
63
+
64
+ It also fed the reliability journal, where the two codes map to distinct kinds.
65
+ One disconnect was recorded under two kinds, quietly skewing any rate computed
66
+ from them. **That contamination is retroactive and cannot be repaired:** journal
67
+ entries written before this release cannot be reattributed, so a
68
+ cancellation-versus-disconnect rate spanning the 0.10.1 boundary should be read
69
+ as suspect rather than corrected.
70
+
71
+ The taxonomy is unchanged. `superviseMcpRequest` can still tell an explicit
72
+ cancellation from a disconnect; the supervisor cannot, so it now names only the
73
+ cause it actually knows.
74
+
75
+ ## Homebrew formula retention
76
+
77
+ The `Retain versioned Homebrew formula` job ran on a GitHub-hosted runner whose
78
+ ephemeral IP is not on the organization's allow list, so it failed at checkout
79
+ with a 403 on every release since it was added — and it cloned
80
+ `knodin/knodin`, a stale source mirror, rather than `knodin/homebrew-tap`.
81
+
82
+ Both are corrected. Every formula through 0.10.0 was retained by hand; this is
83
+ the first release where that job can do it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "knodin": {
5
5
  "compatibility": "breaking"
6
6
  },
@@ -71,6 +71,8 @@
71
71
  "docs/releases/0.8.6.md",
72
72
  "docs/releases/0.8.7.md",
73
73
  "docs/releases/0.9.0.md",
74
+ "docs/releases/0.10.0.md",
75
+ "docs/releases/0.10.1.md",
74
76
  "docs/assets/knodin-favicon.svg",
75
77
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
76
78
  "docs/TELEMETRY.md",