@cat-factory/app 0.87.5 → 0.89.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.
@@ -11,8 +11,14 @@
11
11
  // button is disabled with a hint. Linking needs the block id,
12
12
  // so chosen items are staged locally and import-and-linked once the task is created
13
13
  // (see useContextLinking) — the same context the agents see for every step of the run.
14
- import type { CreateTaskType, DocKind, TaskSourceKind, TaskTypeFields } from '~/types/domain'
15
- import { DOC_KINDS } from '~/types/domain'
14
+ import type {
15
+ CreateTaskType,
16
+ DocKind,
17
+ DocKindFieldKey,
18
+ TaskSourceKind,
19
+ TaskTypeFields,
20
+ } from '~/types/domain'
21
+ import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
16
22
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
17
23
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
18
24
  import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
@@ -97,6 +103,40 @@ const docKind = ref<DocKind | ''>('')
97
103
  const docAudience = ref('')
98
104
  const docTargetPath = ref('')
99
105
  const docOutlineHints = ref('')
106
+ // Per-kind specific fields (see DOC_KIND_FIELDS). Held in one keyed record; only the fields
107
+ // for the selected kind are shown and submitted, so a value from a previously-selected kind is
108
+ // never sent. The catalog keys below keep the labels/placeholders i18n and drift-guarded.
109
+ const docKindFieldValues = reactive<Partial<Record<DocKindFieldKey, string>>>({})
110
+ const docKindFields = computed(() => (docKind.value ? (DOC_KIND_FIELDS[docKind.value] ?? []) : []))
111
+ // Exhaustive Record<DocKindFieldKey, key> of catalog keys — the initiative's drift guard for a
112
+ // dynamic enum→key lookup (a missing enum member is a compile error here; a locale that omits a
113
+ // key falls back via `te()` rather than leaking a raw key). Do NOT inline as bare template keys.
114
+ const DOC_FIELD_LABEL_KEYS: Record<DocKindFieldKey, string> = {
115
+ targetUsers: 'board.addTask.docFields.targetUsers.label',
116
+ successMetrics: 'board.addTask.docFields.successMetrics.label',
117
+ alternativesConsidered: 'board.addTask.docFields.alternativesConsidered.label',
118
+ rolloutConcerns: 'board.addTask.docFields.rolloutConcerns.label',
119
+ decisionDrivers: 'board.addTask.docFields.decisionDrivers.label',
120
+ consideredOptions: 'board.addTask.docFields.consideredOptions.label',
121
+ whenToUse: 'board.addTask.docFields.whenToUse.label',
122
+ escalationPath: 'board.addTask.docFields.escalationPath.label',
123
+ researchQuestion: 'board.addTask.docFields.researchQuestion.label',
124
+ optionsToCompare: 'board.addTask.docFields.optionsToCompare.label',
125
+ apiSurface: 'board.addTask.docFields.apiSurface.label',
126
+ }
127
+ const DOC_FIELD_PLACEHOLDER_KEYS: Record<DocKindFieldKey, string> = {
128
+ targetUsers: 'board.addTask.docFields.targetUsers.placeholder',
129
+ successMetrics: 'board.addTask.docFields.successMetrics.placeholder',
130
+ alternativesConsidered: 'board.addTask.docFields.alternativesConsidered.placeholder',
131
+ rolloutConcerns: 'board.addTask.docFields.rolloutConcerns.placeholder',
132
+ decisionDrivers: 'board.addTask.docFields.decisionDrivers.placeholder',
133
+ consideredOptions: 'board.addTask.docFields.consideredOptions.placeholder',
134
+ whenToUse: 'board.addTask.docFields.whenToUse.placeholder',
135
+ escalationPath: 'board.addTask.docFields.escalationPath.placeholder',
136
+ researchQuestion: 'board.addTask.docFields.researchQuestion.placeholder',
137
+ optionsToCompare: 'board.addTask.docFields.optionsToCompare.placeholder',
138
+ apiSurface: 'board.addTask.docFields.apiSurface.placeholder',
139
+ }
100
140
  const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
101
141
 
