openvisio-agent 0.18.5 → 0.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,7 +50,7 @@ npx -y openvisio-agent@latest connect --backend https://api.your-org.example/dev
50
50
  - `--ws <wss-url>` — the org's API-Gateway WebSocket base (the same value the frontend uses as `NEXT_PUBLIC_BACKEND_WS_URL`).
51
51
  - `--mcp-url <url>` — registers the `openvisio-team` MCP (authed with the agent header pair) so the agent has tools to **act** on the events.
52
52
 
53
- Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and pokes **one** Claude cycle per `task:assigned` / `agent:mention` (a burst of mentions coalesces into a single follow-up). Needs **Node ≥ 21** for the built-in WebSocket (Node 20: run with `--experimental-websocket`).
53
+ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and starts one work cycle per `task:assigned` / `agent:mention` (a burst of mentions coalesces into a follow-up). A work cycle can contain many assistant, tool, and message actions; emitting a progress message does not end it. Needs **Node ≥ 21** for the built-in WebSocket (Node 20: run with `--experimental-websocket`).
54
54
 
55
55
  ### `watch --name <agent>`
56
56
 
@@ -62,7 +62,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
62
62
 
63
63
  Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
64
64
 
65
- An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. Accepted coding work uses the activity indicator instead of a generic pickup message, then returns one verified result or concrete blocker in the source thread.
65
+ An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. Accepted coding work uses the activity indicator instead of a generic pickup message. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
66
66
 
67
67
  Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
