@cat-factory/app 0.184.0 → 0.186.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.
Files changed (34) hide show
  1. package/README.md +4 -1
  2. package/app/assets/css/main.css +2 -0
  3. package/app/assets/css/prose.css +135 -0
  4. package/app/components/board/AddTaskModal.vue +31 -1
  5. package/app/components/brainstorm/BrainstormWindow.vue +4 -37
  6. package/app/components/clarity/ClarityReviewWindow.vue +4 -37
  7. package/app/components/initiative/InitiativePlanReview.vue +351 -0
  8. package/app/components/initiative/InitiativeTrackerWindow.vue +12 -126
  9. package/app/components/panels/AgentStepDetail.vue +19 -122
  10. package/app/components/panels/inspector/ServiceValidationConfig.vue +42 -4
  11. package/app/components/panels/inspector/TaskReviewTarget.vue +69 -0
  12. package/app/components/requirements/RequirementsReviewWindow.vue +4 -37
  13. package/app/composables/useProseComments.ts +126 -0
  14. package/app/composables/useStepApproval.ts +29 -85
  15. package/app/composables/useStepProse.spec.ts +67 -0
  16. package/app/composables/useStepProse.ts +22 -10
  17. package/app/modular/nav-contributions.spec.ts +3 -13
  18. package/app/modular/panels/inspector.logic.spec.ts +7 -0
  19. package/app/modular/panels/inspector.logic.ts +5 -0
  20. package/app/modular/panels/inspector.ts +2 -0
  21. package/app/stores/execution.ts +9 -0
  22. package/app/stores/validationChecks.ts +8 -4
  23. package/app/utils/initiative.spec.ts +24 -0
  24. package/i18n/locales/de.json +17 -3
  25. package/i18n/locales/en.json +18 -3
  26. package/i18n/locales/es.json +17 -3
  27. package/i18n/locales/fr.json +17 -3
  28. package/i18n/locales/he.json +17 -3
  29. package/i18n/locales/it.json +17 -3
  30. package/i18n/locales/ja.json +17 -3
  31. package/i18n/locales/pl.json +17 -3
  32. package/i18n/locales/tr.json +17 -3
  33. package/i18n/locales/uk.json +17 -3
  34. package/package.json +2 -2
