@cat-factory/app 0.147.2 → 0.147.4

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.
@@ -77,6 +77,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
77
77
  // Runs were paused by the spend safeguard. Workspace-scoped (no block to reveal); "act" just
78
78
  // marks it read (the human raises the budget then resumes from the spend panel).
79
79
  budget_paused: { icon: 'i-lucide-wallet', color: 'warning' },
80
+ // Stored credentials could not be decrypted (the ENCRYPTION_KEY changed since they were
81
+ // sealed). Not block-scoped; "act" drops the listed stale ciphertexts so they can be re-entered
82
+ // (or restore the previous key to recover them instead).
83
+ key_drift: { icon: 'i-lucide-key-round', color: 'error' },
80
84
  }
81
85
 
82
86
  // Per-type primary-action label. An exhaustive Record keyed off the notification
@@ -100,6 +104,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
100
104
  initiative: 'layout.notifications.action.initiative',
101
105
  platform_health: 'layout.notifications.action.platform_health',
102
106
  budget_paused: 'layout.notifications.action.budget_paused',
107
+ key_drift: 'layout.notifications.action.key_drift',
103
108
  }
104
109
 
105
110
  /** The localized primary-action label for a notification (te()-guarded against a
@@ -23,7 +23,7 @@ const phase = computed(() => prReviewPhase(props.step.prReview, props.step.subta
23
23
  // working (suppressed on a failed run). `awaiting` is the parked "findings ready" state — a
24
24
  // steady amber prompt, not a spinner.
25
25
  const PHASE_META: Record<PrReviewPhaseKind, { icon: string; spin: boolean; class: string }> = {
26
- slicing: { icon: 'i-lucide-loader-circle', spin: true, class: 'text-indigo-300' },
26
+ planning: { icon: 'i-lucide-loader-circle', spin: true, class: 'text-indigo-300' },
27
27
  reviewing: { icon: 'i-lucide-loader-circle', spin: true, class: 'text-indigo-300' },
28
28
  awaiting: { icon: 'i-lucide-clipboard-check', spin: false, class: 'text-amber-300' },
29
29
  challenging: { icon: 'i-lucide-gavel', spin: true, class: 'text-indigo-300' },
@@ -35,7 +35,7 @@ const PHASE_META: Record<PrReviewPhaseKind, { icon: string; spin: boolean; class
35
35
  // label fails the typecheck (the sanctioned dynamic enum→key guard — tier 1 can't see a
36
36
  // runtime-built key), matching the `CHUNK_STATUS_KEY` pattern in PrReviewWindow.vue.
37
37
  const PHASE_LABEL_KEY: Record<PrReviewPhaseKind, string> = {
38
- slicing: 'prReview.phase.slicing',
38
+ planning: 'prReview.phase.planning',
39
39
  reviewing: 'prReview.phase.reviewing',
40
40
  awaiting: 'prReview.phase.awaiting',
41
41
  challenging: 'prReview.phase.challenging',
@@ -21,7 +21,7 @@ import type {
21
21
  StepSubtasks,
22
22
  } from '~/types/execution'
23
23
  import { subtaskIconClass } from '~/utils/pipelineRender'
24
- import { activeChunkLabels, chunkReviewPercent, isSlicingChunks } from '~/utils/prReviewProgress'
24
+ import { activeChunkLabels, chunkReviewPercent, hasNoSlicePlan } from '~/utils/prReviewProgress'
25
25
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
26
26
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
27
27
 
@@ -55,15 +55,18 @@ const awaiting = computed(() => status.value === 'awaiting_selection')
55
55
  const challenging = computed(() => status.value === 'challenging')
56
56
  // The reviewer's live todo list while it works, streamed onto the step. Its entries are the
57
57
  // cohesive slices/chunks the agent grouped the diff into (plus a final "aggregate" step). The
58
- // derivation of the two `reviewing`-phase sub-states from it lives in `~/utils/prReviewProgress`
59
- // (pure + unit-tested); see that module for the slicing-vs-reviewing signal.
58
+ // derivation of the `reviewing`-phase sub-states from it lives in `~/utils/prReviewProgress`
59
+ // (pure + unit-tested); see that module for the has-a-plan signal.
60
60
  const subtasks = computed<StepSubtasks | null>(() => step.value?.subtasks ?? null)
61
61
 
62
62
  /**
63
- * Slicing sub-phase: the reviewer is still grouping the diff into chunks (no todo list yet).
64
- * Once the list exists, slicing is done and we render the per-chunk status list instead.
63
+ * Planning sub-phase: no per-slice todo plan has been reported yet. This is NEUTRAL the
64
+ * reviewer may still be grouping the diff, OR it may be reviewing via parallel subagents that
65
+ * never write a parent todo plan (ADR 0026 D2.2). Either way we must NOT claim a specific
66
+ * "slicing" phase; we show a neutral "reviewing…" state and switch to the per-slice list the
67
+ * moment a plan exists.
65
68
  */
66
- const slicing = computed(() => status.value === 'reviewing' && isSlicingChunks(subtasks.value))
69
+ const planning = computed(() => status.value === 'reviewing' && hasNoSlicePlan(subtasks.value))
67
70
 
68
71
  /** Chunk-review completion, clamped 0..100 for the progress bar. */
69
72
  const chunkPercent = computed(() => chunkReviewPercent(subtasks.value))
