knodin 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -183,6 +183,7 @@ function buildDocumentedKnodinTools() {
183
183
  "review",
184
184
  "map",
185
185
  "search",
186
+ "textSearch",
186
187
  "query",
187
188
  "prs",
188
189
  "context",
@@ -204,7 +205,7 @@ function buildDocumentedKnodinTools() {
204
205
  "diagnostics",
205
206
  "sealed",
206
207
  ],
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`).",
208
+ 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. textSearch: use when what you are renaming is NOT a code symbol — a product name, a UI label, a literal — so `explain` does not apply. Read-only exact-text match repo-wide, including files yielding no symbols (markdown, config); each hit is classified string/comment/identifier/prose with `basis` stating whether that came from the file's parse tree or only its extension. Names files it could not read, and separates no-matches from could-not-search. `caseSensitive: false` also matches other casings, each labelled. 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`).",
208
209
  },
209
210
  profile: {
210
211
  type: "string",
@@ -221,7 +222,7 @@ function buildDocumentedKnodinTools() {
221
222
  },
222
223
  file: {
223
224
  type: "string",
224
- description: "Repo-relative definition file selector.",
225
+ description: "Repo-relative file. Disambiguates which definition is meant when a symbol resolves in several files; with query impact and impactMode=file it names the target file instead.",
225
226
  },
226
227
  evidenceLevel: {
227
228
  type: "string",
@@ -272,7 +273,7 @@ function buildDocumentedKnodinTools() {
272
273
  impactMode: {
273
274
  type: "string",
274
275
  enum: ["symbol", "file"],
275
- description: "query impact only: stable symbol reach (default) or explicitly labeled changed-file blast radius.",
276
+ description: "query impact only: stable symbol reach (default) or explicitly labeled changed-file blast radius. With `file`, name the target in the `file` parameter, or pass comma-separated paths in `symbol` for a multi-file change set.",
276
277
  },
277
278
  direction: {
278
279
  type: "string",
@@ -399,6 +400,10 @@ function buildDocumentedKnodinTools() {
399
400
  enum: ["all", "test", "production"],
400
401
  description: "search/file_metrics: include all, test-only, or production-only files.",
401
402
  },
403
+ caseSensitive: {
404
+ type: "boolean",
405
+ description: "textSearch only; default true. False also matches other casings; each hit reports the case on disk and whether it matched exactly, so a case-sensitive token is not folded into the same decision as a label.",
406
+ },
402
407
  offset: {
403
408
  type: "number",
404
409
  minimum: 0,
@@ -1398,7 +1403,7 @@ async function handleDiagnosticsOperation(repo, args, bounded) {
1398
1403
  async function dispatchKnodinTool(args) {
1399
1404
  const startedAt = performance.now();
1400
1405
  const parsedArgs = (args ?? {});
1401
- const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, queries, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositories, scope, distributed, repositoryIds, components, evidence, relationshipType, requireComplete, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, planDigest, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
1406
+ const { operation, symbol, base, diffScope, from, toRevision, reviewFiles, query, pattern, queries, to, limit, depth, impactMode, direction, relationKinds, minConfidence, includeTests, includeDataFlow, flowVariable, apply, force, detailLevel, prNumber, prState, branches, auditRange, auditBase, auditHead, expectedLogin, worktreeAction, worktreePath, telemetryAction, telemetryRetentionDays, task, changedFiles, repoPath, section, systemAction, repositories, scope, distributed, repositoryIds, components, evidence, relationshipType, requireComplete, repositoryAction, roots, linkedWorktrees, cursor, allowPartial, dryRun, manifestPath, planDigest, identity, file, kind, toIdentity, toFile, toKind, byteBudget, tokenBudget, itemBudget, includeSource, languages, extensions, kinds, path, architectureFacets, testScope, caseSensitive, offset, minLines, minComplexity, topN, sort, packAction, format, include, exclude, filePolicies, alreadyPresent, chatFiles, lineNumbers, includeTree, outputPath, artifactPath, startLine, endLine, regex, regexFlags, gitDiffScope, gitLog, statusAudit, timeoutMs, client, repairPlan, persistTelemetry, } = parsedArgs;
1402
1407
  const repo = repoPath ?? process.cwd();
1403
1408
  const selector = {
1404
1409
  identity: expandCompactIdentity(identity),
@@ -1744,6 +1749,15 @@ async function dispatchKnodinTool(args) {
1744
1749
  includeSource,
1745
1750
  offset,
1746
1751
  }));
1752
+ case "textSearch": {
1753
+ if (!query)
1754
+ throw new Error("knodin textSearch requires `query` (the exact text to find)");
1755
+ const found = await engine.searchText(query, repo, { caseSensitive });
1756
+ // Not routed through `graphRead`: this reads the working tree rather
1757
+ // than the graph, so it is answerable — and correct — on a repository
1758
+ // whose index is stale, absent, or still building.
1759
+ return bounded(found, "textSearch");
1760
+ }
1747
1761
  case "prs": {
1748
1762
  return bounded(await handlePullRequestsOperation({
1749
1763
  repo,
@@ -1799,8 +1813,23 @@ async function dispatchKnodinTool(args) {
1799
1813
  if (limit !== undefined && (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1))
1800
1814
  throw new Error("knodin query: limit must be a positive integer");
1801
1815
  const repoWide = REPO_WIDE_QUERY_PATTERNS.includes(pattern);
1802
- if (!symbol && !repoWide)
1803
- throw new Error(`knodin query ${pattern} requires \`symbol\``);
1816
+ // File-mode impact takes its target as comma-separated paths in
1817
+ // `symbol`. That is not the call anyone forms from this schema: a `file`
1818
+ // parameter is published right beside `impactMode`, and the obvious
1819
+ // reading — impactMode "file" plus file: <path> — was rejected with
1820
+ // "requires `symbol`", naming a field the caller had deliberately not
1821
+ // used. A user reported the capability as unavailable because of it.
1822
+ //
1823
+ // `file` is now accepted as the target here, and only here: in symbol
1824
+ // mode it keeps its existing meaning as a definition disambiguator.
1825
+ const fileModeImpact = pattern === "impact" && impactMode === "file";
1826
+ if (fileModeImpact && symbol && file && symbol !== file)
1827
+ throw new Error(`knodin query impact with impactMode=file received different targets in \`symbol\` (${symbol}) and \`file\` (${file}); pass one`);
1828
+ const target = fileModeImpact ? (symbol ?? file) : symbol;
1829
+ if (!target && !repoWide)
1830
+ throw new Error(fileModeImpact
1831
+ ? "knodin query impact with impactMode=file requires a target path in `file`, or comma-separated paths in `symbol`"
1832
+ : `knodin query ${pattern} requires \`symbol\``);
1804
1833
  if ((pattern === "shortest_path" || pattern === "cross_substrate_path") && !to)
1805
1834
  throw new Error(`knodin query ${pattern} requires \`to\``);
1806
1835
  if (pattern === "rename_preview" && !to)
@@ -1817,7 +1846,7 @@ async function dispatchKnodinTool(args) {
1817
1846
  .rename(symbol ?? "", to ?? "", repo, apply === true, true, selector)
1818
1847
  .then((result) => bounded(decorateGraphQueryResult(result, queryHealth?.available ? queryHealth.state : "healthy", queryHealth?.available ? queryHealth.graph.freshness : undefined), "query:rename_preview"));
1819
1848
  }
1820
- const queryResult = await engine.query(pattern, symbol ?? "", repo, to, Math.min(limit ?? itemBudget ?? 100, itemBudget ?? 1000), depth, detailLevel === "source" ? undefined : detailLevel, selector, pattern === "impact"
1849
+ const queryResult = await engine.query(pattern, target ?? "", repo, to, Math.min(limit ?? itemBudget ?? 100, itemBudget ?? 1000), depth, detailLevel === "source" ? undefined : detailLevel, selector, pattern === "impact"
1821
1850
  ? {
1822
1851
  mode: impactMode ?? "symbol",
1823
1852
  direction,
@@ -234,7 +234,12 @@ function baseResult(options, status) {
234
234
  networkUsed: false,
235
235
  };
236
236
  }
237
- function parseVersion(version) {
237
+ /**
238
+ * Exported so release classification can reuse this parser rather than adding a
239
+ * third hand-rolled copy (`src/manager-update.ts:424` is the second, and a
240
+ * de-duplication candidate). Strict SemVer: leading zeros are rejected.
241
+ */
242
+ export function parseVersion(version) {
238
243
  const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
239
244
  if (!match)
240
245
  return null;
@@ -62,6 +62,30 @@ function completedStatus(graph) {
62
62
  return "unknown";
63
63
  return null;
64
64
  }
65
+ /**
66
+ * Waiting here can only end at the timeout (EASFDC-8498).
67
+ *
68
+ * `queued` freshness means progress depends on the queued-event drainer, and
69
+ * `reconcileOrdinaryDrift` deliberately declines that state, so the in-process
70
+ * path cannot advance it either. If the drainer also cannot run, every
71
+ * remaining poll is spent relaunching a processor that will not start.
72
+ *
73
+ * This is the exact shape reported on 0.10.2: a hook exiting 127 on every
74
+ * invocation, freshness stuck at `queued`, and `status` recommending
75
+ * `wait --fresh` — which held the reason for its own futility on every poll and
76
+ * reported none of it.
77
+ *
78
+ * Deliberately narrow. A degraded lifecycle with freshness that is NOT queued
79
+ * can still be reconciled in process, and failing fast there would refuse work
80
+ * the command can actually do.
81
+ */
82
+ function blockedOnDeadDrainer(graph) {
83
+ return graph.freshness?.state === "queued" && !graph.lifecycle.refreshCapable;
84
+ }
85
+ function describeDeadDrainer(graph) {
86
+ const detail = graph.lifecycle.lastError ?? graph.lifecycle.issues[0] ?? "cause not recorded";
87
+ return `background refresh cannot run, so queued work will not drain: ${detail}`;
88
+ }
65
89
  async function reconcileOrdinaryDrift(engine, repo, graph) {
66
90
  const shouldReconcile = (graph.status === "stale" || graph.status === "repair-needed") &&
67
91
  graph.freshness.state !== "queued";
@@ -90,9 +114,27 @@ export async function waitForFresh(engine, repo, timeoutMs = 30_000, processQueu
90
114
  status = completedStatus(graph);
91
115
  if (status)
92
116
  return completedResult(status, startedAt, polls, graph);
117
+ // Checked after the completion checks, never before: a repository that is
118
+ // already fresh must still report `fresh`, even with a broken hook.
119
+ if (blockedOnDeadDrainer(graph))
120
+ return {
121
+ status: "lifecycle-degraded",
122
+ waitedMs: Date.now() - startedAt,
123
+ polls,
124
+ graph,
125
+ reason: describeDeadDrainer(graph),
126
+ };
93
127
  const elapsed = Date.now() - startedAt;
94
128
  if (elapsed >= timeoutMs)
95
- return { status: "timeout", waitedMs: elapsed, polls, graph };
129
+ return {
130
+ status: "timeout",
131
+ waitedMs: elapsed,
132
+ polls,
133
+ graph,
134
+ // A timeout that names nothing is what sent the reporter looking in
135
+ // the wrong place. Say what was still outstanding.
136
+ reason: `still waiting on ${graph.lifecycle.queuedEvents} queued event(s)${graph.lifecycle.lastError ? `; last refresh error: ${graph.lifecycle.lastError}` : ""}`,
137
+ };
96
138
  await delay(Math.min(100, timeoutMs - elapsed));
97
139
  }
98
140
  }
@@ -0,0 +1,93 @@
1
+ # knodin 0.10.3
2
+
3
+ Four changes, all from one observation: after 0.10.2 fixed a parser leak that had
4
+ left 174,205 files silently unparsed on a large repository, the recovery advice
5
+ turned out to be unusable. A full reindex of that checkout has no knowable cost,
6
+ no way to ask for less work, and no way to survive an interruption.
7
+
8
+ ## Indexing reports how much work is left, not just how many files
9
+
10
+ A full index of a 902,960-file Salesforce checkout appeared to hang for twenty
11
+ minutes at 71%. It had not hung — it had reached the profiles.
12
+
13
+ Progress was counted in files while the cost lives in bytes, and on that
14
+ repository the two disagree violently: **435 files over 1 MB hold 78% of all
15
+ source bytes.** So the counter raced through the cheap files and appeared to
16
+ stall exactly when the expensive ones began, which is the moment an operator is
17
+ most likely to conclude the process is wedged and kill it. The only way to tell
18
+ grinding from hung was to read the process's accumulated CPU time.
19
+
20
+ Both counters are now reported, and the estimate is derived from the byte rate.
21
+ Files answer "how far through the list"; bytes answer "how much work is left".
22
+
23
+ The estimate is labelled an estimate. During that one run, three separate
24
+ completion figures were derived from observed rates — 38 hours, 4 hours, and 15
25
+ minutes — and every one was wrong, because the rate ranged from 6.5 to 361 files
26
+ per second before collapsing to 0.065. Rates are now measured from the start of
27
+ the phase rather than process start, so the cold-start window no longer drags the
28
+ estimate; extrapolating from it is what produced the 38-hour figure.
29
+
30
+ ## Rebuild one subtree with `--under`
31
+
32
+ `knodin index --under <dir>` rebuilds only that directory. Previously the
33
+ intuitive attempt was also the worst one: a lone directory argument means "index
34
+ this whole repository".
35
+
36
+ The file set is resolved inside the engine rather than by expanding paths
37
+ yourself, so a scoped rebuild indexes exactly the files a full index would. A
38
+ scope that matches nothing fails rather than reporting a successful rebuild of
39
+ nothing.
40
+
41
+ Coverage is reported as a lower bound afterwards, because a whole-repository
42
+ tally no longer describes a partially rebuilt graph. Trading a graph you were
43
+ told to distrust for one that is silently partial would be worse than doing
44
+ nothing.
45
+
46
+ ## Defer embeddings with `--skip-embeddings`
47
+
48
+ The embedding phase dominates indexing cost, and the engine has always supported
49
+ deferring it — only mirrors could ask. `knodin index --skip-embeddings` now
50
+ builds structure alone, leaving `explain`, `query` and impact answerable while
51
+ semantic search waits.
52
+
53
+ The gap is stated rather than left to be discovered. An index that defers
54
+ embeddings says so, and `knodin search` now announces that it will under-return
55
+ until they are built. A short result list is otherwise indistinguishable from a
56
+ thorough search that found little.
57
+
58
+ Running `knodin index` again completes the deferred pass, and only the symbols
59
+ still lacking embeddings are processed.
60
+
61
+ ## An interrupted `--clean` resumes
62
+
63
+ A clean index builds into a candidate database and promotes it atomically. That
64
+ is deliberate — it is why a failed rebuild cannot leave a half-built graph in
65
+ place of a working one, and it is unchanged.
66
+
67
+ But a killed process runs no discard, so the candidate survived on disk and the
68
+ next run ignored it and started over. On a repository where the rebuild takes
69
+ hours, an interruption cost all of it.
70
+
71
+ The next `knodin index --clean` now continues that build, skipping files already
72
+ recorded whose contents still match disk, and says how many it kept. Resumption
73
+ is refused when the tree has moved on since — continuing there would promote a
74
+ graph that never described any single state of the repository — and refused for
75
+ a candidate that was never marked as an interrupted build. Both cases fall back
76
+ to a full rebuild rather than guessing.
77
+
78
+ ## Release compatibility is derived, not declared
79
+
80
+ `knodin.compatibility` had been the literal string `"breaking"` since it was
81
+ introduced, across four releases. Two of those genuinely were breaking and two
82
+ were bugfix patches, so it was accidentally correct half the time — which is
83
+ worse than being consistently wrong, because the correct entries make the field
84
+ look maintained.
85
+
86
+ It is now derived from the version change, with the pre-1.0 rule stated
87
+ explicitly: below 1.0 the minor is the breaking axis, so `0.10.x` to `0.11.0`
88
+ breaks and `0.10.2` to `0.10.3` does not. Nothing inside this repository reads
89
+ the field — the Homebrew tap reads it from the published package — so two guards
90
+ now check it, because otherwise nothing would.
91
+
92
+ Releases already published keep the label they shipped with. Published metadata
93
+ is immutable by design.
@@ -0,0 +1,161 @@
1
+ # knodin 0.10.4
2
+
3
+ Five changes from two pieces of field feedback. The first came from a
4
+ product-name rename in the Nova repository; the second from a checkout whose
5
+ background refresh had been failing silently since a Homebrew upgrade.
6
+
7
+ The theme is the same one 0.10.2 and 0.10.3 were about: a tool that reports
8
+ things it has not verified, and a failure that leaves no trace anywhere a person
9
+ looks.
10
+
11
+ ## `textSearch`: exact text, with every match classified
12
+
13
+ The reported blocker: the refactor workflow assumes a rename targets a code
14
+ symbol. This one was a product-name literal spanning UI strings and documents,
15
+ where `explain` and symbol resolution do not apply. The reporter fell back to
16
+ `rg` and hand-applied patches for the whole task.
17
+
18
+ Searching is the easy half. The half worth building is classification, and their
19
+ case shows why: visible `NOVA` labels had to change, while `NOVA-CANARY-…` was a
20
+ deliberate case-sensitive security token that had to survive. A flat text search
21
+ returns both and leaves a human to separate them by eye across however many hits
22
+ a large repository produces — exactly the position knodin exists to remove
23
+ people from.
24
+
25
+ knodin can classify only because it already parses these files. A match in a
26
+ file with a grammar resolves to its smallest containing syntax node, and the
27
+ parser's own node type is the evidence. A file without a grammar supports a
28
+ claim about the file, not about the match.
29
+
30
+ That difference is reported rather than smoothed over. Every match carries the
31
+ `basis` its classification rests on — `parse-node`, `file-type`, or `none` — and
32
+ the grammar's raw node type travels alongside the coarse label so a bad mapping
33
+ is catchable. Classification will sometimes be wrong; it is offered as evidence
34
+ a human is reviewing, never as a decision already taken.
35
+
36
+ Three details that matter in practice:
37
+
38
+ - **Documents are searched.** Enumeration uses the indexer's prune rules but not
39
+ its source-extension filter, because markdown and config are the point.
40
+ - **Files that could not be read are named**, not omitted. A rename that
41
+ silently skipped files would leave a half-renamed repository looking finished.
42
+ - **"No matches" is distinguishable from "could not search."** An empty list
43
+ that reads as reassurance is the defect this whole operation exists to avoid.
44
+
45
+ `textSearch` reads the working tree rather than the graph, so it stays
46
+ answerable when the index is stale, absent, or still building — often exactly
47
+ when a rename is underway.
48
+
49
+ It previews only. Applying edits is deliberately not in this release.
50
+
51
+ ## `impact` accepts the file you gave it
52
+
53
+ Reported verbatim against 0.10.2:
54
+
55
+ ```
56
+ operation: query, pattern: impact, impactMode: file,
57
+ file: documents/nova-architecture-deck.js
58
+ -> knodin query impact requires `symbol`
59
+ ```
60
+
61
+ File-mode impact was never missing. It read its target from `symbol`, documented
62
+ in one clause buried inside that parameter's prose, while a `file` parameter sat
63
+ published right beside `impactMode` meaning something else entirely. The schema
64
+ offered both, and the combination anyone would form from them was the one that
65
+ failed.
66
+
67
+ That is worse than a missing feature: the reporter concluded the capability was
68
+ broken and moved on. A working feature was recorded as unavailable because of
69
+ parameter naming.
70
+
71
+ `file` is now accepted as the target under file mode, and keeps its original
72
+ meaning in symbol mode. Conflicting `symbol` and `file` values are refused with
73
+ both named rather than resolved by preference — silently picking one would
74
+ answer a question nobody asked. The error now states the supported call.
75
+
76
+ ## Managed hooks survive a Node upgrade
77
+
78
+ A checkout's background refresh had exited 127 seventeen consecutive times:
79
+
80
+ ```
81
+ background-index.sh: /opt/homebrew/Cellar/node/26.5.0_1/bin/node:
82
+ No such file or directory
83
+ ```
84
+
85
+ `brew upgrade node` deletes the versioned Cellar directory. The hook had
86
+ recorded the interpreter's absolute path at generation time, and that path
87
+ contains the version, so it was guaranteed to stop existing.
88
+
89
+ The caller cannot avoid this: Node symlink-resolves `process.execPath`, so
90
+ invoking `/opt/homebrew/opt/node/bin/node` still reports the Cellar path. By the
91
+ time knodin can read its own interpreter, the durable path is gone.
92
+
93
+ The recorded path is now mapped back to `<prefix>/opt/<formula>/…`, the symlink
94
+ Homebrew repoints on upgrade, so it survives by construction rather than by
95
+ falling back at run time. The prefix is derived from the match, so Intel
96
+ `/usr/local` and Linuxbrew work through the same path. A non-Homebrew
97
+ interpreter is left untouched.
98
+
99
+ **Existing repositories need one `knodin init`** to pick up a corrected hook.
100
+ `repair` cannot do it: the fault lives in the hook's contents, not in graph
101
+ state — which is why a `repair` run in that checkout returned "healthy" while
102
+ the lifecycle stayed broken.
103
+
104
+ Lifecycle health now notices this class of failure. It previously checked that
105
+ the background script existed and was executable, neither of which says anything
106
+ about the runtime on its first line, so the repository reported a degraded
107
+ lifecycle while naming no cause. The reported issue now names the missing
108
+ interpreter and the command that rewrites the hook.
109
+
110
+ ## `wait --fresh` stops instead of waiting for something that cannot happen
111
+
112
+ `wait --fresh` has always had a bounded timeout, and it was reached honestly.
113
+ But when the background refresh is failing, waiting cannot succeed, and the
114
+ command already knew that: it recomputes lifecycle health on every poll, so it
115
+ was holding the exit-127 error the entire time it spun, then reported the single
116
+ word `timeout`.
117
+
118
+ It now returns immediately when freshness is queued and lifecycle refresh cannot
119
+ run — the pairing that is genuinely futile — and names the recorded failure.
120
+ Timeouts carry a reason as well: what remained queued, and the last refresh
121
+ error.
122
+
123
+ The guard is narrow on purpose. A degraded lifecycle whose freshness is not
124
+ queued can still be reconciled in process, and a repository that is already
125
+ fresh still reports `fresh` even with a broken hook.
126
+
127
+ ## `init` no longer denies agents that are configured
128
+
129
+ Six seconds apart, with nothing installed in between:
130
+
131
+ ```
132
+ $ knodin init
133
+ Agent integration: personal - no supported coding agents detected
134
+ $ knodin status
135
+ Agent integration: personal (claude, codex, gemini, antigravity)
136
+ ```
137
+
138
+ Both commands were computing something correct — `init` reports what that run
139
+ configured, `status` reports what is configured — but `init` described its empty
140
+ set as a *detection* result it had never run, which reads as a denial of the
141
+ four agents the next command lists. It now says what the value supports: no
142
+ agents configured by this run.
143
+
144
+ ## Known gaps
145
+
146
+ Two items from the same report are deliberately not fixed here rather than
147
+ guessed at:
148
+
149
+ - `status` and `repair` can disagree about indexed-file and symbol counts. In
150
+ the reported session `status` showed 0 indexed files and 95 issues while
151
+ `repair`, seconds later, reported 94 indexed files and 53 with symbols and
152
+ called itself "verified". Whether `repair` inspected or silently rebuilt
153
+ changes what the fix is, and the evidence does not settle it.
154
+ - `init --scope personal` leaves `.agents/`, `.codex/` and `.gemini/` untracked
155
+ and unexcluded, and migrates a tracked `.gitignore` entry without saying so.
156
+
157
+ ## Compatibility
158
+
159
+ Compatible. Nothing is removed or renamed; `textSearch` is a new operation
160
+ alongside the existing ones, and `file` gains a meaning under `impactMode: file`
161
+ while keeping its previous one everywhere else.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "knodin": {
5
- "compatibility": "breaking"
5
+ "compatibility": "compatible"
6
6
  },
7
7
  "description": "knodin — source-evidenced local code intelligence with known bounds. Stable identity, fresh evidence, truthful budgets, and recoverable bounded views.",
8
8
  "license": "MIT",
@@ -74,6 +74,8 @@
74
74
  "docs/releases/0.10.0.md",
75
75
  "docs/releases/0.10.1.md",
76
76
  "docs/releases/0.10.2.md",
77
+ "docs/releases/0.10.3.md",
78
+ "docs/releases/0.10.4.md",
77
79
  "docs/assets/knodin-favicon.svg",
78
80
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
79
81
  "docs/TELEMETRY.md",