Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/agents/content/skills/kb-curate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ A value-bearing flag accepts both `--kb coding` and `--kb=coding`. With no flags

The knowledge base is resolved the same way as `kb-add`: a concrete `--kb <name>` beats a discovered `.kb/` folder, and the registry's `default_kb` is reachable only via `--kb @default`. When no `--kb` is given and no `.kb/` is discoverable, the run is refused rather than defaulting. A read-only report run accepts a KB marked `readonly: true` in `kb.yaml`; `--apply` against a readonly KB is refused with `readonly-kb`. Curating spans a single KB per run — wikilink resolution and supersede chains are only valid within one vault, so curating several vaults is a shell loop over `--kb`.

Which notes are curated is governed by the store's `.kb/config.yaml`: by default, only notes under `content/` are enumerated. A store with a different layout overrides the `targets` glob in its `config.yaml`. A malformed `config.yaml` or `tag-aliases.yaml` fails the run with `invalid-config` rather than being silently ignored.
Which notes are curated is governed by the store's `.kb/config.yaml`: by default, only notes under `content/` are enumerated. A store with a different layout overrides the `targets` glob in its `config.yaml`. A malformed `config.yaml`, `tag-aliases.yaml`, or `taxonomy.yaml` fails the run with `invalid-config` rather than being silently ignored.

## Runtime dependencies

Expand All @@ -37,20 +37,25 @@ Which notes are curated is governed by the store's `.kb/config.yaml`: by default

## Detection categories

The helper reports findings across five categories. Each finding carries a rule code and a severity.
The helper reports findings across six categories. Each finding carries a rule code and a severity.

| Rule code | Severity | Meaning |
| ----------------------- | -------- | -------------------------------------------------------------------------------------- |
| `wikilinks.unresolved` | error | A `[[Target]]` does not resolve to any vault note. |
| `wikilinks.basename` | warning | Two or more notes share a basename (reported once for the vault). |
| `paths.user-home` | error | A hardcoded `/Users/{name}/` path; use `~/` instead. |
| `tag-alias` | warning | A `tags` entry is a known alias of a canonical tag. |
| `taxonomy.undeclared` | warning | A folder holds notes but `.kb/taxonomy.yaml` declares no domain for it. |
| `taxonomy.unused` | warning | A declared domain has no note at or beneath it. |
| `taxonomy.orphan` | warning | A declared domain's parent is undeclared. |
| `verification.unmarked` | warning | The note has no `last-verified` field; reported only when the vault uses verification. |
| `verification.stale` | warning | `last-verified` is older than `--stale-after` days. |
| `supersede.dangling` | error | A `superseded-by`/`supersedes` target is not a vault note. |
| `supersede.cycle` | error | The note participates in a `superseded-by` loop. |
| `supersede.asymmetric` | warning | `A.superseded-by → B` without the matching `B.supersedes → A`. |

The three `taxonomy.*` rules describe the vault rather than a note, so each is reported once against `.kb/taxonomy.yaml` with the domain named in the message. They are self-configuring in the same way `verification.unmarked` is: a vault whose `.kb/taxonomy.yaml` is absent, or present but declaring nothing, reports none of them.

`verification.unmarked` is self-configuring: it is reported only when the vault actually uses verification — that is, when at least one note carries a well-formed `last-verified` value. In a vault that has not adopted verification stamps, an unmarked note is not a finding. A malformed `last-verified` value does not count as adoption, so a vault whose only verification-ish value is unparseable reports no unmarked findings. `verification.stale` is unaffected: a note with a stale `last-verified` is always flagged.

## Remediation under `--apply`
Expand All @@ -68,6 +73,7 @@ The remaining findings name the operator's next step:

- **Stale or unmarked verification** → re-confirm the note, then `kb-edit <path> --verify`.
- **Supersede defects** → repair with `kb-edit <old> --supersede-with <new>`, or correct the offending frontmatter field.
- **Taxonomy drift** → declare the folder in `.kb/taxonomy.yaml`, or move the notes to a declared domain. On a vault adopting a taxonomy for the first time, `kb taxonomy init` declares every folder that already holds notes in one pass.
- **Unresolved wikilinks, basename collisions, hardcoded paths** → resolve manually; these are too context-dependent to auto-fix.

## Process
Expand Down Expand Up @@ -96,7 +102,7 @@ On failure, `ok: false` plus a categorical `error` code:
| Code | What it means | What to do |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `invalid-args` | Unknown flag, missing value, or a non-positive-integer `--stale-after`. | Correct the invocation. The message names the specific defect. |
| `invalid-config` | A malformed `.kb/config.yaml` or `.kb/tag-aliases.yaml` in the store. | Fix the named file. The message names the offending file. |
| `invalid-config` | A malformed `.kb/config.yaml`, `.kb/tag-aliases.yaml`, or `.kb/taxonomy.yaml` in the store. | Fix the named file. The message names the offending file. |
| `no-kb-resolvable` | A KB could not be resolved: `--kb` matched no entry, or no `--kb` and no discoverable `.kb/`, or `--kb @default` with no configured default. | Confirm the `--kb` name, run from inside the vault, or pass `--kb @default`. |
| `readonly-kb` | `--apply` was used against a KB marked `readonly: true` in `kb.yaml`. | Drop `--apply` for a read-only report, or use a writable KB. |

