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: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ docs/plans/
.agents/*.manifest.json
.playwright-mcp/

# Bundled skill helpers (generated by the agents build)
# Bundled helpers (generated by the agents build)
packages/agents/content/scripts/**/*.mjs
packages/agents/content/skills/**/*.mjs

# Credentials
Expand Down
1 change: 1 addition & 0 deletions packages/agents/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/

# Generated esbuild bundles of skill helpers, not authored source.
content/skills/**/*.mjs
content/scripts/**/*.mjs

# Test fixtures that are intentionally syntactically malformed YAML. Prettier cannot
# parse them, and reformatting would erase the defect they exist to test.
Expand Down
105 changes: 105 additions & 0 deletions packages/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,111 @@ Run via the `codeassembly-agents` CLI: `codeassembly-agents <command> [options]`

Global options: `--harness <claude\|rovodev\|all>` (default `all`), `--link`, `--force`, `--dry-run`, and `--help`. Run `codeassembly-agents --help` for the authoritative list.

## Session-lifecycle hooks

Skills report the work they do, but they cannot report a session opening, exiting, or handing a turn back to you — at those moments no skill is running. Each harness reports them instead, through its own event hooks, and `relay-hook-event.mjs` turns a hook into a lifecycle event:

| Event | Claude Code | Rovo Dev |
| ----------------- | ------------------ | ------------------ |
| `session.started` | `SessionStart` | `on_session_start` |
| `session.ended` | `SessionEnd` | `on_session_end` |
| `turn.started` | `UserPromptSubmit` | `on_user_prompt` |
| `turn.completed` | `Stop` | `on_complete` |

`install` places the relay in each harness's `scripts/` directory and then wires the entries below into the harness config (`~/.claude/settings.json`, `~/.rovodev/config.yml`) by default. The wiring is its own step, shared across the CLI:

- `install --skip-hooks` installs everything else and leaves the configs untouched.
- `codeassembly-agents configure-hooks` runs just the wiring, for re-applying it later.
- `configure-hooks --print` prints the entries without writing anything — the manual-adoption path for a config you manage elsewhere. The snippets below are exactly what it emits.
- `uninstall` removes the entries; `status` reports each one as present, drifted, or absent.

Every managed command ends in `--sentinel codeassembly-agents`. That token is the ownership marker: the CLI creates, replaces, and removes only entries whose command carries it, so your own hooks and other tools' entries are never disturbed. The relay accepts the flag and ignores it.

The relay reports a boundary and nothing more. It never carries your prompt text, and it always exits 0 — a relay that failed loudly would be worse than the missing event, since both harnesses read some non-zero hook exits as a signal to block the agent.

### Claude Code

In `~/.claude/settings.json`, under `hooks`. Each entry names the hook it relays, so the relay never has to infer where it was called from:

```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook SessionStart --sentinel codeassembly-agents"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook SessionEnd --sentinel codeassembly-agents"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook UserPromptSubmit --sentinel codeassembly-agents"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook Stop --sentinel codeassembly-agents"
}
]
}
]
}
}
```

Omit `matcher` on all four. `SessionStart` and `SessionEnd` accept one to select a start source or an end reason, and leaving it out is what relays every one of them; `UserPromptSubmit` and `Stop` ignore it.

Keep the whole invocation in `command` rather than splitting the flags into an `args` array: `~` expands only in the single-string form.

### Rovo Dev

In `~/.rovodev/config.yml`, under `eventHooks`:

```yaml
eventHooks:
events:
- name: on_session_start
commands:
- command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_session_start --sentinel codeassembly-agents
- name: on_session_end
commands:
- command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_session_end --sentinel codeassembly-agents
- name: on_user_prompt
commands:
- command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_user_prompt --sentinel codeassembly-agents
- name: on_complete
commands:
- command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_complete --sentinel codeassembly-agents
```

Write your home directory out in full where the snippet shows `/Users/you`: `configure-hooks` writes your machine's absolute path here, matching the entries Rovo's own tooling generates.

Two things to know about Rovo:

- **Restart to pick up the change.** Rovo reads its config at startup, so a running session ignores hooks added under it.
- **`on_complete` fires when a run completes successfully.** A turn that errors or is aborted may not report its end, leaving that session reading as still working until its next event.