@@ -0,0 +1,126 @@
1
+ import { computed, nextTick, ref, watch } from 'vue'
2
+ import { sliceSource } from '~/utils/agentOutput'
3
+
4
+ /** A draft per-block review comment, anchored to a source line range of the reviewed markdown. */
5
+ export interface ProseComment {
6
+ srcStart: number
7
+ srcEnd: number
8
+ quotedSource: string
9
+ body: string
10
+ }
11
+
12
+ /**
13
+ * GitHub-style per-block commenting over a rendered markdown document — the anchoring half of a
14
+ * prose review, with no opinion about WHAT is being reviewed or how the review is submitted.
15
+ *
16
+ * Extracted from `useStepApproval` (which now builds on it) so a second surface can offer the
17
+ * same affordance: the initiative tracker's plan-approval rail reviews the planner's drafted plan,
18
+ * which the engine renders as markdown onto the gate's proposal. Both therefore anchor comments
19
+ * identically — a comment quotes the exact source lines of the block it targets, so a "request
20
+ * changes" re-run can hand the agent back its OWN text rather than a re-rendered approximation.
21
+ *
22
+ * The anchors are the `data-src-start`/`data-src-end` attributes `parseOutputOutline` stamps on
23
+ * every top-level block, so this works over any document that reader renders.
24
+ *
25
+ * @param output the raw markdown being reviewed (comments quote out of it by line range)
26
+ * @param root the element containing the rendered blocks, for the highlight sync
27
+ * @param enabled whether clicking a block should start a comment (review mode is on)
28
+ */
29
+ export function useProseComments(opts: {
30
+ output: () => string
31
+ root: () => HTMLElement | null
32
+ enabled: () => boolean
33
+ }) {
34
+ const comments = ref<ProseComment[]>([])
35
+ const draftTarget = ref<{ srcStart: number; srcEnd: number; quotedSource: string } | null>(null)
36
+ const draftBody = ref('')
37
+
38
+ const blockKey = (c: { srcStart: number; srcEnd: number }) => `${c.srcStart}:${c.srcEnd}`
39
+
40
+ /** Toggle the highlight classes on commented / selected blocks within the rendered document. */
41
+ function syncHighlights() {
42
+ const root = opts.root()
43
+ if (!root) return
44
+ const commented = new Set(comments.value.map(blockKey))
45
+ const selected = draftTarget.value ? blockKey(draftTarget.value) : null
46
+ for (const el of Array.from(root.querySelectorAll('[data-src-start]'))) {
47
+ const key = `${el.getAttribute('data-src-start')}:${el.getAttribute('data-src-end')}`
48
+ el.classList.toggle('cf-commented', commented.has(key))
49
+ el.classList.toggle('cf-selected', key === selected)
50
+ }
51
+ }
52
+
53
+ /** Click a rendered block to start commenting on it (links keep working). */
54
+ function onProseClick(e: MouseEvent) {
55
+ if (!opts.enabled()) return
56
+ const target = e.target as HTMLElement
57
+ if (target.closest('a')) return
58
+ const blockEl = target.closest('[data-src-start]') as HTMLElement | null
59
+ if (!blockEl) return
60
+ const srcStart = Number(blockEl.getAttribute('data-src-start'))
61
+ const srcEnd = Number(blockEl.getAttribute('data-src-end'))
62
+ if (Number.isNaN(srcStart) || Number.isNaN(srcEnd)) return
63
+ draftTarget.value = {
64
+ srcStart,
65
+ srcEnd,
66
+ quotedSource: sliceSource(opts.output(), srcStart, srcEnd),
67
+ }
68
+ draftBody.value = ''
69
+ void nextTick(syncHighlights)
70
+ }
71
+
72
+ function addDraftComment() {
73
+ if (!draftTarget.value || !draftBody.value.trim()) return
74
+ comments.value.push({ ...draftTarget.value, body: draftBody.value.trim() })
75
+ draftTarget.value = null
76
+ draftBody.value = ''
77
+ void nextTick(syncHighlights)
78
+ }
79
+ function cancelDraft() {
80
+ draftTarget.value = null
81
+ draftBody.value = ''
82
+ void nextTick(syncHighlights)
83
+ }
84
+ function removeComment(idx: number) {
85
+ comments.value.splice(idx, 1)
86
+ void nextTick(syncHighlights)
87
+ }
88
+
89
+ /** Drop every draft — a different document (or a different subject) is being reviewed. */
90
+ function reset() {
91
+ comments.value = []
92
+ draftTarget.value = null
93
+ draftBody.value = ''
94
+ void nextTick(syncHighlights)
95
+ }
96
+
97
+ /** The wire shape `requestStepChanges` takes, or undefined when nothing was anchored. */
98
+ const wireComments = computed(() =>
99
+ comments.value.length
100
+ ? comments.value.map((c) => ({
101
+ quotedSource: c.quotedSource,
102
+ srcStart: c.srcStart,
103
+ srcEnd: c.srcEnd,
104
+ body: c.body,
105
+ }))
106
+ : undefined,
107
+ )
108
+
109
+ // Keep the in-document highlights in sync as the document renders or the drafts change.
110
+ watch([opts.enabled, opts.output, comments, draftTarget], () => void nextTick(syncHighlights), {
111
+ deep: true,
112
+ })
113
+
114
+ return {
115
+ comments,
116
+ wireComments,
117
+ draftTarget,
118
+ draftBody,
119
+ syncHighlights,
120
+ onProseClick,
121
+ addDraftComment,
122
+ cancelDraft,
123
+ removeComment,
124
+ reset,
125
+ }
126
+ }
@@ -1,23 +1,17 @@
1
- import { ref, computed, nextTick, watch } from 'vue'
1
+ import { ref, computed, nextTick } from 'vue'
2
2
  import type { PipelineStep } from '~/types/execution'
3
- import { sliceSource } from '~/utils/agentOutput'
4
-
5
- /** A draft per-block review comment, anchored to a source range of the output. */
6
- interface DraftComment {
7
- srcStart: number
8
- srcEnd: number
9
- quotedSource: string
10
- body: string
11
- }
3
+ import { useProseComments } from '~/composables/useProseComments'
12
4
 
13
5
  /**
14
6
  * The GitHub-style approval/review state machine for a pending gate step. When the
15
7
  * step's gate is pending the prose reader doubles as a review surface: the human can
16
8
  * comment on individual source-mapped blocks, leave overall feedback, edit the
17
9
  * conclusions in place, then Approve / Request changes / Reject. This composable owns
18
- * all of that draft state + the in-document highlight syncing; the parent supplies the
19
- * live step, the scroll container (for highlight lookups), the run/approval ids, and a
20
- * `close` callback the actions invoke once they resolve.
10
+ * the approval-API half plus the edit/reject sub-states; the per-block comment drafts and
11
+ * their in-document highlighting live in {@link useProseComments}, which the initiative
12
+ * tracker's plan-approval rail shares. The parent supplies the live step, the scroll
13
+ * container (for highlight lookups), the run/approval ids, and a `close` callback the
14
+ * actions invoke once they resolve.
21
15
  */
