diff --git a/.gitignore b/.gitignore index 2c4aea45..e3ed8881 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ packages/agents/content/skills/kb-add/kb-add.mjs packages/agents/content/skills/kb-curate/kb-curate.mjs packages/agents/content/skills/kb-edit/kb-edit.mjs packages/agents/content/skills/kb-retrieve/kb-retrieve.mjs +packages/agents/content/skills/kb-update-events/kb-update-events.mjs packages/agents/content/skills/update-jira-ticket/update-jira-ticket.mjs # Credentials diff --git a/packages/agents/content/skills/_harnesses/rovodev/systematic-debugging/condition-based-waiting-example.ts b/packages/agents/content/skills/_harnesses/rovodev/systematic-debugging/condition-based-waiting-example.ts index 6dab69c4..fafd7a73 100644 --- a/packages/agents/content/skills/_harnesses/rovodev/systematic-debugging/condition-based-waiting-example.ts +++ b/packages/agents/content/skills/_harnesses/rovodev/systematic-debugging/condition-based-waiting-example.ts @@ -1,3 +1,4 @@ +// @ts-nocheck vendor file // Complete implementation of condition-based waiting utilities // From: Lace test infrastructure improvements (2025-10-03) // Context: Fixed 15 flaky tests by replacing arbitrary timeouts diff --git a/packages/agents/content/skills/kb-update-events/SKILL.md b/packages/agents/content/skills/kb-update-events/SKILL.md new file mode 100644 index 00000000..d2961849 --- /dev/null +++ b/packages/agents/content/skills/kb-update-events/SKILL.md @@ -0,0 +1,75 @@ +--- +name: kb-update-events +description: Edit existing events in the knowledge store — mark one or more events addressed-by a reference, or retag them — in a single batch invocation. The event mutable set only; events stay otherwise write-once. +user-invocable: true +--- + +# Update existing events + +Apply one mutation to one or more existing event records in a single invocation. A bundled helper does the mechanical work — it resolves the event store by name, resolves each id to its record, reads it through the type-blind note I/O layer, parses it to a typed `KbEvent`, applies the operation, and writes it back atomically. You supply the store, the operation, and the event ids. + +The operation surface is the **event mutable set** only: `addressed-by` (mark an event as addressed by a reference) and `tags` (retag). Events are otherwise write-once: there is no body edit, and marking stamps no timestamp — `addressed-by` and `tags` are append-only/curatorial annotations, not substantive edits. For new events, use `capture-event`. For editing assertions, use `kb-edit`. + +**Announce at start:** "Using kb-update-events to {mark|retag} {N} event(s)." + +## Arguments + +| Argument | Description | Required | +| --------------------------- | ------------------------------------------------------------------------- | -------- | +| `--store` | Registry name of the event store, or `@default` for the `default_kb`. | Yes | +| `--add-addressed-by ` | Append comma-separated reference(s) to each event's `addressed-by` list. | One op | +| `--retag ` | Replace each event's `tags` with the comma-separated list. Canonicalizes. | One op | +| `` | One or more event ids; each resolves to `{store}/content/events/{id}.md`. | Yes | + +A value-bearing flag accepts both `--retag fix,observation` and `--retag=fix,observation`. Exactly one operation flag is required per invocation; combining the two is rejected with `invalid-args`. References are free-form (a KB wikilink or relative path, a commit SHA, a PR/issue ref, or a URL); they are stored verbatim and de-duplicated after any existing entries. A reference that begins with `--` is otherwise read as the next flag, so pass it with the inline `--add-addressed-by=` form. + +### Store selection + +`--store` is required: every edit names its store. The helper resolves the store by registry name only and never walks the working directory for a `.kb/` folder. The store must be registered in `kb.yaml`. Pass `--store ` for a named store, or `--store @default` for the registry's `default_kb`. Omitting `--store` is refused with an error that lists the registered stores. + +## Runtime dependencies + +- **`node` ≥ 24** — the bundled helper inherits the Node version floor of `@codeassembly/kb`. + +## Process + +### 1. Gather the event ids + +Collect the ids of the events to edit (typically from a prior recall). Each id is the event's ULID — the filename stem under `content/events/`. + +### 2. Invoke the helper + +```bash +node {harness_home_dir}/skills/kb-update-events/kb-update-events.mjs \ + --store \ + --add-addressed-by \ + [ ...] +``` + +Use `--retag ` in place of `--add-addressed-by` to retag instead. + +The helper prints a JSON object to stdout: + +- `ok: true` with `operation`, `store`, and a `results` array — one entry per id, in order. Each entry is either `{ ok: true, id, path }` or `{ ok: false, id, error, message }`. +- `ok: false` with `error` and `message` on an invocation-level failure (nothing was written). + +### 3. Handle the result + +On `ok: true`, report the per-event outcomes. A per-event `error` is one of: + +- `invalid-id` — the id is not a bare filename stem (contains a path separator). Correct the id. +- `not-found` — no event at the resolved path. Confirm the id and store. +- `parse` — the file is not a valid event record. Inspect it. +- `validation` — the rendered record failed re-validation (unexpected); surface the message. + +On `ok: false`, route by the `error` code: + +- `invalid-args` — surface the message and propose a corrected invocation. +- `missing-store` — `--store` was omitted; the message lists the registered stores. +- `store-not-registered` — the named store is not in `kb.yaml`. +- `readonly-store` — the store is marked readonly; edits are refused. +- `no-default-store` — `--store @default` was given but no `default_kb` is configured. + +## Completion + +Each named event updated in place and re-validated, written atomically. A mixed batch is partial by design: succeeded events are written; failed ids are reported and left untouched. Events remain write-once apart from these `addressed-by`/`tags` annotations. diff --git a/packages/agents/eslint.config.js b/packages/agents/eslint.config.js index 238e464c..de7b38b3 100644 --- a/packages/agents/eslint.config.js +++ b/packages/agents/eslint.config.js @@ -5,14 +5,5 @@ import baseConfig from '../../eslint.config.js'; export default [ ...baseConfig, // Generated esbuild bundles and shipped harness content, not lintable source. - globalIgnores([ - 'content/skills/_harnesses/**', - 'content/skills/capture-event/capture-event.mjs', - 'content/skills/derive-session-context/derive-session-context.mjs', - 'content/skills/kb-add/kb-add.mjs', - 'content/skills/kb-curate/kb-curate.mjs', - 'content/skills/kb-edit/kb-edit.mjs', - 'content/skills/kb-retrieve/kb-retrieve.mjs', - 'content/skills/update-jira-ticket/update-jira-ticket.mjs', - ]), + globalIgnores(['content/skills/**/*.mjs', 'content/skills/**/*-example.ts']), ]; diff --git a/packages/agents/scripts/bundle-skill-helpers.ts b/packages/agents/scripts/bundle-skill-helpers.ts index 08aa0476..50019c18 100644 --- a/packages/agents/scripts/bundle-skill-helpers.ts +++ b/packages/agents/scripts/bundle-skill-helpers.ts @@ -94,6 +94,11 @@ export const targets: BundleTarget[] = [ outFile: 'content/skills/capture-event/capture-event.mjs', smokeTest: makeCaptureEventSmokeTest(), }, + { + entry: 'src/kb-update-events/cli.ts', + outFile: 'content/skills/kb-update-events/kb-update-events.mjs', + smokeTest: makeKbUpdateEventsSmokeTest(), + }, ]; /** @@ -169,6 +174,98 @@ function assertCaptureEventSmokeResult(result: unknown): void { } } +/** + * Stands up an event store carrying a seed event plus an isolated home registering it as `default_kb`, then returns a + * `SmokeTestInvocation` that marks the event `addressed-by` a reference with `--store @default`. Exercises the full + * `@default` resolution → read → parse → mutate → atomic write pipeline, the only path that wires the bundled resolver, + * the per-type record layer, and the note-io writer together. The assertion confirms the reference landed and that no + * `title`/`created`/`updated` was injected onto the event. + */ +function makeKbUpdateEventsSmokeTest(): SmokeTestInvocation { + const storePath = mkdtempSync(path.join(tmpdir(), 'kb-update-events-store-')); + mkdirSync(path.join(storePath, '.kb'), { recursive: true }); + writeFileSync( + path.join(storePath, '.kb', 'schema.yaml'), + [ + 'recordTypes:', + ' event:', + ' recall: recurrence-recency', + ' required: [id, captured-at, session, cwd, summary]', + ' optional: [repo, skill, model, harness, tags, addressed-by]', + '', + ].join('\n'), + 'utf8', + ); + + const eventsDir = path.join(storePath, 'content', 'events'); + mkdirSync(eventsDir, { recursive: true }); + const eventPath = path.join(eventsDir, 'smoke-event.md'); + writeFileSync( + eventPath, + [ + '---', + 'recordType: event', + 'id: smoke-event', + 'captured-at: 2026-06-18T09:41:02Z', + 'session: smoke', + 'cwd: /tmp/smoke', + 'summary: Smoke event', + '---', + '', + 'Body.', + '', + ].join('\n'), + 'utf8', + ); + + const home = mkdtempSync(path.join(tmpdir(), 'kb-update-events-home-')); + mkdirSync(path.join(home, '.agents'), { recursive: true }); + writeFileSync( + path.join(home, '.agents', 'kb.yaml'), + `default_kb: codeassembly\nkbs:\n codeassembly:\n path: ${storePath}\n`, + 'utf8', + ); + + return { + args: ['--store', '@default', '--add-addressed-by', '#849', 'smoke-event'], + env: { ...process.env, HOME: home }, + assertResult: (result) => assertKbUpdateEventsSmokeResult(result, eventPath), + }; +} + +/** + * Assert the kb-update-events smoke produced an ok batch whose one event updated, with the reference written to its + * `addressed-by` list and no assertion fields injected. + */ +function assertKbUpdateEventsSmokeResult(result: unknown, eventPath: string): void { + if (!isRecord(result)) { + throw new TypeError('expected object result from kb-update-events'); + } + if (result.ok !== true) { + throw new Error(`expected ok: true, got ${JSON.stringify(result)}`); + } + if (result.operation !== 'add-addressed-by') { + throw new Error(`expected operation 'add-addressed-by', got ${JSON.stringify(result.operation)}`); + } + if (!Array.isArray(result.results) || result.results.length !== 1) { + throw new Error(`expected one per-event result, got ${JSON.stringify(result.results)}`); + } + const entry: unknown = result.results[0]; + if (!isRecord(entry) || entry.ok !== true) { + throw new Error(`expected the event to update, got ${JSON.stringify(entry)}`); + } + const written = readFileSync(eventPath, 'utf8'); + if (!/^addressed-by:/m.test(written)) { + throw new Error(`expected the written event to carry addressed-by, got:\n${written}`); + } + if (!written.includes('#849')) { + throw new Error(`expected the written event to reference #849, got:\n${written}`); + } + if (/^(title|created|updated):/m.test(written)) { + throw new Error(`expected no assertion fields injected, got:\n${written}`); + } +} + /** * Builds a fixture directory containing a minimal preferences file and returns a `SmokeTestInvocation` * that drives the deriver against it with a known branch name. The deriver's output depends on the diff --git a/packages/agents/src/kb-update-events/__tests__/cli.test.ts b/packages/agents/src/kb-update-events/__tests__/cli.test.ts new file mode 100644 index 00000000..10b5ea00 --- /dev/null +++ b/packages/agents/src/kb-update-events/__tests__/cli.test.ts @@ -0,0 +1,235 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { parseNoteContent } from '@codeassembly/kb/frontmatter'; +import { readNoteContent } from '@codeassembly/kb/note-io'; +import { parseEvent } from '@codeassembly/kb/records'; +import { frontmatterRule, runRules } from '@codeassembly/kb/rules'; +import { loadSchema } from '@codeassembly/kb/schema'; +import { describe, expect, it } from 'vitest'; + +import { parseArgs, runUpdate } from '../cli.ts'; + +const EVENT_ID = '01HZCEVENTAAAAAAAAAAAAAAAA'; + +/** Stand up a temp event store plus an isolated home that registers it under `name` and marks it `default_kb`. */ +async function makeStore(name: string): Promise<{ storePath: string; home: string }> { + const storePath = await mkdtemp(join(tmpdir(), 'update-events-store-')); + await mkdir(join(storePath, '.kb'), { recursive: true }); + + const home = await mkdtemp(join(tmpdir(), 'update-events-home-')); + await mkdir(join(home, '.agents'), { recursive: true }); + await writeFile( + join(home, '.agents', 'kb.yaml'), + `default_kb: ${name}\nkbs:\n ${name}:\n path: ${storePath}\n`, + 'utf8', + ); + + return { storePath, home }; +} + +/** Write an event record under `content/events/{id}.md`, with optional extra frontmatter lines, returning its path. */ +async function seedEvent(storePath: string, id: string, extraFields: string[] = []): Promise { + const dir = join(storePath, 'content', 'events'); + await mkdir(dir, { recursive: true }); + const path = join(dir, `${id}.md`); + const front = [ + 'recordType: event', + `id: ${id}`, + 'captured-at: 2026-06-18T09:41:02Z', + 'session: session-abc', + 'cwd: /tmp/work', + 'summary: Noticed a thing', + ...extraFields, + ]; + await writeFile(path, `---\n${front.join('\n')}\n---\n\nBody.\n`, 'utf8'); + return path; +} + +/** Re-read a written event file and parse it back to a typed record, asserting it round-trips. */ +async function readBackEvent(path: string): Promise> { + const written = await readFile(path, 'utf8'); + const { fields, body } = readNoteContent(written); + return parseEvent(fields, body); +} + +describe(parseArgs, () => { + it('parses an add-addressed-by invocation with a store and ids', () => { + const parsed = parseArgs(['--store', 'codeassembly', '--add-addressed-by', '#849,#850', EVENT_ID]); + expect(parsed).toEqual({ + operation: 'add-addressed-by', + store: 'codeassembly', + ids: [EVENT_ID], + references: ['#849', '#850'], + }); + }); + + it('parses a retag invocation with multiple ids', () => { + const parsed = parseArgs(['--store', 'codeassembly', '--retag', 'fix, observation', EVENT_ID, 'id-two']); + expect(parsed).toEqual({ + operation: 'retag', + store: 'codeassembly', + ids: [EVENT_ID, 'id-two'], + tags: ['fix', 'observation'], + }); + }); + + it('accepts the inline --flag=value form', () => { + const parsed = parseArgs(['--store=codeassembly', '--add-addressed-by=#849', EVENT_ID]); + expect(parsed.store).toBe('codeassembly'); + }); + + it('binds an inline =value verbatim even when it begins with --', () => { + const parsed = parseArgs(['--store', 's', '--add-addressed-by=--weird-ref', EVENT_ID]); + expect(parsed).toEqual({ + operation: 'add-addressed-by', + store: 's', + ids: [EVENT_ID], + references: ['--weird-ref'], + }); + }); + + it('leaves store null when --store is omitted, deferring the refusal to the resolver', () => { + const parsed = parseArgs(['--add-addressed-by', '#849', EVENT_ID]); + expect(parsed.store).toBeNull(); + }); + + it.each([ + { argv: ['--store', 's', EVENT_ID], pattern: /one operation flag is required/ }, + { argv: ['--store', 's', '--add-addressed-by', '#1', '--retag', 'fix', EVENT_ID], pattern: /mutually exclusive/ }, + { argv: ['--store', 's', '--add-addressed-by', '#1'], pattern: /at least one event id/ }, + { argv: ['--store', 's', '--add-addressed-by', '', EVENT_ID], pattern: /at least one reference/ }, + { argv: ['--store', 's', '--bogus', 'x', EVENT_ID], pattern: /unknown flag/ }, + { argv: ['--store', '--add-addressed-by', '#1', EVENT_ID], pattern: /--store requires a value/ }, + ])('throws on $pattern', ({ argv, pattern }) => { + expect(() => parseArgs(argv)).toThrow(pattern); + }); +}); + +describe(runUpdate, () => { + it('marks an event addressed-by a reference, injecting no assertion fields', async () => { + const { storePath, home } = await makeStore('codeassembly'); + const path = await seedEvent(storePath, EVENT_ID); + + const result = await runUpdate({ argv: ['--store', 'codeassembly', '--add-addressed-by', '#849', EVENT_ID], home }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.results).toEqual([{ ok: true, id: EVENT_ID, path }]); + + const written = await readFile(path, 'utf8'); + expect(written).not.toMatch(/^(title|created|updated):/m); + + const parsed = await readBackEvent(path); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.record.addressedBy).toEqual(['#849']); + }); + + it('de-duplicates addressed-by across existing and new entries', async () => { + const { storePath, home } = await makeStore('codeassembly'); + const path = await seedEvent(storePath, EVENT_ID, ["addressed-by: ['#789']"]); + + const result = await runUpdate({ + argv: ['--store', 'codeassembly', '--add-addressed-by', '#789,#999', EVENT_ID], + home, + }); + + expect(result.ok).toBe(true); + const parsed = await readBackEvent(path); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.record.addressedBy).toEqual(['#789', '#999']); + }); + + it('retags an event, replacing its tags', async () => { + const { storePath, home } = await makeStore('codeassembly'); + const path = await seedEvent(storePath, EVENT_ID, ['tags: [old]']); + + const result = await runUpdate({ argv: ['--store', 'codeassembly', '--retag', 'fix,observation', EVENT_ID], home }); + + expect(result.ok).toBe(true); + const parsed = await readBackEvent(path); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.record.tags).toEqual(['fix', 'observation']); + }); + + it('reports per-event success and failure across a mixed batch', async () => { + const { storePath, home } = await makeStore('codeassembly'); + const goodPath = await seedEvent(storePath, EVENT_ID); + const missingId = '01HZCEVENTBBBBBBBBBBBBBBBB'; + + const result = await runUpdate({ + argv: ['--store', 'codeassembly', '--add-addressed-by', '#849', EVENT_ID, missingId], + home, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.results).toHaveLength(2); + expect(result.results[0]).toEqual({ ok: true, id: EVENT_ID, path: goodPath }); + expect(result.results[1]?.ok).toBe(false); + const failure = result.results[1]; + if (failure === undefined || failure.ok) return; + expect(failure.error).toBe('not-found'); + + const parsed = await readBackEvent(goodPath); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.record.addressedBy).toEqual(['#849']); + }); + + it('rejects an id containing a path separator as invalid-id', async () => { + const { home } = await makeStore('codeassembly'); + + const result = await runUpdate({ + argv: ['--store', 'codeassembly', '--add-addressed-by', '#849', '../escape'], + home, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.results[0]?.ok).toBe(false); + const failure = result.results[0]; + if (failure === undefined || failure.ok) return; + expect(failure.error).toBe('invalid-id'); + }); + + it('returns missing-store when --store is omitted', async () => { + const { home } = await makeStore('codeassembly'); + + const result = await runUpdate({ argv: ['--add-addressed-by', '#849', EVENT_ID], home }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('missing-store'); + }); + + it('returns store-not-registered for an unknown store', async () => { + const { home } = await makeStore('codeassembly'); + + const result = await runUpdate({ argv: ['--store', 'ghost', '--add-addressed-by', '#849', EVENT_ID], home }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('store-not-registered'); + }); + + it('writes an edited event that still passes the store frontmatter rule', async () => { + const { storePath, home } = await makeStore('codeassembly'); + const path = await seedEvent(storePath, EVENT_ID); + + const result = await runUpdate({ argv: ['--store', 'codeassembly', '--add-addressed-by', '#849', EVENT_ID], home }); + expect(result.ok).toBe(true); + + const written = await readFile(path, 'utf8'); + const schema = await loadSchema({ + kbRoot: { path: storePath, kbDir: join(storePath, '.kb'), via: 'ancestor-walk' }, + }); + const parsed = parseNoteContent({ content: written, path }); + const findings = runRules({ rules: [frontmatterRule], notes: [parsed], schema }); + expect(findings.filter((finding) => finding.severity === 'error')).toEqual([]); + }); +}); diff --git a/packages/agents/src/kb-update-events/cli.ts b/packages/agents/src/kb-update-events/cli.ts new file mode 100644 index 00000000..e2b735ba --- /dev/null +++ b/packages/agents/src/kb-update-events/cli.ts @@ -0,0 +1,316 @@ +/* eslint n/no-process-exit: off */ +/* eslint unicorn/no-process-exit: off */ +import { realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import type { AliasMap, KbRoot } from '@codeassembly/kb'; +import { type ReadNote, readNote, writeNote } from '@codeassembly/kb/note-io'; +import { parseEvent, renderEvent } from '@codeassembly/kb/records'; +import { loadAliases } from '@codeassembly/kb/tags'; + +import { splitCommaList } from '../kb-shared/note-helpers.ts'; +import { resolveCaptureTarget, type ResolveCaptureTargetOutcome } from '../kb-shared/resolve-capture-target.ts'; +import { parseTagList } from '../kb-shared/tag-helpers.ts'; +import { isMissingFile } from '../lib/type-guards.ts'; +import { addAddressedBy } from './operations/add-addressed-by.ts'; +import { retag } from './operations/retag.ts'; +import type { EventResult, ParsedArgs, UpdateFailure, UpdateResult } from './types.ts'; + +/** Flag names that take a value. */ +const VALUE_FLAGS = ['store', 'add-addressed-by', 'retag'] as const; +type ValueFlag = (typeof VALUE_FLAGS)[number]; + +/** Executes the helper from `process.argv` and writes the JSON result to stdout. */ +async function main(): Promise { + try { + const result = await runUpdate({ argv: process.argv.slice(2) }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`kb-update-events: ${message}\n`); + process.exit(1); + } +} + +if (isEntryPoint()) { + await main(); +} + +/** + * Runs the helper end to end: parses args, resolves the target store by registry name (or the `@default` sentinel), and + * applies the chosen operation to each event id independently. Each id resolves to `{store}/content/events/{id}.md`; + * the event is read, parsed to a typed `KbEvent`, mutated, re-rendered through the per-type renderer, and written back + * atomically. The per-type renderer emits only event fields, so an edit never injects the assertion-only `title`, + * `created`, or `updated` onto an event. A recoverable per-event failure (invalid id, not found, unparseable) is + * captured in that id's result and never aborts the others. + * + * Invocation-level failures (invalid args, an unresolvable or readonly store) become structured `{ ok: false, ... }` + * results. System failures (out-of-disk, permission denied) propagate to the caller's try/catch. + * + * @internal - Exported to allow testing. + */ +export async function runUpdate(input: { argv: readonly string[]; home?: string }): Promise { + let args: ParsedArgs; + try { + args = parseArgs(input.argv); + } catch (error) { + return { ok: false, error: 'invalid-args', message: error instanceof Error ? error.message : String(error) }; + } + + const resolved = await resolveCaptureTarget({ + explicitName: args.store, + ...(input.home !== undefined && { home: input.home }), + }); + if (!resolved.ok) { + return resolutionFailure(resolved); + } + const store = resolved.store; + + // Aliases are only consulted by `retag`; `add-addressed-by` stores references verbatim, so skip the load for it. + const aliases: AliasMap = + args.operation === 'retag' ? await loadAliasesForStore(store.path) : new Map(); + + const results: EventResult[] = []; + for (const id of args.ids) { + results.push(await editOne({ storePath: store.path, id, args, aliases })); + } + + return { ok: true, operation: args.operation, store: store.name, results }; +} + +/** + * Parses the helper's argv. Layout: a required `--store`, exactly one operation flag (`--add-addressed-by` or + * `--retag`), and one or more positional event ids. Each value-bearing flag accepts both `--flag value` and + * `--flag=value`; `--add-addressed-by` and `--retag` take a comma-separated list. An unknown flag, both operation flags, + * neither, no ids, or a missing required value throws with a usage-style message. + * + * @internal - Exported to allow testing. + */ +export function parseArgs(argv: readonly string[]): ParsedArgs { + const ids: string[] = []; + const raw: Partial> = {}; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) { + continue; + } + if (arg.startsWith('--')) { + const matched = matchValueFlag(arg); + if (matched === null) { + throw new Error(`unknown flag: ${arg}`); + } + let value = matched.inlineValue; + if (value === null) { + // A value taken from the next argv is rejected when it is absent or itself a flag. The inline `--flag=value` + // form binds its value verbatim — the `=` disambiguates it from a following flag — so a `--`-prefixed or + // empty value is only reachable inline. + const next = argv[index + 1] ?? null; + if (next === null || next.startsWith('--')) { + throw new Error(`--${matched.key} requires a value`); + } + value = next; + index += 1; + } + raw[matched.key] = value; + continue; + } + ids.push(arg); + } + + const store = raw.store === undefined ? null : raw.store; + if (store === '') { + throw new Error('--store requires a value'); + } + + const hasAddressedBy = raw['add-addressed-by'] !== undefined; + const hasRetag = raw.retag !== undefined; + if (hasAddressedBy && hasRetag) { + throw new Error('operation flags are mutually exclusive; got --add-addressed-by and --retag'); + } + if (!hasAddressedBy && !hasRetag) { + throw new Error('one operation flag is required (--add-addressed-by or --retag)'); + } + if (ids.length === 0) { + throw new Error('at least one event id is required'); + } + + if (hasAddressedBy) { + const references = splitCommaList(raw['add-addressed-by'] ?? ''); + if (references.length === 0) { + throw new Error('--add-addressed-by requires at least one reference'); + } + return { operation: 'add-addressed-by', store, ids, references }; + } + return { operation: 'retag', store, ids, tags: parseTagList(raw.retag ?? '') }; +} + +// region | Helpers + +/** + * Applies the operation to a single event id, mapping any recoverable failure onto a per-event result. Reads through + * the note-io layer and parses to a typed `KbEvent`; a missing file, a frontmatter parse error, or a record that is not + * a valid event each become a structured failure rather than a throw. The rendered output is re-parsed as a defensive + * round-trip guard before the atomic write. + */ +async function editOne(input: { + storePath: string; + id: string; + args: ParsedArgs; + aliases: AliasMap; +}): Promise { + const { storePath, id, args, aliases } = input; + + if (!isSafeId(id)) { + return { + ok: false, + id, + error: 'invalid-id', + message: `event id "${id}" must be a bare filename stem (no path separators)`, + }; + } + + const path = join(storePath, 'content', 'events', `${id}.md`); + + let read: ReadNote; + try { + read = await readNote(path); + } catch (error) { + if (isMissingFile(error)) { + return { ok: false, id, error: 'not-found', message: `no event at ${path}` }; + } + throw error; + } + + if (read.error !== undefined) { + return { ok: false, id, error: 'parse', message: read.error }; + } + + const parsed = parseEvent(read.fields, read.body); + if (!parsed.ok) { + return { ok: false, id, error: 'parse', message: parsed.errors.join('; ') }; + } + + const updated = + args.operation === 'add-addressed-by' + ? addAddressedBy(parsed.record, args.references) + : retag(parsed.record, args.tags, aliases); + + const rendered = renderEvent(updated); + + const reparsed = parseEvent(rendered.fields, rendered.body); + if (!reparsed.ok) { + return { ok: false, id, error: 'validation', message: reparsed.errors.join('; ') }; + } + + await writeNote(path, rendered.fields, rendered.body); + return { ok: true, id, path }; +} + +/** + * Builds the agent-facing error message for an omitted `--store`, naming the registered stores and, when configured, + * the registry default reachable as `--store @default`. + */ +function formatMissingStoreMessage(resolved: { + registeredStores: string[]; + defaultName?: string; + registryError?: string; +}): string { + if (resolved.registryError !== undefined) { + return `--store is required, but the kb.yaml registry could not be loaded: ${resolved.registryError}`; + } + if (resolved.registeredStores.length === 0) { + return '--store is required, but no stores are registered in kb.yaml'; + } + const stores = resolved.registeredStores.join(', '); + const defaultHint = + resolved.defaultName !== undefined + ? `the registry default is "${resolved.defaultName}", reachable as --store @default` + : 'no default_kb is configured'; + return `--store is required. Registered stores: ${stores}. Pass --store to choose one; ${defaultHint}.`; +} + +/** Returns true when this module is the process entry point, resolving both sides through `realpathSync` so a symlinked invocation still matches. */ +function isEntryPoint(): boolean { + const entry = process.argv[1]; + if (entry === undefined) { + return false; + } + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`kb-update-events: warning: could not determine entry point: ${message}\n`); + return false; + } +} + +/** Reports whether an event id is a bare filename stem, rejecting path separators and traversal segments. */ +function isSafeId(id: string): boolean { + return id.length > 0 && !id.includes('/') && !id.includes('\\') && !id.includes('..') && !id.includes('\0'); +} + +/** Loads tag aliases for a store, degrading a malformed or unreadable `tag-aliases.yaml` to an empty map with a warning. */ +async function loadAliasesForStore(storePath: string): Promise { + const kbRoot: KbRoot = { path: storePath, kbDir: join(storePath, '.kb'), via: 'ancestor-walk' }; + try { + return await loadAliases({ kbRoot }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`kb-update-events: warning: could not load tag aliases: ${message}\n`); + return new Map(); + } +} + +/** Matches a value-bearing flag, returning its key and any inline `=value`. */ +function matchValueFlag(arg: string): { key: ValueFlag; inlineValue: string | null } | null { + for (const key of VALUE_FLAGS) { + if (arg === `--${key}`) { + return { key, inlineValue: null }; + } + if (arg.startsWith(`--${key}=`)) { + return { key, inlineValue: arg.slice(`--${key}=`.length) }; + } + } + return null; +} + +/** Maps a store-resolution failure onto the helper's invocation-level failure result. */ +function resolutionFailure(resolved: Extract): UpdateFailure { + switch (resolved.reason) { + case 'missing-store': + return { ok: false, error: 'missing-store', message: formatMissingStoreMessage(resolved) }; + case 'not-registered': + return { + ok: false, + error: 'store-not-registered', + message: + resolved.registryError !== undefined + ? `could not load kb.yaml registry: ${resolved.registryError}` + : `event store "${resolved.requestedName}" is not registered in kb.yaml`, + }; + case 'readonly-store': + return { + ok: false, + error: 'readonly-store', + message: `event store "${resolved.name}" is marked readonly in kb.yaml; edits are refused`, + }; + case 'no-default': + return { + ok: false, + error: 'no-default-store', + message: + resolved.registryError !== undefined + ? `could not resolve the default event store: ${resolved.registryError}` + : '--store @default was given but no default_kb is configured in kb.yaml', + }; + default: { + const _exhaustive: never = resolved; + throw new Error(`unhandled resolveCaptureTarget failure: ${JSON.stringify(_exhaustive)}`); + } + } +} + +// endregion | Helpers diff --git a/packages/agents/src/kb-update-events/operations/__tests__/add-addressed-by.test.ts b/packages/agents/src/kb-update-events/operations/__tests__/add-addressed-by.test.ts new file mode 100644 index 00000000..5efd9f55 --- /dev/null +++ b/packages/agents/src/kb-update-events/operations/__tests__/add-addressed-by.test.ts @@ -0,0 +1,55 @@ +import type { KbEvent } from '@codeassembly/kb/records'; +import { describe, expect, it } from 'vitest'; + +import { addAddressedBy } from '../add-addressed-by.ts'; + +/** Builds a minimal valid event record, overridable per test. */ +function makeEvent(overrides: Partial = {}): KbEvent { + return { + recordType: 'event', + id: '01HZCEVENTAAAAAAAAAAAAAAAA', + capturedAt: '2026-06-18T09:41:02Z', + session: 'session-abc', + cwd: '/tmp/work', + summary: 'Noticed a thing', + tags: [], + addressedBy: [], + extra: {}, + body: 'Body.', + ...overrides, + }; +} + +describe(addAddressedBy, () => { + it('adds references when addressedBy is empty', () => { + const result = addAddressedBy(makeEvent(), ['[[fix]]']); + expect(result.addressedBy).toEqual(['[[fix]]']); + }); + + it('appends to existing entries, preserving order', () => { + const result = addAddressedBy(makeEvent({ addressedBy: ['#789'] }), ['[[fix]]', 'commit-abc']); + expect(result.addressedBy).toEqual(['#789', '[[fix]]', 'commit-abc']); + }); + + it('de-duplicates against existing entries in first-occurrence order', () => { + const result = addAddressedBy(makeEvent({ addressedBy: ['#789', '[[fix]]'] }), ['[[fix]]', '#999']); + expect(result.addressedBy).toEqual(['#789', '[[fix]]', '#999']); + }); + + it('de-duplicates references supplied in the same call', () => { + const result = addAddressedBy(makeEvent(), ['[[fix]]', '[[fix]]']); + expect(result.addressedBy).toEqual(['[[fix]]']); + }); + + it('leaves tags and extra unchanged', () => { + const result = addAddressedBy(makeEvent({ tags: ['observation'], extra: { repo: 'owner/name' } }), ['[[fix]]']); + expect(result.tags).toEqual(['observation']); + expect(result.extra).toEqual({ repo: 'owner/name' }); + }); + + it('does not mutate the input record', () => { + const input = makeEvent({ addressedBy: ['#789'] }); + addAddressedBy(input, ['[[fix]]']); + expect(input.addressedBy).toEqual(['#789']); + }); +}); diff --git a/packages/agents/src/kb-update-events/operations/__tests__/retag.test.ts b/packages/agents/src/kb-update-events/operations/__tests__/retag.test.ts new file mode 100644 index 00000000..68ccc69c --- /dev/null +++ b/packages/agents/src/kb-update-events/operations/__tests__/retag.test.ts @@ -0,0 +1,63 @@ +import type { AliasMap } from '@codeassembly/kb'; +import type { KbEvent } from '@codeassembly/kb/records'; +import { describe, expect, it } from 'vitest'; + +import { retag } from '../retag.ts'; + +const noAliases: AliasMap = new Map(); + +/** Builds a minimal valid event record, overridable per test. */ +function makeEvent(overrides: Partial = {}): KbEvent { + return { + recordType: 'event', + id: '01HZCEVENTAAAAAAAAAAAAAAAA', + capturedAt: '2026-06-18T09:41:02Z', + session: 'session-abc', + cwd: '/tmp/work', + summary: 'Noticed a thing', + tags: [], + addressedBy: [], + extra: {}, + body: 'Body.', + ...overrides, + }; +} + +describe(retag, () => { + it('replaces the existing tag list', () => { + const result = retag(makeEvent({ tags: ['old'] }), ['new', 'fresh'], noAliases); + expect(result.tags).toEqual(['new', 'fresh']); + }); + + it('canonicalizes each tag through the alias map', () => { + const aliases: AliasMap = new Map([['js', 'javascript']]); + const result = retag(makeEvent(), ['js', 'react'], aliases); + expect(result.tags).toEqual(['javascript', 'react']); + }); + + it('de-duplicates when canonicalization collapses aliases onto one canonical', () => { + const aliases: AliasMap = new Map([ + ['js', 'javascript'], + ['ecmascript', 'javascript'], + ]); + const result = retag(makeEvent(), ['js', 'ecmascript'], aliases); + expect(result.tags).toEqual(['javascript']); + }); + + it('clears the tags when given an empty list', () => { + const result = retag(makeEvent({ tags: ['old'] }), [], noAliases); + expect(result.tags).toEqual([]); + }); + + it('leaves addressedBy and extra unchanged', () => { + const result = retag(makeEvent({ addressedBy: ['#789'], extra: { repo: 'owner/name' } }), ['new'], noAliases); + expect(result.addressedBy).toEqual(['#789']); + expect(result.extra).toEqual({ repo: 'owner/name' }); + }); + + it('does not mutate the input record', () => { + const input = makeEvent({ tags: ['old'] }); + retag(input, ['new'], noAliases); + expect(input.tags).toEqual(['old']); + }); +}); diff --git a/packages/agents/src/kb-update-events/operations/add-addressed-by.ts b/packages/agents/src/kb-update-events/operations/add-addressed-by.ts new file mode 100644 index 00000000..261aa299 --- /dev/null +++ b/packages/agents/src/kb-update-events/operations/add-addressed-by.ts @@ -0,0 +1,12 @@ +import type { KbEvent } from '@codeassembly/kb/records'; + +import { dedupeInOrder } from '../../kb-shared/note-helpers.ts'; + +/** + * Appends references to an event's `addressedBy` list, preserving existing entries and de-duplicating in + * first-occurrence order. Events are write-once, so nothing else changes and no timestamp is stamped — `addressed-by` + * is an append-only annotation, not a substantive edit. + */ +export function addAddressedBy(record: KbEvent, references: readonly string[]): KbEvent { + return { ...record, addressedBy: dedupeInOrder([...record.addressedBy, ...references]) }; +} diff --git a/packages/agents/src/kb-update-events/operations/retag.ts b/packages/agents/src/kb-update-events/operations/retag.ts new file mode 100644 index 00000000..64f19c42 --- /dev/null +++ b/packages/agents/src/kb-update-events/operations/retag.ts @@ -0,0 +1,14 @@ +import type { AliasMap } from '@codeassembly/kb'; +import type { KbEvent } from '@codeassembly/kb/records'; +import { canonicalize } from '@codeassembly/kb/tags'; + +import { dedupeInOrder } from '../../kb-shared/note-helpers.ts'; + +/** + * Replaces an event's tag list, canonicalizing each entry through the alias map and de-duplicating in + * first-occurrence order. An empty list is a valid result (it clears the tags). No timestamp is stamped: retagging is + * a curatorial edit, and events are write-once. + */ +export function retag(record: KbEvent, tags: readonly string[], aliases: AliasMap): KbEvent { + return { ...record, tags: dedupeInOrder(tags.map((tag) => canonicalize(tag, aliases))) }; +} diff --git a/packages/agents/src/kb-update-events/types.ts b/packages/agents/src/kb-update-events/types.ts new file mode 100644 index 00000000..da81a0f3 --- /dev/null +++ b/packages/agents/src/kb-update-events/types.ts @@ -0,0 +1,52 @@ +// Shapes for the kb-update-events helper: parsed CLI input and the JSON result emitted to stdout. +// +// The helper's stdout payload is a discriminated union on `ok`. An invocation-level failure (invalid args, or a store +// that cannot be resolved or is readonly) returns `{ ok: false, error, message }` and writes nothing. Otherwise the +// batch returns `{ ok: true, ..., results }`, where each event carries its own success or failed-with-reason entry. +// System errors (out-of-disk, permission denied) are out of band: they print to stderr and exit non-zero. + +/** Operation names — one per mutually-exclusive op flag. */ +export type OperationName = 'add-addressed-by' | 'retag'; + +/** + * Parsed command-line invocation. A discriminated union on `operation`. `store` is `null` when `--store` was omitted, + * which the resolver refuses with `missing-store`; `ids` is the list of event ids the operation applies to. + */ +export type ParsedArgs = + | { operation: 'add-addressed-by'; store: string | null; ids: string[]; references: string[] } + | { operation: 'retag'; store: string | null; ids: string[]; tags: string[] }; + +/** Per-event outcome, in the order the ids were supplied. */ +export type EventResult = + | { ok: true; id: string; path: string } + | { ok: false; id: string; error: EventErrorCode; message: string }; + +/** Categorical per-event error codes. */ +export type EventErrorCode = 'invalid-id' | 'not-found' | 'parse' | 'validation'; + +/** The helper's stdout payload when the batch ran: per-event results carry individual success or failure. */ +export interface UpdateBatchSuccess { + ok: true; + operation: OperationName; + /** Registry name of the store the events belong to. */ + store: string; + results: EventResult[]; +} + +/** The helper's stdout payload on an invocation-level failure: nothing was written. */ +export interface UpdateFailure { + ok: false; + error: UpdateErrorCode; + message: string; +} + +/** Categorical invocation-level error codes the helper can return without an unexpected throw. */ +export type UpdateErrorCode = + | 'invalid-args' + | 'missing-store' + | 'store-not-registered' + | 'readonly-store' + | 'no-default-store'; + +/** The helper's full stdout payload: a discriminated union on `ok`. */ +export type UpdateResult = UpdateBatchSuccess | UpdateFailure; diff --git a/packages/kb/README.md b/packages/kb/README.md index 03d13370..6737422e 100644 --- a/packages/kb/README.md +++ b/packages/kb/README.md @@ -11,9 +11,9 @@ The package exposes nine subpath entries plus a root barrel: | Entry | Description | | --------------- | ------------------------------------------------------------------------- | | `.` | The most-used types plus `defaultSchema` and the rule constants | -| `./check` | `check` — config-driven enumeration plus the generic rules, in one call | +| `./check` | `check`: config-driven enumeration plus the generic rules, in one call | | `./config` | `.kb/config.yaml` loading and the typed `KbLoaderError` the loaders throw | -| `./create` | `create` — scaffold a new store and register it in `kb.yaml` | +| `./create` | `create`: scaffold a new store and register it in `kb.yaml` | | `./discovery` | KB root discovery and `kb.yaml` registry loading, merging, and writing | | `./filesystem` | Filesystem-existence helpers with an explicit absence policy | | `./schema` | The bundled default schema and per-KB `.kb/schema.yaml` resolution | @@ -38,8 +38,8 @@ const root = await findKbRoot({ startDir: process.cwd() }); A KB registry declares one or more knowledge bases. `loadKbRegistry` reads two optional registry files and merges them: -- **user-global** — `~/.agents/kb.yaml` -- **project-local** — `/.agents/kb.yaml` +- **user-global**: `~/.agents/kb.yaml` +- **project-local**: `/.agents/kb.yaml` ```yaml # .agents/kb.yaml @@ -82,7 +82,7 @@ const config = await loadKbRegistry({ projectDir: process.cwd() }); ## The default schema -A record's family is the stored `recordType` discriminant, valued against the schema's declared record-type vocabulary. `defaultSchema` is a deep-frozen `Schema` constant keyed by record type — an `assertion` record type (the canonical vault note, ranked by freshness) and an `event` record type (the ULID-keyed record written by `capture-event`, ranked by recurrence-recency): +A record's family is the stored `recordType` discriminant, valued against the schema's declared record-type vocabulary. `defaultSchema` is a deep-frozen `Schema` constant keyed by record type: an `assertion` record type (the canonical vault note, ranked by freshness) and an `event` record type (the ULID-keyed record written by `capture-event`, ranked by recurrence-recency): ```ts { @@ -101,7 +101,7 @@ A record's family is the stored `recordType` discriminant, valued against the sc } ``` -`loadSchema({ kbRoot })` returns `defaultSchema` verbatim when the KB has no `.kb/schema.yaml`. A `.kb/schema.yaml` declares a `recordTypes:` block keyed by record-type name; each record type declares its own `required`, `optional`, and `recall`. The declared vocabulary **replaces** the bundled default outright. `recordType` is implicitly required on every record — it is the discriminant, so it is never listed in a record type's `required:` array. +`loadSchema({ kbRoot })` returns `defaultSchema` verbatim when the KB has no `.kb/schema.yaml`. A `.kb/schema.yaml` declares a `recordTypes:` block keyed by record-type name; each record type declares its own `required`, `optional`, and `recall`. The declared vocabulary **replaces** the bundled default outright. `recordType` is implicitly required on every record: It is the discriminant, so it is never listed in a record type's `required:` array. ```yaml # .kb/schema.yaml @@ -120,9 +120,9 @@ Validation reads a record type's required set directly via `resolveRequiredForRe ### The addressed-by/addresses relation -`addressed-by`/`addresses` is an inverse-pair relation that threads a problem record to whatever was done about it — a fix, a mitigation, an improved guidance note. Both are optional, multi-valued list fields: +`addressed-by`/`addresses` is an inverse-pair relation that threads a problem record to whatever was done about it: a fix, a mitigation, an improved guidance note. Both are optional, multi-valued list fields: -- `addressed-by` (on the problem record, available on `assertion` and `event`) is the canonical, recall-facing field: a list of references to whatever addressed the problem. It is the only viable store when the responder is external, so its entries are heterogeneous: a KB wikilink or relative path, a commit SHA, a PR/issue ref, or a URL. The field's shape is validated as a list (the `frontmatter.list` rule), while its entries are free-form, like `sources`. +- `addressed-by` (on the problem record, available on `assertion` and `event`) is the canonical, recall-facing field: a list of references to whatever addressed the problem. It is the only viable store when the responder is external, so its entries are heterogeneous: a KB wikilink or relative path, a commit SHA, a PR/issue ref, or a URL. The field's shape is validated as a list (the `frontmatter.list` rule), while its entries are free-form, like `sources`. It is set on events with `kb-update-events` and on assertions with `kb-edit`. - `addresses` (on a KB-note responder, available on `assertion`) is the optional inverse for the rare "what does this address?" query. It is **non-authoritative**: keeping it in sync would be an N-file write, so `kb-curate` deliberately does not police it. The relation is many-to-many (one response can address many problems, and one problem can accrue many responses) and is surfaced flat by recall, with no chain-walking. This is distinct from `supersedes`/`superseded-by`, which _deprecates_ a record through a policed 1:1 chain; an addressed problem is not deprecated. It remains a true observation whose recurrence is worth keeping. @@ -143,7 +143,7 @@ missing files (when a path is given) throw. ## Validation rules -`frontmatterRule` and `tagAliasRule` are `KbRule` objects — `{ name, check }` — that produce `Finding[]`. +`frontmatterRule` and `tagAliasRule` are `KbRule` objects (`{ name, check }`) that produce `Finding[]`. `runRules({ rules, notes, schema, aliases })` applies a rule set across notes and concatenates the findings. The `KbRule` interface is the extension point for future rules. @@ -200,12 +200,12 @@ kb create --no-register # scaffold without writing the registry It creates these files and directories: -| Path | Contents | -| ----------------------------- | ---------------------------------------------------------------- | -| `.kb/schema.yaml` | A copy of the bundled default schema, ready to customize | -| `.kb/config.yaml` | A fully-commented check config; the bundled defaults apply as-is | -| `.kb/tag-aliases.yaml` | An empty `aliases: {}` map | -| `content/`, `content/events/` | The note tree; `capture-event` writes to `content/events/` | +| Path | Contents | +| ----------------------------- | --------------------------------------------------------------------------------------------------------- | +| `.kb/schema.yaml` | A copy of the bundled default schema, ready to customize | +| `.kb/config.yaml` | A fully-commented check config; the bundled defaults apply as-is | +| `.kb/tag-aliases.yaml` | An empty `aliases: {}` map | +| `content/`, `content/events/` | The note tree; `capture-event` writes events to `content/events/`, `kb-update-events` edits them in place | The schema and config seeds are serialized from the in-package `defaultSchema` and `defaultKbConfig`, so a new store cannot drift from the bundled defaults. The generated `.kb/schema.yaml` **replaces** the default outright (the override is a replacement, not a merge): add record types or optional fields freely, but do not remove or rename the default `assertion`/`event` record types or their required fields, since the `kb-*` skills depend on them. Delete the file to re-inherit the bundled default. @@ -257,7 +257,7 @@ Exit codes: ## Error and exception model -Validation rules **return** findings — they never throw. Loaders (`loadKbConfig`, `loadSchema`, `loadAliases`) **throw** a typed `KbLoaderError` on structural defects, malformed YAML, or illegal overrides, with the offending file path named in the message. `KbLoaderError` (exported from `@codeassembly/kb/config`) carries a `kind: 'KbLoaderError'` discriminant — and an `isKbLoaderError` type guard — so a caller can distinguish a recoverable config/schema/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. +Validation rules **return** findings; they never throw. Loaders (`loadKbConfig`, `loadSchema`, `loadAliases`) **throw** a typed `KbLoaderError` on structural defects, malformed YAML, or illegal overrides, with the offending file path named in the message. `KbLoaderError` (exported from `@codeassembly/kb/config`) carries a `kind: 'KbLoaderError'` discriminant — and an `isKbLoaderError` type guard — so a caller can distinguish a recoverable config/schema/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 diff --git a/packages/kb/src/records/__tests__/event.test.ts b/packages/kb/src/records/__tests__/event.test.ts index fd3f28f5..bd21b5c0 100644 --- a/packages/kb/src/records/__tests__/event.test.ts +++ b/packages/kb/src/records/__tests__/event.test.ts @@ -33,23 +33,77 @@ describe(parseEvent, () => { expect(result.ok).toBe(false); }); - it('preserves optional fields in extra', () => { - const result = parseEvent({ ...validFields, repo: 'owner/name', 'addressed-by': ['#849'] }, ''); + it('reads tags and addressed-by as typed fields, not extra', () => { + const result = parseEvent({ ...validFields, tags: ['fix'], 'addressed-by': ['#849'] }, ''); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.record.extra).toEqual({ repo: 'owner/name', 'addressed-by': ['#849'] }); + expect(result.record.tags).toEqual(['fix']); + expect(result.record.addressedBy).toEqual(['#849']); + expect(result.record.extra).toEqual({}); + }); + + it('defaults tags and addressed-by to empty lists when absent', () => { + const result = parseEvent(validFields, ''); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.record.tags).toEqual([]); + expect(result.record.addressedBy).toEqual([]); + }); + + it('reports a non-list addressed-by', () => { + const result = parseEvent({ ...validFields, 'addressed-by': 42 }, ''); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join(' ')).toContain('addressed-by'); + }); + + it('preserves unknown fields in extra', () => { + const result = parseEvent({ ...validFields, repo: 'owner/name' }, ''); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.record.extra).toEqual({ repo: 'owner/name' }); }); }); describe(renderEvent, () => { it('round-trips a well-formed event through parse', () => { - const parsed = parseEvent({ ...validFields, repo: 'owner/name' }, '\nThe body.\n'); + const parsed = parseEvent( + { ...validFields, repo: 'owner/name', tags: ['fix'], 'addressed-by': ['#849'] }, + '\nThe body.\n', + ); expect(parsed.ok).toBe(true); if (!parsed.ok) return; const { fields, body } = renderEvent(parsed.record); expect(parseEvent(fields, body)).toEqual(parsed); }); + it('omits tags and addressed-by when empty', () => { + const parsed = parseEvent(validFields, ''); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + const keys = Object.keys(renderEvent(parsed.record).fields); + expect(keys).not.toContain('tags'); + expect(keys).not.toContain('addressed-by'); + }); + + it('emits tags and addressed-by after the spine and before extra', () => { + const parsed = parseEvent({ ...validFields, repo: 'owner/name', tags: ['fix'], 'addressed-by': ['#849'] }, ''); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + const keys = Object.keys(renderEvent(parsed.record).fields); + expect(keys).toEqual([ + 'recordType', + 'id', + 'captured-at', + 'session', + 'cwd', + 'summary', + 'tags', + 'addressed-by', + 'repo', + ]); + }); + it('emits only the event fields — never title, created, or updated', () => { const parsed = parseEvent(validFields, ''); expect(parsed.ok).toBe(true); diff --git a/packages/kb/src/records/event.ts b/packages/kb/src/records/event.ts index 535ec9a5..dc95df1e 100644 --- a/packages/kb/src/records/event.ts +++ b/packages/kb/src/records/event.ts @@ -1,10 +1,10 @@ -import { isValidDate } from '../note-io/field-validators.ts'; +import { asStringList, isValidDate } from '../note-io/field-validators.ts'; -// The `event` record: the ULID-keyed observation captured to refine assertions. Its required fields are the typed, -// validated contract; any other frontmatter field (e.g. `repo`, `addressed-by`) is preserved verbatim in `extra` for -// faithful round-trip and is promoted to a typed field by the operation that comes to depend on it. +// The `event` record: the ULID-keyed observation captured to refine assertions. Its declared fields are the typed, +// validated contract; any other frontmatter field (e.g. `repo`) is preserved verbatim in `extra` for faithful +// round-trip and is promoted to a typed field by the operation that comes to depend on it. -/** A parsed `event` record: its required fields, the body, and any other frontmatter preserved in `extra`. */ +/** A parsed `event` record: its declared fields, the body, and any other frontmatter preserved in `extra`. */ export interface KbEvent { recordType: 'event'; id: string; @@ -12,6 +12,8 @@ export interface KbEvent { session: string; cwd: string; summary: string; + tags: string[]; + addressedBy: string[]; extra: Record; body: string; } @@ -19,7 +21,7 @@ export interface KbEvent { /** The outcome of parsing frontmatter as an event: the typed record, or the validation errors that blocked it. */ export type ParseEventResult = { ok: true; record: KbEvent } | { ok: false; errors: string[] }; -const TYPED_FIELDS = new Set(['recordType', 'id', 'captured-at', 'session', 'cwd', 'summary']); +const TYPED_FIELDS = new Set(['recordType', 'id', 'captured-at', 'session', 'cwd', 'summary', 'tags', 'addressed-by']); /** Validates a frontmatter field map as an event and projects it onto a {@link KbEvent}, accumulating every error. */ export function parseEvent(fields: Record, body: string): ParseEventResult { @@ -44,13 +46,18 @@ export function parseEvent(fields: Record, body: string): Parse capturedAt = rawCapturedAt; } + const tags = readListField(fields.tags, 'tags', errors); + const addressedBy = readListField(fields['addressed-by'], 'addressed-by', errors); + if ( errors.length > 0 || id === undefined || capturedAt === undefined || session === undefined || cwd === undefined || - summary === undefined + summary === undefined || + tags === undefined || + addressedBy === undefined ) { return { ok: false, errors }; } @@ -62,10 +69,13 @@ export function parseEvent(fields: Record, body: string): Parse } } - return { ok: true, record: { recordType: 'event', id, capturedAt, session, cwd, summary, extra, body } }; + return { + ok: true, + record: { recordType: 'event', id, capturedAt, session, cwd, summary, tags, addressedBy, extra, body }, + }; } -/** Projects an event back to a frontmatter field map (typed fields first, then preserved `extra`) plus its body. */ +/** Projects an event back to a frontmatter field map (declared fields first, then preserved `extra`) plus its body. */ export function renderEvent(record: KbEvent): { fields: Record; body: string } { const fields: Record = { recordType: record.recordType, @@ -74,13 +84,32 @@ export function renderEvent(record: KbEvent): { fields: Record; session: record.session, cwd: record.cwd, summary: record.summary, - ...record.extra, }; + if (record.tags.length > 0) { + fields.tags = record.tags; + } + if (record.addressedBy.length > 0) { + fields['addressed-by'] = record.addressedBy; + } + Object.assign(fields, record.extra); return { fields, body: record.body }; } // region | Helpers +/** + * Reads an optional string-list field: an absent value coerces to an empty list, a list-shaped value yields its string + * members, and a present-but-not-list value records an error and returns `undefined`. + */ +function readListField(value: unknown, field: string, errors: string[]): string[] | undefined { + const list = asStringList(value); + if (list === null) { + errors.push(`${field}: must be a list`); + return undefined; + } + return list; +} + /** Reads a required string field, pushing an error when it is absent or empty; returns the value or `undefined`. */ function requireString(value: unknown, field: string, errors: string[]): string | undefined { if (typeof value === 'string' && value.length > 0) {