@williamthorsen/kb 0.4.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,17 +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.4.0 (2026-08-07)
8
+ ## Release notes — v0.6.0 (2026-08-13)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Add the .kb/taxonomy.yaml format with drift reporting and back-fill (#1210)
12
+ - Attach causes to kb's loader errors and retire its lint deferral (#1272)
13
13
 
14
- Introduces `.kb/taxonomy.yaml`, in which a knowledge base declares the structure of its assertions. `kb check` now reports three kinds of drift between that declaration and the folders on disk: a folder that holds notes nothing declares, a declared area that holds no notes, and a declared area whose parent is undeclared. A knowledge base that already holds notes can adopt a declaration in one pass with the new `kb taxonomy init`, and `--merge` adds only what an existing declaration omits.
15
-
16
- - Guide kb-add note placement with the store's declared taxonomy (#1223)
17
-
18
- Improves classification of captured knowledge-base notes by aligning with the domains declared by the KB's taxonomy rather than looking to the directory structure. If a note is filed in a folder not covered by a domain, that folder is now added to the base's taxonomy. A domain added without confirmation is recorded as awaiting review.
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.
19
15
  <!-- /section:release-notes -->
20
16
 
21
17
  ## Exports
@@ -206,9 +202,10 @@ The package ships a `kb` bin with four subcommands: `check`, `create`, `set-defa
206
202
  `kb create` scaffolds a new knowledge base in the current directory and registers it in the user-global `~/.agents/kb.yaml`.
207
203
 
208
204
  ```bash
209
- kb create # scaffold the current directory, register under its name
210
- kb create --name coding # register under an explicit name
211
- kb create --no-register # scaffold without writing the registry
205
+ kb create # scaffold the current directory, register under its name
206
+ kb create --name coding # register under an explicit name
207
+ kb create --description "Coding notes" # describe the registry entry
208
+ kb create --no-register # scaffold without writing the registry
212
209
  ```
213
210
 
214
211
  It creates these files and directories:
@@ -221,7 +218,7 @@ It creates these files and directories:
221
218
 
222
219
  The config seed is serialized from the in-package `defaultKbConfig`, so a new store cannot drift from the bundled default.
223
220
 
224
- The name defaults to the directory's base name; `--name` overrides it and `--no-register` scaffolds without writing the registry. The registry write preserves any existing comments in `kb.yaml`. `kb create` refuses to clobber: it exits 2 if the directory already contains a `.kb/` store, or if the chosen name is already registered.
221
+ The name defaults to the directory's base name; `--name` overrides it and `--no-register` scaffolds without writing the registry. `--description` sets the new entry's description, and requires registration: combining it with `--no-register` is a usage error. The registry write preserves any existing comments in `kb.yaml` and leaves the `kbs:` entries alphabetically ordered, so a registry that has drifted out of order is tidied as stores are added. `kb create` refuses to clobber: it exits 2 if the directory already contains a `.kb/` store, or if the chosen name is already registered.
225
222
 
226
223
  `kb create` also keeps a default knowledge base set. When the registry's top-level `default_kb` pointer is unset and the new store is the only registered KB, it becomes the default. When other KBs are already registered with no default, `kb create` prompts you to choose one on an interactive terminal — or, when stdin is not interactive, points you to `kb set-default`. An existing `default_kb` is never overwritten.
227
224
 
@@ -285,7 +282,7 @@ Without `--merge`, a store that already declares a taxonomy is left untouched an
285
282
 
286
283
  ## Error and exception model
287
284
 
288
- 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.
289
286
 
290
287
  ## MCP wrappability
291
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,6 +1,6 @@
1
1
  import type { SelectKbPrompt } from '../select-kb-prompt.js';
2
2
  import type { CommandOutput } from './check.js';
3
- export declare const CREATE_HELP = "Usage: kb create [options]\n\nScaffold a new knowledge base in the current directory and register it in the user-global kb.yaml registry.\n\nWhen the registry has no default knowledge base, the new store becomes the default.\nIf other knowledge bases are already registered, you are prompted to choose one (or set it later with \"kb set-default\").\n\nCreates:\n .kb/config.yaml check configuration (commented; defaults apply)\n .kb/tag-aliases.yaml tag-alias map (empty)\n content/, content/events/\n\nOptions:\n --name <name> Registry name for the store. Defaults to the directory name.\n --no-register Scaffold without writing the kb.yaml registry entry.\n -h, --help Show this help.\n\nExit codes:\n 0 store created\n 2 usage error, an existing .kb/ in the directory, or an already-registered name\n";
3
+ export declare const CREATE_HELP = "Usage: kb create [options]\n\nScaffold a new knowledge base in the current directory and register it in the user-global kb.yaml registry.\n\nRegistering leaves the registry's entries in alphabetical order, preserving its comments and formatting.\n\nWhen the registry has no default knowledge base, the new store becomes the default.\nIf other knowledge bases are already registered, you are prompted to choose one (or set it later with \"kb set-default\").\n\nCreates:\n .kb/config.yaml check configuration (commented; defaults apply)\n .kb/tag-aliases.yaml tag-alias map (empty)\n content/, content/events/\n\nOptions:\n --description <text> Description for the registry entry; cannot be combined with --no-register.\n --name <name> Registry name for the store. Defaults to the directory name.\n --no-register Scaffold without writing the kb.yaml registry entry.\n -h, --help Show this help.\n\nExit codes:\n 0 store created\n 2 usage error, an existing .kb/ in the directory, or an already-registered name\n";
4
4
  export declare function runCreate(input: {
5
5
  argv: readonly string[];
6
6
  cwd: string;
@@ -8,6 +8,7 @@ export declare function runCreate(input: {
8
8
  selectKb?: SelectKbPrompt;
9
9
  }): Promise<CommandOutput>;
10
10
  interface CreateOptions {
11
+ description: string | null;
11
12
  name: string | null;
12
13
  noRegister: boolean;
13
14
  help: boolean;
@@ -1,11 +1,15 @@
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";
5
+ import { takeInlineValue, takeValue } from "../parse-flag-value.js";
4
6
  import { runSetDefault } from "./set-default.js";
5
7
  export const CREATE_HELP = `Usage: kb create [options]
