knodin 0.10.6 → 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,90 @@
1
+ /**
2
+ * One credential-pattern set, shared by every redactor.
3
+ *
4
+ * knodin previously carried two independent lists — one in `diagnostics.ts` for
5
+ * recorded failures, one in `output-compression.ts` for captured command output
6
+ * — and they drifted apart in OPPOSITE directions. Diagnostics knew Slack and
7
+ * OpenAI but not AWS or JWTs; output compression knew AWS and JWTs but missed
8
+ * Slack, OpenAI and GitHub's fine-grained tokens. Neither list was a superset of
9
+ * the other, so a credential redacted on one path was disclosed on the other,
10
+ * and patching either one alone would only let them drift again (KNODIN-11).
11
+ *
12
+ * Callers keep their own replacement text: diagnostics collapses a match to a
13
+ * single marker, output compression names the provider. `preservedGroups` is
14
+ * what makes that possible — the leading captures a caller may echo back
15
+ * verbatim, so `password=` survives while its value does not.
16
+ *
17
+ * Fresh `RegExp` objects are built per call. A shared global regex carries
18
+ * `lastIndex` between uses, which turns a redactor into something that depends
19
+ * on what it scrubbed previously.
20
+ */
21
+ export function credentialPatterns() {
22
+ return [
23
+ {
24
+ // `name=value` in any of its spellings. An optional auth scheme is
25
+ // consumed BEFORE the value, because `authorization: Bearer <token>`
26
+ // otherwise matches only the word "Bearer" — redacting the label and
27
+ // preserving the credential, exactly inverted (KNODIN-10).
28
+ label: "credential",
29
+ pattern: /\b(authorization|password|passwd|secret|token|api[_-]?key)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
30
+ preservedGroups: 2,
31
+ },
32
+ {
33
+ // GitHub issues every format with an UNDERSCORE. A hyphen-only pattern
34
+ // matched a shape GitHub never mints and missed every shape it does, so
35
+ // both separators are accepted. `github_pat` is listed explicitly: it
36
+ // does not fit the gh[pousr] shape.
37
+ label: "github-token",
38
+ pattern: /\b(?:gh[pousr]|github_pat)[-_]\w{20,}\b/g,
39
+ preservedGroups: 0,
40
+ },
41
+ {
42
+ label: "slack-token",
43
+ pattern: /\bxox[baprse]-[A-Za-z0-9-]{10,}\b/g,
44
+ preservedGroups: 0,
45
+ },
46
+ {
47
+ label: "openai-key",
48
+ pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
49
+ preservedGroups: 0,
50
+ },
51
+ {
52
+ label: "aws-access-key",
53
+ pattern: /\bAKIA[A-Z0-9]{16}\b/g,
54
+ preservedGroups: 0,
55
+ },
56
+ {
57
+ label: "jwt",
58
+ pattern: /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g,
59
+ preservedGroups: 0,
60
+ },
61
+ {
62
+ // Bearer outside an `authorization:` assignment — a bare header line, or
63
+ // a curl command echoed into a log.
64
+ label: "bearer-token",
65
+ pattern: /\b(Bearer\s+)[-\w.~+/]{12,}=*/gi,
66
+ preservedGroups: 1,
67
+ },
68
+ ];
69
+ }
70
+ /**
71
+ * Apply every shared pattern, letting the caller render the replacement.
72
+ *
73
+ * `marker` receives the provider label; preserved groups are prepended to
74
+ * whatever it returns.
75
+ */
76
+ export function redactCredentials(text, marker) {
77
+ let result = text;
78
+ let count = 0;
79
+ for (const { label, pattern, preservedGroups } of credentialPatterns()) {
80
+ result = result.replace(pattern, (...values) => {
81
+ count++;
82
+ const kept = values
83
+ .slice(1, 1 + preservedGroups)
84
+ .map((value) => (typeof value === "string" ? value : ""))
85
+ .join("");
86
+ return `${kept}${marker(label)}`;
87
+ });
88
+ }
89
+ return { text: result, count };
90
+ }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import zlib from "node:zlib";
8
8
  import { compareBytes } from "./compare.js";
9
+ import { redactCredentials } from "./credential-patterns.js";
9
10
  const CONFIG_PATH = ".knodin/diagnostics/config.json";
10
11
  const JOURNAL_PATH = ".knodin/diagnostics/events.jsonl";
11
12
  const JOURNAL_LOCK_PATH = ".knodin/diagnostics/events.lock";
@@ -528,19 +529,17 @@ function scrubText(raw, repo) {
528
529
  };
529
530
  for (const exact of [repo, os.homedir()].filter(Boolean).sort((a, b) => b.length - a.length))
