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
13 changes: 8 additions & 5 deletions packages/agents/content/skills/kb-curate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ 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`: `--kb <name>` (explicit) beats a discovered `.kb/` folder, which beats the registry's default-marked entry. 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`, `schema.yaml`, or `tag-aliases.yaml` fails the run with `invalid-config` rather than being silently ignored.

## Runtime dependencies

- **`node` ≥ 24** — the bundled helper inherits the Node version floor of `@codeassembly/kb`.
Expand Down Expand Up @@ -92,11 +94,12 @@ The helper prints a JSON object to stdout. On success the payload carries `ok: t

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. |
| `no-kb-resolvable` | No `.kb/` discovered, no registry default, and `--kb` matched nothing. | Confirm the `--kb` name or run from inside the vault. |
| `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. |
| 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`, `.kb/schema.yaml`, or `.kb/tag-aliases.yaml` in the store. | Fix the named file. The message names the offending file. |
| `no-kb-resolvable` | No `.kb/` discovered, no registry default, and `--kb` matched nothing. | Confirm the `--kb` name or run from inside the vault. |
| `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. |

System failures (out-of-disk, permission denied) print to stderr and exit non-zero. They are out of band and never appear as a structured `error` code.

Expand Down
20 changes: 17 additions & 3 deletions packages/agents/scripts/bundle-skill-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,14 @@ function assertKbEditSmokeResult(result: unknown): void {
function makeKbCurateSmokeTest(): SmokeTestInvocation {
const fixtureDir = mkdtempSync(path.join(tmpdir(), 'kb-curate-smoke-'));
mkdirSync(path.join(fixtureDir, '.kb'), { recursive: true });
// The seed note lives under `content/` so the store's default `targets: ['content/**/*.md']` enumerates it; a note
// at the store root would not match and the smoke test would silently report zero notes. The note links to a
// missing target so a successful enumeration always yields a `wikilinks.unresolved` finding — the proof, below,
// that the bundle enumerated the note rather than reporting an empty vault.
mkdirSync(path.join(fixtureDir, 'content'), { recursive: true });
writeFileSync(
path.join(fixtureDir, 'Smoke.md'),
'---\ntitle: Smoke\ntype: howto\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [smoke]\n---\n\nSmoke body.\n',
path.join(fixtureDir, 'content', 'Smoke.md'),
'---\ntitle: Smoke\ntype: howto\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [smoke]\n---\n\nSee [[Missing target]].\n',
'utf8',
);
return {
Expand All @@ -290,7 +295,12 @@ function makeKbCurateSmokeTest(): SmokeTestInvocation {
};
}

/** Assert the kb-curate smoke produced an ok read-only report with an array-valued `findings`. */
/**
* Assert the kb-curate smoke produced an ok read-only report that actually enumerated the seed note. The seed note
* carries an unresolved wikilink, so a non-empty enumeration always surfaces a `wikilinks.unresolved` finding; its
* absence means the bundle enumerated nothing — a broken `content/` scoping must fail here rather than pass with an
* empty report.
*/
function assertKbCurateSmokeResult(result: unknown): void {
if (!isRecord(result)) {
throw new TypeError('expected object result from kb-curate');
Expand All @@ -304,6 +314,10 @@ function assertKbCurateSmokeResult(result: unknown): void {
if (!Array.isArray(result.findings)) {
throw new TypeError(`expected findings to be an array, got ${JSON.stringify(result.findings)}`);
}
const rules = result.findings.map((entry: unknown) => (isRecord(entry) ? entry.rule : undefined));
if (!rules.includes('wikilinks.unresolved')) {
throw new Error(`expected the smoke run to enumerate the seed note; got rules: ${JSON.stringify(rules)}`);
}
}

/** Assert the parsed smoke-test result reports a composition-code-inline-mark finding. */
Expand Down
22 changes: 14 additions & 8 deletions packages/agents/src/kb-curate/__tests__/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,35 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { enumerateNotes } from '@codeassembly/kb/check';
import { defaultKbConfig } from '@codeassembly/kb/config';
import { describe, expect, it } from 'vitest';

import { applyFixes } from '../apply.ts';
import { enumerateNotes } from '../enumerate.ts';

const ORIGINAL_FRONTMATTER =
'---\ntitle: A\ntype: howto\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [todo-item]\n---\n';
const CANONICALIZED_FRONTMATTER =
'---\ntitle: A\ntype: howto\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [todo]\n---\n';
const TARGET = '---\ntitle: Foo\ntype: howto\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [x]\n---\n\nBody.\n';

/** Stands up a temp vault with a `.kb/` and the given note files. */
/** Stands up a temp vault with a `.kb/`, writing each note under `content/` so the default targets enumerate it. */
async function makeVault(files: Record<string, string>): Promise<string> {
const kbPath = await mkdtemp(join(tmpdir(), 'kb-curate-apply-'));
await mkdir(join(kbPath, '.kb'), { recursive: true });
for (const [relativePath, content] of Object.entries(files)) {
const full = join(kbPath, relativePath);
const full = join(kbPath, 'content', relativePath);
await mkdir(join(full, '..'), { recursive: true });
await writeFile(full, content, 'utf8');
}
return kbPath;
}

/** Enumerates a vault's notes under the default targets. */
async function enumerate(kbPath: string) {
return enumerateNotes({ kbRoot: kbPath, config: defaultKbConfig });
}

describe(applyFixes, () => {
// Regression: the inline wikilink writer must operate on current on-disk state, not the enumeration snapshot.
// A tag canonicalization that ran first rewrites the frontmatter on disk; writing the body rewrite from the stale
Expand All @@ -33,29 +39,29 @@ describe(applyFixes, () => {
it('preserves an on-disk frontmatter change made after enumeration when rewriting the body', async () => {
const linker = `${ORIGINAL_FRONTMATTER}\nSee [[old/Foo]].\n`;
const kbPath = await makeVault({ 'Linker.md': linker, 'tools/Foo.md': TARGET });
const notes = await enumerateNotes(kbPath);
const notes = await enumerate(kbPath);

// Simulate the tag fix having rewritten the frontmatter on disk (kb-edit, the sole frontmatter writer).
const linkerPath = join(kbPath, 'Linker.md');
const linkerPath = join(kbPath, 'content', 'Linker.md');
await writeFile(linkerPath, `${CANONICALIZED_FRONTMATTER}\nSee [[old/Foo]].\n`, 'utf8');

const fixes = await applyFixes({ kbPath, notes, findings: [] });

const onDisk = await readFile(linkerPath, 'utf8');
expect(onDisk).toContain('tags: [todo]'); // the on-disk frontmatter change is preserved, not clobbered
expect(onDisk).not.toContain('tags: [todo-item]');
expect(onDisk).toContain('[[tools/Foo]]'); // the body rewrite landed
expect(onDisk).toContain('[[content/tools/Foo]]'); // the body rewrite landed against the content-scoped path
const rewrite = fixes.find((fix) => fix.operation === 'rewrite-wikilink');
expect(rewrite).toMatchObject({ ok: true });
});

it('reports ok:false without writing when the body anchor is not found on disk', async () => {
const linker = `${ORIGINAL_FRONTMATTER}\nSee [[old/Foo]].\n`;
const kbPath = await makeVault({ 'Linker.md': linker, 'tools/Foo.md': TARGET });
const notes = await enumerateNotes(kbPath);
const notes = await enumerate(kbPath);

// Replace the body on disk so the snapshot's body no longer anchors in the current content.
const linkerPath = join(kbPath, 'Linker.md');
const linkerPath = join(kbPath, 'content', 'Linker.md');
await writeFile(linkerPath, `${ORIGINAL_FRONTMATTER}\nEntirely different body.\n`, 'utf8');

const fixes = await applyFixes({ kbPath, notes, findings: [] });
Expand Down
86 changes: 80 additions & 6 deletions packages/agents/src/kb-curate/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,35 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { describe, expect, it } from 'vitest';
import { check } from '@codeassembly/kb/check';
import { defaultKbConfig, KbLoaderError } from '@codeassembly/kb/config';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { parseArgs, runCurate } from '../cli.ts';

// Mock `check` with a passthrough to the real implementation so most tests run
// against real vaults; the error-path tests override it per-call.
vi.mock('@codeassembly/kb/check', async () => {
const actual = await vi.importActual<typeof import('@codeassembly/kb/check')>('@codeassembly/kb/check');
return { ...actual, check: vi.fn(actual.check) };
});

const NOW = new Date('2026-05-29T00:00:00Z');

const VALID =
'---\ntitle: A\ntype: howto\ncreated: 2026-05-01\nupdated: 2026-05-01\nlast-verified: 2026-05-20\ntags: [x]\n---\n\nBody.\n';

/** Stands up a temp vault with a `.kb/` and the given note files, plus an empty home so the registry resolves empty. */
/**
* Stands up a temp vault with a `.kb/` and an empty home so the registry resolves empty. A note path is written under
* `content/` so the store's default `targets: ['content/**\/*.md']` enumerates it; a path beginning with `.kb/` is
* written at the store root so config/schema/alias files land where the loaders read them.
*/
async function makeVault(files: Record<string, string>): Promise<{ kbPath: string; home: string }> {
const home = await mkdtemp(join(tmpdir(), 'kb-curate-home-'));
const kbPath = await mkdtemp(join(tmpdir(), 'kb-curate-vault-'));
await mkdir(join(kbPath, '.kb'), { recursive: true });
for (const [relativePath, content] of Object.entries(files)) {
const full = join(kbPath, relativePath);
const full = relativePath.startsWith('.kb/') ? join(kbPath, relativePath) : join(kbPath, 'content', relativePath);
await mkdir(join(full, '..'), { recursive: true });
await writeFile(full, content, 'utf8');
}
Expand Down Expand Up @@ -52,6 +65,10 @@ describe(parseArgs, () => {
});

describe(runCurate, () => {
afterEach(() => {
vi.mocked(check).mockClear();
});

it('returns no-kb-resolvable when no KB can be found and none is requested', async () => {
const home = await mkdtemp(join(tmpdir(), 'kb-curate-home-'));
const startDir = await mkdtemp(join(tmpdir(), 'kb-curate-empty-'));
Expand Down Expand Up @@ -109,8 +126,8 @@ describe(runCurate, () => {
expect(Array.isArray(result.applied)).toBe(true);
const rewrite = result.applied?.find((fix) => fix.operation === 'rewrite-wikilink');
expect(rewrite).toMatchObject({ ok: true, operation: 'rewrite-wikilink' });
const rewritten = await readFile(join(kbPath, 'Linker.md'), 'utf8');
expect(rewritten).toContain('[[tools/Foo]]');
const rewritten = await readFile(join(kbPath, 'content', 'Linker.md'), 'utf8');
expect(rewritten).toContain('[[content/tools/Foo]]');
});

it('reports a per-finding failure under --apply when the kb-edit sibling is absent for a tag-alias fix', async () => {
Expand All @@ -131,8 +148,9 @@ describe(runCurate, () => {

it('curates a readonly KB in report mode without refusing', async () => {
const kbPath = await mkdtemp(join(tmpdir(), 'kb-curate-ro-'));
await mkdir(join(kbPath, 'content'), { recursive: true });
await mkdir(join(kbPath, '.kb'), { recursive: true });
await writeFile(join(kbPath, 'Note.md'), VALID, 'utf8');
await writeFile(join(kbPath, 'content', 'Note.md'), VALID, 'utf8');
const home = await mkdtemp(join(tmpdir(), 'kb-curate-home-'));
await mkdir(join(home, '.agents'), { recursive: true });
await writeFile(join(home, '.agents', 'kb.yaml'), `kbs:\n ro:\n path: ${kbPath}\n readonly: true\n`, 'utf8');
Expand All @@ -143,4 +161,60 @@ describe(runCurate, () => {
if (!result.ok) return;
expect(result.mode).toBe('report');
});

it('produces the generic and curate findings together for a content-structured vault', async () => {
const { kbPath, home } = await makeVault({
'Bad.md':
'---\ntitle: Bad\ntype: howto\ncreated: 2026-05-01\nlast-verified: 2026-05-20\ntags: [x]\n---\n\nSee [[Ghost]].\n',
});

const result = await runCurate({ argv: [], startDir: kbPath, now: NOW, home });

expect(result.ok).toBe(true);
if (!result.ok) return;
const rules = result.findings.map((finding) => finding.rule);
expect(rules).toContain('frontmatter.required');
expect(rules).toContain('wikilinks.unresolved');
});

it('returns invalid-config when the store config.yaml is malformed', async () => {
const { kbPath, home } = await makeVault({
'Note.md': VALID,
'.kb/config.yaml': 'targets: [unterminated\n',
});

const result = await runCurate({ argv: [], startDir: kbPath, now: NOW, home });

expect(result).toEqual({ ok: false, error: 'invalid-config', message: expect.stringContaining('config.yaml') });
});

it('returns invalid-config when the store schema.yaml is malformed', async () => {
const { kbPath, home } = await makeVault({
'Note.md': VALID,
'.kb/schema.yaml': 'types: [howto\n',
});

const result = await runCurate({ argv: [], startDir: kbPath, now: NOW, home });

expect(result).toEqual({ ok: false, error: 'invalid-config', message: expect.stringContaining('schema.yaml') });
});

it('propagates a non-loader error from check rather than returning invalid-config', async () => {
const { kbPath, home } = await makeVault({ 'Note.md': VALID });
vi.mocked(check).mockRejectedValueOnce(new Error('rule engine crashed'));

await expect(runCurate({ argv: [], startDir: kbPath, now: NOW, home })).rejects.toThrow(/rule engine crashed/);
});

it('returns invalid-config when the residual check after --apply throws a KbLoaderError', async () => {
const { kbPath, home } = await makeVault({ 'Note.md': VALID });
// First (pre-apply) check is clean; the residual re-check throws a loader defect (e.g. config corrupted by a race).
vi.mocked(check)
.mockResolvedValueOnce({ config: defaultKbConfig, notes: [], findings: [] })
.mockRejectedValueOnce(new KbLoaderError('config.yaml: malformed YAML'));

const result = await runCurate({ argv: ['--apply'], startDir: kbPath, now: NOW, home });

expect(result).toEqual({ ok: false, error: 'invalid-config', message: expect.stringContaining('config.yaml') });
});
});
Loading
Loading