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 +1 -1
- package/scripts/certify.mjs +3 -0
- package/src/events.mjs +12 -0
- package/src/watch.mjs +33 -19
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -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.
|
|
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:
|
|
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(
|
|
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${
|
|
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
|
-
|
|
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
|
|
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 (
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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)
|
|
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)) {
|