@cat-factory/app 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  // persists on `step.gate`: the precheck verdict, the helper attempt budget, the gated
6
6
  // commit, and — for CI — the failing checks behind the failure. One window serves both
7
7
  // gates; it branches on the step's `agentKind` for the copy and the failure detail.
8
- import { computed } from 'vue'
8
+ import { computed, ref } from 'vue'
9
9
  import { agentKindMeta } from '~/utils/catalog'
10
10
  import type { GateStepState } from '~/types/execution'
11
11
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
@@ -30,10 +30,37 @@ const step = computed(() => {
30
30
  const gate = computed<GateStepState | null>(() => step.value?.gate ?? null)
31
31
 
32
32
  const isCi = computed(() => step.value?.agentKind === 'ci')
33
+ const isHumanReview = computed(() => step.value?.agentKind === 'human-review')
33
34
  const meta = computed(() => agentKindMeta(step.value?.agentKind ?? 'ci'))
34
- const helperKind = computed(() => (isCi.value ? 'ci-fixer' : 'conflict-resolver'))
35
+ const helperKind = computed(() =>
36
+ isHumanReview.value ? 'fixer' : isCi.value ? 'ci-fixer' : 'conflict-resolver',
37
+ )
35
38
  const helperMeta = computed(() => agentKindMeta(helperKind.value))
36
39
 
40
+ const subtitle = computed(() =>
41
+ isHumanReview.value
42
+ ? 'Waits for a human code review on the PR, looping the fixer on comments'
43
+ : isCi.value
44
+ ? 'Gates the PR on green CI, looping the CI fixer on failure'
45
+ : 'Gates the PR on a clean merge, looping the resolver on conflicts',
46
+ )
47
+
48
+ // Human-review: approval progress + the freeform "request a fix" control.
49
+ const humanReview = useHumanReviewStore()
50
+ const fixInstructions = ref('')
51
+ const fixBusy = computed(() => (blockId.value ? humanReview.isBusy(blockId.value) : false))
52
+ async function submitFix() {
53
+ const id = blockId.value
54
+ const text = fixInstructions.value.trim()
55
+ if (!id || !text) return
56
+ await humanReview.requestFix(id, text)
57
+ fixInstructions.value = ''
58
+ }
59
+
60
+ // The displayed "required approvals" is derived from the cached branch-protection count via
61
+ // the gate's effective floor (`max(1, …)`, see review.logic.ts) rather than persisted twice.
62
+ const requiredApprovals = computed(() => Math.max(1, gate.value?.requiredApprovingReviewCount ?? 1))
63
+
37
64
  const failingChecks = computed(() => gate.value?.failingChecks ?? [])
38
65
  const shortSha = computed(() => (gate.value?.headSha ? gate.value.headSha.slice(0, 7) : null))
39
66
 
@@ -130,13 +157,7 @@ const conflictVerdict = computed(() => {
130
157
  <h2 class="truncate text-sm font-semibold text-slate-100">
131
158
  {{ meta.label }}{{ block ? ` — ${block.title}` : '' }}
132
159
  </h2>
133
- <p class="truncate text-[11px] text-slate-400">
134
- {{
135
- isCi
136
- ? 'Gates the PR on green CI, looping the CI fixer on failure'
137
- : 'Gates the PR on a clean merge, looping the resolver on conflicts'
138
- }}
139
- </p>
160
+ <p class="truncate text-[11px] text-slate-400">{{ subtitle }}</p>
140
161
  </div>
141
162
  <UBadge :color="STATUS_META[status].badge" variant="subtle" size="sm">
142
163
  {{ STATUS_META[status].label }}
@@ -186,6 +207,73 @@ const conflictVerdict = computed(() => {
186
207
  </p>
187
208
  </div>
188
209
 
210
+ <!-- Human review: approval progress, the feedback being fixed, freeform fix box -->
211
+ <template v-else-if="isHumanReview">
212
+ <div
213
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
214
+ >
215
+ <UIcon name="i-lucide-users" class="h-4 w-4 shrink-0 text-violet-300" />
216
+ <span class="text-[13px] text-slate-200">
217
+ {{ gate.lastApprovals ?? 0 }} / {{ requiredApprovals }} approval{{
218
+ requiredApprovals === 1 ? '' : 's'
219
+ }}
220
+ <template v-if="status === 'fixing'"> · fixer addressing comments…</template>
221
+ <template v-else-if="status === 'failing'">
222
+ · review comments to address</template
223
+ >
224
+ <template v-else> · awaiting review</template>
225
+ </span>
226
+ </div>
227
+ <p
228
+ v-if="gate.lastFailureSummary"
229
+ class="mt-2 whitespace-pre-wrap rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] leading-relaxed text-slate-300"
230
+ >
231
+ {{ gate.lastFailureSummary }}
232
+ </p>
233
+ <a
234
+ v-if="prUrl"
235
+ :href="prUrl"
236
+ target="_blank"
237
+ rel="noopener"
238
+ class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200 hover:underline"
239
+ >
240
+ Review pull request on GitHub
241
+ <UIcon name="i-lucide-external-link" class="h-3 w-3" />
242
+ </a>
243
+
244
+ <!-- Freeform fix request: dispatch the fixer now with these instructions. -->
245
+ <section v-if="status !== 'gave-up'" class="mt-4">
246
+ <h3
247
+ class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
248
+ >
249
+ Request a fix
250
+ </h3>
251
+ <p class="mb-2 text-[11px] leading-relaxed text-slate-500">
252
+ Describe a change for the fixer to make on the PR branch now (in addition to any
253
+ review comments, which it addresses automatically).
254
+ </p>
255
+ <textarea
256
+ v-model="fixInstructions"
257
+ rows="3"
258
+ :disabled="fixBusy"
259
+ placeholder="e.g. rename the helper and add a unit test for the empty-input case"
260
+ class="w-full resize-y rounded-md border border-slate-800 bg-slate-950/60 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-violet-500/60 focus:outline-none"
261
+ />
262
+ <div class="mt-2 flex justify-end">
263
+ <UButton
264
+ size="sm"
265
+ color="primary"
266
+ icon="i-lucide-wrench"
267
+ :loading="fixBusy"
268
+ :disabled="fixBusy || fixInstructions.trim().length === 0"
269
+ @click="submitFix"
270
+ >
271
+ Request fix
272
+ </UButton>
273
+ </div>
274
+ </section>
275
+ </template>
276
+
189
277
  <!-- CI: failing checks -->
190
278
  <template v-else-if="isCi">
191
279
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
@@ -321,9 +409,16 @@ const conflictVerdict = computed(() => {
321
409
  {{ helperMeta.label }}
322
410
  </h4>
323
411
  <p class="text-[12px] text-slate-300">
324
- {{ gate.attempts }}/{{ gate.maxAttempts }} attempt{{
325
- gate.maxAttempts === 1 ? '' : 's'
326
- }}
412
+ <!-- The human-review gate's budget is effectively unbounded (it waits for a human
413
+ indefinitely), so render a plain round count rather than "0/9007199254740991". -->
414
+ <template v-if="isHumanReview">
415
+ {{ gate.attempts }} fix round{{ gate.attempts === 1 ? '' : 's' }}
416
+ </template>
417
+ <template v-else>
418
+ {{ gate.attempts }}/{{ gate.maxAttempts }} attempt{{
419
+ gate.maxAttempts === 1 ? '' : 's'
420
+ }}
421
+ </template>
327
422
  <template v-if="gate.phase === 'working'"> · running…</template>
328
423
  <template v-else-if="gate.attempts === 0"> · not needed yet</template>
329
424
  </p>
@@ -36,6 +36,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent; action:
36
36
  // Clicking the title opens the human-testing window for the task (see `reveal`); "act" just
37
37
  // marks it read (the gate is resolved in that window — confirm / request a fix — not here).
38
38
  human_test_ready: { icon: 'i-lucide-user-check', color: 'primary', action: 'Mark read' },
39
+ // Clicking the title opens the task's gate window (where the human can request a freeform
40
+ // fix); "act" just marks it read (approval happens on GitHub, not here).
41
+ human_review: { icon: 'i-lucide-users', color: 'primary', action: 'Mark read' },
39
42
  // Clicking the title opens the Follow-up companion window for the run (see `reveal`); "act"
40
43
  // just marks it read (items are decided in that window — file / send back / answer — not here).
41
44
  followup_pending: { icon: 'i-lucide-compass', color: 'warning', action: 'Mark read' },
@@ -84,10 +87,23 @@ function reveal(n: Notification) {
84
87
  else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
85
88
  else if (n.type === 'decision_required') revealDecision(n)
86
89
  else if (n.type === 'human_test_ready') revealHumanTest(n)
90
+ else if (n.type === 'human_review') revealHumanReview(n)
87
91
  else if (n.type === 'followup_pending') revealFollowUps(n)
88
92
  else ui.select(n.blockId)
89
93
  }
90
94
 
95
+ /**
96
+ * Open the gate window for a parked `human-review` gate: find the run's human-review step and
97
+ * open it through the universal step dispatch (its archetype declares the `gate` result view,
98
+ * where the human can request a freeform fix). Falls back to focusing the block.
99
+ */
100
+ function revealHumanReview(n: Notification) {
101
+ const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
102
+ const idx = instance?.steps.findIndex((s) => s.agentKind === 'human-review') ?? -1
103
+ if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
104
+ else if (n.blockId) ui.select(n.blockId)
105
+ }
106
+
91
107
  /**
92
108
  * Open the Follow-up companion window for a run whose Coder parked on undecided items.
93
109
  * Falls back to focusing the block when the run isn't loaded.
@@ -45,6 +45,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
45
45
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
46
46
  decision_required: { enabled: false, channel: '' },
47
47
  human_test_ready: { enabled: false, channel: '' },
48
+ human_review: { enabled: false, channel: '' },
48
49
  followup_pending: { enabled: false, channel: '' },
49
50
  })
50
51
  const mentionsEnabled = ref(false)
@@ -0,0 +1,18 @@
1
+ import type { ExecutionInstance } from '~/types/domain'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The human-review gate's run-driving action. The gate self-drives off the PR's GitHub review
6
+ * state, but a human can request a freeform fix at any time — dispatched to the `fixer`
7
+ * immediately. Returns the updated execution instance (the gate state rides on its step + the
8
+ * execution stream).
9
+ */
10
+ export function humanReviewApi({ http, ws }: ApiContext) {
11
+ return {
12
+ requestHumanReviewFix: (workspaceId: string, blockId: string, instructions: string) =>
13
+ http<ExecutionInstance>(
14
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/human-review/request-fix`,
15
+ { method: 'POST', body: { instructions } },
16
+ ),
17
+ }
18
+ }
@@ -9,6 +9,7 @@ import { executionApi } from './api/execution'
9
9
  import { followUpsApi } from './api/followUps'
10
10
  import { fragmentsApi } from './api/fragments'
11
11
  import { githubApi } from './api/github'
12
+ import { humanReviewApi } from './api/humanReview'
12
13
  import { humanTestApi } from './api/humanTest'
13
14
  import { kaizenApi } from './api/kaizen'
14
15
  import { localSettingsApi } from './api/localSettings'
@@ -89,6 +90,7 @@ export function useApi() {
89
90
  ...reviewsApi(ctx),
90
91
  ...followUpsApi(ctx),
91
92
  ...humanTestApi(ctx),
93
+ ...humanReviewApi(ctx),
92
94
  ...kaizenApi(ctx),
93
95
  ...localSettingsApi(ctx),
94
96
  ...specApi(ctx),
@@ -0,0 +1,41 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { useExecutionStore } from '~/stores/execution'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * Human-review gate actions. The gate's live state rides on its execution step (`step.gate`) and
8
+ * arrives via the execution stream, so this store holds NO gate state — it only drives the
9
+ * freeform "request a fix" action and patches the execution store from the response. A per-block
10
+ * `busy` flag lets the window disable its control while the request is in flight.
11
+ */
12
+ export const useHumanReviewStore = defineStore('humanReview', () => {
13
+ const api = useApi()
14
+ const ws = useWorkspaceStore()
15
+ const execution = useExecutionStore()
16
+
17
+ const busy = ref<Set<string>>(new Set())
18
+
19
+ function isBusy(blockId: string): boolean {
20
+ return busy.value.has(blockId)
21
+ }
22
+
23
+ /** Dispatch the fixer against the PR from a human's freeform instructions (bypasses grace). */
24
+ async function requestFix(blockId: string, instructions: string): Promise<void> {
25
+ const next = new Set(busy.value)
26
+ next.add(blockId)
27
+ busy.value = next
28
+ try {
29
+ const instance = await api.requestHumanReviewFix(ws.requireId(), blockId, instructions)
30
+ if (instance && typeof instance === 'object' && 'steps' in instance) {
31
+ execution.upsert(instance as Parameters<typeof execution.upsert>[0])
32
+ }
33
+ } finally {
34
+ const after = new Set(busy.value)
35
+ after.delete(blockId)
36
+ busy.value = after
37
+ }
38
+ }
39
+
40
+ return { isBusy, requestFix }
41
+ })
@@ -315,6 +315,10 @@ export type AgentKind =
315
315
  // validate the change in a live URL, dispatching the Tester's `fixer` (from findings) or
316
316
  // the `conflict-resolver` (on a conflicting pull-main) on demand. Opens its own window.
317
317
  | 'human-test'
318
+ // The human-review gate: watches the PR for a human code review on GitHub, looping the
319
+ // `fixer` to address review comments and advancing once approved with no unresolved threads.
320
+ // Opens the shared gate window (with a freeform "request a fix" box).
321
+ | 'human-review'
318
322
  // The Kaizen agent: post-run grader (NOT a pipeline step / palette archetype). Surfaced
319
323
  // only in Model Configuration (its model is pinnable like any agent) and run details.
320
324
  | 'kaizen'
@@ -464,6 +464,22 @@ export interface GateStepState {
464
464
  failingChecks?: GateFailingCheck[] | null
465
465
  /** history of the helper-agent attempts this gate dispatched, newest last */
466
466
  attemptLog?: GateAttempt[] | null
467
+ // ---- human-review gate only ----
468
+ /** approvals the PR had at the last probe (human-review gate) */
469
+ lastApprovals?: number | null
470
+ /**
471
+ * raw branch-protection required count, cached after the first probe (human-review gate).
472
+ * The displayed "required" count is `max(1, this)` — the gate's effective floor.
473
+ */
474
+ requiredApprovingReviewCount?: number | null
475
+ /** review threads handed to the fixer, pending resolve on its completion (human-review gate) */
476
+ pendingThreadIds?: string[] | null
477
+ /** the grace window (minutes) before the fixer addresses a comment batch (human-review gate) */
478
+ humanReviewGraceMinutes?: number | null
479
+ /** newest plain PR comment already handed to the fixer (human-review gate) */
480
+ lastAddressedCommentAt?: number | null
481
+ /** a human-initiated freeform fix parked on the gate, consumed on the next poll (human-review gate) */
482
+ pendingFix?: { instructions: string; at: number } | null
467
483
  }
468
484
 
469
485
  /** Live state of a `tester` step's Tester→Fixer loop (mirrors `testerStepStateSchema`). */
@@ -15,6 +15,7 @@ export type NotificationType =
15
15
  | 'release_regression'
16
16
  | 'decision_required'
17
17
  | 'human_test_ready'
18
+ | 'human_review'
18
19
  | 'followup_pending'
19
20
  export type NotificationStatus = 'open' | 'acted' | 'dismissed'
20
21
 
@@ -352,6 +352,18 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
352
352
  color: '#a3e635',
353
353
  description: 'Scores the PR and auto-merges within the task thresholds, or asks for review.',
354
354
  },
355
+ 'human-review': {
356
+ kind: 'human-review',
357
+ label: 'Human Review Gate',
358
+ icon: 'i-lucide-users',
359
+ color: '#c084fc',
360
+ category: 'gates',
361
+ description:
362
+ 'Waits for a human code review on the PR, looping the fixer to address comments; advances once approved with no unresolved threads.',
363
+ // Opens the dedicated gate window (approval progress, the feedback being fixed, and a
364
+ // freeform "request a fix" box) like the other gates.
365
+ resultView: 'gate',
366
+ },
355
367
  // The Kaizen agent grades agent steps AFTER a run completes (continuous improvement).
356
368
  // It is NOT a pipeline step (never in the palette — no `category`), but it runs an LLM,
357
369
  // so it needs display metadata here and a per-workspace model in Model Configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",