@cat-factory/app 0.181.0 → 0.182.1

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 +11 -4
  2. package/app/components/board/nodes/BlockNode.vue +14 -4
  3. package/app/components/board/nodes/DraggableTask.vue +3 -2
  4. package/app/components/board/nodes/InitiativeCard.vue +27 -2
  5. package/app/components/board/nodes/TaskPipelineMini.vue +9 -12
  6. package/app/components/docs/DocInterviewWindow.vue +24 -3
  7. package/app/components/initiative/InitiativePlanningWindow.vue +27 -7
  8. package/app/components/initiative/InitiativeTrackerWindow.vue +149 -9
  9. package/app/components/panels/inspector/InitiativeInspector.vue +23 -2
  10. package/app/composables/useInitiativePlanning.ts +96 -8
  11. package/app/composables/useTaskExpansion.ts +30 -16
  12. package/app/modular/nav-contributions.spec.ts +13 -3
  13. package/app/modular/nav-contributions.ts +42 -32
  14. package/app/modular/panels/inspector.logic.ts +1 -1
  15. package/app/stores/taskExpansion.spec.ts +88 -0
  16. package/app/stores/taskExpansion.ts +34 -14
  17. package/app/utils/catalog.ts +5 -4
  18. package/app/utils/initiative.spec.ts +51 -1
  19. package/app/utils/initiative.ts +44 -0
  20. package/app/utils/interviewGate.spec.ts +87 -12
  21. package/app/utils/interviewGate.ts +57 -5
  22. package/i18n/locales/de.json +15 -1
  23. package/i18n/locales/en.json +15 -1
  24. package/i18n/locales/es.json +15 -1
  25. package/i18n/locales/fr.json +15 -1
  26. package/i18n/locales/he.json +15 -1
  27. package/i18n/locales/it.json +15 -1
  28. package/i18n/locales/ja.json +15 -1
  29. package/i18n/locales/pl.json +15 -1
  30. package/i18n/locales/tr.json +15 -1
  31. package/i18n/locales/uk.json +15 -1
  32. package/package.json +1 -1
@@ -4,7 +4,24 @@ import { useExecutionStore } from '~/stores/execution'
4
4
  import { useInitiativesStore } from '~/stores/initiative'
5
5
  import { usePipelinesStore } from '~/stores/pipelines'
6
6
  import { useUiStore } from '~/stores/ui'
7
- import { interviewGatePhase } from '~/utils/interviewGate'
7
+ import { agentKindMeta } from '~/utils/catalog'
8
+ import { selectPlanApproval, type InitiativeAttentionKind } from '~/utils/initiative'
9
+ import {
10
+ INITIATIVE_INTERVIEWER_KIND,
11
+ interviewGatePhase,
12
+ interviewStepReached,
13
+ } from '~/utils/interviewGate'
14
+
15
+ /**
16
+ * What an initiative's planning run needs from a human right now: which kind of park it is (the
17
+ * card + inspector resolve its icon/label from the shared `INITIATIVE_ATTENTION_*` maps, so the
18
+ * two surfaces word one park identically), and the action that opens the surface which can
19
+ * RESOLVE it — the step's own dedicated window, via `dispatchStepView`.
20
+ */
21
+ export interface InitiativeAttention {
22
+ kind: InitiativeAttentionKind
23
+ open: () => void
24
+ }
8
25
 
9
26
  /**
10
27
  * Shared planning affordances for an `initiative`-level block, used by BOTH the board card
@@ -41,12 +58,14 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
41
58
  * The live interview phase, derived from the entity AND the planning run (see
42
59
  * {@link interviewGatePhase} for why the run status is load-bearing).
43
60
  */
44
- const interviewPhase = computed(() =>
45
- interviewGatePhase(
61
+ const interviewPhase = computed(() => {
62
+ const run = execution.getByBlock(toValue(blockId))
63
+ return interviewGatePhase(
46
64
  initiative.value?.interview?.status,
47
- execution.getByBlock(toValue(blockId))?.status,
48
- ),
49
- )
65
+ run?.status,
66
+ interviewStepReached(run, INITIATIVE_INTERVIEWER_KIND),
67
+ )
68
+ })
50
69
 
