From 27790ddd5f821183d88473e183952eb7a9dd0add Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 16 Jul 2026 18:55:21 -0700 Subject: [PATCH 1/9] agents|feat: Declare the session and turn lifecycle event types The lifecycle event vocabulary gains four types covering session and turn boundaries: `session.started`, `session.ended`, `turn.started`, and `turn.completed`. A surface watching the event log can now recognize when a session opened, when it exited, and where each turn began and ended, instead of seeing those boundaries as unrecognized types. The four are relayed from the harness's own event hooks rather than emitted by a skill, because a session ends and a turn completes at moments no skill is running to observe. Skills keep emitting the work narration they already do, and the `emit-event` vocabulary marks the relayed types so a skill does not double-count a boundary the harness already reports. --- .../agents/content/skills/emit-event/SKILL.md | 6 ++++++ packages/agents/src/emit-event/types.ts | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/agents/content/skills/emit-event/SKILL.md b/packages/agents/content/skills/emit-event/SKILL.md index 99c04a81..0269766b 100644 --- a/packages/agents/content/skills/emit-event/SKILL.md +++ b/packages/agents/content/skills/emit-event/SKILL.md @@ -34,6 +34,8 @@ 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. | @@ -41,6 +43,10 @@ A value-bearing flag accepts both `--type value` and `--type=value`. | `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. diff --git a/packages/agents/src/emit-event/types.ts b/packages/agents/src/emit-event/types.ts index 1b7e51c7..fcf6849c 100644 --- a/packages/agents/src/emit-event/types.ts +++ b/packages/agents/src/emit-event/types.ts @@ -6,11 +6,18 @@ // zero exit. There is no out-of-band failure channel; the result union is total. /** - * The v0 session-lifecycle vocabulary, ordered by the sequence an instrumented skill emits rather than alphabetically, - * so the list doubles as the shape of a session. Membership is convention, not a gate: an undeclared type warns and is - * still appended, which lets a skill emit a new type before the vocabulary catches up. + * The v0 session-lifecycle vocabulary, ordered by the sequence a session emits rather than alphabetically, so the list + * doubles as the shape of a session: session boundaries enclose turns, which enclose the skills a turn runs. + * Membership is convention, not a gate: an undeclared type warns and is still appended, which lets an emitter use a new + * type before the vocabulary catches up. + * + * Two channels feed the vocabulary. The `session.*` and `turn.*` boundaries come from the harness, relayed from its + * event hooks — a session ends and a turn completes at moments no skill is running to observe. The rest is work + * narration an instrumented skill emits about itself. */ export const EVENT_TYPES = [ + 'session.started', + 'turn.started', 'skill.started', 'skill.progress', 'skill.completed', @@ -18,6 +25,8 @@ export const EVENT_TYPES = [ 'input.requested', 'input.received', 'pr.created', + 'turn.completed', + 'session.ended', ] as const; /** One of the declared v0 event types. */ From 772b39c5d051a0e46ad68879e877272c46be36ff Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 16 Jul 2026 18:55:37 -0700 Subject: [PATCH 2/9] agents|feat: Relay harness hook events into the lifecycle event log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hook relay turns a harness's own session and turn boundaries into lifecycle events, so a watching surface sees a session from the moment it opens and sees it end when it exits — boundaries no skill was ever running to report. It serves Claude Code and Rovo Dev from one implementation, and `install` places it in each harness home ready to be configured as a hook command. The relay reads the hook's payload and files the event against the session and working directory that payload names, so repo, branch, and session attribution match the session the hook fired in rather than wherever the harness spawned the hook. Session start and end carry the harness's own reason for the boundary. Turn boundaries carry nothing further: the prompt text is deliberately left in the session it belongs to. Telemetry never disturbs the session it watches. Every failure the relay can reach — an unusable payload, a hook it has no mapping for, an unwritable log — is reported and exits 0, which matters most on Claude Code, where some non-zero hook exits are read as control signals rather than as failures. --- .gitignore | 3 +- packages/agents/content/scripts/README.md | 15 +- packages/agents/eslint.config.js | 2 +- .../__tests__/smoke-test-skill-helpers.ts | 2 + .../agents/scripts/bundle-skill-helpers.ts | 25 +- .../scripts/testing/smoke-test-utils.ts | 77 +++ .../src/commands/__tests__/install.test.ts | 21 + packages/agents/src/commands/install.ts | 10 +- .../relay-hook-event/__tests__/cli.test.ts | 448 ++++++++++++++++++ packages/agents/src/relay-hook-event/cli.ts | 300 ++++++++++++ .../src/relay-hook-event/hook-mappings.ts | 49 ++ packages/agents/src/relay-hook-event/types.ts | 75 +++ 12 files changed, 1012 insertions(+), 15 deletions(-) create mode 100644 packages/agents/src/relay-hook-event/__tests__/cli.test.ts create mode 100644 packages/agents/src/relay-hook-event/cli.ts create mode 100644 packages/agents/src/relay-hook-event/hook-mappings.ts create mode 100644 packages/agents/src/relay-hook-event/types.ts diff --git a/.gitignore b/.gitignore index 984e96c1..19d9fa80 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/agents/content/scripts/README.md b/packages/agents/content/scripts/README.md index f32171d3..d473d93d 100644 --- a/packages/agents/content/scripts/README.md +++ b/packages/agents/content/scripts/README.md @@ -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 `~//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 `~//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 @@ -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. diff --git a/packages/agents/eslint.config.js b/packages/agents/eslint.config.js index de7b38b3..ce11b73a 100644 --- a/packages/agents/eslint.config.js +++ b/packages/agents/eslint.config.js @@ -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']), ]; diff --git a/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts b/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts index 1845297b..95303c0d 100644 --- a/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts +++ b/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts @@ -23,6 +23,7 @@ import { makeKbEditSmokeTest, makeKbRetrieveEventsSmokeTest, makeKbUpdateEventsSmokeTest, + makeRelayHookEventSmokeTest, makeUpdateJiraTicketSmokeTest, type SmokeTestInvocation, } from '../testing/smoke-test-utils.ts'; @@ -39,6 +40,7 @@ const smokeTests: Record = { '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(), }; diff --git a/packages/agents/scripts/bundle-skill-helpers.ts b/packages/agents/scripts/bundle-skill-helpers.ts index 0edffd2f..d53fda11 100644 --- a/packages/agents/scripts/bundle-skill-helpers.ts +++ b/packages/agents/scripts/bundle-skill-helpers.ts @@ -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'; @@ -19,7 +22,7 @@ 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; @@ -27,7 +30,7 @@ export interface BundleTarget { 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', @@ -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. diff --git a/packages/agents/scripts/testing/smoke-test-utils.ts b/packages/agents/scripts/testing/smoke-test-utils.ts index dcd5d798..c5d38c24 100644 --- a/packages/agents/scripts/testing/smoke-test-utils.ts +++ b/packages/agents/scripts/testing/smoke-test-utils.ts @@ -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. @@ -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 = { + 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 diff --git a/packages/agents/src/commands/__tests__/install.test.ts b/packages/agents/src/commands/__tests__/install.test.ts index 8007719b..c55c58d9 100644 --- a/packages/agents/src/commands/__tests__/install.test.ts +++ b/packages/agents/src/commands/__tests__/install.test.ts @@ -310,6 +310,27 @@ describe(installCommand, () => { expect(statSync(scriptPath).mode & 0o777).toBe(0o755); }); + it('places a bundled .mjs helper alongside the shell scripts', async () => { + const claudeHome = await setupClaudeHome(); + // The hook relay ships as a bundled `.mjs` rather than a `.sh`: the harness invokes it directly, so it reaches a + // harness home by the same path as the shell helpers a skill invokes. + await buildContentTree(contentDir, { scripts: { 'relay-demo.mjs': 'process.stdout.write("{}")\n' } }); + + await installCommand(makeOptions(), tempDir, contentDir); + + expect(existsSync(path.join(claudeHome, 'scripts', 'relay-demo.mjs'))).toBe(true); + expect(existsSync(path.join(claudeHome, 'scripts', 'demo.sh'))).toBe(true); + }); + + it('installs no file that is neither a shell script nor a bundle', async () => { + const claudeHome = await setupClaudeHome(); + await buildContentTree(contentDir, { scripts: { 'README.md': '# Helper scripts\n' } }); + + await installCommand(makeOptions(), tempDir, contentDir); + + expect(existsSync(path.join(claudeHome, 'scripts', 'README.md'))).toBe(false); + }); + it('records script entries with a sha256 hash and linked:false in copy mode', async () => { await setupClaudeHome(); diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index 06dbac3d..69a22a6e 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -30,6 +30,12 @@ import type { SharedManifest, } from '../lib/types.js'; +/** + * The extensions that ship from `content/scripts/` to a harness home: `.sh` helpers a skill invokes, and `.mjs` bundles + * the harness itself invokes. Anything else there — the README — documents the directory rather than shipping from it. + */ +const SCRIPT_EXTENSIONS: ReadonlyArray = ['.mjs', '.sh']; + /** * Executes the install command, installing skills and subagents for the specified harnesses. */ @@ -367,8 +373,8 @@ async function installScripts( continue; } - // Skip non-script files (e.g. README.md); only `.sh` helpers ship to harness homes. - if (!entry.endsWith('.sh')) { + // Skip non-script files (e.g. README.md); only helper scripts ship to harness homes. + if (!SCRIPT_EXTENSIONS.some((extension) => entry.endsWith(extension))) { continue; } diff --git a/packages/agents/src/relay-hook-event/__tests__/cli.test.ts b/packages/agents/src/relay-hook-event/__tests__/cli.test.ts new file mode 100644 index 00000000..3034caaf --- /dev/null +++ b/packages/agents/src/relay-hook-event/__tests__/cli.test.ts @@ -0,0 +1,448 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isRecord } from '../../lib/type-guards.ts'; +import { parseArgs, parseHookPayload, runRelay } from '../cli.ts'; +import type { RelayResult } from '../types.ts'; + +const execFileAsync = promisify(execFile); + +const NOW = new Date('2026-07-16T09:30:00.000Z'); + +const REMOTE_URL = 'git@github.com:williamthorsen/codeassembly.git'; + +const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; + +/** An environment supplying a session id, as a Claude session exposes to a hook it spawns. */ +const ENV_WITH_SESSION: NodeJS.ProcessEnv = { CLAUDE_CODE_SESSION_ID: 'env-session' }; + +describe(parseArgs, () => { + it('parses every flag in its separate-token form', () => { + expect(parseArgs(['--harness', 'claude', '--hook', 'SessionStart', '--home', '/tmp/home'])).toEqual({ + harness: 'claude', + hook: 'SessionStart', + home: '/tmp/home', + }); + }); + + it('parses the inline --flag=value form', () => { + expect(parseArgs(['--harness=rovodev', '--hook=on_complete'])).toEqual({ + harness: 'rovodev', + hook: 'on_complete', + home: null, + }); + }); + + it('throws when --harness is missing', () => { + expect(() => parseArgs(['--hook', 'Stop'])).toThrow(/--harness is required/); + }); + + it('throws when --hook is missing', () => { + expect(() => parseArgs(['--harness', 'claude'])).toThrow(/--hook is required/); + }); + + it('throws on a harness the relay does not serve', () => { + expect(() => parseArgs(['--harness', 'codex', '--hook', 'Stop'])).toThrow(/--harness must be one of/); + }); + + it('throws on an unknown flag', () => { + expect(() => parseArgs(['--harness', 'claude', '--hook', 'Stop', '--mystery', 'x'])).toThrow(/unknown flag/); + }); + + it('throws on an unexpected positional', () => { + expect(() => parseArgs(['SessionStart'])).toThrow(/unexpected argument/); + }); + + it('throws on an empty value', () => { + expect(() => parseArgs(['--harness=', '--hook=Stop'])).toThrow(/--harness requires a value/); + }); +}); + +describe(parseHookPayload, () => { + const mapping = { type: 'session.started', discriminators: ['source'] } as const; + + it('reads the session and working directory both harnesses report', () => { + const stdin = JSON.stringify({ session_id: 'abc', cwd: '/repos/thing', hook_event_name: 'SessionStart' }); + + expect(parseHookPayload({ stdin, mapping })).toEqual({ + ok: true, + value: { session: 'abc', cwd: '/repos/thing', discriminators: {} }, + }); + }); + + it('carries through the mapping’s discriminator keys and nothing else', () => { + // `prompt` is the shape of the field the relay must not carry: the turn boundary is the signal, not what was said. + const stdin = JSON.stringify({ session_id: 'abc', source: 'resume', prompt: 'secret', reason: 'clear' }); + + expect(parseHookPayload({ stdin, mapping })).toMatchObject({ + ok: true, + value: { discriminators: { source: 'resume' } }, + }); + }); + + it('preserves a nested discriminator object as the harness shaped it', () => { + const stdin = JSON.stringify({ attributes: { reason: 'switch', forked: true } }); + + expect( + parseHookPayload({ stdin, mapping: { type: 'session.ended', discriminators: ['attributes'] } }), + ).toMatchObject({ ok: true, value: { discriminators: { attributes: { reason: 'switch', forked: true } } } }); + }); + + it('omits a session and cwd the payload does not carry', () => { + expect(parseHookPayload({ stdin: '{}', mapping })).toEqual({ ok: true, value: { discriminators: {} } }); + }); + + it('treats a non-string session as absent rather than stringifying it', () => { + const stdin = JSON.stringify({ session_id: 42, cwd: '' }); + + expect(parseHookPayload({ stdin, mapping })).toEqual({ ok: true, value: { discriminators: {} } }); + }); + + it('refuses a payload that is not valid JSON', () => { + expect(parseHookPayload({ stdin: '{not json', mapping })).toMatchObject({ + ok: false, + message: expect.stringMatching(/not valid JSON/), + }); + }); + + it('refuses a payload that is valid JSON but not an object', () => { + expect(parseHookPayload({ stdin: '["a"]', mapping })).toMatchObject({ + ok: false, + message: expect.stringMatching(/must be a JSON object/), + }); + }); + + it('refuses an empty payload', () => { + expect(parseHookPayload({ stdin: '', mapping })).toMatchObject({ ok: false }); + }); +}); + +describe(runRelay, () => { + let home: string; + let stderr: ReturnType; + + beforeEach(async () => { + home = await mkdtemp(path.join(tmpdir(), 'relay-hook-home-')); + stderr = spyOnStderr(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(home, { recursive: true, force: true }); + }); + + // The mapping table is the relay's entire contract with each harness, so every row is exercised rather than sampled. + it.each([ + { harness: 'claude', hook: 'SessionStart', type: 'session.started' }, + { harness: 'claude', hook: 'SessionEnd', type: 'session.ended' }, + { harness: 'claude', hook: 'UserPromptSubmit', type: 'turn.started' }, + { harness: 'claude', hook: 'Stop', type: 'turn.completed' }, + { harness: 'rovodev', hook: 'on_session_start', type: 'session.started' }, + { harness: 'rovodev', hook: 'on_session_end', type: 'session.ended' }, + { harness: 'rovodev', hook: 'on_user_prompt', type: 'turn.started' }, + { harness: 'rovodev', hook: 'on_complete', type: 'turn.completed' }, + ])('relays the $harness $hook hook as $type', async ({ harness, hook, type }) => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', harness, '--hook', hook, '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + const [envelope] = await readEvents(result); + expect(envelope).toMatchObject({ type, harness }); + }); + + it('appends a full envelope attributed to the session and repo the payload names', async () => { + const cwd = await makeRepo({ branch: 'MAC-42/feat/thing', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'SessionStart', '--home', home], + stdin: JSON.stringify({ session_id: 'hook-session', cwd, source: 'startup' }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + expect(result).toEqual({ + ok: true, + id: expect.stringMatching(ULID_PATTERN), + path: eventPath(home, 'williamthorsen', 'codeassembly', 'MAC-42-feat-thing', 'hook-session'), + }); + expect(await readEvents(result)).toEqual([ + { + id: expect.stringMatching(ULID_PATTERN), + ts: '2026-07-16T09:30:00.000Z', + type: 'session.started', + repo: 'williamthorsen/codeassembly', + branch: 'MAC-42/feat/thing', + session: 'hook-session', + cwd, + harness: 'claude', + payload: { source: 'startup' }, + }, + ]); + }); + + it('carries a session-end discriminator through into the event payload', async () => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'SessionEnd', '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd, reason: 'prompt_input_exit' }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + const [envelope] = await readEvents(result); + expect(envelope?.payload).toEqual({ reason: 'prompt_input_exit' }); + }); + + it('carries Rovo’s nested attributes through on a session end', async () => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'rovodev', '--hook', 'on_session_end', '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd, attributes: { reason: 'switch' } }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + const [envelope] = await readEvents(result); + expect(envelope?.payload).toEqual({ attributes: { reason: 'switch' } }); + }); + + it('gives a turn boundary an empty payload rather than the prompt text', async () => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'UserPromptSubmit', '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd, prompt: 'do the thing' }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + const [envelope] = await readEvents(result); + expect(envelope?.payload).toEqual({}); + }); + + it('attributes the event to the payload’s cwd, not the directory the hook was spawned in', async () => { + const session = await makeRepo({ branch: 'session-branch', remote: REMOTE_URL }); + const spawned = await makeRepo({ branch: 'spawn-branch', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'Stop', '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd: session }), + cwd: spawned, + env: {}, + now: NOW, + }); + + expect(result).toMatchObject({ + path: eventPath(home, 'williamthorsen', 'codeassembly', 'session-branch', 's1'), + }); + const [envelope] = await readEvents(result); + expect(envelope?.cwd).toBe(session); + + await rm(spawned, { recursive: true, force: true }); + }); + + it('falls back to its own working directory when the payload names none', async () => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'Stop', '--home', home], + stdin: JSON.stringify({ session_id: 's1' }), + cwd, + env: {}, + now: NOW, + }); + + const [envelope] = await readEvents(result); + expect(envelope).toMatchObject({ cwd, branch: 'main' }); + }); + + it('falls back to the environment session when the payload carries none', async () => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'Stop', '--home', home], + stdin: JSON.stringify({ cwd }), + cwd: '/nowhere', + env: ENV_WITH_SESSION, + now: NOW, + }); + + expect(result).toMatchObject({ path: eventPath(home, 'williamthorsen', 'codeassembly', 'main', 'env-session') }); + }); + + it('prefers the payload’s session over the environment’s', async () => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'Stop', '--home', home], + stdin: JSON.stringify({ session_id: 'payload-session', cwd }), + cwd: '/nowhere', + env: ENV_WITH_SESSION, + now: NOW, + }); + + const [envelope] = await readEvents(result); + expect(envelope?.session).toBe('payload-session'); + }); + + it('still relays the event when the session runs outside a git repository', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'relay-hook-bare-')); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'SessionStart', '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + expect(result).toMatchObject({ ok: true, path: eventPath(home, '_no-repo', '_no-repo', '_no-branch', 's1') }); + + await rm(cwd, { recursive: true, force: true }); + }); + + it('relays nothing for a hook the mapping does not know', async () => { + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'PreToolUse', '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd: '/repos/thing' }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + expect(result).toMatchObject({ ok: false, error: 'unknown-hook' }); + await expect(listEventsRoot(home)).resolves.toEqual([]); + expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/maps to no event type/)); + }); + + it('relays nothing for a hook belonging to the other harness', async () => { + const result = await runRelay({ + argv: ['--harness', 'rovodev', '--hook', 'SessionStart', '--home', home], + stdin: JSON.stringify({ session_id: 's1', cwd: '/repos/thing' }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + expect(result).toMatchObject({ ok: false, error: 'unknown-hook' }); + await expect(listEventsRoot(home)).resolves.toEqual([]); + }); + + it('reports invalid args without writing anything', async () => { + const result = await runRelay({ + argv: ['--hook', 'Stop', '--home', home], + stdin: '{}', + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid-args' }); + await expect(listEventsRoot(home)).resolves.toEqual([]); + }); + + it('reports a malformed payload without writing anything', async () => { + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'Stop', '--home', home], + stdin: '{not json', + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid-payload' }); + await expect(listEventsRoot(home)).resolves.toEqual([]); + expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/not valid JSON/)); + }); + + it('reports a failed write rather than throwing', async () => { + const cwd = await makeRepo({ branch: 'main', remote: REMOTE_URL }); + // A regular file where the events root needs a directory: the recursive `mkdir` cannot succeed, which is the + // cheapest reproduction of an unwritable events root. + const blockedHome = path.join(home, 'blocked'); + await writeFile(blockedHome, 'not a directory', 'utf8'); + + const result = await runRelay({ + argv: ['--harness', 'claude', '--hook', 'Stop', '--home', blockedHome], + stdin: JSON.stringify({ session_id: 's1', cwd }), + cwd: '/nowhere', + env: {}, + now: NOW, + }); + + expect(result).toMatchObject({ ok: false, error: 'write-failed' }); + expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/could not append the event/)); + }); +}); + +// region | Helpers + +/** Builds the path the relay should write to, under the test's isolated `home`. */ +function eventPath(home: string, owner: string, name: string, branch: string, session: string): string { + return path.join(home, '.codeassembly', 'events', owner, name, branch, `${session}.jsonl`); +} + +/** Lists the events root under `home`, treating an absent root as empty — the state a declined relay leaves it in. */ +async function listEventsRoot(home: string): Promise { + try { + return await readdir(path.join(home, '.codeassembly')); + } catch { + return []; + } +} + +/** Stands up a throwaway git repo on `branch`, optionally with an `origin` remote. */ +async function makeRepo(input: { branch: string; remote?: string }): Promise { + const repo = await mkdtemp(path.join(tmpdir(), 'relay-hook-repo-')); + await execFileAsync('git', ['-C', repo, 'init', '--quiet', `--initial-branch=${input.branch}`]); + if (input.remote !== undefined) { + await execFileAsync('git', ['-C', repo, 'remote', 'add', 'origin', input.remote]); + } + return repo; +} + +/** + * Reads back every envelope appended to the log the result names, as raw records. Throws when the relay did not + * succeed. The envelopes stay `unknown`-valued rather than typed as `EventEnvelope`: these assertions exist to prove + * what actually reached the file, so re-imposing the producer's type on the bytes it wrote would beg the question. + */ +async function readEvents(result: RelayResult): Promise[]> { + if (!result.ok) { + throw new Error(`expected a successful relay, got ${JSON.stringify(result)}`); + } + const content = await readFile(result.path, 'utf8'); + return content + .split('\n') + .filter((line) => line.length > 0) + .map((line) => { + const envelope: unknown = JSON.parse(line); + if (!isRecord(envelope)) { + throw new Error(`expected a JSON object per line, got: ${line}`); + } + return envelope; + }); +} + +/** Silences the relay's stderr diagnostics and captures them for assertion. */ +function spyOnStderr() { + return vi.spyOn(process.stderr, 'write').mockReturnValue(true); +} + +// endregion | Helpers diff --git a/packages/agents/src/relay-hook-event/cli.ts b/packages/agents/src/relay-hook-event/cli.ts new file mode 100644 index 00000000..65d720eb --- /dev/null +++ b/packages/agents/src/relay-hook-event/cli.ts @@ -0,0 +1,300 @@ +/** + * CLI entry for the harness hook relay. + * + * A harness's event hooks fire at boundaries no skill is running to observe — a session ends, a turn completes — so the + * hook, not the agent, is what reports them. Configured as a hook command, this relay reads the hook's JSON payload on + * stdin, maps `{harness, hook}` to a lifecycle event type, and appends the event attributed to the session and working + * directory the payload names. + * + * The hook's identity comes from the flags, never from stdin: the two harnesses' payload shapes differ, so stdin + * supplies only data and the flags — baked in when the hook entry is configured — supply the mapping key. + * + * Never blocks the session it observes: every failure — bad flags, an unusable payload, an unknown hook, a failed + * write, an unexpected throw — prints a structured `{ ok: false, error, message }` to stdout, warns on stderr, and + * exits 0. A success prints `{ ok: true, id, path }`. There is no non-zero exit path, because Claude Code reads some + * non-zero hook exits as control signals rather than as failures. + * + * Flags: + * --harness The harness whose hook fired (`claude`, `rovodev`). Required. + * --hook The harness's own name for the hook, e.g. `SessionStart`. Required. + * --home Events-root override, so a test can point the write at a fixture directory. + */ +import { realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import process from 'node:process'; +import { text } from 'node:stream/consumers'; +import { fileURLToPath } from 'node:url'; + +import { ulid } from 'ulid'; + +import { composeEnvelope } from '../emit-event/compose-envelope.ts'; +import { resolveEventPath } from '../emit-event/resolve-event-path.ts'; +import { appendEvent } from '../emit-event/write-event.ts'; +import { type FlagSpec, scanFlags, valueFlagMap } from '../lib/parse-flags.ts'; +import { isRecord } from '../lib/type-guards.ts'; +import { resolveCurrentBranch } from '../shared/branch-helpers.ts'; +import { resolveRepo } from '../shared/resolve-repo.ts'; +import { resolveSession } from '../shared/resolve-session.ts'; +import { isRelayHarness, listRelayHarnesses, resolveHookMapping } from './hook-mappings.ts'; +import type { HookMapping, HookPayload, ParsedArgs, RelayErrorCode, RelayFailure, RelayResult } from './types.ts'; + +/** The flags this relay accepts. Every one takes a value; the hook's data arrives on stdin, never as a flag. */ +const FLAGS: readonly FlagSpec[] = [ + { name: 'harness', takesValue: true }, + { name: 'hook', takesValue: true }, + { name: 'home', takesValue: true }, +]; + +/** The payload key each harness reports the session's id under. */ +const SESSION_KEY = 'session_id'; + +/** The payload key each harness reports the session's working directory under. */ +const CWD_KEY = 'cwd'; + +/** Executes the relay from `process.argv` and stdin, writing the JSON result to stdout. Always exits 0. */ +async function main(): Promise { + let result: RelayResult; + try { + result = await runRelay({ + argv: process.argv.slice(2), + stdin: await readStdin(), + cwd: process.cwd(), + env: process.env, + now: new Date(), + }); + } catch (error) { + // The never-block backstop. `runRelay` converts every failure it anticipates into a structured result, so reaching + // here means something unforeseen threw — which still must not disturb the session being observed. + result = failure('internal-error', error instanceof Error ? error.message : String(error)); + } + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (isEntryPoint()) { + await main(); +} + +/** + * Runs the relay end to end: parses the flags, looks the hook up in its harness's mapping, reads the payload, resolves + * the event's context against the working directory the payload names, and appends the event. + * + * Every failure is recoverable by contract, and each returns `{ ok: false, ... }` having written nothing. + * + * @internal - Exported to allow testing. + */ +export async function runRelay(input: { + argv: readonly string[]; + /** The hook's raw JSON payload, as read from stdin. */ + stdin: string; + /** The relay's own working directory; the fallback when the payload names none. */ + cwd: string; + env: NodeJS.ProcessEnv; + now: Date; +}): Promise { + let args: ParsedArgs; + try { + args = parseArgs(input.argv); + } catch (error) { + return failure('invalid-args', error instanceof Error ? error.message : String(error)); + } + + const mapping = resolveHookMapping({ harness: args.harness, hook: args.hook }); + if (mapping === undefined) { + return failure('unknown-hook', `${args.harness} hook "${args.hook}" maps to no event type; relaying nothing`); + } + + const payload = parseHookPayload({ stdin: input.stdin, mapping }); + if (!payload.ok) { + return failure('invalid-payload', payload.message); + } + + // The payload's `cwd` is the session's directory; the relay's own is wherever the harness happened to spawn the hook, + // so it stands in only when the payload names none. Attribution resolves against whichever wins. + const cwd = payload.value.cwd ?? input.cwd; + const [repo, branch] = await Promise.all([resolveRepo(cwd), resolveBranch(cwd)]); + const session = payload.value.session ?? resolveSession(input.env); + + const envelope = composeEnvelope({ + id: ulid(), + now: input.now, + type: mapping.type, + context: { + ...(repo !== undefined && { repo }), + ...(branch !== undefined && { branch }), + ...(session !== undefined && { session }), + cwd, + harness: args.harness, + }, + payload: payload.value.discriminators, + }); + const filePath = resolveEventPath({ + home: args.home ?? homedir(), + ...(repo !== undefined && { repo }), + ...(branch !== undefined && { branch }), + ...(session !== undefined && { session }), + }); + + try { + await appendEvent({ filePath, envelope }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return failure('write-failed', `could not append the event to ${filePath}: ${message}`); + } + + return { ok: true, id: envelope.id, path: filePath }; +} + +/** + * Parses the relay's argv. Each flag accepts both `--flag value` and `--flag=value`. An unknown flag, an unexpected + * positional, an empty value, a missing or unserved `--harness`, or a missing `--hook` throws; the caller turns that + * into an `invalid-args` result. + * + * @internal - Exported to allow testing. + */ +export function parseArgs(argv: readonly string[]): ParsedArgs { + const { positionals, flags } = scanFlags(argv, FLAGS); + if (positionals[0] !== undefined) { + throw new Error(`unexpected argument: ${positionals[0]}`); + } + const raw = valueFlagMap(flags); + for (const [name, value] of Object.entries(raw)) { + if (value === '') { + throw new Error(`--${name} requires a value`); + } + } + + const harness = raw.harness; + if (harness === undefined) { + throw new Error('--harness is required'); + } + if (!isRelayHarness(harness)) { + throw new Error(`--harness must be one of ${listRelayHarnesses().join(', ')}, got: ${harness}`); + } + + const hook = raw.hook; + if (hook === undefined) { + throw new Error('--hook is required'); + } + + return { harness, hook, home: raw.home ?? null }; +} + +/** + * Reads the fields the relay needs out of the hook's raw stdin payload: the session, the working directory, and the + * mapping's discriminator keys. The payload must be a JSON object; anything else fails the relay rather than being + * coerced, because a hook that sent something else is not a hook this relay understands. + * + * Within a well-formed object every field is optional. The harnesses agree on `session_id` and `cwd` today, but a + * harness that stops supplying one should cost that event its attribution — which the envelope already models as an + * omitted key — rather than cost the session its event. A field present but not a string is treated as absent for the + * same reason. + * + * @internal - Exported to allow testing. + */ +export function parseHookPayload(input: { + stdin: string; + mapping: HookMapping; +}): { ok: true; value: HookPayload } | { ok: false; message: string } { + let parsed: unknown; + try { + parsed = JSON.parse(input.stdin); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, message: `the hook payload is not valid JSON: ${message}` }; + } + + if (!isRecord(parsed)) { + return { ok: false, message: `the hook payload must be a JSON object, got: ${input.stdin.trim()}` }; + } + + const discriminators: Record = {}; + for (const key of input.mapping.discriminators) { + if (parsed[key] !== undefined) { + discriminators[key] = parsed[key]; + } + } + + const session = readString(parsed, SESSION_KEY); + const cwd = readString(parsed, CWD_KEY); + return { + ok: true, + value: { + ...(session !== undefined && { session }), + ...(cwd !== undefined && { cwd }), + discriminators, + }, + }; +} + +// region | Helpers + +/** Builds a failure result, warning on stderr so the failure is visible to an operator and not only on stdout. */ +function failure(error: RelayErrorCode, message: string): RelayFailure { + warn(message); + return { ok: false, error, message }; +} + +/** + * Returns true when this module is the process entry point. Both sides are resolved through `realpathSync`, so a + * symlinked invocation path still matches. On a `realpathSync` failure the function emits a warning and returns + * `false`, matching the degrade-with-warning pattern the emit-event helper uses. + */ +function isEntryPoint(): boolean { + const entry = process.argv[1]; + if (entry === undefined) { + return false; + } + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); + } catch (error) { + warn(`could not determine entry point: ${error instanceof Error ? error.message : String(error)}`); + return false; + } +} + +/** + * Reads the hook's payload from stdin to EOF. + * + * A terminal short-circuits to the empty string: with no harness on the other end there is no payload coming, and + * reading would block until the operator typed EOF. Under a hook, stdin is a pipe the harness closes. + */ +async function readStdin(): Promise { + if (process.stdin.isTTY) { + return ''; + } + return await text(process.stdin); +} + +/** Reads `key` from a payload as a non-empty string, or `undefined` when it is absent, empty, or another type. */ +function readString(payload: Record, key: string): string | undefined { + const value = payload[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * The checked-out branch at `cwd`, or `undefined` when there is none to read: git cannot answer (no repository, no git + * binary, a working directory that no longer exists) or HEAD is detached, which git reports as an empty branch name. + * Both warn, because a relay that silently files every event under the no-branch placeholder is indistinguishable from + * one that is working. + */ +async function resolveBranch(cwd: string): Promise { + let branch: string; + try { + branch = await resolveCurrentBranch(cwd); + } catch (error) { + warn(`${error instanceof Error ? error.message : String(error)}; omitting the branch`); + return undefined; + } + if (branch === '') { + warn('HEAD is detached; omitting the branch'); + return undefined; + } + return branch; +} + +/** Writes one diagnostic line to stderr. Stdout carries the machine-readable result, so it stays clean. */ +function warn(message: string): void { + process.stderr.write(`relay-hook-event: warning: ${message}\n`); +} + +// endregion | Helpers diff --git a/packages/agents/src/relay-hook-event/hook-mappings.ts b/packages/agents/src/relay-hook-event/hook-mappings.ts new file mode 100644 index 00000000..a57e9e68 --- /dev/null +++ b/packages/agents/src/relay-hook-event/hook-mappings.ts @@ -0,0 +1,49 @@ +/** The static table binding each harness's event hooks to the lifecycle events they relay as. */ +import type { HarnessId } from '../lib/types.ts'; +import type { HookMapping } from './types.ts'; + +/** + * Every hook the relay serves, keyed by harness and then by the harness's own name for the hook. + * + * Only the four session and turn boundaries are relayed. The tool-level hooks (`PreToolUse`/`PostToolUse`, + * `on_tool_start`/`on_tool_end`) would flood the log with detail no watching surface renders, and Claude's + * `Notification` overlaps the waiting signal `Stop` already carries. + * + * The prompt text itself is deliberately not relayed, on either harness: a turn boundary is a status signal, and the + * event log is read by surfaces that show what a session is doing — not what was said to it. + */ +const HOOK_MAPPINGS: Readonly>>> = { + claude: { + SessionStart: { type: 'session.started', discriminators: ['source'] }, + SessionEnd: { type: 'session.ended', discriminators: ['reason'] }, + UserPromptSubmit: { type: 'turn.started', discriminators: [] }, + Stop: { type: 'turn.completed', discriminators: [] }, + }, + rovodev: { + // Rovo carries its per-event detail in an `attributes` object rather than at the payload's top level, and + // `on_session_end` covers exit, session switch, and fork alike — so what distinguishes them is inside `attributes`. + on_session_start: { type: 'session.started', discriminators: ['attributes'] }, + on_session_end: { type: 'session.ended', discriminators: ['attributes'] }, + on_user_prompt: { type: 'turn.started', discriminators: [] }, + on_complete: { type: 'turn.completed', discriminators: [] }, + }, +}; + +/** Names every harness the relay serves, for a diagnostic that has to list them. */ +export function listRelayHarnesses(): readonly string[] { + return Object.keys(HOOK_MAPPINGS); +} + +/** + * The mapping for `hook` under `harness`, or `undefined` when the table does not know the name. An unknown name is an + * ordinary outcome rather than an error: a harness config is a durable user-curated file that can name a hook this + * version's table has not caught up with, or has stopped serving. + */ +export function resolveHookMapping(input: { harness: HarnessId; hook: string }): HookMapping | undefined { + return HOOK_MAPPINGS[input.harness][input.hook]; +} + +/** True when `value` names a harness the relay serves; narrows a raw `--harness` value to a `HarnessId`. */ +export function isRelayHarness(value: string): value is HarnessId { + return Object.hasOwn(HOOK_MAPPINGS, value); +} diff --git a/packages/agents/src/relay-hook-event/types.ts b/packages/agents/src/relay-hook-event/types.ts new file mode 100644 index 00000000..f2045dae --- /dev/null +++ b/packages/agents/src/relay-hook-event/types.ts @@ -0,0 +1,75 @@ +// Shapes for the hook relay: what a relayed hook maps to, the fields the relay reads out of a hook payload, the parsed +// CLI input, and the JSON result it prints. +// +// The relay observes a session it must never disturb, so — like the emit-event helper whose internals it composes — +// every failure it can reach surfaces as a `{ ok: false, error, message }` payload on stdout with a stderr warning and +// a zero exit. Here the contract is stricter than courtesy: Claude Code reads some non-zero hook exits as control +// signals (a `Stop` hook exiting 2 blocks the agent from stopping), so a relay that exited non-zero on a bad payload +// would not merely lose an event, it would wedge the session. + +import type { EventType } from '../emit-event/types.ts'; +import type { HarnessId } from '../lib/types.ts'; + +/** + * One relayed hook: the event type it becomes, and the payload keys carried through into the event body. + * + * Discriminators are copied verbatim rather than normalized across harnesses. Each harness describes a start or an end + * in its own shape — Claude at the payload's top level, Rovo nested under `attributes` — and only the harness knows + * what its keys mean. Preserving both shapes keeps the relay a courier; flattening them into a common vocabulary would + * mean inventing a mapping neither harness publishes. + */ +export interface HookMapping { + /** The event type the hook relays as. */ + type: EventType; + /** Payload keys copied verbatim into the event body when the hook supplies them. */ + discriminators: readonly string[]; +} + +/** The fields the relay reads out of a hook's stdin payload. Each is absent when the harness did not supply it. */ +export interface HookPayload { + /** The harness's id for the session the hook fired in. */ + session?: string; + /** The directory the session runs in; what repo and branch attribution resolve against. */ + cwd?: string; + /** The mapping's discriminator keys that were present, copied verbatim. `{}` when the hook carries none. */ + discriminators: Record; +} + +/** Parsed command-line invocation of the relay. The hook's identity arrives here, never on stdin. */ +export interface ParsedArgs { + /** The harness whose hook fired, injected when the hook entry is configured. */ + harness: HarnessId; + /** The harness's own name for the hook, injected alongside `harness`. */ + hook: string; + /** Events-root override, so a test can point the write at a fixture instead of the real home directory. */ + home: string | null; +} + +/** The relay's stdout payload on a successful append. */ +export interface RelaySuccess { + ok: true; + /** The generated ULID, matching the appended envelope's `id`. */ + id: string; + /** Absolute path of the JSONL file the envelope was appended to. */ + path: string; +} + +/** The relay's stdout payload when no event was relayed. Nothing was written. */ +export interface RelayFailure { + ok: false; + /** Categorical error code. */ + error: RelayErrorCode; + /** Short human-readable explanation, also written to stderr. */ + message: string; +} + +/** + * Categorical error codes the relay can return. Each exits 0. + * + * `unknown-hook` is the one that is not a defect: a config can name a hook this relay's table does not know yet — the + * config outlives any single version of the mapping — so an unrecognized name declines to emit rather than guessing. + */ +export type RelayErrorCode = 'invalid-args' | 'invalid-payload' | 'unknown-hook' | 'write-failed' | 'internal-error'; + +/** The relay's full stdout payload: a discriminated union on `ok`. */ +export type RelayResult = RelaySuccess | RelayFailure; From eaf8884cf0dd106696d4aba62f8adbf889bb90b3 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 16 Jul 2026 18:58:43 -0700 Subject: [PATCH 3/9] root|tooling: Fold session and turn events into the fleet-view lane view The lane view shows a session from the moment it opens rather than from its first instrumented skill, marks it ended when it exits, and reads a finished turn as waiting on the user. An ended session is dimmed with its status struck through, so a lane of finished work no longer reads as a lane of idle sessions. Ended and waiting-on-user are derived from the latest event rather than tracked, which is what makes a resumed session recover on its own: a harness that ends a session on a switch appends a fresh start when the user switches back, and the session simply reads as live again. A turn boundary also clears the running-skill label, so a turn whose skill never reported finishing cannot leak that label into later turns. --- spikes/fleet-view/index.html | 7 +++++++ spikes/fleet-view/serve.mjs | 29 +++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/spikes/fleet-view/index.html b/spikes/fleet-view/index.html index d793f03c..9d01b8aa 100644 --- a/spikes/fleet-view/index.html +++ b/spikes/fleet-view/index.html @@ -73,6 +73,12 @@ .session.stale { opacity: 0.5; } + .session.ended { + opacity: 0.45; + } + .session.ended .label { + text-decoration: line-through; + } .session.awaiting { border-left: 3px solid var(--awaiting); background: rgba(224, 175, 104, 0.07); @@ -177,6 +183,7 @@

