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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/agents/content/skills/capture-event/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ user-invocable: true

# Capture an event

Append an event record to the shared knowledge substrate, or amend an existing one that has not yet been pushed. A bundled helper does the mechanical work — it resolves the event store by name, auto-fills the record's context (a ULID `id`, the capture timestamp, the session, the working directory, and a best-effort `repo`), validates the event record's required fields, and writes the record atomically. You supply the `summary` and the event body.
Append an event record to the shared knowledge substrate, or amend an existing one that has not yet been pushed. A bundled helper does the mechanical work — it resolves the event store by name, auto-fills the record's context (a ULID `id`, the capture timestamp, the working directory, and a best-effort `session` and `repo`), validates the event record's required fields, and writes the record atomically. You supply the `summary` and the event body.

This is a pure append. Unlike `kb-add`, it runs no survey, no `kb-retrieve` cross-referencing, and no dedup. The point is to capture the event cheaply and move on; recall and triage happen later via `kb-retrieve`.

Expand Down Expand Up @@ -38,7 +38,7 @@ A value-bearing flag accepts both `--summary text` and `--summary=text`; `--allo

### Auto-filled vs agent-supplied

- **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).
- **Auto-filled by the helper:** `recordType` (`event`), `id` (ULID), `captured-at`, `cwd`, `session` (`CLAUDE_CODE_SESSION_ID`, best-effort — omitted silently on a harness that exposes no session id), 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`/`impact`, and the body.

Expand Down
69 changes: 69 additions & 0 deletions packages/agents/src/capture-event/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,75 @@ describe(runCapture, () => {
}
});

it('captures an event with no session field when the harness exposes no session id', 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'],
stdin: bodyStream('Body text.'),
cwd: repo,
env: {},
now: NOW,
home,
});

expect(result.ok).toBe(true);
if (result.ok) {
const written = await readFile(result.path, 'utf8');
expect(written).not.toMatch(/^session:/m);
expect(written).toContain('summary: Noticed a thing');
}
});

it('treats a blank session id as no session at all', 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'],
stdin: bodyStream('Body text.'),
cwd: repo,
env: { CLAUDE_CODE_SESSION_ID: '' },
now: NOW,
home,
});

expect(result.ok).toBe(true);
if (result.ok) {
const written = await readFile(result.path, 'utf8');
expect(written).not.toMatch(/^session:/m);
}
});

it('amends an event stored with an empty session, dropping the empty field', async () => {
const { storePath, home } = await makeStore('codeassembly');
await mkdir(join(storePath, 'content', 'events'), { recursive: true });
await writeFile(
join(storePath, 'content', 'events', `${ID}.md`),
`---\nrecordType: event\nid: ${ID}\ncaptured-at: 2026-06-04T06:57:22Z\nsession: ''\ncwd: /tmp/work\nsummary: Original summary\nharness: rovodev\n---\n\nOriginal body.\n`,
'utf8',
);

const amended = await runCapture({
argv: ['--store', '@default', '--amend', ID, '--summary', 'Corrected summary'],
stdin: bodyStream('Corrected body.'),
cwd: '/tmp/different-cwd',
env: {},
now: NOW,
home,
});

expect(amended.ok).toBe(true);
if (amended.ok) {
const written = await readFile(amended.path, 'utf8');
expect(written).not.toMatch(/^session:/m);
expect(written).toContain('summary: Corrected summary');
expect(written).toContain('harness: rovodev');
expect(written).toContain('cwd: /tmp/work');
}
});

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');
Expand Down
15 changes: 15 additions & 0 deletions packages/agents/src/capture-event/__tests__/prepare-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ describe(prepareEvent, () => {
}
});

it('writes an event with session absent when the harness exposes no session id', () => {
const result = prepareEvent({
args: argsFor({}),
context: { cwd: '/tmp/work', repo: 'owner/name' },
id: ID,
capturedAt: CAPTURED_AT,
body: '',
});

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.prepared.content).not.toMatch(/^session:/m);
}
});