51
70
  /**
52
71
  * The interviewer has PARKED the planning run for the human. NOT keyed on whether individual
@@ -62,8 +81,74 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
62
81
  */
63
82
  const awaitingAnswers = computed(() => interviewPhase.value === 'awaiting')
64
83
 
65
- /** An interviewer pass is running — the human is waiting on the planner, not the reverse. */
66
- const interviewing = computed(() => interviewPhase.value === 'working')
84
+ /**
85
+ * The planning run is mid-flight with nothing for the human to answer — either an interviewer
86
+ * pass is running (`working`) or the run has not reached the interview yet (`preparing`, the
87
+ * codebase analysis that now leads). BOTH phases, deliberately: this drives the card's and
88
+ * inspector's "Planning in progress" button, which is the only route into the planning window
89
+ * while a run owns the block. Narrowing it to `working` would drop that route for the whole of
90
+ * the analysis and fall through to a "Run planning" button for a run already running.
91
+ *
92
+ * The two phases are distinguished INSIDE the window, where there is room to say which is which;
93
+ * the affordance is the same either way.
94
+ */
95
+ const interviewing = computed(
96
+ () => interviewPhase.value === 'working' || interviewPhase.value === 'preparing',
97
+ )
98
+
99
+ /**
100
+ * The planner's parked plan-approval gate, or undefined. `pl_initiative` gates the planner step
101
+ * (`{ kind: 'initiative-planner', gate: true }`), so a finished planning pass PARKS the run on a
102
+ * pending `step.approval` until a human accepts the plan — the state this composable's other
103
+ * flags deliberately do NOT cover (the interview has converged, and the run is `blocked`, so
104
+ * `awaitingAnswers` and `interviewing` are both false and the card would otherwise sit on a
105
+ * disabled, spinning "Run planning").
106
+ *
107
+ * The interviewer's own park is excluded by the window its step routes to (see
108
+ * {@link selectPlanApproval}), not by the interview phase, so the two affordances can never both
109
+ * claim one park.
110
+ */
111
+ const planApproval = computed(() =>
112
+ selectPlanApproval(
113
+ execution.approvalsByBlock.get(toValue(blockId)) ?? [],
114
+ (kind) => agentKindMeta(kind).resultView,
115
+ ),
116
+ )
117
+
118
+ /** An agent-raised decision on the planning run (the analyst is an ordinary agent step). */
119
+ const pendingDecision = computed(() => execution.decisionsByBlock.get(toValue(blockId))?.[0])
120
+
121
+ /**
122
+ * The single thing a human has to act on, or null — the initiative dual of a task card's
123
+ * `attention`. A decision outranks an approval (a step never holds both; this is just a stable
124
+ * order). Opening always goes through the step-view dispatch, so the park lands in the window
125
+ * that can RESOLVE it (the plan gate → the tracker window's plan-review rail) rather than a
126
+ * generic panel that would refuse it.
127
+ */
128
+ const attention = computed<InitiativeAttention | null>(() => {
129
+ const id = toValue(blockId)
130
+ const decision = pendingDecision.value
131
+ if (decision) {
132
+ return {
133
+ kind: 'decision',
134
+ open: () => {
135
+ ui.select(id)
136
+ ui.openDecision(decision.instanceId, decision.decision.id)
137
+ },
138
+ }
139
+ }
140
+ const approval = planApproval.value
141
+ if (approval) {
142
+ return {
143
+ kind: 'approval',
144
+ open: () => {
145
+ ui.select(id)
146
+ ui.openApprovalDetail(approval.instanceId, approval.approval.id)
147
+ },
148
+ }
149
+ }
150
+ return null
151
+ })
67
152
 
