Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hermes-helmet",
"displayName": "Hermes Helmet",
"version": "0.4.0",
"version": "0.5.0",
"description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.",
"author": {
"name": "Machine Wisdom",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hermes-helmet",
"version": "0.4.0",
"version": "0.5.0",
"description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.",
"author": {
"name": "Machine Wisdom",
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to Hermes Helmet are recorded here. The project follows

## Unreleased

### 0.5.0

- Captain’s Bridge Refresh records now rereads the exact bound chat directly
through its read-only app tool, with no agent message, background explanation
or project task. Selection and navigation are kept, cited records are
validated against the chat as it is now, and “Records read” is shown apart
from “Explanation prepared from records read”. New records mark the
explanation older without changing its conclusions or restamping it. Read
errors, unsupported formats, missing evidence, timeouts, foreign and
out-of-order responses keep the last useful view and report the limitation;
a partially written final record is flagged rather than shown as a complete
chat.

### 0.4.0

- Captain’s Bridge now presents the Changes First layout: purpose and outcome
Expand Down
7 changes: 5 additions & 2 deletions mcp/captains-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ not load it.
fail with an explicit message. A chat is never chosen by recency or directory.
- Source text is data, never instructions. Nothing is written: no chat state,
transcript export, resume or collection infrastructure.
- Refresh rereads records and keeps the explanation with its original read time,
flagging it as older when the fingerprint changed. “Update walkthrough”
- Refresh records calls the app-only read-only tool directly (no agent message,
background work or project task), keeps the explanation with its original
read time and flags it as older when the fingerprint changed. Failures,
foreign or out-of-order responses and a partially written final record keep
the last view and report the limit. “Update walkthrough”
sends a request to the first officer. Delegated background preparation and
cancellation are not implemented.
- Show Me and Retro request separately installed skills and report if they are
Expand Down
1 change: 1 addition & 0 deletions mcp/captains-bridge/chat_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,6 @@ def summarize(thread):
'updates': updates, 'outcome': outcome})
return {'title': thread.get('name') or 'This chat',
'readAt': datetime.now(timezone.utc).isoformat(), 'turns': turns, 'records': records,
'partialAppend': bool(thread.get('partialAppend')),
'createdAt': thread.get('createdAt'), 'updatedAt': thread.get('updatedAt'),
'coverage': 'Codex’s existing saved turns and completed item records: messages, tool outcomes, and explicit agent handoffs. Live unfinished operations and worker-side Hermes events are not connected.'}
5 changes: 3 additions & 2 deletions mcp/captains-bridge/saved_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def read_saved_chat(home, thread_id):
path = Path(row['rollout_path']).resolve()
if not any(path.is_relative_to(home / directory) for directory in ('sessions', 'archived_sessions')):
raise ValueError('This chat’s saved record is outside Codex’s session directories.')
turns, item_count, unsupported, identity = {}, 0, 0, None
turns, item_count, unsupported, identity, partial = {}, 0, 0, None, False