6
8
 
7
9
  Scaffold a new knowledge base in the current directory and register it in the user-global kb.yaml registry.
8
10
 
11
+ Registering leaves the registry's entries in alphabetical order, preserving its comments and formatting.
12
+
9
13
  When the registry has no default knowledge base, the new store becomes the default.
10
14
  If other knowledge bases are already registered, you are prompted to choose one (or set it later with "kb set-default").
11
15
 
@@ -15,9 +19,10 @@ Creates:
15
19
  content/, content/events/
16
20
 
17
21
  Options:
18
- --name <name> Registry name for the store. Defaults to the directory name.
19
- --no-register Scaffold without writing the kb.yaml registry entry.
20
- -h, --help Show this help.
22
+ --description <text> Description for the registry entry; cannot be combined with --no-register.
23
+ --name <name> Registry name for the store. Defaults to the directory name.
24
+ --no-register Scaffold without writing the kb.yaml registry entry.
25
+ -h, --help Show this help.
21
26
 
22
27
  Exit codes:
23
28
  0 store created
@@ -38,7 +43,12 @@ export async function runCreate(input) {
38
43
  const base = { targetDir: input.cwd, ...(options.name !== null && { name: options.name }) };
39
44
  const outcome = options.noRegister
40
45
  ? await create({ ...base, register: false })
41
- : await create({ ...base, register: true, registryPath });
46
+ : await create({
47
+ ...base,
48
+ register: true,
49
+ registryPath,
50
+ ...(options.description !== null && { description: options.description }),
51
+ });
42
52
  if (!outcome.ok) {
43
53
  return { exitCode: 2, stdout: '', stderr: `kb create: ${outcome.message}\n` };
44
54
  }
@@ -57,6 +67,7 @@ export async function runCreate(input) {
57
67
  return { exitCode: 0, stdout: summary + selection.stdout, stderr: selection.stderr };
58
68
  }
59
69
  export function parseCreateArgs(argv) {
70
+ let description = null;
60
71
  let name = null;
61
72
  let noRegister = false;
62
73
  let help = false;
@@ -68,35 +79,38 @@ export function parseCreateArgs(argv) {
68
79
  help = true;
69
80
  continue;
70
81
  }
82
+ if (arg === '--description') {
83
+ description = takeValue(argv, index, '--description');
84
+ index += 1;
85
+ continue;
86
+ }
87
+ if (arg.startsWith('--description=')) {
88
+ description = takeInlineValue(arg, '--description=');
89
+ continue;
90
+ }
71
91
  if (arg === '--no-register') {
72
92
  noRegister = true;
73
93
  continue;
74
94
  }
75
95
  if (arg === '--name') {
76
- const next = argv[index + 1] ?? null;
77
- if (next === null || next.startsWith('--')) {
78
- throw new Error('--name requires a value');
79
- }
80
- name = next;
96
+ name = takeValue(argv, index, '--name');
81
97
  index += 1;
82
98
  continue;
83
99
  }
84
100
  if (arg.startsWith('--name=')) {
85
- const value = arg.slice('--name='.length);
86
- if (value === '') {
87
- throw new Error('--name requires a value');
88
- }
89
- name = value;
101
+ name = takeInlineValue(arg, '--name=');
90
102
  continue;
91
103
  }
92
104
  throw new Error(`unknown flag: ${arg}`);
93
105
  }
94
- return { name, noRegister, help };
106
+ if (noRegister && description !== null) {
107
+ throw new Error('--description cannot be combined with --no-register');
108
+ }
109
+ return { description, name, noRegister, help };
95
110
  }
96
111
  const UNSET_DEFAULT_HINT = 'Multiple knowledge bases are registered and no default is set. Run `kb set-default` to choose one.\n';
97
112
  function buildUsageError(error) {
98
- const message = error instanceof Error ? error.message : String(error);
99
- return { exitCode: 2, stdout: '', stderr: `kb create: ${message}\n${CREATE_HELP}` };
113
+ return { exitCode: 2, stdout: '', stderr: `kb create: ${describeError(error)}\n${CREATE_HELP}` };
100
114
  }
