diff --git a/packages/agents/content/skills/capture-event/SKILL.md b/packages/agents/content/skills/capture-event/SKILL.md index c1189572..91d7b93b 100644 --- a/packages/agents/content/skills/capture-event/SKILL.md +++ b/packages/agents/content/skills/capture-event/SKILL.md @@ -22,14 +22,15 @@ A **skill-caused mistake** — an error a clearer skill definition would have pr ## Arguments -| Argument | Description | Required | -| ----------- | ------------------------------------------------------------------------ | -------- | -| `--summary` | A human-readable one-line summary; becomes the record's label on recall. | Yes | -| `--store` | Registry name of the event store, or `@default` for the `default_kb`. | Yes | -| `--skill` | The skill the event relates to. | No | -| `--model` | The model identifier in play. | No | -| `--harness` | The agent platform (`claude`, `rovodev`); install-injected — keep as-is. | Injected | -| `--tags` | Comma-separated tag list. | No | +| Argument | Description | Required | +| ----------- | --------------------------------------------------------------------------------- | -------- | +| `--summary` | A human-readable one-line summary; becomes the record's label on recall. | Yes | +| `--store` | Registry name of the event store, or `@default` for the `default_kb`. | Yes | +| `--skill` | The skill the event relates to. | No | +| `--model` | The model identifier in play. | No | +| `--harness` | The agent platform (`claude`, `rovodev`); install-injected — keep as-is. | Injected | +| `--tags` | Comma-separated tag list. | No | +| `--impact` | Impact rating: one of `low`, `medium`, `high`, `critical`. Omit to leave unrated. | No | A value-bearing flag accepts both `--summary text` and `--summary=text`. The event body is read from stdin to EOF; an empty body is allowed. @@ -37,7 +38,7 @@ A value-bearing flag accepts both `--summary text` and `--summary=text`. The eve - **Auto-filled by the helper:** `recordType` (`event`), `id` (ULID), `captured-at`, `session` (`CLAUDE_CODE_SESSION_ID`), `cwd`, and `repo` (the `owner/name` git remote at `cwd`, best-effort — omitted silently when no remote resolves). - **Template-injected:** `harness` — `codeassembly-agents` writes the agent platform (`claude` or `rovodev`) into the `--harness` flag when it installs this skill. Unlike `model`, which varies per session and is self-reported, the harness is fixed at install time; keep the injected `--harness` flag verbatim rather than filling in a value yourself. -- **Agent-supplied:** `summary`, the optional `skill`/`model`/`tags`, and the body. +- **Agent-supplied:** `summary`, the optional `skill`/`model`/`tags`/`impact`, and the body. ### Store selection @@ -55,6 +56,8 @@ Choose the destination deliberately. When the lesson is specific to a project, p Write a one-line `--summary` that reads well on its own (it is the record's recall label). Put the detail — context, the problem and its resolution, the pattern and its refinement — in the body on stdin. Capture enough context that the event is intelligible months later without the surrounding conversation. +Optionally rate `--impact` (`low`, `medium`, `high`, or `critical`): your subjective read of how much addressing the event matters, which applies equally to a bug and to a beneficial change. Omit it when you have no clear read — an unrated event is left for a later triage pass. The rating is revisable later with `kb-update-events`. + ### 2. Invoke the helper Pipe the body to the bundled helper. A heredoc keeps multi-line bodies legible: @@ -64,7 +67,7 @@ cat <<'EOF' | node {harness_home_dir}/skills/capture-event/capture-event.mjs \ --summary "" \ --store \ --harness {harness_id} \ - [--skill ] [--model ] [--tags ] + [--skill ] [--model ] [--tags ] [--impact ] EOF ``` diff --git a/packages/agents/content/skills/capture-feedback/SKILL.md b/packages/agents/content/skills/capture-feedback/SKILL.md index 7f86c23b..323d5bec 100644 --- a/packages/agents/content/skills/capture-feedback/SKILL.md +++ b/packages/agents/content/skills/capture-feedback/SKILL.md @@ -48,6 +48,7 @@ Invoke the `capture-event` skill to append an immutable record, composing its ar - `--store codeassembly` — the project's agent-guidance KB. Pass a different `--store` only when the user directs the record elsewhere. - `--tags feedback` — always. Add `,mistake` when existing guidance was misapplied (step 1, "Yes"). - `--skill ` — when the refinement target is a skill. +- `--impact ` — optionally rate how much addressing this feedback would improve the agent's future behavior: `low`, `medium`, `high`, or `critical`. Omit it when you have no clear read; the rating is revisable later with `kb-update-events`. - `--summary` — a one-line recall label, for example "Agent title-cased a heading; sentence case is the rule." - **Body** — the generalized lesson, only to the extent needed to act on it later: - The **error→correction pair** (misapplied-guidance mode) or the **desired behavior** (no-guidance mode), generalized — not the raw artifact or diff. diff --git a/packages/agents/content/skills/kb-update-events/SKILL.md b/packages/agents/content/skills/kb-update-events/SKILL.md index d2961849..e671b07c 100644 --- a/packages/agents/content/skills/kb-update-events/SKILL.md +++ b/packages/agents/content/skills/kb-update-events/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: Edit existing events in the knowledge store — mark one or more events addressed-by a reference, retag them, or set their impact — in a single batch invocation. The event mutable set only; events stay otherwise write-once. user-invocable: true --- @@ -8,20 +8,21 @@ user-invocable: true 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`. +The operation surface is the **event mutable set** only: `addressed-by` (mark an event as addressed by a reference), `tags` (retag), and `impact` (set the impact rating). Events are otherwise write-once: there is no body edit, and these mutations stamp no timestamp — they are curatorial annotations, not substantive edits. Impact is a subjective assessment that may legitimately change, which is why it lives in the mutable set rather than the immutable spine. 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)." +**Announce at start:** "Using kb-update-events to {mark|retag|rate} {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 | +| 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 | +| `--set-impact ` | Set each event's `impact` to one of `low`, `medium`, `high`, `critical`. Replaces any prior value. | 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. +A value-bearing flag accepts both `--retag fix,observation` and `--retag=fix,observation`. Exactly one operation flag is required per invocation; combining more than one 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 @@ -46,7 +47,7 @@ node {harness_home_dir}/skills/kb-update-events/kb-update-events.mjs \ [ ...] ``` -Use `--retag ` in place of `--add-addressed-by` to retag instead. +Use `--retag ` or `--set-impact ` in place of `--add-addressed-by` for those operations. The helper prints a JSON object to stdout: @@ -72,4 +73,4 @@ On `ok: false`, route by the `error` code: ## 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. +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`/`impact` annotations. diff --git a/packages/agents/src/capture-event/__tests__/cli.test.ts b/packages/agents/src/capture-event/__tests__/cli.test.ts index 1a687e41..81efe778 100644 --- a/packages/agents/src/capture-event/__tests__/cli.test.ts +++ b/packages/agents/src/capture-event/__tests__/cli.test.ts @@ -25,7 +25,7 @@ const EVENT_SCHEMA = `recordTypes: event: recall: recurrence-recency required: [id, captured-at, session, cwd, summary] - optional: [repo, skill, model, harness, tags, correction, owner, locality, severity] + optional: [repo, skill, model, harness, tags, correction, owner, locality, severity, impact] `; function bodyStream(body: string): Readable { @@ -70,6 +70,8 @@ describe(parseArgs, () => { 'claude', '--tags', 'one, two,three', + '--impact', + 'high', ]); expect(parsed).toEqual({ @@ -79,6 +81,7 @@ describe(parseArgs, () => { model: 'claude-opus-4-8', harness: 'claude', tags: ['one', 'two', 'three'], + impact: 'high', }); }); @@ -90,6 +93,11 @@ describe(parseArgs, () => { expect(parsed.model).toBeNull(); expect(parsed.harness).toBeNull(); expect(parsed.tags).toEqual([]); + expect(parsed.impact).toBeNull(); + }); + + it('throws on an out-of-enum --impact', () => { + expect(() => parseArgs(['--summary', 'x', '--impact', 'urgent'])).toThrow(/--impact must be one of/); }); it('throws when --summary is missing', () => { @@ -182,6 +190,26 @@ describe(runCapture, () => { } }); + it('writes the impact field when --impact is supplied', async () => { + const { home } = await makeStore('codeassembly'); + const repo = await makeRepoWithRemote('git@github.com:williamthorsen/codeassembly.git'); + + const result = await runCapture({ + argv: ['--store', '@default', '--summary', 'Noticed a thing', '--impact', 'high'], + stdin: bodyStream('Body text.'), + cwd: repo, + env: { CLAUDE_CODE_SESSION_ID: 'session-xyz' }, + now: NOW, + home, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + const written = await readFile(result.path, 'utf8'); + expect(written).toMatch(/^impact: high$/m); + } + }); + it('writes the harness field when --harness is supplied', async () => { const { home } = await makeStore('codeassembly'); const repo = await makeRepoWithRemote('git@github.com:williamthorsen/codeassembly.git'); diff --git a/packages/agents/src/capture-event/__tests__/prepare-event.test.ts b/packages/agents/src/capture-event/__tests__/prepare-event.test.ts index 2f535ac3..ffb1bf84 100644 --- a/packages/agents/src/capture-event/__tests__/prepare-event.test.ts +++ b/packages/agents/src/capture-event/__tests__/prepare-event.test.ts @@ -13,7 +13,7 @@ const EVENT_SCHEMA = `recordTypes: event: recall: recurrence-recency required: [id, captured-at, session, cwd, summary] - optional: [repo, skill, model, harness, tags, correction, owner, locality, severity] + optional: [repo, skill, model, harness, tags, correction, owner, locality, severity, impact] `; const ID = '01HZZZZZZZZZZZZZZZZZZZZZZZZ'; @@ -29,6 +29,7 @@ function argsFor(overrides: Partial): ParsedArgs { model: null, harness: null, tags: [], + impact: null, ...overrides, }; } @@ -145,4 +146,36 @@ describe(prepareEvent, () => { expect(result.prepared.content).toContain('tags: [recall, kb]'); } }); + + it('renders the supplied impact into the record', () => { + const result = prepareEvent({ + args: argsFor({ impact: 'high' }), + context: CONTEXT, + id: ID, + capturedAt: CAPTURED_AT, + schema, + body: '', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.prepared.content).toMatch(/^impact: high$/m); + } + }); + + it('omits impact when none is supplied', () => { + const result = prepareEvent({ + args: argsFor({}), + context: CONTEXT, + id: ID, + capturedAt: CAPTURED_AT, + schema, + body: '', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.prepared.content).not.toMatch(/^impact:/m); + } + }); }); diff --git a/packages/agents/src/capture-event/cli.ts b/packages/agents/src/capture-event/cli.ts index 9545dbef..1b1f31d8 100644 --- a/packages/agents/src/capture-event/cli.ts +++ b/packages/agents/src/capture-event/cli.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import type { KbRoot } from '@codeassembly/kb'; +import { EVENT_IMPACT_LEVELS, type EventImpact, isEventImpact } from '@codeassembly/kb/records'; import { loadSchema } from '@codeassembly/kb/schema'; import { ulid } from 'ulid'; @@ -32,6 +33,7 @@ const FLAGS: readonly FlagSpec[] = [ { name: 'model', takesValue: true }, { name: 'harness', takesValue: true }, { name: 'tags', takesValue: true }, + { name: 'impact', takesValue: true }, ]; /** Executes the helper from `process.argv` and writes the JSON result to stdout. */ @@ -156,8 +158,9 @@ export async function runCapture(input: { /** * Parses the helper's argv. Each value-bearing flag accepts both `--flag value` and `--flag=value`; `--tags` accepts a - * comma-separated list. Unknown flags, an unexpected positional, an empty value for any flag, or a missing `--summary` - * throw with a usage-style message. The body comes from stdin rather than the command line, so the layout is flag-only. + * comma-separated list and `--impact` accepts one declared impact level. Unknown flags, an unexpected positional, an + * empty value for any flag, a missing `--summary`, or an out-of-enum `--impact` throw with a usage-style message. The + * body comes from stdin rather than the command line, so the layout is flag-only. * * @internal - Exported to allow testing. */ @@ -178,6 +181,14 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { throw new Error('--summary is required'); } + let impact: EventImpact | null = null; + if (raw.impact !== undefined) { + if (!isEventImpact(raw.impact)) { + throw new Error(`--impact must be one of ${EVENT_IMPACT_LEVELS.join(', ')}`); + } + impact = raw.impact; + } + return { store: raw.store ?? null, summary, @@ -185,6 +196,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { model: raw.model ?? null, harness: raw.harness ?? null, tags: raw.tags === undefined ? [] : parseTagList(raw.tags), + impact, }; } diff --git a/packages/agents/src/capture-event/prepare-event.ts b/packages/agents/src/capture-event/prepare-event.ts index aa500589..0df92a6b 100644 --- a/packages/agents/src/capture-event/prepare-event.ts +++ b/packages/agents/src/capture-event/prepare-event.ts @@ -34,7 +34,8 @@ export type PrepareOutcome = PrepareSuccess | PrepareFailure; * Assembles an immutable event record from agent-supplied args and auto-filled context, renders it to a note string, * and validates the result against the store's schema via `frontmatterRule`. The record carries the stored * `recordType: event` discriminant and the event spine (`id`, `captured-at`, `session`, `cwd`, `repo`, `summary`) plus - * any supplied `skill`/`model`/`harness`/`tags`. No `updated`/`last-verified` field is written: events are write-once. + * any supplied `skill`/`model`/`harness`/`tags`/`impact`. No `updated`/`last-verified` field is written: events are + * write-once. * * Validation round-trips the rendered note through `parseNoteContent` and `runRules`, mirroring `kb-add`'s * prepare-then-validate flow. When any finding has `severity: 'error'`, the outcome is `{ ok: false, findings }` and @@ -73,6 +74,9 @@ export function prepareEvent(input: { if (args.tags.length > 0) { fields.push(['tags', args.tags]); } + if (args.impact !== null) { + fields.push(['impact', args.impact]); + } const content = renderEventNote(fields, body); diff --git a/packages/agents/src/capture-event/types.ts b/packages/agents/src/capture-event/types.ts index 30a4286a..c6e8220b 100644 --- a/packages/agents/src/capture-event/types.ts +++ b/packages/agents/src/capture-event/types.ts @@ -6,6 +6,7 @@ // (out-of-disk, permission denied) are out of band: they print to stderr and exit non-zero. import type { Finding } from '@codeassembly/kb'; +import type { EventImpact } from '@codeassembly/kb/records'; /** Parsed command-line invocation of the capture-event helper. */ export interface ParsedArgs { @@ -24,6 +25,8 @@ export interface ParsedArgs { harness: string | null; /** Optional tag list, in the order the agent supplied them. */ tags: string[]; + /** Optional impact rating; `null` when `--impact` is omitted. */ + impact: EventImpact | null; } /** The auto-filled context an event carries beyond the agent-supplied fields. */ diff --git a/packages/agents/src/kb-update-events/__tests__/cli.test.ts b/packages/agents/src/kb-update-events/__tests__/cli.test.ts index 10b5ea00..57e6b5d6 100644 --- a/packages/agents/src/kb-update-events/__tests__/cli.test.ts +++ b/packages/agents/src/kb-update-events/__tests__/cli.test.ts @@ -75,6 +75,16 @@ describe(parseArgs, () => { }); }); + it('parses a set-impact invocation with multiple ids', () => { + const parsed = parseArgs(['--store', 'codeassembly', '--set-impact', 'high', EVENT_ID, 'id-two']); + expect(parsed).toEqual({ + operation: 'set-impact', + store: 'codeassembly', + ids: [EVENT_ID, 'id-two'], + impact: 'high', + }); + }); + it('accepts the inline --flag=value form', () => { const parsed = parseArgs(['--store=codeassembly', '--add-addressed-by=#849', EVENT_ID]); expect(parsed.store).toBe('codeassembly'); @@ -98,6 +108,11 @@ describe(parseArgs, () => { 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', '--set-impact', 'high', EVENT_ID], + pattern: /mutually exclusive/, + }, + { argv: ['--store', 's', '--set-impact', 'urgent', EVENT_ID], pattern: /--set-impact must be one of/ }, { 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/ }, @@ -156,6 +171,32 @@ describe(runUpdate, () => { expect(parsed.record.tags).toEqual(['fix', 'observation']); }); + it('sets impact on a previously unrated event', async () => { + const { storePath, home } = await makeStore('codeassembly'); + const path = await seedEvent(storePath, EVENT_ID); + + const result = await runUpdate({ argv: ['--store', 'codeassembly', '--set-impact', 'high', 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.impact).toBe('high'); + }); + + it('replaces a prior impact value', async () => { + const { storePath, home } = await makeStore('codeassembly'); + const path = await seedEvent(storePath, EVENT_ID, ['impact: low']); + + const result = await runUpdate({ argv: ['--store', 'codeassembly', '--set-impact', 'critical', 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.impact).toBe('critical'); + }); + 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); diff --git a/packages/agents/src/kb-update-events/cli.ts b/packages/agents/src/kb-update-events/cli.ts index c7b55915..268dff22 100644 --- a/packages/agents/src/kb-update-events/cli.ts +++ b/packages/agents/src/kb-update-events/cli.ts @@ -7,7 +7,7 @@ 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 { EVENT_IMPACT_LEVELS, isEventImpact, type KbEvent, parseEvent, renderEvent } from '@codeassembly/kb/records'; import { loadAliases } from '@codeassembly/kb/tags'; import { splitCommaList } from '../kb-shared/note-helpers.ts'; @@ -17,6 +17,7 @@ import { type FlagSpec, scanFlags, valueFlagMap } from '../lib/parse-flags.ts'; import { isMissingFile } from '../lib/type-guards.ts'; import { addAddressedBy } from './operations/add-addressed-by.ts'; import { retag } from './operations/retag.ts'; +import { setImpact } from './operations/set-impact.ts'; import type { EventResult, ParsedArgs, UpdateFailure, UpdateResult } from './types.ts'; /** The value-bearing flags this helper accepts; positionals are the event ids the operation applies to. */ @@ -24,6 +25,7 @@ const FLAGS: readonly FlagSpec[] = [ { name: 'store', takesValue: true }, { name: 'add-addressed-by', takesValue: true }, { name: 'retag', takesValue: true }, + { name: 'set-impact', takesValue: true }, ]; /** Executes the helper from `process.argv` and writes the JSON result to stdout. */ @@ -85,10 +87,11 @@ export async function runUpdate(input: { argv: readonly string[]; home?: string } /** - * 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. + * Parses the helper's argv. Layout: a required `--store`, exactly one operation flag (`--add-addressed-by`, `--retag`, + * or `--set-impact`), 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 and `--set-impact` takes one declared + * impact level. An unknown flag, more than one operation flag, none, no ids, an out-of-enum `--set-impact`, or a missing + * required value throws with a usage-style message. * * @internal - Exported to allow testing. */ @@ -103,11 +106,15 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { 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'); + const hasSetImpact = raw['set-impact'] !== undefined; + const operationCount = [hasAddressedBy, hasRetag, hasSetImpact].filter(Boolean).length; + if (operationCount > 1) { + throw new Error( + 'operation flags are mutually exclusive; pass exactly one of --add-addressed-by, --retag, or --set-impact', + ); } - if (!hasAddressedBy && !hasRetag) { - throw new Error('one operation flag is required (--add-addressed-by or --retag)'); + if (operationCount === 0) { + throw new Error('one operation flag is required (--add-addressed-by, --retag, or --set-impact)'); } if (ids.length === 0) { throw new Error('at least one event id is required'); @@ -120,11 +127,34 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { } return { operation: 'add-addressed-by', store, ids, references }; } - return { operation: 'retag', store, ids, tags: parseTagList(raw.retag ?? '') }; + if (hasRetag) { + return { operation: 'retag', store, ids, tags: parseTagList(raw.retag ?? '') }; + } + const impact = raw['set-impact'] ?? ''; + if (!isEventImpact(impact)) { + throw new Error(`--set-impact must be one of ${EVENT_IMPACT_LEVELS.join(', ')}`); + } + return { operation: 'set-impact', store, ids, impact }; } // region | Helpers +/** Applies the parsed operation to an event, returning the mutated record. Exhaustive over the operation union. */ +function applyOperation(record: KbEvent, args: ParsedArgs, aliases: AliasMap): KbEvent { + switch (args.operation) { + case 'add-addressed-by': + return addAddressedBy(record, args.references); + case 'retag': + return retag(record, args.tags, aliases); + case 'set-impact': + return setImpact(record, args.impact); + default: { + const _exhaustive: never = args; + throw new Error(`unhandled operation: ${JSON.stringify(_exhaustive)}`); + } + } +} + /** * 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 @@ -169,10 +199,7 @@ async function editOne(input: { 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 updated = applyOperation(parsed.record, args, aliases); const rendered = renderEvent(updated); diff --git a/packages/agents/src/kb-update-events/operations/__tests__/set-impact.test.ts b/packages/agents/src/kb-update-events/operations/__tests__/set-impact.test.ts new file mode 100644 index 00000000..3b7f66fa --- /dev/null +++ b/packages/agents/src/kb-update-events/operations/__tests__/set-impact.test.ts @@ -0,0 +1,49 @@ +import type { KbEvent } from '@codeassembly/kb/records'; +import { describe, expect, it } from 'vitest'; + +import { setImpact } from '../set-impact.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(setImpact, () => { + it('sets the impact on an unrated event', () => { + const result = setImpact(makeEvent(), 'high'); + expect(result.impact).toBe('high'); + }); + + it('overwrites a prior impact', () => { + const result = setImpact(makeEvent({ impact: 'low' }), 'critical'); + expect(result.impact).toBe('critical'); + }); + + it('leaves tags, addressedBy, and extra unchanged', () => { + const result = setImpact( + makeEvent({ tags: ['fix'], addressedBy: ['#789'], extra: { repo: 'owner/name' } }), + 'medium', + ); + expect(result.tags).toEqual(['fix']); + expect(result.addressedBy).toEqual(['#789']); + expect(result.extra).toEqual({ repo: 'owner/name' }); + }); + + it('does not mutate the input record', () => { + const input = makeEvent({ impact: 'low' }); + setImpact(input, 'high'); + expect(input.impact).toBe('low'); + }); +}); diff --git a/packages/agents/src/kb-update-events/operations/set-impact.ts b/packages/agents/src/kb-update-events/operations/set-impact.ts new file mode 100644 index 00000000..11c5e2d9 --- /dev/null +++ b/packages/agents/src/kb-update-events/operations/set-impact.ts @@ -0,0 +1,9 @@ +import type { EventImpact, KbEvent } from '@codeassembly/kb/records'; + +/** + * Replaces an event's impact rating. Impact is a revisable subjective assessment, so this overwrites any prior value and + * stamps no timestamp — like the other event mutations, it is a curatorial annotation, not a substantive edit. + */ +export function setImpact(record: KbEvent, impact: EventImpact): KbEvent { + return { ...record, impact }; +} diff --git a/packages/agents/src/kb-update-events/types.ts b/packages/agents/src/kb-update-events/types.ts index b8a9cbc3..18c070de 100644 --- a/packages/agents/src/kb-update-events/types.ts +++ b/packages/agents/src/kb-update-events/types.ts @@ -5,8 +5,10 @@ // 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. +import type { EventImpact } from '@codeassembly/kb/records'; + /** Operation names — one per mutually-exclusive op flag. */ -export type OperationName = 'add-addressed-by' | 'retag'; +export type OperationName = 'add-addressed-by' | 'retag' | 'set-impact'; /** * Parsed command-line invocation. A discriminated union on `operation`. `store` is `null` when `--store` was omitted, @@ -14,7 +16,8 @@ export type OperationName = 'add-addressed-by' | 'retag'; */ export type ParsedArgs = | { operation: 'add-addressed-by'; store: string | null; ids: string[]; references: string[] } - | { operation: 'retag'; store: string | null; ids: string[]; tags: string[] }; + | { operation: 'retag'; store: string | null; ids: string[]; tags: string[] } + | { operation: 'set-impact'; store: string | null; ids: string[]; impact: EventImpact }; /** Per-event outcome, in the order the ids were supplied. */ export type EventResult = diff --git a/packages/kb/src/records/__tests__/event.test.ts b/packages/kb/src/records/__tests__/event.test.ts index bd21b5c0..74faf648 100644 --- a/packages/kb/src/records/__tests__/event.test.ts +++ b/packages/kb/src/records/__tests__/event.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseEvent, renderEvent } from '../event.ts'; +import { EVENT_IMPACT_LEVELS, isEventImpact, parseEvent, renderEvent } from '../event.ts'; const validFields = { recordType: 'event', @@ -63,6 +63,39 @@ describe(parseEvent, () => { if (!result.ok) return; expect(result.record.extra).toEqual({ repo: 'owner/name' }); }); + + it('reads a valid impact as a typed field, not extra', () => { + const result = parseEvent({ ...validFields, impact: 'high' }, ''); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.record.impact).toBe('high'); + expect(result.record.extra).toEqual({}); + }); + + it('leaves impact undefined when absent', () => { + const result = parseEvent(validFields, ''); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.record.impact).toBeUndefined(); + }); + + it('rejects an out-of-enum impact', () => { + const result = parseEvent({ ...validFields, impact: 'urgent' }, ''); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join(' ')).toContain('impact'); + }); +}); + +describe(isEventImpact, () => { + it('accepts each declared level and rejects anything else', () => { + for (const level of EVENT_IMPACT_LEVELS) { + expect(isEventImpact(level)).toBe(true); + } + expect(isEventImpact('urgent')).toBe(false); + expect(isEventImpact(2)).toBe(false); + expect(isEventImpact(undefined)).toBe(false); + }); }); describe(renderEvent, () => { @@ -113,4 +146,37 @@ describe(renderEvent, () => { expect(keys).not.toContain('created'); expect(keys).not.toContain('updated'); }); + + it('omits impact when unset', () => { + const parsed = parseEvent(validFields, ''); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(Object.keys(renderEvent(parsed.record).fields)).not.toContain('impact'); + }); + + it('emits impact after addressed-by and before extra', () => { + const parsed = parseEvent({ ...validFields, 'addressed-by': ['#849'], impact: 'critical', repo: 'owner/name' }, ''); + 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', + 'addressed-by', + 'impact', + 'repo', + ]); + }); + + it('round-trips an event carrying impact', () => { + const parsed = parseEvent({ ...validFields, impact: 'high' }, '\nThe body.\n'); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + const { fields, body } = renderEvent(parsed.record); + expect(parseEvent(fields, body)).toEqual(parsed); + }); }); diff --git a/packages/kb/src/records/event.ts b/packages/kb/src/records/event.ts index dc95df1e..0b79f0b4 100644 --- a/packages/kb/src/records/event.ts +++ b/packages/kb/src/records/event.ts @@ -4,6 +4,21 @@ import { asStringList, isValidDate } from '../note-io/field-validators.ts'; // 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. +/** The impact levels an event may carry, ordered lowest to highest. */ +export const EVENT_IMPACT_LEVELS = ['low', 'medium', 'high', 'critical'] as const; + +/** An event's impact: the author's subjective, revisable rating of how much addressing the event matters. */ +export type EventImpact = (typeof EVENT_IMPACT_LEVELS)[number]; + +// A widened-element set for membership tests: the `as const` tuple's literal element type rejects a `string` argument +// to `.includes`, and type assertions are banned, so the set's `.has(string)` is the assertion-free lookup. +const IMPACT_LEVEL_SET: ReadonlySet = new Set(EVENT_IMPACT_LEVELS); + +/** Reports whether a value is one of the declared {@link EVENT_IMPACT_LEVELS}. */ +export function isEventImpact(value: unknown): value is EventImpact { + return typeof value === 'string' && IMPACT_LEVEL_SET.has(value); +} + /** A parsed `event` record: its declared fields, the body, and any other frontmatter preserved in `extra`. */ export interface KbEvent { recordType: 'event'; @@ -14,6 +29,7 @@ export interface KbEvent { summary: string; tags: string[]; addressedBy: string[]; + impact?: EventImpact; extra: Record; body: string; } @@ -21,7 +37,17 @@ 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', 'tags', 'addressed-by']); +const TYPED_FIELDS = new Set([ + 'recordType', + 'id', + 'captured-at', + 'session', + 'cwd', + 'summary', + 'tags', + 'addressed-by', + 'impact', +]); /** 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 { @@ -48,6 +74,7 @@ export function parseEvent(fields: Record, body: string): Parse const tags = readListField(fields.tags, 'tags', errors); const addressedBy = readListField(fields['addressed-by'], 'addressed-by', errors); + const impact = readImpactField(fields.impact, errors); if ( errors.length > 0 || @@ -71,7 +98,19 @@ export function parseEvent(fields: Record, body: string): Parse return { ok: true, - record: { recordType: 'event', id, capturedAt, session, cwd, summary, tags, addressedBy, extra, body }, + record: { + recordType: 'event', + id, + capturedAt, + session, + cwd, + summary, + tags, + addressedBy, + ...(impact !== undefined && { impact }), + extra, + body, + }, }; } @@ -91,12 +130,30 @@ export function renderEvent(record: KbEvent): { fields: Record; if (record.addressedBy.length > 0) { fields['addressed-by'] = record.addressedBy; } + if (record.impact !== undefined) { + fields.impact = record.impact; + } Object.assign(fields, record.extra); return { fields, body: record.body }; } // region | Helpers +/** + * Reads the optional `impact` field: an absent value yields `undefined`, a declared level yields it typed, and any other + * value records an error and yields `undefined`. + */ +function readImpactField(value: unknown, errors: string[]): EventImpact | undefined { + if (value === undefined) { + return undefined; + } + if (isEventImpact(value)) { + return value; + } + errors.push(`impact: expected one of ${EVENT_IMPACT_LEVELS.join(', ')}`); + return undefined; +} + /** * 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`. diff --git a/packages/kb/src/records/index.ts b/packages/kb/src/records/index.ts index ed7e681e..4af5eef8 100644 --- a/packages/kb/src/records/index.ts +++ b/packages/kb/src/records/index.ts @@ -1,2 +1,10 @@ export { type KbAssertion, parseAssertion, type ParseAssertionResult, renderAssertion } from './assertion.ts'; -export { type KbEvent, parseEvent, type ParseEventResult, renderEvent } from './event.ts'; +export { + EVENT_IMPACT_LEVELS, + type EventImpact, + isEventImpact, + type KbEvent, + parseEvent, + type ParseEventResult, + renderEvent, +} from './event.ts'; diff --git a/packages/kb/src/schema/__tests__/default-schema.test.ts b/packages/kb/src/schema/__tests__/default-schema.test.ts index 4dfca164..742af2cb 100644 --- a/packages/kb/src/schema/__tests__/default-schema.test.ts +++ b/packages/kb/src/schema/__tests__/default-schema.test.ts @@ -28,6 +28,7 @@ describe('defaultSchema', () => { 'addressed-by', 'correction', 'harness', + 'impact', 'model', 'repo', 'skill', diff --git a/packages/kb/src/schema/default-schema.ts b/packages/kb/src/schema/default-schema.ts index ed69bc97..118f8060 100644 --- a/packages/kb/src/schema/default-schema.ts +++ b/packages/kb/src/schema/default-schema.ts @@ -24,7 +24,7 @@ const assertion: RecordTypeSchema = { const event: RecordTypeSchema = { required: Object.freeze(['captured-at', 'cwd', 'id', 'session', 'summary']), - optional: Object.freeze(['addressed-by', 'correction', 'harness', 'model', 'repo', 'skill', 'tags']), + optional: Object.freeze(['addressed-by', 'correction', 'harness', 'impact', 'model', 'repo', 'skill', 'tags']), recall: 'recurrence-recency', };