530
531
  replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`), "g"), "<path>");
531
- // Either separator. GitHub issues its tokens with an UNDERSCORE after the
532
- // prefix, so a hyphen-only pattern matched a shape GitHub never mints and
533
- // missed every shape it does a real token in an error message survived
534
- // scrubbing and reached the bundle a user shares (KNODIN-10). Slack and
535
- // OpenAI really do use a hyphen, so both separators must be accepted.
536
- replace(/\b(?:ghp|github_pat|sk|xox[baprs])[-_][-A-Za-z0-9_]{10,}\b/g, "<secret>");
537
- // The value pattern stops at whitespace, so `authorization: Bearer <token>`
538
- // used to match only the word "Bearer" and leave the credential in place —
539
- // the standard header form leaked the one thing worth redacting (KNODIN-10).
540
- // An optional scheme is consumed first so the credential after it is the
541
- // part that gets replaced. Only known schemes are skipped, so an ordinary
542
- // `password=x next word` still redacts one value rather than eating prose.
543
- replace(/\b(?:password|passwd|token|secret|api[_-]?key|authorization)\s*[=:]\s*(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi, "<secret>");
532
+ // Provider credentials, from the set shared with output-compression (see
533
+ // credential-patterns.ts). These used to be maintained here separately, and
534
+ // the two lists drifted: this one knew Slack and OpenAI but not AWS keys or
535
+ // JWTs, while output compression knew those and missed these. A credential
536
+ // safe on one path was disclosed on the other (KNODIN-11).
537
+ //
538
+ // `redactCredentials` counts its own replacements, so they are added to the
539
+ // running total rather than counted by the local `replace` helper.
540
+ const credentials = redactCredentials(value, () => "<secret>");
541
+ value = credentials.text;
542
+ redactions += credentials.count;
544
543
  replace(/\b[A-Z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "<path>");
545
544
  replace(/(?:^|[\s('"`])\/(?:[^\s)'"`]+\/)*[^\s)'"`]*/g, "<path>");
546
545
  replace(/\b(?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12})\b/g, "<path>");
