openvisio-agent 0.17.4 → 0.17.5

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.4",
3
+ "version": "0.17.5",
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": {
@@ -30,6 +30,10 @@ const assertions = [
30
30
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
31
31
  ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
32
32
  ['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
33
+ ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes("callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })")],
34
+ ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
35
+ ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
36
+ ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
33
37
  ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
34
38
  ['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')")],
35
39
  ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
package/src/events.mjs CHANGED
@@ -46,6 +46,26 @@ export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
46
46
  return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
47
47
  }
48
48
 
49
+ export function buildTaskCompletionReport(task, { projectId, agentName, fallbackText = '' } = {}) {
50
+ if (!task || typeof task !== 'object' || task.id == null) return null
51
+ if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
52
+
53
+ const evidence = [task.description, fallbackText].filter(Boolean).join('\n')
54
+ const prUrl = /https:\/\/github\.com\/[^\s)\]}>]+\/pull\/\d+/i.exec(evidence)?.[0]?.replace(/[.,;:]+$/, '') || ''
55
+ if (!prUrl) return null
56
+
57
+ const creator = task.creator || task.created_by_user || task.createdByUser || {}
58
+ const requester = (`${creator.first_name || creator.firstName || ''} ${creator.last_name || creator.lastName || ''}`.trim() || creator.name || '').trim()
59
+ const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
60
+ const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
61
+ const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
62
+ const who = String(agentName || 'The agent').trim()
63
+ const mention = requester ? `@${requester} ` : ''
64
+ const content = `${mention}${who} finished ticket #${task.id} “${title}” and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
65
+ const revision = task.updated_at ?? task.updatedAt ?? prUrl
66
+ return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
67
+ }
68
+
49
69
  export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
50
70
  if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
51
71
  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, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
13
+ import { agentStateRequest, buildTaskCompletionReport, 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
@@ -73,7 +73,7 @@ const CODE_CHARTER = [
73
73
  '',
74
74
  'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
75
75
  ' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back.',
76
- ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Post a channel result ONLY when this cycle includes a specific source channel/thread from a human request, and reply in that thread. Backlog-only work has no implied audience: update the ticket with evidence and do not announce it in an unrelated channel.',
76
+ ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the project channel after the PR and ticket handoff are confirmed.',
77
77
  ' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
78
78
  ' 4. One reply per channel per cycle; answer several nudges together.',
79
79
  '',
@@ -89,7 +89,7 @@ const CODE_FULL = [
89
89
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
90
90
  ' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
91
91
  ' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
92
- ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link only in a source thread explicitly supplied by the event. For backlog-only tickets, do not post_message or seek a channel to announce completion.',
92
+ ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
93
93
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
94
94
  ].join('\n')
95
95
 
@@ -345,7 +345,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
345
345
  ...(m ? ['--model', m] : []),
346
346
  ...(mcpOverride ? ['-c', mcpOverride] : []),
347
347
  full]
348
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
348
+ let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
349
349
  let policyBlock = null
350
350
  const mcpCalls = new Set(), mcpErrors = new Set()
351
351
  let didMcpTaskRead = false, didMcpTaskUpdate = false
@@ -355,7 +355,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
355
355
  clearTimeout(timer)
356
356
  const calls = [...mcpCalls]
357
357
  log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
358
- resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
358
+ resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
359
359
  }
360
360
  const inspectDiagnostic = (value) => {
361
361
  const s = String(value || '')
@@ -379,6 +379,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
379
379
  if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
380
380
  if (tool === 'update_ticket') didMcpTaskUpdate = true
381
381
  if (/post_message|comment_ticket/.test(tool)) didMessage = true
382
+ if (/post_message/.test(tool)) didChannelMessage = true
382
383
  if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
383
384
  }
384
385
  } catch { /* non-JSON diagnostic */ }
@@ -442,8 +443,15 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
442
443
  let sessionStartedAt = 0
443
444
  let resolveTurn = null
444
445
  let cycleTimer = null
446
+ let turnToolCalls = new Set()
445
447
  const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
446
- const settleTurn = (o) => { clearCycleTimer(); const r = resolveTurn; resolveTurn = null; if (r) r(o) }
448
+ const settleTurn = (o) => {
449
+ clearCycleTimer()
450
+ const r = resolveTurn
451
+ resolveTurn = null
452
+ const mcpCalls = [...turnToolCalls]
453
+ if (r) r({ ...o, mcpCalls, didChannelMessage: mcpCalls.includes('post_message') })
454
+ }
447
455
 
448
456
  // --debug: surface what the cycle actually does (tool calls, tool errors, text)
449
457
  // so a silent/hung cycle is diagnosable.
@@ -483,7 +491,10 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
483
491
  if (debug) logEvent(o)
484
492
  // Surface tool calls (e.g. post_message) so the loop can emit a live status.
485
493
  if (onTool && o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
486
- for (const b of o.message.content) if (b.type === 'tool_use' && b.name) { try { onTool(b.name) } catch { /* status is best-effort */ } }
494
+ for (const b of o.message.content) if (b.type === 'tool_use' && b.name) {
495
+ turnToolCalls.add(String(b.name).replace(/^mcp__openvisio-team__/, ''))
496
+ try { onTool(b.name) } catch { /* status is best-effort */ }
497
+ }
487
498
  }
488
499
  if (o.type === 'result') {
489
500
  log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
@@ -520,6 +531,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
520
531
  }
521
532
  ensureSession()
522
533
  turnsThisSession++
534
+ turnToolCalls = new Set()
523
535
  resolveTurn = resolve
524
536
  // Backstop: abandon a cycle that never returns a result so `busy` is released
525
537
  // and queued mentions can proceed.
@@ -608,6 +620,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
608
620
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
609
621
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
610
622
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
623
+ // Completion delivery is runtime-owned for assigned coding work. Persist both
624
+ // pending and delivered keys so a reconnect can finish a missed notification
625
+ // without re-running the model or posting the same result twice.
626
+ const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
627
+ const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
611
628
  // A policy-blocked task stays paused across reconnects. It is released only
612
629
  // after the ticket itself carries explicit authorization or is completed/
613
630
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
@@ -615,7 +632,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
615
632
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
616
633
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
617
634
  const persistReplay = () => {
618
- try { writeJson(replayPath, { seenMentions: [...seenMentions], seenActivities: [...seenActivities], blockedTasks: [...blockedTasks] }, true) } catch { /* best-effort */ }
635
+ try {
636
+ writeJson(replayPath, {
637
+ seenMentions: [...seenMentions],
638
+ seenActivities: [...seenActivities],
639
+ blockedTasks: [...blockedTasks],
640
+ pendingCompletionReports: [...pendingCompletionReports],
641
+ reportedCompletions: [...reportedCompletions],
642
+ }, true)
643
+ } catch { /* best-effort */ }
619
644
  }
620
645
  // Context lines from the events themselves (the WS payload already carries the
621
646
  // channel + message / task), so the agent acts on THEM directly instead of
@@ -626,6 +651,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
626
651
  let lastTaskTriggeredAt = 0
627
652
  let lastInboxSignature = ''
628
653
  let selfAgentId = null
654
+ let selfAgentName = slug
629
655
  let mcpSessionId = ''
630
656
  let mcpRpcId = 0
631
657
 
@@ -650,7 +676,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
650
676
  }
651
677
  const ensureMcpSession = async () => {
652
678
  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.4' } } }, false)
679
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.5' } } }, false)
654
680
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
655
681
  await mcpPayload(res)
656
682
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -701,6 +727,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
701
727
  }
702
728
  }
703
729
 
730
+ const announceTaskCompletion = async (taskRef, result = {}) => {
731
+ const projectId = Number(taskRef?.projectId)
732
+ const ticketId = Number(taskRef?.ticketId)
733
+ if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return false
734
+ const taskKey = `${projectId}:${ticketId}`
735
+ const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
736
+ const ticket = current.ticket ?? current.task ?? current
737
+ const report = buildTaskCompletionReport(ticket, { projectId, agentName: selfAgentName, fallbackText: result.outputText })
738
+ if (!report) return false
739
+ if (reportedCompletions.has(report.key)) {
740
+ pendingCompletionReports.delete(taskKey)
741
+ persistReplay()
742
+ return true
743
+ }
744
+ // A model may already have replied when the task originated in a real source
745
+ // thread. Mark that verified completion as delivered instead of posting a
746
+ // second top-level copy.
747
+ if (result.didChannelMessage) {
748
+ reportedCompletions.add(report.key); trimSeen(reportedCompletions)
749
+ pendingCompletionReports.delete(taskKey); persistReplay()
750
+ log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
751
+ return true
752
+ }
753
+ const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
754
+ if (!Number.isFinite(channelId)) return false
755
+ sendStatus(channelId, 'typing')
756
+ await callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })
757
+ reportedCompletions.add(report.key); trimSeen(reportedCompletions)
758
+ pendingCompletionReports.delete(taskKey); persistReplay()
759
+ log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
760
+ return true
761
+ }
762
+
704
763
  const publishBlocker = async ({ prompt, taskRef, notice, ticketNotice = notice, pause = false }) => {
705
764
  const sentenceTask = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
706
765
  const jsonTask = /"id"\s*:\s*(\d+)[\s\S]{0,300}?"projectId"\s*:\s*(\d+)/i.exec(prompt)
@@ -773,6 +832,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
773
832
  const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
774
833
  if (!self?.id) throw new Error('list_agents did not return this BYO agent')
775
834
  selfAgentId = Number(self.id)
835
+ selfAgentName = String(self.name || selfAgentName)
776
836
  const projectsData = toolData(await callMcpTool('list_projects'))
777
837
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
778
838
  const assigned = []
@@ -792,6 +852,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
792
852
  if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
793
853
  const taskKey = `${project.id}:${task.id}`
794
854
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
855
+ if (pendingCompletionReports.has(taskKey)) {
856
+ const activityChannel = await projectStatusChannel(project.id)
857
+ try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
858
+ catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
859
+ }
795
860
  if (blockedTasks.delete(taskKey)) persistReplay()
796
861
  // Release the in-flight de-dupe key at handoff. If a reviewer moves
797
862
  // the ticket back to an actionable column, that update must start a
@@ -832,7 +897,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
832
897
  const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
833
898
  log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
834
899
  const activityChannel = await projectStatusChannel(next.projectId)
835
- void drain('full', `Backlog reconciliation verified this open ticket is assigned to YOU: ${JSON.stringify(next)}. Process this ONE ticket only. This is backlog-only work with NO source message or requester thread: do NOT post_message and do not announce progress or completion in any channel. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to done when appropriate.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
900
+ pendingCompletionReports.add(`${next.projectId}:${next.id}`); trimSeen(pendingCompletionReports); persistReplay()
901
+ void drain('full', `Backlog reconciliation verified this open ticket is assigned to YOU: ${JSON.stringify(next)}. Process this ONE ticket only. This is backlog-only work with NO source message or requester thread: do NOT call post_message yourself. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to review or done when appropriate. The watcher will publish exactly one verified completion result to the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
836
902
  }
837
903
  }
838
904
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
@@ -879,6 +945,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
879
945
  const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
880
946
  try {
881
947
  const result = await runners[laneName].runCycle(prompt, useModel)
948
+ let completionResult = result
882
949
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
883
950
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
884
951
  try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
@@ -925,6 +992,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
925
992
  }
926
993
  lastTaskSignature = ''
927
994
  log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
995
+ return
996
+ }
997
+ completionResult = recovery
998
+ }
999
+ if (kind === 'full' && activeTaskRef) {
1000
+ try {
1001
+ const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
1002
+ if (!delivered) log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence')
1003
+ } catch (e) {
1004
+ log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
928
1005
  }
929
1006
  }
930
1007
  } finally {
@@ -988,6 +1065,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
988
1065
  const agentsData = toolData(await callMcpTool('list_agents'))
989
1066
  const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
990
1067
  selfAgentId = self?.id != null ? Number(self.id) : null
1068
+ selfAgentName = String(self?.name || selfAgentName)
991
1069
  }
992
1070
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
993
1071
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -995,8 +1073,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
995
1073
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
996
1074
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
997
1075
  const key = `${projectId}:${ticketId}`
998
- if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
1076
+ if (!belongsToSelf) { blockedTasks.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
999
1077
  if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1078
+ if (pendingCompletionReports.has(key)) {
1079
+ const activityChannel = await projectStatusChannel(projectId)
1080
+ try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1081
+ catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1082
+ }
1000
1083
  blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
1001
1084
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1002
1085
  return
@@ -1019,7 +1102,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1019
1102
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
1020
1103
  const activityChannel = await projectStatusChannel(projectId)
1021
1104
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1022
- void drain(cycleKind, `Authoritative get_ticket verification confirms ticket #${ticketId} in project ${projectId} is open and assigned to YOU: ${JSON.stringify({ id: ticketId, projectId, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source channel: do not post_message. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. Never announce it in a channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1105
+ if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1106
+ void drain(cycleKind, `Authoritative get_ticket verification confirms ticket #${ticketId} in project ${projectId} is open and assigned to YOU: ${JSON.stringify({ id: ticketId, projectId, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1023
1107
  } catch (e) {
1024
1108
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1025
1109
  }