@yemi33/minions 0.1.2424 → 0.1.2426

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 (185) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +7 -5
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/index.html +9 -2
  55. package/docs/internal-install.md +212 -0
  56. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  57. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  58. package/docs/kb-sweep.md +2 -2
  59. package/docs/keep-processes.md +8 -1
  60. package/docs/live-checkout-mode.md +1 -1
  61. package/docs/managed-spawn.md +21 -3
  62. package/docs/named-agents.md +3 -2
  63. package/docs/onboarding.md +26 -0
  64. package/docs/plan-lifecycle.md +32 -32
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/01-install-and-connect.md +23 -0
  82. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  83. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  84. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  85. package/docs/tutorials/README.md +4 -3
  86. package/docs/visual-evidence-ci.md +103 -0
  87. package/docs/watches.md +35 -30
  88. package/docs/worktree-lifecycle.md +151 -13
  89. package/engine/acp-transport.js +1 -0
  90. package/engine/ado.js +80 -33
  91. package/engine/agent-api-validation.js +571 -0
  92. package/engine/agent-worker-pool.js +7 -6
  93. package/engine/api-contracts/agent-content.js +761 -0
  94. package/engine/api-contracts/cc-ops.js +219 -0
  95. package/engine/api-contracts/config-runtime.js +419 -0
  96. package/engine/api-contracts/core.js +138 -0
  97. package/engine/api-contracts/index.js +506 -0
  98. package/engine/api-contracts/orchestration.js +602 -0
  99. package/engine/api-contracts/pull-requests.js +323 -0
  100. package/engine/api-contracts/qa-process.js +764 -0
  101. package/engine/api-contracts/work-plan-prd.js +494 -0
  102. package/engine/api-validation.js +543 -0
  103. package/engine/cc-api-validation.js +586 -0
  104. package/engine/cc-worker-pool.js +9 -1
  105. package/engine/cleanup.js +393 -271
  106. package/engine/cli.js +65 -7
  107. package/engine/comment-classifier.js +70 -5
  108. package/engine/consolidation.js +123 -5
  109. package/engine/content-api-validation.js +606 -0
  110. package/engine/create-pr-worktree.js +22 -1
  111. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  112. package/engine/dispatch.js +33 -0
  113. package/engine/distribution.js +189 -0
  114. package/engine/execution-model.js +59 -0
  115. package/engine/features.js +12 -0
  116. package/engine/github.js +67 -25
  117. package/engine/harness-context.js +313 -0
  118. package/engine/inbox-store.js +1 -1
  119. package/engine/kb-sweep.js +161 -38
  120. package/engine/keep-process-sweep.js +43 -3
  121. package/engine/lifecycle.js +1066 -165
  122. package/engine/llm.js +4 -0
  123. package/engine/managed-spawn.js +463 -48
  124. package/engine/meeting.js +197 -34
  125. package/engine/memory-retrieval.js +280 -0
  126. package/engine/memory-store.js +821 -38
  127. package/engine/model-discovery.js +17 -3
  128. package/engine/pipeline.js +802 -14
  129. package/engine/plan-prd-validation.js +745 -0
  130. package/engine/playbook.js +206 -17
  131. package/engine/pooled-agent-process.js +6 -1
  132. package/engine/pr-action.js +1 -1
  133. package/engine/pr-issue-validation.js +653 -0
  134. package/engine/pr-resolve.js +75 -13
  135. package/engine/prd-store.js +39 -6
  136. package/engine/preflight.js +7 -2
  137. package/engine/process-utils.js +79 -31
  138. package/engine/projects.js +198 -12
  139. package/engine/promotion.js +371 -0
  140. package/engine/qa-from-prd.js +3 -0
  141. package/engine/qa-process-validation.js +572 -0
  142. package/engine/qa-runbooks.js +41 -37
  143. package/engine/qa-runners/maestro.js +11 -7
  144. package/engine/qa-runners/playwright.js +37 -14
  145. package/engine/qa-runners.js +9 -10
  146. package/engine/qa-runs.js +124 -11
  147. package/engine/qa-sessions.js +139 -40
  148. package/engine/quarantine-refs.js +81 -11
  149. package/engine/queries.js +163 -9
  150. package/engine/review-learning-backfill.js +482 -0
  151. package/engine/review-learning.js +1236 -0
  152. package/engine/runtimes/claude.js +60 -2
  153. package/engine/runtimes/codex.js +57 -16
  154. package/engine/runtimes/contract.js +21 -0
  155. package/engine/runtimes/copilot.js +51 -3
  156. package/engine/runtimes/index.js +1 -0
  157. package/engine/scheduler.js +201 -32
  158. package/engine/settings-validation.js +909 -0
  159. package/engine/shared.js +705 -208
  160. package/engine/spawn-agent.js +48 -21
  161. package/engine/steering-constraints.js +31 -0
  162. package/engine/steering-store.js +10 -2
  163. package/engine/steering.js +14 -2
  164. package/engine/timeout.js +404 -111
  165. package/engine/untrusted-fence.js +24 -10
  166. package/engine/watch-actions.js +7 -2
  167. package/engine/watches.js +353 -51
  168. package/engine/work-item-validation.js +561 -0
  169. package/engine/work-items-store.js +61 -1
  170. package/engine/worktree-gc.js +656 -37
  171. package/engine/worktree-preflight.js +154 -0
  172. package/engine.js +2068 -342
  173. package/package.json +1 -1
  174. package/playbooks/_pr-description-audit.md +88 -41
  175. package/playbooks/build-fix-complex.md +3 -3
  176. package/playbooks/docs.md +2 -2
  177. package/playbooks/fix.md +18 -18
  178. package/playbooks/implement.md +18 -18
  179. package/playbooks/qa-session-draft.md +10 -12
  180. package/playbooks/qa-session-execute.md +16 -12
  181. package/playbooks/qa-validate.md +8 -7
  182. package/playbooks/shared-rules.md +45 -0
  183. package/playbooks/test.md +4 -0
  184. package/prompts/cc-system.md +1 -1
  185. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -6,9 +6,10 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const crypto = require('crypto');
9
10
  const shared = require('./shared');
10
11
  const queries = require('./queries');
11
- const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
12
+ const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutateScheduleRuns, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, WORK_ITEM_PRIORITY, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
12
13
  const routing = require('./routing');
13
14
  const http = require('http');
14
15
  const { shouldRunNow } = require('./scheduler');
@@ -19,6 +20,8 @@ const {
19
20
  FENCE_CLOSE,
20
21
  } = require('./untrusted-fence');
21
22
  const smallStateStore = require('./small-state-store');
23
+ const apiValidation = require('./api-validation');
24
+ const scheduler = require('./scheduler');
22
25
 
23
26
  // All module-relative paths flow through MINIONS_DIR so MINIONS_TEST_DIR
24
27
  // (set by test/unit.test.js createTestMinionsDir) consistently redirects
@@ -30,6 +33,668 @@ const NOTES_INBOX_DIR = path.join(MINIONS_DIR, 'notes', 'inbox');
30
33
  const NOTES_ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
31
34
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
32
35
 
36
+ const PIPELINE_LIMITS = Object.freeze({
37
+ id: 64,
38
+ title: 300,
39
+ description: 20000,
40
+ stages: 100,
41
+ depth: 12,
42
+ nodes: 2000,
43
+ collection: 200,
44
+ resources: 100,
45
+ endpoint: 2048,
46
+ });
47
+ const PIPELINE_ID_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
48
+ const PIPELINE_STAGE_TYPES = Object.freeze(Object.values(STAGE_TYPE));
49
+ const PIPELINE_CONDITION_CHECKS = Object.freeze([
50
+ 'runSucceeded',
51
+ 'noFailedItems',
52
+ 'maxRuns',
53
+ 'allBuildsGreen',
54
+ ]);
55
+ const PIPELINE_CONDITION_ACTIONS = Object.freeze([
56
+ 'complete',
57
+ 'continue',
58
+ 'fail',
59
+ 'stop-pipeline',
60
+ ]);
61
+ const PIPELINE_API_METHODS = Object.freeze(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
62
+ const PIPELINE_WORK_TYPES = Object.freeze(Object.values(WORK_TYPE));
63
+
64
+ function _pipelineInputError(message, options = {}) {
65
+ throw new apiValidation.ApiInputError(message, {
66
+ code: options.code || 'invalid-pipeline',
67
+ statusCode: options.statusCode,
68
+ path: options.path,
69
+ rejectedValue: options.rejectedValue,
70
+ allowedValues: options.allowedValues,
71
+ expected: options.expected,
72
+ });
73
+ }
74
+
75
+ function _childPath(pathValue, child) {
76
+ const base = Array.isArray(pathValue) ? pathValue : [pathValue];
77
+ return [...base.filter(Boolean), child];
78
+ }
79
+
80
+ function _validatePlainTree(value, pathValue = 'pipeline', state = null, depth = 0) {
81
+ const context = state || { nodes: 0, ancestors: new WeakSet() };
82
+ context.nodes += 1;
83
+ if (context.nodes > PIPELINE_LIMITS.nodes || depth > PIPELINE_LIMITS.depth) {
84
+ _pipelineInputError('Pipeline definition is too deeply nested or complex', {
85
+ code: 'pipeline-too-complex',
86
+ path: pathValue,
87
+ rejectedValue: value,
88
+ });
89
+ }
90
+ if (value === undefined || value === null
91
+ || typeof value === 'string' || typeof value === 'boolean') return;
92
+ if (typeof value === 'number' && Number.isFinite(value)) return;
93
+ if (typeof value !== 'object') {
94
+ _pipelineInputError('Pipeline values must be JSON-compatible', {
95
+ code: 'invalid-pipeline-value',
96
+ path: pathValue,
97
+ rejectedValue: value,
98
+ });
99
+ }
100
+ if (context.ancestors.has(value)) {
101
+ _pipelineInputError('Pipeline definition must not contain object cycles', {
102
+ code: 'pipeline-object-cycle',
103
+ path: pathValue,
104
+ rejectedValue: value,
105
+ });
106
+ }
107
+ context.ancestors.add(value);
108
+ try {
109
+ if (Array.isArray(value)) {
110
+ if (value.length > PIPELINE_LIMITS.collection) {
111
+ _pipelineInputError('Pipeline collection is too large', {
112
+ code: 'pipeline-too-complex',
113
+ path: pathValue,
114
+ rejectedValue: value,
115
+ });
116
+ }
117
+ value.forEach((entry, index) => {
118
+ _validatePlainTree(entry, _childPath(pathValue, index), context, depth + 1);
119
+ });
120
+ return;
121
+ }
122
+ apiValidation.validatePlainObject(value, { path: pathValue });
123
+ const keys = Object.keys(value);
124
+ if (keys.length > PIPELINE_LIMITS.collection) {
125
+ _pipelineInputError('Pipeline object has too many fields', {
126
+ code: 'pipeline-too-complex',
127
+ path: pathValue,
128
+ rejectedValue: value,
129
+ });
130
+ }
131
+ for (const key of keys) {
132
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
133
+ _pipelineInputError('Pipeline definition contains a forbidden object key', {
134
+ code: 'dangerous-key',
135
+ path: _childPath(pathValue, key),
136
+ rejectedValue: key,
137
+ });
138
+ }
139
+ _validatePlainTree(value[key], _childPath(pathValue, key), context, depth + 1);
140
+ }
141
+ } finally {
142
+ context.ancestors.delete(value);
143
+ }
144
+ }
145
+
146
+ function validatePipelineIdentifier(id, pathValue = 'id') {
147
+ const validated = apiValidation.validateIdentifier(id, {
148
+ path: pathValue,
149
+ maxLength: PIPELINE_LIMITS.id,
150
+ pattern: PIPELINE_ID_PATTERN,
151
+ });
152
+ if (validated === 'prototype'
153
+ || Object.prototype.hasOwnProperty.call(Object.prototype, validated)) {
154
+ _pipelineInputError(`${pathValue} must not use a prototype-sensitive name`, {
155
+ code: 'invalid-identifier',
156
+ path: pathValue,
157
+ rejectedValue: id,
158
+ expected: 'safe identifier',
159
+ });
160
+ }
161
+ return validated;
162
+ }
163
+
164
+ function _validatePipelineText(value, pathValue, options = {}) {
165
+ return apiValidation.validateString(value, {
166
+ path: pathValue,
167
+ optional: options.optional === true,
168
+ allowEmpty: options.allowEmpty !== false,
169
+ maxLength: options.maxLength || PIPELINE_LIMITS.description,
170
+ });
171
+ }
172
+
173
+ function _validateProjectReference(value, pathValue, config) {
174
+ if (value === undefined || value === null || value === '') return;
175
+ _validatePipelineText(value, pathValue, { allowEmpty: false, maxLength: 128 });
176
+ if (value.trim().toLowerCase() === 'central') return;
177
+ const result = shared.resolveConfiguredProject(value, config || {});
178
+ if (result.error) {
179
+ _pipelineInputError(result.error, {
180
+ code: 'invalid-project',
181
+ path: pathValue,
182
+ rejectedValue: value,
183
+ });
184
+ }
185
+ }
186
+
187
+ function _validateAgentReference(value, pathValue, config, options = {}) {
188
+ if (value === undefined || value === null || value === '') {
189
+ if (options.required === true) {
190
+ _pipelineInputError(`${pathValue.join ? pathValue.join('.') : pathValue} must identify a configured agent`, {
191
+ code: 'invalid-agent',
192
+ path: pathValue,
193
+ rejectedValue: value,
194
+ });
195
+ }
196
+ return;
197
+ }
198
+ _validatePipelineText(value, pathValue, { allowEmpty: false, maxLength: 128 });
199
+ if (options.allowAll === true && value === 'all') return;
200
+ const agents = Object.keys(config?.agents || {});
201
+ if (!agents.includes(value)) {
202
+ _pipelineInputError(`Unknown agent "${value}". Known agents: ${agents.join(', ') || '(none)'}`, {
203
+ code: 'invalid-agent',
204
+ path: pathValue,
205
+ rejectedValue: value,
206
+ allowedValues: agents,
207
+ });
208
+ }
209
+ }
210
+
211
+ function _validateProjectFields(value, pathValue, config) {
212
+ _validateProjectReference(value.project, _childPath(pathValue, 'project'), config);
213
+ if (value.projects !== undefined) {
214
+ apiValidation.validateArray(value.projects, {
215
+ path: _childPath(pathValue, 'projects'),
216
+ maxLength: 50,
217
+ itemValidator(project, context) {
218
+ _validateProjectReference(project, context.path, config);
219
+ },
220
+ });
221
+ }
222
+ }
223
+
224
+ function _validateMonitoredResources(resources, pathValue, config) {
225
+ if (resources === undefined) return;
226
+ apiValidation.validateArray(resources, {
227
+ path: pathValue,
228
+ maxLength: PIPELINE_LIMITS.resources,
229
+ itemValidator(resource, context) {
230
+ if (typeof resource === 'string') {
231
+ _validatePipelineText(resource, context.path, {
232
+ allowEmpty: false,
233
+ maxLength: PIPELINE_LIMITS.endpoint,
234
+ });
235
+ if (shared.getPrNumber({ id: resource, url: resource }) == null) {
236
+ _pipelineInputError('Monitored resource must identify a pull request', {
237
+ code: 'invalid-monitored-resource',
238
+ path: context.path,
239
+ rejectedValue: resource,
240
+ });
241
+ }
242
+ return;
243
+ }
244
+ apiValidation.validatePlainObject(resource, { path: context.path });
245
+ if (resource.type !== undefined) {
246
+ apiValidation.validateEnum(resource.type, {
247
+ path: _childPath(context.path, 'type'),
248
+ values: ['pr'],
249
+ });
250
+ }
251
+ const ref = resource.id || resource.label || resource.url;
252
+ _validatePipelineText(ref, _childPath(context.path, 'id'), {
253
+ allowEmpty: false,
254
+ maxLength: PIPELINE_LIMITS.endpoint,
255
+ });
256
+ _validateProjectReference(
257
+ resource.project ?? resource._project,
258
+ _childPath(context.path, 'project'),
259
+ config,
260
+ );
261
+ },
262
+ });
263
+ }
264
+
265
+ function _validateCondition(condition, pathValue) {
266
+ let check = condition;
267
+ let value;
268
+ if (typeof condition === 'object' && condition !== null) {
269
+ apiValidation.validatePlainObject(condition, { path: pathValue });
270
+ check = condition.check;
271
+ value = condition.value;
272
+ }
273
+ apiValidation.validateEnum(check, {
274
+ path: typeof condition === 'object' && condition !== null
275
+ ? _childPath(pathValue, 'check')
276
+ : pathValue,
277
+ values: PIPELINE_CONDITION_CHECKS,
278
+ });
279
+ if (check === 'maxRuns' && value !== undefined) {
280
+ apiValidation.validateInteger(value, { path: _childPath(pathValue, 'value') });
281
+ apiValidation.validateRange(value, {
282
+ path: _childPath(pathValue, 'value'),
283
+ min: 1,
284
+ max: 1000000,
285
+ });
286
+ }
287
+ }
288
+
289
+ function _validateWorkItemPriority(priority, pathValue) {
290
+ const error = shared.validateWorkItemPriority(priority);
291
+ if (!error) return;
292
+ _pipelineInputError(error.error, {
293
+ code: error.code,
294
+ path: pathValue,
295
+ rejectedValue: error.rejectedValue,
296
+ allowedValues: error.allowedValues,
297
+ });
298
+ }
299
+
300
+ function _validateApiCall(call, pathValue) {
301
+ apiValidation.validatePlainObject(call, { path: pathValue });
302
+ const endpoint = _validatePipelineText(call.endpoint, _childPath(pathValue, 'endpoint'), {
303
+ allowEmpty: false,
304
+ maxLength: PIPELINE_LIMITS.endpoint,
305
+ });
306
+ let normalizedPath;
307
+ let decodedPath;
308
+ try {
309
+ normalizedPath = new URL(endpoint, 'http://localhost').pathname;
310
+ decodedPath = decodeURIComponent(normalizedPath);
311
+ } catch {
312
+ normalizedPath = '';
313
+ decodedPath = '';
314
+ }
315
+ const endpointSegments = decodedPath.split('/');
316
+ if (!endpoint.startsWith('/api/') || !normalizedPath.startsWith('/api/')
317
+ || !decodedPath.startsWith('/api/') || /[?#\\]/.test(decodedPath)
318
+ || endpointSegments.includes('.') || endpointSegments.includes('..')) {
319
+ _pipelineInputError('Pipeline API endpoint must be a canonical local /api/ path', {
320
+ code: 'invalid-api-endpoint',
321
+ path: _childPath(pathValue, 'endpoint'),
322
+ rejectedValue: endpoint,
323
+ });
324
+ }
325
+ apiValidation.validateEnum(call.method, {
326
+ path: _childPath(pathValue, 'method'),
327
+ optional: true,
328
+ values: PIPELINE_API_METHODS,
329
+ });
330
+ }
331
+
332
+ function _validateTaskItem(item, pathValue, config) {
333
+ apiValidation.validatePlainObject(item, { path: pathValue });
334
+ _validatePipelineText(item.title, _childPath(pathValue, 'title'), {
335
+ optional: true,
336
+ maxLength: PIPELINE_LIMITS.title,
337
+ });
338
+ _validatePipelineText(item.description, _childPath(pathValue, 'description'), { optional: true });
339
+ apiValidation.validateEnum(item.type, {
340
+ path: _childPath(pathValue, 'type'),
341
+ optional: true,
342
+ values: PIPELINE_WORK_TYPES,
343
+ });
344
+ _validateWorkItemPriority(item.priority, _childPath(pathValue, 'priority'));
345
+ _validateAgentReference(item.agent, _childPath(pathValue, 'agent'), config);
346
+ _validateProjectFields(item, pathValue, config);
347
+ for (const field of ['agentLock', 'hardAgent']) {
348
+ apiValidation.validateBoolean(item[field], {
349
+ path: _childPath(pathValue, field),
350
+ optional: true,
351
+ });
352
+ }
353
+ }
354
+
355
+ function _rejectStagePayload(stage, fields, pathValue) {
356
+ for (const field of fields) {
357
+ if (stage[field] !== undefined) {
358
+ _pipelineInputError(`Stage type "${stage.type}" does not accept ${field}`, {
359
+ code: 'invalid-stage-payload',
360
+ path: _childPath(pathValue, field),
361
+ rejectedValue: stage[field],
362
+ });
363
+ }
364
+ }
365
+ }
366
+
367
+ function _validateStage(stage, pathValue, context) {
368
+ apiValidation.validatePlainObject(stage, { path: pathValue });
369
+ const stageId = validatePipelineIdentifier(stage.id, _childPath(pathValue, 'id'));
370
+ if (context.allStageIds.has(stageId)) {
371
+ _pipelineInputError(`Duplicate pipeline stage id "${stageId}"`, {
372
+ code: 'duplicate-stage-id',
373
+ path: _childPath(pathValue, 'id'),
374
+ rejectedValue: stageId,
375
+ });
376
+ }
377
+ context.allStageIds.add(stageId);
378
+ apiValidation.validateEnum(stage.type, {
379
+ path: _childPath(pathValue, 'type'),
380
+ values: PIPELINE_STAGE_TYPES,
381
+ });
382
+ // `allowLegacyDisplayFields` relaxes the title requirement so historical
383
+ // stored pipeline definitions don't crash discovery. That leniency must NOT
384
+ // extend to side-effecting stages that consume `stage.title` to name a
385
+ // persisted record when they carry no per-record collection: a task stage
386
+ // without `items` and a meeting stage without `meetings` still require a
387
+ // title, otherwise a malformed stored definition like `{id:"task",type:"task"}`
388
+ // reaches executeTaskStage/executeMeetingStage and dispatches an UNTITLED
389
+ // work item / meeting (review PR #966). Schedule stages are already guarded
390
+ // downstream by validateScheduleDefinition (title required there); plan stages
391
+ // carry a safe `slugify(stage.title || 'pipeline-plan')` fallback.
392
+ const titleBacksSideEffect =
393
+ (stage.type === STAGE_TYPE.TASK && stage.items === undefined)
394
+ || (stage.type === STAGE_TYPE.MEETING && stage.meetings === undefined);
395
+ _validatePipelineText(stage.title, _childPath(pathValue, 'title'), {
396
+ optional: context.allowLegacyDisplayFields === true && !titleBacksSideEffect,
397
+ allowEmpty: false,
398
+ maxLength: PIPELINE_LIMITS.title,
399
+ });
400
+ _validatePipelineText(stage.description, _childPath(pathValue, 'description'), { optional: true });
401
+ _validateWorkItemPriority(stage.priority, _childPath(pathValue, 'priority'));
402
+ _validateAgentReference(stage.agent, _childPath(pathValue, 'agent'), context.config);
403
+ _validateProjectFields(stage, pathValue, context.config);
404
+ _validateMonitoredResources(
405
+ stage.monitoredResources,
406
+ _childPath(pathValue, 'monitoredResources'),
407
+ context.config,
408
+ );
409
+ for (const field of ['agentLock', 'hardAgent', 'autoApprove']) {
410
+ apiValidation.validateBoolean(stage[field], {
411
+ path: _childPath(pathValue, field),
412
+ optional: true,
413
+ });
414
+ }
415
+ if (stage.dependsOn !== undefined) {
416
+ apiValidation.validateArray(stage.dependsOn, {
417
+ path: _childPath(pathValue, 'dependsOn'),
418
+ maxLength: PIPELINE_LIMITS.stages,
419
+ itemValidator(dependency, dependencyContext) {
420
+ validatePipelineIdentifier(dependency, dependencyContext.path);
421
+ },
422
+ });
423
+ if (context.insideParallel === true && stage.dependsOn.length > 0) {
424
+ _pipelineInputError('Parallel sub-stages cannot declare dependencies', {
425
+ code: 'unsupported-parallel-dependency',
426
+ path: _childPath(pathValue, 'dependsOn'),
427
+ rejectedValue: stage.dependsOn,
428
+ });
429
+ }
430
+ }
431
+
432
+ switch (stage.type) {
433
+ case STAGE_TYPE.TASK:
434
+ apiValidation.validateEnum(stage.taskType, {
435
+ path: _childPath(pathValue, 'taskType'),
436
+ optional: true,
437
+ values: PIPELINE_WORK_TYPES,
438
+ });
439
+ if (stage.count !== undefined) {
440
+ apiValidation.validateInteger(stage.count, { path: _childPath(pathValue, 'count') });
441
+ apiValidation.validateRange(stage.count, {
442
+ path: _childPath(pathValue, 'count'),
443
+ min: 1,
444
+ max: PIPELINE_LIMITS.collection,
445
+ });
446
+ }
447
+ if (stage.items !== undefined) {
448
+ apiValidation.validateArray(stage.items, {
449
+ path: _childPath(pathValue, 'items'),
450
+ minLength: 1,
451
+ maxLength: PIPELINE_LIMITS.collection,
452
+ itemValidator(item, itemContext) {
453
+ _validateTaskItem(item, itemContext.path, context.config);
454
+ },
455
+ });
456
+ }
457
+ break;
458
+ case STAGE_TYPE.MEETING:
459
+ if (stage.participants !== undefined) {
460
+ apiValidation.validateArray(stage.participants, {
461
+ path: _childPath(pathValue, 'participants'),
462
+ minLength: 1,
463
+ maxLength: PIPELINE_LIMITS.collection,
464
+ itemValidator(participant, participantContext) {
465
+ _validateAgentReference(
466
+ participant,
467
+ participantContext.path,
468
+ context.config,
469
+ { allowAll: true, required: true },
470
+ );
471
+ },
472
+ });
473
+ if (stage.participants.includes('all') && stage.participants.length !== 1) {
474
+ _pipelineInputError('Meeting participants "all" must be used alone', {
475
+ code: 'invalid-agent',
476
+ path: _childPath(pathValue, 'participants'),
477
+ rejectedValue: stage.participants,
478
+ });
479
+ }
480
+ } else if (Object.keys(context.config?.agents || {}).length === 0) {
481
+ _pipelineInputError('Meeting stage requires participants or configured agents', {
482
+ code: 'invalid-agent',
483
+ path: _childPath(pathValue, 'participants'),
484
+ rejectedValue: stage.participants,
485
+ });
486
+ }
487
+ if (stage.meetings !== undefined) {
488
+ apiValidation.validateArray(stage.meetings, {
489
+ path: _childPath(pathValue, 'meetings'),
490
+ minLength: 1,
491
+ maxLength: PIPELINE_LIMITS.collection,
492
+ itemValidator(meeting, meetingContext) {
493
+ apiValidation.validatePlainObject(meeting, { path: meetingContext.path });
494
+ _validatePipelineText(meeting.title, _childPath(meetingContext.path, 'title'), {
495
+ allowEmpty: false,
496
+ maxLength: PIPELINE_LIMITS.title,
497
+ });
498
+ _validatePipelineText(meeting.agenda, _childPath(meetingContext.path, 'agenda'), {
499
+ optional: true,
500
+ });
501
+ },
502
+ });
503
+ }
504
+ break;
505
+ case STAGE_TYPE.API: {
506
+ if (stage.calls !== undefined) {
507
+ apiValidation.validateArray(stage.calls, {
508
+ path: _childPath(pathValue, 'calls'),
509
+ minLength: 1,
510
+ maxLength: PIPELINE_LIMITS.collection,
511
+ itemValidator(call, callContext) {
512
+ _validateApiCall(call, callContext.path);
513
+ },
514
+ });
515
+ } else {
516
+ _validateApiCall({
517
+ endpoint: stage.endpoint,
518
+ method: stage.method,
519
+ body: stage.body,
520
+ }, pathValue);
521
+ }
522
+ break;
523
+ }
524
+ case STAGE_TYPE.SCHEDULE:
525
+ apiValidation.validateEnum(stage.taskType, {
526
+ path: _childPath(pathValue, 'taskType'),
527
+ optional: true,
528
+ values: PIPELINE_WORK_TYPES,
529
+ });
530
+ if (stage.schedules !== undefined) {
531
+ apiValidation.validateArray(stage.schedules, {
532
+ path: _childPath(pathValue, 'schedules'),
533
+ minLength: 1,
534
+ maxLength: PIPELINE_LIMITS.collection,
535
+ itemValidator(schedule) {
536
+ scheduler.validateScheduleDefinition(schedule, { config: context.config });
537
+ },
538
+ });
539
+ } else {
540
+ scheduler.validateScheduleDefinition({
541
+ id: _pipelineScheduleId(stage.id, context.pipelineId),
542
+ cron: stage.cron,
543
+ title: stage.title,
544
+ type: stage.taskType || WORK_TYPE.IMPLEMENT,
545
+ enabled: true,
546
+ }, { config: context.config });
547
+ }
548
+ break;
549
+ case STAGE_TYPE.CONDITION: {
550
+ const condition = stage.check !== undefined ? stage.check : stage.condition;
551
+ _validateCondition(condition, _childPath(pathValue, 'check'));
552
+ for (const field of ['onMet', 'onUnmet']) {
553
+ apiValidation.validateEnum(stage[field], {
554
+ path: _childPath(pathValue, field),
555
+ optional: true,
556
+ values: PIPELINE_CONDITION_ACTIONS,
557
+ });
558
+ }
559
+ break;
560
+ }
561
+ case STAGE_TYPE.WAIT:
562
+ _rejectStagePayload(stage, [
563
+ 'calls', 'endpoint', 'method', 'body', 'items', 'meetings',
564
+ 'stages', 'schedules', 'check', 'condition', 'cron',
565
+ ], pathValue);
566
+ break;
567
+ case STAGE_TYPE.PARALLEL:
568
+ _validateStageList(stage.stages, _childPath(pathValue, 'stages'), {
569
+ ...context,
570
+ insideParallel: true,
571
+ });
572
+ break;
573
+ case STAGE_TYPE.PLAN:
574
+ case STAGE_TYPE.MERGE_PRS:
575
+ break;
576
+ default:
577
+ break;
578
+ }
579
+ }
580
+
581
+ function _validateStageList(stages, pathValue, context) {
582
+ apiValidation.validateArray(stages, {
583
+ path: pathValue,
584
+ minLength: 1,
585
+ maxLength: PIPELINE_LIMITS.stages,
586
+ });
587
+ const localStages = new Map();
588
+ stages.forEach((stage, index) => {
589
+ _validateStage(stage, _childPath(pathValue, index), context);
590
+ localStages.set(stage.id, stage);
591
+ });
592
+ for (const [stageId, stage] of localStages) {
593
+ for (const dependency of stage.dependsOn || []) {
594
+ if (!localStages.has(dependency)) {
595
+ _pipelineInputError(`Stage "${stageId}" depends on unknown stage "${dependency}"`, {
596
+ code: 'unknown-stage-dependency',
597
+ path: _childPath(pathValue, stageId),
598
+ rejectedValue: dependency,
599
+ });
600
+ }
601
+ }
602
+ }
603
+ const visiting = new Set();
604
+ const visited = new Set();
605
+ function visit(stageId) {
606
+ if (visiting.has(stageId)) {
607
+ _pipelineInputError(`Pipeline stage dependency cycle includes "${stageId}"`, {
608
+ code: 'pipeline-cycle',
609
+ path: pathValue,
610
+ rejectedValue: stageId,
611
+ });
612
+ }
613
+ if (visited.has(stageId)) return;
614
+ visiting.add(stageId);
615
+ for (const dependency of localStages.get(stageId).dependsOn || []) visit(dependency);
616
+ visiting.delete(stageId);
617
+ visited.add(stageId);
618
+ }
619
+ for (const stageId of localStages.keys()) visit(stageId);
620
+ }
621
+
622
+ function validatePipelineDefinition(definition, options = {}) {
623
+ _validatePlainTree(definition);
624
+ apiValidation.validatePlainObject(definition, { path: 'pipeline' });
625
+ validatePipelineIdentifier(definition.id);
626
+ _validatePipelineText(definition.title, 'title', {
627
+ optional: options.allowLegacyDisplayFields === true,
628
+ allowEmpty: false,
629
+ maxLength: PIPELINE_LIMITS.title,
630
+ });
631
+ _validatePipelineText(definition.description, 'description', { optional: true });
632
+ apiValidation.validateBoolean(definition.enabled, { path: 'enabled', optional: true });
633
+ _validateProjectFields(definition, 'pipeline', options.config);
634
+ _validateMonitoredResources(
635
+ definition.monitoredResources,
636
+ 'monitoredResources',
637
+ options.config,
638
+ );
639
+ if (definition.trigger !== undefined && definition.trigger !== null) {
640
+ apiValidation.validatePlainObject(definition.trigger, { path: 'trigger' });
641
+ const triggerKeys = Object.keys(definition.trigger);
642
+ if (triggerKeys.some(key => key !== 'cron')) {
643
+ _pipelineInputError('Pipeline trigger accepts only a cron field', {
644
+ code: 'invalid-pipeline-trigger',
645
+ path: 'trigger',
646
+ rejectedValue: definition.trigger,
647
+ });
648
+ }
649
+ if (triggerKeys.length > 0) {
650
+ scheduler.validateCronExpression(definition.trigger.cron, { path: 'trigger.cron' });
651
+ }
652
+ }
653
+ if (definition.stopWhen !== undefined && definition.stopWhen !== null) {
654
+ _validateCondition(definition.stopWhen, 'stopWhen');
655
+ }
656
+ _validateStageList(definition.stages, 'stages', {
657
+ config: options.config || {},
658
+ pipelineId: definition.id,
659
+ allowLegacyDisplayFields: options.allowLegacyDisplayFields === true,
660
+ allStageIds: new Set(),
661
+ });
662
+ return definition;
663
+ }
664
+
665
+ function findPipelineStage(definition, stageId) {
666
+ for (const stage of definition?.stages || []) {
667
+ if (stage?.id === stageId) return stage;
668
+ const nested = findPipelineStage(stage, stageId);
669
+ if (nested) return nested;
670
+ }
671
+ return null;
672
+ }
673
+
674
+ function _pipelineScheduleId(stageId, pipelineId) {
675
+ const identity = pipelineId ? `${pipelineId}-${stageId}` : stageId;
676
+ const hash = crypto.createHash('sha256')
677
+ .update(`${pipelineId || 'standalone'}\0${stageId}`)
678
+ .digest('hex')
679
+ .slice(0, 32);
680
+ const suffix = `-${hash}-sched`;
681
+ const base = slugify(identity, scheduler.SCHEDULE_LIMITS.id - suffix.length) || 'stage';
682
+ return `${base}${suffix}`;
683
+ }
684
+
685
+ function validatePipelineStoredIdentity(pipelineId, pipeline) {
686
+ validatePipelineIdentifier(pipelineId, 'pipelineId');
687
+ const storedId = validatePipelineIdentifier(pipeline?.id);
688
+ if (storedId !== pipelineId) {
689
+ _pipelineInputError('Stored pipeline id does not match the requested id', {
690
+ code: 'pipeline-id-mismatch',
691
+ path: 'id',
692
+ rejectedValue: storedId,
693
+ });
694
+ }
695
+ return pipeline;
696
+ }
697
+
33
698
  // ── Pipeline CRUD ────────────────────────────────────────────────────────────
34
699
 
35
700
  function getPipelines() {
@@ -51,16 +716,20 @@ function getPipelines() {
51
716
  }
52
717
 
53
718
  function getPipeline(id) {
719
+ validatePipelineIdentifier(id);
54
720
  const filePath = path.join(PIPELINES_DIR, id + '.json');
55
721
  return safeJson(filePath);
56
722
  }
57
723
 
58
- function savePipeline(pipeline) {
724
+ function savePipeline(pipeline, options = {}) {
725
+ if (options.validate === true) validatePipelineDefinition(pipeline, options);
726
+ else validatePipelineIdentifier(pipeline?.id);
59
727
  if (!fs.existsSync(PIPELINES_DIR)) fs.mkdirSync(PIPELINES_DIR, { recursive: true });
60
728
  mutateJsonFileLocked(path.join(PIPELINES_DIR, pipeline.id + '.json'), () => pipeline, { defaultValue: {} });
61
729
  }
62
730
 
63
731
  function deletePipeline(id) {
732
+ validatePipelineIdentifier(id);
64
733
  const filePath = path.join(PIPELINES_DIR, id + '.json');
65
734
  if (!fs.existsSync(filePath)) return false;
66
735
  try {
@@ -96,6 +765,7 @@ function getActiveRun(pipelineId) {
96
765
  }
97
766
 
98
767
  function startRun(pipelineId, pipeline) {
768
+ validatePipelineIdentifier(pipelineId, 'pipelineId');
99
769
  const runId = `run-${uid()}`;
100
770
  const stages = {};
101
771
  for (const stage of (pipeline.stages || [])) {
@@ -106,7 +776,9 @@ function startRun(pipelineId, pipeline) {
106
776
  let alreadyActive = false;
107
777
  mutatePipelineRuns((data) => {
108
778
  if (!data[pipelineId]) data[pipelineId] = [];
109
- if (data[pipelineId].some(r => r.status === PIPELINE_STATUS.RUNNING || r.status === PIPELINE_STATUS.PAUSED)) {
779
+ if (data[pipelineId].some(r => r.status === PIPELINE_STATUS.RUNNING
780
+ || r.status === PIPELINE_STATUS.PAUSED
781
+ || r.status === PIPELINE_STATUS.WAITING_HUMAN)) {
110
782
  alreadyActive = true;
111
783
  return data;
112
784
  }
@@ -123,6 +795,25 @@ function startRun(pipelineId, pipeline) {
123
795
  return run;
124
796
  }
125
797
 
798
+ function validatePipelineStart(pipelineId, pipeline, config) {
799
+ validatePipelineStoredIdentity(pipelineId, pipeline);
800
+ validatePipelineDefinition(pipeline, { config });
801
+ if (pipeline.enabled === false) {
802
+ _pipelineInputError(`Pipeline "${pipelineId}" is disabled`, {
803
+ code: 'pipeline-disabled',
804
+ statusCode: 409,
805
+ path: 'enabled',
806
+ rejectedValue: pipeline.enabled,
807
+ });
808
+ }
809
+ return pipeline;
810
+ }
811
+
812
+ function startValidatedRun(pipelineId, pipeline, config) {
813
+ validatePipelineStart(pipelineId, pipeline, config);
814
+ return startRun(pipelineId, pipeline);
815
+ }
816
+
126
817
  function updateRunStage(pipelineId, runId, stageId, updates) {
127
818
  if (updates && Object.prototype.hasOwnProperty.call(updates, 'status')) {
128
819
  const validStatuses = Object.values(PIPELINE_STATUS);
@@ -465,7 +1156,7 @@ async function executeStage(stage, run, pipeline, config) {
465
1156
  case STAGE_TYPE.MERGE_PRS:
466
1157
  return executeMergePrsStage(resolved, stageState, run, config);
467
1158
  case STAGE_TYPE.SCHEDULE:
468
- return executeScheduleStage(resolved, stageState, config);
1159
+ return executeScheduleStage(resolved, stageState, config, pipeline);
469
1160
  case STAGE_TYPE.CONDITION:
470
1161
  return executeConditionStage(resolved, stageState, run, pipeline, config);
471
1162
  case STAGE_TYPE.WAIT:
@@ -512,7 +1203,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
512
1203
  title: item.title || stage.title,
513
1204
  description: item.description || stage.description || '',
514
1205
  type: wiType,
515
- priority: item.priority || stage.priority || 'medium',
1206
+ priority: item.priority || stage.priority || WORK_ITEM_PRIORITY.MEDIUM,
516
1207
  // Agent is a soft routing hint unless agentLock/hardAgent is set.
517
1208
  ...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
518
1209
  ...(item.agentLock === true || stage.agentLock === true || item.hardAgent === true || stage.hardAgent === true ? { agentLock: true } : {}),
@@ -712,7 +1403,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
712
1403
  } else if (!workItems.some(w => w.id === wiId)) {
713
1404
  workItems.push({
714
1405
  id: wiId, title: `Convert plan to PRD: ${existingPlanFile}`,
715
- type: WORK_TYPE.PLAN_TO_PRD, priority: 'high', status: WI_STATUS.PENDING,
1406
+ type: WORK_TYPE.PLAN_TO_PRD, priority: WORK_ITEM_PRIORITY.HIGH, status: WI_STATUS.PENDING,
716
1407
  planFile: existingPlanFile, created: ts(), createdBy: 'pipeline:' + run.pipelineId,
717
1408
  // W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
718
1409
  _pipelineRun: run.runId, _pipelineStage: stage.id,
@@ -816,7 +1507,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
816
1507
  id: wiId,
817
1508
  title: `Convert plan to PRD: ${path.basename(filePath)}`,
818
1509
  type: WORK_TYPE.PLAN_TO_PRD,
819
- priority: 'high',
1510
+ priority: WORK_ITEM_PRIORITY.HIGH,
820
1511
  status: WI_STATUS.PENDING,
821
1512
  planFile: path.basename(filePath),
822
1513
  created: ts(),
@@ -935,9 +1626,17 @@ function executeMergePrsStage(stage, stageState, run, config) {
935
1626
  return { status: PIPELINE_STATUS.RUNNING, artifacts: { prs: prIds } };
936
1627
  }
937
1628
 
938
- function executeScheduleStage(stage, stageState, config) {
1629
+ function executeScheduleStage(stage, stageState, config, pipeline) {
939
1630
  // Create/update schedules in config
940
- const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: routing.normalizeWorkType(stage.taskType, WORK_TYPE.IMPLEMENT) }];
1631
+ const implicit = !stage.schedules;
1632
+ const schedules = stage.schedules || [{
1633
+ id: _pipelineScheduleId(stage.id, pipeline?.id),
1634
+ cron: stage.cron,
1635
+ title: stage.title,
1636
+ type: routing.normalizeWorkType(stage.taskType, WORK_TYPE.IMPLEMENT),
1637
+ ...(pipeline?.id ? { _pipelineId: pipeline.id } : {}),
1638
+ _pipelineStage: stage.id,
1639
+ }];
941
1640
  // P-bf05-config-write-race — atomic locked write so concurrent
942
1641
  // addProject/removeProject/dashboard-settings writers can't clobber the
943
1642
  // schedules we just appended. The prior `safeWrite(CONFIG_PATH, config)`
@@ -946,10 +1645,60 @@ function executeScheduleStage(stage, stageState, config) {
946
1645
  // don't drop concurrent edits.
947
1646
  mutateJsonFileLocked(CONFIG_PATH, (cfg) => {
948
1647
  if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return cfg;
949
- for (const sched of schedules) {
950
- const existing = (cfg.schedules || []).find(s => s.id === sched.id);
951
- if (!existing) {
952
- cfg.schedules = cfg.schedules || [];
1648
+ cfg.schedules = cfg.schedules || [];
1649
+ for (const definition of schedules) {
1650
+ const sched = { ...definition };
1651
+ let existingIndex = cfg.schedules.findIndex(candidate => candidate.id === sched.id);
1652
+
1653
+ if (implicit) {
1654
+ const sameDefinition = candidate => candidate
1655
+ && candidate.cron === sched.cron
1656
+ && candidate.title === sched.title
1657
+ && routing.normalizeWorkType(candidate.type, WORK_TYPE.IMPLEMENT) === sched.type;
1658
+ const sameOwner = candidate => sameDefinition(candidate)
1659
+ && candidate._pipelineStage === stage.id
1660
+ && (pipeline?.id ? candidate._pipelineId === pipeline.id : !candidate._pipelineId);
1661
+ const legacyId = `${stage.id}-sched`;
1662
+ const legacyIndex = cfg.schedules.findIndex(candidate => (
1663
+ candidate.id === legacyId && sameDefinition(candidate)
1664
+ ));
1665
+
1666
+ if (legacyIndex >= 0) {
1667
+ const legacy = cfg.schedules[legacyIndex];
1668
+ if (existingIndex >= 0 && sameOwner(cfg.schedules[existingIndex])) {
1669
+ const existing = cfg.schedules[existingIndex];
1670
+ if (legacy.enabled !== undefined) existing.enabled = legacy.enabled;
1671
+ Object.assign(existing, sched);
1672
+ if (legacy.enabled !== undefined) existing.enabled = legacy.enabled;
1673
+ _copyScheduleRunHistory(legacyId, existing.id);
1674
+ cfg.schedules.splice(legacyIndex, 1);
1675
+ continue;
1676
+ }
1677
+ if (existingIndex >= 0) {
1678
+ sched.id = _uniqueScheduleId(cfg.schedules, sched.id);
1679
+ existingIndex = -1;
1680
+ }
1681
+ _copyScheduleRunHistory(legacyId, sched.id);
1682
+ cfg.schedules[legacyIndex] = {
1683
+ ...legacy,
1684
+ ...sched,
1685
+ enabled: legacy.enabled === undefined ? true : legacy.enabled,
1686
+ };
1687
+ continue;
1688
+ }
1689
+
1690
+ if (existingIndex >= 0) {
1691
+ const existing = cfg.schedules[existingIndex];
1692
+ if (sameOwner(existing) || (sameDefinition(existing) && !existing._pipelineId)) {
1693
+ Object.assign(existing, sched);
1694
+ continue;
1695
+ }
1696
+ sched.id = _uniqueScheduleId(cfg.schedules, sched.id);
1697
+ existingIndex = -1;
1698
+ }
1699
+ }
1700
+
1701
+ if (existingIndex < 0) {
953
1702
  cfg.schedules.push({ ...sched, enabled: true });
954
1703
  }
955
1704
  }
@@ -958,6 +1707,25 @@ function executeScheduleStage(stage, stageState, config) {
958
1707
  return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts() };
959
1708
  }
960
1709
 
1710
+ function _uniqueScheduleId(schedules, desiredId) {
1711
+ let candidate = desiredId;
1712
+ for (let suffix = 2; schedules.some(schedule => schedule.id === candidate); suffix += 1) {
1713
+ const suffixText = `-${suffix}`;
1714
+ candidate = `${desiredId.slice(0, scheduler.SCHEDULE_LIMITS.id - suffixText.length)}${suffixText}`;
1715
+ }
1716
+ return candidate;
1717
+ }
1718
+
1719
+ function _copyScheduleRunHistory(fromId, toId) {
1720
+ mutateScheduleRuns((runs) => {
1721
+ if (Object.prototype.hasOwnProperty.call(runs, fromId)
1722
+ && !Object.prototype.hasOwnProperty.call(runs, toId)) {
1723
+ runs[toId] = runs[fromId];
1724
+ }
1725
+ return runs;
1726
+ });
1727
+ }
1728
+
961
1729
  function executeConditionStage(stage, stageState, run, pipeline, config) {
962
1730
  const check = stage.check || stage.condition;
963
1731
  if (!check) {
@@ -1179,6 +1947,15 @@ async function discoverPipelineWork(config) {
1179
1947
  const now = new Date();
1180
1948
 
1181
1949
  for (const pipeline of pipelines) {
1950
+ try {
1951
+ validatePipelineDefinition(pipeline, {
1952
+ config,
1953
+ allowLegacyDisplayFields: true,
1954
+ });
1955
+ } catch (error) {
1956
+ log('warn', `Pipeline ${pipeline?.id || '(missing id)'}: invalid definition — skipping (${error.message})`);
1957
+ continue;
1958
+ }
1182
1959
  if (pipeline.enabled === false) continue;
1183
1960
 
1184
1961
  // Check for active run
@@ -1466,11 +2243,22 @@ async function discoverPipelineWork(config) {
1466
2243
 
1467
2244
  module.exports = {
1468
2245
  PIPELINES_DIR,
2246
+ PIPELINE_LIMITS,
2247
+ PIPELINE_STAGE_TYPES,
2248
+ PIPELINE_CONDITION_CHECKS,
2249
+ PIPELINE_CONDITION_ACTIONS,
2250
+ PIPELINE_API_METHODS,
2251
+ validatePipelineIdentifier,
2252
+ validatePipelineDefinition,
2253
+ validatePipelineStoredIdentity,
2254
+ validatePipelineStart,
2255
+ findPipelineStage,
1469
2256
  getPipelines, getPipeline, savePipeline, deletePipeline,
1470
- getPipelineRuns, getActiveRun, startRun, updateRunStage, upsertRunStage, completeRun,
2257
+ getPipelineRuns, getActiveRun, startRun, startValidatedRun, updateRunStage, upsertRunStage, completeRun,
1471
2258
  discoverPipelineWork,
1472
2259
  evaluateCondition, // exported for testing
1473
2260
  executeTaskStage, executePlanStage, executeScheduleStage, executeApiStage, executeMeetingStage, executeMergePrsStage, isStageComplete, resolveTemplate, // exported for testing
2261
+ _pipelineScheduleId, // exported for testing
1474
2262
  _resolvePipelineProjects, // exported for testing
1475
2263
  _findMeetingsInRun, _findExistingPlanForMeeting, _findExistingPrdForPlan, _canonicalPlanName, // exported for testing
1476
2264
  };