diff --git a/packages/agents/content/skills/capture-event/SKILL.md b/packages/agents/content/skills/capture-event/SKILL.md index ccccdd4b..90905522 100644 --- a/packages/agents/content/skills/capture-event/SKILL.md +++ b/packages/agents/content/skills/capture-event/SKILL.md @@ -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`. @@ -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. diff --git a/packages/agents/src/capture-event/__tests__/cli.test.ts b/packages/agents/src/capture-event/__tests__/cli.test.ts index f784cf2f..4a2a3086 100644 --- a/packages/agents/src/capture-event/__tests__/cli.test.ts +++ b/packages/agents/src/capture-event/__tests__/cli.test.ts @@ -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'); 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 3ad5bc30..a2a1eaf8 100644 --- a/packages/agents/src/capture-event/__tests__/prepare-event.test.ts +++ b/packages/agents/src/capture-event/__tests__/prepare-event.test.ts @@ -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'] }), diff --git a/packages/agents/src/capture-event/cli.ts b/packages/agents/src/capture-event/cli.ts index bda2fef2..f084125b 100644 --- a/packages/agents/src/capture-event/cli.ts +++ b/packages/agents/src/capture-event/cli.ts @@ -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 `, it instead rewrites that existing event in place; see * {@link amendEvent}. * @@ -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, diff --git a/packages/agents/src/capture-event/prepare-event.ts b/packages/agents/src/capture-event/prepare-event.ts index 5f78312d..8c3b489c 100644 --- a/packages/agents/src/capture-event/prepare-event.ts +++ b/packages/agents/src/capture-event/prepare-event.ts @@ -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 @@ -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, diff --git a/packages/agents/src/capture-event/types.ts b/packages/agents/src/capture-event/types.ts index 5b196c5a..160eaeba 100644 --- a/packages/agents/src/capture-event/types.ts +++ b/packages/agents/src/capture-event/types.ts @@ -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. */ diff --git a/packages/kb/src/records/__tests__/event.test.ts b/packages/kb/src/records/__tests__/event.test.ts index 74faf648..387ed1da 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 { 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', @@ -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); @@ -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); diff --git a/packages/kb/src/records/event.ts b/packages/kb/src/records/event.ts index 0b79f0b4..707d2b25 100644 --- a/packages/kb/src/records/event.ts +++ b/packages/kb/src/records/event.ts @@ -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[]; @@ -58,7 +59,7 @@ export function parseEvent(fields: Record, 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); @@ -80,7 +81,6 @@ export function parseEvent(fields: Record, body: string): Parse errors.length > 0 || id === undefined || capturedAt === undefined || - session === undefined || cwd === undefined || summary === undefined || tags === undefined || @@ -102,7 +102,7 @@ export function parseEvent(fields: Record, body: string): Parse recordType: 'event', id, capturedAt, - session, + ...(session !== undefined && { session }), cwd, summary, tags, @@ -114,13 +114,17 @@ export function parseEvent(fields: Record, 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; body: string } { const fields: Record = { 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, }; @@ -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) {