@cat-factory/app 0.293.1 → 0.295.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 (39) hide show
  1. package/app/components/board/AddTaskModal.vue +80 -0
  2. package/app/components/board/RecurringPipelineModal.vue +34 -20
  3. package/app/components/bootstrap/BootstrapModal.logic.spec.ts +54 -0
  4. package/app/components/bootstrap/BootstrapModal.logic.ts +44 -0
  5. package/app/components/bootstrap/BootstrapModal.vue +155 -16
  6. package/app/components/bugFishing/BugFishingWindow.vue +524 -0
  7. package/app/components/focus/BlockFocusView.vue +2 -0
  8. package/app/components/github/RepoTreeBrowser.vue +89 -6
  9. package/app/components/layout/NotificationsInbox.vue +15 -0
  10. package/app/components/panels/ResultWindowDrafts.logic.spec.ts +25 -3
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  12. package/app/components/settings/WorkspaceSettingsPanel.vue +46 -1
  13. package/app/components/slack/SlackPanel.vue +1 -0
  14. package/app/composables/api/bugFishing.ts +52 -0
  15. package/app/composables/useApi.ts +2 -0
  16. package/app/composables/usePipelineErrorToast.ts +21 -0
  17. package/app/modular/result-views.ts +6 -0
  18. package/app/stores/agentRuns.spec.ts +1 -0
  19. package/app/stores/bugFishing.ts +143 -0
  20. package/app/stores/ui/resultViews.ts +14 -7
  21. package/app/stores/ui/runStepOpeners.ts +29 -1
  22. package/app/stores/workspaceSettings.ts +1 -0
  23. package/app/types/bootstrap.ts +1 -0
  24. package/app/types/execution.ts +9 -0
  25. package/app/utils/catalog.spec.ts +1 -0
  26. package/app/utils/catalog.ts +25 -0
  27. package/app/utils/repoPath.spec.ts +49 -0
  28. package/app/utils/repoPath.ts +28 -0
  29. package/i18n/locales/de.json +121 -10
  30. package/i18n/locales/en.json +120 -9
  31. package/i18n/locales/es.json +121 -10
  32. package/i18n/locales/fr.json +121 -10
  33. package/i18n/locales/he.json +121 -10
  34. package/i18n/locales/it.json +121 -10
  35. package/i18n/locales/ja.json +121 -10
  36. package/i18n/locales/pl.json +121 -10
  37. package/i18n/locales/tr.json +121 -10
  38. package/i18n/locales/uk.json +121 -10
  39. package/package.json +2 -2
@@ -68,6 +68,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
68
68
  // The PR reviewer surfaced findings to triage. Clicking the title opens the PR-review window
69
69
  // (see `reveal`); "act" just marks it read (findings are selected in that window, not here).
70
70
  pr_review_ready: { icon: 'i-lucide-clipboard-check', color: 'primary' },
71
+ // A bug-fishing expedition finished every angle and is waiting for its catch to be triaged.
72
+ // Clicking the title opens the expedition window (see `reveal`); "act" just marks it read
73
+ // (findings are marked in that window, and each mark spawns its own fix task, not here).
74
+ bug_fishing_triage: { icon: 'i-lucide-fish', color: 'primary' },
71
75
  // The initiative loop needs attention (a blocked task, or completion). Clicking the title
72
76
  // opens the initiative tracker window; "act" just marks it read.
73
77
  initiative: { icon: 'i-lucide-milestone', color: 'primary' },
@@ -112,6 +116,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
112
116
  fork_decision_pending: 'layout.notifications.action.fork_decision_pending',
113
117
  judge_review: 'layout.notifications.action.judge_review',
114
118
  pr_review_ready: 'layout.notifications.action.pr_review_ready',
119
+ bug_fishing_triage: 'layout.notifications.action.bug_fishing_triage',
115
120
  initiative: 'layout.notifications.action.initiative',
116
121
  platform_health: 'layout.notifications.action.platform_health',
117
122
  budget_paused: 'layout.notifications.action.budget_paused',
@@ -240,6 +245,7 @@ function reveal(n: Notification) {
240
245
  else if (n.type === 'fork_decision_pending') revealForkDecision(n)
241
246
  else if (n.type === 'judge_review') revealJudge(n)
242
247
  else if (n.type === 'pr_review_ready') revealPrReview(n)
248
+ else if (n.type === 'bug_fishing_triage') revealBugFishing(n)
243
249
  else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
244
250
  else ui.select(n.blockId)
245
251
  }
@@ -295,6 +301,15 @@ function revealPrReview(n: Notification) {
295
301
  else if (n.blockId) ui.select(n.blockId)
296
302
  }
297
303
 
304
+ /**
305
+ * Open the bug-fishing expedition window for a run whose angles have all settled. Falls back to
306
+ * focusing the block when the run is not loaded, exactly like its PR-review sibling.
307
+ */
308
+ function revealBugFishing(n: Notification) {
309
+ if (n.executionId && execution.getInstance(n.executionId)) ui.openBugFishing(n.executionId)
310
+ else if (n.blockId) ui.select(n.blockId)
311
+ }
312
+
298
313
  /**
299
314
  * Open the human-testing window for a parked `human-test` gate: find the run's parked
300
315
  * human-test step and open it through the universal step dispatch (its archetype declares
@@ -45,6 +45,10 @@ const WINDOWS: Record<string, { drafts: Disposition; why: string }> = {
45
45
  drafts: 'confirm',
46
46
  why: 'per-item replies + the redo comment; a reply resolves an item and the redo starts a pass',
47
47
  },
48
+ 'bugFishing/BugFishingWindow.vue': {
49
+ drafts: 'none',
50
+ why: 'every mark and dismiss commits on click; the fix-pipeline picker and the show-triaged toggle only shape what the NEXT click does',
51
+ },
48
52
  'clarity/ClarityReviewWindow.vue': {
49
53
  drafts: 'flush',
50
54
  why: 'per-finding answers, each recorded on its own',
@@ -112,7 +116,16 @@ const WINDOWS: Record<string, { drafts: Disposition; why: string }> = {
112
116
  * planning window's `v-model:answer="drafts[q.key]"` sat unnoticed in a 'none' row precisely because
113
117
  * it looked enough like the lightbox pair to pass a shape test.
114
118
  */
115
- const VIEW_STATE_BINDINGS = ['v-model:open="lightboxOpen"', 'v-model:index="lightboxIndex"']
119
+ const VIEW_STATE_BINDINGS = [
120
+ 'v-model:open="lightboxOpen"',
121
+ 'v-model:index="lightboxIndex"',
122
+ // The bug-fishing window's two controls. Neither holds unsubmitted work: marking and dismissing
123
+ // a finding each commit on click, so the picker only decides which pipeline the NEXT mark runs
124
+ // and the toggle only decides which rows are listed. Losing either on close loses a selection,
125
+ // not something the user wrote.
126
+ 'v-model="pipelineOverride"',
127
+ 'v-model="showTriaged"',
128
+ ]
116
129
 
117
130
  /** Every component that mounts the shell, keyed by its path relative to `app/components`. */