68
153
  /**
69
154
  * Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
@@ -105,6 +190,9 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
105
190
  interviewPhase,
106
191
  awaitingAnswers,
107
192
  interviewing,
193
+ planApproval,
194
+ pendingDecision,
195
+ attention,
108
196
  starting,
109
197
  runPlanning,
110
198
  openPlanning,
@@ -15,22 +15,24 @@ function sameSet(a: Set<string>, b: Set<string>) {
15
15
  }
16
16
 
17
17
  /**
18
- * Board-level driver deciding which task cards may expand their full pipeline list
19
- * once zoomed in (the deep `steps`/`subtasks` bands). Two gates, recomputed every
20
- * frame against live DOM rects so they follow pan / zoom / drag / resize:
18
+ * Board-level driver deciding which task cards expand their full pipeline list.
19
+ * Recomputed every frame against live DOM rects so it follows pan / zoom / drag / resize,
20
+ * and writes two independent grants into the `taskExpansion` store (which combines them
21
+ * see the store for how they resolve):
21
22
  *
22
- * - visibility: a task expands only while its card overlaps the board viewport.
23
- * - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
24
- * task expands only if its footprint doesn't collide with one already granted, so the
25
- * card you're looking at wins an overlap and the rest stay compact.
26
- * - hover: the task directly under the pointer is granted first, so hovering any card
27
- * expands its pipeline regardless of its position on screen. "Under the pointer" is
28
- * the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
23
+ * - hover: the task directly under the pointer, at ANY zoom level. "Under the pointer"
24
+ * is the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
29
25
  * already covered by another open pipeline keeps that pipeline, not the card beneath.
26
+ * - zoom: at the deep `steps`/`subtasks` bands, every on-screen card, minus overlaps —
27
+ * two sub-gates:
28
+ * - visibility: a task expands only while its card overlaps the board viewport.
29
+ * - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
30
+ * task expands only if its footprint doesn't collide with one already granted, so
31
+ * the card you're looking at wins an overlap and the rest stay compact. The hovered
32
+ * card is granted first, so it wins every overlap it's part of.
30
33
  *
31
- * Writes the permitted id set into the `taskExpansion` store; `TaskPipelineMini` reads it.
32
- * Only tasks with a running pipeline (steps to show) are candidates a task that
33
- * wouldn't expand never blocks a neighbour.
34
+ * Only tasks with a running pipeline (steps to show) are candidates for either grant — a
35
+ * task that wouldn't expand never blocks a neighbour and never lifts an empty card.
34
36
  */
