@cat-factory/app 0.182.0 → 0.183.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.
@@ -30,11 +30,15 @@ export type NavSurface = 'sidebar' | 'command' | 'toolbar'
30
30
  *
31
31
  * `models` and `integrations` are deliberately SEPARATE sections even though a model
32
32
  * provider is technically also an external system we connect to. They answer different
33
- * questions: `models` is the ENGINE the harnesses run on (no provider ⇒ nothing runs at
34
- * all), `integrations` is the optional systems that feed a run context or receive its
35
- * output (source control, trackers, documents, chat, observability) each of which a
36
- * deployment can live without. Folding the providers in among them buried the one
37
- * connection every deployment must make in a list of ones most never touch.
33
+ * questions: `models` is the MODEL LAYER — which engine the harnesses run on (no provider
34
+ * ⇒ nothing runs at all), which model each agent kind uses, and how well a given
35
+ * prompt+agent+model actually performs (`sandbox`, `kaizen`); `integrations` is the
36
+ * optional EXTERNAL SYSTEMS that feed a run context or receive its output (source control,
37
+ * trackers, documents, chat, observability) each of which a deployment can live without.
38
+ * Folding the providers in among them buried the one connection every deployment must make
39
+ * in a list of ones most never touch; parking the two model-quality surfaces there was the
40
+ * same mistake from the other end — neither Sandbox nor Kaizen connects to anything, they
41
+ * evaluate what the `models` section configures.
38
42
  */
39
43
  export type NavSidebarGroup =
40
44
  | 'create'
@@ -268,33 +272,6 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
268
272
  testId: 'nav-integrations',
269
273
  sidebar: { group: 'integrations', order: 10 },
270
274
  },
271
- {
272
- id: 'sandbox',
273
- labelKey: 'nav.sandbox',
274
- icon: 'i-lucide-flask-conical',
275
- surfaces: S('sidebar', 'command'),
276
- advanced: true,
277
- gate: (g) => g.canManageIntegrations,
278
- action: 'sandbox',
279
- testId: 'nav-sandbox',
280
- sidebar: { group: 'integrations', order: 20 },
281
- command: {
282
- group: 'workspace',
283
- order: 70,
284
- labelKey: 'layout.commandBar.cmd.sandbox',
285
- keywordsKey: 'layout.commandBar.keywords.sandbox',
286
- },
287
- },
288
- {
289
- id: 'kaizen',
290
- labelKey: 'nav.kaizen',
291
- icon: 'i-lucide-sparkles',
292
- surfaces: S('sidebar'),
293
- advanced: true,
294
- action: 'kaizen',
295
- testId: 'nav-kaizen',
296
- sidebar: { group: 'integrations', order: 30 },
297
- },
298
275
  {
299
276
  id: 'infrastructure',
300
277
  labelKey: 'nav.infrastructure',
@@ -370,6 +347,39 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
370
347
  keywordsKey: 'layout.commandBar.keywords.modelConfiguration',
371
348
  },
372
349
  },
