knodin 0.10.3 → 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.
package/dist/bin/cli.js CHANGED
@@ -77,8 +77,17 @@ function integrationAgents(repo) {
77
77
  const repositoryDetected = inspectRepositoryIntegrationStatus(repo)?.agents ?? [];
78
78
  return [...new Set([...detectSupportedAgents(), ...previous, ...repositoryDetected])];
79
79
  }
80
- function formatInitHuman(result) {
81
- let agents = `${result.paths.scope} no supported coding agents detected`;
80
+ export function formatInitHuman(result) {
81
+ // `configured` is what THIS run wrote, not what exists on the machine. The
82
+ // old wording called an empty list "no supported coding agents detected",
83
+ // asserting a detection result init never computed — and it read as a flat
84
+ // contradiction of the `status` line seconds later, which lists the agents
85
+ // that genuinely are configured (EASFDC-8499).
86
+ //
87
+ // Deliberately not calling the detector here: a formatter that reads the
88
+ // machine cannot be tested deterministically, and the honest fix is to stop
89
+ // claiming more than this value supports rather than to go find more.
90
+ let agents = `${result.paths.scope} — no agents configured by this run`;
82
91
  if (result.paths.scope === "cli-only")
83
92
  agents = "CLI-only — AI agents are not configured to discover knodin";
84
93
  else if (result.paths.agentIntegration.configured.length > 0)
@@ -46,6 +46,7 @@ import { isIndexableSourcePath } from "./source-policy.js";
46
46
  import { Database } from "./sqlite.js";
47
47
  import { lookupMirror, mayWriteToRepository, resolveDbPath, resolveStateDir, } from "./state-paths.js";
48
48
  import { deleteAllSymbols, deleteSymbolsForFile, deleteSymbolsMatchingPath, ORPHANED_EMBEDDING_PREDICATE, purgeOrphanEmbeddings, } from "./symbol-delete.js";
49
+ import { searchRepoText } from "./text-matches.js";
49
50
  // ES Module resolution
50
51
  const __filename = fileURLToPath(import.meta.url);
51
52
  const __dirname = path.dirname(__filename);
@@ -11555,6 +11556,13 @@ function withStaleness(engine, claimRepository, openDb, openPolicy) {
11555
11556
  row.staleness = staleness;
11556
11557
  return page;
11557
11558
  },
11559
+ searchText(term, repoPath, options) {
11560
+ claimRepository(repoPath);
11561
+ // No staleness annotation: this reads the working tree directly rather
11562
+ // than the graph, so its answers are current by construction and
11563
+ // stamping them with the index's freshness would misreport them.
11564
+ return engine.searchText(term, repoPath, options);
11565
+ },
11558
11566
  async query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options) {
11559
11567
  claimRepository(repoPath);
11560
11568
  const result = await engine.query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options);
@@ -14073,6 +14081,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14073
14081
  indexCoordination.release();
14074
14082
  }
14075
14083
  },