def turn_for(turn_id):
if not isinstance(turn_id, str) or not turn_id:
Expand All @@ -103,6 +103,7 @@ def turn_for(turn_id):
line = stream.readline(remaining)
remaining -= len(line)
if not line or not line.endswith(b'\n'):
partial = bool(line)
break
try:
event = json.loads(line)
Expand Down Expand Up @@ -149,4 +150,4 @@ def turn_for(turn_id):
for turn in turns.values():
turn['items'] = list(turn['items'].values())
return {'name': row.get('name'), 'createdAt': row.get('created_at'), 'updatedAt': row.get('updated_at'),
'turns': list(turns.values())}
'turns': list(turns.values()), 'partialAppend': partial}
5 changes: 4 additions & 1 deletion mcp/captains-bridge/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ def read_view(view):
if account is not None:
# Treat the app's explanation as untrusted input. Its citations must
# still belong to this exact chat; preserve when it was prepared.
checked = validate(account, data)
try:
checked = validate(account, data)
except ValueError as error:
raise ValueError('Some records this explanation cites are not in the chat as it is now, so records were not refreshed. ' + str(error)) from error
original = account.get('fingerprint', '')
explained = account.get('explainedAt', '')
if not isinstance(original, str) or not re.fullmatch(r'[0-9a-f]{64}', original):
Expand Down
70 changes: 70 additions & 0 deletions mcp/captains-bridge/test_refresh.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const fs = require('fs'), vm = require('vm'), assert = require('node:assert/strict');
class Element {
constructor(tag) { this.tagName=tag; this.children=[]; this.textContent=''; this.hidden=false; this.disabled=false; this.style={setProperty(){}}; }
append(c){this.children.push(c)} prepend(c){this.children.unshift(c)} replaceChildren(...c){this.children=c} focus(){}
}
const roots=Object.fromEntries(['app','error','connect','connection','request-status'].map(k=>[k,new Element(k)]));
const document={getElementById:id=>roots[id],createElement:t=>new Element(t),documentElement:new Element('html')};
let listener; const calls=[], held=[], messages=[];
const parent={postMessage(m){
if(m.method==='ui/message'){messages.push(m);return}
if(m.method!=='tools/call')return;
calls.push(m);
held.push(answer=>listener({source:parent,data:{jsonrpc:'2.0',id:m.id,result:answer}}));
}};
const context=vm.createContext({document,parent,window:{scrollY:0,scrollTo(){}},setTimeout,clearTimeout,requestAnimationFrame:f=>f(),addEventListener:(t,fn)=>{if(t==='message')listener=fn},console});
vm.runInContext(fs.readFileSync('view.html','utf8').match(/<script>([\s\S]*)<\/script>/)[1],context);
const visible=e=>e.textContent+' '+e.children.map(visible).join(' ');
const view={threadId:'this-chat-only',walkthrough:{summary:'Prepared'}};
const deck=(readAt,extra={})=>({deck:{title:'Chat',threadId:'this-chat-only',readAt,turns:[],records:[],explanationStale:false,
walkthrough:{summary:'Original conclusion.',objective:'Obj',explainedAt:'2026-10-09T10:00:00Z',evidence:[],items:[{id:'a',title:'Work',group:'changed',status:'Recorded',summary:'S',detail:'D',evidence:[],steps:[],links:[]}]},...extra},view});
const ok=d=>({structuredContent:d});
const tick=()=>new Promise(r=>setImmediate(r));
const btn=()=>({disabled:false});
(async()=>{
vm.runInContext('selected="a"',context);
context.receive(ok(deck('2026-10-09T10:30:00Z')));
assert.match(visible(roots.app),/Original conclusion/);
// Overlapping: a second click while one is in flight is not started.
const b1=btn(), b2=btn();
const first=vm.runInContext('refresh',context)(b1);
const second=vm.runInContext('refresh',context)(b2);
assert.equal(calls.length,1,'one refresh request at a time');
assert.equal(calls[0].params.name,'refresh_observation_deck');
assert.equal(messages.length,0,'refresh sends no agent message');
const stale=deck('2026-10-09T11:00:00Z',{explanationStale:true});
held.shift()(ok(stale)); await first; await second;
const text=visible(roots.app);
assert.match(text,/older than the chat|older than the records/);
assert.match(text,/Original conclusion/,'conclusions unchanged');
assert.match(text,/Explanation prepared from records read/);
assert.equal(vm.runInContext('selected',context),'a','selection kept');
assert.equal(b1.disabled,false);
// Out-of-order: an older read arriving later is ignored.
const older=vm.runInContext('refresh',context)(btn());
held.shift()(ok(deck('2026-10-09T10:45:00Z')));
await older;
assert.equal(vm.runInContext('deck.readAt',context),'2026-10-09T11:00:00Z');
assert.match(roots.error.textContent,/not refreshed/);
assert.equal(roots.error.hidden,false);
assert.match(visible(roots.app),/Original conclusion/);
// Foreign chat answer is ignored.
const foreign=vm.runInContext('refresh',context)(btn());
const other=deck('2026-10-09T12:00:00Z'); other.deck.threadId='someone-else';
held.shift()(ok(other)); await foreign;
assert.match(roots.error.textContent,/different chat/);
assert.equal(vm.runInContext('deck.readAt',context),'2026-10-09T11:00:00Z');
// Read error keeps view and reports the limitation.
const failed=vm.runInContext('refresh',context)(btn());
held.shift()({isError:true,content:[{type:'text',text:'Codex’s saved chat index could not be read in read-only mode.'}]}); await failed;
assert.match(roots.error.textContent,/not refreshed.*read-only mode.*previous view is kept/);
assert.match(visible(roots.app),/Original conclusion/);
// Timeout (host never answers within the rpc limit) uses the same path.
const timeoutCtx=vm.runInContext('(()=>{const f=refresh;return f})()',context);
const pendingRefresh=timeoutCtx(btn());
held.shift()(ok(deck('2026-10-09T13:00:00Z',{partialAppend:true}))); await pendingRefresh;
assert.match(visible(roots.app),/still being written/);
// A successful refresh clears the error.
assert.equal(roots.error.hidden,true);
console.log('PASS: direct refresh freshness, retained state, failures, foreign and out-of-order responses, overlapping requests.');
})().catch(e=>{console.error(e);process.exitCode=1});
100 changes: 100 additions & 0 deletions mcp/captains-bridge/test_refresh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Direct record refresh: freshness, membership and honest failures (synthetic)."""
import json
import unittest
from unittest.mock import patch

import server
import test_walkthrough
from test_chat_reader import SavedChatReader

CHAT = '11111111-1111-4111-8111-111111111111'


class RefreshRecords(unittest.TestCase):
def setUp(self):
fixture = test_walkthrough.WalkthroughTests()
fixture.setUp()
self.data, self.account = fixture.data, fixture.account
self.data['readAt'] = '2026-10-09T10:00:00+00:00'
with patch.object(server, 'read_chat', return_value=self.data):
key, state = server.bind(CHAT)
state['walkthrough'] = server.validate(self.account, self.data)
self.view = server.present(key, state, for_app=True)['structuredContent']['view']

def refresh(self, data, view=None):
with patch.object(server, 'read_chat', return_value=data):
return server.handle('tools/call', {'name': 'refresh_observation_deck', 'arguments': {'view': view or self.view}})

def test_refresh_is_read_only_and_exposes_no_messaging(self):
tool = server.REFRESH
self.assertTrue(tool['annotations']['readOnlyHint'])
self.assertEqual(tool['_meta']['ui']['visibility'], ['app'])
self.assertEqual(set(tool['inputSchema']['properties']), {'view'})

def test_new_records_mark_older_without_restamping_or_changing_conclusions(self):
later = dict(self.data, readAt='2026-10-09T11:00:00+00:00',
records=self.data['records'] + [{'id': 'new', 'kind': 'report', 'text': 'Later', 'turnId': 'turn-a'}])
deck = self.refresh(later)['structuredContent']['deck']
self.assertTrue(deck['explanationStale'])
self.assertEqual(deck['readAt'], '2026-10-09T11:00:00+00:00')
self.assertEqual(deck['walkthrough']['explainedAt'], '2026-10-09T10:00:00+00:00')
self.assertEqual(deck['walkthrough']['fingerprint'], self.view['walkthrough']['fingerprint'])
self.assertEqual(deck['walkthrough']['summary'], self.view['walkthrough']['summary'])

def test_unchanged_records_keep_explanation_current(self):
deck = self.refresh(dict(self.data, readAt='2026-10-09T12:00:00+00:00'))['structuredContent']['deck']
self.assertFalse(deck['explanationStale'])
self.assertEqual(deck['walkthrough']['explainedAt'], '2026-10-09T10:00:00+00:00')

def test_citations_missing_from_the_chat_fail_and_report(self):
missing = dict(self.data, records=[r for r in self.data['records'] if r['id'] != 'command'])
with self.assertRaisesRegex(ValueError, 'not refreshed'):
self.refresh(missing)

def test_forged_foreign_citation_in_the_view_is_rejected(self):
view = json.loads(json.dumps(self.view))
view['walkthrough']['items'][0]['evidence'] = ['another-chat']
with self.assertRaises(ValueError):
self.refresh(self.data, view)

def test_view_without_original_stamp_is_rejected(self):
view = json.loads(json.dumps(self.view))
del view['walkthrough']['explainedAt']
with self.assertRaises(ValueError):
self.refresh(self.data, view)

def test_read_failure_is_a_visible_tool_error_and_sends_nothing(self):
for message in ('Codex’s saved chat index could not be read in read-only mode.',
'This saved item format is not supported by Captain’s Bridge.'):
with patch.object(server, 'read_chat', side_effect=ValueError(message)):
with self.assertRaisesRegex(ValueError, 'index|supported'):
server.handle('tools/call', {'name': 'refresh_observation_deck', 'arguments': {'view': self.view}})

def test_view_bound_to_a_different_chat_reads_only_that_chat(self):
view = dict(self.view, threadId='22222222-2222-4222-8222-222222222222')
seen = []
with patch.object(server, 'read_chat', side_effect=lambda t: seen.append(t) or self.data):
server.handle('tools/call', {'name': 'refresh_observation_deck', 'arguments': {'view': view}})
self.assertEqual(seen, ['22222222-2222-4222-8222-222222222222'])


class PartialAppend(SavedChatReader):
def test_partial_final_line_is_flagged_not_hidden(self):
self.save(tail='{"type": "event_msg", "payload": {"ty')
data = __import__('chat_reader').read_chat(CHAT)
self.assertTrue(data['partialAppend'])
self.assertEqual([r['id'] for r in data['records']], ['request', 'report', 'command'])

def test_complete_file_is_not_flagged(self):
self.assertFalse(__import__('chat_reader').read_chat(CHAT)['partialAppend'])

def test_only_a_partial_append_is_never_an_empty_success(self):
self.path.write_text('{"type": "session_me')
with self.assertRaises(ValueError):
__import__('chat_reader').read_chat(CHAT)


del SavedChatReader

if __name__ == '__main__':
unittest.main()
2 changes: 1 addition & 1 deletion mcp/captains-bridge/test_view.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ run("source(['source'])");assert.match(text(roots.app),/<img src=x onerror=alert
const walk=e=>[e,...e.children.flatMap(walk)];
assert.ok(walk(roots.app).some(e=>e.tagName==='a'&&e.href==='https://github.com/example/repo/pull/1'&&e.rel.includes('noopener')),'Evidence opens the original referenced record.');
run('back();back();back()');assert.equal(window.scrollY,177);assert.match(text(roots.app),/What changed/);
run("navigate({page:'work',id:'interval'});payload._meta.deck.explanationStale=true;receive(payload)");assert.match(text(roots.app),/Records changed/);assert.match(text(roots.app),/Not accepted/);
run("navigate({page:'work',id:'interval'});payload._meta.deck.explanationStale=true;receive(payload)");assert.match(text(roots.app),/Newer records were read/);assert.match(text(roots.app),/Not accepted/);
// Focus returns to the control that opened the work, and the overview keeps time details collapsed.
run("navigate({page:'overview'})");assert.match(text(roots.app),/Elapsed time and gaps/);
const row=walk(roots.app).find(e=>e.tagName==='button'&&e.id==='work-interval');assert.ok(row);row.onclick();
Expand Down
Loading
Loading