@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
package/README.md CHANGED
@@ -131,7 +131,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
131
131
 
132
132
  - **Board canvas** (`components/board`) — `BoardCanvas` + `nodes/` (`BlockNode`,
133
133
  `ModuleFrame`, `TaskCard`), dependency edges, the per-block `AgentFailureCard` /
134
- `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`.
134
+ `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. A running task card expands
135
+ its build pipeline (`TaskPipelineMini`) on hover at any zoom level, and across every
136
+ on-screen card past the `steps` zoom band — the two grants are combined in the
137
+ `taskExpansion` store and driven by `useTaskExpansion`.
135
138
  - **Sidebar & chrome** (`components/layout`) — board/account switchers, palettes
136
139
  entry points, the language + [interface-mode](#interface-modes-basic--advanced)
137
140
  switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
@@ -174,9 +177,13 @@ Two placements are load-bearing enough to state, because putting a new one in th
174
177
  wrong place is invisible until a user cannot find it:
175
178
 
176
179
  - **The sidebar section is a claim about what the destination IS.** `models` is the
177
- engines, `integrations` the optional systems, `infrastructure` where agent
178
- containers and test environments run, `configuration` workspace/account settings.
179
- `nav-contributions.spec.ts` pins the section order and each section's membership.
180
+ model layer — the engines, the per-agent model choice, and the surfaces that
181
+ evaluate a prompt+agent+model combination (Sandbox, Kaizen); `integrations` the
182
+ optional EXTERNAL systems; `infrastructure` where agent containers and test
183
+ environments run; `configuration` workspace/account settings. A surface that
184
+ connects to nothing does not belong in `integrations` however configuration-shaped
185
+ it feels. `nav-contributions.spec.ts` pins the section order and each section's
186
+ membership.
180
187
  - **A flow that edits ONE entity's config is a section of the window that owns that
181
188
  config, not a sibling nav entry.** The guided Docker Compose environment setup
182
189
  (`ComposeEnvironmentSetupSection.vue` → `EnvironmentSetupWizard.vue`) lives inside
@@ -83,12 +83,22 @@ const selected = computed(() => ui.selectedBlockId === props.id)
83
83
  // kept (gated off) so the prior behaviour is one edit away if we want chips back.
84
84
  const showExpanded = computed(() => true)
85
85
 
86
- // Surface a pending decision from this frame OR any of its tasks (O(tasks) map
86
+ // Every child whose parked run the frame badge speaks for: its tasks AND its initiative
87
+ // blocks. An initiative is a frame child like a module and runs an ordinary pipeline (its
88
+ // planner parks on a real approval gate), so leaving it out made a whole class of parked run
89
+ // invisible at frame level — the badge read "nothing needs you" while a plan sat waiting.
90
+ const attentionIds = computed(() => {
91
+ const ids = new Set(taskIds.value)
92
+ for (const i of initiativeBlocks.value) ids.add(i.id)
93
+ return ids
94
+ })
95
+
96
+ // Surface a pending decision from this frame OR any of its children (O(children) map
87
97
  // lookups, not a scan of every open decision per frame).
88
98
  const blockDecisions = computed(() => {
89
99
  const byBlock = execution.decisionsByBlock
90
100
  const out = [...(byBlock.get(props.id) ?? [])]
91
- for (const id of taskIds.value) {
101
+ for (const id of attentionIds.value) {
92
102
  const list = byBlock.get(id)
93
103
  if (list) out.push(...list)
94
104
  }
@@ -100,14 +110,14 @@ function openFirstDecision() {
100
110
  if (d) ui.openDecision(d.instanceId, d.decision.id)
101
111
  }
102
112
 
103
- // Surface a pending approval gate from this frame OR any of its tasks — but NOT an
113
+ // Surface a pending approval gate from this frame OR any of its children — but NOT an
104
114
  // iterative reviewer gate (requirements-review / clarity-review) that's mid-cycle
105
115
  // (incorporating / re-reviewing in the driver), which is background work needing no human,
106
116
  // so it stays off the frame's "Approval" badge.
107
117
  const blockApprovals = computed(() => {
108
118
  const byBlock = execution.approvalsByBlock
109
119
  const candidates = [...(byBlock.get(props.id) ?? [])]
110
- for (const id of taskIds.value) {
120
+ for (const id of attentionIds.value) {
111
121
  const list = byBlock.get(id)
112
122
  if (list) candidates.push(...list)
113
123
  }
@@ -10,8 +10,9 @@ const task = computed(() => board.getBlock(props.taskId))
10
10
  const { draggingId, startDrag } = useBlockDrag()
11
11
 
12
12
  // An expanded pipeline grows downward over its neighbours, so it must stack above the
13
- // other (compact) task cards — never let a neighbour render on top of the pipeline.
14
- const expanded = computed(() => expansion.allowed.has(props.taskId))
13
+ // other (compact) task cards — never let a neighbour render on top of the pipeline. Reads
14
+ // the same predicate the pipeline itself renders on, so the two can't disagree.
15
+ const expanded = computed(() => expansion.isExpanded(props.taskId))
15
16
 
16
17
  // Once a task is merged it stops being a unit of work and becomes part of the
17
18
  // architecture: it no longer renders as a draggable card (arrows fall back to its
@@ -5,12 +5,18 @@
5
5
  // initiative's equivalent "Run planning" (and, while parked mid-interview, "Answer
6
6
  // planning questions") lives right here on the board — the same actions the
7
7
  // inspector offers — so starting an initiative isn't hidden behind selecting it.
8
+ // It likewise mirrors a task card's `attention` affordance: a planning run parked
9
+ // on the plan-approval gate (or on an agent-raised decision) offers the button that
10
+ // opens the window resolving it, instead of leaving the card on a spinning "Run
11
+ // planning" whose only route in was the inspector's execution panel.
8
12
  // The tracker button opens the dedicated window directly. Draggable within its
9
13
  // frame like a task card.
10
14
  import type { InitiativeStatus } from '~/types/domain'
11
15
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
16
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
13
17
  import {
18
+ INITIATIVE_ATTENTION_ICONS,
19
+ INITIATIVE_ATTENTION_LABEL_KEYS,
14
20
  INITIATIVE_STATUS_CHIPS,
15
21
  INITIATIVE_STATUS_LABEL_KEYS,
16
22
  initiativeProgress,
@@ -40,6 +46,7 @@ const {
40
46
  running,
41
47
  awaitingAnswers,
42
48
  interviewing,
49
+ attention,
43
50
  starting,
44
51
  runPlanning,
45
52
  openPlanning,
@@ -79,7 +86,10 @@ function onHandle(e: PointerEvent) {
79
86
  data-testid="initiative-card"
80
87
  :data-status="status"
81
88
  class="cursor-pointer rounded-b-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
82
- :class="[selected ? 'ring-2 ring-indigo-400/60' : '', awaitingAnswers ? 'board-pulse' : '']"
89
+ :class="[
90
+ selected ? 'ring-2 ring-indigo-400/60' : '',
91
+ awaitingAnswers || attention ? 'board-pulse' : '',
92
+ ]"
83
93
  @click.stop="select"
84
94
  >
85
95
  <div class="flex items-start justify-between gap-2">
@@ -106,8 +116,23 @@ function onHandle(e: PointerEvent) {
106
116
  </div>
107
117
  </div>
108
118
  <div class="nodrag mt-2 flex flex-wrap items-center gap-1">
119
+ <!-- Parked for a human: the drafted plan awaits approval, or an agent raised a
120
+ decision. Opens the window that can resolve the park (never the generic panel,
121
+ which the server refuses for a park a dedicated window owns). -->
109
122
  <UButton
110
- v-if="awaitingAnswers"
123
+ v-if="attention"
124
+ data-testid="initiative-card-review"
125
+ :data-attention="attention.kind"
126
+ size="xs"
127
+ variant="solid"
128
+ color="warning"
129
+ :icon="INITIATIVE_ATTENTION_ICONS[attention.kind]"
130
+ @click.stop="attention.open()"
131
+ >
132
+ {{ t(INITIATIVE_ATTENTION_LABEL_KEYS[attention.kind]) }}
133
+ </UButton>
134
+ <UButton
135
+ v-else-if="awaitingAnswers"
111
136
  data-testid="initiative-card-answer-planning"
112
137
  size="xs"
113
138
  variant="solid"
@@ -12,11 +12,11 @@ import { lodAtLeast } from '~/composables/useSemanticZoom'
12
12
  import { prReviewPhase } from '~/utils/prReviewProgress'
13
13
  import PrReviewPhaseBadge from '~/components/prReview/PrReviewPhaseBadge.vue'
14
14
 
15
- // Spatial drill-down inside a task card: at the `steps` zoom band the task's
16
- // build-pipeline steps appear, and one band deeper (`subtasks`) each step's live
17
- // todo breakdown expands — done / in-progress / pending — exactly the way a
18
- // zoomed-in bootstrap card reads. Renders nothing until the task has a run and
19
- // the user has zoomed in far enough, so it's safe to mount on every task card.
15
+ // Drill-down inside a task card: the task's build-pipeline steps appear on hover (at any
16
+ // zoom level) or once the `steps` zoom band is reached, and one band deeper (`subtasks`)
17
+ // each step's live todo breakdown expands — done / in-progress / pending — exactly the way
18
+ // a zoomed-in bootstrap card reads. Renders nothing until the task has a run and the card
19
+ // is expanded, so it's safe to mount on every task card.
20
20
  const props = defineProps<{ taskId: string }>()
21
21
 
22
22
  const execution = useExecutionStore()
@@ -40,13 +40,10 @@ const runFailed = computed(() => instance.value?.status === 'failed')
40
40
  // (spinning "Running") rather than a frozen subtask list.
41
41
  const companionByStep = computed(() => steps.value.map((s) => gateCompanionFor(s, runFailed.value)))
42
42
 
43
- // Expand the pipeline list only when zoomed in far enough AND the board driver
44
- // permits this card — on-screen, and the centre-most of any cards that would
45
- // otherwise overlap (see useTaskExpansion) so deep-zoom expansions don't pile up.
46
- const showSteps = computed(
47
- () =>
48
- lodAtLeast(lod.value, 'steps') && steps.value.length > 0 && expansion.canExpand(props.taskId),
49
- )
43
+ // Expand the pipeline list when the board driver says so: this card is hovered (at any
44
+ // zoom level), or the deep zoom bands granted it — on-screen, and the centre-most of any
45
+ // cards that would otherwise overlap. See stores/taskExpansion.ts.
46
+ const showSteps = computed(() => steps.value.length > 0 && expansion.isExpanded(props.taskId))
50
47
  const showItems = computed(() => lodAtLeast(lod.value, 'subtasks'))
51
48
 
52
49
  // Clicking a step opens the full agent step-detail overlay — execution metadata
@@ -19,7 +19,11 @@
19
19
  import { computed, reactive, watch } from 'vue'
20
20
  import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
21
21
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
22
- import { interviewGatePhase } from '~/utils/interviewGate'
22
+ import {
23
+ DOC_INTERVIEWER_KIND,
24
+ interviewGatePhase,
25
+ interviewStepReached,
26
+ } from '~/utils/interviewGate'
23
27
 
24
28
  const board = useBoardStore()
25
29
  const docInterview = useDocInterviewStore()
@@ -63,7 +67,13 @@ const resuming = computed(() => docInterview.resuming)
63
67
  * actually says, so the questions come back rather than the window sticking on a spinner.
64
68
  */
65
69
  const phase = computed(() =>
66
- resuming.value ? 'working' : interviewGatePhase(session.value?.status, run.value?.status),
70
+ resuming.value
71
+ ? 'working'
72
+ : interviewGatePhase(
73
+ session.value?.status,
74
+ run.value?.status,
75
+ interviewStepReached(run.value, DOC_INTERVIEWER_KIND),
76
+ ),
67
77
  )
68
78
  /** The interview converged: the synthesized authoring brief is what the window shows. */
69
79
  const converged = computed(() => phase.value === 'converged')
@@ -133,10 +143,21 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
133
143
  {{ t('docInterview.intro') }}
134
144
  </p>
135
145
 
146
+ <!-- The run is still ahead of the interview (the researcher + outliner steps). Same
147
+ chrome, different claim: nothing has been asked yet, so the "working on your answers"
148
+ copy below would describe answers that do not exist. -->
149
+ <InterviewGateNotice
150
+ v-if="phase === 'preparing'"
151
+ variant="working"
152
+ :title="t('docInterview.preparing')"
153
+ :hint="t('docInterview.preparingHint')"
154
+ testid="doc-interview-preparing"
155
+ />
156
+
136
157
  <!-- A pass is running: the human is waiting on the interviewer. Without this the window is
137
158
  byte-identical to the parked state and the submit reads as a no-op. -->
138
159
  <InterviewGateNotice
139
- v-if="phase === 'working'"
160
+ v-else-if="phase === 'working'"
140
161
  variant="working"
141
162
  :title="t('docInterview.working')"
142
163
  :hint="t('docInterview.workingHint')"
@@ -4,8 +4,9 @@
4
4
  // (pending `qa` entries with an empty answer) are shown here; the human answers them, then either
5
5
  // SUBMITS them (the `continue` action: the interviewer re-runs and may ask follow-ups) or plans
6
6
  // now (the `proceed` action: skip the remaining questions — the interviewer converges and the run
7
- // advances to the analyst/planner). The labels say submit/plan-now rather than continue/proceed
8
- // because the latter pair both read as "go forward" and were indistinguishable in use.
7
+ // advances to the planner; the analyst already ran, ahead of this gate). The labels say
8
+ // submit/plan-now rather than continue/proceed because the latter pair both read as "go forward"
9
+ // and were indistinguishable in use.
9
10
  // Opened via the universal result-view host: from the inspector / card
10
11
  // (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
11
12
  // stream events patch the store, so an open window follows the interview as it progresses.
@@ -29,7 +30,11 @@ import {
29
30
  isPendingQuestion,
30
31
  orderInterviewQuestions,
31
32
  } from '~/utils/initiative'
32
- import { interviewGatePhase } from '~/utils/interviewGate'
33
+ import {
34
+ INITIATIVE_INTERVIEWER_KIND,
35
+ interviewGatePhase,
36
+ interviewStepReached,
37
+ } from '~/utils/interviewGate'
33
38
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
34
39
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
35
40
 
@@ -117,7 +122,11 @@ const resuming = computed(() => initiatives.resuming)
117
122
  const phase = computed(() =>
118
123
  resuming.value
119
124
  ? 'working'
120
- : interviewGatePhase(initiative.value?.interview?.status, run.value?.status),
125
+ : interviewGatePhase(
126
+ initiative.value?.interview?.status,
127
+ run.value?.status,
128
+ interviewStepReached(run.value, INITIATIVE_INTERVIEWER_KIND),
129
+ ),
121
130
  )
122
131
 
123
132
  /**
@@ -229,10 +238,21 @@ async function onDiscard() {
229
238
  {{ t('initiative.planning.intro') }}
230
239
  </p>
231
240
 
241
+ <!-- The run is still ahead of the interview — the codebase analysis that grounds it. It
242
+ wears the working chrome but says something different on purpose: nothing has been
243
+ asked yet, so "working on your answers" would describe answers that do not exist. -->
244
+ <InterviewGateNotice
245
+ v-if="phase === 'preparing'"
246
+ variant="working"
247
+ :title="t('initiative.planning.preparing')"
248
+ :hint="t('initiative.planning.preparingHint')"
249
+ testid="initiative-planning-preparing"
250
+ />
251
+
232
252
  <!-- A pass is running: the human is waiting on the planner. Without this the window is
233
253
  byte-identical to the parked state and the submit reads as a no-op. -->
234
254
  <InterviewGateNotice
235
- v-if="phase === 'working'"
255
+ v-else-if="phase === 'working'"
236
256
  variant="working"
237
257
  :title="t('initiative.planning.working')"
238
258
  :hint="t('initiative.planning.workingHint')"
@@ -316,8 +336,8 @@ async function onDiscard() {
316
336
  <!-- Action rail. The submit/plan-now pair shows only while the run is actually parked on the
317
337
  human: mid-pass they would re-submit a question set already in flight, and the resume is a
318
338
  no-op once it isn't. Discard is the opposite — it is offered for as long as a run owns the
319
- block, because the phases where those two are hidden (working, failed) are exactly the ones
320
- a wedged run sits in. -->
339
+ block, because the phases where those two are hidden (preparing, working, failed) are
340
+ exactly the ones a wedged run sits in. -->
321
341
  <footer
322
342
  v-if="initiative && (canDiscard || (phase === 'awaiting' && questions.length > 0))"
323
343
  class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
@@ -1,14 +1,20 @@
1
1
  <script setup lang="ts">
2
- // The initiative tracker window — the dedicated read-only view of an initiative's
3
- // plan/tracker entity: goal + constraints, the phases with their per-item status +
4
- // PR links, the execution policy, and the decisions / deviations / follow-ups /
5
- // caveats logs. Renders the DB entity (the source of truth) — never the in-repo
6
- // mirror, which may not exist (GitHub-unwired workspaces). Opened via the universal
7
- // result-view host: from the board card / inspector (`ui.openInitiativeTracker`) or
8
- // as the planner step's result view. Live `initiative` stream events patch the
9
- // store, so an open window follows the plan as it is ingested and later executed.
10
- import { computed, reactive, ref } from 'vue'
2
+ // The initiative tracker window — the dedicated view of an initiative's plan/tracker
3
+ // entity: goal + constraints, the phases with their per-item status + PR links, the
4
+ // execution policy, and the decisions / deviations / follow-ups / caveats logs.
5
+ // Renders the DB entity (the source of truth) — never the in-repo mirror, which may
6
+ // not exist (GitHub-unwired workspaces). Opened via the universal result-view host:
7
+ // from the board card / inspector (`ui.openInitiativeTracker`) or as the planner
8
+ // step's result view. Live `initiative` stream events patch the store, so an open
9
+ // window follows the plan as it is ingested and later executed.
10
+ //
11
+ // It also OWNS the planner's plan-approval gate: this window is where the planner
12
+ // step's park routes (its archetype declares this result view), so the approve /
13
+ // request-changes rail has to live here or the gate has no resolving surface at all —
14
+ // which is exactly how an approved-only-over-REST plan gate shipped.
15
+ import { computed, reactive, ref, watch } from 'vue'
11
16
  import type { InitiativeFollowUp, InitiativeItem } from '~/types/domain'
17
+ import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
12
18
  import {
13
19
  INITIATIVE_FOLLOWUP_STATUS_CHIPS,
14
20
  INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
@@ -23,6 +29,8 @@ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
23
29
 
24
30
  const board = useBoardStore()
25
31
  const initiatives = useInitiativesStore()
32
+ const execution = useExecutionStore()
33
+ const access = useWorkspaceAccess()
26
34
  const { t } = useI18n()
27
35
  const toast = useToast()
28
36
 
@@ -92,6 +100,58 @@ async function checkpointControl(action: 'resume' | 'cancel') {
92
100
  }
93
101
  }
94
102
 
103
+ // ---- Plan review: the planner step's human gate, resolved right here -----------------------
104
+ // Derived from the BLOCK (via the shared planning composable), not from this window's own
105
+ // `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
106
+ // human parked on the gate actually uses. So the rail appears on every route into the window.
107
+ const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
108
+
109
+ /** Draft feedback for "request changes" (the planner re-runs with it), and the rail's in-flight
110
+ * state. Reset when a different initiative opens so a draft can't follow the window. */
111
+ const planFeedback = ref('')
112
+ const requestingChanges = ref(false)
113
+ const resolvingPlan = ref(false)
114
+ watch(blockId, () => {
115
+ planFeedback.value = ''
116
+ requestingChanges.value = false
117
+ })
118
+
119
+ const canRequestChanges = computed(() => planFeedback.value.trim().length > 0)
120
+
121
+ /**
122
+ * Accept the drafted plan: the run advances to the committer, which persists the initiative and
123
+ * arms the execution loop. The window deliberately stays OPEN — the rail disappears with the
124
+ * approval (live), and the tracker is where the plan then starts executing.
125
+ */
126
+ async function approvePlan() {
127
+ const parked = planApproval.value
128
+ if (!parked || resolvingPlan.value) return
129
+ resolvingPlan.value = true
130
+ try {
131
+ await execution.approveStep(parked.instanceId, parked.approval.id)
132
+ } finally {
133
+ resolvingPlan.value = false
134
+ }
135
+ }
136
+
137
+ /** Send the plan back to the planner with what to change; it re-plans and parks again. */
138
+ async function submitPlanChanges() {
139
+ const parked = planApproval.value
140
+ if (!parked || resolvingPlan.value || !canRequestChanges.value) return
141
+ resolvingPlan.value = true
142
+ try {
143
+ const ok = await execution.requestStepChanges(parked.instanceId, parked.approval.id, {
144
+ feedback: planFeedback.value.trim(),
145
+ })
146
+ if (ok) {
147
+ planFeedback.value = ''
148
+ requestingChanges.value = false
149
+ }
150
+ } finally {
151
+ resolvingPlan.value = false
152
+ }
153
+ }
154
+
95
155
  const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
