@williamthorsen/kb 0.3.0 → 0.4.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 (36) hide show
  1. package/README.md +69 -13
  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/taxonomy.d.ts +15 -0
  9. package/dist/esm/cli/commands/taxonomy.js +130 -0
  10. package/dist/esm/cli/format.js +4 -1
  11. package/dist/esm/cli/parse-flag-value.d.ts +2 -0
  12. package/dist/esm/cli/parse-flag-value.js +14 -0
  13. package/dist/esm/cli/resolve-store.d.ts +14 -0
  14. package/dist/esm/cli/resolve-store.js +25 -0
  15. package/dist/esm/cli/run.d.ts +1 -1
  16. package/dist/esm/cli/run.js +5 -0
  17. package/dist/esm/layout/index.d.ts +1 -1
  18. package/dist/esm/layout/index.js +1 -1
  19. package/dist/esm/layout/store-layout.d.ts +1 -0
  20. package/dist/esm/layout/store-layout.js +1 -0
  21. package/dist/esm/lints/index.d.ts +1 -0
  22. package/dist/esm/lints/index.js +1 -0
  23. package/dist/esm/lints/taxonomy.d.ts +12 -0
  24. package/dist/esm/lints/taxonomy.js +52 -0
  25. package/dist/esm/taxonomy/domain-paths.d.ts +3 -0
  26. package/dist/esm/taxonomy/domain-paths.js +23 -0
  27. package/dist/esm/taxonomy/index.d.ts +4 -0
  28. package/dist/esm/taxonomy/index.js +4 -0
  29. package/dist/esm/taxonomy/load-taxonomy.d.ts +5 -0
  30. package/dist/esm/taxonomy/load-taxonomy.js +60 -0
  31. package/dist/esm/taxonomy/taxonomy-schema.d.ts +11 -0
  32. package/dist/esm/taxonomy/taxonomy-schema.js +28 -0
  33. package/dist/esm/taxonomy/write-taxonomy.d.ts +12 -0
  34. package/dist/esm/taxonomy/write-taxonomy.js +142 -0
  35. package/dist/esm/types.d.ts +1 -0
  36. package/package.json +6 -1