it('renders the agent-supplied skill, model, harness, and tags into the record', () => {
const result = prepareEvent({
args: argsFor({ skill: 'kb-retrieve', model: 'claude-opus-4-8', harness: 'claude', tags: ['recall', 'kb'] }),
Expand Down
12 changes: 8 additions & 4 deletions packages/agents/src/capture-event/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ if (isEntryPoint()) {
/**
* Runs the helper end to end: parses args, reads the event body from stdin, and resolves the target store (a concrete
* `--store` by name, or the registry's `default_kb` via the `@default` sentinel; an omitted `--store` is refused). A
* fresh capture fills in the auto-derived context (ULID `id`, `captured-at`, `session`, `cwd`, best-effort `repo`),
* validates the event record's required spine, and writes `content/events/{id}.md` without
* fresh capture fills in the auto-derived context (ULID `id`, `captured-at`, `cwd`, and a best-effort `session` and
* `repo`), validates the event record's required spine, and writes `content/events/{id}.md` without
* overwriting an existing id. With `--amend <id>`, it instead rewrites that existing event in place; see
* {@link amendEvent}.
*
Expand Down Expand Up @@ -143,9 +143,13 @@ export async function runCapture(input: {
return amendEvent({ args, store, body });
}

const session = input.env.CLAUDE_CODE_SESSION_ID ?? '';
const session = input.env.CLAUDE_CODE_SESSION_ID;
const repo = await resolveRepo(input.cwd);
const context: CaptureContext = { session, cwd: input.cwd, ...(repo !== undefined && { repo }) };
const context: CaptureContext = {
cwd: input.cwd,
...(session !== undefined && session.length > 0 && { session }),
...(repo !== undefined && { repo }),
};

const prep = prepareEvent({
args,
Expand Down
11 changes: 6 additions & 5 deletions packages/agents/src/capture-event/prepare-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ export type PrepareOutcome = PrepareSuccess | PrepareFailure;
/**
* Composes a `KbEvent` from agent-supplied args and auto-filled context, renders it through the record module's
* `renderEvent`, and validates the serialized note as an `event` record by re-parsing through `parseEvent`. The record
* carries the stored `recordType: event` discriminant and the typed event spine (`id`, `captured-at`, `session`, `cwd`,
* `summary`, plus any supplied `tags`/`impact`); `repo`/`skill`/`model`/`harness` have no typed field and ride in
* `extra`, which `renderEvent` emits after the spine. No `updated`/`last-verified` field is written: an event carries a
* single canonical state, editable in place via `capture-event --amend` until it is pushed and immutable after.
* carries the stored `recordType: event` discriminant and the typed event spine (`id`, `captured-at`, `cwd`, `summary`,
* plus `session` when the harness exposes one and any supplied `tags`/`impact`); `repo`/`skill`/`model`/`harness` have
* no typed field and ride in `extra`, which `renderEvent` emits after the spine. No `updated`/`last-verified` field is
* written: an event carries a single canonical state, editable in place via `capture-event --amend` until it is pushed
* and immutable after.
*
* Rendering the composed record through the same `renderEvent`/`renderNote` path the amend path uses keeps a fresh
* capture and its later amendments identical in field order. Validation round-trips the serialized note through
Expand Down Expand Up @@ -68,7 +69,7 @@ export function prepareEvent(input: {
recordType: 'event',
id,
capturedAt,
session: context.session,
...(context.session !== undefined && { session: context.session }),
cwd: context.cwd,
summary: args.summary,
tags: args.tags,
Expand Down
4 changes: 2 additions & 2 deletions packages/agents/src/capture-event/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export interface ParsedArgs {

/** The auto-filled context an event carries beyond the agent-supplied fields. */
export interface CaptureContext {
/** Session identifier read from `CLAUDE_CODE_SESSION_ID`. */
session: string;
/** Session identifier read from `CLAUDE_CODE_SESSION_ID`; omitted when the harness exposes none. */
session?: string;
/** Absolute working directory the capture ran from. */
cwd: string;
/** `owner/name` git remote at `cwd`, best-effort; omitted when unresolvable. */
Expand Down
57 changes: 56 additions & 1 deletion packages/kb/src/records/__tests__/event.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { EVENT_IMPACT_LEVELS, isEventImpact, parseEvent, renderEvent } from '../event.ts';
import { EVENT_IMPACT_LEVELS, isEventImpact, type KbEvent, parseEvent, renderEvent } from '../event.ts';

const validFields = {
recordType: 'event',
Expand Down Expand Up @@ -33,6 +33,29 @@ describe(parseEvent, () => {
expect(result.ok).toBe(false);
});

it('parses an event captured with no session', () => {
const { session: _session, ...withoutSession } = validFields;
const result = parseEvent(withoutSession, '');
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.record.session).toBeUndefined();
});

it('reads a stored empty session as absent rather than as an empty string', () => {
const result = parseEvent({ ...validFields, session: '' }, '');
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.record.session).toBeUndefined();
expect(result.record.extra).toEqual({});
});

it('reports a non-string session', () => {
const result = parseEvent({ ...validFields, session: 42 }, '');
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.errors.join(' ')).toContain('session');
});

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);
Expand Down Expand Up @@ -110,6 +133,38 @@ describe(renderEvent, () => {
expect(parseEvent(fields, body)).toEqual(parsed);
});

it('omits session when the event carries none', () => {
const { session: _session, ...withoutSession } = validFields;
const parsed = parseEvent(withoutSession, '');
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const keys = Object.keys(renderEvent(parsed.record).fields);
expect(keys).toEqual(['recordType', 'id', 'captured-at', 'cwd', 'summary']);
});

it('drops the empty session of a record stored before session became optional', () => {
const parsed = parseEvent({ ...validFields, session: '' }, '');
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(Object.keys(renderEvent(parsed.record).fields)).not.toContain('session');
});

it('omits an empty session on a record composed directly rather than parsed', () => {
const record: KbEvent = {
recordType: 'event',
id: '01HZCEVENTAAAAAAAAAAAAAAAA',
capturedAt: '2026-06-18T09:41:02Z',
session: '',
cwd: '/tmp/work',
summary: 'Noticed a phantomwidget glitch',
tags: [],
addressedBy: [],
extra: {},
body: '',
};
expect(Object.keys(renderEvent(record).fields)).not.toContain('session');
});

it('omits tags and addressed-by when empty', () => {
const parsed = parseEvent(validFields, '');
expect(parsed.ok).toBe(true);
Expand Down
32 changes: 26 additions & 6 deletions packages/kb/src/records/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export interface KbEvent {
recordType: 'event';
id: string;
capturedAt: string;
session: string;
/** Harness-dependent provenance: absent when the harness that captured the event exposes no session id. */
session?: string;
cwd: string;
summary: string;
tags: string[];
Expand Down Expand Up @@ -58,7 +59,7 @@ export function parseEvent(fields: Record<string, unknown>, body: string): Parse
}

const id = requireString(fields.id, 'id', errors);
const session = requireString(fields.session, 'session', errors);
const session = readOptionalNonEmptyString(fields.session, 'session', errors);
const cwd = requireString(fields.cwd, 'cwd', errors);
const summary = requireString(fields.summary, 'summary', errors);

Expand All @@ -80,7 +81,6 @@ export function parseEvent(fields: Record<string, unknown>, body: string): Parse
errors.length > 0 ||
id === undefined ||
capturedAt === undefined ||
session === undefined ||
cwd === undefined ||
summary === undefined ||
tags === undefined ||
Expand All @@ -102,7 +102,7 @@ export function parseEvent(fields: Record<string, unknown>, body: string): Parse
recordType: 'event',
id,
capturedAt,
session,
...(session !== undefined && { session }),
cwd,
summary,
tags,
Expand All @@ -114,13 +114,17 @@ export function parseEvent(fields: Record<string, unknown>, body: string): Parse
};
}

/** Projects an event back to a frontmatter field map (declared 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. An
* empty `session` is omitted like an absent one, mirroring {@link parseEvent}: the two spellings of "no session" have a
* single representation on both edges of the module, so no record can reacquire the empty field on a write.
*/
export function renderEvent(record: KbEvent): { fields: Record<string, unknown>; body: string } {
const fields: Record<string, unknown> = {
recordType: record.recordType,
id: record.id,
'captured-at': record.capturedAt,
session: record.session,
...(record.session !== undefined && record.session.length > 0 && { session: record.session }),
cwd: record.cwd,
summary: record.summary,
};
Expand Down Expand Up @@ -167,6 +171,22 @@ function readListField(value: unknown, field: string, errors: string[]): string[
return list;
}

/**
* Reads an optional string field for which the empty string carries no more meaning than absence: both yield
* `undefined`, so the record holds one representation of "not supplied". A present non-string value records an error and
* yields `undefined`.
*/
function readOptionalNonEmptyString(value: unknown, field: string, errors: string[]): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'string') {
return value;
}
errors.push(`${field}: expected a string`);
return undefined;
}

/** 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) {
Expand Down
Loading