@virtengine/openfleet 0.25.0

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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
package/ve-kanban.ps1 ADDED
@@ -0,0 +1,1365 @@
1
+ #!/usr/bin/env pwsh
2
+ <#
3
+ .SYNOPSIS
4
+ VirtEngine Kanban CLI — wraps the vibe-kanban HTTP API for task + attempt management.
5
+
6
+ .DESCRIPTION
7
+ Provides commands to list tasks, submit task attempts, check attempt status,
8
+ rebase attempts, merge PRs, and mark tasks as complete. Designed to be both
9
+ a standalone CLI and a dot-sourceable library for ve-orchestrator.ps1.
10
+
11
+ .EXAMPLE
12
+ # List todo tasks
13
+ ./ve-kanban.ps1 list --status todo
14
+
15
+ # Submit the next N tasks as attempts
16
+ ./ve-kanban.ps1 submit-next --count 2
17
+
18
+ # Show active attempts
19
+ ./ve-kanban.ps1 status
20
+
21
+ # Merge a completed PR
22
+ ./ve-kanban.ps1 merge --branch ve/abc1-feat-portal
23
+
24
+ # Run orchestration loop
25
+ ./ve-kanban.ps1 orchestrate --parallel 2
26
+ #>
27
+
28
+ # ─── Configuration ────────────────────────────────────────────────────────────
29
+ $script:VK_BASE_URL = $env:VK_ENDPOINT_URL ?? $env:VK_BASE_URL ?? "http://127.0.0.1:54089"
30
+ $script:VK_PROJECT_NAME = $env:VK_PROJECT_NAME ?? "virtengine"
31
+ $script:VK_PROJECT_ID = $env:VK_PROJECT_ID ?? "" # Auto-detected if empty
32
+ $script:VK_REPO_ID = $env:VK_REPO_ID ?? "" # Auto-detected if empty
33
+ $script:GH_OWNER = $env:GH_OWNER ?? "virtengine"
34
+ $script:GH_REPO = $env:GH_REPO ?? "virtengine"
35
+ $script:VK_TARGET_BRANCH = $env:VK_TARGET_BRANCH ?? "origin/main"
36
+ $script:VK_INITIALIZED = $false
37
+ $script:VK_LAST_GH_ERROR = $null
38
+ $script:VK_LAST_GH_ERROR_AT = $null
39
+ $script:VK_CLI_RAW_LINE = $null
40
+
41
+ # Executor profiles (used for 50/50 cycling between Codex and Copilot)
42
+ $script:VK_EXECUTORS = @(
43
+ @{ executor = "CODEX"; variant = "DEFAULT" }
44
+ @{ executor = "COPILOT"; variant = "CLAUDE_OPUS_4_6" }
45
+ )
46
+ $script:VK_EXECUTOR_INDEX = (Get-Random -Minimum 0 -Maximum $script:VK_EXECUTORS.Count) # Random start for session diversity
47
+
48
+ # ─── HTTP Helpers ─────────────────────────────────────────────────────────────
49
+
50
+ function Invoke-VKApi {
51
+ <#
52
+ .SYNOPSIS Invoke the vibe-kanban REST API.
53
+ #>
54
+ [CmdletBinding()]
55
+ param(
56
+ [Parameter(Mandatory)][string]$Path,
57
+ [string]$Method = "GET",
58
+ [object]$Body
59
+ )
60
+ $uri = "$script:VK_BASE_URL$Path"
61
+ $params = @{ Uri = $uri; Method = $Method; ContentType = "application/json"; UseBasicParsing = $true }
62
+ if ($Body) { $params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress) }
63
+ try {
64
+ $raw = Invoke-WebRequest @params -ErrorAction Stop
65
+ $resp = $raw.Content | ConvertFrom-Json -Depth 20
66
+ if ($resp.success -eq $false) {
67
+ Write-Warning "API error on $Path : $($resp.message)"
68
+ return $null
69
+ }
70
+ return $resp.data ?? $resp
71
+ }
72
+ catch {
73
+ Write-Error "HTTP $Method $uri failed: $_"
74
+ return $null
75
+ }
76
+ }
77
+
78
+ # ─── Copilot Cloud Guard ─────────────────────────────────────────────────────
79
+
80
+ function Test-CopilotCloudDisabled {
81
+ $flag = $env:COPILOT_CLOUD_DISABLED
82
+ if ($flag -and $flag.ToString().ToLower() -in @("1", "true", "yes")) {
83
+ return $true
84
+ }
85
+ $untilRaw = $env:COPILOT_CLOUD_DISABLED_UNTIL
86
+ if (-not $untilRaw) { return $false }
87
+ try {
88
+ $until = [datetimeoffset]::Parse($untilRaw).ToLocalTime().DateTime
89
+ if ((Get-Date) -lt $until) { return $true }
90
+ }
91
+ catch {
92
+ return $false
93
+ }
94
+ return $false
95
+ }
96
+
97
+ # ─── Auto-Detection & Initialization ─────────────────────────────────────────
98
+
99
+ function Initialize-VKConfig {
100
+ <#
101
+ .SYNOPSIS Auto-detect project ID and repo ID from vibe-kanban by project name.
102
+ Works for any user with their own vibe-kanban setup.
103
+ #>
104
+ if ($script:VK_INITIALIZED) { return $true }
105
+
106
+ # If project ID already set (via env), skip auto-detection for project
107
+ if ($script:VK_PROJECT_ID) {
108
+ Write-Verbose "Using configured project ID: $script:VK_PROJECT_ID"
109
+ }
110
+ else {
111
+ Write-Host " Auto-detecting project '$script:VK_PROJECT_NAME'..." -ForegroundColor DarkGray
112
+ $projects = Invoke-VKApi -Path "/api/projects"
113
+ if (-not $projects) {
114
+ Write-Error "Cannot connect to vibe-kanban at $script:VK_BASE_URL"
115
+ return $false
116
+ }
117
+ $projectList = if ($projects -is [System.Array]) { $projects } elseif ($projects.projects) { $projects.projects } else { @($projects) }
118
+
119
+ # Find project by name (case-insensitive)
120
+ $match = $projectList | Where-Object {
121
+ $_.name -ieq $script:VK_PROJECT_NAME -or
122
+ $_.display_name -ieq $script:VK_PROJECT_NAME -or
123
+ $_.title -ieq $script:VK_PROJECT_NAME
124
+ } | Select-Object -First 1
125
+
126
+ if (-not $match) {
127
+ Write-Error "No project named '$script:VK_PROJECT_NAME' found. Available: $($projectList | ForEach-Object { $_.name ?? $_.display_name ?? $_.title } | Join-String -Separator ', ')"
128
+ return $false
129
+ }
130
+ $script:VK_PROJECT_ID = $match.id
131
+ Write-Host " ✓ Project: $($match.name ?? $match.display_name ?? $match.title) ($($script:VK_PROJECT_ID.Substring(0,8))...)" -ForegroundColor Green
132
+ }
133
+
134
+ # Auto-detect repo ID if not set
135
+ if (-not $script:VK_REPO_ID) {
136
+ Write-Host " Auto-detecting repository..." -ForegroundColor DarkGray
137
+ $repos = Invoke-VKApi -Path "/api/repos?project_id=$script:VK_PROJECT_ID"
138
+ if (-not $repos) {
139
+ # Try alternate endpoint
140
+ $repos = Invoke-VKApi -Path "/api/projects/$script:VK_PROJECT_ID/repos"
141
+ }
142
+ $repoList = if ($repos -is [System.Array]) { $repos } elseif ($repos.repos) { $repos.repos } else { @($repos) }
143
+
144
+ if ($repoList -and @($repoList).Count -gt 0) {
145
+ # Prefer repo matching GH_REPO name, otherwise take first
146
+ $repoMatch = $repoList | Where-Object { $_.name -ieq $script:GH_REPO } | Select-Object -First 1
147
+ if (-not $repoMatch) { $repoMatch = $repoList | Select-Object -First 1 }
148
+ $script:VK_REPO_ID = $repoMatch.id
149
+ Write-Host " ✓ Repo: $($repoMatch.name ?? 'default') ($($script:VK_REPO_ID.Substring(0,8))...)" -ForegroundColor Green
150
+ }
151
+ else {
152
+ Write-Warning "No repos found for project. Repo ID must be set via VK_REPO_ID env var."
153
+ return $false
154
+ }
155
+ }
156
+
157
+ $script:VK_INITIALIZED = $true
158
+ return $true
159
+ }
160
+
161
+ # ─── Executor Cycling ─────────────────────────────────────────────────────────
162
+
163
+ function Get-NextExecutorProfile {
164
+ <#
165
+ .SYNOPSIS Get the next executor profile in the 50/50 Codex/Copilot rotation.
166
+ #>
167
+ $max = $script:VK_EXECUTORS.Count
168
+ if ($max -le 1) {
169
+ return $script:VK_EXECUTORS[0]
170
+ }
171
+ for ($i = 0; $i -lt $max; $i++) {
172
+ $profile = $script:VK_EXECUTORS[$script:VK_EXECUTOR_INDEX]
173
+ $script:VK_EXECUTOR_INDEX = ($script:VK_EXECUTOR_INDEX + 1) % $script:VK_EXECUTORS.Count
174
+ if ((Test-CopilotCloudDisabled) -and $profile.executor -eq "COPILOT") {
175
+ continue
176
+ }
177
+ return $profile
178
+ }
179
+ return $script:VK_EXECUTORS[0]
180
+ }
181
+
182
+ function Get-CurrentExecutorProfile {
183
+ <#
184
+ .SYNOPSIS Peek at the next executor profile without advancing the cycle.
185
+ #>
186
+ return $script:VK_EXECUTORS[$script:VK_EXECUTOR_INDEX]
187
+ }
188
+
189
+ # ─── Task Functions ───────────────────────────────────────────────────────────
190
+
191
+ function Get-VKTasks {
192
+ <#
193
+ .SYNOPSIS List tasks with optional status filter (client-side filtering).
194
+ #>
195
+ [CmdletBinding()]
196
+ param(
197
+ [ValidateSet("todo", "inprogress", "inreview", "done", "cancelled")]
198
+ [string]$Status,
199
+ [int]$Limit = 500
200
+ )
201
+ if (-not (Initialize-VKConfig)) { return @() }
202
+ $result = Invoke-VKApi -Path "/api/tasks?project_id=$script:VK_PROJECT_ID"
203
+ if (-not $result) { return @() }
204
+ # Result is either the tasks array directly, or an object with .tasks
205
+ $tasks = if ($result -is [System.Array]) { $result } elseif ($result.tasks) { $result.tasks } else { @($result) }
206
+ # API doesn't filter server-side, so filter client-side
207
+ if ($Status -and $tasks) {
208
+ $tasks = @($tasks | Where-Object { $_.status -eq $Status })
209
+ }
210
+ return $tasks
211
+ }
212
+
213
+ function Get-VKTask {
214
+ <#
215
+ .SYNOPSIS Get a single task by ID.
216
+ #>
217
+ [CmdletBinding()]
218
+ param([Parameter(Mandatory)][string]$TaskId)
219
+ return Invoke-VKApi -Path "/api/tasks/$TaskId"
220
+ }
221
+
222
+ function Create-VKTask {
223
+ <#
224
+ .SYNOPSIS Create a new task in vibe-kanban.
225
+ #>
226
+ [CmdletBinding()]
227
+ param(
228
+ [Parameter(Mandatory)][string]$Title,
229
+ [Parameter(Mandatory)][string]$Description,
230
+ [ValidateSet("todo", "inprogress", "inreview", "done", "cancelled")]
231
+ [string]$Status = "todo"
232
+ )
233
+ if (-not (Initialize-VKConfig)) { return $null }
234
+ $body = @{
235
+ title = $Title
236
+ description = $Description
237
+ status = $Status
238
+ project_id = $script:VK_PROJECT_ID
239
+ }
240
+ return Invoke-VKApi -Path "/api/tasks" -Method "POST" -Body $body
241
+ }
242
+
243
+ function Update-VKTaskStatus {
244
+ <#
245
+ .SYNOPSIS Update a task's status.
246
+ #>
247
+ [CmdletBinding()]
248
+ param(
249
+ [Parameter(Mandatory)][string]$TaskId,
250
+ [Parameter(Mandatory)]
251
+ [ValidateSet("todo", "inprogress", "inreview", "done", "cancelled")]
252
+ [string]$Status
253
+ )
254
+ return Invoke-VKApi -Path "/api/tasks/$TaskId" -Method "PUT" -Body @{ status = $Status }
255
+ }
256
+
257
+ function Get-VKNextTodoTasks {
258
+ <#
259
+ .SYNOPSIS Get the next N tasks in 'todo' status, ordered by creation date (earliest first = highest priority).
260
+ #>
261
+ [CmdletBinding()]
262
+ param([int]$Count = 1)
263
+ $tasks = Get-VKTasks -Status "todo" -Limit $Count
264
+ if (-not $tasks) { return @() }
265
+ # Return as array
266
+ $arr = @($tasks)
267
+ return $arr | Select-Object -First $Count
268
+ }
269
+
270
+ # ─── Attempt Functions ────────────────────────────────────────────────────────
271
+
272
+ function Get-VKAttempts {
273
+ <#
274
+ .SYNOPSIS List all task attempts.
275
+ #>
276
+ [CmdletBinding()]
277
+ param([switch]$ActiveOnly)
278
+ $result = Invoke-VKApi -Path "/api/task-attempts"
279
+ if (-not $result) { return @() }
280
+ $attempts = if ($result -is [System.Array]) { @($result) } else { @($result) }
281
+ if ($ActiveOnly) {
282
+ $attempts = @($attempts | Where-Object { -not $_.archived })
283
+ }
284
+ return $attempts
285
+ }
286
+
287
+ function Get-VKAttemptSummaries {
288
+ <#
289
+ .SYNOPSIS Get attempt summaries for workspace status (idle/running/failed).
290
+ #>
291
+ [CmdletBinding()]
292
+ param([bool]$Archived = $false)
293
+ $body = @{ archived = $Archived }
294
+ $result = Invoke-VKApi -Path "/api/task-attempts/summary" -Method "POST" -Body $body
295
+ if (-not $result) { return @() }
296
+ $summaries = if ($result.summaries) { $result.summaries } else { $result }
297
+ return @($summaries)
298
+ }
299
+
300
+ function Get-VKArchivedAttempts {
301
+ <#
302
+ .SYNOPSIS List archived task attempts.
303
+ #>
304
+ [CmdletBinding()]
305
+ $attempts = Get-VKAttempts
306
+ if (-not $attempts) { return @() }
307
+ return @($attempts | Where-Object { $_.archived })
308
+ }
309
+
310
+ function Submit-VKTaskAttempt {
311
+ <#
312
+ .SYNOPSIS Submit a task as a new attempt (creates worktree + starts agent).
313
+ Uses the next executor in the Codex/Copilot rotation cycle.
314
+ #>
315
+ [CmdletBinding()]
316
+ param(
317
+ [Parameter(Mandatory)][string]$TaskId,
318
+ [string]$TargetBranch = $script:VK_TARGET_BRANCH,
319
+ [hashtable]$ExecutorOverride
320
+ )
321
+ if (-not (Initialize-VKConfig)) { return $null }
322
+
323
+ # Use override if provided, otherwise cycle to next executor
324
+ $execProfile = if ($ExecutorOverride) { $ExecutorOverride } else { Get-NextExecutorProfile }
325
+
326
+ $body = @{
327
+ task_id = $TaskId
328
+ repos = @(
329
+ @{
330
+ repo_id = $script:VK_REPO_ID
331
+ target_branch = $TargetBranch
332
+ }
333
+ )
334
+ executor_profile_id = @{
335
+ executor = $execProfile.executor
336
+ variant = $execProfile.variant
337
+ }
338
+ }
339
+ Write-Host " Submitting attempt for task $TaskId ($($execProfile.executor)/$($execProfile.variant)) ..." -ForegroundColor Cyan
340
+ $result = Invoke-VKApi -Path "/api/task-attempts" -Method "POST" -Body $body
341
+ if ($result) {
342
+ Write-Host " ✓ Attempt created: $($result.id) → branch $($result.branch)" -ForegroundColor Green
343
+ }
344
+ return $result
345
+ }
346
+
347
+ function Invoke-VKRebase {
348
+ <#
349
+ .SYNOPSIS Rebase an attempt's branch onto the latest target.
350
+ #>
351
+ [CmdletBinding()]
352
+ param(
353
+ [Parameter(Mandatory)][string]$AttemptId,
354
+ [string]$BaseBranch = $script:VK_TARGET_BRANCH
355
+ )
356
+ $body = @{
357
+ repo_id = $script:VK_REPO_ID
358
+ old_base_branch = $BaseBranch
359
+ new_base_branch = $BaseBranch
360
+ }
361
+ return Invoke-VKApi -Path "/api/task-attempts/$AttemptId/rebase" -Method "POST" -Body $body
362
+ }
363
+
364
+ function Archive-VKAttempt {
365
+ <#
366
+ .SYNOPSIS Archive a task attempt so it no longer counts as active.
367
+ #>
368
+ [CmdletBinding()]
369
+ param([Parameter(Mandatory)][string]$AttemptId)
370
+ $body = @{ archived = $true }
371
+ return Invoke-VKApi -Path "/api/task-attempts/$AttemptId" -Method "PUT" -Body $body
372
+ }
373
+
374
+ function Unarchive-VKAttempt {
375
+ <#
376
+ .SYNOPSIS Unarchive a task attempt so it counts as active again.
377
+ #>
378
+ [CmdletBinding()]
379
+ param([Parameter(Mandatory)][string]$AttemptId)
380
+ $body = @{ archived = $false }
381
+ return Invoke-VKApi -Path "/api/task-attempts/$AttemptId" -Method "PUT" -Body $body
382
+ }
383
+
384
+ # ─── GitHub PR Functions ─────────────────────────────────────────────────────
385
+
386
+ function Set-VKLastGithubError {
387
+ param(
388
+ [string]$Type,
389
+ [string]$Message
390
+ )
391
+ $script:VK_LAST_GH_ERROR = @{ type = $Type; message = $Message }
392
+ $script:VK_LAST_GH_ERROR_AT = Get-Date
393
+ }
394
+
395
+ function Clear-VKLastGithubError {
396
+ $script:VK_LAST_GH_ERROR = $null
397
+ $script:VK_LAST_GH_ERROR_AT = $null
398
+ }
399
+
400
+ function Get-VKLastGithubError {
401
+ return $script:VK_LAST_GH_ERROR
402
+ }
403
+
404
+ function Invoke-VKGithub {
405
+ <#
406
+ .SYNOPSIS Invoke gh CLI with rate-limit detection.
407
+ #>
408
+ [CmdletBinding()]
409
+ param(
410
+ [Parameter(Mandatory)][string[]]$Args
411
+ )
412
+ $output = & gh @Args 2>&1
413
+ $exitCode = $LASTEXITCODE
414
+ if ($exitCode -ne 0) {
415
+ if ($output -match "rate limit|API rate limit exceeded|secondary rate limit|abuse detection") {
416
+ Set-VKLastGithubError -Type "rate_limit" -Message $output
417
+ }
418
+ else {
419
+ Set-VKLastGithubError -Type "error" -Message $output
420
+ }
421
+ return $null
422
+ }
423
+ Clear-VKLastGithubError
424
+ return $output
425
+ }
426
+
427
+ function Get-PRForBranch {
428
+ <#
429
+ .SYNOPSIS Find an open or merged PR for a given branch.
430
+ #>
431
+ [CmdletBinding()]
432
+ param([Parameter(Mandatory)][string]$Branch)
433
+ $prJson = Invoke-VKGithub -Args @(
434
+ "pr", "list", "--head", $Branch, "--repo", "$script:GH_OWNER/$script:GH_REPO",
435
+ "--json", "number,state,title,mergeable,statusCheckRollup,mergedAt,createdAt,url",
436
+ "--limit", "1"
437
+ )
438
+ if (-not $prJson -or $prJson -eq "[]") {
439
+ # Also check merged/closed
440
+ $prJson = Invoke-VKGithub -Args @(
441
+ "pr", "list", "--head", $Branch, "--repo", "$script:GH_OWNER/$script:GH_REPO",
442
+ "--state", "merged", "--json", "number,state,title,mergedAt,createdAt,url",
443
+ "--limit", "1"
444
+ )
445
+ }
446
+ if (-not $prJson -or $prJson -eq "[]") { return $null }
447
+ return ($prJson | ConvertFrom-Json) | Select-Object -First 1
448
+ }
449
+
450
+ function Get-PRCheckStatus {
451
+ <#
452
+ .SYNOPSIS Get CI check status for a PR.
453
+ .OUTPUTS "passing", "failing", "pending", or "unknown"
454
+ #>
455
+ [CmdletBinding()]
456
+ param([Parameter(Mandatory)][int]$PRNumber)
457
+ $checksJson = Invoke-VKGithub -Args @(
458
+ "pr", "checks", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
459
+ "--json", "name,state"
460
+ )
461
+ if (-not $checksJson) { return "unknown" }
462
+ $checks = $checksJson | ConvertFrom-Json
463
+ if (-not $checks -or $checks.Count -eq 0) { return "unknown" }
464
+
465
+ $failing = $checks | Where-Object { $_.state -in @("FAILURE", "ERROR") }
466
+ $pending = $checks | Where-Object { $_.state -in @("PENDING", "IN_PROGRESS", "QUEUED") }
467
+
468
+ if ($failing.Count -gt 0) { return "failing" }
469
+ if ($pending.Count -gt 0) { return "pending" }
470
+ return "passing"
471
+ }
472
+
473
+ function Get-PRLatestCheckTimestamp {
474
+ <#
475
+ .SYNOPSIS Get the most recent check started/completed time for a PR.
476
+ #>
477
+ [CmdletBinding()]
478
+ param([Parameter(Mandatory)][int]$PRNumber)
479
+ $checksJson = Invoke-VKGithub -Args @(
480
+ "pr", "checks", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
481
+ "--json", "name,state,startedAt,completedAt"
482
+ )
483
+ if (-not $checksJson) { return $null }
484
+ $checks = $checksJson | ConvertFrom-Json
485
+ if (-not $checks -or $checks.Count -eq 0) { return $null }
486
+
487
+ $timestamps = @()
488
+ foreach ($check in $checks) {
489
+ if ($check.startedAt) { $timestamps += [datetime]$check.startedAt }
490
+ if ($check.completedAt) { $timestamps += [datetime]$check.completedAt }
491
+ }
492
+ if ($timestamps.Count -eq 0) { return $null }
493
+ return ($timestamps | Sort-Object -Descending | Select-Object -First 1)
494
+ }
495
+
496
+ function Get-OpenPullRequests {
497
+ <#
498
+ .SYNOPSIS List open PRs with metadata.
499
+ #>
500
+ [CmdletBinding()]
501
+ param([int]$Limit = 100)
502
+ $prJson = Invoke-VKGithub -Args @(
503
+ "pr", "list", "--repo", "$script:GH_OWNER/$script:GH_REPO",
504
+ "--state", "open", "--limit", $Limit.ToString(),
505
+ "--json", "number,title,author,isDraft,createdAt,headRefName,baseRefName,mergeStateStatus,url,body"
506
+ )
507
+ if (-not $prJson -or $prJson -eq "[]") { return @() }
508
+ return $prJson | ConvertFrom-Json
509
+ }
510
+
511
+ function Test-IsCopilotAuthor {
512
+ <#
513
+ .SYNOPSIS Determine whether a PR author is Copilot.
514
+ #>
515
+ [CmdletBinding()]
516
+ param([Parameter(Mandatory)][object]$Author)
517
+ $login = $Author.login
518
+ if (-not $login) { return $false }
519
+ return ($login -match "copilot")
520
+ }
521
+
522
+ function Get-PRDetails {
523
+ <#
524
+ .SYNOPSIS Get mergeability details for a PR.
525
+ #>
526
+ [CmdletBinding()]
527
+ param([Parameter(Mandatory)][int]$PRNumber)
528
+ $prJson = Invoke-VKGithub -Args @(
529
+ "pr", "view", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
530
+ "--json", "number,state,mergeable,mergeStateStatus,isDraft,reviewDecision,url,headRefName,baseRefName,title,body"
531
+ )
532
+ if (-not $prJson) { return $null }
533
+ return $prJson | ConvertFrom-Json
534
+ }
535
+
536
+ function Get-PRChecksDetail {
537
+ <#
538
+ .SYNOPSIS Get detailed check info for a PR.
539
+ #>
540
+ [CmdletBinding()]
541
+ param([Parameter(Mandatory)][int]$PRNumber)
542
+ $checksJson = Invoke-VKGithub -Args @(
543
+ "pr", "checks", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
544
+ "--json", "name,state,link,startedAt,completedAt"
545
+ )
546
+ if (-not $checksJson) { return @() }
547
+ return $checksJson | ConvertFrom-Json
548
+ }
549
+
550
+ function Get-RequiredChecksForBranch {
551
+ <#
552
+ .SYNOPSIS Get required status check names for a branch.
553
+ #>
554
+ [CmdletBinding()]
555
+ param([Parameter(Mandatory)][string]$Branch)
556
+ $result = Invoke-VKGithub -Args @(
557
+ "api",
558
+ "repos/$script:GH_OWNER/$script:GH_REPO/branches/$Branch/protection/required_status_checks"
559
+ )
560
+ if (-not $result) { return @() }
561
+ $payload = $result | ConvertFrom-Json
562
+ if (-not $payload) { return @() }
563
+
564
+ $names = @()
565
+ if ($payload.contexts) {
566
+ $names += @($payload.contexts | Where-Object { $_ })
567
+ }
568
+ if ($payload.checks) {
569
+ $names += @($payload.checks | ForEach-Object {
570
+ if ($_.context) { $_.context } else { $_.name }
571
+ } | Where-Object { $_ })
572
+ }
573
+
574
+ return @($names | Sort-Object -Unique)
575
+ }
576
+
577
+ function Get-PRRequiredCheckStatus {
578
+ <#
579
+ .SYNOPSIS Evaluate only required checks for a PR.
580
+ .OUTPUTS "passing", "failing", "pending", or "unknown"
581
+ #>
582
+ [CmdletBinding()]
583
+ param(
584
+ [Parameter(Mandatory)][int]$PRNumber,
585
+ [Parameter(Mandatory)][string]$BaseBranch
586
+ )
587
+ $required = Get-RequiredChecksForBranch -Branch $BaseBranch
588
+ if (-not $required -or @($required).Count -eq 0) {
589
+ return "passing"
590
+ }
591
+
592
+ $checks = Get-PRChecksDetail -PRNumber $PRNumber
593
+ if (-not $checks) { return "unknown" }
594
+
595
+ $requiredLower = $required | ForEach-Object { $_.ToLowerInvariant() }
596
+ $checksByName = @{}
597
+ foreach ($check in $checks) {
598
+ if (-not $check.name) { continue }
599
+ $checksByName[$check.name.ToLowerInvariant()] = $check.state
600
+ }
601
+
602
+ $hasPending = $false
603
+ foreach ($name in $requiredLower) {
604
+ if (-not $checksByName.ContainsKey($name)) {
605
+ $hasPending = $true
606
+ continue
607
+ }
608
+ $state = $checksByName[$name]
609
+ if ($state -in @("FAILURE", "ERROR")) { return "failing" }
610
+ if ($state -in @("PENDING", "IN_PROGRESS", "QUEUED")) { $hasPending = $true }
611
+ }
612
+
613
+ if ($hasPending) { return "pending" }
614
+ return "passing"
615
+ }
616
+
617
+ function Create-GithubIssue {
618
+ <#
619
+ .SYNOPSIS Create a GitHub issue.
620
+ #>
621
+ [CmdletBinding()]
622
+ param(
623
+ [Parameter(Mandatory)][string]$Title,
624
+ [Parameter(Mandatory)][string]$Body
625
+ )
626
+ $result = Invoke-VKGithub -Args @(
627
+ "api",
628
+ "repos/$script:GH_OWNER/$script:GH_REPO/issues",
629
+ "-f", "title=$Title",
630
+ "-f", "body=$Body"
631
+ )
632
+ if (-not $result) { return $null }
633
+ return $result | ConvertFrom-Json
634
+ }
635
+
636
+ function Assign-IssueToCopilot {
637
+ <#
638
+ .SYNOPSIS Assign a GitHub issue to Copilot.
639
+ #>
640
+ [CmdletBinding()]
641
+ param([Parameter(Mandatory)][int]$IssueNumber)
642
+ $result = Invoke-VKGithub -Args @(
643
+ "api",
644
+ "repos/$script:GH_OWNER/$script:GH_REPO/issues/$IssueNumber/assignees",
645
+ "-f", "assignees[]=copilot"
646
+ )
647
+ return ($null -ne $result)
648
+ }
649
+
650
+ function Get-RecentMergedPRs {
651
+ <#
652
+ .SYNOPSIS List recent merged PRs.
653
+ #>
654
+ [CmdletBinding()]
655
+ param([int]$Limit = 15)
656
+ $prJson = Invoke-VKGithub -Args @(
657
+ "pr", "list", "--repo", "$script:GH_OWNER/$script:GH_REPO",
658
+ "--state", "merged", "--limit", $Limit.ToString(),
659
+ "--json", "number,title,url,mergedAt"
660
+ )
661
+ if (-not $prJson -or $prJson -eq "[]") { return @() }
662
+ return $prJson | ConvertFrom-Json
663
+ }
664
+
665
+ function Get-FailingWorkflowRuns {
666
+ <#
667
+ .SYNOPSIS Get recent failing workflow runs on main.
668
+ #>
669
+ [CmdletBinding()]
670
+ param([int]$Limit = 8)
671
+ $result = Invoke-VKGithub -Args @(
672
+ "api",
673
+ "repos/$script:GH_OWNER/$script:GH_REPO/actions/runs",
674
+ "-f", "branch=main",
675
+ "-f", "status=completed",
676
+ "-f", "per_page=$Limit"
677
+ )
678
+ if (-not $result) { return @() }
679
+ $payload = $result | ConvertFrom-Json
680
+ $runs = $payload.workflow_runs
681
+ if (-not $runs) { return @() }
682
+ return $runs | Where-Object {
683
+ $_.conclusion -in @("failure", "cancelled", "timed_out", "action_required")
684
+ }
685
+ }
686
+
687
+ function Format-PRCheckFailures {
688
+ <#
689
+ .SYNOPSIS Format failing checks into a short markdown list.
690
+ #>
691
+ [CmdletBinding()]
692
+ param([object[]]$Checks = @())
693
+ if (-not $Checks -or $Checks.Count -eq 0) { return "- No failing checks found" }
694
+ $failed = $Checks | Where-Object { $_.state -eq "FAILURE" -or $_.state -eq "ERROR" }
695
+ if (-not $failed -or $failed.Count -eq 0) { return "- No failing checks found" }
696
+ $lines = $failed | ForEach-Object {
697
+ $link = if ($_.link) { " ($($_.link))" } else { "" }
698
+ "- $($_.name): $($_.state)$link"
699
+ }
700
+ return ($lines -join "`n")
701
+ }
702
+
703
+ function Add-PRComment {
704
+ <#
705
+ .SYNOPSIS Add a comment to a PR.
706
+ #>
707
+ [CmdletBinding()]
708
+ param(
709
+ [Parameter(Mandatory)][int]$PRNumber,
710
+ [Parameter(Mandatory)][string]$Body
711
+ )
712
+ $result = Invoke-VKGithub -Args @(
713
+ "pr", "comment", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
714
+ "--body", $Body
715
+ )
716
+ return ($null -ne $result)
717
+ }
718
+
719
+ function Get-PRComments {
720
+ <#
721
+ .SYNOPSIS Fetch recent PR comments (issue comments).
722
+ #>
723
+ [CmdletBinding()]
724
+ param(
725
+ [Parameter(Mandatory)][int]$PRNumber,
726
+ [int]$Limit = 30
727
+ )
728
+ $result = Invoke-VKGithub -Args @(
729
+ "api",
730
+ "repos/$script:GH_OWNER/$script:GH_REPO/issues/$PRNumber/comments",
731
+ "-f", "per_page=$Limit"
732
+ )
733
+ if (-not $result) { return @() }
734
+ try {
735
+ return $result | ConvertFrom-Json
736
+ }
737
+ catch {
738
+ return @()
739
+ }
740
+ }
741
+
742
+ function Test-CopilotRateLimitComment {
743
+ <#
744
+ .SYNOPSIS Detect Copilot rate limit notices OR "stopped work due to error" in PR comments.
745
+ #>
746
+ [CmdletBinding()]
747
+ param([Parameter(Mandatory)][int]$PRNumber)
748
+ $comments = Get-PRComments -PRNumber $PRNumber -Limit 30
749
+ if (-not $comments) { return $null }
750
+ $pattern = "copilot stopped work|rate limit|rate-limited|secondary rate limit|due to an error"
751
+ foreach ($comment in $comments) {
752
+ if (-not $comment.body) { continue }
753
+ $body = $comment.body.ToString()
754
+ if ($body -match $pattern -and $body -match "copilot") {
755
+ $isError = $body -match "due to an error|stopped work"
756
+ return @{
757
+ hit = $true
758
+ is_error = [bool]$isError
759
+ body = $body
760
+ created_at = $comment.created_at
761
+ author = $comment.user?.login
762
+ }
763
+ }
764
+ }
765
+ return $null
766
+ }
767
+
768
+ function Test-PRHasCopilotComment {
769
+ <#
770
+ .SYNOPSIS Check if @copilot has already been mentioned in PR comments.
771
+ Returns $true if any comment body contains '@copilot' — meaning Copilot
772
+ was already triggered and should NOT be triggered again.
773
+ #>
774
+ [CmdletBinding()]
775
+ param([Parameter(Mandatory)][int]$PRNumber)
776
+ $comments = Get-PRComments -PRNumber $PRNumber -Limit 50
777
+ if (-not $comments) { return $false }
778
+ foreach ($comment in $comments) {
779
+ if (-not $comment.body) { continue }
780
+ if ($comment.body.ToString() -match "@copilot") {
781
+ return $true
782
+ }
783
+ }
784
+ return $false
785
+ }
786
+
787
+ function Test-CopilotPRClosed {
788
+ <#
789
+ .SYNOPSIS Check if a Copilot-authored PR for this PR number was ever closed.
790
+ If so, we should never trigger @copilot for this PR again.
791
+ #>
792
+ [CmdletBinding()]
793
+ param([Parameter(Mandatory)][int]$PRNumber)
794
+ $search = "$PRNumber in:title,body"
795
+ $prJson = Invoke-VKGithub -Args @(
796
+ "pr", "list", "--repo", "$script:GH_OWNER/$script:GH_REPO",
797
+ "--state", "closed", "--search", $search,
798
+ "--json", "number,title,body,author",
799
+ "--limit", "10"
800
+ )
801
+ if (-not $prJson) { return $false }
802
+ $prs = $prJson | ConvertFrom-Json
803
+ if (-not $prs) { return $false }
804
+ $pattern = "(?i)(PR\s*#?$PRNumber|#$PRNumber)"
805
+ foreach ($pr in $prs) {
806
+ if (($pr.title -match $pattern) -or ($pr.body -match $pattern)) {
807
+ $authorLogin = if ($pr.author -and $pr.author.login) { $pr.author.login } else { "" }
808
+ if ($authorLogin -match "copilot|bot") {
809
+ return $true
810
+ }
811
+ }
812
+ }
813
+ return $false
814
+ }
815
+
816
+ function Mark-PRReady {
817
+ <#
818
+ .SYNOPSIS Mark a PR as ready for review (exit draft).
819
+ #>
820
+ [CmdletBinding()]
821
+ param([Parameter(Mandatory)][int]$PRNumber)
822
+ $result = Invoke-VKGithub -Args @(
823
+ "pr", "ready", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO"
824
+ )
825
+ return ($null -ne $result)
826
+ }
827
+
828
+ function Find-CopilotFixPR {
829
+ <#
830
+ .SYNOPSIS Find a Copilot fix PR referencing an original PR number.
831
+ #>
832
+ [CmdletBinding()]
833
+ param([Parameter(Mandatory)][int]$OriginalPRNumber)
834
+ $search = "$OriginalPRNumber in:title,body"
835
+ $prJson = Invoke-VKGithub -Args @(
836
+ "pr", "list", "--repo", "$script:GH_OWNER/$script:GH_REPO",
837
+ "--state", "open", "--search", $search,
838
+ "--json", "number,title,body,headRefName,baseRefName,isDraft,url,createdAt",
839
+ "--limit", "20"
840
+ )
841
+ if (-not $prJson) { return $null }
842
+ $prs = $prJson | ConvertFrom-Json
843
+ if (-not $prs) { return $null }
844
+ $pattern = "(?i)(PR\s*#?$OriginalPRNumber|#$OriginalPRNumber)"
845
+ $match = $prs | Where-Object {
846
+ ($_.title -match $pattern) -or ($_.body -match $pattern)
847
+ } | Sort-Object -Property createdAt -Descending | Select-Object -First 1
848
+ return $match
849
+ }
850
+
851
+ function Test-CopilotPRComplete {
852
+ <#
853
+ .SYNOPSIS Determine whether a Copilot PR is complete (not WIP and not draft).
854
+ #>
855
+ [CmdletBinding()]
856
+ param([Parameter(Mandatory)][object]$PRDetails)
857
+ if (-not $PRDetails) { return $false }
858
+ if ($PRDetails.isDraft) { return $false }
859
+ if ($PRDetails.title -match "^\[WIP\]") { return $false }
860
+ return $true
861
+ }
862
+
863
+ function Close-PRDeleteBranch {
864
+ <#
865
+ .SYNOPSIS Close a PR and delete its branch.
866
+ #>
867
+ [CmdletBinding()]
868
+ param([Parameter(Mandatory)][int]$PRNumber)
869
+ $result = Invoke-VKGithub -Args @(
870
+ "pr", "close", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
871
+ "--delete-branch"
872
+ )
873
+ return ($null -ne $result)
874
+ }
875
+
876
+ function Merge-BranchFromPR {
877
+ <#
878
+ .SYNOPSIS Merge a PR head branch into a target base branch.
879
+ #>
880
+ [CmdletBinding()]
881
+ param(
882
+ [Parameter(Mandatory)][string]$BaseBranch,
883
+ [Parameter(Mandatory)][string]$HeadBranch
884
+ )
885
+ $result = Invoke-VKGithub -Args @(
886
+ "api", "repos/$script:GH_OWNER/$script:GH_REPO/merges",
887
+ "-f", "base=$BaseBranch",
888
+ "-f", "head=$HeadBranch"
889
+ )
890
+ return ($null -ne $result)
891
+ }
892
+
893
+ function Create-PRForBranch {
894
+ <#
895
+ .SYNOPSIS Create a PR for a branch when the agent did not open one.
896
+ #>
897
+ [CmdletBinding()]
898
+ param(
899
+ [Parameter(Mandatory)][string]$Branch,
900
+ [Parameter(Mandatory)][string]$Title,
901
+ [string]$Body = "Automated PR created by ve-orchestrator"
902
+ )
903
+ $baseBranch = $script:VK_TARGET_BRANCH
904
+ if ($baseBranch -like "origin/*") { $baseBranch = $baseBranch.Substring(7) }
905
+ $result = Invoke-VKGithub -Args @(
906
+ "pr", "create", "--repo", "$script:GH_OWNER/$script:GH_REPO",
907
+ "--head", $Branch,
908
+ "--base", $baseBranch,
909
+ "--title", $Title,
910
+ "--body", $Body
911
+ )
912
+ return ($null -ne $result)
913
+ }
914
+
915
+ function Test-RemoteBranchExists {
916
+ <#
917
+ .SYNOPSIS Check if a branch exists on the remote GitHub repo.
918
+ #>
919
+ [CmdletBinding()]
920
+ param([Parameter(Mandatory)][string]$Branch)
921
+ $result = Invoke-VKGithub -Args @(
922
+ "api",
923
+ "repos/$script:GH_OWNER/$script:GH_REPO/branches/$Branch"
924
+ )
925
+ return ($null -ne $result)
926
+ }
927
+
928
+ function Get-VKSessions {
929
+ <#
930
+ .SYNOPSIS List sessions for a workspace.
931
+ #>
932
+ [CmdletBinding()]
933
+ param([Parameter(Mandatory)][string]$WorkspaceId)
934
+ $result = Invoke-VKApi -Path "/api/sessions?workspace_id=$WorkspaceId"
935
+ if (-not $result) { return @() }
936
+ return @($result)
937
+ }
938
+
939
+ function New-VKSession {
940
+ <#
941
+ .SYNOPSIS Create a new session for a workspace.
942
+ #>
943
+ [CmdletBinding()]
944
+ param([Parameter(Mandatory)][string]$WorkspaceId)
945
+ $body = @{ workspace_id = $WorkspaceId }
946
+ return Invoke-VKApi -Path "/api/sessions" -Method "POST" -Body $body
947
+ }
948
+
949
+ function Get-ExecutorProfileForSession {
950
+ <#
951
+ .SYNOPSIS Map a session executor to an executor profile.
952
+ #>
953
+ [CmdletBinding()]
954
+ param([Parameter(Mandatory)][string]$Executor)
955
+ switch ($Executor) {
956
+ "COPILOT" { return @{ executor = "COPILOT"; variant = "CLAUDE_OPUS_4_6" } }
957
+ "CODEX" { return @{ executor = "CODEX"; variant = "DEFAULT" } }
958
+ default { return @{ executor = "CODEX"; variant = "DEFAULT" } }
959
+ }
960
+ }
961
+
962
+ function Send-VKWorkspaceFollowUp {
963
+ <#
964
+ .SYNOPSIS Send a direct follow-up message to the latest session for a workspace.
965
+ #>
966
+ [CmdletBinding()]
967
+ param(
968
+ [Parameter(Mandatory)][string]$WorkspaceId,
969
+ [Parameter(Mandatory)][string]$Message,
970
+ [hashtable]$ExecutorOverride
971
+ )
972
+ $sessions = Get-VKSessions -WorkspaceId $WorkspaceId
973
+ if (-not $sessions -or @($sessions).Count -eq 0) { return $false }
974
+ $session = $sessions | Sort-Object -Property updated_at -Descending | Select-Object -First 1
975
+ $profile = if ($ExecutorOverride) { $ExecutorOverride } else { Get-ExecutorProfileForSession -Executor $session.executor }
976
+ return Send-VKSessionFollowUp -SessionId $session.id -Message $Message -ExecutorProfile $profile
977
+ }
978
+
979
+ function Send-VKSessionFollowUp {
980
+ <#
981
+ .SYNOPSIS Send a follow-up message to a specific session.
982
+ #>
983
+ [CmdletBinding()]
984
+ param(
985
+ [Parameter(Mandatory)][string]$SessionId,
986
+ [Parameter(Mandatory)][string]$Message,
987
+ [Parameter(Mandatory)][hashtable]$ExecutorProfile
988
+ )
989
+ $body = @{ prompt = $Message; executor_profile_id = $ExecutorProfile }
990
+ $result = Invoke-VKApi -Path "/api/sessions/$SessionId/follow-up" -Method "POST" -Body $body
991
+ return ($null -ne $result)
992
+ }
993
+
994
+ function Queue-VKWorkspaceMessage {
995
+ <#
996
+ .SYNOPSIS Enqueue a message to the latest session for a workspace.
997
+ #>
998
+ [CmdletBinding()]
999
+ param(
1000
+ [Parameter(Mandatory)][string]$WorkspaceId,
1001
+ [Parameter(Mandatory)][string]$Message
1002
+ )
1003
+ $sessions = Get-VKSessions -WorkspaceId $WorkspaceId
1004
+ if (-not $sessions -or @($sessions).Count -eq 0) { return $false }
1005
+ $session = $sessions | Sort-Object -Property updated_at -Descending | Select-Object -First 1
1006
+ $profile = Get-ExecutorProfileForSession -Executor $session.executor
1007
+ $body = @{ executor_profile_id = $profile; message = $Message }
1008
+ $result = Invoke-VKApi -Path "/api/sessions/$($session.id)/queue" -Method "POST" -Body $body
1009
+ return ($null -ne $result)
1010
+ }
1011
+
1012
+ function Merge-PR {
1013
+ <#
1014
+ .SYNOPSIS Merge a PR after rebase onto latest main. Returns $true on success.
1015
+ #>
1016
+ [CmdletBinding()]
1017
+ param(
1018
+ [Parameter(Mandatory)][int]$PRNumber,
1019
+ [switch]$AutoMerge,
1020
+ [switch]$Admin
1021
+ )
1022
+ $args = @(
1023
+ "pr", "merge", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
1024
+ "--squash", "--delete-branch"
1025
+ )
1026
+ if ($AutoMerge) { $args += "--auto" }
1027
+ if ($Admin) { $args += "--admin" }
1028
+ if ($AutoMerge) {
1029
+ Write-Host " Enabling auto-merge for PR #$PRNumber ..." -ForegroundColor Yellow
1030
+ }
1031
+ elseif ($Admin) {
1032
+ Write-Host " Admin merging PR #$PRNumber ..." -ForegroundColor Yellow
1033
+ }
1034
+ else {
1035
+ Write-Host " Merging PR #$PRNumber ..." -ForegroundColor Yellow
1036
+ }
1037
+ $result = Invoke-VKGithub -Args $args
1038
+ return ($null -ne $result)
1039
+ }
1040
+
1041
+ function Enable-AutoMerge {
1042
+ <#
1043
+ .SYNOPSIS Enable auto-merge on a PR so it merges when CI passes.
1044
+ #>
1045
+ [CmdletBinding()]
1046
+ param([Parameter(Mandatory)][int]$PRNumber)
1047
+ $result = Invoke-VKGithub -Args @(
1048
+ "pr", "merge", $PRNumber.ToString(), "--repo", "$script:GH_OWNER/$script:GH_REPO",
1049
+ "--auto", "--squash", "--delete-branch"
1050
+ )
1051
+ return ($null -ne $result)
1052
+ }
1053
+
1054
+ # ─── Orchestration Helpers ────────────────────────────────────────────────────
1055
+
1056
+ function Get-ActiveAttemptCount {
1057
+ <#
1058
+ .SYNOPSIS Count non-archived attempts that have running agents.
1059
+ #>
1060
+ $attempts = Get-VKAttempts -ActiveOnly
1061
+ return @($attempts).Count
1062
+ }
1063
+
1064
+ function Get-AttemptTaskMap {
1065
+ <#
1066
+ .SYNOPSIS Build a hashtable mapping task_id → latest attempt for active (non-archived) attempts.
1067
+ #>
1068
+ $attempts = Get-VKAttempts -ActiveOnly
1069
+ $map = @{}
1070
+ foreach ($a in $attempts) {
1071
+ $map[$a.task_id] = $a
1072
+ }
1073
+ return $map
1074
+ }
1075
+
1076
+ function Test-AttemptComplete {
1077
+ <#
1078
+ .SYNOPSIS Check if an attempt has completed (branch pushed + PR exists or is merged).
1079
+ #>
1080
+ [CmdletBinding()]
1081
+ param([Parameter(Mandatory)][object]$Attempt)
1082
+ if (-not $Attempt.branch) { return $false }
1083
+ $pr = Get-PRForBranch -Branch $Attempt.branch
1084
+ return ($null -ne $pr)
1085
+ }
1086
+
1087
+ # ─── Display Helpers ──────────────────────────────────────────────────────────
1088
+
1089
+ function Show-Tasks {
1090
+ [CmdletBinding()]
1091
+ param(
1092
+ [ValidateSet("todo", "inprogress", "inreview", "done", "cancelled")]
1093
+ [string]$Status = "todo"
1094
+ )
1095
+ $tasks = Get-VKTasks -Status $Status
1096
+ if (-not $tasks) {
1097
+ Write-Host " No tasks with status '$Status'" -ForegroundColor Yellow
1098
+ return
1099
+ }
1100
+ Write-Host ""
1101
+ Write-Host " ┌─ $($Status.ToUpper()) Tasks ($(@($tasks).Count)) ─────────────────" -ForegroundColor Cyan
1102
+ foreach ($t in $tasks) {
1103
+ $age = if ($t.created_at) { [math]::Round(((Get-Date) - [datetime]$t.created_at).TotalDays, 0) } else { "?" }
1104
+ $inProgress = if ($t.has_in_progress_attempt) { " [RUNNING]" } else { "" }
1105
+ Write-Host " │ $($t.id.Substring(0,8)) $($t.title)$inProgress" -ForegroundColor $(if ($t.has_in_progress_attempt) { "Yellow" } else { "White" })
1106
+ }
1107
+ Write-Host " └───────────────────────────────────────────" -ForegroundColor Cyan
1108
+ Write-Host ""
1109
+ }
1110
+
1111
+ function Show-Status {
1112
+ [CmdletBinding()]
1113
+ param(
1114
+ [switch]$ShowIdle
1115
+ )
1116
+ if (-not (Initialize-VKConfig)) { return }
1117
+ $active = Get-VKAttempts -ActiveOnly
1118
+ $todoTasks = Get-VKTasks -Status "todo"
1119
+ $inProgressTasks = Get-VKTasks -Status "inprogress"
1120
+
1121
+ Write-Host ""
1122
+ Write-Host " ╔═══════════════════════════════════════════╗" -ForegroundColor Cyan
1123
+ Write-Host " ║ VirtEngine Kanban Status ║" -ForegroundColor Cyan
1124
+ Write-Host " ╚═══════════════════════════════════════════╝" -ForegroundColor Cyan
1125
+ Write-Host ""
1126
+ Write-Host " Todo: $(@($todoTasks).Count) tasks" -ForegroundColor White
1127
+ Write-Host " In-Progress: $(@($inProgressTasks).Count) tasks" -ForegroundColor Yellow
1128
+ Write-Host " Active Attempts: $(@($active).Count)" -ForegroundColor Green
1129
+ Write-Host ""
1130
+
1131
+ if ($active -and @($active).Count -gt 0) {
1132
+ Write-Host " ┌─ Active Attempts ──────────────────────────" -ForegroundColor Green
1133
+ foreach ($a in $active) {
1134
+ $name = if ($a.name) { $a.name.Substring(0, [Math]::Min(60, $a.name.Length)) } else { "(unnamed)" }
1135
+ $pr = Get-PRForBranch -Branch $a.branch 2>$null
1136
+ $prStatus = if ($pr) { "PR #$($pr.number) ($($pr.state))" } else { "No PR" }
1137
+ Write-Host " │ $($a.id.Substring(0,8)) $($a.branch)" -ForegroundColor White
1138
+ Write-Host " │ $name" -ForegroundColor DarkGray
1139
+ Write-Host " │ $prStatus" -ForegroundColor $(if ($pr -and $pr.state -eq "MERGED") { "Green" } elseif ($pr) { "Yellow" } else { "DarkGray" })
1140
+ if ($ShowIdle) {
1141
+ $lastUpdate = if ($a.updated_at) { [datetime]$a.updated_at } elseif ($a.created_at) { [datetime]$a.created_at } else { $null }
1142
+ $idleMinutes = if ($lastUpdate) { [math]::Round(((Get-Date) - $lastUpdate).TotalMinutes, 1) } else { "?" }
1143
+ $updatedAtText = if ($lastUpdate) { $lastUpdate.ToString("u") } else { "unknown" }
1144
+ Write-Host " │ Idle: ${idleMinutes} min (last update: $updatedAtText)" -ForegroundColor DarkGray
1145
+ }
1146
+ }
1147
+ Write-Host " └────────────────────────────────────────────" -ForegroundColor Green
1148
+ }
1149
+ Write-Host ""
1150
+ }
1151
+
1152
+ # ─── CLI Dispatch ─────────────────────────────────────────────────────────────
1153
+
1154
+ function Invoke-CLI {
1155
+ param([string[]]$Arguments)
1156
+
1157
+ if ($null -eq $Arguments) {
1158
+ $Arguments = @()
1159
+ }
1160
+ else {
1161
+ $Arguments = @($Arguments)
1162
+ }
1163
+
1164
+ if ($Arguments.Count -eq 0) {
1165
+ Show-Usage
1166
+ return
1167
+ }
1168
+
1169
+ $command = $Arguments[0]
1170
+ $rest = if ($Arguments.Count -gt 1) { $Arguments[1..($Arguments.Count - 1)] } else { @() }
1171
+
1172
+ switch ($command) {
1173
+ "create" {
1174
+ $title = $null
1175
+ $description = $null
1176
+ $descFile = $null
1177
+ $status = "todo"
1178
+ for ($i = 0; $i -lt $rest.Count; $i++) {
1179
+ if ($rest[$i] -in @("--title", "-t") -and ($i + 1) -lt $rest.Count) { $title = $rest[$i + 1] }
1180
+ if ($rest[$i] -in @("--description", "--desc", "-d") -and ($i + 1) -lt $rest.Count) { $description = $rest[$i + 1] }
1181
+ if ($rest[$i] -in @("--description-file", "--desc-file") -and ($i + 1) -lt $rest.Count) { $descFile = $rest[$i + 1] }
1182
+ if ($rest[$i] -in @("--status", "-s") -and ($i + 1) -lt $rest.Count) { $status = $rest[$i + 1] }
1183
+ }
1184
+ if (-not $description -and $descFile) {
1185
+ try { $description = Get-Content -Path $descFile -Raw }
1186
+ catch { Write-Error "Failed to read description file: $descFile"; return }
1187
+ }
1188
+ if (-not $title -or -not $description) {
1189
+ Write-Error "Usage: ve-kanban create --title <title> --description <markdown> [--status todo]"
1190
+ return
1191
+ }
1192
+ $result = Create-VKTask -Title $title -Description $description -Status $status
1193
+ if ($result -and $result.id) {
1194
+ Write-Host " ✓ Task created: $($result.id) — $title" -ForegroundColor Green
1195
+ }
1196
+ elseif ($result) {
1197
+ Write-Host " ✓ Task created: $title" -ForegroundColor Green
1198
+ }
1199
+ else {
1200
+ Write-Error "Failed to create task."
1201
+ }
1202
+ }
1203
+ "list" {
1204
+ $status = "todo"
1205
+ for ($i = 0; $i -lt $rest.Count; $i++) {
1206
+ if ($rest[$i] -in @("--status", "-s") -and ($i + 1) -lt $rest.Count) { $status = $rest[$i + 1] }
1207
+ }
1208
+ Show-Tasks -Status $status
1209
+ }
1210
+ "status" {
1211
+ $showIdle = $false
1212
+ for ($i = 0; $i -lt $rest.Count; $i++) {
1213
+ if ($rest[$i] -in @("--verbose", "-v")) { $showIdle = $true }
1214
+ }
1215
+ if ($VerbosePreference -eq "Continue") { $showIdle = $true }
1216
+ if (Test-CLIFlagPresent -Flags @("--verbose", "-v") -Arguments $rest) { $showIdle = $true }
1217
+ Show-Status -ShowIdle:$showIdle
1218
+ }
1219
+ "archived" {
1220
+ $archived = Get-VKArchivedAttempts
1221
+ if (-not $archived -or @($archived).Count -eq 0) {
1222
+ Write-Host " No archived attempts." -ForegroundColor Yellow
1223
+ return
1224
+ }
1225
+ Write-Host "";
1226
+ Write-Host " ┌─ Archived Attempts ─────────────────────────" -ForegroundColor DarkGray
1227
+ foreach ($a in $archived) {
1228
+ $name = if ($a.name) { $a.name.Substring(0, [Math]::Min(60, $a.name.Length)) } else { "(unnamed)" }
1229
+ Write-Host " │ $($a.id.Substring(0,8)) $($a.branch)" -ForegroundColor White
1230
+ Write-Host " │ $name" -ForegroundColor DarkGray
1231
+ }
1232
+ Write-Host " └────────────────────────────────────────────" -ForegroundColor DarkGray
1233
+ Write-Host ""
1234
+ }
1235
+ "unarchive" {
1236
+ if ($rest.Count -eq 0) { Write-Error "Usage: ve-kanban unarchive <attempt-id>"; return }
1237
+ Unarchive-VKAttempt -AttemptId $rest[0] | Out-Null
1238
+ Write-Host " ✓ Attempt $($rest[0]) unarchived" -ForegroundColor Green
1239
+ }
1240
+ "submit" {
1241
+ if ($rest.Count -eq 0) { Write-Error "Usage: ve-kanban submit <task-id>"; return }
1242
+ $taskId = $rest[0]
1243
+ Submit-VKTaskAttempt -TaskId $taskId
1244
+ }
1245
+ "submit-next" {
1246
+ $count = 1
1247
+ for ($i = 0; $i -lt $rest.Count; $i++) {
1248
+ if ($rest[$i] -in @("--count", "-n") -and ($i + 1) -lt $rest.Count) { $count = [int]$rest[$i + 1] }
1249
+ }
1250
+ $tasks = Get-VKNextTodoTasks -Count $count
1251
+ if (-not $tasks -or @($tasks).Count -eq 0) {
1252
+ Write-Host " No todo tasks available." -ForegroundColor Yellow
1253
+ return
1254
+ }
1255
+ foreach ($t in $tasks) {
1256
+ Write-Host " → $($t.title)" -ForegroundColor White
1257
+ Submit-VKTaskAttempt -TaskId $t.id
1258
+ }
1259
+ }
1260
+ "rebase" {
1261
+ if ($rest.Count -eq 0) { Write-Error "Usage: ve-kanban rebase <attempt-id>"; return }
1262
+ Invoke-VKRebase -AttemptId $rest[0]
1263
+ }
1264
+ "merge" {
1265
+ $branch = $null
1266
+ $auto = $false
1267
+ for ($i = 0; $i -lt $rest.Count; $i++) {
1268
+ if ($rest[$i] -in @("--branch", "-b") -and ($i + 1) -lt $rest.Count) { $branch = $rest[$i + 1] }
1269
+ if ($rest[$i] -eq "--auto") { $auto = $true }
1270
+ }
1271
+ if (-not $branch -and $rest.Count -gt 0 -and $rest[0] -notlike "--*") { $branch = $rest[0] }
1272
+ if (-not $branch) { Write-Error "Usage: ve-kanban merge <branch> [--auto]"; return }
1273
+ $pr = Get-PRForBranch -Branch $branch
1274
+ if (-not $pr) { Write-Error "No PR found for branch $branch"; return }
1275
+ if ($auto) {
1276
+ Enable-AutoMerge -PRNumber $pr.number
1277
+ }
1278
+ else {
1279
+ Merge-PR -PRNumber $pr.number
1280
+ }
1281
+ }
1282
+ "complete" {
1283
+ if ($rest.Count -eq 0) { Write-Error "Usage: ve-kanban complete <task-id>"; return }
1284
+ Update-VKTaskStatus -TaskId $rest[0] -Status "done"
1285
+ Write-Host " ✓ Task $($rest[0]) marked as done" -ForegroundColor Green
1286
+ }
1287
+ "orchestrate" {
1288
+ $parallel = 2
1289
+ $interval = 60
1290
+ for ($i = 0; $i -lt $rest.Count; $i++) {
1291
+ if ($rest[$i] -in @("--parallel", "-p") -and ($i + 1) -lt $rest.Count) { $parallel = [int]$rest[$i + 1] }
1292
+ if ($rest[$i] -in @("--interval", "-i") -and ($i + 1) -lt $rest.Count) { $interval = [int]$rest[$i + 1] }
1293
+ }
1294
+ Write-Host " Delegating to ve-orchestrator.ps1 with parallel=$parallel, interval=$interval" -ForegroundColor Cyan
1295
+ & "$PSScriptRoot/ve-orchestrator.ps1" -MaxParallel $parallel -PollIntervalSec $interval
1296
+ }
1297
+ "help" { Show-Usage }
1298
+ default {
1299
+ Write-Error "Unknown command: $command"
1300
+ Show-Usage
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ function Show-Usage {
1306
+ Write-Host @"
1307
+
1308
+ VirtEngine Kanban CLI (ve-kanban)
1309
+ ═════════════════════════════════
1310
+
1311
+ COMMANDS:
1312
+ create --title <title> --description <md> [--status todo]
1313
+ Create a new task
1314
+ list [--status <status>] List tasks (default: todo)
1315
+ status Show dashboard (active attempts + queues)
1316
+ status --verbose | -v Show dashboard with idle minutes
1317
+ archived List archived attempts
1318
+ unarchive <attempt-id> Unarchive an attempt
1319
+ submit <task-id> Submit a single task as an attempt
1320
+ submit-next [--count N] Submit next N todo tasks as attempts
1321
+ rebase <attempt-id> Rebase an attempt branch onto latest main
1322
+ merge <branch> [--auto] Merge PR for a branch (--auto enables auto-merge)
1323
+ complete <task-id> Mark a task as done
1324
+ orchestrate [--parallel N] Run orchestration loop (default: 2 parallel)
1325
+ help Show this help
1326
+
1327
+ ENVIRONMENT:
1328
+ VK_BASE_URL Vibe-kanban API (default: http://127.0.0.1:54089)
1329
+ VK_PROJECT_NAME Project name to auto-detect (default: virtengine)
1330
+ VK_PROJECT_ID Project UUID (auto-detected if empty)
1331
+ VK_REPO_ID Repository UUID (auto-detected if empty)
1332
+ GH_OWNER GitHub owner (default: virtengine-gh)
1333
+ GH_REPO GitHub repo (default: virtengine)
1334
+
1335
+ EXECUTOR CYCLING:
1336
+ Alternates between CODEX/DEFAULT and COPILOT/CLAUDE_OPUS_4_6
1337
+ at 50/50 rate to avoid rate-limiting on either agent.
1338
+ "@ -ForegroundColor White
1339
+ }
1340
+
1341
+ function Test-CLIFlagPresent {
1342
+ [CmdletBinding()]
1343
+ param(
1344
+ [Parameter(Mandatory)][string[]]$Flags,
1345
+ [string[]]$Arguments
1346
+ )
1347
+ if ($Arguments) {
1348
+ foreach ($flag in $Flags) {
1349
+ if ($Arguments -contains $flag) { return $true }
1350
+ }
1351
+ }
1352
+ if ($script:VK_CLI_RAW_LINE) {
1353
+ foreach ($flag in $Flags) {
1354
+ $pattern = "(^|\s)" + [regex]::Escape($flag) + "(\s|$)"
1355
+ if ($script:VK_CLI_RAW_LINE -match $pattern) { return $true }
1356
+ }
1357
+ }
1358
+ return $false
1359
+ }
1360
+
1361
+ # Run CLI if invoked directly (not dot-sourced)
1362
+ if ($MyInvocation.InvocationName -ne ".") {
1363
+ $script:VK_CLI_RAW_LINE = $MyInvocation.Line
1364
+ Invoke-CLI -Arguments $args
1365
+ }