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 +1 -1
- package/scripts/certify.mjs +1 -1
- package/src/assignment-routing.mjs +32 -0
- package/src/watch.mjs +47 -3
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -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'") &&
|
|
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
|
-
|
|
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 + ')' : ''))
|