diff --git a/packages/agents/content/skills/capture-event/SKILL.md b/packages/agents/content/skills/capture-event/SKILL.md index b1bb65bb..d672ba66 100644 --- a/packages/agents/content/skills/capture-event/SKILL.md +++ b/packages/agents/content/skills/capture-event/SKILL.md @@ -28,6 +28,7 @@ A **skill-caused mistake** — an error a clearer skill definition would have pr | `--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 | 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. @@ -35,6 +36,7 @@ A value-bearing flag accepts both `--summary text` and `--summary=text`. The eve ### 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). +- **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. ### Store selection @@ -61,6 +63,7 @@ Pipe the body to the bundled helper. A heredoc keeps multi-line bodies legible: cat <<'EOF' | node {platform_home_dir}/skills/capture-event/capture-event.mjs \ --summary "" \ --store \ + --harness {harness_id} \ [--skill ] [--model ] [--tags ] EOF diff --git a/packages/agents/src/capture-event/__tests__/cli.test.ts b/packages/agents/src/capture-event/__tests__/cli.test.ts index b42cf064..02f0a57f 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, tags, correction, owner, locality, severity] + optional: [repo, skill, model, harness, tags, correction, owner, locality, severity] `; function bodyStream(body: string): Readable { @@ -66,6 +66,8 @@ describe(parseArgs, () => { 'kb-retrieve', '--model', 'claude-opus-4-8', + '--harness', + 'claude', '--tags', 'one, two,three', ]); @@ -75,6 +77,7 @@ describe(parseArgs, () => { summary: 'A summary', skill: 'kb-retrieve', model: 'claude-opus-4-8', + harness: 'claude', tags: ['one', 'two', 'three'], }); }); @@ -85,6 +88,7 @@ describe(parseArgs, () => { expect(parsed.store).toBeNull(); expect(parsed.skill).toBeNull(); expect(parsed.model).toBeNull(); + expect(parsed.harness).toBeNull(); expect(parsed.tags).toEqual([]); }); @@ -158,6 +162,27 @@ describe(runCapture, () => { expect(written).toContain('summary: Noticed a thing'); expect(written).toContain('session: session-xyz'); expect(written).toContain('repo: williamthorsen/codeassembly'); + expect(written).not.toMatch(/^harness:/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'); + + const result = await runCapture({ + argv: ['--store', '@default', '--summary', 'Noticed a thing', '--harness', 'claude'], + 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(/^harness: claude$/m); } }); 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 350b54ed..2f535ac3 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, tags, correction, owner, locality, severity] + optional: [repo, skill, model, harness, tags, correction, owner, locality, severity] `; const ID = '01HZZZZZZZZZZZZZZZZZZZZZZZZ'; @@ -27,6 +27,7 @@ function argsFor(overrides: Partial): ParsedArgs { summary: 'Something noticed', skill: null, model: null, + harness: null, tags: [], ...overrides, }; @@ -126,9 +127,9 @@ describe(prepareEvent, () => { } }); - it('renders the agent-supplied skill, model, and tags into the record', () => { + 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', tags: ['recall', 'kb'] }), + args: argsFor({ skill: 'kb-retrieve', model: 'claude-opus-4-8', harness: 'claude', tags: ['recall', 'kb'] }), context: CONTEXT, id: ID, capturedAt: CAPTURED_AT, @@ -140,6 +141,7 @@ describe(prepareEvent, () => { if (result.ok) { expect(result.prepared.content).toContain('skill: kb-retrieve'); expect(result.prepared.content).toContain('model: claude-opus-4-8'); + expect(result.prepared.content).toContain('harness: claude'); expect(result.prepared.content).toContain('tags: [recall, kb]'); } }); diff --git a/packages/agents/src/capture-event/cli.ts b/packages/agents/src/capture-event/cli.ts index 9aab9796..ebd59ba9 100644 --- a/packages/agents/src/capture-event/cli.ts +++ b/packages/agents/src/capture-event/cli.ts @@ -24,7 +24,7 @@ import { writeEvent } from './write-event.ts'; const execFileAsync = promisify(execFile); /** Flag names that take a value. */ -const VALUE_FLAGS = ['store', 'summary', 'skill', 'model', 'tags'] as const; +const VALUE_FLAGS = ['store', 'summary', 'skill', 'model', 'harness', 'tags'] as const; type ValueFlag = (typeof VALUE_FLAGS)[number]; /** Executes the helper from `process.argv` and writes the JSON result to stdout. */ @@ -188,6 +188,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { summary, skill: raw.skill ?? null, model: raw.model ?? null, + harness: raw.harness ?? null, tags: raw.tags === undefined ? [] : parseTagList(raw.tags), }; } diff --git a/packages/agents/src/capture-event/prepare-event.ts b/packages/agents/src/capture-event/prepare-event.ts index 1ba47359..aa500589 100644 --- a/packages/agents/src/capture-event/prepare-event.ts +++ b/packages/agents/src/capture-event/prepare-event.ts @@ -34,7 +34,7 @@ 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`/`tags`. No `updated`/`last-verified` field is written: events are write-once. + * any supplied `skill`/`model`/`harness`/`tags`. 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 @@ -67,6 +67,9 @@ export function prepareEvent(input: { if (args.model !== null) { fields.push(['model', args.model]); } + if (args.harness !== null) { + fields.push(['harness', args.harness]); + } if (args.tags.length > 0) { fields.push(['tags', args.tags]); } diff --git a/packages/agents/src/capture-event/types.ts b/packages/agents/src/capture-event/types.ts index 9145d1ee..a93bac7f 100644 --- a/packages/agents/src/capture-event/types.ts +++ b/packages/agents/src/capture-event/types.ts @@ -20,6 +20,8 @@ export interface ParsedArgs { skill: string | null; /** Optional model identifier. */ model: string | null; + /** Optional harness (agent platform) identifier, injected from the installed invocation template. */ + harness: string | null; /** Optional tag list, in the order the agent supplied them. */ tags: string[]; } diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index 04d16554..8100af35 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -210,6 +210,7 @@ async function installSkills( options, skillsPrefix, homeDir, + platformId, contentDir, toolMapping, ); @@ -245,6 +246,7 @@ async function installSkills( options, skillsPrefix, homeDir, + platformId, contentDir, toolMapping, '(platform-specific)', @@ -270,6 +272,7 @@ async function installSkillEntry( options: InstallOptions, skillsPrefix: string, homeDir: string, + harnessId: string, contentDir: string, toolMapping: ReadonlyMap, label = '', @@ -316,7 +319,7 @@ async function installSkillEntry( } await writeExpandedSkillDir(srcPath, destPath, expandedDirContents); const skillsDestDir = path.dirname(destPath); - await rewritePathsInDirectory(destPath, skillsDestDir, skillsPrefix, homeDir); + await rewritePathsInDirectory(destPath, skillsDestDir, skillsPrefix, homeDir, harnessId); await injectMarkersInDirectory(destPath, (fileRelPath) => buildSourceUrl(`${sourceRelativeRoot}/${fileRelPath}`)); } else if (srcPath.endsWith('.md') && expandedFileContent !== undefined) { // Single-file `.md` skill entries: write the previously expanded content directly. @@ -468,7 +471,7 @@ async function installSubagents( await writeFile(destPath, withMarker, 'utf8'); // Expand `{platform_home_dir}` tokens so the body's script references resolve to real paths. - await rewritePathsInFile(destPath, entry, platformConfig.homeDir, platformConfig.homeDir); + await rewritePathsInFile(destPath, entry, platformConfig.homeDir, platformConfig.homeDir, platformConfig.id); const hash = await computeContentHash(destPath); entries.push({ @@ -852,7 +855,7 @@ async function installPlatformGuidance( if (expandedContent !== undefined) { await writeFile(destPath, expandedContent, 'utf8'); } - await rewritePathsInFile(destPath, entry, platformConfig.homeDir, platformConfig.homeDir); + await rewritePathsInFile(destPath, entry, platformConfig.homeDir, platformConfig.homeDir, platformConfig.id); await injectMarkerInFile(destPath, buildSourceUrl(`guidance/_platforms/${platformId}/${entry}`)); } diff --git a/packages/agents/src/lib/__tests__/path-rewriter.test.ts b/packages/agents/src/lib/__tests__/path-rewriter.test.ts index 9849373d..90aceedd 100644 --- a/packages/agents/src/lib/__tests__/path-rewriter.test.ts +++ b/packages/agents/src/lib/__tests__/path-rewriter.test.ts @@ -103,24 +103,36 @@ describe(rewriteMarkdownPaths, () => { describe(rewriteTemplateVariables, () => { it('replaces {platform_home_dir} with tilde-prefixed homeDir', () => { const content = '{platform_home_dir}/scripts/describe-change.sh --scope agents --type feat'; - expect(rewriteTemplateVariables(content, '.claude')).toBe( + expect(rewriteTemplateVariables(content, '.claude', 'claude')).toBe( '~/.claude/scripts/describe-change.sh --scope agents --type feat', ); }); it('replaces multiple occurrences', () => { const content = 'Run {platform_home_dir}/scripts/a.sh then {platform_home_dir}/scripts/b.sh'; - expect(rewriteTemplateVariables(content, '.claude')).toBe('Run ~/.claude/scripts/a.sh then ~/.claude/scripts/b.sh'); + expect(rewriteTemplateVariables(content, '.claude', 'claude')).toBe( + 'Run ~/.claude/scripts/a.sh then ~/.claude/scripts/b.sh', + ); }); it('returns content unchanged when no template variables are present', () => { const content = '# No variables here\n\nJust plain text.'; - expect(rewriteTemplateVariables(content, '.claude')).toBe(content); + expect(rewriteTemplateVariables(content, '.claude', 'claude')).toBe(content); }); it('resolves to the correct path for different platforms', () => { const content = '{platform_home_dir}/scripts/describe-change.sh'; - expect(rewriteTemplateVariables(content, '.rovodev')).toBe('~/.rovodev/scripts/describe-change.sh'); + expect(rewriteTemplateVariables(content, '.rovodev', 'rovodev')).toBe('~/.rovodev/scripts/describe-change.sh'); + }); + + it('replaces {harness_id} with the harness identifier, leaving no placeholder', () => { + const content = 'node {platform_home_dir}/skills/capture-event/capture-event.mjs --harness {harness_id}'; + expect(rewriteTemplateVariables(content, '.claude', 'claude')).toBe( + 'node ~/.claude/skills/capture-event/capture-event.mjs --harness claude', + ); + expect(rewriteTemplateVariables(content, '.rovodev', 'rovodev')).toBe( + 'node ~/.rovodev/skills/capture-event/capture-event.mjs --harness rovodev', + ); }); }); @@ -143,7 +155,7 @@ describe(rewritePathsInDirectory, () => { await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), 'See [format](../_data/title-templates.md) for spec.', 'utf8'); - await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'); + await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude', 'claude'); const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8'); expect(result).toBe('See [format](~/.claude/skills/_data/title-templates.md) for spec.'); @@ -158,7 +170,13 @@ describe(rewritePathsInDirectory, () => { 'utf8', ); - await rewritePathsInDirectory(path.join(skillsDestDir, 'orchestrate'), skillsDestDir, '.claude/skills', '.claude'); + await rewritePathsInDirectory( + path.join(skillsDestDir, 'orchestrate'), + skillsDestDir, + '.claude/skills', + '.claude', + 'claude', + ); const result = await readFile(path.join(nestedDir, 'review-cycle.md'), 'utf8'); expect(result).toBe('See [conventions](~/.claude/skills/_data/artifact-conventions.md) for details.'); @@ -170,7 +188,7 @@ describe(rewritePathsInDirectory, () => { const originalContent = 'See [format](../_data/title-templates.md) for spec.'; await writeFile(path.join(skillDir, 'notes.txt'), originalContent, 'utf8'); - await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'); + await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude', 'claude'); const result = await readFile(path.join(skillDir, 'notes.txt'), 'utf8'); expect(result).toBe(originalContent); @@ -181,7 +199,7 @@ describe(rewritePathsInDirectory, () => { await mkdir(skillDir, { recursive: true }); await expect( - rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'), + rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude', 'claude'), ).resolves.toBeUndefined(); }); @@ -194,7 +212,7 @@ describe(rewritePathsInDirectory, () => { 'utf8', ); - await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'); + await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude', 'claude'); const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8'); expect(result).toBe('~/.claude/scripts/describe-change.sh --scope {scope} --type {type}'); @@ -209,7 +227,7 @@ describe(rewritePathsInDirectory, () => { 'utf8', ); - await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'); + await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude', 'claude'); const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8'); expect(result).toBe( @@ -222,7 +240,7 @@ describe(rewritePathsInDirectory, () => { await mkdir(skillDir, { recursive: true }); await writeFile(path.join(skillDir, 'SKILL.md'), '# No links here\n', 'utf8'); - await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'); + await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude', 'claude'); const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8'); expect(result).toBe('# No links here\n'); diff --git a/packages/agents/src/lib/path-rewriter.ts b/packages/agents/src/lib/path-rewriter.ts index a4eae2a6..fb15c04e 100644 --- a/packages/agents/src/lib/path-rewriter.ts +++ b/packages/agents/src/lib/path-rewriter.ts @@ -39,28 +39,31 @@ export function rewriteMarkdownPaths(content: string, fileRelPath: string, pathP } /** - * Replaces `{platform_home_dir}` with `~/{homeDir}` (e.g., `~/.claude`) in `content`. + * Expands install-time template variables in `content`: `{platform_home_dir}` to `~/{homeDir}` (e.g. `~/.claude`), and + * `{harness_id}` to the platform's harness identifier (e.g. `claude`), the value capture-event records as the agent + * platform. */ -export function rewriteTemplateVariables(content: string, homeDir: string): string { - return content.replaceAll('{platform_home_dir}', `~/${homeDir}`); +export function rewriteTemplateVariables(content: string, homeDir: string, harnessId: string): string { + return content.replaceAll('{platform_home_dir}', `~/${homeDir}`).replaceAll('{harness_id}', harnessId); } /** * Applies Markdown path rewriting and template variable expansion to a single `.md` file. * `fileRelPath` is the file's path relative to the tree root that `pathPrefix` names. * For flat guidance files (one directory, no nesting) the caller typically passes the file's - * basename. + * basename. `harnessId` is the platform's harness identifier used to expand `{harness_id}`. */ export async function rewritePathsInFile( filePath: string, fileRelPath: string, pathPrefix: string, homeDir: string, + harnessId: string, ): Promise { try { const content = await readFile(filePath, 'utf8'); let rewritten = rewriteMarkdownPaths(content, fileRelPath, pathPrefix); - rewritten = rewriteTemplateVariables(rewritten, homeDir); + rewritten = rewriteTemplateVariables(rewritten, homeDir, harnessId); if (rewritten !== content) { await writeFile(filePath, rewritten, 'utf8'); } @@ -77,13 +80,15 @@ export async function rewritePathsInFile( * `pathPrefix` is the platform-relative prefix for rewriting link targets (e.g., `.claude/skills` * for skills, `.claude` for platform guidance). * `homeDir` is the platform home directory segment (e.g., `.claude`), used to expand - * `{platform_home_dir}` template variables. + * `{platform_home_dir}` template variables. `harnessId` is the platform's harness identifier, + * used to expand `{harness_id}`. */ export async function rewritePathsInDirectory( dirPath: string, destRoot: string, pathPrefix: string, homeDir: string, + harnessId: string, ): Promise { const entries = await readdir(dirPath); @@ -96,10 +101,10 @@ export async function rewritePathsInDirectory( } if (stats.isDirectory()) { - await rewritePathsInDirectory(fullPath, destRoot, pathPrefix, homeDir); + await rewritePathsInDirectory(fullPath, destRoot, pathPrefix, homeDir, harnessId); } else if (entry.endsWith('.md')) { const fileRelPath = path.relative(destRoot, fullPath).split(path.sep).join('/'); - await rewritePathsInFile(fullPath, fileRelPath, pathPrefix, homeDir); + await rewritePathsInFile(fullPath, fileRelPath, pathPrefix, homeDir, harnessId); } } } diff --git a/packages/kb/src/schema/__tests__/default-schema.test.ts b/packages/kb/src/schema/__tests__/default-schema.test.ts index af78d1ba..4dfca164 100644 --- a/packages/kb/src/schema/__tests__/default-schema.test.ts +++ b/packages/kb/src/schema/__tests__/default-schema.test.ts @@ -27,6 +27,7 @@ describe('defaultSchema', () => { expect(defaultSchema.recordTypes.event?.optional).toEqual([ 'addressed-by', 'correction', + 'harness', 'model', 'repo', 'skill', diff --git a/packages/kb/src/schema/default-schema.ts b/packages/kb/src/schema/default-schema.ts index f551493e..ed69bc97 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', 'model', 'repo', 'skill', 'tags']), + optional: Object.freeze(['addressed-by', 'correction', 'harness', 'model', 'repo', 'skill', 'tags']), recall: 'recurrence-recency', };