@williamthorsen/kb 0.3.1 → 0.5.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.
Files changed (41) hide show
  1. package/README.md +78 -15
  2. package/dist/esm/check/check.js +10 -2
  3. package/dist/esm/check/enumerate.d.ts +4 -0
  4. package/dist/esm/check/enumerate.js +32 -20
  5. package/dist/esm/check/index.d.ts +1 -1
  6. package/dist/esm/check/index.js +1 -1
  7. package/dist/esm/cli/commands/check.js +3 -35
  8. package/dist/esm/cli/commands/create.d.ts +2 -1
  9. package/dist/esm/cli/commands/create.js +32 -15
  10. package/dist/esm/cli/commands/taxonomy.d.ts +15 -0
  11. package/dist/esm/cli/commands/taxonomy.js +130 -0
  12. package/dist/esm/cli/format.js +4 -1
  13. package/dist/esm/cli/parse-flag-value.d.ts +2 -0
  14. package/dist/esm/cli/parse-flag-value.js +14 -0
  15. package/dist/esm/cli/resolve-store.d.ts +14 -0
  16. package/dist/esm/cli/resolve-store.js +25 -0
  17. package/dist/esm/cli/run.d.ts +1 -1
  18. package/dist/esm/cli/run.js +5 -0
  19. package/dist/esm/create/create.d.ts +2 -0
  20. package/dist/esm/create/create.js +3 -2
  21. package/dist/esm/discovery/register-store.js +23 -4
  22. package/dist/esm/layout/index.d.ts +1 -1
  23. package/dist/esm/layout/index.js +1 -1
  24. package/dist/esm/layout/store-layout.d.ts +1 -0
  25. package/dist/esm/layout/store-layout.js +1 -0
  26. package/dist/esm/lints/index.d.ts +1 -0
  27. package/dist/esm/lints/index.js +1 -0
  28. package/dist/esm/lints/taxonomy.d.ts +12 -0
  29. package/dist/esm/lints/taxonomy.js +52 -0
  30. package/dist/esm/taxonomy/domain-paths.d.ts +3 -0
  31. package/dist/esm/taxonomy/domain-paths.js +23 -0
  32. package/dist/esm/taxonomy/index.d.ts +4 -0
  33. package/dist/esm/taxonomy/index.js +4 -0
  34. package/dist/esm/taxonomy/load-taxonomy.d.ts +5 -0
  35. package/dist/esm/taxonomy/load-taxonomy.js +60 -0
  36. package/dist/esm/taxonomy/taxonomy-schema.d.ts +11 -0
  37. package/dist/esm/taxonomy/taxonomy-schema.js +28 -0
  38. package/dist/esm/taxonomy/write-taxonomy.d.ts +12 -0
  39. package/dist/esm/taxonomy/write-taxonomy.js +142 -0
  40. package/dist/esm/types.d.ts +1 -0
  41. package/package.json +6 -1