@@ -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.
@@ -8779,26 +8780,28 @@ export async function getOrInitDb(repoPath, options = {}) {
8779
8780
  else {
8780
8781
  const stateDir = resolveStateDir(normalizedPath);
8781
8782
  await fs.promises.mkdir(stateDir, { recursive: true });
8782
- // Ensure .knodin is in .gitignore. Skipped for mirrors: their state
8783
- // lives outside the clone, and the clone must stay byte-identical to
8784
- // the remote so a refetch has nothing of ours to discard.
8783
+ // Ensure .knodin is ignored, through `.git/info/exclude` rather than
8784
+ // `.gitignore`.
8785
+ //
8786
+ // `.gitignore` is a SHARED file: it is tracked by convention, so
8787
+ // writing it put a change into someone's working tree that they did
8788
+ // not ask for and would carry into their next commit. That is the
8789
+ // one thing `knodin init --scope personal` promises not to do, and
8790
+ // this ran regardless of scope — a bare `knodin index` did it too
8791
+ // (KNODIN-1).
8792
+ //
8793
+ // `.git/info/exclude` has the same effect and is repo-local: git
8794
+ // never commits it, so nothing reaches a diff or a teammate.
8795
+ //
8796
+ // Skipped for mirrors: their state lives outside the clone, and the
8797
+ // clone must stay byte-identical to the remote so a refetch has
8798
+ // nothing of ours to discard.
8785
8799
  if (mayWriteToRepository(normalizedPath)) {
8786
8800
  try {
8787
- const gitignorePath = path.join(normalizedPath, ".gitignore");
8788
- let gitignoreContent = "";
8789
- if (fs.existsSync(gitignorePath)) {
8790
- gitignoreContent = await fs.promises.readFile(gitignorePath, "utf-8");
8791
- }
8792
- const lines = gitignoreContent.split("\n").map((l) => l.trim());
8793
- if (!lines.includes(".knodin") &&
8794
- !lines.includes(".knodin/") &&
8795
- !lines.includes("/.knodin")) {
8796
- const prefix = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
8797
- await fs.promises.appendFile(gitignorePath, `${prefix}\n# knodin\n.knodin\n`);
8798
- }
8801
+ await excludeStateDirectoryLocally(normalizedPath);
8799
8802
  }
8800
8803
  catch (e) {
8801
- console.error("Failed to update .gitignore", e);
8804
+ console.error("Failed to exclude .knodin from git", e);
8802
8805
  }
8803
8806
  }
8804
8807
  dbPath = path.join(stateDir, "db.sqlite");
@@ -10092,6 +10095,45 @@ function parseUnifiedDiff(stdout) {
10092
10095
  }
10093
10096
  return modifiedFiles;
10094
10097
  }
10098
+ /**
10099
+ * Ignore `.knodin/` through `.git/info/exclude`, which git never commits.
10100
+ *
10101
+ * Resolved with `git rev-parse --git-path` rather than assembled by hand:
10102
+ * inside a linked worktree `.git` is a FILE pointing elsewhere, so
10103
+ * `<repo>/.git/info/exclude` is not a path that exists. Idempotent — a second
10104
+ * call over an already-excluded repository writes nothing.
10105
+ */
10106
+ async function excludeStateDirectoryLocally(repoPath) {
10107
+ // A directory that is not a git repository has nothing to exclude, and git
10108
+ // exits non-zero there. That is an ordinary state — knodin indexes plain
10109
+ // directories — so it must not surface as an error. The previous
10110
+ // `.gitignore` write never had to ask git anything, which is why this case
10111
+ // only appears now.
10112
+ let raw;
10113
+ try {
10114
+ raw = runGit(repoPath, ["rev-parse", "--git-path", "info/exclude"]).trim();
10115
+ }
10116
+ catch {
10117
+ return;
10118
+ }
10119
+ if (!raw)
10120
+ return;
10121
+ const excludePath = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw);
10122
+ let existing = "";
10123
+ try {
10124
+ existing = await fs.promises.readFile(excludePath, "utf-8");
10125
+ }
10126
+ catch (error) {
10127
+ if (error.code !== "ENOENT")
10128
+ throw error;
10129
+ }
10130
+ const entries = new Set(existing.split(/\r?\n/).map((line) => line.trim()));
10131
+ if (entries.has(".knodin") || entries.has(".knodin/") || entries.has("/.knodin"))
10132
+ return;
10133
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
10134
+ await fs.promises.mkdir(path.dirname(excludePath), { recursive: true });
10135
+ await fs.promises.appendFile(excludePath, `${prefix}.knodin/\n`, "utf-8");
10136
+ }
10095
10137
  function runGit(repoPath, args) {
10096
10138
  return child_process.execFileSync("git", args, {
10097
10139
  cwd: repoPath,
@@ -13042,6 +13084,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13042
13084
  verification: { mode: "deep-audit", verifiedAt },
13043
13085
  freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
13044
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
+ : []),
13045
13092
  "Run `knodin repair` to create the local index.",
13046
13093
  "Run `knodin status` again to verify health.",
13047
13094
  ],
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { redactCredentials } from "./credential-patterns.js";
4
5
  import { resolveStateDir } from "./engine/state-paths.js";
5
6
  const DEFAULT_LINE_BUDGET = 200;
6
7
  const DEFAULT_BYTE_BUDGET = 16_384;
@@ -90,20 +91,20 @@ function splitEvent(event) {
90
91
  lines.push({ raw: "", sourceBytes: 0 });
91
92
  return lines;
92
93
  }
94
+ /**
95
+ * Redact credentials from captured output.
96
+ *
97
+ * The pattern set is shared with `diagnostics.ts` (see credential-patterns.ts).
98
+ * It used to be maintained here separately, and the two lists drifted: this one
99
+ * knew AWS keys and JWTs but missed Slack, OpenAI and GitHub's fine-grained
100
+ * tokens, while diagnostics missed AWS and JWTs. A credential safe on one path
101
+ * was disclosed on the other (KNODIN-11).
102
+ *
103
+ * The provider-named markers are kept, because a reader of compressed output
104
+ * benefits from knowing WHAT was removed.
105
+ */
93
106
  function redact(text) {
94
- let count = 0;
95
- const replace = (pattern, replacement) => {
96
- text = text.replace(pattern, (...values) => {
97
- count++;
98
- return typeof replacement === "string" ? replacement : replacement(...values);
99
- });
100
- };
101
- replace(/\b(authorization|password|passwd|secret|token|api[_-]?key)\s*([:=])\s*([^\s,;]+)/gi, (_match, name, separator) => `${name}${separator}[REDACTED:credential]`);
102
- replace(/\bAKIA[A-Z0-9]{16}\b/g, "[REDACTED:aws-access-key]");
103
- replace(/\bgh[pousr]_\w{20,}\b/g, "[REDACTED:github-token]");
104
- replace(/\bBearer\s+[-\w.~+/]{12,}=*\b/gi, "Bearer [REDACTED:bearer-token]");
105
- replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g, "[REDACTED:jwt]");
106
- return { text, count };
107
+ return redactCredentials(text, (label) => `[REDACTED:${label}]`);
107
108
  }
