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/CHANGELOG.md b/CHANGELOG.md index e9690f5..1d0e21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to Hermes Helmet are recorded here. The project follows ## Unreleased +### 0.11.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 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. + 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.10.0 - Captain’s Bridge Show Me and Retro now state a portable skill-discovery path diff --git a/docs/first-officer-plugins.md b/docs/first-officer-plugins.md index 0de3a81..9deb924 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 and the background explanation arrive in later releases -under the same state contract. +pane draws, the command returns a short text status instead. The background +explanation arrives in a later release 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`. 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 diff --git a/hooks/actions.test.tsx b/hooks/actions.test.tsx index 145672b..dcbbe46 100644 --- a/hooks/actions.test.tsx +++ b/hooks/actions.test.tsx @@ -291,12 +291,49 @@ for (const surface of SURFACES) { test('the explanation stays intact around a press', async ($, on) => { const h = host(on, { listed: names('show-me') }) await open($) - fixture.selected = 'widget' + // Overview: the walkthrough is drawn with its item, then an item press + // from the actual pane opens the detail and the action keeps it intact. const pane = await $.ui.mount({ ...PANE, surface }) + expect(await pane.find({ text: 'Ship the synthetic widget' })).not.toBe(undefined) + expect(await pane.find({ key: 'item:widget' })).not.toBe(undefined) + await pane.press({ key: 'item:widget' }) 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) + expect(h.runs[0].args).toContain('Add the synthetic widget') + expect(await pane.find({ text: 'Explanation' })).not.toBe(undefined) + expect(await pane.find({ text: 'Add the synthetic widget' })).not.toBe(undefined) + await pane.unmount() + }) + + test('every Refresh records press discovers commands again', async ($, on) => { + const h = host(on, { listed: names() }) + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + expect(await pane.find({ key: 'show-me' })).toBe(undefined) + const before = h.listCalls + h.listed.current = names('show-me') + await pane.press({ key: 'refresh' }) + expect(h.listCalls).toBeGreaterThan(before) + await pane.press({ key: 'refresh' }) + expect(h.listCalls).toBeGreaterThan(before + 1) + await pane.press({ key: 'show-me' }) + expect(h.runs.length).toBe(1) + await pane.unmount() + }) + + test('the item opened in the real pane sets the scope, and back returns to the session', async ($, on) => { + const h = host(on, { listed: names('show-me', 'retro') }) + await open($) + const pane = await $.ui.mount({ ...PANE, surface }) + await pane.press({ key: 'item:widget' }) + await pane.press({ key: 'retro' }) + expect(h.runs.length).toBe(1) + 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.press({ key: 'back' }) + await pane.press({ key: 'show-me' }) + expect(h.runs[1]).toEqual({ command: 'show-me', args: 'this session' }) await pane.unmount() }) }) diff --git a/hooks/bridge.test.tsx b/hooks/bridge.test.tsx new file mode 100644 index 0000000..1f11b36 --- /dev/null +++ b/hooks/bridge.test.tsx @@ -0,0 +1,627 @@ +// 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', + ]) + // 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() + }) + + 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: '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: 'Objective' })).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 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) { + 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('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])) + + 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 47d8d19..2bd625b 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) @@ -27,12 +42,12 @@ 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 }, ) +const view = atom({ plugin: 'hermes-helmet', key: 'view' } as const, { stack: [] }) +const pending = atom({ plugin: 'hermes-helmet', key: 'pending' } as const, null) // Sources of a classic SessionStart that start a different conversation. const CHANGING_SOURCES = ['clear', 'resume', 'fork'] @@ -60,7 +75,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'], @@ -87,6 +102,682 @@ 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' && + 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'), + ) + ) +} + +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 (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 (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) { + 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 + run: (action: Action) => Promise +} + +type Ctx = { + ui: any + act: Actions + body: Walkthrough | null + index: Map + stack: Array<{ itemId?: string; scroll?: number; open?: string[] }> + names: ActionNames + busy: Action | null +} + +// 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 + 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 + , + ...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) + 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 + , + ...textBlocks(ctx.ui, '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})`} + , + ...textBlocks(ctx.ui, `step:${i}:detail`, 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( + , + ) + 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) { + 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( + , + ) + if (sourcesOpen) { + cited.forEach(rec => { + blocks.push( + + {clock(rec.timestamp)} · {actorOf(rec)} + , + ...textBlocks(ctx.ui, `source:${rec.ref}`, excerpt(rec.text)), + ) + }) + } + 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( + + + {(['show-me', 'retro'] as Action[]).map(action => + actionBlock( + { Box, Text, Button }, + action, + ctx.names[action], + ctx.busy === action, + () => ctx.act.run(action), + ), + )} + + + Update explanation: not available in this release. No model is called and nothing is sent. + + + , + ) + return blocks +} + +// --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- // 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 @@ -270,7 +961,11 @@ export function actionBlock( let drawsPanes = true export const register: Register = (on, options) => { + const configured = (options as Record | undefined)?.helmetCommand const names = actionNames(options) + 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({ @@ -336,9 +1031,11 @@ export const register: Register = (on, options) => { : 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) } + // Opening reads the saved records (the reader only; no model, no turn). + await discoverCommands($, names) + await refreshRecords($, helmetCommand) if (drawsPanes && opened.isPlaced) { return { text: `${PANE_TITLE} is open for this conversation.` } } @@ -351,14 +1048,29 @@ export const register: Register = (on, options) => { 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, Button } = $.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 busy = await read($, pending) + const current = await read($, records) + const nav = await read($, view) if (!isSupported(version)) { return ( @@ -378,31 +1090,105 @@ export const register: Register = (on, options) => { ) } - 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: async () => { + await discoverCommands($, names) + await refreshRecords($, helmetCommand) + }, + run: action => runAction($, action, names[action]), + } if (availability === null) await discoverCommands($, names) - const ctx = { Box, Text, Button } + const busy = await read($, pending) + const ctx: Ctx = { + ui, + act, + body, + index: byRef(summary), + stack, + names, + busy: busy === null ? null : busy.action, + } + + // 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) + // 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 = [ + + This item is not part of the current explanation. + , + ] + } else { + blocks = 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} - ))} - - {(['show-me', 'retro'] as Action[]).map(action => - actionBlock( - ctx, - action, - names[action], - busy !== null && busy.action === action, - () => runAction($, action, names[action]), - ), - )} - + {header} + {blocks.slice(first)} ) }) 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/scripts/verify.sh b/scripts/verify.sh index 3ad5bf0..6c0e99a 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 \ hooks/actions.test.tsx \ types/index.d.ts \ src/hermes_helmet/claude_mod_toolchain.py \ 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", diff --git a/tests/test_claude_mod_toolchain.py b/tests/test_claude_mod_toolchain.py index 5fa1391..dcbe2f2 100644 --- a/tests/test_claude_mod_toolchain.py +++ b/tests/test_claude_mod_toolchain.py @@ -207,6 +207,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..6c78e6f 100644 --- a/tests/test_first_officer_plugin.py +++ b/tests/test_first_officer_plugin.py @@ -307,11 +307,24 @@ 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", + "hooks/actions.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", + "hooks/actions.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 }