@cat-factory/app 0.226.0 → 0.228.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 (32) hide show
  1. package/README.md +25 -0
  2. package/app/components/board/nodes/TaskCard.vue +68 -8
  3. package/app/components/outcome/OutcomeSummaryWindow.vue +614 -0
  4. package/app/components/panels/MergerResultView.vue +7 -0
  5. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  6. package/app/components/panels/inspector/TaskExecution.vue +33 -0
  7. package/app/components/riskPolicy/RiskPolicyPreview.vue +6 -2
  8. package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +65 -2
  9. package/app/components/settings/MergeRolePolicyEditor.logic.ts +72 -0
  10. package/app/components/settings/MergeRolePolicyEditor.vue +133 -39
  11. package/app/components/settings/RiskPolicyPanel.vue +8 -0
  12. package/app/composables/usePipelineErrorToast.ts +4 -0
  13. package/app/composables/useRunDeepLink.ts +12 -4
  14. package/app/modular/result-views.ts +4 -0
  15. package/app/stores/ui/resultViews.ts +28 -0
  16. package/app/stores/ui.dispatch.spec.ts +55 -0
  17. package/app/types/merge.ts +2 -0
  18. package/app/utils/riskPolicy.spec.ts +21 -2
  19. package/app/utils/riskPolicy.ts +12 -0
  20. package/app/utils/runOutcome.spec.ts +492 -0
  21. package/app/utils/runOutcome.ts +509 -0
  22. package/i18n/locales/de.json +124 -6
  23. package/i18n/locales/en.json +127 -6
  24. package/i18n/locales/es.json +124 -6
  25. package/i18n/locales/fr.json +124 -6
  26. package/i18n/locales/he.json +124 -6
  27. package/i18n/locales/it.json +124 -6
  28. package/i18n/locales/ja.json +124 -6
  29. package/i18n/locales/pl.json +124 -6
  30. package/i18n/locales/tr.json +124 -6
  31. package/i18n/locales/uk.json +124 -6
  32. package/package.json +2 -2
@@ -19,6 +19,7 @@ import type { ChangeClass, ReviewEffort } from '~/types/merge'
19
19
  import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
20
20
  import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
21
21
  import { inputGateNoticeFor } from '~/utils/inputGate'
22
+ import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
22
23
 
23
24
  const props = defineProps<{ block: Block }>()
24
25
 