101
115
  function formatCreated(created, registryPath) {
102
116
  const lines = [`Created knowledge base "${created.name}" at ${created.storePath}`];
@@ -104,6 +118,9 @@ function formatCreated(created, registryPath) {
104
118
  lines.push(` ${path}`);
105
119
  }
106
120
  lines.push(created.registered ? `Registered in ${registryPath}` : 'Not registered (--no-register).');
121
+ if (created.description !== undefined) {
122
+ lines.push(`Description: ${created.description}`);
123
+ }
107
124
  if (created.defaultKb === 'set') {
108
125
  lines.push('Set as the default knowledge base.');
109
126
  }
@@ -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 !== '')
@@ -7,7 +7,7 @@ export function takeInlineValue(arg, prefix) {
7
7
  }
8
8
  export function takeValue(argv, index, flag) {
9
9
  const next = argv[index + 1] ?? null;
10
- if (next === null || next.startsWith('--')) {
10
+ if (next === null || next === '' || next.startsWith('--')) {
11
11
  throw new Error(`${flag} requires a value`);
12
12
  }
13
13
  return next;
@@ -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) {
@@ -2,6 +2,7 @@ export type DefaultKbOutcome = 'set' | 'unchanged' | 'needs-selection';
2
2
  export interface CreatedStore {
3
3
  name: string;
4
4
  storePath: string;
5
+ description?: string;
5
6
  registered: boolean;
6
7
  created: readonly string[];
7
8
  defaultKb?: DefaultKbOutcome;
@@ -14,6 +15,7 @@ export type CreateInput = {
14
15
  } | {
15
16
  register: true;
16
17
  registryPath: string;
18
+ description?: string;
17
19
  });
18
20
  export type CreateOutcome = {
19
21
  ok: true;
@@ -22,12 +22,13 @@ export async function create(input) {
22
22
  return { ok: false, reason: 'name-registered', message: nameRegisteredMessage(name, registryPath) };
23
23
  }
24
24
  const created = await scaffold(storePath);
25
- const result = await registerStore({ registryPath, name, storePath });
25
+ const described = input.description !== undefined && { description: input.description };
26
+ const result = await registerStore({ registryPath, name, storePath, ...described });
26
27
  if (result.status === 'already-present') {
27
28
  return { ok: false, reason: 'name-registered', message: nameRegisteredMessage(name, registryPath) };
28
29
  }
29
30
  const defaultKb = await ensureDefaultKb({ registryPath, name, before });
30
- return { ok: true, created: { name, storePath, registered: true, created, defaultKb } };
31
+ return { ok: true, created: { name, storePath, ...described, registered: true, created, defaultKb } };
31
32
  }
32
33
  async function ensureDefaultKb(input) {
33
34
  if (input.before.defaultKb !== undefined) {
@@ -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) {
@@ -1,5 +1,6 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
+ import { isMap, isScalar } from 'yaml';
3
4
  import { kbRegistryFileSchema } from "./kb-registry-schema.js";
4
5
  import { loadRegistryDocument } from "./registry-document.js";
5
6
  export async function registerStore(input) {
@@ -11,11 +12,12 @@ export async function registerStore(input) {
11
12
  if (doc.hasIn(['kbs', input.name])) {
12
13
  return { status: 'already-present' };
13
14
  }
14
- const entry = { path: input.storePath };
15
- if (input.description !== undefined) {
16
- entry.description = input.description;
17
- }
15
+ const entry = {
16
+ ...(input.description !== undefined && { description: input.description }),
17
+ path: input.storePath,
18
+ };
18
19
  doc.setIn(['kbs', input.name], entry);
20
+ sortRegistryEntries(doc);
19
21
  const result = kbRegistryFileSchema.safeParse(doc.toJS());
20
22
  if (!result.success) {
21
23
  throw new Error(`${input.registryPath}: cannot register "${input.name}" — ${result.error.issues[0]?.message ?? 'invalid entry'}`);
@@ -24,3 +26,20 @@ export async function registerStore(input) {
24
26
  await writeFile(input.registryPath, doc.toString(), 'utf8');
25
27
  return { status: 'added' };
26
28
  }
29
+ function compareRegistryNames(left, right) {
30
+ const caseInsensitive = left.localeCompare(right, 'en', { sensitivity: 'base' });
31
+ return caseInsensitive === 0 ? left.localeCompare(right) : caseInsensitive;
32
+ }
33
+ function sortRegistryEntries(doc) {
34
+ const kbs = doc.getIn(['kbs'], true);
35
+ if (!isMap(kbs)) {
36
+ return;
37
+ }
38
+ kbs.items.sort((left, right) => compareRegistryNames(readName(left.key), readName(right.key)));
39
+ }
40
+ function readName(key) {
41
+ if (isScalar(key)) {
42
+ return String(key.value);
43
+ }
44
+ return typeof key === 'string' ? key : '';
45
+ }
@@ -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.4.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
  }