68
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,7 @@ const assertions = [
47
47
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
48
48
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
49
49
  ['optional ticket comments cannot block verified completion', watcher.includes("callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('comment_ticket is not exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
50
- ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
50
+ ['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
51
51
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
52
52
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
53
53
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
@@ -87,7 +87,9 @@ const assertions = [
87
87
  ['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
88
88
  ['OpenCode backend prompts forbid relay and resource-discovery tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, get_resource, list_mcp_resources, list_mcp_resource_templates')],
89
89
  ['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
90
- ['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
90
+ ['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
91
+ ['Codex prose cannot masquerade as repository evidence', watcher.includes('codexEventEvidence(event)') && events.includes("item.type === 'agent_message'") && events.includes("event.type === 'item.completed'")],
92
+ ['coding recovery retains source context and requires a final result', watcher.includes('ORIGINAL REQUEST AND ROUTING CONTEXT') && events.includes('resultMessageRequired && !result?.didResultMessage') && events.includes("outputText: second?.outputText || first?.outputText || ''")],
91
93
  ['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
92
94
  ['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
93
95
  ['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
@@ -99,7 +101,8 @@ const assertions = [
99
101
  ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
100
102
  ['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
101
103
  ['repository push authorization automatically resumes the paused ticket', watcher.includes('blockedTaskRepos: [...blockedTaskRepos]') && watcher.includes('repositoryHasPrPushAuthorization')],
102
- ['Codex prefers linked-codebase MCP PR delivery', watcher.includes('CODEX PR DELIVERY') && watcher.includes('create_codebase_branch/create_codebase_commit/create_pull_request') && events.includes('const codebaseMutation')],
104
+ ['BYO coding prefers an existing local repository', watcher.includes('A usable local clone is your primary code surface') && watcher.includes('do not use remote codebase tools') && watcher.includes('Remote codebase tools are a fallback only when the repository cannot be obtained locally')],
105
+ ['Codex publishes local branches with the constrained helper', watcher.includes('CODEX PR DELIVERY') && watcher.includes('openvisio-agent push-pr-branch') && watcher.includes('Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback') && events.includes('const codebaseMutation')],
103
106
  ['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', 'origin', destination")],
104
107
  ['PR push helper rejects protected/alternate/force targets by construction', prPush.includes("/^agent\\/") && prPush.includes('entry?.root === root && entry?.remote === remote') && prPush.includes('accepts no force, remote, or ref args')],
105
108
  ['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
package/src/events.mjs CHANGED
@@ -56,7 +56,21 @@ export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
56
56
  return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
57
57
  }
58
58
 
59
- export function buildTaskCompletionReport(task, { projectId, fallbackText = '' } = {}) {
59
+ function personDisplayName(person) {
60
+ if (!person || typeof person !== 'object') return ''
61
+ return (`${person.first_name || person.firstName || ''} ${person.last_name || person.lastName || ''}`.trim() || person.name || '').trim()
62
+ }
63
+
64
+ /** The human responsible for a BYO agent is the person who added it, not the
65
+ * creator of whichever ticket it happens to complete. Backend deployments have
66
+ * exposed that relationship under both association and transport-style names. */
67
+ export function agentAddedByName(agent) {
68
+ if (!agent || typeof agent !== 'object') return ''
69
+ const addedBy = agent.agentCreator || agent.agent_creator || agent.createdByUser || agent.created_by_user || agent.owner || agent.user
70
+ return personDisplayName(addedBy)
71
+ }
72
+
73
+ export function buildTaskCompletionReport(task, { projectId, fallbackText = '', recipientName = '' } = {}) {
60
74
  if (!task || typeof task !== 'object' || task.id == null) return null
61
75
  if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
62
76
 
@@ -64,8 +78,7 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' }
64
78
  const prUrl = /https:\/\/github\.com\/[^\s)\]}>]+\/pull\/\d+/i.exec(evidence)?.[0]?.replace(/[.,;:]+$/, '') || ''
65
79
  if (!prUrl) return null
66
80
 
67
- const creator = task.creator || task.created_by_user || task.createdByUser || {}
68
- const requester = (`${creator.first_name || creator.firstName || ''} ${creator.last_name || creator.lastName || ''}`.trim() || creator.name || '').trim()
81
+ const requester = String(recipientName || '').trim()
69
82
  const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
70
83
  const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
71
84
  const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
@@ -131,6 +144,131 @@ export function opencodeEventEvidence(event) {
131
144
  }
132
145
  }
133
146
 
147
+ // Codex emits started/completed JSONL records for the same item. Trust only a
148
+ // completed, non-failed record for mutation and MCP evidence. In particular,
149
+ // words such as "apply_patch" inside an agent_message are just prose.
150
+ export function codexEventEvidence(event) {
151
+ if (!event || typeof event !== 'object') return {}
152
+ const item = event.item ?? event
153
+ if (!item || typeof item !== 'object') return {}
154
+ if (item.type === 'agent_message') return { outputText: typeof item.text === 'string' ? item.text : '' }
155
+
156
+ const status = String(item.status ?? '').toLowerCase()
157
+ const completed = event.type === 'item.completed' || /^(?:completed|success|succeeded|ok)$/.test(status)
158
+ const failed = event.type === 'item.failed' || /(?:fail|error|denied|rejected)/.test(status) || item.error != null || Number(item.exit_code) > 0
159
+ const itemType = String(item.type ?? '')
160
+ if (/^(?:command_execution|file_change|apply_patch|shell_command|exec_command)$/.test(itemType)) {
161
+ const command = String(item.command ?? item.input?.command ?? item.input?.cmd ?? '')
162
+ return {
163
+ failed,
164
+ completed: completed && !failed,
165
+ didCode: completed && !failed,
166
+ didRepoMutation: completed && !failed && (/^(?:file_change|apply_patch)$/.test(itemType) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create|openvisio-agent\s+push-pr-branch)\b/i.test(command)),
167
+ }
168
+ }
169
+ if (itemType !== 'mcp_tool_call') return {}
170
+
171
+ const rawTool = String(item.tool ?? item.name ?? item.method ?? 'unknown')
172
+ const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(rawTool)
173
+ const mcpTool = String(prefixed?.[1] ?? rawTool).replace(/[-.]/g, '_')
174
+ const succeeded = completed && !failed
175
+ return {
176
+ mcpTool,
177
+ failed,
178
+ completed: succeeded,
179
+ didCode: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
180
+ didRepoMutation: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
181
+ didMcpTaskRead: succeeded && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
182
+ didMcpTaskUpdate: succeeded && mcpTool === 'update_ticket',
183
+ didMessage: succeeded && /^(?:post_message|comment_ticket)$/.test(mcpTool),
184
+ didChannelMessage: succeeded && mcpTool === 'post_message',
185
+ }
186
+ }
187
+
188
+ // Claude's stream-json protocol separates tool_use blocks from their later
189
+ // tool_result blocks. Keep the pending uses supplied by the caller and reduce
190
+ // only completed results to work evidence; announcing a tool call is not proof
191
+ // that the action succeeded.
192
+ export function claudeEventEvidence(event, pendingToolUses = new Map()) {
193
+ if (!event || typeof event !== 'object') return {}
194
+ const content = Array.isArray(event.message?.content) ? event.message.content : []
195
+ const output = []
196
+ const toolCalls = []
197
+ const toolResults = []
198
+
199
+ if (event.type === 'assistant') {
200
+ for (const block of content) {
201
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) output.push(block.text.trim())
202
+ if (block?.type !== 'tool_use' || !block.name) continue
203
+ const id = String(block.id ?? block.tool_use_id ?? '')
204
+ const use = { name: String(block.name), input: block.input && typeof block.input === 'object' ? block.input : {} }
205
+ if (id) pendingToolUses.set(id, use)
206
+ toolCalls.push(use.name)
207
+ }
208
+ }
209
+
210
+ if (event.type === 'user') {
211
+ for (const block of content) {
212
+ if (block?.type !== 'tool_result') continue
213
+ const id = String(block.tool_use_id ?? block.id ?? '')
214
+ const use = id ? pendingToolUses.get(id) : null
215
+ if (!use) continue
216
+ pendingToolUses.delete(id)
217
+ const failed = block.is_error === true
218
+ toolResults.push(opencodeEventEvidence({
219
+ type: 'tool_use',
220
+ part: {
221
+ type: 'tool',
222
+ tool: use.name,
223
+ state: {
224
+ status: failed ? 'error' : 'completed',
225
+ input: use.input,
226
+ ...(failed ? { error: block.content ?? 'Claude tool call failed' } : {}),
227
+ },
228
+ },
229
+ }))
230
+ }
231
+ }
232
+
233
+ return {
234
+ ...(output.length ? { outputText: output.join(' ') } : {}),
235
+ ...(toolCalls.length ? { toolCalls } : {}),
236
+ ...(toolResults.length ? { toolResults } : {}),
237
+ }
238
+ }
239
+
240
+ export function missingRuntimeWorkEvidence(result, { ticketCycle = false, resultMessageRequired = false } = {}) {
241
+ const missing = [
242
+ ticketCycle && !result?.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks',
243
+ !result?.didRepoMutation && 'perform and verify the repository change',
244
+ ticketCycle && !result?.didMcpTaskUpdate && 'update the ticket through update_ticket',
245
+ resultMessageRequired && !result?.didResultMessage && 'post a final result after the repository work',
246
+ ].filter(Boolean)
247
+ if (result?.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
248
+ return missing
249
+ }
250
+
251
+ export function combineRuntimeWorkEvidence(first, second) {
252
+ return {
253
+ ...second,
254
+ didCode: !!first?.didCode || !!second?.didCode,
255
+ didRepoMutation: !!first?.didRepoMutation || !!second?.didRepoMutation,
256
+ didMessage: !!first?.didMessage || !!second?.didMessage,
257
+ didChannelMessage: !!first?.didChannelMessage || !!second?.didChannelMessage,
258
+ // A message in a continuation comes after repository work completed in the
259
+ // first turn, even when that continuation itself performs no new mutation.
260
+ didResultMessage: !!first?.didResultMessage || !!second?.didResultMessage || (!!first?.didRepoMutation && !!second?.didChannelMessage),
261
+ didMcpTaskRead: !!first?.didMcpTaskRead || !!second?.didMcpTaskRead,
262
+ didMcpTaskUpdate: !!first?.didMcpTaskUpdate || !!second?.didMcpTaskUpdate,
263
+ mcpCalls: [...new Set([...(first?.mcpCalls || []), ...(second?.mcpCalls || [])])],
264
+ // A focused continuation gets the chance to clear an earlier transient MCP
265
+ // failure. Only failures still present in the continuation remain blockers.
266
+ mcpErrors: second?.mcpErrors || [],
267
+ // Never deliver a stale promise together with a later verified result.
268
+ outputText: second?.outputText || first?.outputText || '',
269
+ }
270
+ }
271
+
134
272
  export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
135
273
  if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
136
274
  const id = Number(channelId)
package/src/watch.mjs CHANGED
@@ -10,7 +10,7 @@ import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { agentStateRequest, buildTaskCompletionReport, classifyConversationTarget, codexPolicyBlock, conversationNeedsCode, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, ticketDisplaySlug } from './events.mjs'
13
+ import { agentAddedByName, agentStateRequest, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, ticketDisplaySlug } from './events.mjs'
14
14
  import { createByoMemoryGraph } from './memory.mjs'
15
15
  import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
16
16
  import { createMcpHttpClient } from './mcp-http.mjs'
@@ -34,7 +34,7 @@ const REPLY_DISCIPLINE = [
34
34
  ' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
35
35
  ' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
36
36
  ' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
37
- ' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT post again. Do not send a generic pickup acknowledgement; activity shows that work is underway. The first durable task message is a verified result, a link, or a real blocker. One answer per question.',
37
+ ' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT repeat the same message. Do not send a generic pickup acknowledgement; activity shows that work is underway. For longer code work, you may send at most one concrete progress update after work has actually begun, but that update NEVER completes the cycle: keep using tools, then send one distinct verified result or real blocker. One final answer per question.',
38
38
  ' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
39
39
  ' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
40
40
  ' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
@@ -75,14 +75,14 @@ const CODE_CHARTER = [
75
75
  ' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, and update_ticket, plus post_message/react_message/list_activity. Ticket comments are optional: use a comment tool only when it appears in the current tool list. Relay runtimes may additionally expose poll_inbox or get_marching_orders.',
76
76
  ' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
77
77
  ' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
78
- 'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. Reuse existing clones and the context you already verified. Read repository AGENTS.md instructions before changing code. For any task: locate the relevant repo under the workspace; clone it only when it is genuinely absent, then work inside that subfolder. Never ask the user for a path you can discover yourself.',
78
+ 'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. Reuse existing clones and the context you already verified. A usable local clone is your primary code surface: inspect, search, edit, branch, test, and commit with the local file and Bash/git tools. Do not call remote codebase read/write/branch/commit tools for a repository that already exists locally. Read repository AGENTS.md instructions before changing code. For any task: locate the relevant repo under the workspace; clone it only when it is genuinely absent, then work inside that subfolder. Never ask the user for a path you can discover yourself. Remote codebase tools are a fallback only when the repository cannot be obtained locally.',
79
79
  'CAPABILITY CHECK: before you EVER answer "I can\'t do that", verify against the tools above. If a tool exists for it, DO it. To be explicit: you CAN read/inspect any of the org\'s codebases, clone a repo you don\'t have yet, work on it, create a branch, and raise a PR — say YES to these and then actually do them.',
80
80
  '',
81
81
  'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
82
- ' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back.',
82
+ ' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back. Sending an intent or progress message is not a stop condition: continue using tools and send the verified result or blocker afterward.',
83
83
  ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the project channel after the PR and ticket handoff are confirmed.',
84
84
  ' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
85
- ' 4. One reply per channel per cycle; answer several nudges together.',
85
+ ' 4. One final reply per request; answer several nudges together. A single concrete progress update is allowed during longer work, but it must be followed by the final result or blocker in the same cycle.',
86
86
  ' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
87
87
  '',
88
88
  REPLY_DISCIPLINE,
@@ -92,7 +92,7 @@ const CODE_FULL = [
92
92
  'THIS CYCLE: discover assigned tickets with the tools that actually exist, then act on them. If get_marching_orders/poll_inbox exist, use them. On the backend MCP, identify yourself with list_agents, call list_projects, then list_tasks for each project and keep tasks whose agent_id or nested agent.identifier is yours.',
93
93
  'DO NOT post a promise or pre-work acknowledgement. Start the repository work immediately. Your first task/channel update must contain either a verified result (branch, commit, PR, tests) or a concrete blocker you actually encountered.',
94
94
  'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
95
- ' 1. GET THE CODE: use verified thread/history/recall context first, locate the target repo under your workspace root, and read its AGENTS.md. Reuse an existing clone; clone only if absent. Check `git status` before changing anything and preserve unrelated user work. Update from the remote only when it is safe. Do this yourself; never ask the user for a path you can discover.',
95
+ ' 1. GET THE CODE: use verified thread/history/recall context first, locate the target repo under your workspace root, and read its AGENTS.md. Reuse an existing clone; clone only if absent. When the repo exists locally, use Read/Grep/Glob and local git for all code discovery and changes; do not use remote codebase tools. Check `git status` before changing anything and preserve unrelated user work. Update from the remote only when it is safe. Do this yourself; never ask the user for a path you can discover.',
96
96
  ' 2. BRANCH: git checkout -B agent/<short-task-slug>. NEVER work on, commit to, or push main/master.',
97
97
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
98
98
  ' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
@@ -106,7 +106,7 @@ const CODE_FAST = [
106
106
  ' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
107
107
  ' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
108
108
  'If the message asks YOU for real CODE work, do the WHOLE job now — branch (checkout -B agent/<slug>), edit, run tests, commit, push your branch, open a PR (gh pr create), then reply once with the PR link and @mention the requester by exact full name. Never push to main, never --force, never merge.',
109
- 'Post ONE message for the thing you are answering send it once; never a reply then a "better" version; never repeat a reply you already sent; be sure before you send. Do NOT promise and stop finish and report in THIS cycle. 1-3 sentences, no summary. Then stop.',
109
+ 'For a non-code question, post ONE answer and stop. For code work, do not post a generic pickup message. You may post one concrete progress update after work starts, but keep working after it; then post one distinct final result with the PR/test evidence or a real blocker. Never repeat the same message. The final update should be 1-3 sentences.',
110
110
  ].join('\n')
111
111
 
112
112
  // ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
@@ -328,7 +328,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
328
328
  // JSON mode is the evidence boundary. Formatted stdout only tells us that
329
329
  // OpenCode exited; raw events tell us which tools actually completed.
330
330
  const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
331
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
331
+ let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
332
332
  let cancel = null
333
333
  let outputText = '', jsonlBuffer = ''
334
334
  const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
@@ -339,7 +339,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
339
339
  clearTimeout(timer)
340
340
  const calls = [...mcpCalls]
341
341
  log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
342
- resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
342
+ resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
343
343
  }
344
344
  const inspectLine = (line) => {
345
345
  const value = String(line || '').trim()
@@ -367,6 +367,8 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
367
367
  if (evidence.failed) mcpErrors.add(evidence.mcpTool)
368
368
  else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
369
369
  }
370
+ const repositoryWasChanged = didRepoMutation
371
+ if (evidence.didChannelMessage && repositoryWasChanged) didResultMessage = true
370
372
  didCode ||= !!evidence.didCode
371
373
  didRepoMutation ||= !!evidence.didRepoMutation
372
374
  didMessage ||= !!evidence.didMessage
@@ -446,7 +448,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
446
448
  ...(m ? ['--model', m] : []),
447
449
  ...(mcpOverride ? ['-c', mcpOverride] : []),
448
450
  full]
449
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
451
+ let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
450
452
  let cancel = null
451
453
  let policyBlock = null
452
454
  const mcpCalls = new Set(), mcpErrors = new Set()
@@ -458,7 +460,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
458
460
  clearTimeout(timer)
459
461
  const calls = [...mcpCalls]
460
462
  log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
461
- resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
463
+ resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
462
464
  }
463
465
  const inspectDiagnostic = (value) => {
464
466
  const s = String(value || '')
@@ -483,24 +485,23 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
483
485
  const s = line.trim()
484
486
  if (!s) return
485
487
  policyBlock = codexPolicyBlock(s) || policyBlock
486
- if (/command_execution|file_change|apply_patch|shell_command|exec_command/i.test(s)) didCode = true
487
- if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create|openvisio-agent\s+push-pr-branch)\b/i.test(s)) didRepoMutation = true
488
- if (/post_message|comment_ticket/i.test(s)) didMessage = true
489
488
  try {
490
489
  const event = JSON.parse(s)
491
- const item = event.item ?? event
492
- if (item?.type === 'agent_message' && typeof item.text === 'string') outputText += ' ' + item.text
493
- if (item?.type === 'mcp_tool_call') {
494
- const tool = String(item.tool || item.name || item.method || 'unknown').replace(/^openvisio-team[.:/]/, '')
495
- mcpCalls.add(tool)
496
- try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
497
- if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
498
- if (tool === 'update_ticket') didMcpTaskUpdate = true
499
- if (/^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(tool)) { didCode = true; didRepoMutation = true }
500
- if (/post_message|comment_ticket/.test(tool)) didMessage = true
501
- if (/post_message/.test(tool)) didChannelMessage = true
502
- if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
490
+ const evidence = codexEventEvidence(event)
491
+ if (evidence.outputText) outputText += ' ' + evidence.outputText
492
+ if (evidence.mcpTool) {
493
+ mcpCalls.add(evidence.mcpTool)
494
+ try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
495
+ if (evidence.failed) mcpErrors.add(evidence.mcpTool)
496
+ else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
503
497
  }
498
+ if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
499
+ didCode ||= !!evidence.didCode
500
+ didRepoMutation ||= !!evidence.didRepoMutation
501
+ didMessage ||= !!evidence.didMessage
502
+ didChannelMessage ||= !!evidence.didChannelMessage
503
+ didMcpTaskRead ||= !!evidence.didMcpTaskRead
504
+ didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
504
505
  } catch { /* non-JSON diagnostic */ }
505
506
  if (debug) log(' · ' + s.slice(0, 220))
506
507
  }
@@ -567,13 +568,35 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
567
568
  let resolveTurn = null
568
569
  let cycleTimer = null
569
570
  let turnToolCalls = new Set()
571
+ let turnToolUses = new Map()
572
+ let turnMcpErrors = new Set()
573
+ let turnDidCode = false
574
+ let turnDidRepoMutation = false
575
+ let turnDidMessage = false
576
+ let turnDidChannelMessage = false
577
+ let turnDidResultMessage = false
578
+ let turnDidMcpTaskRead = false
579
+ let turnDidMcpTaskUpdate = false
580
+ let turnOutputText = ''
570
581
  const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
571
582
  const settleTurn = (o) => {
572
583
  clearCycleTimer()
573
584
  const r = resolveTurn
574
585
  resolveTurn = null
575
586
  const mcpCalls = [...turnToolCalls]
576
- if (r) r({ ...o, mcpCalls, didChannelMessage: mcpCalls.includes('post_message') })
587
+ if (r) r({
588
+ ...o,
589
+ mcpCalls,
590
+ mcpErrors: [...turnMcpErrors],
591
+ didCode: turnDidCode,
592
+ didRepoMutation: turnDidRepoMutation,
593
+ didMessage: turnDidMessage,
594
+ didChannelMessage: turnDidChannelMessage,
595
+ didResultMessage: turnDidResultMessage,
596
+ didMcpTaskRead: turnDidMcpTaskRead,
597
+ didMcpTaskUpdate: turnDidMcpTaskUpdate,
598
+ outputText: turnOutputText.trim(),
599
+ })
577
600
  }
578
601
 
579
602
  // --debug: surface what the cycle actually does (tool calls, tool errors, text)
@@ -612,12 +635,25 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
612
635
  if (!line.trim()) continue
613
636
  let o; try { o = JSON.parse(line) } catch { continue }
614
637
  if (debug) logEvent(o)
615
- // Surface tool calls (e.g. post_message) so the loop can emit a live status.
616
- if (onTool && o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
617
- for (const b of o.message.content) if (b.type === 'tool_use' && b.name) {
618
- turnToolCalls.add(String(b.name).replace(/^mcp__openvisio-team__/, ''))
619
- try { onTool(b.name) } catch { /* status is best-effort */ }
638
+ const evidence = claudeEventEvidence(o, turnToolUses)
639
+ if (evidence.outputText) turnOutputText += (turnOutputText ? ' ' : '') + evidence.outputText
640
+ for (const name of evidence.toolCalls || []) {
641
+ turnToolCalls.add(String(name).replace(/^mcp__openvisio-team__/, ''))
642
+ try { onTool && onTool(name) } catch { /* status is best-effort */ }
643
+ }
644
+ for (const fact of evidence.toolResults || []) {
645
+ if (fact.mcpTool) {
646
+ turnToolCalls.add(fact.mcpTool)
647
+ if (fact.failed) turnMcpErrors.add(fact.mcpTool)
648
+ else if (fact.completed) turnMcpErrors.delete(fact.mcpTool)
620
649
  }
650
+ if (fact.didChannelMessage && turnDidRepoMutation) turnDidResultMessage = true
651
+ turnDidCode ||= !!fact.didCode
652
+ turnDidRepoMutation ||= !!fact.didRepoMutation
653
+ turnDidMessage ||= !!fact.didMessage
654
+ turnDidChannelMessage ||= !!fact.didChannelMessage
655
+ turnDidMcpTaskRead ||= !!fact.didMcpTaskRead
656
+ turnDidMcpTaskUpdate ||= !!fact.didMcpTaskUpdate
621
657
  }
622
658
  if (o.type === 'result') {
623
659
  log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
@@ -655,6 +691,16 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
655
691
  ensureSession()
656
692
  turnsThisSession++
657
693
  turnToolCalls = new Set()
694
+ turnToolUses = new Map()
695
+ turnMcpErrors = new Set()
696
+ turnDidCode = false
697
+ turnDidRepoMutation = false
698
+ turnDidMessage = false
699
+ turnDidChannelMessage = false
700
+ turnDidResultMessage = false
701
+ turnDidMcpTaskRead = false
702
+ turnDidMcpTaskUpdate = false
703
+ turnOutputText = ''
658
704
  resolveTurn = resolve
659
705
  // Backstop: abandon a cycle that never returns a result so `busy` is released
660
706
  // and queued mentions can proceed.
@@ -727,7 +773,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
727
773
  reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
728
774
  }
729
775
  const codexPushGuide = agent === 'codex' && canCode
730
- ? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker.'
776
+ ? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
731
777
  : ''
732
778
  const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, list_mcp_resource_templates, or other relay/resource-discovery tools; they are not backend team actions. Use only names present in the current openvisio-team tool list. For discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. Never call comment_ticket unless that exact optional tool is present.'
733
779
  const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
@@ -813,7 +859,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
813
859
  let lastTaskTriggeredAt = 0
814
860
  let lastInboxSignature = ''
815
861
  let selfAgentId = null
862
+ let completionRecipientName = ''
816
863
  const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
864
+ const rememberSelfAgent = (self) => {
865
+ if (!self || typeof self !== 'object') return
866
+ if (self.id != null) selfAgentId = Number(self.id)
867
+ for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
868
+ const addedBy = agentAddedByName(self)
869
+ if (addedBy) completionRecipientName = addedBy
870
+ }
817
871
  const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
818
872
  const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
819
873
  let mcpToolNames = null
@@ -978,7 +1032,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
978
1032
  const taskKey = `${projectId}:${ticketId}`
979
1033
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
980
1034
  const ticket = current.ticket ?? current.task ?? current
981
- const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
1035
+ const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, recipientName: completionRecipientName })
982
1036
  if (!report) return false
983
1037
  const memoryKey = `ticket:${projectId}:${ticketId}`
984
1038
  memory.remember({
@@ -1112,8 +1166,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1112
1166
  const agents = Array.isArray(agentsData.agents) ? agentsData.agents : []
1113
1167
  const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
1114
1168
  if (!self?.id) throw new Error('list_agents did not return this BYO agent')
1115
- selfAgentId = Number(self.id)
1116
- for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
1169
+ rememberSelfAgent(self)
1117
1170
  const projectsData = toolData(await callMcpTool('list_projects'))
1118
1171
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1119
1172
  const assigned = []
@@ -1232,33 +1285,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1232
1285
  const baseFor = (kind, delivery) => delivery?.watcherOwned
1233
1286
  ? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
1234
1287
  : kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1235
- // Codex and OpenCode expose structured action streams. Require runtime facts
1236
- // from those streams before accepting a full coding cycle. Claude's evidence
1237
- // shape is different and remains on its existing completion path.
1238
- const evidenceGatedRuntime = agent === 'codex' || agent === 'opencode'
1239
- const missingWorkEvidence = (result, ticketCycle) => {
1240
- const missing = [
1241
- ticketCycle && !result?.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks',
1242
- !result?.didRepoMutation && 'perform and verify the repository change',
1243
- ticketCycle && !result?.didMcpTaskUpdate && 'update the ticket through update_ticket',
1244
- ].filter(Boolean)
1245
- if (result?.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
1246
- return missing
1247
- }
1248
- const combineWorkEvidence = (first, second) => ({
1249
- ...second,
1250
- didCode: !!first?.didCode || !!second?.didCode,
1251
- didRepoMutation: !!first?.didRepoMutation || !!second?.didRepoMutation,
1252
- didMessage: !!first?.didMessage || !!second?.didMessage,
1253
- didChannelMessage: !!first?.didChannelMessage || !!second?.didChannelMessage,
1254
- didMcpTaskRead: !!first?.didMcpTaskRead || !!second?.didMcpTaskRead,
1255
- didMcpTaskUpdate: !!first?.didMcpTaskUpdate || !!second?.didMcpTaskUpdate,
1256
- mcpCalls: [...new Set([...(first?.mcpCalls || []), ...(second?.mcpCalls || [])])],
1257
- // A focused recovery is allowed to clear an earlier MCP failure. Only calls
1258
- // still failing in the recovery remain blockers.
1259
- mcpErrors: second?.mcpErrors || [],
1260
- outputText: [first?.outputText, second?.outputText].filter(Boolean).join(' '),
1261
- })
1288
+ // Every coding runtime now exposes structured action evidence. A normal CLI
1289
+ // result or assistant prose never closes a work cycle by itself.
1290
+ const cycleSucceeded = (result) => !!result && !result.is_error && [undefined, null, '', 'ok', 'success'].includes(result.subtype)
1262
1291
  const releaseTaskForRetry = (taskRef, prompt) => {
1263
1292
  const directProject = Number(taskRef?.projectId)
1264
1293
  const directTicket = Number(taskRef?.ticketId)
@@ -1323,9 +1352,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1323
1352
  catch (e) { log('failed to publish policy blocker: ' + (e?.message || e)) }
1324
1353
  return
1325
1354
  }
1326
- if (kind === 'full' && ['timeout', 'spawn-failed', 'error'].includes(result?.subtype)) {
1327
- const notice = `I'm blocked because the coding cycle ended with ${result.subtype}. I'm not claiming completion, and I've left the ticket open for retry.`
1328
- log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
1355
+ if (kind === 'full' && !cycleSucceeded(result)) {
1356
+ const outcome = result?.subtype || 'an unknown runtime error'
1357
+ const notice = `I'm blocked because the coding cycle ended with ${outcome}. I'm not claiming completion, and I've left the ticket open for retry.`
1358
+ log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
1329
1359
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
1330
1360
  catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
1331
1361
  releaseTaskForRetry(activeTaskRef, prompt)
@@ -1341,13 +1371,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1341
1371
  // Model prose never proves success or a blocker. Full cycles must produce
1342
1372
  // runtime-observed ticket reads, repository evidence, and ticket updates.
1343
1373
  const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
1344
- const missing = missingWorkEvidence(result, ticketCycle)
1345
- if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1374
+ const resultMessageRequired = kind === 'full' && !!delivery && !delivery.watcherOwned
1375
+ const missing = missingRuntimeWorkEvidence(result, { ticketCycle, resultMessageRequired })
1376
+ if (kind === 'full' && cycleSucceeded(result) && missing.length) {
1346
1377
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1347
- const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
1348
- const recoveredResult = combineWorkEvidence(result, recovery)
1349
- const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1350
- if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
1378
+ const recoveryPrompt = `CONTINUE THE SAME OPENVISIO REQUEST. Your earlier output did not complete it. Missing runtime evidence: ${missing.join('; ')}. An intent or progress message is not completion. Continue the actual work now, verify it, and then post one distinct final result or real blocker in the supplied source thread; a final result after an earlier progress message is explicitly allowed and required. Do not repeat the progress message. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''}\n\nORIGINAL REQUEST AND ROUTING CONTEXT:\n${prompt}`
1379
+ const recovery = await runners.work.runCycle(recoveryPrompt, codeModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
1380
+ const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
1381
+ const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
1382
+ if (!cycleSucceeded(recovery) || recoveryMissing.length) {
1351
1383
  if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
1352
1384
  try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
1353
1385
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
@@ -1446,8 +1478,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1446
1478
  if (selfAgentId == null) {
1447
1479
  const agentsData = toolData(await callMcpTool('list_agents'))
1448
1480
  const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1449
- selfAgentId = self?.id != null ? Number(self.id) : null
1450
- for (const alias of [self?.name, self?.identifier, self?.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
1481
+ selfAgentId = null
1482
+ rememberSelfAgent(self)
1451
1483
  }
1452
1484
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1453
1485
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -1582,7 +1614,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1582
1614
  const ctx = cid != null
1583
1615
  ? replyDelivery?.watcherOwned
1584
1616
  ? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
1585
- : `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'This is concrete repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates, and after your single reply, STOP.`
1617
+ : `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : 'Send EXACTLY ONE reply with post_message'}: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
1586
1618
  : undefined
1587
1619
  if (codingMention) {
1588
1620
  // Activity indicators make accepted work visible. Do not add a canned