22
16
  export function useStepApproval(opts: {
23
17
  step: () => PipelineStep | null
@@ -30,11 +24,8 @@ export function useStepApproval(opts: {
30
24
  }) {
31
25
  const execution = useExecutionStore()
32
26
 
33
- const reviewComments = ref<DraftComment[]>([])
34
27
  const feedback = ref('')
35
28
  const submitting = ref(false)
36
- const draftTarget = ref<{ srcStart: number; srcEnd: number; quotedSource: string } | null>(null)
37
- const draftBody = ref('')
38
29
 
39
30
  // "Approve with corrections" mode: a deliberate state distinct from the read-only
40
31
  // review — the human edits the conclusions directly and those edits flow forward as
@@ -45,56 +36,25 @@ export function useStepApproval(opts: {
45
36
  // Reject stops the whole run, so it's a two-step inline confirm (no native dialog).
46
37
  const rejectArmed = ref(false)
47
38
 
48
- const blockKey = (c: { srcStart: number; srcEnd: number }) => `${c.srcStart}:${c.srcEnd}`
49
-
50
- /** Toggle the highlight classes on commented / selected blocks within the reader. */
51
- function syncHighlights() {
52
- const root = opts.scrollEl()
53
- if (!root) return
54
- const commented = new Set(reviewComments.value.map(blockKey))
55
- const selected = draftTarget.value ? blockKey(draftTarget.value) : null
56
- for (const el of Array.from(root.querySelectorAll('[data-src-start]'))) {
57
- const key = `${el.getAttribute('data-src-start')}:${el.getAttribute('data-src-end')}`
58
- el.classList.toggle('cf-commented', commented.has(key))
59
- el.classList.toggle('cf-selected', key === selected)
60
- }
61
- }
62
-
63
- /** Click a rendered block to start commenting on it (links keep working). */
64
- function onProseClick(e: MouseEvent) {
65
- if (!opts.approvalPending() || opts.companionExceeded() || editing.value) return
66
- const target = e.target as HTMLElement
67
- if (target.closest('a')) return
68
- const blockEl = target.closest('[data-src-start]') as HTMLElement | null
69
- if (!blockEl) return
70
- const srcStart = Number(blockEl.getAttribute('data-src-start'))
71
- const srcEnd = Number(blockEl.getAttribute('data-src-end'))
72
- if (Number.isNaN(srcStart) || Number.isNaN(srcEnd)) return
73
- draftTarget.value = {
74
- srcStart,
75
- srcEnd,
76
- quotedSource: sliceSource(opts.step()?.output ?? '', srcStart, srcEnd),
77
- }
78
- draftBody.value = ''
79
- void nextTick(syncHighlights)
80
- }
81
-
82
- function addDraftComment() {
83
- if (!draftTarget.value || !draftBody.value.trim()) return
84
- reviewComments.value.push({ ...draftTarget.value, body: draftBody.value.trim() })
85
- draftTarget.value = null
86
- draftBody.value = ''
87
- void nextTick(syncHighlights)
88
- }
89
- function cancelDraft() {
90
- draftTarget.value = null
91
- draftBody.value = ''
92
- void nextTick(syncHighlights)
93
- }
94
- function removeComment(idx: number) {
95
- reviewComments.value.splice(idx, 1)
96
- void nextTick(syncHighlights)
97
- }
39
+ // The per-block comment drafts + their in-document highlights. Commenting is off while the
40
+ // human is in edit mode: the two paths are mutually exclusive, and the editor replaces the
41
+ // rendered document with a textarea, so there are no blocks to anchor to.
42
+ const prose = useProseComments({
43
+ output: () => opts.step()?.output ?? '',
44
+ root: () => opts.scrollEl(),
45
+ enabled: () => opts.approvalPending() && !opts.companionExceeded() && !editing.value,
46
+ })
47
+ const {
48
+ comments: reviewComments,
49
+ wireComments,
50
+ draftTarget,
51
+ draftBody,
52
+ syncHighlights,
53
+ onProseClick,
54
+ addDraftComment,
55
+ cancelDraft,
56
+ removeComment,
57
+ } = prose
98
58
 
99
59
  const canRequestChanges = computed(
100
60
  () => !!feedback.value.trim() || reviewComments.value.length > 0,
@@ -119,7 +79,7 @@ export function useStepApproval(opts: {
119
79
  editing.value = true
120
80
  // Editing and the review/reject path are mutually exclusive — clear the other.
121
81
  rejectArmed.value = false
122
- draftTarget.value = null
82
+ prose.cancelDraft()
123
83
  void nextTick(syncHighlights)
124
84
  }
125
85
  function cancelEditing() {
@@ -143,14 +103,7 @@ export function useStepApproval(opts: {
143
103
  try {
144
104
  const ok = await execution.requestStepChanges(opts.instanceId()!, id, {
145
105
  feedback: feedback.value.trim() || undefined,
146
- comments: reviewComments.value.length
147
- ? reviewComments.value.map((c) => ({
148
- quotedSource: c.quotedSource,
149
- srcStart: c.srcStart,
150
- srcEnd: c.srcEnd,
151
- body: c.body,
152
- }))
153
- : undefined,
106
+ comments: wireComments.value,
154
107
  })
155
108
  if (ok) opts.close()
156
109
  } finally {
@@ -189,22 +142,13 @@ export function useStepApproval(opts: {
189
142
 
190
143
  /** Full reset of every draft when a different gate/step opens. */
191
144
  function resetForStep() {
192
- reviewComments.value = []
145
+ prose.reset()
193
146
  feedback.value = ''
194
- draftTarget.value = null
195
- draftBody.value = ''
196
147
  rejectArmed.value = false
197
148
  editing.value = false
198
149
  draftProposal.value = ''
199
150
  }
200
151
 
201
- // Keep the in-document highlights in sync as the output renders or comments change.
202
- watch(
203
- [opts.approvalPending, () => opts.step()?.output, reviewComments, draftTarget],
204
- () => void nextTick(syncHighlights),
205
- { deep: true },
206
- )
207
-
208
152
  return {
209
153
  reviewComments,
210
154
  feedback,
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useStepProse } from './useStepProse'
3
+
4
+ const DOC = ['# Plan', '', 'Intro.', '', '## Phase 1', '', 'First.', '', '## Phase 2', ''].join(
5
+ '\n',
6
+ )
7
+
8
+ /**
9
+ * A fake measurable element: the scroll-spy only ever reads `getBoundingClientRect().top`, so
10
+ * the vertical position is the whole of what a section is to it.
11
+ */
12
+ function sectionAt(top: number): HTMLElement {
13
+ return { getBoundingClientRect: () => ({ top }) } as unknown as HTMLElement
14
+ }
15
+
16
+ describe('useStepProse scroll-spy', () => {
17
+ /**
18
+ * The reader's own layout: a details card ahead of the prose, which the consumer registers.
19
+ */
20
+ it('tracks the last anchor above the fold, lead section included', () => {
21
+ const prose = useStepProse(() => DOC)
22
+ prose.scrollEl.value = sectionAt(0)
23
+ const [first, second] = prose.tocSections.value
24
+ prose.sectionEls['step-details'] = sectionAt(-200)
25
+ prose.sectionEls[first!.id] = sectionAt(-100)
26
+ prose.sectionEls[second!.id] = sectionAt(400)
27
+
28
+ prose.onScroll()
29
+ expect(prose.activeId.value).toBe(first!.id)
30
+
31
+ // Scrolling on past the second heading moves the highlight to it.
32
+ prose.sectionEls[second!.id] = sectionAt(20)
33
+ prose.onScroll()
34
+ expect(prose.activeId.value).toBe(second!.id)
35
+ })
36
+
37
+ /**
38
+ * The regression this option exists for. A consumer that renders the document ALONE (the
39
+ * initiative tracker's plan-approval rail) never registers a lead anchor, and the spy walks
40
+ * anchors in document order and stops at the first one it cannot measure — so with the lead
41
+ * anchor hardcoded it stopped immediately, pinning `activeId` to a section that does not
42
+ * exist. Nothing threw; the ToC simply never highlighted anything, and any click-to-navigate
43
+ * highlight was wiped by the scroll event the smooth scroll itself fires.
44
+ */
45
+ it('tracks sections when the consumer renders no lead anchor', () => {
46
+ const prose = useStepProse(() => DOC, { leadAnchorId: null })
47
+ prose.scrollEl.value = sectionAt(0)
48
+ const [first, second] = prose.tocSections.value
49
+ prose.sectionEls[first!.id] = sectionAt(-100)
50
+ prose.sectionEls[second!.id] = sectionAt(400)
51
+
52
+ prose.onScroll()
53
+ expect(prose.activeId.value).toBe(first!.id)
54
+ })
55
+
56
+ it('starts on the lead anchor, or on nothing when there is none', () => {
57
+ expect(useStepProse(() => DOC).activeId.value).toBe('step-details')
58
+ expect(useStepProse(() => DOC, { leadAnchorId: null }).activeId.value).toBe('')
59
+ })
60
+
61
+ it('re-seeds the active anchor on reset', () => {
62
+ const prose = useStepProse(() => DOC, { leadAnchorId: null })
63
+ prose.activeId.value = 'somewhere'
64
+ prose.reset()
65
+ expect(prose.activeId.value).toBe('')
66
+ })
67
+ })
@@ -4,23 +4,35 @@ import { parseOutputOutline } from '~/utils/agentOutput'
4
4
  /**
5
5
  * The prose reader for an agent step's markdown output: its heading outline, the
6
6
  * per-section collapse state, and the scroll-spy that keeps the ToC in sync.
7
- * Owns the scroll container + per-section element refs the template binds; the
8
- * details card is always the first anchor. `reset()` re-seeds (all sections
9
- * expanded, scrolled to top) whenever a different step opens.
7
+ * Owns the scroll container + per-section element refs the template binds.
8
+ * `reset()` re-seeds (all sections expanded, scrolled to top) whenever a different
9
+ * step opens.
10
+ *
11
+ * `leadAnchorId` names a section the CONSUMER renders ahead of the prose and registers in
12
+ * `sectionEls` — the step reader's details card. It is an option rather than a constant
13
+ * because the scroll-spy walks the anchors in document order and stops at the first one it
14
+ * cannot measure: a consumer that renders no such element (the initiative tracker's
15
+ * plan-approval rail, which has the document alone) would otherwise have its spy stop dead on
16
+ * an anchor that never exists, pinning `activeId` to a section nobody can see and leaving the
17
+ * ToC with nothing highlighted. Pass `null` when the prose is the whole document.
10
18
  */
11
- export function useStepProse(getOutput: () => string) {
19
+ export function useStepProse(getOutput: () => string, opts: { leadAnchorId?: string | null } = {}) {
20
+ const leadAnchorId = opts.leadAnchorId === undefined ? 'step-details' : opts.leadAnchorId
12
21
  const outline = computed(() => parseOutputOutline(getOutput()))
13
22
  const tocSections = computed(() => outline.value.sections.filter((s) => s.depth > 0))
14
23
  const hasOutput = computed(() => !!getOutput().trim())
15
24
 
16
25
  const collapsed = reactive<Record<string, boolean>>({})
17
- const activeId = ref<string>('step-details')
26
+ const activeId = ref<string>(leadAnchorId ?? '')
18
27
  const scrollEl = ref<HTMLElement | null>(null)
19
28
  const sectionEls = reactive<Record<string, HTMLElement | null>>({})
20
29
 
21
- // Anchors the ToC navigates + the scroll-spy tracks: the details card first, then
22
- // every heading section of the prose.
23
- const anchors = computed(() => ['step-details', ...tocSections.value.map((s) => s.id)])
30
+ // Anchors the ToC navigates + the scroll-spy tracks: the lead section (when the consumer
31
+ // renders one) first, then every heading section of the prose.
32
+ const anchors = computed(() => [
33
+ ...(leadAnchorId ? [leadAnchorId] : []),
34
+ ...tocSections.value.map((s) => s.id),
35
+ ])
24
36
 
25
37
  function toggle(id: string) {
26
38
  collapsed[id] = !collapsed[id]
@@ -43,7 +55,7 @@ export function useStepProse(getOutput: () => string) {
43
55
  const container = scrollEl.value
44
56
  if (!container) return
45
57
  const line = container.getBoundingClientRect().top + 80
46
- let current = anchors.value[0] ?? 'step-details'
58
+ let current = anchors.value[0] ?? ''
47
59
  for (const id of anchors.value) {
48
60
  const el = sectionEls[id]
49
61
  if (el && el.getBoundingClientRect().top <= line) current = id
@@ -55,7 +67,7 @@ export function useStepProse(getOutput: () => string) {
55
67
  // Re-seed (all sections expanded, scrolled to top) for a freshly-opened step.
56
68
  function reset() {
57
69
  for (const k of Object.keys(collapsed)) delete collapsed[k]
58
- activeId.value = 'step-details'
70
+ activeId.value = leadAnchorId ?? ''
59
71
  void nextTick(() => scrollEl.value?.scrollTo({ top: 0 }))
60
72
  }
61
73
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import enCatalog from '../../i18n/locales/en.json'
2
+ import { hasI18nKey } from '../../test/i18nKeys'
3
3
  import {
4
4
  groupCommands,
5
5
  groupSidebar,
@@ -10,18 +10,8 @@ import {
10
10
  } from './nav-contributions'
11
11
  import type { AppSlots, NavGates } from './nav-contributions'
12
12
 
13
- /** The layer's base i18n catalog, used to prove every referenced key resolves. */
14
- const en = enCatalog as Record<string, unknown>
15
-
16
- /** Walk a dotted vue-i18n key path; true when it resolves to a leaf string. */
17
- function hasKey(path: string): boolean {
18
- let node: unknown = en
19
- for (const part of path.split('.')) {
20
- if (typeof node !== 'object' || node === null || !(part in node)) return false
21
- node = (node as Record<string, unknown>)[part]
22
- }
23
- return typeof node === 'string'
24
- }
13
+ /** Prove every referenced key resolves in the layer's base catalog (see `test/i18nKeys`). */
14
+ const hasKey = hasI18nKey
25
15
 
26
16
  const NO_GATES: NavGates = {
27
17
  canWriteBoard: false,
@@ -80,6 +80,13 @@ describe('inspector panel group', () => {
80
80
  expect(visibleIds(block('module'))).toEqual(['container-summary'])
81
81
  })
82
82
 
83
+ // The reviewed PR is the review task's SUBJECT, so it leads the body — above the context the
84
+ // task was given and the run that acts on it. Every other task type never sees the panel.
85
+ it('a review task leads with its review target', () => {
86
+ const review = { ...block('task'), taskType: 'review' } as Block
87
+ expect(visibleIds(review)[0]).toBe('task-review-target')
88
+ })
89
+
83
90
  it('a task shows the task body in the pre-slice-4 order', () => {
84
91
  expect(visibleIds(block('task'))).toEqual([
85
92
  'task-context-docs',
@@ -36,6 +36,7 @@ export const inspectorPanels = definePanelGroup<Block>(INSPECTOR_PANELS_SLOT)
36
36
  * a silently-dropped panel). */
37
37
  export const INSPECTOR_PANEL_IDS = [
38
38
  // task body (rendered in this order under the identity block)
39
+ 'task-review-target',
39
40
  'task-context-docs',
40
41
  'task-context-issues',
41
42
  'recurring-schedule',
@@ -119,6 +120,10 @@ const isDeployableFrame = (b: Block) => isFrame(b) && b.type !== 'document'
119
120
  * extensibility value.
120
121
  */
121
122
  export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
123
+ // FIRST in the task body: for a review task the reviewed PR is what the task IS, so it reads
124
+ // above the context it was given and the run that acts on it. Gated on the task TYPE alone —
125
+ // the panel itself hides when the task carries no PR reference.
126
+ { id: 'task-review-target', order: 5, when: (b) => isTask(b) && b.taskType === 'review' },
122
127
  // Shared with the initiative body — an initiative takes the same attachments a task does.
123
128
  { id: 'task-context-docs', order: 10, when: takesContext },
124
129
  { id: 'task-context-issues', order: 20, when: takesContext },
@@ -16,6 +16,7 @@ import TaskContextDocs from '~/components/documents/TaskContextDocs.vue'
16
16
  import TaskContextIssues from '~/components/tasks/TaskContextIssues.vue'
17
17
  import RecurringScheduleSettings from '~/components/panels/inspector/RecurringScheduleSettings.vue'
18
18
  import TaskExecution from '~/components/panels/inspector/TaskExecution.vue'
19
+ import TaskReviewTarget from '~/components/panels/inspector/TaskReviewTarget.vue'
19
20
  import TaskEstimateBadge from '~/components/panels/inspector/TaskEstimateBadge.vue'
20
21
  import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
21
22
  import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
@@ -65,6 +66,7 @@ function blockPanel(component: Component, id: InspectorPanelId): PanelComponent
65
66
  /** Exhaustive id → sub-panel map. Typed `Record<InspectorPanelId, …>` so adding a
66
67
  * spec without a component (or vice-versa) fails the typecheck. */
67
68
  const COMPONENTS: Record<InspectorPanelId, Component> = {
69
+ 'task-review-target': TaskReviewTarget,
68
70
  'task-context-docs': TaskContextDocs,
69
71
  'task-context-issues': TaskContextIssues,
70
72
  'recurring-schedule': RecurringScheduleSettings,
@@ -188,6 +188,14 @@ export const useExecutionStore = defineStore('execution', () => {
188
188
  blockId: string
189
189
  approval: StepApproval
190
190
  agentKind: PipelineStep['agentKind']
191
+ /**
192
+ * Whether the gate's proposal is a RENDERING of an artifact the step already committed
193
+ * (`step.outputIsRendered`). Projected here because a surface that reviews the proposal
194
+ * WITHOUT the step in hand — the initiative tracker's plan-approval rail — otherwise has
195
+ * no way to tell a rendered document from the agent's raw transcript summary, and would
196
+ * present a one-line summary as though it were the artifact.
197
+ */
198
+ outputIsRendered: boolean
191
199
  }[] = []
192
200
  for (const e of instances.value) {
193
201
  for (const s of e.steps) {
@@ -197,6 +205,7 @@ export const useExecutionStore = defineStore('execution', () => {
197
205
  blockId: e.blockId,
198
206
  approval: s.approval,
199
207
  agentKind: s.agentKind,
208
+ outputIsRendered: s.outputIsRendered === true,
200
209
  })
201
210
  }
202
211
  }
@@ -86,21 +86,25 @@ export const useValidationChecksStore = defineStore('validationChecks', () => {
86
86
  }
87
87
 
88
88
  /**
89
- * Save a service frame's checks. An EMPTY list clears the config on the backend (the service
90
- * deletes the row), which restores the exact pre-feature behaviour so the local list drops
91
- * the entry rather than keeping an empty one that reads as "configured".
89
+ * Save a service frame's checks and its dependency-prepopulation install. The backend deletes
90
+ * the row only when BOTH are empty (restoring the exact pre-feature behaviour), so the local
91
+ * list mirrors that rule — dropping the entry on an empty save of both, and keeping it for a
92
+ * service that declares only an install. Testing `checks` alone here would evict a live
93
+ * install-only config from the store and report the service as unconfigured until a reload.
92
94
  */
93
95
  async function save(
94
96
  blockId: string,
95
97
  checks: ValidationCheck[],
96
98
  maxAttempts: number,
99
+ dependencyInstall?: string,
97
100
  ): Promise<void> {
98
101
  const ws = useWorkspaceStore()
99
102
  const saved = await api.setServiceValidationConfig(ws.requireId(), blockId, {
100
103
  checks,
101
104
  maxAttempts,
105
+ ...(dependencyInstall ? { dependencyInstall } : {}),
102
106
  })
103
- if (saved.checks.length === 0) dropLocal(blockId)
107
+ if (saved.checks.length === 0 && !saved.dependencyInstall) dropLocal(blockId)
104
108
  else upsertLocal(saved)
105
109
  }
106
110
 
@@ -1,7 +1,11 @@
1
1
  import { INITIATIVE_ITEM_TERMINAL_STATUSES } from '@cat-factory/contracts'
2
2
  import { describe, it, expect } from 'vitest'
3
3
  import type { InitiativeItem, InitiativePhase, InitiativeQa } from '~/types/domain'
4
+ import { missingI18nKeys } from '../../test/i18nKeys'
4
5
  import {
6
+ INITIATIVE_ATTENTION_LABEL_KEYS,
7
+ INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
8
+ INITIATIVE_STATUS_LABEL_KEYS,
5
9
  isPendingQuestion,
6
10
  orderInterviewQuestions,
7
11
  pendingCheckpointPhase,
@@ -200,3 +204,23 @@ describe('selectPlanApproval', () => {
200
204
  expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_custom')
201
205
  })
202
206
  })
207
+
208
+ /**
209
+ * These tables are the reason the initiative card and the inspector word one park identically,
210
+ * and they are exactly the shape both i18n drift guards are blind to: the typed-key check and
211
+ * `i18n:check` only see a key written literally at a `t()` call site, while the exhaustive
212
+ * `Record` only proves every enum MEMBER has an entry — never that the entry still names a key
213
+ * the catalog holds. Without this, deleting a key reads as a clean removal and the affordance
214
+ * renders its own key path to the user.
215
+ */
216
+ describe('the initiative label-key tables', () => {
217
+ it('name keys the base catalog actually holds', () => {
218
+ expect(
219
+ missingI18nKeys([
220
+ ...Object.values(INITIATIVE_ATTENTION_LABEL_KEYS),
221
+ ...Object.values(INITIATIVE_STATUS_LABEL_KEYS),
222
+ ...Object.values(INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS),
223
+ ]),
224
+ ).toEqual([])
225
+ })
226
+ })
@@ -1285,6 +1285,12 @@
1285
1285
  "risk": "Risiko",
1286
1286
  "impact": "Wirkung"
1287
1287
  },
1288
+ "reviewTarget": {
1289
+ "title": "Geprüfter Pull Request",
1290
+ "hint": "Der bestehende Pull Request, den diese Aufgabe prüft.",
1291
+ "prNumber": "PR #{number}",
1292
+ "focus": "Schwerpunkt: {focus}"
1293
+ },
1288
1294
  "execution": {
1289
1295
  "title": "Ausführung",
1290
1296
  "hint": "Der Live-Lauf der Pipeline: jeder Agent-Schritt, sein Fortschritt und der resultierende Pull Request.",
@@ -1433,6 +1439,8 @@
1433
1439
  "validationChecks": {
1434
1440
  "title": "Prüfungen vor dem PR",
1435
1441
  "sectionHint": "Befehle, die nach dem Coder und vor dem Öffnen eines Pull Requests im Checkout laufen. Ein Fehlschlag geht zur Behebung an den Agenten zurück; nur ein fehlerfreier Checkout öffnet einen PR.",
1442
+ "dependencyInstall": "Abhängigkeiten installieren",
1443
+ "dependencyInstallHint": "Läuft, bevor der Agent startet",
1436
1444
  "hint": "Jeder Befehl läuft der Reihe nach mit `sh -c` im Checkout dieses Dienstes. Der Agent soll den Code reparieren, nicht die Prüfung abschwächen.",
1437
1445
  "clear": "Leeren",
1438
1446
  "configNoun": "Prüfungen",
@@ -1452,6 +1460,7 @@
1452
1460
  "action": "Erkennen",
1453
1461
  "hint": "Prüfungen aus dem Repository dieses Dienstes vorschlagen",
1454
1462
  "added": "{count} Prüfung hinzugefügt | {count} Prüfungen hinzugefügt",
1463
+ "installOnly": "Abhängigkeitsinstallation eingetragen",
1455
1464
  "found": "Erkannt: {ecosystems}.",
1456
1465
  "capped": "Einige Vorschläge wurden ausgelassen — ein Dienst nimmt höchstens {max} Prüfungen auf.",
1457
1466
  "nothingNew": "Nichts hinzuzufügen",
@@ -1622,6 +1631,7 @@
1622
1631
  "editConclusionsPlaceholder": "Bearbeite die Schlussfolgerungen des Agents; deine Änderungen werden bei der Freigabe gespeichert…",
1623
1632
  "noProseOutput": "Dieser Agent hat keine Prosa-Ausgabe erzeugt.",
1624
1633
  "approveWithCorrections": "Mit Korrekturen freigeben",
1634
+ "renderedOutputNote": "Diese Ausgabe ist eine Darstellung dessen, was der Schritt erzeugt hat, und kann hier nicht bearbeitet werden. Fordere Änderungen an, um sie überarbeiten zu lassen.",
1625
1635
  "reviewAndApprove": "Prüfen & freigeben",
1626
1636
  "editHint": "Bearbeite die Schlussfolgerungen links; deine Änderungen werden bei der Freigabe gespeichert.",
1627
1637
  "reviewHint": "Klicke auf einen beliebigen Block in der Ausgabe, um ihn zu kommentieren, oder hinterlasse unten allgemeines Feedback.",
@@ -2421,7 +2431,9 @@
2421
2431
  "focus": "Prüfungsschwerpunkt",
2422
2432
  "focusPlaceholder": "z. B. Fokus auf die Auth-Änderungen und Fehlerbehandlung",
2423
2433
  "derivedTitle": "{ref} prüfen",
2424
- "derivedTitleFallback": "Pull Request prüfen"
2434
+ "derivedTitleFallback": "Pull Request prüfen",
2435
+ "prNotFound": "Pull Request #{number} wurde im Repository dieses Service nicht gefunden. Prüfe die Nummer, oder verknüpfe den Service mit dem Repository, in dem der Pull Request liegt.",
2436
+ "prRepoMismatch": "Dieser Pull Request liegt in einem anderen Repository. Dieser Service prüft {repo}; lege die Prüfaufgabe unter dem Service an, der mit dem Repository des Pull Requests verknüpft ist."
2425
2437
  }
2426
2438
  },
2427
2439
  "recurring": {
@@ -4329,9 +4341,11 @@
4329
4341
  "title": "Dieser Plan wartet auf dich",
4330
4342
  "body": "Der Planner hat die Phasen und Aufgaben unten entworfen. Gib sie frei, um den Plan zu committen und die Arbeit zu starten, oder schicke den Plan mit deinen Änderungswünschen zurück.",
4331
4343
  "approve": "Plan freigeben",
4332
- "requestChanges": "Änderungen anfordern",
4333
4344
  "feedbackPlaceholder": "Was soll der Planner ändern? Umfang, Reihenfolge der Phasen, fehlende Arbeit, eine Aufgabe, die woanders hingehört …",
4334
- "sendBack": "An den Planner zurückschicken"
4345
+ "sendBack": "An den Planner zurückschicken",
4346
+ "noDocument": "Dieser Plan entstand, bevor die Plattform Pläne zur Prüfung gerendert hat — es gibt kein Dokument zum Navigieren; die Abschnitte unten sind der Plan.",
4347
+ "needsFeedback": "Füge zuerst einen Kommentar oder eine Rückmeldung hinzu — der Planer plant damit neu.",
4348
+ "commentHint": "Klicke auf einen Teil des Plans, um ihn zu kommentieren."
4335
4349
  },
4336
4350
  "planning": {
4337
4351
  "title": "Die Initiative planen",