openvisio-agent 0.17.3 → 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.3",
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)')],
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.3' } } }, 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') || ''
@@ -783,21 +783,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
783
783
  callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
784
784
  callMcpTool('list_activity', { project_id: project.id }).then(toolData),
785
785
  ])
786
- 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)))
787
789
  for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
788
790
  const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
789
791
  const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
790
- if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
791
- const taskKey = `${project.id}:${task.id}`
792
- if (blockedTasks.has(taskKey)) {
793
- const approvalText = [task.title, task.description].filter(Boolean).join(' ')
794
- 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)) {
795
- blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
796
- log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
797
- } else continue
798
- }
799
- 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
800
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 })
801
810
  }
802
811
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
803
812
  const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
@@ -987,7 +996,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
987
996
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
988
997
  const key = `${projectId}:${ticketId}`
989
998
  if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
990
- 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
+ }
991
1004
  if (blockedTasks.has(key)) {
992
1005
  const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
993
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)) {