96
156
  function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
97
157
  const axes = [
@@ -234,6 +294,86 @@ async function savePolicy() {
234
294
  </div>
235
295
 
236
296
  <template v-else>
297
+ <!-- The planner's human gate: the plan below is drafted but NOT yet committed. This
298
+ is the only surface that can resolve it (the generic approval panel is never
299
+ reached — the planner's archetype routes its park to this window), so approve /
300
+ request changes live here, beside the plan they judge. -->
301
+ <section
302
+ v-if="planApproval"
303
+ class="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3.5"
304
+ data-testid="initiative-plan-review"
305
+ >
306
+ <div class="flex items-start gap-2.5">
307
+ <UIcon
308
+ name="i-lucide-clipboard-check"
309
+ class="mt-0.5 h-4 w-4 shrink-0 text-amber-300"
310
+ />
311
+ <div class="min-w-0 flex-1">
312
+ <h3 class="text-[13px] font-semibold text-amber-200">
313
+ {{ t('initiative.planReview.title') }}
314
+ </h3>
315
+ <p class="mt-0.5 text-[12px] leading-relaxed text-amber-100/80">
316
+ {{ t('initiative.planReview.body') }}
317
+ </p>
318
+ <div v-if="!requestingChanges" class="mt-2.5 flex flex-wrap gap-2">
319
+ <button
320
+ class="rounded bg-indigo-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
321
+ :disabled="resolvingPlan || !access.canExecuteRuns.value"
322
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
323
+ data-testid="initiative-plan-approve"
324
+ @click="approvePlan"
325
+ >
326
+ {{ t('initiative.planReview.approve') }}
327
+ </button>
328
+ <button
329
+ class="rounded border border-amber-400/50 px-2.5 py-1 text-[11px] font-medium text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
330
+ :disabled="resolvingPlan || !access.canExecuteRuns.value"
331
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
332
+ data-testid="initiative-plan-request-changes"
333
+ @click="requestingChanges = true"
334
+ >
335
+ {{ t('initiative.planReview.requestChanges') }}
336
+ </button>
337
+ </div>
338
+ <!-- Request-changes composer: the feedback is what the planner re-plans FROM,
339
+ so it is required — an empty send would re-run the planner with nothing
340
+ to act on and park again on the same plan. -->
341
+ <div v-else class="mt-2.5">
342
+ <UTextarea
343
+ v-model="planFeedback"
344
+ :rows="3"
345
+ autoresize
346
+ size="sm"
347
+ class="w-full"
348
+ data-testid="initiative-plan-feedback"
349
+ :placeholder="t('initiative.planReview.feedbackPlaceholder')"
350
+ />
351
+ <div class="mt-2 flex flex-wrap gap-2">
352
+ <button
353
+ class="rounded bg-amber-500 px-2.5 py-1 text-[11px] font-medium text-slate-950 hover:bg-amber-400 disabled:opacity-50"
354
+ :disabled="
355
+ resolvingPlan || !canRequestChanges || !access.canExecuteRuns.value
356
+ "
357
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
358
+ data-testid="initiative-plan-send-back"
359
+ @click="submitPlanChanges"
360
+ >
361
+ {{ t('initiative.planReview.sendBack') }}
362
+ </button>
363
+ <button
364
+ class="rounded border border-slate-600 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:bg-slate-800 disabled:opacity-50"
365
+ :disabled="resolvingPlan"
366
+ data-testid="initiative-plan-cancel-changes"
367
+ @click="requestingChanges = false"
368
+ >
369
+ {{ t('common.cancel') }}
370
+ </button>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </section>
376
+
237
377
  <!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
238
378
  review before the next phase spawns. Read the phase's artifacts/PRs below,
239
379
  then resume (continue) or cancel (stop) the initiative right here. -->
@@ -6,7 +6,12 @@
6
6
  // with slice 4.
7
7
  import type { Block, InitiativeStatus } from '~/types/domain'
8
8
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
9
- import { INITIATIVE_STATUS_LABEL_KEYS, initiativeProgress } from '~/utils/initiative'
9
+ import {
10
+ INITIATIVE_ATTENTION_ICONS,
11
+ INITIATIVE_ATTENTION_LABEL_KEYS,
12
+ INITIATIVE_STATUS_LABEL_KEYS,
13
+ initiativeProgress,
14
+ } from '~/utils/initiative'
10
15
 
11
16
  const props = defineProps<{ block: Block }>()
12
17
 
@@ -24,6 +29,7 @@ const {
24
29
  running,
25
30
  awaitingAnswers,
26
31
  interviewing,
32
+ attention,
27
33
  starting,
28
34
  runPlanning,
29
35
  openPlanning,
@@ -56,8 +62,23 @@ function control(action: 'pause' | 'resume' | 'cancel') {
56
62
  </p>
57
63
 
58
64
  <div class="flex flex-wrap items-center gap-2">
65
+ <!-- Parked for a human (the drafted plan awaits approval, or an agent raised a decision).
66
+ The same affordance the board card carries, resolved from the same composable — the
67
+ run's park must not be reachable only through the execution panel's step list. -->
59
68
  <UButton
60
- v-if="awaitingAnswers"
69
+ v-if="attention"
70
+ data-testid="initiative-review"
71
+ :data-attention="attention.kind"
72
+ color="warning"
73
+ variant="solid"
74
+ size="sm"
75
+ :icon="INITIATIVE_ATTENTION_ICONS[attention.kind]"
76
+ @click="attention.open()"
77
+ >
78
+ {{ t(INITIATIVE_ATTENTION_LABEL_KEYS[attention.kind]) }}
79
+ </UButton>
80
+ <UButton
81
+ v-else-if="awaitingAnswers"
61
82
  data-testid="initiative-answer-planning"
62
83
  color="primary"
63
84
  variant="solid"