knodin 0.10.7 → 0.10.8

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
@@ -456,10 +456,17 @@ function formatStatusHuman(result) {
456
456
  ? result.missing.records[0]
457
457
  : (result.missing.files[0] ?? result.missing.records[0]);
458
458
  const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
459
- const repairCommand = result.lifecycle?.status === "degraded" &&
460
- result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
461
- ? "Run `knodin init`."
462
- : "Run `knodin repair`.";
459
+ // A linked worktree with no database is the one case where `repair` is both
460
+ // the wrong first step and the expensive one: `init` seeds from an indexed
461
+ // sibling and reconciles only what differs, while `repair` builds from
462
+ // scratch. The engine has already worked out that this is that case, so
463
+ // defer to the step it wrote rather than recomputing the judgement here.
464
+ const worktreeStep = result.repairSteps?.find((step) => step.includes("per-worktree"));
465
+ const repairCommand = worktreeStep ??
466
+ (result.lifecycle?.status === "degraded" &&
467
+ result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
468
+ ? "Run `knodin init`."
469
+ : "Run `knodin repair`.");
463
470
  return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
464
471
  }
465
472
  function humanLabel(key) {
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Git checkout-layout questions, kept out of the engine on purpose.
5
+ *
6
+ * `engine/index.ts` carries a pinned budget on direct `fs` reads
7
+ * (`sealed-reader-inventory.spec.ts`) because a sealed artifact answers with no
8
+ * working tree: a reader that touches the filesystem there gets ENOENT, which
9
+ * the nearest guard turns into `""` or `null`, and that reads downstream as
10
+ * "this symbol has no body" rather than "this was not covered".
11
+ *
12
+ * Git *metadata* is categorically outside that concern — a sealed artifact has
13
+ * no `.git` at all, and these functions are never on a source-content path — so
14
+ * routing them through the sealed resolver would add a branch that can never
15
+ * execute. Keeping them here says that in the layout rather than by spending
16
+ * budget the engine reserves for source reads, and it makes them directly
17
+ * unit-testable, which they are not as engine-private helpers.
18
+ */
19
+ /**
20
+ * True only for a `git worktree add` checkout.
21
+ *
22
+ * A linked worktree's `.git` is a FILE pointing at an administrative directory
23
+ * under the main checkout, and that directory contains `commondir` naming the
24
+ * shared repository.
25
+ *
26
+ * The `.git` file alone is NOT sufficient and treating it as sufficient is a
27
+ * live bug: submodules and `--separate-git-dir` clones use one too, and neither
28
+ * has a main checkout whose graph could cover it, so per-worktree guidance would
29
+ * be actively wrong for them. `commondir` is the marker that actually
30
+ * distinguishes the case.
31
+ */
32
+ export function isLinkedWorktree(repo) {
33
+ const marker = path.join(repo, ".git");
34
+ try {
35
+ // An ordinary checkout keeps a `.git` directory; `statSync` throws when
36
+ // there is no `.git` at all, which is an ordinary case — knodin indexes
37
+ // plain directories too.
38
+ if (!fs.statSync(marker).isFile())
39
+ return false;
40
+ const pointer = fs.readFileSync(marker, "utf8").trim();
41
+ const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
42
+ if (!gitDir)
43
+ return false;
44
+ return fs.existsSync(path.join(path.resolve(repo, gitDir), "commondir"));
45
+ }
46
+ catch {
47
+ // Every read is inside the guard on purpose. This is best-effort detection
48
+ // reached from the missing-database remediation path, and that path has to
49
+ // answer: an unreadable `.git`, or one that disappears between the stat and
50
+ // the read, must degrade to "not a worktree" rather than crash `status`.
51
+ return false;
52
+ }
53
+ }
@@ -32,6 +32,7 @@ import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidat
32
32
  import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
33
33
  import { walkRepoFiles } from "./file-walker.js";
34
34
  import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
35
+ import { isLinkedWorktree } from "./git-layout.js";
35
36
  // Runtime import, but not a cycle: parse-pool imports only TYPES from here,
36
37
  // which erase at compile time. parse-worker's runtime import of this module
37
38
  // resolves inside the worker thread, never in this one.
@@ -13083,6 +13084,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13083
13084
  verification: { mode: "deep-audit", verifiedAt },
13084
13085
  freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
13085
13086
  repairSteps: [
13087
+ ...(isLinkedWorktree(resolved)
13088
+ ? [
13089
+ "Run `knodin init` here: graphs are per-worktree and this one has none of its own, so the main checkout's graph does not cover it. `init` seeds from an indexed sibling worktree where one exists rather than rebuilding from scratch.",
13090
+ ]
13091
+ : []),
13086
13092
  "Run `knodin repair` to create the local index.",
13087
13093
  "Run `knodin status` again to verify health.",
13088
13094
  ],
@@ -24,6 +24,36 @@ Results distinguish:
24
24
  - stale linked-worktree metadata;
25
25
  - an unrelated repository under the same discovery root.
26
26
 
27
+ ### Graphs are per-worktree
28
+
29
+ A linked worktree carries its own `.knodin` with its own graph, `indexedHead`,
30
+ and freshness. A healthy main checkout does not cover it, so a worktree created
31
+ by `git worktree add` has **no graph until `knodin init` runs inside it**.
32
+
33
+ `git worktree add` creates no `.knodin` of its own — the directory is knodin's,
34
+ written by `init` and by the managed lifecycle hooks. Where those hooks are
35
+ already installed in the repository, a new worktree can therefore appear to have
36
+ a `.knodin` while holding no graph at all, which is the more confusing shape of
37
+ this: the directory exists, and it is empty of anything that can answer.
38
+
39
+ That does not mean a new worktree pays for a full index. `init` selects the
40
+ already-indexed sibling worktree closest in commit distance, reflink-copies its
41
+ baseline where the filesystem supports it, reconciles only the paths that differ
42
+ between that sibling's `indexedHead` and this HEAD, deep-audits the result, and
43
+ promotes it only if it is healthy — falling back to a full index when no
44
+ schema- and build-compatible sibling exists. `KNODIN_DISABLE_WORKTREE_SEED=1`
45
+ forces the full path.
46
+
47
+ Seeding is on the `init` path only, which is why an uninitialized worktree is
48
+ steered to `init` rather than `repair`: both produce a correct index, but
49
+ `repair` rebuilds from scratch.
50
+
51
+ Nothing silently answers from the wrong graph in that state: structural queries
52
+ refuse with `available: false`, `state: not-initialized`, and exit code 1 rather
53
+ than returning an empty result, and status leads its remediation with the
54
+ per-worktree model and `knodin init`. Read a refusal as "this checkout was never
55
+ indexed", never as "there is nothing here to find".
56
+
27
57
  ### Opt-in repository signals
28
58
 
29
59
  `repos discover --json --signals` adds a deterministic `signals` object to
@@ -0,0 +1,165 @@
1
+ # knodin 0.10.8
2
+
3
+ One change, prompted by a field report from an agent session that built a
4
+ duplicate implementation of a hook that already existed. Most of that report
5
+ described behaviour knodin already has; the part it got right is fixed here.
6
+
7
+ ## A linked worktree with no graph now says so in its remediation
8
+
9
+ `git worktree add` gives you a checkout with no graph in it. Graphs are
10
+ **per-worktree**, so a perfectly healthy main checkout tells you nothing about
11
+ the worktree you are standing in — and that makes the missing graph *more*
12
+ surprising, not less.
13
+
14
+ Git creates no `.knodin`; that directory is knodin's, written by `init` and by
15
+ the managed lifecycle hooks. Where those hooks are already installed, a fresh
16
+ worktree can present a `.knodin` that holds no graph — which is the more
17
+ confusing shape of this, and the one the original report hit.
18
+
19
+ Until now, status in that state produced generic remediation:
20
+
21
+ ```
22
+ remediation:
23
+ - Run `knodin repair` to create the local index.
24
+ - Run `knodin status` again to verify health.
25
+ ```
26
+
27
+ That guidance works — `repair` does build the index from cold, and then tells
28
+ you to run `init` for lifecycle routing. What it does not do is explain *why*
29
+ there was no index in a repository whose main checkout is fine. A reader who
30
+ does not already know the per-worktree model has no way to tell "this checkout
31
+ was never indexed" from "there is nothing here to find", and the second reading
32
+ is the one that quietly confirms whatever hypothesis sent them looking.
33
+
34
+ A linked worktree now leads with the model and the fix, on both the
35
+ machine-readable `remediation` and the human `status` line:
36
+
37
+ ```
38
+ Run `knodin init` here: graphs are per-worktree and this one has none of its
39
+ own, so the main checkout's graph does not cover it. `init` seeds from an
40
+ indexed sibling worktree where one exists rather than rebuilding from scratch.
41
+ ```
42
+
43
+ Ordinary checkouts are unchanged. Detection reads the `.git` file's `gitdir:`
44
+ pointer and requires a `commondir` beside it — no subprocess, no registry read,
45
+ and it works on a repository too damaged to answer anything else.
46
+
47
+ A `.git` **file** alone is not enough, and treating it as enough was wrong in
48
+ the first cut of this fix: submodules and `--separate-git-dir` clones use one
49
+ too, and neither has a main checkout whose graph could cover it, so they would
50
+ have been handed an explanation that does not apply to them. `commondir` is
51
+ what actually distinguishes a linked worktree.
52
+
53
+ The check lives in a new `src/engine/git-layout.ts` rather than in the engine,
54
+ for a reason worth stating. `engine/index.ts` carries a pinned budget on direct
55
+ `fs` reads, because a sealed artifact answers with no working tree and a reader
56
+ that touches the filesystem there gets ENOENT — which the nearest guard turns
57
+ into `""` or `null`, and which reads downstream as "this symbol has no body"
58
+ rather than "this was not covered". Git *metadata* is categorically outside
59
+ that concern: a sealed artifact has no `.git` at all, so routing these reads
60
+ through the sealed resolver would add a branch that can never execute. Saying
61
+ so in the layout is more honest than spending budget the engine reserves for
62
+ source reads — and it makes the check directly unit-testable, which it was not
63
+ as an engine-private helper.
64
+
65
+ Its spec covers every branch (100% of statements and branches): a plain
66
+ directory, an ordinary checkout, a real linked worktree, a real submodule, a
67
+ real `--separate-git-dir` clone, a malformed `.git` file naming no gitdir, and a
68
+ dangling pointer whose administrative directory was pruned.
69
+
70
+ ### Why `init` and not `repair` here
71
+
72
+ `repair` does build the index from cold, and for an ordinary checkout it stays
73
+ the right advice. In a linked worktree it is the *expensive* answer: seeding
74
+ lives on the `init` path only (`indexOrSeed`), so `repair` rebuilds from scratch
75
+ while `init` reflink-copies an indexed sibling's baseline, reconciles only the
76
+ paths that differ between that sibling's `indexedHead` and this HEAD,
77
+ deep-audits the candidate, and promotes it only if it passes — falling back to a
78
+ full index when no compatible sibling exists.
79
+
80
+ The human `status` renderer had been computing its own one-line advice and
81
+ ignoring `repairSteps` entirely, so the first fix reached the JSON surface and
82
+ not the line a person actually reads. It now defers to the step the engine
83
+ already wrote. Measured on a 20-file fixture, `init` in a fresh worktree with an
84
+ indexed sibling completes in about three seconds.
85
+
86
+ Worth stating plainly, because the surprise runs the other way from the cost:
87
+ the per-worktree model does **not** mean a new worktree pays for a full index.
88
+
89
+ ## What the same report claimed that measurement did not support
90
+
91
+ Recorded because acting on any of these would have been a regression, and
92
+ because each was reported in good faith from accumulated notes rather than from
93
+ a run against this release.
94
+
95
+ **"Structural queries return empty rather than erroring on an uninitialized
96
+ graph."** They do not, and have not for some time. Against a committed
97
+ repository with no index, `knodin query callers_of <symbol>` returns
98
+ `available: false`, `status: unavailable`, `state: not-initialized`, the full
99
+ status envelope, and **exit code 1**. The same holds for `impact`, `dead_code`,
100
+ `tests_for`, and `file_summary`, on both the CLI and the MCP gateway — both
101
+ route through the same `inspectGraphQueryHealth` gate before and after the
102
+ operation runs. There was no empty result set to fix.
103
+
104
+ This one matters most, because an empty structural result that agrees with your
105
+ hypothesis is the worst failure this tool could have. It is worth restating that
106
+ the gate is there, and that it fails loudly on both surfaces.
107
+
108
+ **"`knodin init --scope personal` exits 0 having done nothing."** It exits **1**.
109
+ The refusal is thrown, and every non-`agent-event` throw reaches the top-level
110
+ handler, which prints to stderr and exits 1. Reproduced against a repository with
111
+ tracked team integration active.
112
+
113
+ **"A backgrounded `knodin repair` produces no progress output."** Repair emits
114
+ ordered per-phase progress on stderr — audit, planning, file reconciliation,
115
+ symbol identities, embeddings, orphan cleanup, verification — and finishes with a
116
+ verified summary naming indexed file and symbol counts.
117
+
118
+ Both of these turned out to have the same cause, since confirmed by the
119
+ reporter: the commands were run through `| tail`. A pipeline's exit status is the
120
+ last command's, so `init`'s 1 was recorded as `tail`'s 0; and `tail` flushes
121
+ nothing until the stream closes, so a capture file read mid-run looked empty
122
+ while repair was in fact reporting every phase. Neither surface changed. Worth
123
+ naming because one habit produced two bug reports against the wrong component.
124
+
125
+ ## Not changed here: semantic search ranking
126
+
127
+ A follow-up from the same session measured something real and unaddressed: on a
128
+ healthy graph, `"hands-free voice conversation mode hook with barge-in"` ranked a
129
+ constant list (`BARGE_IN_PHRASES`, 0.588) and the caller's own minutes-old
130
+ duplicate (0.552) above the canonical 513-line implementation, which did not
131
+ appear in the top hits at all. Mechanism-shaped phrasing
132
+ (`"voice pipeline auto restart mic after TTS"`) put the canonical hook at #2,
133
+ 0.517.
134
+
135
+ So feature-shaped phrasing — the phrasing a developer uses when asking "does this
136
+ already exist?" — ranked worst for exactly that question, and surfaced the new
137
+ duplicate as apparent confirmation. Centrality data the fix would need
138
+ (in-degree, community membership) is already in the graph and already in the
139
+ output; it is not weighted into ranking.
140
+
141
+ That is a ranking change with its own evaluation burden and it is not bundled
142
+ into a remediation-string fix. It is recorded here so it is not rediscovered from
143
+ scratch.
144
+
145
+ ## The pre-push test gate was not gating
146
+
147
+ Found because this release's own broken commit sailed through it.
148
+
149
+ `lefthook.yml`'s `quality-gates` block ran `bun run test:coverage` and then
150
+ `sh scripts/run-sonar-scan.sh` as two lines of one shell block with no
151
+ `set -e`. A shell block's exit status is its **last** command's, so a failing
152
+ suite followed by a passing Sonar scan reported success, and the push was
153
+ allowed. The suite printed `1 failed | 2058 passed` and
154
+ `script "test:coverage" exited with code 1`, and the push completed anyway.
155
+
156
+ `set -e` now leads the block. Coverage thresholds and Sonar were unaffected;
157
+ what was broken is that a red suite could not stop a push.
158
+
159
+ ## Verification
160
+
161
+ `npm test`, `npm run lint`, and `npm run typecheck` pass. The new behaviour is
162
+ pinned by a test in `src/__tests__/unit/index-health.spec.ts` that builds a real
163
+ linked worktree with `git worktree add`, asserts the per-worktree line leads its
164
+ remediation, and asserts the ordinary checkout keeps repair-first guidance with
165
+ no per-worktree line.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.7",
3
+ "version": "0.10.8",
4
4
  "knodin": {
5
5
  "compatibility": "compatible"
6
6
  },
@@ -62,6 +62,7 @@
62
62
  "docs/releases/0.10.5.md",
63
63
  "docs/releases/0.10.6.md",
64
64
  "docs/releases/0.10.7.md",
65
+ "docs/releases/0.10.8.md",
65
66
  "docs/releases/0.3.0.md",
66
67
  "docs/releases/0.4.0.md",
67
68
  "docs/releases/0.4.1.md",