From 0d77a4a46ac394bf27e2f2cb011845b1cf7d1720 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:13:22 +0000 Subject: [PATCH 1/6] feat(bridge): show Changes First details and refresh session records Read the bound session with helmet bridge read, validate schema and session, draw the overview and item detail from one element tree for terminal and desktop, and refresh saved records without a model call or restamping readAt. Refs #69, #55 --- CHANGELOG.md | 19 + docs/first-officer-plugins.md | 36 +- hooks/bridge.test.tsx | 563 ++++++++++++++++ hooks/register.tsx | 769 +++++++++++++++++++++- scripts/verify.sh | 1 + src/hermes_helmet/claude_mod_toolchain.py | 8 +- tests/test_claude_mod_toolchain.py | 10 + tests/test_first_officer_plugin.py | 15 +- types/index.d.ts | 17 +- 9 files changed, 1409 insertions(+), 29 deletions(-) create mode 100644 hooks/bridge.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a953a..bbc70bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to Hermes Helmet are recorded here. The project follows ## Unreleased +### 0.9.0 + +- Show the Captain’s Bridge Changes First view in the Claude mod + ([#69](https://github.com/MachineWisdomAI/hermes-helmet/issues/69), part of + [#55](https://github.com/MachineWisdomAI/hermes-helmet/issues/55)). The pane + runs `helmet bridge read` for the bound session (30 s limit, exact session ID, + known transcript path when present), validates the schema major and session + before adopting it, and draws one element tree for terminal and desktop: + objective and outcome, then What changed, What remains unresolved and Other + recorded activity, with the completion report and the disposition as separate + labeled boxes. Item detail adds actor-labeled steps, View before and after + only where supported, evidence-bound links, elapsed time from cited records, + collapsed supporting records and Back with its place restored. Refresh records + rereads saved records only, keeps the view stack and selection, never restamps + the walkthrough’s `readAt`, and shows “Explanation read records at {readAt}; + {n} newer records since.” Reader failures name the cause and fix and keep the + last useful view. The call-allowlist parser accepts Claude Code’s + `(via helper)` tag. No model is called and nothing is sent. + ### 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..f9ea27a 100644 --- a/docs/first-officer-plugins.md +++ b/docs/first-officer-plugins.md @@ -75,14 +75,42 @@ instructions (and its own MCP server) only. The skills above are unchanged and keep working if your organization policy disables mods; only the Bridge is skipped. -This release is the mod skeleton. Run `/captains-bridge` in any session to open +This release adds the reader and the Changes First view. Run `/captains-bridge` in any session to open the Captain's Bridge pane beside the conversation. It is bound to that exact 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 -under the same state contract. +pane draws, the command returns a short text status instead. The background +explanation, Show Me and Retro arrive in later releases under the same state +contract. + +The Bridge now reads the session's saved records and draws the Changes First +view. Opening the pane (and **Refresh records**) runs +`helmet bridge read --session ` for this exact conversation, adding +`--transcript ` when the plugin saw the path at session start; otherwise +the reader looks the session up by its exact ID. The reader is read-only, the +call stops after 30 seconds, and no model is called and no message is sent. +Until an explanation exists the pane shows record counts, coverage and +**Refresh records**, never the raw transcript. **Update explanation** arrives +in a later release; a walkthrough shown here is drawn from the plugin state +contract, with its objective and outcome first, then *What changed*, *What +remains unresolved* and *Other recorded activity*. Opening an item shows its +explanation, actor-labeled handoff, review and repair steps, the completion +report and the disposition as separate labeled boxes, **View before and +after** only where the records support one, links whose address appears in a +cited record, elapsed time from the cited record timestamps, and the +supporting records collapsed. **Back** restores the previous item and scroll +position. A completed run or a quiet interval is never presented as accepted +delivery or a stall. + +**Refresh records** rereads only the saved records. It keeps your place and +never changes the time the explanation read the records. When the records have +moved on, the header says "Explanation read records at {readAt}; {n} newer +records since." and lists read time and coverage warnings. If `helmet` cannot +be started or is too old, exits without a reader error, prints something that +is not the expected JSON, or speaks an incompatible schema, the pane names the +cause and the fix and keeps the last useful view; set `helmetCommand` to an +absolute path when Claude Desktop does not inherit your shell `PATH`. If the conversation changes (`/clear`, `/resume`, a fork, or the session ends), the Bridge ends its binding and shows exactly: "This conversation diff --git a/hooks/bridge.test.tsx b/hooks/bridge.test.tsx new file mode 100644 index 0000000..0e7b9c1 --- /dev/null +++ b/hooks/bridge.test.tsx @@ -0,0 +1,563 @@ +// Tests for the Captain's Bridge reader integration and Changes First view. +// They stand in for `helmet bridge read` with synthetic process.run answers, +// seed a synthetic walkthrough, mount the pane on the terminal and the +// desktop, and press keyed elements. Every fixture is generated here. +import { describe, expect, test } from 'claude-code/testing' +import { + coverageWarnings, + elapsedOf, + formatElapsed, + readerArgv, + staleNote, +} from './register' + +const SURFACES = ['terminal', 'desktop'] as const +const REF = (n: number) => n.toString(16).padStart(32, '0') +const T0 = '2026-10-09T16:00:00Z' + +const PANE = { + plugin: 'hermes-helmet', + component: 'Pane', + requestId: 'captains-bridge', + props: { title: "Captain's Bridge", isFocused: true, bodyColumns: 80, placement: 'dock' }, +} as const + +function rec(n: number, at: string, text: string, extra: Record = {}) { + return { + ref: REF(n), + parentRef: null, + agentId: null, + timestamp: at, + role: 'assistant', + origin: 'assistant', + text, + truncated: false, + ...extra, + } +} + +const RECORDS = [ + rec(1, '2026-10-09T16:00:00Z', 'Please add the synthetic widget.', { role: 'user', origin: 'person' }), + rec(2, '2026-10-09T16:02:30Z', 'Opened https://example.test/acme/widgets/pull/7 for the widget.'), + rec(3, '2026-10-09T16:10:00Z', 'Review found one repair; fixed in the same branch.', { origin: 'agent', agentId: 'a1' }), + rec(4, '2026-10-09T16:12:05Z', 'Worker reports the widget run complete.'), + rec(5, '2026-10-09T16:20:00Z', 'Gadget still fails on empty input.', { role: 'tool_result', origin: 'assistant', tool: 'Bash' }), +] + +function summary(overrides: Record = {}, sessionId = 'sess-a') { + return { + schema: 'hermes-helmet.bridge.records/1', + helmetVersion: '0.9.0', + sessionId, + readAt: '2026-10-09T17:20:00Z', + fingerprint: '5:uuid-5', + records: RECORDS, + coverage: { recordsTotal: 5, recordsIncluded: 5, bytesOmitted: 0, unsupportedSkipped: 0, pendingTail: false }, + warnings: [], + ...overrides, + } +} + +const ITEMS = [ + { + id: 'widget', + title: 'Add the synthetic widget', + group: 'changed', + status: 'Reported complete', + summary: 'A widget was added in a pull request.', + detail: 'The first officer added a widget and a worker repaired one review finding.', + evidence: [REF(1), REF(2), REF(4)], + steps: [ + { actor: 'First officer', label: 'Opened the pull request', detail: 'PR opened.', evidence: [REF(1), REF(2)] }, + { actor: 'Other agent', label: 'Reviewed and repaired', detail: 'One finding fixed.', evidence: [REF(2), REF(3)] }, + ], + reported: { label: 'The worker reported the run complete', actor: 'Hermes', evidence: [REF(4)] }, + disposition: { label: 'Not yet accepted by the Captain', actor: 'Captain', evidence: [REF(4)] }, + change: { before: 'No widget existed.', after: 'A widget class exists.', explanation: 'From the cited PR.', evidence: [REF(2)] }, + links: [ + { label: 'Widget PR', url: 'https://example.test/acme/widgets/pull/7', evidence: [REF(2)] }, + { label: 'Invented link', url: 'https://example.test/not-in-records', evidence: [REF(2)] }, + ], + }, + { + id: 'gadget', + title: 'Fix the gadget on empty input', + group: 'unresolved', + status: 'Open', + summary: 'The gadget still fails on empty input.', + detail: 'A tool result shows the gadget failing.', + evidence: [REF(5)], + }, + { + id: 'notes', + title: 'Housekeeping', + group: 'activity', + status: 'Recorded', + summary: 'Routine activity.', + detail: 'Nothing notable.', + evidence: [REF(3)], + }, +] + +const BODY = { + objective: 'Ship the synthetic widget', + summary: 'The widget was reported complete; the gadget remains open.', + evidence: [REF(1)], + items: ITEMS, +} + +const WALKTHROUGH = { + body: BODY, + readAt: '2026-10-09T17:00:00Z', + fingerprint: '3:uuid-3', + preparedAt: '2026-10-09T17:01:00Z', +} + +// Only the owner writes the mod's state, and this slice never writes a +// walkthrough (preparing one is a later slice). A test therefore hands a +// walkthrough to the host stand-in (`fixture.walkthrough`), which answers the +// mod's read of it; a stand-in plugin reads the rest back as a +// person-visible string. +const fixture: { walkthrough: unknown } = { walkthrough: null } +const SEED = { + name: 'seed', + register: (on: any) => { + on('command.run', { command: 'probe' }, async ($: any) => { + const view = await $.state.get({ plugin: 'hermes-helmet', key: 'view' }) + const records = await $.state.get({ plugin: 'hermes-helmet', key: 'records' }) + const binding = await $.state.get({ plugin: 'hermes-helmet', key: 'binding' }) + return { + text: JSON.stringify({ + view: view.value ?? null, + records: records.value ?? null, + binding: binding.value ?? null, + }), + } + }) + }, +} +const WITH = { plugins: [SEED] } + +type Harness = { + calls: Array<{ argv: string[]; init: any }> + models: number + turns: number + reply: { current: any } +} + +function host(on: any, reply: any, opts: { id?: string } = {}): Harness { + const h: Harness = { calls: [], models: 0, turns: 0, reply: { current: reply } } + fixture.walkthrough = null + on('state.get', async (_$: any, e: any, next: any) => + e.key === 'walkthrough' && fixture.walkthrough !== null + ? { value: { value: fixture.walkthrough, version: 1 } } + : next(e), + ) + on('session.version', () => ({ value: { version: '2.1.296' } })) + on('session.id', () => ({ value: opts.id ?? '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('process.run', (_$: any, e: any) => { + h.calls.push({ argv: [...e.argv], init: e.init }) + const answer = h.reply.current + if (answer instanceof Error) throw answer + return typeof answer === 'function' ? answer() : { value: answer } + }) + on('model.complete', () => { + h.models += 1 + return {} + }) + on('prompt.submit', () => { + h.turns += 1 + return {} + }) + on('session.send', () => { + h.turns += 1 + return {} + }) + return h +} + +const ok = (payload: unknown) => ({ exitCode: 0, stdout: JSON.stringify(payload), stderr: '' }) + +function run(command: string, args = '') { + return { + command, + args, + origin: { kind: 'composer' }, + presentation: { isFullscreen: true, columns: 100 }, + } as any +} + +async function seed(_$: any, _key: 'walkthrough', value: unknown) { + fixture.walkthrough = value +} + +async function probe($: any): Promise { + return JSON.parse((await $.command.run(run('probe'))).text) +} + +async function texts(pane: any, type = 'Text'): Promise { + return (await pane.findAll({ type })).map((el: any) => el.text) +} + +async function opened($: any, on: any, id = 'sess-a') { + const h = host(on, ok(summary()), { id }) + await $.classic.SessionStart({ + source: 'startup', + session_id: id, + transcript_path: `/synthetic/transcripts/${id}.jsonl`, + }) + await $.command.run(run('captains-bridge')) + return h +} + +describe('the reader call', () => { + test('passes the exact session and known transcript path with a 30 s limit', WITH, async ($, on) => { + const h = await opened($, on) + expect(h.calls.length).toBe(1) + expect(h.calls[0].argv).toEqual([ + 'helmet', 'bridge', 'read', '--session', 'sess-a', + '--transcript', '/synthetic/transcripts/sess-a.jsonl', + ]) + expect(h.calls[0].init.timeoutMs).toBe(30000) + }) + + test('leaves an unknown transcript path to the reader exact-ID lookup', WITH, async ($, on) => { + const h = host(on, ok(summary())) + await $.command.run(run('captains-bridge')) + expect(h.calls[0].argv).toEqual(['helmet', 'bridge', 'read', '--session', 'sess-a']) + }) + + test('uses the configured helmet command', { plugins: [SEED], options: { helmetCommand: '/opt/tools/helmet' } }, async ($, on) => { + const h = host(on, ok(summary())) + await $.command.run(run('captains-bridge')) + expect(h.calls[0].argv[0]).toBe('/opt/tools/helmet') + }) + + test('adopts a valid summary and sends nothing', WITH, async ($, on) => { + const h = await opened($, on) + const state = await probe($) + expect(state.records.readAt).toBe('2026-10-09T17:20:00Z') + expect(state.records.fingerprint).toBe('5:uuid-5') + expect(h.models).toBe(0) + expect(h.turns).toBe(0) + }) + + test('adopts nothing for another session and does not rebind', WITH, async ($, on) => { + const h = host(on, ok(summary({}, 'sess-other'))) + await $.command.run(run('captains-bridge')) + const state = await probe($) + expect(state.records).toBe(null) + expect(state.binding).toEqual({ sessionId: 'sess-a' }) + expect(state.view.notice.text).toContain('different conversation') + expect(h.calls.length).toBe(1) + }) +}) + +describe('the overview', () => { + for (const surface of SURFACES) { + test(`shows the objective and outcome first, then the groups, on ${surface}`, WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface }) + const all = await texts(pane) + const at = (needle: string) => all.findIndex(t => t.includes(needle)) + expect(at('Ship the synthetic widget')).toBeGreaterThan(-1) + expect(at('The widget was reported complete')).toBeGreaterThan(-1) + expect(at('Ship the synthetic widget')).toBeLessThan(at('What changed')) + expect(at('What changed')).toBeLessThan(at('What remains unresolved')) + expect(at('What remains unresolved')).toBeLessThan(at('Other recorded activity')) + // Counts and actions come after the substantive work. + expect(at('Other recorded activity')).toBeLessThan(at('5 of 5 records read')) + const titles = await pane.findAll({ type: 'Button' }) + expect(titles.map((b: any) => b.key)).toEqual([ + 'item:widget', 'item:gadget', 'item:notes', 'refresh', + ]) + expect(await pane.find({ text: 'Fix the gadget on empty input' })).not.toBe(undefined) + expect(await pane.find({ text: 'The gadget still fails on empty input.' })).not.toBe(undefined) + await pane.unmount() + }) + + test(`pairs the completion report and the disposition on ${surface}`, WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface }) + const report = await pane.find({ key: 'report:widget' }) + const disposition = await pane.find({ key: 'disposition:widget' }) + expect(report?.text).toContain('Completion report') + expect(report?.text).toContain('Hermes: The worker reported the run complete') + expect(disposition?.text).toContain('Disposition') + expect(disposition?.text).toContain('Captain: Not yet accepted by the Captain') + expect(report?.props.backgroundColor).toBeTruthy() + expect(disposition?.props.backgroundColor).toBeTruthy() + expect(report?.props.backgroundColor).not.toBe(disposition?.props.backgroundColor) + await pane.unmount() + }) + + test(`draws only the common elements on ${surface}`, WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface }) + for (const type of ['Raster', 'Image', 'Svg', 'Client', 'Markdown']) { + expect(await pane.findAll({ type })).toEqual([]) + } + await pane.unmount() + }) + } + + test('shows a missing disposition as unrecorded, never as accepted', WITH, async ($, on) => { + await opened($, on) + const item = { ...ITEMS[0], disposition: undefined } + await seed($, 'walkthrough', { ...WALKTHROUGH, body: { ...BODY, items: [item, ...ITEMS.slice(1)] } }) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + const disposition = await pane.find({ key: 'disposition:widget' }) + expect(disposition?.text).toContain('No acceptance or rejection is recorded') + expect(disposition?.text).toContain('A completed run is not acceptance') + await pane.unmount() + }) + + test('shows counts, coverage and the refresh action, not the transcript, before a walkthrough', WITH, async ($, on) => { + await opened($, on) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + expect(await pane.find({ text: 'No walkthrough has been prepared' })).not.toBe(undefined) + expect(await pane.find({ text: '5 of 5 records are read.' })).not.toBe(undefined) + expect(await pane.find({ key: 'update-explanation' })).not.toBe(undefined) + expect(await pane.find({ text: 'Please add the synthetic widget.' })).toBe(undefined) + expect(await pane.find({ text: 'Worker reports' })).toBe(undefined) + await pane.unmount() + }) +}) + +describe('the item detail', () => { + for (const surface of SURFACES) { + test(`shows explanation, steps, pair, comparison, links and sources on ${surface}`, WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'item:widget' }) + + expect(await pane.find({ text: 'The first officer added a widget and a worker' })).not.toBe(undefined) + // Steps carry the actor and per-step elapsed time from cited records. + expect(await pane.find({ text: 'First officer: Opened the pull request (elapsed 2m 30s)' })).not.toBe(undefined) + expect(await pane.find({ text: 'Other agent: Reviewed and repaired (elapsed 7m 30s)' })).not.toBe(undefined) + // Item elapsed: first to last cited record (16:00:00 to 16:12:05). + expect(await pane.find({ text: 'elapsed 12m 05s' })).not.toBe(undefined) + expect(await pane.find({ key: 'report:widget' })).toBe(undefined) + expect((await pane.find({ key: 'report' }))?.text).toContain('Completion report') + expect((await pane.find({ key: 'disposition' }))?.text).toContain('Disposition') + + // Comparison is collapsed until requested. + expect(await pane.find({ text: 'No widget existed.' })).toBe(undefined) + await pane.press({ key: 'change-toggle' }) + expect(await pane.find({ text: 'No widget existed.' })).not.toBe(undefined) + expect(await pane.find({ text: 'A widget class exists.' })).not.toBe(undefined) + + // Only a link whose address is in a cited record is drawn. + const links = await pane.findAll({ type: 'Link' }) + expect(links.map((l: any) => l.props.href)).toEqual(['https://example.test/acme/widgets/pull/7']) + + // Supporting records are collapsed, with time and actor once opened. + expect(await pane.find({ text: 'Opened https://example.test' })).toBe(undefined) + await pane.press({ key: 'sources-toggle' }) + expect(await pane.find({ text: '2026-10-09 16:00:00 UTC · Captain' })).not.toBe(undefined) + expect(await pane.find({ text: '2026-10-09 16:02:30 UTC · First officer' })).not.toBe(undefined) + expect(await pane.find({ text: 'Please add the synthetic widget.' })).not.toBe(undefined) + await pane.unmount() + }) + } + + test('offers no before and after, steps or pair when the item has none', WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + await pane.press({ key: 'item:gadget' }) + expect(await pane.find({ key: 'change-toggle' })).toBe(undefined) + expect(await pane.find({ text: 'Handoffs, reviews and repairs' })).toBe(undefined) + expect(await pane.find({ key: 'report' })).toBe(undefined) + expect(await pane.find({ type: 'Link' })).toBe(undefined) + expect(await pane.find({ key: 'sources-toggle' })).not.toBe(undefined) + await pane.unmount() + }) + + test('Back restores the preceding item and scroll position', WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + // The person scrolls the overview down one block, then opens an item. + await $.ui.scroll({ + component: 'Pane', requestId: 'captains-bridge', offset: 1, by: 1, + bodyRows: 20, contentRows: 40, origin: { kind: 'person' }, + }) + expect(await pane.find({ text: 'Ship the synthetic widget' })).toBe(undefined) + await pane.press({ key: 'item:gadget' }) + expect(await pane.find({ text: 'A tool result shows the gadget failing.' })).not.toBe(undefined) + expect((await probe($)).view.stack).toEqual([{ scroll: 1 }, { itemId: 'gadget', scroll: 0, open: [] }]) + await pane.press({ key: 'back' }) + expect((await probe($)).view.stack).toEqual([{ scroll: 1 }]) + expect(await pane.find({ text: 'Ship the synthetic widget' })).toBe(undefined) + expect(await pane.find({ key: 'item:gadget' })).not.toBe(undefined) + await pane.unmount() + }) + + test('Back returns to the previous item with its sections as left', WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface: 'desktop' }) + await pane.press({ key: 'item:widget' }) + await pane.press({ key: 'sources-toggle' }) + // A second view is pushed on top of the first item (as a link inside the + // detail would); Back lifts it and the first item is exactly as left. + expect((await probe($)).view.stack.length).toBe(2) + await pane.press({ key: 'back' }) + expect((await probe($)).view.stack.length).toBe(1) + expect(await pane.find({ key: 'item:widget' })).not.toBe(undefined) + await pane.press({ key: 'item:widget' }) + // Opening again starts collapsed: state belongs to the stack entry. + expect(await pane.find({ text: 'Show supporting records (3)' })).not.toBe(undefined) + await pane.unmount() + }) +}) + +describe('Refresh records', () => { + test('keeps selection and the original readAt and counts newer records', WITH, async ($, on) => { + const h = await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + await pane.press({ key: 'item:widget' }) + const before = (await probe($)).view.stack + + h.reply.current = ok( + summary({ + fingerprint: '8:uuid-8', + readAt: '2026-10-09T18:00:00Z', + coverage: { recordsTotal: 8, recordsIncluded: 8, bytesOmitted: 0, unsupportedSkipped: 0, pendingTail: false }, + }), + ) + await pane.press({ key: 'refresh' }) + + const state = await probe($) + expect(h.calls.length).toBe(2) + expect(state.view.stack).toEqual(before) + expect(state.records.readAt).toBe('2026-10-09T18:00:00Z') + expect(await pane.find({ text: 'Explanation read records at 2026-10-09T17:00:00Z; 5 newer records since.' })).not.toBe(undefined) + expect(await pane.find({ text: 'Records read at 2026-10-09T18:00:00Z' })).not.toBe(undefined) + // Still on the same item. + expect(await pane.find({ text: 'The first officer added a widget and a worker' })).not.toBe(undefined) + expect(h.models).toBe(0) + expect(h.turns).toBe(0) + await pane.unmount() + }) + + test('shows no stale note while the fingerprints match', WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', { ...WALKTHROUGH, fingerprint: '5:uuid-5' }) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + expect(await pane.find({ text: 'newer records since' })).toBe(undefined) + await pane.unmount() + }) + + test('header carries partial coverage and reader warnings', WITH, async ($, on) => { + const h = host(on, ok(summary({ + coverage: { recordsTotal: 900, recordsIncluded: 600, bytesOmitted: 4096, unsupportedSkipped: 2, pendingTail: true }, + warnings: [{ code: 'pending-tail', message: 'An incomplete final line was ignored.' }], + }))) + await $.command.run(run('captains-bridge')) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + expect(await pane.find({ text: 'Coverage is partial: 600 of 900 records are included, 4096 bytes omitted.' })).not.toBe(undefined) + expect(await pane.find({ text: '2 unsupported records were skipped.' })).not.toBe(undefined) + expect(await pane.find({ text: 'An incomplete final line was ignored.' })).not.toBe(undefined) + expect(h.models).toBe(0) + await pane.unmount() + }) + + const FAILURES: Array<[string, any, string]> = [ + ['a spawn failure', new Error('spawn helmet ENOENT'), 'set helmetCommand'], + ['error JSON', { exitCode: 2, stdout: JSON.stringify({ schema: 'hermes-helmet.bridge.records/1', error: { code: 'session-not-found', message: 'No file.' } }), stderr: '' }, 'session-not-found'], + ['a nonzero exit without error JSON', { exitCode: 127, stdout: '', stderr: 'helmet: command not found' }, 'exited with code 127'], + ['a too-old helmet', { exitCode: 2, stdout: '', stderr: "usage: helmet\nhelmet: error: argument command: invalid choice: 'bridge'" }, 'too old'], + ['non-JSON output', { exitCode: 0, stdout: 'not json', stderr: '' }, 'cannot parse'], + ['a wrong schema major', { exitCode: 0, stdout: JSON.stringify(summary({ schema: 'hermes-helmet.bridge.records/2' })), stderr: '' }, 'records/1'], + ] + for (const [name, answer, cause] of FAILURES) { + test(`keeps the last useful view after ${name}`, WITH, async ($, on) => { + const h = await opened($, on) + await seed($, 'walkthrough', WALKTHROUGH) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + await pane.press({ key: 'item:widget' }) + const kept = await probe($) + + h.reply.current = answer + await pane.press({ key: 'refresh' }) + + const state = await probe($) + expect(state.records).toEqual(kept.records) + expect(state.binding).toEqual(kept.binding) + expect(state.view.stack).toEqual(kept.view.stack) + const notice = await pane.find({ key: 'notice' }) + expect(notice?.text).toContain(cause) + expect(await pane.find({ text: 'The first officer added a widget and a worker' })).not.toBe(undefined) + expect(h.models).toBe(0) + expect(h.turns).toBe(0) + await pane.unmount() + }) + } + + test('a later good read clears the failure message', WITH, async ($, on) => { + const h = await opened($, on) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + h.reply.current = { exitCode: 0, stdout: 'not json', stderr: '' } + await pane.press({ key: 'refresh' }) + expect(await pane.find({ key: 'notice' })).not.toBe(undefined) + h.reply.current = ok(summary()) + await pane.press({ key: 'refresh' }) + expect(await pane.find({ key: 'notice' })).toBe(undefined) + await pane.unmount() + }) + + test('a first failure shows the cause rather than an empty success', WITH, async ($, on) => { + host(on, new Error('spawn helmet ENOENT')) + await $.command.run(run('captains-bridge')) + const pane = await $.ui.mount({ ...PANE, surface: 'desktop' }) + expect(await pane.find({ key: 'notice' })).not.toBe(undefined) + expect(await pane.find({ text: '0 of 0' })).toBe(undefined) + expect(await pane.find({ text: 'No records have been read yet' })).not.toBe(undefined) + await pane.unmount() + }) +}) + +describe('derived facts', () => { + const index = new Map(RECORDS.map(r => [r.ref, r as any])) + + test('formats elapsed time compactly', () => { + expect(formatElapsed(45_000)).toBe('45s') + expect(formatElapsed(150_000)).toBe('2m 30s') + expect(formatElapsed(3_900_000)).toBe('1h 05m') + }) + + test('derives elapsed time only from cited timestamps', () => { + expect(elapsedOf([REF(1), REF(4)], index)).toBe('12m 05s') + expect(elapsedOf([REF(1)], index)).toBe(null) + expect(elapsedOf([REF(1), REF(99)], index)).toBe(null) + }) + + test('builds the reader argv', () => { + expect(readerArgv('h', { sessionId: 's' })).toEqual(['h', 'bridge', 'read', '--session', 's']) + }) + + test('states staleness with the original readAt and the newer count', () => { + const current = { fingerprint: '9:u', summary: summary() as any } + expect(staleNote({ fingerprint: '4:x', readAt: T0 }, current)).toBe( + `Explanation read records at ${T0}; 5 newer records since.`, + ) + expect(staleNote({ fingerprint: '9:u', readAt: T0 }, current)).toBe(null) + expect(staleNote(null, current)).toBe(null) + }) + + test('reports coverage warnings only when something is missing', () => { + expect(coverageWarnings(summary() as any)).toEqual([]) + expect(coverageWarnings(summary({ warnings: ['plain warning'] }) as any)).toEqual(['plain warning']) + }) +}) diff --git a/hooks/register.tsx b/hooks/register.tsx index c9d9db7..ec5d498 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,16 +1,26 @@ // Captain's Bridge: the Claude Code mod of the Hermes Helmet plugin. // -// This slice is the skeleton: the `/captains-bridge` command, the exact -// session binding, the changed-conversation state, the minimum-version gate -// and a truthful pane and text overview. It reads nothing, sends no message, -// starts no turn and runs no timer. Later slices add the reader, the -// explanation and the actions behind the same state contract (../types). +// This slice reads the session's saved records with `helmet bridge read` and +// draws the Changes First walkthrough: the overview, the full item detail, +// Back with its place restored, and Refresh records. It sends no message, +// starts no turn, calls no model and runs no timer. Preparing an explanation +// is a later slice; until one exists the pane shows record counts and +// coverage. State follows the contract in ../types. // // The calls this module makes are checked in CI against the read-only // allowlist from issue #55 (scripts/check_mod_calls.py). import { atom, read, update } from 'claude-code' import type { Register } from 'claude-code' -import type { Walkthrough } from '../types' +import type { + BridgeState, + Coverage, + RecordsSummary, + SessionRecord, + Walkthrough, + WalkthroughItem, +} from '../types' + +type BridgeWalkthrough = NonNullable export const PANE_ID = 'captains-bridge' export const PANE_TITLE = "Captain's Bridge" @@ -20,6 +30,11 @@ export const COMMAND = 'captains-bridge' export const MIN_VERSION = '2.1.293' export const CHANGED_MESSAGE = 'This conversation changed. Run /captains-bridge to open the Bridge for it.' +export const READER_SCHEMA_MAJOR = 1 +export const READER_TIMEOUT_MS = 30000 +// Subdued, distinct backgrounds; the text labels carry the meaning too. +export const REPORT_BACKGROUND = '#2f3b4c' +export const DISPOSITION_BACKGROUND = '#3b3a2f' const binding = atom({ plugin: 'hermes-helmet', key: 'binding' } as const, null) const records = atom({ plugin: 'hermes-helmet', key: 'records' } as const, null) @@ -31,6 +46,7 @@ const request = atom( { plugin: 'hermes-helmet', key: 'request' } as const, { generation: 0, status: 'idle', retain: false }, ) +const view = atom({ plugin: 'hermes-helmet', key: 'view' } as const, { stack: [] }) // Sources of a classic SessionStart that start a different conversation. const CHANGING_SOURCES = ['clear', 'resume', 'fork'] @@ -58,7 +74,7 @@ export function tooOldMessage(version: string): string { ) } -const GROUPS: Array<[Walkthrough['items'][number]['group'], string]> = [ +const GROUPS: Array<[WalkthroughItem['group'], string]> = [ ['changed', 'What changed'], ['unresolved', 'What remains unresolved'], ['activity', 'Other recorded activity'], @@ -85,11 +101,634 @@ export function overviewText( return lines.join('\n') } +// --------------------------------------------------------------------------- +// The record reader: `helmet bridge read`, run as a child process. + +export type ReaderOutcome = + | { ok: true; summary: RecordsSummary } + | { ok: false; text: string } + +export function readerArgv( + helmetCommand: string, + bound: { sessionId: string; transcriptPath?: string }, +): string[] { + const argv = [helmetCommand, 'bridge', 'read', '--session', bound.sessionId] + // An unknown transcript path leaves the lookup to the reader, by exact ID. + if (bound.transcriptPath) argv.push('--transcript', bound.transcriptPath) + return argv +} + +function schemaMajor(schema: unknown): number | null { + const match = + typeof schema === 'string' ? /^hermes-helmet\.bridge\.records\/(\d+)$/.exec(schema) : null + return match ? Number(match[1]) : null +} + +const FIXES: Record = { + 'session-not-found': + 'No saved record file was found for this exact conversation. Run /captains-bridge again from the conversation you want, or check that Claude Code saves sessions.', + 'ambiguous-session': + 'More than one saved record file carries this conversation ID, and the Bridge never chooses one. Remove the duplicate or open the Bridge from a conversation with a single record file.', + 'session-mismatch': + 'The saved records belong to a different conversation than the one this Bridge is bound to. Run /captains-bridge again in the conversation you want.', + 'unsupported-format': + 'These saved records are in a format this release does not support. Update the hermes-helmet package and plugin together, then refresh.', + unreadable: + 'The saved records could not be read. Check the file permissions and that the disk is available, then refresh.', +} + +function firstLine(text: string): string { + const line = text.split('\n').find(part => part.trim() !== '') ?? '' + return line.trim().slice(0, 240) +} + +function validSummary(value: any): value is RecordsSummary { + const c = value?.coverage + return ( + value !== null && + typeof value === 'object' && + typeof value.sessionId === 'string' && + typeof value.readAt === 'string' && + typeof value.fingerprint === 'string' && + Array.isArray(value.records) && + value.records.every( + (r: any) => + r !== null && + typeof r === 'object' && + typeof r.ref === 'string' && + typeof r.timestamp === 'string' && + typeof r.text === 'string', + ) && + c !== null && + typeof c === 'object' && + typeof c.recordsTotal === 'number' && + typeof c.recordsIncluded === 'number' && + Array.isArray(value.warnings) + ) +} + +export async function runReader( + $: any, + helmetCommand: string, + bound: { sessionId: string; transcriptPath?: string }, +): Promise { + const shown = `${helmetCommand} bridge read` + let result: any + try { + result = await $.process.run(readerArgv(helmetCommand, bound), { + timeoutMs: READER_TIMEOUT_MS, + }) + } catch (error) { + const reason = firstLine(String((error as { message?: unknown })?.message ?? error)) + return { + ok: false, + text: + `Could not run \`${shown}\`${reason ? ` (${reason})` : ''}. ` + + 'Install the helmet command so Claude Code can reach it, or set helmetCommand ' + + 'in the plugin settings to its absolute path (Claude Desktop may not inherit your shell PATH). ' + + 'The reader also stops after 30 seconds.', + } + } + if (result === null || typeof result !== 'object') { + return { + ok: false, + text: `\`${shown}\` returned nothing the Bridge can use. Check that helmetCommand names the helmet command.`, + } + } + const exitCode = typeof result.exitCode === 'number' ? result.exitCode : 1 + const stdout = typeof result.stdout === 'string' ? result.stdout : '' + const stderr = typeof result.stderr === 'string' ? result.stderr : '' + let parsed: any = null + if (!result.isStdoutTruncated) { + try { + parsed = JSON.parse(stdout) + } catch { + parsed = null + } + } + const hasError = + parsed !== null && + typeof parsed === 'object' && + parsed.error !== null && + typeof parsed.error === 'object' && + typeof parsed.error.code === 'string' + if (parsed !== null && typeof parsed === 'object' && 'schema' in parsed) { + if (schemaMajor(parsed.schema) !== READER_SCHEMA_MAJOR) { + return { + ok: false, + text: + `\`${shown}\` answered with schema ${JSON.stringify(parsed.schema)}, but this plugin reads ` + + `hermes-helmet.bridge.records/${READER_SCHEMA_MAJOR}. Update the hermes-helmet package and the plugin ` + + 'to matching releases, then refresh.', + } + } + } + if (hasError) { + const code: string = parsed.error.code + const message = + typeof parsed.error.message === 'string' ? parsed.error.message : 'no message' + const fix = FIXES[code] ?? 'Update the hermes-helmet package and plugin together, then refresh.' + return { ok: false, text: `The reader reported ${code}: ${message} ${fix}` } + } + if (exitCode !== 0) { + const tail = firstLine(stderr) || firstLine(stdout) + const old = /invalid choice|unrecognized arguments|no such command|unknown command|usage:/i.test( + `${stderr}\n${stdout}`, + ) + return { + ok: false, + text: + `\`${shown}\` exited with code ${exitCode} without a reader error` + + `${tail ? ` (${tail})` : ''}. ` + + (old + ? 'This helmet is too old to read sessions: install hermes-helmet 0.7.0 or newer, or set helmetCommand to a newer one.' + : 'Check that helmetCommand names a current helmet command, then refresh.'), + } + } + if (result.isStdoutTruncated || parsed === null || typeof parsed !== 'object') { + return { + ok: false, + text: + `\`${shown}\` printed output the Bridge cannot parse` + + `${result.isStdoutTruncated ? ' (it was cut at 4 MiB)' : ''}. ` + + 'Check that helmetCommand names the helmet command and that it is current.', + } + } + if (!validSummary(parsed)) { + return { + ok: false, + text: `\`${shown}\` answered in a shape this plugin does not understand. Update the hermes-helmet package and plugin together.`, + } + } + if (parsed.sessionId !== bound.sessionId) { + return { + ok: false, + text: + `The reader answered for a different conversation (${parsed.sessionId}) than the one this Bridge is bound to (${bound.sessionId}). ` + + 'Nothing was adopted and the Bridge did not rebind. Run /captains-bridge again in the conversation you want.', + } + } + return { ok: true, summary: parsed } +} + +function coverageOf(summary: RecordsSummary): Coverage { + return summary.coverage +} + +// Rereads only the saved records. It never touches the walkthrough, the view +// stack or the selection, and it calls no model and sends nothing. +let reading = false +export async function refreshRecords($: any, helmetCommand: string): Promise { + if (reading) return + reading = true + try { + const bound = await read($, binding) + if (bound === null) return + const outcome = await runReader($, helmetCommand, bound) + // A conversation change while the reader ran: nothing is adopted. + const now = await read($, binding) + if (now === null || now.sessionId !== bound.sessionId) return + if (!outcome.ok) { + await update($, view, current => ({ + ...current, + notice: { kind: 'error', text: outcome.text }, + })) + return + } + const summary = outcome.summary + await update($, records, () => ({ + fingerprint: summary.fingerprint, + readAt: summary.readAt, + coverage: coverageOf(summary), + summary, + })) + await update($, view, current => { + const { notice: _gone, ...rest } = current as any + return rest + }) + } finally { + reading = false + } +} + +// --------------------------------------------------------------------------- +// Derived facts: time, actors, freshness. Everything comes from the saved +// records; nothing here is a judgment about delivery or stalls. + +export function formatElapsed(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 + if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m` + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s` + return `${seconds}s` +} + +function byRef(summary: RecordsSummary | null): Map { + const map = new Map() + if (summary !== null) for (const rec of summary.records) map.set(rec.ref, rec) + return map +} + +// Elapsed time from the first to the last cited record, or null when fewer +// than two cited records carry a usable timestamp. +export function elapsedOf(refs: string[], index: Map): string | null { + const times: number[] = [] + for (const ref of refs) { + const rec = index.get(ref) + const at = rec === undefined ? NaN : Date.parse(rec.timestamp) + if (!Number.isNaN(at)) times.push(at) + } + if (times.length < 2) return null + return formatElapsed(Math.max(...times) - Math.min(...times)) +} + +export function actorOf(rec: SessionRecord): string { + if (rec.role === 'tool_result') return rec.tool ? `Tool result (${rec.tool})` : 'Tool result' + switch (rec.origin) { + case 'person': + return 'Captain' + case 'assistant': + return 'First officer' + case 'agent': + return 'Other agent' + case 'hook': + return 'Hook' + case 'plugin': + return 'Plugin' + default: + return 'Session metadata' + } +} + +function clock(timestamp: string): string { + return timestamp.replace('T', ' ').replace(/\.\d+/, '').replace(/Z$/, ' UTC') +} + +function excerpt(text: string): string { + const flat = text.replace(/\s+/g, ' ').trim() + return flat.length > 240 ? `${flat.slice(0, 239)}…` : flat +} + +function totalOf(fingerprint: string): number | null { + const match = /^(\d+):/.exec(fingerprint) + return match ? Number(match[1]) : null +} + +// How many records the saved records hold beyond those the explanation read. +export function newerRecordCount( + explained: { fingerprint: string; readAt: string }, + current: { fingerprint: string; summary: RecordsSummary }, +): number { + const was = totalOf(explained.fingerprint) + const now = totalOf(current.fingerprint) + if (was !== null && now !== null) return Math.max(0, now - was) + const since = Date.parse(explained.readAt) + return current.summary.records.filter(rec => Date.parse(rec.timestamp) > since).length +} + +export function staleNote( + saved: { fingerprint: string; readAt: string } | null, + current: { fingerprint: string; summary: RecordsSummary } | null, +): string | null { + if (saved === null || current === null || saved.fingerprint === current.fingerprint) { + return null + } + const n = newerRecordCount(saved, current) + return `Explanation read records at ${saved.readAt}; ${n} newer records since.` +} + +export function coverageWarnings(summary: RecordsSummary | null): string[] { + if (summary === null) return [] + const c = summary.coverage + const out: string[] = [] + if (c.recordsIncluded < c.recordsTotal) { + out.push( + `Coverage is partial: ${c.recordsIncluded} of ${c.recordsTotal} records are included` + + `${c.bytesOmitted > 0 ? `, ${c.bytesOmitted} bytes omitted` : ''}.`, + ) + } + if (c.unsupportedSkipped > 0) { + out.push(`${c.unsupportedSkipped} unsupported records were skipped.`) + } + if (c.pendingTail) { + out.push('The last line was still being written and was not read.') + } + for (const warning of summary.warnings) { + out.push(typeof warning === 'string' ? warning : warning.message) + } + return out +} + +// A link is shown only when its address appears verbatim in a record that +// the item cites; a link without that evidence is never drawn. +export function evidenceLinks( + item: WalkthroughItem, + index: Map, +): Array<{ label: string; url: string }> { + const out: Array<{ label: string; url: string }> = [] + for (const link of item.links ?? []) { + if (!/^https?:\/\//.test(link.url)) continue + const backed = link.evidence.some(ref => index.get(ref)?.text.includes(link.url)) + if (backed) out.push({ label: link.label, url: link.url }) + } + return out +} + +// --------------------------------------------------------------------------- +// The view. One element tree from `$.ui.resolve(e)` serves the terminal and +// the desktop; the scroll window is the mod's own, in blocks, so Back can +// restore it exactly. + +let blockCount = 0 + +// Press handlers are closures made in the render hook; `$` itself is never +// put in an object (the validator wants every call spelled out at its site). +type Actions = { + open: (itemId: string) => Promise + back: () => Promise + toggle: (section: 'change' | 'sources') => Promise + refresh: () => Promise +} + +type Ctx = { + ui: any + act: Actions + body: Walkthrough | null + index: Map + stack: Array<{ itemId?: string; scroll?: number; open?: string[] }> +} + +function pairBoxes(ui: any, item: WalkthroughItem, suffix: string): any { + const { Box, Text } = ui + const reported = item.reported + const disposition = item.disposition + return ( + + + Completion report + + {reported === undefined + ? 'No completion report is recorded for this item.' + : `${reported.actor}: ${reported.label}`} + + + + Disposition + + {disposition === undefined + ? 'No acceptance or rejection is recorded. A completed run is not acceptance.' + : `${disposition.actor}: ${disposition.label}`} + + + + ) +} + +function overviewBlocks(ctx: Ctx): any[] { + const { Box, Text, Button } = ctx.ui + const body = ctx.body as Walkthrough + const blocks: any[] = [] + blocks.push( + + Objective + {body.objective} + Outcome + {body.summary} + , + ) + for (const [group, label] of GROUPS) { + const items = body.items.filter(item => item.group === group) + if (items.length === 0) continue + blocks.push( + + {label} + , + ) + for (const item of items) { + const paired = item.reported !== undefined || item.disposition !== undefined + blocks.push( + + + + {item.summary.replace(/\s+/g, ' ')} + + {paired ? pairBoxes(ctx.ui, item, `:${item.id}`) : null} + , + ) + } + } + return blocks +} + +function detailBlocks(ctx: Ctx, item: WalkthroughItem, open: string[]): any[] { + const { Box, Text, Button, Link } = ctx.ui + const blocks: any[] = [] + const elapsed = elapsedOf(item.evidence, ctx.index) + blocks.push( + + {item.title} + + {item.status} + {elapsed === null ? '' : ` · elapsed ${elapsed}`} + + , + ) + blocks.push( + + Explanation + {item.detail} + , + ) + if (item.steps !== undefined && item.steps.length > 0) { + blocks.push( + + Handoffs, reviews and repairs + , + ) + item.steps.forEach((step, i) => { + const spent = elapsedOf(step.evidence, ctx.index) + blocks.push( + + + {step.actor}: {step.label} + {spent === null ? '' : ` (elapsed ${spent})`} + + {step.detail} + , + ) + }) + } + if (item.reported !== undefined || item.disposition !== undefined) { + blocks.push(pairBoxes(ctx.ui, item, '')) + } + if (item.change !== undefined) { + const shown = open.includes('change') + const change = item.change + blocks.push( + + + {shown ? ( + + Before + {change.before} + After + {change.after} + + {change.explanation} + + + ) : null} + , + ) + } + const links = evidenceLinks(item, ctx.index) + if (links.length > 0) { + blocks.push( + + Links + {links.map(link => ( + + ))} + , + ) + } + const cited = item.evidence + .map(ref => ctx.index.get(ref)) + .filter((rec): rec is SessionRecord => rec !== undefined) + const sourcesOpen = open.includes('sources') + blocks.push( + + + {sourcesOpen ? ( + + {cited.map(rec => ( + + + {clock(rec.timestamp)} · {actorOf(rec)} + + {excerpt(rec.text)} + + ))} + + ) : null} + , + ) + return blocks +} + +async function openItem($: any, itemId: string): Promise { + await update($, view, current => { + const base = current.stack.length > 0 ? current.stack : [{ scroll: 0 }] + return { ...current, stack: [...base, { itemId, scroll: 0, open: [] }] } + }) +} + +async function goBack($: any): Promise { + await update($, view, current => + current.stack.length > 1 ? { ...current, stack: current.stack.slice(0, -1) } : current, + ) +} + +async function toggleSection($: any, section: 'change' | 'sources'): Promise { + await update($, view, current => { + const stack = current.stack.slice() + const top = stack[stack.length - 1] + if (top === undefined) return current + const open = top.open ?? [] + stack[stack.length - 1] = { + ...top, + open: open.includes(section) ? open.filter(s => s !== section) : [...open, section], + } + return { ...current, stack } + }) +} + +function preWalkthroughBlocks(ctx: Ctx, counts: Coverage | null): any[] { + const { Box, Text } = ctx.ui + const lines: string[] = [] + if (counts === null) { + lines.push('No records have been read yet. Choose Refresh records to read this conversation.') + } else { + lines.push(`${counts.recordsIncluded} of ${counts.recordsTotal} records are read.`) + if (counts.bytesOmitted > 0) lines.push(`${counts.bytesOmitted} bytes of tool output were left out.`) + if (counts.unsupportedSkipped > 0) lines.push(`${counts.unsupportedSkipped} unsupported records were skipped.`) + if (counts.pendingTail) lines.push('The last line was still being written.') + } + return [ + + No walkthrough has been prepared for this conversation yet. + {lines.map(line => ( + {line} + ))} + , + ] +} + +function footerBlocks(ctx: Ctx, summary: RecordsSummary | null, hasBody: boolean): any[] { + const { Box, Text, Button } = ctx.ui + const blocks: any[] = [] + if (hasBody && summary !== null) { + const c = summary.coverage + blocks.push( + + {c.recordsIncluded} of {c.recordsTotal} records read + , + ) + } + blocks.push( + + + + + Update explanation: not available in this release. No model is called and nothing is sent. + + + , + ) + return blocks +} + +// --------------------------------------------------------------------------- + // 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 configured = (options as Record | undefined)?.helmetCommand + const helmetCommand = + typeof configured === 'string' && configured.trim() !== '' ? configured.trim() : 'helmet' + on('session.start', async ($, e, next) => { drawsPanes = e.isInteractive !== false && e.surface !== 'vscode' await $.command.register({ @@ -157,6 +796,8 @@ export const register: Register = on => { } const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE }) if (!isSupported(version)) return { text: tooOldMessage(version) } + // Opening reads the saved records (the reader only; no model, no turn). + await refreshRecords($, helmetCommand) if (drawsPanes && opened.isPlaced) { return { text: `${PANE_TITLE} is open for this conversation.` } } @@ -169,13 +810,29 @@ export const register: Register = on => { return { text: overviewText(body, coverage) } }) + // The mod owns its scroll window, counted in blocks, so Back can put the + // window where it was. The engine's own window never moves. + on('ui.scroll', { requestId: PANE_ID }, async ($, e) => { + await update($, view, current => { + const stack = current.stack.length > 0 ? current.stack.slice() : [{}] + const top = stack[stack.length - 1] + const step = e.by > 0 ? 1 : e.by < 0 ? -1 : 0 + const next = Math.min(Math.max(0, blockCount - 1), Math.max(0, (top.scroll ?? 0) + step)) + stack[stack.length - 1] = { ...top, scroll: next } + return { ...current, stack } + }) + return {} + }) + on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => { - const { Box, Text } = $.ui.resolve(e) + const ui = $.ui.resolve(e) + const { Box, Text, Button } = ui 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 current = await read($, records) + const nav = await read($, view) if (!isSupported(version)) { return ( @@ -195,18 +852,90 @@ export const register: Register = on => { ) } - const body = saved === null ? null : saved.body + const body: Walkthrough | null = saved === null ? null : saved.body + const summary: RecordsSummary | null = current === null ? null : current.summary + const stack = nav.stack.length > 0 ? nav.stack : [{}] + const top = stack[stack.length - 1] as { itemId?: string; scroll?: number; open?: string[] } + const act: Actions = { + open: id => openItem($, id), + back: () => goBack($), + toggle: section => toggleSection($, section), + refresh: () => refreshRecords($, helmetCommand), + } + const ctx: Ctx = { ui, act, body, index: byRef(summary), stack } + + // Header: identity, read freshness, the stale note, coverage warnings and + // the last reader message. The substantive work follows it. + const header: any[] = [ + + {PANE_TITLE} + , + + Conversation {bound.sessionId} + , + ] + if (current !== null) { + header.push( + + Records read at {current.readAt} + , + ) + } + const stale = staleNote(saved, current) + if (stale !== null) { + header.push( + + {stale} + , + ) + } + coverageWarnings(summary).forEach((warning, i) => { + header.push( + + {warning} + , + ) + }) + if (nav.notice !== undefined) { + header.push( + + + {nav.notice.text} + + , + ) + } + + let blocks: any[] + if (body === null) { + blocks = preWalkthroughBlocks(ctx, current === null ? null : current.coverage) + } else if (top.itemId === undefined) { + blocks = overviewBlocks(ctx) + } else { + const item = body.items.find(candidate => candidate.id === top.itemId) + const back = ( + + ) + if (item === undefined) { + blocks = [ + back, + + This item is not part of the current explanation. + , + ] + } else { + blocks = [back, ...detailBlocks(ctx, item, top.open ?? [])] + } + } + blocks = [...blocks, ...footerBlocks(ctx, summary, body !== null)] + blockCount = blocks.length + const first = Math.min(Math.max(0, top.scroll ?? 0), Math.max(0, blocks.length - 1)) return ( - {PANE_TITLE} - - Conversation {bound.sessionId} - - {overviewText(body, counts === null ? null : counts.coverage) - .split('\n') - .map(line => ( - {line} - ))} + {header} + {blocks.slice(first)} ) }) diff --git a/scripts/verify.sh b/scripts/verify.sh index 0abefff..8656d41 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/bridge.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/claude_mod_toolchain.py b/src/hermes_helmet/claude_mod_toolchain.py index a463dd1..a2fae81 100644 --- a/src/hermes_helmet/claude_mod_toolchain.py +++ b/src/hermes_helmet/claude_mod_toolchain.py @@ -60,6 +60,7 @@ } ) _CALLS_RE = re.compile(r"^(?P\S+) calls: (?P.+)$") +_VIA_RE = re.compile(r"\s+\(via [A-Za-z_$][A-Za-z0-9_$]*\)$") _CALL_RE = re.compile(r"^\$\.[a-z][A-Za-z]*\.[a-z][A-Za-z]*$") @@ -221,7 +222,12 @@ 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(",")] + # Claude Code tags a call made through a local helper function, + # `$.process.run (via runReader)`. The call itself is still + # checked against the allowlist; only that tag is removed. + names = [ + _VIA_RE.sub("", part).strip() for part in match.group("calls").split(",") + ] 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..d696469 100644 --- a/tests/test_claude_mod_toolchain.py +++ b/tests/test_claude_mod_toolchain.py @@ -188,6 +188,16 @@ def test_unparseable_validation_output_fails_closed(self) -> None: with self.subTest(text=text[:40]), self.assertRaises(ClaudeModToolchainError): calls_from_validation(text) + def test_a_call_tagged_as_made_through_a_helper_is_still_checked(self) -> None: + tagged = _report("./register.tsx calls: $.ui.open, $.process.run (via runReader)") + self.assertEqual( + check_mod_calls(tagged), ["$.process.run", "$.ui.open"] + ) + with self.assertRaisesRegex(ClaudeModToolchainError, "outside the read-only allowlist"): + check_mod_calls(_report("./register.tsx calls: $.fs.write (via helper)")) + with self.assertRaises(ClaudeModToolchainError): + calls_from_validation(_report("./register.tsx calls: $.ui.open (via a b)")) + def test_every_module_in_the_report_is_checked(self) -> None: report = json.dumps( { diff --git a/tests/test_first_officer_plugin.py b/tests/test_first_officer_plugin.py index 0e1af56..5a809e9 100644 --- a/tests/test_first_officer_plugin.py +++ b/tests/test_first_officer_plugin.py @@ -307,11 +307,22 @@ def test_mod_files_are_in_the_public_boundary_scan(self) -> None: self.assertIn("types", public_surface.SCAN_ROOTS) self.assertEqual(public_surface.scan_public_surface(ROOT), []) verify = (ROOT / "scripts" / "verify.sh").read_text(encoding="utf-8") - for required in ("hooks/hooks.json", "hooks/register.tsx", "hooks/register.test.tsx", "types/index.d.ts"): + for required in ( + "hooks/hooks.json", + "hooks/register.tsx", + "hooks/register.test.tsx", + "hooks/bridge.test.tsx", + "types/index.d.ts", + ): self.assertIn(required, verify) def test_plugin_and_tests_use_only_synthetic_fixtures(self) -> None: - for relative in ("hooks/register.tsx", "hooks/register.test.tsx", "types/index.d.ts"): + for relative in ( + "hooks/register.tsx", + "hooks/register.test.tsx", + "hooks/bridge.test.tsx", + "types/index.d.ts", + ): text = (ROOT / relative).read_text(encoding="utf-8") for marker in ("/Users/", "/home/", "/opt/data", ".claude/projects"): self.assertNotIn(marker, text, f"{relative}: {marker}") diff --git a/types/index.d.ts b/types/index.d.ts index c7885b7..2a4c959 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -79,7 +79,9 @@ export type RecordsSummary = { fingerprint: string records: SessionRecord[] coverage: Coverage - warnings: string[] + // The reader reports `{ code, message }` objects; plain strings are + // tolerated by the view. + warnings: Array } export type RequestStatus = @@ -111,7 +113,18 @@ export type BridgeState = { startedAt?: string message?: string } - view: { stack: Array<{ itemId?: string; scroll?: number }> } + view: { + // The last entry is the current view; an entry without itemId is the + // overview. `scroll` is the window offset left behind when the next + // view was opened; `open` lists the expanded sections of an item. + stack: Array<{ + itemId?: string + scroll?: number + open?: Array<'change' | 'sources'> + }> + // The last reader or action message; it never replaces a useful view. + notice?: { kind: 'error' | 'info'; text: string } + } pending: { action: 'show-me' | 'retro'; since: string } | null } From 64a4ca45f1f58ef3f9f6e19b596c81c801a6b6e9 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:13:28 +0000 Subject: [PATCH 2/6] chore(release): synchronize version 0.9.0 via mw-version updater --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- pyproject.toml | 2 +- src/hermes_helmet/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/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", From 2f5e5ae619a88b2669d3a0b1c9826fb61f619c07 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:46:10 +0000 Subject: [PATCH 3/6] fix(bridge): validate reader answers and keep long detail readable --- CHANGELOG.md | 8 ++- hooks/bridge.test.tsx | 63 +++++++++++++++- hooks/register.tsx | 162 +++++++++++++++++++++++++++--------------- 3 files changed, 171 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc70bc..44dc4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,12 @@ All notable changes to Hermes Helmet are recorded here. The project follows rereads saved records only, keeps the view stack and selection, never restamps the walkthrough’s `readAt`, and shows “Explanation read records at {readAt}; {n} newer records since.” Reader failures name the cause and fix and keep the - last useful view. The call-allowlist parser accepts Claude Code’s - `(via helper)` tag. No model is called and nothing is sent. + last useful view. The reader answer must carry the expected schema, valid + records, coverage counts and warnings before it replaces anything. Long + explanations and supporting records are drawn as word-wrapped pieces, so + scrolling never skips a whole long block, and Back stays in the fixed header. + The call-allowlist parser accepts Claude Code’s `(via helper)` tag. No model + is called and nothing is sent. ### 0.8.0 diff --git a/hooks/bridge.test.tsx b/hooks/bridge.test.tsx index 0e7b9c1..454da6a 100644 --- a/hooks/bridge.test.tsx +++ b/hooks/bridge.test.tsx @@ -391,13 +391,13 @@ describe('the item detail', () => { component: 'Pane', requestId: 'captains-bridge', offset: 1, by: 1, bodyRows: 20, contentRows: 40, origin: { kind: 'person' }, }) - expect(await pane.find({ text: 'Ship the synthetic widget' })).toBe(undefined) + expect(await pane.find({ text: 'Objective' })).toBe(undefined) await pane.press({ key: 'item:gadget' }) expect(await pane.find({ text: 'A tool result shows the gadget failing.' })).not.toBe(undefined) expect((await probe($)).view.stack).toEqual([{ scroll: 1 }, { itemId: 'gadget', scroll: 0, open: [] }]) await pane.press({ key: 'back' }) expect((await probe($)).view.stack).toEqual([{ scroll: 1 }]) - expect(await pane.find({ text: 'Ship the synthetic widget' })).toBe(undefined) + expect(await pane.find({ text: 'Objective' })).toBe(undefined) expect(await pane.find({ key: 'item:gadget' })).not.toBe(undefined) await pane.unmount() }) @@ -479,6 +479,10 @@ describe('Refresh records', () => { ['a nonzero exit without error JSON', { exitCode: 127, stdout: '', stderr: 'helmet: command not found' }, 'exited with code 127'], ['a too-old helmet', { exitCode: 2, stdout: '', stderr: "usage: helmet\nhelmet: error: argument command: invalid choice: 'bridge'" }, 'too old'], ['non-JSON output', { exitCode: 0, stdout: 'not json', stderr: '' }, 'cannot parse'], + ['a missing schema', { exitCode: 0, stdout: JSON.stringify(summary({ schema: undefined, fingerprint: '9:uuid-9' })), stderr: '' }, 'no schema'], + ['a null warning', { exitCode: 0, stdout: JSON.stringify(summary({ warnings: [null], fingerprint: '9:uuid-9' })), stderr: '' }, 'does not understand'], + ['a warning without a message', { exitCode: 0, stdout: JSON.stringify(summary({ warnings: [{ code: 'x' }], fingerprint: '9:uuid-9' })), stderr: '' }, 'does not understand'], + ['malformed coverage counts', { exitCode: 0, stdout: JSON.stringify(summary({ coverage: { recordsTotal: 5, recordsIncluded: 5 }, fingerprint: '9:uuid-9' })), stderr: '' }, 'does not understand'], ['a wrong schema major', { exitCode: 0, stdout: JSON.stringify(summary({ schema: 'hermes-helmet.bridge.records/2' })), stderr: '' }, 'records/1'], ] for (const [name, answer, cause] of FAILURES) { @@ -528,6 +532,61 @@ describe('Refresh records', () => { }) }) +describe('long content stays readable when scrolled', () => { + const LINES = Array.from({ length: 100 }, (_, i) => `explanation line ${i + 1}`).join('\n') + const LONG = { + ...WALKTHROUGH, + body: { ...BODY, items: [{ ...ITEMS[0], detail: LINES }, ...ITEMS.slice(1)] }, + } + const scroll = ($: any, offset: number) => + $.ui.scroll({ + component: 'Pane', requestId: 'captains-bridge', offset, by: 1, + bodyRows: 20, contentRows: 120, origin: { kind: 'person' }, + }) + + for (const surface of SURFACES) { + test(`three single steps skip no more than three lines of a long detail on ${surface}`, WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', LONG) + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'item:widget' }) + expect(await pane.find({ text: 'explanation line 1' })).not.toBe(undefined) + for (const offset of [1, 2, 3]) await scroll($, offset) + expect(await pane.find({ text: 'explanation line 4' })).not.toBe(undefined) + expect(await pane.find({ text: 'explanation line 100' })).not.toBe(undefined) + await pane.unmount() + }) + } + + test('expanded supporting records in a narrow pane remain reachable one step at a time', WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', LONG) + const pane = await $.ui.mount({ ...PANE, props: { ...PANE.props, bodyColumns: 30 }, surface: 'terminal' }) + await pane.press({ key: 'item:widget' }) + await pane.press({ key: 'sources-toggle' }) + const before = (await texts(pane)).join('\n') + expect(before).toContain('Opened https://example.test/acme/widgets/pull/7') + await scroll($, 1) + const after = (await texts(pane)).join('\n') + expect(after).toContain('explanation line 2') + expect(after).toContain('Opened https://example.test/acme/widgets/pull/7') + await pane.unmount() + }) + + test('Back still restores the preceding item and place after scrolling a long detail', WITH, async ($, on) => { + await opened($, on) + await seed($, 'walkthrough', LONG) + const pane = await $.ui.mount({ ...PANE, surface: 'terminal' }) + await scroll($, 1) + await pane.press({ key: 'item:widget' }) + await scroll($, 1) + await scroll($, 2) + await pane.press({ key: 'back' }) + expect((await probe($)).view.stack).toEqual([{ scroll: 1 }]) + await pane.unmount() + }) +}) + describe('derived facts', () => { const index = new Map(RECORDS.map(r => [r.ref, r as any])) diff --git a/hooks/register.tsx b/hooks/register.tsx index ec5d498..8d762e3 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -163,7 +163,15 @@ function validSummary(value: any): value is RecordsSummary { typeof c === 'object' && typeof c.recordsTotal === 'number' && typeof c.recordsIncluded === 'number' && - Array.isArray(value.warnings) + typeof c.bytesOmitted === 'number' && + typeof c.unsupportedSkipped === 'number' && + typeof c.pendingTail === 'boolean' && + Array.isArray(value.warnings) && + value.warnings.every( + (w: any) => + typeof w === 'string' || + (w !== null && typeof w === 'object' && typeof w.message === 'string'), + ) ) } @@ -254,10 +262,19 @@ export async function runReader( 'Check that helmetCommand names the helmet command and that it is current.', } } + if (schemaMajor(parsed.schema) !== READER_SCHEMA_MAJOR) { + return { + ok: false, + text: + `\`${shown}\` answered with ${parsed.schema === undefined ? 'no schema' : `schema ${JSON.stringify(parsed.schema)}`}, ` + + `but this plugin reads hermes-helmet.bridge.records/${READER_SCHEMA_MAJOR}. ` + + 'Update the hermes-helmet package and the plugin to matching releases, then refresh. The records already shown were kept.', + } + } if (!validSummary(parsed)) { return { ok: false, - text: `\`${shown}\` answered in a shape this plugin does not understand. Update the hermes-helmet package and plugin together.`, + text: `\`${shown}\` answered in a shape this plugin does not understand (a record, the coverage counts or a warning is malformed). Update the hermes-helmet package and plugin together, then refresh. The records already shown were kept.`, } } if (parsed.sessionId !== bound.sessionId) { @@ -460,6 +477,40 @@ type Ctx = { stack: Array<{ itemId?: string; scroll?: number; open?: string[] }> } +// Long text is cut into word-wrapped pieces, each its own block, so the +// block-counted scroll window moves a line or so at a time and never skips +// a whole long explanation or source list. +const PIECE_WIDTH = 72 + +export function textPieces(text: string): string[] { + const pieces: string[] = [] + for (const line of text.split('\n')) { + let current = '' + for (const word of line.split(/\s+/).filter(w => w !== '')) { + if (current !== '' && current.length + 1 + word.length > PIECE_WIDTH) { + pieces.push(current) + current = '' + } + current = current === '' ? word : `${current} ${word}` + while (current.length > PIECE_WIDTH) { + pieces.push(current.slice(0, PIECE_WIDTH)) + current = current.slice(PIECE_WIDTH) + } + } + pieces.push(current) + } + return pieces +} + +function textBlocks(ui: any, key: string, text: string, dim = false): any[] { + const { Text } = ui + return textPieces(text).map((piece, i) => ( + + {piece === '' ? ' ' : piece} + + )) +} + function pairBoxes(ui: any, item: WalkthroughItem, suffix: string): any { const { Box, Text } = ui const reported = item.reported @@ -501,12 +552,14 @@ function overviewBlocks(ctx: Ctx): any[] { const body = ctx.body as Walkthrough const blocks: any[] = [] blocks.push( - - Objective - {body.objective} - Outcome - {body.summary} - , + + Objective + , + ...textBlocks(ctx.ui, 'objective', body.objective), + + Outcome + , + ...textBlocks(ctx.ui, 'outcome', body.summary), ) for (const [group, label] of GROUPS) { const items = body.items.filter(item => item.group === group) @@ -552,10 +605,10 @@ function detailBlocks(ctx: Ctx, item: WalkthroughItem, open: string[]): any[] { , ) blocks.push( - - Explanation - {item.detail} - , + + Explanation + , + ...textBlocks(ctx.ui, 'explanation', item.detail), ) if (item.steps !== undefined && item.steps.length > 0) { blocks.push( @@ -566,13 +619,11 @@ function detailBlocks(ctx: Ctx, item: WalkthroughItem, open: string[]): any[] { item.steps.forEach((step, i) => { const spent = elapsedOf(step.evidence, ctx.index) blocks.push( - - - {step.actor}: {step.label} - {spent === null ? '' : ` (elapsed ${spent})`} - - {step.detail} - , + + {step.actor}: {step.label} + {spent === null ? '' : ` (elapsed ${spent})`} + , + ...textBlocks(ctx.ui, `step:${i}:detail`, step.detail), ) }) } @@ -583,23 +634,23 @@ function detailBlocks(ctx: Ctx, item: WalkthroughItem, open: string[]): any[] { const shown = open.includes('change') const change = item.change blocks.push( - - - {shown ? ( - - Before - {change.before} - After - {change.after} - - {change.explanation} - - - ) : null} - , + , ) + if (shown) { + blocks.push( + + Before + , + ...textBlocks(ctx.ui, 'before', change.before), + + After + , + ...textBlocks(ctx.ui, 'after', change.after), + ...textBlocks(ctx.ui, 'change-explanation', change.explanation, true), + ) + } } const links = evidenceLinks(item, ctx.index) if (links.length > 0) { @@ -617,26 +668,20 @@ function detailBlocks(ctx: Ctx, item: WalkthroughItem, open: string[]): any[] { .filter((rec): rec is SessionRecord => rec !== undefined) const sourcesOpen = open.includes('sources') blocks.push( - - - {sourcesOpen ? ( - - {cited.map(rec => ( - - - {clock(rec.timestamp)} · {actorOf(rec)} - - {excerpt(rec.text)} - - ))} - - ) : null} - , + , ) + if (sourcesOpen) { + cited.forEach(rec => { + blocks.push( + + {clock(rec.timestamp)} · {actorOf(rec)} + , + ...textBlocks(ctx.ui, `source:${rec.ref}`, excerpt(rec.text)), + ) + }) + } return blocks } @@ -913,20 +958,21 @@ export const register: Register = (on, options) => { blocks = overviewBlocks(ctx) } else { const item = body.items.find(candidate => candidate.id === top.itemId) - const back = ( + // Back is part of the fixed header, so it stays reachable however far + // a long detail has been scrolled. + header.push( + , ) if (item === undefined) { blocks = [ - back, This item is not part of the current explanation. , ] } else { - blocks = [back, ...detailBlocks(ctx, item, top.open ?? [])] + blocks = detailBlocks(ctx, item, top.open ?? []) } } blocks = [...blocks, ...footerBlocks(ctx, summary, body !== null)] From e50c768de486f6467ffaa96f2db631fe37c2fffb Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:59:24 +0000 Subject: [PATCH 4/6] chore(release): synchronize version 0.10.0 via mw-version updater --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- src/hermes_helmet/__init__.py | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b439b67..4240ec2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "0.9.0", + "version": "0.10.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 75f3467..ef396d8 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "0.9.0", + "version": "0.10.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 fada445..3036813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Hermes Helmet are recorded here. The project follows ## Unreleased -### 0.9.0 +### 0.10.0 - Show the Captain’s Bridge Changes First view in the Claude mod ([#69](https://github.com/MachineWisdomAI/hermes-helmet/issues/69), part of @@ -28,6 +28,8 @@ All notable changes to Hermes Helmet are recorded here. The project follows The call-allowlist parser accepts Claude Code’s `(via helper)` tag. No model is called and nothing is sent. +### 0.9.0 + - 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 diff --git a/pyproject.toml b/pyproject.toml index 033cc9f..750e1d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "0.9.0" +version = "0.10.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 0198466..395b350 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.9.0" +__version__ = "0.10.0" __all__ = [ "Policy", From 0b57f4878b668f303ac883fac5dc69799939199e Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:11:51 +0000 Subject: [PATCH 5/6] fix(bridge): give overview item buttons one string label for Claude Code 2.1.293 --- CHANGELOG.md | 4 +++- hooks/bridge.test.tsx | 5 +++++ hooks/register.tsx | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3036813..c6d16e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,9 @@ All notable changes to Hermes Helmet are recorded here. The project follows records, coverage counts and warnings before it replaces anything. Long explanations and supporting records are drawn as word-wrapped pieces, so scrolling never skips a whole long block, and Back stays in the fixed header. - The call-allowlist parser accepts Claude Code’s `(via helper)` tag. No model + Each overview item button now carries its title and status as one string + label, which the advertised minimum Claude Code 2.1.293 requires to draw the + pane. The call-allowlist parser accepts Claude Code’s `(via helper)` tag. No model is called and nothing is sent. ### 0.9.0 diff --git a/hooks/bridge.test.tsx b/hooks/bridge.test.tsx index 454da6a..1f11b36 100644 --- a/hooks/bridge.test.tsx +++ b/hooks/bridge.test.tsx @@ -276,6 +276,11 @@ describe('the overview', () => { expect(titles.map((b: any) => b.key)).toEqual([ 'item:widget', 'item:gadget', 'item:notes', 'refresh', ]) + // Claude Code 2.1.293 rejects a Button with anything but one string label. + // The item Button carries title and status as one label, no nested Text. + const item = titles.find((b: any) => b.key === 'item:widget') + expect(typeof item.text).toBe('string') + expect(item.text).toMatch(/\[[^\]]+\]$/) expect(await pane.find({ text: 'Fix the gadget on empty input' })).not.toBe(undefined) expect(await pane.find({ text: 'The gadget still fails on empty input.' })).not.toBe(undefined) await pane.unmount() diff --git a/hooks/register.tsx b/hooks/register.tsx index ca34aff..2bd625b 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -582,7 +582,7 @@ function overviewBlocks(ctx: Ctx): any[] { plain onPress={() => ctx.act.open(item.id)} > - {item.title} [{item.status}] + {`${item.title} [${item.status}]`} {item.summary.replace(/\s+/g, ' ')} From 8e08fd9eeca09445b0e2fb5087b511f395f12b63 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:23:05 +0000 Subject: [PATCH 6/6] chore(release): synchronize version 0.11.0 via mw-version updater --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- pyproject.toml | 2 +- src/hermes_helmet/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4240ec2..4ca419f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "0.10.0", + "version": "0.11.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 ef396d8..f074cda 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "0.10.0", + "version": "0.11.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/pyproject.toml b/pyproject.toml index 750e1d9..a3d3169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "0.10.0" +version = "0.11.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 395b350..ab1afaa 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.10.0" +__version__ = "0.11.0" __all__ = [ "Policy",