${esc(lane.repo)} ${esc(lane.branch)}

const status = session.status ?? {}; const classes = ['session']; if (status.awaitingInput) classes.push('awaiting'); + if (status.ended) classes.push('ended'); if (status.stale) classes.push('stale'); const harness = session.harness || 'unknown'; const tail = (session.tail ?? []).map(formatEvent).join('\n'); diff --git a/spikes/fleet-view/serve.mjs b/spikes/fleet-view/serve.mjs index c4b89759..a03ee527 100644 --- a/spikes/fleet-view/serve.mjs +++ b/spikes/fleet-view/serve.mjs @@ -80,6 +80,9 @@ function start() { } // Fold one parsed event into a session, keeping only the last CONFIG.tail events. +// A turn boundary clears the running skill as well as skill.completed does: a turn +// that ends with its skill.completed missing would otherwise leak a stale "running +// {skill}" label into every later turn. function applyEvent(session, event) { session.events.push(event); if (session.events.length > CONFIG.tail) { @@ -90,7 +93,7 @@ function applyEvent(session, event) { } if (event.type === 'skill.started') { session.currentSkill = event.payload?.skill ?? null; - } else if (event.type === 'skill.completed') { + } else if (event.type === 'skill.completed' || event.type === 'turn.started' || event.type === 'turn.completed') { session.currentSkill = null; } } @@ -124,6 +127,7 @@ function buildSnapshot(nowMs) { phase: status.phase, label: status.label, awaitingInput: status.awaitingInput, + ended: status.ended, stale: status.stale, }, lastEventTs: status.lastEventTs, @@ -139,19 +143,20 @@ function buildSnapshot(nowMs) { // Compute a session's status as a pure function of its events and the clock. // Stale is an overlay that applies only to active phases (running, wrote artifact); -// waiting and resting phases are expected to be quiet and never read as stale. +// waiting, resting, and ended phases are expected to be quiet and never read as stale. function deriveStatus(session, nowMs) { const lastEventTs = session.lastEventTs ?? null; const last = session.events[session.events.length - 1]; if (last === undefined) { - return { phase: 'idle', label: 'idle', awaitingInput: false, stale: false, lastEventTs }; + return { phase: 'idle', label: 'idle', awaitingInput: false, ended: false, stale: false, lastEventTs }; } const { phase, label } = resolvePhase(last, session.currentSkill); const awaitingInput = phase === 'awaiting input'; + const ended = phase === 'ended'; const isActive = phase === 'running' || phase === 'wrote artifact'; const lastMs = toEpochMs(lastEventTs); const stale = isActive && lastMs > 0 && nowMs - lastMs > CONFIG.staleMs; - return { phase, label, awaitingInput, stale, lastEventTs }; + return { phase, label, awaitingInput, ended, stale, lastEventTs }; } // Read the newly appended bytes of one session file and fold complete lines. @@ -290,8 +295,24 @@ function resolveHarness(session) { } // Map the last event to a phase key and a display label. +// +// Ended and waiting-on-user are both derived here rather than tracked: a session whose +// latest event is session.ended has ended, and one whose latest event is turn.completed +// has handed the conversation back to the user. Deriving keeps resume working for free — +// Rovo ends a session on a switch, and switching back appends a session.started that +// becomes the new latest event. function resolvePhase(event, currentSkill) { switch (event.type) { + case 'session.started': + // Open, but nothing asked of it yet. Not awaiting input: no turn has completed, so + // there is no answer for the user to read. + return { phase: 'idle', label: 'session started' }; + case 'session.ended': + return { phase: 'ended', label: 'ended' }; + case 'turn.started': + return { phase: 'running', label: 'running' }; + case 'turn.completed': + return { phase: 'awaiting input', label: 'awaiting input' }; case 'input.requested': return { phase: 'awaiting input', label: 'awaiting input' }; case 'input.received': From 557236698e70445eb9fb01dbae5e8880de61d0ff Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 16 Jul 2026 19:03:45 -0700 Subject: [PATCH 4/9] agents|docs: Document hook configuration for both harnesses Readers wiring up session-lifecycle events get copy-pasteable hook entries for Claude Code's `settings.json` and Rovo Dev's `config.yml`, covering all four session and turn boundaries on each harness, plus the mapping from each harness's hook names to the events they relay as. The Claude entries omit the matcher, which is what relays every start source and end reason rather than a selected one, and keep the whole invocation in `command`, the only form where `~` expands. The Rovo entries spell out an absolute path for the same reason in reverse. Two Rovo behaviors readers would otherwise hit as surprises are called out: hooks are read at startup, so a running session ignores newly added ones; and `on_complete` reports a successful run, so a turn that errors or is aborted may leave its session reading as still working. --- packages/agents/README.md | 98 +++++++++++++++++++ .../relay-hook-event/__tests__/cli.test.ts | 6 +- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/agents/README.md b/packages/agents/README.md index 773b1850..5b85555a 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -18,6 +18,104 @@ Run via the `codeassembly-agents` CLI: `codeassembly-agents [options]` Global options: `--harness ` (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. Wiring it to the hooks is a separate step: the harness configs below are yours, and nothing writes to them on your behalf. Add the entries for the harnesses you want. + +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 Claude Code reads a `Stop` hook's non-zero exit as a signal to keep the agent from stopping. + +### 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" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook SessionEnd" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook UserPromptSubmit" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook Stop" + } + ] + } + ] + } +} +``` + +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 + - name: on_session_end + commands: + - command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_session_end + - name: on_user_prompt + commands: + - command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_user_prompt + - name: on_complete + commands: + - command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_complete +``` + +Write your home directory out in full, as above: Rovo's own generated entries use absolute paths, and `~` is not known to expand here. + +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). diff --git a/packages/agents/src/relay-hook-event/__tests__/cli.test.ts b/packages/agents/src/relay-hook-event/__tests__/cli.test.ts index 3034caaf..5e79a2fa 100644 --- a/packages/agents/src/relay-hook-event/__tests__/cli.test.ts +++ b/packages/agents/src/relay-hook-event/__tests__/cli.test.ts @@ -76,8 +76,8 @@ describe(parseHookPayload, () => { }); it('carries through the mapping’s discriminator keys and nothing else', () => { - // `prompt` is the shape of the field the relay must not carry: the turn boundary is the signal, not what was said. - const stdin = JSON.stringify({ session_id: 'abc', source: 'resume', prompt: 'secret', reason: 'clear' }); + // `user_input` is the field the relay must not carry: the turn boundary is the signal, not what was said. + const stdin = JSON.stringify({ session_id: 'abc', source: 'resume', user_input: 'secret', reason: 'clear' }); expect(parseHookPayload({ stdin, mapping })).toMatchObject({ ok: true, @@ -227,7 +227,7 @@ describe(runRelay, () => { const result = await runRelay({ argv: ['--harness', 'claude', '--hook', 'UserPromptSubmit', '--home', home], - stdin: JSON.stringify({ session_id: 's1', cwd, prompt: 'do the thing' }), + stdin: JSON.stringify({ session_id: 's1', cwd, user_input: 'do the thing' }), cwd: '/nowhere', env: {}, now: NOW, From 2bb354f1b5ba713e1964856c16fad4a4881e4b35 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 17 Jul 2026 19:04:42 -0700 Subject: [PATCH 5/9] agents|fix: Match the Rovo hook utility to the real config.yml schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed event-hook entries in `~/.rovodev/config.yml` are now written in the schema the Rovo Dev CLI actually accepts, so installed hooks run and later checks and removals find them again. Previously the utility modeled the events section as a map keyed by event name — a shape verified to make Rovo Dev treat the whole config as corrupt — and wrote bare command strings where the CLI expects command-keyed items; against a real config it could neither read nor write. A config whose events section carries the rejected map shape is now refused with a clear error instead of being silently misread, and a hand-edited managed entry reads as drifted rather than current. --- .../lib/__tests__/rovo-config-hooks.test.ts | 237 +++++++---- packages/agents/src/lib/rovo-config-hooks.ts | 385 ++++++++---------- 2 files changed, 327 insertions(+), 295 deletions(-) diff --git a/packages/agents/src/lib/__tests__/rovo-config-hooks.test.ts b/packages/agents/src/lib/__tests__/rovo-config-hooks.test.ts index 3afc8839..68fe3a65 100644 --- a/packages/agents/src/lib/__tests__/rovo-config-hooks.test.ts +++ b/packages/agents/src/lib/__tests__/rovo-config-hooks.test.ts @@ -4,8 +4,8 @@ import { type Document, isSeq, parseDocument } from 'yaml'; import { checkHookEntries, ensureHookEntries, + type HookEntry, type HookSentinelMatcher, - type OwnedHookEntry, removeHookEntries, RovoConfigParseError, } from '../rovo-config-hooks.ts'; @@ -13,9 +13,29 @@ import { /** A test sentinel: ownership is marked by a `--ca` token in any command. Encoding is the caller's choice. */ const isOwned: HookSentinelMatcher = (entry) => entry.commands.some((command) => command.includes('--ca')); -/** Builds an owned entry for `eventKey` carrying the sentinel token. */ -function buildOwnedEntry(eventKey: string, name: string): OwnedHookEntry { - return { eventKey, entry: { name, commands: [`run ${name} --ca`] } }; +/** The shape the vendor documents and real configs use: a list of `{name, commands: [{command}]}` items. */ +const VENDOR_SHAPED_CONFIG = [ + 'eventHooks:', + ' logFile: "~/.rovodev/event_hooks.log"', + ' events:', + ' - name: on_complete', + ' commands:', + " - command: echo 'Agent run finished'", + ' - name: on_session_end', + ' commands:', + " - command: echo 'Session ended'", + '', +].join('\n'); + +/** Builds an owned entry for the named hook event, carrying the sentinel token. */ +function buildOwnedEntry(name: string): HookEntry { + return { name, commands: [`run ${name} --ca`] }; +} + +/** The number of items in the events list, or -1 when it is missing or malformed. */ +function eventsLength(document: Document): number { + const array = document.getIn(['eventHooks', 'events']); + return isSeq(array) ? array.items.length : -1; } /** Parses YAML source into a document, preserving any parse errors for the parse-guard tests. */ @@ -23,59 +43,57 @@ function parseConfig(source: string): Document { return parseDocument(source); } -/** The number of items in the named event array, or -1 when it is missing or malformed. */ -function eventArrayLength(document: Document, eventKey: string): number { - const array = document.getIn(['eventHooks', 'events', eventKey]); - return isSeq(array) ? array.items.length : -1; -} - describe(ensureHookEntries, () => { - it('creates eventHooks, events, and the array when the document is empty', () => { + it('creates eventHooks and the events list when the document is empty', () => { const document = parseConfig(''); - const result = ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned); + const result = ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); expect(result.changed).toBe(true); expect(String(document)).toContain('eventHooks:'); - expect(checkHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned)[0]?.status).toBe('present'); + expect(checkHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned)[0]?.status).toBe('present'); + }); + + it('writes each command string wrapped as a {command} map', () => { + const document = parseConfig(''); + ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); + + expect(String(document)).toContain('- command: run on_session_start --ca'); }); it('is a no-op on an immediate re-run', () => { const document = parseConfig(''); - ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned); + ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); - expect(ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned).changed).toBe(false); + expect(ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned).changed).toBe(false); }); it('replaces a drifted owned entry in place rather than duplicating it', () => { const document = parseConfig(''); - ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned); + ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); - const drifted: OwnedHookEntry = { - eventKey: 'sessionStart', - entry: { name: 'a', commands: ['run a --ca --extra'] }, - }; + const drifted: HookEntry = { name: 'on_session_start', commands: ['run on_session_start --ca --extra'] }; const result = ensureHookEntries(document, [drifted], isOwned); expect(result.changed).toBe(true); - expect(eventArrayLength(document, 'sessionStart')).toBe(1); + expect(eventsLength(document)).toBe(1); expect(String(document)).toContain('--extra'); }); - it('installs two owned entries in the same event array, keeps a re-run a no-op, and drifts only one', () => { + it('installs entries for several events, keeps a re-run a no-op, and drifts only one', () => { const document = parseConfig(''); - const both = [buildOwnedEntry('sessionStart', 'a'), buildOwnedEntry('sessionStart', 'b')]; + const both = [buildOwnedEntry('on_session_start'), buildOwnedEntry('on_session_end')]; expect(ensureHookEntries(document, both, isOwned).changed).toBe(true); - expect(eventArrayLength(document, 'sessionStart')).toBe(2); + expect(eventsLength(document)).toBe(2); expect(ensureHookEntries(document, both, isOwned).changed).toBe(false); - const drifted: OwnedHookEntry[] = [ - buildOwnedEntry('sessionStart', 'a'), - { eventKey: 'sessionStart', entry: { name: 'b', commands: ['run b --ca --v2'] } }, + const drifted: HookEntry[] = [ + buildOwnedEntry('on_session_start'), + { name: 'on_session_end', commands: ['run on_session_end --ca --v2'] }, ]; expect(ensureHookEntries(document, drifted, isOwned).changed).toBe(true); - expect(eventArrayLength(document, 'sessionStart')).toBe(2); + expect(eventsLength(document)).toBe(2); const statuses = checkHookEntries(document, drifted, isOwned).map((check) => check.status); expect(statuses).toEqual(['present', 'present']); @@ -83,94 +101,146 @@ describe(ensureHookEntries, () => { it('throws when a supplied entry does not satisfy the sentinel matcher', () => { const document = parseConfig(''); - const unsentineled: OwnedHookEntry = { eventKey: 'sessionStart', entry: { name: 'a', commands: ['run a'] } }; + const unsentineled: HookEntry = { name: 'on_session_start', commands: ['run plain'] }; expect(() => ensureHookEntries(document, [unsentineled], isOwned)).toThrow(/sentinel/); expect(String(document)).not.toContain('eventHooks'); }); + it('adds owned entries to a vendor-shaped config without disturbing its entries or keys', () => { + const document = parseConfig(VENDOR_SHAPED_CONFIG); + + const result = ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); + const out = String(document); + + expect(result.changed).toBe(true); + expect(eventsLength(document)).toBe(3); + expect(out).toContain('logFile: "~/.rovodev/event_hooks.log"'); + expect(out).toContain("echo 'Agent run finished'"); + expect(out).toContain("echo 'Session ended'"); + expect(out).toContain('run on_session_start --ca'); + }); + it('leaves foreign entries, foreign comments, and unrelated keys untouched', () => { const source = [ '# top comment', 'otherKey: 42 # inline comment', 'eventHooks:', ' events:', - ' sessionStart:', - ' - name: foreign # foreign inline', - ' commands:', - ' - echo hi', + ' - name: on_session_start # foreign inline', + ' commands:', + ' - command: echo hi', '', ].join('\n'); const document = parseConfig(source); - ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'ca-hook')], isOwned); + ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); const out = String(document); expect(out).toContain('# top comment'); expect(out).toContain('42 # inline comment'); - expect(out).toContain('name: foreign # foreign inline'); + expect(out).toContain('on_session_start # foreign inline'); expect(out).toContain('echo hi'); - expect(out).toContain('ca-hook'); + expect(out).toContain('run on_session_start --ca'); }); - it('matches and mutates owned entries scattered across event arrays, interleaved with foreign entries', () => { + it('matches and mutates owned entries interleaved with foreign entries', () => { const source = [ 'eventHooks:', ' events:', - ' sessionStart:', - ' - name: foreign-a', - ' commands: [echo a]', - ' - name: ca-start', - ' commands: [run start --ca]', - ' sessionEnd:', - ' - name: ca-end', - ' commands: [run end --ca]', - ' - name: foreign-b', - ' commands: [echo b]', + ' - name: on_session_start', + ' commands:', + ' - command: echo a', + ' - name: on_session_start', + ' commands:', + ' - command: run start --ca', + ' - name: on_session_end', + ' commands:', + ' - command: run end --ca', + ' - name: on_session_end', + ' commands:', + ' - command: echo b', '', ].join('\n'); const document = parseConfig(source); - const drifted: OwnedHookEntry[] = [ - { eventKey: 'sessionStart', entry: { name: 'ca-start', commands: ['run start --ca --v2'] } }, - { eventKey: 'sessionEnd', entry: { name: 'ca-end', commands: ['run end --ca --v2'] } }, + const drifted: HookEntry[] = [ + { name: 'on_session_start', commands: ['run start --ca --v2'] }, + { name: 'on_session_end', commands: ['run end --ca --v2'] }, ]; const result = ensureHookEntries(document, drifted, isOwned); const out = String(document); expect(result.changed).toBe(true); + expect(eventsLength(document)).toBe(4); expect(out).toContain('run start --ca --v2'); expect(out).toContain('run end --ca --v2'); - expect(out).toContain('name: foreign-a'); - expect(out).toContain('name: foreign-b'); + expect(out).toContain('command: echo a'); + expect(out).toContain('command: echo b'); + }); + + it('throws on a map-shaped events value rather than treating it as empty', () => { + const source = [ + 'eventHooks:', + ' events:', + ' on_session_start:', + ' - name: a', + ' commands:', + ' - command: run a --ca', + '', + ].join('\n'); + const document = parseConfig(source); + + expect(() => ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned)).toThrow(/list/); }); }); describe(checkHookEntries, () => { it('reports present, drifted, and absent by name', () => { const document = parseConfig(''); - ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'present')], isOwned); + ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); const results = checkHookEntries( document, [ - buildOwnedEntry('sessionStart', 'present'), - { eventKey: 'sessionStart', entry: { name: 'present', commands: ['run present --ca --changed'] } }, - buildOwnedEntry('sessionEnd', 'missing'), + buildOwnedEntry('on_session_start'), + { name: 'on_session_start', commands: ['run on_session_start --ca --changed'] }, ], isOwned, ); expect(results[0]?.status).toBe('present'); expect(results[1]?.status).toBe('drifted'); - expect(results[2]?.status).toBe('absent'); }); - it('reports drifted when the event holds owned entries but none matches the supplied name', () => { + it('reports absent when the document holds no owned entries', () => { + const document = parseConfig(VENDOR_SHAPED_CONFIG); + + const result = checkHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); + expect(result[0]?.status).toBe('absent'); + }); + + it('reports drifted when owned entries exist but none matches the supplied name', () => { const document = parseConfig(''); - ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned); + ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); + + const result = checkHookEntries(document, [buildOwnedEntry('on_session_end')], isOwned); + expect(result[0]?.status).toBe('drifted'); + }); + + it('reports drifted when an owned entry carries a hand-added extra key', () => { + const source = [ + 'eventHooks:', + ' events:', + ' - name: on_session_start', + ' commands:', + ' - command: run on_session_start --ca', + ' timeout: 5', + '', + ].join('\n'); + const document = parseConfig(source); - const result = checkHookEntries(document, [buildOwnedEntry('sessionStart', 'other')], isOwned); + const result = checkHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); expect(result[0]?.status).toBe('drifted'); }); }); @@ -180,11 +250,12 @@ describe(removeHookEntries, () => { const source = [ 'eventHooks:', ' events:', - ' sessionStart:', - ' - name: foreign # keep me', - ' commands: [echo hi]', - ' - name: ca-hook', - ' commands: [run --ca]', + ' - name: on_session_start # keep me', + ' commands:', + ' - command: echo hi', + ' - name: on_session_start', + ' commands:', + ' - command: run --ca', '', ].join('\n'); const document = parseConfig(source); @@ -193,13 +264,13 @@ describe(removeHookEntries, () => { const out = String(document); expect(result).toEqual({ changed: true, removedCount: 1 }); - expect(out).toContain('name: foreign # keep me'); + expect(out).toContain('on_session_start # keep me'); expect(out).not.toContain('--ca'); }); it('prunes structure emptied by removal', () => { const document = parseConfig('otherKey: 1\n'); - ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'a'), buildOwnedEntry('sessionEnd', 'b')], isOwned); + ensureHookEntries(document, [buildOwnedEntry('on_session_start'), buildOwnedEntry('on_session_end')], isOwned); const result = removeHookEntries(document, isOwned); const out = String(document); @@ -209,32 +280,20 @@ describe(removeHookEntries, () => { expect(out).toContain('otherKey: 1'); }); - it('leaves partially-foreign structure intact', () => { - const source = [ - 'eventHooks:', - ' events:', - ' sessionStart:', - ' - name: ca-hook', - ' commands: [run --ca]', - ' sessionEnd:', - ' - name: foreign', - ' commands: [echo hi]', - '', - ].join('\n'); - const document = parseConfig(source); + it('keeps eventHooks when other keys remain after the events list empties', () => { + const document = parseConfig('eventHooks:\n logFile: "~/.rovodev/event_hooks.log"\n'); + ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned); - removeHookEntries(document, isOwned); + const result = removeHookEntries(document, isOwned); const out = String(document); - expect(out).toContain('sessionEnd:'); - expect(out).toContain('name: foreign'); - expect(out).not.toContain('sessionStart:'); + expect(result).toEqual({ changed: true, removedCount: 1 }); + expect(out).toContain('logFile:'); + expect(out).not.toContain('events:'); }); it('returns unchanged when no owned entries exist', () => { - const document = parseConfig( - 'eventHooks:\n events:\n sessionStart:\n - name: foreign\n commands: [echo hi]\n', - ); + const document = parseConfig(VENDOR_SHAPED_CONFIG); expect(removeHookEntries(document, isOwned)).toEqual({ changed: false, removedCount: 0 }); }); }); @@ -244,10 +303,10 @@ describe(RovoConfigParseError, () => { const document = parseConfig('eventHooks: [unterminated\n'); expect(document.errors.length).toBeGreaterThan(0); - expect(() => ensureHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned)).toThrow( + expect(() => ensureHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned)).toThrow( RovoConfigParseError, ); - expect(() => checkHookEntries(document, [buildOwnedEntry('sessionStart', 'a')], isOwned)).toThrow( + expect(() => checkHookEntries(document, [buildOwnedEntry('on_session_start')], isOwned)).toThrow( RovoConfigParseError, ); expect(() => removeHookEntries(document, isOwned)).toThrow(RovoConfigParseError); diff --git a/packages/agents/src/lib/rovo-config-hooks.ts b/packages/agents/src/lib/rovo-config-hooks.ts index 6b1a1bf4..4bdd0944 100644 --- a/packages/agents/src/lib/rovo-config-hooks.ts +++ b/packages/agents/src/lib/rovo-config-hooks.ts @@ -1,35 +1,35 @@ /** - * Managed event-hook entries within a Rovo Dev `config.yml`. CodeAssembly owns individual `eventHooks` items scattered - * across several event arrays, interleaved with foreign items written by other tools. Ownership is per-item, identified - * by a caller-supplied sentinel matcher rather than a contiguous region — no comment fence can delimit interleaved - * ownership. Every function operates on a parsed `yaml` `Document` and mutates it in place via the comment-preserving - * Document API, so foreign items, foreign comments, and unrelated keys survive untouched. File IO belongs to the caller. + * Managed event-hook entries within a Rovo Dev `config.yml`. CodeAssembly owns individual items of the + * `eventHooks.events` list, interleaved with foreign items written by other tools. Ownership is per-item, identified by + * a caller-supplied sentinel matcher — no comment fence can delimit interleaved ownership. Every function operates on a + * parsed `yaml` `Document` and mutates it in place via the comment-preserving Document API, so foreign items, foreign + * comments, and unrelated keys survive untouched. File IO belongs to the caller. * - * The module is agnostic about how the sentinel is encoded (a token in a command, a metadata field, etc.); the caller - * (the harness-wiring consumer) fixes the encoding and passes a matcher. It is also agnostic about how Rovo Dev groups - * items by event: each supplied entry names the `eventKey` array it belongs in. + * The schema is the one real configs use, verified against a live config and a hook experiment: `eventHooks.events` is + * a YAML list of `{name, commands}` items, where `name` is the hook event (several items may share one) and each + * `commands` item is a map carrying a `command` string. A map keyed by event name — the shape some documentation + * describes — makes Rovo Dev treat the whole config as corrupt, so this module refuses it rather than modeling it. * - * The ensure/check/remove shapes come from `managed-entry-contract.ts`, shared with the Claude sibling so #1005 wires - * both harnesses uniformly. + * The module is agnostic about how the sentinel is encoded (a token in a command string, etc.); the caller fixes the + * encoding and passes a matcher. The ensure/check/remove shapes come from `managed-entry-contract.ts`, shared with the + * Claude sibling so the harness wiring stays uniform. */ import { type Document, isMap, isSeq, YAMLMap, YAMLSeq } from 'yaml'; import type { EnsureResult, EntryCheck, ManagedEntryStatus, RemoveResult } from './managed-entry-contract.ts'; -/** A single `eventHooks` item, mirroring the Rovo Dev shape. The module never interprets command contents. */ +/** + * A single `eventHooks.events` item, mirroring the Rovo Dev shape: the hook event it fires on, and its command + * strings. In the file each command string is wrapped as a `{command}` map; this module owns that translation. The + * module never interprets command contents. + */ export interface HookEntry { + /** The hook event this entry fires on (e.g. `on_session_start`). Not unique: foreign items may share it. */ readonly name: string; readonly commands: readonly string[]; } -/** An owned entry together with the event array it belongs in. */ -export interface OwnedHookEntry { - /** The `eventHooks.events` key whose array holds this entry (e.g. a lifecycle event name). */ - readonly eventKey: string; - readonly entry: HookEntry; -} - /** Identifies CodeAssembly-owned entries wherever they sit. Encoding is the caller's concern. */ export type HookSentinelMatcher = (entry: HookEntry) => boolean; @@ -45,233 +45,182 @@ export class RovoConfigParseError extends Error { } /** - * Installs each owned entry into its event array, creating missing structure (`eventHooks`, `events`, the event array) - * as needed. Per event, the owned subset of the array is replaced wholesale by the supplied entries for that event, - * matched by `name`: a re-run is a no-op, a drifted owned entry is replaced in place, accidental duplicates collapse to - * the supplied set, and foreign entries keep their relative order. `changed` is false when nothing moved. - * - * Throws when a supplied entry does not itself satisfy the matcher: an entry written without the sentinel could never - * be found again by check or remove. + * Reports each supplied entry as `present` (an owned item with its name is equal to it), `drifted` (an owned item with + * its name differs, or owned items exist under other names only), or `absent` (no owned item anywhere). The report is + * scoped to the entries supplied; an all-present result does not imply ensure would leave the document unchanged, + * since ensure would still drop an owned item the caller did not supply. */ -export function ensureHookEntries( +export function checkHookEntries( doc: Document, - ownedHookEntries: readonly OwnedHookEntry[], + entries: readonly HookEntry[], isOwned: HookSentinelMatcher, -): EnsureResult { +): ReadonlyArray> { assertParsable(doc); - assertAllOwned(ownedHookEntries, isOwned); - let changed = false; - for (const [eventKey, entries] of groupByEvent(ownedHookEntries)) { - const array = ensureEventArray(doc, eventKey); - if (replaceOwnedItems(array, entries, isOwned)) { - changed = true; - } - } - - return { changed }; + const array = getEventsList(doc); + const owned = array ? readOwnedItems(array, isOwned) : []; + return entries.map((entry) => ({ entry, status: classify(owned, entry) })); } /** - * Reports each supplied entry as `present` (an owned entry under its event is equal to it), `drifted` (its event holds - * owned entries but none matches), or `absent` (its event holds no owned entry). The report is scoped to the entries - * supplied; an all-present result does not imply ensure would leave the document unchanged, since ensure would still - * drop an owned entry the caller did not supply. + * Installs `entries` into the `eventHooks.events` list, creating missing structure (`eventHooks`, `events`) as needed. + * The owned subset of the list is replaced wholesale by the supplied entries — spliced in at the first owned position, + * appended when the list holds none. That is what makes a re-run a no-op, replaces drifted entries in place, collapses + * accidental duplicates into the supplied set, and leaves foreign items in their original relative order. `changed` is + * false when nothing moved. + * + * Throws when a supplied entry does not itself satisfy the matcher: an entry written without the sentinel could never + * be found again by check or remove. */ -export function checkHookEntries( +export function ensureHookEntries( doc: Document, - ownedHookEntries: readonly OwnedHookEntry[], + entries: readonly HookEntry[], isOwned: HookSentinelMatcher, -): ReadonlyArray> { +): EnsureResult { assertParsable(doc); + assertAllOwned(entries, isOwned); - return ownedHookEntries.map((owned) => { - const array = getEventArray(doc, owned.eventKey); - const ownedEntries = array ? readOwnedEntries(array, isOwned) : []; - return { entry: owned, status: classify(ownedEntries, owned.entry) }; - }); + if (entries.length === 0 && getEventsList(doc) === undefined) { + return { changed: false }; + } + return { changed: replaceOwnedItems(ensureEventsList(doc), entries, isOwned) }; } /** - * Deletes every sentinel-matching entry across all event arrays, then prunes structure the deletion emptied: an emptied - * event array drops its key, an emptied `events` map drops it, and an emptied `eventHooks` drops that key. Structure - * still holding foreign entries is left intact. `removedCount` counts the owned entries deleted. + * Deletes every sentinel-matching item from the `eventHooks.events` list, then prunes structure the deletion emptied: + * an emptied `events` drops its key, and an `eventHooks` left holding nothing drops too. An `eventHooks` still holding + * other keys (`logFile`) survives. `removedCount` counts the owned items deleted. */ export function removeHookEntries(doc: Document, isOwned: HookSentinelMatcher): RemoveResult { assertParsable(doc); - const events = getEventsMap(doc); - if (!events) { + const array = getEventsList(doc); + if (!array) { return { changed: false, removedCount: 0 }; } - let removedCount = 0; - const emptiedKeys: unknown[] = []; - for (const pair of events.items) { - const array = pair.value; - if (!isSeq(array)) { - continue; - } - - const kept = array.items.filter((item) => !isOwnedItem(item, isOwned)); - const removed = array.items.length - kept.length; - if (removed === 0) { - continue; - } - - removedCount += removed; - if (kept.length === 0) { - emptiedKeys.push(pair.key); - } else { - array.items = kept; - } - } - + const kept = array.items.filter((item) => !isOwnedItem(item, isOwned)); + const removedCount = array.items.length - kept.length; if (removedCount === 0) { return { changed: false, removedCount: 0 }; } - for (const key of emptiedKeys) { - events.delete(key); + if (kept.length > 0) { + array.items = kept; + } else { + doc.deleteIn(['eventHooks', 'events']); + const eventHooks = doc.get('eventHooks', true); + if (isMap(eventHooks) && eventHooks.items.length === 0) { + doc.delete('eventHooks'); + } } - pruneEmptyContainers(doc, events); return { changed: true, removedCount }; } // region | Helpers -/** Throws {@link RovoConfigParseError} when the document carries parse errors, guarding every mutation and read. */ -function assertParsable(doc: Document): void { - if (doc.errors.length > 0) { - throw new RovoConfigParseError(doc.errors.map((error) => error.message)); - } -} - /** Throws when a supplied entry fails the matcher, since an unsentineled entry could never be found again. */ -function assertAllOwned(ownedHookEntries: readonly OwnedHookEntry[], isOwned: HookSentinelMatcher): void { - for (const { eventKey, entry } of ownedHookEntries) { +function assertAllOwned(entries: readonly HookEntry[], isOwned: HookSentinelMatcher): void { + for (const entry of entries) { if (!isOwned(entry)) { throw new Error( - `Refusing to write a hook entry '${entry.name}' for '${eventKey}' that the sentinel matcher does not claim; ` + + `Refusing to write a hook entry '${entry.name}' that the sentinel matcher does not claim; ` + 'an entry without the sentinel could not be found again.', ); } } } -/** Collects the entries for each event, preserving the supplied order within an event and across events. */ -function groupByEvent(ownedHookEntries: readonly OwnedHookEntry[]): Map { - const grouped = new Map(); - for (const { eventKey, entry } of ownedHookEntries) { - const existing = grouped.get(eventKey); - if (existing) { - existing.push(entry); - } else { - grouped.set(eventKey, [entry]); - } +/** Throws {@link RovoConfigParseError} when the document carries parse errors, guarding every mutation and read. */ +function assertParsable(doc: Document): void { + if (doc.errors.length > 0) { + throw new RovoConfigParseError(doc.errors.map((error) => error.message)); } - return grouped; } /** - * Replaces the owned subset of an event array with `entries`, matched by name: the supplied set is spliced in at the - * first owned position (appended when the array holds none), other owned items are dropped, and foreign items keep - * their order. Returns whether the array changed. + * Classifies a desired entry against the owned items present, matched by name: `present` needs an equal, pristine + * match; an owned item under its name that differs — or owned items under other names only — is `drifted`; no owned + * item anywhere is `absent`. */ -function replaceOwnedItems(array: YAMLSeq, entries: readonly HookEntry[], isOwned: HookSentinelMatcher): boolean { - const firstOwned = array.items.findIndex((item) => isOwnedItem(item, isOwned)); - const desired = entries.map(toYamlEntry); - - if (firstOwned === -1) { - if (desired.length === 0) { - return false; - } - array.items.push(...desired); - return true; - } - - const head = array.items.slice(0, firstOwned); - const tail = array.items.slice(firstOwned + 1).filter((item) => !isOwnedItem(item, isOwned)); - const before = array.items.slice(firstOwned).filter((item) => isOwnedItem(item, isOwned)); - - if (ownedItemsEqual(before, entries)) { - return false; +function classify(owned: readonly ReadItem[], desired: HookEntry): ManagedEntryStatus { + const match = owned.find((item) => item.entry.name === desired.name); + if (match === undefined) { + return owned.length > 0 ? 'drifted' : 'absent'; } - - array.items = [...head, ...desired, ...tail]; - return true; + return match.pristine && entriesEqual(match.entry, desired) ? 'present' : 'drifted'; } -/** True when the current owned items match the desired entries in order, by name and command list. */ -function ownedItemsEqual(current: readonly unknown[], desired: readonly HookEntry[]): boolean { - if (current.length !== desired.length) { - return false; - } - return current.every((item, index) => { - const entry = readEntry(item); - const target = desired[index]; - return entry !== undefined && target !== undefined && entriesEqual(entry, target); - }); +/** Structural equality of two entries: same name and same ordered command list. */ +function entriesEqual(a: HookEntry, b: HookEntry): boolean { + return ( + a.name === b.name && a.commands.length === b.commands.length && a.commands.every((c, i) => c === b.commands[i]) + ); } -/** Returns the `eventHooks.events` array for `eventKey`, creating `eventHooks`, `events`, and the array as needed. */ -function ensureEventArray(doc: Document, eventKey: string): YAMLSeq { - const existing = getEventArray(doc, eventKey); +/** Returns the `eventHooks.events` list, creating `eventHooks` and `events` as needed. */ +function ensureEventsList(doc: Document): YAMLSeq { + const existing = getEventsList(doc); if (existing) { return existing; } const array = new YAMLSeq(); - doc.setIn(['eventHooks', 'events', eventKey], array); + doc.setIn(['eventHooks', 'events'], array); return array; } -/** Returns the `eventHooks.events` array for `eventKey`, or undefined when any level is missing or malformed. */ -function getEventArray(doc: Document, eventKey: string): YAMLSeq | undefined { - const events = getEventsMap(doc); - if (!events) { +/** + * Returns the `eventHooks.events` list, or undefined when `eventHooks` or `events` is missing. A present `events` + * that is not a list — the map shape in particular — throws rather than reading as empty: Rovo Dev rejects such a + * config as corrupt, and treating it as empty would append entries the CLI never runs. + */ +function getEventsList(doc: Document): YAMLSeq | undefined { + const events = doc.getIn(['eventHooks', 'events'], true); + if (events === undefined) { return undefined; } - const array = events.get(eventKey, true); - return isSeq(array) ? array : undefined; -} - -/** Returns the `eventHooks.events` map, or undefined when either level is missing or malformed. */ -function getEventsMap(doc: Document): YAMLMap | undefined { - const events = doc.getIn(['eventHooks', 'events'], true); - return isMap(events) ? events : undefined; -} - -/** The owned entries in an array, in order, as plain {@link HookEntry} values. */ -function readOwnedEntries(array: YAMLSeq, isOwned: HookSentinelMatcher): HookEntry[] { - const owned: HookEntry[] = []; - for (const item of array.items) { - const entry = readEntry(item); - if (entry !== undefined && isOwned(entry)) { - owned.push(entry); - } + if (!isSeq(events)) { + throw new TypeError("Expected 'eventHooks.events' in the Rovo config to be a list, but it is not."); } - return owned; + return events; } /** True when the YAML item reads as a hook entry the matcher claims. */ function isOwnedItem(item: unknown, isOwned: HookSentinelMatcher): boolean { - const entry = readEntry(item); - return entry !== undefined && isOwned(entry); + const read = readItem(item); + return read !== undefined && isOwned(read.entry); } -/** Classifies a desired entry against the owned entries present under its event, matched by name. */ -function classify(ownedEntries: readonly HookEntry[], desired: HookEntry): ManagedEntryStatus { - const match = ownedEntries.find((entry) => entry.name === desired.name); - if (match === undefined) { - return ownedEntries.length > 0 ? 'drifted' : 'absent'; +/** True when the current owned items match the desired entries in order, each pristine and equal. */ +function ownedItemsEqual(current: readonly ReadItem[], desired: readonly HookEntry[]): boolean { + if (current.length !== desired.length) { + return false; } - return entriesEqual(match, desired) ? 'present' : 'drifted'; + return current.every((item, index) => { + const target = desired[index]; + return target !== undefined && item.pristine && entriesEqual(item.entry, target); + }); } -/** Reads a YAML seq item into a {@link HookEntry}, or undefined when it is not a well-formed entry. */ -function readEntry(item: unknown): HookEntry | undefined { +/** + * A YAML item read back as an entry. `pristine` records that every command map carried only the `command` key; a + * hand-added extra key (a timeout, say) must read as drift, not as an equal entry, so ensure rebuilds it and check + * reports it honestly. + */ +interface ReadItem { + readonly entry: HookEntry; + readonly pristine: boolean; +} + +/** + * Reads a YAML list item into a {@link ReadItem}, or undefined when it is not a well-formed entry: a map holding a + * string `name` and a `commands` list whose every item is a map with a string `command`. Reading is lenient about + * extra keys — a foreign item carrying more than this module writes must still be recognizable, or ownership checks + * would go blind to it. + */ +function readItem(item: unknown): ReadItem | undefined { if (!isMap(item)) { return undefined; } @@ -280,58 +229,82 @@ function readEntry(item: unknown): HookEntry | undefined { if (typeof name !== 'string' || !isSeq(commands)) { return undefined; } + const commandStrings: string[] = []; + let pristine = item.items.length === 2; for (const command of commands.items) { - const value = scalarString(command); - if (value === undefined) { + if (!isMap(command)) { + return undefined; + } + const value = command.get('command'); + if (typeof value !== 'string') { return undefined; } commandStrings.push(value); + pristine &&= command.items.length === 1; } - return { name, commands: commandStrings }; + return { entry: { name, commands: commandStrings }, pristine }; } -/** Structural equality of two entries: same name and same ordered command list. */ -function entriesEqual(a: HookEntry, b: HookEntry): boolean { - return ( - a.name === b.name && a.commands.length === b.commands.length && a.commands.every((c, i) => c === b.commands[i]) - ); +/** The owned items in the list, in order. */ +function readOwnedItems(array: YAMLSeq, isOwned: HookSentinelMatcher): ReadItem[] { + const owned: ReadItem[] = []; + for (const item of array.items) { + const read = readItem(item); + if (read !== undefined && isOwned(read.entry)) { + owned.push(read); + } + } + return owned; } -/** Builds a fresh YAML map for an entry. Owned items are rebuilt as a unit; their inner comments are not preserved. */ +/** + * Replaces the owned subset of the events list with `entries`: the supplied set is spliced in at the first owned + * position (appended when the list holds none), other owned items are dropped, and foreign items keep their order. + * Returns whether the list changed. + */ +function replaceOwnedItems(array: YAMLSeq, entries: readonly HookEntry[], isOwned: HookSentinelMatcher): boolean { + const firstOwned = array.items.findIndex((item) => isOwnedItem(item, isOwned)); + const desired = entries.map(toYamlEntry); + + if (firstOwned === -1) { + if (desired.length === 0) { + return false; + } + array.items.push(...desired); + return true; + } + + const head = array.items.slice(0, firstOwned); + const tail = array.items.slice(firstOwned + 1).filter((item) => !isOwnedItem(item, isOwned)); + const before = array.items + .slice(firstOwned) + .map(readItem) + .filter((read): read is ReadItem => read !== undefined && isOwned(read.entry)); + + if (ownedItemsEqual(before, entries)) { + return false; + } + + array.items = [...head, ...desired, ...tail]; + return true; +} + +/** + * Builds a fresh YAML map for an entry, wrapping each command string as a `{command}` map per the file shape. Owned + * items are rebuilt as a unit; their inner comments are not preserved. + */ function toYamlEntry(entry: HookEntry): YAMLMap { const map = new YAMLMap(); map.set('name', entry.name); const commands = new YAMLSeq(); for (const command of entry.commands) { - commands.add(command); + const commandMap = new YAMLMap(); + commandMap.set('command', command); + commands.add(commandMap); } map.set('commands', commands); return map; } -/** Drops `events` when it holds no arrays and `eventHooks` when it holds nothing but an emptied `events`. */ -function pruneEmptyContainers(doc: Document, events: YAMLMap): void { - if (events.items.length > 0) { - return; - } - doc.deleteIn(['eventHooks', 'events']); - - const eventHooks = doc.get('eventHooks', true); - if (isMap(eventHooks) && eventHooks.items.length === 0) { - doc.delete('eventHooks'); - } -} - -/** The string value of a scalar node or plain string, or undefined for anything else. */ -function scalarString(node: unknown): string | undefined { - if (typeof node === 'string') { - return node; - } - if (node !== null && typeof node === 'object' && 'value' in node && typeof node.value === 'string') { - return node.value; - } - return undefined; -} - // endregion | Helpers From 0475e8ed7f0b2991e08f7eee00d65e4701c2f924 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 17 Jul 2026 19:20:44 -0700 Subject: [PATCH 6/9] agents|internal: Add the hook-entry catalog and Rovo config file layer Adds the building blocks for CLI-managed session-lifecycle hooks: a single catalog of the hook entries each harness gets, a file layer that reads and writes `~/.rovodev/config.yml` without disturbing foreign entries or comments, and per-harness config-file path resolution. Every composed hook command now ends in `--sentinel codeassembly-agents`, the ownership marker the config tools use to find, replace, and remove only their own entries. The relay accepts the flag and ignores it, so the marker rides as an ordinary argument regardless of how a harness executes hook commands. --- .../lib/__tests__/hook-entry-catalog.test.ts | 71 +++++++++++ .../__tests__/rovo-config-settings.test.ts | 119 ++++++++++++++++++ packages/agents/src/lib/harness.ts | 4 + packages/agents/src/lib/hook-entry-catalog.ts | 65 ++++++++++ .../agents/src/lib/rovo-config-settings.ts | 97 ++++++++++++++ packages/agents/src/lib/types.ts | 6 + .../relay-hook-event/__tests__/cli.test.ts | 8 ++ packages/agents/src/relay-hook-event/cli.ts | 10 +- .../src/relay-hook-event/hook-mappings.ts | 8 ++ 9 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 packages/agents/src/lib/__tests__/hook-entry-catalog.test.ts create mode 100644 packages/agents/src/lib/__tests__/rovo-config-settings.test.ts create mode 100644 packages/agents/src/lib/hook-entry-catalog.ts create mode 100644 packages/agents/src/lib/rovo-config-settings.ts diff --git a/packages/agents/src/lib/__tests__/hook-entry-catalog.test.ts b/packages/agents/src/lib/__tests__/hook-entry-catalog.test.ts new file mode 100644 index 00000000..c283e939 --- /dev/null +++ b/packages/agents/src/lib/__tests__/hook-entry-catalog.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { listRelayHooks } from '../../relay-hook-event/hook-mappings.ts'; +import { buildClaudeHookEntries, buildRovoHookEntries, HOOK_SENTINEL, isSentinelOwned } from '../hook-entry-catalog.ts'; +import { isRecord } from '../type-guards.ts'; + +/** Reads the single command string out of a Claude matcher group, asserting the group's expected shape. */ +function readClaudeCommand(group: Record): string { + const hooks = group.hooks; + if (!Array.isArray(hooks) || hooks.length !== 1) { + throw new Error('Expected the matcher group to hold exactly one hook'); + } + const hook: unknown = hooks[0]; + if (!isRecord(hook) || typeof hook.command !== 'string') { + throw new Error('Expected the hook to carry a command string'); + } + return hook.command; +} + +describe(buildClaudeHookEntries, () => { + it('builds one entry per relayed Claude hook, in relay order', () => { + const entries = buildClaudeHookEntries(); + + expect(entries.map((entry) => entry.event)).toEqual([...listRelayHooks('claude')]); + }); + + it('bakes the hook identity, a tilde relay path, and the sentinel into each command', () => { + for (const entry of buildClaudeHookEntries()) { + const command = readClaudeCommand(entry.group); + expect(command).toContain('node ~/.claude/scripts/relay-hook-event.mjs'); + expect(command).toContain('--harness claude'); + expect(command).toContain(`--hook ${entry.event}`); + expect(command).toContain(HOOK_SENTINEL); + } + }); + + it('declares matcher-free groups, so every start source and end reason relays', () => { + for (const entry of buildClaudeHookEntries()) { + expect(Object.keys(entry.group)).toEqual(['hooks']); + } + }); +}); + +describe(buildRovoHookEntries, () => { + it('builds one entry per relayed Rovo hook, named by the hook, in relay order', () => { + const entries = buildRovoHookEntries('/home/user/.rovodev/scripts'); + + expect(entries.map((entry) => entry.name)).toEqual([...listRelayHooks('rovodev')]); + }); + + it('bakes the hook identity, the absolute relay path, and the sentinel into each command', () => { + for (const entry of buildRovoHookEntries('/home/user/.rovodev/scripts')) { + expect(entry.commands).toHaveLength(1); + const command = entry.commands[0] ?? ''; + expect(command).toContain('node /home/user/.rovodev/scripts/relay-hook-event.mjs'); + expect(command).toContain('--harness rovodev'); + expect(command).toContain(`--hook ${entry.name}`); + expect(command).toContain(HOOK_SENTINEL); + } + }); + + it('produces entries the sentinel matcher claims', () => { + for (const entry of buildRovoHookEntries('/home/user/.rovodev/scripts')) { + expect(isSentinelOwned(entry)).toBe(true); + } + }); + + it('does not claim a foreign entry that merely mentions the CLI name', () => { + expect(isSentinelOwned({ name: 'on_complete', commands: ['codeassembly-agents status'] })).toBe(false); + }); +}); diff --git a/packages/agents/src/lib/__tests__/rovo-config-settings.test.ts b/packages/agents/src/lib/__tests__/rovo-config-settings.test.ts new file mode 100644 index 00000000..63c88cc5 --- /dev/null +++ b/packages/agents/src/lib/__tests__/rovo-config-settings.test.ts @@ -0,0 +1,119 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { HookEntry, HookSentinelMatcher } from '../rovo-config-hooks.ts'; +import { checkRovoHookEntries, ensureRovoHookEntries, removeRovoHookEntries } from '../rovo-config-settings.ts'; + +/** A test sentinel: ownership is marked by a `--ca` token in any command. */ +const isOwned: HookSentinelMatcher = (entry) => entry.commands.some((command) => command.includes('--ca')); + +/** Builds an owned entry for the named hook event, carrying the sentinel token. */ +function buildOwnedEntry(name: string): HookEntry { + return { name, commands: [`run ${name} --ca`] }; +} + +describe('rovo-config-settings', () => { + let tempDir: string; + let configPath: string; + + beforeEach(async () => { + tempDir = path.join(tmpdir(), `rovo-config-settings-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tempDir, { recursive: true }); + configPath = path.join(tempDir, '.rovodev', 'config.yml'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('creates the file and its parent directory on first ensure', async () => { + const result = await ensureRovoHookEntries(configPath, [buildOwnedEntry('on_session_start')], isOwned); + + expect(result.changed).toBe(true); + expect(await readFile(configPath, 'utf8')).toContain('run on_session_start --ca'); + }); + + it('leaves the file untouched on an unchanged re-run', async () => { + await ensureRovoHookEntries(configPath, [buildOwnedEntry('on_session_start')], isOwned); + const written = await readFile(configPath, 'utf8'); + + const rerun = await ensureRovoHookEntries(configPath, [buildOwnedEntry('on_session_start')], isOwned); + + expect(rerun.changed).toBe(false); + expect(await readFile(configPath, 'utf8')).toBe(written); + }); + + it('preserves foreign entries, comments, and unrelated keys across a round trip', async () => { + await mkdir(path.dirname(configPath), { recursive: true }); + const source = [ + '# my config', + 'otherKey: 42', + 'eventHooks:', + ' logFile: "~/.rovodev/event_hooks.log"', + ' events:', + ' - name: on_complete # foreign', + ' commands:', + " - command: echo 'done'", + '', + ].join('\n'); + await writeFile(configPath, source, 'utf8'); + + await ensureRovoHookEntries(configPath, [buildOwnedEntry('on_session_end')], isOwned); + const out = await readFile(configPath, 'utf8'); + + expect(out).toContain('# my config'); + expect(out).toContain('otherKey: 42'); + expect(out).toContain('logFile:'); + expect(out).toContain('on_complete # foreign'); + expect(out).toContain("echo 'done'"); + expect(out).toContain('run on_session_end --ca'); + }); + + it('writes a long command as a single unfolded line', async () => { + const longCommand = `run on_session_start --with-a-flag-long-enough-to-cross-the-default-fold-width --and-then-some --ca`; + await ensureRovoHookEntries(configPath, [{ name: 'on_session_start', commands: [longCommand] }], isOwned); + + expect(await readFile(configPath, 'utf8')).toContain(`- command: ${longCommand}\n`); + }); + + it('reports every entry absent for a missing file, without creating it', async () => { + const checks = await checkRovoHookEntries(configPath, [buildOwnedEntry('on_session_start')], isOwned); + + expect(checks.map((check) => check.status)).toEqual(['absent']); + expect(existsSync(configPath)).toBe(false); + }); + + it('round-trips ensure, check, and remove against one file', async () => { + const entries = [buildOwnedEntry('on_session_start'), buildOwnedEntry('on_session_end')]; + await ensureRovoHookEntries(configPath, entries, isOwned); + + const checks = await checkRovoHookEntries(configPath, entries, isOwned); + expect(checks.map((check) => check.status)).toEqual(['present', 'present']); + + const removal = await removeRovoHookEntries(configPath, isOwned); + expect(removal).toEqual({ changed: true, removedCount: 2 }); + expect(await readFile(configPath, 'utf8')).not.toContain('--ca'); + }); + + it('does not create a missing file on remove', async () => { + const result = await removeRovoHookEntries(configPath, isOwned); + + expect(result).toEqual({ changed: false, removedCount: 0 }); + expect(existsSync(configPath)).toBe(false); + }); + + it('surfaces a parse failure naming the file, and never writes', async () => { + await mkdir(path.dirname(configPath), { recursive: true }); + const broken = 'eventHooks: [unterminated\n'; + await writeFile(configPath, broken, 'utf8'); + + await expect(ensureRovoHookEntries(configPath, [buildOwnedEntry('on_session_start')], isOwned)).rejects.toThrow( + configPath, + ); + expect(await readFile(configPath, 'utf8')).toBe(broken); + }); +}); diff --git a/packages/agents/src/lib/harness.ts b/packages/agents/src/lib/harness.ts index 8493a5a1..0460e500 100644 --- a/packages/agents/src/lib/harness.ts +++ b/packages/agents/src/lib/harness.ts @@ -12,6 +12,7 @@ export const HARNESSES: Record = { skillsDirName: 'skills', subagentsDirName: 'agents', scriptsDirName: 'scripts', + configFileName: 'settings.json', frontmatterFile: 'claude.yaml', skillSigil: '/', subagentSigil: '', @@ -22,6 +23,7 @@ export const HARNESSES: Record = { skillsDirName: 'skills', subagentsDirName: 'subagents', scriptsDirName: 'scripts', + configFileName: 'config.yml', frontmatterFile: 'rovodev.yaml', skillSigil: '!', subagentSigil: '', @@ -64,6 +66,7 @@ export function resolveHarnessPaths( skillsDir: string; subagentsDir: string; scriptsDir: string; + configFile: string; } { const home = baseDir ?? homedir(); const config = HARNESSES[harnessId]; @@ -73,6 +76,7 @@ export function resolveHarnessPaths( skillsDir: path.join(harnessHome, config.skillsDirName), subagentsDir: path.join(harnessHome, config.subagentsDirName), scriptsDir: path.join(harnessHome, config.scriptsDirName), + configFile: path.join(harnessHome, config.configFileName), }; } diff --git a/packages/agents/src/lib/hook-entry-catalog.ts b/packages/agents/src/lib/hook-entry-catalog.ts new file mode 100644 index 00000000..2ccd29bc --- /dev/null +++ b/packages/agents/src/lib/hook-entry-catalog.ts @@ -0,0 +1,65 @@ +/** + * The catalog of session-lifecycle hook entries CodeAssembly installs into each harness's config file. Ensure, remove, + * print, and status reporting all compose their entries from this one module, so no two of them can disagree about + * what "the entries" are — and the printed snippet is by construction the snippet that gets installed. + * + * Each entry's command invokes the installed relay with `--harness`/`--hook` baked in, plus the ownership sentinel. + * The sentinel is a real flag the relay accepts and ignores, not a shell comment: it survives any execution semantics + * a harness uses, and it is distinctive enough that a foreign command will not carry it by accident. + */ + +import path from 'node:path'; + +import { listRelayHooks } from '../relay-hook-event/hook-mappings.ts'; +import type { ClaudeHookEntry } from './claude-hook-entries.ts'; +import { HARNESSES } from './harness.ts'; +import type { HookEntry, HookSentinelMatcher } from './rovo-config-hooks.ts'; + +/** + * The ownership marker carried in every managed hook command. The config utilities find, replace, and remove only + * commands containing this exact string, so it includes the flag name: the bare value could collide with an unrelated + * command that merely mentions the CLI's name. + */ +export const HOOK_SENTINEL = '--sentinel codeassembly-agents'; + +/** The filename of the relay bundle that `install` places in each harness's scripts directory. */ +const RELAY_FILENAME = 'relay-hook-event.mjs'; + +/** + * The Claude Code hook entries, one matcher group per relayed hook. The relay path uses a literal `~`: Claude runs + * hook commands through a shell, which expands it, and the unexpanded form keeps the entries identical across + * machines and portable through dotfile syncing. + */ +export function buildClaudeHookEntries(): ReadonlyArray { + const config = HARNESSES.claude; + const relayPath = `~/${config.homeDir}/${config.scriptsDirName}/${RELAY_FILENAME}`; + return listRelayHooks('claude').map((hook) => ({ + event: hook, + group: { hooks: [{ type: 'command', command: buildRelayCommand(relayPath, 'claude', hook) }] }, + })); +} + +/** + * The Rovo Dev hook entries, one `events` item per relayed hook. The relay path is the resolved absolute path under + * `scriptsDir`, matching the entries Rovo's own tooling generates rather than assuming the config expands `~`. + */ +export function buildRovoHookEntries(scriptsDir: string): ReadonlyArray { + const relayPath = path.join(scriptsDir, RELAY_FILENAME); + return listRelayHooks('rovodev').map((hook) => ({ + name: hook, + commands: [buildRelayCommand(relayPath, 'rovodev', hook)], + })); +} + +/** The Rovo ownership matcher: an entry is CodeAssembly's when any of its commands carries the sentinel. */ +export const isSentinelOwned: HookSentinelMatcher = (entry) => + entry.commands.some((command) => command.includes(HOOK_SENTINEL)); + +// region | Helpers + +/** Composes one relay invocation with the hook's identity and the ownership sentinel baked in. */ +function buildRelayCommand(relayPath: string, harness: string, hook: string): string { + return `node ${relayPath} --harness ${harness} --hook ${hook} ${HOOK_SENTINEL}`; +} + +// endregion | Helpers diff --git a/packages/agents/src/lib/rovo-config-settings.ts b/packages/agents/src/lib/rovo-config-settings.ts new file mode 100644 index 00000000..c866b670 --- /dev/null +++ b/packages/agents/src/lib/rovo-config-settings.ts @@ -0,0 +1,97 @@ +/** + * The file layer over the Rovo Dev hook-entry transforms: read and parse `config.yml`, delegate to the pure transform, + * and write the mutated document back, so foreign entries, foreign comments, and unrelated keys reach disk exactly as + * the comment-preserving transform left them. The path is supplied by the caller, which resolves it per harness. A + * file that cannot be parsed is reported and never written. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { type Document, parseDocument } from 'yaml'; + +import type { EnsureResult, EntryCheck, RemoveResult } from './managed-entry-contract.ts'; +import { + checkHookEntries, + ensureHookEntries, + type HookEntry, + type HookSentinelMatcher, + removeHookEntries, +} from './rovo-config-hooks.ts'; +import { isEnoent } from './type-guards.ts'; + +/** Reports each supplied entry's status in the config file. A file that does not exist reports every entry absent. */ +export async function checkRovoHookEntries( + filePath: string, + entries: readonly HookEntry[], + isOwned: HookSentinelMatcher, +): Promise>> { + const doc = await readConfigDocument(filePath); + return checkHookEntries(doc, entries, isOwned); +} + +/** + * Installs `entries` into the config file, creating the file and its parent directory when absent. The file is + * rewritten only when the entries were missing or drifted, so a re-run leaves its mtime alone. + */ +export async function ensureRovoHookEntries( + filePath: string, + entries: readonly HookEntry[], + isOwned: HookSentinelMatcher, +): Promise { + const doc = await readConfigDocument(filePath); + const result = ensureHookEntries(doc, entries, isOwned); + if (result.changed) { + await writeConfigDocument(filePath, doc); + } + return result; +} + +/** Deletes every sentinel-matching entry from the config file. A file that does not exist is left uncreated. */ +export async function removeRovoHookEntries(filePath: string, isOwned: HookSentinelMatcher): Promise { + const doc = await readConfigDocument(filePath); + const result = removeHookEntries(doc, isOwned); + if (result.changed) { + await writeConfigDocument(filePath, doc); + } + return result; +} + +// region | Helpers + +/** + * Reads and parses the config file; an absent file reads as an empty document. A file that parses with errors throws + * here, naming the file, so no operation ever mutates or rewrites a document the parser could not fully understand. + */ +async function readConfigDocument(filePath: string): Promise { + const text = await readConfigText(filePath); + const doc = parseDocument(text ?? ''); + if (doc.errors.length > 0) { + const details = doc.errors.map((error) => error.message).join('; '); + throw new Error(`Cannot parse ${filePath} as YAML: ${details}`); + } + return doc; +} + +/** Reads the file as UTF-8, returning undefined when it does not exist. */ +async function readConfigText(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch (error: unknown) { + if (isEnoent(error)) { + return undefined; + } + throw error; + } +} + +/** + * Writes the document, creating the parent directory as needed. Line wrapping is disabled so a long hook command stays + * one line rather than being folded across several — parse-equivalent, but unreadable and noisy in diffs. + */ +async function writeConfigDocument(filePath: string, doc: Document): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, doc.toString({ lineWidth: 0 }), 'utf8'); +} + +// endregion | Helpers diff --git a/packages/agents/src/lib/types.ts b/packages/agents/src/lib/types.ts index 0462577a..3b879c99 100644 --- a/packages/agents/src/lib/types.ts +++ b/packages/agents/src/lib/types.ts @@ -19,6 +19,8 @@ export interface HarnessConfig { readonly subagentsDirName: string; /** Name of the scripts directory under the harness home. */ readonly scriptsDirName: string; + /** Filename of the harness's user-curated config file under its home (e.g. `settings.json`). */ + readonly configFileName: string; /** Filename of the frontmatter overlay YAML for this harness. */ readonly frontmatterFile: string; /** Prefix a `{skill:}` invocation token renders to (e.g. `/` for Claude, `!` for Rovo). */ @@ -41,6 +43,10 @@ export interface InstallOptions { readonly link: boolean; readonly force: boolean; readonly dryRun: boolean; + /** Whether `install` also wires the session-lifecycle hook entries; `--skip-hooks` clears it (absent reads as true). */ + readonly hooks?: boolean; + /** Whether `configure-hooks` prints the hook entries instead of writing them (`--print`). */ + readonly print?: boolean; } /** A single entry in the manifest tracking an installed file or directory. */ diff --git a/packages/agents/src/relay-hook-event/__tests__/cli.test.ts b/packages/agents/src/relay-hook-event/__tests__/cli.test.ts index 5e79a2fa..dff54ed9 100644 --- a/packages/agents/src/relay-hook-event/__tests__/cli.test.ts +++ b/packages/agents/src/relay-hook-event/__tests__/cli.test.ts @@ -38,6 +38,14 @@ describe(parseArgs, () => { }); }); + it('accepts and ignores the ownership sentinel a configured entry carries', () => { + expect(parseArgs(['--harness', 'claude', '--hook', 'Stop', '--sentinel', 'codeassembly-agents'])).toEqual({ + harness: 'claude', + hook: 'Stop', + home: null, + }); + }); + it('throws when --harness is missing', () => { expect(() => parseArgs(['--hook', 'Stop'])).toThrow(/--harness is required/); }); diff --git a/packages/agents/src/relay-hook-event/cli.ts b/packages/agents/src/relay-hook-event/cli.ts index 65d720eb..57bf6d36 100644 --- a/packages/agents/src/relay-hook-event/cli.ts +++ b/packages/agents/src/relay-hook-event/cli.ts @@ -15,9 +15,12 @@ * non-zero hook exits as control signals rather than as failures. * * Flags: - * --harness The harness whose hook fired (`claude`, `rovodev`). Required. - * --hook The harness's own name for the hook, e.g. `SessionStart`. Required. - * --home Events-root override, so a test can point the write at a fixture directory. + * --harness The harness whose hook fired (`claude`, `rovodev`). Required. + * --hook The harness's own name for the hook, e.g. `SessionStart`. Required. + * --home Events-root override, so a test can point the write at a fixture directory. + * --sentinel Ownership marker the configured hook entries carry so the config tools can find them again. + * Accepted and ignored here: it rides as an ordinary argument so it survives any execution + * semantics a harness uses, rather than relying on shell comment stripping. */ import { realpathSync } from 'node:fs'; import { homedir } from 'node:os'; @@ -43,6 +46,7 @@ const FLAGS: readonly FlagSpec[] = [ { name: 'harness', takesValue: true }, { name: 'hook', takesValue: true }, { name: 'home', takesValue: true }, + { name: 'sentinel', takesValue: true }, ]; /** The payload key each harness reports the session's id under. */ diff --git a/packages/agents/src/relay-hook-event/hook-mappings.ts b/packages/agents/src/relay-hook-event/hook-mappings.ts index a57e9e68..55576b29 100644 --- a/packages/agents/src/relay-hook-event/hook-mappings.ts +++ b/packages/agents/src/relay-hook-event/hook-mappings.ts @@ -34,6 +34,14 @@ export function listRelayHarnesses(): readonly string[] { return Object.keys(HOOK_MAPPINGS); } +/** + * The hook names the relay serves for `harness`, in table order. The hook-entry catalog composes the configured + * entries from this list, so the entries a harness config carries and the hooks this relay answers cannot drift apart. + */ +export function listRelayHooks(harness: HarnessId): readonly string[] { + return Object.keys(HOOK_MAPPINGS[harness]); +} + /** * The mapping for `hook` under `harness`, or `undefined` when the table does not know the name. An unknown name is an * ordinary outcome rather than an error: a harness config is a durable user-curated file that can name a hook this From 695181f45603a08095c23b0ea8954da1ca8590a0 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 17 Jul 2026 19:20:58 -0700 Subject: [PATCH 7/9] agents|feat: Configure session-lifecycle hooks through the CLI `install` now wires the session-lifecycle hook entries into each harness's config (`~/.claude/settings.json`, `~/.rovodev/config.yml`) by default, so a fresh install starts reporting session and turn boundaries without a manual editing step. Only entries carrying the ownership sentinel are ever created, replaced, or removed; the rest of the config is untouched. The wiring is a discrete step with its own surface: `install --skip-hooks` opts out, the new `configure-hooks` command re-applies the wiring alone, and `configure-hooks --print` emits the entries as copyable snippets for a config managed elsewhere. `uninstall` removes the entries, and `status` reports each one as present, drifted, or absent alongside the installed items. After touching the Rovo config, the CLI reminds that a running session picks the change up only after a restart. The README's hook section now documents the CLI surface and no longer claims the configs are wired by hand; its manual-adoption snippets are exactly what `configure-hooks --print` emits, enforced by test. --- packages/agents/.prettierignore | 1 + packages/agents/README.md | 29 ++-- packages/agents/src/cli.ts | 29 +++- .../__tests__/configure-hooks.test.ts | 161 ++++++++++++++++++ .../src/commands/__tests__/install.test.ts | 28 +++ .../src/commands/__tests__/status.test.ts | 32 ++++ .../src/commands/__tests__/uninstall.test.ts | 19 ++- .../agents/src/commands/configure-hooks.ts | 142 +++++++++++++++ packages/agents/src/commands/install.ts | 11 ++ packages/agents/src/commands/status.ts | 35 +++- packages/agents/src/commands/uninstall.ts | 10 +- 11 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 packages/agents/src/commands/__tests__/configure-hooks.test.ts create mode 100644 packages/agents/src/commands/configure-hooks.ts diff --git a/packages/agents/.prettierignore b/packages/agents/.prettierignore index 7971a14c..68558f3f 100644 --- a/packages/agents/.prettierignore +++ b/packages/agents/.prettierignore @@ -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. diff --git a/packages/agents/README.md b/packages/agents/README.md index 5b85555a..400ada0d 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -29,9 +29,16 @@ Skills report the work they do, but they cannot report a session opening, exitin | `turn.started` | `UserPromptSubmit` | `on_user_prompt` | | `turn.completed` | `Stop` | `on_complete` | -`install` places the relay in each harness's `scripts/` directory. Wiring it to the hooks is a separate step: the harness configs below are yours, and nothing writes to them on your behalf. Add the entries for the harnesses you want. +`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: -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 Claude Code reads a `Stop` hook's non-zero exit as a signal to keep the agent from stopping. +- `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 @@ -45,7 +52,7 @@ In `~/.claude/settings.json`, under `hooks`. Each entry names the hook it relays "hooks": [ { "type": "command", - "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook SessionStart" + "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook SessionStart --sentinel codeassembly-agents" } ] } @@ -55,7 +62,7 @@ In `~/.claude/settings.json`, under `hooks`. Each entry names the hook it relays "hooks": [ { "type": "command", - "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook SessionEnd" + "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook SessionEnd --sentinel codeassembly-agents" } ] } @@ -65,7 +72,7 @@ In `~/.claude/settings.json`, under `hooks`. Each entry names the hook it relays "hooks": [ { "type": "command", - "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook UserPromptSubmit" + "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook UserPromptSubmit --sentinel codeassembly-agents" } ] } @@ -75,7 +82,7 @@ In `~/.claude/settings.json`, under `hooks`. Each entry names the hook it relays "hooks": [ { "type": "command", - "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook Stop" + "command": "node ~/.claude/scripts/relay-hook-event.mjs --harness claude --hook Stop --sentinel codeassembly-agents" } ] } @@ -97,19 +104,19 @@ eventHooks: events: - name: on_session_start commands: - - command: node /Users/you/.rovodev/scripts/relay-hook-event.mjs --harness rovodev --hook on_session_start + - 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 + - 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 + - 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 + - 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, as above: Rovo's own generated entries use absolute paths, and `~` is not known to expand here. +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: diff --git a/packages/agents/src/cli.ts b/packages/agents/src/cli.ts index 61907dc0..3755bedb 100644 --- a/packages/agents/src/cli.ts +++ b/packages/agents/src/cli.ts @@ -2,6 +2,7 @@ /* eslint unicorn/no-process-exit: off */ import process from 'node:process'; +import { configureHooksCommand } from './commands/configure-hooks.ts'; import { generateLabelMap, printGenerateUsage } from './commands/generate-label-map.ts'; import { initCommand, initGlobalCommand } from './commands/init.ts'; import { installCommand } from './commands/install.ts'; @@ -29,6 +30,9 @@ async function main(): Promise { case 'install': await installCommand(options); break; + case 'configure-hooks': + await configureHooksCommand(options); + break; case 'init': await (global ? initGlobalCommand(options) : initCommand(options)); break; @@ -95,6 +99,8 @@ function parseArgs(argv: ReadonlyArray): { let link = false; let force = false; let dryRun = false; + let hooks = true; + let print = false; let help = false; let global = false; @@ -116,6 +122,12 @@ function parseArgs(argv: ReadonlyArray): { case 'dry-run': dryRun = true; break; + case 'skip-hooks': + hooks = false; + break; + case 'print': + print = true; + break; case 'global': global = true; break; @@ -140,19 +152,23 @@ function parseArgs(argv: ReadonlyArray): { return { command, subcommand, - options: { harness, link, force, dryRun }, + options: { harness, link, force, dryRun, hooks, print }, help, global, }; } -function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'global' | 'harness' | null { - const flags: Record = { +type FlagName = 'help' | 'link' | 'force' | 'dry-run' | 'skip-hooks' | 'print' | 'global' | 'harness'; + +function parseFlag(arg: string): FlagName | null { + const flags: Record = { '--help': 'help', '-h': 'help', '--link': 'link', '--force': 'force', '--dry-run': 'dry-run', + '--skip-hooks': 'skip-hooks', + '--print': 'print', '--global': 'global', '--harness': 'harness', }; @@ -183,10 +199,11 @@ function printUsage(): void { Commands: install Install shared guidance, harness-specific skills, scripts, and support data into harness directories + configure-hooks Write the session-lifecycle hook entries into harness configs (also run by install; see --print) init Scaffold .agents/codeassembly.yaml (or --global for ~/.agents/codeassembly.yaml) sync Resolve .agents/codeassembly.yaml and materialize declared rulebooks, skills, and subagents - uninstall Remove installed guidance, skills, and subagents - status Show the current state of installed items + uninstall Remove installed guidance, skills, subagents, and hook entries + status Show the current state of installed items, including hook entries library list List available library artifacts (rulebooks, skills, subagents) generate Generate a configuration file (e.g., label-map) @@ -195,6 +212,8 @@ Options: --link Use symlinks instead of copies (install only) --force Overwrite or remove modified files (install/uninstall) --dry-run Show what would be done without making changes (install, sync, init) + --skip-hooks Leave harness configs untouched during install (install only) + --print Print the hook entries instead of writing them (configure-hooks only) --global Target the user-global tier (~/.agents/codeassembly.yaml) in the home; applies to sync and init --help, -h Show this help message`); } diff --git a/packages/agents/src/commands/__tests__/configure-hooks.test.ts b/packages/agents/src/commands/__tests__/configure-hooks.test.ts new file mode 100644 index 00000000..5ea4c8cc --- /dev/null +++ b/packages/agents/src/commands/__tests__/configure-hooks.test.ts @@ -0,0 +1,161 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HOOK_SENTINEL } from '../../lib/hook-entry-catalog.ts'; +import { + checkHarnessHookEntries, + configureHooksCommand, + ensureHarnessHookEntries, + removeHarnessHookEntries, + renderClaudeHookSnippet, + renderRovoHookSnippet, +} from '../configure-hooks.ts'; + +/** The package README, whose manual-adoption snippets must be exactly what the render functions emit. */ +const README_PATH = fileURLToPath(new URL('../../../README.md', import.meta.url)); + +/** Extracts the contents of the first fenced code block of `language` after `heading`, without the fences. */ +async function readReadmeSnippet(heading: string, language: string): Promise { + const readme = await readFile(README_PATH, 'utf8'); + const section = readme.slice(readme.indexOf(heading)); + const fence = `\`\`\`${language}\n`; + const start = section.indexOf(fence) + fence.length; + const end = section.indexOf('```', start); + return section.slice(start, end); +} + +describe('configure-hooks', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(tmpdir(), `configure-hooks-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe(configureHooksCommand, () => { + it('writes the hook entries into both harness configs', async () => { + await mkdir(path.join(tempDir, '.claude'), { recursive: true }); + await mkdir(path.join(tempDir, '.rovodev'), { recursive: true }); + + await configureHooksCommand({ harness: 'all' }, tempDir); + + const settings = await readFile(path.join(tempDir, '.claude', 'settings.json'), 'utf8'); + const config = await readFile(path.join(tempDir, '.rovodev', 'config.yml'), 'utf8'); + expect(settings).toContain(HOOK_SENTINEL); + expect(settings).toContain('SessionStart'); + expect(config).toContain(HOOK_SENTINEL); + expect(config).toContain('on_session_start'); + }); + + it('writes nothing in print mode', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + let output: string; + try { + await configureHooksCommand({ harness: 'claude', print: true }, tempDir); + output = infoSpy.mock.calls.map((call) => String(call[0])).join('\n'); + } finally { + infoSpy.mockRestore(); + } + + expect(existsSync(path.join(tempDir, '.claude', 'settings.json'))).toBe(false); + expect(output).toContain('"SessionStart"'); + expect(output).toContain(HOOK_SENTINEL); + }); + }); + + describe(ensureHarnessHookEntries, () => { + it('is idempotent per harness', async () => { + await ensureHarnessHookEntries('claude', tempDir); + const first = await readFile(path.join(tempDir, '.claude', 'settings.json'), 'utf8'); + + await ensureHarnessHookEntries('claude', tempDir); + + expect(await readFile(path.join(tempDir, '.claude', 'settings.json'), 'utf8')).toBe(first); + }); + + it('preserves foreign hook entries and unrelated settings keys', async () => { + const settingsPath = path.join(tempDir, '.claude', 'settings.json'); + await mkdir(path.dirname(settingsPath), { recursive: true }); + const existing = { + model: 'opus', + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-guard.sh' }] }] }, + }; + await writeFile(settingsPath, `${JSON.stringify(existing, undefined, 2)}\n`, 'utf8'); + + await ensureHarnessHookEntries('claude', tempDir); + + const settings = await readFile(settingsPath, 'utf8'); + expect(settings).toContain('"model": "opus"'); + expect(settings).toContain('my-guard.sh'); + expect(settings).toContain(HOOK_SENTINEL); + }); + }); + + describe(checkHarnessHookEntries, () => { + it('reports absent before configuration and present after, for both harnesses', async () => { + expect((await checkHarnessHookEntries('claude', tempDir)).every((entry) => entry.status === 'absent')).toBe(true); + expect((await checkHarnessHookEntries('rovodev', tempDir)).every((entry) => entry.status === 'absent')).toBe( + true, + ); + + await ensureHarnessHookEntries('claude', tempDir); + await ensureHarnessHookEntries('rovodev', tempDir); + + const claudeChecks = await checkHarnessHookEntries('claude', tempDir); + const rovoChecks = await checkHarnessHookEntries('rovodev', tempDir); + expect(claudeChecks).toHaveLength(4); + expect(rovoChecks).toHaveLength(4); + expect(claudeChecks.every((entry) => entry.status === 'present')).toBe(true); + expect(rovoChecks.every((entry) => entry.status === 'present')).toBe(true); + }); + }); + + describe(removeHarnessHookEntries, () => { + it('removes only sentinel-marked entries, leaving foreign content intact', async () => { + const configPath = path.join(tempDir, '.rovodev', 'config.yml'); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile( + configPath, + [ + 'eventHooks:', + ' events:', + ' - name: on_complete', + ' commands:', + ' - command: echo done', + '', + ].join('\n'), + 'utf8', + ); + await ensureHarnessHookEntries('rovodev', tempDir); + + await removeHarnessHookEntries('rovodev', tempDir); + + const config = await readFile(configPath, 'utf8'); + expect(config).toContain('echo done'); + expect(config).not.toContain(HOOK_SENTINEL); + }); + }); + + describe('README parity', () => { + it('documents the Claude snippet exactly as rendered', async () => { + const snippet = await readReadmeSnippet('### Claude Code', 'json'); + + expect(snippet.trimEnd()).toBe(renderClaudeHookSnippet().trimEnd()); + }); + + it('documents the Rovo snippet exactly as rendered for the placeholder home', async () => { + const snippet = await readReadmeSnippet('### Rovo Dev', 'yaml'); + + expect(snippet.trimEnd()).toBe(renderRovoHookSnippet('/Users/you/.rovodev/scripts').trimEnd()); + }); + }); +}); diff --git a/packages/agents/src/commands/__tests__/install.test.ts b/packages/agents/src/commands/__tests__/install.test.ts index c55c58d9..b53a0e6c 100644 --- a/packages/agents/src/commands/__tests__/install.test.ts +++ b/packages/agents/src/commands/__tests__/install.test.ts @@ -299,6 +299,34 @@ describe(installCommand, () => { expect(rovodevPaths).not.toContain('prompts.yml'); }); + describe('session-lifecycle hooks', () => { + it('wires the hook entries into the harness config by default', async () => { + const claudeHome = await setupClaudeHome(); + + await installCommand(makeOptions({ harness: 'claude' }), tempDir, contentDir); + + const settings = await readFile(path.join(claudeHome, 'settings.json'), 'utf8'); + expect(settings).toContain('--sentinel codeassembly-agents'); + expect(settings).toContain('SessionStart'); + }); + + it('leaves the harness config untouched with --skip-hooks', async () => { + const claudeHome = await setupClaudeHome(); + + await installCommand(makeOptions({ harness: 'claude', hooks: false }), tempDir, contentDir); + + expect(existsSync(path.join(claudeHome, 'settings.json'))).toBe(false); + }); + + it('leaves the harness config untouched in dry-run mode', async () => { + const claudeHome = await setupClaudeHome(); + + await installCommand(makeOptions({ dryRun: true }), tempDir, contentDir); + + expect(existsSync(path.join(claudeHome, 'settings.json'))).toBe(false); + }); + }); + describe('scripts', () => { it('places scripts and sets the executable bit', async () => { const claudeHome = await setupClaudeHome(); diff --git a/packages/agents/src/commands/__tests__/status.test.ts b/packages/agents/src/commands/__tests__/status.test.ts index 70c29f38..39b3786e 100644 --- a/packages/agents/src/commands/__tests__/status.test.ts +++ b/packages/agents/src/commands/__tests__/status.test.ts @@ -46,6 +46,38 @@ describe('statusCommand', () => { infoSpy.mockRestore(); }); + it('reports the session-lifecycle hook entries alongside the installed items', async () => { + const claudeHome = path.join(tempDir, '.claude'); + await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); + await mkdir(path.join(claudeHome, 'agents'), { recursive: true }); + + await installCommand(makeInstallOptions(), tempDir, contentDir); + + const infoSpy = vi.spyOn(console, 'info'); + await statusCommand({ harness: 'claude' }, tempDir); + + const output = infoSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('Hooks: 4 present, 0 drifted, 0 absent'); + + infoSpy.mockRestore(); + }); + + it('reports hooks as not configured after a --skip-hooks install', async () => { + const claudeHome = path.join(tempDir, '.claude'); + await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); + await mkdir(path.join(claudeHome, 'agents'), { recursive: true }); + + await installCommand(makeInstallOptions({ hooks: false }), tempDir, contentDir); + + const infoSpy = vi.spyOn(console, 'info'); + await statusCommand({ harness: 'claude' }, tempDir); + + const output = infoSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('Hooks: not configured'); + + infoSpy.mockRestore(); + }); + it('should report not installed for a harness with no manifest', async () => { const claudeHome = path.join(tempDir, '.claude'); await mkdir(claudeHome, { recursive: true }); diff --git a/packages/agents/src/commands/__tests__/uninstall.test.ts b/packages/agents/src/commands/__tests__/uninstall.test.ts index 00b6940a..e19e5128 100644 --- a/packages/agents/src/commands/__tests__/uninstall.test.ts +++ b/packages/agents/src/commands/__tests__/uninstall.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; import { existsSync, lstatSync } from 'node:fs'; -import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -64,6 +64,23 @@ describe('uninstallCommand', () => { return { linkPath, source }; } + it('removes the session-lifecycle hook entries but not foreign settings content', async () => { + const claudeHome = path.join(tempDir, '.claude'); + await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); + await mkdir(path.join(claudeHome, 'agents'), { recursive: true }); + const settingsPath = path.join(claudeHome, 'settings.json'); + await writeFile(settingsPath, `${JSON.stringify({ model: 'opus' }, undefined, 2)}\n`, 'utf8'); + + await installCommand(makeInstallOptions(), tempDir, contentDir); + expect(await readFile(settingsPath, 'utf8')).toContain('--sentinel codeassembly-agents'); + + await uninstallCommand({ harness: 'claude', force: false }, tempDir); + + const settings = await readFile(settingsPath, 'utf8'); + expect(settings).not.toContain('--sentinel codeassembly-agents'); + expect(settings).toContain('"model": "opus"'); + }); + it('should remove only manifest-tracked files', async () => { const claudeHome = path.join(tempDir, '.claude'); await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); diff --git a/packages/agents/src/commands/configure-hooks.ts b/packages/agents/src/commands/configure-hooks.ts new file mode 100644 index 00000000..b24ad2ae --- /dev/null +++ b/packages/agents/src/commands/configure-hooks.ts @@ -0,0 +1,142 @@ +/** + * Configures the session-lifecycle hook entries in each harness's config file — the wiring that turns the installed + * relay script into a running event source. `install` invokes the same per-harness functions by default and + * `uninstall` reverses them; running the command alone (re)applies just the hook wiring. `--print` emits the entries + * as copyable snippets instead of writing, for configs managed elsewhere. + * + * All writes go through the sentinel-scoped config utilities, so only CodeAssembly-owned entries are ever created, + * replaced, or removed; the rest of the user's config is untouched. + */ + +import { stringify } from 'yaml'; + +import { + checkClaudeHookEntries, + ensureClaudeHookEntries, + removeClaudeHookEntries, +} from '../lib/claude-hook-settings.ts'; +import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.ts'; +import { + buildClaudeHookEntries, + buildRovoHookEntries, + HOOK_SENTINEL, + isSentinelOwned, +} from '../lib/hook-entry-catalog.ts'; +import type { ManagedEntryStatus } from '../lib/managed-entry-contract.ts'; +import { checkRovoHookEntries, ensureRovoHookEntries, removeRovoHookEntries } from '../lib/rovo-config-settings.ts'; +import type { HarnessId, InstallOptions } from '../lib/types.ts'; + +/** One relayed hook's installed state in a harness config, keyed by the harness's own name for the hook. */ +export interface HookEntryStatus { + readonly hook: string; + readonly status: ManagedEntryStatus; +} + +/** + * Executes the configure-hooks command: writes the hook entries into each targeted harness's config file, or prints + * them without writing when `print` is set. + */ +export async function configureHooksCommand( + options: Pick, + baseDir?: string, +): Promise { + const harnesses = resolveHarnessIds(options.harness, baseDir); + if (harnesses.length === 0) { + console.info('No target harnesses detected. Nothing to configure.'); + return; + } + + for (const harnessId of harnesses) { + if (options.print === true) { + printHarnessHookEntries(harnessId, baseDir); + } else { + await ensureHarnessHookEntries(harnessId, baseDir); + } + } +} + +/** Reports each relayed hook's entry status in the harness's config file. A missing file reports every hook absent. */ +export async function checkHarnessHookEntries( + harnessId: HarnessId, + baseDir?: string, +): Promise> { + const paths = resolveHarnessPaths(harnessId, baseDir); + if (harnessId === 'claude') { + const checks = await checkClaudeHookEntries(paths.configFile, buildClaudeHookEntries(), HOOK_SENTINEL); + return checks.map((check) => ({ hook: check.entry.event, status: check.status })); + } + const checks = await checkRovoHookEntries(paths.configFile, buildRovoHookEntries(paths.scriptsDir), isSentinelOwned); + return checks.map((check) => ({ hook: check.entry.name, status: check.status })); +} + +/** + * Writes the harness's hook entries into its config file, creating it when absent, and reports what happened. On Rovo + * a change earns the restart reminder: the config is read at startup, so a running session ignores new hooks. + */ +export async function ensureHarnessHookEntries(harnessId: HarnessId, baseDir?: string): Promise { + const paths = resolveHarnessPaths(harnessId, baseDir); + const result = + harnessId === 'claude' + ? await ensureClaudeHookEntries(paths.configFile, buildClaudeHookEntries(), HOOK_SENTINEL) + : await ensureRovoHookEntries(paths.configFile, buildRovoHookEntries(paths.scriptsDir), isSentinelOwned); + + console.info( + result.changed + ? ` ✅ Wired session-lifecycle hooks in ${paths.configFile}` + : ` Session-lifecycle hooks already wired in ${paths.configFile}`, + ); + if (result.changed && harnessId === 'rovodev') { + console.info(' ⚠️ Rovo Dev reads its config at startup: restart any running session to pick up the hooks.'); + } +} + +/** Deletes the harness's sentinel-marked hook entries from its config file, leaving everything else untouched. */ +export async function removeHarnessHookEntries(harnessId: HarnessId, baseDir?: string): Promise { + const paths = resolveHarnessPaths(harnessId, baseDir); + const result = + harnessId === 'claude' + ? await removeClaudeHookEntries(paths.configFile, HOOK_SENTINEL) + : await removeRovoHookEntries(paths.configFile, isSentinelOwned); + + if (result.changed) { + console.info(` ✅ Removed ${result.removedCount} session-lifecycle hook entries from ${paths.configFile}`); + if (harnessId === 'rovodev') { + console.info(' ⚠️ Rovo Dev reads its config at startup: restart any running session to drop the hooks.'); + } + } +} + +/** The Claude hook entries as the JSON fragment to merge into `settings.json` — also the manual-adoption snippet. */ +export function renderClaudeHookSnippet(): string { + const hooks: Record = {}; + for (const entry of buildClaudeHookEntries()) { + hooks[entry.event] = [entry.group]; + } + return JSON.stringify({ hooks }, undefined, 2); +} + +/** The Rovo hook entries as the YAML fragment to merge into `config.yml` — also the manual-adoption snippet. */ +export function renderRovoHookSnippet(scriptsDir: string): string { + const events = buildRovoHookEntries(scriptsDir).map((entry) => ({ + name: entry.name, + commands: entry.commands.map((command) => ({ command })), + })); + // Wrapping is disabled so each command stays one line, matching what the config writer emits. + return stringify({ eventHooks: { events } }, { lineWidth: 0 }); +} + +// region | Helpers + +/** Prints the harness's hook entries as a copyable snippet, headed by the config file they belong in. */ +function printHarnessHookEntries(harnessId: HarnessId, baseDir?: string): void { + const paths = resolveHarnessPaths(harnessId, baseDir); + if (harnessId === 'claude') { + console.info(`# ${paths.configFile} — merge under "hooks"`); + console.info(renderClaudeHookSnippet()); + } else { + console.info(`# ${paths.configFile} — merge under eventHooks.events`); + console.info(renderRovoHookSnippet(paths.scriptsDir)); + } +} + +// endregion | Helpers diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index 69a22a6e..8a013218 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -29,6 +29,7 @@ import type { ManifestEntry, SharedManifest, } from '../lib/types.js'; +import { ensureHarnessHookEntries } from './configure-hooks.ts'; /** * The extensions that ship from `content/scripts/` to a harness home: `.sh` helpers a skill invokes, and `.mjs` bundles @@ -118,6 +119,16 @@ export async function installCommand( ); entries.push(...scriptEntries); + // Wire the session-lifecycle hook entries once the relay script is in place, so the configured commands point at + // a script that exists. `--skip-hooks` leaves the harness config untouched. + if (options.hooks !== false) { + if (options.dryRun) { + console.info(' [hooks] Would wire session-lifecycle hook entries'); + } else { + await ensureHarnessHookEntries(harnessId, baseDir); + } + } + // Install harness-specific guidance file const guidanceEntries = await installHarnessGuidance(contentDir, paths, harnessId, existingByPath, options); entries.push(...guidanceEntries); diff --git a/packages/agents/src/commands/status.ts b/packages/agents/src/commands/status.ts index e474c517..b41acf2c 100644 --- a/packages/agents/src/commands/status.ts +++ b/packages/agents/src/commands/status.ts @@ -1,6 +1,7 @@ import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; import { detectDrift, getManifestPath, readManifest, resolveSharedHome } from '../lib/manifest.js'; -import type { InstallOptions } from '../lib/types.js'; +import type { HarnessId, InstallOptions } from '../lib/types.js'; +import { checkHarnessHookEntries } from './configure-hooks.ts'; /** * Executes the status command, showing the current state of installed items. @@ -22,6 +23,8 @@ export async function statusCommand(options: Pick, ba const harnessManifest = manifest.harnesses[harnessId]; if (!harnessManifest) { console.info(`\n${harnessId}: not installed`); + // Hook entries can exist without an install (configure-hooks alone); stay quiet only when there are none. + await reportHookEntryStatus(harnessId, true, baseDir); continue; } @@ -53,6 +56,36 @@ export async function statusCommand(options: Pick, ba } console.info(` Summary: ${currentCount} current, ${modifiedCount} modified, ${missingCount} missing`); + await reportHookEntryStatus(harnessId, false, baseDir); + } +} + +/** + * Reports the session-lifecycle hook entries' state in the harness's config file. When `quietWhenUnconfigured` is set + * (the harness has no installation), an all-absent result prints nothing rather than noise about a feature not in use. + */ +async function reportHookEntryStatus( + harnessId: HarnessId, + quietWhenUnconfigured: boolean, + baseDir?: string, +): Promise { + const statuses = await checkHarnessHookEntries(harnessId, baseDir); + const presentCount = statuses.filter((entry) => entry.status === 'present').length; + const driftedCount = statuses.filter((entry) => entry.status === 'drifted').length; + const absentCount = statuses.filter((entry) => entry.status === 'absent').length; + + if (absentCount === statuses.length) { + if (!quietWhenUnconfigured) { + console.info(' Hooks: not configured'); + } + return; + } + + console.info(` Hooks: ${presentCount} present, ${driftedCount} drifted, ${absentCount} absent`); + for (const entry of statuses) { + if (entry.status !== 'present') { + console.info(` ${entry.status}: ${entry.hook}`); + } } } diff --git a/packages/agents/src/commands/uninstall.ts b/packages/agents/src/commands/uninstall.ts index 2d4f995e..b9ac0799 100644 --- a/packages/agents/src/commands/uninstall.ts +++ b/packages/agents/src/commands/uninstall.ts @@ -3,6 +3,7 @@ import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; import { removeItem } from '../lib/installer.js'; import { getManifestPath, readManifest, resolveSharedHome, writeManifest } from '../lib/manifest.js'; import type { AgentsManifest, InstallOptions, ManifestEntry, SharedManifest } from '../lib/types.js'; +import { removeHarnessHookEntries } from './configure-hooks.ts'; /** * Executes the uninstall command, removing installed skills, subagents, and guidance files. @@ -28,13 +29,18 @@ export async function uninstallCommand( } for (const harnessId of harnesses) { + console.info(`\nUninstalling for harness: ${harnessId}`); + + // Remove the hook entries regardless of manifest state: they live inside a shared user config rather than as + // tracked files, and configure-hooks can have written them without an install. + await removeHarnessHookEntries(harnessId, baseDir); + const harnessManifest = manifest.harnesses[harnessId]; if (!harnessManifest) { - console.info(`\nNo installation found for harness: ${harnessId}`); + console.info(' No installed items tracked for this harness.'); continue; } - console.info(`\nUninstalling for harness: ${harnessId}`); const paths = resolveHarnessPaths(harnessId, baseDir); const skippedEntries = await removeTrackedEntries(harnessManifest.entries, paths.harnessHome, options.force, ''); From 69f3505f37615a7b56dad28fe6038a36a4c3504c Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 17 Jul 2026 20:31:52 -0700 Subject: [PATCH 8/9] agents|fix: Keep hook-wiring failures non-fatal outside configure-hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A harness config that cannot be parsed no longer aborts `install`, `uninstall`, or `status`: the hook step is skipped with a warning naming the file, and everything else — copied files, tracked removals, the manifest update, the rest of the status report — completes as usual. Running `configure-hooks` directly still fails loudly, since fixing the config is that command's whole purpose. --- .../src/commands/__tests__/install.test.ts | 21 ++++++++++++++++ .../src/commands/__tests__/status.test.ts | 25 +++++++++++++++++++ .../src/commands/__tests__/uninstall.test.ts | 25 ++++++++++++++++++- packages/agents/src/commands/install.ts | 12 +++++++-- packages/agents/src/commands/status.ts | 11 ++++++-- packages/agents/src/commands/uninstall.ts | 9 +++++-- 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/agents/src/commands/__tests__/install.test.ts b/packages/agents/src/commands/__tests__/install.test.ts index b53a0e6c..63aad478 100644 --- a/packages/agents/src/commands/__tests__/install.test.ts +++ b/packages/agents/src/commands/__tests__/install.test.ts @@ -325,6 +325,27 @@ describe(installCommand, () => { expect(existsSync(path.join(claudeHome, 'settings.json'))).toBe(false); }); + + it('warns and completes the install when the harness config cannot be parsed', async () => { + const claudeHome = await setupClaudeHome(); + const settingsPath = path.join(claudeHome, 'settings.json'); + await writeFile(settingsPath, '{ not json', 'utf8'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let warnLines: ReadonlyArray; + try { + await installCommand(makeOptions({ harness: 'claude' }), tempDir, contentDir); + warnLines = warnSpy.mock.calls.map((call) => String(call[0])); + } finally { + warnSpy.mockRestore(); + } + + expect(warnLines.some((line) => line.includes('Skipping hook wiring'))).toBe(true); + // The broken config is left alone, and the rest of the install still lands and is tracked. + expect(await readFile(settingsPath, 'utf8')).toBe('{ not json'); + const manifest = await readManifest(getManifestPath(tempDir)); + expect(manifest.harnesses.claude?.entries.length).toBeGreaterThan(0); + }); }); describe('scripts', () => { diff --git a/packages/agents/src/commands/__tests__/status.test.ts b/packages/agents/src/commands/__tests__/status.test.ts index 39b3786e..bd9a996b 100644 --- a/packages/agents/src/commands/__tests__/status.test.ts +++ b/packages/agents/src/commands/__tests__/status.test.ts @@ -62,6 +62,31 @@ describe('statusCommand', () => { infoSpy.mockRestore(); }); + it('warns and completes the report when the harness config cannot be parsed', async () => { + const claudeHome = path.join(tempDir, '.claude'); + await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); + await mkdir(path.join(claudeHome, 'agents'), { recursive: true }); + + await installCommand(makeInstallOptions({ hooks: false }), tempDir, contentDir); + await writeFile(path.join(claudeHome, 'settings.json'), '{ not json', 'utf8'); + + const infoSpy = vi.spyOn(console, 'info'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let output: string; + let warnLines: ReadonlyArray; + try { + await statusCommand({ harness: 'claude' }, tempDir); + output = infoSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + warnLines = warnSpy.mock.calls.map((call) => String(call[0])); + } finally { + infoSpy.mockRestore(); + warnSpy.mockRestore(); + } + + expect(warnLines.some((line) => line.includes('could not read the config'))).toBe(true); + expect(output).toContain('Summary:'); + }); + it('reports hooks as not configured after a --skip-hooks install', async () => { const claudeHome = path.join(tempDir, '.claude'); await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); diff --git a/packages/agents/src/commands/__tests__/uninstall.test.ts b/packages/agents/src/commands/__tests__/uninstall.test.ts index e19e5128..19ccbbe4 100644 --- a/packages/agents/src/commands/__tests__/uninstall.test.ts +++ b/packages/agents/src/commands/__tests__/uninstall.test.ts @@ -4,7 +4,7 @@ import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { computeContentHash, getManifestPath, readManifest, writeManifest } from '../../lib/manifest.ts'; import type { AgentsManifest, InstallOptions } from '../../lib/types.ts'; @@ -64,6 +64,29 @@ describe('uninstallCommand', () => { return { linkPath, source }; } + it('warns and still removes tracked items when the harness config cannot be parsed', async () => { + const claudeHome = path.join(tempDir, '.claude'); + await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); + await mkdir(path.join(claudeHome, 'agents'), { recursive: true }); + + await installCommand(makeInstallOptions({ hooks: false }), tempDir, contentDir); + const settingsPath = path.join(claudeHome, 'settings.json'); + await writeFile(settingsPath, '{ not json', 'utf8'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let warnLines: ReadonlyArray; + try { + await uninstallCommand({ harness: 'claude', force: false }, tempDir); + warnLines = warnSpy.mock.calls.map((call) => String(call[0])); + } finally { + warnSpy.mockRestore(); + } + + expect(warnLines.some((line) => line.includes('Skipping hook-entry removal'))).toBe(true); + const manifest = await readManifest(getManifestPath(tempDir)); + expect(manifest.harnesses.claude).toBeUndefined(); + }); + it('removes the session-lifecycle hook entries but not foreign settings content', async () => { const claudeHome = path.join(tempDir, '.claude'); await mkdir(path.join(claudeHome, 'skills'), { recursive: true }); diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index 8a013218..6d9a7f9e 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -120,12 +120,20 @@ export async function installCommand( entries.push(...scriptEntries); // Wire the session-lifecycle hook entries once the relay script is in place, so the configured commands point at - // a script that exists. `--skip-hooks` leaves the harness config untouched. + // a script that exists. `--skip-hooks` leaves the harness config untouched. A failure — an unparseable config — + // costs the hooks a warning, never the rest of the install: the manifest must still record what was copied. if (options.hooks !== false) { if (options.dryRun) { console.info(' [hooks] Would wire session-lifecycle hook entries'); } else { - await ensureHarnessHookEntries(harnessId, baseDir); + try { + await ensureHarnessHookEntries(harnessId, baseDir); + } catch (error) { + console.warn( + ` ⚠️ Skipping hook wiring: ${error instanceof Error ? error.message : String(error)} ` + + '(fix the config, then run configure-hooks)', + ); + } } } diff --git a/packages/agents/src/commands/status.ts b/packages/agents/src/commands/status.ts index b41acf2c..9435fb38 100644 --- a/packages/agents/src/commands/status.ts +++ b/packages/agents/src/commands/status.ts @@ -1,7 +1,7 @@ import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; import { detectDrift, getManifestPath, readManifest, resolveSharedHome } from '../lib/manifest.js'; import type { HarnessId, InstallOptions } from '../lib/types.js'; -import { checkHarnessHookEntries } from './configure-hooks.ts'; +import { checkHarnessHookEntries, type HookEntryStatus } from './configure-hooks.ts'; /** * Executes the status command, showing the current state of installed items. @@ -69,7 +69,14 @@ async function reportHookEntryStatus( quietWhenUnconfigured: boolean, baseDir?: string, ): Promise { - const statuses = await checkHarnessHookEntries(harnessId, baseDir); + let statuses: ReadonlyArray; + try { + statuses = await checkHarnessHookEntries(harnessId, baseDir); + } catch (error) { + // An unparseable config is itself a status worth reporting; it must not abort the rest of the report. + console.warn(` ⚠️ Hooks: could not read the config: ${error instanceof Error ? error.message : String(error)}`); + return; + } const presentCount = statuses.filter((entry) => entry.status === 'present').length; const driftedCount = statuses.filter((entry) => entry.status === 'drifted').length; const absentCount = statuses.filter((entry) => entry.status === 'absent').length; diff --git a/packages/agents/src/commands/uninstall.ts b/packages/agents/src/commands/uninstall.ts index b9ac0799..0cdda205 100644 --- a/packages/agents/src/commands/uninstall.ts +++ b/packages/agents/src/commands/uninstall.ts @@ -32,8 +32,13 @@ export async function uninstallCommand( console.info(`\nUninstalling for harness: ${harnessId}`); // Remove the hook entries regardless of manifest state: they live inside a shared user config rather than as - // tracked files, and configure-hooks can have written them without an install. - await removeHarnessHookEntries(harnessId, baseDir); + // tracked files, and configure-hooks can have written them without an install. An unparseable config costs the + // hook removal a warning, never the removal of the tracked items or the manifest update. + try { + await removeHarnessHookEntries(harnessId, baseDir); + } catch (error) { + console.warn(` ⚠️ Skipping hook-entry removal: ${error instanceof Error ? error.message : String(error)}`); + } const harnessManifest = manifest.harnesses[harnessId]; if (!harnessManifest) { From 47efced7398404d7852818cb5313b39a8a514a77 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 17 Jul 2026 20:31:54 -0700 Subject: [PATCH 9/9] agents|fix: Print hook snippets without requiring harness homes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `configure-hooks --print` now emits the snippets for every requested harness even on a machine with no harness home directories — printing writes nothing, and its audience is exactly the reader whose config lives elsewhere. Previously the default `--harness all` printed nothing there. --- .../__tests__/configure-hooks.test.ts | 24 +++++++++++++++++++ .../agents/src/commands/configure-hooks.ts | 7 ++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/agents/src/commands/__tests__/configure-hooks.test.ts b/packages/agents/src/commands/__tests__/configure-hooks.test.ts index 5ea4c8cc..e36597d5 100644 --- a/packages/agents/src/commands/__tests__/configure-hooks.test.ts +++ b/packages/agents/src/commands/__tests__/configure-hooks.test.ts @@ -56,6 +56,30 @@ describe('configure-hooks', () => { expect(config).toContain('on_session_start'); }); + it('prints snippets for every harness when no harness home exists', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + let output: string; + try { + await configureHooksCommand({ harness: 'all', print: true }, tempDir); + output = infoSpy.mock.calls.map((call) => String(call[0])).join('\n'); + } finally { + infoSpy.mockRestore(); + } + + expect(output).toContain('"SessionStart"'); + expect(output).toContain('on_session_start'); + expect(existsSync(path.join(tempDir, '.claude'))).toBe(false); + expect(existsSync(path.join(tempDir, '.rovodev'))).toBe(false); + }); + + it('fails loudly when run standalone against an unparseable config', async () => { + const settingsPath = path.join(tempDir, '.claude', 'settings.json'); + await mkdir(path.dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, '{ not json', 'utf8'); + + await expect(configureHooksCommand({ harness: 'claude' }, tempDir)).rejects.toThrow(/Cannot parse/); + }); + it('writes nothing in print mode', async () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); let output: string; diff --git a/packages/agents/src/commands/configure-hooks.ts b/packages/agents/src/commands/configure-hooks.ts index b24ad2ae..2dffcb8f 100644 --- a/packages/agents/src/commands/configure-hooks.ts +++ b/packages/agents/src/commands/configure-hooks.ts @@ -15,7 +15,7 @@ import { ensureClaudeHookEntries, removeClaudeHookEntries, } from '../lib/claude-hook-settings.ts'; -import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.ts'; +import { ALL_HARNESS_IDS, resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.ts'; import { buildClaudeHookEntries, buildRovoHookEntries, @@ -40,7 +40,10 @@ export async function configureHooksCommand( options: Pick, baseDir?: string, ): Promise { - const harnesses = resolveHarnessIds(options.harness, baseDir); + // Printing writes nothing, so it does not gate on which harness homes exist — the manual-adoption reader may not + // have the harness materialized on this machine at all. + const harnesses = + options.print === true && options.harness === 'all' ? ALL_HARNESS_IDS : resolveHarnessIds(options.harness, baseDir); if (harnesses.length === 0) { console.info('No target harnesses detected. Nothing to configure.'); return;