@@ -87,6 +88,22 @@ const failedRun = computed(() => {
87
88
  // CURRENT status, so the error trail stays viewable after a restart clears the top banner.
88
89
  const failureHistory = computed(() => agentRuns.byBlock[props.block.id]?.failureHistory ?? [])
89
90
 
91
+ /**
92
+ * Whether this task has a result worth reading in product terms: a pull request, or a step that
93
+ * recorded evidence. Asked of the shared reduction rather than re-derived here, so the panel and
94
+ * the board card can never disagree about which runs have an outcome to open.
95
+ *
96
+ * The spec is deliberately not loaded for this check: it only ever adds TITLES to requirement
97
+ * rows, never sections, so nothing about whether there is something to show depends on it. The
98
+ * window loads it on open.
99
+ */
100
+ const outcomeReadable = computed(() =>
101
+ hasOutcomeToShow(composeRunOutcome({ block: props.block, instance: instance.value ?? null })),
102
+ )
103
+ function openOutcome() {
104
+ ui.openOutcome(props.block.id, instance.value?.id ?? null)
105
+ }
106
+
90
107
  const pr = computed(() => props.block.pullRequest)
91
108
  /** A PR is merged once the block is `done`; otherwise it is open awaiting merge. */
92
109
  const prMerged = computed(() => props.block.status === 'done')
@@ -605,6 +622,22 @@ async function mergePr() {
605
622
  <!-- error trail of prior attempts (survives a retry/restart that cleared the banner) -->
606
623
  <AgentFailureHistory :failures="failureHistory" />
607
624
 
625
+ <!-- Read the result: the outcome summary is the way in, and the pull request below is the
626
+ way to the diff. Offered whenever there is evidence or a PR to read, which includes a
627
+ merged task whose run instance is long gone. -->
628
+ <UButton
629
+ v-if="outcomeReadable"
630
+ color="primary"
631
+ variant="soft"
632
+ size="sm"
633
+ icon="i-lucide-clipboard-check"
634
+ block
635
+ data-testid="inspector-open-outcome"
636
+ @click="openOutcome"
637
+ >
638
+ {{ t('inspector.execution.readOutcome') }}
639
+ </UButton>
640
+
608
641
  <!-- Open PR: link straight to it on GitHub -->
609
642
  <div v-if="pr" class="space-y-2">
610
643
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
@@ -23,11 +23,12 @@ const ROLE_LABEL: Record<WorkspaceRole, () => string> = {
23
23
  viewer: () => t('merge.role.viewer'),
24
24
  }
25
25
  const roleLayer = computed(() => {
26
- const { sandboxed, narrowed } = rolePolicySummary(props.policy)
26
+ const { sandboxed, narrowed, scoped } = rolePolicySummary(props.policy)
27
27
  return {
28
28
  sandboxed: sandboxed.map((r) => ROLE_LABEL[r]()).join(', '),
29
29
  narrowed: narrowed.map((r) => ROLE_LABEL[r]()).join(', '),
30
- any: sandboxed.length > 0 || narrowed.length > 0,
30
+ scoped: scoped.map((r) => ROLE_LABEL[r]()).join(', '),
31
+ any: sandboxed.length > 0 || narrowed.length > 0 || scoped.length > 0,
31
32
  }
32
33
  })
33
34
 
@@ -94,6 +95,9 @@ const ceilings = computed(() =>
94
95
  <p v-if="roleLayer.sandboxed" class="text-[12px] leading-snug text-slate-400">
95
96
  {{ t('riskPolicy.preview.roleSandboxed', { roles: roleLayer.sandboxed }) }}
96
97
  </p>
98
+ <p v-if="roleLayer.scoped" class="text-[12px] leading-snug text-slate-400">
99
+ {{ t('riskPolicy.preview.roleScoped', { roles: roleLayer.scoped }) }}
100
+ </p>
97
101
  <p v-if="roleLayer.narrowed" class="text-[12px] leading-snug text-slate-400">
98
102
  {{ t('riskPolicy.preview.roleNarrowed', { roles: roleLayer.narrowed }) }}
99
103
  </p>
@@ -1,13 +1,17 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { MERGE_CLASS_RULES } from '@cat-factory/contracts'
3
- import type { ClassRulesByRole, MergeClassRules } from '~/types/merge'
2
+ import { MERGE_CLASS_RULES, RULEABLE_CHANGE_CLASSES } from '@cat-factory/contracts'
3
+ import type { ClassRulesByRole, MergeClassRules, SubmissionClassesByRole } from '~/types/merge'
4
4
  import {
5
5
  INHERIT_RULE,
6
6
  narrowingOptionsFor,
7
7
  roleClassRuleRows,
8
8
  roleNarrowedCount,
9
+ roleSubmissionRows,
10
+ roleSubmissionScoped,
9
11
  setRoleClassRule,
12
+ setRoleSubmissionScoped,
10
13
  toggleDryRunRole,
14
+ toggleSubmissionClass,
11
15
  } from '~/components/settings/MergeRolePolicyEditor.logic'
12
16
 
13
17
  describe('narrowingOptionsFor', () => {
@@ -144,3 +148,62 @@ describe('roleNarrowedCount', () => {
144
148
  expect(roleNarrowedCount({ docs: 'never', source: 'never' })).toBe(2)
145
149
  })
146
150
  })
151
+
152
+ // The submission allowlist's editing shape turns on one distinction the wire contract makes and a
153
+ // tick-box grid cannot render on its own: an ABSENT entry (unrestricted) and an EMPTY list (lands
154
+ // nothing) are different policies. Every case below is about keeping those two apart.
155
+ describe('roleSubmissionScoped', () => {
156
+ it('reads an EMPTY allowlist as scoped, not as the unrestricted default', () => {
157
+ expect(roleSubmissionScoped({ member: [] }, 'member')).toBe(true)
158
+ expect(roleSubmissionScoped({ member: ['docs'] }, 'member')).toBe(true)
159
+ })
160
+
161
+ it('reads a role with no entry as unrestricted', () => {
162
+ expect(roleSubmissionScoped({ member: ['docs'] }, 'admin')).toBe(false)
163
+ expect(roleSubmissionScoped({}, 'member')).toBe(false)
164
+ })
165
+ })
166
+
167
+ describe('roleSubmissionRows', () => {
168
+ it('renders one row per ruleable class, in the shared order, ticked by membership', () => {
169
+ const rows = roleSubmissionRows(['source', 'docs'])
170
+ expect(rows.map((r) => r.changeClass)).toEqual([...RULEABLE_CHANGE_CLASSES])
171
+ expect(rows.filter((r) => r.allowed).map((r) => r.changeClass)).toEqual(['docs', 'source'])
172
+ })
173
+ })
174
+
175
+ describe('setRoleSubmissionScoped', () => {
176
+ // Turning the switch ON must not silently impose a policy nobody clicked: seeding today's
177
+ // landable classes leaves the role exactly where it was, and the operator subtracts from there.
178
+ it('seeds every class when scoping is turned on', () => {
179
+ expect(setRoleSubmissionScoped({}, 'member', true).member).toEqual([...RULEABLE_CHANGE_CLASSES])
180
+ })
181
+
182
+ it('removes the entry entirely when scoping is turned off', () => {
183
+ const byRole: SubmissionClassesByRole = { member: [], admin: ['docs'] }
184
+ const next = setRoleSubmissionScoped(byRole, 'member', false)
185
+ expect('member' in next).toBe(false)
186
+ expect(next.admin).toEqual(['docs'])
187
+ })
188
+ })
189
+
190
+ describe('toggleSubmissionClass', () => {
191
+ it('adds and removes one class, keeping the shared class order', () => {
192
+ const ticked = toggleSubmissionClass({ member: ['source'] }, 'member', 'docs', true)
193
+ expect(ticked.member).toEqual(['docs', 'source'])
194
+ expect(toggleSubmissionClass(ticked, 'member', 'source', false).member).toEqual(['docs'])
195
+ })
196
+
197
+ // Unticking the last class is the policy "this role lands nothing", so it must NOT prune back
198
+ // to an absent entry, which would silently invert the click into "unrestricted".
199
+ it('leaves an EMPTY list rather than un-scoping the role', () => {
200
+ const next = toggleSubmissionClass({ member: ['docs'] }, 'member', 'docs', false)
201
+ expect(next.member).toEqual([])
202
+ expect(roleSubmissionScoped(next, 'member')).toBe(true)
203
+ })
204
+
205
+ it('leaves other roles untouched', () => {
206
+ const next = toggleSubmissionClass({ admin: ['docs'] }, 'member', 'docs', true)
207
+ expect(next.admin).toEqual(['docs'])
208
+ })
209
+ })
@@ -23,6 +23,7 @@ import type {
23
23
  MergeClassRule,
24
24
  MergeClassRules,
25
25
  RuleableChangeClass,
26
+ SubmissionClassesByRole,
26
27
  WorkspaceRole,
27
28
  } from '~/types/merge'
28
29
 
@@ -131,3 +132,74 @@ export function toggleDryRunRole(
131
132
  export function roleNarrowedCount(entry: MergeClassRules | undefined): number {
132
133
  return entry ? Object.keys(entry).length : 0
133
134
  }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // The per-role SUBMISSION ALLOWLIST: which change classes a role may LAND at all. A third
138
+ // setting rather than a fourth rule value, because it answers a different question: a class rule
139
+ // decides how much review landing takes, where this decides whether the platform lands it at all,
140
+ // and it is refused at BOTH exits, the manual merge included.
141
+ //
142
+ // The editing shape follows the wire shape's own distinction: an ABSENT entry is unrestricted
143
+ // and an EMPTY list is "this role lands nothing", which are different policies. So the row is a
144
+ // switch (scoped / not) plus a set of tick boxes, never a set of tick boxes alone. With tick
145
+ // boxes alone, "unrestricted" and "lands nothing" would be the same rendered state.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** One class's tick box inside one role's allowlist. */
149
+ export interface RoleSubmissionRow {
150
+ changeClass: RuleableChangeClass
151
+ allowed: boolean
152
+ }
153
+
154
+ /** Whether this role carries an allowlist at all (as opposed to being unrestricted). */
155
+ export function roleSubmissionScoped(
156
+ byRole: SubmissionClassesByRole,
157
+ role: WorkspaceRole,
158
+ ): boolean {
159
+ return byRole[role] !== undefined
160
+ }
161
+
162
+ /** One role's tick boxes, in the shared class order. Empty rows for an unscoped role. */
163
+ export function roleSubmissionRows(entry: readonly RuleableChangeClass[]): RoleSubmissionRow[] {
164
+ return RULEABLE_CHANGE_CLASSES.map((changeClass) => ({
165
+ changeClass,
166
+ allowed: entry.includes(changeClass),
167
+ }))
168
+ }
169
+
170
+ /**
171
+ * Turn the allowlist on or off for a role. Turning it ON seeds the classes that are landable
172
+ * TODAY, which is behaviourally where the role already was, so the operator subtracts from a
173
+ * known state rather than starting from a policy ("lands nothing") they did not ask for.
174
+ *
175
+ * That seeded list is NOT the identity, and deliberately: from here on the role is scoped, so a
176
+ * class the vocabulary gains in a later release falls outside it. Opting in is what earns that.
177
+ */
178
+ export function setRoleSubmissionScoped(
179
+ byRole: SubmissionClassesByRole,
180
+ role: WorkspaceRole,
181
+ scoped: boolean,
182
+ ): SubmissionClassesByRole {
183
+ const next: SubmissionClassesByRole = { ...byRole }
184
+ if (scoped) next[role] = [...RULEABLE_CHANGE_CLASSES]
185
+ else delete next[role]
186
+ return next
187
+ }
188
+
189
+ /**
190
+ * Tick or untick one class for a role that is already scoped, keeping the shared class order so
191
+ * two presets carrying the same policy carry the same array. Unticking the last class leaves an
192
+ * EMPTY list rather than removing the entry: that is the real policy "this role lands nothing",
193
+ * and silently promoting it to unrestricted would be the exact inversion of what was clicked.
194
+ */
195
+ export function toggleSubmissionClass(
196
+ byRole: SubmissionClassesByRole,
197
+ role: WorkspaceRole,
198
+ changeClass: RuleableChangeClass,
199
+ allowed: boolean,
200
+ ): SubmissionClassesByRole {
201
+ const current = new Set(byRole[role] ?? [])
202
+ if (allowed) current.add(changeClass)
203
+ else current.delete(changeClass)
204
+ return { ...byRole, [role]: RULEABLE_CHANGE_CLASSES.filter((c) => current.has(c)) }
205
+ }
@@ -1,11 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  // The ROLE layer of one merge preset: what happens to a run depending on WHO started it.
3
3
  //
4
- // Two settings, edited together because they answer the same question at two strengths. A role can
5
- // be held to stricter per-class rules than the preset's base map (`classRulesByRole`), or held to
6
- // dry runs entirely (`dryRunRoles`): the pipeline works and opens its pull request, and nothing
7
- // merges. The second outranks the first, which is why a sandboxed role says so on its row rather
8
- // than leaving the class rules below reading as the policy.
4
+ // Three settings, edited together because they answer the same question at three strengths. A role
5
+ // can be held to stricter per-class rules than the preset's base map (`classRulesByRole`), held to
6
+ // an allowlist of the change classes it may LAND at all (`submissionClassesByRole`), or held to dry
7
+ // runs entirely (`dryRunRoles`): the pipeline works and opens its pull request, and nothing merges.
8
+ // The strongest of the three that applies is what governs, which is why a sandboxed role says so on
9
+ // its row rather than leaving the two below it reading as the policy.
9
10
  //
10
11
  // Composition is NARROW-ONLY, so this editor only offers a role rules STRICTER than the base one
11
12
  // (see MergeRolePolicyEditor.logic.ts). A looser rule is discarded by the engine, and an editor
@@ -18,14 +19,19 @@ import type {
18
19
  MergeClassRule,
19
20
  MergeClassRules,
20
21
  RuleableChangeClass,
22
+ SubmissionClassesByRole,
21
23
  WorkspaceRole,
22
24
  } from '~/types/merge'
23
25
  import {
24
26
  INHERIT_RULE,
25
27
  roleClassRuleRows,
26
28
  roleNarrowedCount,
29
+ roleSubmissionRows,
30
+ roleSubmissionScoped,
27
31
  setRoleClassRule,
32
+ setRoleSubmissionScoped,
28
33
  toggleDryRunRole,
34
+ toggleSubmissionClass,
29
35
  type RoleRuleSelection,
30
36
  } from '~/components/settings/MergeRolePolicyEditor.logic'
31
37
 
@@ -34,6 +40,12 @@ const props = defineProps<{
34
40
  classRulesByRole: ClassRulesByRole
35
41
  /** The roles whose runs this preset sandboxes. */
36
42
  dryRunRoles: DryRunRoles
43
+ /**
44
+ * Which change classes each role may LAND. A role with no entry is unrestricted; an entry with
45
+ * no classes lands nothing. The editor keeps those two apart (a switch, then tick boxes),
46
+ * because tick boxes alone would render them identically.
47
+ */
48
+ submissionClassesByRole: SubmissionClassesByRole
37
49
  /** The base rules the role layer narrows, so each row can show what it inherits. */
38
50
  classRules: MergeClassRules
39
51
  /**
@@ -50,6 +62,7 @@ const props = defineProps<{
50
62
  const emit = defineEmits<{
51
63
  'update:classRulesByRole': [ClassRulesByRole]
52
64
  'update:dryRunRoles': [DryRunRoles]
65
+ 'update:submissionClassesByRole': [SubmissionClassesByRole]
53
66
  }>()
54
67
 
55
68
  const { t } = useI18n()
@@ -92,6 +105,14 @@ const roles = computed(() =>
92
105
  sandboxed: props.dryRunRoles.includes(role),
93
106
  narrowed,
94
107
  open: !!expanded.value[role],
108
+ // Scoped-ness is read off the MAP rather than off the entry being truthy, so an empty
109
+ // allowlist (a real policy: this role lands nothing) stays scoped instead of reading as
110
+ // the unrestricted default.
111
+ submissionScoped: roleSubmissionScoped(props.submissionClassesByRole, role),
112
+ submissionRows: roleSubmissionRows(props.submissionClassesByRole[role] ?? []).map((row) => ({
113
+ ...row,
114
+ label: t(CLASS_LABEL_KEYS[row.changeClass]),
115
+ })),
95
116
  rows: roleClassRuleRows(props.classRules, entry).map((row) => ({
96
117
  ...row,
97
118
  label: t(CLASS_LABEL_KEYS[row.changeClass]),
@@ -118,6 +139,24 @@ function setSandboxed(role: WorkspaceRole, sandboxed: boolean) {
118
139
  function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: RoleRuleSelection) {
119
140
  emit('update:classRulesByRole', setRoleClassRule(props.classRulesByRole, role, changeClass, rule))
120
141
  }
142
+
143
+ function setSubmissionScoped(role: WorkspaceRole, scoped: boolean) {
144
+ emit(
145
+ 'update:submissionClassesByRole',
146
+ setRoleSubmissionScoped(props.submissionClassesByRole, role, scoped),
147
+ )
148
+ }
149
+
150
+ function setSubmissionClass(
151
+ role: WorkspaceRole,
152
+ changeClass: RuleableChangeClass,
153
+ allowed: boolean,
154
+ ) {
155
+ emit(
156
+ 'update:submissionClassesByRole',
157
+ toggleSubmissionClass(props.submissionClassesByRole, role, changeClass, allowed),
158
+ )
159
+ }
121
160
  </script>
122
161
 
123
162
  <template>
@@ -129,8 +168,9 @@ function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: Ro
129
168
  <p class="mt-0.5 text-[11px] leading-snug text-slate-500">
130
169
  {{ t('settings.riskPolicy.roleRules.help') }}
131
170
  </p>
132
- <!-- Auto-merge off already sends every pull request to a human, so only the sandbox half of
133
- this editor still changes anything (it refuses the manual merge as well). -->
171
+ <!-- Auto-merge off already sends every pull request to a human, so the per-class narrowing
172
+ has nothing left to subtract. The other two settings still bite, because both refuse the
173
+ MANUAL merge as well, which is the one thing the master switch leaves open. -->
134
174
  <p
135
175
  v-if="!autoMergeEnabled"
136
176
  class="mt-1 text-[11px] leading-snug text-amber-400/90"
@@ -174,11 +214,20 @@ function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: Ro
174
214
  )
175
215
  }}
176
216
  </UButton>
217
+ <!-- A collapsed group must not hide an active policy: the tally above counts class rules
218
+ only, and a role can land nothing at all while showing "No class limits". -->
219
+ <span
220
+ v-if="group.submissionScoped"
221
+ class="text-[11px] text-amber-400/90"
222
+ :data-testid="`merge-role-submission-badge-${group.role}`"
223
+ >
224
+ {{ t('settings.riskPolicy.roleRules.submissionBadge') }}
225
+ </span>
177
226
  </div>
178
227
 
179
- <!-- A sandboxed role merges nothing at all, so the class rules below cannot make its runs
180
- any stricter. They stay editable (removing the sandbox brings them straight back) and
181
- the row says which of the two is actually governing. -->
228
+ <!-- A sandboxed role merges nothing at all, so neither limit below can make its runs any
229
+ stricter. Both stay editable (removing the sandbox brings them straight back) and the
230
+ row says which of the three is actually governing. -->
182
231
  <p
183
232
  v-if="group.sandboxed"
184
233
  class="mt-1 text-[11px] leading-snug text-amber-400/90"
@@ -187,38 +236,83 @@ function setRule(role: WorkspaceRole, changeClass: RuleableChangeClass, rule: Ro
187
236
  {{ t('settings.riskPolicy.roleRules.sandboxNote') }}
188
237
  </p>
189
238
 
190
- <div v-if="group.open" class="mt-2 space-y-1.5 border-t border-slate-800 pt-2">
191
- <p v-if="!anyBaseRule" class="text-[11px] leading-snug text-slate-500">
192
- {{ t('settings.riskPolicy.roleRules.baseHint') }}
193
- </p>
194
- <div
195
- v-for="row in group.rows"
196
- :key="row.changeClass"
197
- class="flex flex-wrap items-center gap-2"
198
- :data-testid="`merge-role-row-${group.role}-${row.changeClass}`"
199
- >
200
- <span class="min-w-[7rem] text-xs text-slate-400">{{ row.label }}</span>
201
- <USelect
202
- :model-value="row.selected"
203
- :items="row.items"
204
- value-key="value"
239
+ <div v-if="group.open" class="mt-2 space-y-2 border-t border-slate-800 pt-2">
240
+ <!-- What this role may LAND at all. Above the class rules because it outranks them: a
241
+ class can be auto-mergeable under the rules and still outside this list, and then
242
+ nothing merges, through the review card's own button included. -->
243
+ <div class="space-y-1.5">
244
+ <USwitch
245
+ :model-value="group.submissionScoped"
205
246
  size="sm"
206
- class="w-52"
207
- :disabled="disabled || row.items.length === 1"
208
- :data-testid="`merge-role-rule-${group.role}-${row.changeClass}`"
209
- @update:model-value="setRule(group.role, row.changeClass, $event as RoleRuleSelection)"
247
+ :disabled="disabled"
248
+ :label="t('settings.riskPolicy.roleRules.submissionLabel')"
249
+ :data-testid="`merge-role-submission-${group.role}`"
250
+ @update:model-value="setSubmissionScoped(group.role, $event)"
210
251
  />
211
- <!-- Nothing left to narrow: the base rule already routes this class to a human. -->
212
- <span v-if="row.items.length === 1" class="text-[11px] text-slate-500">
213
- {{ t('settings.riskPolicy.roleRules.alreadyStrictest') }}
214
- </span>
215
- <span
216
- v-else-if="row.redundant"
217
- class="text-[11px] text-amber-400/90"
218
- :data-testid="`merge-role-redundant-${group.role}-${row.changeClass}`"
252
+ <p class="text-[11px] leading-snug text-slate-500">
253
+ {{ t('settings.riskPolicy.roleRules.submissionHelp') }}
254
+ </p>
255
+ <div v-if="group.submissionScoped" class="flex flex-wrap gap-x-4 gap-y-1">
256
+ <UCheckbox
257
+ v-for="row in group.submissionRows"
258
+ :key="row.changeClass"
259
+ :model-value="row.allowed"
260
+ size="sm"
261
+ :disabled="disabled"
262
+ :label="row.label"
263
+ :data-testid="`merge-role-submission-class-${group.role}-${row.changeClass}`"
264
+ @update:model-value="setSubmissionClass(group.role, row.changeClass, $event === true)"
265
+ />
266
+ </div>
267
+ <!-- An empty allowlist is a real policy, not an unfinished edit, so it says what it
268
+ does rather than reading as a switch somebody forgot to fill in. -->
269
+ <p
270
+ v-if="group.submissionScoped && !group.submissionRows.some((r) => r.allowed)"
271
+ class="text-[11px] leading-snug text-amber-400/90"
272
+ :data-testid="`merge-role-submission-none-${group.role}`"
273
+ >
274
+ {{ t('settings.riskPolicy.roleRules.submissionNone') }}
275
+ </p>
276
+ </div>
277
+
278
+ <div class="space-y-1.5 border-t border-slate-800/70 pt-2">
279
+ <p class="text-[11px] leading-snug text-slate-500">
280
+ {{ t('settings.riskPolicy.roleRules.classHeading') }}
281
+ </p>
282
+ <p v-if="!anyBaseRule" class="text-[11px] leading-snug text-slate-500">
283
+ {{ t('settings.riskPolicy.roleRules.baseHint') }}
284
+ </p>
285
+ <div
286
+ v-for="row in group.rows"
287
+ :key="row.changeClass"
288
+ class="flex flex-wrap items-center gap-2"
289
+ :data-testid="`merge-role-row-${group.role}-${row.changeClass}`"
219
290
  >
220
- {{ t('settings.riskPolicy.roleRules.redundant') }}
221
- </span>
291
+ <span class="min-w-[7rem] text-xs text-slate-400">{{ row.label }}</span>
292
+ <USelect
293
+ :model-value="row.selected"
294
+ :items="row.items"
295
+ value-key="value"
296
+ size="sm"
297
+ class="w-52"
298
+ :disabled="disabled || row.items.length === 1"
299
+ :data-testid="`merge-role-rule-${group.role}-${row.changeClass}`"
300
+ @update:model-value="
301
+ setRule(group.role, row.changeClass, $event as RoleRuleSelection)
302
+ "
303
+ />
304
+ <!-- Nothing left to narrow: the base rule already routes this class to a human. -->
305
+ <span v-if="row.items.length === 1" class="text-[11px] text-slate-500">
306
+ {{ t('settings.riskPolicy.roleRules.alreadyStrictest') }}
307
+ </span>
308
+ <span
309
+ v-else-if="row.redundant"
310
+ class="text-[11px] text-amber-400/90"
311
+ :data-testid="`merge-role-redundant-${group.role}-${row.changeClass}`"
312
+ >
313
+ {{ t('settings.riskPolicy.roleRules.redundant') }}
314
+ </span>
315
+ </div>
222
316
  </div>
223
317
  </div>
224
318
  </div>
@@ -11,6 +11,7 @@ import type {
11
11
  MergeClassRules,
12
12
  RiskPolicy,
13
13
  RequirementConcernLevel,
14
+ SubmissionClassesByRole,
14
15
  } from '~/types/merge'
15
16
  import type { StepGating } from '@cat-factory/contracts'
16
17
  import {
@@ -90,6 +91,9 @@ interface Draft {
90
91
  // which is why clearing one in the editor is a plain omission.
91
92
  classRulesByRole: ClassRulesByRole
92
93
  dryRunRoles: DryRunRoles
94
+ // Which classes each role may LAND at all. A role with no entry is unrestricted, so `{}` is the
95
+ // identity; an entry with no classes is the different policy that the role lands nothing.
96
+ submissionClassesByRole: SubmissionClassesByRole
93
97
  // Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
94
98
  forkEnabled: boolean
95
99
  forkMinComplexity: number
@@ -129,6 +133,7 @@ function toDraft(p: RiskPolicy): Draft {
129
133
  classRules: { ...p.classRules },
130
134
  classRulesByRole: { ...p.classRulesByRole },
131
135
  dryRunRoles: [...p.dryRunRoles],
136
+ submissionClassesByRole: { ...p.submissionClassesByRole },
132
137
  forkEnabled: p.forkDecision?.enabled ?? false,
133
138
  forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
134
139
  forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
@@ -174,6 +179,7 @@ async function save(p: RiskPolicy) {
174
179
  classRules: d.classRules,
175
180
  classRulesByRole: d.classRulesByRole,
176
181
  dryRunRoles: d.dryRunRoles,
182
+ submissionClassesByRole: d.submissionClassesByRole,
177
183
  forkDecision: forkGating(d),
178
184
  })
179
185
  toast.add({
@@ -235,6 +241,7 @@ const draft = reactive<Draft>({
235
241
  classRules: {},
236
242
  classRulesByRole: {},
237
243
  dryRunRoles: [],
244
+ submissionClassesByRole: {},
238
245
  forkEnabled: false,
239
246
  forkMinComplexity: 50,
240
247
  forkMinRisk: 40,
@@ -395,6 +402,7 @@ async function create() {
395
402
  <MergeRolePolicyEditor
396
403
  v-model:class-rules-by-role="drafts[p.id]!.classRulesByRole"
397
404
  v-model:dry-run-roles="drafts[p.id]!.dryRunRoles"
405
+ v-model:submission-classes-by-role="drafts[p.id]!.submissionClassesByRole"
398
406
  :class-rules="drafts[p.id]!.classRules"
399
407
  :auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
400
408
  :disabled="busy === p.id"
@@ -117,6 +117,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
117
117
  titleKey: 'errors.conflict.title.dry_run_not_mergeable',
118
118
  descriptionKey: 'errors.conflict.description.dry_run_not_mergeable',
119
119
  },
120
+ submission_not_allowed: {
121
+ titleKey: 'errors.conflict.title.submission_not_allowed',
122
+ descriptionKey: 'errors.conflict.description.submission_not_allowed',
123
+ },
120
124
  github_not_connected: {
121
125
  titleKey: 'errors.conflict.title.github_not_connected',
122
126
  descriptionKey: 'errors.conflict.description.github_not_connected',
@@ -82,10 +82,18 @@ export function useRunDeepLink(): void {
82
82
  if (!ready || applied) return
83
83
  applied = true
84
84
  if (link.blockId) ui.select(link.blockId)
85
- // Two views are served: the observability panel and the Tester's result window (where the
86
- // screenshots the report's environment-lifecycle section lists are rendered). An unknown
87
- // view still lands the user on the right board and task rather than failing the navigation.
88
- if (link.view === 'observability') ui.openObservability(link.runId)
85
+ // Three views are served: the run's outcome summary (the non-code answer to "what did this
86
+ // change"), the observability panel, and the Tester's result window (where the screenshots
87
+ // the report's environment-lifecycle section lists are rendered). An unknown view still
88
+ // lands the user on the right board and task rather than failing the navigation.
89
+ //
90
+ // `outcome` is passed the block as well as the run: it is the one of the three that stays
91
+ // readable on a task whose run the snapshot no longer carries, and a link followed weeks
92
+ // after the work merged is exactly that case. The engine emits no `view=outcome` link
93
+ // today (its report links a REVIEWER to the two run-scoped panels); this is the entry
94
+ // point for one, and for a URL a person shares.
95
+ if (link.view === 'outcome') ui.openRunOutcome(link.runId, link.blockId)
96
+ else if (link.view === 'observability') ui.openObservability(link.runId)
89
97
  else if (link.view === 'test-evidence') ui.openTestEvidence(link.runId)
90
98
  stop?.()
91
99
  },
@@ -1,6 +1,7 @@
1
1
  import type { Component } from 'vue'
2
2
  import { defineModule } from '@modular-vue/core'
3
3
  import { RESULT_VIEW_IDS, type ResultViewId } from '@cat-factory/contracts'
4
+ import OutcomeSummaryWindow from '~/components/outcome/OutcomeSummaryWindow.vue'
4
5
  import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
5
6
  import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
6
7
  import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
@@ -51,6 +52,9 @@ import type { ResultViewContribution } from './slots'
51
52
  * that could ship. Consumer namespaced ids are validated separately by `pairById`.
52
53
  */
53
54
  const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
55
+ // The run's non-code outcome summary: what changed in product terms, with the captured
56
+ // evidence, and the diff one click away. RUN-keyed (no step), opened by `ui.openOutcome`.
57
+ outcome: OutcomeSummaryWindow,
54
58
  'requirements-review': RequirementsReviewWindow,
55
59
  'clarity-review': ClarityReviewWindow,
56
60
  // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
@@ -113,6 +113,32 @@ export function createUiResultViews() {
113
113
  stepDetail.value = { instanceId, stepIndex }
114
114
  }
115
115
 
116
+ /**
117
+ * Open a task's NON-CODE OUTCOME summary — what the run changed in product terms, with the
118
+ * evidence behind it, and the diff one click away. BLOCK-keyed rather than run-keyed because a
119
+ * merged task keeps its pull request long after its run instance is gone, and that task is
120
+ * exactly the one somebody comes back to read. The run rides along when there is one, which is
121
+ * where every piece of evidence comes from; `stepIndex` stays null because the summary is
122
+ * composed from the WHOLE run and there is no step for it to be about.
123
+ */
124
+ function openOutcome(blockId: string, instanceId: string | null = null) {
125
+ resultView.value = { view: 'outcome', blockId, instanceId, stepIndex: null }
126
+ }
127
+
128
+ /**
129
+ * Open the outcome summary from a caller that knows the RUN: the `outcome` deep link.
130
+ *
131
+ * The run id is a LOOKUP here, not the key — the window is block-keyed (above) — so a link
132
+ * naming its block opens on it even when the store never hydrated that run, which is the
133
+ * normal state of following a link into a task that finished long ago. Falling back to the
134
+ * run's own `blockId` keeps a link that carries only `run=` working. Only a link that
135
+ * resolves neither is a silent no-op, matching the run-step openers.
136
+ */
137
+ function openRunOutcome(instanceId: string, blockId: string | null = null) {
138
+ const resolved = blockId ?? useExecutionStore().getInstance(instanceId)?.blockId ?? null
139
+ if (resolved) openOutcome(resolved, instanceId)
140
+ }
141
+
116
142
  function openRequirementReview(blockId: string) {
117
143
  resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
118
144
  }
@@ -185,6 +211,8 @@ export function createUiResultViews() {
185
211
  openForkDecision,
186
212
  openPrReview,
187
213
  openTestEvidence,
214
+ openOutcome,
215
+ openRunOutcome,
188
216
  closeResultView,
189
217
  closeRequirementReview,
190
218
  openStepDetail,