@cat-factory/app 0.192.0 → 0.193.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.
@@ -387,13 +387,15 @@ const fragmentPool = computed(() => fragments.forBlockType(frame.value?.type ??
387
387
 
388
388
  // Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
389
389
  // UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate). Also
390
- // hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and,
391
- // for a `document` / `review` task, every pipeline whose purpose doesn't match (a doc task authors
392
- // a doc, a review task reviews a PR only document / review pipelines are relevant, per the
393
- // `purpose` classifier). Re-filters as the chosen task type changes.
390
+ // hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and every
391
+ // pipeline whose purpose doesn't match the chosen task type (a doc task authors a doc, a review task
392
+ // reviews a PR, and a `feature`/`bug` task ships codeso it is offered build + research only, not
393
+ // the doc/review/planning presets). `blockLevel: 'task'` is passed literally because this modal only
394
+ // ever creates a task leaf, which also drops the three planning presets the backend would refuse.
395
+ // Re-filters as the chosen task type changes.
394
396
  const selectablePipelines = computed(() =>
395
397
  pipelines.pipelines.filter((p) =>
396
- pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
398
+ pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value, 'task'),
397
399
  ),
398
400
  )
399
401
  // Some task types want their type-default pipeline surfaced in the modal up front, so picking the
@@ -74,9 +74,15 @@ const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
74
74
 
75
75
  // Infer the template from the picked pipeline so the backend seeds the right block
76
76
  // description (and so we know to show the tracker config).
77
+ //
78
+ // Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
79
+ // way. `dep-update` no longer can: its pipeline was retired in the catalog collapse (it was the
80
+ // ordinary build tail under a recurring name), so a dependency-update schedule now runs an ordinary
81
+ // build rung — which is also what every generic schedule runs, so inferring the template from it
82
+ // would mislabel all of them. The template itself survives for an explicit API caller; see
83
+ // `scheduleTemplateSchema`.
77
84
  const template = computed<ScheduleTemplate>(() => {
78
85
  if (pipelineId.value === 'pl_tech_debt') return 'tech-debt'
79
- if (pipelineId.value === 'pl_dep_update') return 'dep-update'
80
86
  if (pipelineId.value === 'pl_bug_triage') return 'bug-triage'
81
87
  return 'custom'
82
88
  })
@@ -99,11 +105,10 @@ watch(open, (isOpen) => {
99
105
  if (!isOpen) return
100
106
  name.value = ''
101
107
  description.value = ''
102
- // Default to the Dependency-updates pipeline if present, else the first.
103
- pipelineId.value =
104
- pipelines.pipelines.find((p) => p.id === 'pl_dep_update')?.id ??
105
- pipelines.pipelines[0]?.id ??
106
- ''
108
+ // Default to the first schedulable pipeline, which is the ladder's own default rung. There is no
109
+ // longer a canned recurring build preset to prefer — the dependency-update pipeline was the
110
+ // ordinary build tail under another name — so the default build is the honest starting point.
111
+ pipelineId.value = selectablePipelines.value[0]?.id ?? pipelines.pipelines[0]?.id ?? ''
107
112
  recurrence.value = defaultRecurrence()
108
113
  onDemand.value = false
109
114
  saving.value = false
@@ -30,12 +30,20 @@ const deps = computed(() =>
30
30
  )
31
31
 
32
32
  // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
33
- // pipelines (a manual run of one is refused server-side), and for a `document` task — every
34
- // non-document pipeline (per the `purpose` classifier) — see the backend gate.
33
+ // pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
34
+ // match this block's task type or LEVEL (per the `purpose` classifier) — see the backend gate. The
35
+ // level is what keeps the planning presets on initiative blocks and off everything else, in both
36
+ // directions, so this menu never offers a run the engine answers with a 409.
35
37
  const runOptions = computed(() => {
36
38
  const frame = block.value ? board.serviceOf(block.value) : undefined
37
39
  return pipelines.pipelines.filter((p) =>
38
- pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType),
40
+ pipelineAllowedForManualStart(
41
+ p,
42
+ frame,
43
+ board.blocks,
44
+ block.value?.taskType,
45
+ block.value?.level,
46
+ ),
39
47
  )
40
48
  })
41
49
 
@@ -178,13 +178,24 @@ const taskBranchUrl = computed(() => {
178
178
  return base ? `${base}/tree/${pr.branch}` : null
179
179
  })
180
180
 
181
- // Hide UI-testing pipelines when this block's frame has no UI to exercise, and `'recurring'`-only
182
- // pipelines (a manual run of one is refused server-side) they'd be refused at run start (see
183
- // utils/pipeline + the backend gate).
181
+ // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
182
+ // pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
183
+ // match this block's task type or LEVEL — they'd be refused at run start (see utils/pipeline + the
184
+ // backend gate). The level is what keeps the planning presets on initiative blocks and off
185
+ // everything else, so this menu never offers a run the engine answers with a 409; it applies to
186
+ // frames and modules too, which is why it reads `block.level` rather than assuming a task.
184
187
  const runMenu = computed(() => {
185
188
  const frame = block.value ? board.serviceOf(block.value) : undefined
186
189
  return pipelines.pipelines
187
- .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks))
190
+ .filter((p) =>
191
+ pipelineAllowedForManualStart(
192
+ p,
193
+ frame,
194
+ board.blocks,
195
+ block.value?.taskType,
196
+ block.value?.level,
197
+ ),
198
+ )
188
199
  .map((p) => ({
189
200
  label: p.name,
190
201
  icon: 'i-lucide-play',
@@ -144,10 +144,14 @@ const selectedPipeline = computed(() =>
144
144
  // pipelines (the task's manual Run control can't start one), and — for a `document` task — every
145
145
  // non-document pipeline (it authors a doc, so a build/test pipeline makes no sense). All would be
146
146
  // refused / wrong at run start (see utils/pipeline + the backend gate + the purpose classifier).
147
+ // `blockLevel: 'task'` is passed literally because this panel only ever edits a task leaf, which
148
+ // drops the planning presets the engine would refuse. The task-type narrowing already excludes them
149
+ // for `feature`/`bug`, but NOT for a `spike` / `ralph` / deployment-custom type — and a planning
150
+ // preset settable as a task's DEFAULT pipeline is a 409 on every later Start.
147
151
  const taskFrame = computed(() => board.serviceOf(props.block))
148
152
  const selectablePipelines = computed(() =>
149
153
  pipelines.pipelines.filter((p) =>
150
- pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType),
154
+ pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType, 'task'),
151
155
  ),
152
156
  )
153
157
  function setPipeline(id: string) {
@@ -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).`
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.192.0",
3
+ "version": "0.193.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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.199.0"
43
+ "@cat-factory/contracts": "0.200.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",