@cat-factory/app 0.192.0 → 0.194.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.
@@ -2,56 +2,150 @@ import { ref } from 'vue'
2
2
  import type { Block } from '~/types/domain'
3
3
 
4
4
  /**
5
- * Pointer-driven resizing for service frames (Miro-style border drag). The drag
6
- * delta is divided by the board zoom so the edge tracks the cursor, and the new
7
- * size is clamped to the frame's content extent so dragging in never clips the
8
- * tasks/modules inside. The frame grows live (the store block is mutated in place,
9
- * which `containerSize` reads back), and the final size is persisted once on
10
- * release rather than on every move.
5
+ * The eight border/corner grips, each as the pair of unit factors saying how far the drag moves
6
+ * the container's ORIGIN versus its far edge. `1` on an origin axis means that axis's border is
7
+ * the one being dragged (west/north), so the box grows the OPPOSITE way from the pointer delta.
8
+ *
9
+ * Encoding the geometry as data rather than a `switch` per axis is what keeps the corners honest:
10
+ * `nw` is exactly `n` and `w` applied together, and there is no eighth case to forget.
11
+ */
12
+ const HANDLES = {
13
+ n: { ox: 0, oy: 1, sx: 0, sy: -1, cursor: 'ns-resize' },
14
+ s: { ox: 0, oy: 0, sx: 0, sy: 1, cursor: 'ns-resize' },
15
+ e: { ox: 0, oy: 0, sx: 1, sy: 0, cursor: 'ew-resize' },
16
+ w: { ox: 1, oy: 0, sx: -1, sy: 0, cursor: 'ew-resize' },
17
+ ne: { ox: 0, oy: 1, sx: 1, sy: -1, cursor: 'nesw-resize' },
18
+ nw: { ox: 1, oy: 1, sx: -1, sy: -1, cursor: 'nwse-resize' },
19
+ se: { ox: 0, oy: 0, sx: 1, sy: 1, cursor: 'nwse-resize' },
20
+ sw: { ox: 1, oy: 0, sx: -1, sy: 1, cursor: 'nesw-resize' },
21
+ } as const
22
+
23
+ export type ResizeEdge = keyof typeof HANDLES
24
+
25
+ /** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
26
+ export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
27
+
28
+ /** The `cursor` a given grip shows, and holds on `<body>` while its drag runs. */
29
+ export function resizeCursor(edge: ResizeEdge): string {
30
+ return HANDLES[edge].cursor
31
+ }
32
+
33
+ /**
34
+ * Pointer-driven resizing for containers (service frames and modules) by dragging any border or
35
+ * corner, Miro-style. The drag delta is divided by the board zoom so the border tracks the
36
+ * cursor, and the new size is clamped to the container's content extent so dragging inwards never
37
+ * clips the tasks/modules inside.
38
+ *
39
+ * Dragging the north or west border also moves the container's ORIGIN, and a child's position is
40
+ * stored relative to that origin — so the store translates the children by the inverse (see
41
+ * `previewResize`) and the backend does the same on commit, which is what makes the border extend
42
+ * past the contents instead of dragging them along. The origin is derived from the CLAMPED size
43
+ * rather than from the raw pointer delta: once the box has hit its content floor the border must
44
+ * stop dead, and a separately-clamped origin would keep sliding, walking the whole container
45
+ * across the board.
46
+ *
47
+ * The container grows live off the store's optimistic geometry, and the final bounds are
48
+ * persisted ONCE on release rather than on every move.
11
49
  */