35
37
  export function useTaskExpansion(container: Ref<HTMLElement | null>) {
36
38
  const board = useBoardStore()
@@ -65,14 +67,27 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
65
67
  // The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
66
68
  // rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
67
69
  // a region obscured by another pipeline doesn't switch to the card hidden beneath it.
70
+ //
71
+ // Blocks with no pipeline to show are filtered out here rather than left to the card:
72
+ // a frame, a module, or a task with no run expands to nothing, and granting it would
73
+ // still lift an empty card over its neighbours (see DraggableTask's z-index).
68
74
  function hoveredTaskId(): string | null {
69
75
  if (!pointer) return null
70
76
  const hit = document.elementFromPoint(pointer.x, pointer.y)
71
- return hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
77
+ const id = hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
78
+ if (!id || !execution.getByBlock(id)?.steps.length) return null
79
+ return id
72
80
  }
73
81
 
74
82
  function recompute() {
75
- // Task cards only expand at the deep zoom bands; clear everything otherwise.
83
+ // Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
84
+ // zoom gate below — resolving it after would collapse the hovered card the moment the
85
+ // user zoomed back out past the `steps` band.
86
+ const hovered = hoveredTaskId()
87
+ if (store.hoveredId !== hovered) store.setHovered(hovered)
88
+
89
+ // The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
90
+ // only; clear its grants otherwise. The hover grant above stands on its own.
76
91
  if (!lodAtLeast(ui.lod, 'steps')) {
77
92
  if (store.allowed.size) store.setAllowed(new Set())
78
93
  return
@@ -117,7 +132,6 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
117
132
  // clears every footprint already granted, so the centre-most card wins any overlap.
118
133
  // The hovered card is granted FIRST, so hovering a card expands it regardless of its
119
134
  // distance from the centre (and a centre-most neighbour it overlaps yields to it).
120
- const hovered = hoveredTaskId()
121
135
  const claimed: Rect[] = []
122
136
  const next = new Set<string>()
123
137
  const hoveredCard = hovered ? candidates.find((c) => c.id === hovered) : undefined
@@ -285,10 +285,20 @@ describe('nav grouping helpers', () => {
285
285
  'workspaceContext',
286
286
  'configuration',
287
287
  ])
288
- // The engines are their own section, ahead of the optional integrations; `model-config`
289
- // sits beside the providers it picks models from rather than under `configuration`.
288
+ // The model layer is its own section, ahead of the optional integrations: the engines,
289
+ // the per-agent model choice (beside the providers it picks from, rather than under
290
+ // `configuration`), and the two surfaces that evaluate a prompt+agent+model. Sandbox and
291
+ // Kaizen used to sit under `integrations`, which read as a claim they connect to an
292
+ // external system; they don't — they exercise and grade what this section configures.
290
293
  const models = groups.find((g) => g.group === 'models')
291
- expect(models?.items.map((i) => i.id)).toEqual(['model-providers', 'model-config'])
294
+ expect(models?.items.map((i) => i.id)).toEqual([
295
+ 'model-providers',
296
+ 'model-config',
297
+ 'sandbox',
298
+ 'kaizen',
299
+ ])
300
+ const integrations = groups.find((g) => g.group === 'integrations')
301
+ expect(integrations?.items.map((i) => i.id)).toEqual(['integrations-hub'])
292
302
  const configuration = groups.find((g) => g.group === 'configuration')
293
303
  expect(configuration?.items.map((i) => i.id)).toEqual([
294
304
  'workspace-settings',
@@ -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',
@@ -76,7 +76,7 @@ const isTask = (b: Block) => b.level === 'task'
76
76
  const isFrame = (b: Block) => b.level === 'frame'
77
77
  /**
78
78
  * A block whose inspector carries a pipeline RUN. An initiative's planning pipeline is an
79
- * ordinary run of ordinary agent steps (intervieweranalyst → planner → committer), so it
79
+ * ordinary run of ordinary agent steps (analystinterviewer → planner → committer), so it
80
80
  * gets the same execution panel a task does — step list, live phases, step-detail drill-down,
81
81
  * and the Stop / Discard-run controls that are the only way to unwedge a stalled planning run.
82
82
  * Before this it had no run surface at all, which is why a stuck plan was a dead end.
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { useTaskExpansionStore } from '~/stores/taskExpansion'
3
+ import { useUiStore } from '~/stores/ui'
4
+
5
+ /**
6
+ * The expansion gate combines two independent grants (hover at any zoom, the deep zoom
7
+ * bands otherwise). Both `TaskPipelineMini` (what renders) and `DraggableTask` (what
8
+ * stacks on top) read `isExpanded`, so these cases pin the rule they share.
9
+ *
10
+ * Zoom is set through the ui store's raw `zoom`, the same value the board canvas writes;
11
+ * `lod` derives from it (< 1.8 is shallower than the `steps` band).
12
+ */
13
+ function setup(zoom: number) {
14
+ const ui = useUiStore()
15
+ ui.zoom = zoom
16
+ const store = useTaskExpansionStore()
17
+ store.setDriverActive(true)
18
+ return store
19
+ }
20
+
21
+ describe('taskExpansion — hover expands at any zoom level', () => {
22
+ it('expands the hovered card while zoomed out past the steps band', () => {
23
+ const store = setup(0.5) // `far`
24
+ store.setHovered('task-a')
25
+ expect(store.isExpanded('task-a')).toBe(true)
26
+ // Hover is a grant of ONE card: its neighbours stay compact at this zoom.
27
+ expect(store.isExpanded('task-b')).toBe(false)
28
+ })
29
+
30
+ it('expands the hovered card at every band, not just the shallow ones', () => {
31
+ const store = setup(1) // `mid`
32
+ store.setHovered('task-a')
33
+ expect(store.isExpanded('task-a')).toBe(true)
34
+ useUiStore().zoom = 1.5 // `close`
35
+ expect(store.isExpanded('task-a')).toBe(true)
36
+ useUiStore().zoom = 3 // `subtasks`
37
+ expect(store.isExpanded('task-a')).toBe(true)
38
+ })
39
+
40
+ it('collapses again once the pointer leaves the card', () => {
41
+ const store = setup(0.5)
42
+ store.setHovered('task-a')
43
+ store.setHovered(null)
44
+ expect(store.isExpanded('task-a')).toBe(false)
45
+ })
46
+
47
+ it('expands a hovered card the zoom gate denied for overlapping a neighbour', () => {
48
+ const store = setup(2) // `steps`
49
+ store.setAllowed(new Set(['task-b']))
50
+ store.setHovered('task-a')
51
+ expect(store.isExpanded('task-a')).toBe(true)
52
+ expect(store.isExpanded('task-b')).toBe(true)
53
+ })
54
+ })
55
+
56
+ describe('taskExpansion — the zoom grant is unchanged', () => {
57
+ it('honours the driver grant only from the steps band up', () => {
58
+ const store = setup(1.5) // `close` — one band shallower than `steps`
59
+ store.setAllowed(new Set(['task-a']))
60
+ expect(store.isExpanded('task-a')).toBe(false)
61
+ useUiStore().zoom = 2 // `steps`
62
+ expect(store.isExpanded('task-a')).toBe(true)
63
+ })
64
+
65
+ it('denies a card the driver left out of the permitted set', () => {
66
+ const store = setup(2)
67
+ store.setAllowed(new Set(['task-b']))
68
+ expect(store.isExpanded('task-a')).toBe(false)
69
+ })
70
+
71
+ it('falls back to allowed with no driver mounted, still only at the deep bands', () => {
72
+ const ui = useUiStore()
73
+ ui.zoom = 2
74
+ const store = useTaskExpansionStore() // driverActive stays false
75
+ expect(store.isExpanded('task-a')).toBe(true)
76
+ ui.zoom = 1
77
+ expect(store.isExpanded('task-a')).toBe(false)
78
+ })
79
+
80
+ it('drops both grants when the driver unmounts', () => {
81
+ const store = setup(2)
82
+ store.setAllowed(new Set(['task-a']))
83
+ store.setHovered('task-b')
84
+ store.setDriverActive(false)
85
+ expect(store.allowed.size).toBe(0)
86
+ expect(store.hoveredId).toBeNull()
87
+ })
88
+ })
@@ -1,37 +1,57 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
+ import { lodAtLeast } from '~/composables/useSemanticZoom'
4
+ import { useUiStore } from '~/stores/ui'
3
5
 
4
6
  /**
5
- * Which task cards may expand their full build-pipeline list once zoomed in.
7
+ * Which task cards render their full build-pipeline list.
6
8
  *
7
- * Deep-zoom (`steps`/`subtasks`) grows a task card downward, and cards are
8
- * absolutely positioned in their frame, so several expanded cards stacked
9
- * vertically pile on top of each other. The board driver (`useTaskExpansion`)
10
- * recomputes a permitted set every frame — only on-screen cards, the one closest to
11
- * the screen centre when two would overlap, plus whichever card the pointer is hovering
12
- * (hover expands a card regardless of position) — and writes it here.
13
- * `TaskPipelineMini` reads `canExpand` to decide whether to expand or stay compact.
9
+ * Two independent grants, both written every frame by the board driver
10
+ * (`useTaskExpansion`) and combined HERE so the render (`TaskPipelineMini`) and the
11
+ * stacking (`DraggableTask`) can never disagree about which cards are expanded:
14
12
  *
15
- * `driverActive` lets the gate degrade gracefully: with no board driver mounted
16
- * (e.g. a card rendered in isolation) `canExpand` falls back to "allowed", so the
17
- * plain zoom behaviour is unchanged.
13
+ * - HOVER the card under the pointer expands at ANY zoom level. Pointing at a task
14
+ * is asking what it is doing right now, and that answer used to be reachable only
15
+ * by first zooming past the `steps` band.
16
+ * - ZOOM — past the `steps` band, every on-screen card expands. Deep-zoom grows a card
17
+ * downward and cards are absolutely positioned in their frame, so several expanded
18
+ * cards stacked vertically pile on top of each other; the driver denies whichever
19
+ * would collide with a card nearer the screen centre and writes the survivors here.
20
+ *
21
+ * `driverActive` lets the zoom grant degrade gracefully: with no board driver mounted
22
+ * (e.g. a card rendered in isolation) it falls back to "allowed", so the plain zoom
23
+ * behaviour is unchanged. The hover grant needs no fallback — without the driver
24
+ * nothing is hovered.
18
25
  */
19
26
  export const useTaskExpansionStore = defineStore('taskExpansion', () => {
27
+ const ui = useUiStore()
20
28
  const allowed = ref<Set<string>>(new Set())
29
+ const hoveredId = ref<string | null>(null)
21
30
  const driverActive = ref(false)
22
31
 
23
32
  function setAllowed(ids: Set<string>) {
24
33
  allowed.value = ids
25
34
  }
26
35
 
36
+ function setHovered(id: string | null) {
37
+ hoveredId.value = id
38
+ }
39
+
27
40
  function setDriverActive(active: boolean) {
28
41
  driverActive.value = active
29
- if (!active) allowed.value = new Set()
42
+ if (!active) {
43
+ allowed.value = new Set()
44
+ hoveredId.value = null
45
+ }
30
46
  }
31
47
 
32
- function canExpand(id: string) {
48
+ /** Whether this task card should render its pipeline: hovered at any zoom, else
49
+ * granted by the deep-zoom bands. */
50
+ function isExpanded(id: string) {
51
+ if (hoveredId.value === id) return true
52
+ if (!lodAtLeast(ui.lod, 'steps')) return false
33
53
  return driverActive.value ? allowed.value.has(id) : true
34
54
  }
35
55
 
36
- return { allowed, driverActive, setAllowed, setDriverActive, canExpand }
56
+ return { allowed, hoveredId, driverActive, setAllowed, setHovered, setDriverActive, isExpanded }
37
57
  })
@@ -439,16 +439,17 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
439
439
  description:
440
440
  'Provisions the ephemeral environment the tester and human-test gate run against (kubernetes / custom services); a no-op for docker-compose / infraless.',
441
441
  },
442
- // The Initiative Planning pipeline's two steps. Only runnable on an initiative
442
+ // The Initiative Planning pipeline's steps. Only runnable on an initiative
443
443
  // block (pl_initiative — enforced by the engine), so they are display-metadata
444
- // system kinds, never palette archetypes.
444
+ // system kinds, never palette archetypes. The analyst runs FIRST, ahead of the
445
+ // interviewer, so the interview covers only what the code cannot answer.
445
446
  'initiative-interviewer': {
446
447
  kind: 'initiative-interviewer',
447
448
  label: 'Initiative Interviewer',
448
449
  icon: 'i-lucide-messages-square',
449
450
  color: '#818cf8',
450
451
  description:
451
- 'Interviews you on the goals, scope and constraints of the initiative, then synthesizes the agreed brief the analyst and planner build on.',
452
+ 'Interviews you on the goals, scope and constraints the codebase cannot answer, then synthesizes the agreed brief the planner builds on.',
452
453
  // Opens the dedicated planning Q&A window (answer / continue / proceed) while parked.
453
454
  resultView: 'initiative-planning',
454
455
  },
@@ -458,7 +459,7 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
458
459
  icon: 'i-lucide-microscope',
459
460
  color: '#818cf8',
460
461
  description:
461
- 'Explores the codebase and writes an analysis (architecture, touch points, risks) that grounds the plan. Makes no changes.',
462
+ 'Explores the codebase first and writes an analysis (architecture, touch points, risks) that grounds both the interview and the plan. Makes no changes.',
462
463
  resultView: 'initiative-tracker',
463
464
  },
464
465
  'initiative-planner': {
@@ -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',