## Project declaration

A project opts into shared artifacts through `.agents/codeassembly.yaml`. Run `codeassembly-agents init` to scaffold one, declare the artifacts you want, then run `codeassembly-agents sync` to materialize them. The same declaration format resolves in two independent domains — the repo (via `sync`) and the user-global home (via `sync --global`). For the home domain, `codeassembly-agents init --global` scaffolds `~/.agents/codeassembly.yaml`, seeded with the `all` collection. See [Scopes](#scopes).
Expand Down
15 changes: 13 additions & 2 deletions packages/agents/content/scripts/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Helper scripts

Shared shell helpers consumed by skills and subagents. The install pipeline copies (or symlinks) each `.sh` file in this directory into `~/<platform_home>/scripts/` for every platform target (e.g., `~/.claude/scripts/`, `~/.codex/scripts/`).
Shared helpers installed into every platform target. The install pipeline copies (or symlinks) each `.sh` and `.mjs` file in this directory into `~/<platform_home>/scripts/` (e.g., `~/.claude/scripts/`, `~/.codex/scripts/`).

Non-`.sh` files in this directory (such as this README) are not installed.
Two kinds of helper live here, distinguished by who invokes them:

- **`.sh` — invoked by an agent.** Shell helpers a skill or subagent runs, via the `{harness_home_dir}/scripts/` prefix documented below.
- **`.mjs` — invoked by the harness.** Bundled TypeScript helpers wired into a harness's own configuration, with no agent in the loop. The bundles are build output, generated into this directory by `scripts/bundle-skill-helpers.ts` and git-ignored; the source lives under `src/`.

Files of any other extension (such as this README) are not installed.

## Invocation convention

Expand All @@ -20,12 +25,18 @@ Prose mentions of script names that are not invocations (e.g., ``"the `describe-

## Scripts

Agent-invoked:

- `describe-change.sh`: Renders titles for commits, tickets, PRs, and merges from declarative templates.
- `get-ticket-id.sh`: Extracts a ticket ID from a branch name.
- `resolve-frontmatter.sh`: Emits canonical artifact frontmatter (YAML or JSON) with provenance, ticket, branch, commit, and PR fields.
- `resolve-merge-options.sh`: Resolves merge-method and squash-title inputs from CLI overrides, label maps, and commit majority.
- `resolve-reviewer-context.sh`: Assembles the reviewer context block from a coder-emitted sidecar and a static lookup table.

Harness-invoked:

- `relay-hook-event.mjs`: Relays a harness event hook to a lifecycle event. Configured as a hook command, never run by an agent.

## Drift detection

The regression test at `packages/agents/src/__tests__/script-invocation-conventions.test.ts` walks every `.md` file under `content/skills/` and `content/subagents/` and fails when any executable invocation of a known helper script lacks the `{harness_home_dir}/scripts/` prefix.
Expand Down
6 changes: 6 additions & 0 deletions packages/agents/content/skills/emit-event/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ A value-bearing flag accepts both `--type value` and `--type=value`.

| Type | Emit when |
| ------------------ | ------------------------------------------------------------------------------- |
| `session.started` | **Relayed, not yours.** A session opened. Payload: the harness's start reason. |
| `turn.started` | **Relayed, not yours.** The user submitted a prompt. |
| `skill.started` | A skill begins. Payload: the skill name, and any argument that framed the run. |
| `skill.progress` | A skill reaches a milestone worth showing mid-run. Payload: what just finished. |
| `skill.completed` | A skill finishes. Payload: the outcome. |
| `artifact.written` | A file the user will want to open has been written. Payload: its path and kind. |
| `input.requested` | The skill has asked the user something and is waiting. |
| `input.received` | The user has answered. |
| `pr.created` | A pull request has been opened. Payload: its number and URL. |
| `turn.completed` | **Relayed, not yours.** The agent finished responding. |
| `session.ended` | **Relayed, not yours.** A session exited, switched, or forked. |

The four relayed types are emitted by the hook relay the CLI installs into the harness, which fires at boundaries no skill is running to observe. **Never emit one from a skill**: you would double-count a boundary the harness already reports. They are listed here so you recognize them when reading a log, not so you can produce them.

The vocabulary is convention, not a gate: an undeclared type warns on stderr and is appended anyway. Prefer a declared type — a watching surface only renders what it recognizes — but emit a new one rather than dropping an event that has no home yet.

Expand Down
2 changes: 1 addition & 1 deletion packages/agents/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ import baseConfig from '../../eslint.config.js';
export default [
...baseConfig,
// Generated esbuild bundles and shipped harness content, not lintable source.
globalIgnores(['content/skills/**/*.mjs', 'content/skills/**/*-example.ts']),
globalIgnores(['content/scripts/**/*.mjs', 'content/skills/**/*.mjs', 'content/skills/**/*-example.ts']),
];
2 changes: 2 additions & 0 deletions packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
makeKbEditSmokeTest,
makeKbRetrieveEventsSmokeTest,
makeKbUpdateEventsSmokeTest,
makeRelayHookEventSmokeTest,
makeUpdateJiraTicketSmokeTest,
type SmokeTestInvocation,
} from '../testing/smoke-test-utils.ts';
Expand All @@ -39,6 +40,7 @@ const smokeTests: Record<string, SmokeTestInvocation> = {
'src/kb-edit/cli.ts': makeKbEditSmokeTest(),
'src/kb-retrieve-events/cli.ts': makeKbRetrieveEventsSmokeTest(),
'src/kb-update-events/cli.ts': makeKbUpdateEventsSmokeTest(),
'src/relay-hook-event/cli.ts': makeRelayHookEventSmokeTest(),
'src/update-jira-ticket/cli.ts': makeUpdateJiraTicketSmokeTest(),
};

Expand Down
25 changes: 16 additions & 9 deletions packages/agents/scripts/bundle-skill-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
/**
* Build step: Bundle each skill's TypeScript helper into a single self-contained `.mjs` placed inside
* the skill's content directory.
* Build step: Bundle each TypeScript helper into a single self-contained `.mjs` placed inside the content tree.
*
* A skill installs to a platform directory outside the monorepo, so it cannot import a private workspace package.
* esbuild bundles the helper with `@codeassembly/kb` and its `yaml` / `zod` dependencies inlined, producing
* a file that runs under `node` with no monorepo packages present on disk.
* The bundle is written into `content/skills/`, so a subsequent `copy-content.ts` carries it into `dist/content/`
* A helper installs to a platform directory outside the monorepo, so it cannot import a private workspace package.
* esbuild bundles it with `@codeassembly/kb` and its `yaml` / `zod` dependencies inlined, producing a file that runs
* under `node` with no monorepo packages present on disk.
* The bundle is written under `content/`, so a subsequent `copy-content.ts` carries it into `dist/content/`
* and the dev and built layouts both ship the helper.
*
* The bundle list is a plain array of `BundleTarget` entries; new skills register themselves by appending one.
* A helper's destination follows its consumer: a skill's helper bundles into that skill's own directory under
* `content/skills/`, while a helper with no skill to belong to — one the harness itself invokes — bundles into
* `content/scripts/`, alongside the shell helpers that install to every harness home.
*
* The bundle list is a plain array of `BundleTarget` entries; a new helper registers itself by appending one.
*/
import path from 'node:path';
import process from 'node:process';
Expand All @@ -19,15 +22,15 @@ import { build } from 'esbuild';
/** Absolute path to the `@codeassembly/agents` package root. */
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

/** One skill helper to bundle: its TypeScript entry point and the `.mjs` output it produces. */
/** One helper to bundle: its TypeScript entry point and the `.mjs` output it produces. */
export interface BundleTarget {
/** Path to the helper's entry module, relative to the package root. */
entry: string;
/** Path to the bundled output, relative to the package root. */
outFile: string;
}

/** Every skill helper bundle; the smoke test reuses this list to exercise each built `.mjs`. */
/** Every helper bundle; the smoke test reuses this list to exercise each built `.mjs`. */
export const targets: BundleTarget[] = [
{
entry: 'src/kb-add/cli.ts',
Expand Down Expand Up @@ -73,6 +76,10 @@ export const targets: BundleTarget[] = [
entry: 'src/emit-event/cli.ts',
outFile: 'content/skills/emit-event/emit-event.mjs',
},
{
entry: 'src/relay-hook-event/cli.ts',
outFile: 'content/scripts/relay-hook-event.mjs',
},
];

// A CommonJS dependency (`yaml`) reaches Node built-ins via bare `require('process')` calls.
Expand Down
77 changes: 77 additions & 0 deletions packages/agents/scripts/testing/smoke-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,43 @@ export function makeKbUpdateEventsSmokeTest(): SmokeTestInvocation {
};
}

/**
* Stands up a throwaway git repo on a known branch with an `origin` remote, plus a fixture events root, then returns a
* `SmokeTestInvocation` that pipes a Claude `SessionStart` payload at the relay exactly as the harness would.
*
* The bundle is the only place the relay's stdin read is exercised against a real pipe: the unit suite hands `runRelay`
* a string, so a regression in the stream read — the one thing standing between a hook firing and an event existing —
* would pass unit tests and fail silently in every installed harness.
*/
export function makeRelayHookEventSmokeTest(): SmokeTestInvocation {
const home = mkdtempSync(path.join(tmpdir(), 'relay-hook-event-home-'));

const repo = mkdtempSync(path.join(tmpdir(), 'relay-hook-event-repo-'));
execFileSync('git', ['-C', repo, 'init', '--quiet', '--initial-branch=1005/smoke']);
execFileSync('git', ['-C', repo, 'remote', 'add', 'origin', 'git@github.com:williamthorsen/codeassembly.git']);

const expectedPath = path.join(
home,
'.codeassembly',
'events',
'williamthorsen',
'codeassembly',
'1005-smoke',
'smoke-session.jsonl',
);

return {
args: ['--harness', 'claude', '--hook', 'SessionStart', '--home', home],
stdin: JSON.stringify({
session_id: 'smoke-session',
cwd: repo,
hook_event_name: 'SessionStart',
source: 'startup',
}),
assertResult: (result) => assertRelayHookEventSmokeResult(result, expectedPath),
};
}

/**
* Returns a `SmokeTestInvocation` that pipes an HTML fragment wrapping inline code in bold — a composition violation —
* and asserts the checker reports a `composition-code-inline-mark` finding.
Expand Down Expand Up @@ -482,6 +519,46 @@ function assertKbUpdateEventsSmokeResult(result: unknown, eventPath: string): vo
}
}

/**
* Assert the relay smoke read its payload from the pipe and appended a `session.started` envelope at the path the
* payload's `cwd` implies — attribution the relay could only have derived from stdin, since it was spawned elsewhere.
*/
function assertRelayHookEventSmokeResult(result: unknown, expectedPath: string): void {
if (!isRecord(result)) {
throw new TypeError('expected object result from relay-hook-event');
}
if (result.ok !== true) {
throw new Error(`expected ok: true, got ${JSON.stringify(result)}`);
}
if (result.path !== expectedPath) {
throw new Error(`expected the event at ${expectedPath}, got ${JSON.stringify(result.path)}`);
}

const lines = readFileSync(expectedPath, 'utf8').split('\n').filter(Boolean);
if (lines.length !== 1) {
throw new Error(`expected exactly one appended line, got ${lines.length}`);
}
const envelope: unknown = JSON.parse(lines[0] ?? '');
if (!isRecord(envelope)) {
throw new TypeError(`expected the appended line to be a JSON object, got: ${lines[0]}`);
}
const expectedFields: Record<string, unknown> = {
type: 'session.started',
repo: 'williamthorsen/codeassembly',
branch: '1005/smoke',
session: 'smoke-session',
harness: 'claude',
};
for (const [field, expected] of Object.entries(expectedFields)) {
if (envelope[field] !== expected) {
throw new Error(`expected ${field} ${JSON.stringify(expected)}, got ${JSON.stringify(envelope[field])}`);
}
}
if (!isRecord(envelope.payload) || envelope.payload.source !== 'startup') {
throw new Error(`expected the start discriminator to pass through, got ${JSON.stringify(envelope.payload)}`);
}
}

/**
* Stands up a throwaway git repo on a known branch with an `origin` remote, plus a fixture events root, then returns a
* `SmokeTestInvocation` that emits one event against them. Exercises the full context-autofill → envelope → append
Expand Down
Loading
Loading