12
50
  export function useFrameResize() {
13
51
  const board = useBoardStore()
14
52
  const ui = useUiStore()
15
53
  const access = useWorkspaceAccess()
16
- /** Id of the frame currently being resized, for cursor/handle styling. */
54
+ /** Id of the container currently being resized, for cursor/grip styling. */
17
55
  const resizingId = ref<string | null>(null)
18
56
 
19
57
  /**
20
- * Begin a resize from one of the frame's edges/corner. `edge` selects which
21
- * dimensions move: `'e'` width only, `'s'` height only, `'se'` both.
58
+ * How far the origin may travel INWARD before the nearest child would land at a negative
59
+ * offset i.e. outside the box, spilling over the very border being dragged. `contentSize` is
60
+ * no help here: it measures only the FAR edge of the contents, which a north/west shrink moves
61
+ * inward in step with the border, so nothing there ever objects. `Infinity` for an empty
62
+ * container, which is then bounded by `contentSize`'s empty floor alone.
22
63
  */
23
- function startResize(block: Block, e: PointerEvent, edge: 'e' | 's' | 'se') {
64
+ function originSlack(id: string): { x: number; y: number } {
65
+ const children = board.childrenOf(id)
66
+ if (!children.length) return { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY }
67
+ return {
68
+ x: Math.min(...children.map((c) => c.position.x)),
69
+ y: Math.min(...children.map((c) => c.position.y)),
70
+ }
71
+ }
72
+
73
+ /** Begin a resize from one of the container's borders or corners. */
74
+ function startResize(block: Block, e: PointerEvent, edge: ResizeEdge) {
24
75
  if (e.button !== 0) return
25
- // Resizing a frame persists its size — a `board.write` mutation, so a read-only
26
- // viewer's resize no-ops (the grips are hidden for them at the component level).
76
+ // Resizing persists geometry — a `board.write` mutation, so a read-only viewer's resize
77
+ // no-ops (the grips are hidden for them at the component level).
27
78
  if (!access.canWriteBoard.value) return
28
79
  e.preventDefault()
29
80
  e.stopPropagation()
81
+ const handle = HANDLES[edge]
30
82
  const startX = e.clientX
31
83
  const startY = e.clientY
32
- // The content extent is the floor never shrink a frame below its tasks.
33
- const min = board.contentSize(block.id)
34
- // Seed from the current rendered size so the first move doesn't jump.
84
+ // Seed from the current rendered geometry so the first move doesn't jump. `size` may be
85
+ // absent (an auto-sized container), which is also what a rejected resize must restore.
35
86
  const start = board.containerSize(block.id)
87
+ const from = {
88
+ position: { ...block.position },
89
+ size: block.size ? { ...block.size } : undefined,
90
+ }
91
+ // The floors, snapshotted once: on an origin-axis drag the children MOVE, so a floor re-read
92
+ // mid-drag would chase them. `contentSize` bounds the far edge; the near-edge bound applies
93
+ // only where the origin travels, and only inwards.
94
+ const min = board.contentSize(block.id)
95
+ const slack = originSlack(block.id)
96
+ const floor = {
97
+ w: handle.ox ? Math.max(min.w, start.w - slack.x) : min.w,
98
+ h: handle.oy ? Math.max(min.h, start.h - slack.y) : min.h,
99
+ }
36
100
  resizingId.value = block.id
37
101
 
102
+ // Hold the resize cursor on `<body>` (and kill text selection) for the whole drag: the
103
+ // pointer routinely outruns the 12px grip, and without this the cursor flips back to the
104
+ // default mid-drag, which reads as "the grab was dropped" even though the border is still
105
+ // tracking.
106
+ const body = document.body
107
+ const priorCursor = body.style.cursor
108
+ const priorUserSelect = body.style.userSelect
109
+ body.style.cursor = handle.cursor
110
+ body.style.userSelect = 'none'
111
+
112
+ let bounds = { position: from.position, size: start }
113
+ let moved = false
38
114
  const onMove = (ev: PointerEvent) => {
39
115
  const z = ui.zoom || 1
40
- const w = edge === 's' ? start.w : Math.max(min.w, start.w + (ev.clientX - startX) / z)
41
- const h = edge === 'e' ? start.h : Math.max(min.h, start.h + (ev.clientY - startY) / z)
42
- // Optimistic, local-only: mutate the cached block so the frame grows live
43
- // without a round-trip on every pointer move.
44
- block.size = { w: Math.round(w), h: Math.round(h) }
116
+ const dx = (ev.clientX - startX) / z
117
+ const dy = (ev.clientY - startY) / z
118
+ const w = Math.round(Math.max(floor.w, start.w + handle.sx * dx))
119
+ const h = Math.round(Math.max(floor.h, start.h + handle.sy * dy))
120
+ // A grown box on an origin axis extends BACKWARDS from where the far edge stays put, so
121
+ // the origin moves by whatever the size actually gained after clamping.
122
+ bounds = {
123
+ position: {
124
+ x: from.position.x - handle.ox * (w - start.w),
125
+ y: from.position.y - handle.oy * (h - start.h),
126
+ },
127
+ size: { w, h },
128
+ }
129
+ moved = true
130
+ // Optimistic, local-only (the store also translates the children): no round-trip per move.
131
+ board.previewResize(block.id, bounds.position, bounds.size)
45
132
  }
46
133
  const onUp = () => {
47
134
  window.removeEventListener('pointermove', onMove)
48
135
  window.removeEventListener('pointerup', onUp)
136
+ window.removeEventListener('pointercancel', onUp)
137
+ body.style.cursor = priorCursor
138
+ body.style.userSelect = priorUserSelect
49
139
  resizingId.value = null
50
- // Persist the final size once (also re-applies it as the authoritative block).
51
- if (block.size) void board.updateBlock(block.id, { size: block.size })
140
+ // A press with no movement is not a resize: committing it would emit a coarse board signal
141
+ // (every other client re-hydrates) to store the geometry it already had.
142
+ if (moved) void board.resizeBlock(block.id, bounds, from)
52
143
  }
53
144
  window.addEventListener('pointermove', onMove)
54
145
  window.addEventListener('pointerup', onUp)
146
+ // A cancelled pointer (touch interrupted by a gesture, window losing the pointer) never fires
147
+ // `pointerup`, so without this the body cursor stays stuck on `ew-resize` for the session.
148
+ window.addEventListener('pointercancel', onUp)
55
149
  }
56
150
 
57
151
  return { resizingId, startResize }
@@ -140,6 +140,38 @@ describe('usePipelineHealth', () => {
140
140
  expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
141
141
  })
142
142
 
143
+ // The regression this pins: the advisory carried its own "only a companion may be gated" rule,
144
+ // so when the engine generalised gating to `BUILTIN_GATABLE_KINDS` the shipped `pl_simple`
145
+ // ("Adaptive build" — an estimate-gated `architect`) was reported invalid in EVERY workspace.
146
+ // Because the advisory auto-opens a modal over the board, that made the board unusable rather
147
+ // than merely warning wrongly. Both sides now read the shared contracts constant.
148
+ it('accepts an estimate-gated NON-companion producer that the shared gatable set allows', () => {
149
+ const adaptive = builtin(['task-estimator', 'architect', 'architect-companion', 'coder'], {
150
+ gating: [null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }, null, null],
151
+ })
152
+ const { hasIssues } = scan([adaptive])
153
+ expect(hasIssues.value).toBe(false)
154
+ })
155
+
156
+ it('still flags an estimate-gated kind the shared gatable set excludes (merger)', () => {
157
+ const gatedMerger = builtin(['task-estimator', 'coder', 'merger'], {
158
+ gating: [null, null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
159
+ })
160
+ const { invalid } = scan([gatedMerger])
161
+ expect(invalid.value).toHaveLength(1)
162
+ expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
163
+ })
164
+
165
+ it('flags a step carrying BOTH a human approval gate and an estimate gate (shape)', () => {
166
+ const both = builtin(['task-estimator', 'architect'], {
167
+ gates: [false, true],
168
+ gating: [null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
169
+ })
170
+ const { invalid } = scan([both])
171
+ expect(invalid.value).toHaveLength(1)
172
+ expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
173
+ })
174
+
143
175
  it('reports a built-in whose catalog version moved ahead as outdated (not invalid)', () => {
144
176
  const stale = builtin(['coder', 'reviewer'], { id: 'pl_stale', version: 1 })
145
177
  const { invalid, outdated } = scan([stale], { pl_stale: 2 })
@@ -1,6 +1,7 @@
1
1
  import { computed } from 'vue'
2
2
  import type { Pipeline } from '~/types/domain'
3
3
  import type { StepGating } from '~/types/consensus'
4
+ import { isBuiltinGatableKind } from '@cat-factory/contracts'
4
5
  import { COMPANION_FOR_PRODUCER, isKnownAgentKind, isProducerCompanion } from '~/utils/catalog'
5
6
  import { usePipelinesStore } from '~/stores/pipelines'
6
7
 
@@ -78,6 +79,11 @@ const isEnabledAt = (p: Pipeline, i: number) => p.enabled?.[i] !== false
78
79
  * gating, over the ENABLED subset), collecting the first problem instead of throwing. Returns a
79
80
  * human message, or null when the shape is valid. Kept in step with
80
81
  * `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
82
+ *
83
+ * A rule here must be keyed off vocabulary SHARED with that module (`@cat-factory/contracts`)
84
+ * wherever one exists, never re-stated locally — see the gating note below for what a drifted copy
85
+ * costs. Adding a rule to `assertValidGating` without adding it here is the milder half of the same
86
+ * drift: a pipeline the engine refuses at save that this advisory calls healthy.
81
87
  */
82
88
  function shapeProblem(p: Pipeline): string | null {
83
89
  const kinds = p.agentKinds
@@ -102,16 +108,29 @@ function shapeProblem(p: Pipeline): string | null {
102
108
  return `Companion '${kind}' must run immediately after an enabled step it can review (${targets.join(', ')}).`
103
109
  }
104
110
  }
105
- // Estimate gating: an enabled gated step must be a companion, set ≥1 threshold, and have an
106
- // enabled task-estimator earlier in the chain.
111
+ // Estimate gating: an enabled gated step must be a GATABLE kind, must not also carry a human
112
+ // approval gate, must set ≥1 threshold, and must have an enabled task-estimator earlier in the
113
+ // chain. Gatability reads the SHARED `BUILTIN_GATABLE_KINDS` rather than a local rule, because
114
+ // this advisory auto-opens a modal over the board: a copy of the rule that drifts behind the
115
+ // engine's does not merely warn wrongly, it calls a pipeline the product SHIPS invalid and leaves
116
+ // the board unusable. A DEPLOYMENT-registered kind can override gatability for itself through the
117
+ // agent-kind registry, which the SPA cannot see, so the two are not perfectly symmetric: such a
118
+ // kind is reported here and accepted by the engine. That is the safe direction of the asymmetry —
119
+ // a dismissible advisory rather than a refused save — and the only one available without shipping
120
+ // the registry to the browser.
107
121
  const gating = p.gating
108
122
  if (gating) {
109
123
  for (let i = 0; i < kinds.length; i++) {
110
124
  const g = gating[i] as StepGating | null | undefined
111
125
  if (!g?.enabled || !isEnabledAt(p, i)) continue
112
126
  const kind = kinds[i]
113
- if (!kind || !isProducerCompanion(kind)) {
114
- return `Step '${kind}' cannot be estimate-gated — only companion steps may be skipped on the estimate.`
127
+ if (!kind || !isBuiltinGatableKind(kind)) {
128
+ return `Step '${kind}' may not be estimate-gated — its output is required by the rest of the run. Only a step whose result later steps read as context (a design, a review, an extra verification pass) may be skipped on the estimate.`
129
+ }
130
+ // A human approval gate and an estimate gate on the same step contradict: the estimate may
131
+ // ADD a human checkpoint but never CANCEL a pause the pipeline author asked for.
132
+ if (p.gates?.[i] === true) {
133
+ return `Step '${kind}' carries a human approval gate, so it cannot also be estimate-gated — the estimate may add a human checkpoint but never remove one.`
115
134
  }
116
135
  if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
117
136
  return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
@@ -12,7 +12,7 @@ import { UNDO_WINDOW_MS } from './context'
12
12
  * in-closure functions, and the split is purely to keep every function within the size budget.
13
13
  */
14
14
  export function createBoardPlacement(ctx: BoardWriteContext) {
15
- const { getBlock, upsert, api, toast, tr } = ctx
15
+ const { blocks, getBlock, upsert, api, toast, tr } = ctx
16
16
 
17
17
  /**
18
18
  * Move a block into a new container at a new local position. Drag-reparent commits
@@ -120,6 +120,67 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * Translate every DIRECT child of a container — the client half of the compensation the
125
+ * backend's `shiftChildPositions` applies. A child's position is relative to its container's
126
+ * content origin, so moving that origin (a north/west border drag) has to move the children
127
+ * the other way or the contents slide with the border. Grandchildren ride their module.
128
+ */
129
+ function shiftChildren(parentId: string, dx: number, dy: number) {
130
+ if (!dx && !dy) return
131
+ for (const child of blocks.value) {
132
+ if (child.parentId !== parentId) continue
133
+ child.position = { x: child.position.x + dx, y: child.position.y + dy }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Local-only geometry update during an active border drag — the resize counterpart of
139
+ * {@link previewMove}, and for the same reason: persisting every pointer move would let an
140
+ * out-of-order response land a stale size after the user let go. Takes ABSOLUTE bounds and
141
+ * derives the origin delta itself, so a caller can drive it from a running drag without
142
+ * tracking what it has already applied. {@link resizeBlock} commits the final bounds once.
143
+ */
144
+ function previewResize(
145
+ id: string,
146
+ position: { x: number; y: number },
147
+ size?: { w: number; h: number },
148
+ ) {
149
+ const b = getBlock(id)
150
+ if (!b) return
151
+ shiftChildren(id, b.position.x - position.x, b.position.y - position.y)
152
+ b.position = position
153
+ b.size = size
154
+ }
155
+
156
+ /**
157
+ * Commit a border-drag resize: ONE call carrying both halves of the geometry, because only an
158
+ * operation that sees the origin delta can translate the container's children with it (see
159
+ * `BoardService.resizeBlock`). `from` is the pre-drag geometry — a rejected resize replays it
160
+ * through {@link previewResize}, which undoes the child translation by the same arithmetic that
161
+ * applied it, so a failure can't leave the contents offset from a box the server never stored.
162
+ */
163
+ async function resizeBlock(
164
+ id: string,
165
+ bounds: { position: { x: number; y: number }; size: { w: number; h: number } },
166
+ from: { position: { x: number; y: number }; size?: { w: number; h: number } },
167
+ ) {
168
+ const b = getBlock(id)
169
+ if (!b) return
170
+ previewResize(id, bounds.position, bounds.size)
171
+ try {
172
+ upsert(await api.resizeBlock(useWorkspaceStore().requireId(), id, bounds))
173
+ } catch (e) {
174
+ previewResize(id, from.position, from.size)
175
+ toast.add({
176
+ title: tr('board.toast.resizeFailed'),
177
+ description: e instanceof Error ? e.message : String(e),
178
+ icon: 'i-lucide-triangle-alert',
179
+ color: 'error',
180
+ })
181
+ }
182
+ }
183
+
123
184
  /** Patch the user-editable fields of a block (title, features, threshold…). */
124
185
  async function updateBlock(id: string, patch: UpdateBlockInput) {
125
186
  const b = getBlock(id)
@@ -196,6 +257,8 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
196
257
  reparentBlock,
197
258
  previewMove,
198
259
  moveBlock,
260
+ previewResize,
261
+ resizeBlock,
199
262
  updateBlock,
200
263
  toggleDependency,
201
264
  removeDependency,
@@ -310,6 +310,57 @@ describe('board store optimistic rollback', () => {
310
310
  expect(store.getBlock('t1')?.description).toBe('keep')
311
311
  })
312
312
 
313
+ it('previewResize translates the children when the drag moves the content origin', () => {
314
+ // A child's position is relative to its container's content origin, so growing the frame
315
+ // 40px west (origin -40) has to move every direct child +40 or the whole content slides with
316
+ // the border. A grandchild rides its module and must NOT move on its own.
317
+ const store = useBoardStore()
318
+ store.hydrate([
319
+ frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
320
+ moduleBlock('m1', 'f1', { position: { x: 20, y: 30 } }),
321
+ task('t1', 'f1', { position: { x: 10, y: 20 } }),
322
+ task('t2', 'm1', { position: { x: 5, y: 5 } }),
323
+ ])
324
+ store.previewResize('f1', { x: 60, y: 100 }, { w: 640, h: 400 })
325
+ expect(store.getBlock('t1')?.position).toEqual({ x: 50, y: 20 })
326
+ expect(store.getBlock('m1')?.position).toEqual({ x: 60, y: 30 })
327
+ expect(store.getBlock('t2')?.position).toEqual({ x: 5, y: 5 })
328
+ })
329
+
330
+ it('previewResize leaves the children alone when only the far border moved', () => {
331
+ const store = useBoardStore()
332
+ store.hydrate([
333
+ frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
334
+ task('t1', 'f1', { position: { x: 10, y: 20 } }),
335
+ ])
336
+ store.previewResize('f1', { x: 100, y: 100 }, { w: 700, h: 500 })
337
+ expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
338
+ expect(store.getBlock('f1')?.size).toEqual({ w: 700, h: 500 })
339
+ })
340
+
341
+ it('resizeBlock rolls the bounds AND the child translation back when the API rejects', async () => {
342
+ // The rollback has to undo both halves: a restored box with its contents still offset is the
343
+ // one failure mode that looks fine until the next refresh moves everything.
344
+ vi.stubGlobal('useApi', () => ({
345
+ resizeBlock: () => Promise.reject(new Error('conflict')),
346
+ }))
347
+ setActivePinia(createPinia())
348
+ useWorkspaceStore().workspaceId = 'ws1'
349
+ const store = useBoardStore()
350
+ store.hydrate([
351
+ frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
352
+ task('t1', 'f1', { position: { x: 10, y: 20 } }),
353
+ ])
354
+ await store.resizeBlock(
355
+ 'f1',
356
+ { position: { x: 60, y: 70 }, size: { w: 640, h: 430 } },
357
+ { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } },
358
+ )
359
+ expect(store.getBlock('f1')?.position).toEqual({ x: 100, y: 100 })
360
+ expect(store.getBlock('f1')?.size).toEqual({ w: 600, h: 400 })
361
+ expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
362
+ })
363
+
313
364
  it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
314
365
  vi.stubGlobal('useApi', () => ({
315
366
  reparentBlock: async (
@@ -15,6 +15,7 @@ export type {
15
15
  RequirementReviewItem,
16
16
  RequirementReviewStatus,
17
17
  ResolveRequirementsExceededChoice,
18
+ RecommendationSource,
18
19
  RecommendationStatus,
19
20
  RequirementRecommendation,
20
21
  RequirementReview,
@@ -1,8 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { pipelineAllowedForTaskType, purposeAllowsAgentCategory } from '@cat-factory/contracts'
2
+ import {
3
+ pipelineAllowedForBlockLevel,
4
+ pipelineAllowedForTaskType,
5
+ purposeAllowsAgentCategory,
6
+ } from '@cat-factory/contracts'
3
7
  import type { Block, Pipeline } from '~/types/domain'
4
8
  import {
5
9
  pipelineAllowedForManualStart,
10
+ pipelineAllowedForSchedule,
6
11
  pipelineDisplaySteps,
7
12
  pipelineGateCount,
8
13
  } from '~/utils/pipeline'
@@ -65,13 +70,67 @@ describe('pipelineAllowedForTaskType', () => {
65
70
  expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
66
71
  })
67
72
 
68
- it('every other task type is unrestricted (any purpose, and undefined type)', () => {
69
- for (const type of ['feature', 'bug', 'spike', 'ralph', undefined] as const) {
73
+ it('a programmatic task (feature / bug) hides only what cannot ship code', () => {
74
+ // These ship code, so a doc-authoring or PR-review preset is meaningless for them — the mirror
75
+ // of the narrowing document/review tasks already had. `research` stays because reaching for a
76
+ // spike before committing to an approach is legitimate on an unscoped feature.
77
+ for (const type of ['feature', 'bug'] as const) {
70
78
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), type)).toBe(true)
71
- expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), type)).toBe(true)
72
- expect(pipelineAllowedForTaskType(pipeline({ purpose: 'review' }), type)).toBe(true)
79
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: 'research' }), type)).toBe(true)
80
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), type)).toBe(false)
81
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: 'review' }), type)).toBe(false)
82
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: 'planning' }), type)).toBe(false)
83
+ }
84
+ })
85
+
86
+ it('keeps an UNCLASSIFIED pipeline on a feature / bug task', () => {
87
+ // The one place this narrowing runs opposite to the document/review one, and it has to: a
88
+ // `purpose` is optional at every write boundary (the builder leaves it unset by default, a
89
+ // registered deployment pipeline need not declare one), so requiring it here would hide a
90
+ // workspace's own hand-built pipelines from the picker they were built for — silently, with
91
+ // nothing on screen to explain the absence. Unclassified is not known-wrong for a feature the
92
+ // way a document preset is.
93
+ for (const type of ['feature', 'bug'] as const) {
73
94
  expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), type)).toBe(true)
74
95
  }
96
+ // Still hidden from the types whose narrowing DOES demand the explicit classifier.
97
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
98
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
99
+ })
100
+
101
+ it('an un-narrowed task type stays unrestricted (spike, ralph, custom, undefined)', () => {
102
+ // A custom (namespaced) deployment type has no purpose mapping we could infer, and `spike` /
103
+ // `ralph` pin their own default pipeline instead of narrowing the picker.
104
+ for (const type of ['spike', 'ralph', 'acme:incident', undefined] as const) {
105
+ for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
106
+ expect(pipelineAllowedForTaskType(pipeline({ purpose }), type)).toBe(true)
107
+ }
108
+ }
109
+ })
110
+ })
111
+
112
+ describe('pipelineAllowedForBlockLevel (initiative binding)', () => {
113
+ it('offers an initiative block only planning pipelines', () => {
114
+ expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'planning' }), 'initiative')).toBe(true)
115
+ for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
116
+ expect(pipelineAllowedForBlockLevel(pipeline({ purpose }), 'initiative')).toBe(false)
117
+ }
118
+ })
119
+
120
+ it('hides planning pipelines from every ordinary block level', () => {
121
+ // The surface half of the engine's BIDIRECTIONAL guard. Without it the planning presets were
122
+ // offered on ordinary tasks and then refused at start with a 409 — the user having already
123
+ // chosen before learning it could not run.
124
+ for (const level of ['task', 'frame', 'module', 'epic'] as const) {
125
+ expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'planning' }), level)).toBe(false)
126
+ expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'build' }), level)).toBe(true)
127
+ }
128
+ })
129
+
130
+ it('is unrestricted when the level is unknown', () => {
131
+ for (const purpose of ['build', 'planning', undefined] as const) {
132
+ expect(pipelineAllowedForBlockLevel(pipeline({ purpose }), undefined)).toBe(true)
133
+ }
75
134
  })
76
135
  })
77
136
 
@@ -118,3 +177,24 @@ describe('pipelineAllowedForManualStart composes the task-type gate', () => {
118
177
  expect(pipelineAllowedForManualStart(recurring, noFrame, blocks, 'document')).toBe(false)
119
178
  })
120
179
  })
180
+
181
+ describe('pipelineAllowedForSchedule', () => {
182
+ const noFrame = undefined
183
+ const blocks: Block[] = []
184
+
185
+ it('keeps an ordinary build pipeline and drops a one-off-only one', () => {
186
+ expect(pipelineAllowedForSchedule(pipeline({ purpose: 'build' }), noFrame, blocks)).toBe(true)
187
+ const oneOff = pipeline({ purpose: 'build', availability: 'one-off' })
188
+ expect(pipelineAllowedForSchedule(oneOff, noFrame, blocks)).toBe(false)
189
+ })
190
+
191
+ it('drops the planning presets, which nothing else keeps out of this picker', () => {
192
+ // A schedule seeds a `level: 'task'` block on every fire, so the engine refuses a planning
193
+ // pipeline exactly as it would on a manual start — and the planning presets carry no
194
+ // `availability`, so the one-off filter above never touched them. Worse than the manual case
195
+ // because a schedule fires unattended: nobody sees the refusal, the work just stops happening.
196
+ expect(pipelineAllowedForSchedule(pipeline({ purpose: 'planning' }), noFrame, blocks)).toBe(
197
+ false,
198
+ )
199
+ })
200
+ })
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  frameAllowsVisualPipeline,
3
+ pipelineAllowedForBlockLevel,
3
4
  pipelineAllowedForTaskType,
4
5
  pipelineHasVisualStep,
5
6
  } from '@cat-factory/contracts'
6
- import type { AgentKind, Block, Pipeline } from '~/types/domain'
7
+ import type { AgentKind, Block, BlockLevel, Pipeline } from '~/types/domain'
7
8
 
8
9
  /** One agent step of a pipeline as shown in a preview: its kind + whether it's a human-gated step. */
9
10
  export interface PipelineDisplayStep {
@@ -38,9 +39,9 @@ export function pipelineGateCount(pipeline: Pipeline): number {
38
39
  return pipelineDisplaySteps(pipeline).filter((s) => s.gated).length
39
40
  }
40
41
 
41
- // Re-exported so a picker can import the task-type gate from the same module as the
42
- // launch/frame gates it composes with (the classifier itself lives in `@cat-factory/contracts`).
43
- export { pipelineAllowedForTaskType }
42
+ // Re-exported so a picker can import the purpose gates from the same module as the launch/frame
43
+ // gates they compose with (the classifiers themselves live in `@cat-factory/contracts`).
44
+ export { pipelineAllowedForBlockLevel, pipelineAllowedForTaskType }
44
45
 
45
46
  // Surface counterpart to the backend's slice-4c run-start gate: a pipeline with a visual step
46
47
  // (`tester-ui` / `visual-confirmation`) may run only on a frame with a UI to exercise — a
@@ -69,32 +70,52 @@ export function pipelineAllowedForFrame(
69
70
 
70
71
  /**
71
72
  * Whether `pipeline` may be started as a MANUAL one-off task run (the board/inspector Run menus,
72
- * the add-task modal, the task run-settings default). Excludes `'recurring'`-only pipelines the
73
- * backend would refuse, visual pipelines on a frame with no UI, and — when a `taskType` is given —
74
- * pipelines whose `purpose` doesn't fit that task type (a `document` task offers only document
75
- * pipelines). `taskType` omitted no task-type restriction (an un-typed context shows all).
73
+ * the add-task modal, the task run-settings default). Excludes, in turn:
74
+ *
75
+ * - `'recurring'`-only pipelines the backend would refuse;
76
+ * - visual pipelines on a frame with no UI;
77
+ * - pipelines whose `purpose` doesn't fit the given `taskType` (a `document` task offers only
78
+ * document pipelines; a `feature`/`bug` task only build + research ones);
79
+ * - pipelines whose `purpose` doesn't fit the given `blockLevel` (planning pipelines run only on
80
+ * an initiative block, and an initiative block runs only those).
81
+ *
82
+ * `taskType` / `blockLevel` omitted ⇒ that restriction is not applied, so an un-typed context still
83
+ * shows everything.
76
84
  */
77
85
  export function pipelineAllowedForManualStart(
78
86
  pipeline: Pipeline,
79
87
  frame: Block | undefined,
80
88
  blocks: readonly Block[],
81
89
  taskType?: Block['taskType'],
90
+ blockLevel?: BlockLevel,
82
91
  ): boolean {
83
92
  return (
84
93
  pipeline.availability !== 'recurring' &&
85
94
  pipelineAllowedForFrame(pipeline, frame, blocks) &&
86
- pipelineAllowedForTaskType(pipeline, taskType)
95
+ pipelineAllowedForTaskType(pipeline, taskType) &&
96
+ pipelineAllowedForBlockLevel(pipeline, blockLevel)
87
97
  )
88
98
  }
89
99
 
90
100
  /**
91
101
  * Whether `pipeline` may be attached to a RECURRING schedule (the recurring-pipeline modal).
92
- * Excludes `'one-off'`-only pipelines the backend would refuse.
102
+ * Excludes `'one-off'`-only pipelines the backend would refuse, visual pipelines on a frame with no
103
+ * UI, and the planning presets.
104
+ *
105
+ * The block-level gate applies here for the same reason it applies to a manual start, and it is
106
+ * keyed to `'task'` because a schedule seeds a `level: 'task'` block under its frame on every fire
107
+ * (`RecurringPipelineService`). The planning presets carry no `availability`, so nothing else keeps
108
+ * them out of this picker — and a schedule the engine refuses is WORSE than a manual start it
109
+ * refuses: it fires unattended, so nobody sees the error and the work simply never happens.
93
110
  */
94
111
  export function pipelineAllowedForSchedule(
95
112
  pipeline: Pipeline,
96
113
  frame: Block | undefined,
97
114
  blocks: readonly Block[],
98
115
  ): boolean {
99
- return pipeline.availability !== 'one-off' && pipelineAllowedForFrame(pipeline, frame, blocks)
116
+ return (
117
+ pipeline.availability !== 'one-off' &&
118
+ pipelineAllowedForFrame(pipeline, frame, blocks) &&
119
+ pipelineAllowedForBlockLevel(pipeline, 'task')
120
+ )
100
121
  }
@@ -2313,6 +2313,7 @@
2313
2313
  "updateFailed": "Änderungen konnten nicht gespeichert werden",
2314
2314
  "epicFailed": "Epic konnte nicht geändert werden",
2315
2315
  "moveFailed": "Verschieben nicht möglich",
2316
+ "resizeFailed": "Größe konnte nicht geändert werden",
2316
2317
  "deleteFailed": "Löschen nicht möglich",
2317
2318
  "linkFailed": "Aufgaben konnten nicht verknüpft werden",
2318
2319
  "unlinkFailed": "Abhängigkeit konnte nicht entfernt werden",
@@ -2566,7 +2567,6 @@
2566
2567
  "mergedOfTotal": "{merged}/{total} gemergt",
2567
2568
  "noTasksYet": "Noch keine Aufgaben",
2568
2569
  "prCount": "{count} PR",
2569
- "implemented": "{merged}/{total} implementiert",
2570
2570
  "prReadyCount": "{count} PR bereit",
2571
2571
  "taskCount": "{count} Aufgabe | {count} Aufgaben",
2572
2572
  "moduleCount": "{count} Modul | {count} Module",
@@ -3864,6 +3864,12 @@
3864
3864
  "recommendationProgress": "{ready} / {total} bereit",
3865
3865
  "generatingSuggestion": "Ein fundierter Vorschlag wird generiert…",
3866
3866
  "currentStandard": "Aktueller Standard: {title}",
3867
+ "grounding": {
3868
+ "standard": "Team-Standard",
3869
+ "project-spec": "Projektspezifikation",
3870
+ "web": "Webquelle",
3871
+ "general-practice": "Allgemeine Praxis"
3872
+ },
3867
3873
  "accept": "Annehmen",
3868
3874
  "reject": "Ablehnen",
3869
3875
  "reRequestPlaceholder": "Nach einer anderen Empfehlung fragen…",