diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9de8292..b439b67 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "0.8.0", + "version": "0.9.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination, plus the Captain's Bridge mod, which opens a pane bound to the current conversation with /captains-bridge.", "author": { "name": "Machine Wisdom", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 41703d4..75f3467 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "0.8.0", + "version": "0.9.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a953a..e05f59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to Hermes Helmet are recorded here. The project follows ## Unreleased +### 0.9.0 + +Show Me and Retro in the Claude Bridge. + +- Run the Captain’s installed Show Me and Retro commands from the Claude + Bridge pane + ([#71](https://github.com/MachineWisdomAI/hermes-helmet/issues/71), part of + [#55](https://github.com/MachineWisdomAI/hermes-helmet/issues/55)). + Availability comes from `$.command.list()` when the Bridge opens and on each + refresh, for `showMeCommand`/`retroCommand` or `:`. A missing + command is a disabled action with the install wording and sends nothing; an + unknown-name rejection returns to that state. A present command calls + `$.command.run` once with the scope “this session” or the selected item’s + title, evidence refs and cited timestamps, relying on the host’s queue to wait + for an idle first officer. `pending` ignores repeat presses until settlement. + No prompt, subagent or cross-session send is used and neither skill is + bundled. The CI allowlist check now accepts the `(via helper)` annotation that + `claude plugin validate` adds to calls made in helper functions, while still + checking every call name. + ### 0.8.0 - Add the Captain’s Bridge mod skeleton to the Claude plugin diff --git a/docs/first-officer-plugins.md b/docs/first-officer-plugins.md index 4580005..0de3a81 100644 --- a/docs/first-officer-plugins.md +++ b/docs/first-officer-plugins.md @@ -81,9 +81,24 @@ session ID, starts no turn and messages no one, so it can be opened while the first officer is working and at narrow terminal widths. It never opens by itself and runs no timers. In VS Code and headless or SDK sessions, where no pane draws, the command returns a short text status instead. The walkthrough, -refresh, background explanation, Show Me and Retro arrive in later releases +refresh and the background explanation arrive in later releases under the same state contract. +Show Me and Retro run your own installed commands in this same session. The +Bridge checks `$.command.list()` when it opens and on every refresh, and accepts +the configured name (`showMeCommand`, default `show-me`; `retroCommand`, default +`retro`) or `:`. When the command is present, the button calls +`$.command.run` once, with the scope "this session" or, for a selected item, its +title with its evidence refs and the timestamps of the records it cites. Claude +Code queues that run until the first officer is idle, so it never interrupts a +turn; the Bridge submits no prompt, starts no subagent and sends nothing to +another session. Retro asks for suggestions and applies none. While a request +is unsettled the button shows "(queued)" and repeat presses are ignored. When +the command is missing the action is disabled and sends nothing: "Show Me isn't +installed. Install a skill named show-me, or set showMeCommand." (and the same +for Retro). If the host rejects the name as unknown, the action returns to that +missing state. The Bridge bundles neither skill and works fully without them. + If the conversation changes (`/clear`, `/resume`, a fork, or the session ends), the Bridge ends its binding and shows exactly: "This conversation changed. Run /captains-bridge to open the Bridge for it." It never rebinds the @@ -105,8 +120,8 @@ Requirements: Settings (plugin `userConfig`, defaults in parentheses): `helmetCommand` (`helmet`), `explanationModel` (`sonnet`), `explanationTimeoutSeconds` (`180`), -`showMeCommand` (`show-me`), `retroCommand` (`retro`). The last four take -effect with later releases. +`showMeCommand` (`show-me`), `retroCommand` (`retro`). `showMeCommand` and +`retroCommand` take effect now; the other three take effect with later releases. Update with `claude plugin update hermes-helmet@hermes-helmet`, then `/reload-plugins` or start a new session. Third-party marketplaces do not @@ -117,8 +132,9 @@ The mod's calls are limited in CI to a read-only allowlist: `$.session.id`, `$.command.list`, `$.command.run`, `$.ui.open`, `$.ui.resolve`, `$.ui.status`, `$.ui.toast`, `$.state.get`, `$.state.set` and `$.clock.after`. It cannot write files, spawn agents, submit prompts, send messages or reach the network. This -slice uses only `$.command.register`, `$.session.id`, `$.session.version`, -`$.state.get`, `$.state.set`, `$.ui.open` and `$.ui.resolve`. +slice uses only `$.command.list`, `$.command.register`, `$.command.run`, +`$.session.id`, `$.session.version`, `$.state.get`, `$.state.set`, `$.ui.open` +and `$.ui.resolve`. ### Codex diff --git a/hooks/actions.test.tsx b/hooks/actions.test.tsx new file mode 100644 index 0000000..145672b --- /dev/null +++ b/hooks/actions.test.tsx @@ -0,0 +1,303 @@ +// Tests for Show Me and Retro. They stand in for $.command.list and +// $.command.run, mount the pane on the terminal and the desktop and press the +// keyed buttons. Every fixture is synthetic. +import { describe, expect, test } from 'claude-code/testing' +import { availabilityOf, missingMessage, scopeArgs } from './register' + +const SURFACES = ['terminal', 'desktop'] as const +const REF = (n: number) => n.toString(16).padStart(32, '0') + +const PANE = { + plugin: 'hermes-helmet', + component: 'Pane', + requestId: 'captains-bridge', + props: { title: "Captain's Bridge", isFocused: true, bodyColumns: 80, placement: 'dock' }, +} as const + +const SHOW_ME_MISSING = + "Show Me isn't installed. Install a skill named show-me, or set showMeCommand." +const RETRO_MISSING = "Retro isn't installed. Install a skill named retro, or set retroCommand." + +const RECORD_LIST = [ + { ref: REF(1), parentRef: null, agentId: null, timestamp: '2026-10-09T16:00:00Z', role: 'user', origin: 'person', text: 'Add the widget.', truncated: false }, + { ref: REF(2), parentRef: null, agentId: null, timestamp: '2026-10-09T16:02:30Z', role: 'assistant', origin: 'assistant', text: 'Opened the PR.', truncated: false }, +] +const COVERAGE = { recordsTotal: 2, recordsIncluded: 2, bytesOmitted: 0, unsupportedSkipped: 0, pendingTail: false } +const RECORDS = { + fingerprint: '2:uuid-2', + readAt: '2026-10-09T17:20:00Z', + coverage: COVERAGE, + summary: { + schema: 'hermes-helmet.bridge.records/1', + helmetVersion: '0.9.0', + sessionId: 'sess-a', + readAt: '2026-10-09T17:20:00Z', + fingerprint: '2:uuid-2', + records: RECORD_LIST, + coverage: COVERAGE, + warnings: [], + }, +} + +const WALKTHROUGH = { + body: { + objective: 'Ship the synthetic widget', + summary: 'The widget was added.', + evidence: [REF(1)], + items: [ + { id: 'widget', title: 'Add the synthetic widget', group: 'changed', status: 'Merged', summary: 's', detail: 'd', evidence: [REF(1), REF(2)] }, + ], + }, + readAt: '2026-10-09T17:00:00Z', + fingerprint: '2:uuid-2', + preparedAt: '2026-10-09T17:01:00Z', +} + +// Scope plumbing in the skeleton state: this slice reads the selected item +// from `view`, so it is testable before the overview/detail slice lands. A +// stand-in answers the mod's read of that selection. +const fixture: { selected: string | null; seeded: boolean } = { selected: null, seeded: true } + +type Harness = { + listed: { current: any } + listCalls: number + runs: Array<{ command: string; args: string }> + turns: number + models: number + gates: Array<() => void> + mode: { current: 'immediate' | 'delayed' | 'unknown' | 'error' } +} + +function host(on: any, opts: { listed?: any; seeded?: boolean } = {}): Harness { + const h: Harness = { + listed: { current: opts.listed ?? [] }, + listCalls: 0, + runs: [], + turns: 0, + models: 0, + gates: [], + mode: { current: 'immediate' }, + } + fixture.selected = null + fixture.seeded = opts.seeded ?? true + on('state.get', async (_$: any, e: any, next: any) => { + if (fixture.seeded && e.key === 'walkthrough') return { value: { value: WALKTHROUGH, version: 1 } } + if (fixture.seeded && e.key === 'records') return { value: { value: RECORDS, version: 1 } } + if (e.key === 'view' && fixture.selected !== null) { + return { value: { value: { stack: [{ itemId: fixture.selected }] }, version: 1 } } + } + return next(e) + }) + on('session.version', () => ({ value: { version: '2.1.296' } })) + on('session.id', () => ({ value: 'sess-a' })) + on('session.start', () => ({ cwd: '/work' })) + on('classic.SessionStart', () => ({})) + on('command.register', (_$: any, e: any) => ({ value: { command: e.name } })) + on('ui.open', () => ({ value: { isPlaced: true } })) + on('command.list', () => { + h.listCalls += 1 + return { value: h.listed.current } + }) + on('command.run', async (_$: any, e: any) => { + if (e.command === 'captains-bridge') return { text: 'open' } + h.runs.push({ command: e.command, args: e.args }) + if (h.mode.current === 'unknown') throw new Error(`$.command.run: no command named /${e.command} in this session`) + if (h.mode.current === 'error') throw new Error('queue refused') + if (h.mode.current === 'delayed') await new Promise(resolve => h.gates.push(resolve)) + return { text: 'queued' } + }) + for (const event of ['prompt.submit', 'prompt.fill', 'session.send', 'agent.spawn']) { + on(event, () => { + h.turns += 1 + return {} + }) + } + on('model.complete', () => { + h.models += 1 + return {} + }) + return h +} + +function open($: any) { + return $.command.run({ + command: 'captains-bridge', + args: '', + origin: { kind: 'composer' }, + presentation: { isFullscreen: true, columns: 100 }, + } as any) +} + +const names = (...n: string[]) => n.map(name => ({ name, description: 'x' })) +const tick = () => new Promise(resolve => setTimeout(resolve, 20)) + +describe('availability', () => { + const defaults = { 'show-me': 'show-me', retro: 'retro' } + + test('unqualified, namespaced and configured names are accepted', () => { + expect(availabilityOf(names('show-me', 'retro'), defaults)).toEqual({ 'show-me': 'show-me', retro: 'retro' }) + expect(availabilityOf(names('captain-kit:show-me', 'captain-kit:retro'), defaults)).toEqual({ + 'show-me': 'captain-kit:show-me', + retro: 'captain-kit:retro', + }) + expect(availabilityOf(names('walk', 'look-back'), { 'show-me': 'walk', retro: 'look-back' })).toEqual({ + 'show-me': 'walk', + retro: 'look-back', + }) + expect(availabilityOf({ commands: names('retro') }, defaults)).toEqual({ 'show-me': null, retro: 'retro' }) + }) + + test('similar names do not match', () => { + expect(availabilityOf(names('show-me-more', 'x:retro-old', 'noretro'), defaults)).toEqual({ + 'show-me': null, + retro: null, + }) + }) + + test('the missing wording names the skill and the setting', () => { + expect(missingMessage('show-me', 'show-me')).toBe(SHOW_ME_MISSING) + expect(missingMessage('retro', 'retro')).toBe(RETRO_MISSING) + }) +}) + +describe('scope', () => { + test('is the whole session without a selected item', () => { + expect(scopeArgs(null, null)).toBe('this session') + }) + + test('is the item title with evidence refs and cited timestamps', () => { + const args = scopeArgs({ title: 'Add the synthetic widget', evidence: [REF(1), REF(2), REF(9)] }, RECORD_LIST) + expect(args).toContain('Add the synthetic widget') + expect(args).toContain(`${REF(1)} (2026-10-09T16:00:00Z)`) + expect(args).toContain(`${REF(2)} (2026-10-09T16:02:30Z)`) + expect(args).toContain(REF(9)) + }) +}) + +for (const surface of SURFACES) { + describe(`Show Me and Retro on ${surface}`, () => { + test('are disabled with the missing wording and send nothing', async ($, on) => { + const h = host(on) + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + expect(await pane.find({ text: SHOW_ME_MISSING })).not.toBe(undefined) + expect(await pane.find({ text: RETRO_MISSING })).not.toBe(undefined) + expect(await pane.find({ key: 'show-me' })).toBe(undefined) + expect(await pane.find({ key: 'retro' })).toBe(undefined) + expect(await pane.find({ text: "Captain's Bridge" })).not.toBe(undefined) + expect(h.runs).toEqual([]) + await pane.unmount() + }) + + test('discovery runs when the Bridge opens', async ($, on) => { + const h = host(on, { listed: names('show-me') }) + await open($) + expect(h.listCalls).toBeGreaterThan(0) + const pane = await $.ui.mount({ ...PANE, surface }) + expect(await pane.find({ key: 'show-me' })).not.toBe(undefined) + expect(await pane.find({ text: RETRO_MISSING })).not.toBe(undefined) + await pane.unmount() + }) + + test('a press runs the whole-session scope once, with no prompt or subagent', async ($, on) => { + const h = host(on, { listed: names('show-me', 'retro'), seeded: false }) + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'show-me' }) + expect(h.runs).toEqual([{ command: 'show-me', args: 'this session' }]) + await pane.press({ key: 'retro' }) + expect(h.runs[1]).toEqual({ command: 'retro', args: 'this session' }) + expect(h.turns).toBe(0) + expect(h.models).toBe(0) + await pane.unmount() + }) + + test('a namespaced command runs under its listed name', async ($, on) => { + const h = host(on, { listed: names('captain-kit:retro') }) + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'retro' }) + expect(h.runs).toEqual([{ command: 'captain-kit:retro', args: 'this session' }]) + await pane.unmount() + }) + + test('the selected item scope carries title, refs and timestamps', async ($, on) => { + const h = host(on, { listed: names('show-me', 'retro') }) + await open($) + fixture.selected = 'widget' + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'retro' }) + expect(h.runs.length).toBe(1) + expect(h.runs[0].command).toBe('retro') + expect(h.runs[0].args).toContain('Add the synthetic widget') + expect(h.runs[0].args).toContain(`${REF(1)} (2026-10-09T16:00:00Z)`) + expect(h.runs[0].args).toContain(`${REF(2)} (2026-10-09T16:02:30Z)`) + await pane.unmount() + }) + + test('one queued call during delayed settlement; repeat presses are ignored', async ($, on) => { + const h = host(on, { listed: names('show-me', 'retro') }) + h.mode.current = 'delayed' + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + const first = pane.press({ key: 'show-me' }) + await tick() + expect(h.runs.length).toBe(1) + await pane.press({ key: 'show-me' }) + await pane.press({ key: 'show-me' }) + // Retro shares the single pending slot while Show Me is unsettled. + await pane.press({ key: 'retro' }) + expect(h.runs.length).toBe(1) + h.gates.forEach(release => release()) + await first + h.mode.current = 'immediate' + await pane.press({ key: 'show-me' }) + expect(h.runs.length).toBe(2) + expect(h.turns).toBe(0) + await pane.unmount() + }) + + test('an error settles pending, shows a notice and keeps the button', async ($, on) => { + const h = host(on, { listed: names('show-me') }) + h.mode.current = 'error' + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'show-me' }) + expect(await pane.find({ text: "Show Me couldn't be queued. Try again." })).not.toBe(undefined) + expect(await pane.find({ key: 'show-me' })).not.toBe(undefined) + h.mode.current = 'immediate' + await pane.press({ key: 'show-me' }) + expect(h.runs.length).toBe(2) + expect(await pane.find({ text: "Show Me couldn't be queued. Try again." })).toBe(undefined) + await pane.unmount() + }) + + test('an unknown-name rejection returns to the missing state', async ($, on) => { + const h = host(on, { listed: names('show-me', 'retro') }) + h.mode.current = 'unknown' + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + // The host rejects the name and no longer lists it. + h.listed.current = names('retro') + await pane.press({ key: 'show-me' }) + expect(h.runs.length).toBe(1) + expect(await pane.find({ text: SHOW_ME_MISSING })).not.toBe(undefined) + expect(await pane.find({ key: 'show-me' })).toBe(undefined) + expect(await pane.find({ key: 'retro' })).not.toBe(undefined) + expect(await pane.find({ text: "Captain's Bridge" })).not.toBe(undefined) + await pane.unmount() + }) + + test('the explanation stays intact around a press', async ($, on) => { + const h = host(on, { listed: names('show-me') }) + await open($) + fixture.selected = 'widget' + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'show-me' }) + expect(h.runs.length).toBe(1) + expect(await pane.find({ text: 'Objective: Ship the synthetic widget' })).not.toBe(undefined) + expect(await pane.find({ text: '- Add the synthetic widget (Merged)' })).not.toBe(undefined) + await pane.unmount() + }) + }) +} diff --git a/hooks/register.tsx b/hooks/register.tsx index c9d9db7..47d8d19 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -27,6 +27,8 @@ const walkthrough = atom( { plugin: 'hermes-helmet', key: 'walkthrough' } as const, null, ) +const view = atom({ plugin: 'hermes-helmet', key: 'view' } as const, { stack: [] }) +const pending = atom({ plugin: 'hermes-helmet', key: 'pending' } as const, null) const request = atom( { plugin: 'hermes-helmet', key: 'request' } as const, { generation: 0, status: 'idle', retain: false }, @@ -85,11 +87,190 @@ export function overviewText( return lines.join('\n') } +// --------------------------------------------------------------------------- +// Show Me and Retro. They run the Captain's own installed commands in this +// exact session through $.command.run, which the host queues until the first +// officer is idle. Nothing is bundled, no prompt is submitted or filled, no +// subagent starts and nothing goes to another session. + +export type Action = 'show-me' | 'retro' +export type ActionNames = { 'show-me': string; retro: string } +export type Availability = Record + +export const ACTION_LABELS: Record = { 'show-me': 'Show Me', retro: 'Retro' } +export const DEFAULT_NAMES: ActionNames = { 'show-me': 'show-me', retro: 'retro' } + +export function missingMessage(action: Action, configured: string): string { + const key = action === 'show-me' ? 'showMeCommand' : 'retroCommand' + return `${ACTION_LABELS[action]} isn't installed. Install a skill named ${configured}, or set ${key}.` +} + +export function actionNames(options: unknown): ActionNames { + const bag = (options ?? {}) as Record + const pick = (value: unknown, fallback: string): string => + typeof value === 'string' && value.trim() !== '' ? value.trim().replace(/^\//, '') : fallback + return { + 'show-me': pick(bag.showMeCommand, DEFAULT_NAMES['show-me']), + retro: pick(bag.retroCommand, DEFAULT_NAMES.retro), + } +} + +// The names $.command.list() reports, whatever the entry shape. +export function listedNames(listed: unknown): string[] { + const raw = Array.isArray(listed) + ? listed + : listed !== null && typeof listed === 'object' && Array.isArray((listed as any).commands) + ? (listed as any).commands + : [] + const names: string[] = [] + for (const entry of raw) { + const name = typeof entry === 'string' ? entry : (entry as { name?: unknown } | null)?.name + if (typeof name === 'string' && name !== '') names.push(name.replace(/^\//, '')) + } + return names +} + +// A configured name matches itself, or `:` for an installed plugin. +export function resolveCommand(configured: string, names: string[]): string | null { + if (names.includes(configured)) return configured + return names.find(name => name.endsWith(`:${configured}`)) ?? null +} + +export function availabilityOf(listed: unknown, names: ActionNames): Availability { + const found = listedNames(listed) + return { + 'show-me': resolveCommand(names['show-me'], found), + retro: resolveCommand(names.retro, found), + } +} + +// Commands the host refused as unknown since the last discovery. +const unknown = new Set() +let availability: Availability | null = null + +// Called when the Bridge opens and on every refresh. +export async function discoverCommands($: any, names: ActionNames): Promise { + unknown.clear() + let listed: unknown = [] + try { + listed = await $.command.list() + } catch { + listed = [] + } + availability = availabilityOf(listed, names) + return availability +} + +export function commandFor(action: Action): string | null { + return unknown.has(action) || availability === null ? null : availability[action] +} + +type ScopeItem = { title: string; evidence: string[] } + +// "this session", or the selected item's title with its evidence refs and the +// timestamps of the records it cites. +export function scopeArgs( + item: ScopeItem | null, + records: Array<{ ref: string; timestamp: string }> | null, +): string { + if (item === null) return 'this session' + const at = new Map((records ?? []).map(r => [r.ref, r.timestamp])) + const cited = item.evidence.map(ref => { + const when = at.get(ref) + return when === undefined ? ref : `${ref} (${when})` + }) + return cited.length === 0 + ? `${item.title}` + : `${item.title}; evidence: ${cited.join(', ')}` +} + +export async function selectedScope($: any): Promise { + const nav = await read($, view) + const top = nav.stack[nav.stack.length - 1] as { itemId?: string } | undefined + const saved = await read($, walkthrough) + if (top?.itemId === undefined || saved === null) return 'this session' + const item = saved.body.items.find(candidate => candidate.id === top.itemId) + if (item === undefined) return 'this session' + const summary = await read($, records) + return scopeArgs(item, summary === null ? null : summary.summary.records) +} + +const notices: Partial> = {} + +// One press: claim `pending` atomically (repeat presses are ignored), queue +// exactly one command.run, and clear on settlement whatever happened. +export async function runAction($: any, action: Action, configured: string): Promise { + const command = commandFor(action) + if (command === null) return + let claimed = false + await update($, pending, current => { + claimed = current === null + return current === null ? { action, since: new Date().toISOString() } : current + }) + if (!claimed) return + delete notices[action] + try { + const args = await selectedScope($) + await $.command.run({ command, args }) + } catch (error) { + // A rejection naming an unknown command, or a command that is no longer + // listed, is the missing state. Any other rejection is a retryable notice. + const text = error instanceof Error ? error.message : String(error) + let stillListed = true + try { + stillListed = resolveCommand(configured, listedNames(await $.command.list())) !== null + } catch { + stillListed = true + } + if (!stillListed || /no command named|unknown command/i.test(text)) { + unknown.add(action) + } else { + notices[action] = `${ACTION_LABELS[action]} couldn't be queued. Try again.` + } + } finally { + await update($, pending, () => null) + } +} + +export function actionBlock( + ctx: { Box: any; Text: any; Button: any }, + action: Action, + configured: string, + isPending: boolean, + press: () => Promise, +): any { + const { Box, Text, Button } = ctx + const label = ACTION_LABELS[action] + const command = commandFor(action) + if (command === null) { + return ( + + + {label}: {missingMessage(action, configured)} + + + ) + } + return ( + + + {notices[action] === undefined ? null : ( + + {notices[action]} + + )} + + ) +} + // Whether this session draws panes at all. Set at session.start from the // surface the host reports; unknown (null) is decided by what ui.open says. let drawsPanes = true -export const register: Register = on => { +export const register: Register = (on, options) => { + const names = actionNames(options) on('session.start', async ($, e, next) => { drawsPanes = e.isInteractive !== false && e.surface !== 'vscode' await $.command.register({ @@ -155,6 +336,7 @@ export const register: Register = on => { : current, ) } + if (isSupported(version)) await discoverCommands($, names) const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE }) if (!isSupported(version)) return { text: tooOldMessage(version) } if (drawsPanes && opened.isPlaced) { @@ -170,12 +352,13 @@ export const register: Register = on => { }) on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => { - const { Box, Text } = $.ui.resolve(e) + const { Box, Text, Button } = $.ui.resolve(e) const { version } = await $.session.version() const bound = await read($, binding) const status = await read($, request) const saved = await read($, walkthrough) const counts = await read($, records) + const busy = await read($, pending) if (!isSupported(version)) { return ( @@ -196,6 +379,8 @@ export const register: Register = on => { ) } const body = saved === null ? null : saved.body + if (availability === null) await discoverCommands($, names) + const ctx = { Box, Text, Button } return ( {PANE_TITLE} @@ -207,6 +392,17 @@ export const register: Register = on => { .map(line => ( {line} ))} + + {(['show-me', 'retro'] as Action[]).map(action => + actionBlock( + ctx, + action, + names[action], + busy !== null && busy.action === action, + () => runAction($, action, names[action]), + ), + )} + ) }) diff --git a/pyproject.toml b/pyproject.toml index 458ceea..033cc9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "0.8.0" +version = "0.9.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/verify.sh b/scripts/verify.sh index 0abefff..3ad5bf0 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -52,6 +52,7 @@ for required in \ hooks/hooks.json \ hooks/register.tsx \ hooks/register.test.tsx \ + hooks/actions.test.tsx \ types/index.d.ts \ src/hermes_helmet/claude_mod_toolchain.py \ tests/test_claude_mod_toolchain.py \ diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 12bfa8f..0198466 100644 --- a/src/hermes_helmet/__init__.py +++ b/src/hermes_helmet/__init__.py @@ -7,7 +7,7 @@ from hermes_helmet.authority import Policy, load_authority, load_policy, render_crew_contract -__version__ = "0.8.0" +__version__ = "0.9.0" __all__ = [ "Policy", diff --git a/src/hermes_helmet/claude_mod_toolchain.py b/src/hermes_helmet/claude_mod_toolchain.py index a463dd1..69a5dd1 100644 --- a/src/hermes_helmet/claude_mod_toolchain.py +++ b/src/hermes_helmet/claude_mod_toolchain.py @@ -194,6 +194,25 @@ def install(*, bin_dir: Path, version: str = CLAUDE_CODE_VERSION) -> Path: return destination +_VIA = r"(?: \(via [A-Za-z_$][\w$]*(?:, [A-Za-z_$][\w$]*)*\))?" +_CALL_ENTRY = rf"\$\.[a-z][A-Za-z]*\.[a-z][A-Za-z]*{_VIA}" +_CALL_LIST_RE = re.compile(rf"{_CALL_ENTRY}(?:, {_CALL_ENTRY})*") + + +def _split_calls(text: str) -> list[str]: + """Split a ``calls:`` list, dropping the ``(via helper, ...)`` notes. + + ``claude plugin validate`` annotates a call made inside a helper function + with that helper's name. Only that exact annotation is accepted; anything + else in the list is unparseable (an empty result), so every call name is + still checked against the allowlist. + """ + + if _CALL_LIST_RE.fullmatch(text) is None: + return [] + return re.findall(r"\$\.[a-z][A-Za-z]*\.[a-z][A-Za-z]*", text) + + def calls_from_validation(report_text: str) -> dict[str, list[str]]: """Return the ``calls:`` each hooks module makes, per ``claude plugin validate --json``. @@ -221,7 +240,7 @@ def calls_from_validation(report_text: str) -> dict[str, list[str]]: match = _CALLS_RE.match(note) if match is None: continue - names = [part.strip() for part in match.group("calls").split(",")] + names = _split_calls(match.group("calls")) if not names or any(_CALL_RE.match(name) is None for name in names): raise ClaudeModToolchainError(f"validation calls are unparseable: {note!r}") found.setdefault(match.group("module"), []).extend(names) diff --git a/tests/test_claude_mod_toolchain.py b/tests/test_claude_mod_toolchain.py index ce67623..5fa1391 100644 --- a/tests/test_claude_mod_toolchain.py +++ b/tests/test_claude_mod_toolchain.py @@ -150,6 +150,25 @@ def test_calls_within_the_allowlist_pass(self) -> None: ) self.assertEqual(calls, ["$.command.register", "$.session.id", "$.ui.open"]) + def test_helper_annotations_are_dropped_but_every_call_is_checked(self) -> None: + calls = check_mod_calls( + _report( + "./register.tsx calls: $.command.list (via discoverCommands, runAction), " + "$.command.run (via runAction), $.ui.open" + ) + ) + self.assertEqual(calls, ["$.command.list", "$.command.run", "$.ui.open"]) + with self.assertRaisesRegex(ClaudeModToolchainError, r"\$\.fs\.write"): + check_mod_calls(_report("./register.tsx calls: $.fs.write (via helper), $.ui.open")) + for text in ( + "./register.tsx calls: $.ui.open (via )", + "./register.tsx calls: $.ui.open (via a b)", + "./register.tsx calls: $.ui.open (also $.fs.write)", + "./register.tsx calls: $.ui.open (via a), ", + ): + with self.subTest(text=text), self.assertRaises(ClaudeModToolchainError): + calls_from_validation(_report(text)) + def test_forbidden_calls_fail_closed(self) -> None: for call in ( "$.fs.write",