350
+ {
351
+ // Trying prompt versions and models against graded fixtures — a model-layer surface,
352
+ // not an integration: it connects to no external system, it exercises the providers
353
+ // and per-agent models the two entries above configure.
354
+ id: 'sandbox',
355
+ labelKey: 'nav.sandbox',
356
+ icon: 'i-lucide-flask-conical',
357
+ surfaces: S('sidebar', 'command'),
358
+ advanced: true,
359
+ gate: (g) => g.canManageIntegrations,
360
+ action: 'sandbox',
361
+ testId: 'nav-sandbox',
362
+ sidebar: { group: 'models', order: 30 },
363
+ command: {
364
+ group: 'workspace',
365
+ order: 70,
366
+ labelKey: 'layout.commandBar.cmd.sandbox',
367
+ keywordsKey: 'layout.commandBar.keywords.sandbox',
368
+ },
369
+ },
370
+ {
371
+ // The same axis after the fact: grading history and verified prompt+agent+model combos.
372
+ // Sandbox asks "which combination should we use", Kaizen answers "how is the one we
373
+ // shipped doing" — both read the model layer, so they sit with it.
374
+ id: 'kaizen',
375
+ labelKey: 'nav.kaizen',
376
+ icon: 'i-lucide-sparkles',
377
+ surfaces: S('sidebar'),
378
+ advanced: true,
379
+ action: 'kaizen',
380
+ testId: 'nav-kaizen',
381
+ sidebar: { group: 'models', order: 40 },
382
+ },
373
383
  {
374
384
  id: 'service-fragment-defaults',
375
385
  labelKey: 'layout.commandBar.cmd.serviceFragmentDefaults',
@@ -437,7 +437,6 @@ watch(
437
437
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
438
438
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
439
439
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
440
- <PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
441
440
  <ApiTokensPanel v-if="ui.apiTokensOpen" />
442
441
  <InfrastructureWindow v-if="ui.infrastructureOpen" />
443
442
  <EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { usePackageRegistriesStore } from '~/stores/packageRegistries'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { PackageRegistryEntryView } from '~/types/packageRegistries'
5
+
6
+ /**
7
+ * The availability probe and the list read share one `load()`, but their failure contracts are
8
+ * opposite, and the split is what these cases pin:
9
+ *
10
+ * - a 503 is an ANSWER ("this deployment has no registries module") and resolves normally, so
11
+ * the Infrastructure window simply shows no tab;
12
+ * - anything else is a FAILURE that propagates, because the panel — which only renders once
13
+ * the probe already succeeded — is the surface that can tell a reader the list they are
14
+ * looking at could not be fetched.
15
+ */
16
+ function entry(over: Partial<PackageRegistryEntryView> = {}): PackageRegistryEntryView {
17
+ return {
18
+ id: 'pkgreg_1',
19
+ ecosystem: 'npm',
20
+ vendor: 'npmjs',
21
+ scopes: ['@acme'],
22
+ tokenTail: 'cdef',
23
+ ...over,
24
+ }
25
+ }
26
+
27
+ describe('packageRegistries store', () => {
28
+ beforeEach(() => {
29
+ useWorkspaceStore().workspaceId = 'ws1'
30
+ })
31
+
32
+ it('load stores the entries and marks the module available', async () => {
33
+ vi.stubGlobal('useApi', () => ({
34
+ listPackageRegistries: () => Promise.resolve({ entries: [entry()] }),
35
+ }))
36
+
37
+ const store = usePackageRegistriesStore()
38
+ await store.load()
39
+
40
+ expect(store.available).toBe(true)
41
+ expect(store.entries).toHaveLength(1)
42
+ expect(store.loading).toBe(false)
43
+ })
44
+
45
+ it('a definitive 503 latches the module unavailable without throwing', async () => {
46
+ vi.stubGlobal('useApi', () => ({
47
+ listPackageRegistries: () => Promise.reject({ statusCode: 503 }),
48
+ }))
49
+
50
+ const store = usePackageRegistriesStore()
51
+ await expect(store.load()).resolves.toBeUndefined()
52
+
53
+ expect(store.available).toBe(false)
54
+ expect(store.entries).toEqual([])
55
+ })
56
+
57
+ it('a transient failure propagates and leaves `available` null so the probe stays retryable', async () => {
58
+ vi.stubGlobal('useApi', () => ({
59
+ listPackageRegistries: () => Promise.reject({ statusCode: 500 }),
60
+ }))
61
+
62
+ const store = usePackageRegistriesStore()
63
+ await expect(store.load()).rejects.toMatchObject({ statusCode: 500 })
64
+
65
+ // Never cached as a false "unavailable": a reachable-but-flaky backend must not hide a tab
66
+ // the deployment really has.
67
+ expect(store.available).toBeNull()
68
+ expect(store.loading).toBe(false)
69
+ })
70
+
71
+ it('keeps an already-loaded list when a later refresh fails', async () => {
72
+ let fail = false
73
+ vi.stubGlobal('useApi', () => ({
74
+ listPackageRegistries: () =>
75
+ fail ? Promise.reject({ statusCode: 500 }) : Promise.resolve({ entries: [entry()] }),
76
+ }))
77
+
78
+ const store = usePackageRegistriesStore()
79
+ await store.load()
80
+ fail = true
81
+ await expect(store.load()).rejects.toMatchObject({ statusCode: 500 })
82
+
83
+ // The panel reports the failure; it must not also blank the list the reader had.
84
+ expect(store.available).toBe(true)
85
+ expect(store.entries).toHaveLength(1)
86
+ })
87
+
88
+ it('ensureLoaded probes once and stays retryable after a transient failure', async () => {
89
+ let calls = 0
90
+ let fail = true
91
+ vi.stubGlobal('useApi', () => ({
92
+ listPackageRegistries: () => {
93
+ calls += 1
94
+ return fail ? Promise.reject({ statusCode: 500 }) : Promise.resolve({ entries: [] })
95
+ },
96
+ }))
97
+
98
+ const store = usePackageRegistriesStore()
99
+ await expect(store.ensureLoaded()).rejects.toMatchObject({ statusCode: 500 })
100
+ expect(calls).toBe(1)
101
+
102
+ fail = false
103
+ await store.ensureLoaded()
104
+ expect(calls).toBe(2)
105
+ expect(store.available).toBe(true)
106
+
107
+ // Settled now, so a third caller costs no request.
108
+ await store.ensureLoaded()
109
+ expect(calls).toBe(2)
110
+ })
111
+ })
@@ -7,8 +7,9 @@ import { apiErrorStatus } from '~/composables/api/errors'
7
7
  /**
8
8
  * The workspace's private package-registry entries (npm private orgs, GitHub
9
9
  * Packages) that agent containers install with. Tokens are write-only — the store
10
- * only ever holds the redacted summary views. Loaded on demand (the registries
11
- * panel + the Integrations hub badge), not from the snapshot.
10
+ * only ever holds the redacted summary views. Loaded on demand (the Infrastructure
11
+ * window's "Package registries" tab, whose very existence gates on the probe below),
12
+ * not from the snapshot.
12
13
  */
13
14
  export const usePackageRegistriesStore = defineStore('packageRegistries', () => {
14
15
  const api = useApi()
@@ -16,8 +17,8 @@ export const usePackageRegistriesStore = defineStore('packageRegistries', () =>
16
17
  const entries = ref<PackageRegistryEntryView[]>([])
17
18
  const loading = ref(false)
18
19
  // Mirrors the backend's opt-in gate (the module 503s when the encryption key is
19
- // absent): `null` until first probed, then `true`/`false`. The hub hides its
20
- // registries entry point when this is false.
20
+ // absent): `null` until first probed, then `true`/`false`. The Infrastructure window
21
+ // shows no registries tab unless this is `true`.
21
22
  const available = ref<boolean | null>(null)
22
23
  let inFlight: Promise<void> | null = null
23
24
 
@@ -31,13 +32,25 @@ export const usePackageRegistriesStore = defineStore('packageRegistries', () =>
31
32
  } catch (err) {
32
33
  if (apiErrorStatus(err) === 503) {
33
34
  // A definitive 503 means the integration is unconfigured (no encryption key on
34
- // the backend): hide the UI entry points and stop probing.
35
+ // the backend): hide the UI entry points and stop probing. This is an ANSWER, not a
36
+ // failure, so it resolves normally.
35
37
  available.value = false
36
38
  entries.value = []
39
+ return
37
40
  }
38
- // Any other failure (transient 5xx / network) is left untouched: it must not hide
39
- // an already-available panel nor cache a false "unavailable". `available` stays
40
- // `null` when never probed, so `ensureLoaded` remains retryable on the next open.
41
+ // Any other failure (transient 5xx / network) leaves the state untouched: it must not
42
+ // hide an already-available panel nor cache a false "unavailable". `available` stays
43
+ // `null` when never probed, so `ensureLoaded` remains retryable on the next open — and
44
+ // the error PROPAGATES so a caller can say so. Swallowing it made every caller's error
45
+ // branch dead code: "the backend is unreachable" and "your deployment has no registries
46
+ // module" are different problems that must not render identically, and the panel is the
47
+ // one surface that can tell a reader which it hit. The PROBE callers still swallow (a
48
+ // failed probe means no tab, not a broken window) — the split is deliberate.
49
+ //
50
+ // NB `publicApiKeys` carries the same availability shape and still swallows here. It is
51
+ // not being changed alongside: its probe only hides one row of a hub full of others,
52
+ // whereas this one gates the feature's ONLY surface.
53
+ throw err
41
54
  } finally {
42
55
  loading.value = false
43
56
  }
@@ -1,5 +1,6 @@
1
1
  import { ref } from 'vue'
2
2
  import type { DocumentSourceKind, InfraSetupArea, TaskSourceKind } from '~/types/domain'
3
+ import type { InfrastructureTab, ProviderConnectionKind } from '~/types/providerConnections'
3
4
  import type { PendingContext } from '~/composables/useContextLinking'
4
5
  import {
5
6
  DEFAULT_PROVISION_DEEP_LINK_PARAM,
@@ -452,9 +453,8 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
452
453
  // model / agent kind, spend + activity per workspace / service / task type). Admin-gated.
453
454
  // Distinct from `operatorDashboardOpen`, which answers the deployment-HEALTH question.
454
455
  const reportsOpen = ref(false)
455
- // Private package registries: the workspace's npm/GitHub-Packages entries agent
456
- // containers install with. Opened from the Integrations hub.
457
- const packageRegistriesOpen = ref(false)
456
+ // NOTE: private package registries are no longer a panel of their own — they are a tab of
457
+ // the Infrastructure window (`infrastructureOpen`), reached from the navbar.
458
458
  // API access tokens: the workspace's inbound public-API keys external systems present to
459
459
  // the `/api/v1` surface. Opened from the Integrations hub.
460
460
  const apiTokensOpen = ref(false)
@@ -505,13 +505,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
505
505
  function closeReports() {
506
506
  reportsOpen.value = false
507
507
  }
508
- function openPackageRegistries() {
509
- resetHubReturn()
510
- packageRegistriesOpen.value = true
511
- }
512
- function closePackageRegistries() {
513
- packageRegistriesOpen.value = false
514
- }
515
508
  function openApiTokens() {
516
509
  resetHubReturn()
517
510
  apiTokensOpen.value = true
@@ -564,7 +557,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
564
557
  observabilityConnectionOpen,
565
558
  operatorDashboardOpen,
566
559
  reportsOpen,
567
- packageRegistriesOpen,
568
560
  apiTokensOpen,
569
561
  modelConfigOpen,
570
562
  vendorCredentialsOpen,
@@ -582,8 +574,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
582
574
  openReports,
583
575
  closeReports,
584
576
  closeOperatorDashboard,
585
- openPackageRegistries,
586
- closePackageRegistries,
587
577
  openApiTokens,
588
578
  closeApiTokens,
589
579
  openModelConfig,
@@ -683,13 +673,19 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
683
673
  */
684
674
  function createInfraModals(resetHubReturn: ResetHubReturn) {
685
675
  // The single tabbed Infrastructure window — a TOP-LEVEL navbar destination (no longer
686
- // reached via the Integrations hub). Two topical tabs: "Agent containers" (the execution
687
- // backend + self-hosted runner pool, plus the local-mode warm pool/checkout) and "Test
688
- // environments" (the ephemeral-environment provider). `infrastructureOpen` is the modal
689
- // flag; `infrastructureTab` selects the tab. `openInfrastructure()` is the navbar entry;
690
- // `openProviderConnection(kind)` remains for deep-links (a banner's "Configure…" button).
676
+ // reached via the Integrations hub). Its topical tabs: "Agent containers" (the execution
677
+ // backend + self-hosted runner pool, plus the local-mode warm pool/checkout), "Test
678
+ // environments" (the ephemeral-environment provider), "Shared stacks" (long-lived Compose
679
+ // infra an environment attaches to) and "Package registries" (the private registries a
680
+ // checkout installs from). `infrastructureOpen` is the modal flag; `infrastructureTab`
681
+ // selects the tab. `openInfrastructure()` is the navbar entry; `openProviderConnection(kind)`
682
+ // remains for deep-links (a banner's "Configure…" button).
683
+ //
684
+ // The ref is typed against the FULL `InfrastructureTab` union, not the provider-connection
685
+ // kinds: a tab this cannot name is a tab nothing can deep-link to, which is how the
686
+ // non-connection tabs ended up reachable only by opening the window and clicking across.
691
687
  const infrastructureOpen = ref(false)
692
- const infrastructureTab = ref<'environment' | 'runner-pool'>('runner-pool')
688
+ const infrastructureTab = ref<InfrastructureTab>('runner-pool')
693
689
  // Non-secret prefill captured from the `cat-factory k3s` CLI deep-link (see
694
690
  // `consumeK3sSetupDeepLink`). When set, the Test-environments tab's kube engine form seeds the
695
691
  // `local-k3s` connection from it; the ServiceAccount token is deliberately NOT in the link (a
@@ -704,12 +700,15 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
704
700
 
705
701
  // Top-level navbar entry into the Infrastructure window. No hub-return marker (it isn't
706
702
  // reached from the Integrations hub), so the window shows no "Back to Integrations" control.
707
- function openInfrastructure(tab: 'environment' | 'runner-pool' = 'runner-pool') {
703
+ function openInfrastructure(tab: InfrastructureTab = 'runner-pool') {
708
704
  resetHubReturn()
709
705
  infrastructureTab.value = tab
710
706
  infrastructureOpen.value = true
711
707
  }
712
- function openProviderConnection(kind: 'environment' | 'runner-pool') {
708
+ // Deep-link into a PROVIDER's tab specifically (a config banner's "Configure…" button), so
709
+ // this one stays narrowed to the connection kinds — it means "connect this provider", not
710
+ // "open the window somewhere". Use `openInfrastructure(tab)` for any other tab.
711
+ function openProviderConnection(kind: ProviderConnectionKind) {
713
712
  resetHubReturn()
714
713
  infrastructureTab.value = kind
715
714
  infrastructureOpen.value = true
@@ -17,6 +17,18 @@ export type {
17
17
  /** The two infrastructure providers configured through the generic connect form. */
18
18
  export type ProviderConnectionKind = 'environment' | 'runner-pool'
19
19
 
20
+ /**
21
+ * A tab of the Infrastructure window, and the vocabulary `ui.infrastructureTab` selects from.
22
+ *
23
+ * Deliberately WIDER than {@link ProviderConnectionKind}: the window also hosts tabs that are
24
+ * not a provider connection at all (long-lived shared Compose stacks, the workspace's private
25
+ * package registries). Typing the store's tab ref as the connection kinds alone is what made
26
+ * those tabs unreachable by deep link — a banner, a command-palette entry or an Integrations-hub
27
+ * pointer could open the window but never land the user on the tab it meant. Every tab
28
+ * `InfrastructureWindow.vue` can render must have a name here.
29
+ */
30
+ export type InfrastructureTab = ProviderConnectionKind | 'shared-stacks' | 'package-registries'
31
+
20
32
  /** A workspace's provider binding, as exposed to clients (never secret values). */
21
33
  export interface ProviderConnection {
22
34
  /** The runner-backend kind for a runner-pool connection (`manifest` | `kubernetes`). */
@@ -1,7 +1,12 @@
1
1
  import { INITIATIVE_ITEM_TERMINAL_STATUSES } from '@cat-factory/contracts'
2
2
  import { describe, it, expect } from 'vitest'
3
3
  import type { InitiativeItem, InitiativePhase, InitiativeQa } from '~/types/domain'
4
- import { isPendingQuestion, orderInterviewQuestions, pendingCheckpointPhase } from './initiative'
4
+ import {
5
+ isPendingQuestion,
6
+ orderInterviewQuestions,
7
+ pendingCheckpointPhase,
8
+ selectPlanApproval,
9
+ } from './initiative'
5
10
 
6
11
  // `pendingCheckpointPhase` mirrors the backend `pendingCheckpoint` (orchestration
7
12
  // `initiative.logic.ts`); these pin the same ordering/edge cases the loop pauses on, so the
@@ -150,3 +155,48 @@ describe('orderInterviewQuestions', () => {
150
155
  expect(orderInterviewQuestions([])).toEqual([])
151
156
  })
152
157
  })
158
+
159
+ // The plan-review park: which of a block's pending approvals the board card / inspector offer as
160
+ // "Review plan", and which one they must leave alone. Both gates on the planning pipeline park on
161
+ // a `step.approval`, so the interviewer's park (owned by the planning window's "Answer planning
162
+ // questions") is the case this selector exists to keep out — offering it here would give one park
163
+ // two differently-worded buttons, and the tracker window it opened could not resolve it.
164
+
165
+ /** A pending approval as `execution.approvalsByBlock` carries it (only the fields read here). */
166
+ const parked = (agentKind: string, id: string) => ({ agentKind, approval: { id } })
167
+
168
+ /** The catalog's result-view resolver, as the composable passes it in. */
169
+ const resultViewOf = (kind: string): string | undefined =>
170
+ kind === 'initiative-interviewer'
171
+ ? 'initiative-planning'
172
+ : kind.startsWith('initiative-')
173
+ ? 'initiative-tracker'
174
+ : undefined
175
+
176
+ describe('selectPlanApproval', () => {
177
+ it('is undefined when nothing is parked', () => {
178
+ expect(selectPlanApproval([], resultViewOf)).toBeUndefined()
179
+ })
180
+
181
+ it('picks the planner gate — the plan awaiting approval', () => {
182
+ const approvals = [parked('initiative-planner', 'ap_1')]
183
+ expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_1')
184
+ })
185
+
186
+ it('leaves the interviewer park to the planning window', () => {
187
+ const approvals = [parked('initiative-interviewer', 'ap_interview')]
188
+ expect(selectPlanApproval(approvals, resultViewOf)).toBeUndefined()
189
+ })
190
+
191
+ it('finds the plan gate past an interview park (a re-run interviewing again)', () => {
192
+ const approvals = [parked('initiative-interviewer', 'ap_interview'), parked('x', 'ap_plan')]
193
+ expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_plan')
194
+ })
195
+
196
+ it('offers a gated step of a custom planning pipeline, whatever window it routes to', () => {
197
+ // A kind with no dedicated window at all still parks a human; the affordance opens whatever
198
+ // `dispatchStepView` routes it to (the generic panel), which is exactly what resolves it.
199
+ const approvals = [parked('some-custom-kind', 'ap_custom')]
200
+ expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_custom')
201
+ })
202
+ })
@@ -59,6 +59,50 @@ export const INITIATIVE_ITEM_STATUS_CHIPS: Record<InitiativeItemStatus, BadgeCol
59
59
  skipped: 'neutral',
60
60
  }
61
61
 
62
+ /**
63
+ * The two ways an initiative's planning run parks for a human: an agent-raised `decision`, or a
64
+ * pending step `approval` (the plan-approval gate the `initiative-planner` step carries). Resolved
65
+ * per block by `useInitiativePlanning().attention`.
66
+ */
67
+ export type InitiativeAttentionKind = 'decision' | 'approval'
68
+
69
+ /** Park kind → i18n label key for the card/inspector button that opens the resolving window. */
70
+ export const INITIATIVE_ATTENTION_LABEL_KEYS: Record<InitiativeAttentionKind, string> = {
71
+ decision: 'initiative.inspector.resolveDecision',
72
+ approval: 'initiative.inspector.reviewPlan',
73
+ }
74
+
75
+ /** Park kind → button icon, so the board card and the inspector can't diverge on one park. */
76
+ export const INITIATIVE_ATTENTION_ICONS: Record<InitiativeAttentionKind, string> = {
77
+ decision: 'i-lucide-circle-help',
78
+ approval: 'i-lucide-clipboard-check',
79
+ }
80
+
81
+ /**
82
+ * The result view the INTERVIEW gate owns. Its park rides the same `step.approval` mechanism as
83
+ * every other gate, so anything offering "there is a plan to review here" has to exclude it: the
84
+ * interview park is already owned by the planning window, behind the differently-worded "Answer
85
+ * planning questions".
86
+ */
87
+ export const INTERVIEW_GATE_RESULT_VIEW = 'initiative-planning'
88
+
89
+ /**
90
+ * The block's parked approval that is a PLAN REVIEW — the planner's human gate (`pl_initiative`
91
+ * declares `{ kind: 'initiative-planner', gate: true }`), or any other gated step of a custom
92
+ * planning pipeline — as opposed to the interviewer's park.
93
+ *
94
+ * Discriminated by the step's own result view, the seam `dispatchStepView` routes on, rather than
95
+ * by an agent-kind list or by the interview phase: the affordance's ACTION is that dispatch, so
96
+ * keying the offer on the same fact guarantees the button opens a window that can resolve what it
97
+ * offered — and that the interview and plan affordances can never both claim one park.
98
+ */
99
+ export function selectPlanApproval<A extends { agentKind: string }>(
100
+ approvals: readonly A[],
101
+ resultViewOf: (agentKind: string) => string | undefined,
102
+ ): A | undefined {
103
+ return approvals.find((a) => resultViewOf(a.agentKind) !== INTERVIEW_GATE_RESULT_VIEW)
104
+ }
105
+
62
106
  /** Follow-up triage status → i18n label key. Exhaustive so a new status fails the build. */
63
107
  export const INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS: Record<InitiativeFollowUp['status'], string> = {
64
108
  open: 'initiative.followUpStatus.open',
@@ -537,20 +537,22 @@
537
537
  "incidentNoun": "der Incident-Provider"
538
538
  },
539
539
  "packageRegistries": {
540
- "title": "Private Package-Registries",
540
+ "tab": "Package-Registries",
541
541
  "intro": "Verbinde die privaten Registries, aus denen deine Repositories installieren. Agenten erhalten sie beim Auschecken eines Repositorys, sodass private Abhängigkeiten während der Installation aufgelöst werden. Tokens sind nur schreibbar: um eines zu ändern, entferne den Eintrag und füge ihn erneut hinzu.",
542
542
  "entryNoun": "Registry-Eintrag",
543
543
  "list": {
544
544
  "heading": "Verbundene Registries",
545
545
  "tokenTail": "Token …{tail}",
546
- "remove": "Eintrag entfernen"
546
+ "remove": "Eintrag entfernen",
547
+ "noScopes": "Keine Scopes (das Token authentifiziert nur dieses Registry)"
547
548
  },
548
549
  "add": {
549
550
  "heading": "Eine Registry hinzufügen",
550
551
  "vendor": "Registry",
551
552
  "host": "Das Token wird nur an {host} gesendet.",
552
553
  "scopes": "Package-Scopes",
553
- "scopesHelp": "Kommagetrennte npm-Scopes",
554
+ "scopesHelp": "Optional. Kommagetrennte npm-Scopes.",
555
+ "scopesNote": "Optional. Ein hier eingetragener Scope leitet jedes Paket darunter an dieses Registry, sodass ein Scope, der auch öffentlich veröffentlichte Pakete enthält, nicht mehr aufgelöst wird. Lass das Feld leer, wenn das Repository eine eigene .npmrc enthält oder wenn einzelne Abhängigkeiten über ein benanntes Registry-Präfix wie gh: festgelegt werden (pnpm 11.1 oder neuer) - das Token authentifiziert das Registry ohnehin.",
554
556
  "token": "Zugriffstoken",
555
557
  "save": "Registry hinzufügen"
556
558
  },
@@ -2147,10 +2149,6 @@
2147
2149
  "label": "Post-Release-Health",
2148
2150
  "description": "Überwachen Sie Monitore und SLOs, nachdem ein Release ausgeliefert wurde (Datadog)."
2149
2151
  },
2150
- "packageRegistries": {
2151
- "label": "Private Package-Registries",
2152
- "description": "npm- und GitHub-Packages-Tokens, mit denen Agenten private Abhängigkeiten installieren."
2153
- },
2154
2152
  "apiTokens": {
2155
2153
  "label": "API-Zugriffstokens",
2156
2154
  "description": "Tokens, die externe Systeme vorlegen, um die cat-factory-API aufzurufen."
@@ -4282,12 +4280,22 @@
4282
4280
  "inspector": {
4283
4281
  "runPlanning": "Planung ausführen",
4284
4282
  "answerPlanning": "Planungsfragen beantworten",
4283
+ "reviewPlan": "Plan prüfen",
4284
+ "resolveDecision": "Auflösen",
4285
4285
  "planningInProgress": "Planung läuft",
4286
4286
  "pause": "Pausieren",
4287
4287
  "resume": "Fortsetzen",
4288
4288
  "cancel": "Initiative abbrechen",
4289
4289
  "hint": "Die Planning-Pipeline erkundet die Codebasis, entwirft den mehrphasigen Plan zur Freigabe und committet dann das Tracker-Dokument ins Repository."
4290
4290
  },
4291
+ "planReview": {
4292
+ "title": "Dieser Plan wartet auf dich",
4293
+ "body": "Der Planner hat die Phasen und Aufgaben unten entworfen. Gib sie frei, um den Plan zu committen und die Arbeit zu starten, oder schicke den Plan mit deinen Änderungswünschen zurück.",
4294
+ "approve": "Plan freigeben",
4295
+ "requestChanges": "Änderungen anfordern",
4296
+ "feedbackPlaceholder": "Was soll der Planner ändern? Umfang, Reihenfolge der Phasen, fehlende Arbeit, eine Aufgabe, die woanders hingehört …",
4297
+ "sendBack": "An den Planner zurückschicken"
4298
+ },
4291
4299
  "planning": {
4292
4300
  "title": "Die Initiative planen",
4293
4301
  "subtitle": "Beantworten Sie die Fragen des Planers, damit er die Initiative eingrenzen kann",
@@ -2163,10 +2163,6 @@
2163
2163
  "label": "Post-release health",
2164
2164
  "description": "Watch monitors and SLOs after a release ships (Datadog)."
2165
2165
  },
2166
- "packageRegistries": {
2167
- "label": "Private package registries",
2168
- "description": "npm and GitHub Packages tokens agents use to install private dependencies."
2169
- },
2170
2166
  "apiTokens": {
2171
2167
  "label": "API access tokens",
2172
2168
  "description": "Tokens external systems present to call the cat-factory API."
@@ -2813,20 +2809,22 @@
2813
2809
  "incidentNoun": "the incident provider"
2814
2810
  },
2815
2811
  "packageRegistries": {
2816
- "title": "Private package registries",
2812
+ "tab": "Package registries",
2817
2813
  "intro": "Connect the private registries your repositories install from. Agents receive them when they check out a repository, so private dependencies resolve during installs. Tokens are write-only: to change one, remove the entry and add it again.",
2818
2814
  "entryNoun": "registry entry",
2819
2815
  "list": {
2820
2816
  "heading": "Connected registries",
2821
2817
  "tokenTail": "token …{tail}",
2822
- "remove": "Remove entry"
2818
+ "remove": "Remove entry",
2819
+ "noScopes": "No scopes (the token authenticates this registry only)"
2823
2820
  },
2824
2821
  "add": {
2825
2822
  "heading": "Add a registry",
2826
2823
  "vendor": "Registry",
2827
2824
  "host": "The token is sent only to {host}.",
2828
2825
  "scopes": "Package scopes",
2829
- "scopesHelp": "Comma-separated npm scopes",
2826
+ "scopesHelp": "Optional. Comma-separated npm scopes.",
2827
+ "scopesNote": "Optional. A scope listed here routes every package under it to this registry, so a scope that also holds publicly published packages stops resolving. Leave it empty when the repository commits its own .npmrc, or when single dependencies are pinned with a named-registry prefix such as gh: (pnpm 11.1 or newer) - the token authenticates the registry either way.",
2830
2828
  "token": "Access token",
2831
2829
  "save": "Add registry"
2832
2830
  },
@@ -5459,12 +5457,22 @@
5459
5457
  "inspector": {
5460
5458
  "runPlanning": "Run planning",
5461
5459
  "answerPlanning": "Answer planning questions",
5460
+ "reviewPlan": "Review plan",
5461
+ "resolveDecision": "Resolve",
5462
5462
  "planningInProgress": "Planning in progress",
5463
5463
  "pause": "Pause",
5464
5464
  "resume": "Resume",
5465
5465
  "cancel": "Cancel initiative",
5466
5466
  "hint": "The planning pipeline explores the codebase, drafts the multi-phase plan for approval, then commits the tracker document to the repository."
5467
5467
  },
5468
+ "planReview": {
5469
+ "title": "This plan is waiting for you",
5470
+ "body": "The planner drafted the phases and items below. Approve them to commit the plan and start the work, or send the plan back with what to change.",
5471
+ "approve": "Approve plan",
5472
+ "requestChanges": "Request changes",
5473
+ "feedbackPlaceholder": "What should the planner change? Scope, phase order, missing work, an item that belongs elsewhere…",
5474
+ "sendBack": "Send back to the planner"
5475
+ },
5468
5476
  "planning": {
5469
5477
  "title": "Plan the initiative",
5470
5478
  "subtitle": "Answer the planner's questions so it can scope the initiative",