@@ -256,28 +259,29 @@ async function onDismiss(id: string): Promise<void> {
256
259
 
257
260
  <div class="flex min-h-0 flex-1">
258
261
  <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
259
- <!-- Reviewing: the read-only reviewer is still working. The phase is told apart precisely —
260
- SLICING (still grouping the diff into chunks, no plan yet) vs REVIEWING (slicing done,
261
- working through the chunks) so the copy never claims "reviewing" while it's slicing. -->
262
+ <!-- Reviewing: the read-only reviewer is still working. Two sub-states, told apart by
263
+ whether a per-slice todo plan exists a NEUTRAL "planning" state (no plan reported yet;
264
+ may be grouping OR reviewing via subagents that write no parent plan) vs the per-slice
265
+ list. The copy never asserts a specific "slicing" phase from an empty todo list. -->
262
266
  <div
263
267
  v-if="status === 'reviewing'"
264
268
  data-testid="pr-review-reviewing"
265
269
  class="flex h-full flex-col"
266
270
  >
267
- <!-- SLICING: no todo list yet — the reviewer is still grouping the diff into chunks. -->
271
+ <!-- PLANNING: no per-slice plan yet — a neutral "reviewing…" state, not a "slicing" claim. -->
268
272
  <div
269
- v-if="slicing"
270
- data-testid="pr-review-slicing"
273
+ v-if="planning"
274
+ data-testid="pr-review-planning"
271
275
  class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
272
276
  >
273
277
  <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
274
- <p class="text-sm text-slate-200">{{ t('prReview.reviewing.slicing.title') }}</p>
278
+ <p class="text-sm text-slate-200">{{ t('prReview.reviewing.planning.title') }}</p>
275
279
  <p class="max-w-sm text-[11px] text-slate-500">
276
- {{ t('prReview.reviewing.slicing.hint') }}
280
+ {{ t('prReview.reviewing.planning.hint') }}
277
281
  </p>
278
282
  </div>
279
283
 
280
- <!-- REVIEWING: slicing is done — show every chunk with its status + which are active now. -->
284
+ <!-- REVIEWING: a per-slice plan exists — show every slice with its status + which are active now. -->
281
285
  <div v-else data-testid="pr-review-reviewing-chunks" class="py-2">
282
286
  <div class="mb-1 flex items-center gap-2 text-sm text-slate-200">
283
287
  <UIcon
@@ -70,6 +70,8 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
70
70
  initiative: { enabled: false, channel: '' },
71
71
  platform_health: { enabled: false, channel: '' },
72
72
  budget_paused: { enabled: false, channel: '' },
73
+ // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
74
+ key_drift: { enabled: false, channel: '' },
73
75
  })
74
76
  const mentionsEnabled = ref(false)
75
77
  // Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
@@ -1,17 +1,22 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest'
2
2
  import { ApiError } from '~/composables/api/errors'
3
- import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
3
+ import {
4
+ personalPasswordCacheKey,
5
+ usePersonalSubscriptionsStore,
6
+ } from '~/stores/personalSubscriptions'
4
7
 
5
- // The single localStorage key the store caches the personal password under (private to the
6
- // store; hard-coded here so the buffer/expiry semantics can be asserted directly).
7
- const CACHE_KEY = 'cf.personal-pw'
8
+ // The scoped localStorage key the store caches under, for the stubbed apiBase ('') + user
9
+ // (null 'anon') the default test setup provides (see test/setup.ts). ADR 0026 D7.
10
+ const CACHE_KEY = personalPasswordCacheKey('', null)
11
+ /** The retired pre-scoping global key. */
12
+ const LEGACY_CACHE_KEY = 'cf.personal-pw'
8
13
  const HOUR = 60 * 60 * 1000
9
14
  /** Mirrors PASSWORD_EXPIRY_BUFFER_MS in the store — the runway a key must have to be ridden. */
10
15
  const BUFFER_MS = 8 * HOUR
11
16
 
12
17
  /** Write a cache entry with an explicit remaining lifetime (positive = valid, negative = past). */
13
- function seedCache(password: string, msFromNow: number) {
14
- localStorage.setItem(CACHE_KEY, JSON.stringify({ password, expiresAt: Date.now() + msFromNow }))
18
+ function seedCache(password: string, msFromNow: number, key = CACHE_KEY) {
19
+ localStorage.setItem(key, JSON.stringify({ password, expiresAt: Date.now() + msFromNow }))
15
20
  }
16
21
 
17
22
  /** A 428 credential_required error shaped like the server envelope the store parses. */
@@ -61,6 +66,52 @@ describe('personal-password cache expiry buffer', () => {
61
66
  })
62
67
  })
63
68
 
