openvisio-agent 0.17.2 → 0.17.4

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.2",
3
+ "version": "0.17.4",
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": {
@@ -28,6 +28,8 @@ const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
28
28
  const assertions = [
29
29
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
30
30
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
31
+ ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
32
+ ['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
31
33
  ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
32
34
  ['work and reply activity targets are isolated', watcher.includes("laneStatusTargets = { work: new Set(), reply: new Set() }") && watcher.includes("emitLaneStatus('work', 'typing')") && watcher.includes("emitLaneStatus('reply', 'typing')")],
33
35
  ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
@@ -45,6 +47,7 @@ const assertions = [
45
47
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes('Action required: Alex is blocked')],
46
48
  ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
47
49
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
50
+ ['ticket blocker cannot self-authorize', watcher.includes('ticketNotice = `Alex is paused') && watcher.includes('publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })')],
48
51
  ['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
49
52
  ]
50
53
  for (const [label, ok] of assertions) {
package/src/events.mjs CHANGED
@@ -34,6 +34,18 @@ export function taskIsCompleted(task, completedTypeIds = new Set()) {
34
34
  return /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(state)
35
35
  }
36
36
 
37
+ // A coding agent has handed work off once the board reaches review, testing, QA,
38
+ // or approval. These are not "completed" states: a reviewer may still send the
39
+ // ticket back to an actionable column. Treating them as settled for the watcher,
40
+ // however, prevents reconnect reconciliation from reopening the same PR every
41
+ // 30 minutes while it waits for a human.
42
+ export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
43
+ if (!task || typeof task !== 'object') return false
44
+ if (reviewTypeIds.has(Number(task.type_id ?? task.typeId ?? task.status_id ?? task.statusId))) return true
45
+ const state = [task.status, task.state, task.type?.name, task.task_type?.name].filter(Boolean).join(' ')
46
+ return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
47
+ }
48
+
37
49
  export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
38
50
  if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
39
51
  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, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsCompleted } from './events.mjs'
13
+ import { agentStateRequest, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
14
14
 
15
15
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
16
16
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
@@ -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.2' } } }, 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.4' } } }, 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,7 +701,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
701
701
  }
702
702
  }
703
703
 
704
- const publishBlocker = async ({ prompt, taskRef, notice, pause = false }) => {
704
+ const publishBlocker = async ({ prompt, taskRef, notice, ticketNotice = notice, pause = false }) => {
705
705
  const sentenceTask = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
706
706
  const jsonTask = /"id"\s*:\s*(\d+)[\s\S]{0,300}?"projectId"\s*:\s*(\d+)/i.exec(prompt)
707
707
  const ticketId = Number(taskRef?.ticketId ?? sentenceTask?.[1] ?? jsonTask?.[1])
@@ -731,7 +731,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
731
731
  const key = `${projectId}:${ticketId}`
732
732
  if (pause) { blockedTasks.add(key); persistReplay() }
733
733
  try {
734
- await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: notice })
734
+ await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
735
735
  delivered = true
736
736
  return
737
737
  } catch (e) {
@@ -740,11 +740,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
740
740
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
741
741
  const ticket = current.ticket ?? current.task ?? current
742
742
  const description = String(ticket.description || '')
743
- if (!description.includes(notice)) {
743
+ if (!description.includes(ticketNotice)) {
744
744
  await callMcpTool('update_ticket', {
745
745
  project_id: projectId,
746
746
  ticket_id: ticketId,
747
- description: `${description}${description ? '\n\n' : ''}[Agent blocker]\n${notice}`,
747
+ description: `${description}${description ? '\n\n' : ''}[Agent blocker]\n${ticketNotice}`,
748
748
  })
749
749
  delivered = true
750
750
  }
@@ -758,7 +758,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
758
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
759
  : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
760
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 })
761
+ const ticketNotice = `Alex is paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the project channel; no automatic retry will occur.`
762
+ return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
762
763
  }
763
764
 
764
765
  // Reconcile everything that may have arrived while disconnected. This spends no
@@ -782,21 +783,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
782
783
  callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
783
784
  callMcpTool('list_activity', { project_id: project.id }).then(toolData),
784
785
  ])
785
- const doneIds = new Set((Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []).filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
786
+ const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
787
+ const doneIds = new Set(taskTypes.filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
788
+ const reviewIds = new Set(taskTypes.filter((t) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
786
789
  for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
787
790
  const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
788
791
  const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
789
- if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
790
- const taskKey = `${project.id}:${task.id}`
791
- if (blockedTasks.has(taskKey)) {
792
- const approvalText = [task.title, task.description].filter(Boolean).join(' ')
793
- if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
794
- blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
795
- log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
796
- } else continue
797
- }
798
- assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
792
+ if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
793
+ const taskKey = `${project.id}:${task.id}`
794
+ if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
795
+ if (blockedTasks.delete(taskKey)) persistReplay()
796
+ // Release the in-flight de-dupe key at handoff. If a reviewer moves
797
+ // the ticket back to an actionable column, that update must start a
798
+ // fresh work cycle.
799
+ seenTasks.delete(taskKey)
800
+ continue
801
+ }
802
+ if (blockedTasks.has(taskKey)) {
803
+ const approvalText = [task.title, task.description].filter(Boolean).join(' ')
804
+ if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
805
+ blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
806
+ log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
807
+ } else continue
799
808
  }
809
+ assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
800
810
  }
801
811
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
802
812
  const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
@@ -986,7 +996,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
986
996
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
987
997
  const key = `${projectId}:${ticketId}`
988
998
  if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
989
- if (taskIsCompleted(ticket)) { blockedTasks.delete(key); persistReplay(); seenTasks.add(key); log(kind + ' ticket #' + ticketId + ' is already complete — ignored'); return }
999
+ if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1000
+ blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
1001
+ log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1002
+ return
1003
+ }
990
1004
  if (blockedTasks.has(key)) {
991
1005
  const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
992
1006
  if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {