@cat-factory/app 0.111.2 → 0.112.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.
@@ -0,0 +1,300 @@
1
+ <script setup lang="ts">
2
+ // Implementation-fork decision window — the dedicated surface for the read-only proposer's
3
+ // materially different implementation approaches, opened via the universal result-view host
4
+ // (`ui.openForkDecision`). It reads the live fork state straight off the run's Coder step
5
+ // (`step.forkDecision`, kept fresh by the execution stream) and lets a human pick a proposed
6
+ // fork OR enter their own free-text approach. Once chosen, the Coder re-runs with the chosen
7
+ // approach folded in. Chat is added in a later slice.
8
+ import { computed, ref, watch } from 'vue'
9
+ import { useResultView } from '~/composables/useResultView'
10
+ import { useExecutionStore } from '~/stores/execution'
11
+ import { useBoardStore } from '~/stores/board'
12
+ import { useForkDecisionStore } from '~/stores/forkDecision'
13
+ import type { ForkDecisionStepState, ForkOption } from '~/types/execution'
14
+ import { FORK_DECISION_META } from '~/utils/catalog'
15
+
16
+ const execution = useExecutionStore()
17
+ const board = useBoardStore()
18
+ const forkDecision = useForkDecisionStore()
19
+
20
+ const { t } = useI18n()
21
+
22
+ // Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
23
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
24
+ onOpen: (id) => void forkDecision.load(id),
25
+ })
26
+
27
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
28
+ const instance = computed(() =>
29
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
30
+ )
31
+ const step = computed(() => {
32
+ if (instance.value === null || stepIndex.value === null) return null
33
+ return instance.value.steps[stepIndex.value] ?? null
34
+ })
35
+ const state = computed<ForkDecisionStepState | null>(() => step.value?.forkDecision ?? null)
36
+ const status = computed(() => state.value?.status ?? null)
37
+ const forks = computed<ForkOption[]>(() => state.value?.forks ?? [])
38
+ const awaiting = computed(() => status.value === 'awaiting_choice')
39
+
40
+ // The human's selection: a proposed fork id, or the sentinel 'custom' for the free-text path.
41
+ const selected = ref<string | null>(null)
42
+ const customText = ref('')
43
+ const note = ref('')
44
+
45
+ // Default the selection to the recommended fork whenever the fork set changes.
46
+ watch(
47
+ forks,
48
+ (list) => {
49
+ if (
50
+ selected.value &&
51
+ (selected.value === 'custom' || list.some((f) => f.id === selected.value))
52
+ )
53
+ return
54
+ selected.value = list.find((f) => f.recommended)?.id ?? list[0]?.id ?? null
55
+ },
56
+ { immediate: true },
57
+ )
58
+
59
+ const canChoose = computed(() => {
60
+ if (!awaiting.value || forkDecision.choosing) return false
61
+ if (selected.value === 'custom') return customText.value.trim().length > 0
62
+ return selected.value != null
63
+ })
64
+
65
+ async function onChoose() {
66
+ const id = instanceId.value
67
+ if (!id || !canChoose.value) return
68
+ const noteText = note.value.trim() || undefined
69
+ const choice =
70
+ selected.value === 'custom'
71
+ ? { custom: customText.value.trim(), note: noteText }
72
+ : { forkId: selected.value!, note: noteText }
73
+ await forkDecision.choose(id, choice).catch(() => {})
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <Teleport to="body">
79
+ <div
80
+ v-if="open"
81
+ data-testid="fork-decision-window"
82
+ class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
83
+ @click.self="close"
84
+ >
85
+ <div
86
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
87
+ role="dialog"
88
+ aria-modal="true"
89
+ >
90
+ <!-- Header -->
91
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
92
+ <span
93
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/15 text-violet-300"
94
+ >
95
+ <UIcon :name="FORK_DECISION_META.icon" class="h-4 w-4" />
96
+ </span>
97
+ <div class="min-w-0 flex-1">
98
+ <h2 class="truncate text-sm font-semibold text-slate-100">
99
+ {{
100
+ block
101
+ ? t('forkDecision.titleWithBlock', { title: block.title })
102
+ : t('forkDecision.title')
103
+ }}
104
+ </h2>
105
+ <p class="truncate text-[11px] text-slate-400">{{ t('forkDecision.subtitle') }}</p>
106
+ </div>
107
+ <button
108
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
109
+ @click="close"
110
+ >
111
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
112
+ </button>
113
+ </header>
114
+
115
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
116
+ <!-- Proposing: the read-only proposer is still working. -->
117
+ <div
118
+ v-if="status === 'proposing'"
119
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
120
+ >
121
+ <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
122
+ <p class="text-sm">{{ t('forkDecision.proposing.title') }}</p>
123
+ <p class="max-w-sm text-[11px] text-slate-500">
124
+ {{ t('forkDecision.proposing.hint') }}
125
+ </p>
126
+ </div>
127
+
128
+ <!-- A single path (no materially different alternatives): a read-only record. -->
129
+ <div
130
+ v-else-if="status === 'single_path'"
131
+ class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 text-slate-300"
132
+ >
133
+ <p class="text-[13px] font-medium text-slate-100">
134
+ {{ t('forkDecision.singlePath.title') }}
135
+ </p>
136
+ <p v-if="state?.singlePathReason" class="mt-1 text-[12px]">
137
+ {{ state.singlePathReason }}
138
+ </p>
139
+ </div>
140
+
141
+ <!-- Chosen: a read-only record of what was decided. -->
142
+ <div
143
+ v-else-if="status === 'chosen'"
144
+ class="rounded-xl border border-violet-500/40 bg-slate-900/60 px-4 py-3 text-slate-300"
145
+ >
146
+ <p class="text-[13px] font-medium text-violet-200">
147
+ {{ t('forkDecision.chosen.title') }}
148
+ </p>
149
+ <p v-if="state?.chosen?.custom" class="mt-1 whitespace-pre-wrap text-[12px]">
150
+ {{ state.chosen.custom }}
151
+ </p>
152
+ <p v-else-if="state?.chosen?.forkId" class="mt-1 text-[12px]">
153
+ {{ forks.find((f) => f.id === state?.chosen?.forkId)?.title }}
154
+ </p>
155
+ <p v-if="state?.chosen?.note" class="mt-1 text-[11px] text-slate-400">
156
+ {{ t('forkDecision.chosen.note', { note: state.chosen.note }) }}
157
+ </p>
158
+ </div>
159
+
160
+ <!-- Awaiting the human's choice. -->
161
+ <div v-else-if="awaiting" class="space-y-3">
162
+ <p
163
+ v-if="forkDecision.error"
164
+ class="rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
165
+ >
166
+ {{ forkDecision.error }}
167
+ </p>
168
+
169
+ <p
170
+ v-if="state?.seamSummary"
171
+ class="rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
172
+ >
173
+ <span class="text-slate-500">{{ t('forkDecision.seam') }}</span>
174
+ {{ state.seamSummary }}
175
+ </p>
176
+
177
+ <!-- Proposed fork cards -->
178
+ <article
179
+ v-for="fork in forks"
180
+ :key="fork.id"
181
+ data-testid="fork-option-card"
182
+ class="cursor-pointer rounded-xl border px-4 py-3 transition"
183
+ :class="
184
+ selected === fork.id
185
+ ? 'border-violet-500/70 bg-violet-500/5'
186
+ : 'border-slate-800 bg-slate-900/60 hover:border-slate-700'
187
+ "
188
+ @click="selected = fork.id"
189
+ >
190
+ <div class="flex items-start gap-2">
191
+ <input
192
+ type="radio"
193
+ class="mt-1 accent-violet-500"
194
+ :checked="selected === fork.id"
195
+ @change="selected = fork.id"
196
+ />
197
+ <div class="min-w-0 flex-1">
198
+ <div class="flex items-center gap-2">
199
+ <h3 class="min-w-0 flex-1 text-[13px] font-medium text-slate-100">
200
+ {{ fork.title }}
201
+ </h3>
202
+ <UBadge v-if="fork.recommended" color="primary" variant="subtle" size="sm">
203
+ {{ t('forkDecision.recommended') }}
204
+ </UBadge>
205
+ </div>
206
+ <p v-if="fork.summary" class="mt-0.5 text-[12px] text-slate-400">
207
+ {{ fork.summary }}
208
+ </p>
209
+ <p class="mt-1.5 whitespace-pre-wrap text-[12px] text-slate-300">
210
+ {{ fork.approach }}
211
+ </p>
212
+ <ul v-if="fork.tradeoffs.length" class="mt-1.5 space-y-0.5">
213
+ <li
214
+ v-for="(tr, i) in fork.tradeoffs"
215
+ :key="i"
216
+ class="flex gap-1.5 text-[11px] text-slate-400"
217
+ >
218
+ <span class="text-slate-600">•</span>{{ tr }}
219
+ </li>
220
+ </ul>
221
+ <p v-if="fork.riskNotes" class="mt-1.5 text-[11px] text-amber-300/90">
222
+ <span class="text-amber-500/70">{{ t('forkDecision.riskNotes') }}</span>
223
+ {{ fork.riskNotes }}
224
+ </p>
225
+ </div>
226
+ </div>
227
+ </article>
228
+
229
+ <!-- Custom approach -->
230
+ <article
231
+ class="rounded-xl border px-4 py-3 transition"
232
+ :class="
233
+ selected === 'custom'
234
+ ? 'border-violet-500/70 bg-violet-500/5'
235
+ : 'border-slate-800 bg-slate-900/60'
236
+ "
237
+ >
238
+ <label class="flex cursor-pointer items-center gap-2" @click="selected = 'custom'">
239
+ <input type="radio" class="accent-violet-500" :checked="selected === 'custom'" />
240
+ <span class="text-[13px] font-medium text-slate-100">{{
241
+ t('forkDecision.custom.title')
242
+ }}</span>
243
+ </label>
244
+ <textarea
245
+ v-model="customText"
246
+ data-testid="fork-custom-input"
247
+ rows="3"
248
+ :placeholder="t('forkDecision.custom.placeholder')"
249
+ class="mt-2 w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60"
250
+ @focus="selected = 'custom'"
251
+ />
252
+ </article>
253
+
254
+ <!-- Optional steering note -->
255
+ <div>
256
+ <label class="mb-1 block text-[11px] text-slate-400">{{
257
+ t('forkDecision.noteLabel')
258
+ }}</label>
259
+ <input
260
+ v-model="note"
261
+ type="text"
262
+ :placeholder="t('forkDecision.notePlaceholder')"
263
+ class="w-full rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none"
264
+ />
265
+ </div>
266
+ </div>
267
+
268
+ <!-- Skipped / no state: nothing to decide. -->
269
+ <div
270
+ v-else
271
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
272
+ >
273
+ <UIcon :name="FORK_DECISION_META.icon" class="h-8 w-8 opacity-40" />
274
+ <p class="text-sm">{{ t('forkDecision.empty.title') }}</p>
275
+ </div>
276
+ </div>
277
+
278
+ <footer
279
+ v-if="awaiting"
280
+ class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
281
+ >
282
+ <UButton color="neutral" variant="ghost" size="sm" @click="close">
283
+ {{ t('common.cancel') }}
284
+ </UButton>
285
+ <UButton
286
+ data-testid="fork-option-choose"
287
+ color="primary"
288
+ size="sm"
289
+ icon="i-lucide-check"
290
+ :loading="forkDecision.choosing"
291
+ :disabled="!canChoose"
292
+ @click="onChoose"
293
+ >
294
+ {{ t('forkDecision.choose') }}
295
+ </UButton>
296
+ </footer>
297
+ </div>
298
+ </div>
299
+ </Teleport>
300
+ </template>
@@ -60,6 +60,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
60
60
  // Clicking the title opens the Follow-up companion window for the run (see `reveal`); "act"
61
61
  // just marks it read (items are decided in that window — file / send back / answer — not here).
62
62
  followup_pending: { icon: 'i-lucide-compass', color: 'warning' },
63
+ // The fork-decision phase surfaced materially different implementation approaches. Clicking
64
+ // the title opens the fork-decision window (see `reveal`); "act" just marks it read (the
65
+ // choice is made in that window — pick a fork / enter a custom approach — not here).
66
+ fork_decision_pending: { icon: 'i-lucide-git-fork', color: 'warning' },
63
67
  // The initiative loop needs attention (a blocked task, or completion). Clicking the title
64
68
  // opens the initiative tracker window; "act" just marks it read.
65
69
  initiative: { icon: 'i-lucide-milestone', color: 'primary' },
@@ -81,6 +85,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
81
85
  visual_confirmation_ready: 'layout.notifications.action.visual_confirmation_ready',
82
86
  human_review: 'layout.notifications.action.human_review',
83
87
  followup_pending: 'layout.notifications.action.followup_pending',
88
+ fork_decision_pending: 'layout.notifications.action.fork_decision_pending',
84
89
  initiative: 'layout.notifications.action.initiative',
85
90
  }
86
91
 
@@ -151,6 +156,7 @@ function reveal(n: Notification) {
151
156
  else if (n.type === 'visual_confirmation_ready') revealVisualConfirm(n)
152
157
  else if (n.type === 'human_review') revealHumanReview(n)
153
158
  else if (n.type === 'followup_pending') revealFollowUps(n)
159
+ else if (n.type === 'fork_decision_pending') revealForkDecision(n)
154
160
  else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
155
161
  else ui.select(n.blockId)
156
162
  }
@@ -176,6 +182,15 @@ function revealFollowUps(n: Notification) {
176
182
  else if (n.blockId) ui.select(n.blockId)
177
183
  }
178
184
 
185
+ /**
186
+ * Open the implementation-fork decision window for a run parked awaiting a fork choice.
187
+ * Falls back to focusing the block when the run isn't loaded.
188
+ */
189
+ function revealForkDecision(n: Notification) {
190
+ if (n.executionId && execution.getInstance(n.executionId)) ui.openForkDecision(n.executionId)
191
+ else if (n.blockId) ui.select(n.blockId)
192
+ }
193
+
179
194
  /**
180
195
  * Open the human-testing window for a parked `human-test` gate: find the run's parked
181
196
  * human-test step and open it through the universal step dispatch (its archetype declares
@@ -22,6 +22,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
22
22
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
23
23
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
24
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
25
+ import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
25
26
  import MergerResultView from '~/components/panels/MergerResultView.vue'
26
27
  import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
27
28
  import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
@@ -52,6 +53,10 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
52
53
  // The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
53
54
  // Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
54
55
  'follow-ups': FollowUpWindow,
56
+ // The implementation-fork decision: the proposer's materially different approaches + the
57
+ // human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
58
+ // chip, the inspector button, and the `fork_decision_pending` card).
59
+ 'fork-decision': ForkDecisionWindow,
55
60
  // The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
56
61
  // or awaiting-review decision (and why), instead of the agent's raw JSON.
57
62
  merger: MergerResultView,
@@ -136,6 +136,11 @@ function openStep(i: number) {
136
136
  if (instance.value) ui.openStepDetail(instance.value.id, i)
137
137
  }
138
138
 
139
+ // Open the implementation-fork decision window for a coder step parked awaiting a choice.
140
+ function openForkFor(i: number) {
141
+ if (instance.value) ui.openForkDecision(instance.value.id, i)
142
+ }
143
+
139
144
  // Stop the run WITHOUT deleting it: halts the container + driver and records a
140
145
  // `cancelled` failure, leaving the run readable + retryable (the block goes
141
146
  // `blocked`). The destructive reset (delete the run, return the task to `planned`)
@@ -335,6 +340,22 @@ async function mergePr() {
335
340
  >
336
341
  {{ t('inspector.execution.decide') }}
337
342
  </UButton>
343
+ <!-- A coder step parked on the implementation-fork decision: pick an approach
344
+ (or enter a custom one) in the dedicated window, not a plain approval. -->
345
+ <UButton
346
+ v-else-if="
347
+ s.approval &&
348
+ s.approval.status === 'pending' &&
349
+ s.forkDecision?.status === 'awaiting_choice'
350
+ "
351
+ color="primary"
352
+ variant="soft"
353
+ size="xs"
354
+ icon="i-lucide-git-fork"
355
+ @click="openForkFor(i)"
356
+ >
357
+ {{ t('inspector.execution.chooseApproach') }}
358
+ </UButton>
338
359
  <UButton
339
360
  v-else-if="s.approval && s.approval.status === 'pending'"
340
361
  color="warning"
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { AgentState, ExecutionInstance } from '~/types/domain'
3
3
  import type { PipelineStep } from '~/types/execution'
4
- import { agentKindMeta, FOLLOW_UP_COMPANION_META } from '~/utils/catalog'
4
+ import { agentKindMeta, FOLLOW_UP_COMPANION_META, FORK_DECISION_META } from '~/utils/catalog'
5
5
  import {
6
6
  subtaskIconClass,
7
7
  gateCompanionFor,
@@ -68,6 +68,12 @@ function followUpLabel(step: PipelineStep): string {
68
68
  : t('pipeline.progress.followUp.allDecided')
69
69
  }
70
70
 
71
+ /** The active fork-decision phase status on a coder step (proposing / awaiting a choice). */
72
+ function forkPhase(step: PipelineStep): 'proposing' | 'awaiting_choice' | null {
73
+ const status = step.forkDecision?.status
74
+ return status === 'proposing' || status === 'awaiting_choice' ? status : null
75
+ }
76
+
71
77
  // --- restart from a step -----------------------------------------------------
72
78
  // Re-run the pipeline from a chosen step onward: the server resets that step +
73
79
  // every later step's iteration counters and re-drives a fresh run, keeping the
@@ -543,6 +549,40 @@ const ITEM_ICON: Record<string, string> = {
543
549
  </span>
544
550
  </button>
545
551
 
552
+ <!-- Implementation-fork decision phase (Coder step): a spinner while the proposer
553
+ surfaces approaches, then a clickable chip to choose one. -->
554
+ <button
555
+ v-if="forkPhase(s)"
556
+ type="button"
557
+ class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed px-2.5 py-1.5 text-start transition hover:border-violet-400/60"
558
+ :class="
559
+ forkPhase(s) === 'awaiting_choice'
560
+ ? 'border-violet-500/50 bg-violet-500/10 followup-blink'
561
+ : 'border-slate-700/70 bg-slate-900/40'
562
+ "
563
+ :disabled="forkPhase(s) === 'proposing'"
564
+ @click="ui.openForkDecision(instance.id, i)"
565
+ >
566
+ <span
567
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-violet-500/40 bg-violet-500/15"
568
+ >
569
+ <UIcon
570
+ :name="
571
+ forkPhase(s) === 'proposing' ? 'i-lucide-loader-circle' : FORK_DECISION_META.icon
572
+ "
573
+ class="h-3 w-3 text-violet-300"
574
+ :class="forkPhase(s) === 'proposing' ? 'animate-spin' : ''"
575
+ />
576
+ </span>
577
+ <span class="min-w-0 flex-1 truncate text-[12px] text-slate-300">
578
+ {{
579
+ forkPhase(s) === 'proposing'
580
+ ? t('pipeline.progress.forkDecision.proposing')
581
+ : t('pipeline.progress.forkDecision.choose')
582
+ }}
583
+ </span>
584
+ </button>
585
+
546
586
  <!-- reviewer gate folding/re-reviewing in the background: a working indicator,
547
587
  NOT a "Review & approve" gate (the human is summoned only if needed) -->
548
588
  <div
@@ -6,6 +6,7 @@
6
6
  // default; it cannot be deleted or un-defaulted (the backend enforces this too).
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { RiskPolicy, RequirementConcernLevel } from '~/types/merge'
9
+ import type { StepGating } from '@cat-factory/contracts'
9
10
 
10
11
  const { t } = useI18n()
11
12
 
@@ -42,9 +43,32 @@ interface Draft {
42
43
  maxRequirementIterations: number
43
44
  maxRequirementConcernAllowed: RequirementConcernLevel
44
45
  autoMergeEnabled: boolean
46
+ // Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
47
+ forkEnabled: boolean
48
+ forkMinComplexity: number
49
+ forkMinRisk: number
50
+ forkMinImpact: number
51
+ forkOnMissing: 'run' | 'skip'
45
52
  }
46
53
  const drafts = reactive<Record<string, Draft>>({})
47
54
 
55
+ // On-missing-estimate options for the fork gating group (fail toward asking / skipping).
56
+ const ON_MISSING_OPTIONS = computed<{ value: 'run' | 'skip'; label: string }[]>(() => [
57
+ { value: 'run', label: t('settings.riskPolicy.forkDecision.onMissing.run') },
58
+ { value: 'skip', label: t('settings.riskPolicy.forkDecision.onMissing.skip') },
59
+ ])
60
+
61
+ /** Build the `StepGating` payload for the fork-decision gate from a draft (or null when off). */
62
+ function forkGating(d: Draft): StepGating {
63
+ return {
64
+ enabled: d.forkEnabled,
65
+ minComplexity: d.forkMinComplexity / 100,
66
+ minRisk: d.forkMinRisk / 100,
67
+ minImpact: d.forkMinImpact / 100,
68
+ onMissingEstimate: d.forkOnMissing,
69
+ }
70
+ }
71
+
48
72
  function toDraft(p: RiskPolicy): Draft {
49
73
  return {
50
74
  name: p.name,
@@ -55,6 +79,11 @@ function toDraft(p: RiskPolicy): Draft {
55
79
  maxRequirementIterations: p.maxRequirementIterations,
56
80
  maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
57
81
  autoMergeEnabled: p.autoMergeEnabled,
82
+ forkEnabled: p.forkDecision?.enabled ?? false,
83
+ forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
84
+ forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
85
+ forkMinImpact: Math.round((p.forkDecision?.minImpact ?? 0.4) * 100),
86
+ forkOnMissing: p.forkDecision?.onMissingEstimate ?? 'run',
58
87
  }
59
88
  }
60
89
 
@@ -92,6 +121,7 @@ async function save(p: RiskPolicy) {
92
121
  maxRequirementIterations: d.maxRequirementIterations,
93
122
  maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
94
123
  autoMergeEnabled: d.autoMergeEnabled,
124
+ forkDecision: forkGating(d),
95
125
  })
96
126
  toast.add({
97
127
  title: t('settings.riskPolicy.toast.saved'),
@@ -146,6 +176,11 @@ const draft = reactive<Draft>({
146
176
  maxRequirementIterations: 6,
147
177
  maxRequirementConcernAllowed: 'none',
148
178
  autoMergeEnabled: true,
179
+ forkEnabled: false,
180
+ forkMinComplexity: 50,
181
+ forkMinRisk: 40,
182
+ forkMinImpact: 40,
183
+ forkOnMissing: 'run',
149
184
  })
150
185
 
151
186
  async function create() {
@@ -161,6 +196,7 @@ async function create() {
161
196
  maxRequirementIterations: draft.maxRequirementIterations,
162
197
  maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
163
198
  autoMergeEnabled: draft.autoMergeEnabled,
199
+ forkDecision: forkGating(draft),
164
200
  })
165
201
  draft.name = ''
166
202
  draft.autoMergeEnabled = true
@@ -305,6 +341,61 @@ async function create() {
305
341
  </label>
306
342
  </div>
307
343
 
344
+ <!-- Implementation-fork decision gate: propose materially different approaches before the
345
+ Coder writes code (in `auto` tri-state, gated on the task estimate). -->
346
+ <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
347
+ <USwitch
348
+ v-model="drafts[p.id]!.forkEnabled"
349
+ size="sm"
350
+ :label="t('settings.riskPolicy.forkDecision.label')"
351
+ :description="t('settings.riskPolicy.forkDecision.hint')"
352
+ />
353
+ <div v-if="drafts[p.id]!.forkEnabled" class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
354
+ <label class="block">
355
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
356
+ {{ t('settings.riskPolicy.forkDecision.minComplexity') }}
357
+ </span>
358
+ <UInput
359
+ v-model.number="drafts[p.id]!.forkMinComplexity"
360
+ type="number"
361
+ size="sm"
362
+ :min="0"
363
+ :max="100"
364
+ />
365
+ </label>
366
+ <label class="block">
367
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
368
+ {{ t('settings.riskPolicy.forkDecision.minRisk') }}
369
+ </span>
370
+ <UInput
371
+ v-model.number="drafts[p.id]!.forkMinRisk"
372
+ type="number"
373
+ size="sm"
374
+ :min="0"
375
+ :max="100"
376
+ />
377
+ </label>
378
+ <label class="block">
379
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
380
+ {{ t('settings.riskPolicy.forkDecision.minImpact') }}
381
+ </span>
382
+ <UInput
383
+ v-model.number="drafts[p.id]!.forkMinImpact"
384
+ type="number"
385
+ size="sm"
386
+ :min="0"
387
+ :max="100"
388
+ />
389
+ </label>
390
+ <label class="block">
391
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
392
+ {{ t('settings.riskPolicy.forkDecision.onMissingLabel') }}
393
+ </span>
394
+ <USelect v-model="drafts[p.id]!.forkOnMissing" :items="ON_MISSING_OPTIONS" size="sm" />
395
+ </label>
396
+ </div>
397
+ </div>
398
+
308
399
  <div class="mt-3 flex items-center justify-between gap-3">
309
400
  <USwitch
310
401
  v-model="drafts[p.id]!.autoMergeEnabled"
@@ -403,6 +494,11 @@ async function create() {
403
494
  size="sm"
404
495
  :label="t('settings.riskPolicy.field.autoMerge')"
405
496
  />
497
+ <USwitch
498
+ v-model="draft.forkEnabled"
499
+ size="sm"
500
+ :label="t('settings.riskPolicy.forkDecision.label')"
501
+ />
406
502
  <UButton
407
503
  color="primary"
408
504
  size="sm"
@@ -56,6 +56,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
56
56
  visual_confirmation_ready: { enabled: false, channel: '' },
57
57
  human_review: { enabled: false, channel: '' },
58
58
  followup_pending: { enabled: false, channel: '' },
59
+ fork_decision_pending: { enabled: false, channel: '' },
59
60
  initiative: { enabled: false, channel: '' },
60
61
  })
61
62
  const mentionsEnabled = ref(false)
@@ -0,0 +1,29 @@
1
+ import { chooseForkContract, getForkDecisionContract } from '@cat-factory/contracts'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The implementation-fork decision phase: before the Coder writes code the read-only
6
+ * proposer surfaces materially different approaches on the run's coder step and the run
7
+ * parks. These endpoints read the surfaced approaches and record the human's choice (a
8
+ * proposed fork or their own free-text approach); choosing re-runs the Coder with the chosen
9
+ * approach folded in. The read returns null when no coder step carries fork state.
10
+ */
11
+ export function forkDecisionApi({ send, ws }: ApiContext) {
12
+ return {
13
+ // The live fork-decision state for a run (null when no coder step carries one).
14
+ getForkDecision: (workspaceId: string, executionId: string) =>
15
+ send(getForkDecisionContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
16
+
17
+ // Choose an implementation approach — a proposed fork id or a custom approach (+ note).
18
+ chooseFork: (
19
+ workspaceId: string,
20
+ executionId: string,
21
+ body: { forkId?: string | null; custom?: string | null; note?: string | null },
22
+ ) =>
23
+ send(chooseForkContract, {
24
+ pathPrefix: ws(workspaceId),
25
+ pathParams: { executionId },
26
+ body,
27
+ }),
28
+ }
29
+ }
@@ -8,6 +8,7 @@ import { boardApi } from './api/board'
8
8
  import { documentsApi } from './api/documents'
9
9
  import { executionApi } from './api/execution'
10
10
  import { followUpsApi } from './api/followUps'
11
+ import { forkDecisionApi } from './api/forkDecision'
11
12
  import { fragmentsApi } from './api/fragments'
12
13
  import { githubApi } from './api/github'
13
14
  import { humanReviewApi } from './api/humanReview'
@@ -108,6 +109,7 @@ export function useApi() {
108
109
  ...tasksApi(ctx),
109
110
  ...reviewsApi(ctx),
110
111
  ...followUpsApi(ctx),
112
+ ...forkDecisionApi(ctx),
111
113
  ...humanTestApi(ctx),
112
114
  ...visualConfirmApi(ctx),
113
115
  ...humanReviewApi(ctx),