@williamthorsen/kb 0.5.0 → 0.6.0

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/README.md CHANGED
@@ -5,15 +5,13 @@ Provides knowledge-base discovery, registry loading, frontmatter parsing and wri
5
5
  It underpins the knowledge-base skills — among them `kb-retrieve` (assertion recall) and `kb-retrieve-events` (event recall), `kb-add`, `kb-curate`, `capture-event`, and `kb-update-events` — and the planned `@williamthorsen/kb-mcp` server.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v0.5.0 (2026-08-08)
8
+ ## Release notes — v0.6.0 (2026-08-13)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Register a new store with a description and keep the registry sorted (#1237)
12
+ - Attach causes to kb's loader errors and retire its lint deferral (#1272)
13
13
 
14
- Adds a `--description` flag to `kb create`, so that a new knowledge base can be given a description as it is created. Alphabetical ordering of keys in `kb.yaml` is now enforced on every write.
15
-
16
- Also fixes an issue where creating a knowledge base under an empty name could leave a stray registry entry. Across the CLI, a flag given an empty value is now refused.
14
+ Errors `kb` raises on malformed input now carry the underlying parse error as their cause. Callers constructing one of these errors themselves can attach a cause. When `kb` reports a thrown error that carries no message, the diagnostic now names the error's class instead of trailing off empty.
17
15
  <!-- /section:release-notes -->
18
16
 
19
17
  ## Exports
@@ -284,7 +282,7 @@ Without `--merge`, a store that already declares a taxonomy is left untouched an
284
282
 
285
283
  ## Error and exception model
286
284
 
287
- The checks **return** findings; they never throw. Loaders (`loadKbConfig`, `loadAliases`, `loadTaxonomy`) **throw** a typed `KbLoaderError` on structural defects or malformed YAML, with the offending file path named in the message. `KbLoaderError` (exported from `@williamthorsen/kb/config`) carries a `kind: 'KbLoaderError'` discriminant — and an `isKbLoaderError` type guard — so a caller can distinguish a recoverable config or alias defect from any other throw. `loadKbRegistry` throws a plain `Error` on its own structural defects. I/O errors other than a missing optional file propagate.
285
+ The checks **return** findings; they never throw. Loaders (`loadKbConfig`, `loadAliases`, `loadTaxonomy`) **throw** a typed `KbLoaderError` on structural defects or malformed YAML, with the offending file path named in the message. `KbLoaderError` (exported from `@williamthorsen/kb/config`) carries a `kind: 'KbLoaderError'` discriminant — and an `isKbLoaderError` type guard — so a caller can distinguish a recoverable config or alias defect from any other throw. An underlying failure, such as a YAML parse error, is attached as the thrown error's `cause`. `loadKbRegistry` throws a plain `Error` on its own structural defects. I/O errors other than a missing optional file propagate.
288
286
 
289
287
  ## MCP wrappability
290
288
 
package/bin/kb.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Imports only node builtins: a top-level import resolves before the gate below runs, so an
4
+ // unresolvable dependency would replace this file's build-first message with ERR_MODULE_NOT_FOUND.
3
5
  import { existsSync } from 'node:fs';
4
6
 
5
7
  // Thin wrapper so pnpm can symlink the bin at install time, before `dist/` exists.
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import process from 'node:process';
4
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
5
  import { createNoteScopeMatcher } from "../config/note-scope.js";
5
6
  import { readNoteContent } from "../note-io/read-note.js";
6
7
  import { isGlobSegment } from "./glob-segments.js";
@@ -26,8 +27,7 @@ export async function enumerateNotes(input) {
26
27
  });
27
28
  }
28
29
  catch (error) {
29
- const message = error instanceof Error ? error.message : String(error);
30
- process.stderr.write(`kb: warning: could not read note ${path}; skipping: ${message}\n`);
30
+ process.stderr.write(`kb: warning: could not read note ${path}; skipping: ${describeError(error)}\n`);
31
31
  }
32
32
  }
33
33
  return notes;
@@ -58,8 +58,7 @@ async function walk(input) {
58
58
  entries = await readdir(dir, { withFileTypes: true });
59
59
  }