102
142
  function buildTypeFields(): TaskTypeFields | undefined {
@@ -121,6 +161,11 @@ function buildTypeFields(): TaskTypeFields | undefined {
121
161
  if (docAudience.value.trim()) f.audience = docAudience.value.trim()
122
162
  if (docTargetPath.value.trim()) f.targetPath = docTargetPath.value.trim()
123
163
  if (docOutlineHints.value.trim()) f.outlineHints = docOutlineHints.value.trim()
164
+ // Only the selected kind's fields are read, so a stale value for another kind is dropped.
165
+ for (const spec of docKindFields.value) {
166
+ const value = docKindFieldValues[spec.key]?.trim()
167
+ if (value) f[spec.key] = value
168
+ }
124
169
  return Object.keys(f).length ? f : undefined
125
170
  }
126
171
  return undefined
@@ -336,6 +381,8 @@ watch(open, (isOpen) => {
336
381
  docAudience.value = ''
337
382
  docTargetPath.value = ''
338
383
  docOutlineHints.value = ''
384
+ for (const key of Object.keys(docKindFieldValues) as DocKindFieldKey[])
385
+ delete docKindFieldValues[key]
339
386
  mergePresetId.value = ''
340
387
  modelPresetId.value = ''
341
388
  pipelineId.value = ''
@@ -610,6 +657,27 @@ async function add() {
610
657
  class="w-full"
611
658
  />
612
659
  </UFormField>
660
+ <!-- Kind-specific fields — only those relevant to the selected docKind are shown. -->
661
+ <UFormField
662
+ v-for="spec in docKindFields"
663
+ :key="spec.key"
664
+ :label="t(DOC_FIELD_LABEL_KEYS[spec.key])"
665
+ :hint="t('board.addTask.optional')"
666
+ >
667
+ <UTextarea
668
+ v-if="spec.multiline"
669
+ v-model="docKindFieldValues[spec.key]"
670
+ :rows="2"
671
+ :placeholder="t(DOC_FIELD_PLACEHOLDER_KEYS[spec.key])"
672
+ class="w-full"
673
+ />
674
+ <UInput
675
+ v-else
676
+ v-model="docKindFieldValues[spec.key]"
677
+ :placeholder="t(DOC_FIELD_PLACEHOLDER_KEYS[spec.key])"
678
+ class="w-full"
679
+ />
680
+ </UFormField>
613
681
  </div>
614
682
 
615
683
  <div class="grid grid-cols-2 gap-3">
@@ -5,7 +5,8 @@
5
5
  // task block inside the frame that the schedule re-runs. When the Tech-debt
6
6
  // pipeline is picked, the workspace issue-tracker choice is surfaced inline (it is
7
7
  // where that pipeline files its ticket) and saved alongside.
8
- import type { Recurrence, ScheduleTemplate } from '~/types/recurring'
8
+ import type { IssueIntakeConfig, Recurrence, ScheduleTemplate } from '~/types/recurring'
9
+ import type { TaskSourceKind } from '~/types/domain'
9
10
  import { pipelineAllowedForSchedule } from '~/utils/pipeline'
10
11
 
11
12
  const ui = useUiStore()
@@ -13,6 +14,7 @@ const board = useBoardStore()
13
14
  const pipelines = usePipelinesStore()
14
15
  const recurring = useRecurringPipelinesStore()
15
16
  const tracker = useTrackerStore()
17
+ const tasks = useTasksStore()
16
18
  const toast = useToast()
17
19
  const { t } = useI18n()
18
20
 
@@ -41,6 +43,17 @@ const trackerKind = ref<'github' | 'jira' | 'linear' | null>(null)
41
43
  const jiraProjectKey = ref('')
42
44
  const linearTeamId = ref('')
43
45
 
46
+ // Issue-intake config (only relevant when the picked pipeline has a `bug-intake` step). Which
47
+ // tracker board + predicates a recurring bug-triage run pulls its one issue from, per-schedule.
48
+ const intakeSource = ref<TaskSourceKind | null>(null)
49
+ const intakeJiraProjectKey = ref('')
50
+ const intakeLinearTeamId = ref('')
51
+ const intakeGithubRepo = ref('')
52
+ const intakeTitleFragment = ref('')
53
+ const intakeLabels = ref('') // comma-separated in the UI, sent as an array
54
+ const intakeIssueType = ref('')
55
+ const intakeInProgressLabel = ref('')
56
+
44
57
  function defaultRecurrence(): Recurrence {
45
58
  return {
46
59
  intervalHours: 168, // weekly
@@ -77,6 +90,19 @@ const template = computed<ScheduleTemplate>(() => {
77
90
  })
78
91
  const isTechDebt = computed(() => template.value === 'tech-debt')
79
92
 
93
+ // A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
94
+ // the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
95
+ // (a disabled step imposes nothing), so the modal doesn't demand config for a step that won't run.
96
+ const isBugIntake = computed(() => {
97
+ const pipeline = selectedPipeline.value
98
+ if (!pipeline) return false
99
+ return pipeline.agentKinds.some(
100
+ (kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
101
+ )
102
+ })
103
+ // Sources that can back intake right now (connected / App-installed AND enabled).
104
+ const intakeSources = computed(() => tasks.offeredSources)
105
+
80
106
  watch(open, (isOpen) => {
81
107
  if (!isOpen) return
82
108
  name.value = ''
@@ -92,9 +118,62 @@ watch(open, (isOpen) => {
92
118
  trackerKind.value = tracker.settings.tracker
93
119
  jiraProjectKey.value = tracker.settings.jiraProjectKey ?? ''
94
120
  linearTeamId.value = tracker.settings.linearTeamId ?? ''
121
+ intakeSource.value = null
122
+ intakeJiraProjectKey.value = ''
123
+ intakeLinearTeamId.value = ''
124
+ intakeGithubRepo.value = ''
125
+ intakeTitleFragment.value = ''
126
+ intakeLabels.value = ''
127
+ intakeIssueType.value = ''
128
+ intakeInProgressLabel.value = ''
129
+ // Load the connected task sources so the intake source picker is populated.
130
+ void tasks.probe()
131
+ })
132
+
133
+ // The board field required for the picked source must be filled before a bug-intake schedule saves.
134
+ const intakeReady = computed(() => {
135
+ if (!isBugIntake.value) return true
136
+ if (intakeSource.value === 'jira') return intakeJiraProjectKey.value.trim().length > 0
137
+ if (intakeSource.value === 'linear') return intakeLinearTeamId.value.trim().length > 0
138
+ if (intakeSource.value === 'github') return intakeGithubRepo.value.trim().length > 0
139
+ return false
95
140
  })
96
141
 
97
- const canAdd = computed(() => name.value.trim().length > 0 && pipelineId.value.length > 0)
142
+ function buildIssueIntake(): IssueIntakeConfig {
143
+ const source = intakeSource.value as TaskSourceKind
144
+ const labels = intakeLabels.value
145
+ .split(',')
146
+ .map((l) => l.trim())
147
+ .filter(Boolean)
148
+ return {
149
+ source,
150
+ board: {
151
+ ...(source === 'jira' && intakeJiraProjectKey.value.trim()
152
+ ? { jiraProjectKey: intakeJiraProjectKey.value.trim() }
153
+ : {}),
154
+ ...(source === 'linear' && intakeLinearTeamId.value.trim()
155
+ ? { linearTeamId: intakeLinearTeamId.value.trim() }
156
+ : {}),
157
+ ...(source === 'github' && intakeGithubRepo.value.trim()
158
+ ? { githubRepo: intakeGithubRepo.value.trim() }
159
+ : {}),
160
+ },
161
+ predicates: {
162
+ ...(intakeTitleFragment.value.trim()
163
+ ? { titleFragment: intakeTitleFragment.value.trim() }
164
+ : {}),
165
+ ...(labels.length ? { labels } : {}),
166
+ ...(intakeIssueType.value.trim() ? { issueType: intakeIssueType.value.trim() } : {}),
167
+ },
168
+ ...(source === 'github' && intakeInProgressLabel.value.trim()
169
+ ? { inProgressLabel: intakeInProgressLabel.value.trim() }
170
+ : {}),
171
+ }
172
+ }
173
+
174
+ const canAdd = computed(
175
+ () => name.value.trim().length > 0 && pipelineId.value.length > 0 && intakeReady.value,
176
+ )
98
177
 
99
178
  async function add() {
100
179
  const frameId = ui.addRecurringFrameId
@@ -119,6 +198,7 @@ async function add() {
119
198
  onDemand: onDemand.value,
120
199
  ...(onDemand.value ? {} : { recurrence: recurrence.value }),
121
200
  ...(description.value.trim() ? { description: description.value.trim() } : {}),
201
+ ...(isBugIntake.value ? { issueIntake: buildIssueIntake() } : {}),
122
202
  })
123
203
  ui.closeAddRecurring()
124
204
  } catch (e) {
@@ -238,6 +318,86 @@ async function add() {
238
318
  </UFormField>
239
319
  </div>
240
320
 
321
+ <div v-if="isBugIntake" class="space-y-3 rounded-lg border border-slate-800 p-3">
322
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
323
+ {{ t('board.recurring.intake') }}
324
+ </p>
325
+ <p class="text-[11px] text-slate-500">
326
+ {{ t('board.recurring.intakeHint') }}
327
+ </p>
328
+ <p v-if="intakeSources.length === 0" class="text-[11px] text-amber-500">
329
+ {{ t('board.recurring.intakeNoSources') }}
330
+ </p>
331
+ <div v-else class="flex flex-wrap gap-1">
332
+ <UButton
333
+ v-for="s in intakeSources"
334
+ :key="s.source"
335
+ size="xs"
336
+ :color="intakeSource === s.source ? 'primary' : 'neutral'"
337
+ :variant="intakeSource === s.source ? 'solid' : 'subtle'"
338
+ :icon="s.icon"
339
+ @click="intakeSource = s.source"
340
+ >
341
+ {{ s.label }}
342
+ </UButton>
343
+ </div>
344
+
345
+ <UFormField
346
+ v-if="intakeSource === 'jira'"
347
+ :label="t('board.recurring.jiraProjectKey')"
348
+ required
349
+ >
350
+ <UInput
351
+ v-model="intakeJiraProjectKey"
352
+ :placeholder="t('board.recurring.jiraProjectKeyPlaceholder')"
353
+ class="w-full"
354
+ />
355
+ </UFormField>
356
+ <UFormField
357
+ v-if="intakeSource === 'linear'"
358
+ :label="t('board.recurring.linearTeamId')"
359
+ required
360
+ >
361
+ <UInput v-model="intakeLinearTeamId" placeholder="team_…" class="w-full" />
362
+ </UFormField>
363
+ <UFormField
364
+ v-if="intakeSource === 'github'"
365
+ :label="t('board.recurring.intakeGithubRepo')"
366
+ required
367
+ >
368
+ <!-- A GitHub repo ref is always the literal `owner/name` path, never localized. -->
369
+ <UInput v-model="intakeGithubRepo" placeholder="owner/name" class="w-full" />
370
+ </UFormField>
371
+
372
+ <template v-if="intakeSource">
373
+ <UFormField :label="t('board.recurring.intakeTitleFragment')">
374
+ <UInput
375
+ v-model="intakeTitleFragment"
376
+ :placeholder="t('board.recurring.intakeTitleFragmentPlaceholder')"
377
+ class="w-full"
378
+ />
379
+ </UFormField>
380
+ <UFormField :label="t('board.recurring.intakeLabels')">
381
+ <UInput
382
+ v-model="intakeLabels"
383
+ :placeholder="t('board.recurring.intakeLabelsPlaceholder')"
384
+ class="w-full"
385
+ />
386
+ </UFormField>
387
+ <UFormField :label="t('board.recurring.intakeIssueType')">
388
+ <!-- A literal issue-type example (tracker vocabulary), kept verbatim across locales. -->
389
+ <UInput v-model="intakeIssueType" placeholder="bug" class="w-full" />
390
+ </UFormField>
391
+ <UFormField
392
+ v-if="intakeSource === 'github'"
393
+ :label="t('board.recurring.intakeInProgressLabel')"
394
+ >
395
+ <!-- A literal label example, kept verbatim across locales. -->
396
+ <UInput v-model="intakeInProgressLabel" placeholder="in-progress" class="w-full" />
397
+ </UFormField>
398
+ </template>
399
+ </div>
400
+
241
401
  <p class="text-[11px] text-slate-500">
242
402
  {{ t('board.recurring.footerHint') }}
243
403
  </p>
@@ -7,9 +7,11 @@
7
7
  // result-view host: from the board card / inspector (`ui.openInitiativeTracker`) or
8
8
  // as the planner step's result view. Live `initiative` stream events patch the
9
9
  // store, so an open window follows the plan as it is ingested and later executed.
10
- import { computed } from 'vue'
11
- import type { InitiativeItem } from '~/types/domain'
10
+ import { computed, reactive, ref } from 'vue'
11
+ import type { InitiativeFollowUp, InitiativeItem } from '~/types/domain'
12
12
  import {
13
+ INITIATIVE_FOLLOWUP_STATUS_CHIPS,
14
+ INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
13
15
  INITIATIVE_ITEM_STATUS_CHIPS,
14
16
  INITIATIVE_ITEM_STATUS_LABEL_KEYS,
15
17
  INITIATIVE_STATUS_LABEL_KEYS,
@@ -19,6 +21,7 @@ import {
19
21
  const board = useBoardStore()
20
22
  const initiatives = useInitiativesStore()
21
23
  const { t } = useI18n()
24
+ const toast = useToast()
22
25
 
23
26
  const { open, blockId, close } = useResultView('initiative-tracker', {
24
27
  onOpen: (id) => void initiatives.load(id),
@@ -52,6 +55,93 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
52
55
  ].filter((a): a is string => a !== null)
53
56
  return axes.length ? axes.join(' · ') : t('initiative.tracker.axisNever')
54
57
  }
58
+
59
+ // ---- Curation (slice 4): only meaningful while the initiative is still executing ----
60
+ const editable = computed(() => initiative.value?.status === 'executing')
61
+
62
+ /** Report a failed curation call as a toast (a stale-rev CAS conflict, an illegal edit, …). */
63
+ function reportError(error: unknown) {
64
+ const message = error instanceof Error ? error.message : t('initiative.curation.failed')
65
+ toast.add({ title: t('initiative.curation.failed'), description: message, color: 'error' })
66
+ }
67
+
68
+ // Follow-up promotion: an inline per-follow-up form (phase + optional title override).
69
+ const promotingId = ref<string | null>(null)
70
+ const promoteForm = reactive<{ phaseId: string; title: string }>({ phaseId: '', title: '' })
71
+
72
+ function startPromote(followUp: InitiativeFollowUp) {
73
+ const sourcePhase = (initiative.value?.items ?? []).find(
74
+ (i) => i.id === followUp.sourceItemId,
75
+ )?.phaseId
76
+ promoteForm.phaseId = sourcePhase ?? phases.value[0]?.id ?? ''
77
+ promoteForm.title = followUp.title
78
+ promotingId.value = followUp.id
79
+ }
80
+
81
+ async function submitPromote(followUp: InitiativeFollowUp) {
82
+ if (!initiative.value || !promoteForm.phaseId) return
83
+ try {
84
+ await initiatives.promoteFollowUp(initiative.value.id, followUp.id, {
85
+ phaseId: promoteForm.phaseId,
86
+ ...(promoteForm.title.trim() && promoteForm.title.trim() !== followUp.title
87
+ ? { title: promoteForm.title.trim() }
88
+ : {}),
89
+ })
90
+ promotingId.value = null
91
+ } catch (error) {
92
+ reportError(error)
93
+ }
94
+ }
95
+
96
+ async function dismissFollowUp(followUp: InitiativeFollowUp) {
97
+ if (!initiative.value) return
98
+ try {
99
+ await initiatives.dismissFollowUp(initiative.value.id, followUp.id)
100
+ } catch (error) {
101
+ reportError(error)
102
+ }
103
+ }
104
+
105
+ // Item status control: retry a blocked item, or skip a pending/blocked one.
106
+ async function itemAction(item: InitiativeItem, action: 'retry' | 'skip') {
107
+ if (!initiative.value) return
108
+ try {
109
+ await initiatives.updateItem(initiative.value.id, item.id, { action })
110
+ } catch (error) {
111
+ reportError(error)
112
+ }
113
+ }
114
+
115
+ // Policy editing: retune the two scalar knobs (concurrency + default pipeline) while preserving
116
+ // the planner-authored rules. A full rule editor stays out of scope — re-plan to reshape rules.
117
+ const editingPolicy = ref(false)
118
+ const policyForm = reactive<{ maxConcurrent: number; defaultPipelineId: string }>({
119
+ maxConcurrent: 1,
120
+ defaultPipelineId: '',
121
+ })
122
+
123
+ function startEditPolicy() {
124
+ const policy = initiative.value?.policy
125
+ if (!policy) return
126
+ policyForm.maxConcurrent = policy.maxConcurrent
127
+ policyForm.defaultPipelineId = policy.defaultPipelineId
128
+ editingPolicy.value = true
129
+ }
130
+
131
+ async function savePolicy() {
132
+ const policy = initiative.value?.policy
133
+ if (!initiative.value || !policy) return
134
+ try {
135
+ await initiatives.updatePolicy(initiative.value.id, {
136
+ ...policy,
137
+ maxConcurrent: policyForm.maxConcurrent,
138
+ defaultPipelineId: policyForm.defaultPipelineId.trim() || policy.defaultPipelineId,
139
+ })
140
+ editingPolicy.value = false
141
+ } catch (error) {
142
+ reportError(error)
143
+ }
144
+ }
55
145
  </script>
56
146
 
57
147
  <template>
@@ -193,10 +283,34 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
193
283
  <div v-if="item.note" class="mt-0.5 text-[10px] text-amber-300/80">
194
284
  {{ item.note }}
195
285
  </div>
286
+ <div
287
+ v-if="
288
+ editable && (item.status === 'blocked' || item.status === 'pending')
289
+ "
290
+ class="mt-1 flex gap-1.5"
291
+ >
292
+ <button
293
+ v-if="item.status === 'blocked'"
294
+ class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
295
+ :disabled="initiatives.curating"
296
+ :data-testid="`initiative-item-retry-${item.id}`"
297
+ @click="itemAction(item, 'retry')"
298
+ >
299
+ {{ t('initiative.curation.retry') }}
300
+ </button>
301
+ <button
302
+ class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
303
+ :disabled="initiatives.curating"
304
+ :data-testid="`initiative-item-skip-${item.id}`"
305
+ @click="itemAction(item, 'skip')"
306
+ >
307
+ {{ t('initiative.curation.skip') }}
308
+ </button>
309
+ </div>
196
310
  </td>
197
311
  <td class="px-3 py-2 align-top">
198
312
  <UBadge
199
- :color="INITIATIVE_ITEM_STATUS_CHIPS[item.status] as any"
313
+ :color="INITIATIVE_ITEM_STATUS_CHIPS[item.status]"
200
314
  variant="subtle"
201
315
  size="sm"
202
316
  >
@@ -225,10 +339,20 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
225
339
 
226
340
  <!-- Execution policy -->
227
341
  <section v-if="initiative.policy" class="mb-4">
228
- <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
229
- {{ t('initiative.tracker.policy') }}
230
- </h3>
231
- <ul class="text-[12px] text-slate-300">
342
+ <div class="mb-1 flex items-center gap-2">
343
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
344
+ {{ t('initiative.tracker.policy') }}
345
+ </h3>
346
+ <button
347
+ v-if="editable && !editingPolicy"
348
+ class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800"
349
+ data-testid="initiative-policy-edit"
350
+ @click="startEditPolicy"
351
+ >
352
+ {{ t('initiative.curation.edit') }}
353
+ </button>
354
+ </div>
355
+ <ul v-if="!editingPolicy" class="text-[12px] text-slate-300">
232
356
  <li>
233
357
  {{
234
358
  t('initiative.tracker.maxConcurrent', {
@@ -245,6 +369,45 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
245
369
  <code class="text-sky-300">{{ initiative.policy.defaultPipelineId }}</code>
246
370
  </li>
247
371
  </ul>
372
+ <!-- Edit form: the two scalar knobs; planner-authored rules are preserved. -->
373
+ <div v-else class="flex flex-col gap-2 rounded-lg border border-slate-800 p-3">
374
+ <label class="flex items-center gap-2 text-[12px] text-slate-300">
375
+ <span class="w-40">{{ t('initiative.curation.maxConcurrentField') }}</span>
376
+ <input
377
+ v-model.number="policyForm.maxConcurrent"
378
+ type="number"
379
+ min="1"
380
+ max="20"
381
+ class="w-20 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-200"
382
+ data-testid="initiative-policy-max-concurrent"
383
+ />
384
+ </label>
385
+ <label class="flex items-center gap-2 text-[12px] text-slate-300">
386
+ <span class="w-40">{{ t('initiative.curation.defaultPipelineField') }}</span>
387
+ <input
388
+ v-model="policyForm.defaultPipelineId"
389
+ type="text"
390
+ class="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 font-mono text-[11px] text-slate-200"
391
+ data-testid="initiative-policy-default-pipeline"
392
+ />
393
+ </label>
394
+ <div class="flex gap-2">
395
+ <button
396
+ class="rounded bg-indigo-600 px-2 py-1 text-[11px] text-white hover:bg-indigo-500 disabled:opacity-50"
397
+ :disabled="initiatives.curating"
398
+ data-testid="initiative-policy-save"
399
+ @click="savePolicy"
400
+ >
401
+ {{ t('initiative.curation.save') }}
402
+ </button>
403
+ <button
404
+ class="rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
405
+ @click="editingPolicy = false"
406
+ >
407
+ {{ t('initiative.curation.cancel') }}
408
+ </button>
409
+ </div>
410
+ </div>
248
411
  </section>
249
412
 
250
413
  <!-- Logs -->
@@ -275,10 +438,85 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
275
438
  <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
276
439
  {{ t('initiative.tracker.followUps') }}
277
440
  </h3>
278
- <ul class="list-inside list-disc text-[13px] text-slate-300">
279
- <li v-for="f in initiative.followUps" :key="f.id">
280
- <span class="font-medium">{{ f.title }}</span>
281
- <span v-if="f.detail" class="text-slate-400"> — {{ f.detail }}</span>
441
+ <ul class="flex flex-col gap-2 text-[13px] text-slate-300">
442
+ <li
443
+ v-for="f in initiative.followUps"
444
+ :key="f.id"
445
+ class="rounded-lg border border-slate-800 p-2.5"
446
+ :data-testid="`initiative-followup-${f.id}`"
447
+ >
448
+ <div class="flex items-start gap-2">
449
+ <div class="min-w-0 flex-1">
450
+ <span class="font-medium">{{ f.title }}</span>
451
+ <span v-if="f.detail" class="text-slate-400"> — {{ f.detail }}</span>
452
+ </div>
453
+ <UBadge
454
+ :color="INITIATIVE_FOLLOWUP_STATUS_CHIPS[f.status]"
455
+ variant="subtle"
456
+ size="sm"
457
+ >
458
+ {{ t(INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS[f.status]) }}
459
+ </UBadge>
460
+ </div>
461
+ <!-- Triage actions for an open follow-up (only while executing) -->
462
+ <div v-if="editable && f.status === 'open'" class="mt-2">
463
+ <div v-if="promotingId === f.id" class="flex flex-col gap-2">
464
+ <label class="flex items-center gap-2 text-[12px]">
465
+ <span class="text-slate-400">{{
466
+ t('initiative.curation.phaseField')
467
+ }}</span>
468
+ <select
469
+ v-model="promoteForm.phaseId"
470
+ class="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-200"
471
+ data-testid="initiative-promote-phase"
472
+ >
473
+ <option v-for="p in phases" :key="p.id" :value="p.id">
474
+ {{ p.title }}
475
+ </option>
476
+ </select>
477
+ </label>
478
+ <input
479
+ v-model="promoteForm.title"
480
+ type="text"
481
+ class="rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[12px] text-slate-200"
482
+ :placeholder="t('initiative.curation.itemTitlePlaceholder')"
483
+ data-testid="initiative-promote-title"
484
+ />
485
+ <div class="flex gap-2">
486
+ <button
487
+ class="rounded bg-indigo-600 px-2 py-1 text-[11px] text-white hover:bg-indigo-500 disabled:opacity-50"
488
+ :disabled="initiatives.curating || !promoteForm.phaseId"
489
+ data-testid="initiative-promote-submit"
490
+ @click="submitPromote(f)"
491
+ >
492
+ {{ t('initiative.curation.promoteConfirm') }}
493
+ </button>
494
+ <button
495
+ class="rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
496
+ @click="promotingId = null"
497
+ >
498
+ {{ t('initiative.curation.cancel') }}
499
+ </button>
500
+ </div>
501
+ </div>
502
+ <div v-else class="flex gap-1.5">
503
+ <button
504
+ class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800"
505
+ data-testid="initiative-followup-promote"
506
+ @click="startPromote(f)"
507
+ >
508
+ {{ t('initiative.curation.promote') }}
509
+ </button>
510
+ <button
511
+ class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
512
+ :disabled="initiatives.curating"
513
+ data-testid="initiative-followup-dismiss"
514
+ @click="dismissFollowUp(f)"
515
+ >
516
+ {{ t('initiative.curation.dismiss') }}
517
+ </button>
518
+ </div>
519
+ </div>
282
520
  </li>
283
521
  </ul>
284
522
  </section>
@@ -66,6 +66,7 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
66
66
  no_rationale: 'panels.mergerResult.reason.no_rationale',
67
67
  no_assessment: 'panels.mergerResult.reason.no_assessment',
68
68
  merge_failed: 'panels.mergerResult.reason.merge_failed',
69
+ merge_partial: 'panels.mergerResult.reason.merge_partial',
69
70
  }
70
71
  const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
71
72
  auto_merged: 'panels.mergerResult.outcome.auto_merged',