Expand Down
4 changes: 2 additions & 2 deletions packages/agents/src/kb-curate/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {

/**
* Runs the helper end to end: parses args, resolves a single KB, enumerates and parses every note, runs detection
* across all five categories, and (under `--apply`) performs the two safe fixes before re-reporting residual
* across all six categories, and (under `--apply`) performs the two safe fixes before re-reporting residual
* findings. Recoverable failures (invalid args, no resolvable KB, a readonly KB under `--apply`) become structured
* `{ ok: false, ... }` results. System failures propagate to `main`'s try/catch.
*
Expand Down Expand Up @@ -153,7 +153,7 @@ type GuardedCheck =
{ ok: true; value: { notes: readonly EnumeratedNote[]; findings: Finding[] } } | { ok: false; failure: CurateResult };

/**
* Runs {@link curateCheck} and maps a `KbLoaderError` (malformed config or aliases) to a structured
* Runs {@link curateCheck} and maps a `KbLoaderError` (malformed config, aliases, or taxonomy) to a structured
* `invalid-config` failure. Any other throw — an enumeration or detection crash — propagates as a real failure rather
* than being relabeled as a config error. Both `runCurate` check calls route through here so the guard cannot drift.
*/
Expand Down
70 changes: 60 additions & 10 deletions packages/kb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ It underpins the knowledge-base skills — among them `kb-retrieve` (assertion r

## Exports

The package exposes ten subpath entries plus a root barrel:
The package exposes twelve subpath entries plus a root barrel:

| Entry | Description |
| ------------------- | ------------------------------------------------------------------------------ |
Expand All @@ -19,9 +19,11 @@ The package exposes ten subpath entries plus a root barrel:
| `./discovery` | KB root discovery and `kb.yaml` registry loading, merging, and writing |
| `./filesystem` | Filesystem-existence helpers with an explicit absence policy |
| `./frontmatter` | Note parsing into typed frontmatter and writing it back to YAML |
| `./layout` | The store's on-disk layout: every path inside a `.kb/` store derives from here |
| `./note-io` | Type-blind note read/write as an ordered frontmatter field map |
| `./records` | The typed `assertion`/`event` record parsers and renderers |
| `./tags` | `.kb/tag-aliases.yaml` loading and tag canonicalization |
| `./taxonomy` | `.kb/taxonomy.yaml` loading and comment-preserving domain declaration |
| `./vault-integrity` | Type-blind `[[link]]` resolution and basename-uniqueness over a note set |

Every public function takes a single plain-object input so a future MCP wrapper can mechanically bind Zod-validated payloads.
Expand Down Expand Up @@ -112,6 +114,8 @@ missing files (when a path is given) throw.

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.

`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.

```ts
import { checkVaultIntegrity } from '@williamthorsen/kb/vault-integrity';

Expand All @@ -120,15 +124,15 @@ const findings = checkVaultIntegrity(notes);

## Checking a store

`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.
`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.

```ts
import { check } from '@williamthorsen/kb/check';

const { notes, findings } = await check({ kbRoot });
```

A structural defect in either loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.
A structural defect in any loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.

### Which notes are checked: `.kb/config.yaml`

Expand All @@ -149,9 +153,39 @@ exclude:

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`.

### The declared structure: `.kb/taxonomy.yaml`

`.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.

```yaml
# .kb/taxonomy.yaml
domains:
engineering: Software engineering practice
engineering/tooling: Build, test, and development tooling
provisional:
engineering/tooling/versioning: Release and version management
languages:
```

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.

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.

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:

| Rule | Meaning |
| --------------------- | ----------------------------------------------- |
| `taxonomy.undeclared` | A folder holds notes but no domain declares it. |
| `taxonomy.unused` | A declared domain has no note at or beneath it. |
| `taxonomy.orphan` | A declared domain's parent is undeclared. |

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.

`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. Both are exported from `@williamthorsen/kb/taxonomy`.

## The `kb` command

The package ships a `kb` bin with three subcommands: `create`, `set-default`, and `check`.
The package ships a `kb` bin with four subcommands: `check`, `create`, `set-default`, and `taxonomy`.

### kb create

Expand Down Expand Up @@ -213,15 +247,31 @@ Because the exit code reflects only the selected notes, a per-batch or pre-commi

Exit codes:

| Code | Meaning |
| ---- | --------------------------------------------------------------------------------------------------------------------------- |
| `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. |
| `1` | One or more error-severity findings in the checked notes. |
| `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config` or `tag-aliases` file. |
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. |
| `1` | One or more error-severity findings in the checked notes. |
| `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config`, `tag-aliases`, or `taxonomy` file. |

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.

### kb taxonomy

`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.

```bash
kb taxonomy init # declare every folder holding notes, and its ancestors
kb taxonomy init --kb coding # back-fill the named store from the kb.yaml registry
kb taxonomy init --merge # add only the domains an existing taxonomy omits
```

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.

Without `--merge`, a store that already declares a taxonomy is left untouched and the command exits 2.

## Error and exception model

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.
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.

## MCP wrappability

Expand Down
5 changes: 5 additions & 0 deletions packages/kb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"types": "./dist/esm/tags/index.d.ts",
"import": "./dist/esm/tags/index.js"
},
"./taxonomy": {
"source": "./src/taxonomy/index.ts",
"types": "./dist/esm/taxonomy/index.d.ts",
"import": "./dist/esm/taxonomy/index.js"
},
"./vault-integrity": {
"source": "./src/vault-integrity/index.ts",
"types": "./dist/esm/vault-integrity/index.d.ts",
Expand Down
Loading
Loading