package/README.md CHANGED
@@ -5,18 +5,22 @@ 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.3.0 (2026-08-04)
8
+ ## Release notes — v0.4.0 (2026-08-07)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Make codeassembly and kb CLI tools publishable (#1164)
12
+ - Add the .kb/taxonomy.yaml format with drift reporting and back-fill (#1210)
13
13
 
14
- The `codeassembly` CLI now installs from npm, and its `install` and `sync` commands deploy the rulebooks, skills, and subagents it ships into any consuming project. The knowledge-base library `@williamthorsen/kb` and the session-lifecycle event package `codeassembly-lifecycle` are published alongside it.
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.
15
19
  <!-- /section:release-notes -->
16
20
 
17
21
  ## Exports
18
22
 
19
- The package exposes ten subpath entries plus a root barrel:
23
+ The package exposes twelve subpath entries plus a root barrel:
20
24
 
21
25
  | Entry | Description |
22
26
  | ------------------- | ------------------------------------------------------------------------------ |
@@ -27,9 +31,11 @@ The package exposes ten subpath entries plus a root barrel:
27
31
  | `./discovery` | KB root discovery and `kb.yaml` registry loading, merging, and writing |
28
32
  | `./filesystem` | Filesystem-existence helpers with an explicit absence policy |
29
33
  | `./frontmatter` | Note parsing into typed frontmatter and writing it back to YAML |
34
+ | `./layout` | The store's on-disk layout: every path inside a `.kb/` store derives from here |
30
35
  | `./note-io` | Type-blind note read/write as an ordered frontmatter field map |
31
36
  | `./records` | The typed `assertion`/`event` record parsers and renderers |
32
37
  | `./tags` | `.kb/tag-aliases.yaml` loading and tag canonicalization |
38
+ | `./taxonomy` | `.kb/taxonomy.yaml` loading, comment-preserving declaration, and path mapping |
33
39
  | `./vault-integrity` | Type-blind `[[link]]` resolution and basename-uniqueness over a note set |
34
40
 
35
41
  Every public function takes a single plain-object input so a future MCP wrapper can mechanically bind Zod-validated payloads.
@@ -120,6 +126,8 @@ missing files (when a path is given) throw.
120
126
 
121
127
  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.
122
128
 
129
+ `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.
130
+
123
131
  ```ts
124
132
  import { checkVaultIntegrity } from '@williamthorsen/kb/vault-integrity';
125
133
 
@@ -128,7 +136,7 @@ const findings = checkVaultIntegrity(notes);
128
136
 
129
137
  ## Checking a store
130
138
 
131
- `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.
139
+ `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.
132
140
 
133
141
  ```ts
134
142
  import { check } from '@williamthorsen/kb/check';
@@ -136,7 +144,9 @@ import { check } from '@williamthorsen/kb/check';
136
144
  const { notes, findings } = await check({ kbRoot });
137
145
  ```
138
146
 
139
- A structural defect in either loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.
147
+ A structural defect in any loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.
148
+
149
+ `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.
140
150
 
141
151
  ### Which notes are checked: `.kb/config.yaml`
142
152
 
@@ -157,9 +167,39 @@ exclude:
157
167
 
158
168
  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`.
159
169
 
170
+ ### The declared structure: `.kb/taxonomy.yaml`
171
+
172
+ `.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.
173
+
174
+ ```yaml
175
+ # .kb/taxonomy.yaml
176
+ domains:
177
+ engineering: Software engineering practice
178
+ engineering/tooling: Build, test, and development tooling
179
+ provisional:
180
+ engineering/tooling/versioning: Release and version management
181
+ languages:
182
+ ```
183
+
184
+ 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.
185
+
186
+ 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.
187
+
188
+ 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:
189
+
190
+ | Rule | Meaning |
191
+ | --------------------- | ----------------------------------------------- |
192
+ | `taxonomy.undeclared` | A folder holds notes but no domain declares it. |
193
+ | `taxonomy.unused` | A declared domain has no note at or beneath it. |
194
+ | `taxonomy.orphan` | A declared domain's parent is undeclared. |
195
+
196
+ 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.
197
+
198
+ `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`.
199
+
160
200
  ## The `kb` command
161
201
 
162
- The package ships a `kb` bin with three subcommands: `create`, `set-default`, and `check`.
202
+ The package ships a `kb` bin with four subcommands: `check`, `create`, `set-default`, and `taxonomy`.
163
203
 
164
204
  ### kb create
165
205
 
@@ -221,15 +261,31 @@ Because the exit code reflects only the selected notes, a per-batch or pre-commi
221
261
 
222
262
  Exit codes:
223
263
 
224
- | Code | Meaning |
225
- | ---- | --------------------------------------------------------------------------------------------------------------------------- |
226
- | `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. |
227
- | `1` | One or more error-severity findings in the checked notes. |
228
- | `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config` or `tag-aliases` file. |
264
+ | Code | Meaning |
265
+ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
266
+ | `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. |
267
+ | `1` | One or more error-severity findings in the checked notes. |
268
+ | `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config`, `tag-aliases`, or `taxonomy` file. |
269
+
270
+ 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.
271
+
272
+ ### kb taxonomy
273
+
274
+ `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.
275
+
276
+ ```bash
277
+ kb taxonomy init # declare every folder holding notes, and its ancestors
278
+ kb taxonomy init --kb coding # back-fill the named store from the kb.yaml registry
279
+ kb taxonomy init --merge # add only the domains an existing taxonomy omits
280
+ ```
281
+
282
+ 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.
283
+
284
+ Without `--merge`, a store that already declares a taxonomy is left untouched and the command exits 2.
229
285
 
230
286
  ## Error and exception model
231
287
 
232
- 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.
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.
233
289
 
234
290
  ## MCP wrappability
235
291
 
@@ -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
- }
@@ -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.startsWith('--')) {
11
+ throw new Error(`${flag} requires a value`);
12
+ }
13
+ return next;
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { StoreRef } from './format.js';
2
+ export type ResolveStoreOutcome = {
3
+ ok: true;
4
+ store: StoreRef;
5
+ readonly: boolean;
6
+ } | {
7
+ ok: false;
8
+ message: string;
9
+ };
10
+ export declare function resolveStore(input: {
11
+ explicitKb: string | null;
12
+ cwd: string;
13
+ home?: string;
14
+ }): Promise<ResolveStoreOutcome>;
@@ -0,0 +1,25 @@
1
+ import { findKbRoot } from "../discovery/find-kb-root.js";
2
+ import { tryLoadKbRegistry } from "../discovery/load-registry.js";
3
+ export async function resolveStore(input) {
4
+ const { config } = await tryLoadKbRegistry({
5
+ projectDir: input.cwd,
6
+ ...(input.home !== undefined && { home: input.home }),
7
+ });
8
+ if (input.explicitKb !== null) {
9
+ const match = config.entries.find((entry) => entry.name === input.explicitKb);
10
+ if (match === undefined) {
11
+ return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` };
12
+ }
13
+ return { ok: true, store: { name: match.name, path: match.path }, readonly: match.readonly ?? false };
14
+ }
15
+ const discovered = await findKbRoot({ startDir: input.cwd });
16
+ if (discovered === null) {
17
+ return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' };
18
+ }
19
+ const registered = config.entries.find((entry) => entry.path === discovered.path);
20
+ return {
21
+ ok: true,
22
+ store: { name: registered?.name ?? null, path: discovered.path },
23
+ readonly: registered?.readonly ?? false,
24
+ };
25
+ }
@@ -1,6 +1,6 @@
1
1
  import { type CommandOutput } from './commands/check.js';
2
2
  import type { SelectKbPrompt } from './select-kb-prompt.js';
3
- export declare const HELP = "Usage: kb <command> [options]\n\nCommands:\n check Validate a knowledge base, optionally scoped to selected notes.\n create Scaffold a new knowledge base and register it in the kb.yaml registry.\n set-default Set, clear, or choose the default knowledge base.\n\nRun \"kb <command> --help\" for command options.\n";
3
+ export declare const HELP = "Usage: kb <command> [options]\n\nCommands:\n check Validate a knowledge base, optionally scoped to selected notes.\n create Scaffold a new knowledge base and register it in the kb.yaml registry.\n set-default Set, clear, or choose the default knowledge base.\n taxonomy Derive a knowledge base's taxonomy from the notes it already holds.\n\nRun \"kb <command> --help\" for command options.\n";
4
4
  export declare function run(input: {
5
5
  argv: readonly string[];
6
6
  cwd: string;
@@ -1,12 +1,14 @@
1
1
  import { runCheck } from "./commands/check.js";
2
2
  import { runCreate } from "./commands/create.js";
3
3
  import { runSetDefault } from "./commands/set-default.js";
4
+ import { runTaxonomy } from "./commands/taxonomy.js";
4
5
  export const HELP = `Usage: kb <command> [options]
5
6
 
6
7
  Commands:
7
8
  check Validate a knowledge base, optionally scoped to selected notes.
8
9
  create Scaffold a new knowledge base and register it in the kb.yaml registry.
9
10
  set-default Set, clear, or choose the default knowledge base.
11
+ taxonomy Derive a knowledge base's taxonomy from the notes it already holds.
10
12
 
11
13
  Run "kb <command> --help" for command options.
12
14
  `;
@@ -26,6 +28,9 @@ export async function run(input) {
26
28
  ...(input.selectKb !== undefined && { selectKb: input.selectKb }),
27
29
  });
28
30
  }
31
+ if (command === 'taxonomy') {
32
+ return runTaxonomy({ argv: rest, cwd: input.cwd, ...(input.home !== undefined && { home: input.home }) });
33
+ }
29
34
  if (command === 'set-default') {
30
35
  return runSetDefault({
31
36
  argv: rest,
@@ -1 +1 @@
1
- export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, } from './store-layout.js';
1
+ export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, TAXONOMY_FILE, } from './store-layout.js';
@@ -1 +1 @@
1
- export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, } from "./store-layout.js";
1
+ export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, TAXONOMY_FILE, } from "./store-layout.js";
@@ -5,6 +5,7 @@ export declare const ALIASES_FILE = ".kb/tag-aliases.yaml";
5
5
  export declare const ASSERTIONS_DIR = "content/assertions";
6
6
  export declare const CONFIG_FILE = ".kb/config.yaml";
7
7
  export declare const EVENTS_DIR = "content/events";
8
+ export declare const TAXONOMY_FILE = ".kb/taxonomy.yaml";
8
9
  export declare function buildEventPath(id: string): string;
9
10
  export declare function resolveAssertionsDir(storePath: string): string;
10
11
  export declare function resolveEventPath(input: {
@@ -6,6 +6,7 @@ export const ALIASES_FILE = `${KB_DIR}/tag-aliases.yaml`;
6
6
  export const ASSERTIONS_DIR = `${CONTENT_DIR}/${ASSERTIONS_SEGMENT}`;
7
7
  export const CONFIG_FILE = `${KB_DIR}/config.yaml`;
8
8
  export const EVENTS_DIR = `${CONTENT_DIR}/events`;
9
+ export const TAXONOMY_FILE = `${KB_DIR}/taxonomy.yaml`;
9
10
  export function buildEventPath(id) {
10
11
  return `${EVENTS_DIR}/${id}.md`;
11
12
  }
@@ -1,2 +1,3 @@
1
1
  export { pathsFindings, type PathsNote } from './paths.js';
2
2
  export { tagAliasFindings, type TagAliasNote } from './tag-alias.js';
3
+ export { taxonomyFindings, type TaxonomyNote } from './taxonomy.js';
@@ -1,2 +1,3 @@
1
1
  export { pathsFindings } from "./paths.js";
2
2
  export { tagAliasFindings } from "./tag-alias.js";
3
+ export { taxonomyFindings } from "./taxonomy.js";
@@ -0,0 +1,12 @@
1
+ import type { KbConfig } from '../config/config-schema.js';
2
+ import type { Taxonomy } from '../taxonomy/taxonomy-schema.js';
3
+ import type { Finding } from '../types.js';
4
+ export interface TaxonomyNote {
5
+ relativePath: string;
6
+ }
7
+ export declare function taxonomyFindings(input: {
8
+ notes: readonly TaxonomyNote[];
9
+ taxonomy: Taxonomy;
10
+ config: KbConfig;
11
+ taxonomyPath: string;
12
+ }): Finding[];
@@ -0,0 +1,52 @@
1
+ import { createNoteScopeMatcher } from "../config/note-scope.js";
2
+ import { ASSERTIONS_DIR } from "../layout/index.js";
3
+ import { resolveDomain, resolveParent } from "../taxonomy/domain-paths.js";
4
+ export function taxonomyFindings(input) {
5
+ const { notes, taxonomy, config, taxonomyPath } = input;
6
+ const declared = taxonomy.keys().toArray().toSorted();
7
+ if (declared.length === 0) {
8
+ return [];
9
+ }
10
+ const observed = new Set();
11
+ for (const note of notes) {
12
+ const domain = resolveDomain(note.relativePath);
13
+ if (domain !== undefined) {
14
+ observed.add(domain);
15
+ }
16
+ }
17
+ const matcher = createNoteScopeMatcher(config);
18
+ const findings = [];
19
+ for (const domain of [...observed].toSorted()) {
20
+ if (!taxonomy.has(domain)) {
21
+ findings.push(buildFinding(taxonomyPath, 'undeclared', `folder "${domain}" holds notes but no domain declares it`));
22
+ }
23
+ }
24
+ for (const domain of declared) {
25
+ if (holdsNote(domain, observed) || matcher.isExcluded(`${ASSERTIONS_DIR}/${domain}`)) {
26
+ continue;
27
+ }
28
+ findings.push(buildFinding(taxonomyPath, 'unused', `domain "${domain}" is declared but holds no notes`));
29
+ }
30
+ for (const domain of declared) {
31
+ const parent = resolveParent(domain);
32
+ if (parent !== undefined && !taxonomy.has(parent)) {
33
+ findings.push(buildFinding(taxonomyPath, 'orphan', `domain "${domain}" is declared but its parent "${parent}" is not`));
34
+ }
35
+ }
36
+ return findings;
37
+ }
38
+ function buildFinding(taxonomyPath, rule, message) {
39
+ return { path: taxonomyPath, scope: 'vault', rule: `taxonomy.${rule}`, severity: 'warning', message };
40
+ }
41
+ function holdsNote(domain, observed) {
42
+ if (observed.has(domain)) {
43
+ return true;
44
+ }
45
+ const prefix = `${domain}/`;
46
+ for (const candidate of observed) {
47
+ if (candidate.startsWith(prefix)) {
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
@@ -0,0 +1,3 @@
1
+ export declare function deriveDomains(relativePaths: Iterable<string>): string[];
2
+ export declare function resolveDomain(relativePath: string): string | undefined;
3
+ export declare function resolveParent(path: string): string | undefined;
@@ -0,0 +1,23 @@
1
+ import { ASSERTIONS_DIR } from "../layout/index.js";
2
+ export function deriveDomains(relativePaths) {
3
+ const domains = new Set();
4
+ for (const relativePath of relativePaths) {
5
+ let domain = resolveDomain(relativePath);
6
+ while (domain !== undefined) {
7
+ domains.add(domain);
8
+ domain = resolveParent(domain);
9
+ }
10
+ }
11
+ return domains.values().toArray().toSorted();
12
+ }
13
+ export function resolveDomain(relativePath) {
14
+ const prefix = `${ASSERTIONS_DIR}/`;
15
+ if (!relativePath.startsWith(prefix)) {
16
+ return undefined;
17
+ }
18
+ return resolveParent(relativePath.slice(prefix.length));
19
+ }
20
+ export function resolveParent(path) {
21
+ const lastSlash = path.lastIndexOf('/');
22
+ return lastSlash === -1 ? undefined : path.slice(0, lastSlash);
23
+ }
@@ -0,0 +1,4 @@
1
+ export { resolveDomain, resolveParent } from './domain-paths.js';
2
+ export { loadTaxonomy } from './load-taxonomy.js';
3
+ export { describeKeyDefect, type Taxonomy, type TaxonomyEntry, taxonomyFileShape } from './taxonomy-schema.js';
4
+ export { type TaxonomyDeclaration, writeTaxonomy } from './write-taxonomy.js';
@@ -0,0 +1,4 @@
1
+ export { resolveDomain, resolveParent } from "./domain-paths.js";
2
+ export { loadTaxonomy } from "./load-taxonomy.js";
3
+ export { describeKeyDefect, taxonomyFileShape } from "./taxonomy-schema.js";
4
+ export { writeTaxonomy } from "./write-taxonomy.js";
@@ -0,0 +1,5 @@
1
+ import type { KbRoot } from '../types.js';
2
+ import { type Taxonomy } from './taxonomy-schema.js';
3
+ export declare function loadTaxonomy(input: {
4
+ kbRoot: KbRoot;
5
+ }): Promise<Taxonomy>;
@@ -0,0 +1,60 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { KbLoaderError } from "../config/kb-loader-error.js";
5
+ import { TAXONOMY_FILE } from "../layout/index.js";
6
+ import { isEnoent } from "../type-guards.js";
7
+ import { describeKeyDefect, taxonomyFileShape } from "./taxonomy-schema.js";
8
+ export async function loadTaxonomy(input) {
9
+ const path = join(input.kbRoot.path, TAXONOMY_FILE);
10
+ let text;
11
+ try {
12
+ text = await readFile(path, 'utf8');
13
+ }
14
+ catch (error) {
15
+ if (isEnoent(error)) {
16
+ return new Map();
17
+ }
18
+ throw error;
19
+ }
20
+ let parsed;
21
+ try {
22
+ parsed = parse(text);
23
+ }
24
+ catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ throw new KbLoaderError(`${path}: malformed YAML — ${message}`);
27
+ }
28
+ const result = taxonomyFileShape.safeParse(parsed ?? {});
29
+ if (!result.success) {
30
+ throw new KbLoaderError(`${path}: invalid taxonomy.yaml${describeIssueLocation(result.error)}`);
31
+ }
32
+ const entries = new Map();
33
+ collectBlock({ entries, block: result.data.domains ?? undefined, provisional: false, path });
34
+ collectBlock({ entries, block: result.data.provisional ?? undefined, provisional: true, path });
35
+ return entries;
36
+ }
37
+ function collectBlock(input) {
38
+ const { entries, block, provisional, path } = input;
39
+ if (block === undefined) {
40
+ return;
41
+ }
42
+ for (const [key, description] of Object.entries(block)) {
43
+ const defect = describeKeyDefect(key);
44
+ if (defect !== undefined) {
45
+ throw new KbLoaderError(`${path}: domain "${key}" ${defect}`);
46
+ }
47
+ if (entries.has(key)) {
48
+ throw new KbLoaderError(`${path}: domain "${key}" is declared in both domains and provisional`);
49
+ }
50
+ entries.set(key, { description: description ?? '', provisional });
51
+ }
52
+ }
53
+ function describeIssueLocation(error) {
54
+ const issue = error.issues[0];
55
+ if (issue === undefined) {
56
+ return ' — unknown error';
57
+ }
58
+ const location = issue.path.length > 0 ? ` at ${issue.path.join('.')}` : '';
59
+ return `${location} — ${issue.message}`;
60
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ export declare function describeKeyDefect(key: string): string | undefined;
3
+ export type Taxonomy = ReadonlyMap<string, TaxonomyEntry>;
4
+ export interface TaxonomyEntry {
5
+ description: string;
6
+ provisional: boolean;
7
+ }
8
+ export declare const taxonomyFileShape: z.ZodObject<{
9
+ domains: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
10
+ provisional: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
11
+ }, z.core.$strip>;
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { ASSERTIONS_DIR, ASSERTIONS_SEGMENT, CONTENT_DIR } from "../layout/index.js";
3
+ export function describeKeyDefect(key) {
4
+ if (key === '') {
5
+ return 'is empty';
6
+ }
7
+ if (key.includes('\\')) {
8
+ return 'contains a backslash; domain paths are slash-separated';
9
+ }
10
+ if (key.startsWith('/') || key.endsWith('/')) {
11
+ return 'has a leading or trailing slash';
12
+ }
13
+ const segments = key.split('/');
14
+ if (segments.some((segment) => segment.trim() === '')) {
15
+ return 'has an empty segment';
16
+ }
17
+ if (segments.some((segment) => segment === '.' || segment === '..')) {
18
+ return 'has a "." or ".." segment';
19
+ }
20
+ if (segments[0] === ASSERTIONS_SEGMENT || (segments[0] === CONTENT_DIR && segments[1] === ASSERTIONS_SEGMENT)) {
21
+ return `restates the ${ASSERTIONS_DIR}/ prefix, which every key implies`;
22
+ }
23
+ return undefined;
24
+ }
25
+ export const taxonomyFileShape = z.object({
26
+ domains: z.record(z.string(), z.string().nullable()).nullable().optional(),
27
+ provisional: z.record(z.string(), z.string().nullable()).nullable().optional(),
28
+ });
@@ -0,0 +1,12 @@
1
+ import type { KbRoot } from '../types.js';
2
+ export interface TaxonomyDeclaration {
3
+ path: string;
4
+ description?: string;
5
+ provisional: boolean;
6
+ }
7
+ export declare function writeTaxonomy(input: {
8
+ kbRoot: KbRoot;
9
+ declarations: readonly TaxonomyDeclaration[];
10
+ }): Promise<{
11
+ added: string[];
12
+ }>;
@@ -0,0 +1,142 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { isMap, isPair, isScalar, parseDocument } from 'yaml';
5
+ import { KbLoaderError } from "../config/kb-loader-error.js";
6
+ import { TAXONOMY_FILE } from "../layout/index.js";
7
+ import { isEnoent, isRecord } from "../type-guards.js";
8
+ import { describeKeyDefect } from "./taxonomy-schema.js";
9
+ const BLOCKS = new Set(['domains', 'provisional']);
10
+ export async function writeTaxonomy(input) {
11
+ const path = join(input.kbRoot.path, TAXONOMY_FILE);
12
+ for (const declaration of input.declarations) {
13
+ const defect = describeKeyDefect(declaration.path);
14
+ if (defect !== undefined) {
15
+ throw new KbLoaderError(`${path}: domain "${declaration.path}" ${defect}`);
16
+ }
17
+ }
18
+ const document = await readDocument(path);
19
+ const declared = readDeclaredPaths(document);
20
+ const added = [];
21
+ const byBlock = new Map();
22
+ for (const declaration of sortByPath(input.declarations)) {
23
+ if (declared.has(declaration.path)) {
24
+ continue;
25
+ }
26
+ declared.add(declaration.path);
27
+ added.push(declaration.path);
28
+ const block = declaration.provisional ? 'provisional' : 'domains';
29
+ const description = declaration.description ?? '';
30
+ const entries = byBlock.get(block) ?? [];
31
+ entries.push([declaration.path, description === '' ? null : description]);
32
+ byBlock.set(block, entries);
33
+ }
34
+ if (added.length === 0) {
35
+ return { added };
36
+ }
37
+ for (const [block, entries] of byBlock) {
38
+ declareInBlock({ document, block, entries });
39
+ }
40
+ await writeAtomic(path, document.toString({ nullStr: '' }));
41
+ return { added };
42
+ }
43
+ function assertBlocksAppendable(document, path) {
44
+ const contents = document.contents;
45
+ if (!isMap(contents)) {
46
+ return;
47
+ }
48
+ for (const pair of contents.items) {
49
+ if (!isPair(pair) || !isScalar(pair.key) || typeof pair.key.value !== 'string') {
50
+ continue;
51
+ }
52
+ if (!BLOCKS.has(pair.key.value) || isMap(pair.value) || isEmptyBlockValue(pair.value)) {
53
+ continue;
54
+ }
55
+ throw new KbLoaderError(`${path}: "${pair.key.value}" must be a mapping of domain paths to descriptions`);
56
+ }
57
+ }
58
+ function declareInBlock(input) {
59
+ const { document, block, entries } = input;
60
+ const emptied = findEmptyBlock(document, block);
61
+ if (emptied === undefined) {
62
+ for (const [path, description] of entries) {
63
+ document.setIn([block, path], description);
64
+ }
65
+ return;
66
+ }
67
+ const comment = isScalar(emptied.value) ? emptied.value.comment : undefined;
68
+ if (typeof comment === 'string' && isScalar(emptied.key)) {
69
+ emptied.key.comment = comment;
70
+ }
71
+ emptied.value = document.createNode(Object.fromEntries(entries));
72
+ }
73
+ function findEmptyBlock(document, block) {
74
+ const contents = document.contents;
75
+ if (!isMap(contents)) {
76
+ return undefined;
77
+ }
78
+ for (const pair of contents.items) {
79
+ if (isPair(pair) && isScalar(pair.key) && pair.key.value === block && isEmptyBlockValue(pair.value)) {
80
+ return pair;
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+ function isEmptyBlockValue(value) {
86
+ return value === null || (isScalar(value) && value.value === null);
87
+ }
88
+ async function readDocument(path) {
89
+ let text = '';
90
+ try {
91
+ text = await readFile(path, 'utf8');
92
+ }
93
+ catch (error) {
94
+ if (!isEnoent(error)) {
95
+ throw error;
96
+ }
97
+ }
98
+ const document = parseDocument(text);
99
+ const firstError = document.errors[0];
100
+ if (firstError !== undefined) {
101
+ throw new KbLoaderError(`${path}: malformed YAML — ${firstError.message}`);
102
+ }
103
+ if (document.contents !== null && !isMap(document.contents)) {
104
+ throw new KbLoaderError(`${path}: top-level must be a mapping`);
105
+ }
106
+ assertBlocksAppendable(document, path);
107
+ return document;
108
+ }
109
+ function readDeclaredPaths(document) {
110
+ const paths = new Set();
111
+ const contents = document.toJS();
112
+ if (!isRecord(contents)) {
113
+ return paths;
114
+ }
115
+ for (const block of BLOCKS) {
116
+ const declarations = contents[block];
117
+ if (isRecord(declarations)) {
118
+ for (const path of Object.keys(declarations)) {
119
+ paths.add(path);
120
+ }
121
+ }
122
+ }
123
+ return paths;
124
+ }
125
+ function sortByPath(declarations) {
126
+ return declarations.toSorted((a, b) => {
127
+ if (a.path === b.path)
128
+ return 0;
129
+ return a.path < b.path ? -1 : 1;
130
+ });
131
+ }
132
+ async function writeAtomic(path, content) {
133
+ const tempPath = `${path}.${randomBytes(8).toString('hex')}.tmp`;
134
+ await writeFile(tempPath, content, 'utf8');
135
+ try {
136
+ await rename(tempPath, path);
137
+ }
138
+ catch (error) {
139
+ await unlink(tempPath).catch(() => { });
140
+ throw error;
141
+ }
142
+ }
@@ -42,6 +42,7 @@ export interface ParsedNote {
42
42
  export type FindingSeverity = 'error' | 'warning';
43
43
  export interface Finding {
44
44
  path: string;
45
+ scope?: 'note' | 'vault';
45
46
  line?: number;
46
47
  rule: string;
47
48
  severity: FindingSeverity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/kb",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Knowledge-base foundation: discovery, config, frontmatter parsing, records, tags, and vault-integrity checks",
5
5
  "keywords": [
6
6
  "frontmatter",
@@ -76,6 +76,11 @@
76
76
  "types": "./dist/esm/tags/index.d.ts",
77
77
  "import": "./dist/esm/tags/index.js"
78
78
  },
79
+ "./taxonomy": {
80
+ "source": "./src/taxonomy/index.ts",
81
+ "types": "./dist/esm/taxonomy/index.d.ts",
82
+ "import": "./dist/esm/taxonomy/index.js"
83
+ },
79
84
  "./vault-integrity": {
80
85
  "source": "./src/vault-integrity/index.ts",
81
86
  "types": "./dist/esm/vault-integrity/index.d.ts",