108
109
  function csiSequenceEnd(text, escapeIndex) {
109
110
  for (let index = escapeIndex + 2; index < text.length; index++) {
@@ -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,79 @@
1
+ # knodin 0.10.7
2
+
3
+ Two fixes, both about knodin doing something to your repository or your
4
+ credentials that you did not ask for.
5
+
6
+ ## knodin no longer writes to your .gitignore
7
+
8
+ Indexing appended `.knodin` to `.gitignore`. That file is **shared**: tracked by
9
+ convention, so the edit landed in your working tree and would ride into your next
10
+ commit — and on a narrowly-scoped branch, `git add -A` swept it in.
11
+
12
+ It ran regardless of scope, so a bare `knodin index` did it too. That matters
13
+ because `knodin init --scope personal` exists precisely to promise that nothing
14
+ reaches git.
15
+
16
+ The same protection now goes to `.git/info/exclude`, which git never commits.
17
+ The rule is resolved through `git rev-parse --git-path` rather than assembled by
18
+ hand, because inside a linked worktree `.git` is a file and
19
+ `<repo>/.git/info/exclude` is not a path that exists. A directory that is not a
20
+ git repository has nothing to exclude and is left alone silently — knodin indexes
21
+ plain directories too.
22
+
23
+ ### What this changed about freshness reporting
24
+
25
+ Worth knowing if you have ever read the `staleness` field.
26
+
27
+ Because every repository knodin touched carried that untracked `.gitignore`, its
28
+ working tree was permanently dirty, and a dirty tree forces the reconcile path.
29
+ So a branch switch reported `reconciled`. With a genuinely clean tree the same
30
+ switch reports `fresh`.
31
+
32
+ Nothing about the answer changed — measured directly, the query returns the
33
+ symbols of the branch you are on, with no trace of the one you left. `fresh` is
34
+ accurate: the graph does match the tree. Only the label is different, and it is
35
+ different because the tree is finally clean.
36
+
37
+ Three internal assertions had been reading that side effect rather than the
38
+ behaviour they named, and now measure the returned symbols instead.
39
+
40
+ ## One credential-pattern set, not two
41
+
42
+ knodin had **two independent secret redactors** — one for diagnostics bundles,
43
+ one for compressed command output — each with its own list. They had drifted in
44
+ opposite directions:
45
+
46
+ | | diagnostics | output compression |
47
+ |---|---|---|
48
+ | GitHub classic | yes | yes |
49
+ | GitHub fine-grained | yes | **no** |
50
+ | Slack | yes | **no** |
51
+ | OpenAI | yes | **no** |
52
+ | AWS access key | **no** | yes |
53
+ | JWT | **no** | yes |
54
+
55
+ Neither list was a superset of the other, so the same log line was scrubbed on
56
+ one path and disclosed on the other. Redaction is on by default, so nothing
57
+ unusual had to be configured to hit this.
58
+
59
+ Both now use one shared set covering every row. Each caller keeps its own
60
+ replacement text: compressed output still names the provider, because a reader
61
+ benefits from knowing what was removed, while diagnostics collapses to a single
62
+ marker. Both preserve the key name and drop only the value, so `password=`
63
+ survives and its secret does not.
64
+
65
+ Tests assert every provider on **both** paths — a property no single-module test
66
+ could hold. What redaction must leave alone is pinned too: a 40-character hex
67
+ git SHA passes through untouched, because redacting commit SHAs would make diffs
68
+ and review output unreadable.
69
+
70
+ ## Coverage
71
+
72
+ Measured across all sixteen shards: 78.22% branches, 87.77% statements, 91.20%
73
+ functions, 90.33% lines.
74
+
75
+ The 77.16% recorded in 0.10.6 came from fifteen shards, the sixteenth having
76
+ failed to write its report. That number was described at the time as a floor. It
77
+ was not — a missing shard shrinks the denominator faster than the numerator, so
78
+ it flattered the result. A partial-shard measurement is unusable rather than
79
+ conservative.
@@ -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.6",
3
+ "version": "0.10.8",
4
4
  "knodin": {
5
5
  "compatibility": "compatible"
6
6
  },
@@ -61,6 +61,8 @@
61
61
  "docs/releases/0.10.4.md",
62
62
  "docs/releases/0.10.5.md",
63
63
  "docs/releases/0.10.6.md",
64
+ "docs/releases/0.10.7.md",
65
+ "docs/releases/0.10.8.md",
64
66
  "docs/releases/0.3.0.md",
65
67
  "docs/releases/0.4.0.md",
66
68
  "docs/releases/0.4.1.md",