69
+ // ADR 0026 D7: the cache is scoped per installation (apiBase) + user, and the retired global
70
+ // `cf.personal-pw` key is purged on sight and never reused.
71
+ describe('per-installation + per-user cache scoping', () => {
72
+ beforeEach(() => {
73
+ localStorage.clear()
74
+ })
75
+
76
+ it('derives distinct keys per installation and per user', () => {
77
+ // Same origin, different installations (apiBase) → different keys.
78
+ expect(personalPasswordCacheKey('https://a.example', 'usr_1')).not.toBe(
79
+ personalPasswordCacheKey('https://b.example', 'usr_1'),
80
+ )
81
+ // Same installation, different users → different keys.
82
+ expect(personalPasswordCacheKey('https://a.example', 'usr_1')).not.toBe(
83
+ personalPasswordCacheKey('https://a.example', 'usr_2'),
84
+ )
85
+ // A missing user id collapses to a stable `anon` segment (auth disabled).
86
+ expect(personalPasswordCacheKey('https://a.example', null)).toBe(
87
+ personalPasswordCacheKey('https://a.example', undefined),
88
+ )
89
+ })
90
+
91
+ it('does NOT read another installation/user cache entry', () => {
92
+ const store = usePersonalSubscriptionsStore()
93
+ // A healthy entry belonging to a DIFFERENT scope must be invisible to this store.
94
+ seedCache('other-secret', 40 * HOUR, personalPasswordCacheKey('https://other', 'usr_x'))
95
+ expect(store.getCachedPassword()).toBeUndefined()
96
+ })
97
+
98
+ it('purges the retired global `cf.personal-pw` key on read and never reuses it', () => {
99
+ const store = usePersonalSubscriptionsStore()
100
+ seedCache('legacy-secret', 40 * HOUR, LEGACY_CACHE_KEY)
101
+ // The legacy value is not offered (the scoped key is absent)…
102
+ expect(store.getCachedPassword()).toBeUndefined()
103
+ // …and the retired global key is removed so it can't be reused across installs/users.
104
+ expect(localStorage.getItem(LEGACY_CACHE_KEY)).toBeNull()
105
+ })
106
+
107
+ it('round-trips a password under the scoped key', () => {
108
+ const store = usePersonalSubscriptionsStore()
109
+ seedCache('mine', 40 * HOUR)
110
+ expect(store.getCachedPassword()).toBe('mine')
111
+ expect(localStorage.getItem(CACHE_KEY)).not.toBeNull()
112
+ })
113
+ })
114
+
64
115
  describe('withCredential early re-entry', () => {
65
116
  beforeEach(() => {
66
117
  localStorage.clear()
@@ -12,12 +12,22 @@ import type {
12
12
  // a personal PASSWORD. The token itself is double-encrypted server-side and never returned;
13
13
  // this store only carries metadata + the renewal warning.
14
14
  //
15
- // To keep the password mostly transparent, it is cached CLIENT-SIDE in localStorage under a
16
- // SINGLE key with a TTL: the backend gate applies one password to ALL of a run's
17
- // individual-usage vendors, so there's no point keying the cache per vendor. A task
18
- // start/retry (and each interaction with a live run) rides along with the cached password —
19
- // sent as the `X-Personal-Password` header — and the user is only re-prompted once it
20
- // expires (or is wrong).
15
+ // To keep the password mostly transparent, it is cached CLIENT-SIDE in localStorage with a
16
+ // TTL: the backend gate applies one password to ALL of a run's individual-usage vendors, so
17
+ // there's no point keying the cache per vendor. A task start/retry (and each interaction with a
18
+ // live run) rides along with the cached password — sent as the `X-Personal-Password` header —
19
+ // and the user is only re-prompted once it expires (or is wrong).
20
+ //
21
+ // The cache key is scoped PER INSTALLATION and PER USER (ADR 0026 D7): `cf.personal-pw:<hash of
22
+ // the configured API base>:<user id>`. The bare `cf.personal-pw` it replaced was keyed only by
23
+ // browser origin, so on a shared origin (two local installs on localhost, or one hosted origin
24
+ // fronting several deployments) one installation's cached password was offered to another as
25
+ // `X-Personal-Password`, and a second signed-in user on a shared browser profile inherited the
26
+ // first user's password. Keying on the API base (distinct per installation even when the origin
27
+ // is shared) and the user id closes both: another installation or user sees no cache and is
28
+ // challenged normally. Per-workspace scoping is NOT added — the password is a per-user secret
29
+ // (the backend applies one to all of a run's individual-usage vendors), so it would only add
30
+ // redundant prompts.
21
31
  //
22
32
  // Every gated action (start / retry / confirm) re-validates the cache against an 8h EXPIRY
23
33
  // BUFFER: a key with less than that runway left is withheld (treated as absent) so the
@@ -43,7 +53,37 @@ const PASSWORD_TTL_MS = 40 * 60 * 60 * 1000
43
53
  * EARLY — a buffer wide enough that a pipeline kicked off now won't outlive the key (8h).
44
54
  */
45
55
  const PASSWORD_EXPIRY_BUFFER_MS = 8 * 60 * 60 * 1000
46
- const CACHE_KEY = 'cf.personal-pw'
56
+
57
+ /** Prefix for the per-installation + per-user cache key (see the file header). */
58
+ const CACHE_KEY_PREFIX = 'cf.personal-pw'
59
+ /**
60
+ * The pre-scoping GLOBAL key (origin-only), retired by ADR 0026 D7. It is purged on sight and
61
+ * its value is NEVER reused — migrating it would re-introduce exactly the cross-installation /
62
+ * cross-user reuse the scoping removes, so the affected user is simply re-challenged once.
63
+ */
64
+ const LEGACY_CACHE_KEY = 'cf.personal-pw'
65
+
66
+ /** A short, stable non-cryptographic hash (FNV-1a → base36) of the API base, for the key scope. */
67
+ function scopeHash(input: string): string {
68
+ let h = 0x811c9dc5
69
+ for (let i = 0; i < input.length; i++) {
70
+ h ^= input.charCodeAt(i)
71
+ h = Math.imul(h, 0x01000193)
72
+ }
73
+ return (h >>> 0).toString(36)
74
+ }
75
+
76
+ /**
77
+ * The localStorage key the personal password is cached under, scoped per installation (the
78
+ * configured `apiBase`, hashed) and per user (`userId`, or `anon` when auth is off / nobody is
79
+ * signed in). Exported for the store's unit test to seed/read the exact key the store uses.
80
+ */
81
+ export function personalPasswordCacheKey(
82
+ apiBase: string,
83
+ userId: string | null | undefined,
84
+ ): string {
85
+ return `${CACHE_KEY_PREFIX}:${scopeHash(apiBase)}:${userId ?? 'anon'}`
86
+ }
47
87
 
48
88
  /** A credential prompt the UI must satisfy (set when the server replies 428). */
49
89
  export interface PendingCredential {
@@ -71,6 +111,14 @@ export function parseCredentialError(
71
111
 
72
112
  export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions', () => {
73
113
  const api = useApi()
114
+ const auth = useAuthStore()
115
+ // The configured API base is a static per-installation fact, so hash it once at setup; the user
116
+ // id is dynamic, so it is read lazily off the auth store when the key is computed.
117
+ const apiBase = String(useRuntimeConfig().public.apiBase ?? '')
118
+ /** The current per-installation + per-user cache key. */
119
+ function cacheKey(): string {
120
+ return personalPasswordCacheKey(apiBase, auth.user?.id)
121
+ }
74
122
  const subscriptions = ref<PersonalSubscriptionStatus[]>([])
75
123
  const loading = ref(false)
76
124
  /** When set, a credential modal should open to satisfy the pending prompt. */
@@ -119,12 +167,15 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
119
167
  */
120
168
  function getCachedPassword(bufferMs = 0): string | undefined {
121
169
  if (typeof localStorage === 'undefined') return undefined
170
+ // Retire the pre-scoping global key on sight (never reused — see LEGACY_CACHE_KEY).
171
+ purgeLegacyCache()
122
172
  try {
123
- const raw = localStorage.getItem(CACHE_KEY)
173
+ const key = cacheKey()
174
+ const raw = localStorage.getItem(key)
124
175
  if (!raw) return undefined
125
176
  const { password, expiresAt } = JSON.parse(raw) as { password: string; expiresAt: number }
126
177
  if (Date.now() > expiresAt) {
127
- localStorage.removeItem(CACHE_KEY)
178
+ localStorage.removeItem(key)
128
179
  return undefined
129
180
  }
130
181
  // Within the buffer the key is still valid — keep it, but withhold it so the action
@@ -140,7 +191,7 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
140
191
  if (typeof localStorage === 'undefined') return
141
192
  try {
142
193
  localStorage.setItem(
143
- CACHE_KEY,
194
+ cacheKey(),
144
195
  JSON.stringify({ password, expiresAt: Date.now() + PASSWORD_TTL_MS }),
145
196
  )
146
197
  } catch {
@@ -151,7 +202,17 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
151
202
  function clearCachedPassword() {
152
203
  if (typeof localStorage === 'undefined') return
153
204
  try {
154
- localStorage.removeItem(CACHE_KEY)
205
+ localStorage.removeItem(cacheKey())
206
+ } catch {
207
+ // best-effort
208
+ }
209
+ }
210
+
211
+ /** Remove the retired global `cf.personal-pw` key so its value can never be offered again. */
212
+ function purgeLegacyCache() {
213
+ if (typeof localStorage === 'undefined') return
214
+ try {
215
+ localStorage.removeItem(LEGACY_CACHE_KEY)
155
216
  } catch {
156
217
  // best-effort
157
218
  }
@@ -3,7 +3,7 @@ import type { PrReviewStepState, StepSubtasks } from '~/types/execution'
3
3
  import {
4
4
  activeChunkLabels,
5
5
  chunkReviewPercent,
6
- isSlicingChunks,
6
+ hasNoSlicePlan,
7
7
  prReviewPhase,
8
8
  } from './prReviewProgress'
9
9
 
@@ -14,18 +14,19 @@ const subtasks = (over: Partial<StepSubtasks>): StepSubtasks => ({
14
14
  ...over,
15
15
  })
16
16
 
17
- describe('isSlicingChunks', () => {
18
- it('is slicing when there is no todo list yet (null/undefined or empty)', () => {
19
- // No plan committed the reviewer is still grouping the diff into chunks.
20
- expect(isSlicingChunks(null)).toBe(true)
21
- expect(isSlicingChunks(undefined)).toBe(true)
22
- expect(isSlicingChunks(subtasks({ total: 0 }))).toBe(true)
17
+ describe('hasNoSlicePlan', () => {
18
+ it('has no plan when there is no todo list yet (null/undefined or empty)', () => {
19
+ // No per-slice plan reported a NEUTRAL signal (grouping, OR a subagent review that never
20
+ // writes a parent plan), NOT proof of "slicing".
21
+ expect(hasNoSlicePlan(null)).toBe(true)
22
+ expect(hasNoSlicePlan(undefined)).toBe(true)
23
+ expect(hasNoSlicePlan(subtasks({ total: 0 }))).toBe(true)
23
24
  })
24
25
 
25
- it('is not slicing once the todo list exists (slicing is done)', () => {
26
- expect(isSlicingChunks(subtasks({ total: 3, completed: 1 }))).toBe(false)
27
- // A single-chunk plan still counts as sliced — a plan of size 1 is a committed plan.
28
- expect(isSlicingChunks(subtasks({ total: 1 }))).toBe(false)
26
+ it('has a plan once the todo list exists', () => {
27
+ expect(hasNoSlicePlan(subtasks({ total: 3, completed: 1 }))).toBe(false)
28
+ // A single-slice plan still counts — a plan of size 1 is a committed plan.
29
+ expect(hasNoSlicePlan(subtasks({ total: 1 }))).toBe(false)
29
30
  })
30
31
  })
31
32
 
@@ -89,15 +90,16 @@ describe('prReviewPhase', () => {
89
90
  expect(prReviewPhase(state('skipped'), null)).toBeNull()
90
91
  })
91
92
 
92
- it('is slicing while reviewing with no todo list yet (counts zeroed)', () => {
93
- // No plan committeddon't leak a misleading 0/0 slice count.
93
+ it('is the neutral `planning` kind while reviewing with no todo list yet (counts zeroed)', () => {
94
+ // No plan reportedsurface a neutral "reviewing, planning" phase (NOT "slicing") and don't
95
+ // leak a misleading 0/0 slice count.
94
96
  expect(prReviewPhase(state('reviewing'), null)).toEqual({
95
- kind: 'slicing',
97
+ kind: 'planning',
96
98
  completed: 0,
97
99
  total: 0,
98
100
  })
99
101
  expect(prReviewPhase(state('reviewing'), subtasks({ total: 0 }))).toEqual({
100
- kind: 'slicing',
102
+ kind: 'planning',
101
103
  completed: 0,
102
104
  total: 0,
103
105
  })
@@ -3,14 +3,21 @@ import type { PrReviewStepState, StepSubtasks } from '~/types/execution'
3
3
  // Pure derivation of the PR deep-reviewer's `reviewing`-phase progress, factored out of
4
4
  // `PrReviewWindow.vue` so it is unit-testable independently of the Vue component.
5
5
  //
6
- // The reviewer maintains a per-slice todo list (`step.subtasks`) once it has grouped the diff
7
- // into cohesive chunks. The PRESENCE of that list is the signal that slicing is done, so the two
8
- // `reviewing` sub-phases are told apart by it:
9
- // - no todo list yet (`total === 0`) still SLICING the diff into chunks (no plan committed).
10
- // - todo list present (`total > 0`) slicing DONE, working through the chunks.
6
+ // When the reviewer maintains a per-slice todo list (`step.subtasks`) one entry per cohesive
7
+ // chunk of the diff the UI shows per-slice progress. But that list is NOT always present: the
8
+ // reviewer often reviews via parallel general-purpose subagents, which never write a parent-level
9
+ // TodoWrite plan, so `subtasks` stays empty for the whole review (ADR 0026 P2/D2.2). So an empty
10
+ // todo list is NOT proof the reviewer is "still slicing" it only means no per-slice plan has
11
+ // been reported yet. We therefore surface a NEUTRAL "reviewing, planning slices" state and switch
12
+ // to per-slice status the moment a plan exists — the UI never asserts a specific "slicing" phase
13
+ // purely because the parent stream emitted no todo list.
11
14
 
12
- /** True while the reviewer is still grouping the diff into chunks (it has not committed a plan). */
13
- export function isSlicingChunks(subtasks: StepSubtasks | null | undefined): boolean {
15
+ /**
16
+ * True while NO per-slice todo plan has been reported (`total <= 0`). This is a neutral
17
+ * "no plan yet" signal — the reviewer may be grouping the diff OR reviewing via subagents that
18
+ * don't write a parent plan — NOT an assertion that it is "still slicing" (see the note above).
19
+ */
20
+ export function hasNoSlicePlan(subtasks: StepSubtasks | null | undefined): boolean {
14
21
  return (subtasks?.total ?? 0) <= 0
15
22
  }
16
23
 
@@ -29,13 +36,16 @@ export function activeChunkLabels(subtasks: StepSubtasks | null | undefined): st
29
36
 
30
37
  /**
31
38
  * The at-a-glance phase of a `pr-reviewer` step, collapsing its `prReview.status` (+ the
32
- * slicing-vs-reviewing signal from the todo list) into a single kind the board surfaces label
33
- * without re-deriving. `completed`/`total` carry the slice counts for the `reviewing` kind.
39
+ * has-a-plan signal from the todo list) into a single kind the board surfaces label without
40
+ * re-deriving. `completed`/`total` carry the slice counts for the `reviewing` kind. The
41
+ * `planning` kind is the NEUTRAL "reviewing, no per-slice plan reported yet" state (see
42
+ * {@link hasNoSlicePlan}) — it deliberately does NOT claim a specific "slicing" phase, since an
43
+ * empty todo list is the normal shape of a subagent-driven review, not proof of slicing.
34
44
  * Returns `null` for the terminal / passed-through states (`done`/`skipped`) and when there is
35
45
  * no live review — those have no in-flight phase to show.
36
46
  */
37
47
  export type PrReviewPhaseKind =
38
- | 'slicing'
48
+ | 'planning'
39
49
  | 'reviewing'
40
50
  | 'awaiting'
41
51
  | 'challenging'
@@ -44,9 +54,9 @@ export type PrReviewPhaseKind =
44
54
 
45
55
  export interface PrReviewPhase {
46
56
  kind: PrReviewPhaseKind
47
- /** Slices whose review is finished (0 while slicing). */
57
+ /** Slices whose review is finished (0 while planning). */
48
58
  completed: number
49
- /** Total slices the reviewer grouped the diff into (0 while slicing). */
59
+ /** Total slices the reviewer grouped the diff into (0 while planning). */
50
60
  total: number
51
61
  }
52
62
 
@@ -60,9 +70,10 @@ export function prReviewPhase(
60
70
  const total = subtasks?.total ?? 0
61
71
  switch (status) {
62
72
  case 'reviewing':
63
- // No todo list yet ⇒ still grouping the diff; otherwise working through the chunks.
64
- return isSlicingChunks(subtasks)
65
- ? { kind: 'slicing', completed: 0, total: 0 }
73
+ // No per-slice plan reported yet ⇒ neutral "planning" (don't claim "slicing"); once a plan
74
+ // exists, work through the chunks with live counts.
75
+ return hasNoSlicePlan(subtasks)
76
+ ? { kind: 'planning', completed: 0, total: 0 }
66
77
  : { kind: 'reviewing', completed, total }
67
78
  case 'awaiting_selection':
68
79
  return { kind: 'awaiting', completed, total }
@@ -1745,7 +1745,8 @@
1745
1745
  "markRead": "Als gelesen markieren",
1746
1746
  "fork_decision_pending": "Als gelesen markieren",
1747
1747
  "pr_review_ready": "Als gelesen markieren",
1748
- "budget_paused": "Als gelesen markieren"
1748
+ "budget_paused": "Als gelesen markieren",
1749
+ "key_drift": "Veraltete Zugangsdaten entfernen"
1749
1750
  }
1750
1751
  },
1751
1752
  "aiProvidersBanner": {
@@ -4943,9 +4944,9 @@
4943
4944
  "suggestedFix": "Lösungsvorschlag:",
4944
4945
  "line": "Zeile {line}",
4945
4946
  "reviewing": {
4946
- "slicing": {
4947
- "title": "Diff wird in Abschnitte zerlegt…",
4948
- "hint": "Die geänderten Dateien werden in zusammenhängende Abschnitte gruppiert, damit jeder einzeln geprüft werden kann. Die Abschnittsliste erscheint hier, sobald die Zerlegung abgeschlossen ist."
4947
+ "planning": {
4948
+ "title": "Pull Request wird geprüft…",
4949
+ "hint": "Der Prüfer arbeitet den Diff durch. Der Fortschritt pro Abschnitt erscheint hier, sobald die Änderungen in Abschnitte gruppiert werden."
4949
4950
  },
4950
4951
  "reviewingChunks": {
4951
4952
  "title": "Abschnitte werden geprüft…",
@@ -4962,7 +4963,7 @@
4962
4963
  }
4963
4964
  },
4964
4965
  "phase": {
4965
- "slicing": "Wird aufgeteilt…",
4966
+ "planning": "Wird geprüft…",
4966
4967
  "reviewing": "Prüfe {completed}/{total} Abschnitte",
4967
4968
  "awaiting": "Ergebnisse bereit",
4968
4969
  "challenging": "Wird untersucht…",
@@ -1725,7 +1725,8 @@
1725
1725
  "markRead": "Mark read",
1726
1726
  "fork_decision_pending": "Mark read",
1727
1727
  "pr_review_ready": "Mark read",
1728
- "budget_paused": "Mark read"
1728
+ "budget_paused": "Mark read",
1729
+ "key_drift": "Drop stale credentials"
1729
1730
  }
1730
1731
  },
1731
1732
  "aiProvidersBanner": {
@@ -5072,9 +5073,9 @@
5072
5073
  "suggestedFix": "Suggested fix:",
5073
5074
  "line": "line {line}",
5074
5075
  "reviewing": {
5075
- "slicing": {
5076
- "title": "Slicing the diff into chunks…",
5077
- "hint": "Grouping the changed files into cohesive chunks so each can be reviewed on its own. The chunk list appears here once slicing is done."
5076
+ "planning": {
5077
+ "title": "Reviewing the pull request…",
5078
+ "hint": "The reviewer is working through the diff. Per-slice progress appears here once it groups the changes into slices."
5078
5079
  },
5079
5080
  "reviewingChunks": {
5080
5081
  "title": "Reviewing the chunks…",
@@ -5091,7 +5092,7 @@
5091
5092
  }
5092
5093
  },
5093
5094
  "phase": {
5094
- "slicing": "Slicing…",
5095
+ "planning": "Reviewing…",
5095
5096
  "reviewing": "Reviewing {completed}/{total} slices",
5096
5097
  "awaiting": "Findings ready",
5097
5098
  "challenging": "Investigating…",
@@ -1656,7 +1656,8 @@
1656
1656
  "markRead": "Marcar como leída",
1657
1657
  "fork_decision_pending": "Marcar como leído",
1658
1658
  "pr_review_ready": "Marcar como leída",
1659
- "budget_paused": "Marcar como leída"
1659
+ "budget_paused": "Marcar como leída",
1660
+ "key_drift": "Descartar credenciales obsoletas"
1660
1661
  },
1661
1662
  "toast": {
1662
1663
  "acted": "Marcado como resuelto",
@@ -4931,9 +4932,9 @@
4931
4932
  "suggestedFix": "Corrección sugerida:",
4932
4933
  "line": "línea {line}",
4933
4934
  "reviewing": {
4934
- "slicing": {
4935
- "title": "Dividiendo el diff en fragmentos…",
4936
- "hint": "Agrupando los archivos modificados en fragmentos coherentes para revisar cada uno por separado. La lista de fragmentos aparecerá aquí cuando termine la división."
4935
+ "planning": {
4936
+ "title": "Revisando la pull request…",
4937
+ "hint": "El revisor está analizando el diff. El progreso por sección aparecerá aquí cuando agrupe los cambios en secciones."
4937
4938
  },
4938
4939
  "reviewingChunks": {
4939
4940
  "title": "Revisando los fragmentos…",
@@ -4950,7 +4951,7 @@
4950
4951
  }
4951
4952
  },
4952
4953
  "phase": {
4953
- "slicing": "Dividiendo…",
4954
+ "planning": "Revisando…",
4954
4955
  "reviewing": "Revisando {completed}/{total} secciones",
4955
4956
  "awaiting": "Resultados listos",
4956
4957
  "challenging": "Investigando…",
@@ -1656,7 +1656,8 @@
1656
1656
  "markRead": "Marquer comme lu",
1657
1657
  "fork_decision_pending": "Marquer comme lu",
1658
1658
  "pr_review_ready": "Marquer comme lu",
1659
- "budget_paused": "Marquer comme lu"
1659
+ "budget_paused": "Marquer comme lu",
1660
+ "key_drift": "Supprimer les identifiants obsolètes"
1660
1661
  },
1661
1662
  "toast": {
1662
1663
  "acted": "Marqué comme traité",
@@ -4931,9 +4932,9 @@
4931
4932
  "suggestedFix": "Correction suggérée :",
4932
4933
  "line": "ligne {line}",
4933
4934
  "reviewing": {
4934
- "slicing": {
4935
- "title": "Découpage du diff en sections…",
4936
- "hint": "Regroupement des fichiers modifiés en sections cohérentes afin de pouvoir examiner chacune séparément. La liste des sections apparaîtra ici une fois le découpage terminé."
4935
+ "planning": {
4936
+ "title": "Révision de la pull request…",
4937
+ "hint": "Le relecteur parcourt le diff. La progression par section apparaîtra ici une fois les modifications regroupées en sections."
4937
4938
  },
4938
4939
  "reviewingChunks": {
4939
4940
  "title": "Revue des sections…",
@@ -4950,7 +4951,7 @@
4950
4951
  }
4951
4952
  },
4952
4953
  "phase": {
4953
- "slicing": "Découpage…",
4954
+ "planning": "Révision…",
4954
4955
  "reviewing": "Révision {completed}/{total} sections",
4955
4956
  "awaiting": "Résultats prêts",
4956
4957
  "challenging": "Analyse en cours…",
@@ -1656,7 +1656,8 @@
1656
1656
  "markRead": "סמן כנקרא",
1657
1657
  "fork_decision_pending": "סמן כנקרא",
1658
1658
  "pr_review_ready": "סמן כנקרא",
1659
- "budget_paused": "סמן כנקרא"
1659
+ "budget_paused": "סמן כנקרא",
1660
+ "key_drift": "מחק אישורים מיושנים"
1660
1661
  },
1661
1662
  "toast": {
1662
1663
  "acted": "סומן כטופל",
@@ -4942,9 +4943,9 @@
4942
4943
  "suggestedFix": "תיקון מוצע:",
4943
4944
  "line": "שורה {line}",
4944
4945
  "reviewing": {
4945
- "slicing": {
4946
- "title": "מחלק את ההבדלים למקטעים…",
4947
- "hint": "מקבץ את הקבצים שהשתנו למקטעים לכידים כדי לבדוק כל אחד בנפרד. רשימת המקטעים תופיע כאן לאחר סיום החלוקה."
4946
+ "planning": {
4947
+ "title": "בודק את בקשת המשיכה…",
4948
+ "hint": "הבודק עובר על ההבדלים. ההתקדמות לפי מקטע תופיע כאן לאחר שהשינויים יקובצו למקטעים."
4948
4949
  },
4949
4950
  "reviewingChunks": {
4950
4951
  "title": "בודק את המקטעים…",
@@ -4961,7 +4962,7 @@
4961
4962
  }
4962
4963
  },
4963
4964
  "phase": {
4964
- "slicing": "מחלק…",
4965
+ "planning": "בודק…",
4965
4966
  "reviewing": "בודק {completed}/{total} מקטעים",
4966
4967
  "awaiting": "הממצאים מוכנים",
4967
4968
  "challenging": "חוקר…",
@@ -1745,7 +1745,8 @@
1745
1745
  "markRead": "Segna come letto",
1746
1746
  "fork_decision_pending": "Segna come letto",
1747
1747
  "pr_review_ready": "Segna come letto",
1748
- "budget_paused": "Segna come letto"
1748
+ "budget_paused": "Segna come letto",
1749
+ "key_drift": "Elimina credenziali obsolete"
1749
1750
  }
1750
1751
  },
1751
1752
  "aiProvidersBanner": {
@@ -4943,9 +4944,9 @@
4943
4944
  "suggestedFix": "Correzione suggerita:",
4944
4945
  "line": "riga {line}",
4945
4946
  "reviewing": {
4946
- "slicing": {
4947
- "title": "Suddivisione del diff in blocchi…",
4948
- "hint": "Raggruppamento dei file modificati in blocchi coerenti per esaminare ciascuno separatamente. L'elenco dei blocchi comparirà qui al termine della suddivisione."
4947
+ "planning": {
4948
+ "title": "Revisione della pull request…",
4949
+ "hint": "Il revisore sta esaminando il diff. L'avanzamento per sezione comparirà qui una volta raggruppate le modifiche in sezioni."
4949
4950
  },
4950
4951
  "reviewingChunks": {
4951
4952
  "title": "Revisione dei blocchi…",
@@ -4962,7 +4963,7 @@
4962
4963
  }
4963
4964
  },
4964
4965
  "phase": {
4965
- "slicing": "Suddivisione…",
4966
+ "planning": "Revisione…",
4966
4967
  "reviewing": "Revisione {completed}/{total} sezioni",
4967
4968
  "awaiting": "Risultati pronti",
4968
4969
  "challenging": "Analisi in corso…",
@@ -1656,7 +1656,8 @@
1656
1656
  "markRead": "既読にする",
1657
1657
  "fork_decision_pending": "既読にする",
1658
1658
  "pr_review_ready": "既読にする",
1659
- "budget_paused": "既読にする"
1659
+ "budget_paused": "既読にする",
1660
+ "key_drift": "古い認証情報を削除"
1660
1661
  },
1661
1662
  "toast": {
1662
1663
  "acted": "対応済みにしました",
@@ -4943,9 +4944,9 @@
4943
4944
  "suggestedFix": "修正案:",
4944
4945
  "line": "{line}行目",
4945
4946
  "reviewing": {
4946
- "slicing": {
4947
- "title": "差分をチャンクに分割中…",
4948
- "hint": "変更されたファイルをまとまりのあるチャンクにグループ化し、各チャンクを個別にレビューできるようにしています。分割が完了すると、ここにチャンク一覧が表示されます。"
4947
+ "planning": {
4948
+ "title": "プルリクエストをレビュー中…",
4949
+ "hint": "レビュー担当者が差分を確認しています。変更が区分にグループ化されると、区分ごとの進捗がここに表示されます。"
4949
4950
  },
4950
4951
  "reviewingChunks": {
4951
4952
  "title": "チャンクをレビュー中…",
@@ -4962,7 +4963,7 @@
4962
4963
  }
4963
4964
  },
4964
4965
  "phase": {
4965
- "slicing": "分割中…",
4966
+ "planning": "レビュー中…",
4966
4967
  "reviewing": "レビュー中 {completed}/{total} 区分",
4967
4968
  "awaiting": "指摘の準備完了",
4968
4969
  "challenging": "調査中…",
@@ -1656,7 +1656,8 @@
1656
1656
  "markRead": "Oznacz jako przeczytane",
1657
1657
  "fork_decision_pending": "Oznacz jako przeczytane",
1658
1658
  "pr_review_ready": "Oznacz jako przeczytane",
1659
- "budget_paused": "Oznacz jako przeczytane"
1659
+ "budget_paused": "Oznacz jako przeczytane",
1660
+ "key_drift": "Usuń nieaktualne poświadczenia"
1660
1661
  },
1661
1662
  "toast": {
1662
1663
  "acted": "Oznaczono jako obsłużone",
@@ -4931,9 +4932,9 @@
4931
4932
  "suggestedFix": "Sugerowana poprawka:",
4932
4933
  "line": "wiersz {line}",
4933
4934
  "reviewing": {
4934
- "slicing": {
4935
- "title": "Dzielenie zmian na fragmenty…",
4936
- "hint": "Grupowanie zmienionych plików w spójne fragmenty, aby każdy można było przejrzeć osobno. Lista fragmentów pojawi się tutaj po zakończeniu podziału."
4935
+ "planning": {
4936
+ "title": "Przeglądanie pull requesta…",
4937
+ "hint": "Recenzent analizuje zmiany. Postęp poszczególnych fragmentów pojawi się tutaj po pogrupowaniu zmian we fragmenty."
4937
4938
  },
4938
4939
  "reviewingChunks": {
4939
4940
  "title": "Przeglądanie fragmentów…",
@@ -4950,7 +4951,7 @@
4950
4951
  }
4951
4952
  },
4952
4953
  "phase": {
4953
- "slicing": "Dzielenie…",
4954
+ "planning": "Przegląd…",
4954
4955
  "reviewing": "Przegląd {completed}/{total} fragmentów",
4955
4956
  "awaiting": "Wyniki gotowe",
4956
4957
  "challenging": "Analizowanie…",
@@ -1656,7 +1656,8 @@
1656
1656
  "markRead": "Okundu işaretle",
1657
1657
  "fork_decision_pending": "Okundu olarak işaretle",
1658
1658
  "pr_review_ready": "Okundu işaretle",
1659
- "budget_paused": "Okundu işaretle"
1659
+ "budget_paused": "Okundu işaretle",
1660
+ "key_drift": "Eski kimlik bilgilerini kaldır"
1660
1661
  },
1661
1662
  "toast": {
1662
1663
  "acted": "İşlendi olarak işaretlendi",
@@ -4943,9 +4944,9 @@
4943
4944
  "suggestedFix": "Önerilen düzeltme:",
4944
4945
  "line": "satır {line}",
4945
4946
  "reviewing": {
4946
- "slicing": {
4947
- "title": "Fark parçalara bölünüyor…",
4948
- "hint": "Değiştirilen dosyalar, her biri ayrı ayrı incelenebilsin diye tutarlı parçalara gruplanıyor. Bölme tamamlandığında parça listesi burada görünecek."
4947
+ "planning": {
4948
+ "title": "Pull request inceleniyor…",
4949
+ "hint": "İnceleyici farkı gözden geçiriyor. Değişiklikler bölümlere gruplandığında bölüm bazlı ilerleme burada görünecek."
4949
4950
  },
4950
4951
  "reviewingChunks": {
4951
4952
  "title": "Parçalar inceleniyor…",
@@ -4962,7 +4963,7 @@
4962
4963
  }
4963
4964
  },
4964
4965
  "phase": {
4965
- "slicing": "Dilimleniyor…",
4966
+ "planning": "İnceleniyor…",
4966
4967
  "reviewing": "İnceleniyor {completed}/{total} bölüm",
4967
4968
  "awaiting": "Bulgular hazır",
4968
4969
  "challenging": "Araştırılıyor…",
@@ -1656,7 +1656,8 @@
1656
1656
  "markRead": "Позначити прочитаним",
1657
1657
  "fork_decision_pending": "Позначити прочитаним",
1658
1658
  "pr_review_ready": "Позначити прочитаним",
1659
- "budget_paused": "Позначити прочитаним"
1659
+ "budget_paused": "Позначити прочитаним",
1660
+ "key_drift": "Видалити застарілі облікові дані"
1660
1661
  },
1661
1662
  "toast": {
1662
1663
  "acted": "Позначено як опрацьоване",
@@ -4931,9 +4932,9 @@
4931
4932
  "suggestedFix": "Пропоноване виправлення:",
4932
4933
  "line": "рядок {line}",
4933
4934
  "reviewing": {
4934
- "slicing": {
4935
- "title": "Поділ змін на фрагменти…",
4936
- "hint": "Групування змінених файлів у цілісні фрагменти, щоб кожен можна було переглянути окремо. Список фрагментів з'явиться тут після завершення поділу."
4935
+ "planning": {
4936
+ "title": "Перевірка pull request…",
4937
+ "hint": "Рецензент переглядає зміни. Прогрес за фрагментами з'явиться тут після групування змін у фрагменти."
4937
4938
  },
4938
4939
  "reviewingChunks": {
4939
4940
  "title": "Огляд фрагментів…",
@@ -4950,7 +4951,7 @@
4950
4951
  }
4951
4952
  },
4952
4953
  "phase": {
4953
- "slicing": "Розбиття…",
4954
+ "planning": "Перевірка…",
4954
4955
  "reviewing": "Перевірка {completed}/{total} фрагментів",
4955
4956
  "awaiting": "Результати готові",
4956
4957
  "challenging": "Дослідження…",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.147.2",
3
+ "version": "0.147.4",
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",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.154.0"
43
+ "@cat-factory/contracts": "0.154.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",