118
131
  function findConsumers(): Map<string, string> {
@@ -167,9 +180,18 @@ function stateBindings(source: string): string[] {
167
180
  return [...source.matchAll(pattern)].map((match) => match[0])
168
181
  }
169
182
 
170
- /** A native form control, which holds typed input whether or not it carries a `v-model`. */
183
+ /**
184
+ * A native form control, which holds typed input whether or not it carries a `v-model`.
185
+ *
186
+ * Checkboxes and radios are deliberately excluded: they hold a CHOICE, not typed input, so one
187
+ * left unsubmitted is a control the user can re-make in a click rather than something they wrote
188
+ * and would lose. The exclusion costs nothing, because this predicate is only the fallback for a
189
+ * control carrying no `v-model` at all — a toggle anybody reads is bound, and a binding is
190
+ * unknown to {@link VIEW_STATE_BINDINGS} until someone classifies it.
191
+ */
171
192
  function hasNativeControl(source: string): boolean {
172
- return /<(?:input|textarea|select)\b/.test(source)
193
+ const controls = source.match(/<(?:input|textarea|select)\b[^>]*>/g) ?? []
194
+ return controls.some((tag) => !/type="(?:checkbox|radio)"/.test(tag))
173
195
  }
174
196
 
175
197
  /** What makes this window suspect for a 'none' row, or `null` when nothing does. */
@@ -35,6 +35,10 @@ const WINDOWS: Record<string, { width: ResultWindowWidth; why: string }> = {
35
35
  width: 'full',
36
36
  why: 'options column + the choose/dismiss action rail',
37
37
  },
38
+ 'bugFishing/BugFishingWindow.vue': {
39
+ width: 'full',
40
+ why: 'the angle rail + the catch column, whose finding rows lay badges, path and the mark/dismiss actions out beside each other',
41
+ },
38
42
  'clarity/ClarityReviewWindow.vue': {
39
43
  width: 'full',
40
44
  why: 'findings column + the answer/dismiss action rail',
@@ -9,7 +9,7 @@
9
9
  // by external-tool URL resolvers); present only where any are declared.
10
10
  // The latter three are body-only section components rendered in tabs here (no longer
11
11
  // standalone modals).
12
- import { reactive, ref, watch } from 'vue'
12
+ import { computed, reactive, ref, watch } from 'vue'
13
13
  import { useReactiveSlots } from '@modular-vue/runtime'
14
14
  import type { InputGateMode, ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
15
15
  import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
@@ -22,12 +22,15 @@ import WorkspaceMembersSettings from '~/components/layout/WorkspaceMembersSettin
22
22
  import WorkspaceMetadataSettings from '~/components/settings/WorkspaceMetadataSettings.vue'
23
23
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
24
24
  import type { AppSlots } from '~/modular/slots'
25
+ import { usePipelinesStore } from '~/stores/pipelines'
26
+ import { pipelineAllowedForTaskType } from '~/utils/pipeline'
25
27
 
26
28
  const { t, te } = useI18n()
27
29
  const ui = useUiStore()
28
30
  const store = useWorkspaceSettingsStore()
29
31
  const workspace = useWorkspaceStore()
30
32
  const access = useWorkspaceAccess()
33
+ const pipelines = usePipelinesStore()
31
34
  const toast = useToast()
32
35
  const { present } = usePipelineErrorToast()
33
36
  const slots = useReactiveSlots<AppSlots>()
@@ -182,6 +185,22 @@ const REVIEW_FRICTION_MODES = computed<{ value: ReviewFrictionMode; label: strin
182
185
  { value: 'enforce', label: t('settings.workspaceSettings.reviewFriction.modes.enforce') },
183
186
  ])
184
187
 
188
+ /**
189
+ * The pipelines a bug-fishing expedition's spawned fix task may run, plus the "use the built-in
190
+ * bug-fix preset" row that clears the pin.
191
+ *
192
+ * Narrowed by the same predicate the create form uses for a `bug` task, because a spawned fix IS
193
+ * one — a board that could pin a document-authoring preset here would spawn tasks that author a
194
+ * document instead of fixing the defect they were spawned for. The clearing row carries the empty
195
+ * string rather than being absent, so "no board default" is a value someone can choose back to.
196
+ */
197
+ const fixPipelineOptions = computed<{ value: string; label: string }[]>(() => [
198
+ { value: '', label: t('settings.workspaceSettings.bugFishing.builtInDefault') },
199
+ ...pipelines.pipelines
200
+ .filter((p) => pipelineAllowedForTaskType(p, 'bug'))
201
+ .map((p) => ({ value: p.id, label: p.name })),
202
+ ])
203
+
185
204
  /** The localized "Max {type} tasks" label for a per-type running-task limit input. */
186
205
  function maxTaskTypeLabel(type: LimitTaskType): string {
187
206
  const key = TASK_TYPE_KEYS[type]
@@ -203,6 +222,8 @@ const draft = reactive({
203
222
  doneLaneRetentionDays: 14 as number,
204
223
  kaizenEnabled: true,
205
224
  allowInitiatorPat: true,
225
+ // '' means "no board default", which resolves to the built-in bug-fix preset at spawn time.
226
+ bugFishingFixPipelineId: '',
206
227
  inputGateMode: 'standard' as InputGateMode,
207
228
  reviewFrictionMode: 'off' as ReviewFrictionMode,
208
229
  reviewFrictionWarnCount: 3,
@@ -230,6 +251,7 @@ function hydrate() {
230
251
  draft.doneLaneRetentionDays = s.doneLaneRetentionDays ?? 14
231
252
  draft.kaizenEnabled = s.kaizenEnabled
232
253
  draft.allowInitiatorPat = s.allowInitiatorPat
254
+ draft.bugFishingFixPipelineId = s.bugFishingFixPipelineId ?? ''
233
255
  draft.inputGateMode = s.inputGateMode
234
256
  draft.reviewFrictionMode = s.reviewFrictionMode
235
257
  draft.reviewFrictionWarnCount = s.reviewFrictionWarnCount
@@ -290,6 +312,9 @@ async function save() {
290
312
  doneLaneRetentionDays: draft.doneLaneRetentionEnabled ? draft.doneLaneRetentionDays : null,
291
313
  kaizenEnabled: draft.kaizenEnabled,
292
314
  allowInitiatorPat: draft.allowInitiatorPat,
315
+ // The empty string clears the pin back to the built-in preset (the backend trims it to null),
316
+ // which is how every other pinned-pipeline field on the platform is cleared.
317
+ bugFishingFixPipelineId: draft.bugFishingFixPipelineId,
293
318
  inputGateMode: draft.inputGateMode,
294
319
  reviewFrictionMode: draft.reviewFrictionMode,
295
320
  reviewFrictionWarnCount: draft.reviewFrictionWarnCount,
@@ -630,6 +655,26 @@ async function save() {
630
655
  </p>
631
656
  </section>
632
657
 
658
+ <!-- Bug-fishing expedition: the pipeline a MARKED finding's spawned fix task runs.
659
+ It is a property of how this team fixes bugs rather than of any one hunt, which
660
+ is why it is a board setting and not a field on the expedition. -->
661
+ <section class="space-y-2">
662
+ <h3 class="text-sm font-semibold text-slate-200">
663
+ {{ t('settings.workspaceSettings.bugFishing.heading') }}
664
+ </h3>
665
+ <p class="text-[11px] text-slate-400">
666
+ {{ t('settings.workspaceSettings.bugFishing.body') }}
667
+ </p>
668
+ <USelectMenu
669
+ v-model="draft.bugFishingFixPipelineId"
670
+ :items="fixPipelineOptions"
671
+ value-key="value"
672
+ size="sm"
673
+ class="max-w-md"
674
+ data-testid="workspace-settings-bug-fishing-pipeline"
675
+ />
676
+ </section>
677
+
633
678
  <!-- Kaizen agent -->
634
679
  <section class="space-y-2">
635
680
  <h3 class="text-sm font-semibold text-slate-200">
@@ -74,6 +74,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
74
74
  fork_decision_pending: { enabled: false, channel: '' },
75
75
  judge_review: { enabled: false, channel: '' },
76
76
  pr_review_ready: { enabled: false, channel: '' },
77
+ bug_fishing_triage: { enabled: false, channel: '' },
77
78
  initiative: { enabled: false, channel: '' },
78
79
  platform_health: { enabled: false, channel: '' },
79
80
  budget_paused: { enabled: false, channel: '' },
@@ -0,0 +1,52 @@
1
+ import {
2
+ addressBugFishingFindingsContract,
3
+ dismissBugFishingFindingContract,
4
+ getBugFishingContract,
5
+ resolveBugFishingContract,
6
+ } from '@cat-factory/contracts'
7
+ import type { ApiContext } from './context'
8
+
9
+ /**
10
+ * The bug-fishing expedition: the read-only `bug-fisher` agent reads the service's codebase once
11
+ * per ANGLE and reports what each angle caught, and the run parks once every angle has settled.
12
+ * These endpoints read the live catch, MARK findings (each spawning its own bug-fix task), drop
13
+ * one from triage, and finish a parked expedition. The read returns null when no `bug-fisher`
14
+ * step carries expedition state.
15
+ */
16
+ export function bugFishingApi({ send, ws }: ApiContext) {
17
+ return {
18
+ // The live expedition state for a run (null when no bug-fisher step carries one).
19
+ getBugFishing: (workspaceId: string, executionId: string) =>
20
+ send(getBugFishingContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
21
+
22
+ // Mark findings to be addressed: one bug-fix task per finding, linked to the expedition.
23
+ // Accepted while later angles are still fishing, which is the point of the phase loop.
24
+ // `pipelineId` overrides the board's default fix pipeline for this batch only.
25
+ addressBugFishingFindings: (
26
+ workspaceId: string,
27
+ executionId: string,
28
+ body: { findingIds: string[]; pipelineId?: string },
29
+ ) =>
30
+ send(addressBugFishingFindingsContract, {
31
+ pathPrefix: ws(workspaceId),
32
+ pathParams: { executionId },
33
+ body,
34
+ }),
35
+
36
+ // Dismiss a finding: it stays on the record, struck through, and can no longer be marked.
37
+ dismissBugFishingFinding: (workspaceId: string, executionId: string, findingId: string) =>
38
+ send(dismissBugFishingFindingContract, {
39
+ pathPrefix: ws(workspaceId),
40
+ pathParams: { executionId, findingId },
41
+ body: {},
42
+ }),
43
+
44
+ // Finish a parked expedition (triage is done); the run advances past the step.
45
+ resolveBugFishing: (workspaceId: string, executionId: string) =>
46
+ send(resolveBugFishingContract, {
47
+ pathPrefix: ws(workspaceId),
48
+ pathParams: { executionId },
49
+ body: {},
50
+ }),
51
+ }
52
+ }
@@ -18,6 +18,7 @@ import { forkDecisionApi } from './api/forkDecision'
18
18
  import { inputGateApi } from './api/inputGate'
19
19
  import { judgeApi } from './api/judge'
20
20
  import { prReviewApi } from './api/prReview'
21
+ import { bugFishingApi } from './api/bugFishing'
21
22
  import { fragmentsApi } from './api/fragments'
22
23
  import { foundationalServicesApi } from './api/foundationalServices'
23
24
  import { skillsApi } from './api/skills'
@@ -138,6 +139,7 @@ export function useApi() {
138
139
  ...inputGateApi(ctx),
139
140
  ...judgeApi(ctx),
140
141
  ...prReviewApi(ctx),
142
+ ...bugFishingApi(ctx),
141
143
  ...humanTestApi(ctx),
142
144
  ...visualConfirmApi(ctx),
143
145
  ...humanReviewApi(ctx),
@@ -125,6 +125,27 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
125
125
  titleKey: 'errors.conflict.title.kaizen_entry_not_settled',
126
126
  descriptionKey: 'errors.conflict.description.kaizen_entry_not_settled',
127
127
  },
128
+ // The four bug-fishing-expedition refusals. `no_expedition` / `not_awaiting_triage` are the
129
+ // "wrong run" / "already finished" pair (the window refreshes rather than re-offering the
130
+ // control); `already_addressed` names findings that already have a fix task, so a second
131
+ // request cannot double-spawn; `no_host_frame` is a board-shape problem the operator fixes by
132
+ // putting the expedition under a service.
133
+ no_expedition: {
134
+ titleKey: 'errors.conflict.title.no_expedition',
135
+ descriptionKey: 'errors.conflict.description.no_expedition',
136
+ },
137
+ not_awaiting_triage: {
138
+ titleKey: 'errors.conflict.title.not_awaiting_triage',
139
+ descriptionKey: 'errors.conflict.description.not_awaiting_triage',
140
+ },
141
+ already_addressed: {
142
+ titleKey: 'errors.conflict.title.already_addressed',
143
+ descriptionKey: 'errors.conflict.description.already_addressed',
144
+ },
145
+ no_host_frame: {
146
+ titleKey: 'errors.conflict.title.no_host_frame',
147
+ descriptionKey: 'errors.conflict.description.no_host_frame',
148
+ },
128
149
  task_limit_reached: {
129
150
  titleKey: 'errors.conflict.title.task_limit_reached',
130
151
  descriptionKey: 'errors.conflict.description.task_limit_reached',
@@ -48,6 +48,9 @@ const ForkDecisionWindow = defineAsyncView(
48
48
  () => import('~/components/forkDecision/ForkDecisionWindow.vue'),
49
49
  )
50
50
  const PrReviewWindow = defineAsyncView(() => import('~/components/prReview/PrReviewWindow.vue'))
51
+ const BugFishingWindow = defineAsyncView(
52
+ () => import('~/components/bugFishing/BugFishingWindow.vue'),
53
+ )
51
54
  const MergerResultView = defineAsyncView(() => import('~/components/panels/MergerResultView.vue'))
52
55
  const InitiativeTrackerWindow = defineAsyncView(
53
56
  () => import('~/components/initiative/InitiativeTrackerWindow.vue'),
@@ -120,6 +123,9 @@ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
120
123
  'binary-candidates': BinaryCandidatesWindow,
121
124
  // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
122
125
  'pr-review': PrReviewWindow,
126
+ // The bug-fishing expedition: the per-angle catch, and the human's per-finding triage (each
127
+ // marked finding spawns its own bug-fix task).
128
+ 'bug-fishing': BugFishingWindow,
123
129
  // The merger's verdict: PR complexity/risk/impact scores + the engine's decision (and why).
124
130
  merger: MergerResultView,
125
131
  // The initiative tracker: phases, per-item status + PR links, decisions, deviations, caveats.
@@ -20,6 +20,7 @@ function job(id: string, over: Partial<BootstrapJob> = {}): BootstrapJob {
20
20
  failure: null,
21
21
  monorepo: null,
22
22
  phase: null,
23
+ delivery: 'direct_push',
23
24
  adoptionPlan: null,
24
25
  adoptionReview: null,
25
26
  prUrl: null,
@@ -0,0 +1,143 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { BugFishingStepState } from '~/types/execution'
4
+ import { useApi } from '~/composables/useApi'
5
+ import { useWorkspaceStore } from '~/stores/workspace'
6
+ import { useExecutionStore } from '~/stores/execution'
7
+
8
+ /**
9
+ * The bug-fishing expedition's action surface. The live state lives on the run's `bug-fisher`
10
+ * step (`step.bugFishing`) and is kept fresh by the execution stream, so the window reads it
11
+ * straight off the execution store — this store only wraps the actions, tracks what is in
12
+ * flight so the window can disable its controls, and reflects the returned state back onto the
13
+ * execution store so the UI updates before the stream echoes the change. Keyed by executionId,
14
+ * mirroring the PR-review store.
15
+ */
16
+ export const useBugFishingStore = defineStore('bugFishing', () => {
17
+ const api = useApi()
18
+ const workspace = useWorkspaceStore()
19
+ const execution = useExecutionStore()
20
+
21
+ /**
22
+ * The finding ids whose fix task is being spawned right now. A SET rather than one boolean
23
+ * because marking is available while the expedition is still fishing, so a person can mark a
24
+ * second finding while the first is still spawning — and a shared flag would grey out the row
25
+ * they just clicked along with every other one.
26
+ */
27
+ const spawning = ref<Set<string>>(new Set())
28
+ /** True while the finish call is in flight (drives the Finish button's spinner). */
29
+ const resolving = ref(false)
30
+ /** The last error message from an action, surfaced inline; cleared on the next action. */
31
+ const error = ref<string | null>(null)
32
+
33
+ /**
34
+ * Apply an authoritative expedition state to the run's `bug-fisher` step. A pipeline could
35
+ * carry more than one, so target the one this state is about: prefer the step still awaiting
36
+ * triage, then the current step, then the first step carrying expedition state.
37
+ *
38
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the
39
+ * event stream already delivered a newer revision. That guard matters most here: marking a
40
+ * finding is accepted mid-expedition, so an unguarded echo could put a later phase's findings
41
+ * back to the set the mark request happened to see.
42
+ */
43
+ function assign(
44
+ instance: ReturnType<typeof execution.getInstance> & object,
45
+ state: BugFishingStepState,
46
+ ): void {
47
+ const isLive = (s: (typeof instance.steps)[number]) =>
48
+ s.agentKind === 'bug-fisher' && s.bugFishing?.status === 'awaiting_triage'
49
+ const current = instance.steps[instance.currentStep]
50
+ const step =
51
+ instance.steps.find(isLive) ??
52
+ (current?.agentKind === 'bug-fisher' && current.bugFishing ? current : undefined) ??
53
+ instance.steps.find((s) => s.bugFishing)
54
+ if (step) step.bugFishing = state
55
+ }
56
+
57
+ /** Warm the live state from the GET (the stream also keeps it fresh). Best-effort. */
58
+ async function load(executionId: string): Promise<void> {
59
+ error.value = null
60
+ try {
61
+ await execution.echoAfter(
62
+ executionId,
63
+ () => api.getBugFishing(workspace.requireId(), executionId),
64
+ (state, instance) => {
65
+ if (state) assign(instance, state as BugFishingStepState)
66
+ },
67
+ )
68
+ } catch (e) {
69
+ error.value = e instanceof Error ? e.message : 'Failed to load'
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Mark findings to be addressed: each spawns its own bug-fix task, linked to the expedition.
75
+ * `pipelineId` overrides the board's default fix pipeline for this batch only.
76
+ */
77
+ async function address(
78
+ executionId: string,
79
+ findingIds: string[],
80
+ pipelineId?: string,
81
+ ): Promise<void> {
82
+ error.value = null
83
+ spawning.value = new Set([...spawning.value, ...findingIds])
84
+ try {
85
+ await execution.echoAfter(
86
+ executionId,
87
+ () =>
88
+ api.addressBugFishingFindings(workspace.requireId(), executionId, {
89
+ findingIds,
90
+ ...(pipelineId ? { pipelineId } : {}),
91
+ }),
92
+ (state, instance) => assign(instance, state as BugFishingStepState),
93
+ )
94
+ } catch (e) {
95
+ error.value = e instanceof Error ? e.message : 'Failed to create the fix task'
96
+ throw e
97
+ } finally {
98
+ const next = new Set(spawning.value)
99
+ for (const id of findingIds) next.delete(id)
100
+ spawning.value = next
101
+ }
102
+ }
103
+
104
+ /** Dismiss a finding: it stays on the record, struck through, and can no longer be marked. */
105
+ async function dismiss(executionId: string, findingId: string): Promise<void> {
106
+ error.value = null
107
+ spawning.value = new Set([...spawning.value, findingId])
108
+ try {
109
+ await execution.echoAfter(
110
+ executionId,
111
+ () => api.dismissBugFishingFinding(workspace.requireId(), executionId, findingId),
112
+ (state, instance) => assign(instance, state as BugFishingStepState),
113
+ )
114
+ } catch (e) {
115
+ error.value = e instanceof Error ? e.message : 'Failed to dismiss the finding'
116
+ throw e
117
+ } finally {
118
+ const next = new Set(spawning.value)
119
+ next.delete(findingId)
120
+ spawning.value = next
121
+ }
122
+ }
123
+
124
+ /** Finish a parked expedition: triage is done and the run advances past the step. */
125
+ async function resolve(executionId: string): Promise<void> {
126
+ error.value = null
127
+ resolving.value = true
128
+ try {
129
+ await execution.echoAfter(
130
+ executionId,
131
+ () => api.resolveBugFishing(workspace.requireId(), executionId),
132
+ (state, instance) => assign(instance, state as BugFishingStepState),
133
+ )
134
+ } catch (e) {
135
+ error.value = e instanceof Error ? e.message : 'Failed to finish the expedition'
136
+ throw e
137
+ } finally {
138
+ resolving.value = false
139
+ }
140
+ }
141
+
142
+ return { spawning, resolving, error, load, address, dismiss, resolve }
143
+ })
@@ -164,13 +164,19 @@ export function createUiResultViews() {
164
164
  // The run-scoped openers (a caller that knows only the RUN, so the step index has to be
165
165
  // resolved) live in a sibling module: they share one shape and one hazard, and lifting them out
166
166
  // keeps this factory inside its per-function line budget. Their two seams are bound here.
167
- const { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence } =
168
- createRunStepOpeners({
169
- dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
170
- setResultView: (view, instance, stepIndex) => {
171
- resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
172
- },
173
- })
167
+ const {
168
+ openFollowUps,
169
+ openForkDecision,
170
+ openBinaryCandidates,
171
+ openPrReview,
172
+ openBugFishing,
173
+ openTestEvidence,
174
+ } = createRunStepOpeners({
175
+ dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
176
+ setResultView: (view, instance, stepIndex) => {
177
+ resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
178
+ },
179
+ })
174
180
 
175
181
  function closeResultView() {
176
182
  resultView.value = null
@@ -212,6 +218,7 @@ export function createUiResultViews() {
212
218
  openForkDecision,
213
219
  openBinaryCandidates,
214
220
  openPrReview,
221
+ openBugFishing,
215
222
  openTestEvidence,
216
223
  openOutcome,
217
224
  openRunOutcome,
@@ -136,6 +136,27 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
136
136
  )
137
137
  }
138
138
 
139
+ // Open the BUG-FISHING expedition window for a run's `bug-fisher` step (from the
140
+ // `bug_fishing_triage` notification / the step). Resolves the step index from the run when not
141
+ // given, preferring the step parked awaiting triage.
142
+ function openBugFishing(instanceId: string, stepIndex: number | null = null) {
143
+ withStep(
144
+ instanceId,
145
+ stepIndex,
146
+ (instance) => {
147
+ const awaiting = indexOf(
148
+ instance,
149
+ (s) => s.agentKind === 'bug-fisher' && s.bugFishing?.status === 'awaiting_triage',
150
+ )
151
+ if (awaiting >= 0) return awaiting
152
+ const current = instance.steps[instance.currentStep]
153
+ if (current?.agentKind === 'bug-fisher' && current.bugFishing) return instance.currentStep
154
+ return indexOf(instance, (s) => s.agentKind === 'bug-fisher' && !!s.bugFishing)
155
+ },
156
+ (instance, idx) => deps.setResultView('bug-fishing', instance, idx),
157
+ )
158
+ }
159
+
139
160
  // Open the Tester's result window for a run, where the screenshots and per-area outcomes it
140
161
  // captured are rendered. The entry point is the `test-evidence` deep link the engine puts in
141
162
  // every PR verification report's environment-lifecycle section, so the caller only ever knows
@@ -161,5 +182,12 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
161
182
  )
162
183
  }
163
184
 
164
- return { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence }
185
+ return {
186
+ openFollowUps,
187
+ openForkDecision,
188
+ openBinaryCandidates,
189
+ openPrReview,
190
+ openBugFishing,
191
+ openTestEvidence,
192
+ }
165
193
  }
@@ -35,6 +35,7 @@ const DEFAULTS: WorkspaceSettings = {
35
35
  allowInitiatorPat: true,
36
36
  // The custom metadata bag: empty until someone fills a declared field in. Never null — an
37
37
  // external-tool resolver indexes it (`ctx.metadata.gameId`) with no guard.
38
+ bugFishingFixPipelineId: null,
38
39
  metadata: {},
39
40
  }
40
41
 
@@ -18,6 +18,7 @@ export type {
18
18
  BootstrapFailure,
19
19
  BootstrapJob,
20
20
  BootstrapPhase,
21
+ BootstrapDelivery,
21
22
  BootstrapRepoInput,
22
23
  MonorepoBootstrapTarget,
23
24
  MonorepoBootstrapRef,
@@ -83,6 +83,15 @@ export type {
83
83
  PrReviewResolution,
84
84
  PrReviewPostReport,
85
85
  PrReviewPostFailure,
86
+ BugFishingStepState,
87
+ BugFishingStatus,
88
+ BugFishingPhase,
89
+ BugFishingPhaseStatus,
90
+ BugFishingFinding,
91
+ BugFishingSeverity,
92
+ BugFishingFindingKind,
93
+ BugFishingConfidence,
94
+ BugFishingSpawn,
86
95
  AgentEffortReport,
87
96
  FragmentAdherence,
88
97
  FragmentAdherenceItem,
@@ -22,6 +22,7 @@ const AGENT_KINDS: AgentKind[] = [
22
22
  'requirements-brainstorm',
23
23
  'architecture-brainstorm',
24
24
  'bug-investigator',
25
+ 'bug-fisher',
25
26
  'pr-reviewer',
26
27
  'spike',
27
28
  'task-estimator',
@@ -80,6 +80,25 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
80
80
  'Read-only, multi-repo codebase investigation that traces the bug to its root cause and decides whether the report is fixable as-is or needs the reporter to clarify (no code changes).',
81
81
  resultView: 'generic-structured',
82
82
  },
83
+ {
84
+ // The BUG-FISHING expedition's single step, dispatched once per ANGLE by the engine's phase
85
+ // loop. Registered on the backend so it also arrives via the workspace manifest, but modelled
86
+ // statically here for the reason `pr-reviewer` is: `agentKindMeta('bug-fisher').resultView`
87
+ // has to resolve to the expedition window on every surface, not only once the manifest is
88
+ // hydrated. Mirrors the backend `presentation` in `bug-fisher.ts`.
89
+ kind: 'bug-fisher',
90
+ tier: 'intermediate',
91
+ label: 'Bug Fisher',
92
+ icon: 'i-lucide-fish',
93
+ color: '#0ea5e9',
94
+ category: 'review',
95
+ // Hunts for defects nobody reported: it belongs with the bug work rather than the build
96
+ // ladder, and with review because its product is findings.
97
+ purposes: ['bugfix', 'review'],
98
+ description:
99
+ 'Read-only, multi-angle hunt through an existing codebase for genuine logic gaps, latent bugs, footguns and unhandled edge cases — one pass per angle, nothing changed.',
100
+ resultView: 'bug-fishing',
101
+ },
83
102
  {
84
103
  // A read-only, token-bounded deep review of an EXISTING open pull request (the `pl_review`
85
104
  // pipeline's single step). Registered on the backend so it also arrives via the workspace
@@ -953,6 +972,12 @@ export const TASK_TYPE_META: Record<string, TaskTypeMeta> = {
953
972
  color: '#f87171',
954
973
  labelKey: 'board.addTask.types.bug',
955
974
  },
975
+ 'bug-fishing': {
976
+ taskType: 'bug-fishing',
977
+ icon: 'i-lucide-fish',
978
+ color: '#0ea5e9',
979
+ labelKey: 'board.addTask.types.bugFishing',
980
+ },
956
981
  document: {
957
982
  taskType: 'document',
958
983
  icon: 'i-lucide-file-text',