holo-codex 0.1.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 (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,1515 @@
1
+ import { AgentLoopError } from "./errors.js";
2
+ import { PR_LOOP_STATES } from "./loop-shapes.js";
3
+ import { resolveProfile, workflowStages } from "./profiles.js";
4
+ import type { MergeReadiness } from "./autonomy-policy.js";
5
+ import { redactSecrets } from "./redaction.js";
6
+ import { selectDefaultDeliveryRun, type DeliveryWorkItem } from "./delivery-work-item.js";
7
+ import type { HookCaptureReport } from "./hook-capture.js";
8
+ import type { AgentLoopState } from "./state-types.js";
9
+ import type {
10
+ AgentLoopArtifactRecord,
11
+ AgentLoopCiCheck,
12
+ AgentLoopConfig,
13
+ AgentLoopDecision,
14
+ AgentLoopEvent,
15
+ AgentLoopGate,
16
+ AgentLoopPrLink,
17
+ AgentLoopReviewComment,
18
+ AgentLoopRun,
19
+ AgentLoopRunCheck,
20
+ AgentLoopStorage,
21
+ WorkerRun
22
+ } from "./types.js";
23
+
24
+ export const WORKFLOW_STAGE_IDS = [
25
+ "work_item",
26
+ "plan",
27
+ "build",
28
+ "verify",
29
+ "pr",
30
+ "review",
31
+ "merge_readiness",
32
+ "cleanup"
33
+ ] as const;
34
+
35
+ export type WorkflowStageId = (typeof WORKFLOW_STAGE_IDS)[number];
36
+ export type WorkflowStageStatus = "pending" | "active" | "blocked" | "done" | "skipped" | "manual" | "failed";
37
+ export type WorkflowStageSource = "run_state" | "workflow_evidence" | "gate" | "historical";
38
+ export type WorkflowReviewReviewer = "claude_acp" | "agy_gemini" | "internal_tester" | "internal_reviewer" | "github" | "human" | "custom";
39
+ export type WorkflowReviewRequirement = "required" | "optional" | "not_required" | "unknown";
40
+ export type WorkflowReviewProgress = "requested" | "started" | "in_progress" | "incomplete" | "complete" | "skipped" | "unknown";
41
+ export type WorkflowReviewResult = "pass" | "block" | "warn" | "unknown";
42
+ export type WorkflowReviewSeveritySummary = "none" | "p3_only" | "p2_or_higher" | "unknown";
43
+ export type WorkflowActor =
44
+ | "codex"
45
+ | "worker"
46
+ | "tester"
47
+ | "reviewer"
48
+ | "claude_acp"
49
+ | "agy_gemini"
50
+ | "github"
51
+ | "github_ci"
52
+ | "gitnexus"
53
+ | "browser"
54
+ | "human";
55
+
56
+ export interface WorkflowActorChip {
57
+ actor: WorkflowActor;
58
+ label: string;
59
+ status: WorkflowStageStatus;
60
+ model?: string;
61
+ sessionId?: string;
62
+ }
63
+
64
+ export interface WorkflowEvidenceCounts {
65
+ events: number;
66
+ artifacts: number;
67
+ gates: number;
68
+ prComments: number;
69
+ gitnexus: number;
70
+ browser: number;
71
+ ci: number;
72
+ reports: number;
73
+ }
74
+
75
+ export interface WorkflowDrillDownTarget {
76
+ page:
77
+ | "Event Ledger"
78
+ | "Gate Center"
79
+ | "Worker Runs"
80
+ | "Artifact Diff Viewer"
81
+ | "PR Inbox"
82
+ | "Scope Guard"
83
+ | "Recovery Center";
84
+ }
85
+
86
+ export interface WorkflowEvidenceRef {
87
+ id: string;
88
+ kind: "event" | "artifact" | "gate" | "pr_comment" | "github_check" | "gitnexus" | "browser" | "report";
89
+ label: string;
90
+ summary: string;
91
+ interaction: "popover" | "drill_down_link";
92
+ drillDownTarget?: WorkflowDrillDownTarget;
93
+ createdAt?: string;
94
+ source?: string;
95
+ }
96
+
97
+ export interface WorkflowRequirement {
98
+ id: string;
99
+ label: string;
100
+ status: "pending" | "satisfied" | "blocked" | "skipped";
101
+ evidenceRefIds: string[];
102
+ skippedReason?: string;
103
+ blockedBy?: string;
104
+ }
105
+
106
+ export interface WorkflowBlocker {
107
+ id: string;
108
+ severity: "P0" | "P1" | "P2" | "P3" | "policy" | "ci" | "review" | "manual";
109
+ title: string;
110
+ reason: string;
111
+ owner: string;
112
+ nextAction: string;
113
+ blockedBy?: string;
114
+ evidenceRefIds: string[];
115
+ }
116
+
117
+ export interface WorkflowStageAction {
118
+ label: string;
119
+ command?: string;
120
+ safeToRunFromDashboard: boolean;
121
+ requiresConfirmation: boolean;
122
+ }
123
+
124
+ export interface WorkflowBoardSubstage {
125
+ id: string;
126
+ label: string;
127
+ status: WorkflowStageStatus;
128
+ evidenceCounts: WorkflowEvidenceCounts;
129
+ latestEvidence: WorkflowEvidenceRef[];
130
+ requiredEvidence: WorkflowRequirement[];
131
+ }
132
+
133
+ export interface WorkflowBoardStage {
134
+ id: WorkflowStageId;
135
+ label: string;
136
+ status: WorkflowStageStatus;
137
+ actorChips: WorkflowActorChip[];
138
+ evidenceCounts: WorkflowEvidenceCounts;
139
+ substages: WorkflowBoardSubstage[];
140
+ latestAction?: WorkflowStageAction;
141
+ blockers: WorkflowBlocker[];
142
+ nextAction?: string;
143
+ }
144
+
145
+ export interface WorkflowBoardWorkItem {
146
+ issueNumber?: number | undefined;
147
+ issueTitle?: string | undefined;
148
+ issueUrl?: string | undefined;
149
+ runId?: string | undefined;
150
+ branch?: string | undefined;
151
+ currentState?: string | undefined;
152
+ status?: string | undefined;
153
+ loopShape: string;
154
+ workflowProfile?: string | undefined;
155
+ prUrl?: string | undefined;
156
+ prNumber?: number | undefined;
157
+ lastUpdate?: string | undefined;
158
+ activeGate?: string | undefined;
159
+ readOnly: boolean;
160
+ }
161
+
162
+ export interface WorkflowReviewReportRow {
163
+ id: string;
164
+ agent: string;
165
+ model?: string | undefined;
166
+ status: "pass" | "block" | "warn" | "pending" | "skipped" | "unknown";
167
+ prComment: "posted" | "missing" | "not_required" | "unknown";
168
+ severitySummary: string;
169
+ requirement?: WorkflowReviewRequirement | undefined;
170
+ progress?: WorkflowReviewProgress | undefined;
171
+ result?: WorkflowReviewResult | undefined;
172
+ commentUrl?: string | undefined;
173
+ commentId?: string | undefined;
174
+ sessionId?: string | undefined;
175
+ conversationId?: string | undefined;
176
+ reason?: string | undefined;
177
+ nextAction?: string | undefined;
178
+ followUp?: string | undefined;
179
+ evidenceRefIds: string[];
180
+ }
181
+
182
+ export interface WorkflowCheckRow {
183
+ id: string;
184
+ label: string;
185
+ status: "passed" | "failed" | "pending" | "blocked" | "skipped" | "unknown";
186
+ evidence: string;
187
+ owner: string;
188
+ blockedBy?: string | undefined;
189
+ }
190
+
191
+ export interface WorkflowBoard {
192
+ runId?: string | undefined;
193
+ mode: "empty" | "active" | "historical" | "unsupported" | "unknown_state";
194
+ activeStageId?: WorkflowStageId | undefined;
195
+ selectedStageId: WorkflowStageId;
196
+ stageSource: WorkflowStageSource;
197
+ stageSourceEvent?: { id: string; status: WorkflowStageStatus; createdAt: string } | undefined;
198
+ hookCapture?: HookCaptureReport | undefined;
199
+ workItem: WorkflowBoardWorkItem;
200
+ stages: WorkflowBoardStage[];
201
+ evidenceRefs: WorkflowEvidenceRef[];
202
+ reviewReports: WorkflowReviewReportRow[];
203
+ verificationChecks: WorkflowCheckRow[];
204
+ mergeReadinessChecks: WorkflowCheckRow[];
205
+ cleanupChecks: WorkflowCheckRow[];
206
+ appendEvidenceEnabled: boolean;
207
+ message?: string | undefined;
208
+ }
209
+
210
+ export interface AppendWorkflowEvidenceInput {
211
+ runId?: string | undefined;
212
+ stageId?: string | undefined;
213
+ substageId?: string | undefined;
214
+ summary?: string | undefined;
215
+ evidenceRefIds?: unknown;
216
+ artifactIds?: unknown;
217
+ actor?: string | undefined;
218
+ status?: string | undefined;
219
+ source?: string | undefined;
220
+ review?: unknown;
221
+ }
222
+
223
+ interface WorkflowStageSignal {
224
+ stageId: WorkflowStageId;
225
+ status: WorkflowStageStatus;
226
+ event: AgentLoopEvent;
227
+ }
228
+
229
+ export interface AppendWorkflowEvidenceResult {
230
+ event: AgentLoopEvent;
231
+ evidence: WorkflowEvidenceRef;
232
+ }
233
+
234
+ export interface WorkflowReviewEvidence {
235
+ reviewer: WorkflowReviewReviewer;
236
+ requirement: WorkflowReviewRequirement;
237
+ progress: WorkflowReviewProgress;
238
+ result: WorkflowReviewResult;
239
+ severitySummary: WorkflowReviewSeveritySummary;
240
+ model?: string | undefined;
241
+ sessionId?: string | undefined;
242
+ conversationId?: string | undefined;
243
+ commentUrl?: string | undefined;
244
+ commentId?: string | undefined;
245
+ reason?: string | undefined;
246
+ }
247
+
248
+ export const WORKFLOW_STAGE_DEFINITIONS: Array<{
249
+ id: WorkflowStageId;
250
+ label: string;
251
+ substages: Array<{ id: string; label: string }>;
252
+ nextAction: string;
253
+ }> = [
254
+ {
255
+ id: "work_item",
256
+ label: "Work Item",
257
+ nextAction: "Write or confirm the plan.",
258
+ substages: [
259
+ { id: "issue_selected", label: "Issue selected" },
260
+ { id: "scope_confirmed", label: "Scope confirmed" },
261
+ { id: "handoff_checked", label: "Handoff checked" },
262
+ { id: "non_goals_recorded", label: "Non-goals recorded" }
263
+ ]
264
+ },
265
+ {
266
+ id: "plan",
267
+ label: "Plan",
268
+ nextAction: "Create branch and implement.",
269
+ substages: [
270
+ { id: "impact_checked", label: "Impact checked" },
271
+ { id: "plan_written", label: "Plan written" },
272
+ { id: "test_plan_defined", label: "Test plan defined" },
273
+ { id: "review_rules_confirmed", label: "Review rules confirmed" }
274
+ ]
275
+ },
276
+ {
277
+ id: "build",
278
+ label: "Build",
279
+ nextAction: "Run verification.",
280
+ substages: [
281
+ { id: "branch_created", label: "Branch created" },
282
+ { id: "implementation_active", label: "Implementation active" },
283
+ { id: "files_changed", label: "Files changed" },
284
+ { id: "local_smoke", label: "Local smoke" }
285
+ ]
286
+ },
287
+ {
288
+ id: "verify",
289
+ label: "Verify",
290
+ nextAction: "Publish the PR.",
291
+ substages: [
292
+ { id: "lint", label: "Lint" },
293
+ { id: "focused_tests", label: "Focused tests" },
294
+ { id: "full_tests", label: "Full tests" },
295
+ { id: "gitnexus_detect", label: "GitNexus detect" },
296
+ { id: "browser_validation", label: "Browser validation" },
297
+ { id: "internal_tester", label: "Internal tester" },
298
+ { id: "internal_reviewer", label: "Internal reviewer" }
299
+ ]
300
+ },
301
+ {
302
+ id: "pr",
303
+ label: "PR",
304
+ nextAction: "Run post-PR reviews.",
305
+ substages: [
306
+ { id: "commit_created", label: "Commit created" },
307
+ { id: "branch_pushed", label: "Branch pushed" },
308
+ { id: "pr_opened", label: "PR opened" },
309
+ { id: "pr_body_completed", label: "PR body completed" },
310
+ { id: "delivery_comment_posted", label: "Delivery comment posted" }
311
+ ]
312
+ },
313
+ {
314
+ id: "review",
315
+ label: "Review",
316
+ nextAction: "Wait for CI and merge readiness.",
317
+ substages: [
318
+ { id: "claude_acp_review", label: "Claude ACP review" },
319
+ { id: "agy_gemini_review", label: "AGY/Gemini review" },
320
+ { id: "github_comments_inspected", label: "GitHub comments inspected" },
321
+ { id: "findings_classified", label: "Findings classified" },
322
+ { id: "reports_posted", label: "Reports posted to PR" }
323
+ ]
324
+ },
325
+ {
326
+ id: "merge_readiness",
327
+ label: "Merge Readiness",
328
+ nextAction: "Merge PR, or fix the blocking condition in this PR.",
329
+ substages: [
330
+ { id: "ci_checks", label: "CI checks" },
331
+ { id: "review_approval", label: "Review approval" },
332
+ { id: "findings_gate", label: "Findings gate" },
333
+ { id: "scope_guard", label: "Scope guard" },
334
+ { id: "merge_policy", label: "Merge policy" }
335
+ ]
336
+ },
337
+ {
338
+ id: "cleanup",
339
+ label: "Cleanup",
340
+ nextAction: "Move to the next issue.",
341
+ substages: [
342
+ { id: "pr_merged", label: "PR merged" },
343
+ { id: "switched_main", label: "Switched to main" },
344
+ { id: "pulled_latest", label: "Pulled latest" },
345
+ { id: "gitnexus_reindexed", label: "GitNexus index rebuilt" },
346
+ { id: "worktree_clean", label: "Worktree clean" },
347
+ { id: "next_issue_selected", label: "Next issue selected" }
348
+ ]
349
+ }
350
+ ];
351
+
352
+ const STAGE_BY_ID = new Map(WORKFLOW_STAGE_DEFINITIONS.map((stage) => [stage.id, stage]));
353
+ const PR_STATES = new Set<string>(PR_LOOP_STATES);
354
+ const WORKFLOW_EVIDENCE_KIND = "workflow_stage_evidence";
355
+ const MAX_SUMMARY_LENGTH = 280;
356
+ const REVIEW_REVIEWERS: WorkflowReviewReviewer[] = ["claude_acp", "agy_gemini", "internal_tester", "internal_reviewer", "github", "human", "custom"];
357
+ const REVIEW_REQUIREMENTS: WorkflowReviewRequirement[] = ["required", "optional", "not_required", "unknown"];
358
+ const REVIEW_PROGRESS: WorkflowReviewProgress[] = ["requested", "started", "in_progress", "incomplete", "complete", "skipped", "unknown"];
359
+ const REVIEW_RESULTS: WorkflowReviewResult[] = ["pass", "block", "warn", "unknown"];
360
+ const REVIEW_SEVERITIES: WorkflowReviewSeveritySummary[] = ["none", "p3_only", "p2_or_higher", "unknown"];
361
+
362
+ export interface WorkflowBoardInput {
363
+ config: AgentLoopConfig;
364
+ run?: AgentLoopRun | undefined;
365
+ currentRun?: AgentLoopRun | undefined;
366
+ gates: AgentLoopGate[];
367
+ events: AgentLoopEvent[];
368
+ workers: WorkerRun[];
369
+ artifacts: AgentLoopArtifactRecord[];
370
+ pr?: AgentLoopPrLink | undefined;
371
+ ci: AgentLoopCiCheck[];
372
+ reviewComments: AgentLoopReviewComment[];
373
+ decisions: AgentLoopDecision[];
374
+ runChecks: AgentLoopRunCheck[];
375
+ mergeReadiness?: MergeReadiness | undefined;
376
+ deliveryWorkItem?: DeliveryWorkItem | undefined;
377
+ hookCapture?: HookCaptureReport | undefined;
378
+ }
379
+
380
+ export function selectWorkflowBoardRun(storage: AgentLoopStorage, runId?: string): AgentLoopRun | undefined {
381
+ const runs = storage.listRuns(200);
382
+ if (runId) {
383
+ return runs.find((run) => run.id === runId);
384
+ }
385
+ return selectDefaultDeliveryRun(storage);
386
+ }
387
+
388
+ export function deriveWorkflowBoard(input: WorkflowBoardInput): WorkflowBoard {
389
+ const run = input.run;
390
+ const loopShape = input.config.loopShape;
391
+ if (!run) {
392
+ return emptyBoard(loopShape);
393
+ }
394
+ const readOnly = !(run.status === "RUNNING" || run.status === "BLOCKED");
395
+ const deliveryWorkItem = input.deliveryWorkItem;
396
+ const workItem: WorkflowBoardWorkItem = {
397
+ ...(deliveryWorkItem ? {
398
+ issueNumber: deliveryWorkItem.issue,
399
+ issueTitle: deliveryWorkItem.title,
400
+ issueUrl: deliveryWorkItem.url
401
+ } : {}),
402
+ runId: run.id,
403
+ branch: run.branch,
404
+ currentState: run.currentState,
405
+ status: run.status,
406
+ loopShape,
407
+ workflowProfile: input.config.workflowProfile,
408
+ prUrl: input.pr?.url,
409
+ prNumber: input.pr?.prNumber,
410
+ lastUpdate: run.updatedAt,
411
+ activeGate: input.gates.find((gate) => gate.status === "open")?.kind,
412
+ readOnly
413
+ };
414
+ if (loopShape !== "pr-loop") {
415
+ return unsupportedBoard(workItem, "PR O observes only $pr-delivery-loop / pr-loop runs.");
416
+ }
417
+ if (run.currentState && !PR_STATES.has(run.currentState)) {
418
+ return unknownStateBoard(workItem, `Unknown PR loop state: ${run.currentState}`);
419
+ }
420
+
421
+ const appendedRefs = appendedEvidenceRefs(input.events);
422
+ const evidenceRefs = [
423
+ ...appendedRefs,
424
+ ...gateEvidenceRefs(input.gates),
425
+ ...eventEvidenceRefs(input.events),
426
+ ...artifactEvidenceRefs(input.artifacts),
427
+ ...ciEvidenceRefs(input.ci),
428
+ ...reviewEvidenceRefs(input.reviewComments),
429
+ ...workerEvidenceRefs(input.workers)
430
+ ];
431
+ const activeGate = input.gates.find((gate) => gate.status === "open");
432
+ const effectiveState = effectivePrState(run.currentState, input.events);
433
+ const stateStage = boardStageForState(effectiveState, input);
434
+ const stageSignal = workflowStageSignal(input.events);
435
+ const signalOverridesState = stageSignal ? isCurrentStageSignalStatus(stageSignal.status) : false;
436
+ const stageFromState = signalOverridesState && stageSignal
437
+ ? stageSignal.stageId
438
+ : advanceStageWithEvidence(
439
+ stateStage,
440
+ stageSignal?.stageId,
441
+ effectiveState
442
+ );
443
+ const activeStageId = activeGate ? stageForGate(activeGate, stageFromState) : stageFromState;
444
+ const stageSignalApplies = Boolean(stageSignal && !activeGate && (
445
+ signalOverridesState || (stageSignal.stageId === activeStageId && activeStageId !== stateStage)
446
+ ));
447
+ const stageSource: WorkflowStageSource = activeGate
448
+ ? "gate"
449
+ : readOnly
450
+ ? "historical"
451
+ : stageSignalApplies
452
+ ? "workflow_evidence"
453
+ : activeStageId === stateStage ? "run_state" : "workflow_evidence";
454
+ const statusOverride: Partial<Record<WorkflowStageId, WorkflowStageStatus>> = {};
455
+ if (activeGate) {
456
+ statusOverride[activeStageId] = "blocked";
457
+ } else if (stageSignalApplies && stageSignal) {
458
+ statusOverride[stageSignal.stageId] = stageSignal.status;
459
+ } else if (readOnly) {
460
+ statusOverride[activeStageId] = "pending";
461
+ }
462
+ const profile = resolveProfile(input.config, isAgentLoopState(effectiveState) ? effectiveState : undefined);
463
+ const stageMetadata = workflowStages(input.config);
464
+ const stages = WORKFLOW_STAGE_DEFINITIONS.map((definition) =>
465
+ buildStage({
466
+ definition,
467
+ activeStageId,
468
+ statusOverride,
469
+ evidenceRefs,
470
+ input,
471
+ profileRoleMapping: profile.roleMapping,
472
+ stageMetadata
473
+ })
474
+ );
475
+ const blockers = activeGate ? [gateBlocker(activeGate, activeStageId)] : [];
476
+ const blockedStage = stages.find((stage) => stage.id === activeStageId);
477
+ if (blockedStage && blockers.length > 0) {
478
+ blockedStage.blockers = blockers;
479
+ }
480
+
481
+ return {
482
+ runId: run.id,
483
+ mode: readOnly ? "historical" : "active",
484
+ activeStageId,
485
+ selectedStageId: activeStageId,
486
+ stageSource,
487
+ ...(stageSignalApplies && stageSignal ? { stageSourceEvent: { id: stageSignal.event.id, status: stageSignal.status, createdAt: stageSignal.event.createdAt } } : {}),
488
+ ...(input.hookCapture ? { hookCapture: workflowBoardHookCapture(input.hookCapture) } : {}),
489
+ workItem,
490
+ stages,
491
+ evidenceRefs,
492
+ reviewReports: reviewRows(input, appendedRefs),
493
+ verificationChecks: verificationRows(input),
494
+ mergeReadinessChecks: mergeReadinessRows(input),
495
+ cleanupChecks: cleanupRows(input),
496
+ appendEvidenceEnabled: !readOnly,
497
+ ...(readOnly ? { message: "Historical run; workflow board is read-only." } : {})
498
+ };
499
+ }
500
+
501
+ export function appendWorkflowEvidence(storage: AgentLoopStorage, input: AppendWorkflowEvidenceInput): AppendWorkflowEvidenceResult {
502
+ const currentRun = input.runId
503
+ ? storage.listRuns(200).find((run) => run.id === input.runId)
504
+ : storage.getCurrentRun();
505
+ if (!currentRun) {
506
+ throw new AgentLoopError("storage_error", "No run is available for workflow evidence.");
507
+ }
508
+ if (currentRun.status !== "RUNNING" && currentRun.status !== "BLOCKED") {
509
+ throw new AgentLoopError("policy_violation", "Workflow evidence can only be appended to a running or blocked run.", {
510
+ details: { runId: currentRun.id, status: currentRun.status }
511
+ });
512
+ }
513
+ const normalized = normalizeWorkflowEvidenceInput(input);
514
+ const event = storage.appendEvent({
515
+ runId: currentRun.id,
516
+ kind: WORKFLOW_EVIDENCE_KIND,
517
+ message: normalized.summary,
518
+ payload: {
519
+ stageId: normalized.stageId,
520
+ ...(normalized.substageId ? { substageId: normalized.substageId } : {}),
521
+ evidenceRefIds: normalized.evidenceRefIds,
522
+ actor: normalized.actor,
523
+ status: normalized.status,
524
+ source: normalized.source,
525
+ ...(normalized.review ? { review: normalized.review } : {})
526
+ },
527
+ artifactIds: normalized.artifactIds
528
+ });
529
+ return {
530
+ event,
531
+ evidence: {
532
+ id: event.id,
533
+ kind: evidenceKindFromSource(normalized.source),
534
+ label: WORKFLOW_STAGE_DEFINITIONS.find((stage) => stage.id === normalized.stageId)?.label ?? normalized.stageId,
535
+ summary: normalized.summary,
536
+ interaction: "drill_down_link",
537
+ drillDownTarget: { page: "Event Ledger" },
538
+ createdAt: event.createdAt,
539
+ source: normalized.source
540
+ }
541
+ };
542
+ }
543
+
544
+ export function normalizeWorkflowEvidenceInput(input: AppendWorkflowEvidenceInput): {
545
+ stageId: WorkflowStageId;
546
+ substageId?: string;
547
+ summary: string;
548
+ evidenceRefIds: string[];
549
+ artifactIds: string[];
550
+ actor: WorkflowActor;
551
+ status: WorkflowStageStatus;
552
+ source: string;
553
+ review?: WorkflowReviewEvidence;
554
+ } {
555
+ const stageId = input.stageId;
556
+ if (!isWorkflowStageId(stageId)) {
557
+ throw new AgentLoopError("invalid_config", "workflow evidence stageId is invalid.");
558
+ }
559
+ const stage = STAGE_BY_ID.get(stageId);
560
+ const substageId = typeof input.substageId === "string" && input.substageId.trim().length > 0
561
+ ? input.substageId.trim()
562
+ : undefined;
563
+ if (substageId && !stage?.substages.some((substage) => substage.id === substageId)) {
564
+ throw new AgentLoopError("invalid_config", "workflow evidence substageId is invalid.");
565
+ }
566
+ const rawSummary = typeof input.summary === "string" ? input.summary.trim() : "";
567
+ if (rawSummary.length === 0) {
568
+ throw new AgentLoopError("invalid_config", "workflow evidence summary is required.");
569
+ }
570
+ if (rawSummary.length > MAX_SUMMARY_LENGTH) {
571
+ throw new AgentLoopError("invalid_config", `workflow evidence summary must be ${MAX_SUMMARY_LENGTH} characters or shorter.`);
572
+ }
573
+ const summary = redactSecrets(rawSummary);
574
+ const actor = isWorkflowActor(input.actor) ? input.actor : "codex";
575
+ const status = input.status === undefined ? "done" : workflowStageStatus(input.status);
576
+ const source = typeof input.source === "string" && input.source.trim().length > 0 ? input.source.trim() : "cli";
577
+ const review = normalizeReviewEvidence(input.review, stageId);
578
+ return {
579
+ stageId,
580
+ ...(substageId ? { substageId } : {}),
581
+ summary,
582
+ evidenceRefIds: stringArray(input.evidenceRefIds),
583
+ artifactIds: stringArray(input.artifactIds),
584
+ actor,
585
+ status,
586
+ source,
587
+ ...(review ? { review } : {})
588
+ };
589
+ }
590
+
591
+ function workflowStageStatus(value: unknown): WorkflowStageStatus {
592
+ if (isWorkflowStageStatus(value)) return value;
593
+ throw new AgentLoopError("invalid_config", "workflow evidence status is invalid.");
594
+ }
595
+
596
+ function workflowBoardHookCapture(report: HookCaptureReport): HookCaptureReport {
597
+ return {
598
+ status: report.status,
599
+ reason: report.reason
600
+ };
601
+ }
602
+
603
+ function normalizeReviewEvidence(value: unknown, stageId: WorkflowStageId): WorkflowReviewEvidence | undefined {
604
+ if (value === undefined) return undefined;
605
+ if (stageId !== "review") {
606
+ throw new AgentLoopError("invalid_config", "structured review evidence is only valid for the review stage.");
607
+ }
608
+ if (!isRecord(value)) {
609
+ throw new AgentLoopError("invalid_config", "structured review evidence must be an object.");
610
+ }
611
+ const reviewer = enumValue(value.reviewer, REVIEW_REVIEWERS, "reviewer");
612
+ const requirement = enumValue(value.requirement, REVIEW_REQUIREMENTS, "requirement");
613
+ const progress = enumValue(value.progress, REVIEW_PROGRESS, "progress");
614
+ const result = enumValue(value.result, REVIEW_RESULTS, "result");
615
+ const severitySummary = enumValue(value.severitySummary, REVIEW_SEVERITIES, "severitySummary");
616
+ const commentUrl = optionalRedactedString(value.commentUrl, "commentUrl");
617
+ const commentId = optionalRedactedString(value.commentId, "commentId");
618
+ if (commentUrl && !isGitHubIssueCommentUrl(commentUrl)) {
619
+ throw new AgentLoopError("invalid_config", "review evidence commentUrl must be a GitHub PR comment, review, or discussion URL.");
620
+ }
621
+ if (progress === "complete" && requirement !== "not_required" && !commentUrl) {
622
+ throw new AgentLoopError("invalid_config", "complete review evidence requires a valid --comment-url.");
623
+ }
624
+ if (progress === "skipped" && requirement !== "not_required") {
625
+ throw new AgentLoopError("invalid_config", "skipped review evidence must use requirement not_required.");
626
+ }
627
+ return {
628
+ reviewer,
629
+ requirement,
630
+ progress,
631
+ result,
632
+ severitySummary,
633
+ ...optionalReviewField("model", value.model),
634
+ ...optionalReviewField("sessionId", value.sessionId),
635
+ ...optionalReviewField("conversationId", value.conversationId),
636
+ ...(commentUrl ? { commentUrl } : {}),
637
+ ...(commentId ? { commentId } : {}),
638
+ ...optionalReviewField("reason", value.reason)
639
+ };
640
+ }
641
+
642
+ function enumValue<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
643
+ if (typeof value === "string" && allowed.includes(value as T)) return value as T;
644
+ throw new AgentLoopError("invalid_config", `review evidence ${field} is invalid.`);
645
+ }
646
+
647
+ function optionalReviewField<K extends keyof WorkflowReviewEvidence>(key: K, value: unknown): Pick<WorkflowReviewEvidence, K> | Record<string, never> {
648
+ const normalized = optionalRedactedString(value, String(key));
649
+ return normalized ? { [key]: normalized } as Pick<WorkflowReviewEvidence, K> : {};
650
+ }
651
+
652
+ function optionalRedactedString(value: unknown, field: string): string | undefined {
653
+ if (value === undefined || value === null || value === "") return undefined;
654
+ if (typeof value !== "string") {
655
+ throw new AgentLoopError("invalid_config", `review evidence ${field} must be a string.`);
656
+ }
657
+ const normalized = redactSecrets(value.trim());
658
+ if (normalized.length > MAX_SUMMARY_LENGTH) {
659
+ throw new AgentLoopError("invalid_config", `review evidence ${field} must be ${MAX_SUMMARY_LENGTH} characters or shorter.`);
660
+ }
661
+ return normalized.length > 0 ? normalized : undefined;
662
+ }
663
+
664
+ function isGitHubIssueCommentUrl(value: string): boolean {
665
+ return /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+(?:\/files)?(?:\?[^#\s]+)?#(?:issuecomment-\d+|pullrequestreview-\d+|discussion_r\d+)(?:\?[^#\s]+)?$/i.test(value);
666
+ }
667
+
668
+ function isRecord(value: unknown): value is Record<string, unknown> {
669
+ return typeof value === "object" && value !== null && !Array.isArray(value);
670
+ }
671
+
672
+ export function isWorkflowStageId(value: unknown): value is WorkflowStageId {
673
+ return typeof value === "string" && WORKFLOW_STAGE_IDS.includes(value as WorkflowStageId);
674
+ }
675
+
676
+ function emptyCounts(): WorkflowEvidenceCounts {
677
+ return { events: 0, artifacts: 0, gates: 0, prComments: 0, gitnexus: 0, browser: 0, ci: 0, reports: 0 };
678
+ }
679
+
680
+ function emptyBoard(loopShape: string): WorkflowBoard {
681
+ const stages = WORKFLOW_STAGE_DEFINITIONS.map((definition) => buildEmptyStage(definition));
682
+ return {
683
+ mode: "empty",
684
+ selectedStageId: "work_item",
685
+ stageSource: "historical",
686
+ workItem: { loopShape, readOnly: true },
687
+ stages,
688
+ evidenceRefs: [],
689
+ reviewReports: [],
690
+ verificationChecks: [],
691
+ mergeReadinessChecks: [],
692
+ cleanupChecks: [],
693
+ appendEvidenceEnabled: false,
694
+ message: "No active PR delivery run is selected."
695
+ };
696
+ }
697
+
698
+ function unsupportedBoard(workItem: WorkflowBoardWorkItem, message: string): WorkflowBoard {
699
+ return {
700
+ runId: workItem.runId,
701
+ mode: "unsupported",
702
+ selectedStageId: "work_item",
703
+ stageSource: "historical",
704
+ workItem: { ...workItem, readOnly: true },
705
+ stages: WORKFLOW_STAGE_DEFINITIONS.map((definition) => ({ ...buildEmptyStage(definition), status: "skipped" as const })),
706
+ evidenceRefs: [],
707
+ reviewReports: [],
708
+ verificationChecks: [],
709
+ mergeReadinessChecks: [],
710
+ cleanupChecks: [],
711
+ appendEvidenceEnabled: false,
712
+ message
713
+ };
714
+ }
715
+
716
+ function unknownStateBoard(workItem: WorkflowBoardWorkItem, message: string): WorkflowBoard {
717
+ return {
718
+ runId: workItem.runId,
719
+ mode: "unknown_state",
720
+ selectedStageId: "work_item",
721
+ stageSource: "historical",
722
+ workItem: { ...workItem, readOnly: true },
723
+ stages: WORKFLOW_STAGE_DEFINITIONS.map((definition) => buildEmptyStage(definition)),
724
+ evidenceRefs: [],
725
+ reviewReports: [],
726
+ verificationChecks: [],
727
+ mergeReadinessChecks: [],
728
+ cleanupChecks: [],
729
+ appendEvidenceEnabled: false,
730
+ message
731
+ };
732
+ }
733
+
734
+ function buildEmptyStage(definition: (typeof WORKFLOW_STAGE_DEFINITIONS)[number]): WorkflowBoardStage {
735
+ return {
736
+ id: definition.id,
737
+ label: definition.label,
738
+ status: "pending",
739
+ actorChips: actorChipsForStage(definition.id, "pending"),
740
+ evidenceCounts: emptyCounts(),
741
+ substages: definition.substages.map((substage) => ({
742
+ ...substage,
743
+ status: "pending",
744
+ evidenceCounts: emptyCounts(),
745
+ latestEvidence: [],
746
+ requiredEvidence: []
747
+ })),
748
+ blockers: [],
749
+ nextAction: definition.nextAction
750
+ };
751
+ }
752
+
753
+ function buildStage(input: {
754
+ definition: (typeof WORKFLOW_STAGE_DEFINITIONS)[number];
755
+ activeStageId: WorkflowStageId;
756
+ statusOverride: Partial<Record<WorkflowStageId, WorkflowStageStatus>>;
757
+ evidenceRefs: WorkflowEvidenceRef[];
758
+ input: WorkflowBoardInput;
759
+ profileRoleMapping: ReturnType<typeof resolveProfile>["roleMapping"];
760
+ stageMetadata: ReturnType<typeof workflowStages>;
761
+ }): WorkflowBoardStage {
762
+ const index = WORKFLOW_STAGE_IDS.indexOf(input.definition.id);
763
+ const activeIndex = WORKFLOW_STAGE_IDS.indexOf(input.activeStageId);
764
+ const baseStatus: WorkflowStageStatus = index < activeIndex ? "done" : index === activeIndex ? "active" : "pending";
765
+ const status = input.statusOverride[input.definition.id] ?? inferredStageStatus(input.definition.id, baseStatus, input.input);
766
+ const stageEvidence = evidenceForStage(input.definition.id, input.evidenceRefs);
767
+ const counts = evidenceCounts(stageEvidence);
768
+ return {
769
+ id: input.definition.id,
770
+ label: input.definition.label,
771
+ status,
772
+ actorChips: actorChipsForStage(input.definition.id, status, input.profileRoleMapping, input.stageMetadata),
773
+ evidenceCounts: counts,
774
+ substages: input.definition.substages.map((substage, substageIndex) => ({
775
+ ...substage,
776
+ status: substageIndex === 0 && status === "active" ? "active" : status === "done" ? "done" : "pending",
777
+ evidenceCounts: counts,
778
+ latestEvidence: stageEvidence.slice(0, 3),
779
+ requiredEvidence: []
780
+ })),
781
+ latestAction: { label: status === "blocked" ? "Resolve blocker" : input.definition.nextAction, safeToRunFromDashboard: false, requiresConfirmation: false },
782
+ blockers: [],
783
+ nextAction: input.definition.nextAction
784
+ };
785
+ }
786
+
787
+ function inferredStageStatus(stageId: WorkflowStageId, baseStatus: WorkflowStageStatus, input: WorkflowBoardInput): WorkflowStageStatus {
788
+ if (stageId === "verify" && input.ci.some((check) => check.conclusion === "failure" || check.conclusion === "timed_out")) {
789
+ return "failed";
790
+ }
791
+ if (stageId === "pr" && baseStatus === "active" && !input.pr) {
792
+ return "manual";
793
+ }
794
+ if (stageId === "merge_readiness" && input.mergeReadiness && !input.mergeReadiness.ready && baseStatus === "active") {
795
+ return "blocked";
796
+ }
797
+ return baseStatus;
798
+ }
799
+
800
+ function effectivePrState(state: string | undefined, events: AgentLoopEvent[]): string | undefined {
801
+ if (state !== "BLOCKED" && state !== "STOPPED") return state;
802
+ const historical = [...events].sort((a, b) => b.seq - a.seq);
803
+ for (const event of historical) {
804
+ const candidates = [event.stateBefore, event.stateAfter];
805
+ for (const candidate of candidates) {
806
+ if (candidate && PR_STATES.has(candidate) && candidate !== "BLOCKED" && candidate !== "STOPPED") {
807
+ return candidate;
808
+ }
809
+ }
810
+ }
811
+ return undefined;
812
+ }
813
+
814
+ function advanceStageWithEvidence(
815
+ stateStage: WorkflowStageId,
816
+ evidenceStage: WorkflowStageId | undefined,
817
+ state: string | undefined
818
+ ): WorkflowStageId {
819
+ if (!evidenceStage || state === "SYNC_MAIN") return stateStage;
820
+ return stageIndex(evidenceStage) > stageIndex(stateStage) ? evidenceStage : stateStage;
821
+ }
822
+
823
+ function workflowStageSignal(events: AgentLoopEvent[]): WorkflowStageSignal | undefined {
824
+ let latestDone: WorkflowStageSignal | undefined;
825
+ for (const event of [...events]
826
+ .filter((event) => event.kind === WORKFLOW_EVIDENCE_KIND)
827
+ .sort((left, right) => right.seq - left.seq)) {
828
+ const status = payloadString(event, "status") ?? "done";
829
+ const stageId = payloadStage(event);
830
+ if (status === "done") {
831
+ const doneSignal: WorkflowStageSignal = { event, stageId, status };
832
+ if (!latestDone || stageIndex(stageId) > stageIndex(latestDone.stageId)) {
833
+ latestDone = doneSignal;
834
+ }
835
+ continue;
836
+ }
837
+ if (!isCurrentStageSignalStatus(status)) continue;
838
+ if (latestDone && stageIndex(latestDone.stageId) >= stageIndex(stageId)) {
839
+ return latestDone;
840
+ }
841
+ return { event, stageId, status };
842
+ }
843
+ return latestDone;
844
+ }
845
+
846
+ function isCurrentStageSignalStatus(value: string | undefined): value is WorkflowStageStatus {
847
+ return value === "active" || value === "manual" || value === "blocked" || value === "failed";
848
+ }
849
+
850
+ function stageIndex(stageId: WorkflowStageId): number {
851
+ return WORKFLOW_STAGE_IDS.indexOf(stageId);
852
+ }
853
+
854
+ function boardStageForState(state: string | undefined, input: WorkflowBoardInput): WorkflowStageId {
855
+ if (state === "WRITE_SPEC") return "plan";
856
+ if (state === "CREATE_BRANCH" || state === "IMPLEMENT") return "build";
857
+ if (state === "SELF_CHECK") return "verify";
858
+ if (state === "COMMIT_PUSH_PR" || state === "PUSH_FIX") return "pr";
859
+ if (state === "FIX_REVIEW") return "review";
860
+ if (state === "READY_TO_MERGE") return "merge_readiness";
861
+ if (state === "MERGE") return "cleanup";
862
+ if (state === "WAIT_REVIEW_OR_CI") return reviewOrMergeReadiness(input);
863
+ if (state === "SYNC_MAIN") return hasCleanupEvidence(input) ? "cleanup" : "work_item";
864
+ if (state === "DISCOVER_PROGRESS" || state === "SELECT_NEXT_PR" || state === undefined) {
865
+ return "work_item";
866
+ }
867
+ return "work_item";
868
+ }
869
+
870
+ function reviewOrMergeReadiness(input: WorkflowBoardInput): WorkflowStageId {
871
+ if (input.reviewComments.some((comment) => comment.actionable && !comment.isResolved && !comment.isOutdated)) {
872
+ return "review";
873
+ }
874
+ if (input.events.some((event) => event.kind === WORKFLOW_EVIDENCE_KIND && payloadStage(event) === "review")) {
875
+ return "review";
876
+ }
877
+ return "merge_readiness";
878
+ }
879
+
880
+ function hasCleanupEvidence(input: WorkflowBoardInput): boolean {
881
+ return input.events.some((event) => {
882
+ const text = `${event.kind} ${event.message}`.toLowerCase();
883
+ return payloadStage(event) === "cleanup" || text.includes("merged") || text.includes("gitnexus analyze") || text.includes("pulled latest");
884
+ });
885
+ }
886
+
887
+ function stageForGate(gate: AgentLoopGate, fallback: WorkflowStageId): WorkflowStageId {
888
+ const map: Partial<Record<string, WorkflowStageId>> = {
889
+ needs_repo_init: "work_item",
890
+ unsupported_remote: "work_item",
891
+ needs_secret_or_login: "work_item",
892
+ ambiguous_next_pr: "work_item",
893
+ generic_goal_needs_confirmation: "work_item",
894
+ policy_violation: "plan",
895
+ required_tool_unavailable: "plan",
896
+ gitnexus_check_failed: "verify",
897
+ dirty_unowned_worktree: "build",
898
+ worker_failed: "build",
899
+ worker_output_invalid: "build",
900
+ worker_timeout: "build",
901
+ worker_already_running: "build",
902
+ review_out_of_scope: "review",
903
+ generic_human_gate: "review",
904
+ generic_scope_change_requested: "review",
905
+ ci_required_checks_missing: "merge_readiness",
906
+ ci_pending_timeout: "merge_readiness",
907
+ merge_requires_confirmation: "merge_readiness",
908
+ github_transient_failure: fallback === "pr" ? "pr" : "merge_readiness",
909
+ github_resource_not_found: fallback === "pr" ? "pr" : "merge_readiness"
910
+ };
911
+ return map[gate.kind] ?? fallback;
912
+ }
913
+
914
+ function gateBlocker(gate: AgentLoopGate, stageId: WorkflowStageId): WorkflowBlocker {
915
+ return {
916
+ id: gate.id,
917
+ severity: gate.kind.startsWith("ci_") ? "ci" : gate.kind.startsWith("review_") ? "review" : "policy",
918
+ title: gate.kind,
919
+ reason: gate.message,
920
+ owner: stageId === "merge_readiness" ? "GitHub / Codex" : "Codex",
921
+ nextAction: "Inspect the gate and resolve the required condition.",
922
+ evidenceRefIds: [gate.id]
923
+ };
924
+ }
925
+
926
+ function actorChipsForStage(
927
+ stageId: WorkflowStageId,
928
+ status: WorkflowStageStatus,
929
+ profileRoleMapping: ReturnType<typeof resolveProfile>["roleMapping"] = [],
930
+ stageMetadata: ReturnType<typeof workflowStages> = []
931
+ ): WorkflowActorChip[] {
932
+ const active = status === "active" || status === "blocked" || status === "manual";
933
+ const metadataActors = stageMetadata
934
+ .filter((item) => stageForProfileState(item.state) === stageId && item.workerType)
935
+ .map((item): WorkflowActorChip => {
936
+ const role = profileRoleMapping.find((mapping) => mapping.state === item.state);
937
+ return {
938
+ actor: workflowActorForWorkerType(item.workerType),
939
+ label: role?.label ?? item.roleAlias ?? item.workerType ?? "Worker",
940
+ status: active ? status : status === "done" ? "done" : "pending",
941
+ ...(item.workerType ? { model: `${item.workerType}${item.sandbox ? ` / ${item.sandbox}` : ""}` } : {})
942
+ };
943
+ });
944
+ if (metadataActors.length > 0) return uniqueActorChips(metadataActors);
945
+ const stageActors: Record<WorkflowStageId, Array<{ actor: WorkflowActor; label: string }>> = {
946
+ work_item: [{ actor: "codex", label: "Codex" }, { actor: "human", label: "Human" }],
947
+ plan: [{ actor: "codex", label: "Codex" }, { actor: "gitnexus", label: "GitNexus" }],
948
+ build: [{ actor: "codex", label: "Codex" }, { actor: "worker", label: "Worker" }],
949
+ verify: [{ actor: "codex", label: "Codex" }, { actor: "tester", label: "Tester" }, { actor: "reviewer", label: "Reviewer" }],
950
+ pr: [{ actor: "codex", label: "Codex" }, { actor: "github", label: "GitHub" }],
951
+ review: [{ actor: "claude_acp", label: "Claude ACP" }, { actor: "agy_gemini", label: "AGY/Gemini" }, { actor: "github", label: "GitHub" }],
952
+ merge_readiness: [{ actor: "github_ci", label: "GitHub CI" }, { actor: "reviewer", label: "Reviewer" }, { actor: "human", label: "Human" }],
953
+ cleanup: [{ actor: "codex", label: "Codex" }, { actor: "gitnexus", label: "GitNexus" }]
954
+ };
955
+ return stageActors[stageId].map((item) => ({
956
+ ...item,
957
+ status: active ? status : status === "done" ? "done" : "pending"
958
+ }));
959
+ }
960
+
961
+ function stageForProfileState(state: string): WorkflowStageId {
962
+ return boardStageForState(state, {
963
+ config: {} as AgentLoopConfig,
964
+ gates: [],
965
+ events: [],
966
+ workers: [],
967
+ artifacts: [],
968
+ ci: [],
969
+ reviewComments: [],
970
+ decisions: [],
971
+ runChecks: []
972
+ });
973
+ }
974
+
975
+ function workflowActorForWorkerType(workerType: string | undefined): WorkflowActor {
976
+ if (workerType === "reviewer") return "reviewer";
977
+ if (workerType === "review-fix") return "reviewer";
978
+ return "worker";
979
+ }
980
+
981
+ function uniqueActorChips(chips: WorkflowActorChip[]): WorkflowActorChip[] {
982
+ const seen = new Set<string>();
983
+ return chips.filter((chip) => {
984
+ const key = `${chip.actor}:${chip.label}`;
985
+ if (seen.has(key)) return false;
986
+ seen.add(key);
987
+ return true;
988
+ });
989
+ }
990
+
991
+ function evidenceCounts(refs: WorkflowEvidenceRef[]): WorkflowEvidenceCounts {
992
+ return refs.reduce((counts, ref) => {
993
+ if (ref.kind === "event") counts.events += 1;
994
+ if (ref.kind === "artifact") counts.artifacts += 1;
995
+ if (ref.kind === "gate") counts.gates += 1;
996
+ if (ref.kind === "pr_comment") counts.prComments += 1;
997
+ if (ref.kind === "gitnexus") counts.gitnexus += 1;
998
+ if (ref.kind === "browser") counts.browser += 1;
999
+ if (ref.kind === "github_check") counts.ci += 1;
1000
+ if (ref.kind === "report") counts.reports += 1;
1001
+ return counts;
1002
+ }, emptyCounts());
1003
+ }
1004
+
1005
+ function evidenceForStage(stageId: WorkflowStageId, refs: WorkflowEvidenceRef[]): WorkflowEvidenceRef[] {
1006
+ return refs.filter((ref) => ref.source === stageId || ref.id.startsWith(`${stageId}:`));
1007
+ }
1008
+
1009
+ function appendedEvidenceRefs(events: AgentLoopEvent[]): WorkflowEvidenceRef[] {
1010
+ return events
1011
+ .filter((event) => event.kind === WORKFLOW_EVIDENCE_KIND)
1012
+ .map((event) => ({
1013
+ id: event.id,
1014
+ kind: evidenceKindFromSource(payloadString(event, "source") ?? "manual"),
1015
+ label: stageLabel(payloadStage(event)),
1016
+ summary: redactSecrets(event.message),
1017
+ interaction: "drill_down_link" as const,
1018
+ drillDownTarget: { page: "Event Ledger" as const },
1019
+ createdAt: event.createdAt,
1020
+ source: payloadStage(event)
1021
+ }));
1022
+ }
1023
+
1024
+ function gateEvidenceRefs(gates: AgentLoopGate[]): WorkflowEvidenceRef[] {
1025
+ return gates.map((gate) => ({
1026
+ id: gate.id,
1027
+ kind: "gate" as const,
1028
+ label: gate.kind,
1029
+ summary: gate.message,
1030
+ interaction: "drill_down_link" as const,
1031
+ drillDownTarget: { page: "Gate Center" as const },
1032
+ createdAt: gate.createdAt,
1033
+ source: stageForGate(gate, "work_item")
1034
+ }));
1035
+ }
1036
+
1037
+ function eventEvidenceRefs(events: AgentLoopEvent[]): WorkflowEvidenceRef[] {
1038
+ return events
1039
+ .filter((event) => event.kind !== WORKFLOW_EVIDENCE_KIND)
1040
+ .slice(0, 20)
1041
+ .map((event) => ({
1042
+ id: event.id,
1043
+ kind: event.kind.includes("gitnexus") ? "gitnexus" as const : event.kind.includes("browser") ? "browser" as const : "event" as const,
1044
+ label: event.kind,
1045
+ summary: redactSecrets(event.message),
1046
+ interaction: "drill_down_link" as const,
1047
+ drillDownTarget: { page: event.kind.includes("gitnexus") ? "Scope Guard" as const : "Event Ledger" as const },
1048
+ createdAt: event.createdAt,
1049
+ source: eventStageGuess(event)
1050
+ }));
1051
+ }
1052
+
1053
+ function artifactEvidenceRefs(artifacts: AgentLoopArtifactRecord[]): WorkflowEvidenceRef[] {
1054
+ return artifacts.map((artifact) => ({
1055
+ id: artifact.id,
1056
+ kind: "artifact" as const,
1057
+ label: artifact.name,
1058
+ summary: artifact.kind,
1059
+ interaction: "drill_down_link" as const,
1060
+ drillDownTarget: { page: "Artifact Diff Viewer" as const },
1061
+ createdAt: artifact.createdAt,
1062
+ source: artifact.kind.includes("spec") ? "plan" : "build"
1063
+ }));
1064
+ }
1065
+
1066
+ function ciEvidenceRefs(ci: AgentLoopCiCheck[]): WorkflowEvidenceRef[] {
1067
+ return ci.map((check) => ({
1068
+ id: check.id,
1069
+ kind: "github_check" as const,
1070
+ label: check.name,
1071
+ summary: check.conclusion ?? check.status,
1072
+ interaction: "drill_down_link" as const,
1073
+ drillDownTarget: { page: "PR Inbox" as const },
1074
+ createdAt: check.observedAt,
1075
+ source: "merge_readiness"
1076
+ }));
1077
+ }
1078
+
1079
+ function reviewEvidenceRefs(comments: AgentLoopReviewComment[]): WorkflowEvidenceRef[] {
1080
+ return comments.map((comment) => ({
1081
+ id: comment.id,
1082
+ kind: "pr_comment" as const,
1083
+ label: comment.author,
1084
+ summary: redactSecrets(comment.body.slice(0, 180)),
1085
+ interaction: "drill_down_link" as const,
1086
+ drillDownTarget: { page: "PR Inbox" as const },
1087
+ createdAt: comment.observedAt,
1088
+ source: "review"
1089
+ }));
1090
+ }
1091
+
1092
+ function workerEvidenceRefs(workers: WorkerRun[]): WorkflowEvidenceRef[] {
1093
+ return workers.map((worker) => ({
1094
+ id: worker.id,
1095
+ kind: "event" as const,
1096
+ label: worker.type,
1097
+ summary: worker.error ? redactSecrets(worker.error) : worker.status,
1098
+ interaction: "drill_down_link" as const,
1099
+ drillDownTarget: { page: "Worker Runs" as const },
1100
+ createdAt: worker.completedAt ?? worker.startedAt,
1101
+ source: worker.type === "reviewer" ? "verify" : "build"
1102
+ }));
1103
+ }
1104
+
1105
+ function reviewRows(input: WorkflowBoardInput, appended: WorkflowEvidenceRef[]): WorkflowReviewReportRow[] {
1106
+ const rows: WorkflowReviewReportRow[] = [];
1107
+ const structured = latestStructuredReviewEvidence(input.events);
1108
+ for (const { event, review } of structured.values()) {
1109
+ const ref = appended.find((item) => item.id === event.id);
1110
+ rows.push(reviewRowFromEvidence(event, review, ref));
1111
+ }
1112
+ rows.push(...input.reviewComments.map((comment) => ({
1113
+ id: comment.id,
1114
+ agent: comment.author,
1115
+ status: comment.actionable && !comment.isResolved ? "block" as const : "unknown" as const,
1116
+ prComment: "posted" as const,
1117
+ severitySummary: "no severity evidence",
1118
+ nextAction: comment.actionable && !comment.isResolved ? "Classify and fix or reply." : "No action from available evidence.",
1119
+ evidenceRefIds: [comment.id]
1120
+ })));
1121
+ // Legacy review events preserve coarse completion status, but never infer P0/P1/P2 severity from free text.
1122
+ for (const event of input.events.filter((item) => item.kind === WORKFLOW_EVIDENCE_KIND && payloadStage(item) === "review")) {
1123
+ if (parseStoredReviewEvidence(event)) continue;
1124
+ const ref = appended.find((item) => item.id === event.id);
1125
+ const refs = payloadStringArray(event, "evidenceRefIds");
1126
+ const actor = payloadString(event, "actor");
1127
+ const status = payloadString(event, "status");
1128
+ rows.push({
1129
+ id: event.id,
1130
+ agent: reportAgentLabel(actor, event.message),
1131
+ status: status === "skipped" ? "skipped" : status === "blocked" || status === "failed" ? "block" : status === "done" ? "pass" : "unknown",
1132
+ prComment: refs.some(isGitHubIssueCommentUrl) ? "posted" : "unknown",
1133
+ severitySummary: "no severity evidence",
1134
+ reason: status === "skipped" ? event.message : undefined,
1135
+ nextAction: "Inspect legacy review evidence; structured completion data is unavailable.",
1136
+ evidenceRefIds: ref ? [ref.id, ...refs] : [event.id, ...refs]
1137
+ });
1138
+ }
1139
+ if (!rows.some((row) => row.agent === "Claude ACP")) {
1140
+ rows.push({
1141
+ id: "review:claude-unknown",
1142
+ agent: "Claude ACP",
1143
+ status: "unknown",
1144
+ prComment: "unknown",
1145
+ severitySummary: "no requirement source",
1146
+ requirement: "unknown",
1147
+ progress: "unknown",
1148
+ result: "unknown",
1149
+ reason: "No required Claude review evidence source exists yet.",
1150
+ nextAction: "Attach Claude review evidence when this work requires it.",
1151
+ evidenceRefIds: []
1152
+ });
1153
+ }
1154
+ if (!rows.some((row) => row.agent === "AGY/Gemini")) {
1155
+ rows.push({
1156
+ id: "review:agy-unknown",
1157
+ agent: "AGY/Gemini",
1158
+ status: "unknown",
1159
+ prComment: "unknown",
1160
+ severitySummary: "no requirement source",
1161
+ requirement: "unknown",
1162
+ progress: "unknown",
1163
+ result: "unknown",
1164
+ reason: "No required AGY/Gemini review evidence source exists yet.",
1165
+ nextAction: "Attach AGY/Gemini review evidence when this work requires it.",
1166
+ evidenceRefIds: []
1167
+ });
1168
+ }
1169
+ return rows;
1170
+ }
1171
+
1172
+ function latestStructuredReviewEvidence(events: AgentLoopEvent[]): Map<WorkflowReviewReviewer, { event: AgentLoopEvent; review: WorkflowReviewEvidence }> {
1173
+ const latest = new Map<WorkflowReviewReviewer, { event: AgentLoopEvent; review: WorkflowReviewEvidence }>();
1174
+ for (const event of [...events].sort((left, right) => left.seq - right.seq)) {
1175
+ if (event.kind !== WORKFLOW_EVIDENCE_KIND || payloadStage(event) !== "review") continue;
1176
+ const review = parseStoredReviewEvidence(event);
1177
+ if (review) latest.set(review.reviewer, { event, review });
1178
+ }
1179
+ return latest;
1180
+ }
1181
+
1182
+ function reviewRowFromEvidence(event: AgentLoopEvent, review: WorkflowReviewEvidence, ref: WorkflowEvidenceRef | undefined): WorkflowReviewReportRow {
1183
+ const refs = payloadStringArray(event, "evidenceRefIds");
1184
+ const progress = effectiveReviewProgress(review);
1185
+ const prComment = review.requirement === "not_required" || progress === "skipped"
1186
+ ? "not_required"
1187
+ : review.commentUrl
1188
+ ? "posted"
1189
+ : review.requirement === "required"
1190
+ ? "missing"
1191
+ : "unknown";
1192
+ return {
1193
+ id: event.id,
1194
+ agent: reviewAgentLabel(review.reviewer),
1195
+ model: review.model,
1196
+ status: reviewStatus(review, progress),
1197
+ prComment,
1198
+ severitySummary: reviewSeverityLabel(review.severitySummary),
1199
+ requirement: review.requirement,
1200
+ progress,
1201
+ result: review.result,
1202
+ commentUrl: review.commentUrl,
1203
+ commentId: review.commentId,
1204
+ sessionId: review.sessionId,
1205
+ conversationId: review.conversationId,
1206
+ reason: review.reason,
1207
+ nextAction: reviewNextAction(review, progress, prComment),
1208
+ evidenceRefIds: [
1209
+ ...(ref ? [ref.id] : [event.id]),
1210
+ ...refs,
1211
+ ...(review.commentUrl ? [review.commentUrl] : [])
1212
+ ]
1213
+ };
1214
+ }
1215
+
1216
+ function effectiveReviewProgress(review: WorkflowReviewEvidence): WorkflowReviewProgress {
1217
+ if (review.requirement === "not_required" && review.progress === "skipped") return "skipped";
1218
+ if (review.requirement === "not_required" && review.progress === "complete") return "complete";
1219
+ if (review.progress === "complete" && !review.commentUrl) return "incomplete";
1220
+ if (review.requirement === "required" && review.progress === "unknown") return "incomplete";
1221
+ return review.progress;
1222
+ }
1223
+
1224
+ function reviewStatus(review: WorkflowReviewEvidence, progress: WorkflowReviewProgress): WorkflowReviewReportRow["status"] {
1225
+ if (progress === "skipped") return "skipped";
1226
+ if (review.result === "block") return "block";
1227
+ if (review.result === "warn") return "warn";
1228
+ if (review.result === "pass") return "pass";
1229
+ if (progress === "requested" || progress === "started" || progress === "in_progress" || progress === "incomplete") return "pending";
1230
+ return "unknown";
1231
+ }
1232
+
1233
+ function reviewNextAction(review: WorkflowReviewEvidence, progress: WorkflowReviewProgress, prComment: WorkflowReviewReportRow["prComment"]): string {
1234
+ if (review.result === "block" || review.severitySummary === "p2_or_higher") return "Fix or route blocking findings before merge.";
1235
+ if (progress === "incomplete") return "Attach the missing required report evidence.";
1236
+ if (progress === "requested" || progress === "started" || progress === "in_progress") return "Wait for the reviewer report and PR comment.";
1237
+ if (prComment === "missing") return "Post or link the PR review report comment.";
1238
+ if (progress === "skipped") return review.reason ?? "Reviewer explicitly skipped.";
1239
+ if (progress === "complete") return "Keep report linked in PR evidence.";
1240
+ return "Attach structured review evidence when available.";
1241
+ }
1242
+
1243
+ function reviewAgentLabel(reviewer: WorkflowReviewReviewer): string {
1244
+ const labels: Record<WorkflowReviewReviewer, string> = {
1245
+ claude_acp: "Claude ACP",
1246
+ agy_gemini: "AGY/Gemini",
1247
+ internal_tester: "Internal tester",
1248
+ internal_reviewer: "Internal reviewer",
1249
+ github: "GitHub",
1250
+ human: "Human",
1251
+ custom: "Custom reviewer"
1252
+ };
1253
+ return labels[reviewer];
1254
+ }
1255
+
1256
+ function reviewSeverityLabel(severity: WorkflowReviewSeveritySummary): string {
1257
+ const labels: Record<WorkflowReviewSeveritySummary, string> = {
1258
+ none: "none",
1259
+ p3_only: "P3 only",
1260
+ p2_or_higher: "P2 or higher",
1261
+ unknown: "no severity evidence"
1262
+ };
1263
+ return labels[severity];
1264
+ }
1265
+
1266
+ function verificationRows(input: WorkflowBoardInput): WorkflowCheckRow[] {
1267
+ const checks: WorkflowCheckRow[] = [
1268
+ { id: "lint", label: "Lint", status: "unknown", evidence: "no appended evidence", owner: "Codex" },
1269
+ { id: "focused_tests", label: "Focused tests", status: "unknown", evidence: "no appended evidence", owner: "Codex" },
1270
+ { id: "full_tests", label: "Full tests", status: "unknown", evidence: "no appended evidence", owner: "Codex" },
1271
+ { id: "gitnexus_detect", label: "GitNexus detect", status: "unknown", evidence: "no appended evidence", owner: "GitNexus" }
1272
+ ];
1273
+ for (const check of input.ci) {
1274
+ checks.push({
1275
+ id: `ci:${check.id}`,
1276
+ label: check.name,
1277
+ status: check.conclusion === "success" ? "passed" : check.conclusion ? "failed" : "pending",
1278
+ evidence: check.conclusion ?? check.status,
1279
+ owner: "GitHub CI"
1280
+ });
1281
+ }
1282
+ return checks;
1283
+ }
1284
+
1285
+ function mergeReadinessRows(input: WorkflowBoardInput): WorkflowCheckRow[] {
1286
+ const readiness = input.mergeReadiness;
1287
+ if (hasCleanupEvidence(input)) {
1288
+ return [
1289
+ { id: "merge_policy", label: "Merge policy", status: "passed", evidence: "cleanup evidence supersedes pre-merge blockers", owner: "Codex" },
1290
+ { id: "findings_gate", label: "No unresolved P0/P1/P2", status: "passed", evidence: "cleanup evidence recorded after merge", owner: "Reviewer" }
1291
+ ];
1292
+ }
1293
+ if (!readiness) {
1294
+ return [{ id: "merge_policy", label: "Merge policy", status: "unknown", evidence: "merge readiness not available", owner: "Codex" }];
1295
+ }
1296
+ const evidenceRows = readiness.evidence.map((item, index) => ({
1297
+ id: `merge:evidence:${index}`,
1298
+ label: item,
1299
+ status: "passed" as const,
1300
+ evidence: item,
1301
+ owner: "Codex"
1302
+ }));
1303
+ const missingRows = readiness.missingConditions.map((item, index) => ({
1304
+ id: `merge:missing:${index}`,
1305
+ label: item,
1306
+ status: "blocked" as const,
1307
+ evidence: item,
1308
+ owner: "Codex"
1309
+ }));
1310
+ const blockingReview = blockingReviewEvidence(input.events);
1311
+ const satisfiedReview = satisfiedReviewEvidence(input.events);
1312
+ const findingsGate = blockingReview
1313
+ ? { id: "findings_gate", label: "No unresolved P0/P1/P2", status: "blocked" as const, evidence: blockingReview.message, owner: reviewAgentLabel(blockingReview.review.reviewer) }
1314
+ : satisfiedReview
1315
+ ? { id: "findings_gate", label: "No unresolved P0/P1/P2", status: "passed" as const, evidence: satisfiedReview, owner: "Reviewer" }
1316
+ : { id: "findings_gate", label: "No unresolved P0/P1/P2", status: "unknown" as const, evidence: "no severity evidence", owner: "Reviewer" };
1317
+ return [
1318
+ ...evidenceRows,
1319
+ ...missingRows,
1320
+ findingsGate
1321
+ ];
1322
+ }
1323
+
1324
+ function blockingReviewEvidence(events: AgentLoopEvent[]): { message: string; review: WorkflowReviewEvidence } | undefined {
1325
+ for (const { event, review } of latestStructuredReviewEvidence(events).values()) {
1326
+ if (review && (review.result === "block" || review.severitySummary === "p2_or_higher")) {
1327
+ return { message: event.message, review };
1328
+ }
1329
+ }
1330
+ return undefined;
1331
+ }
1332
+
1333
+ function satisfiedReviewEvidence(events: AgentLoopEvent[]): string | undefined {
1334
+ const required = [...latestStructuredReviewEvidence(events).values()]
1335
+ .filter(({ review }) => review.requirement === "required");
1336
+ if (required.length === 0) return undefined;
1337
+ const allClear = required.every(({ review }) =>
1338
+ effectiveReviewProgress(review) === "complete" &&
1339
+ review.result === "pass" &&
1340
+ (review.severitySummary === "none" || review.severitySummary === "p3_only")
1341
+ );
1342
+ return allClear ? "all required structured reviews passed without P0/P1/P2 evidence" : undefined;
1343
+ }
1344
+
1345
+ function cleanupRows(input: WorkflowBoardInput): WorkflowCheckRow[] {
1346
+ const evidence = cleanupEvidenceBySubstage(input.events);
1347
+ return [
1348
+ cleanupCheck("pr_merged", "PR merged", "GitHub", evidence, input.pr?.state === "MERGED", input.pr?.state ?? "no PR link"),
1349
+ cleanupCheck("switched_main", "Switched to main", "Codex", evidence),
1350
+ cleanupCheck("pulled_latest", "Pulled latest", "Codex", evidence),
1351
+ cleanupCheck("gitnexus_reindexed", "GitNexus index rebuilt", "GitNexus", evidence),
1352
+ cleanupCheck("worktree_clean", "Worktree clean", "Codex", evidence, input.run?.worktreeClean === true, String(input.run?.worktreeClean ?? "unknown"))
1353
+ ];
1354
+ }
1355
+
1356
+ function cleanupCheck(
1357
+ id: string,
1358
+ label: string,
1359
+ owner: string,
1360
+ evidence: Map<string, AgentLoopEvent>,
1361
+ fallbackPassed = false,
1362
+ fallbackEvidence = "no appended evidence"
1363
+ ): WorkflowCheckRow {
1364
+ const event = evidence.get(id);
1365
+ if (event) {
1366
+ return { id, label, status: "passed", evidence: event.message, owner };
1367
+ }
1368
+ return { id, label, status: fallbackPassed ? "passed" : "pending", evidence: fallbackEvidence, owner };
1369
+ }
1370
+
1371
+ function cleanupEvidenceBySubstage(events: AgentLoopEvent[]): Map<string, AgentLoopEvent> {
1372
+ const bySubstage = new Map<string, AgentLoopEvent>();
1373
+ for (const event of [...events].sort((left, right) => right.seq - left.seq)) {
1374
+ if (event.kind !== WORKFLOW_EVIDENCE_KIND || payloadStage(event) !== "cleanup") continue;
1375
+ const substageId = payloadString(event, "substageId");
1376
+ if (substageId && !bySubstage.has(substageId)) {
1377
+ bySubstage.set(substageId, event);
1378
+ }
1379
+ }
1380
+ return bySubstage;
1381
+ }
1382
+
1383
+ function payloadStage(event: AgentLoopEvent): WorkflowStageId {
1384
+ const stageId = payloadString(event, "stageId");
1385
+ return isWorkflowStageId(stageId) ? stageId : eventStageGuess(event);
1386
+ }
1387
+
1388
+ function payloadString(event: AgentLoopEvent, key: string): string | undefined {
1389
+ const payload = event.payload;
1390
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
1391
+ return undefined;
1392
+ }
1393
+ const value = (payload as Record<string, unknown>)[key];
1394
+ return typeof value === "string" ? value : undefined;
1395
+ }
1396
+
1397
+ function payloadStringArray(event: AgentLoopEvent, key: string): string[] {
1398
+ const payload = event.payload;
1399
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
1400
+ return [];
1401
+ }
1402
+ const value = (payload as Record<string, unknown>)[key];
1403
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
1404
+ }
1405
+
1406
+ function parseStoredReviewEvidence(event: AgentLoopEvent): WorkflowReviewEvidence | undefined {
1407
+ if (!isRecord(event.payload)) return undefined;
1408
+ const review = event.payload.review;
1409
+ if (!isRecord(review)) return undefined;
1410
+ if (!isWorkflowReviewReviewer(review.reviewer)) return undefined;
1411
+ if (!isWorkflowReviewRequirement(review.requirement)) return undefined;
1412
+ if (!isWorkflowReviewProgress(review.progress)) return undefined;
1413
+ if (!isWorkflowReviewResult(review.result)) return undefined;
1414
+ if (!isWorkflowReviewSeverity(review.severitySummary)) return undefined;
1415
+ const commentUrl = typeof review.commentUrl === "string" ? review.commentUrl : undefined;
1416
+ return {
1417
+ reviewer: review.reviewer,
1418
+ requirement: review.requirement,
1419
+ progress: review.progress,
1420
+ result: review.result,
1421
+ severitySummary: review.severitySummary,
1422
+ ...optionalStoredReviewString("model", review.model),
1423
+ ...optionalStoredReviewString("sessionId", review.sessionId),
1424
+ ...optionalStoredReviewString("conversationId", review.conversationId),
1425
+ ...(commentUrl && isGitHubIssueCommentUrl(commentUrl) ? { commentUrl } : {}),
1426
+ ...optionalStoredReviewString("commentId", review.commentId),
1427
+ ...optionalStoredReviewString("reason", review.reason)
1428
+ };
1429
+ }
1430
+
1431
+ function optionalStoredReviewString<K extends keyof WorkflowReviewEvidence>(key: K, value: unknown): Pick<WorkflowReviewEvidence, K> | Record<string, never> {
1432
+ return typeof value === "string" && value.trim().length > 0 ? { [key]: value } as Pick<WorkflowReviewEvidence, K> : {};
1433
+ }
1434
+
1435
+ function isWorkflowReviewReviewer(value: unknown): value is WorkflowReviewReviewer {
1436
+ return typeof value === "string" && REVIEW_REVIEWERS.includes(value as WorkflowReviewReviewer);
1437
+ }
1438
+
1439
+ function isWorkflowReviewRequirement(value: unknown): value is WorkflowReviewRequirement {
1440
+ return typeof value === "string" && REVIEW_REQUIREMENTS.includes(value as WorkflowReviewRequirement);
1441
+ }
1442
+
1443
+ function isWorkflowReviewProgress(value: unknown): value is WorkflowReviewProgress {
1444
+ return typeof value === "string" && REVIEW_PROGRESS.includes(value as WorkflowReviewProgress);
1445
+ }
1446
+
1447
+ function isWorkflowReviewResult(value: unknown): value is WorkflowReviewResult {
1448
+ return typeof value === "string" && REVIEW_RESULTS.includes(value as WorkflowReviewResult);
1449
+ }
1450
+
1451
+ function isWorkflowReviewSeverity(value: unknown): value is WorkflowReviewSeveritySummary {
1452
+ return typeof value === "string" && REVIEW_SEVERITIES.includes(value as WorkflowReviewSeveritySummary);
1453
+ }
1454
+
1455
+ function eventStageGuess(event: AgentLoopEvent): WorkflowStageId {
1456
+ const text = `${event.kind} ${event.message}`.toLowerCase();
1457
+ if (text.includes("review")) return "review";
1458
+ if (text.includes("ci") || text.includes("merge readiness")) return "merge_readiness";
1459
+ if (text.includes("pr ") || text.includes("pull request")) return "pr";
1460
+ if (text.includes("test") || text.includes("lint") || text.includes("gitnexus")) return "verify";
1461
+ if (text.includes("branch") || text.includes("implement") || text.includes("worker")) return "build";
1462
+ if (text.includes("plan") || text.includes("spec")) return "plan";
1463
+ if (text.includes("merge") || text.includes("cleanup")) return "cleanup";
1464
+ return "work_item";
1465
+ }
1466
+
1467
+ function stageLabel(stageId: WorkflowStageId): string {
1468
+ return STAGE_BY_ID.get(stageId)?.label ?? stageId;
1469
+ }
1470
+
1471
+ function evidenceKindFromSource(source: string): WorkflowEvidenceRef["kind"] {
1472
+ const normalized = source.toLowerCase();
1473
+ if (normalized.includes("gitnexus")) return "gitnexus";
1474
+ if (normalized.includes("browser")) return "browser";
1475
+ if (normalized.includes("review") || normalized.includes("claude") || normalized.includes("agy") || normalized.includes("gemini")) return "report";
1476
+ if (normalized.includes("ci")) return "github_check";
1477
+ return "event";
1478
+ }
1479
+
1480
+ function reportAgentLabel(actor: string | undefined, summary: string): string {
1481
+ const lower = summary.toLowerCase();
1482
+ if (actor === "agy_gemini" || lower.includes("agy") || lower.includes("gemini")) return "AGY/Gemini";
1483
+ if (actor === "claude_acp" || lower.includes("claude")) return "Claude ACP";
1484
+ if (actor === "tester") return "Internal tester";
1485
+ if (actor === "reviewer") return "Internal reviewer";
1486
+ return "Review evidence";
1487
+ }
1488
+
1489
+ function stringArray(value: unknown): string[] {
1490
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
1491
+ }
1492
+
1493
+ function isWorkflowActor(value: unknown): value is WorkflowActor {
1494
+ return typeof value === "string" && [
1495
+ "codex",
1496
+ "worker",
1497
+ "tester",
1498
+ "reviewer",
1499
+ "claude_acp",
1500
+ "agy_gemini",
1501
+ "github",
1502
+ "github_ci",
1503
+ "gitnexus",
1504
+ "browser",
1505
+ "human"
1506
+ ].includes(value);
1507
+ }
1508
+
1509
+ function isWorkflowStageStatus(value: unknown): value is WorkflowStageStatus {
1510
+ return typeof value === "string" && ["pending", "active", "blocked", "done", "skipped", "manual", "failed"].includes(value);
1511
+ }
1512
+
1513
+ function isAgentLoopState(value: string | undefined): value is AgentLoopState {
1514
+ return typeof value === "string" && PR_STATES.has(value);
1515
+ }