60
60
  catch (error) {
61
- const message = error instanceof Error ? error.message : String(error);
62
- process.stderr.write(`kb: warning: could not read directory ${dir}; skipping: ${message}\n`);
61
+ process.stderr.write(`kb: warning: could not read directory ${dir}; skipping: ${describeError(error)}\n`);
63
62
  return;
64
63
  }
65
64
  const atRoot = dir === root;
@@ -1,3 +1,4 @@
1
+ import { describeError } from '@williamthorsen/toolbelt.errors';
1
2
  import { check } from "../../check/check.js";
2
3
  import { isKbLoaderError } from "../../config/kb-loader-error.js";
3
4
  import { formatHuman, formatJson, summarize } from "../format.js";
@@ -119,8 +120,7 @@ export function parseCheckArgs(argv) {
119
120
  return { kb, json, help, patterns, vs };
120
121
  }
121
122
  function buildUsageError(error) {
122
- const message = error instanceof Error ? error.message : String(error);
123
- return { exitCode: 2, stdout: '', stderr: `kb check: ${message}\n${CHECK_HELP}` };
123
+ return { exitCode: 2, stdout: '', stderr: `kb check: ${describeError(error)}\n${CHECK_HELP}` };
124
124
  }
125
125
  async function resolveSelection(input) {
126
126
  const { options, store, result } = input;
@@ -1,5 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { create } from "../../create/create.js";
4
5
  import { takeInlineValue, takeValue } from "../parse-flag-value.js";
5
6
  import { runSetDefault } from "./set-default.js";
@@ -109,8 +110,7 @@ export function parseCreateArgs(argv) {
109
110
  }
110
111
  const UNSET_DEFAULT_HINT = 'Multiple knowledge bases are registered and no default is set. Run `kb set-default` to choose one.\n';
111
112
  function buildUsageError(error) {
112
- const message = error instanceof Error ? error.message : String(error);
113
- return { exitCode: 2, stdout: '', stderr: `kb create: ${message}\n${CREATE_HELP}` };
113
+ return { exitCode: 2, stdout: '', stderr: `kb create: ${describeError(error)}\n${CREATE_HELP}` };
114
114
  }
115
115
  function formatCreated(created, registryPath) {
116
116
  const lines = [`Created knowledge base "${created.name}" at ${created.storePath}`];
@@ -1,5 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { tryLoadKbRegistry } from "../../discovery/load-registry.js";
4
5
  import { clearDefaultKb, setDefaultKb } from "../../discovery/set-default-kb.js";
5
6
  export const SET_DEFAULT_HELP = `Usage: kb set-default [name] [options]
@@ -38,11 +39,11 @@ export async function runSetDefault(input) {
38
39
  if (error !== undefined) {
39
40
  return { exitCode: 2, stdout: '', stderr: `kb set-default: ${error}\n` };
40
41
  }
41
- const entries = config.entries;
42
42
  if (options.none) {
43
43
  await clearDefaultKb({ registryPath });
44
44
  return { exitCode: 0, stdout: 'The default knowledge base has been cleared.\n', stderr: '' };
45
45
  }
46
+ const entries = config.entries;
46
47
  if (options.name !== null) {
47
48
  if (entries.length === 0) {
48
49
  return { exitCode: 2, stdout: '', stderr: buildNoStoresMessage() };
@@ -118,6 +119,5 @@ function buildSetConfirmation(name) {
118
119
  return `Default knowledge base has been set to "${name}".\n`;
119
120
  }
120
121
  function buildUsageError(error) {
121
- const message = error instanceof Error ? error.message : String(error);
122
- return { exitCode: 2, stdout: '', stderr: `kb set-default: ${message}\n${SET_DEFAULT_HELP}` };
122
+ return { exitCode: 2, stdout: '', stderr: `kb set-default: ${describeError(error)}\n${SET_DEFAULT_HELP}` };
123
123
  }
@@ -1,3 +1,4 @@
1
+ import { describeError } from '@williamthorsen/toolbelt.errors';
1
2
  import { enumerateNotes } from "../../check/enumerate.js";
2
3
  import { isKbLoaderError } from "../../config/kb-loader-error.js";
3
4
  import { loadKbConfig } from "../../config/load-config.js";
@@ -34,8 +35,7 @@ export async function runTaxonomy(input) {
34
35
  options = parseTaxonomyArgs(input.argv);
35
36
  }
36
37
  catch (error) {
37
- const message = error instanceof Error ? error.message : String(error);
38
- return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n${TAXONOMY_HELP}` };
38
+ return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${describeError(error)}\n${TAXONOMY_HELP}` };
39
39
  }
40
40
  if (options.help) {
41
41
  return { exitCode: 0, stdout: TAXONOMY_HELP, stderr: '' };
@@ -1,4 +1,5 @@
1
1
  import process from 'node:process';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { run } from "./run.js";
3
4
  import { readlineSelectKbPrompt } from "./select-kb-prompt.js";
4
5
  async function main() {
@@ -12,8 +13,7 @@ async function main() {
12
13
  });
13
14
  }
14
15
  catch (error) {
15
- const message = error instanceof Error ? error.message : String(error);
16
- process.stderr.write(`kb: unexpected error: ${message}\n`);
16
+ process.stderr.write(`kb: unexpected error: ${describeError(error)}\n`);
17
17
  process.exit(2);
18
18
  }
19
19
  if (output.stdout !== '')
@@ -12,9 +12,10 @@ Commands:
12
12
 
13
13
  Run "kb <command> --help" for command options.
14
14
  `;
15
+ const HELP_COMMANDS = new Set([undefined, '--help', '-h']);
15
16
  export async function run(input) {
16
17
  const [command, ...rest] = input.argv;
17
- if (command === undefined || command === '--help' || command === '-h') {
18
+ if (HELP_COMMANDS.has(command)) {
18
19
  return { exitCode: 0, stdout: HELP, stderr: '' };
19
20
  }
20
21
  if (command === 'check') {
@@ -14,7 +14,7 @@ export function parseSelection(answer, kbCount) {
14
14
  if (answer === '')
15
15
  return { kind: 'cancel' };
16
16
  const choice = Number(answer);
17
- if (!Number.isInteger(choice))
17
+ if (!Number.isSafeInteger(choice))
18
18
  return null;
19
19
  if (choice >= 1 && choice <= kbCount)
20
20
  return { kind: 'kb', index: choice - 1 };
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { join, relative, sep } from 'node:path';
4
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
5
  import { isRecord } from "../../type-guards.js";
5
6
  export function resolveChangedPaths(input) {
6
7
  const { storeRoot, ref } = input;
@@ -37,7 +38,7 @@ function extractGitErrorMessage(error) {
37
38
  if (isRecord(error) && typeof error.stderr === 'string' && error.stderr.trim() !== '') {
38
39
  return error.stderr.trim();
39
40
  }
40
- return error instanceof Error ? error.message : String(error);
41
+ return describeError(error);
41
42
  }
42
43
  function tryGit(storeRoot, args) {
43
44
  try {
@@ -1,5 +1,5 @@
1
1
  export declare class KbLoaderError extends Error {
2
2
  readonly kind: "KbLoaderError";
3
- constructor(message: string);
3
+ constructor(message: string, options?: ErrorOptions);
4
4
  }
5
5
  export declare function isKbLoaderError(error: unknown): error is KbLoaderError;
@@ -1,7 +1,7 @@
1
1
  export class KbLoaderError extends Error {
2
2
  kind = 'KbLoaderError';
3
- constructor(message) {
4
- super(message);
3
+ constructor(message, options) {
4
+ super(message, options);
5
5
  this.name = 'KbLoaderError';
6
6
  }
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { parse } from 'yaml';
4
5
  import { CONFIG_FILE } from "../layout/index.js";
5
6
  import { isEnoent } from "../type-guards.js";
@@ -22,8 +23,7 @@ export async function loadKbConfig(input) {
22
23
  parsed = parse(text);
23
24
  }
24
25
  catch (error) {
25
- const message = error instanceof Error ? error.message : String(error);
26
- throw new KbLoaderError(`${path}: malformed YAML — ${message}`);
26
+ throw new KbLoaderError(`${path}: malformed YAML: ${describeError(error)}`, { cause: error });
27
27
  }
28
28
  const result = configFileShape.safeParse(parsed ?? {});
29
29
  if (!result.success) {
@@ -1,6 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { describeError } from '@williamthorsen/toolbelt.errors';
5
+ import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
4
6
  import { parse } from 'yaml';
5
7
  import { isEnoent } from "../type-guards.js";
6
8
  import { kbRegistryFileSchema } from "./kb-registry-schema.js";
@@ -36,8 +38,7 @@ export async function tryLoadKbRegistry(input = {}) {
36
38
  return { config: await loadKbRegistry(input) };
37
39
  }
38
40
  catch (error) {
39
- const message = error instanceof Error ? error.message : String(error);
40
- return { config: { entries: [], sources: {} }, error: message };
41
+ return { config: { entries: [], sources: {} }, error: describeError(error) };
41
42
  }
42
43
  }
43
44
  function expandTilde(value, home) {
@@ -65,8 +66,7 @@ async function loadRegistryFile(path, source, home) {
65
66
  parsed = parse(text);
66
67
  }
67
68
  catch (error) {
68
- const message = error instanceof Error ? error.message : String(error);
69
- throw new Error(`${path}: malformed YAML — ${message}`);
69
+ throw chainError(`${path}: malformed YAML`, error);
70
70
  }
71
71
  if (parsed === null || parsed === undefined) {
72
72
  return { entries: [] };
@@ -77,7 +77,8 @@ async function loadRegistryFile(path, source, home) {
77
77
  }
78
78
  const configDir = dirname(path);
79
79
  const entries = [];
80
- for (const [name, fileEntry] of Object.entries(result.data.kbs ?? {})) {
80
+ const fileEntries = Object.entries(result.data.kbs ?? {});
81
+ for (const [name, fileEntry] of fileEntries) {
81
82
  entries.push({
82
83
  name,
83
84
  path: resolvePath(fileEntry.path, configDir, home),
@@ -96,7 +97,7 @@ function mergeEntries(userEntries, projectEntries) {
96
97
  for (const entry of projectEntries) {
97
98
  byName.set(entry.name, entry);
98
99
  }
99
- return [...byName.values()];
100
+ return byName.values().toArray();
100
101
  }
101
102
  function resolveDefaultKb(entries, name, sourcePath) {
102
103
  if (name === undefined) {
@@ -91,7 +91,7 @@ function toFrontmatter(doc) {
91
91
  tags = stringList(item.value);
92
92
  break;
93
93
  default:
94
- extra[key] = key in plainRecord ? plainRecord[key] : null;
94
+ extra[key] = Object.hasOwn(plainRecord, key) ? plainRecord[key] : null;
95
95
  }
96
96
  }
97
97
  return { title, recordType, created, updated, tags, extra };
@@ -15,7 +15,11 @@ export async function writeNote(path, fields, body) {
15
15
  await rename(tempPath, path);
16
16
  }
17
17
  catch (error) {
18
- await unlink(tempPath).catch(() => { });
18
+ try {
19
+ await unlink(tempPath);
20
+ }
21
+ catch {
22
+ }
19
23
  throw error;
20
24
  }
21
25
  }
@@ -1,6 +1,7 @@
1
1
  import { asStringList, isValidDate } from "../note-io/field-validators.js";
2
2
  export const EVENT_IMPACT_LEVELS = ['low', 'medium', 'high', 'critical'];
3
3
  const IMPACT_LEVEL_SET = new Set(EVENT_IMPACT_LEVELS);
4
+ const NOT_SUPPLIED_VALUES = new Set([undefined, null, '']);
4
5
  export function isEventImpact(value) {
5
6
  return typeof value === 'string' && IMPACT_LEVEL_SET.has(value);
6
7
  }
@@ -110,7 +111,7 @@ function readListField(value, field, errors) {
110
111
  return list;
111
112
  }
112
113
  function readOptionalNonEmptyString(value, field, errors) {
113
- if (value === undefined || value === null || value === '') {
114
+ if (NOT_SUPPLIED_VALUES.has(value)) {
114
115
  return undefined;
115
116
  }
116
117
  if (typeof value === 'string') {
@@ -1,5 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
3
5
  import { parse } from 'yaml';
4
6
  import { KbLoaderError } from "../config/kb-loader-error.js";
5
7
  import { ALIASES_FILE } from "../layout/index.js";
@@ -20,8 +22,7 @@ export async function loadAliases(input) {
20
22
  return parseAliases(text, path);
21
23
  }
22
24
  catch (error) {
23
- const message = error instanceof Error ? error.message : String(error);
24
- throw new KbLoaderError(message);
25
+ throw new KbLoaderError(describeError(error), { cause: error });
25
26
  }
26
27
  }
27
28
  export function parseAliases(text, contextLabel = 'tag-aliases') {
@@ -30,8 +31,7 @@ export function parseAliases(text, contextLabel = 'tag-aliases') {
30
31
  parsed = parse(text);
31
32
  }
32
33
  catch (error) {
33
- const message = error instanceof Error ? error.message : String(error);
34
- throw new Error(`${contextLabel}: malformed YAML — ${message}`);
34
+ throw chainError(`${contextLabel}: malformed YAML`, error);
35
35
  }
36
36
  if (!isRecord(parsed)) {
37
37
  throw new Error(`${contextLabel}: top-level must be a mapping`);
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { parse } from 'yaml';
4
5
  import { KbLoaderError } from "../config/kb-loader-error.js";
5
6
  import { TAXONOMY_FILE } from "../layout/index.js";
@@ -22,8 +23,7 @@ export async function loadTaxonomy(input) {
22
23
  parsed = parse(text);
23
24
  }
24
25
  catch (error) {
25
- const message = error instanceof Error ? error.message : String(error);
26
- throw new KbLoaderError(`${path}: malformed YAML — ${message}`);
26
+ throw new KbLoaderError(`${path}: malformed YAML: ${describeError(error)}`, { cause: error });
27
27
  }
28
28
  const result = taxonomyFileShape.safeParse(parsed ?? {});
29
29
  if (!result.success) {
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
5
  import { isMap, isPair, isScalar, parseDocument } from 'yaml';
5
6
  import { KbLoaderError } from "../config/kb-loader-error.js";
6
7
  import { TAXONOMY_FILE } from "../layout/index.js";
@@ -98,7 +99,7 @@ async function readDocument(path) {
98
99
  const document = parseDocument(text);
99
100
  const firstError = document.errors[0];
100
101
  if (firstError !== undefined) {
101
- throw new KbLoaderError(`${path}: malformed YAML ${firstError.message}`);
102
+ throw new KbLoaderError(`${path}: malformed YAML: ${describeError(firstError)}`, { cause: firstError });
102
103
  }
103
104
  if (document.contents !== null && !isMap(document.contents)) {
104
105
  throw new KbLoaderError(`${path}: top-level must be a mapping`);
@@ -136,7 +137,11 @@ async function writeAtomic(path, content) {
136
137
  await rename(tempPath, path);
137
138
  }
138
139
  catch (error) {
139
- await unlink(tempPath).catch(() => { });
140
+ try {
141
+ await unlink(tempPath);
142
+ }
143
+ catch {
144
+ }
140
145
  throw error;
141
146
  }
142
147
  }
@@ -6,7 +6,7 @@ export function checkVaultIntegrity(notes) {
6
6
  }
7
7
  function basenameFindings(vaultIndex) {
8
8
  const findings = [];
9
- for (const key of [...vaultIndex.keys()].toSorted()) {
9
+ for (const key of vaultIndex.keys().toArray().toSorted()) {
10
10
  const paths = vaultIndex.get(key);
11
11
  if (paths === undefined || paths.size < 2)
12
12
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/kb",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Knowledge-base foundation: discovery, config, frontmatter parsing, records, tags, and vault-integrity checks",
5
5
  "keywords": [
6
6
  "frontmatter",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "license": "ISC",
21
21
  "author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
22
+ "sideEffects": false,
22
23
  "type": "module",
23
24
  "exports": {
24
25
  ".": {
@@ -96,6 +97,7 @@
96
97
  "dist"
97
98
  ],
98
99
  "dependencies": {
100
+ "@williamthorsen/toolbelt.errors": "0.3.0",
99
101
  "picomatch": "4.0.5",
100
102
  "yaml": "2.9.0",
101
103
  "zod": "4.4.3"
@@ -109,7 +111,5 @@
109
111
  "publishConfig": {
110
112
  "access": "public"
111
113
  },
112
- "scripts": {
113
- "build": "nmr compile"
114
- }
114
+ "scripts": {}
115
115
  }