openvisio-agent 0.17.1 → 0.17.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
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": {
@@ -42,7 +42,9 @@ const assertions = [
42
42
  ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('inspectDiagnostic(d)')],
43
43
  ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
44
44
  ['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
45
- ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, result.policyBlock)') && watcher.includes('[Agent action required]')],
45
+ ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes('Action required: Alex is blocked')],
46
+ ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
47
+ ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
46
48
  ['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
47
49
  ]
48
50
  for (const [label, ok] of assertions) {
package/src/events.mjs CHANGED
@@ -54,7 +54,9 @@ export function codexPolicyBlock(value) {
54
54
  const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
55
55
  const reasonTail = text.split(/Reason:\s*/i)[1] || ''
56
56
  const reason = reasonTail.split(/(?:\\n|\n)The agent\b/i)[0].replace(/[\\"') }]+$/, '').trim() || 'Codex requires explicit user approval for this external action.'
57
- return { kind: 'authorization-required', command, reason: reason.slice(0, 900) }
57
+ const commit = /\bcommit\s+([0-9a-f]{7,40})\b/i.exec(reason)?.[1] || ''
58
+ const push = /\bgit\s+push(?:\s+-\S+)*(?:\s+\S+)*\s+(\S+)\s+(\S+)\s*$/i.exec(command)
59
+ return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
58
60
  }
59
61
 
60
62
  // A mention event means this agent's name appeared somewhere, not necessarily
package/src/watch.mjs CHANGED
@@ -593,8 +593,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
593
593
  let liteModel = chatModel || model
594
594
 
595
595
  const lanes = {
596
- work: { busy: false, queued: null, pending: [], targets: new Set() },
597
- reply: { busy: false, queued: null, pending: [], targets: new Set() },
596
+ work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
597
+ reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
598
598
  }
599
599
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
600
600
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
@@ -650,7 +650,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
650
650
  }
651
651
  const ensureMcpSession = async () => {
652
652
  if (mcpSessionId) return
653
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.1' } } }, false)
653
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.2' } } }, false)
654
654
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
655
655
  await mcpPayload(res)
656
656
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -701,44 +701,64 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
701
701
  }
702
702
  }
703
703
 
704
- const reportPolicyBlock = async (prompt, block) => {
705
- const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
704
+ const publishBlocker = async ({ prompt, taskRef, notice, pause = false }) => {
705
+ const sentenceTask = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
706
+ const jsonTask = /"id"\s*:\s*(\d+)[\s\S]{0,300}?"projectId"\s*:\s*(\d+)/i.exec(prompt)
707
+ const ticketId = Number(taskRef?.ticketId ?? sentenceTask?.[1] ?? jsonTask?.[1])
708
+ const projectId = Number(taskRef?.projectId ?? sentenceTask?.[2] ?? jsonTask?.[2])
706
709
  const channelMatch = /channel\s+(\d+)/i.exec(prompt)
707
710
  const parentMatch = /(?:parent_id|thread)\s+(\d+)/i.exec(prompt)
708
- const command = block?.command || 'the requested external repository action'
709
- const notice = `Action required: Codex reached ${command.includes('git push') ? 'the branch push step' : 'an external action'}, but the safety layer blocked \`${command}\` because private repository code cannot be sent to a remote without explicit approval for the destination and payload. Please explicitly authorize this exact push (repository remote + branch). Alex has paused the ticket and will not retry it automatically.`
711
+ const channelId = Number(channelMatch?.[1] ?? taskRef?.channelId)
710
712
 
711
- if (channelMatch) {
712
- await callMcpTool('post_message', {
713
- channel_id: Number(channelMatch[1]),
714
- ...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
715
- content: notice,
716
- })
713
+ let delivered = false
714
+ if (Number.isFinite(channelId)) {
715
+ try {
716
+ await callMcpTool('post_message', {
717
+ channel_id: channelId,
718
+ ...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
719
+ content: notice,
720
+ })
721
+ delivered = true
722
+ } catch (e) {
723
+ log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
724
+ }
725
+ }
726
+ if (!Number.isFinite(ticketId) || !Number.isFinite(projectId)) {
727
+ if (!Number.isFinite(channelId)) log('blocker has no source channel or ticket to update')
717
728
  return
718
729
  }
719
- if (!taskMatch) { log('policy blocker has no source channel or ticket to update'); return }
720
730
 
721
- const ticketId = Number(taskMatch[1])
722
- const projectId = Number(taskMatch[2])
723
731
  const key = `${projectId}:${ticketId}`
724
- blockedTasks.add(key)
725
- persistReplay()
732
+ if (pause) { blockedTasks.add(key); persistReplay() }
726
733
  try {
727
734
  await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: notice })
735
+ delivered = true
728
736
  return
729
737
  } catch (e) {
730
- log('comment_ticket unavailable for policy blocker; recording it on ticket #' + ticketId)
738
+ log('comment_ticket unavailable for blocker; recording it on ticket #' + ticketId)
731
739
  }
732
740
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
733
741
  const ticket = current.ticket ?? current.task ?? current
734
742
  const description = String(ticket.description || '')
735
- if (!description.includes('[Agent action required]')) {
743
+ if (!description.includes(notice)) {
736
744
  await callMcpTool('update_ticket', {
737
745
  project_id: projectId,
738
746
  ticket_id: ticketId,
739
- description: `${description}${description ? '\n\n' : ''}[Agent action required]\n${notice}`,
747
+ description: `${description}${description ? '\n\n' : ''}[Agent blocker]\n${notice}`,
740
748
  })
749
+ delivered = true
741
750
  }
751
+ if (!delivered) throw new Error('no blocker delivery path succeeded')
752
+ }
753
+
754
+ const reportPolicyBlock = async (prompt, taskRef, block) => {
755
+ const command = block?.command || 'the requested external repository action'
756
+ const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
757
+ const approval = block?.commit && block?.branch
758
+ ? `Reply with: “I explicitly authorize Alex to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
759
+ : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
760
+ const notice = `Action required: Alex is blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} The ticket is paused until that approval is recorded.`
761
+ return publishBlocker({ prompt, taskRef, notice, pause: true })
742
762
  }
743
763
 
744
764
  // Reconcile everything that may have arrived while disconnected. This spends no
@@ -802,7 +822,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
802
822
  const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
803
823
  log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
804
824
  const activityChannel = await projectStatusChannel(next.projectId)
805
- void drain('full', `Backlog reconciliation verified this open ticket is assigned to YOU: ${JSON.stringify(next)}. Process this ONE ticket only. This is backlog-only work with NO source message or requester thread: do NOT post_message and do not announce progress or completion in any channel. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to done when appropriate.`, activityChannel == null ? [] : [activityChannel])
825
+ void drain('full', `Backlog reconciliation verified this open ticket is assigned to YOU: ${JSON.stringify(next)}. Process this ONE ticket only. This is backlog-only work with NO source message or requester thread: do NOT post_message and do not announce progress or completion in any channel. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to done when appropriate.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
806
826
  }
807
827
  }
