diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 75be1d7..8c9271f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index e88f62d..81ca114 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index ef34b49..3b2e951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/mcp/captains-bridge/README.md b/mcp/captains-bridge/README.md index 11ecd8a..e75299c 100644 --- a/mcp/captains-bridge/README.md +++ b/mcp/captains-bridge/README.md @@ -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 diff --git a/mcp/captains-bridge/chat_reader.py b/mcp/captains-bridge/chat_reader.py index 2fb3c2c..b673a00 100644 --- a/mcp/captains-bridge/chat_reader.py +++ b/mcp/captains-bridge/chat_reader.py @@ -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.'} diff --git a/mcp/captains-bridge/saved_chat.py b/mcp/captains-bridge/saved_chat.py index ec96ae9..8e54693 100644 --- a/mcp/captains-bridge/saved_chat.py +++ b/mcp/captains-bridge/saved_chat.py @@ -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: @@ -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) @@ -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} diff --git a/mcp/captains-bridge/server.py b/mcp/captains-bridge/server.py index 986a47f..031c890 100644 --- a/mcp/captains-bridge/server.py +++ b/mcp/captains-bridge/server.py @@ -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): diff --git a/mcp/captains-bridge/test_refresh.cjs b/mcp/captains-bridge/test_refresh.cjs new file mode 100644 index 0000000..ea1ddfe --- /dev/null +++ b/mcp/captains-bridge/test_refresh.cjs @@ -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(/