openvisio-agent 0.19.9 → 0.19.10

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.19.9",
3
+ "version": "0.19.10",
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": {
@@ -128,7 +128,7 @@ const assertions = [
128
128
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("Action required: I'm blocked")],
129
129
  ['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
130
130
  ['reply discovery failures stay scoped while failed mutations fail closed', watcher.includes('blockingReplyMcpErrors(result?.mcpErrors)') && watcher.includes('preserving the scoped model reply') && events.includes('export function blockingReplyMcpErrors')],
131
- ['pending-ticket questions use watcher-owned MCP reads without a model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && !watcher.includes("callMcpReadWithRetry('list_agents'")],
131
+ ['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && watcher.includes("if (selfAgentId == null) {\n const agentsData = toolData(await callMcpReadWithRetry('list_agents'))")],
132
132
  ['chat ACP allows opaque MCP approvals behind a strict read-only proxy', mastraHarness.includes('export function acpPermissionResponse') && mastraHarness.includes('opaqueMcpApproval') && mastraHarness.includes('OPENVISIO_CODEX_ALLOWED_TOOLS') && codexProxy.includes('export function toolAllowed') && codexProxy.includes('allowedTools.has(name)')],
133
133
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
134
134
  ['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })') && !watcher.includes('test(approvalText)')],
@@ -0,0 +1,32 @@
1
+ // Assignment requests are routed by the watcher, before the restricted chat
2
+ // runtime can mistake its own tool limits for the coding worker's capabilities.
3
+ export function assignmentStatusOnly(value) {
4
+ return /\b(?:do not|don't|dont|stop|cancel|stand down|status only|just (?:list|check|tell|show)|only (?:list|check|tell|show))\b/i.test(String(value || ''))
5
+ }
6
+
7
+ export function assignmentRequest(value) {
8
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
9
+ if (assignmentStatusOnly(text)) return null
10
+ const slugs = [...new Set((text.match(/\b[a-z][a-z0-9]*-\d+\b/gi) || []).map((slug) => slug.toUpperCase()))]
11
+ const tickets = /\b(?:tickets?|tasks?|assignments?|backlog)\b/i.test(text) || slugs.length > 0
12
+ const action = /\b(?:start|begin|resume|continue|implement|fix|finish|complete|handle|execute|dispatch|pick\s+up|work\s+(?:on|through))\b/i.test(text)
13
+ const assigned = /\b(?:assigned|gave)\s+(?:\S+\s+){0,3}you\b|\byou\s+have\b.*\b(?:pending|assigned)\b/i.test(text)
14
+ return tickets && (action || assigned) ? { slugs } : null
15
+ }
16
+
17
+ export async function routeAssignments({ request, loadTickets, handleTask, isCancelled = () => false }) {
18
+ const tickets = await loadTickets()
19
+ const selected = tickets.filter((ticket) => !request.slugs.length || request.slugs.includes(ticket.slug))
20
+ // A project-scoped slug must resolve uniquely before any work is started.
21
+ for (const slug of request.slugs) {
22
+ if (selected.filter((ticket) => ticket.slug === slug).length !== 1) {
23
+ throw new Error(`I couldn't uniquely resolve ${slug} among my open assignments.`)
24
+ }
25
+ }
26
+ const actionable = selected.filter((ticket) => !ticket.awaitingReview)
27
+ for (const ticket of actionable) {
28
+ if (isCancelled()) return
29
+ await handleTask('task:assigned', { task: { id: ticket.id, project_id: ticket.projectId } })
30
+ }
31
+ return actionable.length
32
+ }
package/src/watch.mjs CHANGED
@@ -18,6 +18,7 @@ import { createMcpHttpClient } from './mcp-http.mjs'
18
18
  import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
19
19
  import { buildCodexMcpOverride } from './codex-config.mjs'
20
20
  import { createCycleQueue } from './cycle-queue.mjs'
21
+ import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
21
22
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
22
23
  import { createMastraAcpRunner } from './mastra-harness.mjs'
23
24
  import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
@@ -1075,6 +1076,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1075
1076
  }
1076
1077
 
1077
1078
  const loadPendingTickets = async () => {
1079
+ if (selfAgentId == null) {
1080
+ const agentsData = toolData(await callMcpReadWithRetry('list_agents'))
1081
+ const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1082
+ if (!self?.id) throw new Error('list_agents did not return this BYO agent')
1083
+ rememberSelfAgent(self)
1084
+ }
1078
1085
  const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
1079
1086
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1080
1087
  const groups = await Promise.all(projects.filter((project) => project?.id != null).map(async (project) => {
@@ -1089,12 +1096,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1089
1096
  const typesData = typesResult ? toolData(typesResult) : {}
1090
1097
  const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1091
1098
  const doneIds = new Set(types.filter((type) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
1099
+ const reviewIds = new Set(types.filter((type) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
1092
1100
  return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
1093
1101
  const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
1094
1102
  const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
1095
1103
  const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1096
1104
  return assignedHere && !taskIsCompleted(task, doneIds)
1097
1105
  }).map((task) => ({
1106
+ id: task.id,
1107
+ projectId: project.id,
1108
+ awaitingReview: taskIsAwaitingReview(task, reviewIds),
1098
1109
  slug: ticketDisplaySlug(task),
1099
1110
  title: String(task.title || 'Untitled ticket'),
1100
1111
  state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
@@ -1104,9 +1115,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1104
1115
  return groups.flat()
1105
1116
  }
1106
1117
 
1107
- const answerPendingTickets = async ({ delivery, who }) => {
1118
+ const answerPendingTickets = async ({ delivery, who, text }) => {
1108
1119
  try {
1109
1120
  const tickets = await loadPendingTickets()
1121
+ // Assignments already authorize pickup. A board lookup should not leave
1122
+ // discovered work idle until a second human nudge arrives.
1123
+ if (canCode && !assignmentStatusOnly(text)) {
1124
+ await routeAssignments({
1125
+ request: { slugs: [] }, loadTickets: async () => tickets, handleTask: handleTaskSignal,
1126
+ isCancelled: () => memory.has(threadControlKey(delivery?.channelId, delivery?.parentId), 'cancelled'),
1127
+ })
1128
+ }
1110
1129
  const prefix = who ? `@${who} ` : ''
1111
1130
  const details = tickets.slice(0, 8).map((ticket) => `${ticket.slug || ticket.title}${ticket.slug ? ` “${ticket.title}”` : ''}${ticket.state ? ` (${ticket.state})` : ''}${ticket.project ? ` in ${ticket.project}` : ''}`)
1112
1131
  const extra = tickets.length > details.length ? `, plus ${tickets.length - details.length} more` : ''
@@ -1384,7 +1403,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1384
1403
  const [tasksData, typesData, activityData] = await Promise.all([
1385
1404
  callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
1386
1405
  callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
1387
- callMcpTool('list_activity', { project_id: project.id }).then(toolData),
1406
+ // Chat history is optional for assignment pickup. An unavailable
1407
+ // activity feed must not strand otherwise verified pending tickets.
1408
+ callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
1409
+ log('activity reconciliation unavailable: ' + (error?.message || error))
1410
+ return {}
1411
+ }),
1388
1412
  ])
1389
1413
  const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1390
1414
  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)))
@@ -1875,9 +1899,29 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1875
1899
  }
1876
1900
  return
1877
1901
  }
1902
+ const assignment = assignmentRequest(text)
1903
+ if (assignment) {
1904
+ const delivery = conversationDelivery('assignment-routing')
1905
+ const isCancelled = () => !!controlKey && memory.has(controlKey, 'cancelled')
1906
+ // Enqueue each verified ticket separately, using the same ownership,
1907
+ // revision and queue dedupe gates as live assignment events.
1908
+ void (async () => {
1909
+ try {
1910
+ if (!canCode) throw new Error('This watcher is configured with --chat-only and has no coding worker. Enable its coding workspace to execute assigned tickets.')
1911
+ const count = await routeAssignments({ request: assignment, loadTickets: loadPendingTickets, handleTask: handleTaskSignal, isCancelled })
1912
+ if (count === 0 && delivery && !isCancelled()) {
1913
+ await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I have no open assignments ready for implementation; tickets awaiting review or testing stay in their current stage.` })
1914
+ }
1915
+ } catch (error) {
1916
+ log('assignment routing failed: ' + (error?.message || error))
1917
+ if (delivery && !isCancelled()) await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I couldn't route the assigned work: ${error?.message || error}` })
1918
+ }
1919
+ })().catch((error) => log('assignment routing delivery failed: ' + (error?.message || error)))
1920
+ return
1921
+ }
1878
1922
  if (cid != null && conversationAsksPendingTickets(text)) {
1879
1923
  log('pending-ticket question -> watcher-owned MCP lookup')
1880
- void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who })
1924
+ void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who, text })
1881
1925
  return
1882
1926
  }
1883
1927
  log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))