14084
+ async searchText(term, repoPath, options = {}) {
14085
+ if (term.length === 0)
14086
+ throw new Error("knodin searchText: term must not be empty");
14087
+ // Reads the working tree, not the graph, so it needs no open database
14088
+ // and no freshness check. `getLanguageForFile` is the same grammar
14089
+ // loader indexing uses, which is what makes the classification
14090
+ // evidence rather than a heuristic over file extensions.
14091
+ return searchRepoText(path.resolve(repoPath), term, getLanguageForFile, () => new Parser(), options);
14092
+ },
14076
14093
  async search(query, repoPath, limit, options = {}) {
14077
14094
  const resolvedRepoPath = path.resolve(repoPath);
14078
14095
  const offset = options.offset ?? 0;
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Exact-text search with per-match classification (EASFDC-8496).
3
+ *
4
+ * The point of this module is the one thing `rg` cannot do: say *what a match
5
+ * is*. A product-name rename spans UI strings, prose and identifiers, and the
6
+ * reported case that motivated this had visible `NOVA` labels needing change
7
+ * while `NOVA-CANARY-…` was a deliberate security token that must not. A flat
8
+ * text search returns both and leaves a human to separate them by eye across
9
+ * however many hits a large repository yields.
10
+ *
11
+ * knodin can do better only because it already parses these files. For a file
12
+ * with a grammar, a match resolves to the smallest syntax node containing it,
13
+ * and the parser's own node type is the evidence. For a file without one, the
14
+ * strongest honest claim is about the file, not the match.
15
+ *
16
+ * That gap is not hidden. Every match carries the `basis` it was classified on,
17
+ * so a reviewer can see which answers came from a parse tree and which are an
18
+ * inference from a file extension. Classification will sometimes be wrong; it
19
+ * is offered as evidence a human is reviewing, never as a decision already
20
+ * taken on their behalf.
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { walkRepoFiles } from "./file-walker.js";
25
+ const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
26
+ /** Upper bound on `TextMatch.enclosingText`, so one row cannot flood a preview. */
27
+ const ENCLOSING_TEXT_LIMIT = 120;
28
+ function isWordCharacter(value) {
29
+ return value !== undefined && WORD_CHARACTER.test(value);
30
+ }
31
+ /**
32
+ * Map a grammar's node type onto a coarse class.
33
+ *
34
+ * Deliberately substring-based rather than an exhaustive per-language table.
35
+ * Across the grammars this project loads, the same concept appears as `string`,
36
+ * `string_literal`, `template_string`, `interpreted_string_literal`, `comment`,
37
+ * `line_comment`, `identifier`, `property_identifier` and more, and any fixed
38
+ * list would silently degrade to "unclassified" the first time a grammar is
39
+ * updated or added. An unrecognised type keeps `unclassified` while the real
40
+ * node type still travels on the match, so nothing is lost by guessing less.
41
+ */
42
+ export function classifyNodeType(nodeType) {
43
+ const normalized = nodeType.toLowerCase();
44
+ if (normalized.includes("comment"))
45
+ return "comment";
46
+ if (normalized.includes("string") || normalized.includes("char_literal"))
47
+ return "string-literal";
48
+ if (normalized.includes("identifier") || normalized === "word")
49
+ return "identifier";
50
+ return "unclassified";
51
+ }
52
+ /**
53
+ * Extensions whose contents are prose rather than code.
54
+ *
55
+ * A file-type claim, and labelled as one: `basis: "file-type"`. Prose is the
56
+ * common case for the renames this feature exists to serve, so saying nothing
57
+ * at all about a markdown file would leave most matches unclassified.
58
+ */
59
+ const PROSE_EXTENSIONS = new Set([".md", ".mdx", ".markdown", ".txt", ".rst", ".adoc", ".org"]);
60
+ function extensionOf(file) {
61
+ const base = file.slice(file.lastIndexOf("/") + 1);
62
+ const dot = base.lastIndexOf(".");
63
+ return dot <= 0 ? "" : base.slice(dot).toLowerCase();
64
+ }
65
+ export function classifyByFileType(file) {
66
+ return PROSE_EXTENSIONS.has(extensionOf(file)) ? "prose" : "unclassified";
67
+ }
68
+ /** Every occurrence of `term`, non-overlapping, left to right. */
69
+ function findOccurrences(content, term, caseSensitive) {
70
+ if (term.length === 0)
71
+ return [];
72
+ const haystack = caseSensitive ? content : content.toLowerCase();
73
+ const needle = caseSensitive ? term : term.toLowerCase();
74
+ const found = [];
75
+ let from = 0;
76
+ for (;;) {
77
+ const at = haystack.indexOf(needle, from);
78
+ if (at === -1)
79
+ break;
80
+ found.push({
81
+ startIndex: at,
82
+ endIndex: at + term.length,
83
+ // Sliced from the ORIGINAL content, so the reported text carries the
84
+ // case actually on disk rather than the case that was searched for.
85
+ matchedText: content.slice(at, at + term.length),
86
+ });
87
+ from = at + term.length;
88
+ }
89
+ return found;
90
+ }
91
+ /** Byte-free line/column lookup, built once per file rather than per match. */
92
+ function lineStarts(content) {
93
+ const starts = [0];
94
+ for (let i = 0; i < content.length; i++)
95
+ if (content[i] === "\n")
96
+ starts.push(i + 1);
97
+ return starts;
98
+ }
99
+ function lineIndexFor(starts, index) {
100
+ let low = 0;
101
+ let high = starts.length - 1;
102
+ while (low < high) {
103
+ const mid = (low + high + 1) >> 1;
104
+ if (starts[mid] <= index)
105
+ low = mid;
106
+ else
107
+ high = mid - 1;
108
+ }
109
+ return low;
110
+ }
111
+ /**
112
+ * A tree-sitter abort is permanent and process-wide, not a per-file failure.
113
+ *
114
+ * Mirrors the engine's own guard (EASFDC-8445): swallowing one of these turns a
115
+ * dead WASM module into "this file has no matches" for every remaining file,
116
+ * which is precisely the silent-undercount this feature must not produce.
117
+ */
118
+ function isWasmAbort(error) {
119
+ return (typeof error === "object" &&
120
+ error !== null &&
121
+ error.name === "ExitStatus");
122
+ }
123
+ /**
124
+ * Classify each match in one file against its parse tree.
125
+ *
126
+ * Parses once for the whole file, not once per match. Returns null when the
127
+ * file has no grammar, leaving the caller to fall back to a file-type claim.
128
+ */
129
+ function classifyAgainstTree(parser, content, raw) {
130
+ const tree = parser.parse(content);
131
+ if (!tree)
132
+ return null;
133
+ try {
134
+ return raw.map((match) => {
135
+ // `endIndex - 1` because the range is inclusive: passing the exclusive
136
+ // end can select the following sibling for a match ending at a
137
+ // boundary, which mislabels exactly the tokens that sit flush against
138
+ // punctuation — quotes, most of all.
139
+ const node = tree.rootNode.descendantForIndex(match.startIndex, Math.max(match.startIndex, match.endIndex - 1));
140
+ const nodeType = node?.type ?? "";
141
+ const text = node?.text ?? "";
142
+ return {
143
+ classification: classifyNodeType(nodeType),
144
+ nodeType,
145
+ // Bounded: a match inside a large node (a whole template literal,
146
+ // a long comment) must not turn one row of a preview into a page.
147
+ enclosingText: text.length > ENCLOSING_TEXT_LIMIT ? `${text.slice(0, ENCLOSING_TEXT_LIMIT)}…` : text,
148
+ };
149
+ });
150
+ }
151
+ finally {
152
+ tree.delete();
153
+ }
154
+ }
155
+ /**
156
+ * Search already-read files for an exact term and classify every hit.
157
+ *
158
+ * `loadLanguage` is injected rather than imported so this module can be tested
159
+ * without standing up the engine's grammar loader, and so a caller that already
160
+ * knows a file has no grammar can skip the lookup.
161
+ *
162
+ * The parser is created lazily and freed in a `finally`: this walks the same
163
+ * ground as the leak that left 174,205 files silently unparsed, and a search
164
+ * over a large repository touches enough files to reproduce it exactly.
165
+ */
166
+ export async function searchFiles(files, term, loadLanguage, newParser, options = {}) {
167
+ const caseSensitive = options.caseSensitive ?? true;
168
+ const matches = [];
169
+ const uncovered = [];
170
+ let filesSearched = 0;
171
+ for (const { file, content } of files) {
172
+ const raw = findOccurrences(content, term, caseSensitive);
173
+ filesSearched++;
174
+ if (raw.length === 0)
175
+ continue;
176
+ let classified = null;
177
+ let language = null;
178
+ try {
179
+ language = await loadLanguage(file);
180
+ }
181
+ catch {
182
+ // A grammar that will not load costs classification, not the match.
183
+ language = null;
184
+ }
185
+ if (language) {
186
+ const parser = newParser();
187
+ try {
188
+ parser.setLanguage(language);
189
+ classified = classifyAgainstTree(parser, content, raw);
190
+ }
191
+ catch (error) {
192
+ if (isWasmAbort(error))
193
+ throw error;
194
+ // Parsing failed on this file only. The matches are still real and
195
+ // still reported — just without node evidence.
196
+ classified = null;
197
+ uncovered.push({
198
+ file,
199
+ reason: `matched but could not be parsed for classification: ${error instanceof Error ? error.message : String(error)}`,
200
+ });
201
+ }
202
+ finally {
203
+ try {
204
+ parser.delete();
205
+ }
206
+ catch {
207
+ /* a dead module has nothing to reclaim */
208
+ }
209
+ }
210
+ }
211
+ const starts = lineStarts(content);
212
+ raw.forEach((match, position) => {
213
+ const lineIndex = lineIndexFor(starts, match.startIndex);
214
+ const lineStart = starts[lineIndex];
215
+ const nextStart = starts[lineIndex + 1] ?? content.length + 1;
216
+ const node = classified?.[position] ?? null;
217
+ matches.push({
218
+ file,
219
+ line: lineIndex + 1,
220
+ column: match.startIndex - lineStart + 1,
221
+ startIndex: match.startIndex,
222
+ endIndex: match.endIndex,
223
+ lineText: content.slice(lineStart, Math.max(lineStart, nextStart - 1)).replace(/\r$/, ""),
224
+ matchedText: match.matchedText,
225
+ caseMatchesQuery: match.matchedText === term,
226
+ withinLargerWord: isWordCharacter(content[match.startIndex - 1]) ||
227
+ isWordCharacter(content[match.endIndex]),
228
+ classification: node ? node.classification : classifyByFileType(file),
229
+ basis: node ? "parse-node" : language ? "none" : "file-type",
230
+ nodeType: node ? node.nodeType : null,
231
+ enclosingText: node ? node.enclosingText : null,
232
+ });
233
+ });
234
+ }
235
+ return { matches, uncovered, filesSearched, searched: true };
236
+ }
237
+ /**
238
+ * Read a file for searching, or say why it could not be.
239
+ *
240
+ * Binary detection is a NUL-byte sniff over the head of the file rather than an
241
+ * extension list: the extensions that matter here are open-ended, and a
242
+ * misjudged binary read produces garbage matches rather than an honest skip.
243
+ */
244
+ function readSearchable(absolute) {
245
+ let buffer;
246
+ try {
247
+ buffer = fs.readFileSync(absolute);
248
+ }
249
+ catch (error) {
250
+ return { reason: `unreadable: ${error instanceof Error ? error.message : String(error)}` };
251
+ }
252
+ if (buffer.subarray(0, 8192).includes(0))
253
+ return { reason: "binary" };
254
+ return { content: buffer.toString("utf8") };
255
+ }
256
+ /**
257
+ * Exact-text search across a whole repository (R1).
258
+ *
259
+ * Enumerates with the indexer's prune rules but NOT its source-extension
260
+ * filter. That difference is the requirement: a product-name rename lives in
261
+ * markdown, UI strings and changelogs, so restricting the search to files that
262
+ * yield symbols would answer a different question than the one being asked.
263
+ *
264
+ * Every file that could not be searched is returned in `uncovered` (R5). A
265
+ * rename that silently skipped files would leave a half-renamed repository
266
+ * looking finished, which is the worst available outcome for this operation.
267
+ */
268
+ export async function searchRepoText(repoPath, term, loadLanguage, newParser, options = {}) {
269
+ let candidates;
270
+ try {
271
+ // Checked explicitly because `walkRepoFiles` swallows a missing directory
272
+ // and returns an empty list. Without this, searching a path that does not
273
+ // exist reports a completed search over zero files — "the term appears
274
+ // nowhere", which is the precise failure AC5 exists to make impossible.
275
+ const stats = fs.statSync(repoPath);
276
+ if (!stats.isDirectory())
277
+ throw new Error("not a directory");
278
+ candidates = walkRepoFiles(repoPath);
279
+ }
280
+ catch (error) {
281
+ // Enumeration failed, so nothing was searched. Reported as `searched:
282
+ // false` rather than as an empty match list, which would read as "the
283
+ // term appears nowhere" (AC5).
284
+ return {
285
+ matches: [],
286
+ uncovered: [
287
+ {
288
+ file: repoPath,
289
+ reason: `could not enumerate: ${error instanceof Error ? error.message : String(error)}`,
290
+ },
291
+ ],
292
+ filesSearched: 0,
293
+ searched: false,
294
+ };
295
+ }
296
+ const readable = [];
297
+ const uncovered = [];
298
+ for (const file of candidates) {
299
+ const outcome = readSearchable(path.join(repoPath, file));
300
+ if ("reason" in outcome)
301
+ uncovered.push({ file, reason: outcome.reason });
302
+ else
303
+ readable.push({ file, content: outcome.content });
304
+ }
305
+ const result = await searchFiles(readable, term, loadLanguage, newParser, options);
306
+ // Files skipped at read time and files that failed classification are both
307
+ // gaps in the same claim, so they are reported through one list.
308
+ return { ...result, uncovered: [...uncovered, ...result.uncovered] };
309
+ }
package/dist/src/init.js CHANGED
@@ -7,6 +7,7 @@ import { compareBytes } from "./compare.js";
7
7
  import { isIndexableSourcePath } from "./engine/source-policy.js";
8
8
  import { lookupMirror } from "./engine/state-paths.js";
9
9
  import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
10
+ import { stableInterpreterPath } from "./node-runtime.js";
10
11
  import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
11
12
  import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
12
13
  import { registerInitializedWorktree } from "./worktree-lifecycle.js";
@@ -426,7 +427,13 @@ function backgroundScript(command) {
426
427
  // silently switch which runtime executes, which is its own bug.
427
428
  const [interpreter, ...rest] = command;
428
429
  const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
429
- const preferred = shellQuote(interpreter ?? process.execPath);
430
+ // Recorded through `stableInterpreterPath` so a Homebrew interpreter is
431
+ // written as its version-stable `opt` path rather than the versioned Cellar
432
+ // path Node reports. Without it the preferred path is guaranteed to break on
433
+ // the next `brew upgrade` and every hook leans on the fallback below to stay
434
+ // alive — which works, but means the recorded path is wrong from the moment
435
+ // it is written (EASFDC-8497).
436
+ const preferred = shellQuote(stableInterpreterPath(interpreter ?? process.execPath));
430
437
  return String.raw `#!/bin/sh
431
438
  # knodin packaged background refresh. Generated by knodin init.
432
439
  set -u
@@ -136,6 +136,39 @@ function inspectManagedHooks(repo, hooksDirectory) {
136
136
  routing: hookRouting(wrappers, managerNative),
137
137
  };
138
138
  }
139
+ /**
140
+ * The interpreter a generated hook records, when that interpreter is gone.
141
+ *
142
+ * Existence and the executable bit say the script is runnable; they say nothing
143
+ * about whether the runtime on its first line still exists. A hook written
144
+ * against a Homebrew Cellar path stops working at the next `brew upgrade` and
145
+ * fails with exit 127 in the background, where the only trace is a log file
146
+ * (EASFDC-8497). Health then reported "degraded" without ever naming the cause,
147
+ * and `repair` could not help because the fault is in the hook's contents, not
148
+ * in graph state.
149
+ *
150
+ * Returns null when the hook is fine, unreadable, or shaped differently than
151
+ * expected — an unparseable hook is not evidence of a missing interpreter, and
152
+ * guessing here would invent a failure rather than report one.
153
+ */
154
+ function recordedInterpreterIfMissing(backgroundScriptPath) {
155
+ let contents;
156
+ try {
157
+ contents = fs.readFileSync(backgroundScriptPath, "utf8");
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ const recorded = /^KNODIN_NODE='([^']+)'/m.exec(contents)?.[1];
163
+ if (!recorded)
164
+ return null;
165
+ if (fs.existsSync(recorded))
166
+ return null;
167
+ // A working `node` on PATH means the hook's own fallback will carry it, so
168
+ // this is not currently breaking anything — but the recorded path is still
169
+ // wrong and the next environment without that fallback breaks silently.
170
+ return recorded;
171
+ }
139
172
  function readRefreshFailure(repo) {
140
173
  const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
141
174
  if (!fs.existsSync(failurePath))
@@ -218,6 +251,9 @@ export function inspectLifecycleHealth(repoPath) {
218
251
  const issues = missingHooks.map((hook) => `${hook} no longer routes through knodin's active Git hook path`);
219
252
  if (!backgroundReady)
220
253
  issues.push("background indexer is missing or not executable");
254
+ const staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
255
+ if (staleInterpreter)
256
+ issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin init\` to rewrite the hook`);
221
257
  const lastError = readRefreshFailure(repo);
222
258
  if (lastError)
223
259
  issues.push(`${lastError} See .knodin/indexer.log for details`);
@@ -155,3 +155,36 @@ export function handoffCurrentProcessToSupportedNodeRuntime() {
155
155
  listDirectories,
156
156
  });
