@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.
- package/README.md +11 -4
- package/app/components/board/nodes/BlockNode.vue +14 -4
- package/app/components/board/nodes/DraggableTask.vue +3 -2
- package/app/components/board/nodes/InitiativeCard.vue +27 -2
- package/app/components/board/nodes/TaskPipelineMini.vue +9 -12
- package/app/components/docs/DocInterviewWindow.vue +24 -3
- package/app/components/initiative/InitiativePlanningWindow.vue +27 -7
- package/app/components/initiative/InitiativeTrackerWindow.vue +149 -9
- package/app/components/panels/inspector/InitiativeInspector.vue +23 -2
- package/app/composables/useInitiativePlanning.ts +96 -8
- package/app/composables/useTaskExpansion.ts +30 -16
- package/app/modular/nav-contributions.spec.ts +13 -3
- package/app/modular/nav-contributions.ts +42 -32
- package/app/modular/panels/inspector.logic.ts +1 -1
- package/app/stores/taskExpansion.spec.ts +88 -0
- package/app/stores/taskExpansion.ts +34 -14
- package/app/utils/catalog.ts +5 -4
- package/app/utils/initiative.spec.ts +51 -1
- package/app/utils/initiative.ts +44 -0
- package/app/utils/interviewGate.spec.ts +87 -12
- package/app/utils/interviewGate.ts +57 -5
- package/i18n/locales/de.json +15 -1
- package/i18n/locales/en.json +15 -1
- package/i18n/locales/es.json +15 -1
- package/i18n/locales/fr.json +15 -1
- package/i18n/locales/he.json +15 -1
- package/i18n/locales/it.json +15 -1
- package/i18n/locales/ja.json +15 -1
- package/i18n/locales/pl.json +15 -1
- package/i18n/locales/tr.json +15 -1
- package/i18n/locales/uk.json +15 -1
- 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,
|
|
178
|
-
|
|
179
|
-
`
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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="[
|
|
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="
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// todo breakdown expands — done / in-progress / pending — exactly the way
|
|
18
|
-
// zoomed-in bootstrap card reads. Renders nothing until the task has a run and
|
|
19
|
-
//
|
|
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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
8
|
-
// because the latter pair both read as "go forward"
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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 {
|
|
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="
|
|
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"
|