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
3 changes: 3 additions & 0 deletions packages/agents/content/skills/capture-event/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ 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.

### 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
Expand All @@ -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 "<one-line summary>" \
--store <name|@default> \
--harness {harness_id} \
[--skill <skill>] [--model <model>] [--tags <comma,separated>]
<event body, may span multiple lines and contain any characters>
EOF
Expand Down
27 changes: 26 additions & 1 deletion packages/agents/src/capture-event/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -66,6 +66,8 @@ describe(parseArgs, () => {
'kb-retrieve',
'--model',
'claude-opus-4-8',
'--harness',
'claude',
'--tags',
'one, two,three',
]);
Expand All @@ -75,6 +77,7 @@ describe(parseArgs, () => {
summary: 'A summary',
skill: 'kb-retrieve',
model: 'claude-opus-4-8',
harness: 'claude',
tags: ['one', 'two', 'three'],
});
});
Expand All @@ -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([]);
});

Expand Down Expand Up @@ -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);
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,6 +27,7 @@ function argsFor(overrides: Partial<ParsedArgs>): ParsedArgs {
summary: 'Something noticed',
skill: null,
model: null,
harness: null,
tags: [],
...overrides,
};
Expand Down Expand Up @@ -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,
Expand All @@ -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]');
}
});
Expand Down
3 changes: 2 additions & 1 deletion packages/agents/src/capture-event/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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),
};
}
Expand Down
5 changes: 4 additions & 1 deletion packages/agents/src/capture-event/prepare-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/agents/src/capture-event/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
9 changes: 6 additions & 3 deletions packages/agents/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ async function installSkills(
options,
skillsPrefix,
homeDir,
platformId,
contentDir,
toolMapping,
);
Expand Down Expand Up @@ -245,6 +246,7 @@ async function installSkills(
options,
skillsPrefix,
homeDir,
platformId,
contentDir,
toolMapping,
'(platform-specific)',
Expand All @@ -270,6 +272,7 @@ async function installSkillEntry(
options: InstallOptions,
skillsPrefix: string,
homeDir: string,
harnessId: string,
contentDir: string,
toolMapping: ReadonlyMap<string, string>,
label = '',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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}`));
}

Expand Down
40 changes: 29 additions & 11 deletions packages/agents/src/lib/__tests__/path-rewriter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});

Expand All @@ -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.');
Expand All @@ -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.');
Expand All @@ -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);
Expand All @@ -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();
});

Expand All @@ -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}');
Expand All @@ -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(
Expand All @@ -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');
Expand Down
21 changes: 13 additions & 8 deletions packages/agents/src/lib/path-rewriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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');
}
Expand All @@ -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<void> {
const entries = await readdir(dirPath);

Expand All @@ -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);
}
}
}
Loading
Loading