808
828
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
@@ -821,14 +841,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
821
841
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
822
842
  const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
823
843
 
824
- async function drain(kind, context, targetChannels = []) {
844
+ async function drain(kind, context, targetChannels = [], taskRef = null) {
825
845
  const laneName = kind === 'full' ? 'work' : 'reply'
826
846
  const lane = lanes[laneName]
827
847
  if (context) lane.pending.push(context)
848
+ if (taskRef) lane.taskRefs.push(taskRef)
828
849
  for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
829
850
  if (lane.busy) { lane.queued = (RANK[kind] ?? 0) >= (RANK[lane.queued] ?? 0) ? kind : lane.queued; log(laneName + ' lane busy — queued a ' + kind + ' follow-up cycle'); return }
830
851
  lane.busy = true
831
852
  const ctx = lane.pending.splice(0)
853
+ const activeTaskRef = lane.taskRefs.splice(0)[0] || null
832
854
  const targets = [...lane.targets]
833
855
  lane.targets.clear()
834
856
  laneStatusTargets[laneName] = new Set(targets)
@@ -849,13 +871,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
849
871
  const result = await runners[laneName].runCycle(prompt, useModel)
850
872
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
851
873
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
852
- try { await reportPolicyBlock(prompt, result.policyBlock) }
874
+ try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
853
875
  catch (e) { log('failed to publish policy blocker: ' + (e?.message || e)) }
854
876
  return
855
877
  }
878
+ if (kind === 'full' && ['timeout', 'spawn-failed', 'error'].includes(result?.subtype)) {
879
+ const notice = `Alex is blocked: the coding cycle ended with ${result.subtype}. No completion is being claimed and the ticket remains open for retry.`
880
+ log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
881
+ try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
882
+ catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
883
+ return
884
+ }
885
+ if (kind !== 'full' && result?.mcpErrors?.length) {
886
+ const notice = `Alex is blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. No success is being claimed; the item needs retry or intervention.`
887
+ log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
888
+ try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
889
+ catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
890
+ return
891
+ }
856
892
  // Model prose never proves success or a blocker. Full cycles must produce
857
893
  // runtime-observed ticket reads, repository evidence, and ticket updates.
858
- const ticketCycle = /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
894
+ const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
859
895
  const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
860
896
  if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
861
897
  const missing = [ticketCycle && !result.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks', !result.didRepoMutation && 'perform and verify the repository change', ticketCycle && !result.didMcpTaskUpdate && 'update the ticket through update_ticket'].filter(Boolean)
@@ -864,8 +900,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
864
900
  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, commit and push an agent/* branch, open the PR, and call update_ticket with the correct board column. Post only when the original context supplies a source thread.`, codeModel)
865
901
  const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
866
902
  if (recoveryIncomplete) {
867
- const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
868
- if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
903
+ if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
904
+ try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
905
+ catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
906
+ } else {
907
+ const notice = `Alex is blocked after one recovery attempt. Missing required evidence: ${missing.join('; ')}. The ticket remains open and no completion is being claimed.`
908
+ try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
909
+ catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
910
+ }
911
+ if (activeTaskRef) seenTasks.delete(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`)
912
+ else {
913
+ const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
914
+ if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
915
+ }
869
916
  lastTaskSignature = ''
870
917
  log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
871
918
  }
@@ -958,7 +1005,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
958
1005
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
959
1006
  const activityChannel = await projectStatusChannel(projectId)
960
1007
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
961
- void drain(cycleKind, `Authoritative get_ticket verification confirms ticket #${ticketId} in project ${projectId} is open and assigned to YOU: ${JSON.stringify({ id: ticketId, projectId, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source channel: do not post_message. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. Never announce it in a channel.`, activityChannel == null ? [] : [activityChannel])
1008
+ void drain(cycleKind, `Authoritative get_ticket verification confirms ticket #${ticketId} in project ${projectId} is open and assigned to YOU: ${JSON.stringify({ id: ticketId, projectId, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source channel: do not post_message. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. Never announce it in a channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
962
1009
  } catch (e) {
963
1010
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
964
1011
  }