package/README.md CHANGED
@@ -4,11 +4,21 @@ Foundation library for knowledge-base tooling.
4
4
  Provides knowledge-base discovery, registry loading, frontmatter parsing and writing, tag canonicalization, and type-blind vault-integrity checks.
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
- <!-- section:release-notes --><!-- /section:release-notes -->
7
+ <!-- section:release-notes -->
8
+ ## Release notes — v0.5.0 (2026-08-08)
9
+
10
+ ### 🎉 Features
11
+
12
+ - Register a new store with a description and keep the registry sorted (#1237)
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.
17
+ <!-- /section:release-notes -->
8
18
 
9
19
  ## Exports
10
20
 
11
- The package exposes ten subpath entries plus a root barrel:
21
+ The package exposes twelve subpath entries plus a root barrel:
12
22
 
13
23
  | Entry | Description |
14
24
  | ------------------- | ------------------------------------------------------------------------------ |
@@ -19,9 +29,11 @@ The package exposes ten subpath entries plus a root barrel:
19
29
  | `./discovery` | KB root discovery and `kb.yaml` registry loading, merging, and writing |
20
30
  | `./filesystem` | Filesystem-existence helpers with an explicit absence policy |
21
31
  | `./frontmatter` | Note parsing into typed frontmatter and writing it back to YAML |
32
+ | `./layout` | The store's on-disk layout: every path inside a `.kb/` store derives from here |
22
33
  | `./note-io` | Type-blind note read/write as an ordered frontmatter field map |
23
34
  | `./records` | The typed `assertion`/`event` record parsers and renderers |
24
35
  | `./tags` | `.kb/tag-aliases.yaml` loading and tag canonicalization |
36
+ | `./taxonomy` | `.kb/taxonomy.yaml` loading, comment-preserving declaration, and path mapping |
25
37
  | `./vault-integrity` | Type-blind `[[link]]` resolution and basename-uniqueness over a note set |
26
38
 
27
39
  Every public function takes a single plain-object input so a future MCP wrapper can mechanically bind Zod-validated payloads.
@@ -112,6 +124,8 @@ missing files (when a path is given) throw.
112
124
 
113
125
  The type-blind per-note lints — `tagAliasFindings(note, aliases)` (`tag-alias`, warning) and `pathsFindings(note)` (`paths.user-home`, error) — catch what write-time record validation can't: alias-vocabulary drift and hardcoded `/Users/{name}/` paths in captured content.
114
126
 
127
+ `taxonomyFindings({ notes, taxonomy, config, taxonomyPath })` reports where a store's assertion folders and its declared taxonomy disagree (see [`.kb/taxonomy.yaml`](#the-declared-structure-kbtaxonomyyaml)). Its findings carry `scope: 'vault'`: they describe the store rather than any one note, so a consumer that narrows a report to selected notes must keep them rather than filter them out by path.
128
+
115
129
  ```ts
116
130
  import { checkVaultIntegrity } from '@williamthorsen/kb/vault-integrity';
117
131
 
@@ -120,7 +134,7 @@ const findings = checkVaultIntegrity(notes);
120
134
 
121
135
  ## Checking a store
122
136
 
123
- `check({ kbRoot })` runs a store's full check in one call: it loads `.kb/config.yaml` and `.kb/tag-aliases.yaml`, enumerates the notes the config selects, and composes whole-vault integrity with the `tag-alias` and `paths` lints. It performs no frontmatter validation — record types own that at write time. It returns **both** the enumerated notes and the findings, so a consumer can layer its own detectors over the same enumeration without walking the store twice.
137
+ `check({ kbRoot })` runs a store's full check in one call: it loads `.kb/config.yaml`, `.kb/tag-aliases.yaml`, and `.kb/taxonomy.yaml`, enumerates the notes the config selects, and composes whole-vault integrity and taxonomy drift with the `tag-alias` and `paths` lints. It performs no frontmatter validation — record types own that at write time. It returns **both** the enumerated notes and the findings, so a consumer can layer its own detectors over the same enumeration without walking the store twice.
124
138
 
125
139
  ```ts
126
140
  import { check } from '@williamthorsen/kb/check';
@@ -128,7 +142,9 @@ import { check } from '@williamthorsen/kb/check';
128
142
  const { notes, findings } = await check({ kbRoot });
129
143
  ```
130
144
 
131
- A structural defect in either loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.
145
+ A structural defect in any loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.
146
+
147
+ `enumerateNotes({ kbRoot, config })` performs the enumeration on its own, and `enumerateNotePaths({ kbRoot, config })` returns the same note set as store-root-relative paths without opening a single note. Both are exported from `@williamthorsen/kb/check`; the paths-only variant serves a caller that needs the note set's shape rather than its content.
132
148
 
133
149
  ### Which notes are checked: `.kb/config.yaml`
134
150
 
@@ -149,18 +165,49 @@ exclude:
149
165
 
150
166
  Matching uses dotfile-insensitive globbing, so dot-directories (`.kb`, `.git`, `.agents`) are skipped without naming them. The default targets the `content/`-scoped layout; a store with a different layout overrides `targets` to match. `loadKbConfig({ kbRoot })` returns the effective config and is exported from `@williamthorsen/kb/config`.
151
167
 
168
+ ### The declared structure: `.kb/taxonomy.yaml`
169
+
170
+ `.kb/taxonomy.yaml` states where a store's assertions are meant to live. It is the source of truth for intended structure: folders on disk are derived from it, not the reverse. It governs `content/assertions/` only, since `content/events/` is flat and ULID-keyed.
171
+
172
+ ```yaml
173
+ # .kb/taxonomy.yaml
174
+ domains:
175
+ engineering: Software engineering practice
176
+ engineering/tooling: Build, test, and development tooling
177
+ provisional:
178
+ engineering/tooling/versioning: Release and version management
179
+ languages:
180
+ ```
181
+
182
+ Two disjoint maps of domain path to one-line description. `domains` holds reviewed declarations and `provisional` holds those declared but not yet reviewed; promotion is writing a description and moving the line up. A domain may be declared without a description, as `languages` is above.
183
+
184
+ Keys are relative to `content/assertions/` and may nest to any depth. Parents are not implied: declaring `engineering/tooling` does not declare `engineering`. A path declared in both maps fails the load, as does a malformed key — one restating the `content/assertions/` prefix, or carrying a leading or trailing slash, an empty segment, or a `.`/`..` segment.
185
+
186
+ An absent taxonomy, and one present but declaring nothing, are both valid and report nothing, so the rules apply only to a store that has adopted a taxonomy. Three warnings report drift once one has:
187
+
188
+ | Rule | Meaning |
189
+ | --------------------- | ----------------------------------------------- |
190
+ | `taxonomy.undeclared` | A folder holds notes but no domain declares it. |
191
+ | `taxonomy.unused` | A declared domain has no note at or beneath it. |
192
+ | `taxonomy.orphan` | A declared domain's parent is undeclared. |
193
+
194
+ A domain counts as used when any note lives at or beneath it, so a grouping domain that holds only subfolders is not reported unused. A domain inside a `config.exclude` subtree is exempt from `taxonomy.unused`, since its notes never enumerate.
195
+
196
+ `loadTaxonomy({ kbRoot })` reads both blocks into one map of domain path to `{ description, provisional }`, and `writeTaxonomy({ kbRoot, declarations })` declares domains while preserving the file's existing comments, key order, and formatting. `resolveDomain(relativePath)` maps a store-root-relative note path to the domain it sits in (`undefined` for a non-assertion or a note at the assertions root), and `resolveParent(path)` yields a domain's parent (`undefined` at the top level); the drift rules and the back-fill both derive their answers from this pair, so a consumer that classifies notes against the taxonomy stays in agreement with what `kb check` reports. All four are exported from `@williamthorsen/kb/taxonomy`.
197
+
152
198
  ## The `kb` command
153
199
 
154
- The package ships a `kb` bin with three subcommands: `create`, `set-default`, and `check`.
200
+ The package ships a `kb` bin with four subcommands: `check`, `create`, `set-default`, and `taxonomy`.
155
201
 
156
202
  ### kb create
157
203
 
158
204
  `kb create` scaffolds a new knowledge base in the current directory and registers it in the user-global `~/.agents/kb.yaml`.
159
205
 
160
206
  ```bash
161
- kb create # scaffold the current directory, register under its name
162
- kb create --name coding # register under an explicit name
163
- kb create --no-register # scaffold without writing the registry
207
+ kb create # scaffold the current directory, register under its name
208
+ kb create --name coding # register under an explicit name
209
+ kb create --description "Coding notes" # describe the registry entry
210
+ kb create --no-register # scaffold without writing the registry
164
211
  ```
165
212
 
166
213
  It creates these files and directories:
@@ -173,7 +220,7 @@ It creates these files and directories:
173
220
 
174
221
  The config seed is serialized from the in-package `defaultKbConfig`, so a new store cannot drift from the bundled default.
175
222
 
176
- 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.
223
+ 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.
177
224
 
178
225
  `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.
179
226
 
@@ -213,15 +260,31 @@ Because the exit code reflects only the selected notes, a per-batch or pre-commi
213
260
 
214
261
  Exit codes:
215
262
 
216
- | Code | Meaning |
217
- | ---- | --------------------------------------------------------------------------------------------------------------------------- |
218
- | `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. |
219
- | `1` | One or more error-severity findings in the checked notes. |
220
- | `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config` or `tag-aliases` file. |
263
+ | Code | Meaning |
264
+ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
265
+ | `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. |
266
+ | `1` | One or more error-severity findings in the checked notes. |
267
+ | `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config`, `tag-aliases`, or `taxonomy` file. |
268
+
269
+ A finding carrying `scope: 'vault'` describes the store rather than any one note, so it is reported under every run, including a targeted one, a `--vs` one, and one that matched no notes at all. The taxonomy rules are the ones that produce them.
270
+
271
+ ### kb taxonomy
272
+
273
+ `kb taxonomy init` derives a starting taxonomy from the notes a store already holds, so a taxonomy can be introduced to a populated store without every folder reporting as undeclared.
274
+
275
+ ```bash
276
+ kb taxonomy init # declare every folder holding notes, and its ancestors
277
+ kb taxonomy init --kb coding # back-fill the named store from the kb.yaml registry
278
+ kb taxonomy init --merge # add only the domains an existing taxonomy omits
279
+ ```
280
+
281
+ Every derived domain lands under `provisional:` with no description: the command cannot invent descriptions, and provisional already means "declared, not yet reviewed". Because the derivation reads the same enumeration `kb check` does, a back-filled store reports no taxonomy drift.
282
+
283
+ Without `--merge`, a store that already declares a taxonomy is left untouched and the command exits 2.
221
284
 
222
285
  ## Error and exception model
223
286
 
224
- The checks **return** findings; they never throw. Loaders (`loadKbConfig`, `loadAliases`) **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.
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.
225
288
 
226
289
  ## MCP wrappability
227
290
 
@@ -1,16 +1,24 @@
1
+ import { join } from 'node:path';
1
2
  import { loadKbConfig } from "../config/load-config.js";
2
- import { resolveKbDir } from "../layout/index.js";
3
+ import { resolveKbDir, TAXONOMY_FILE } from "../layout/index.js";
3
4
  import { pathsFindings } from "../lints/paths.js";
4
5
  import { tagAliasFindings } from "../lints/tag-alias.js";
6
+ import { taxonomyFindings } from "../lints/taxonomy.js";
5
7
  import { loadAliases } from "../tags/load-aliases.js";
8
+ import { loadTaxonomy } from "../taxonomy/load-taxonomy.js";
6
9
  import { checkVaultIntegrity } from "../vault-integrity/check-vault-integrity.js";
7
10
  import { enumerateNotes } from "./enumerate.js";
8
11
  export async function check(input) {
9
12
  const kbRoot = { path: input.kbRoot, kbDir: resolveKbDir(input.kbRoot) };
10
- const [config, aliases] = await Promise.all([loadKbConfig({ kbRoot }), loadAliases({ kbRoot })]);
13
+ const [config, aliases, taxonomy] = await Promise.all([
14
+ loadKbConfig({ kbRoot }),
15
+ loadAliases({ kbRoot }),
16
+ loadTaxonomy({ kbRoot }),
17
+ ]);
11
18
  const notes = await enumerateNotes({ kbRoot: input.kbRoot, config });
12
19
  const findings = [
13
20
  ...checkVaultIntegrity(notes),
21
+ ...taxonomyFindings({ notes, taxonomy, config, taxonomyPath: join(input.kbRoot, TAXONOMY_FILE) }),
14
22
  ...notes.flatMap((note) => [...tagAliasFindings(note, aliases), ...pathsFindings(note)]),
15
23
  ];
16
24
  return { config, notes, findings };
@@ -8,6 +8,10 @@ export interface EnumeratedNote {
8
8
  bodyStartLine: number;
9
9
  error?: string;
10
10
  }
11
+ export declare function enumerateNotePaths(input: {
12
+ kbRoot: string;
13
+ config: KbConfig;
14
+ }): Promise<string[]>;
11
15
  export declare function enumerateNotes(input: {
12
16
  kbRoot: string;
13
17
  config: KbConfig;
@@ -4,13 +4,41 @@ import process from 'node:process';
4
4
  import { createNoteScopeMatcher } from "../config/note-scope.js";
5
5
  import { readNoteContent } from "../note-io/read-note.js";
6
6
  import { isGlobSegment } from "./glob-segments.js";
7
+ export async function enumerateNotePaths(input) {
8
+ const locations = await collectNoteLocations(input);
9
+ return locations.map((location) => location.relativePath);
10
+ }
7
11
  export async function enumerateNotes(input) {
12
+ const locations = await collectNoteLocations(input);
13
+ const notes = [];
14
+ for (const { path, relativePath } of locations) {
15
+ try {
16
+ const content = await readFile(path, 'utf8');
17
+ const { fields, body, bodyStartLine, error: parseError } = readNoteContent(content);
18
+ notes.push({
19
+ path,
20
+ relativePath,
21
+ fields,
22
+ body,
23
+ content,
24
+ bodyStartLine,
25
+ ...(parseError !== undefined && { error: parseError }),
26
+ });
27
+ }
28
+ 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`);
31
+ }
32
+ }
33
+ return notes;
34
+ }
35
+ async function collectNoteLocations(input) {
8
36
  const { kbRoot, config } = input;
9
37
  const matcher = createNoteScopeMatcher(config);
10
38
  const topLevelDirs = leadingLiteralSegments(config.targets);
11
- const notes = [];
12
- await walk({ root: kbRoot, dir: kbRoot, matcher, topLevelDirs, out: notes });
13
- return notes;
39
+ const locations = [];
40
+ await walk({ root: kbRoot, dir: kbRoot, matcher, topLevelDirs, out: locations });
41
+ return locations;
14
42
  }
15
43
  function leadingLiteralSegments(targets) {
16
44
  const dirs = new Set();
@@ -50,22 +78,6 @@ async function walk(input) {
50
78
  continue;
51
79
  if (!matcher.isNote(relativePath))
52
80
  continue;
53
- try {
54
- const content = await readFile(absolutePath, 'utf8');
55
- const { fields, body, bodyStartLine, error: parseError } = readNoteContent(content);
56
- out.push({
57
- path: absolutePath,
58
- relativePath,
59
- fields,
60
- body,
61
- content,
62
- bodyStartLine,
63
- ...(parseError !== undefined && { error: parseError }),
64
- });
65
- }
66
- catch (error) {
67
- const message = error instanceof Error ? error.message : String(error);
68
- process.stderr.write(`kb: warning: could not read note ${absolutePath}; skipping: ${message}\n`);
69
- }
81
+ out.push({ path: absolutePath, relativePath });
70
82
  }
71
83
  }
@@ -1,2 +1,2 @@
1
1
  export { check, type CheckResult } from './check.js';
2
- export { type EnumeratedNote, enumerateNotes } from './enumerate.js';
2
+ export { type EnumeratedNote, enumerateNotePaths, enumerateNotes } from './enumerate.js';
@@ -1,2 +1,2 @@
1
1
  export { check } from "./check.js";
2
- export { enumerateNotes } from "./enumerate.js";
2
+ export { enumerateNotePaths, enumerateNotes } from "./enumerate.js";
@@ -1,8 +1,8 @@
1
1
  import { check } from "../../check/check.js";
2
2
  import { isKbLoaderError } from "../../config/kb-loader-error.js";
3
- import { findKbRoot } from "../../discovery/find-kb-root.js";
4
- import { tryLoadKbRegistry } from "../../discovery/load-registry.js";
5
3
  import { formatHuman, formatJson, summarize } from "../format.js";
4
+ import { takeInlineValue, takeValue } from "../parse-flag-value.js";
5
+ import { resolveStore } from "../resolve-store.js";
6
6
  import { resolveChangedPaths } from "../targeting/resolve-changed-paths.js";
7
7
  import { selectNotes } from "../targeting/select-notes.js";
8
8
  export const CHECK_HELP = `Usage: kb check [paths...] [options]
@@ -142,38 +142,6 @@ async function resolveSelection(input) {
142
142
  return { ok: false, message: `no notes matched: ${selection.unmatched.join(', ')}` };
143
143
  }
144
144
  const selectedPaths = new Set(selection.selected.map((entry) => entry.path));
145
- const findings = result.findings.filter((finding) => selectedPaths.has(finding.path));
145
+ const findings = result.findings.filter((finding) => finding.scope === 'vault' || selectedPaths.has(finding.path));
146
146
  return { ok: true, scope, notes: selection.selected, findings };
147
147
  }
148
- async function resolveStore(input) {
149
- if (input.explicitKb !== null) {
150
- const { config } = await tryLoadKbRegistry({
151
- projectDir: input.cwd,
152
- ...(input.home !== undefined && { home: input.home }),
153
- });
154
- const match = config.entries.find((entry) => entry.name === input.explicitKb);
155
- if (match === undefined) {
156
- return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` };
157
- }
158
- return { ok: true, store: { name: match.name, path: match.path } };
159
- }
160
- const discovered = await findKbRoot({ startDir: input.cwd });
161
- if (discovered === null) {
162
- return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' };
163
- }
164
- return { ok: true, store: { name: null, path: discovered.path } };
165
- }
166
- function takeInlineValue(arg, prefix) {
167
- const value = arg.slice(prefix.length);
168
- if (value === '') {
169
- throw new Error(`${prefix.replace(/=$/, '')} requires a value`);
170
- }
171
- return value;
172
- }
173
- function takeValue(argv, index, flag) {
174
- const next = argv[index + 1] ?? null;
175
- if (next === null || next.startsWith('--')) {
176
- throw new Error(`${flag} requires a value`);
177
- }
178
- return next;
179
- }
@@ -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,14 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import { create } from "../../create/create.js";
4
+ import { takeInlineValue, takeValue } from "../parse-flag-value.js";
4
5
  import { runSetDefault } from "./set-default.js";
5
6
  export const CREATE_HELP = `Usage: kb create [options]
6
7
 
7
8
  Scaffold a new knowledge base in the current directory and register it in the user-global kb.yaml registry.
8
9
 
10
+ Registering leaves the registry's entries in alphabetical order, preserving its comments and formatting.
11
+
9
12
  When the registry has no default knowledge base, the new store becomes the default.
10
13
  If other knowledge bases are already registered, you are prompted to choose one (or set it later with "kb set-default").
11
14
 
@@ -15,9 +18,10 @@ Creates:
15
18
  content/, content/events/
16
19
 
17
20
  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.
21
+ --description <text> Description for the registry entry; cannot be combined with --no-register.
22
+ --name <name> Registry name for the store. Defaults to the directory name.
23
+ --no-register Scaffold without writing the kb.yaml registry entry.
24
+ -h, --help Show this help.
21
25
 
22
26
  Exit codes:
23
27
  0 store created
@@ -38,7 +42,12 @@ export async function runCreate(input) {
38
42
  const base = { targetDir: input.cwd, ...(options.name !== null && { name: options.name }) };
39
43
  const outcome = options.noRegister
40
44
  ? await create({ ...base, register: false })
41
- : await create({ ...base, register: true, registryPath });
45
+ : await create({
46
+ ...base,
47
+ register: true,
48
+ registryPath,
49
+ ...(options.description !== null && { description: options.description }),
50
+ });
42
51
  if (!outcome.ok) {
43
52
  return { exitCode: 2, stdout: '', stderr: `kb create: ${outcome.message}\n` };
44
53
  }
@@ -57,6 +66,7 @@ export async function runCreate(input) {
57
66
  return { exitCode: 0, stdout: summary + selection.stdout, stderr: selection.stderr };
58
67
  }
59
68
  export function parseCreateArgs(argv) {
69
+ let description = null;
60
70
  let name = null;
61
71
  let noRegister = false;
62
72
  let help = false;
@@ -68,30 +78,34 @@ export function parseCreateArgs(argv) {
68
78
  help = true;
69
79
  continue;
70
80
  }
81
+ if (arg === '--description') {
82
+ description = takeValue(argv, index, '--description');
83
+ index += 1;
84
+ continue;
85
+ }
86
+ if (arg.startsWith('--description=')) {
87
+ description = takeInlineValue(arg, '--description=');
88
+ continue;
89
+ }
71
90
  if (arg === '--no-register') {
72
91
  noRegister = true;
73
92
  continue;
74
93
  }
75
94
  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;
95
+ name = takeValue(argv, index, '--name');
81
96
  index += 1;
82
97
  continue;
83
98
  }
84
99
  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;
100
+ name = takeInlineValue(arg, '--name=');
90
101
  continue;
91
102
  }
92
103
  throw new Error(`unknown flag: ${arg}`);
93
104
  }
94
- return { name, noRegister, help };
105
+ if (noRegister && description !== null) {
106
+ throw new Error('--description cannot be combined with --no-register');
107
+ }
108
+ return { description, name, noRegister, help };
95
109
  }
96
110
  const UNSET_DEFAULT_HINT = 'Multiple knowledge bases are registered and no default is set. Run `kb set-default` to choose one.\n';
97
111
  function buildUsageError(error) {
@@ -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
  }
@@ -0,0 +1,15 @@
1
+ import type { CommandOutput } from './check.js';
2
+ export declare const TAXONOMY_HELP = "Usage: kb taxonomy init [options]\n\nDerive a starting taxonomy from the notes a knowledge base already holds, so a\ntaxonomy can be introduced to a populated store without every folder reporting\nas undeclared. Every folder holding notes is declared, along with each of its\nancestors, under \"provisional:\" with no description: the command cannot invent\ndescriptions, and provisional already means \"declared, not yet reviewed\".\n\nOptions:\n --kb <name> Use the named store from the kb.yaml registry. Without it, the\n nearest ancestor .kb/ directory is used.\n --merge Add only the domains an existing taxonomy does not declare.\n Without it, a store that already has a taxonomy is left\n untouched.\n -h, --help Show this help.\n\nExit codes:\n 0 the taxonomy was written, or already declared every derived domain\n 2 usage error, unresolvable store, a store marked readonly in kb.yaml,\n malformed config or taxonomy, or an existing taxonomy without --merge\n";
3
+ export declare function runTaxonomy(input: {
4
+ argv: readonly string[];
5
+ cwd: string;
6
+ home?: string;
7
+ }): Promise<CommandOutput>;
8
+ interface TaxonomyOptions {
9
+ subcommand: 'init' | null;
10
+ kb: string | null;
11
+ merge: boolean;
12
+ help: boolean;
13
+ }
14
+ export declare function parseTaxonomyArgs(argv: readonly string[]): TaxonomyOptions;
15
+ export {};
@@ -0,0 +1,130 @@
1
+ import { enumerateNotes } from "../../check/enumerate.js";
2
+ import { isKbLoaderError } from "../../config/kb-loader-error.js";
3
+ import { loadKbConfig } from "../../config/load-config.js";
4
+ import { resolveKbDir, TAXONOMY_FILE } from "../../layout/index.js";
5
+ import { deriveDomains } from "../../taxonomy/domain-paths.js";
6
+ import { loadTaxonomy } from "../../taxonomy/load-taxonomy.js";
7
+ import { writeTaxonomy } from "../../taxonomy/write-taxonomy.js";
8
+ import { takeInlineValue, takeValue } from "../parse-flag-value.js";
9
+ import { resolveStore } from "../resolve-store.js";
10
+ export const TAXONOMY_HELP = `Usage: kb taxonomy init [options]
11
+
12
+ Derive a starting taxonomy from the notes a knowledge base already holds, so a
13
+ taxonomy can be introduced to a populated store without every folder reporting
14
+ as undeclared. Every folder holding notes is declared, along with each of its
15
+ ancestors, under "provisional:" with no description: the command cannot invent
16
+ descriptions, and provisional already means "declared, not yet reviewed".
17
+
18
+ Options:
19
+ --kb <name> Use the named store from the kb.yaml registry. Without it, the
20
+ nearest ancestor .kb/ directory is used.
21
+ --merge Add only the domains an existing taxonomy does not declare.
22
+ Without it, a store that already has a taxonomy is left
23
+ untouched.
24
+ -h, --help Show this help.
25
+
26
+ Exit codes:
27
+ 0 the taxonomy was written, or already declared every derived domain
28
+ 2 usage error, unresolvable store, a store marked readonly in kb.yaml,
29
+ malformed config or taxonomy, or an existing taxonomy without --merge
30
+ `;
31
+ export async function runTaxonomy(input) {
32
+ let options;
33
+ try {
34
+ options = parseTaxonomyArgs(input.argv);
35
+ }
36
+ catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n${TAXONOMY_HELP}` };
39
+ }
40
+ if (options.help) {
41
+ return { exitCode: 0, stdout: TAXONOMY_HELP, stderr: '' };
42
+ }
43
+ if (options.subcommand === null) {
44
+ return { exitCode: 2, stdout: '', stderr: `kb taxonomy: no subcommand given\n${TAXONOMY_HELP}` };
45
+ }
46
+ const resolved = await resolveStore({
47
+ explicitKb: options.kb,
48
+ cwd: input.cwd,
49
+ ...(input.home !== undefined && { home: input.home }),
50
+ });
51
+ if (!resolved.ok) {
52
+ return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${resolved.message}\n` };
53
+ }
54
+ if (resolved.readonly) {
55
+ const name = resolved.store.name ?? resolved.store.path;
56
+ const message = `knowledge base "${name}" is marked readonly in kb.yaml; taxonomy init is refused`;
57
+ return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n` };
58
+ }
59
+ const kbRoot = { path: resolved.store.path, kbDir: resolveKbDir(resolved.store.path) };
60
+ try {
61
+ return await initTaxonomy({ kbRoot, merge: options.merge });
62
+ }
63
+ catch (error) {
64
+ if (isKbLoaderError(error)) {
65
+ return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${error.message}\n` };
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+ export function parseTaxonomyArgs(argv) {
71
+ let subcommand = null;
72
+ let kb = null;
73
+ let merge = false;
74
+ let help = false;
75
+ for (let index = 0; index < argv.length; index += 1) {
76
+ const arg = argv[index];
77
+ if (arg === undefined)
78
+ continue;
79
+ if (arg === '--help' || arg === '-h') {
80
+ help = true;
81
+ continue;
82
+ }
83
+ if (arg === '--merge') {
84
+ merge = true;
85
+ continue;
86
+ }
87
+ if (arg === '--kb') {
88
+ kb = takeValue(argv, index, '--kb');
89
+ index += 1;
90
+ continue;
91
+ }
92
+ if (arg.startsWith('--kb=')) {
93
+ kb = takeInlineValue(arg, '--kb=');
94
+ continue;
95
+ }
96
+ if (arg.startsWith('-')) {
97
+ throw new Error(`unknown flag: ${arg}`);
98
+ }
99
+ if (arg !== 'init') {
100
+ throw new Error(`unknown subcommand: ${arg}`);
101
+ }
102
+ if (subcommand !== null) {
103
+ throw new Error('only one subcommand may be given');
104
+ }
105
+ subcommand = arg;
106
+ }
107
+ return { subcommand, kb, merge, help };
108
+ }
109
+ async function initTaxonomy(input) {
110
+ const { kbRoot, merge } = input;
111
+ const existing = await loadTaxonomy({ kbRoot });
112
+ if (existing.size > 0 && !merge) {
113
+ const message = `${TAXONOMY_FILE} already declares ${existing.size} domains; pass --merge to add the missing ones`;
114
+ return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n` };
115
+ }
116
+ const config = await loadKbConfig({ kbRoot });
117
+ const notes = await enumerateNotes({ kbRoot: kbRoot.path, config });
118
+ const domains = deriveDomains(notes.map((note) => note.relativePath));
119
+ if (domains.length === 0) {
120
+ return { exitCode: 0, stdout: `no assertion folders hold notes; ${TAXONOMY_FILE} not written\n`, stderr: '' };
121
+ }
122
+ const { added } = await writeTaxonomy({
123
+ kbRoot,
124
+ declarations: domains.map((path) => ({ path, provisional: true })),
125
+ });
126
+ if (added.length === 0) {
127
+ return { exitCode: 0, stdout: `${TAXONOMY_FILE} already declares every derived domain\n`, stderr: '' };
128
+ }
129
+ return { exitCode: 0, stdout: `declared ${added.length} domains in ${TAXONOMY_FILE}\n`, stderr: '' };
130
+ }
@@ -1,12 +1,15 @@
1
1
  export function formatHuman(input) {
2
2
  const { summary, findings, targets, scope } = input;
3
- if (summary.notes === 0) {
3
+ if (summary.notes === 0 && findings.length === 0) {
4
4
  return `${zeroMatchLine(scope, targets)}\n`;
5
5
  }
6
6
  if (findings.length === 0) {
7
7
  return `✓ no findings (${summary.notes} notes checked)\n`;
8
8
  }
9
9
  const lines = [];
10
+ if (summary.notes === 0) {
11
+ lines.push(zeroMatchLine(scope, targets), '');
12
+ }
10
13
  for (const [path, group] of groupByPath(findings)) {
11
14
  lines.push(path);
12
15
  for (const finding of group) {
@@ -0,0 +1,2 @@
1
+ export declare function takeInlineValue(arg: string, prefix: string): string;
2
+ export declare function takeValue(argv: readonly string[], index: number, flag: string): string;
@@ -0,0 +1,14 @@
1
+ export function takeInlineValue(arg, prefix) {
2
+ const value = arg.slice(prefix.length);
3
+ if (value === '') {
4
+ throw new Error(`${prefix.replace(/=$/, '')} requires a value`);
5
+ }
6
+ return value;
7
+ }
8
+ export function takeValue(argv, index, flag) {
9
+ const next = argv[index + 1] ?? null;
10
+ if (next === null || next === '' || next.startsWith('--')) {
11
+ throw new Error(`${flag} requires a value`);
12
+ }
13
+ return next;
14
+ }