157
157
  }
158
+ /**
159
+ * Rewrite a Homebrew Cellar path to the version-stable `opt` path it came from.
160
+ *
161
+ * `process.execPath` is symlink-resolved by Node, so invoking a stable entry
162
+ * point does not preserve it:
163
+ *
164
+ * $ /opt/homebrew/opt/node/bin/node -p "process.execPath"
165
+ * /opt/homebrew/Cellar/node/26.7.0/bin/node
166
+ *
167
+ * Both stable Homebrew entry points report the versioned path, which means the
168
+ * caller cannot avoid this by launching differently — by the time knodin can
169
+ * read its own interpreter, the durable path is already gone. Anything that
170
+ * records `execPath` for later therefore records a path Homebrew deletes on the
171
+ * next `brew upgrade`, and a managed hook written against it dies with exit 127
172
+ * (EASFDC-8497, and the 174k-file class of failure before it: a background
173
+ * process failing where nobody is looking).
174
+ *
175
+ * `<prefix>/opt/<formula>` is the symlink Homebrew repoints on upgrade, so the
176
+ * path survives by construction rather than by a runtime fallback. Derived from
177
+ * the matched prefix rather than hardcoding `/opt/homebrew`, so Intel
178
+ * (`/usr/local`) and Linuxbrew layouts work unchanged.
179
+ *
180
+ * Returns the input untouched when it is not a Cellar path, or when the `opt`
181
+ * path does not exist — a rewrite is only safe if the destination is real.
182
+ */
183
+ export function stableInterpreterPath(executable, exists = (candidate) => fs.existsSync(candidate)) {
184
+ const match = /^(.*)\/Cellar\/([^/]+)\/[^/]+\/(.+)$/.exec(executable);
185
+ if (!match)
186
+ return executable;
187
+ const [, prefix, formula, remainder] = match;
188
+ const candidate = `${prefix}/opt/${formula}/${remainder}`;
189
+ return exists(candidate) ? candidate : executable;
190
+ }
@@ -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,
@@ -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,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,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "knodin": {
5
5
  "compatibility": "compatible"
6
6
  },
@@ -75,6 +75,7 @@
75
75
  "docs/releases/0.10.1.md",
76
76
  "docs/releases/0.10.2.md",
77
77
  "docs/releases/0.10.3.md",
78
+ "docs/releases/0.10.4.md",
78
79
  "docs/assets/knodin-favicon.svg",
79
80
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
80
81
  "docs/TELEMETRY.md",